fix: stabilize authoring graph selection

This commit is contained in:
lda
2026-08-09 06:30:27 +07:00 Verified
parent c037e73bee
commit d4c28850d8
14 changed files with 303 additions and 156 deletions
@@ -43,6 +43,10 @@ describe("AuthoringGraph", () => {
"data-active",
"true",
);
expect(container.querySelector('[data-node-id="review"]')).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("selects a connector by its source step and outcome", () => {
@@ -51,7 +55,7 @@ describe("AuthoringGraph", () => {
<AuthoringGraph draft={workspace.draft} selection={{ kind: "canvas" }} onSelectionChange={onSelectionChange} />,
);
const edgeButton = container.querySelector('[data-edge-id="e-collect-review-0"]');
const edgeButton = container.querySelector('[data-edge-id="e-7:collect2:ok6:review"]');
expect(edgeButton).not.toBeNull();
fireEvent.click(edgeButton!);
@@ -2,9 +2,8 @@ import type { ReactNode } from "react";
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { CapabilitySummary } from "../domain/capability-models.js";
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
const MAX_RAW_DRAFT_CHARS = 12_000;
const TRUNCATION_MARKER = "... truncated ...";
import { withDiagnosticKeys } from "./diagnostic-key.js";
import { formatBoundedJson } from "./format-bounded-json.js";
type ContextInspectorProps = {
readonly draft: DraftWorkspace;
@@ -12,9 +11,6 @@ type ContextInspectorProps = {
readonly selection: WorkbenchSelection;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const formatStatus = (status: DraftWorkspace["status"]): string =>
status.charAt(0).toUpperCase() + status.slice(1);
@@ -24,100 +20,6 @@ const formatValue = (value: unknown): string => {
return encoded ?? String(value);
};
export const formatBoundedJson = (
value: unknown,
maxChars = MAX_RAW_DRAFT_CHARS,
): string => {
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
const contentLimit = Math.max(0, maxChars);
let output = "";
let truncated = false;
const activeObjects = new WeakSet<object>();
const append = (chunk: string): void => {
if (truncated) return;
if (output.length + chunk.length > contentLimit) {
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
truncated = true;
return;
}
output += chunk;
};
const appendJsonString = (text: string): void => {
append('"');
for (let index = 0; index < text.length; index++) {
if (truncated) return;
const code = text.charCodeAt(index);
if (code === 0x22) append('\\"');
else if (code === 0x5c) append("\\\\");
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
else if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = text.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
append(text.slice(index, index + 2));
index++;
} else append(`\\u${code.toString(16).padStart(4, "0")}`);
} else if (code >= 0xdc00 && code <= 0xdfff) {
append(`\\u${code.toString(16).padStart(4, "0")}`);
} else append(text.charAt(index));
}
if (!truncated) append('"');
};
const visit = (current: unknown, depth: number): void => {
if (truncated) return;
if (current === null || typeof current !== "object") {
if (typeof current === "string") appendJsonString(current);
else if (typeof current === "number") append(Number.isFinite(current) ? String(current) : "null");
else if (typeof current === "boolean") append(current ? "true" : "false");
else append("null");
return;
}
if (activeObjects.has(current)) {
append('"[Circular]"');
return;
}
activeObjects.add(current);
const indent = " ".repeat(depth);
const childIndent = " ".repeat(depth + 1);
if (Array.isArray(current)) {
append("[");
let first = true;
for (const item of current) {
if (truncated) break;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
visit(item, depth + 1);
first = false;
}
if (!truncated) append(first ? "]" : `\n${indent}]`);
} else {
if (!isRecord(current)) {
activeObjects.delete(current);
return;
}
const record = current;
append("{");
let first = true;
for (const key in record) {
if (!Object.prototype.hasOwnProperty.call(record, key) || truncated) continue;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
appendJsonString(key);
append(": ");
visit(record[key], depth + 1);
first = false;
}
if (!truncated) append(first ? "}" : `\n${indent}}`);
}
activeObjects.delete(current);
};
visit(value, 0);
if (!truncated) return output;
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
return `${output.slice(0, markerStart)}${truncationMarker}`;
};
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
<div>
<dt>{label}</dt>
@@ -159,8 +61,8 @@ const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<Draf
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
{diagnostics.length > 0 ? (
<ol className="draft-detail__diagnostics">
{diagnostics.map((diagnostic, index) => (
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
{withDiagnosticKeys(diagnostics).map(({ diagnostic, key }) => (
<Diagnostic key={key} diagnostic={diagnostic} />
))}
</ol>
) : <p>No diagnostics reported.</p>}
@@ -13,9 +13,11 @@ type DraftWorkbenchProps = {
readonly onSelectionChange?: (selection: WorkbenchSelection) => void;
};
const EMPTY_CAPABILITIES: ReadonlyArray<CapabilitySummary> = [];
export const DraftWorkbench = ({
draft,
capabilities = [],
capabilities = EMPTY_CAPABILITIES,
initialSelection = { kind: "canvas" },
onSelectionChange,
}: DraftWorkbenchProps) => {
@@ -32,10 +32,10 @@ describe("projectAuthoringGraph", () => {
["collect", "use"],
["review", "interrupt"],
]);
expect(model.edges.map((edge) => [edge.id, edge.source, edge.label, edge.target])).toEqual([
["e-collect-review-0", "collect", "ok", "review"],
["e-review-__end__-1", "review", "approved", "__end__"],
["e-review-collect-2", "review", "needs_changes", "collect"],
expect(model.edges.map((edge) => [edge.source, edge.label, edge.target])).toEqual([
["collect", "ok", "review"],
["review", "approved", "__end__"],
["review", "needs_changes", "collect"],
]);
expect(model.nodes.find((node) => node.id === "collect")?.data.nodeRef).toBe(
"demo.collect",
@@ -25,7 +25,14 @@ const stringValue = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
const stringList = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
(() => {
const values: string[] = [];
if (!Array.isArray(value)) return values;
for (const item of value) {
if (typeof item === "string") values.push(item);
}
return values;
})();
const stepKind = (step: JsonRecord): string => {
for (const kind of [
@@ -75,18 +82,44 @@ const nodeForStep = (id: string, step: JsonRecord): JsonRecord => {
return node;
};
const sortedRecords = (value: JsonRecord | null): Array<[string, JsonRecord]> =>
value === null
? []
: Object.entries(value)
.filter((entry): entry is [string, JsonRecord] => isRecord(entry[1]))
.toSorted(([left], [right]) => left.localeCompare(right));
const sortedRecords = (value: JsonRecord | null): Array<[string, JsonRecord]> => {
if (value === null) return [];
const records: Array<[string, JsonRecord]> = [];
for (const [key, item] of Object.entries(value)) {
if (isRecord(item)) records.push([key, item]);
}
return records.toSorted(([left], [right]) => left.localeCompare(right));
};
const sortedEntries = (value: JsonRecord | null): Array<[string, unknown]> =>
value === null
? []
: Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right));
const recordArray = (value: unknown): JsonRecord[] => {
const records: JsonRecord[] = [];
if (!Array.isArray(value)) return records;
for (const item of value) {
if (isRecord(item)) records.push(item);
}
return records;
};
const copiedRecordArray = (value: unknown): Array<Record<string, unknown>> => {
const records: Array<Record<string, unknown>> = [];
for (const item of recordArray(value)) records.push({ ...item });
return records;
};
const nodeIdsFor = (nodes: ReadonlyArray<JsonRecord>): Set<string> => {
const nodeIds = new Set<string>();
for (const node of nodes) {
const id = stringValue(node.id);
if (id !== null) nodeIds.add(id);
}
return nodeIds;
};
const routesForSteps = (routes: JsonRecord | null): Array<Record<string, unknown>> => {
if (routes === null) return [];
const edges: Array<Record<string, unknown>> = [];
@@ -104,14 +137,12 @@ const compiledPlan = (draft: JsonRecord): {
readonly nodes: Array<JsonRecord>;
readonly edges: Array<Record<string, unknown>>;
} => {
const rawNodes = Array.isArray(draft.nodes)
? draft.nodes.filter(isRecord)
: [];
const rawNodes = recordArray(draft.nodes);
const rawEdges = Array.isArray(draft.edges)
? draft.edges.filter(isRecord).map((edge) => ({ ...edge }))
? copiedRecordArray(draft.edges)
: routesForSteps(recordValue(draft.routes));
const nodes = rawNodes.map((node) => ({ ...node }));
const nodeIds = new Set(nodes.map((node) => stringValue(node.id)).filter((id): id is string => id !== null));
const nodes = rawNodes;
const nodeIds = nodeIdsFor(nodes);
for (const edge of rawEdges) {
const target = stringValue(edge.to);
@@ -130,9 +161,10 @@ const keyedPlan = (draft: JsonRecord): {
} => {
const steps = recordValue(draft.steps);
if (steps === null) return { nodes: [], edges: [] };
const nodes = sortedRecords(steps).map(([id, step]) => nodeForStep(id, step));
const nodes: JsonRecord[] = [];
for (const [id, step] of sortedRecords(steps)) nodes.push(nodeForStep(id, step));
const edges = routesForSteps(recordValue(draft.routes));
const nodeIds = new Set(nodes.map((node) => stringValue(node.id)).filter((id): id is string => id !== null));
const nodeIds = nodeIdsFor(nodes);
if (edges.some((edge) => edge.to === "__end__") && !nodeIds.has("__end__")) {
nodes.push({ id: "__end__", type: "end", outcome: "ok" });
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { DraftDiagnostic } from "../domain/draft-workspace-models.js";
import { withDiagnosticKeys } from "./diagnostic-key.js";
const diagnostic = (overrides: Partial<DraftDiagnostic> = {}): DraftDiagnostic => ({
code: "missing_route",
path: "routes.review",
message: "Review needs a route.",
stepId: "review",
repairHint: "Add a submitted route.",
details: {},
...overrides,
});
describe("withDiagnosticKeys", () => {
it("keeps unchanged diagnostic keys stable when another diagnostic is inserted", () => {
const first = withDiagnosticKeys([diagnostic(), diagnostic({ code: "invalid_step" })]);
const second = withDiagnosticKeys([
diagnostic({ code: "missing_start" }),
diagnostic(),
diagnostic({ code: "invalid_step" }),
]);
expect(second[1]?.key).toBe(first[0]?.key);
expect(second[2]?.key).toBe(first[1]?.key);
});
it("gives duplicate diagnostics distinct stable occurrence keys", () => {
const entries = withDiagnosticKeys([diagnostic(), diagnostic()]);
expect(entries[0]?.key).not.toBe(entries[1]?.key);
expect(entries[0]?.key).toBe(withDiagnosticKeys([diagnostic()])[0]?.key);
});
});
@@ -0,0 +1,33 @@
import type { DraftDiagnostic } from "../domain/draft-workspace-models.js";
export type DiagnosticEntry = {
readonly diagnostic: DraftDiagnostic;
readonly key: string;
};
const diagnosticIdentity = (diagnostic: DraftDiagnostic): string =>
JSON.stringify([
diagnostic.code,
diagnostic.path,
diagnostic.message,
diagnostic.stepId,
diagnostic.repairHint,
diagnostic.details,
]);
export const withDiagnosticKeys = (
diagnostics: ReadonlyArray<DraftDiagnostic>,
): ReadonlyArray<DiagnosticEntry> => {
const occurrenceByIdentity = new Map<string, number>();
const entries: DiagnosticEntry[] = [];
for (const diagnostic of diagnostics) {
const identity = diagnosticIdentity(diagnostic);
const occurrence = occurrenceByIdentity.get(identity) ?? 0;
occurrenceByIdentity.set(identity, occurrence + 1);
entries.push({
diagnostic,
key: `diagnostic-${identity}-${occurrence}`,
});
}
return entries;
};
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { formatBoundedJson } from "./format-bounded-json.js";
describe("formatBoundedJson", () => {
it("bounds traversal without reading remote fields after the budget", () => {
const draft = {
first: "x".repeat(1_000),
get later() {
throw new Error("later field should not be read");
},
};
expect(() => formatBoundedJson(draft, 80)).not.toThrow();
expect(formatBoundedJson(draft, 80)).toHaveLength(80);
expect(formatBoundedJson(draft, 80)).toContain("truncated");
});
it("keeps an exact-fit JSON document complete and valid", () => {
const draft = { step: "collect", count: 2 };
const completeJson = JSON.stringify(draft, null, 2);
expect(formatBoundedJson(draft, completeJson.length)).toBe(completeJson);
expect(JSON.parse(formatBoundedJson(draft, completeJson.length))).toEqual(draft);
});
it("does not truncate a complete document just below the boundary", () => {
const draft = { step: "collect", count: 2 };
const completeJson = JSON.stringify(draft, null, 2);
expect(formatBoundedJson(draft, completeJson.length + 1)).toBe(completeJson);
expect(formatBoundedJson(draft, completeJson.length - 1)).toContain("truncated");
});
});
@@ -0,0 +1,101 @@
const MAX_RAW_DRAFT_CHARS = 12_000;
const TRUNCATION_MARKER = "... truncated ...";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
// Traverse until the display budget is exhausted so a large remote object is
// never fully materialized just to produce a clipped escape-hatch preview.
export const formatBoundedJson = (
value: unknown,
maxChars = MAX_RAW_DRAFT_CHARS,
): string => {
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
const contentLimit = Math.max(0, maxChars);
let output = "";
let truncated = false;
const activeObjects = new WeakSet<object>();
const append = (chunk: string): void => {
if (truncated) return;
if (output.length + chunk.length > contentLimit) {
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
truncated = true;
return;
}
output += chunk;
};
const appendJsonString = (text: string): void => {
append('"');
for (let index = 0; index < text.length; index++) {
if (truncated) return;
const code = text.charCodeAt(index);
if (code === 0x22) append('\\"');
else if (code === 0x5c) append("\\\\");
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
else if (code >= 0xd800 && code <= 0xdbff) {
const nextCode = text.charCodeAt(index + 1);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
append(text.slice(index, index + 2));
index++;
} else append(`\\u${code.toString(16).padStart(4, "0")}`);
} else if (code >= 0xdc00 && code <= 0xdfff) {
append(`\\u${code.toString(16).padStart(4, "0")}`);
} else append(text.charAt(index));
}
if (!truncated) append('"');
};
const visit = (current: unknown, depth: number): void => {
if (truncated) return;
if (current === null || typeof current !== "object") {
if (typeof current === "string") appendJsonString(current);
else if (typeof current === "number") append(Number.isFinite(current) ? String(current) : "null");
else if (typeof current === "boolean") append(current ? "true" : "false");
else append("null");
return;
}
if (activeObjects.has(current)) {
append('"[Circular]"');
return;
}
activeObjects.add(current);
const indent = " ".repeat(depth);
const childIndent = " ".repeat(depth + 1);
if (Array.isArray(current)) {
append("[");
let first = true;
for (const item of current) {
if (truncated) break;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
visit(item, depth + 1);
first = false;
}
if (!truncated) append(first ? "]" : `\n${indent}]`);
} else {
if (!isRecord(current)) {
activeObjects.delete(current);
return;
}
const record = current;
append("{");
let first = true;
for (const key in record) {
if (!Object.prototype.hasOwnProperty.call(record, key) || truncated) continue;
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
appendJsonString(key);
append(": ");
visit(record[key], depth + 1);
first = false;
}
if (!truncated) append(first ? "}" : `\n${indent}}`);
}
activeObjects.delete(current);
};
visit(value, 0);
if (!truncated) return output;
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
return `${output.slice(0, markerStart)}${truncationMarker}`;
};
@@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
import { DraftDetailRoute, formatBoundedJson } from "./DraftDetailRoute.js";
import { DraftDetailRoute } from "./DraftDetailRoute.js";
import { formatBoundedJson } from "../authoring/format-bounded-json.js";
const globalStyles = readFileSync(
"src/styles/global.css",
@@ -117,35 +118,6 @@ describe("DraftDetailRoute", () => {
expect(rawJson).toHaveAttribute("tabindex", "0");
});
it("bounds JSON traversal without reading remote fields after the budget", () => {
const draft = {
first: "x".repeat(1_000),
get later() {
throw new Error("later field should not be read");
},
};
expect(() => formatBoundedJson(draft, 80)).not.toThrow();
expect(formatBoundedJson(draft, 80)).toHaveLength(80);
expect(formatBoundedJson(draft, 80)).toContain("truncated");
});
it("keeps an exact-fit JSON document complete and valid", () => {
const draft = { step: "collect", count: 2 };
const completeJson = JSON.stringify(draft, null, 2);
expect(formatBoundedJson(draft, completeJson.length)).toBe(completeJson);
expect(JSON.parse(formatBoundedJson(draft, completeJson.length))).toEqual(draft);
});
it("does not truncate a complete document just below the boundary", () => {
const draft = { step: "collect", count: 2 };
const completeJson = JSON.stringify(draft, null, 2);
expect(formatBoundedJson(draft, completeJson.length + 1)).toBe(completeJson);
expect(formatBoundedJson(draft, completeJson.length - 1)).toContain("truncated");
});
it("does not render a selected workspace whose identity differs from the URL", () => {
mockedUseDraftWorkspace.mockReturnValue(
controller({ selected: workspace({ workspaceId: "draft-old" }) }),
@@ -3,7 +3,6 @@ import type {
DraftWorkspace,
} from "../domain/draft-workspace-models.js";
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
export { formatBoundedJson } from "../authoring/ContextInspector.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
const titleFor = (workspace: DraftWorkspace): string =>