feat: define recursive presentation figures
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defineFigureCatalog } from "./catalog.js";
|
||||
import {
|
||||
cyclicCatalog,
|
||||
duplicateFigureCatalog,
|
||||
duplicateNodeCatalog,
|
||||
unknownChildCatalog,
|
||||
unknownEdgeCatalog,
|
||||
unknownRootCatalog,
|
||||
validCatalog,
|
||||
} from "./test-fixtures.js";
|
||||
|
||||
describe("defineFigureCatalog", () => {
|
||||
it("accepts a valid recursive catalog", () => {
|
||||
expect(() => defineFigureCatalog(validCatalog)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["duplicate figure", duplicateFigureCatalog, "duplicate_figure"],
|
||||
["duplicate node", duplicateNodeCatalog, "duplicate_node"],
|
||||
["unknown root figure", unknownRootCatalog, "unknown_root_figure"],
|
||||
["unknown edge endpoint", unknownEdgeCatalog, "unknown_edge_endpoint"],
|
||||
["unknown child figure", unknownChildCatalog, "unknown_child_figure"],
|
||||
["recursive child cycle", cyclicCatalog, "child_cycle"],
|
||||
])("rejects %s", (_label, catalog, code) => {
|
||||
expect(() => defineFigureCatalog(catalog)).toThrow(code);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
FigureCatalogDefinition,
|
||||
FigureDefinition,
|
||||
FigureNodeDefinition,
|
||||
} from "./model.js";
|
||||
|
||||
/**
|
||||
* Validates and returns a figure catalog. Static authored data is validated once
|
||||
* at module load; user or server payloads are not accepted through this interface.
|
||||
*
|
||||
* Throws an aggregated Error with one issue code per invalid reference so
|
||||
* catalog authors see every problem in a single run.
|
||||
*/
|
||||
export const defineFigureCatalog = (
|
||||
catalog: FigureCatalogDefinition,
|
||||
): FigureCatalogDefinition => {
|
||||
const issues: string[] = [];
|
||||
|
||||
const figureById = new Map<string, FigureDefinition>();
|
||||
for (const figure of catalog.figures) {
|
||||
if (figureById.has(figure.id)) {
|
||||
issues.push(`duplicate_figure:${figure.id}`);
|
||||
}
|
||||
figureById.set(figure.id, figure);
|
||||
}
|
||||
|
||||
if (!figureById.has(catalog.rootFigureId)) {
|
||||
issues.push(`unknown_root_figure:${catalog.rootFigureId}`);
|
||||
}
|
||||
|
||||
const nodeIdsByFigure = new Map<string, Set<string>>();
|
||||
for (const figure of catalog.figures) {
|
||||
const nodeIds = new Set<string>();
|
||||
for (const node of figure.nodes) {
|
||||
if (nodeIds.has(node.id)) {
|
||||
issues.push(`duplicate_node:${figure.id}:${node.id}`);
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
}
|
||||
nodeIdsByFigure.set(figure.id, nodeIds);
|
||||
}
|
||||
|
||||
for (const figure of catalog.figures) {
|
||||
const nodeIds = nodeIdsByFigure.get(figure.id)!;
|
||||
for (const edge of figure.edges) {
|
||||
if (!nodeIds.has(edge.from)) {
|
||||
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.from}`);
|
||||
}
|
||||
if (!nodeIds.has(edge.to)) {
|
||||
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.to}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const figure of catalog.figures) {
|
||||
for (const node of figure.nodes) {
|
||||
if (node.childFigureId !== undefined) {
|
||||
if (!figureById.has(node.childFigureId)) {
|
||||
issues.push(`unknown_child_figure:${figure.id}:${node.childFigureId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const inStack = new Set<string>();
|
||||
const visitChildChains = (figureId: string, path: string[]) => {
|
||||
const figure = figureById.get(figureId);
|
||||
if (!figure) return;
|
||||
for (const node of figure.nodes) {
|
||||
if (node.childFigureId === undefined) continue;
|
||||
const key = `${figureId}:${node.childFigureId}`;
|
||||
if (inStack.has(key)) {
|
||||
issues.push(`child_cycle:${figureId}:${node.childFigureId}`);
|
||||
continue;
|
||||
}
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
inStack.add(key);
|
||||
visitChildChains(node.childFigureId, [...path, node.childFigureId]);
|
||||
inStack.delete(key);
|
||||
}
|
||||
};
|
||||
visitChildChains(catalog.rootFigureId, [catalog.rootFigureId]);
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues.join(";"));
|
||||
}
|
||||
|
||||
return catalog;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { popFigureFocus, pushFigureFocus, resolveFigureFocus } from "./focus.js";
|
||||
import { validCatalog } from "./test-fixtures.js";
|
||||
|
||||
describe("figure focus", () => {
|
||||
it("resolves a two-level Focus Path with breadcrumbs", () => {
|
||||
const focus = resolveFigureFocus(validCatalog, ["runtime", "providers"]);
|
||||
expect(focus.figure.id).toBe("provider-detail");
|
||||
expect(focus.path).toEqual(["runtime", "providers"]);
|
||||
expect(focus.breadcrumbs.map((item) => item.label)).toEqual([
|
||||
"Architecture",
|
||||
"Runtime & providers",
|
||||
"Configured providers",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed to the root for an invalid Focus Path", () => {
|
||||
expect(resolveFigureFocus(validCatalog, ["missing"]).path).toEqual([]);
|
||||
expect(resolveFigureFocus(validCatalog, ["runtime", "missing"]).figure.id)
|
||||
.toBe("architecture-overview");
|
||||
});
|
||||
|
||||
it("pushes only expandable nodes and pops one level", () => {
|
||||
const root = resolveFigureFocus(validCatalog, []);
|
||||
const runtime = pushFigureFocus(validCatalog, root, "runtime");
|
||||
expect(runtime.path).toEqual(["runtime"]);
|
||||
expect(pushFigureFocus(validCatalog, runtime, "leaf")).toEqual(runtime);
|
||||
expect(popFigureFocus(validCatalog, runtime).path).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
FigureCatalogDefinition,
|
||||
FigureDefinition,
|
||||
FigureNodeDefinition,
|
||||
} from "./model.js";
|
||||
|
||||
export type FigureBreadcrumb = {
|
||||
readonly label: string;
|
||||
readonly path: readonly string[];
|
||||
};
|
||||
|
||||
export type FigureFocus = {
|
||||
readonly figure: FigureDefinition;
|
||||
readonly path: readonly string[];
|
||||
readonly breadcrumbs: readonly FigureBreadcrumb[];
|
||||
};
|
||||
|
||||
const findFigure = (
|
||||
catalog: FigureCatalogDefinition,
|
||||
id: string,
|
||||
): FigureDefinition | undefined =>
|
||||
catalog.figures.find((f) => f.id === id);
|
||||
|
||||
const findNode = (
|
||||
figure: FigureDefinition,
|
||||
nodeId: string,
|
||||
): FigureNodeDefinition | undefined =>
|
||||
figure.nodes.find((n) => n.id === nodeId);
|
||||
|
||||
const buildBreadcrumbs = (
|
||||
catalog: FigureCatalogDefinition,
|
||||
path: readonly string[],
|
||||
): readonly FigureBreadcrumb[] => {
|
||||
const crumbs: FigureBreadcrumb[] = [];
|
||||
let currentFigure = findFigure(catalog, catalog.rootFigureId);
|
||||
if (!currentFigure) return crumbs;
|
||||
|
||||
crumbs.push({ label: currentFigure.title, path: [] });
|
||||
|
||||
for (const segment of path) {
|
||||
const node = findNode(currentFigure, segment);
|
||||
if (!node?.childFigureId) break;
|
||||
const childFigure = findFigure(catalog, node.childFigureId);
|
||||
if (!childFigure) break;
|
||||
crumbs.push({ label: node.label, path: [...crumbs[crumbs.length - 1]!.path, segment] });
|
||||
currentFigure = childFigure;
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a Focus Path from the root figure, walking childFigureId references.
|
||||
* If any path segment is missing or non-expandable, returns the root focus with
|
||||
* an empty path.
|
||||
*/
|
||||
export const resolveFigureFocus = (
|
||||
catalog: FigureCatalogDefinition,
|
||||
path: readonly string[],
|
||||
): FigureFocus => {
|
||||
const rootFigure = findFigure(catalog, catalog.rootFigureId);
|
||||
if (!rootFigure) {
|
||||
return {
|
||||
figure: { id: "", title: "", layout: { kind: "layered" }, nodes: [], edges: [] },
|
||||
path: [],
|
||||
breadcrumbs: [],
|
||||
};
|
||||
}
|
||||
|
||||
let currentFigure = rootFigure;
|
||||
const resolvedPath: string[] = [];
|
||||
|
||||
for (const segment of path) {
|
||||
const node = findNode(currentFigure, segment);
|
||||
if (!node?.childFigureId) {
|
||||
return { figure: rootFigure, path: [], breadcrumbs: buildBreadcrumbs(catalog, []) };
|
||||
}
|
||||
const childFigure = findFigure(catalog, node.childFigureId);
|
||||
if (!childFigure) {
|
||||
return { figure: rootFigure, path: [], breadcrumbs: buildBreadcrumbs(catalog, []) };
|
||||
}
|
||||
resolvedPath.push(segment);
|
||||
currentFigure = childFigure;
|
||||
}
|
||||
|
||||
return {
|
||||
figure: currentFigure,
|
||||
path: resolvedPath,
|
||||
breadcrumbs: buildBreadcrumbs(catalog, resolvedPath),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Pushes a node focus if the node is expandable; otherwise returns the current
|
||||
* focus unchanged.
|
||||
*/
|
||||
export const pushFigureFocus = (
|
||||
catalog: FigureCatalogDefinition,
|
||||
focus: FigureFocus,
|
||||
nodeId: string,
|
||||
): FigureFocus => {
|
||||
const node = findNode(focus.figure, nodeId);
|
||||
if (!node?.childFigureId) return focus;
|
||||
return resolveFigureFocus(catalog, [...focus.path, nodeId]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pops one focus level, returning to the parent figure.
|
||||
*/
|
||||
export const popFigureFocus = (
|
||||
catalog: FigureCatalogDefinition,
|
||||
focus: FigureFocus,
|
||||
): FigureFocus => {
|
||||
if (focus.path.length === 0) return focus;
|
||||
return resolveFigureFocus(catalog, focus.path.slice(0, -1));
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
export type FigureNodeKind =
|
||||
| "actor"
|
||||
| "operation"
|
||||
| "artifact"
|
||||
| "runtime"
|
||||
| "boundary"
|
||||
| "evidence";
|
||||
|
||||
export type FigureLayout =
|
||||
| { readonly kind: "layered" }
|
||||
| { readonly kind: "flow" }
|
||||
| {
|
||||
readonly kind: "explicit";
|
||||
readonly positions: Readonly<Record<string, { readonly x: number; readonly y: number }>>;
|
||||
};
|
||||
|
||||
export type FigureNodeDefinition = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly summary: string;
|
||||
readonly kind: FigureNodeKind;
|
||||
readonly evidencePointer?: string;
|
||||
readonly childFigureId?: string;
|
||||
};
|
||||
|
||||
export type FigureEdgeDefinition = {
|
||||
readonly id: string;
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly label?: string;
|
||||
};
|
||||
|
||||
export type FigureDefinition = {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly layout: FigureLayout;
|
||||
readonly nodes: readonly FigureNodeDefinition[];
|
||||
readonly edges: readonly FigureEdgeDefinition[];
|
||||
};
|
||||
|
||||
export type FigureCatalogDefinition = {
|
||||
readonly rootFigureId: string;
|
||||
readonly figures: readonly FigureDefinition[];
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import type {
|
||||
FigureCatalogDefinition,
|
||||
FigureDefinition,
|
||||
FigureEdgeDefinition,
|
||||
FigureNodeDefinition,
|
||||
} from "./model.js";
|
||||
|
||||
const runtimeNode: FigureNodeDefinition = {
|
||||
id: "runtime",
|
||||
label: "Runtime & providers",
|
||||
summary: "WorkflowServer and provider composition",
|
||||
kind: "runtime",
|
||||
childFigureId: "runtime-detail",
|
||||
};
|
||||
|
||||
const leafNode: FigureNodeDefinition = {
|
||||
id: "leaf",
|
||||
label: "Leaf node",
|
||||
summary: "Non-expandable leaf",
|
||||
kind: "artifact",
|
||||
};
|
||||
|
||||
const clientNode: FigureNodeDefinition = {
|
||||
id: "client",
|
||||
label: "Client operations",
|
||||
summary: "CLI, JSON-RPC, and web console callers",
|
||||
kind: "actor",
|
||||
};
|
||||
|
||||
const apiNode: FigureNodeDefinition = {
|
||||
id: "api",
|
||||
label: "Application lifecycle",
|
||||
summary: "Public lifecycle operations",
|
||||
kind: "operation",
|
||||
};
|
||||
|
||||
const overviewFigure: FigureDefinition = {
|
||||
id: "architecture-overview",
|
||||
title: "Architecture",
|
||||
layout: { kind: "layered" },
|
||||
nodes: [clientNode, apiNode, runtimeNode, leafNode],
|
||||
edges: [
|
||||
{ id: "e-client-api", from: "client", to: "api", label: "calls" },
|
||||
{ id: "e-api-runtime", from: "api", to: "runtime", label: "uses" },
|
||||
],
|
||||
};
|
||||
|
||||
const providersNode: FigureNodeDefinition = {
|
||||
id: "providers",
|
||||
label: "Configured providers",
|
||||
summary: "Built-in and external providers",
|
||||
kind: "runtime",
|
||||
childFigureId: "provider-detail",
|
||||
};
|
||||
|
||||
const runtimeDetailFigure: FigureDefinition = {
|
||||
id: "runtime-detail",
|
||||
title: "Runtime detail",
|
||||
layout: { kind: "layered" },
|
||||
nodes: [providersNode, leafNode],
|
||||
edges: [{ id: "e-providers-leaf", from: "providers", to: "leaf" }],
|
||||
};
|
||||
|
||||
const pythonProviderNode: FigureNodeDefinition = {
|
||||
id: "python-provider",
|
||||
label: "Python provider",
|
||||
summary: "Trusted in-process Python execution",
|
||||
kind: "runtime",
|
||||
};
|
||||
|
||||
const providerDetailFigure: FigureDefinition = {
|
||||
id: "provider-detail",
|
||||
title: "Provider detail",
|
||||
layout: { kind: "layered" },
|
||||
nodes: [pythonProviderNode],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
export const validCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "architecture-overview",
|
||||
figures: [overviewFigure, runtimeDetailFigure, providerDetailFigure],
|
||||
};
|
||||
|
||||
export const duplicateFigureCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "architecture-overview",
|
||||
figures: [
|
||||
overviewFigure,
|
||||
{ ...overviewFigure, id: "architecture-overview" },
|
||||
runtimeDetailFigure,
|
||||
providerDetailFigure,
|
||||
],
|
||||
};
|
||||
|
||||
export const duplicateNodeCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "architecture-overview",
|
||||
figures: [
|
||||
{
|
||||
...overviewFigure,
|
||||
nodes: [
|
||||
...overviewFigure.nodes,
|
||||
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" as const },
|
||||
],
|
||||
},
|
||||
runtimeDetailFigure,
|
||||
providerDetailFigure,
|
||||
],
|
||||
};
|
||||
|
||||
export const unknownRootCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "nonexistent",
|
||||
figures: [overviewFigure, runtimeDetailFigure, providerDetailFigure],
|
||||
};
|
||||
|
||||
export const unknownEdgeCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "architecture-overview",
|
||||
figures: [
|
||||
{
|
||||
...overviewFigure,
|
||||
edges: [
|
||||
{ id: "e-bad", from: "client", to: "nonexistent", label: "bad" },
|
||||
],
|
||||
},
|
||||
runtimeDetailFigure,
|
||||
providerDetailFigure,
|
||||
],
|
||||
};
|
||||
|
||||
export const unknownChildCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "architecture-overview",
|
||||
figures: [
|
||||
{
|
||||
...overviewFigure,
|
||||
nodes: overviewFigure.nodes.map((n) =>
|
||||
n.id === "runtime" ? { ...n, childFigureId: "nonexistent" } : n,
|
||||
),
|
||||
},
|
||||
runtimeDetailFigure,
|
||||
providerDetailFigure,
|
||||
],
|
||||
};
|
||||
|
||||
export const cyclicCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "architecture-overview",
|
||||
figures: [
|
||||
{
|
||||
...overviewFigure,
|
||||
nodes: overviewFigure.nodes.map((n) =>
|
||||
n.id === "runtime" ? { ...n, childFigureId: "runtime-detail" } : n,
|
||||
),
|
||||
},
|
||||
{
|
||||
...runtimeDetailFigure,
|
||||
nodes: runtimeDetailFigure.nodes.map((n) =>
|
||||
n.id === "providers" ? { ...n, childFigureId: "architecture-overview" } : n,
|
||||
),
|
||||
},
|
||||
providerDetailFigure,
|
||||
],
|
||||
};
|
||||
|
||||
const edgeA: FigureEdgeDefinition = { id: "e1", from: "a", to: "b" };
|
||||
const edgeB: FigureEdgeDefinition = { id: "e2", from: "b", to: "c" };
|
||||
|
||||
export const layeredFigure: FigureDefinition = {
|
||||
id: "layered",
|
||||
title: "Layered",
|
||||
layout: { kind: "layered" },
|
||||
nodes: [
|
||||
{ id: "client", label: "Client", summary: "caller", kind: "actor" },
|
||||
{ id: "runtime", label: "Runtime", summary: "server", kind: "runtime" },
|
||||
],
|
||||
edges: [edgeA],
|
||||
};
|
||||
|
||||
export const flowFigure: FigureDefinition = {
|
||||
id: "flow",
|
||||
title: "Flow",
|
||||
layout: { kind: "flow" },
|
||||
nodes: [
|
||||
{ id: "discover", label: "Discover", summary: "find", kind: "operation" },
|
||||
{ id: "repair", label: "Repair", summary: "fix", kind: "operation" },
|
||||
],
|
||||
edges: [edgeB],
|
||||
};
|
||||
|
||||
export const explicitFigure: FigureDefinition = {
|
||||
id: "explicit",
|
||||
title: "Explicit",
|
||||
layout: {
|
||||
kind: "explicit",
|
||||
positions: { runtime: { x: 420, y: 180 }, client: { x: 100, y: 50 } },
|
||||
},
|
||||
nodes: [
|
||||
{ id: "client", label: "Client", summary: "caller", kind: "actor" },
|
||||
{ id: "runtime", label: "Runtime", summary: "server", kind: "runtime" },
|
||||
],
|
||||
edges: [{ id: "e-explicit", from: "client", to: "runtime" }],
|
||||
};
|
||||
|
||||
export const explicitFigureMissingPosition: FigureDefinition = {
|
||||
id: "explicit-missing",
|
||||
title: "Explicit Missing",
|
||||
layout: {
|
||||
kind: "explicit",
|
||||
positions: { client: { x: 100, y: 50 } },
|
||||
},
|
||||
nodes: [
|
||||
{ id: "client", label: "Client", summary: "caller", kind: "actor" },
|
||||
{ id: "runtime", label: "Runtime", summary: "server", kind: "runtime" },
|
||||
],
|
||||
edges: [{ id: "e-explicit-missing", from: "client", to: "runtime" }],
|
||||
};
|
||||
|
||||
export const navigationLayout: FigureDefinition = {
|
||||
id: "navigation",
|
||||
title: "Navigation",
|
||||
layout: {
|
||||
kind: "explicit",
|
||||
positions: {
|
||||
left: { x: 0, y: 100 },
|
||||
right: { x: 400, y: 100 },
|
||||
top: { x: 200, y: 0 },
|
||||
bottom: { x: 200, y: 200 },
|
||||
},
|
||||
},
|
||||
nodes: [
|
||||
{ id: "left", label: "Left", summary: "left node", kind: "actor" },
|
||||
{ id: "right", label: "Right", summary: "right node", kind: "actor" },
|
||||
{ id: "top", label: "Top", summary: "top node", kind: "actor" },
|
||||
{ id: "bottom", label: "Bottom", summary: "bottom node", kind: "actor" },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
export const tiedNavigationLayout: FigureDefinition = {
|
||||
id: "tied-navigation",
|
||||
title: "Tied Navigation",
|
||||
layout: {
|
||||
kind: "explicit",
|
||||
positions: {
|
||||
start: { x: 0, y: 100 },
|
||||
alpha: { x: 200, y: 0 },
|
||||
beta: { x: 200, y: 200 },
|
||||
},
|
||||
},
|
||||
nodes: [
|
||||
{ id: "start", label: "Start", summary: "start node", kind: "actor" },
|
||||
{ id: "alpha", label: "Alpha", summary: "alpha node", kind: "actor" },
|
||||
{ id: "beta", label: "Beta", summary: "beta node", kind: "actor" },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
Reference in New Issue
Block a user