Files
C.E.L_Slide_test2/Front/client/tests/imp90_text_edit_capture.test.tsx
kyeongmin 4da22adb43
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
feat(#90): IMP-56 u1-u19 catch-up before final close (post-u20 push fix)
u1: text_overrides axis in user_overrides_io
u2: structure_overrides axis in user_overrides_io
u3: vite allowlist for new endpoints
u4: text_override_resolver
u5: Step 12 text_overrides apply in phase_z2_pipeline
u6: structure_override_resolver
u7: text_path_stamper
u8: SlideCanvas text-edit capture
u9: SlideCanvas structure-edit overlay
u10: userOverridesApi service extension
u11: designAgent types extension
u12: slidePlanUtils restore
u13: user_overrides endpoint tests
u14: user_overrides restore tests
u15: pipeline fallback tests
u16: edit-mode state + gating tests
u17: slide_base print mode CSS
u18: /api/connect endpoint (vite)
u19: /api/export endpoint (vite)

Recovery scope: 29 files (12 modified + 17 new). u20 already pushed in
9439575; this commit lands u1-u19 that were authored but not committed
before #90 was externally closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:12:13 +09:00

260 lines
9.1 KiB
TypeScript

// 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<string, string>;
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<TextEditCapture>({
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<TextEditCapture>({
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`);
});
});