feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del)
This commit is contained in:
@@ -474,6 +474,76 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The author whose span covers `offset` (half-open [start,end)), or null. */
|
||||||
|
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
|
||||||
|
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
|
||||||
|
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author-colored word diff for a changed PROSE block in the review.
|
||||||
|
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
|
||||||
|
* offset maps into `spans`. Insertions are colored by the author covering their
|
||||||
|
* offset; deletions inherit the author of the insertion they are paired with in
|
||||||
|
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
|
||||||
|
* cluster has no insertion. Pure, deterministic.
|
||||||
|
*/
|
||||||
|
export function wordDiffByAuthor(
|
||||||
|
beforeRaw: string,
|
||||||
|
afterRaw: string,
|
||||||
|
afterStart: number,
|
||||||
|
spans: AuthorSpan[],
|
||||||
|
): string {
|
||||||
|
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
|
||||||
|
// First pass: find each change cluster's insertion author (first added run).
|
||||||
|
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
|
||||||
|
let afterOff = afterStart;
|
||||||
|
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
|
||||||
|
{
|
||||||
|
let off = afterStart;
|
||||||
|
let i = 0;
|
||||||
|
while (i < parts.length) {
|
||||||
|
const p = parts[i];
|
||||||
|
if (!p.added && !p.removed) {
|
||||||
|
clusterAuthor[i] = null;
|
||||||
|
off += p.value.length;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// a cluster: scan to its end, capturing the first added run's author
|
||||||
|
let j = i;
|
||||||
|
let author: AuthorKind | null = null;
|
||||||
|
let scanOff = off;
|
||||||
|
while (j < parts.length && (parts[j].added || parts[j].removed)) {
|
||||||
|
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
|
||||||
|
if (parts[j].added) scanOff += parts[j].value.length;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
for (let k = i; k < j; k++) clusterAuthor[k] = author;
|
||||||
|
off = scanOff;
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second pass: emit.
|
||||||
|
const out: string[] = [];
|
||||||
|
parts.forEach((p, i) => {
|
||||||
|
if (p.added) {
|
||||||
|
const a = authorAt(afterOff, spans);
|
||||||
|
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
|
||||||
|
afterOff += p.value.length;
|
||||||
|
} else if (p.removed) {
|
||||||
|
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
|
||||||
|
} else {
|
||||||
|
out.push(p.value);
|
||||||
|
afterOff += p.value.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderOptions {
|
export interface RenderOptions {
|
||||||
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
||||||
render?: (src: string) => string;
|
render?: (src: string) => string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, test, expect } from "vitest";
|
import { describe, it, test, expect } from "vitest";
|
||||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||||
|
|
||||||
describe("splitBlocks", () => {
|
describe("splitBlocks", () => {
|
||||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||||
@@ -209,6 +209,46 @@ describe("colorByAuthor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
import { renderPlain } from "../src/trackChangesModel";
|
import { renderPlain } from "../src/trackChangesModel";
|
||||||
|
|
||||||
describe("renderPlain", () => {
|
describe("renderPlain", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user