feat: persist presentation figure focus
- Add focusPath to MainLocation and FigureBeatDefinition to SceneBeatDefinition - Canonical hash format: #scene/<scene>/<beat>/focus/<segment>/<segment> - Add set_focus_path reducer action - Next/previous restores destination beat's canonical Focus Path - Discussion return preserves exact originating Focus Path - Fix catalog cycle detection to cover disconnected subgraphs - Add FigureCatalogIssue typed issue model - Fix test fixtures using nonexistent edge endpoints - Remove unsafe non-null assertions in catalog and focus modules
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { defineFigureCatalog } from "./catalog.js";
|
||||
import {
|
||||
cyclicCatalog,
|
||||
disconnectedCyclicCatalog,
|
||||
duplicateFigureCatalog,
|
||||
duplicateNodeCatalog,
|
||||
unknownChildCatalog,
|
||||
@@ -22,6 +23,7 @@ describe("defineFigureCatalog", () => {
|
||||
["unknown edge endpoint", unknownEdgeCatalog, "unknown_edge_endpoint"],
|
||||
["unknown child figure", unknownChildCatalog, "unknown_child_figure"],
|
||||
["recursive child cycle", cyclicCatalog, "child_cycle"],
|
||||
["disconnected child cycle", disconnectedCyclicCatalog, "child_cycle"],
|
||||
])("rejects %s", (_label, catalog, code) => {
|
||||
expect(() => defineFigureCatalog(catalog)).toThrow(code);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import type {
|
||||
FigureCatalogDefinition,
|
||||
FigureDefinition,
|
||||
FigureNodeDefinition,
|
||||
} from "./model.js";
|
||||
|
||||
export type FigureCatalogIssue =
|
||||
| { readonly code: "duplicate_figure"; readonly figureId: string }
|
||||
| { readonly code: "duplicate_node"; readonly figureId: string; readonly nodeId: string }
|
||||
| { readonly code: "unknown_root_figure"; readonly figureId: string }
|
||||
| { readonly code: "unknown_edge_endpoint"; readonly figureId: string; readonly endpointId: string }
|
||||
| { readonly code: "unknown_child_figure"; readonly figureId: string; readonly childFigureId: string }
|
||||
| { readonly code: "child_cycle"; readonly fromFigureId: string; readonly toFigureId: string };
|
||||
|
||||
const issueToCode = (issue: FigureCatalogIssue): string => {
|
||||
switch (issue.code) {
|
||||
case "duplicate_figure":
|
||||
return `duplicate_figure:${issue.figureId}`;
|
||||
case "duplicate_node":
|
||||
return `duplicate_node:${issue.figureId}:${issue.nodeId}`;
|
||||
case "unknown_root_figure":
|
||||
return `unknown_root_figure:${issue.figureId}`;
|
||||
case "unknown_edge_endpoint":
|
||||
return `unknown_edge_endpoint:${issue.figureId}:${issue.endpointId}`;
|
||||
case "unknown_child_figure":
|
||||
return `unknown_child_figure:${issue.figureId}:${issue.childFigureId}`;
|
||||
case "child_cycle":
|
||||
return `child_cycle:${issue.fromFigureId}:${issue.toFigureId}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -14,18 +38,18 @@ import type {
|
||||
export const defineFigureCatalog = (
|
||||
catalog: FigureCatalogDefinition,
|
||||
): FigureCatalogDefinition => {
|
||||
const issues: string[] = [];
|
||||
const issues: FigureCatalogIssue[] = [];
|
||||
|
||||
const figureById = new Map<string, FigureDefinition>();
|
||||
for (const figure of catalog.figures) {
|
||||
if (figureById.has(figure.id)) {
|
||||
issues.push(`duplicate_figure:${figure.id}`);
|
||||
issues.push({ code: "duplicate_figure", figureId: figure.id });
|
||||
}
|
||||
figureById.set(figure.id, figure);
|
||||
}
|
||||
|
||||
if (!figureById.has(catalog.rootFigureId)) {
|
||||
issues.push(`unknown_root_figure:${catalog.rootFigureId}`);
|
||||
issues.push({ code: "unknown_root_figure", figureId: catalog.rootFigureId });
|
||||
}
|
||||
|
||||
const nodeIdsByFigure = new Map<string, Set<string>>();
|
||||
@@ -33,7 +57,7 @@ export const defineFigureCatalog = (
|
||||
const nodeIds = new Set<string>();
|
||||
for (const node of figure.nodes) {
|
||||
if (nodeIds.has(node.id)) {
|
||||
issues.push(`duplicate_node:${figure.id}:${node.id}`);
|
||||
issues.push({ code: "duplicate_node", figureId: figure.id, nodeId: node.id });
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
}
|
||||
@@ -41,13 +65,14 @@ export const defineFigureCatalog = (
|
||||
}
|
||||
|
||||
for (const figure of catalog.figures) {
|
||||
const nodeIds = nodeIdsByFigure.get(figure.id)!;
|
||||
const nodeIds = nodeIdsByFigure.get(figure.id);
|
||||
if (!nodeIds) continue;
|
||||
for (const edge of figure.edges) {
|
||||
if (!nodeIds.has(edge.from)) {
|
||||
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.from}`);
|
||||
issues.push({ code: "unknown_edge_endpoint", figureId: figure.id, endpointId: edge.from });
|
||||
}
|
||||
if (!nodeIds.has(edge.to)) {
|
||||
issues.push(`unknown_edge_endpoint:${figure.id}:${edge.to}`);
|
||||
issues.push({ code: "unknown_edge_endpoint", figureId: figure.id, endpointId: edge.to });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,35 +81,39 @@ export const defineFigureCatalog = (
|
||||
for (const node of figure.nodes) {
|
||||
if (node.childFigureId !== undefined) {
|
||||
if (!figureById.has(node.childFigureId)) {
|
||||
issues.push(`unknown_child_figure:${figure.id}:${node.childFigureId}`);
|
||||
issues.push({ code: "unknown_child_figure", figureId: figure.id, childFigureId: node.childFigureId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const inStack = new Set<string>();
|
||||
const visitChildChains = (figureId: string, path: string[]) => {
|
||||
// Detect child-figure cycles from every figure, not just the root, so
|
||||
// disconnected subgraphs with cycles are also caught.
|
||||
const edgeVisited = new Set<string>();
|
||||
const edgeInStack = new Set<string>();
|
||||
const visitChildChains = (figureId: 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}`);
|
||||
const edgeKey = `${figureId}:${node.childFigureId}`;
|
||||
if (edgeInStack.has(edgeKey)) {
|
||||
issues.push({ code: "child_cycle", fromFigureId: figureId, toFigureId: node.childFigureId });
|
||||
continue;
|
||||
}
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
inStack.add(key);
|
||||
visitChildChains(node.childFigureId, [...path, node.childFigureId]);
|
||||
inStack.delete(key);
|
||||
if (edgeVisited.has(edgeKey)) continue;
|
||||
edgeVisited.add(edgeKey);
|
||||
edgeInStack.add(edgeKey);
|
||||
visitChildChains(node.childFigureId);
|
||||
edgeInStack.delete(edgeKey);
|
||||
}
|
||||
};
|
||||
visitChildChains(catalog.rootFigureId, [catalog.rootFigureId]);
|
||||
for (const figure of catalog.figures) {
|
||||
visitChildChains(figure.id);
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues.join(";"));
|
||||
throw new Error(issues.map(issueToCode).join(";"));
|
||||
}
|
||||
|
||||
return catalog;
|
||||
|
||||
@@ -42,7 +42,8 @@ const buildBreadcrumbs = (
|
||||
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] });
|
||||
const prevCrumb = crumbs[crumbs.length - 1];
|
||||
crumbs.push({ label: node.label, path: [...(prevCrumb?.path ?? []), segment] });
|
||||
currentFigure = childFigure;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import type { FigureDefinition, FigureNodeDefinition } from "./model.js";
|
||||
import { layoutFigure, type PositionedFigure } from "./layout.js";
|
||||
|
||||
const position = (layout: PositionedFigure, nodeId: string) =>
|
||||
layout.nodes.find((n) => n.id === nodeId)!.position;
|
||||
const position = (layout: PositionedFigure, nodeId: string) => {
|
||||
const node = layout.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) throw new Error(`node ${nodeId} not found in layout`);
|
||||
return node.position;
|
||||
};
|
||||
|
||||
describe("layoutFigure", () => {
|
||||
it("places layered edges from top to bottom", () => {
|
||||
|
||||
@@ -98,7 +98,7 @@ export const duplicateNodeCatalog: FigureCatalogDefinition = {
|
||||
...overviewFigure,
|
||||
nodes: [
|
||||
...overviewFigure.nodes,
|
||||
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" as const },
|
||||
{ id: "client", label: "Dup", summary: "Dup", kind: "actor" },
|
||||
],
|
||||
},
|
||||
runtimeDetailFigure,
|
||||
@@ -158,8 +158,33 @@ export const cyclicCatalog: FigureCatalogDefinition = {
|
||||
],
|
||||
};
|
||||
|
||||
const edgeA: FigureEdgeDefinition = { id: "e1", from: "a", to: "b" };
|
||||
const edgeB: FigureEdgeDefinition = { id: "e2", from: "b", to: "c" };
|
||||
export const disconnectedCyclicCatalog: FigureCatalogDefinition = {
|
||||
rootFigureId: "architecture-overview",
|
||||
figures: [
|
||||
overviewFigure,
|
||||
{
|
||||
id: "disconnected",
|
||||
title: "Disconnected",
|
||||
layout: { kind: "layered" as const },
|
||||
nodes: [
|
||||
{ id: "x", label: "X", summary: "x", kind: "actor" as const, childFigureId: "disconnected-2" },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
{
|
||||
id: "disconnected-2",
|
||||
title: "Disconnected 2",
|
||||
layout: { kind: "layered" as const },
|
||||
nodes: [
|
||||
{ id: "y", label: "Y", summary: "y", kind: "actor" as const, childFigureId: "disconnected" },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const edgeA: FigureEdgeDefinition = { id: "e1", from: "client", to: "runtime" };
|
||||
const edgeB: FigureEdgeDefinition = { id: "e2", from: "discover", to: "repair" };
|
||||
|
||||
export const layeredFigure: FigureDefinition = {
|
||||
id: "layered",
|
||||
|
||||
Reference in New Issue
Block a user