diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 19:16:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 19:16:08 +0900 |
| commit | 9c201c60a727106f1e3bf8c2af5f86aa7427098f (patch) | |
| tree | 37c0fa07913154386785126bd75093866f05c975 /src/features/workspaces/tile-layout-store.test.ts | |
| parent | 61733924352285df6c44f66edf69ea6f738fae3e (diff) | |
| download | dispatch-web-9c201c60a727106f1e3bf8c2af5f86aa7427098f.tar.gz dispatch-web-9c201c60a727106f1e3bf8c2af5f86aa7427098f.zip | |
feat(workspaces): sync the home tile layout per device orientation
The Win8-Start home grid's arrangement now syncs from the backend's
home-tile-layout plugin: ONE shared document per device orientation
(wide = landscape/desktop, tall = portrait/mobile) — every landscape
device renders the same layout, every portrait device the same other
one. localStorage persistence is retired (no migration — devices start
from the shared document; the first arrangement creates it).
- adapter/tile-layout-http.ts: the injected HTTP edge (GET/PUT/DELETE
/home/tile-layout/:mode), the features/layout adapter's pattern
- tile-layout-store: server-backed, keyed by geometry mode — boot +
orientation-change probes (stale in-flight responses never land),
locally-applied edits + debounced full-document PUT with rollback to
the last server document, WS push filtered to the active orientation,
quiet ephemeral degrade when the backend is unreachable (no error UI),
sync gated while a probe is in flight
- WorkspacesHome locks the store's orientation to the resolved grid
geometry (the landscape/portrait split IS the device-type split)
- app store: the tile store lives beside the layout store; the WS
dispatch feeds 'tile-layout' broadcasts into it; 'tiles' prop from
App.svelte (the page entry owns the httpBase)
- ws adapter: the 'tile-layout' server message (parse guard) + socket
handler; wire conformance case
Diffstat (limited to 'src/features/workspaces/tile-layout-store.test.ts')
| -rw-r--r-- | src/features/workspaces/tile-layout-store.test.ts | 368 |
1 files changed, 258 insertions, 110 deletions
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts index be6a14e..15f7a74 100644 --- a/src/features/workspaces/tile-layout-store.test.ts +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -1,176 +1,301 @@ -import { describe, expect, it } from "vitest"; -import type { LocalStore } from "../../adapters/local-storage"; -import { geometryFor, type TileLayoutData, type TileLayoutEntry } from "./logic/tile-layout"; +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http"; +import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout"; import { createTileLayoutStore } from "./tile-layout-store.svelte"; const WIDE = geometryFor(1920, 1080); -/** An in-memory `LocalStore` (the injected outermost edge — no mocks of our own modules). */ -function memoryStore(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & { - saved: TileLayoutData[]; +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +function doc(...tiles: TileLayoutEntry[]): TileLayoutData { + return { version: 2, tiles }; +} + +/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */ +function fakeHttp( + initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {}, +): TileLayoutHttp & { + docs: Map<TileGeometryMode, TileLayoutData>; + puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>; + failNext: { get: string | null; put: string | null }; } { - let value = initial; - const saved: TileLayoutData[] = []; + const docs = new Map<TileGeometryMode, TileLayoutData>( + Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>, + ); + const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = []; + const failNext = { get: null as string | null, put: null as string | null }; + const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout }); return { - saved, - load: () => value, - save(next: TileLayoutData) { - saved.push(next); - value = next; + docs, + puts, + failNext, + async getLayout(mode) { + if (failNext.get !== null) { + const error = failNext.get; + failNext.get = null; + return { ok: false, notFound: false, error }; + } + return ok(docs.get(mode) ?? doc()); }, - clear() { - value = null; + async putLayout(mode, layout) { + if (failNext.put !== null) { + const error = failNext.put; + failNext.put = null; + return { ok: false, notFound: false, error }; + } + puts.push({ mode, layout }); + docs.set(mode, layout); + return ok(layout); + }, + async deleteLayout(mode) { + docs.set(mode, doc()); + return ok(docs.get(mode) as TileLayoutData); }, }; } -function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { - return { id, order, colSpan, rowSpan }; +/** Flush the boot probe + debounced timers (microtasks first, then the clock). */ +async function settle(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); +} + +async function flushOnly(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); } describe("createTileLayoutStore", () => { - it("loads the persisted document at creation (v1 migrates to v2 in memory)", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] }); - const store = createTileLayoutStore(persistence); - expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); }); - it("loads a v2 document with anchors intact", () => { - const persistence = memoryStore({ - version: 2, - tiles: [{ ...tile("a", 0), col: 2, row: 3 }], - }); - const store = createTileLayoutStore(persistence); - expect(store.entries).toEqual([{ ...tile("a", 0), col: 2, row: 3 }]); + it("the boot probe applies the server document for the initial mode", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + expect(store.providerState).toBe("present"); + expect(store.mode).toBe("wide"); }); - it("sanitizes a corrupt persisted document to empty", () => { - const persistence = memoryStore(null); - // The adapter's load is untyped at the boundary — simulate garbage. - const store = createTileLayoutStore({ - load: () => ({ junk: true }) as unknown as TileLayoutData, - save: () => {}, - clear: () => {}, - }); + it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => { + const http = fakeHttp(); + http.docs.set("wide", { junk: true } as unknown as TileLayoutData); + const store = createTileLayoutStore({ http }); + await flushOnly(); expect(store.entries).toEqual([]); - expect(persistence.saved).toEqual([]); }); - it("sync appends unknown workspaces and drops deleted ones, persisting once", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0), tile("gone", 1)] }); - const store = createTileLayoutStore(persistence); + it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => { + const warn = vi.fn(); + const http = fakeHttp(); + http.failNext.get = "network down"; + const store = createTileLayoutStore({ http, warn }); + await flushOnly(); + expect(store.providerState).toBe("absent"); - store.sync(["a", "b"]); + store.resize("a", 3, 3); // applied locally… + store.sync(["a"]); + await settle(); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); + expect(http.puts).toHaveLength(0); // …but never written + expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI + }); - expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); - expect(persistence.saved).toHaveLength(1); - // The max order counts KEPT entries (the dropped one doesn't reserve a slot). - expect(persistence.saved[0]).toEqual({ - version: 2, - tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }], + it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => { + const http = fakeHttp({ + wide: doc(tile("desktop", 0)), + tall: doc(tile("mobile", 0)), }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + + store.setMode("tall"); + await flushOnly(); + expect(store.mode).toBe("tall"); + expect(store.entries.map((t) => t.id)).toEqual(["mobile"]); + + store.setMode("tall"); // same-mode call: a no-op + store.setMode("wide"); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); }); - it("sync keeps pinned anchors (a deleted tile's cells become gaps)", () => { - const persistence = memoryStore({ - version: 2, - tiles: [ - { ...tile("a", 0), col: 1, row: 1 }, - { ...tile("gone", 1), col: 1, row: 2 }, - ], - }); - const store = createTileLayoutStore(persistence); + it("an in-flight probe for a stale mode never lands", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + // A holder object defeats TS's closure narrowing (a bare `let` read after + // the callback-assigned write narrows to `null` and the call sites to + // `never`). + const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null }; + const slow = http.getLayout.bind(http); + http.getLayout = (mode) => + mode === "tall" + ? new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }) + : slow(mode); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.setMode("tall"); // probe in flight… + store.setMode("wide"); // …superseded before it resolves + gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) }); + await flushOnly(); + + // The stale tall response must NOT overwrite the active wide document. + expect(store.mode).toBe("wide"); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); - store.sync(["a"]); + it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); - expect(store.entries).toEqual([{ ...tile("a", 0), col: 1, row: 1 }]); + store.onServerTileLayout("tall", doc(tile("mobile-push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered + + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied }); - it("sync is a no-op (no write) when the merge changes nothing", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); + it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); - store.sync(["a"]); - store.sync(["a"]); + store.resize("a", 4, 2); + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait + expect(http.puts).toHaveLength(0); - expect(persistence.saved).toHaveLength(0); + await vi.advanceTimersByTimeAsync(300); + expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]); }); - it("resize persists the new spans without clamping", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); + it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => { + const http = fakeHttp({ tall: doc(tile("m", 0)) }); + const store = createTileLayoutStore({ http }); + store.setMode("tall"); + await flushOnly(); + store.resize("m", 2, 2); + await settle(); + expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]); + }); + + it("a failed PUT on a present backend rolls back to the last server document", async () => { + const warn = vi.fn(); + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, warn, debounceMs: 300 }); + await flushOnly(); + + http.failNext.put = "500 gone"; store.resize("a", 4, 2); + await settle(); - expect(store.entries).toEqual([tile("a", 0, 4, 2)]); - expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]); + expect(store.entries).toEqual([tile("a", 0)]); // rolled back + expect(warn).toHaveBeenCalledTimes(1); }); - it("placeAtCell commits the displacement and pins EVERY tile", () => { - const persistence = memoryStore({ - version: 1, - tiles: [tile("a", 0), tile("b", 1)], - }); - const store = createTileLayoutStore(persistence); + it("sync merges the live workspace list (gated while the first probe is in flight)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + // NO flush — the boot probe is still in flight (providerState unknown). + + store.sync(["a", "b"]); // would merge into the empty placeholder… + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles + + await flushOnly(); + store.sync(["a", "b"]); + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("sync appends unknown ids, drops deleted ones, and persists once", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a", "b"]); + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(http.puts.at(-1)?.layout).toEqual( + doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }), + ); + }); + + it("sync is a no-op (no write) when the merge changes nothing", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a"]); + store.sync(["a"]); + await settle(); + + expect(http.puts).toHaveLength(0); + }); + + it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + await settle(); const entries = store.entries; expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); - // Written as v2. - expect(persistence.saved.at(-1)?.version).toBe(2); + expect(http.puts.at(-1)?.layout.version).toBe(2); }); - it("placeAtCell with an unknown id is a no-op (no write)", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); + it("placeAtCell with an unknown id is a no-op (no write)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); + await settle(); - expect(persistence.saved).toHaveLength(0); + expect(http.puts).toHaveLength(0); expect(store.entries).toEqual([tile("a", 0)]); }); - it("placeFirstEmpty PINS the new id at the first empty slot", () => { - const persistence = memoryStore({ - version: 1, - tiles: [tile("a", 0, 1, 2), tile("b", 1)], - }); - const store = createTileLayoutStore(persistence); + it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); store.placeFirstEmpty("new", WIDE); + await settle(); // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); - expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); - }); - - it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => { - const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); - const store = createTileLayoutStore(persistence); - store.sync(["a", "x"]); // the refresh appended the new workspace (unpinned) - - store.placeFirstEmpty("x", WIDE); - const pinned = store.entries; - store.placeFirstEmpty("x", WIDE); // the create flow's re-call must be stable + expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); - expect(store.entries).toEqual(pinned); - expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1); - // x is PINNED at the first empty slot: a@(1,1) → (1,2). - expect(store.entries.find((t) => t.id === "x")).toMatchObject({ col: 1, row: 2 }); + store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); }); - it("sortByRecentActivity re-applies recent-activity order, CLEARs pins, persists", () => { - const persistence = memoryStore({ - version: 2, - tiles: [ - { ...tile("a", 0), col: 1, row: 1 }, - { ...tile("b", 1), col: 2, row: 3 }, - ], + it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => { + const http = fakeHttp({ + wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }), }); - const store = createTileLayoutStore(persistence); + const store = createTileLayoutStore({ http }); + await flushOnly(); store.sortByRecentActivity( new Map([ @@ -182,4 +307,27 @@ describe("createTileLayoutStore", () => { expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); }); + + it("a WS push supersedes a queued edit (the server's copy wins)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); // edit queued… + store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]); + expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT + }); + + it("probe() re-reads the active orientation (the reconnect catch-up)", async () => { + const http = fakeHttp({ wide: doc() }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + http.docs.set("wide", doc(tile("fresh", 0))); + await store.probe(); + expect(store.entries.map((t) => t.id)).toEqual(["fresh"]); + }); }); |
