practice: the chain table — every reasoning chain in one selectable overlay (owner-approved plan, 2026-09-29)
- core formatChainLine: names + arrow one-liner, singleton marker, pure
- graph-view Chains button (chrome row, empty-graph hidden) opens the
overlay: fuzzy search (names/aliases/statements), collapsible chain →
member → statement rows, birth-session + created columns, no
understanding-levels column
- pinned Selected section derives the agents' actual input live
(chainsForSelection over the selection; premise without its
conclusion shows as the singleton it becomes)
- Generate opens the creation dialog in CUSTOM mode: selection summary
+ dated default title, posts {nodeIds, params, note}; cluster mode
untouched on /practice
- live-verified on the owner's dev server: 219 chains, derived preview,
search-never-filters-Selected, dialog handoff (selection pass-through
bug caught + fixed en route), Esc/backdrop dismiss, zero writes
- practice UI files re-normalized to tabs (my earlier prettier runs had
drifted them to the 2-space default; whitespace-only)
17 render/unit pins added; gates: tsc 0, 1174/1174, lint 0, build 0
This commit is contained in:
+22
-1
@@ -9,7 +9,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { EdgeType } from "../src/core/contracts.ts";
|
||||
import { chainCoverage, chainsForSelection, chainsToGraph } from "../src/core/practice.ts";
|
||||
import { chainCoverage, chainsForSelection, chainsToGraph, formatChainLine } from "../src/core/practice.ts";
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -138,3 +138,24 @@ describe("chainCoverage", () => {
|
||||
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChainLine (the chain table's one-line shape, 2026-09-29)", () => {
|
||||
const props = {
|
||||
p1: { name: "Premise one" },
|
||||
p2: { name: "Premise two" },
|
||||
c1: { name: "Conclusion" },
|
||||
solo: { name: "Standalone" },
|
||||
};
|
||||
|
||||
it("joins sorted premise NAMES by the derivation arrow", () => {
|
||||
assert.equal(formatChainLine({ premises: ["p2", "p1"], conclusion: "c1" }, props), "Premise one, Premise two → Conclusion");
|
||||
});
|
||||
|
||||
it("a singleton shows its (no premises) marker", () => {
|
||||
assert.equal(formatChainLine({ premises: [], conclusion: "solo" }, props), "Standalone (no premises)");
|
||||
});
|
||||
|
||||
it("unknown ids fall back to the raw id (honest, never guessed)", () => {
|
||||
assert.equal(formatChainLine({ premises: ["ghost"], conclusion: "c1" }, props), "ghost → Conclusion");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* THE CHAIN TABLE — SSR render pins (the render-suite discipline: pure
|
||||
* bodies, renderToStaticMarkup, testids). The chains overlay (owner-
|
||||
* approved plan, 2026-09-29): every reasoning chain in one selectable
|
||||
* table; the pinned Selected section derives N propositions → M chains
|
||||
* via chainsForSelection over the selection (the agents' ACTUAL input);
|
||||
* fuzzy search filters the list only; the practice dialog opens in
|
||||
* CUSTOM mode from the Generate button.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import { ChainTableBody } from "../components/chain-table.tsx";
|
||||
import type { GraphEdge, GraphNode } from "../src/core/contracts.ts";
|
||||
|
||||
/** The fixture graph — two follows-chains + one standalone:
|
||||
* a → c1, b → c1 (chain one), d → e (chain two), solo (singleton). */
|
||||
function node(id: string, name: string, statement: string, chatId: string | null): GraphNode {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
statement,
|
||||
aliases: [],
|
||||
state: "new",
|
||||
createdAt: "2026-09-20T10:00:00.000Z",
|
||||
provenance: chatId === null ? [] : [{ chatId, messageId: `m_${id}` }],
|
||||
} as unknown as GraphNode;
|
||||
}
|
||||
|
||||
const NODES: GraphNode[] = [
|
||||
node("a", "Premise one", "Statement A.", "chat1"),
|
||||
node("b", "Premise two", "Statement B.", "chat2"),
|
||||
node("c1", "Conclusion", "Statement C.", "chat1"),
|
||||
node("d", "Support", "Statement D.", null),
|
||||
node("e", "Follow-up", "Statement E with keyword zebra.", "notes1"),
|
||||
node("solo", "Standalone", "Statement S.", "chat1"),
|
||||
];
|
||||
|
||||
const EDGES: GraphEdge[] = [
|
||||
{ from: "a", to: "c1", type: "follows" },
|
||||
{ from: "b", to: "c1", type: "follows" },
|
||||
{ from: "d", to: "e", type: "follows" },
|
||||
] as GraphEdge[];
|
||||
|
||||
const SESSIONS = { chat1: "Ethics lesson", chat2: "Second chat" };
|
||||
const FILE_SOURCES = { notes1: "notes/raw/ethics.md" };
|
||||
|
||||
const BASE = {
|
||||
nodes: NODES,
|
||||
edges: EDGES,
|
||||
sessions: SESSIONS,
|
||||
fileSources: FILE_SOURCES,
|
||||
query: "",
|
||||
onQuery: () => {},
|
||||
selection: new Set<string>(),
|
||||
onToggleNode: () => {},
|
||||
onToggleChain: () => {},
|
||||
onClear: () => {},
|
||||
onGenerate: () => {},
|
||||
onClose: () => {},
|
||||
};
|
||||
|
||||
function render(props: Partial<typeof BASE> = {}) {
|
||||
return renderToStaticMarkup(createElement(ChainTableBody, { ...BASE, ...props }));
|
||||
}
|
||||
|
||||
describe("ChainTableBody — the chain rows", () => {
|
||||
it("renders every chain as a formatted name line, with birth + created columns", () => {
|
||||
const html = render();
|
||||
// chain ids are the conclusion ids (chainsForSelection's law)
|
||||
assert.ok(html.includes('data-testid="chain-row-c1"'), "chain row for c1");
|
||||
assert.ok(html.includes('data-testid="chain-row-e"'), "chain row for e");
|
||||
assert.ok(html.includes('data-testid="chain-row-solo"'), "the singleton renders as its own chain");
|
||||
assert.ok(html.includes("Premise one, Premise two → Conclusion"), "the two-premise line");
|
||||
assert.ok(html.includes("Support → Follow-up"), "the one-premise line");
|
||||
assert.ok(html.includes("Standalone (no premises)"), "the singleton marker");
|
||||
// birth labels: the union of member labels, honest "unlabeled" included
|
||||
assert.ok(html.includes("Ethics lesson · Second chat"), "chain one spans two labeled sessions");
|
||||
assert.ok(html.includes("notes/raw/ethics.md"), "a notes birth shows the file path");
|
||||
assert.ok(html.includes("unlabeled"), "an unnameable birth is honest about it");
|
||||
// created = the conclusion's createdAt (the practice-list date shape)
|
||||
assert.ok(html.includes('data-testid="chain-date-c1"'));
|
||||
assert.ok(html.includes("2026-09-20"), "the date column renders");
|
||||
});
|
||||
|
||||
it("the first chain row expands by default to member proposition rows; closed members keep their statements unmounted", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes('data-testid="prop-row-a"'), "premise row a");
|
||||
assert.ok(html.includes('data-testid="prop-row-c1"'), "conclusion row");
|
||||
// member statements expand on click — Radix unmounts closed
|
||||
// content, so SSR honestly does not contain them
|
||||
assert.ok(!html.includes("Statement A."), "statements wait for their click");
|
||||
});
|
||||
|
||||
it("every chain has a checkbox; expanded chains expose member checkboxes", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes('data-testid="chain-checkbox-c1"'));
|
||||
assert.ok(html.includes('data-testid="chain-checkbox-e"'));
|
||||
assert.ok(html.includes('data-testid="chain-checkbox-solo"'));
|
||||
assert.ok(html.includes('data-testid="prop-checkbox-a"'), "inside the default-open chain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainTableBody — the pinned Selected section", () => {
|
||||
it("empty selection: guidance, no summary, no generate enablement pins", () => {
|
||||
const html = render();
|
||||
assert.ok(html.includes("Nothing selected"));
|
||||
assert.ok(!html.includes('data-testid="chain-selected-summary"'));
|
||||
});
|
||||
|
||||
it("a full chain selection: N propositions → M chains + derived line", () => {
|
||||
const html = render({ selection: new Set(["a", "b", "c1"]) });
|
||||
assert.ok(html.includes('data-testid="chain-selected-summary"'));
|
||||
assert.ok(html.includes("3 propositions → 1 chain"), "the derived shape, exactly");
|
||||
assert.ok(html.includes("Premise one, Premise two → Conclusion"), "the derived chain line");
|
||||
});
|
||||
|
||||
it("a premise WITHOUT its conclusion derives as the singleton it becomes", () => {
|
||||
const html = render({ selection: new Set(["a", "d"]) });
|
||||
assert.ok(html.includes("2 propositions → 2 chains"), "no follows-edge survives inside the selection");
|
||||
assert.ok(html.includes("Premise one (no premises)"), "regrouped singleton a");
|
||||
assert.ok(html.includes("Support (no premises)"), "regrouped singleton d");
|
||||
});
|
||||
|
||||
it("a node in several chains is ONE flat selection wherever it appears", () => {
|
||||
// e is conclusion of d→e; picking e alone = 1 proposition, 1 singleton chain
|
||||
const html = render({ selection: new Set(["e"]) });
|
||||
assert.ok(html.includes("1 proposition → 1 chain"));
|
||||
assert.ok(html.includes("Follow-up (no premises)"));
|
||||
});
|
||||
|
||||
it("Clear + Generate problem set render in the pinned section", () => {
|
||||
const html = render({ selection: new Set(["a"]) });
|
||||
assert.ok(html.includes('data-testid="chain-clear"'));
|
||||
assert.ok(html.includes('data-testid="chain-generate"'));
|
||||
assert.ok(html.includes("Generate problem set"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChainTableBody — the fuzzy search (list only, never Selected)", () => {
|
||||
it("filters chains by member name / statement / alias; the pinned selection survives", () => {
|
||||
const aliased = NODES.map((n) =>
|
||||
n.id === "b" ? { ...n, aliases: ["zebra alias"] } : n,
|
||||
);
|
||||
const html = render({
|
||||
nodes: aliased,
|
||||
query: "zebra",
|
||||
selection: new Set(["a", "b", "c1"]), // selection spans BOTH chains
|
||||
});
|
||||
// zebra hits: chain e (statement) + chain c1 (alias on b)
|
||||
assert.ok(html.includes('data-testid="chain-row-c1"'), "alias match keeps the chain");
|
||||
assert.ok(html.includes('data-testid="chain-row-e"'), "statement match keeps the chain");
|
||||
assert.ok(!html.includes('data-testid="chain-row-solo"'), "non-matching chain hidden");
|
||||
// the pinned section is never filtered — it still shows the full derived line
|
||||
assert.ok(html.includes("3 propositions → 1 chain"));
|
||||
assert.ok(html.includes("Premise one, Premise two → Conclusion"));
|
||||
});
|
||||
|
||||
it("no matches: the honest empty line", () => {
|
||||
const html = render({ query: "qqqq" });
|
||||
assert.ok(html.includes('data-testid="chain-table-nomatch"'));
|
||||
assert.ok(html.includes("No matching chains."));
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const PROPS = {
|
||||
onImportFiles: () => {},
|
||||
settings: {} as GraphRenderSettings,
|
||||
changeSettings: () => {},
|
||||
onOpenChains: () => {},
|
||||
};
|
||||
|
||||
describe("GraphChromeRow — the empty-graph ruling (owner, 2026-09-25)", () => {
|
||||
@@ -42,16 +43,18 @@ describe("GraphChromeRow — the empty-graph ruling (owner, 2026-09-25)", () =>
|
||||
// the settings gear is the lucide-settings svg (tooltip wave: its old
|
||||
// native title is gone — the styled tooltip carries the label now)
|
||||
assert.ok(!html.includes("lucide-settings"), "settings is a lie on an empty graph");
|
||||
assert.ok(!html.includes('data-testid="open-chain-table"'), "chains is a lie on an empty graph");
|
||||
assert.ok(!html.includes("graph-chrome-fade-in"), "nothing gated = nothing fading in");
|
||||
});
|
||||
|
||||
it("POPULATED graph: all three render, the gated pair carries the fade", () => {
|
||||
it("POPULATED graph: all four render, the gated trio carries the fade", () => {
|
||||
const html = renderToStaticMarkup(createElement(GraphChromeRow, { ...PROPS, isEmpty: false }));
|
||||
assert.ok(html.includes('data-testid="import-notes"'));
|
||||
assert.ok(html.includes("Search nodes…"));
|
||||
assert.ok(html.includes("lucide-settings"), "the settings gear renders");
|
||||
// the appear-once fade rides exactly the two gated controls, never Import Notes
|
||||
assert.equal(html.split("graph-chrome-fade-in").length - 1, 2, "two fade carriers: search + settings");
|
||||
assert.ok(html.includes('data-testid="open-chain-table"'), "the chains entry renders");
|
||||
// the appear-once fade rides exactly the three gated controls, never Import Notes
|
||||
assert.equal(html.split("graph-chrome-fade-in").length - 1, 3, "three fade carriers: search + chains + settings");
|
||||
});
|
||||
|
||||
it("the no-match note renders only when the wrapper says so (its truth stays upstream)", () => {
|
||||
|
||||
@@ -189,6 +189,49 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PracticeDialogBody — creation (CUSTOM mode, the chains overlay's handoff)", () => {
|
||||
const base = {
|
||||
params: { ...DEFAULT_PRACTICE_PARAMS },
|
||||
onParam: () => {},
|
||||
note: "",
|
||||
onNote: () => {},
|
||||
clusters: clusters(),
|
||||
selectedKey: null,
|
||||
onSelect: () => {},
|
||||
busy: false,
|
||||
failure: null as PracticeFailure | null,
|
||||
onOpenSettings: () => {},
|
||||
onClose: () => {},
|
||||
onRun: () => {},
|
||||
};
|
||||
|
||||
it("no cluster picker — the selection summary + dated default title instead", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PracticeDialogBody
|
||||
{...base}
|
||||
mode="custom"
|
||||
selection={{ nodeIds: ["n1", "n2", "n3", "n4"], propositions: 4, chains: 2 }}
|
||||
/>,
|
||||
);
|
||||
assert.ok(!html.includes('data-testid="practice-dialog-clusters"'), "custom mode has no picker");
|
||||
assert.ok(html.includes('data-testid="practice-dialog-selection"'));
|
||||
assert.match(html, /Practice — (<!-- -->)?\d{4}-\d{2}-\d{2}/, "the dated default title (the pipeline's own stamp)");
|
||||
assert.ok(html.includes('data-testid="practice-dialog-selection-line"'));
|
||||
assert.ok(html.includes("4 propositions"));
|
||||
assert.ok(html.includes("2 chains"));
|
||||
assert.ok(!html.includes('disabled=""'), "a non-empty selection enables Create immediately");
|
||||
});
|
||||
|
||||
it("dials + note still ride along — the params/note laws are mode-independent", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PracticeDialogBody {...base} mode="custom" selection={{ nodeIds: ["n1"], propositions: 1, chains: 1 }} />,
|
||||
);
|
||||
assert.ok(html.includes('data-testid="param-ladder"'));
|
||||
assert.ok(html.includes('data-testid="practice-note"'));
|
||||
assert.ok(html.includes("1 proposition →"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the list page ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("PracticeListBody — the set list", () => {
|
||||
|
||||
Reference in New Issue
Block a user