// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure // helper that resolves a contentEditable focusout target into the // (zone_id, text_path, value) capture tuple emitted by SlideCanvas. // // Upstream contract (verified by prior units): // - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{ // line_index}"` on every rendered text-line opening tag at Step 13. // - u9 wires the stamper into `render_slide` so the final.html consumed // by SlideCanvas's iframe carries those attributes. // - Phase Z slide-base wraps every zone in `.zone[data-zone-position]` // (verified at SlideCanvas.tsx onLoad measure block). // // u13 scope: derive the capture tuple from any descendant of a stamped // line, OR the stamped line itself. Non-stamped targets (slide-base // title/footer, decorative spans outside the zone tree) return null so // the focusout handler silently skips them — never crashes. // // Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT // alter the (target) -> {zoneId, textPath, value} | null contract verified // here. Any change to the resolution semantics is a scope-violation // against the u13 binding contract. // // jsdom is NOT in devDependencies (verified in Front/package.json); this // test mocks `TextEditCaptureTarget` with structurally-typed objects per // the established u11/u12 pure-helper pattern. import { describe, it, expect } from "vitest"; import { deriveTextEditCapture, type TextEditCapture, type TextEditCaptureTarget, } from "../src/components/SlideCanvas"; // --- minimal closest-aware mock builders ----------------------------- // Each node only needs to know which selectors it matches and its // parent chain — `closest` is implemented by walking parent pointers. interface MockNodeSpec { matches: string[]; attrs?: Record; text?: string | null; parent?: MockNode | null; } interface MockNode extends TextEditCaptureTarget { matches(sel: string): boolean; parent: MockNode | null; } function makeNode(spec: MockNodeSpec): MockNode { const node: MockNode = { parent: spec.parent ?? null, matches(sel: string) { return spec.matches.includes(sel); }, closest(sel: string): TextEditCaptureTarget | null { let cur: MockNode | null = node; while (cur) { if (cur.matches(sel)) return cur; cur = cur.parent; } return null; }, getAttribute(name: string): string | null { return spec.attrs?.[name] ?? null; }, textContent: spec.text === undefined ? null : spec.text, }; return node; } // Canonical zone + line scaffold used across happy-path tests. // `null` for any field is preserved verbatim so edge cases (missing attr / // null textContent) can exercise the helper's defensive branches. function makeZoneLineScaffold(opts: { zoneId?: string | null; textPath?: string | null; lineText?: string | null; }) { const zone = makeNode({ matches: [".zone[data-zone-position]"], attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" }, }); const line = makeNode({ matches: ["[data-text-path]"], attrs: opts.textPath === null ? {} : { "data-text-path": opts.textPath ?? "row_1_left_body.0" }, text: opts.lineText === undefined ? "hello world" : opts.lineText, parent: zone, }); return { zone, line }; } describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => { it("returns null when target is null", () => { expect(deriveTextEditCapture(null)).toBeNull(); }); it("returns null when no ancestor has data-text-path (e.g., slide title)", () => { const title = makeNode({ matches: [".slide-title"], text: "Phase Z 슬라이드", }); expect(deriveTextEditCapture(title)).toBeNull(); }); it("returns null when the stamped line has no enclosing zone", () => { // Decorative line stamped by the future u8 but rendered outside a // zone (e.g., footer pill). u13 silently skips — caller never sees // a half-resolved capture. const orphanLine = makeNode({ matches: ["[data-text-path]"], attrs: { "data-text-path": "footer.0" }, text: "결론", }); expect(deriveTextEditCapture(orphanLine)).toBeNull(); }); }); describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => { it("resolves (zoneId, textPath, value) when target IS the stamped line", () => { const { line } = makeZoneLineScaffold({ zoneId: "top", textPath: "row_1_left_body.0", lineText: "분석 결과", }); expect(deriveTextEditCapture(line)).toEqual({ zoneId: "top", textPath: "row_1_left_body.0", value: "분석 결과", }); }); it("walks up to the stamped line when target is a nested descendant", () => { const { zone, line } = makeZoneLineScaffold({ zoneId: "bottom_l", textPath: "left_body.2", lineText: "wrapped", }); // emulate a SPAN inside the stamped line (e.g., bold inline span) const innerSpan = makeNode({ matches: ["span.highlight"], text: "ignored — closest walks to the line", parent: line, }); void zone; expect(deriveTextEditCapture(innerSpan)).toEqual({ zoneId: "bottom_l", textPath: "left_body.2", value: "wrapped", }); }); it("preserves the line's textContent without HTML normalization", () => { const { line } = makeZoneLineScaffold({ zoneId: "primary", textPath: "headline.0", lineText: " spaced inner words ", }); // u13 trims outer whitespace but does NOT collapse interior whitespace // — value mirrors what user typed, modulo blur-edge trim. expect(deriveTextEditCapture(line)?.value).toBe("spaced inner words"); }); it("returns empty string when textContent is null (edge: empty line)", () => { const { line } = makeZoneLineScaffold({ zoneId: "top", textPath: "row_1_left_body.0", lineText: null, }); expect(deriveTextEditCapture(line)?.value).toBe(""); }); it("returns empty string when textContent is whitespace-only", () => { const { line } = makeZoneLineScaffold({ zoneId: "top", textPath: "row_1_left_body.0", lineText: " \n \t ", }); expect(deriveTextEditCapture(line)?.value).toBe(""); }); }); describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => { it("returns null when data-text-path attribute is absent on the matched line", () => { // Should not happen with the u8 stamper, but a downstream mutation // (e.g., user pasting a fresh element) could create a stamped-class // node without the actual attribute. u13 stays defensive. const zone = makeNode({ matches: [".zone[data-zone-position]"], attrs: { "data-zone-position": "top" }, }); const lineNoPath = makeNode({ matches: ["[data-text-path]"], attrs: {}, text: "hello", parent: zone, }); expect(deriveTextEditCapture(lineNoPath)).toBeNull(); }); it("returns null when data-zone-position attribute is absent on the matched zone", () => { const zoneNoId = makeNode({ matches: [".zone[data-zone-position]"], attrs: {}, }); const line = makeNode({ matches: ["[data-text-path]"], attrs: { "data-text-path": "row_1_left_body.0" }, text: "hello", parent: zoneNoId, }); expect(deriveTextEditCapture(line)).toBeNull(); }); }); describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => { it("multiple calls with the same target return equal captures", () => { const { line } = makeZoneLineScaffold({ zoneId: "top", textPath: "row_1_left_body.0", lineText: "stable", }); const a = deriveTextEditCapture(line); const b = deriveTextEditCapture(line); expect(a).toEqual(b); expect(a).not.toBe(b); // fresh objects each call (caller-friendly) }); it("does not mutate the target element (attrs / parent / textContent unchanged)", () => { const { line, zone } = makeZoneLineScaffold({ zoneId: "top", textPath: "row_1_left_body.0", lineText: "immutable", }); deriveTextEditCapture(line); expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0"); expect(line.textContent).toBe("immutable"); expect(zone.getAttribute("data-zone-position")).toBe("top"); }); }); describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => { // u13 does not validate the zone id shape — Phase Z slide-base owns the // canonical zone position vocabulary, and u15 / pipeline-side resolver // (u4) re-validate downstream. u13 just forwards whatever the stamped // DOM declared. const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"]; it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => { const { line } = makeZoneLineScaffold({ zoneId: zid, textPath: `${zid}.0`, lineText: "x", }); const cap = deriveTextEditCapture(line); expect(cap?.zoneId).toBe(zid); expect(cap?.textPath).toBe(`${zid}.0`); }); });