From 30b9c8a0737b7fefdc3ffecd4ed921c4abdf034f Mon Sep 17 00:00:00 2001 From: anandgupta42 <93243293+anandgupta42@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:13:43 -0700 Subject: [PATCH 1/2] =?UTF-8?q?test:=20altimate=20tools=20=E2=80=94=20impa?= =?UTF-8?q?ct=20analysis=20DAG=20traversal=20and=20training=20import=20mar?= =?UTF-8?q?kdown=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tests for two recently added tools (impact_analysis, training_import) that had zero test coverage. These tools are user-facing and incorrect behavior leads to wrong blast radius assessments or silent data loss when importing team standards. Co-Authored-By: Claude Opus 4.6 (1M context) https://claude.ai/code/session_01H7d93hQP5qAwYhLgRdkqhV --- .../test/altimate/impact-analysis.test.ts | 239 +++++++++++++++ .../test/altimate/training-import.test.ts | 289 ++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100644 packages/opencode/test/altimate/impact-analysis.test.ts create mode 100644 packages/opencode/test/altimate/training-import.test.ts diff --git a/packages/opencode/test/altimate/impact-analysis.test.ts b/packages/opencode/test/altimate/impact-analysis.test.ts new file mode 100644 index 000000000..6f1ca47a3 --- /dev/null +++ b/packages/opencode/test/altimate/impact-analysis.test.ts @@ -0,0 +1,239 @@ +/** + * Tests for the impact_analysis tool — DAG traversal, severity classification, + * and report formatting. + * + * Mocks Dispatcher.call to supply known dbt manifests so we can verify + * findDownstream logic without a real napi binary or dbt project. + */ +import { describe, test, expect, spyOn, afterAll, beforeEach } from "bun:test" +import * as Dispatcher from "../../src/altimate/native/dispatcher" +import { ImpactAnalysisTool } from "../../src/altimate/tools/impact-analysis" +import { SessionID, MessageID } from "../../src/session/schema" + +// Disable telemetry +beforeEach(() => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" +}) +afterAll(() => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +// Spy on Dispatcher.call so we control what "dbt.manifest" and "lineage.check" return +let dispatcherSpy: ReturnType + +function mockDispatcher(responses: Record) { + dispatcherSpy?.mockRestore() + dispatcherSpy = spyOn(Dispatcher, "call").mockImplementation(async (method: string, params: any) => { + if (responses[method]) return responses[method] + throw new Error(`No mock for ${method}`) + }) +} + +afterAll(() => { + dispatcherSpy?.mockRestore() +}) + +describe("impact_analysis: empty / missing manifest", () => { + test("reports NO MANIFEST when manifest has no models", async () => { + mockDispatcher({ + "dbt.manifest": { models: [], model_count: 0, test_count: 0 }, + }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "stg_orders", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.title).toContain("NO MANIFEST") + expect(result.metadata.success).toBe(false) + expect(result.output).toContain("dbt compile") + }) + + test("reports MODEL NOT FOUND when model is absent", async () => { + mockDispatcher({ + "dbt.manifest": { + models: [{ name: "dim_customers", depends_on: [], materialized: "table" }], + model_count: 1, + test_count: 0, + }, + }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "stg_orders", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.title).toContain("MODEL NOT FOUND") + expect(result.metadata.success).toBe(false) + expect(result.output).toContain("dim_customers") + }) +}) + +describe("impact_analysis: DAG traversal", () => { + const linearDAG = { + models: [ + { name: "stg_orders", depends_on: [], materialized: "view" }, + { name: "int_orders", depends_on: ["stg_orders"], materialized: "ephemeral" }, + { name: "fct_orders", depends_on: ["int_orders"], materialized: "table" }, + { name: "rpt_daily", depends_on: ["fct_orders"], materialized: "table" }, + ], + model_count: 4, + test_count: 5, + } + + test("finds direct and transitive dependents in a linear chain", async () => { + mockDispatcher({ "dbt.manifest": linearDAG }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "stg_orders", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.success).toBe(true) + expect(result.metadata.direct_count).toBe(1) // int_orders + expect(result.metadata.transitive_count).toBe(2) // fct_orders, rpt_daily + expect(result.output).toContain("int_orders") + expect(result.output).toContain("fct_orders") + expect(result.output).toContain("rpt_daily") + expect(result.output).toContain("BREAKING") + }) + + test("SAFE severity when no downstream models exist", async () => { + mockDispatcher({ "dbt.manifest": linearDAG }) + const tool = await ImpactAnalysisTool.init() + // rpt_daily is a leaf — nothing depends on it + const result = await tool.execute( + { model: "rpt_daily", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.success).toBe(true) + expect(result.metadata.severity).toBe("SAFE") + expect(result.metadata.direct_count).toBe(0) + expect(result.output).toContain("safe to make") + }) + + test("diamond dependency counts each model only once", async () => { + mockDispatcher({ + "dbt.manifest": { + models: [ + { name: "src", depends_on: [], materialized: "view" }, + { name: "left", depends_on: ["src"], materialized: "table" }, + { name: "right", depends_on: ["src"], materialized: "table" }, + { name: "merge", depends_on: ["left", "right"], materialized: "table" }, + ], + model_count: 4, + test_count: 0, + }, + }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "src", change_type: "rename", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.success).toBe(true) + // left, right are direct; merge is transitive. merge must appear only once. + expect(result.metadata.direct_count).toBe(2) + expect(result.metadata.transitive_count).toBe(1) + // Total = 3, which is LOW severity + expect(result.metadata.severity).toBe("LOW") + }) + + test("handles dotted depends_on references (e.g. project.model)", async () => { + mockDispatcher({ + "dbt.manifest": { + models: [ + { name: "stg_users", depends_on: [], materialized: "view" }, + { name: "dim_users", depends_on: ["myproject.stg_users"], materialized: "table" }, + ], + model_count: 2, + test_count: 0, + }, + }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "stg_users", change_type: "retype", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.success).toBe(true) + expect(result.metadata.direct_count).toBe(1) + expect(result.output).toContain("dim_users") + expect(result.output).toContain("CAUTION") // retype warning + }) +}) + +describe("impact_analysis: severity classification", () => { + function makeManifest(downstreamCount: number) { + const models = [{ name: "root", depends_on: [] as string[], materialized: "view" }] + for (let i = 0; i < downstreamCount; i++) { + models.push({ name: `model_${i}`, depends_on: ["root"], materialized: "table" }) + } + return { models, model_count: models.length, test_count: 0 } + } + + test("MEDIUM severity for 4-10 downstream models", async () => { + mockDispatcher({ "dbt.manifest": makeManifest(7) }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.severity).toBe("MEDIUM") + }) + + test("HIGH severity for >10 downstream models", async () => { + mockDispatcher({ "dbt.manifest": makeManifest(12) }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.severity).toBe("HIGH") + }) +}) + +describe("impact_analysis: error handling", () => { + test("returns ERROR when Dispatcher throws", async () => { + mockDispatcher({}) // no mock for dbt.manifest — will throw + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "x", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.title).toContain("ERROR") + expect(result.metadata.success).toBe(false) + expect(result.output).toContain("dbt compile") + }) +}) + +describe("impact_analysis: blast radius percentage", () => { + test("output includes blast radius percentage", async () => { + mockDispatcher({ + "dbt.manifest": { + models: [ + { name: "root", depends_on: [], materialized: "view" }, + { name: "child1", depends_on: ["root"], materialized: "table" }, + { name: "child2", depends_on: ["root"], materialized: "table" }, + { name: "unrelated", depends_on: [], materialized: "view" }, + ], + model_count: 4, + test_count: 3, + }, + }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "root", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + // 2 out of 4 = 50.0% + expect(result.output).toContain("50.0%") + expect(result.output).toContain("2/4") + }) +}) diff --git a/packages/opencode/test/altimate/training-import.test.ts b/packages/opencode/test/altimate/training-import.test.ts new file mode 100644 index 000000000..9ae52df90 --- /dev/null +++ b/packages/opencode/test/altimate/training-import.test.ts @@ -0,0 +1,289 @@ +/** + * Tests for the training_import tool — markdown parsing, section extraction, + * capacity enforcement, and dry-run preview. + * + * Mocks fs.readFile, TrainingStore, and TrainingPrompt so we can exercise + * the parsing and import logic without a real filesystem or memory store. + */ +import { describe, test, expect, spyOn, afterAll, beforeEach } from "bun:test" +import { TrainingImportTool } from "../../src/altimate/tools/training-import" +import { TrainingStore } from "../../src/altimate/training" +import { TrainingPrompt } from "../../src/altimate/training" +import { SessionID, MessageID } from "../../src/session/schema" +import * as fs from "fs/promises" + +// Disable telemetry +beforeEach(() => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" +}) +afterAll(() => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +// --- Spies --- +let readFileSpy: ReturnType +let countSpy: ReturnType +let saveSpy: ReturnType +let budgetSpy: ReturnType + +function setupMocks(opts: { + fileContent: string + currentCount?: number + saveShouldFail?: boolean +}) { + readFileSpy?.mockRestore() + countSpy?.mockRestore() + saveSpy?.mockRestore() + budgetSpy?.mockRestore() + + readFileSpy = spyOn(fs, "readFile").mockImplementation(async () => opts.fileContent) + countSpy = spyOn(TrainingStore, "count").mockImplementation(async () => ({ + standard: opts.currentCount ?? 0, + glossary: opts.currentCount ?? 0, + playbook: opts.currentCount ?? 0, + naming: opts.currentCount ?? 0, + pattern: opts.currentCount ?? 0, + })) + saveSpy = spyOn(TrainingStore, "save").mockImplementation(async () => { + if (opts.saveShouldFail) throw new Error("store write failed") + return {} as any + }) + budgetSpy = spyOn(TrainingPrompt, "budgetUsage").mockImplementation(async () => ({ + used: 500, + budget: 8000, + percent: 6, + })) +} + +afterAll(() => { + readFileSpy?.mockRestore() + countSpy?.mockRestore() + saveSpy?.mockRestore() + budgetSpy?.mockRestore() +}) + +describe("training_import: markdown parsing (dry_run)", () => { + test("extracts H2 sections as entries", async () => { + setupMocks({ + fileContent: [ + "# SQL Style Guide", + "", + "## Naming Conventions", + "Use snake_case for all identifiers.", + "", + "## SELECT Formatting", + "Always list columns explicitly.", + "Never use SELECT *.", + ].join("\n"), + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "style-guide.md", kind: "standard", scope: "project", dry_run: true, max_entries: 20 }, + ctx, + ) + expect(result.metadata.success).toBe(true) + expect(result.metadata.dry_run).toBe(true) + expect(result.metadata.count).toBe(2) + expect(result.output).toContain("naming-conventions") + expect(result.output).toContain("select-formatting") + }) + + test("includes H1 context prefix in section content", async () => { + setupMocks({ + fileContent: [ + "# Data Engineering Standards", + "", + "## CTE Usage", + "Always use CTEs instead of subqueries.", + ].join("\n"), + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "doc.md", kind: "standard", scope: "project", dry_run: true, max_entries: 20 }, + ctx, + ) + expect(result.metadata.count).toBe(1) + // The H1 title should appear as context inside the entry + expect(result.output).toContain("Context: Data Engineering Standards") + }) + + test("returns NO SECTIONS when markdown has no H2 headings", async () => { + setupMocks({ + fileContent: [ + "# Just a Title", + "", + "Some body text but no ## headings.", + "", + "More text here.", + ].join("\n"), + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "empty.md", kind: "glossary", scope: "project", dry_run: true, max_entries: 20 }, + ctx, + ) + expect(result.title).toContain("NO SECTIONS") + expect(result.metadata.success).toBe(false) + expect(result.metadata.count).toBe(0) + }) + + test("skips H2 sections with empty content", async () => { + setupMocks({ + fileContent: [ + "## Empty Section", + "", + "## Section With Content", + "This has actual content.", + ].join("\n"), + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "mixed.md", kind: "standard", scope: "project", dry_run: true, max_entries: 20 }, + ctx, + ) + // "Empty Section" has only a blank line — should be trimmed and skipped + // (parseMarkdownSections checks currentContent.length > 0 AND trims) + expect(result.metadata.count).toBeGreaterThanOrEqual(1) + expect(result.output).toContain("section-with-content") + }) + + test("respects max_entries limit", async () => { + const sections = Array.from({ length: 10 }, (_, i) => + `## Section ${i}\nContent for section ${i}.` + ).join("\n\n") + setupMocks({ fileContent: sections }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "many.md", kind: "standard", scope: "project", dry_run: true, max_entries: 3 }, + ctx, + ) + expect(result.metadata.count).toBe(3) + }) +}) + +describe("training_import: capacity enforcement", () => { + test("warns when capacity is nearly full", async () => { + setupMocks({ + fileContent: [ + "## Entry 1", + "Content 1", + "", + "## Entry 2", + "Content 2", + "", + "## Entry 3", + "Content 3", + ].join("\n"), + currentCount: 48, // TRAINING_MAX_PATTERNS_PER_KIND is 50 + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "doc.md", kind: "standard", scope: "project", dry_run: true, max_entries: 20 }, + ctx, + ) + // Only 2 slots available, 3 entries found — should show WARNING + expect(result.metadata.count).toBe(2) + expect(result.output).toContain("WARNING") + expect(result.output).toContain("SKIP") + }) +}) + +describe("training_import: actual import (dry_run=false)", () => { + test("calls TrainingStore.save for each entry", async () => { + setupMocks({ + fileContent: [ + "## Naming Rules", + "Use snake_case.", + "", + "## Join Style", + "Always use explicit JOIN.", + ].join("\n"), + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "guide.md", kind: "standard", scope: "project", dry_run: false, max_entries: 20 }, + ctx, + ) + expect(result.metadata.success).toBe(true) + expect(result.metadata.count).toBe(2) + expect(saveSpy).toHaveBeenCalledTimes(2) + expect(result.output).toContain("Imported 2") + expect(result.output).toContain("Training usage:") + }) + + test("reports errors when TrainingStore.save fails", async () => { + setupMocks({ + fileContent: [ + "## Rule A", + "Content A", + ].join("\n"), + saveShouldFail: true, + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "fail.md", kind: "standard", scope: "project", dry_run: false, max_entries: 20 }, + ctx, + ) + expect(result.metadata.success).toBe(true) // tool itself succeeds + expect(result.metadata.count).toBe(0) // but no entries saved + expect(result.metadata.skipped).toBe(1) + expect(result.output).toContain("FAIL") + }) +}) + +describe("training_import: slugify edge cases", () => { + test("handles special characters and unicode in headings", async () => { + setupMocks({ + fileContent: [ + "## CTE Best Practices (v2.0) \u2014 Updated!", + "Always name CTEs descriptively.", + ].join("\n"), + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "special.md", kind: "standard", scope: "project", dry_run: true, max_entries: 20 }, + ctx, + ) + expect(result.metadata.count).toBe(1) + // Slugified name should strip special chars and use hyphens + expect(result.output).toContain("cte-best-practices-v20-updated") + }) +}) + +describe("training_import: error handling", () => { + test("returns ERROR when file cannot be read", async () => { + readFileSpy?.mockRestore() + readFileSpy = spyOn(fs, "readFile").mockImplementation(async () => { + throw new Error("ENOENT: no such file") + }) + + const tool = await TrainingImportTool.init() + const result = await tool.execute( + { file_path: "nonexistent.md", kind: "standard", scope: "project", dry_run: true, max_entries: 20 }, + ctx, + ) + expect(result.title).toContain("ERROR") + expect(result.metadata.success).toBe(false) + expect(result.output).toContain("ENOENT") + }) +}) From 4d8e6ec48870cc763d436f2d24556324388e1da2 Mon Sep 17 00:00:00 2001 From: anandgupta42 <93243293+anandgupta42@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:16:57 -0700 Subject: [PATCH 2/2] =?UTF-8?q?test:=20address=20critic=20review=20?= =?UTF-8?q?=E2=80=94=20boundary=20tests,=20tighter=20assertions,=20constan?= =?UTF-8?q?t=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add severity boundary tests at exact thresholds (3→LOW, 4→MEDIUM, 10→MEDIUM, 11→HIGH) - Fix blast radius test to verify model_count drives denominator, not models.length - Import TRAINING_MAX_PATTERNS_PER_KIND instead of hardcoding 48 - Document that parseMarkdownSections includes empty sections (potential future fix) - Tighten empty-section assertion from toBeGreaterThanOrEqual to exact toBe https://claude.ai/code/session_01H7d93hQP5qAwYhLgRdkqhV --- .../test/altimate/impact-analysis.test.ts | 40 ++++++++++++++----- .../test/altimate/training-import.test.ts | 15 ++++--- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/opencode/test/altimate/impact-analysis.test.ts b/packages/opencode/test/altimate/impact-analysis.test.ts index 6f1ca47a3..17640322c 100644 --- a/packages/opencode/test/altimate/impact-analysis.test.ts +++ b/packages/opencode/test/altimate/impact-analysis.test.ts @@ -178,8 +178,18 @@ describe("impact_analysis: severity classification", () => { return { models, model_count: models.length, test_count: 0 } } - test("MEDIUM severity for 4-10 downstream models", async () => { - mockDispatcher({ "dbt.manifest": makeManifest(7) }) + test("LOW severity boundary: exactly 3 downstream models", async () => { + mockDispatcher({ "dbt.manifest": makeManifest(3) }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.severity).toBe("LOW") + }) + + test("MEDIUM severity boundary: exactly 4 downstream models", async () => { + mockDispatcher({ "dbt.manifest": makeManifest(4) }) const tool = await ImpactAnalysisTool.init() const result = await tool.execute( { model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" }, @@ -188,8 +198,18 @@ describe("impact_analysis: severity classification", () => { expect(result.metadata.severity).toBe("MEDIUM") }) - test("HIGH severity for >10 downstream models", async () => { - mockDispatcher({ "dbt.manifest": makeManifest(12) }) + test("MEDIUM severity boundary: exactly 10 downstream models", async () => { + mockDispatcher({ "dbt.manifest": makeManifest(10) }) + const tool = await ImpactAnalysisTool.init() + const result = await tool.execute( + { model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" }, + ctx, + ) + expect(result.metadata.severity).toBe("MEDIUM") + }) + + test("HIGH severity boundary: exactly 11 downstream models", async () => { + mockDispatcher({ "dbt.manifest": makeManifest(11) }) const tool = await ImpactAnalysisTool.init() const result = await tool.execute( { model: "root", change_type: "modify", manifest_path: "target/manifest.json", dialect: "snowflake" }, @@ -214,7 +234,9 @@ describe("impact_analysis: error handling", () => { }) describe("impact_analysis: blast radius percentage", () => { - test("output includes blast radius percentage", async () => { + test("percentage uses model_count, not models array length", async () => { + // model_count (20) intentionally differs from models.length (4) + // to verify the denominator comes from the declared count mockDispatcher({ "dbt.manifest": { models: [ @@ -223,7 +245,7 @@ describe("impact_analysis: blast radius percentage", () => { { name: "child2", depends_on: ["root"], materialized: "table" }, { name: "unrelated", depends_on: [], materialized: "view" }, ], - model_count: 4, + model_count: 20, test_count: 3, }, }) @@ -232,8 +254,8 @@ describe("impact_analysis: blast radius percentage", () => { { model: "root", change_type: "remove", manifest_path: "target/manifest.json", dialect: "snowflake" }, ctx, ) - // 2 out of 4 = 50.0% - expect(result.output).toContain("50.0%") - expect(result.output).toContain("2/4") + // 2 downstream out of 20 declared = 10.0% + expect(result.output).toContain("10.0%") + expect(result.output).toContain("2/20") }) }) diff --git a/packages/opencode/test/altimate/training-import.test.ts b/packages/opencode/test/altimate/training-import.test.ts index 9ae52df90..847c32333 100644 --- a/packages/opencode/test/altimate/training-import.test.ts +++ b/packages/opencode/test/altimate/training-import.test.ts @@ -9,6 +9,7 @@ import { describe, test, expect, spyOn, afterAll, beforeEach } from "bun:test" import { TrainingImportTool } from "../../src/altimate/tools/training-import" import { TrainingStore } from "../../src/altimate/training" import { TrainingPrompt } from "../../src/altimate/training" +import { TRAINING_MAX_PATTERNS_PER_KIND } from "../../src/altimate/training" import { SessionID, MessageID } from "../../src/session/schema" import * as fs from "fs/promises" @@ -141,7 +142,11 @@ describe("training_import: markdown parsing (dry_run)", () => { expect(result.metadata.count).toBe(0) }) - test("skips H2 sections with empty content", async () => { + test("includes H2 sections even when content is only whitespace", async () => { + // NOTE: parseMarkdownSections checks currentContent.length > 0 (array length) + // but does NOT check whether the trimmed content is empty. This means a + // section with only blank lines still gets included. This documents the + // actual behavior — a future fix could skip truly empty sections. setupMocks({ fileContent: [ "## Empty Section", @@ -156,10 +161,10 @@ describe("training_import: markdown parsing (dry_run)", () => { { file_path: "mixed.md", kind: "standard", scope: "project", dry_run: true, max_entries: 20 }, ctx, ) - // "Empty Section" has only a blank line — should be trimmed and skipped - // (parseMarkdownSections checks currentContent.length > 0 AND trims) - expect(result.metadata.count).toBeGreaterThanOrEqual(1) + expect(result.metadata.count).toBe(2) expect(result.output).toContain("section-with-content") + // Empty section is included with 0 chars after trim + expect(result.output).toContain("empty-section") }) test("respects max_entries limit", async () => { @@ -190,7 +195,7 @@ describe("training_import: capacity enforcement", () => { "## Entry 3", "Content 3", ].join("\n"), - currentCount: 48, // TRAINING_MAX_PATTERNS_PER_KIND is 50 + currentCount: TRAINING_MAX_PATTERNS_PER_KIND - 2, }) const tool = await TrainingImportTool.init()