2026-09-29 18:40:07 +03:00
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" ;
2026-09-29 19:26:37 +03:00
import type { GraphClusterGroup } from "../src/core" ;
2026-09-29 18:40:07 +03:00
import { PracticeExamBody } from "../components/practice-exam.tsx" ;
2026-09-29 19:26:37 +03:00
import type { Problem , ProblemSet } from "../src/core" ;
2026-09-29 18:40:07 +03:00
/**
2026-09-29 19:26:37 +03:00
* 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),
2026-09-29 18:40:07 +03:00
* 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 ,
};
2026-09-29 19:26:37 +03:00
function clusters () : GraphClusterGroup [] {
2026-09-29 18:40:07 +03:00
return [
2026-09-29 19:26:37 +03:00
{ 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 },
2026-09-29 18:40:07 +03:00
];
}
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 ─────────────────────────────────────────────────────
2026-09-29 19:26:37 +03:00
describe ( "PracticeDialogBody — creation (cluster picker)" , () => {
2026-09-29 18:40:07 +03:00
const base = {
values : dialValues ,
onDial : () => {},
2026-09-29 19:26:37 +03:00
clusters : clusters (),
selectedKey : null ,
2026-09-29 18:40:07 +03:00
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 ));
}
});
2026-09-29 19:26:37 +03:00
it ( "cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked" , () => {
2026-09-29 18:40:07 +03:00
const html = renderToStaticMarkup (< PracticeDialogBody { ...base } />);
2026-09-29 19:26:37 +03:00
assert . ok ( html . includes ( 'data-testid="practice-dialog-clusters"' ));
assert . ok ( html . includes ( 'data-testid="practice-cluster-chat_1"' ));
2026-09-29 18:40:07 +03:00
assert . ok ( html . includes ( "Ethics lesson" ));
2026-09-29 19:26:37 +03:00
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" );
2026-09-29 18:40:07 +03:00
2026-09-29 19:26:37 +03:00
const picked = renderToStaticMarkup (< PracticeDialogBody { ...base } selectedKey = "src_ab12" />);
assert . ok ( picked . includes ( "~/notes/physics.md" ));
2026-09-29 18:40:07 +03:00
});
2026-09-29 19:26:37 +03:00
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"' ));
2026-09-29 18:40:07 +03:00
});
2026-09-29 19:26:37 +03:00
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" , () => {
2026-09-29 18:40:07 +03:00
const html = renderToStaticMarkup (
2026-09-29 19:26:37 +03:00
< PracticeDialogBody { ...base } selectedKey = "chat_1" failure = {{ status : 400 , error : "selection has no propositions to practice" , code : "empty_selection" }} />,
2026-09-29 18:40:07 +03:00
);
assert . ok ( html . includes ( 'data-testid="practice-dialog-error"' ));
2026-09-29 19:26:37 +03:00
assert . ok ( html . includes ( "Nothing practicable is left in that cluster" ));
2026-09-29 18:40:07 +03:00
});
it ( "503 renders the server's line + the settings deep link" , () => {
const html = renderToStaticMarkup (
2026-09-29 19:26:37 +03:00
< PracticeDialogBody { ...base } selectedKey = "chat_1" failure = {{ status : 503 , error : "No model configured — set up your models first" , code : "models_not_configured" }} />,
2026-09-29 18:40:07 +03:00
);
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" );
});
});