// IMP-90 (#90) u12 — vitest coverage for `computeEditModeGates`, the pure // helper that drives SlideCanvas's mutually-exclusive gesture gating. // u11 introduced the `EditMode` enum + toolbar; u12 splits the prior // `isEditMode` shim (which fired ALL gates whenever any edit mode was // active) into 5 per-gate booleans: // textEditing — designMode + contentEditable (text mode only). // imageSelection — in-iframe user-content image click listener // (image-zone mode only). // iframePointerAuto — iframe pointer-events:auto so in-iframe gestures // (text caret OR image click) can reach the doc. // text mode + image-zone mode; structure stays // pe:none because u14 will overlay React controls. // zoneGestures — zone resize 8-handle ring + drag perimeter strips // + canDrag in handleZoneMouseDown // (image-zone mode only). // imageOverlay — React-side image edit overlay (image-zone only). // // Mutually-exclusive contract (from the issue body's "discriminated edit // mode"): no editMode value enables both `textEditing` and either // `imageSelection` or `zoneGestures` simultaneously. structure mode is // the no-op placeholder — u14 will plant the structure overlay there. // pendingLayout fully suppresses every gate (mirrors the existing // useEffect that forces editMode='off' on pendingLayout entry). // // Scope guard: this test exercises the pure helper only — no React // rendering, no DOM. testing-library/react is NOT in devDependencies // (verified in Front/package.json); helper-level coverage is the // established u11 pattern. import { describe, it, expect } from "vitest"; import { computeEditModeGates, type EditMode, type EditModeGates, } from "../src/components/SlideCanvas"; const ALL_MODES: EditMode[] = ["off", "text", "structure", "image-zone"]; describe("computeEditModeGates (IMP-90 u12) — pendingLayout suppression", () => { it.each(ALL_MODES)( "pendingLayout=true forces every gate false (editMode=%s)", (mode) => { const g = computeEditModeGates(mode, true); expect(g).toEqual({ textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false, }); } ); }); describe("computeEditModeGates (IMP-90 u12) — off baseline", () => { it("editMode=off pendingLayout=false: every gate false", () => { expect(computeEditModeGates("off", false)).toEqual({ textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false, }); }); }); describe("computeEditModeGates (IMP-90 u12) — text mode", () => { const g = computeEditModeGates("text", false); it("textEditing = true (designMode + contentEditable activate)", () => { expect(g.textEditing).toBe(true); }); it("iframePointerAuto = true (caret needs to reach the doc)", () => { expect(g.iframePointerAuto).toBe(true); }); it("imageSelection = false (no in-iframe image click listener)", () => { expect(g.imageSelection).toBe(false); }); it("zoneGestures = false (no zone resize / drag affordances)", () => { expect(g.zoneGestures).toBe(false); }); it("imageOverlay = false (no React-side image overlay)", () => { expect(g.imageOverlay).toBe(false); }); }); describe("computeEditModeGates (IMP-90 u12) — structure mode", () => { const g = computeEditModeGates("structure", false); // structure mode is the u14 placeholder — no gestures here yet. All five // gates stay false so the iframe and React overlays remain quiescent // until u14 plants the structure overlay on the React layer. it("every gate false (u14 will plant the structure overlay later)", () => { expect(g).toEqual({ textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false, }); }); }); describe("computeEditModeGates (IMP-90 u12) — image-zone mode", () => { const g = computeEditModeGates("image-zone", false); it("textEditing = false (contentEditable would steal image clicks)", () => { expect(g.textEditing).toBe(false); }); it("imageSelection = true (in-iframe img click → selectedImageId)", () => { expect(g.imageSelection).toBe(true); }); it("iframePointerAuto = true (so image clicks reach the doc)", () => { expect(g.iframePointerAuto).toBe(true); }); it("zoneGestures = true (zone resize + drag affordances visible)", () => { expect(g.zoneGestures).toBe(true); }); it("imageOverlay = true (React-side overlay renders the drag handles)", () => { expect(g.imageOverlay).toBe(true); }); }); describe("computeEditModeGates (IMP-90 u12) — mutually exclusive contract", () => { it("text mode never co-activates image-zone gates (imageSelection / zoneGestures / imageOverlay)", () => { const g = computeEditModeGates("text", false); expect(g.textEditing).toBe(true); expect(g.imageSelection).toBe(false); expect(g.zoneGestures).toBe(false); expect(g.imageOverlay).toBe(false); }); it("image-zone mode never co-activates text gates (textEditing)", () => { const g = computeEditModeGates("image-zone", false); expect(g.imageSelection).toBe(true); expect(g.textEditing).toBe(false); }); it.each(ALL_MODES)( "for every editMode (%s), textEditing AND zoneGestures are NEVER both true", (mode) => { const g = computeEditModeGates(mode, false); expect(g.textEditing && g.zoneGestures).toBe(false); } ); it.each(ALL_MODES)( "for every editMode (%s), textEditing AND imageOverlay are NEVER both true", (mode) => { const g = computeEditModeGates(mode, false); expect(g.textEditing && g.imageOverlay).toBe(false); } ); it.each(ALL_MODES)( "for every editMode (%s), textEditing AND imageSelection are NEVER both true", (mode) => { const g = computeEditModeGates(mode, false); expect(g.textEditing && g.imageSelection).toBe(false); } ); }); describe("computeEditModeGates (IMP-90 u12) — iframePointerAuto coupling", () => { // pe:auto is the iframe-side prerequisite for ANY in-iframe gesture // (text caret OR image click). The helper must NOT advertise an // in-iframe gate as active while pe is none, or those gestures would // be silently swallowed by the wrapper. it.each(ALL_MODES)( "textEditing → iframePointerAuto (editMode=%s)", (mode) => { const g = computeEditModeGates(mode, false); if (g.textEditing) expect(g.iframePointerAuto).toBe(true); } ); it.each(ALL_MODES)( "imageSelection → iframePointerAuto (editMode=%s)", (mode) => { const g = computeEditModeGates(mode, false); if (g.imageSelection) expect(g.iframePointerAuto).toBe(true); } ); }); describe("computeEditModeGates (IMP-90 u12) — referential transparency", () => { it("multiple calls with the same inputs return equal output", () => { const a = computeEditModeGates("image-zone", false); const b = computeEditModeGates("image-zone", false); const c = computeEditModeGates("image-zone", false); expect(a).toEqual(b); expect(b).toEqual(c); }); it("does not mutate captured state across calls (independent invocations)", () => { const a = computeEditModeGates("text", false); const _b = computeEditModeGates("image-zone", false); // a must still reflect text mode after b's call. expect(a.textEditing).toBe(true); expect(a.imageSelection).toBe(false); }); }); describe("computeEditModeGates (IMP-90 u12) — gate truthtable snapshot", () => { // Snapshot for human-readable inspection — the per-mode flag layout // is the contract u13 (text capture) and u14 (structure overlay) // will build against. Any change requires updating both this test // AND the consuming gates in SlideCanvas.tsx. it("non-pendingLayout truthtable matches the u12 contract", () => { const rows = (["off", "text", "structure", "image-zone"] as EditMode[]).map( (m) => ({ mode: m, ...computeEditModeGates(m, false) }) ); expect(rows).toEqual([ { mode: "off", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false }, { mode: "text", textEditing: true, imageSelection: false, iframePointerAuto: true, zoneGestures: false, imageOverlay: false }, { mode: "structure", textEditing: false, imageSelection: false, iframePointerAuto: false, zoneGestures: false, imageOverlay: false }, { mode: "image-zone", textEditing: false, imageSelection: true, iframePointerAuto: true, zoneGestures: true, imageOverlay: true }, ]); }); });