562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
// 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<unknown>;
|
|
};
|
|
|
|
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<void> {
|
|
// 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/<key>", 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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 } },
|
|
});
|
|
});
|
|
});
|