summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/logic/plan.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 16:29:01 +0900
committerAdam Malczewski <[email protected]>2026-06-10 16:29:01 +0900
commit55f635bea24f673f6d0242c2c7b749b4fd5329a2 (patch)
tree65b86a5ed2b824f62312c7f18dec8182856c17a1 /src/features/surface-host/logic/plan.test.ts
parent3ff6164fc9f5d76c901c16f18b1e0a76efa8795e (diff)
downloaddispatch-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.ts64
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([]);
});
});