summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/store.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/layout/store.test.ts')
-rw-r--r--src/features/layout/store.test.ts529
1 files changed, 529 insertions, 0 deletions
diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts
new file mode 100644
index 0000000..331af95
--- /dev/null
+++ b/src/features/layout/store.test.ts
@@ -0,0 +1,529 @@
+import {
+ type ApplyOpsResult,
+ DEFAULT_LAYOUT,
+ type LayoutOp,
+ type LayoutTree,
+} from "@dispatch/layout-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
+import { overlaySidePanels } from "./logic/seed";
+import { createLayoutStore } from "./store.svelte";
+
+/**
+ * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the
+ * injected `LayoutHttp` is the store's outermost boundary; nothing internal
+ * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit
+ * PUT debounce deterministically.
+ */
+
+/** A recording fake of the LayoutHttp edge, scripted per test. */
+interface HttpFake extends LayoutHttp {
+ getCalls: string[];
+ putCalls: Array<{ workspaceId: string; tree: LayoutTree }>;
+ deleteCalls: string[];
+ getResult: (workspaceId: string) => LayoutHttpResult;
+ putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult;
+ deleteResult: (workspaceId: string) => LayoutHttpResult;
+ opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult;
+}
+
+function fakeHttp(
+ getResult: (workspaceId: string) => LayoutHttpResult = () => ({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ }),
+ putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({
+ ok: true,
+ tree,
+ }),
+): HttpFake {
+ const getCalls: string[] = [];
+ const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = [];
+ const deleteCalls: string[] = [];
+ const fake: HttpFake = {
+ getCalls,
+ putCalls,
+ deleteCalls,
+ getResult,
+ putResult,
+ // Re-scriptable outcome (default: the fresh seed a reset returns).
+ deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }),
+ // Re-scriptable outcome (default: the typed no-provider failure the ABI
+ // documents; tests re-assign mid-test like putResult).
+ opsResult: () => ({ ok: false, error: "no layout provider active" }),
+ // Delegate to the CURRENT properties so a test can re-script outcomes
+ // mid-test (http.putResult = …).
+ async getTree(workspaceId) {
+ getCalls.push(workspaceId);
+ return fake.getResult(workspaceId);
+ },
+ async putTree(workspaceId, tree) {
+ putCalls.push({ workspaceId, tree });
+ return fake.putResult(workspaceId, tree);
+ },
+ async deleteTree(workspaceId) {
+ deleteCalls.push(workspaceId);
+ return fake.deleteResult(workspaceId);
+ },
+ async postOps(workspaceId, ops) {
+ return fake.opsResult(workspaceId, ops);
+ },
+ };
+ return fake;
+}
+
+function storageWith(views: string[] | null): Storage {
+ const map = new Map<string, string>();
+ if (views !== null) map.set("test.sidebar.views", JSON.stringify(views));
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem: (k) => map.get(k) ?? null,
+ key: () => null,
+ removeItem: (k) => map.delete(k),
+ setItem: (k, v) => map.set(k, v),
+ };
+}
+
+const KNOWN = ["tabs", "model", "extensions", "vision"];
+
+function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) {
+ return createLayoutStore({
+ http,
+ workspaceId: "ws-a",
+ knownViews: KNOWN,
+ sidebarViewsKey: "test.sidebar.views",
+ debounceMs: 100,
+ warn: (message) => warnings.push(message),
+ ...extra,
+ });
+}
+
+let warnings: string[] = [];
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ warnings = [];
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("layout store — no-provider fallback (§2.6)", () => {
+ it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => {
+ const http = fakeHttp();
+ const store = makeStore(http);
+
+ await store.probe("ws-a", { boot: true });
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain("no layout provider");
+
+ // A re-probe (extensions-changed while still absent) does not re-warn.
+ await store.probe("ws-a");
+ expect(warnings).toHaveLength(1);
+ });
+
+ it("a network failure degrades like a 404 (quiet, no error UI)", async () => {
+ const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ });
+
+ it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => {
+ const http = fakeHttp();
+ const store = makeStore(http);
+ await store.probe("ws-a"); // absent now
+ expect(store.providerState).toBe("absent");
+
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ expect(store.tree).toEqual(edited);
+
+ vi.advanceTimersByTime(500);
+ expect(http.putCalls).toEqual([]); // ephemeral: no write attempted
+ // Quiet = the single absence log (from the probe), nothing more.
+ expect(warnings).toHaveLength(1);
+ });
+
+ it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => {
+ const http = fakeHttp();
+ // The PUT is a 404 (the probe will confirm: no provider).
+ http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" });
+ const store = makeStore(http);
+
+ void store.probe("ws-a"); // in flight (microtask pending)
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited); // lands BEFORE the probe resolves
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(edited); // no rollback — there is no server tree
+ expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more
+ });
+});
+
+describe("layout store — provider present", () => {
+ it("a 200 probe replaces the tree and marks the provider present", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ expect(warnings).toEqual([]);
+ });
+});
+
+describe("layout store — WS push (§2.5)", () => {
+ it("a layout push for the ACTIVE workspace replaces the tree", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ store.onServerTree("ws-a", pushed);
+ expect(store.tree).toEqual(pushed);
+ });
+
+ it("a push for a NON-active workspace is ignored", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ store.onServerTree("ws-b", other);
+ expect(store.tree).toEqual(served);
+ });
+
+ it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => {
+ const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ let served = servedA;
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ served = servedB;
+ store.onWorkspaceChanged("ws-b");
+ await vi.advanceTimersByTimeAsync(0); // the probe's promise
+ expect(store.tree).toEqual(servedB);
+
+ // A late push for the OLD workspace must not land.
+ const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]);
+ store.onServerTree("ws-a", stale);
+ expect(store.tree).toEqual(servedB);
+ });
+});
+
+describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => {
+ it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => {
+ const served = DEFAULT_LAYOUT;
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ // Not yet — debounced.
+ expect(http.putCalls).toEqual([]);
+ await vi.advanceTimersByTimeAsync(150);
+ expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]);
+ expect(warnings).toEqual([]);
+ });
+
+ it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ await vi.advanceTimersByTimeAsync(50);
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(http.putCalls).toHaveLength(1);
+ expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ });
+
+ it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => {
+ const served = DEFAULT_LAYOUT;
+ const http = fakeHttp(
+ () => ({ ok: true, tree: served }),
+ () => ({ ok: false, notFound: false, error: "invalid tree" }),
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("present");
+
+ const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(bad);
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(store.tree).toEqual(served); // rolled back
+ expect(store.providerState).toBe("present"); // the provider is still there
+ expect(warnings.some((w) => w.includes("rolled back"))).toBe(true);
+ });
+
+ it("a 5xx failure rolls back too (present provider, transient error)", async () => {
+ const served = DEFAULT_LAYOUT;
+ let failPut = false;
+ const http = fakeHttp(
+ () => ({ ok: true, tree: served }),
+ () =>
+ failPut
+ ? { ok: false, notFound: false, error: "storage down" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ failPut = true;
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ await vi.advanceTimersByTimeAsync(200);
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ // The provider unloads before the edit's PUT fires.
+ http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" });
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(store.tree).toEqual(edited); // the edit STAYS (in-memory)
+ expect(store.providerState).toBe("absent");
+ expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more
+ });
+});
+
+describe("layout store — probe triggers (§2.6)", () => {
+ it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a layout plugin got enabled in THIS workspace
+ store.onWorkspacePluginsChanged("ws-a");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here
+ store.onWorkspacePluginsChanged("ws-other");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(http.getCalls).toEqual(["ws-a"]); // no second probe
+ expect(store.providerState).toBe("absent");
+ });
+
+ it("extensions-changed re-probes the active workspace", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout provider is active" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a layout plugin got enabled on the server
+ store.onExtensionsChanged();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => {
+ const http = fakeHttp((ws) =>
+ ws === "ws-b"
+ ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) }
+ : { ok: true, tree: DEFAULT_LAYOUT },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ // Queue an edit for ws-a, then switch before the debounce fires.
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ store.onWorkspaceChanged("ws-b");
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(store.activeWorkspaceId).toBe("ws-b");
+ expect(http.getCalls).toEqual(["ws-a", "ws-b"]);
+ expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ // The queued edit never PUTs — it belonged to the old workspace.
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ store.onWorkspaceChanged("ws-a");
+ expect(http.getCalls).toEqual(["ws-a"]);
+ });
+});
+
+describe("layout store — one-way localStorage migration (§2.7)", () => {
+ it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => {
+ const storage = storageWith(["extensions", "vision"]);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([
+ { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) },
+ ]);
+ // The migrated tree is now the rendered tree.
+ expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]));
+
+ // The key is never read again: a later probe PUTs nothing.
+ await store.probe("ws-a");
+ expect(http.putCalls).toHaveLength(1);
+ });
+
+ it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => {
+ const storage = storageWith(null);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("a custom provider tree (panels differ) is NOT migrated over", async () => {
+ const storage = storageWith(["extensions"]);
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ const http = fakeHttp(() => ({ ok: true, tree: custom }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+ expect(store.tree).toEqual(custom);
+ });
+
+ it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => {
+ const storage = storageWith(["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a");
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => {
+ const storage = storageWith(["extensions"]);
+ const http = fakeHttp(
+ () => ({ ok: true, tree: DEFAULT_LAYOUT }),
+ () => ({ ok: false, notFound: false, error: "invalid tree" }),
+ );
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toHaveLength(1);
+ expect(warnings.some((w) => w.includes("migration"))).toBe(true);
+ // The rendered tree is untouched (the PUT failed).
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ await store.probe("ws-a");
+ expect(http.putCalls).toHaveLength(1);
+ });
+
+ it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => {
+ const storage = storageWith(["extensions"]);
+ let absent = true;
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout provider is active" }
+ : { ok: true, tree: DEFAULT_LAYOUT },
+ );
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+
+ absent = false;
+ await store.probe("ws-a"); // extensions-changed probe — first success
+ expect(http.putCalls).toEqual([
+ { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) },
+ ]);
+ });
+});
+
+describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => {
+ it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => {
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp();
+ // The DELETE returns the FRESH SEED (the server re-seeded).
+ http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT });
+ const store = makeStore(http);
+
+ // Get to a NON-default tree first: a present provider + a pushed edit.
+ http.getResult = () => ({ ok: true, tree: custom });
+ await store.probe("ws-a");
+ expect(store.tree).toEqual(custom);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBeNull();
+ expect(http.deleteCalls).toEqual(["ws-a"]);
+ // The fresh seed is applied locally (the WS broadcast echo is idempotent).
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ expect(store.providerState).toBe("present");
+ });
+
+ it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => {
+ const http = fakeHttp();
+ http.deleteResult = () => ({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ });
+ const store = makeStore(http);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("no layout plugin enabled");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default
+ });
+
+ it("another failure (5xx on a present provider) resolves to its error message", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" });
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("storage write failed");
+ });
+});