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" ;
2026-09-29 20:06:35 +03:00
import { DEFAULT_PRACTICE_PARAMS , 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 ────────────────────────────────────────────────────────────────
2026-09-29 20:06:35 +03:00
const PARAMS = { ladderRung : 2 , cueDistance : 1 , presentation : "theory" } as const ;
2026-09-29 18:40:07 +03:00
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
];
}
2026-09-29 19:28:51 +03:00
/** 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 },
];
}
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 = {
2026-09-29 20:06:35 +03:00
params : { ... DEFAULT_PRACTICE_PARAMS },
onParam : () => {},
2026-09-29 22:16:28 +03:00
note : "" ,
onNote : () => {},
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 : () => {},
};
2026-09-29 20:06:35 +03:00
it ( "stepped controls: 5-detent ladder + 3-detent cue + Theory|Application toggle, defaults rung 2 / cue 1 / theory" , () => {
2026-09-29 18:40:07 +03:00
const html = renderToStaticMarkup (< PracticeDialogBody { ...base } />);
2026-09-29 20:06:35 +03:00
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" );
2026-09-29 18:40:07 +03:00
});
2026-09-29 22:16:28 +03:00
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" );
});
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-30 01:19:03 +03:00
assert . match ( html , /notes · <!-- -->3 nodes|notes · 3 nodes/ );
assert . ok ( html . includes ( "4 nodes" ), "chat cluster shows its count" );
2026-09-29 19:26:37 +03:00
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" ));
2026-09-30 01:19:03 +03:00
assert . ok ( html . includes ( "once a session births its first node" ), "no 3+ mechanics in copy" );
2026-09-29 19:28:51 +03:00
});
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" );
2026-09-30 01:19:03 +03:00
assert . ok ( html . includes ( "1 node" ));
2026-09-29 19:28:51 +03:00
assert . ok ( html . includes ( 'data-testid="practice-cluster-chat_3"' ), "pair group listed" );
2026-09-30 01:19:03 +03:00
assert . ok ( html . includes ( "2 nodes" ));
2026-09-29 19:26:37 +03:00
});
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"' ));
});
});
2026-09-29 22:41:52 +03:00
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"' ));
2026-09-30 01:19:03 +03:00
assert . ok ( html . includes ( "4 nodes" ));
2026-09-29 22:41:52 +03:00
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"' ));
2026-09-30 01:19:03 +03:00
assert . ok ( html . includes ( "1 node →" ));
2026-09-29 22:41:52 +03:00
});
});
2026-09-29 18:40:07 +03:00
// ─── 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 : [
2026-09-29 21:54:32 +03:00
readyProblem ( "pp_1" , [ "n3" ], { answer : "a" , grade : { score : 9 , reasoning : "r" , gradedAt : "2026-09-29T13:00:00.000Z" } }),
2026-09-29 18:40:07 +03:00
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 : () => {},
2026-09-29 21:54:32 +03:00
submittingId : null as string | null ,
submitErrors : {} as Record < string , string >,
liveGradeIds : new Set < string >(),
2026-09-29 18:40:07 +03:00
};
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.
2026-09-29 21:54:32 +03:00
assert . ok ( ! html . includes ( 'data-testid="practice-submit-' ));
2026-09-29 18:40:07 +03:00
});
2026-09-29 21:54:32 +03:00
it ( "the top bar carries the title + sidebar toggle; the body carries the levels line (owner, UI round 2026-09-29)" , () => {
2026-09-29 20:06:35 +03:00
const s = set ({ problems : [ readyProblem ( "pp_1" , [ "n3" ])] });
const html = renderToStaticMarkup (< PracticeExamBody set = { s } { ...examBase } />);
2026-09-29 21:54:32 +03:00
// 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"' ));
2026-09-29 20:06:35 +03:00
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" );
});
2026-09-29 18:40:07 +03:00
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"' ));
2026-09-29 21:54:32 +03:00
// 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"' ));
2026-09-29 18:40:07 +03:00
assert . ok ( gated . includes ( 'disabled=""' ), "empty drafts — submit disabled" );
const filled = renderToStaticMarkup (< PracticeExamBody set = { s } { ...examBase } drafts = {{ pp_1 : "because…" }} />);
2026-09-29 21:54:32 +03:00
assert . ok ( ! filled . includes ( 'disabled=""' ), "a draft arms the submit" );
assert . ok ( ! filled . includes ( 'data-testid="practice-submit-error-pp_1"' ));
2026-09-29 18:40:07 +03:00
});
2026-09-29 21:54:32 +03:00
it ( "submitted + graded: answer read-only, score + reasoning + solution revealed, rubric collapsed" , () => {
2026-09-29 18:40:07 +03:00
const s = set ({
problems : [
readyProblem ( "pp_1" , [ "n3" ], {
answer : "because arguing is intentional action" ,
2026-09-29 21:54:32 +03:00
grade : { score : 6 , reasoning : "Half the target came to mind." , gradedAt : "2026-09-29T13:00:00.000Z" },
2026-09-29 18:40:07 +03:00
}),
readyProblem ( "pp_2" , [ "n1" ]),
],
});
const html = renderToStaticMarkup (< PracticeExamBody set = { s } { ...examBase } />);
2026-09-29 21:54:32 +03:00
// The locked answer — one attempt, done.
2026-09-29 18:40:07 +03:00
assert . ok ( html . includes ( "because arguing is intentional action" ));
2026-09-29 21:54:32 +03:00
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"' ));
2026-09-29 18:40:07 +03:00
assert . ok ( html . includes ( "Half the target came to mind." ));
2026-09-29 21:54:32 +03:00
// The instant reveal.
assert . ok ( html . includes ( 'data-testid="reveal-pp_1"' ));
2026-09-29 18:40:07 +03:00
assert . ok ( html . includes ( 'data-testid="solution-pp_1"' ));
assert . ok ( html . includes ( "The model solution." ));
2026-09-29 21:54:32 +03:00
// The rubric rides a collapsed <details> (owner 2026-09-29).
assert . ok ( html . includes ( "<details" ));
assert . ok ( ! html . includes ( "<details open" ), "collapsed by default" );
2026-09-29 18:40:07 +03:00
assert . ok ( html . includes ( 'data-testid="rubric-pp_1"' ));
assert . ok ( html . includes ( "names self-ownership" ));
2026-09-29 21:54:32 +03:00
// Unanswered sibling stays answerable.
2026-09-29 18:40:07 +03:00
assert . ok ( html . includes ( 'data-testid="answer-pp_2"' ));
2026-09-29 21:54:32 +03:00
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"' ));
2026-09-29 18:40:07 +03:00
});
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" );
});
});