delete the batch practice-problems module (superseded by practice sessions)
Owner ruling 2026-10-04: the module is a sunk cost, retrievable from history if ever revisited. Removed: core practice contracts + pipeline helpers, the PracticeStore port + adapter + data dir path, server service + API routes (sets, clusters picker, practice aborts), the practice pages + dialog/exam/list UI, chain-table's Generate handoff, sidebar nav, activity-dock practice rows, and the partitioner/ problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5). Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain, chainsForSelection, formatChainLine — the chain table and graph emission still consume them.
This commit is contained in:
@@ -1,404 +0,0 @@
|
||||
/**
|
||||
* THE PRACTICE AGENTS (slice 3, 2026-09-29): partitioner / problem
|
||||
* generator / grader on the shared runSubmitAgent seam — single-submit
|
||||
* workers whose LAWS (true partition; target echo) error back to the
|
||||
* model as tool results and retry. Faux-engine overrides, the same
|
||||
* seams as extraction.test.ts.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
createFauxCore,
|
||||
fauxAssistantMessage,
|
||||
fauxToolCall,
|
||||
type FauxResponseStep,
|
||||
type ToolCall,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import {
|
||||
GENERATOR_DOCTRINE,
|
||||
GRADER_INSTRUCTIONS,
|
||||
} from "../src/agent/subagents/practice.ts";
|
||||
import { createPracticeAgents, renderChains, renderParams, renderPreviousProblems } from "../src/agent/subagents/practice.ts";
|
||||
import { ModelsNotConfiguredError, ProviderKeyMissingError, type ProviderKeyMissing } from "../src/core/settings.ts";
|
||||
import type { PracticeParams, ReasoningChain } from "../src/core/contracts.ts";
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const propositions = {
|
||||
n1: { name: "Self-ownership", statement: "Each person owns themself." },
|
||||
n2: { name: "Homesteading", statement: "Unowned goods are claimed by first use." },
|
||||
n3: { name: "Argumentation", statement: "Arguing presupposes self-ownership." },
|
||||
};
|
||||
const chains: ReasoningChain[] = [
|
||||
{ id: "n3", premises: ["n1"], conclusion: "n3" },
|
||||
{ id: "n2", premises: [], conclusion: "n2" },
|
||||
];
|
||||
const params: PracticeParams = { ladderRung: 3, cueDistance: 2, presentation: "application" };
|
||||
|
||||
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
||||
const faux = createFauxCore({});
|
||||
faux.setResponses(steps);
|
||||
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
||||
}
|
||||
|
||||
/** The whole deliverable in ONE submit call; stops after the clean submit. */
|
||||
function submitModel(name: string, input: unknown): PiEngine {
|
||||
return fauxEngine([
|
||||
() =>
|
||||
fauxAssistantMessage([fauxToolCall(name, input as ToolCall["arguments"], { id: `${name}-call` })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A first (LAW-violating) submit, then the corrected one — the retry path. */
|
||||
function retryModel(name: string, bad: unknown, good: unknown): PiEngine {
|
||||
return fauxEngine([
|
||||
() =>
|
||||
fauxAssistantMessage([fauxToolCall(name, bad as ToolCall["arguments"], { id: `${name}-bad` })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
() =>
|
||||
fauxAssistantMessage([fauxToolCall(name, good as ToolCall["arguments"], { id: `${name}-good` })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/** An engine that never calls a tool (the failure path). */
|
||||
function silentModel(): PiEngine {
|
||||
return fauxEngine([fauxAssistantMessage("I refuse to use tools"), fauxAssistantMessage("…")]);
|
||||
}
|
||||
|
||||
/** A one-shot submitter that first records the LAST USER MESSAGE it saw —
|
||||
* for pinning what actually rode the prompt. */
|
||||
function capturingSubmitModel(name: string, input: unknown, seen: string[]): PiEngine {
|
||||
return fauxEngine([
|
||||
(context) => {
|
||||
const messages = (context as { messages: Array<{ role: string; content: unknown }> }).messages;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.role !== "user") continue;
|
||||
seen.push(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||
break;
|
||||
}
|
||||
return fauxAssistantMessage([fauxToolCall(name, input as ToolCall["arguments"], { id: `${name}-call` })], {
|
||||
stopReason: "toolUse",
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("renderParams — named levels, never a number", () => {
|
||||
it("renders the exact named-level block", () => {
|
||||
const out = renderParams(params);
|
||||
assert.equal(
|
||||
out,
|
||||
[
|
||||
"THE PARAMETERS",
|
||||
"Ladder rung 3 — Composition: several steps, chained; the path is narrow but must be held and run.",
|
||||
"Cue distance 2 — Far context: as far from the typical cue as recognition allows, the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.",
|
||||
'Presentation: Application — a concrete worldly affair — a case, episode, system, or story — in which the proposition\'s structure appears in the wild.',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering (the agent-facing text)", () => {
|
||||
it("renderChains: legend with names + statements, chain ids LEAD every line (the referenceable anchor)", () => {
|
||||
const text = renderChains(chains, propositions);
|
||||
assert.match(text, /\[n1\] Self-ownership — Each person owns themself\./);
|
||||
assert.match(text, /\[n2\] Homesteading — Unowned goods are claimed by first use\./);
|
||||
// The chain id precedes every line — the id the submit references.
|
||||
assert.match(text, /\[n3\] n1 → n3/);
|
||||
// A bare proposition: its own id, twice — the chain id IS the
|
||||
// proposition id (owner fix, 2026-09-30 evening).
|
||||
assert.match(text, /\[n2\] n2 \(no premises\)/);
|
||||
});
|
||||
|
||||
it("renderPreviousProblems: numbered statements + chains; empty = first-set line", () => {
|
||||
assert.equal(renderPreviousProblems([]), "None — this is the learner's first set.");
|
||||
assert.match(
|
||||
renderPreviousProblems([{ statement: "Why does arguing presuppose ownership?", chains: ["n3"] }]),
|
||||
/^1\. Why does arguing presuppose ownership\? \(chains: n3\)$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the partitioner ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("runPartitioner", () => {
|
||||
it("accepts a true partition and returns its step log", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
partitioner: submitModel("submit_partition", { partitions: [{ chains: ["n3"] }, { chains: ["n2"] }] }),
|
||||
});
|
||||
const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
|
||||
assert.deepEqual(out.partitions, [{ chains: ["n3"] }, { chains: ["n2"] }]);
|
||||
assert.ok(out.log.model);
|
||||
assert.equal(out.log.steps.length, 1);
|
||||
});
|
||||
|
||||
it("errors back and retries when a chain is missing (coverage)", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
partitioner: retryModel(
|
||||
"submit_partition",
|
||||
{ partitions: [{ chains: ["n3"] }] }, // n2 dropped
|
||||
{ partitions: [{ chains: ["n3", "n2"] }] },
|
||||
),
|
||||
});
|
||||
const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
|
||||
assert.deepEqual(out.partitions, [{ chains: ["n3", "n2"] }]);
|
||||
assert.equal(out.log.steps.length, 2); // the retry is billed
|
||||
});
|
||||
|
||||
it("errors back and retries on a doubled chain (exactly-once law)", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
partitioner: retryModel(
|
||||
"submit_partition",
|
||||
{ partitions: [{ chains: ["n3"] }, { chains: ["n3", "n2"] }] }, // n3 twice
|
||||
{ partitions: [{ chains: ["n3", "n2"] }] },
|
||||
),
|
||||
});
|
||||
const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
|
||||
assert.deepEqual(out.partitions, [{ chains: ["n3", "n2"] }]);
|
||||
});
|
||||
|
||||
it("errors back on unknown chain ids", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
partitioner: retryModel(
|
||||
"submit_partition",
|
||||
{ partitions: [{ chains: ["n3", "n2", "nX"] }] },
|
||||
{ partitions: [{ chains: ["n3", "n2"] }] },
|
||||
),
|
||||
});
|
||||
const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
|
||||
assert.deepEqual(out.partitions, [{ chains: ["n3", "n2"] }]);
|
||||
});
|
||||
|
||||
it("neverSubmitted: a model that refuses to submit throws", async () => {
|
||||
const agents = createPracticeAgents({ partitioner: silentModel() });
|
||||
await assert.rejects(
|
||||
agents.runPartitioner({ chains, propositions, params, previousProblems: [] }),
|
||||
/the partitioner never submitted a partition/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the learner's note: rides the prompt as its own block when present, absent when unset", async () => {
|
||||
const seen: string[] = [];
|
||||
const agents = createPracticeAgents({
|
||||
partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seen),
|
||||
});
|
||||
await agents.runPartitioner({
|
||||
chains,
|
||||
propositions,
|
||||
params,
|
||||
previousProblems: [],
|
||||
note: "one big problem, please — and lean on pricing",
|
||||
});
|
||||
assert.equal(seen.length, 1);
|
||||
assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"), "the block title");
|
||||
assert.ok(seen[0]!.includes("one big problem, please — and lean on pricing"), "the note text");
|
||||
|
||||
const seenBare: string[] = [];
|
||||
const bare = createPracticeAgents({
|
||||
partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seenBare),
|
||||
});
|
||||
await bare.runPartitioner({ chains, propositions, params, previousProblems: [] });
|
||||
assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the problem generator ───────────────────────────────────────────────────
|
||||
|
||||
describe("runProblemGenerator", () => {
|
||||
const generatorInput = {
|
||||
target: ["n3"],
|
||||
fullChains: chains,
|
||||
propositions,
|
||||
params,
|
||||
previousProblems: [],
|
||||
};
|
||||
|
||||
it("accepts a problem echoing its target verbatim", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
problemGenerator: submitModel("submit_problem", {
|
||||
chains: ["n3"],
|
||||
statement: "A critic of property rights starts arguing. What does his arguing already presuppose?",
|
||||
solution: "That he owns himself — argumentation presupposes self-ownership.",
|
||||
rubric: "- names self-ownership as the presupposition",
|
||||
}),
|
||||
});
|
||||
const out = await agents.runProblemGenerator(generatorInput);
|
||||
assert.deepEqual(out.problem.chains, ["n3"]);
|
||||
assert.ok(out.log.model);
|
||||
});
|
||||
|
||||
it("errors back and retries when chains do not echo the target (equality law)", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
problemGenerator: retryModel(
|
||||
"submit_problem",
|
||||
{
|
||||
chains: ["n3", "n2"], // invented scope — the target is n3 only
|
||||
statement: "…",
|
||||
solution: "…",
|
||||
rubric: "…",
|
||||
},
|
||||
{
|
||||
chains: ["n3"],
|
||||
statement: "…",
|
||||
solution: "…",
|
||||
rubric: "…",
|
||||
},
|
||||
),
|
||||
});
|
||||
const out = await agents.runProblemGenerator(generatorInput);
|
||||
assert.deepEqual(out.problem.chains, ["n3"]);
|
||||
assert.equal(out.log.steps.length, 2);
|
||||
});
|
||||
|
||||
it("neverSubmitted: a silent model throws", async () => {
|
||||
const agents = createPracticeAgents({ problemGenerator: silentModel() });
|
||||
await assert.rejects(agents.runProblemGenerator(generatorInput), /the problem generator never submitted a problem/);
|
||||
});
|
||||
|
||||
it("the learner's note: rides the prompt when present, absent when unset", async () => {
|
||||
const seen: string[] = [];
|
||||
const agents = createPracticeAgents({
|
||||
problemGenerator: capturingSubmitModel(
|
||||
"submit_problem",
|
||||
{ chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
|
||||
seen,
|
||||
),
|
||||
});
|
||||
await agents.runProblemGenerator({ ...generatorInput, note: "situations from central banking, if you can" });
|
||||
assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"));
|
||||
assert.ok(seen[0]!.includes("situations from central banking, if you can"));
|
||||
|
||||
const seenBare: string[] = [];
|
||||
const bare = createPracticeAgents({
|
||||
problemGenerator: capturingSubmitModel(
|
||||
"submit_problem",
|
||||
{ chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
|
||||
seenBare,
|
||||
),
|
||||
});
|
||||
await bare.runProblemGenerator(generatorInput);
|
||||
assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the grader ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("runGrader", () => {
|
||||
const graderInput = {
|
||||
problem: {
|
||||
statement: "What does arguing presuppose?",
|
||||
solution: "Self-ownership.",
|
||||
rubric: "- names self-ownership",
|
||||
},
|
||||
propositions: { n3: propositions.n3! },
|
||||
answer: "Well, if I'm arguing at all, I must own myself…",
|
||||
};
|
||||
|
||||
it("returns the score + reasoning with its step log", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
grader: submitModel("submit_grade", { score: 9, reasoning: "The recall happened." }),
|
||||
});
|
||||
const out = await agents.runGrader(graderInput);
|
||||
assert.equal(out.score, 9);
|
||||
assert.equal(out.reasoning, "The recall happened.");
|
||||
assert.ok(out.log.model);
|
||||
assert.equal(out.log.steps.length, 1);
|
||||
});
|
||||
|
||||
it("schema-rejects an out-of-range score and retries", async () => {
|
||||
const agents = createPracticeAgents({
|
||||
grader: retryModel(
|
||||
"submit_grade",
|
||||
{ score: 12, reasoning: "…" },
|
||||
{ score: 6, reasoning: "Half the target came to mind." },
|
||||
),
|
||||
});
|
||||
const out = await agents.runGrader(graderInput);
|
||||
assert.equal(out.score, 6);
|
||||
assert.equal(out.log.steps.length, 2);
|
||||
});
|
||||
|
||||
it("neverSubmitted: a silent model throws", async () => {
|
||||
const agents = createPracticeAgents({ grader: silentModel() });
|
||||
await assert.rejects(agents.runGrader(graderInput), /the grader never submitted a grade/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── refusal gates ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("refusal gates (BYOK honest refusal, extraction's pattern)", () => {
|
||||
it("throws ModelsNotConfiguredError when no engines resolve", () => {
|
||||
const agents = createPracticeAgents({});
|
||||
assert.throws(() => agents.refuseIfStagesUnready(), ModelsNotConfiguredError);
|
||||
});
|
||||
|
||||
it("throws ProviderKeyMissingError when a configured stage's provider key is gone", () => {
|
||||
const engine = { model: { provider: "prov", id: "m" } } as PiEngine;
|
||||
const refusal: ProviderKeyMissing = { provider: "prov", slots: ["strong"], error: "prov key missing", code: "provider_key_missing" };
|
||||
const agents = createPracticeAgents({
|
||||
engineFor: () => engine,
|
||||
providerKeyMissing: () => refusal,
|
||||
});
|
||||
assert.throws(() => agents.refuseIfStagesUnready(), ProviderKeyMissingError);
|
||||
});
|
||||
|
||||
it("passes when every stage has an engine and its key", () => {
|
||||
const engine = { model: { provider: "prov", id: "m" } } as PiEngine;
|
||||
const agents = createPracticeAgents({ engineFor: () => engine, providerKeyMissing: () => null });
|
||||
agents.refuseIfStagesUnready(); // no throw
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatting doctrine (owner-approved 2026-09-29) ─────────────────────────
|
||||
|
||||
describe("formatting doctrine pins", () => {
|
||||
it("GENERATOR_DOCTRINE pins the learner-note stance (weighed, never owed)", () => {
|
||||
assert.ok(
|
||||
GENERATOR_DOCTRINE.includes("- THE LEARNER'S NOTE (optional): free-form text the learner attached when creating the set"),
|
||||
);
|
||||
assert.ok(GENERATOR_DOCTRINE.includes("never let it move a problem off its rung or out of its band"));
|
||||
});
|
||||
|
||||
it("GENERATOR_DOCTRINE carries the FORMATTING bullet verbatim (Markdown + LaTeX + fenced code + tables/lists)", () => {
|
||||
assert.ok(
|
||||
GENERATOR_DOCTRINE.includes(
|
||||
"- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as \\(…\\), display as \\[…\\] on its own lines — a bare $ is literal currency and never starts math. Inside math, use LaTeX commands for symbols, not unicode stand-ins (they fail to render). Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as `code`. Use tables for parallel structure and comparisons, lists for enumerations. Skip headers — at this size they are pointless decoration; structure with paragraphs, bold labels, and lists (a genuinely long derivation may earn one). Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.",
|
||||
),
|
||||
"the exact FORMATTING sentence",
|
||||
);
|
||||
// it sits after the parameters bullet, before PREVIOUS PROBLEMS
|
||||
assert.ok(
|
||||
GENERATOR_DOCTRINE.indexOf("- FORMATTING:") > GENERATOR_DOCTRINE.indexOf("THE PRESENTATION:"),
|
||||
"after the presentation bullet",
|
||||
);
|
||||
assert.ok(
|
||||
GENERATOR_DOCTRINE.indexOf("- PREVIOUS PROBLEMS:") > GENERATOR_DOCTRINE.indexOf("- FORMATTING:"),
|
||||
"before PREVIOUS PROBLEMS",
|
||||
);
|
||||
});
|
||||
|
||||
it("GRADER_INSTRUCTIONS carries the formatting paragraph before the OUTPUT section", () => {
|
||||
assert.ok(
|
||||
GRADER_INSTRUCTIONS.includes(
|
||||
"The reasoning is plain prose, one or two sentences: which rubric points were met or missed and, when something failed, WHICH proposition failed to come to mind. Mathematics is LaTeX — inline as \\(…\\), display as \\[…\\] (a bare $ is literal currency); no headers, no lists — minimal is the standard.",
|
||||
),
|
||||
"the exact grader paragraph",
|
||||
);
|
||||
assert.ok(
|
||||
GRADER_INSTRUCTIONS.indexOf("Feedback renders as full Markdown") < GRADER_INSTRUCTIONS.indexOf("OUTPUT — your entire deliverable is ONE submit_grade call"),
|
||||
"before the OUTPUT section",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createPracticeSetResponse } from "../src/server/practice.ts";
|
||||
|
||||
/**
|
||||
* The practice create-route's parameter validation (owner, 2026-09-29):
|
||||
* the three axes are NAMED LEVELS — exact unions, nothing between. A
|
||||
* rung is a rung; a float between rungs, a wrong string, or a missing
|
||||
* field is a 400 BEFORE the pipeline is ever reached (these pins only
|
||||
* exercise the rejecting paths — the accepting path runs the real
|
||||
* pipeline and belongs to the pipeline suite).
|
||||
*/
|
||||
|
||||
const NODE_IDS = { nodeIds: ["n1", "n2", "n3"], title: "Ethics cluster" };
|
||||
|
||||
async function statusOf(params: unknown): Promise<number> {
|
||||
const res = await createPracticeSetResponse({ ...NODE_IDS, params });
|
||||
return res.status;
|
||||
}
|
||||
|
||||
describe("createPracticeSetResponse — strict named-level params", () => {
|
||||
it("floats between levels → 400", async () => {
|
||||
assert.equal(await statusOf({ ladderRung: 2.5, cueDistance: 1, presentation: "theory" }), 400);
|
||||
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1.5, presentation: "theory" }), 400);
|
||||
});
|
||||
|
||||
it("out-of-range integers → 400", async () => {
|
||||
assert.equal(await statusOf({ ladderRung: 5, cueDistance: 1, presentation: "theory" }), 400);
|
||||
assert.equal(await statusOf({ ladderRung: -1, cueDistance: 1, presentation: "theory" }), 400);
|
||||
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 3, presentation: "theory" }), 400);
|
||||
});
|
||||
|
||||
it("wrong presentation → 400", async () => {
|
||||
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1, presentation: "real-world" }), 400);
|
||||
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1, presentation: 1 }), 400);
|
||||
});
|
||||
|
||||
it("missing / legacy float fields → 400", async () => {
|
||||
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1 }), 400);
|
||||
assert.equal(await statusOf({ searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 }), 400, "legacy dials are dead");
|
||||
assert.equal(await statusOf("high"), 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPracticeSetResponse — the learner's note (owner, 2026-09-29)", () => {
|
||||
const PARAMS = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
|
||||
|
||||
it("non-string note → 400", async () => {
|
||||
const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: 42 });
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it("over 2000 characters → 400 (the API is the law; the textarea's maxLength mirrors it)", async () => {
|
||||
const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: "x".repeat(2001) });
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
assert.match(body.error, /over 2000 characters/);
|
||||
});
|
||||
|
||||
it("exactly 2000 passes the note check (boundary — the next check, not the note, refuses)", async () => {
|
||||
// Malformed nodeIds AFTER a max-length note: reaching the nodeIds
|
||||
// error proves the note check passed (all checks are rejecting-path;
|
||||
// the pipeline is never reached — this suite's doctrine).
|
||||
const res = await createPracticeSetResponse({ nodeIds: 5, params: PARAMS, note: "x".repeat(2000) });
|
||||
assert.equal(res.status, 400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
assert.match(body.error, /Body must be/);
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* PracticeStore (slice 2, 2026-09-29): the fs adapter for problem sets —
|
||||
* one JSON document per set in data/practice/ (a BRAND-NEW domain dir, so
|
||||
* no snapshot/restore dance: the whole dir is ours and gets removed in
|
||||
* after()). Round-trip, corrupt-tolerance, validator pins, id shape.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { after, afterEach, describe, it } from "node:test";
|
||||
import { PATHS } from "../config.ts";
|
||||
import { isProblemSet, practiceStore } from "../src/store/practice.ts";
|
||||
import type { ProblemSet } from "../src/core/contracts.ts";
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(PATHS.practice, { recursive: true, force: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(PATHS.practice, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const init = (over: Partial<Omit<ProblemSet, "id">> = {}): Omit<ProblemSet, "id"> => ({
|
||||
title: "Libertarianism — session practice",
|
||||
createdAt: "2026-09-29T12:00:00.000Z",
|
||||
params: { ladderRung: 2, cueDistance: 1, presentation: "theory" },
|
||||
nodeIds: ["n_a", "n_b", "n_c"],
|
||||
propositions: {
|
||||
n_a: { name: "Self-ownership", statement: "Each person owns themself." },
|
||||
n_b: { name: "Homesteading", statement: "Unowned goods are claimed by first use." },
|
||||
n_c: { name: "Argumentation", statement: "Arguing presupposes self-ownership." },
|
||||
},
|
||||
chains: [
|
||||
{ id: "n_c", premises: ["n_a"], conclusion: "n_c" },
|
||||
{ id: "n_b", premises: [], conclusion: "n_b" },
|
||||
],
|
||||
status: "partitioning",
|
||||
problems: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
// ─── round-trip ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("updatedAt — the live-run heartbeat", () => {
|
||||
it("createSet stamps updatedAt = createdAt; saveSet re-stamps on every write", () => {
|
||||
const set = practiceStore.createSet(init());
|
||||
assert.equal(set.updatedAt, set.createdAt, "birth stamp");
|
||||
const before = new Date().toISOString();
|
||||
const edited = { ...set, title: "Renamed" };
|
||||
practiceStore.saveSet(edited);
|
||||
const read = practiceStore.getSet(set.id)!
|
||||
assert.ok(read.updatedAt !== undefined && read.updatedAt >= before, "every write re-stamps");
|
||||
assert.notEqual(read.title, set.title);
|
||||
});
|
||||
|
||||
it("legacy rows without updatedAt still validate (readers fall back to createdAt)", () => {
|
||||
const set = practiceStore.createSet(init());
|
||||
const bare = { ...set } as Partial<typeof set>;
|
||||
delete bare.updatedAt;
|
||||
assert.ok(isProblemSet({ ...bare, id: set.id }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the practice store", () => {
|
||||
it("createSet assigns a ps_ id, mkdirs, and round-trips through getSet", () => {
|
||||
const set = practiceStore.createSet(init());
|
||||
assert.match(set.id, /^ps_[a-z0-9]+_[a-f0-9]{8}$/);
|
||||
assert.deepEqual(practiceStore.getSet(set.id), set);
|
||||
});
|
||||
|
||||
it("saveSet replaces whole — mutations persist", () => {
|
||||
const set = practiceStore.createSet(init({ status: "generating" }));
|
||||
set.problems.push({
|
||||
id: "pp_x",
|
||||
chains: ["n_c"],
|
||||
statement: "Why does arguing presuppose self-ownership?",
|
||||
solution: "Because…",
|
||||
rubric: "- names the performative contradiction",
|
||||
status: "ready",
|
||||
answer: "Because…",
|
||||
grade: { score: 9, reasoning: "Named it.", gradedAt: "2026-09-29T13:00:00.000Z" },
|
||||
});
|
||||
set.status = "ready";
|
||||
practiceStore.saveSet(set);
|
||||
const reread = practiceStore.getSet(set.id);
|
||||
assert.equal(reread?.status, "ready");
|
||||
assert.equal(reread?.problems[0]?.grade?.score, 9);
|
||||
});
|
||||
|
||||
it("listSets returns createdAt-descending and skips corrupt/unparseable files", () => {
|
||||
const older = practiceStore.createSet(init({ createdAt: "2026-09-28T00:00:00.000Z" }));
|
||||
const newer = practiceStore.createSet(init({ createdAt: "2026-09-29T00:00:00.000Z", title: "Newer" }));
|
||||
fs.writeFileSync(`${PATHS.practice}/ps_corrupt.json`, "{not json", "utf-8");
|
||||
fs.writeFileSync(
|
||||
`${PATHS.practice}/ps_wrongshape.json`,
|
||||
JSON.stringify({ id: "ps_wrongshape", title: 42 }),
|
||||
"utf-8",
|
||||
);
|
||||
const listed = practiceStore.listSets();
|
||||
assert.deepEqual(
|
||||
listed.map((s) => s.id),
|
||||
[newer.id, older.id],
|
||||
);
|
||||
assert.equal(practiceStore.getSet("ps_corrupt"), null);
|
||||
assert.equal(practiceStore.getSet("ps_wrongshape"), null);
|
||||
});
|
||||
|
||||
it("missing dir: listSets is [] and getSet is null, never a throw", () => {
|
||||
assert.deepEqual(practiceStore.listSets(), []);
|
||||
assert.equal(practiceStore.getSet("ps_missing"), null);
|
||||
});
|
||||
|
||||
it("deleteSet: true then false, file gone", () => {
|
||||
const set = practiceStore.createSet(init());
|
||||
assert.equal(practiceStore.deleteSet(set.id), true);
|
||||
assert.equal(practiceStore.getSet(set.id), null);
|
||||
assert.equal(practiceStore.deleteSet(set.id), false);
|
||||
});
|
||||
|
||||
it("saveSet refuses to write an invalid record (file integrity seam)", () => {
|
||||
const set = practiceStore.createSet(init());
|
||||
const bad = { ...set, status: "nonsense" } as unknown as ProblemSet;
|
||||
assert.throws(() => practiceStore.saveSet(bad));
|
||||
// the original survives untouched
|
||||
assert.equal(practiceStore.getSet(set.id)?.status, "partitioning");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validator pins ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("isProblemSet", () => {
|
||||
it("accepts the full shape, all statuses, empty problems", () => {
|
||||
for (const status of ["partitioning", "generating", "ready", "error"] as const) {
|
||||
assert.ok(isProblemSet({ ...init({ status }), id: "ps_valid" }));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects garbage", () => {
|
||||
assert.ok(!isProblemSet(null));
|
||||
assert.ok(!isProblemSet("set"));
|
||||
assert.ok(!isProblemSet({}));
|
||||
assert.ok(!isProblemSet({ ...init(), id: "xx_1" }));
|
||||
assert.ok(!isProblemSet({ ...init(), params: { searchSolutionRatio: "high" } }));
|
||||
// Legacy float-params sets fail the named-level unions and are
|
||||
// skipped from lists (owner, 2026-09-29: no migration — data/practice
|
||||
// is wiped before testing).
|
||||
assert.ok(!isProblemSet({ ...init(), id: "ps_ok", params: { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 } }));
|
||||
assert.ok(!isProblemSet({ ...init(), id: "ps_ok2", params: { ladderRung: 2.5, cueDistance: 1, presentation: "theory" } }), "no floats between rungs");
|
||||
assert.ok(!isProblemSet({ ...init(), chains: [{ id: "n_c", premises: "n_a", conclusion: "n_c" }] }));
|
||||
assert.ok(!isProblemSet({ ...init(), propositions: { n_a: { name: "x" } } }));
|
||||
assert.ok(!isProblemSet({ ...init(), status: "cooked" }));
|
||||
});
|
||||
|
||||
it("the learner's note: optional string, round-trips; non-string refused (owner, 2026-09-29)", () => {
|
||||
assert.ok(isProblemSet({ ...init({ note: "keep it geometric" }), id: "ps_noted" }));
|
||||
assert.ok(!isProblemSet({ ...init({ note: 42 as unknown as string }), id: "ps_badnote" }));
|
||||
const created = practiceStore.createSet(init({ note: "lean toward pricing" }));
|
||||
assert.equal(practiceStore.getSet(created.id)!.note, "lean toward pricing");
|
||||
});
|
||||
|
||||
it("tolerates optional fields — a minimal stub (no error/answer/grade) reads", () => {
|
||||
const minimal = { ...init({ problems: [{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "generating" }] }), id: "ps_valid" };
|
||||
assert.ok(isProblemSet(minimal));
|
||||
});
|
||||
|
||||
it("grades are integer scores 0–10 with non-empty reasoning (one-attempt redesign)", () => {
|
||||
const withGrade = (score: unknown, reasoning = "why"): unknown => ({
|
||||
...init(),
|
||||
id: "ps_valid",
|
||||
problems: [
|
||||
{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "ready", grade: { score, reasoning, gradedAt: "t" } },
|
||||
],
|
||||
});
|
||||
assert.ok(isProblemSet(withGrade(0)));
|
||||
assert.ok(isProblemSet(withGrade(10)));
|
||||
assert.ok(!isProblemSet(withGrade(11)), "above the range");
|
||||
assert.ok(!isProblemSet(withGrade(-1)), "below the range");
|
||||
assert.ok(!isProblemSet(withGrade(7.5)), "fractional scores are refused");
|
||||
assert.ok(!isProblemSet(withGrade("9")), "string scores are refused");
|
||||
assert.ok(!isProblemSet(withGrade(9, "")), "empty reasoning is refused");
|
||||
assert.ok(!isProblemSet({ ...init(), id: "ps_valid", problems: [{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "ready", grade: { verdict: "solid", feedback: "legacy", gradedAt: "t" } }] }), "legacy verdict grades fail (wipe regime)");
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* PRACTICE-MODULE CORE: chainsForSelection over the STORED chains
|
||||
* (chains-primary, owner 2026-09-30: a chain rides along iff every member
|
||||
* is selected; nodes in no surviving chain are singletons) and
|
||||
* chainCoverage (the set's completeness law: union of the problems' chains
|
||||
* must equal the set's chains).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { GraphChain } from "../src/core/contracts.ts";
|
||||
import { chainCoverage, chainsForSelection, formatChainLine } from "../src/core/practice.ts";
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const n = (id: string) => ({ id });
|
||||
const arg = (id: string, premises: string[], conclusion: string): GraphChain => ({
|
||||
id,
|
||||
premises,
|
||||
conclusion,
|
||||
provenance: [],
|
||||
createdAt: "2026-09-30T00:00:00.000Z",
|
||||
});
|
||||
|
||||
/** The plan's canonical shape: diamond a→b, a→c, b∧c→d — three stored
|
||||
* arguments (each its own arg_ id, conclusion ids may repeat). */
|
||||
const diamondNodes = [n("a"), n("b"), n("c"), n("d")];
|
||||
const diamondArgs = [arg("arg_b", ["a"], "b"), arg("arg_c", ["a"], "c"), arg("arg_d", ["b", "c"], "d")];
|
||||
|
||||
// ─── chainsForSelection ──────────────────────────────────────────────────────
|
||||
|
||||
describe("chainsForSelection", () => {
|
||||
it("keeps every fully-selected stored argument, each under its own id", () => {
|
||||
assert.deepEqual(chainsForSelection(diamondNodes, diamondArgs), [
|
||||
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
||||
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
||||
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a premise-only node is NOT its own chain — it lives inside the arguments it feeds", () => {
|
||||
const ids = chainsForSelection(diamondNodes, diamondArgs).map((c) => c.id);
|
||||
assert.ok(!ids.includes("a")); // a is only ever a premise
|
||||
});
|
||||
|
||||
it("an isolated node becomes a singleton chain", () => {
|
||||
const chains = chainsForSelection([...diamondNodes, n("z")], diamondArgs);
|
||||
assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" });
|
||||
});
|
||||
|
||||
it("two independent arguments for one conclusion stay DISTINCT chains", () => {
|
||||
// The bijection problem that killed edge-grouping (owner, 2026-09-30):
|
||||
// A∧B⇒E and C∧D⇒E must never union into A∧B∧C∧D⇒E.
|
||||
const chains = chainsForSelection(
|
||||
[n("a"), n("b"), n("c"), n("d"), n("e")],
|
||||
[arg("arg_e1", ["a", "b"], "e"), arg("arg_e2", ["c", "d"], "e")],
|
||||
);
|
||||
assert.deepEqual(chains, [
|
||||
{ id: "arg_e1", premises: ["a", "b"], conclusion: "e" },
|
||||
{ id: "arg_e2", premises: ["c", "d"], conclusion: "e" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("an argument with ANY member outside the selection is excluded entirely", () => {
|
||||
// arg_ab needs b; b is not selected, so the argument is gone and
|
||||
// a (nothing else left) falls back to a singleton.
|
||||
const chains = chainsForSelection([n("a")], [arg("arg_ab", ["a"], "b"), arg("arg_ia", ["in"], "a")]);
|
||||
assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]);
|
||||
});
|
||||
|
||||
it("a chain referencing unknown node ids simply never applies", () => {
|
||||
const chains = chainsForSelection([n("a"), n("b")], [arg("arg_ghost", ["ghost"], "b")]);
|
||||
assert.deepEqual(chains, [
|
||||
{ id: "a", premises: [], conclusion: "a" },
|
||||
{ id: "b", premises: [], conclusion: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is deterministic — chains sorted by id, premises sorted", () => {
|
||||
const shuffled = chainsForSelection(
|
||||
[n("d"), n("c"), n("b"), n("a")],
|
||||
[arg("arg_d", ["c", "b"], "d"), arg("arg_c", ["a"], "c"), arg("arg_b", ["a"], "b")],
|
||||
);
|
||||
assert.deepEqual(shuffled, [
|
||||
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
||||
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
||||
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── chainCoverage ───────────────────────────────────────────────────────────
|
||||
|
||||
const diamondChains = [
|
||||
{ id: "arg_b", premises: ["a"], conclusion: "b" },
|
||||
{ id: "arg_c", premises: ["a"], conclusion: "c" },
|
||||
{ id: "arg_d", premises: ["b", "c"], conclusion: "d" },
|
||||
];
|
||||
|
||||
describe("chainCoverage", () => {
|
||||
it("complete cover: every chain exercised, no missing", () => {
|
||||
const set = {
|
||||
chains: diamondChains,
|
||||
problems: [{ chains: ["arg_b", "arg_c"] }, { chains: ["arg_d"] }],
|
||||
};
|
||||
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
|
||||
});
|
||||
|
||||
it("missing: a chain no problem exercises", () => {
|
||||
const set = {
|
||||
chains: diamondChains,
|
||||
problems: [{ chains: ["arg_d"] }],
|
||||
};
|
||||
assert.deepEqual(chainCoverage(set), { missing: ["arg_b", "arg_c"], duplicated: [] });
|
||||
});
|
||||
|
||||
it("duplicated is informational — a chain exercised twice is allowed", () => {
|
||||
const set = {
|
||||
chains: diamondChains,
|
||||
problems: [{ chains: ["arg_b", "arg_c", "arg_d"] }, { chains: ["arg_b", "arg_d"] }],
|
||||
};
|
||||
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["arg_b", "arg_d"] });
|
||||
});
|
||||
|
||||
it("problems claiming chain ids outside the set neither cover nor fail anything", () => {
|
||||
const set = {
|
||||
chains: [{ id: "arg_b" }] as Array<{ id: string }>,
|
||||
problems: [{ chains: ["arg_b", "ghost"] }],
|
||||
};
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -265,13 +265,12 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
|
||||
assert.ok(!html.includes('data-testid="chain-selection-bar"'));
|
||||
});
|
||||
|
||||
it("appears with the derived N nodes → M chains + Clear + Generate + the FENCED Delete", () => {
|
||||
it("appears with the derived N nodes → M chains + Clear + the FENCED Delete", () => {
|
||||
const html = render({ selection: new Set(["a", "b", "c1"]) });
|
||||
assert.ok(html.includes('data-testid="chain-selection-bar"'));
|
||||
assert.ok(html.includes('data-testid="chain-selected-summary"'));
|
||||
assert.ok(html.includes("3 nodes → 1 chain"));
|
||||
assert.ok(html.includes('data-testid="chain-clear"'));
|
||||
assert.ok(html.includes('data-testid="chain-generate"'));
|
||||
// The danger fence (owner, 2026-09-29 night): a divider quarantines
|
||||
// the destructive action at the bar's right end, red + trash icon,
|
||||
// riding the same confirm dialog as the canvas delete.
|
||||
@@ -280,8 +279,7 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
|
||||
assert.match(del, /border-destructive\/50/); // the destructive pair
|
||||
assert.match(del, /aria-label="Delete 3 selected nodes"/);
|
||||
assert.ok(html.includes("lucide-trash-2"), "the trash icon");
|
||||
// the fence is AFTER generate in the DOM (right end of the bar)
|
||||
assert.ok(html.indexOf('chain-generate"') < html.indexOf('chain-danger-divider"'));
|
||||
// the fence is before Delete in the DOM (right end of the bar)
|
||||
assert.ok(html.indexOf('chain-danger-divider"') < html.indexOf('chain-delete"'));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
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 { DEFAULT_PRACTICE_PARAMS, 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 = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
|
||||
|
||||
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 },
|
||||
];
|
||||
}
|
||||
|
||||
/** Small groups are listable (owner ruling: practice availability never
|
||||
* inherits the hull-drawing threshold). */
|
||||
function smallClusters(): GraphClusterGroup[] {
|
||||
return [
|
||||
{ key: "chat_2", kind: "chat", label: "One-off question", nodeIds: ["n20"], nodeCount: 1 },
|
||||
{ key: "chat_3", kind: "chat", label: "Quick exchange", nodeIds: ["n30", "n31"], nodeCount: 2 },
|
||||
];
|
||||
}
|
||||
|
||||
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 = {
|
||||
params: { ...DEFAULT_PRACTICE_PARAMS },
|
||||
onParam: () => {},
|
||||
note: "",
|
||||
onNote: () => {},
|
||||
clusters: clusters(),
|
||||
selectedKey: null,
|
||||
onSelect: () => {},
|
||||
busy: false,
|
||||
failure: null as PracticeFailure | null,
|
||||
onOpenSettings: () => {},
|
||||
onClose: () => {},
|
||||
onRun: () => {},
|
||||
};
|
||||
|
||||
it("stepped controls: 5-detent ladder + 3-detent cue + Theory|Application toggle, defaults rung 2 / cue 1 / theory", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
|
||||
assert.ok(html.includes('data-testid="param-ladder"'));
|
||||
assert.match(html, /min="0" max="4" step="1"/);
|
||||
assert.ok(html.includes('data-testid="param-cue"'));
|
||||
assert.match(html, /min="0" max="2" step="1"/);
|
||||
assert.ok(html.includes('data-testid="param-presentation-theory"'));
|
||||
assert.ok(html.includes('data-testid="param-presentation-application"'));
|
||||
// The DEFAULTS: rung 2 / cue 1 / theory, with names + definitions.
|
||||
assert.match(html, /value="2"/); // ladder detent
|
||||
assert.match(html, /value="1"/); // cue detent
|
||||
assert.ok(html.includes("Direct exercise"), "default rung name");
|
||||
assert.ok(html.includes("Adjacent context"), "default cue name");
|
||||
assert.ok(html.includes("one step, no hint"), "current rung definition shown");
|
||||
assert.ok(html.includes("same domain, different setup/packaging"), "current cue definition shown");
|
||||
});
|
||||
|
||||
it("moving a detent swaps the shown name + definition; the toggle switches", () => {
|
||||
const moved = renderToStaticMarkup(
|
||||
<PracticeDialogBody {...base} params={{ ladderRung: 4, cueDistance: 0, presentation: "application" }} />,
|
||||
);
|
||||
assert.ok(moved.includes("Path-finding"));
|
||||
assert.ok(moved.includes("the statement suggesting wrong methods"), "rung-4 definition");
|
||||
assert.ok(moved.includes("Lesson context"));
|
||||
assert.ok(moved.includes("textbook/toy-example shape"), "cue-0 definition");
|
||||
assert.ok(moved.includes("a concrete worldly affair"), "application definition shown");
|
||||
assert.match(moved, /param-presentation-application[^]*border-primary/, "application segment active");
|
||||
});
|
||||
|
||||
it("the learner's note: optional textarea under the params, carries text, capped client-side", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} note="lean toward pricing applications" />);
|
||||
assert.ok(html.includes('data-testid="practice-note"'));
|
||||
assert.ok(html.includes("Anything else? (optional)"), "the label");
|
||||
assert.ok(html.includes("lean toward pricing applications"), "controlled value renders");
|
||||
assert.match(html, /maxlength="2000"/i, "client-side cap mirrors the API law");
|
||||
// Empty default: present but empty — optional in every sense.
|
||||
const bare = renderToStaticMarkup(<PracticeDialogBody {...base} />);
|
||||
assert.match(bare, /data-testid="practice-note"[^>]*><\/textarea>/, "no value when unset");
|
||||
});
|
||||
|
||||
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 nodes|notes · 3 nodes/);
|
||||
assert.ok(html.includes("4 nodes"), "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"));
|
||||
assert.ok(html.includes("once a session births its first node"), "no 3+ mechanics in copy");
|
||||
});
|
||||
|
||||
it("1- and 2-proposition groups appear — no hull threshold in the picker", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} clusters={smallClusters()} />);
|
||||
assert.ok(html.includes('data-testid="practice-cluster-chat_2"'), "singleton group listed");
|
||||
assert.ok(html.includes("1 node"));
|
||||
assert.ok(html.includes('data-testid="practice-cluster-chat_3"'), "pair group listed");
|
||||
assert.ok(html.includes("2 nodes"));
|
||||
});
|
||||
|
||||
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"'));
|
||||
});
|
||||
});
|
||||
|
||||
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 nodes"));
|
||||
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 node →"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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"], { answer: "a", grade: { score: 9, reasoning: "r", 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: () => {},
|
||||
submittingId: null as string | null,
|
||||
submitErrors: {} as Record<string, string>,
|
||||
liveGradeIds: new Set<string>(),
|
||||
};
|
||||
|
||||
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("the top bar carries the title + sidebar toggle; the body carries the levels line (owner, UI round 2026-09-29)", () => {
|
||||
const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
|
||||
const html = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
|
||||
// The chat-pattern top bar: drag surface, toggle slot, truncating title.
|
||||
assert.ok(html.includes("lh-drag"));
|
||||
assert.ok(html.includes('aria-label="Open sidebar"'));
|
||||
assert.ok(html.includes('data-testid="practice-exam-title"'));
|
||||
assert.ok(html.includes("Ethics lesson"));
|
||||
// The internal scroller (the sidebar stays fixed when this page scrolls).
|
||||
assert.ok(html.includes('data-testid="practice-exam-scroll"'));
|
||||
assert.ok(html.includes('data-testid="practice-exam-levels"'));
|
||||
assert.ok(html.includes("Direct exercise · Adjacent context · Theory"), "the fixture's rung 2 / cue 1 / theory");
|
||||
});
|
||||
|
||||
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"'));
|
||||
// PER-PROBLEM submit (owner 2026-09-29): the button lives under the
|
||||
// box, gated on a draft; there is NO global submit anywhere.
|
||||
assert.ok(gated.includes('data-testid="practice-submit-pp_1"'));
|
||||
assert.ok(gated.includes('disabled=""'), "empty drafts — submit disabled");
|
||||
const filled = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} drafts={{ pp_1: "because…" }} />);
|
||||
assert.ok(!filled.includes('disabled=""'), "a draft arms the submit");
|
||||
assert.ok(!filled.includes('data-testid="practice-submit-error-pp_1"'));
|
||||
});
|
||||
|
||||
it("submitted + graded: answer read-only, score + reasoning + solution revealed, rubric collapsed", () => {
|
||||
const s = set({
|
||||
problems: [
|
||||
readyProblem("pp_1", ["n3"], {
|
||||
answer: "because arguing is intentional action",
|
||||
grade: { score: 6, reasoning: "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 — one attempt, done.
|
||||
assert.ok(html.includes("because arguing is intentional action"));
|
||||
assert.ok(/readOnly/.test(html), "the submitted answer is read-only");
|
||||
assert.ok(!html.includes('data-testid="practice-submit-pp_1"'), "no re-submission");
|
||||
// The grade: mono score + brief reasoning.
|
||||
assert.ok(html.includes('data-testid="grade-pp_1"'));
|
||||
assert.ok(html.includes("6/10"));
|
||||
assert.ok(html.includes('data-testid="reasoning-pp_1"'));
|
||||
assert.ok(html.includes("Half the target came to mind."));
|
||||
// The instant reveal.
|
||||
assert.ok(html.includes('data-testid="reveal-pp_1"'));
|
||||
assert.ok(html.includes('data-testid="solution-pp_1"'));
|
||||
assert.ok(html.includes("The model solution."));
|
||||
// The rubric rides a collapsed <details> (owner 2026-09-29).
|
||||
assert.ok(html.includes("<details"));
|
||||
assert.ok(!html.includes("<details open"), "collapsed by default");
|
||||
assert.ok(html.includes('data-testid="rubric-pp_1"'));
|
||||
assert.ok(html.includes("names self-ownership"));
|
||||
// Unanswered sibling stays answerable.
|
||||
assert.ok(html.includes('data-testid="answer-pp_2"'));
|
||||
assert.ok(html.includes('data-testid="practice-submit-pp_2"'));
|
||||
});
|
||||
|
||||
it("submitted, grade pending: the reveal shows instantly, grading spins per problem", () => {
|
||||
const s = set({
|
||||
problems: [readyProblem("pp_1", ["n3"], { answer: "because arguing is intentional action" })],
|
||||
});
|
||||
const live = renderToStaticMarkup(
|
||||
<PracticeExamBody set={s} {...examBase} liveGradeIds={new Set(["pp_1"])} />,
|
||||
);
|
||||
// Instant reveal — the solution needs no grade.
|
||||
assert.ok(live.includes('data-testid="solution-pp_1"'));
|
||||
assert.ok(live.includes('data-testid="rubric-pp_1"'));
|
||||
// The live spinner for exactly this problem.
|
||||
assert.ok(live.includes('data-testid="grading-pp_1"'));
|
||||
assert.ok(live.includes("Grading"));
|
||||
assert.ok(!live.includes('data-testid="grade-pp_1"'), "no score yet");
|
||||
// No live row (dropped grade): honest, no eternal spinner.
|
||||
const dropped = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
|
||||
assert.ok(dropped.includes('data-testid="grade-interrupted-pp_1"'));
|
||||
assert.ok(!dropped.includes('data-testid="grading-pp_1"'));
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ describe("parseAgentSettingsBody", () => {
|
||||
});
|
||||
const bad = parseAgentSettingsBody({ models: { teacher: {} } });
|
||||
assert.equal(bad.ok, false);
|
||||
if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, auditor, partitioner, problemGenerator, grader\)/);
|
||||
if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, auditor\)/);
|
||||
const malformed = parseAgentSettingsBody({ models: { researcher: { model: { provider: 1, id: "x" } } } });
|
||||
assert.equal(malformed.ok, false);
|
||||
});
|
||||
|
||||
@@ -203,16 +203,13 @@ describe("sanitizeUserSettings", () => {
|
||||
assert.deepEqual(out.agent?.tools, { quiz: false, demo: true });
|
||||
});
|
||||
|
||||
it("BACKGROUND_MODEL_ROLES matches the eight background roles (practice trio 2026-09-29)", () => {
|
||||
it("BACKGROUND_MODEL_ROLES matches the five background roles (practice trio deleted 2026-10-04)", () => {
|
||||
assert.deepEqual([...BACKGROUND_MODEL_ROLES], [
|
||||
"researcher",
|
||||
"demoBuilder",
|
||||
"visualMaker",
|
||||
"extractor",
|
||||
"auditor",
|
||||
"partitioner",
|
||||
"problemGenerator",
|
||||
"grader",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,9 +128,6 @@ const catalog = (strongProvider: string | null, ...keyedProviders: string[]): Mo
|
||||
visualMaker: null,
|
||||
extractor: null,
|
||||
auditor: null,
|
||||
partitioner: null,
|
||||
problemGenerator: null,
|
||||
grader: null,
|
||||
},
|
||||
slots: {
|
||||
strong: strongProvider
|
||||
|
||||
Reference in New Issue
Block a user