#47 (SLICE-2, review): per-block document proposals + word-precise accept
Document-edit flow SLICE-2 (specs/coauthoring-document-edit-flow.md §7.2,
INV-39/40/41 — P1 "too much to review"). A whole-document rewrite now proposes
ONE F4 proposal per CHANGED BLOCK (the unit a human reviews), but accepting a
block reconciles attribution at WORD granularity (the unit F3 records). Block =
decision unit; word = attribution unit. Supersedes INV-37's per-word cut for
document edits; selection edits unchanged.
- trackChangesModel.ts: new pure diffToBlockHunks(current, rewritten) — block-key
alignment (reusing diffArrays/diffBlocks keying): an isolated changed block →
one block-aligned hunk → the rewritten block raw (a code/mermaid fence is one
atomic whole-fence hunk, INV-23); insert/delete runs → one gap-span hunk over
the inter-anchor region (separators included) so reconstruction stays exact; a
zero-width gap-span is anchored (INV-41). Also split diffToHunks into the raw,
un-anchored wordEditHunks + the anchoring wrapper (the anchoring could grow an
insertion to overlap an adjacent hunk, corrupting a batch apply — a latent bug
that only surfaced once hunks are applied as a batch).
- trackChangesPreview.ts: runEditAndPropose document branch uses diffToBlockHunks
and tags each proposal granularity:"block".
- model.ts / proposalModel.ts: additive optional Proposal.granularity
("block"|"single"; absent ⇒ single, back-compat — no migration).
- proposalController.ts: accept of a block proposal runs an intra-block word
sub-diff (wordEditHunks, disjoint) and applies one applyAgentEdit per changed
run, descending offset — only the words Claude changed land Claude-attributed;
unchanged spans keep prior authorship (INV-40).
- Tests: diffToBlockHunks unit (reconstruction + fence atomic + add/remove);
f12Review host E2E (M blocks→M proposals, unchanged→none, fence atomic, INV-40
attribution, INV-41 insertion accept); updated the f11 document-path E2E to
per-block (INV-39 supersedes INV-37); MANUAL-SMOKE-F12 §2.
Seam note: pendingEdits.matchEvent resolves one registration per change event, so
INV-40's per-run attribution is sequential applyAgentEdit calls (N undo steps),
not one multi-replace WorkspaceEdit — see transcript Deferred decisions.
214 unit + 69/5 host E2E green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, test, expect } from "vitest";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
|
||||
|
||||
describe("splitBlocks", () => {
|
||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||
@@ -402,6 +402,90 @@ describe("F11 diffToHunks (INV-37)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #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
|
||||
|
||||
Reference in New Issue
Block a user