summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/surface-host/logic')
-rw-r--r--src/features/surface-host/logic/plan.test.ts161
-rw-r--r--src/features/surface-host/logic/plan.ts74
-rw-r--r--src/features/surface-host/logic/types.ts52
3 files changed, 287 insertions, 0 deletions
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
new file mode 100644
index 0000000..50d6f11
--- /dev/null
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -0,0 +1,161 @@
+import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import { buildInvoke, planSurface } from "./plan";
+
+const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({
+ id: "test-surface",
+ region: "test",
+ title: "Test Surface",
+ fields,
+});
+
+describe("planSurface", () => {
+ it("maps a toggle field to a ToggleFieldView", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }),
+ );
+ expect(plan.fields).toEqual([
+ { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } },
+ ]);
+ });
+
+ it("maps a progress field to a ProgressFieldView", () => {
+ const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 }));
+ expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]);
+ });
+
+ it("maps a selector field to a SelectorFieldView", () => {
+ const plan = planSurface(
+ makeSpec({
+ kind: "selector",
+ label: "Model",
+ value: "gpt-4",
+ options: [
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5", label: "GPT-3.5" },
+ ],
+ action: { actionId: "set-model" },
+ }),
+ );
+ expect(plan.fields).toEqual([
+ {
+ kind: "selector",
+ label: "Model",
+ value: "gpt-4",
+ options: [
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5", label: "GPT-3.5" },
+ ],
+ action: { actionId: "set-model" },
+ },
+ ]);
+ });
+
+ it("maps a stat field to a StatFieldView", () => {
+ const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" }));
+ expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]);
+ });
+
+ it("maps a button field to a ButtonFieldView", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }),
+ );
+ expect(plan.fields).toEqual([
+ { kind: "button", label: "Retry", action: { actionId: "retry" } },
+ ]);
+ });
+
+ it("preserves field order", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "stat", label: "A", value: "1" },
+ { kind: "toggle", label: "B", value: false, action: { actionId: "b" } },
+ { kind: "progress", label: "C", value: 0.5 },
+ { kind: "button", label: "D", action: { actionId: "d" } },
+ ),
+ );
+ expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]);
+ });
+
+ it("drops unknown field kinds gracefully", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "stat", label: "Known", value: "ok" }, {
+ kind: "future-kind" as "stat",
+ label: "Unknown",
+ value: "?",
+ } as SurfaceField),
+ );
+ expect(plan.fields).toHaveLength(1);
+ expect(plan.fields[0]?.label).toBe("Known");
+ });
+
+ it("drops custom fields (no renderer registered)", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "stat", label: "Before", value: "1" },
+ { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } },
+ { kind: "stat", label: "After", value: "2" },
+ ),
+ );
+ expect(plan.fields).toHaveLength(2);
+ expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]);
+ });
+
+ it("returns empty fields for an empty spec", () => {
+ const plan = planSurface(makeSpec());
+ expect(plan.fields).toEqual([]);
+ });
+
+ it("drops all fields when all are custom", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "custom", rendererId: "x", payload: null },
+ { kind: "custom", rendererId: "y", payload: 42 },
+ ),
+ );
+ expect(plan.fields).toEqual([]);
+ });
+});
+
+describe("buildInvoke", () => {
+ it("builds an invoke message for a toggle field", () => {
+ const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } };
+ const msg = buildInvoke("s1", field, true);
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true });
+ });
+
+ it("builds an invoke message for a selector field", () => {
+ const field = {
+ kind: "selector" as const,
+ label: "S",
+ value: "a",
+ options: [],
+ action: { actionId: "sel" },
+ };
+ const msg = buildInvoke("s1", field, "b");
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" });
+ });
+
+ it("builds an invoke message without payload for a button field", () => {
+ const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
+ const msg = buildInvoke("s1", field);
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" });
+ });
+
+ it("omits payload key when value is undefined", () => {
+ const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
+ const msg = buildInvoke("s1", field, undefined);
+ expect(msg).not.toHaveProperty("payload");
+ });
+
+ it("uses the field's actionId, not a surface-level id", () => {
+ const field = {
+ kind: "toggle" as const,
+ label: "X",
+ value: true,
+ action: { actionId: "custom-action-123" },
+ };
+ const msg = buildInvoke("surf", field, false);
+ expect(msg.actionId).toBe("custom-action-123");
+ });
+});
diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts
new file mode 100644
index 0000000..5b4530b
--- /dev/null
+++ b/src/features/surface-host/logic/plan.ts
@@ -0,0 +1,74 @@
+import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { FieldView, SurfaceRenderPlan } from "./types";
+
+const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]);
+
+/**
+ * Validate and normalise a SurfaceSpec into a renderable plan.
+ * Keeps known field kinds in order; drops unknown kinds and `custom` fields
+ * (no renderer registry yet — graceful skip, never throw).
+ */
+export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
+ const fields: FieldView[] = [];
+ for (const field of spec.fields) {
+ if (!KNOWN_KINDS.has(field.kind)) continue;
+ switch (field.kind) {
+ case "toggle":
+ fields.push({
+ kind: "toggle",
+ label: field.label,
+ value: field.value,
+ action: field.action,
+ });
+ break;
+ case "progress":
+ fields.push({
+ kind: "progress",
+ label: field.label,
+ value: field.value,
+ });
+ break;
+ case "selector":
+ fields.push({
+ kind: "selector",
+ label: field.label,
+ value: field.value,
+ options: field.options,
+ action: field.action,
+ });
+ break;
+ case "stat":
+ fields.push({
+ kind: "stat",
+ label: field.label,
+ value: field.value,
+ });
+ break;
+ case "button":
+ fields.push({
+ kind: "button",
+ label: field.label,
+ action: field.action,
+ });
+ break;
+ }
+ }
+ return { fields };
+}
+
+/**
+ * Construct a typed `invoke` client message for an actionable field.
+ * For toggle the payload is the new boolean; for selector the chosen value;
+ * for button the payload is omitted.
+ */
+export function buildInvoke(
+ surfaceId: string,
+ field: Extract<FieldView, { action: unknown }>,
+ value?: unknown,
+): InvokeMessage {
+ const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId };
+ if (value !== undefined) {
+ return { ...base, payload: value };
+ }
+ return base;
+}
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
new file mode 100644
index 0000000..f24438a
--- /dev/null
+++ b/src/features/surface-host/logic/types.ts
@@ -0,0 +1,52 @@
+import type { ActionRef, SurfaceOption } from "@dispatch/ui-contract";
+
+/** Normalised view-model for a toggle field. */
+export interface ToggleFieldView {
+ readonly kind: "toggle";
+ readonly label: string;
+ readonly value: boolean;
+ readonly action: ActionRef;
+}
+
+/** Normalised view-model for a progress field. */
+export interface ProgressFieldView {
+ readonly kind: "progress";
+ readonly label: string;
+ readonly value: number;
+}
+
+/** Normalised view-model for a selector field. */
+export interface SelectorFieldView {
+ readonly kind: "selector";
+ readonly label: string;
+ readonly value: string;
+ readonly options: readonly SurfaceOption[];
+ readonly action: ActionRef;
+}
+
+/** Normalised view-model for a stat field. */
+export interface StatFieldView {
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
+}
+
+/** Normalised view-model for a button field. */
+export interface ButtonFieldView {
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
+}
+
+/** A normalised field view-model — one entry per renderable field kind. */
+export type FieldView =
+ | ToggleFieldView
+ | ProgressFieldView
+ | SelectorFieldView
+ | StatFieldView
+ | ButtonFieldView;
+
+/** The output of `planSurface`: the ordered list of renderable fields. */
+export interface SurfaceRenderPlan {
+ readonly fields: readonly FieldView[];
+}