Files
learnharness/tests/practice-store.test.ts
T
amosblomqvist e92679254d practice — discretized parameter axes: named levels, never a number (owner-approved 2026-09-29)
Contracts: LadderRung 0-4, CueDistance 0-2, Presentation theory|
application; PracticeParams carries the three named levels. Doc
comments hold the three definition tables verbatim (rung names +
definitions from the ladder doctrine; cue levels; presentation
levels).

Core: LADDER_RUNGS/CUE_LEVELS/PRESENTATIONS tables in src/core/
practice.ts (definitions verbatim) — ONE source spoken by the agents'
prompts, the dialog's stepped controls, and the exam header; core
barrel re-exports practice.ts (pure domain, browser-safe).

Agents: Part I's ladder intro now 'five rungs, numbered; the request
arrives as a rung' with bullets numbered 0-4 (definitions untouched);
Part III's parameters block replaced with the named-level doctrine
(rung = exact class, no continuum; cue counts lesson examples AND
previous problems' situations; presentation dresses, never changes);
partitioner's RESPECT THE PARAMETERS replaced (low rungs = many small
assignments, high = larger; far cue makes confusables-together MORE
important; presentation does not shape the partition). renderParams
outputs the exact named-level block. Anchor/0..1 language swept dead.

Service + store: strict union validation — floats between levels,
out-of-range, wrong strings, legacy dial shapes all 400 at the API
and fail isProblemSet (legacy sets skipped from lists; no migration —
data/practice is disposable scratch, owner wipes before testing).

UI: dialog = 5-detent ladder slider + 3-detent cue slider (each shows
the level NAME + one-line definition) + Theory|Application segmented
toggle; defaults rung 2 / cue 1 / theory; Create posts the new shape.
Exam header gains the levels line ('Direct exercise · Adjacent
context · Theory').

Tests: renderParams exact-block pin; strict-validation pins (new
tests/practice-service.test.ts — bad unions 400, legacy shape 400);
store pins (legacy floats + 2.5 rung fail); render pins (detents +
names + definitions, defaults, detent-swap, toggle active segment,
levels line); every params fixture moved off floats. 1138/1138, tsc
0, lint 0, build 0.

Docs: architecture.md — discretization paragraph (compact tables,
named-level invocation, defaults, one-table source), UI mentions;
README axes sentence.
2026-09-29 20:06:35 +03:00

154 lines
6.5 KiB
TypeScript

/**
* 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("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: { verdict: "solid", feedback: "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?.verdict, "solid");
});
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("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("grade verdicts are the three-level union", () => {
const withGrade = (verdict: string): unknown => ({
...init(),
id: "ps_valid",
problems: [
{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "ready", grade: { verdict, feedback: "", gradedAt: "t" } },
],
});
assert.ok(isProblemSet(withGrade("solid")));
assert.ok(isProblemSet(withGrade("partial")));
assert.ok(isProblemSet(withGrade("missed")));
assert.ok(!isProblemSet(withGrade("excellent")));
});
});