import { describe, it, test, expect } from "vitest"; import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { const blocks = splitBlocks("# Heading\n\nFirst para.\n\nSecond para.\n"); expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "prose"]); expect(blocks[0].raw).toBe("# Heading"); expect(blocks[2].raw).toBe("Second para."); }); it("keeps a fenced code block whole and tags it `code`", () => { const text = "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro.\n"; const blocks = splitBlocks(text); expect(blocks.map((b) => b.type)).toEqual(["prose", "code", "prose"]); expect(blocks[1].raw).toBe("```ts\nconst a = 1;\n\nconst b = 2;\n```"); }); it("tags a mermaid fence `mermaid`", () => { const blocks = splitBlocks("```mermaid\nflowchart LR\n a-->b\n```\n"); expect(blocks).toHaveLength(1); expect(blocks[0].type).toBe("mermaid"); }); it("normalizes the key (whitespace-collapsed) for matching", () => { const blocks = splitBlocks("Hello world\n"); expect(blocks[0].key).toBe("hello world"); }); }); describe("diffBlocks", () => { const kinds = (base: string, cur: string) => diffBlocks(base, cur).map((o) => o.kind); it("unchanged doc → all unchanged", () => { const doc = "Alpha.\n\nBravo.\n"; expect(kinds(doc, doc)).toEqual(["unchanged", "unchanged"]); }); it("a pure addition → an added op", () => { const ops = diffBlocks("Alpha.\n", "Alpha.\n\nBravo.\n"); expect(ops.map((o) => o.kind)).toEqual(["unchanged", "added"]); }); it("a pure deletion → a removed op", () => { const ops = diffBlocks("Alpha.\n\nBravo.\n", "Alpha.\n"); expect(ops.map((o) => o.kind)).toEqual(["unchanged", "removed"]); }); it("a prose modification → a non-atomic changed op", () => { const ops = diffBlocks("The quick fox.\n", "The slow fox.\n"); expect(ops).toHaveLength(1); expect(ops[0].kind).toBe("changed"); expect(ops[0].kind === "changed" && ops[0].atomic).toBe(false); }); it("a code-fence change → an ATOMIC changed op (INV-23)", () => { const base = "```ts\nconst a = 1;\n```\n"; const cur = "```ts\nconst a = 2;\n```\n"; const ops = diffBlocks(base, cur); expect(ops).toHaveLength(1); expect(ops[0].kind).toBe("changed"); expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); }); it("a mermaid-fence change → an ATOMIC changed op", () => { const base = "```mermaid\nflowchart LR\n a-->b\n```\n"; const cur = "```mermaid\nflowchart LR\n a-->c\n```\n"; const ops = diffBlocks(base, cur); expect(ops[0].kind).toBe("changed"); expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true); }); it("a reorder → keeps a matched block unchanged", () => { const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n"); const k = ops.map((o) => o.kind); expect(k).toContain("unchanged"); }); }); describe("renderTrackChanges", () => { it("wraps each block in a cw-blk div with its kind class", () => { const html = renderTrackChanges("Alpha.\n", "Alpha.\n\nBravo.\n"); expect(html).toContain('class="cw-blk cw-unchanged"'); expect(html).toContain('class="cw-blk cw-added"'); }); it("emits inline / for a prose modification", () => { const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n"); expect(html).toContain(""); expect(html).toContain(""); expect(html).toContain("cw-changed"); }); it("renders a changed CODE fence atomically: cw-changed, no inline ins/del", () => { const html = renderTrackChanges("```ts\nconst a = 1;\n```\n", "```ts\nconst a = 2;\n```\n"); expect(html).toContain("cw-changed"); expect(html).not.toContain(""); expect(html).not.toContain(""); expect(html).toContain("cw-badge"); }); it('emits
 for a changed flowchart, augmented intra-diagram (#22)', () => {
    const html = renderTrackChanges(
      "```mermaid\nflowchart LR\n a-->b\n```\n",
      "```mermaid\nflowchart LR\n a-->c\n```\n",
    );
    expect(html).toContain('
');
    expect(html).toContain("a-->c"); // current source preserved as prefix, escaped
    // a changed flowchart is now diffed in place (INV-29): styling, not a whole-block badge
    expect(html).toContain("classDef cwAdded");
    expect(html).not.toContain('changed');
  });

  it("unchanged doc renders with no marks", () => {
    const html = renderTrackChanges("Alpha.\n", "Alpha.\n");
    expect(html).not.toContain("cw-added");
    expect(html).not.toContain("cw-removed");
    expect(html).not.toContain("cw-changed");
  });

  it("is deterministic (same inputs → identical HTML) (INV-22)", () => {
    const a = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
    const b = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
    expect(a).toBe(b);
  });
});

describe("error chip (PUC-6)", () => {
  it("a block whose render throws becomes an error chip; the rest still renders", () => {
    const throwing = (src: string) => {
      if (src.includes("BOOM")) throw new Error("kaboom");
      return `

${src}

`; }; const html = renderTrackChanges("Good one.\n\nBOOM here.\n", "Good one.\n\nBOOM here.\n", { render: throwing, }); expect(html).toContain("cw-error"); expect(html).toContain("kaboom"); expect(html).toContain("

Good one.

"); // the healthy block still rendered }); }); describe("splitBlocksWithRanges — block offsets align with the source string", () => { it("each block's [start,end) slices back to its raw from the source", () => { const text = "# Title\n\nA prose paragraph.\n\n```js\ncode()\n```\n"; const blocks = splitBlocksWithRanges(text); expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "code"]); for (const b of blocks) { expect(text.slice(b.start, b.end)).toBe(b.raw); } expect(blocks[1].raw).toBe("A prose paragraph."); }); it("marks a mermaid fence and preserves its source offsets", () => { const text = "intro\n\n```mermaid\ngraph TD; A-->B;\n```\n"; const blocks = splitBlocksWithRanges(text); expect(blocks[1].type).toBe("mermaid"); expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw); }); }); describe("colorByAuthor", () => { test("colorByAuthor wraps human-authored prose in cw-by-human spans", () => { const raw = "hello world"; const spans: AuthorSpan[] = [{ start: 0, end: 5, author: "human" }]; const render = (src: string) => `

${src}

`; const html = colorByAuthor(raw, 0, spans, render); expect(html).toContain('hello'); expect(html).toContain("world"); }); }); import { renderPlain } from "../src/trackChangesModel"; describe("renderPlain", () => { test("renderPlain renders current buffer as plain markdown (no marks)", () => { const html = renderPlain("# Title\n\nhello"); expect(html).toContain("

Title

"); expect(html).toContain("hello"); expect(html).not.toContain("cw-"); }); }); import { renderReview, type ProposalView } from "../src/trackChangesModel"; describe("renderReview", () => { test("renderReview: human addition since baseline renders green ins / cw-by-human", () => { const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); expect(html).toMatch(/[^<]*world[^<]*<\/ins>|cw-by-human/); }); test("renderReview: deletion since baseline renders struck del/cw-del", () => { const html = renderReview("hello world", "hello", [], []); expect(html).toMatch(/|cw-del/); }); test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ actions", () => { const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }]; const html = renderReview("hello", "hello", [], proposals); expect(html).toContain('class="cw-proposal"'); expect(html).toContain('data-proposal-id="p1"'); expect(html).toContain("cw-actions"); expect(html).toContain("goodbye"); expect(html).toMatch(/]*>[^<]*hello[^<]*<\/del>|cw-del/); }); test("renderReview: an unresolved proposal renders as a trailing block (never dropped)", () => { const proposals: ProposalView[] = [{ id: "p2", anchorStart: null, anchorEnd: null, replaced: "x", replacement: "y" }]; const html = renderReview("a", "a", [], proposals); expect(html).toContain('data-proposal-id="p2"'); expect(html).toContain("cw-proposal-unanchored"); }); test("renderReview is deterministic (same inputs → identical HTML)", () => { const a = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); const b = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []); expect(a).toBe(b); }); test("renderReview: an atomic mermaid change is diffed whole (no inner author sentinels)", () => { const base = "```mermaid\nflowchart LR\n A --> B\n```"; const cur = "```mermaid\nflowchart LR\n A --> C\n```"; const html = renderReview(base, cur, [], []); expect(html).toContain("mermaid"); expect(html).not.toContain("cw-by-"); }); test("renderReview: author-colors the correct block when two paragraphs are identical", () => { // Two identical paragraphs; baseline has only the first, so the SECOND is an // added block authored by human. Its span must color THAT block, not the first. const baseline = "Hello world"; const current = "Hello world\n\nHello world"; // second "Hello world" starts at offset 13; "world" at 19..24 const spans = [{ start: 19, end: 24, author: "human" as const }]; const html = renderReview(baseline, current, spans, []); // exactly one cw-by-human span (the added second block's "world"), not zero, not on the first. const count = (html.match(/cw-by-human/g) ?? []).length; expect(count).toBe(1); }); // #31 — a resolved proposal renders INLINE at its anchor's block, not as a trailing block. test("renderReview: a resolved proposal renders in place after its anchor's block, not trailing", () => { // Two paragraphs; "Alpha here" at 0..10, "Beta there" at 12..22. const doc = "Alpha here\n\nBeta there"; const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "Alpha", replacement: "Omega" }]; const html = renderReview(doc, doc, [], proposals); const pIdx = html.indexOf('data-proposal-id="p1"'); const alphaIdx = html.indexOf("Alpha here"); const betaIdx = html.indexOf("Beta there"); expect(pIdx).toBeGreaterThan(alphaIdx); // after its own block expect(pIdx).toBeLessThan(betaIdx); // and BEFORE the next block (in place, not trailing) }); test("renderReview: two proposals in distinct blocks each render after their own block", () => { const doc = "Alpha here\n\nBeta there"; const proposals: ProposalView[] = [ { id: "pA", anchorStart: 0, anchorEnd: 5, replaced: "Alpha", replacement: "Omega" }, { id: "pB", anchorStart: 12, anchorEnd: 16, replaced: "Beta", replacement: "Gamma" }, ]; const html = renderReview(doc, doc, [], proposals); const betaIdx = html.indexOf("Beta there"); expect(html.indexOf('data-proposal-id="pA"')).toBeLessThan(betaIdx); // before second block expect(html.indexOf('data-proposal-id="pB"')).toBeGreaterThan(betaIdx); // after second block }); test("renderReview: two proposals in the same block are ordered by anchorStart (deterministic)", () => { // single block "one two three": one@0, two@4, three@8 const doc = "one two three"; const proposals: ProposalView[] = [ { id: "late", anchorStart: 8, anchorEnd: 13, replaced: "three", replacement: "trois" }, { id: "early", anchorStart: 0, anchorEnd: 3, replaced: "one", replacement: "uno" }, ]; const html = renderReview(doc, doc, [], proposals); expect(html.indexOf('data-proposal-id="early"')).toBeLessThan(html.indexOf('data-proposal-id="late"')); }); test("renderReview: a resolved proposal renders before a trailing unanchored one (INV-34)", () => { const doc = "Alpha here\n\nBeta there"; const proposals: ProposalView[] = [ { id: "anc", anchorStart: 0, anchorEnd: 5, replaced: "Alpha", replacement: "Omega" }, { id: "unanc", anchorStart: null, anchorEnd: null, replaced: "x", replacement: "y" }, ]; const html = renderReview(doc, doc, [], proposals); const uIdx = html.indexOf('data-proposal-id="unanc"'); expect(html.indexOf('data-proposal-id="anc"')).toBeLessThan(uIdx); // resolved in-place first expect(uIdx).toBeGreaterThan(html.indexOf("Beta there")); // unanchored still trails the body expect(html).toContain("cw-proposal-unanchored"); }); test("renderReview is deterministic with mixed anchored/unanchored proposals", () => { const doc = "one two three"; const proposals: ProposalView[] = [ { id: "late", anchorStart: 8, anchorEnd: 13, replaced: "three", replacement: "trois" }, { id: "early", anchorStart: 0, anchorEnd: 3, replaced: "one", replacement: "uno" }, { id: "floating", anchorStart: null, anchorEnd: null, replaced: "z", replacement: "w" }, ]; const a = renderReview(doc, doc, [], proposals); const b = renderReview(doc, doc, [], proposals); expect(a).toBe(b); }); }); import { renderTrackChanges as rtc2 } from "../src/trackChangesModel"; describe("renderTrackChanges — intra-diagram mermaid (#22)", () => { it("augments a changed flowchart with styling directives (INV-29)", () => { const base = "```mermaid\nflowchart LR\n A-->B\n```\n"; const cur = "```mermaid\nflowchart LR\n A-->B\n B-->C\n```\n"; const html = rtc2(base, cur); expect(html).toContain("classDef cwAdded"); expect(html).toMatch(/class\s+C\s+cwAdded/); expect(html).toContain('class="cw-mermaid-legend"'); // no single whole-block "changed" badge when we augmented expect(html).not.toContain('changed'); }); it("augments a changed sequence diagram", () => { const base = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n"; const cur = "```mermaid\nsequenceDiagram\n A->>B: hi\n B->>C: on\n```\n"; const html = rtc2(base, cur); expect(html).toContain("rect rgb(46, 160, 67)"); }); it("falls back to the v1 badge for an unsupported diagram type (INV-30)", () => { const base = "```mermaid\nclassDiagram\n class A\n```\n"; const cur = "```mermaid\nclassDiagram\n class B\n```\n"; const html = rtc2(base, cur); expect(html).toContain('changed'); expect(html).not.toContain("classDef cwAdded"); }); }); // F11 SLICE-3 (INV-37, §6.4): a whole-document rewrite is diffed into per-hunk // proposal ranges — each an independent F4 single-range proposal. Pure, // vscode-free, deterministic; offsets index into currentText. describe("F11 diffToHunks (INV-37)", () => { test("an identical rewrite → zero hunks", () => { expect(diffToHunks("The same text.\n", "The same text.\n")).toEqual([]); }); test("a single changed word → one hunk over exactly that word", () => { const current = "The quick brown fox."; const hunks = diffToHunks(current, "The quick red fox."); expect(hunks).toHaveLength(1); expect(current.slice(hunks[0].start, hunks[0].end)).toBe("brown"); expect(hunks[0].replacement).toBe("red"); }); test("two disjoint changes → two hunks with correct ranges + replacements", () => { const current = "one two three four"; const hunks = diffToHunks(current, "one TWO three FOUR"); expect(hunks).toHaveLength(2); expect(current.slice(hunks[0].start, hunks[0].end)).toBe("two"); expect(hunks[0].replacement).toBe("TWO"); expect(current.slice(hunks[1].start, hunks[1].end)).toBe("four"); expect(hunks[1].replacement).toBe("FOUR"); // disjoint + ordered expect(hunks[0].end).toBeLessThanOrEqual(hunks[1].start); }); test("a wholesale replacement (nothing in common) → one full-range hunk", () => { const current = "alpha"; const hunks = diffToHunks(current, "omega"); expect(hunks).toHaveLength(1); expect(hunks[0]).toEqual({ start: 0, end: current.length, replacement: "omega" }); }); test("is deterministic — same inputs → identical hunks", () => { const a = diffToHunks("a b c d", "a B c D"); const b = diffToHunks("a b c d", "a B c D"); expect(a).toEqual(b); }); }); // F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end // (source char offsets from BlockWithRange) on every LIVE-source rendered block, // in BOTH modes. The webview's selection→source mapping walks the DOM to the // nearest data-src ancestor; these offsets are the contract. describe("F11 data-src emission (INV-36)", () => { /** Pull every data-src-start/end pair from an HTML string, in document order. */ const srcRanges = (html: string): Array<{ start: number; end: number }> => Array.from(html.matchAll(/data-src-start="(\d+)" data-src-end="(\d+)"/g)).map((m) => ({ start: Number(m[1]), end: Number(m[2]), })); test("renderPlain wraps every block with data-src offsets equal to splitBlocksWithRanges (and stays clean)", () => { const doc = "# Title\n\nFirst para.\n\nSecond para.\n"; const blocks = splitBlocksWithRanges(doc); const html = renderPlain(doc); expect(srcRanges(html)).toEqual(blocks.map((b) => ({ start: b.start, end: b.end }))); // off-mode stays the clean preview: no annotation marks. expect(html).not.toContain("cw-"); // each range slices back to its block's raw source. for (const b of blocks) expect(doc.slice(b.start, b.end)).toBe(b.raw); }); test("renderReview emits data-src on each live block; removed + proposal blocks carry none (INV-36)", () => { // baseline has an extra paragraph that is REMOVED in current; current adds one. const baseline = "Keep this.\n\nDrop this.\n"; const current = "Keep this.\n\nBrand new.\n"; const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 4, replaced: "Keep", replacement: "Hold" }]; const html = renderReview(baseline, current, [], proposals); const liveBlocks = splitBlocksWithRanges(current); // exactly one data-src per LIVE (current-side) block — removed/proposal blocks excluded. expect(srcRanges(html)).toEqual(liveBlocks.map((b) => ({ start: b.start, end: b.end }))); // the proposal block itself is not a live-source block. const propIdx = html.indexOf('data-proposal-id="p1"'); const propTag = html.slice(html.lastIndexOf(" { const a = renderPlain("alpha\n\nbeta\n"); const b = renderPlain("alpha\n\nbeta\n"); expect(a).toBe(b); const r1 = renderReview("x", "x\n\ny", [], []); const r2 = renderReview("x", "x\n\ny", [], []); expect(r1).toBe(r2); }); });