summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/adapter
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
committerAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
commit254f90a4081a3f15dfe54277da922579dbaee254 (patch)
treef5f4e409196109baf938cd9d557e244242df18ce /src/features/layout/adapter
parent535215bfd1fee6087613108f42d28cd2807184d3 (diff)
downloaddispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.tar.gz
dispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.zip
feat(layout): plan 04 — ABI layout.applyOps + reset affordance
Diffstat (limited to 'src/features/layout/adapter')
-rw-r--r--src/features/layout/adapter/abi-port.test.ts96
-rw-r--r--src/features/layout/adapter/abi-port.ts60
-rw-r--r--src/features/layout/adapter/http.test.ts45
-rw-r--r--src/features/layout/adapter/http.ts62
4 files changed, 261 insertions, 2 deletions
diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts
new file mode 100644
index 0000000..d11bf9e
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.test.ts
@@ -0,0 +1,96 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import { describe, expect, it, vi } from "vitest";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+import { abiTree, createLayoutAbiPort } from "./abi-port";
+
+/**
+ * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition
+ * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the
+ * outermost edges; nothing internal is mocked — asymmetric testing).
+ */
+
+function fakeStore(
+ overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {},
+): LayoutStore {
+ return {
+ get tree(): LayoutTree {
+ return DEFAULT_LAYOUT;
+ },
+ get providerState(): LayoutProviderState {
+ return "present";
+ },
+ get activeWorkspaceId(): string {
+ return "ws-a";
+ },
+ probe: vi.fn(async () => {}),
+ onWorkspaceChanged: vi.fn(),
+ onServerTree: vi.fn(),
+ onTreeChange: vi.fn(),
+ resetToDefault: vi.fn(async () => null),
+ onExtensionsChanged: vi.fn(),
+ ...overrides,
+ } as LayoutStore;
+}
+
+describe("abiTree — the getTree decision (pure)", () => {
+ it("a PRESENT provider exposes the tree", () => {
+ expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT);
+ });
+
+ it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => {
+ expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("an unknown (not-yet-probed) provider exposes NULL too", () => {
+ expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull();
+ });
+});
+
+describe("createLayoutAbiPort — applyOps → the ops route", () => {
+ it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => {
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const edited: LayoutTree = { ...DEFAULT_LAYOUT };
+ const store = fakeStore();
+ const seen: string[] = [];
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async (workspaceId, o) => {
+ seen.push(workspaceId);
+ return { ok: true, tree: edited, applied: o.length };
+ },
+ });
+
+ const result = await port.applyOps(ops);
+ expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time
+ expect(result).toEqual({ ok: true, tree: edited, applied: 1 });
+ // The successful apply echoes the tree into the store (instant local
+ // re-render; the WS broadcast echo is idempotent with this).
+ expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited);
+ });
+
+ it("a failed batch (typed no-provider error) does NOT touch the store", async () => {
+ const store = fakeStore({ providerState: "absent" });
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+
+ const result = await port.applyOps([{ type: "removeNode", target: "side" }]);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ expect(store.onServerTree).not.toHaveBeenCalled();
+ });
+
+ it("getTree returns the active tree while the provider is present, null when absent", () => {
+ const port = createLayoutAbiPort({
+ store: fakeStore({ providerState: "present" }),
+ postOps: async () => ({ ok: false, error: "unused" }),
+ });
+ expect(port.getTree()).toBe(DEFAULT_LAYOUT);
+
+ const absent = createLayoutAbiPort({
+ store: fakeStore({ providerState: "absent" }),
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+ expect(absent.getTree()).toBeNull();
+ });
+});
diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts
new file mode 100644
index 0000000..69576c6
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.ts
@@ -0,0 +1,60 @@
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+
+/**
+ * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5).
+ * The composition root builds it with {@link createLayoutAbiPort} and the
+ * plugin host hands it to every `activate(api)` — plugins never see the
+ * store or the HTTP edge, only THIS.
+ */
+export interface LayoutAbiPort {
+ /**
+ * Apply op-vocabulary edits to the ACTIVE workspace's tree through the
+ * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider
+ * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`.
+ */
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+}
+
+export interface LayoutAbiPortDeps {
+ /** The layout store (the active workspace's tree + provider state). */
+ readonly store: LayoutStore;
+ /** The ops HTTP edge — bound per CALL to the then-active workspace. */
+ readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+}
+
+/**
+ * Build the ABI's `layout` port over the layout store + the ops HTTP edge
+ * (pure composition — no ambient state; both deps injected). A successful
+ * apply feeds the returned tree back through `onServerTree` so the local
+ * client re-renders immediately (the server's WS broadcast is the
+ * authoritative echo for every client, this one included — applying it twice
+ * is idempotent). The workspace is read at CALL time, so a workspace switch
+ * between calls targets the right tree; a switch mid-flight is guarded by
+ * `onServerTree`'s own workspace filter.
+ */
+export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort {
+ return {
+ async applyOps(ops): Promise<ApplyOpsResult> {
+ const workspaceId = deps.store.activeWorkspaceId;
+ const result = await deps.postOps(workspaceId, ops);
+ if (result.ok) {
+ deps.store.onServerTree(workspaceId, result.tree);
+ }
+ return result;
+ },
+ getTree(): LayoutTree | null {
+ // `present` = an active provider is serving the active workspace. In
+ // no-provider (and not-yet-probed) mode the local tree is a fallback,
+ // not shared state — null tells the plugin there is nothing to read.
+ return abiTree(deps.store.providerState, deps.store.tree);
+ },
+ };
+}
+
+/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */
+export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null {
+ return state === "present" ? tree : null;
+}
diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts
index 1801ca6..cc7d7e9 100644
--- a/src/features/layout/adapter/http.test.ts
+++ b/src/features/layout/adapter/http.test.ts
@@ -135,3 +135,48 @@ describe("createLayoutHttp — deleteTree", () => {
expect(result).toMatchObject({ ok: false, notFound: true });
});
});
+
+describe("createLayoutHttp — postOps (plan 04 §4.2)", () => {
+ it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture),
+ );
+ const result = await http.postOps("ws-a", ops);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 });
+ expect(capture.url).toBe(`${LAYOUT_URL}/ops`);
+ expect(capture.init?.method).toBe("POST");
+ expect(capture.init?.body).toBe(JSON.stringify({ ops }));
+ });
+
+ it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ });
+
+ it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }),
+ );
+ const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]);
+ expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 });
+ });
+
+ it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 }));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ it("a network failure degrades to a typed failure (never throws)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("fetch failed");
+ });
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "fetch failed" });
+ });
+});
diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts
index 2f91e43..ff15a70 100644
--- a/src/features/layout/adapter/http.ts
+++ b/src/features/layout/adapter/http.ts
@@ -1,11 +1,15 @@
-import type { LayoutTree } from "@dispatch/layout-contract";
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
/**
* Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the
* ACTIVE layout provider's well-known routes:
* - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace)
* - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }`
- * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; plan 04 adds the UI)
+ * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins
+ * modal's "Reset layout to default" button — plan 04 §4.4)
+ * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }`
+ * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE
+ * ABI's `layout.applyOps` door)
*
* Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl`
* (testable without the network). The 404 is a SIGNAL, not an error: it means
@@ -22,6 +26,13 @@ export interface LayoutHttp {
getTree(workspaceId: string): Promise<LayoutHttpResult>;
putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>;
deleteTree(workspaceId: string): Promise<LayoutHttpResult>;
+ /**
+ * The plan-04 ops door (§4.2): apply op-vocabulary edits through the
+ * provider. The result IS the contract's `ApplyOpsResult` — the 404
+ * (no provider) maps to the typed `{ ok: false, error: "no layout
+ * provider active" }` failure the ABI documents.
+ */
+ postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>;
}
/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */
@@ -113,5 +124,52 @@ export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): Lay
};
}
},
+
+ async postOps(workspaceId, ops): Promise<ApplyOpsResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ops }),
+ },
+ );
+ // The no-provider 404 is the ABI's typed signal (plan 04 §4.5).
+ if (res.status === 404) {
+ return { ok: false, error: "no layout provider active" };
+ }
+ if (!res.ok) return await opsError(res);
+ const body = (await res.json()) as { tree?: unknown; applied?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return { ok: false, error: "Malformed layout response" };
+ return {
+ ok: true,
+ tree: body.tree as LayoutTree,
+ applied: typeof body.applied === "number" ? body.applied : ops.length,
+ };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
};
}
+
+/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */
+async function opsError(res: Response): Promise<ApplyOpsResult> {
+ try {
+ const body = (await res.json()) as { error?: unknown; failedOp?: unknown };
+ const error =
+ typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error,
+ ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}),
+ };
+ } catch {
+ return { ok: false, error: `HTTP ${res.status}` };
+ }
+}