cf65528711
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
844 lines
42 KiB
TypeScript
844 lines
42 KiB
TypeScript
import { describe, it, test, expect } from "vitest";
|
|
import MarkdownIt from "markdown-it";
|
|
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, countLineHunks, type AuthorSpan } from "../src/trackChangesModel";
|
|
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
|
|
|
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-->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");
|
|
});
|
|
|
|
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
|
|
// exercise colorByAuthor directly with the real markdown-it renderer (unchanged
|
|
// blocks now render plain in renderReview, so sentinel testing requires a direct call).
|
|
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
|
|
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
|
|
const html = colorByAuthor("a**b**c", 0, [{ start: 0, end: 2, author: "human" }], src => md.render(src));
|
|
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
|
|
expect(html).not.toContain("**"); // no raw delimiters left
|
|
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
|
|
expect(html).toContain('class="cw-by-human"'); // coloring present
|
|
});
|
|
// CASE3 — a span boundary inside an emphasis run must color the text without
|
|
// misnesting span/element (the span is split at the element boundary).
|
|
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
|
|
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 4, author: "human" }], src => md.render(src)); // covers "**bo"
|
|
expect(html).toContain("<strong>");
|
|
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
|
|
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
|
|
});
|
|
// CASE2 — a span covering a whole emphasis run stays correct (regression).
|
|
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
|
|
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 8, author: "human" }], src => md.render(src));
|
|
expect(html).toContain("<strong>");
|
|
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
|
|
expect(html).toContain("bold");
|
|
});
|
|
test("#33: no Private-Use-Area sentinel chars leak into the rendered output", () => {
|
|
const doc = "a**b**c and `co de` and _x_";
|
|
const spans: AuthorSpan[] = [
|
|
{ start: 0, end: 2, author: "human" },
|
|
{ start: 12, end: 16, author: "claude" },
|
|
];
|
|
const html = renderReview(doc, doc, spans, []);
|
|
expect(html).not.toMatch(/[\uE000-\uF8FF]/); // no leftover BMP Private-Use-Area sentinels
|
|
});
|
|
});
|
|
|
|
describe("authorAt", () => {
|
|
const spans: AuthorSpan[] = [
|
|
{ start: 0, end: 5, author: "human" },
|
|
{ start: 5, end: 10, author: "claude" },
|
|
];
|
|
test("returns the covering span's author (half-open)", () => {
|
|
expect(authorAt(0, spans)).toBe("human");
|
|
expect(authorAt(4, spans)).toBe("human");
|
|
expect(authorAt(5, spans)).toBe("claude");
|
|
expect(authorAt(9, spans)).toBe("claude");
|
|
});
|
|
test("returns null outside any span", () => {
|
|
expect(authorAt(10, spans)).toBeNull();
|
|
expect(authorAt(-1, spans)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("wordDiffByAuthor", () => {
|
|
test("an inserted run is colored by the author covering its landed offset", () => {
|
|
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
|
|
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
|
|
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
|
|
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
|
|
expect(html).toContain("hello ");
|
|
});
|
|
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
|
|
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
|
|
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
|
|
const html = wordDiffByAuthor("light", "dark", 0, spans);
|
|
expect(html).toContain('<del class="cw-del-claude">light</del>');
|
|
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
|
|
});
|
|
test("a standalone deletion (no paired insertion) is neutral", () => {
|
|
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
|
|
expect(html).toContain('<del class="cw-del-none">this </del>');
|
|
expect(html).not.toContain("cw-del-human");
|
|
expect(html).not.toContain("cw-del-claude");
|
|
});
|
|
test("emits SIBLING ins/del elements, never nested — CSS overlap rule in preview.css is a defensive forward guarantee", () => {
|
|
// The engine assigns the cluster's insertion author to all deletions in the same
|
|
// cluster (adjacency heuristic), so <del> and <ins> always carry the SAME author
|
|
// as siblings. Cross-author nesting (<del class="cw-del-X"><ins class="cw-ins-Y">)
|
|
// is never produced today. The CSS rule `.cw-del-claude .cw-ins-human { text-decoration: inherit }`
|
|
// guards against a future regression where such nesting appears.
|
|
const spans: AuthorSpan[] = [{ start: 0, end: 100, author: "human" }];
|
|
const html = wordDiffByAuthor("old text", "new text", 0, spans);
|
|
expect(html).toContain('<del class="cw-del-human">');
|
|
expect(html).toContain('<ins class="cw-ins-human">');
|
|
// Structural check: no <del> immediately wraps an <ins> (sibling, not nested).
|
|
// /<del[^>]*>[^<]*<ins/ would match a nested case but not a sibling case
|
|
// because [^<]* stops before the closing </del> tag.
|
|
expect(html).not.toMatch(/<del[^>]*>[^<]*<ins/);
|
|
});
|
|
});
|
|
|
|
import { renderPlain, wordEditHunks, decorationPlan } from "../src/trackChangesModel";
|
|
|
|
test("committed change: wordEditHunks(current, baseline) → start/end index current; replacement is baseline text", () => {
|
|
const baseline = "the light mode";
|
|
const current = "the dark mode";
|
|
const [h] = wordEditHunks(current, baseline);
|
|
expect(current.slice(h.start, h.end)).toContain("dark"); // applied (current) side
|
|
expect(h.replacement).toContain("light"); // baseline (deleted) side
|
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
|
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
|
|
});
|
|
|
|
test("committed change with NO shared prefix: current offsets + deleted text both correct", () => {
|
|
const baseline = "alpha tail";
|
|
const current = "beta tail";
|
|
const [h] = wordEditHunks(current, baseline);
|
|
expect(h.start).toBe(0);
|
|
expect(current.slice(h.start, h.end)).toContain("beta"); // applied
|
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
|
expect(plan.deletions.some((d) => d.text.includes("alpha"))).toBe(true); // deletion not lost
|
|
});
|
|
|
|
test("standalone committed deletion → plan has no insertions (controller routes del to neutral)", () => {
|
|
// current = "keep word" (baseline had "this " between "keep " and "word" — standalone deletion)
|
|
const [h] = wordEditHunks("keep word", "keep this word");
|
|
const plan = decorationPlan(h.start, h.replacement, "keep word".slice(h.start, h.end));
|
|
expect(plan.insertions.length).toBe(0);
|
|
expect(plan.deletions.some((d) => d.text.includes("this"))).toBe(true);
|
|
});
|
|
|
|
test("paired committed replace → plan has insertions (deletion inherits author, not neutral)", () => {
|
|
// current = "the dark mode", baseline = "the light mode" — replacement, so paired
|
|
const [h] = wordEditHunks("the dark mode", "the light mode");
|
|
const plan = decorationPlan(h.start, h.replacement, "the dark mode".slice(h.start, h.end));
|
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
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 author-colored ins", () => {
|
|
// " world" is one diff token starting at offset 5 (the space), so the author span
|
|
// must start at 5 or earlier to cover the token's start offset.
|
|
const html = renderReview("hello", "hello world", [{ start: 5, end: 11, author: "human" }], []);
|
|
expect(html).toContain("cw-ins-human");
|
|
});
|
|
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
|
|
const html = renderReview("hello world", "hello", [], []);
|
|
expect(html).toMatch(/cw-del-none|cw-removed/);
|
|
});
|
|
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject 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.
|
|
// (Unchanged first block renders plain; added second block uses wordDiffByAuthor.)
|
|
const baseline = "Hello world";
|
|
const current = "Hello world\n\nHello world";
|
|
// second "Hello world" starts at offset 13 (past the "\n\n"); span covers the block.
|
|
// wordDiffByAuthor emits the whole block as one added token at offset 13.
|
|
const spans = [{ start: 13, end: 24, author: "human" as const }];
|
|
const html = renderReview(baseline, current, spans, []);
|
|
// exactly one cw-ins-human (the added second block), not zero, not on the first.
|
|
const count = (html.match(/cw-ins-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");
|
|
});
|
|
// #48: a PINNED baseline with zero changes leaves the panel fully un-annotated —
|
|
// no authorship coloring on unchanged blocks — while pending proposals still show.
|
|
test("renderReview: pinned + zero diff with author spans renders NO authorship coloring", () => {
|
|
const doc = "Human wrote this.\n\nClaude wrote that.";
|
|
const spans: AuthorSpan[] = [
|
|
{ start: 0, end: 17, author: "human" },
|
|
{ start: 19, end: doc.length, author: "claude" },
|
|
];
|
|
const html = renderReview(doc, doc, spans, [], { pinned: true });
|
|
expect(html).not.toContain("cw-by-human");
|
|
expect(html).not.toContain("cw-by-claude");
|
|
expect(html).not.toContain("cw-add");
|
|
expect(html).not.toContain("cw-del");
|
|
// still a selection→source surface (INV-36): blocks carry data-src offsets.
|
|
expect(html).toContain("data-src-start");
|
|
// the body text is still there, just plain.
|
|
expect(html).toContain("Human wrote this.");
|
|
expect(html).toContain("Claude wrote that.");
|
|
});
|
|
test("renderReview: pinned + zero diff still renders a pending proposal block", () => {
|
|
const doc = "Human wrote this.\n\nClaude wrote that.";
|
|
const spans: AuthorSpan[] = [{ start: 0, end: 17, author: "human" }];
|
|
const proposals: ProposalView[] = [
|
|
{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "Human", replacement: "Person" },
|
|
];
|
|
const html = renderReview(doc, doc, spans, proposals, { pinned: true });
|
|
expect(html).not.toContain("cw-by-human"); // body still clean
|
|
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
|
|
expect(html).toContain("Person");
|
|
});
|
|
test("renderReview: zero diff (baseline == current) renders unchanged blocks plain (Task 2 behavior)", () => {
|
|
// Task 2: unchanged blocks always render plain — color means "changed since baseline".
|
|
// When baseline == current (e.g. right after a fresh snapshot) there's no diff → no coloring.
|
|
const doc = "Human wrote this.\n\nClaude wrote that.";
|
|
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
|
|
const html = renderReview(doc, doc, spans, []); // no pinned flag
|
|
expect(html).not.toContain("cw-by-claude");
|
|
expect(html).toContain("Claude wrote that."); // text still present
|
|
});
|
|
test("renderReview: with REAL changes since a pin, author coloring returns on added blocks", () => {
|
|
// a genuine added block is still author-colored even when pinned (only the
|
|
// zero-diff-after-pin state is clean). Task 2: coloring is via cw-ins-*.
|
|
const baseline = "Hello world";
|
|
const current = "Hello world\n\nHello world";
|
|
// span covers the added block from its start (offset 13) so the whole token is colored.
|
|
const spans: AuthorSpan[] = [{ start: 13, end: 24, author: "human" }];
|
|
const html = renderReview(baseline, current, spans, [], { pinned: true });
|
|
expect((html.match(/cw-ins-human/g) ?? []).length).toBe(1);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test("renderReview renders an applied proposal ONCE, not also as a landed diff (INV-50)", () => {
|
|
const baseline = "# T\n\nThe brown fox.\n";
|
|
const current = "# T\n\nThe red fox.\n"; // proposal already optimistically applied
|
|
const proposals: ProposalView[] = [{
|
|
id: "pr_1",
|
|
anchorStart: current.indexOf("The red fox."),
|
|
anchorEnd: current.indexOf("The red fox.") + "The red fox.".length,
|
|
replaced: "The brown fox.", // original
|
|
replacement: "The red fox.",
|
|
}];
|
|
const html = renderReview(baseline, current, [], proposals);
|
|
// exactly one proposal block
|
|
expect((html.match(/cw-proposal/g) ?? []).length).toBe(1);
|
|
// the applied paragraph is NOT also emitted as a word-merged changed block
|
|
expect(html).not.toContain("<del>brown</del>"); // no double-render of the change as a baseline diff
|
|
});
|
|
|
|
test("renderReview keeps author coloring aligned on an ADDED block AFTER a length-changing pending proposal (INV-49/50)", () => {
|
|
// block 1 has a pending proposal (MUCH LONGER than original); block 2 is a NEW
|
|
// paragraph with a Claude authorship span (in currentText coords). The toLanded
|
|
// mapping must shift the span's coords so the added block is correctly colored.
|
|
const baseline = "old.";
|
|
const current = "LONGER REPLACEMENT.\n\nAdded para.";
|
|
const proposals: ProposalView[] = [{
|
|
id: "pr_1",
|
|
anchorStart: 0,
|
|
anchorEnd: "LONGER REPLACEMENT.".length,
|
|
replaced: "old.",
|
|
replacement: "LONGER REPLACEMENT.",
|
|
original: "old.",
|
|
}];
|
|
// "Added" in currentText starts at: "LONGER REPLACEMENT.\n\n".length = 21
|
|
const aStart = current.indexOf("Added");
|
|
const authorSpans = [{ start: aStart, end: aStart + "Added".length, author: "claude" as const }];
|
|
const html = renderReview(baseline, current, authorSpans, proposals);
|
|
// "Added" in the landedText block should be claude-colored despite the coordinate shift.
|
|
expect(html).toContain("cw-ins-claude");
|
|
});
|
|
|
|
test("proposalBlockHtml renders Accept/Reject controls with dropdown carets (#64)", () => {
|
|
const html = renderReview("a\n", "b\n", [], [
|
|
{ id: "pr_1", anchorStart: 0, anchorEnd: 1, replaced: "a", replacement: "b" },
|
|
]);
|
|
expect(html).toMatch(/data-action="accept"/);
|
|
expect(html).toMatch(/data-action="reject"/);
|
|
expect(html).toMatch(/data-action="acceptAll"/);
|
|
expect(html).toMatch(/data-action="rejectAll"/);
|
|
expect(html).toMatch(/Accept/);
|
|
expect(html).toMatch(/Reject/);
|
|
});
|
|
|
|
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
|
|
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
|
|
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
|
|
expect(html).toContain('cw-ins-claude');
|
|
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
|
|
});
|
|
|
|
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
|
|
const doc = "Alpha stays.";
|
|
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
|
|
expect(html).not.toContain("cw-by-");
|
|
expect(html).not.toContain("cw-ins-");
|
|
});
|
|
|
|
test("renderReview: an added block is author-colored as an insertion", () => {
|
|
// baseline empty-ish -> add a new paragraph authored by human
|
|
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
|
|
expect(html).toContain("cw-ins-human");
|
|
});
|
|
|
|
test("renderReview: an added heading block renders as a heading AND is author-colored as an insertion", () => {
|
|
// "Intro.\n\n" = 8 chars; "## New Section" = 14 chars → offset 8..22 in currentText.
|
|
// Before fix: wordDiffByAuthor wrapped raw in <ins> BEFORE markdown-it parsed it,
|
|
// so "## New Section" rendered as literal text (structural bug). After fix:
|
|
// colorByAuthor uses the sentinel technique — markdown-it parses the block first,
|
|
// so ## becomes a real <h2>, THEN sentinels are replaced with cw-ins-* spans.
|
|
const html = renderReview("Intro.", "Intro.\n\n## New Section", [{ start: 8, end: 22, author: "human" }], []);
|
|
expect(html).toMatch(/<h[12][^>]*>/); // real heading element, not literal "## "
|
|
expect(html).toContain("cw-ins-human"); // author-colored insertion
|
|
expect(html).not.toContain(">>"); // sanity: markers not escaped as text
|
|
});
|
|
|
|
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
|
|
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
|
|
const html = renderReview("hello", "hello", [], proposals);
|
|
expect(html).toContain('cw-del-claude');
|
|
expect(html).toContain('cw-ins-claude');
|
|
});
|
|
});
|
|
|
|
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
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// #47 SLICE-2 (INV-39): a whole-document rewrite is diffed into ONE EditHunk per
|
|
// CHANGED BLOCK (the unit a human reviews), superseding INV-37's per-word hunks.
|
|
// Built by coarsening diffToHunks to block boundaries; fences stay atomic
|
|
// (INV-23); inserted/deleted blocks keep their anchored gap hunks (INV-41).
|
|
// Applying all hunks right→left must still reconstruct the rewrite exactly.
|
|
describe("#47 diffToBlockHunks (INV-39/41)", () => {
|
|
const applyHunks = (current: string, hunks: ReturnType<typeof diffToBlockHunks>): 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("an identical rewrite → zero hunks", () => {
|
|
expect(diffToBlockHunks("# H\n\nSame body.\n", "# H\n\nSame body.\n")).toEqual([]);
|
|
});
|
|
|
|
test("two word edits in ONE paragraph → ONE block hunk (supersedes INV-37's two)", () => {
|
|
const current = "# Doc\n\nThe quick brown fox jumps over the lazy dog.\n";
|
|
const rewrite = "# Doc\n\nThe quick RED fox jumps over the lazy CAT.\n";
|
|
const hunks = diffToBlockHunks(current, rewrite);
|
|
expect(hunks).toHaveLength(1);
|
|
// the hunk spans the whole changed paragraph block
|
|
const para = "The quick brown fox jumps over the lazy dog.";
|
|
const start = current.indexOf(para);
|
|
expect(hunks[0].start).toBe(start);
|
|
expect(hunks[0].end).toBe(start + para.length);
|
|
expect(hunks[0].replacement).toBe("The quick RED fox jumps over the lazy CAT.");
|
|
expect(applyHunks(current, hunks)).toBe(rewrite);
|
|
});
|
|
|
|
test("edits across two paragraphs → two block hunks, one per changed block; unchanged → none", () => {
|
|
const current = "First para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
|
|
const rewrite = "First para ALPHA.\n\nSecond para beta.\n\nThird para GAMMA.\n";
|
|
const hunks = diffToBlockHunks(current, rewrite);
|
|
expect(hunks).toHaveLength(2);
|
|
// each hunk lands on a real block boundary in current
|
|
const blocks = splitBlocksWithRanges(current);
|
|
for (const h of hunks) {
|
|
expect(blocks.some((b) => b.start === h.start && b.end === h.end)).toBe(true);
|
|
}
|
|
expect(applyHunks(current, hunks)).toBe(rewrite);
|
|
});
|
|
|
|
test("a changed code fence → ONE atomic whole-fence hunk (INV-23)", () => {
|
|
const current = "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n";
|
|
const rewrite = "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n";
|
|
const hunks = diffToBlockHunks(current, rewrite);
|
|
expect(hunks).toHaveLength(1);
|
|
const fence = "```js\nconst a = 1;\nconst b = 2;\n```";
|
|
const start = current.indexOf(fence);
|
|
expect(hunks[0].start).toBe(start);
|
|
expect(hunks[0].end).toBe(start + fence.length);
|
|
expect(hunks[0].replacement).toBe("```js\nconst a = 10;\nconst b = 2;\n```");
|
|
expect(applyHunks(current, hunks)).toBe(rewrite);
|
|
});
|
|
|
|
test("every hunk is resolvable (non-zero-width, real source text) and reconstructs", () => {
|
|
const cases: Array<[string, string]> = [
|
|
["# H\n\nThe brown fox sleeps.\n", "# H\n\nThe brown fox QUIETLY sleeps today.\n"], // insert words mid-block
|
|
["A para.\n\nB para.\n\nC para.\n", "A para.\n\nC para.\n"], // delete a whole block
|
|
["A para.\n\nB para.\n", "A para.\n\nNEW para.\n\nB para.\n"], // insert a whole block
|
|
["Only one block here.\n", "A totally different single block.\n"], // wholesale
|
|
["Keep me.\n\nDrop this one.\n", "Keep me.\n"], // delete trailing block
|
|
["unchanged body\n", "unchanged body\n"], // no-op
|
|
];
|
|
for (const [current, rewrite] of cases) {
|
|
const hunks = diffToBlockHunks(current, rewrite);
|
|
for (const h of hunks) {
|
|
expect(h.end).toBeGreaterThan(h.start); // non-zero-width → F4 fp resolves
|
|
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0);
|
|
}
|
|
expect(applyHunks(current, hunks)).toBe(rewrite);
|
|
}
|
|
});
|
|
|
|
test("is deterministic — same inputs → identical hunks", () => {
|
|
const c = "P one.\n\nP two.\n";
|
|
const r = "P ONE.\n\nP two.\n";
|
|
expect(diffToBlockHunks(c, r)).toEqual(diffToBlockHunks(c, r));
|
|
});
|
|
});
|
|
|
|
// 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"');
|
|
});
|
|
});
|
|
|
|
describe("countLineHunks", () => {
|
|
it("0 for identical text", () => {
|
|
expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0);
|
|
});
|
|
it("1 for one contiguous change", () => {
|
|
expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1);
|
|
});
|
|
it("2 for two separated changes", () => {
|
|
expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2);
|
|
});
|
|
it("counts pure insertions and deletions", () => {
|
|
expect(countLineHunks("a\n", "a\nb\n")).toBe(1);
|
|
expect(countLineHunks("a\nb\n", "a\n")).toBe(1);
|
|
});
|
|
});
|