Files
learnharness/tests/render-practice.test.tsx
T
amosblomqvist f39e1b4294 practice fix — creation is graph-domain: cluster picker, nodeIds-only API (owner ruling 2026-09-29)
Owner ruling: 'problem sets always take in nodes — graph objects' —
practice is graph-domain, not chat-domain. No session-created sets.

API: POST /api/practice/sets takes {nodeIds, params, title?} ONLY —
the chatId branch, UnknownChatError, and the any-provenance filter are
deleted; the selector is nodeIds (readNodes filtered to the selection,
unknown ids drop, empty live intersection → 400 empty_selection);
title optional from the UI (the picker's cluster label), dated default
stays. chatStore leaves the pipeline deps entirely (labels resolve
store-side in the clusters service).

GET /api/graph/clusters (new): the birth-session groups the graph view
renders — graphSnapshot's clusters verbatim (same ordering, same
MIN_CLUSTER_SIZE presentation filter), mapped to {key, kind
chat|notes, label (null when the naming record is gone — honest,
never guessed), nodeIds, nodeCount}; corrupt/empty graph → empty
list. GraphClusterGroup contract in core/contracts/graph.ts (client-
safe — the dialog imports it).

practice-dialog: session picker → cluster picker (rows show label +
proposition count, notes clusters marked); Create posts the cluster's
nodeIds + label as title + params. 400 empty-selection copy reworded
for the vanished-nodes edge ('Nothing practicable is left in that
cluster — the graph changed.'); 503 deep link unchanged. Preselected
mode gone — the dialog is the only creation entry for now (the graph-
view cluster entry comes later with free-form selection).

Sidebar: session right-click 'Practice this session's propositions'
removed (item + state + dialog wiring); the /practice nav row stays.

Tests: pipeline — chatId/any-provenance tests die, nodeIds selection
pins added (unknown-id filtering, dated-default title, title hint
rides, empty/vanished → EmptySelectionError); render — cluster picker
pins (label+count rows, notes kind, unlabeled cluster muted, empty
graph line), preselected pins removed; 16 render + 13 pipeline.

Docs: architecture.md §7 API+UI paragraph rewritten (graph-domain
ruling, /api/graph/clusters, cluster picker as the only entry, no
404-unknown-chat); README creation-flow line updated.

1130/1130, tsc 0, lint 0, build 0.
2026-09-29 19:26:37 +03:00

311 lines
13 KiB
TypeScript

import React from "react";
(globalThis as Record<string, unknown>).React = React;
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
DeleteSetBody,
PracticeListBody,
setStatusWord,
} from "../components/practice-list.tsx";
import {
PRACTICE_DIALS,
PracticeDialogBody,
type PracticeFailure,
} from "../components/practice-dialog.tsx";
import type { GraphClusterGroup } from "../src/core";
import { PracticeExamBody } from "../components/practice-exam.tsx";
import type { Problem, ProblemSet } from "../src/core";
/**
* The practice UI surfaces (slice 5, 2026-09-29; cluster-picker fix the
* same evening — graph-domain, not chat-domain): creation dialog (dials
* + cluster picker), list page (rows, delete flow),
* exam page states (generating stubs — propositions never surface —
* ready + answerable, graded reveal + lock). The pure bodies are pinned
* here (the render-suite discipline); the live fetch chains (polling,
* dictation, submit) are Playwright's beat.
*/
// ─── fixtures ────────────────────────────────────────────────────────────────
const PARAMS = { searchSolutionRatio: 0.5, cueDistance: 0.5, presentation: 0.5 };
const dialValues = {
searchSolutionRatio: 0.7,
cueDistance: 0.5,
presentation: 0.3,
};
function clusters(): GraphClusterGroup[] {
return [
{ key: "chat_1", kind: "chat", label: "Ethics lesson", nodeIds: ["n1", "n2", "n3", "n4"], nodeCount: 4 },
{ key: "src_ab12", kind: "notes", label: "~/notes/physics.md", nodeIds: ["n5", "n6", "n7"], nodeCount: 3 },
{ key: "chat_9", kind: "chat", label: null, nodeIds: ["n8", "n9", "n10"], nodeCount: 3 },
];
}
function stubProblem(id: string, chains: string[]): Problem {
return { id, chains, statement: "", solution: "", rubric: "", status: "generating" };
}
function readyProblem(id: string, chains: string[], over: Partial<Problem> = {}): Problem {
return {
id,
chains,
statement: `Why does arguing presuppose **self-ownership**?`,
solution: "The model solution.",
rubric: "- names self-ownership",
status: "ready",
...over,
};
}
function set(over: Partial<ProblemSet> = {}): ProblemSet {
return {
id: "ps_1",
title: "Ethics lesson",
createdAt: "2026-09-29T12:00:00.000Z",
params: PARAMS,
nodeIds: ["n1", "n3"],
propositions: {
n1: { name: "Self-ownership", statement: "Each person owns themself." },
n3: { name: "Argumentation", statement: "Arguing presupposes self-ownership." },
},
chains: [
{ id: "n3", premises: ["n1"], conclusion: "n3" },
],
status: "ready",
problems: [],
...over,
};
}
// ─── the creation dialog ─────────────────────────────────────────────────────
describe("PracticeDialogBody — creation (cluster picker)", () => {
const base = {
values: dialValues,
onDial: () => {},
clusters: clusters(),
selectedKey: null,
onSelect: () => {},
busy: false,
failure: null as PracticeFailure | null,
onOpenSettings: () => {},
onClose: () => {},
onRun: () => {},
};
it("renders the three dials with anchor labels", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
for (const dial of PRACTICE_DIALS) {
assert.ok(html.includes(`data-testid="dial-${dial.key}"`), `the ${dial.key} dial`);
assert.ok(html.includes(dial.anchors), `the ${dial.key} anchors`);
assert.ok(html.includes(dial.label));
}
});
it("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
assert.ok(html.includes('data-testid="practice-dialog-clusters"'));
assert.ok(html.includes('data-testid="practice-cluster-chat_1"'));
assert.ok(html.includes("Ethics lesson"));
assert.match(html, /notes · <!-- -->3 propositions|notes · 3 propositions/);
assert.ok(html.includes("4 propositions"), "chat cluster shows its count");
assert.ok(html.includes('disabled=""'), "no cluster picked — Create disabled");
const picked = renderToStaticMarkup(<PracticeDialogBody {...base} selectedKey="src_ab12" />);
assert.ok(picked.includes("~/notes/physics.md"));
});
it("an unlabeled cluster (naming record gone) renders muted, never guessed", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
assert.ok(html.includes("unlabeled cluster"));
assert.ok(html.includes('data-testid="practice-cluster-chat_9"'));
});
it("an empty graph is an honest empty list", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} clusters={[]} />);
assert.ok(html.includes("No clusters yet"));
});
it("empty selection (vanished nodes) renders the honest inline line", () => {
const html = renderToStaticMarkup(
<PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 400, error: "selection has no propositions to practice", code: "empty_selection" }} />,
);
assert.ok(html.includes('data-testid="practice-dialog-error"'));
assert.ok(html.includes("Nothing practicable is left in that cluster"));
});
it("503 renders the server's line + the settings deep link", () => {
const html = renderToStaticMarkup(
<PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 503, error: "No model configured — set up your models first", code: "models_not_configured" }} />,
);
assert.ok(html.includes("No model configured"));
assert.ok(html.includes('data-testid="practice-dialog-open-settings"'));
});
});
// ─── the list page ───────────────────────────────────────────────────────────
describe("PracticeListBody — the set list", () => {
it("rows: title, date, counts, status word; the delete affordance", () => {
const s = set({
status: "generating",
problems: [readyProblem("pp_1", ["n3"]), stubProblem("pp_2", ["n1"])],
});
const html = renderToStaticMarkup(
<PracticeListBody sets={[s]} onOpen={() => {}} onDelete={() => {}} onNew={() => {}} />,
);
assert.ok(html.includes('data-testid="practice-row-ps_1"'));
assert.ok(html.includes("Ethics lesson"));
assert.ok(html.includes("2026-09-29"));
assert.ok(html.includes("1/2 problems"));
assert.ok(html.includes('data-testid="practice-status-ps_1"'));
assert.ok(html.includes("generating"));
assert.ok(html.includes('data-testid="practice-delete-ps_1"'));
assert.ok(html.includes('data-testid="practice-new"'));
});
it("graded count appears; empty list states it honestly", () => {
const s = set({
problems: [
readyProblem("pp_1", ["n3"], { grade: { verdict: "solid", feedback: "f", gradedAt: "2026-09-29T13:00:00.000Z" } }),
readyProblem("pp_2", ["n1"]),
],
});
const html = renderToStaticMarkup(
<PracticeListBody sets={[s]} onOpen={() => {}} onDelete={() => {}} onNew={() => {}} />,
);
assert.ok(html.includes("1 graded"));
const empty = renderToStaticMarkup(
<PracticeListBody sets={[]} onOpen={() => {}} onDelete={() => {}} onNew={() => {}} />,
);
assert.ok(empty.includes('data-testid="practice-empty"'));
assert.ok(empty.includes("No problem sets yet"));
});
it("setStatusWord covers the four states", () => {
assert.equal(setStatusWord({ status: "partitioning" }), "partitioning");
assert.equal(setStatusWord({ status: "generating" }), "generating");
assert.equal(setStatusWord({ status: "ready" }), "ready");
assert.equal(setStatusWord({ status: "error" }), "error");
});
});
describe("DeleteSetBody — the delete confirm", () => {
it("the title, the plain-consequence copy, destructive Delete", () => {
const html = renderToStaticMarkup(
<DeleteSetBody title="Ethics lesson" busy={false} failure={null} onClose={() => {}} onRun={() => {}} />,
);
assert.ok(html.includes('data-testid="delete-set-dialog"'));
assert.ok(html.includes("Delete problem set?"));
assert.ok(html.includes("Ethics lesson"));
assert.ok(html.includes("Nothing in the graph or your sessions changes."));
assert.ok(html.includes('data-testid="delete-set-run"'));
});
it("failures render inline", () => {
const html = renderToStaticMarkup(
<DeleteSetBody title="X" busy={false} failure="Delete failed (409)" onClose={() => {}} onRun={() => {}} />,
);
assert.ok(html.includes('data-testid="delete-set-error"'));
assert.ok(html.includes("Delete failed (409)"));
});
});
// ─── the exam page ───────────────────────────────────────────────────────────
describe("PracticeExamBody — exam states", () => {
const examBase = {
drafts: {} as Record<string, string>,
onDraft: () => {},
onSubmit: () => {},
submitting: false,
submitError: null as string | null,
grading: false,
};
it("generating: numbered stubs with the placeholder — propositions NEVER surface", () => {
const s = set({
status: "generating",
problems: [stubProblem("pp_1", ["n3"]), stubProblem("pp_2", ["n1"])],
});
const html = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
assert.ok(html.includes("Problem 1"));
assert.ok(html.includes("Problem 2"));
assert.ok(html.includes('data-testid="generating-pp_1"'));
assert.ok(html.includes("Generating problem"));
// The never-announce law starts early: no proposition names/statements.
assert.ok(!html.includes("Self-ownership"));
assert.ok(!html.includes("Each person owns themself"));
assert.ok(!html.includes('data-testid="answer-pp_1"'), "no answer box for a stub");
// No submit while generating.
assert.ok(!html.includes('data-testid="practice-submit"'));
});
it("ready: the statement renders as markdown, the box is answerable, submit gated on a draft", () => {
const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
const gated = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
assert.ok(gated.includes("self-ownership")); // markdown strong rendered
assert.ok(gated.includes('data-testid="answer-pp_1"'));
assert.ok(gated.includes('data-testid="practice-submit"'));
assert.ok(gated.includes('disabled=""'), "empty drafts — submit disabled");
const filled = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} drafts={{ pp_1: "because…" }} />);
assert.ok(!filled.includes('data-testid="practice-submit-error"'));
});
it("graded: answer read-only, verdict chip + feedback + solution + rubric revealed", () => {
const s = set({
problems: [
readyProblem("pp_1", ["n3"], {
answer: "because arguing is intentional action",
grade: { verdict: "partial", feedback: "Half the target came to mind.", gradedAt: "2026-09-29T13:00:00.000Z" },
}),
readyProblem("pp_2", ["n1"]),
],
});
const html = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
// The locked answer.
assert.ok(html.includes("because arguing is intentional action"));
assert.ok(/readOnly/.test(html), "the graded answer is read-only");
// The reveal.
assert.ok(html.includes('data-testid="reveal-pp_1"'));
assert.ok(html.includes("partial"));
assert.ok(html.includes('data-testid="feedback-pp_1"'));
assert.ok(html.includes("Half the target came to mind."));
assert.ok(html.includes('data-testid="solution-pp_1"'));
assert.ok(html.includes("The model solution."));
assert.ok(html.includes('data-testid="rubric-pp_1"'));
assert.ok(html.includes("names self-ownership"));
// Ungraded sibling stays answerable.
assert.ok(html.includes('data-testid="answer-pp_2"'));
});
it("a failed problem says so; a failed set says so", () => {
const s = set({
status: "ready",
problems: [readyProblem("pp_1", ["n3"], { status: "error", error: "the problem generator never submitted a problem" })],
});
const html = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
assert.ok(html.includes('data-testid="problem-error-pp_1"'));
assert.ok(html.includes("This problem failed to generate"));
const dead = set({ status: "error", error: "Aborted", problems: [] });
const deadHtml = renderToStaticMarkup(<PracticeExamBody set={dead} {...examBase} />);
assert.ok(deadHtml.includes("Aborted"));
});
it("math healing rides the display seam (restoreMathEscapes at render)", () => {
// The streaming-args parse turns single-backslash LaTeX into control
// chars; the seam restores — the healed string must contain the slash.
const s = set({
problems: [readyProblem("pp_1", ["n3"], { statement: "Show that \\\\frac{a}{b} holds." })],
});
const html = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
assert.ok(html.includes("frac"), "the LaTeX survives to render");
});
});