Files
vscode-cowriting-plugin/test/trackChangesModel.test.ts
T
Ben Stull 47cc733026 fix(f11): address code review — insertion anchoring, turnId, accept-all coverage (#43)
Code-review follow-up on the F11 branch (5966907..1564ef5). Three real findings:

- CRITICAL — pure-insertion hunks were born-orphaned. A document rewrite that
  INSERTS text produced a zero-width hunk (start==end) → buildFingerprint yields
  empty fp.text → anchorer.resolve orphans an empty needle → the proposal could
  never be accepted (silently). diffToHunks now anchors every zero-width
  insertion to an adjacent source token (anchorInsertion): the range absorbs the
  token and the replacement keeps it, so net text is identical but fp.text is
  non-empty and resolvable. New unit tests: applying hunks always reconstructs
  the rewrite (substitute/delete/insert/multi); insertions are never zero-width.
  New host E2E ACCEPTS a rewrite-with-insertion end to end and asserts accept-all
  reconstructs the intended document (also covers sequential multi-hunk accept).

- IMPORTANT — renderPlain cross-block fidelity. The per-block off-mode rendering
  (SLICE-2) can't resolve a reference-link definition in a separate block.
  Documented as a conscious tradeoff of the operator-locked block-level mapping
  (§6.7) — renderReview already rendered per-block, so this keeps both modes
  consistent — with a characterization test + a docstring note.

- MINOR — F11 proposals now carry a turnId (one per Ask-Claude gesture, shared
  across a document rewrite's N hunks), matching the editor-menu editSelection
  path so a rewrite groups as one agent turn.

208 unit + 9/9 F11 host E2E green. (The lone red E2E is a pre-existing,
F11-independent undoMarks timing flake — proven by isolation: it fails
identically with all F11 tests removed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:16:49 -07:00

471 lines
22 KiB
TypeScript

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 <ins>/<del> for a prose modification", () => {
const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n");
expect(html).toContain("<ins>");
expect(html).toContain("<del>");
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("<ins>");
expect(html).not.toContain("<del>");
expect(html).toContain("cw-badge");
});
it('emits <pre class="mermaid"> 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('<pre class="mermaid">');
expect(html).toContain("a--&gt;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('<span class="cw-badge">changed</span>');
});
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 `<p>${src}</p>`;
};
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("<p>Good one.</p>"); // 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) => `<p>${src}</p>`;
const html = colorByAuthor(raw, 0, spans, render);
expect(html).toContain('<span class="cw-by-human">hello</span>');
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("<h1>Title</h1>");
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(/<ins>[^<]*world[^<]*<\/ins>|cw-by-human/);
});
test("renderReview: deletion since baseline renders struck del/cw-del", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/<del>|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(/<del[^>]*>[^<]*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('<span class="cw-badge">changed</span>');
});
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('<span class="cw-badge">changed</span>');
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);
});
/** Apply hunks (right→left so earlier offsets stay valid) to reconstruct the rewrite. */
const applyHunks = (current: string, hunks: ReturnType<typeof diffToHunks>): string => {
let out = current;
for (const h of [...hunks].sort((a, b) => b.start - a.start)) {
out = out.slice(0, h.start) + h.replacement + out.slice(h.end);
}
return out;
};
test("applying the hunks always reconstructs the rewrite (substitute / delete / insert / multi)", () => {
const cases: Array<[string, string]> = [
["The quick brown fox.", "The quick red fox."],
["one two three four", "one TWO three FOUR"],
["alpha", "omega"],
["keep this and drop that", "keep this"],
["one two three", "one INSERTED two three"],
["start middle end", "PREFIX start middle end SUFFIX"],
["unchanged body", "unchanged body"],
];
for (const [current, rewrite] of cases) {
expect(applyHunks(current, diffToHunks(current, rewrite))).toBe(rewrite);
}
});
test("an inserted run anchors to adjacent text — never a zero-width, unacceptable hunk (INV-37)", () => {
// A pure insertion would otherwise produce start==end → an empty fingerprint
// → resolve() orphans it → the proposal can never be accepted. Each hunk must
// span real source text so its F4 fingerprint resolves.
for (const [current, rewrite] of [
["one two three", "one INSERTED two three"],
["tail anchor", "tail anchor APPENDED"],
["lead", "PREPENDED lead"],
] as Array<[string, string]>) {
for (const h of diffToHunks(current, rewrite)) {
expect(h.end).toBeGreaterThan(h.start); // non-zero-width
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0); // real fp.text
}
}
});
});
// 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("<div", propIdx), propIdx + 1);
expect(propTag).not.toContain("data-src-start");
});
test("data-src emission is deterministic — same inputs → identical HTML (extends INV-22)", () => {
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);
});
// CHARACTERIZATION (conscious tradeoff of the locked block-level mapping, §6.7
// fork 1): both modes render markdown PER BLOCK so each block can carry its
// data-src offsets. A consequence is that markdown constructs whose parts span
// blank-line-separated blocks — a reference-link USE and its DEFINITION — do not
// resolve across blocks (markdown-it sees each block in isolation). renderReview
// already had this; F11 brings the off/clean preview into line with it (both
// per-block) rather than leaving the two modes rendering differently. If
// cross-block fidelity is wanted later, it is a follow-up (source-map driven
// wrapping), not a change to the locked block-level decision.
test("a reference-link definition in a separate block does not resolve (block-level rendering)", () => {
const doc = "See [the spec][ref] for details.\n\n[ref]: https://example.com/spec\n";
const html = renderPlain(doc);
// the link is NOT resolved to an <a href> — the [text][ref] is rendered literally.
expect(html).not.toContain('href="https://example.com/spec"');
expect(html).toContain("[the spec][ref]");
// both modes agree (renderReview is likewise per-block) — the consistency point.
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
});
});