feat(f11): SLICE-2 — block-level data-src emission (#43, INV-36)

The pure render layer now emits data-src-start/data-src-end (source char offsets
from BlockWithRange) on every LIVE-source rendered block, in BOTH modes — the
contract the webview's selection→source mapping (SLICE-4) walks the DOM for.
Per spec §6.1 INV-36 / §7.2 SLICE-2.

- trackChangesModel: shared `srcAttr(blk)` helper; threaded through renderOp +
  renderReviewOp + the renderReview loop (removed blocks → "" / no data-src, as
  they have no live source). renderPlain switches from a single whole-document
  markdown pass to per-block `<div data-src-start/end>` wrappers — bare divs (no
  cw- class) so the off/clean preview stays visually clean while becoming a
  selection→source surface. Pure, vscode-free, deterministic (extends INV-22).
- unit (test/trackChangesModel.test.ts): data-src offsets equal
  splitBlocksWithRanges in both modes; removed + proposal blocks carry none;
  off-mode stays cw--free; determinism.

200 unit + 47 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-12 13:45:02 -07:00
parent 8b9e61a1da
commit 1ef9451e89
2 changed files with 82 additions and 11 deletions
+48
View File
@@ -319,3 +319,51 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
expect(html).not.toContain("classDef cwAdded");
});
});
// 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);
});
});