// IMP-52 u5 — vitest coverage for the typed frontend client at // `Front/client/src/services/userOverridesApi.ts`. // // Scope (Stage 2 unit u5 contract): // 1) getUserOverrides: // • 200 with object body → typed payload echoed. // • 200 with array / primitive / non-JSON body → {} (graceful). // • 4xx / 5xx → {}. // • fetch reject (network) → {} (no throw to caller). // 2) saveUserOverrides: // • Single call: PUT fires after exactly 300 ms with the mutated-axis // partial as body (NOT a full snapshot of UserOverrides). // • Rapid coalescing: N calls in <300 ms window collapse to ONE PUT // carrying the union of mutated axes. // • Per-axis later-wins: later call's value replaces earlier pending // value for the same axis; axes the user did not touch stay absent. // • null sentinel: forwarded verbatim so u4 mergeUserOverrides can // `delete` the axis on disk. // • Per-key isolation: rapid edits to "03" do not delay flush of "04". // • Promise resolves with the server-side merged document. // • Promise rejects on 4xx/5xx and on fetch reject. // 3) flushUserOverrides: // • No arg → flushes all pending buckets immediately (no 300 ms wait). // • Specific key → flushes only that bucket; other buckets stay // pending. // • No-op when no buckets are pending. // // All tests mock `fetch` and use `vi.useFakeTimers()` to make the 300 ms // debounce deterministic — no real wall-clock waits. import { afterEach, beforeEach, describe, expect, it, vi, type Mock, } from "vitest"; import { __resetUserOverridesBuckets_FOR_TEST, flushUserOverrides, getUserOverrides, saveUserOverrides, type UserOverridesPartial, } from "../src/services/userOverridesApi"; // --------------------------------------------------------------------------- // fetch mock — minimal Response stub with the two methods the service uses // (.ok / .status / .json()). We track the call log so debounce + coalescing // can be asserted by counting PUTs and inspecting their bodies. // --------------------------------------------------------------------------- type MockResponse = { ok: boolean; status: number; json: () => Promise; }; function mockResponse(body: unknown, ok = true, status = 200): MockResponse { return { ok, status, json: async () => body, }; } let fetchMock: Mock; beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); vi.useFakeTimers(); __resetUserOverridesBuckets_FOR_TEST(); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); __resetUserOverridesBuckets_FOR_TEST(); }); // Microtask-flushing helper. vi.advanceTimersByTime fires timers, but the // promise chain inside flushBucket (await fetch → await res.json() → resolve // waiters) needs the microtask queue to drain before assertions run. async function drainMicrotasks(): Promise { // Multiple ticks because each `await` in flushBucket adds another tick. for (let i = 0; i < 4; i++) { await Promise.resolve(); } } function lastPutBody(): unknown { const lastCall = fetchMock.mock.calls.at(-1); if (!lastCall) throw new Error("fetch was not called"); const init = lastCall[1] as RequestInit | undefined; if (!init?.body) throw new Error("fetch was called without a body"); return JSON.parse(String(init.body)); } function putCallsCount(): number { return fetchMock.mock.calls.filter( (call) => (call[1] as RequestInit | undefined)?.method === "PUT", ).length; } // ============================================================================ // getUserOverrides // ============================================================================ describe("getUserOverrides (IMP-52 u5)", () => { it("issues GET against /api/user-overrides/", async () => { fetchMock.mockResolvedValueOnce(mockResponse({ layout: "x" })); await getUserOverrides("03"); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("/api/user-overrides/03"); expect((init as RequestInit).method).toBe("GET"); }); it("returns the parsed object on 200 with object body", async () => { const payload = { layout: "two_zone_split", frames: { "03-1+03-2": "frame_07" }, zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, zone_sections: { top: ["03-1", "03-2"] }, }; fetchMock.mockResolvedValueOnce(mockResponse(payload)); const got = await getUserOverrides("03"); expect(got).toEqual(payload); }); it("returns {} when JSON root is an array (mirrors u3 graceful degrade)", async () => { fetchMock.mockResolvedValueOnce(mockResponse([1, 2, 3])); expect(await getUserOverrides("03")).toEqual({}); }); it("returns {} when JSON root is a primitive", async () => { fetchMock.mockResolvedValueOnce(mockResponse(42)); expect(await getUserOverrides("03")).toEqual({}); }); it("returns {} when JSON root is null", async () => { fetchMock.mockResolvedValueOnce(mockResponse(null)); expect(await getUserOverrides("03")).toEqual({}); }); it("returns {} on 4xx (invalid key path from u3)", async () => { fetchMock.mockResolvedValueOnce( mockResponse({ error: "invalid key" }, false, 400), ); expect(await getUserOverrides("..")).toEqual({}); }); it("returns {} on 5xx", async () => { fetchMock.mockResolvedValueOnce( mockResponse({ error: "boom" }, false, 500), ); expect(await getUserOverrides("03")).toEqual({}); }); it("returns {} when response.json() throws (non-JSON body)", async () => { fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => { throw new SyntaxError("Unexpected token"); }, }); expect(await getUserOverrides("03")).toEqual({}); }); it("returns {} when fetch rejects (network error) — does NOT throw", async () => { fetchMock.mockRejectedValueOnce(new Error("network down")); await expect(getUserOverrides("03")).resolves.toEqual({}); }); }); // ============================================================================ // saveUserOverrides — debounce + coalescing // ============================================================================ describe("saveUserOverrides (IMP-52 u5) — debounce", () => { it("does NOT fire fetch before 300 ms have elapsed", async () => { fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" })); void saveUserOverrides("03", { layout: "two_zone_split" }); vi.advanceTimersByTime(299); await drainMicrotasks(); expect(putCallsCount()).toBe(0); }); it("fires exactly one PUT at the 300 ms boundary", async () => { fetchMock.mockResolvedValue(mockResponse({ layout: "two_zone_split" })); void saveUserOverrides("03", { layout: "two_zone_split" }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); const lastCall = fetchMock.mock.calls.at(-1)!; expect(lastCall[0]).toBe("/api/user-overrides/03"); expect((lastCall[1] as RequestInit).method).toBe("PUT"); expect((lastCall[1] as RequestInit).headers).toMatchObject({ "Content-Type": "application/json", }); expect(lastPutBody()).toEqual({ layout: "two_zone_split" }); }); it("PUT body contains ONLY the mutated axis (not a full snapshot)", async () => { // The frontend handler only knows the axis it just mutated; the server // is responsible for partial-merge against axes already on disk. fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, }); vi.advanceTimersByTime(300); await drainMicrotasks(); const body = lastPutBody() as Record; expect(Object.keys(body)).toEqual(["zone_geometries"]); expect("layout" in body).toBe(false); expect("frames" in body).toBe(false); expect("zone_sections" in body).toBe(false); }); it("coalesces N rapid calls into a SINGLE PUT after the debounce", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "old" }); vi.advanceTimersByTime(100); void saveUserOverrides("03", { frames: { "03-1": "frame_01" } }); vi.advanceTimersByTime(100); void saveUserOverrides("03", { zone_sections: { top: ["03-1"] } }); vi.advanceTimersByTime(100); // After 300 ms total (but the timer was reset each call to start the // 300 ms window over), so we need one more 300 ms to fire. expect(putCallsCount()).toBe(0); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); // All three axes accumulated. const body = lastPutBody() as Record; expect(body).toEqual({ layout: "old", frames: { "03-1": "frame_01" }, zone_sections: { top: ["03-1"] }, }); }); it("per-axis later-wins: same axis mutated twice keeps the LAST value", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "first" }); void saveUserOverrides("03", { layout: "second" }); void saveUserOverrides("03", { layout: "final" }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); expect(lastPutBody()).toEqual({ layout: "final" }); }); it("forwards null sentinel verbatim (explicit clear)", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: null }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(lastPutBody()).toEqual({ layout: null }); }); it("null can override a prior non-null pending value for the same axis", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "two_zone_split" }); void saveUserOverrides("03", { layout: null }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(lastPutBody()).toEqual({ layout: null }); }); it("resolves the caller promise with the server-merged document", async () => { fetchMock.mockResolvedValueOnce( mockResponse({ layout: "two_zone_split", // server's view includes axes preserved on disk that the partial // PUT did NOT carry — confirms we surface the full merged state. frames: { "03-1": "frame_01" }, }), ); const p = saveUserOverrides("03", { layout: "two_zone_split" }); vi.advanceTimersByTime(300); await drainMicrotasks(); await expect(p).resolves.toEqual({ layout: "two_zone_split", frames: { "03-1": "frame_01" }, }); }); it("rejects all coalesced waiters on 5xx response", async () => { fetchMock.mockResolvedValueOnce( mockResponse({ error: "write failed" }, false, 500), ); const p1 = saveUserOverrides("03", { layout: "x" }); const p2 = saveUserOverrides("03", { frames: { "03-1": "f01" } }); vi.advanceTimersByTime(300); await drainMicrotasks(); await expect(p1).rejects.toThrow(/500/); await expect(p2).rejects.toThrow(/500/); }); it("rejects waiters on fetch network error", async () => { fetchMock.mockRejectedValueOnce(new Error("ECONNRESET")); const p = saveUserOverrides("03", { layout: "x" }); vi.advanceTimersByTime(300); await drainMicrotasks(); await expect(p).rejects.toThrow("ECONNRESET"); }); it("after a successful flush, a new save starts a fresh debounce window", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "first" }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); expect(lastPutBody()).toEqual({ layout: "first" }); void saveUserOverrides("03", { layout: "second" }); vi.advanceTimersByTime(299); await drainMicrotasks(); expect(putCallsCount()).toBe(1); // not fired yet vi.advanceTimersByTime(1); await drainMicrotasks(); expect(putCallsCount()).toBe(2); expect(lastPutBody()).toEqual({ layout: "second" }); }); }); // ============================================================================ // saveUserOverrides — per-key isolation // ============================================================================ describe("saveUserOverrides (IMP-52 u5) — per-key isolation", () => { it("rapid edits to key A do not delay key B's flush", async () => { fetchMock.mockResolvedValue(mockResponse({})); // Schedule a save on "03" void saveUserOverrides("03", { layout: "x" }); // Schedule a save on "04" at t=0 void saveUserOverrides("04", { layout: "y" }); vi.advanceTimersByTime(150); // Keep extending "03"'s window void saveUserOverrides("03", { layout: "x2" }); // "04" should still fire at t=300 (untouched after first call) vi.advanceTimersByTime(150); // t=300 await drainMicrotasks(); const puts = fetchMock.mock.calls.filter( (c) => (c[1] as RequestInit).method === "PUT", ); expect(puts.length).toBe(1); expect(puts[0][0]).toBe("/api/user-overrides/04"); expect(JSON.parse(String((puts[0][1] as RequestInit).body))).toEqual({ layout: "y", }); }); it("each key's PUT carries only that key's mutated axes", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "for-03" }); void saveUserOverrides("04", { frames: { "04-1": "frame_05" } }); vi.advanceTimersByTime(300); await drainMicrotasks(); const puts = fetchMock.mock.calls.filter( (c) => (c[1] as RequestInit).method === "PUT", ); expect(puts.length).toBe(2); const byUrl = new Map( puts.map((c) => [ c[0], JSON.parse(String((c[1] as RequestInit).body)) as Record< string, unknown >, ]), ); expect(byUrl.get("/api/user-overrides/03")).toEqual({ layout: "for-03" }); expect(byUrl.get("/api/user-overrides/04")).toEqual({ frames: { "04-1": "frame_05" }, }); }); }); // ============================================================================ // flushUserOverrides // ============================================================================ describe("flushUserOverrides (IMP-52 u5)", () => { it("with no arg, flushes ALL pending buckets immediately (no 300 ms wait)", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "x" }); void saveUserOverrides("04", { layout: "y" }); expect(putCallsCount()).toBe(0); const flushP = flushUserOverrides(); await drainMicrotasks(); await flushP; expect(putCallsCount()).toBe(2); }); it("with a key arg, flushes only that bucket; others stay pending", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "x" }); void saveUserOverrides("04", { layout: "y" }); await flushUserOverrides("03"); await drainMicrotasks(); const puts = fetchMock.mock.calls.filter( (c) => (c[1] as RequestInit).method === "PUT", ); expect(puts.length).toBe(1); expect(puts[0][0]).toBe("/api/user-overrides/03"); // "04" should still fire at the regular 300 ms boundary. vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(2); }); it("is a no-op when no buckets are pending", async () => { fetchMock.mockResolvedValue(mockResponse({})); await flushUserOverrides(); expect(fetchMock).not.toHaveBeenCalled(); }); it("resolves the original saveUserOverrides promise via the in-flight PUT", async () => { fetchMock.mockResolvedValueOnce(mockResponse({ layout: "flushed" })); const savePromise = saveUserOverrides("03", { layout: "flushed" }); const flushPromise = flushUserOverrides(); await drainMicrotasks(); await flushPromise; await expect(savePromise).resolves.toEqual({ layout: "flushed" }); }); it("propagates PUT failure as caller rejection (flush itself swallows)", async () => { fetchMock.mockResolvedValueOnce( mockResponse({ error: "boom" }, false, 500), ); const savePromise = saveUserOverrides("03", { layout: "x" }); // flush itself should not throw — the original waiter takes the rejection. const flushPromise = flushUserOverrides(); await drainMicrotasks(); await expect(flushPromise).resolves.toBeUndefined(); await expect(savePromise).rejects.toThrow(/500/); }); }); // ============================================================================ // type-level export sanity check (compile-time evidence; runtime no-op) // ============================================================================ describe("UserOverridesPartial type (IMP-52 u5)", () => { it("permits per-axis null sentinels and partial keys", () => { // Compile-time only — if any of these stops being a valid assignment, // the test suite fails at build with a TS error before this assertion // runs. The expect() is a placebo to keep vitest happy. const a: UserOverridesPartial = { layout: "x" }; const b: UserOverridesPartial = { layout: null }; const c: UserOverridesPartial = { frames: { unit: "tmpl" } }; const d: UserOverridesPartial = {}; const e: UserOverridesPartial = { image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, }; const f: UserOverridesPartial = { image_overrides: null }; expect([a, b, c, d, e, f]).toHaveLength(6); }); }); // ============================================================================ // IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage // // Same debounce / coalescing / clear / per-key isolation guarantees as the // 4 sibling axes (layout / frames / zone_geometries / zone_sections), but // asserted explicitly so a regression in the type or the runtime allowlist // fails here instead of in a downstream u8~u11 handler. // ============================================================================ describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => { it("PUT body carries only image_overrides when that is the sole mutated axis", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, }); vi.advanceTimersByTime(300); await drainMicrotasks(); const body = lastPutBody() as Record; expect(Object.keys(body)).toEqual(["image_overrides"]); expect(body.image_overrides).toEqual({ "img-1": { x: 10, y: 20, w: 30, h: 25 }, }); expect("layout" in body).toBe(false); expect("frames" in body).toBe(false); expect("zone_geometries" in body).toBe(false); expect("zone_sections" in body).toBe(false); }); it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } }, }); void saveUserOverrides("03", { image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } }, }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); expect(lastPutBody()).toEqual({ image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } }, }); }); it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { image_overrides: null }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(lastPutBody()).toEqual({ image_overrides: null }); }); it("coalesces with sibling axes in a single PUT", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { layout: "two_zone_split" }); void saveUserOverrides("03", { image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); expect(lastPutBody()).toEqual({ layout: "two_zone_split", image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, }); }); }); // ============================================================================ // IMP-55 #93 u1 — manual_section_assignment axis (7th axis) parity coverage // // The bool intent marker rides on the same per-axis coalescing rails as the // 6 sibling axes. These tests lock the typed client behavior so a regression // in the boolean serialization (e.g., coercion to "true" string, dropped // `false` due to truthy filtering) fails here instead of in Home.tsx (u6/u7) // or the backend gate (u9~u11). // ============================================================================ describe("saveUserOverrides (IMP-55 #93 u1) — manual_section_assignment axis", () => { it("PUT body carries only manual_section_assignment when it is the sole mutated axis", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { manual_section_assignment: true }); vi.advanceTimersByTime(300); await drainMicrotasks(); const body = lastPutBody() as Record; expect(Object.keys(body)).toEqual(["manual_section_assignment"]); expect(body.manual_section_assignment).toBe(true); }); it("later-wins coalesces true → false within a single debounce window", async () => { // Drag-then-cancel inside 300 ms — server must see only the final // `false`, not a transient `true` that would re-enable backend // consumption of stale zone_sections. fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { manual_section_assignment: true }); void saveUserOverrides("03", { manual_section_assignment: false }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); expect(lastPutBody()).toEqual({ manual_section_assignment: false }); }); it("forwards null sentinel verbatim (explicit clear)", async () => { fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { manual_section_assignment: null }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(lastPutBody()).toEqual({ manual_section_assignment: null }); }); it("coalesces with zone_sections sibling into a single PUT (drag-drop pair)", async () => { // Real-world drag flow (u6): one save() sets the bool + zone_sections // together. Asserts both axes survive coalescing as a single PUT body. fetchMock.mockResolvedValue(mockResponse({})); void saveUserOverrides("03", { zone_sections: { left: ["03-2"], right: ["03-1"] }, manual_section_assignment: true, }); vi.advanceTimersByTime(300); await drainMicrotasks(); expect(putCallsCount()).toBe(1); expect(lastPutBody()).toEqual({ zone_sections: { left: ["03-2"], right: ["03-1"] }, manual_section_assignment: true, }); }); });