diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 16:29:01 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 16:29:01 +0900 |
| commit | 55f635bea24f673f6d0242c2c7b749b4fd5329a2 (patch) | |
| tree | 65b86a5ed2b824f62312c7f18dec8182856c17a1 /src/features/surface-host/logic/plan.test.ts | |
| parent | 3ff6164fc9f5d76c901c16f18b1e0a76efa8795e (diff) | |
| download | dispatch-web-55f635bea24f673f6d0242c2c7b749b4fd5329a2.tar.gz dispatch-web-55f635bea24f673f6d0242c2c7b749b4fd5329a2.zip | |
feat(views,surface-host): Extensions sidebar view — auto-expanded surfaces + tables
views (new feature):
- pure panel-stack reducer + thin generic ViewSidebar (dropdown picker + add/remove),
switches on view KIND, never a surface id
Extensions view (composition root):
- folds frontend modules + backend surfaces into one "Extensions" view
- frontend module list AGGREGATED from each feature's public `manifest` export
(can't drift); no per-module version (FE features are internal to dispatch-web)
- surfaces are AUTO-SUBSCRIBED on catalog + rendered expanded (no catalog buttons)
surface-host:
- consecutive `stat` fields coalesce into one aligned label/value table (StatTable)
- generic custom-field renderer: dispatch on rendererId === "table" → SurfaceTable
(pure parseTablePayload), so a backend `custom`/table field renders generically
- shared presentational components/Table.svelte (used by both, neither feature
depends on the other)
store:
- auto-subscribe every catalog entry, unsubscribe vanished ones, re-subscribe all
on reconnect; expose all received specs via `surfaces` (drops single-selection)
backend-handoff: CR-1 — emit Loaded Extensions as a custom/table field; notes
what's already covered FE-side (renderer shipped, stat-table fallback works).
Diffstat (limited to 'src/features/surface-host/logic/plan.test.ts')
| -rw-r--r-- | src/features/surface-host/logic/plan.test.ts | 64 |
1 files changed, 56 insertions, 8 deletions
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts index 50d6f11..a5727b4 100644 --- a/src/features/surface-host/logic/plan.test.ts +++ b/src/features/surface-host/logic/plan.test.ts @@ -1,6 +1,7 @@ import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; -import { buildInvoke, planSurface } from "./plan"; +import { buildInvoke, groupRenderFields, planSurface } from "./plan"; +import type { FieldView } from "./types"; const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({ id: "test-surface", @@ -74,7 +75,7 @@ describe("planSurface", () => { { kind: "button", label: "D", action: { actionId: "d" } }, ), ); - expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]); + expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]); }); it("drops unknown field kinds gracefully", () => { @@ -86,10 +87,11 @@ describe("planSurface", () => { } as SurfaceField), ); expect(plan.fields).toHaveLength(1); - expect(plan.fields[0]?.label).toBe("Known"); + const first = plan.fields[0]; + expect(first && "label" in first ? first.label : null).toBe("Known"); }); - it("drops custom fields (no renderer registered)", () => { + it("carries custom fields through verbatim, preserving order", () => { const plan = planSurface( makeSpec( { kind: "stat", label: "Before", value: "1" }, @@ -97,8 +99,12 @@ describe("planSurface", () => { { kind: "stat", label: "After", value: "2" }, ), ); - expect(plan.fields).toHaveLength(2); - expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]); + expect(plan.fields).toHaveLength(3); + expect(plan.fields[1]).toEqual({ + kind: "custom", + rendererId: "chart", + payload: { data: [1, 2, 3] }, + }); }); it("returns empty fields for an empty spec", () => { @@ -106,14 +112,56 @@ describe("planSurface", () => { expect(plan.fields).toEqual([]); }); - it("drops all fields when all are custom", () => { + it("keeps every custom field (render-time decides whether to show each)", () => { const plan = planSurface( makeSpec( { kind: "custom", rendererId: "x", payload: null }, { kind: "custom", rendererId: "y", payload: 42 }, ), ); - expect(plan.fields).toEqual([]); + expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]); + }); +}); + +describe("groupRenderFields", () => { + const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value }); + const toggle = (label: string): FieldView => ({ + kind: "toggle", + label, + value: false, + action: { actionId: label }, + }); + + it("coalesces consecutive stats into a single stats group", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]); + expect(groups).toHaveLength(1); + expect(groups[0]).toEqual({ + type: "stats", + stats: [ + { kind: "stat", label: "a", value: "1" }, + { kind: "stat", label: "b", value: "2" }, + { kind: "stat", label: "c", value: "3" }, + ], + }); + }); + + it("keeps non-stat fields as standalone groups and preserves order", () => { + const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + const first = groups[0]; + const last = groups[2]; + if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping"); + expect(first.stats.map((s) => s.label)).toEqual(["a"]); + expect(last.stats.map((s) => s.label)).toEqual(["b"]); + }); + + it("starts a new stats run after an interrupting field", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + }); + + it("returns no groups for an empty field list", () => { + expect(groupRenderFields([])).toEqual([]); }); }); |
