2026-08-22 00:19:49 +03:00
/**
2026-08-22 02:18:27 +03:00
* Unit tests — question-core (pure logic, no server, no LLM).
2026-08-22 00:19:49 +03:00
*/
import assert from "node:assert/strict" ;
import { describe , it } from "node:test" ;
2026-09-25 23:32:13 +03:00
import { Value } from "typebox/value" ;
import type { TSchema } from "typebox" ;
2026-08-22 00:19:49 +03:00
import {
2026-09-01 01:47:47 +03:00
askInputSchema ,
2026-09-26 19:08:39 +03:00
quizInputBase ,
2026-09-01 01:47:47 +03:00
askOptionProblems ,
2026-08-22 00:19:49 +03:00
buildAnswer ,
coerceCorrectAnswer ,
findPendingQuestion ,
formatAnswerMessage ,
isCorrect ,
makeId ,
normalizeOptions ,
QUESTION_ANSWERED_ENTRY ,
QUESTION_ENTRY ,
2026-09-01 01:47:47 +03:00
quizInputProblems ,
2026-09-27 08:55:13 +03:00
explainInputBase ,
explainInputObject ,
explainInputProblems ,
2026-09-01 01:47:47 +03:00
quizInputSchema ,
2026-09-25 03:31:53 +03:00
probeQuizInputSchema ,
2026-08-22 00:19:49 +03:00
resolveCorrect ,
2026-09-12 04:45:58 +03:00
toggleOption ,
toggleOther ,
emptyDockSelection ,
2026-08-22 00:19:49 +03:00
shuffleOptions ,
type QuestionData ,
toClientQuestion ,
2026-08-22 02:18:27 +03:00
} from "../src/core/question-core.ts" ;
2026-08-22 00:19:49 +03:00
2026-09-25 23:32:13 +03:00
// ─── the authoring contract (TypeBox schemas + execute-time refinements) ──
2026-09-01 01:47:47 +03:00
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
2026-09-25 23:32:13 +03:00
// dies at the tool-call boundary (structural TypeBox check in pi's loop +
// the refinement problems in the tool's execute — a ToolInputError the
// model retries on), and never reaches the learner's dock. The boundary
// predicate is BOTH: structural check AND zero authoring problems.
const boundaryOk = ( schema : TSchema , input : unknown , problems : string []) : boolean =>
Value . Check ( schema , input ) && problems . length === 0 ;
2026-09-01 01:47:47 +03:00
2026-09-25 03:31:53 +03:00
describe ( "the schema's argument order (reasoning before the pick)" , () => {
// Argument-order ruling (owner, 2026-09-25): the explanation is
// reasoning, and reasoning happens BEFORE the answer is chosen — models
// emit tool args in schema order, so explanation sits immediately before
2026-09-25 23:32:13 +03:00
// correctAnswer. The TypeBox authoring order IS the model-facing JSON
// Schema property order, so this pin guards the emission end to end.
// All consumers read by key — the order is authoring guidance.
2026-09-25 03:31:53 +03:00
it ( "emits explanation immediately before correctAnswer (both agents' schemas)" , () => {
for ( const schema of [ quizInputSchema , probeQuizInputSchema ]) {
2026-09-25 23:32:13 +03:00
const keys = Object . keys (( schema as { properties : Record < string , unknown > }). properties );
2026-09-25 03:31:53 +03:00
assert . ok (
keys . indexOf ( "explanation" ) < keys . indexOf ( "correctAnswer" ),
`explanation must precede correctAnswer in the emitted schema (got: ${ keys . join ( ", " ) } )` ,
);
assert . deepEqual ( keys , [
"question" ,
"options" ,
"multiSelect" ,
"explanation" ,
"correctAnswer" ,
"details" ,
...( keys . includes ( "nodeId" ) ? [ "nodeId" ] : []),
]);
}
});
});
2026-09-01 01:47:47 +03:00
describe ( "quizInputSchema / quizInputProblems (the authoring contract)" , () => {
const valid = {
question : "Which planet is closest to the Sun?" ,
options : [{ label : "Venus" }, { label : "Mercury" }, { label : "Mars" }],
correctAnswer : "Mercury" ,
explanation : "Mercury is the innermost planet." ,
2026-09-27 02:05:51 +03:00
nodeId : "planet-order" ,
2026-09-01 01:47:47 +03:00
};
it ( "accepts a valid single-select quiz (label as correctAnswer)" , () => {
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , valid , quizInputProblems ( valid )), true );
2026-09-01 01:47:47 +03:00
assert . deepEqual ( quizInputProblems ( valid ), []);
});
it ( "accepts explicit values and multi-select arrays (incl. JSON-stringified)" , () => {
const multi = {
... valid ,
options : [
{ label : "A" , value : "a" },
{ label : "B" , value : "b" },
{ label : "C" , value : "c" },
],
correctAnswer : [ "a" , "b" ],
multiSelect : true ,
};
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , multi , quizInputProblems ( multi )), true );
2026-09-01 01:47:47 +03:00
// The union lists String first, so providers sometimes stringify arrays.
const stringified = { ... multi , correctAnswer : JSON.stringify ( multi . correctAnswer ) };
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , stringified , quizInputProblems ( stringified )), true );
2026-09-01 01:47:47 +03:00
assert . deepEqual ( quizInputProblems ( stringified ), []);
});
it ( "rejects the observed failure: a correctAnswer slug matching no option value, listing the valid values" , () => {
// The exact class GLM produced live (Gate 3 + live verification).
const bad = {
question : "What is Nietzsche's genealogical claim about religion?" ,
options : [
{ label : "A cultural diagnosis" },
{ label : "Atheism as a conclusion" },
{ label : "A call to convert people" },
],
correctAnswer : "cultural-diagnosis" ,
explanation : "..." ,
};
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , bad , quizInputProblems ( bad )), false );
2026-09-01 01:47:47 +03:00
const problems = quizInputProblems ( bad );
assert . equal ( problems . length , 1 );
assert . match ( problems [ 0 ] ! , /does not match any option value/ );
assert . match ( problems [ 0 ] ! , /"A cultural diagnosis"/ ); // the valid values are listed for the retry
});
it ( "rejects multiple correct answers without multiSelect (ungradeable single-select)" , () => {
const bad = { ... valid , correctAnswer : [ "Venus" , "Mercury" ] };
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , bad , quizInputProblems ( bad )), false );
2026-09-01 01:47:47 +03:00
assert . match ( quizInputProblems ( bad ). join ( " " ), /multiSelect/ );
});
it ( "rejects duplicate option values" , () => {
const bad = {
... valid ,
options : [
{ label : "Mercury" , value : "same" },
{ label : "Venus" , value : "same" },
],
};
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , bad , quizInputProblems ( bad )), false );
2026-09-01 01:47:47 +03:00
assert . match ( quizInputProblems ( bad ). join ( " " ), /same value/ );
});
it ( "rejects an empty option label" , () => {
const bad = { ... valid , options : [{ label : " " }, { label : "Mercury" }, { label : "Mars" }] };
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , bad , quizInputProblems ( bad )), false );
2026-09-01 01:47:47 +03:00
assert . match ( quizInputProblems ( bad ). join ( " " ), /label is empty/ );
});
it ( "requires a resolvable correctAnswer" , () => {
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( quizInputSchema , { ... valid , correctAnswer : "" }, quizInputProblems ({ ... valid , correctAnswer : "" })), false );
assert . equal ( boundaryOk ( quizInputSchema , { ... valid , correctAnswer : "[]" }, quizInputProblems ({ ... valid , correctAnswer : "[]" })), false );
2026-09-01 01:47:47 +03:00
assert . match ( quizInputProblems ({ ... valid , correctAnswer : "" }). join ( " " ), /required/ );
});
});
describe ( "askInputSchema / askOptionProblems" , () => {
it ( "accepts a valid ask" , () => {
2026-09-25 23:32:13 +03:00
const ask = {
2026-09-01 01:47:47 +03:00
question : "Which topic first?" ,
options : [{ label : "Limits" }, { label : "Chain rule" }],
2026-09-25 23:32:13 +03:00
};
assert . equal ( boundaryOk ( askInputSchema , ask , askOptionProblems ( ask . options ?? [])), true );
2026-09-01 01:47:47 +03:00
});
it ( "rejects duplicate option values and empty labels (they break the dock's keying)" , () => {
const bad = { question : "?" , options : [{ label : "A" , value : "x" }, { label : "B" , value : "x" }] };
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( askInputSchema , bad , askOptionProblems ( bad . options ?? [])), false );
2026-09-01 01:47:47 +03:00
assert . match ( askOptionProblems ( bad . options ). join ( " " ), /same value/ );
const emptyLabel = { question : "?" , options : [{ label : "" }, { label : "B" }] };
2026-09-25 23:32:13 +03:00
assert . equal ( boundaryOk ( askInputSchema , emptyLabel , askOptionProblems ( emptyLabel . options ?? [])), false );
2026-09-01 01:47:47 +03:00
assert . match ( askOptionProblems ( emptyLabel . options ). join ( " " ), /label is empty/ );
});
});
2026-08-22 00:19:49 +03:00
// ─── normalizeOptions ───────────────────────────────────────────────────────
describe ( "normalizeOptions" , () => {
it ( "trims labels and defaults value to label" , () => {
const opts = normalizeOptions ([{ label : " Mercury " }, { label : "Venus" , value : " v " }]);
assert . deepEqual ( opts , [
{ label : "Mercury" , value : "Mercury" , description : undefined },
{ label : "Venus" , value : "v" , description : undefined },
]);
});
it ( "drops empty labels" , () => {
const opts = normalizeOptions ([{ label : "" }, { label : "ok" }]);
assert . equal ( opts . length , 1 );
});
it ( "throws on duplicate option values" , () => {
assert . throws (() => normalizeOptions ([{ label : "a" , value : "x" }, { label : "b" , value : "x" }]), /duplicate/ );
2026-09-26 19:08:39 +03:00
// GLM wobble guard (2026-09-26 gradient-descent session): the model
// sometimes emits `options` as a JSON-encoded string — 6 of 7 quiz
// validation failures in that session. The dock (and grading) coerce
// via the same parseOptionsIfString the server's prepareArguments
// runs pre-validation.
const asString = JSON . stringify ([{ label : "Slope" , value : "slope" }, { label : "Value" , value : "value" }]);
const coerced = normalizeOptions ( asString );
assert . deepEqual ( coerced , [
{ label : "Slope" , value : "slope" , description : undefined },
{ label : "Value" , value : "value" , description : undefined },
], "a JSON-encoded string coerces to the same normalized options" );
// Non-JSON strings pass through and behave like no options (the
// server's schema validation reports the honest error instead).
assert . deepEqual ( normalizeOptions ( "not json" ), []);
});
it ( "quiz options carry NO description field; ask keeps it (owner nuke, 2026-09-26)" , () => {
// The model used quiz option descriptions to telegraph correctness
// ("that's not the gradient") in every quiz of the gradient-descent
// session — the field is gone from BOTH quiz schemas; ask (ungraded,
// nothing to leak) keeps it.
const quizOption = ( quizInputBase . properties . options as { items ?: { properties? : Record < string , unknown > } })
. items ! . properties ! ;
assert . ok ( ! ( "description" in quizOption ), "quiz option schema has no description" );
const askOption = ( askInputSchema . properties . options as { items ?: { properties? : Record < string , unknown > } })
. items ! . properties ! ;
assert . ok ( "description" in askOption , "ask option schema keeps description" );
2026-08-22 00:19:49 +03:00
});
it ( "handles undefined input" , () => {
assert . deepEqual ( normalizeOptions ( undefined ), []);
});
});
// ─── shuffleOptions ─────────────────────────────────────────────────────────
describe ( "shuffleOptions" , () => {
it ( "permutes without adding, removing, or mutating options" , () => {
const input = Array . from ({ length : 20 }, ( _ , i ) => ({ label : `o ${ i } ` , value : `o ${ i } ` }));
const shuffled = shuffleOptions ( input );
assert . equal ( shuffled . length , input . length );
assert . deepEqual ([... shuffled ]. sort (( a , b ) => Number ( a . label . slice ( 1 )) - Number ( b . label . slice ( 1 ))), input );
assert . notEqual ( input , shuffled ); // a copy
});
it ( "is stable for single/empty inputs" , () => {
assert . deepEqual ( shuffleOptions ([]), []);
assert . deepEqual ( shuffleOptions ([{ label : "only" , value : "only" }]), [{ label : "only" , value : "only" }]);
});
});
// ─── coerceCorrectAnswer ────────────────────────────────────────────────────
describe ( "coerceCorrectAnswer" , () => {
it ( "wraps a single string" , () => {
assert . deepEqual ( coerceCorrectAnswer ( "mercury" ), [ "mercury" ]);
});
it ( "passes arrays through" , () => {
assert . deepEqual ( coerceCorrectAnswer ([ "a" , "b" ]), [ "a" , "b" ]);
});
it ( "parses a JSON-stringified multi-select array" , () => {
assert . deepEqual ( coerceCorrectAnswer ( '["a", "b"]' ), [ "a" , "b" ]);
});
it ( "leaves a non-JSON bracketed string alone" , () => {
assert . deepEqual ( coerceCorrectAnswer ( "[not json" ), [ "[not json" ]);
});
});
// ─── resolveCorrect ─────────────────────────────────────────────────────────
describe ( "resolveCorrect" , () => {
const options = normalizeOptions ([
{ label : "Mercury" , value : "mercury" },
{ label : "Venus" , value : "venus" },
{ label : "Earth" , value : "earth" },
]);
it ( "resolves a single value to its 1-based display index" , () => {
assert . deepEqual ( resolveCorrect ( "venus" , options ), { indices : [ 2 ] });
});
it ( "resolves multi-select values, deduped and sorted" , () => {
assert . deepEqual ( resolveCorrect ([ "earth" , "mercury" , "mercury" ], options ), { indices : [ 1 , 3 ] });
});
it ( "errors when the value matches no option" , () => {
const r = resolveCorrect ( "mars" , options );
assert . ok ( r . error );
assert . match ( r . error , /does not match any option value/ );
});
it ( "errors on missing correctAnswer" , () => {
assert . ok ( resolveCorrect ( undefined , options ). error );
});
it ( "resolves against POST-shuffle order (indices track display)" , () => {
const shuffled = shuffleOptions ( options );
const value = "earth" ;
const r = resolveCorrect ( value , shuffled );
const displayedIndex = shuffled . findIndex (( o ) => o . value === value ) + 1 ;
assert . deepEqual ( r . indices , [ displayedIndex ]);
});
});
// ─── isCorrect (exact-set match) ────────────────────────────────────────────
describe ( "isCorrect" , () => {
it ( "matches identical sets regardless of order" , () => {
assert . ok ( isCorrect ([ 3 , 1 ], [ 1 , 3 ]));
});
it ( "rejects partial matches" , () => {
assert . ok ( ! isCorrect ([ 1 ], [ 1 , 3 ]));
assert . ok ( ! isCorrect ([ 1 , 2 , 3 ], [ 1 , 3 ]));
});
it ( "rejects wrong selections of the same size" , () => {
assert . ok ( ! isCorrect ([ 1 , 2 ], [ 1 , 3 ]));
});
it ( "rejects empty selection against a non-empty answer" , () => {
assert . ok ( ! isCorrect ([], [ 1 ]));
});
});
// ─── buildAnswer + formatAnswerMessage ──────────────────────────────────────
function quizData ( overrides? : Partial < QuestionData >) : QuestionData {
return {
id : "q_test" ,
kind : "quiz" ,
question : "Which planet is closest to the Sun?" ,
mode : "single-select" ,
options : normalizeOptions ([
{ label : "Mercury" , value : "mercury" },
{ label : "Venus" , value : "venus" },
]),
correctIndices : [ 1 ],
explanation : "Mercury orbits closest." ,
createdAt : "2026-01-01T00:00:00Z" ,
... overrides ,
};
}
describe ( "buildAnswer" , () => {
it ( "grades a correct selection" , () => {
const ad = buildAnswer ( quizData (), { values : [ "mercury" ] });
assert . equal ( ad . correct , true );
assert . equal ( ad . answers [ 0 ] ? . label , "Mercury" );
assert . equal ( ad . answers [ 0 ] ? . index , 1 );
assert . equal ( ad . dontKnow , false );
});
it ( "grades a wrong selection" , () => {
const ad = buildAnswer ( quizData (), { values : [ "venus" ] });
assert . equal ( ad . correct , false );
});
it ( "dontKnow is never correct and records no answers" , () => {
const ad = buildAnswer ( quizData (), { dontKnow : true });
assert . equal ( ad . correct , false );
assert . equal ( ad . dontKnow , true );
assert . deepEqual ( ad . answers , []);
});
it ( "keeps the note only when non-empty" , () => {
assert . equal ( buildAnswer ( quizData (), { values : [ "mercury" ], note : " " }). note , undefined );
assert . equal ( buildAnswer ( quizData (), { values : [ "mercury" ], note : " x " }). note , "x" );
});
it ( "ignores unknown option values" , () => {
const ad = buildAnswer ( quizData (), { values : [ "mars" , "mercury" ] });
assert . deepEqual ( ad . answers . map (( a ) => a . value ), [ "mercury" ]);
});
it ( "multi-select grades as exact-set" , () => {
const multi = quizData ({
mode : "multi-select" ,
options : normalizeOptions ([
{ label : "A" , value : "a" },
{ label : "B" , value : "b" },
{ label : "C" , value : "c" },
]),
correctIndices : [ 1 , 2 ],
});
assert . equal ( buildAnswer ( multi , { values : [ "a" , "b" ] }). correct , true );
assert . equal ( buildAnswer ( multi , { values : [ "a" ] }). correct , false );
assert . equal ( buildAnswer ( multi , { values : [ "a" , "b" , "c" ] }). correct , false );
});
it ( "ask questions are not graded" , () => {
const ask = quizData ({ kind : "ask" , correctIndices : undefined , explanation : undefined });
const ad = buildAnswer ( ask , { values : [ "mercury" ] });
assert . equal ( ad . correct , undefined );
});
});
describe ( "formatAnswerMessage" , () => {
it ( "formats a correct quiz answer for the agent" , () => {
const ad = buildAnswer ( quizData (), { values : [ "mercury" ] });
const text = formatAnswerMessage ( ad );
assert . ok ( text . startsWith ( "[Quiz answer]" ));
assert . match ( text , /Selected: 1\. Mercury/ );
assert . match ( text , /Result: CORRECT/ );
});
it ( "formats dontKnow as a gap, not a guess" , () => {
const ad = buildAnswer ( quizData (), { dontKnow : true });
const text = formatAnswerMessage ( ad );
assert . match ( text , /did NOT attempt/ );
assert . match ( text , /knowledge gap/ );
assert . ok ( ! text . includes ( "Result:" ));
});
it ( "includes the note when present" , () => {
const ad = buildAnswer ( quizData (), { values : [ "venus" ], note : "guessed" });
assert . match ( formatAnswerMessage ( ad ), /Note: guessed/ );
});
it ( "formats ask answers" , () => {
const ask = quizData ({ kind : "ask" , correctIndices : undefined , explanation : undefined });
const ad = buildAnswer ( ask , { other : "whatever" });
const text = formatAnswerMessage ( ad );
assert . ok ( text . startsWith ( "[Answer to your question]" ));
assert . match ( text , /\(Other\): whatever/ );
});
it ( "formats a dismissed question" , () => {
const ad = buildAnswer ( quizData (), {});
ad . dismissed = true ;
assert . match ( formatAnswerMessage ( ad ), /dismissed/ );
});
});
// ─── toClientQuestion (leak safety) ─────────────────────────────────────────
describe ( "toClientQuestion" , () => {
it ( "NEVER exposes correctIndices or explanation" , () => {
const cq = toClientQuestion ( quizData ());
const raw = JSON . stringify ( cq );
assert . ok ( ! raw . includes ( "correctIndices" ));
assert . ok ( ! raw . includes ( "Mercury orbits closest" ));
assert . equal ( cq . id , "q_test" );
assert . equal ( cq . kind , "quiz" );
assert . equal ( cq . options . length , 2 );
});
});
// ─── findPendingQuestion (resume re-arm) ────────────────────────────────────
describe ( "findPendingQuestion" , () => {
it ( "finds an unanswered question" , () => {
const entries = [
{ type : "custom" , customType : QUESTION_ENTRY , data : quizData () },
];
const pq = findPendingQuestion ( entries );
assert . ok ( pq );
assert . equal ( pq . id , "q_test" );
});
it ( "returns null once answered" , () => {
const entries = [
{ type : "custom" , customType : QUESTION_ENTRY , data : quizData () },
{
type : "custom" ,
customType : QUESTION_ANSWERED_ENTRY ,
data : { questionId : "q_test" , dontKnow : true , answers : [] },
},
];
assert . equal ( findPendingQuestion ( entries ), null );
});
it ( "returns the OLDEST unanswered question" , () => {
const entries = [
{ type : "custom" , customType : QUESTION_ENTRY , data : quizData ({ id : "q1" }) },
{ type : "custom" , customType : QUESTION_ENTRY , data : quizData ({ id : "q2" }) },
];
assert . equal ( findPendingQuestion ( entries ) ? . id , "q1" );
});
it ( "skips a dismissed question" , () => {
const entries = [
{ type : "custom" , customType : QUESTION_ENTRY , data : quizData () },
{
type : "custom" ,
customType : QUESTION_ANSWERED_ENTRY ,
data : { questionId : "q_test" , dismissed : true , answers : [] },
},
];
assert . equal ( findPendingQuestion ( entries ), null );
});
it ( "ignores other custom entries" , () => {
const entries = [{ type : "custom" , customType : "md-log" , data : { file : null } }];
assert . equal ( findPendingQuestion ( entries ), null );
});
});
// ─── makeId ─────────────────────────────────────────────────────────────────
describe ( "makeId" , () => {
it ( "generates unique ids" , () => {
const ids = new Set ( Array . from ({ length : 200 }, () => makeId ()));
assert . equal ( ids . size , 200 );
});
});
2026-09-12 04:45:58 +03:00
// ─── the dock selection state machine (multi-select Other coexistence) ──
// The live bug: in a multi-select ask, picking the custom "Other…" row
// cleared every already-picked option (single-select displacement logic
// applied to both modes). The machine lives here, pure and pinned.
describe ( "dock selection (toggleOption / toggleOther)" , () => {
it ( "multi-select: options and Other coexist — neither clears the other" , () => {
let sel = emptyDockSelection ();
sel = toggleOption ( "multi-select" , sel , 0 );
sel = toggleOption ( "multi-select" , sel , 1 );
assert . deepEqual ([... sel . selected ], [ 0 , 1 ]);
// Picking Other keeps the sibling selections.
sel = toggleOther ( "multi-select" , sel );
assert . deepEqual ([... sel . selected ], [ 0 , 1 ], "Other never clears picked options" );
assert . equal ( sel . otherSelected , true );
// Picking another option keeps Other selected.
sel = toggleOption ( "multi-select" , sel , 2 );
assert . equal ( sel . otherSelected , true , "options never clear a typed custom answer" );
assert . deepEqual ([... sel . selected ], [ 0 , 1 , 2 ]);
// Deselecting an option toggles it alone.
sel = toggleOption ( "multi-select" , sel , 0 );
assert . deepEqual ([... sel . selected ], [ 1 , 2 ]);
// Other toggles off alone; the siblings survive.
sel = toggleOther ( "multi-select" , sel );
assert . equal ( sel . otherSelected , false );
assert . deepEqual ([... sel . selected ], [ 1 , 2 ]);
});
it ( "multi-select: any selection clears dont-know" , () => {
let sel = { selected : new Set < number >(), otherSelected : false , dontKnow : true };
sel = toggleOption ( "multi-select" , sel , 0 );
assert . equal ( sel . dontKnow , false );
sel = toggleOther ( "multi-select" , sel );
assert . equal ( sel . dontKnow , false );
});
it ( "single-select: displacement semantics are unchanged" , () => {
let sel = emptyDockSelection ();
sel = toggleOption ( "single-select" , sel , 0 );
sel = toggleOther ( "single-select" , sel );
assert . deepEqual ([... sel . selected ], [], "Other displaces the picked option" );
assert . equal ( sel . otherSelected , true );
sel = toggleOption ( "single-select" , sel , 1 );
assert . equal ( sel . otherSelected , false , "an option displaces Other" );
assert . deepEqual ([... sel . selected ], [ 1 ]);
});
});
2026-09-27 08:55:13 +03:00
// ─── the explain tool: free-recall quizzing (owner 2026-09-27) ─────────────
describe ( "explainInputBase / explainInputProblems (the commitment contract)" , () => {
2026-09-27 09:13:34 +03:00
it ( "has ONE authoring field — no explanation remnant (owner ruling 2026-09-27)" , () => {
// The MC quiz's explanation/answer split was quiz baggage: free
// recall commits a single correctAnswer, reasoning included.
2026-09-27 08:55:13 +03:00
const keys = Object . keys ( explainInputBase . properties );
2026-09-27 09:13:34 +03:00
assert . deepEqual ( keys , [ "question" , "correctAnswer" , "details" ]);
2026-09-27 08:55:13 +03:00
});
it ( "the teach object adds the required nodeId AFTER the base fields" , () => {
const keys = Object . keys ( explainInputObject . properties );
assert . deepEqual ( keys , [... Object . keys ( explainInputBase . properties ), "nodeId" ]);
});
it ( "an empty commitment is the one authoring problem" , () => {
assert . deepEqual ( explainInputProblems ({ correctAnswer : "Full speed is c." }), []);
assert . deepEqual ( explainInputProblems ({ correctAnswer : " " }), [
"correctAnswer is required — commit to the full correct answer BEFORE posing the question." ,
]);
});
});
describe ( "buildAnswer / formatAnswerMessage: explain" , () => {
const explainQD = ( overrides? : Partial < QuestionData >) : QuestionData => ({
id : "e1" ,
kind : "explain" ,
question : "Why does the Michelson-Morley null result matter?" ,
mode : "text" ,
options : [],
2026-09-27 09:13:34 +03:00
committedAnswer : "It ruled out the luminiferous aether — a stationary aether would produce a fringe shift, none was observed — and set up special relativity." ,
2026-09-27 08:55:13 +03:00
createdAt : new Date (). toISOString (),
... overrides ,
});
it ( "snapshots the commitment + explanation; the client NEVER grades free text" , () => {
const ad = buildAnswer ( explainQD (), { text : "Something about light speed being constant?" });
assert . equal ( ad . correct , undefined , "no client verdict — the model grades in its reply" );
assert . equal ( ad . correctIndices , undefined );
2026-09-27 09:13:34 +03:00
assert . equal ( ad . committed , "It ruled out the luminiferous aether — a stationary aether would produce a fringe shift, none was observed — and set up special relativity." );
2026-09-27 08:55:13 +03:00
assert . equal ( ad . text , "Something about light speed being constant?" );
});
it ( "dontKnow records the gap with no attempted text" , () => {
// The DOCK never sends text alongside dontKnow (its submit gates it);
// buildAnswer stays a pure mapping of the request it receives.
const ad = buildAnswer ( explainQD (), { dontKnow : true });
assert . equal ( ad . dontKnow , true );
assert . equal ( ad . text , undefined );
});
it ( "formats the model's view: his answer, the immutable commitment, and the grading instruction" , () => {
const text = formatAnswerMessage ( buildAnswer ( explainQD (), { text : "No idea about aether." }));
assert . ok ( text . startsWith ( "[Explain answer]" ));
assert . match ( text , /The learner answered: No idea about aether\./ );
assert . match ( text , /Your committed correct answer \(immutable/ );
assert . match ( text , /luminiferous aether/ );
assert . match ( text , /Grade his answer strictly/ );
});
});