0311ad89ba
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { describe, it, test, expect } from "vitest";
|
|
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, renderAuthorship, 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-->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("renderAuthorship", () => {
|
|
const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => {
|
|
const start = text.indexOf(sub);
|
|
return { start, end: start + sub.length, author };
|
|
};
|
|
|
|
it("empty spans → plain render (no author wrappers)", () => {
|
|
const text = "# Hi\n\nplain paragraph.\n";
|
|
const html = renderAuthorship(text, []);
|
|
expect(html).not.toContain("cw-by-claude");
|
|
expect(html).not.toContain("cw-by-human");
|
|
expect(html).toContain("plain paragraph.");
|
|
});
|
|
|
|
it("wraps a single Claude span inline", () => {
|
|
const text = "The cat sat on the mat.\n";
|
|
const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]);
|
|
expect(html).toContain('<span class="cw-by-claude">cat sat</span>');
|
|
});
|
|
|
|
it("marks two authors in one paragraph at exact boundaries", () => {
|
|
const text = "Alpha beta gamma.\n";
|
|
const html = renderAuthorship(text, [
|
|
spanAt(text, "Alpha", "human"),
|
|
spanAt(text, "gamma", "claude"),
|
|
]);
|
|
expect(html).toContain('<span class="cw-by-human">Alpha</span>');
|
|
expect(html).toContain('<span class="cw-by-claude">gamma</span>');
|
|
});
|
|
|
|
it("handles two ADJACENT spans (one's end == next's start) in order", () => {
|
|
const text = "ONETWO\n";
|
|
const html = renderAuthorship(text, [
|
|
{ start: 0, end: 3, author: "human" }, // ONE
|
|
{ start: 3, end: 6, author: "claude" }, // TWO
|
|
]);
|
|
expect(html).toContain('<span class="cw-by-human">ONE</span><span class="cw-by-claude">TWO</span>');
|
|
});
|
|
|
|
it("clips a span to its block (does not bleed across blocks)", () => {
|
|
const text = "Para one.\n\nPara two.\n";
|
|
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
|
|
expect(html).toContain('<span class="cw-by-claude">Para one.</span>');
|
|
expect(html).toContain('<span class="cw-by-claude">Para two.</span>');
|
|
});
|
|
|
|
it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => {
|
|
const text = "```js\nconst x = 1;\n```\n";
|
|
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
|
|
expect(html).toContain("cw-by-claude");
|
|
expect(html).toContain("cw-badge");
|
|
expect(html).not.toMatch(/[\uE000-\uE003]/); // no sentinel leaked
|
|
expect(html).toContain("const x = 1;");
|
|
});
|
|
|
|
it("a mermaid fence authored by Claude renders as a diagram with a badge", () => {
|
|
const text = "```mermaid\ngraph TD; A-->B;\n```\n";
|
|
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
|
|
expect(html).toContain('pre class="mermaid"');
|
|
expect(html).toContain("cw-by-claude");
|
|
expect(html).toContain("cw-badge");
|
|
});
|
|
|
|
it("never leaks a raw sentinel into prose output", () => {
|
|
const text = "Some mixed text here.\n";
|
|
const html = renderAuthorship(text, [spanAt(text, "mixed", "claude")]);
|
|
expect(html).not.toMatch(/[\uE000-\uE003]/);
|
|
});
|
|
|
|
it("is deterministic", () => {
|
|
const text = "Stable input paragraph.\n";
|
|
const spans: AuthorSpan[] = [spanAt(text, "input", "claude")];
|
|
expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
|
|
});
|
|
});
|
|
|
|
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 { 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");
|
|
});
|
|
});
|