Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 20s
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>
260 lines
9.1 KiB
TypeScript
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`);
|
|
});
|
|
});
|