practice slice 5 — UI: list page, exam page, creation dialog, sidebar entries
components/practice-dialog.tsx: portal dialog (delete-session shape), three dial rows (SettingSlider markup, anchor text as display: flashcard-competition / near the lesson-far transfer / theory- application, defaults 0.5), session picker (generic mode, /api/chats) or preselected lock (right-click path); 400 empty_selection renders 'This session has no propositions yet', 503 renders the server line + Open settings deep link (useSettingsDialog, zero-keys precedent). Pure body exported for SSR pins. components/practice-list.tsx: rows (title, date, ready/total problems, graded count, status word; destructive tint on error), click-through, delete affordance → portaled confirm (DeleteSetBody: no mode checkbox — a set has no preserve-graph question), 2s reveal-poll while any set is partitioning/generating. components/practice-exam.tsx: numbered problems; generating stubs (BrailleSpinner + 'Generating problem…', propositions never surface); ready statements/solutions/rubrics/ feedback as MessageResponse markdown with restoreMathEscapes at the display seam; per-problem textarea + useDictation instance with the DictationBar rendered only on box focus; one Submit answers POST per set; graded reveal (verdict chip, feedback, model solution, rubric) with read-only answer; polling while not ready OR grading-live (activity feed practice rows — dropped grades terminate the poll). app/practice/page.tsx (force-dynamic, listPracticeSets) + app/practice/ [id]/page.tsx (getPracticeSet + notFound). Sidebar: quiet Practice nav row under New session (GraduationCapIcon, active on /practice) + 'Practice this session's propositions' in the session right-click below the hairline, above CoverageMenuBlock — opens the dialog preselected. tests/render-practice.test.tsx — 15 pins: dials + anchors, picker selection gating, preselected lock, empty-selection line, 503 deep link, list rows/counts/empty, delete confirm + inline failure, exam generating (no proposition text, no answer boxes, no submit), ready (markdown statement, gated submit), graded reveal + read-only + sibling answerable, failed problem/set lines, math healing. 1129/1129, tsc 0, lint 0, build 0.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* One exam page (slice 5): the set server-prefetched, notFound() on an
|
||||
* unknown id; the client body owns polling (generation + grading),
|
||||
* answering, and the reveal.
|
||||
*/
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPracticeSet } from "@/src/server";
|
||||
import { PracticeExam } from "@/components/practice-exam";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const set = getPracticeSet(id);
|
||||
if (!set) notFound();
|
||||
return <PracticeExam initial={set} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The practice list page (slice 5): the set list, server-prefetched
|
||||
* (force-dynamic — a static list would go stale between requests), the
|
||||
* client body owns polling + dialogs. Reads through the server barrel
|
||||
* only (app/ never touches src/store directly).
|
||||
*/
|
||||
import { listPracticeSets } from "@/src/server";
|
||||
import { PracticeListPage } from "@/components/practice-list";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <PracticeListPage initialSets={listPracticeSets()} />;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The problem-set creation dialog (practice slice 5, 2026-09-29): the
|
||||
* three parameter dials + the session picker → POST /api/practice/sets
|
||||
* → router.push to the new exam page. In the delete-session dialog's
|
||||
* shape (portaled z-50, pure body exported for SSR pins).
|
||||
*
|
||||
* TWO OPEN MODES:
|
||||
* - generic (the list page's "New problem set"): a session picker —
|
||||
* the selection is "everything this session touched";
|
||||
* - preselected (the sidebar's "Practice this session's
|
||||
* propositions"): picker hidden, the session shown locked.
|
||||
*
|
||||
* Honest failures inline: 400 empty_selection → "This session has no
|
||||
* propositions yet" (verbatim per brief); 503 (models/key refusal) →
|
||||
* the server's line + an "Open settings" deep link (the zero-keys
|
||||
* precedent — setup owns the fix, the dialog owns the pointer).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import type { ChatListItem } from "@/src/core";
|
||||
|
||||
/** The three dials — key order matches PracticeParams; anchors are the
|
||||
* honest display (a raw 0..1 number tells the learner nothing). */
|
||||
export const PRACTICE_DIALS = [
|
||||
{ key: "searchSolutionRatio", label: "Search ↔ Solve", anchors: "flashcard · competition" },
|
||||
{ key: "cueDistance", label: "Cue distance", anchors: "near the lesson · far transfer" },
|
||||
{ key: "presentation", label: "Presentation", anchors: "theory · application" },
|
||||
] as const;
|
||||
|
||||
export interface PracticeFailure {
|
||||
status: number;
|
||||
error: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** The dialog's pure body — exported for SSR pins (the delete-session /
|
||||
* settings body pattern). The wrapper owns state + fetch + portal. */
|
||||
export function PracticeDialogBody({
|
||||
values,
|
||||
onDial,
|
||||
chats,
|
||||
selectedId,
|
||||
onSelect,
|
||||
lockedTitle,
|
||||
busy,
|
||||
failure,
|
||||
onOpenSettings,
|
||||
onClose,
|
||||
onRun,
|
||||
}: {
|
||||
values: Record<(typeof PRACTICE_DIALS)[number]["key"], number>;
|
||||
onDial: (key: (typeof PRACTICE_DIALS)[number]["key"], v: number) => void;
|
||||
/** Null = preselected mode (picker hidden, `lockedTitle` shown). */
|
||||
chats: ChatListItem[] | null;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
lockedTitle: string | null;
|
||||
busy: boolean;
|
||||
failure: PracticeFailure | null;
|
||||
onOpenSettings: () => void;
|
||||
onClose: () => void;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
const canRun = !busy && (lockedTitle !== null || selectedId !== null);
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New problem set"
|
||||
data-testid="practice-dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[85vh] w-[32rem] max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-card p-5 shadow-xl"
|
||||
>
|
||||
<h2 className="font-title text-2xl tracking-tight">New problem set</h2>
|
||||
|
||||
{/* The session: everything it touched (any provenance point). */}
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{lockedTitle !== null ? (
|
||||
<div data-testid="practice-dialog-locked" className="rounded-lg border bg-muted/40 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">From session — </span>
|
||||
<span className="font-medium">{lockedTitle}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Session</span>
|
||||
<div className="max-h-40 overflow-y-auto rounded-lg border" data-testid="practice-dialog-sessions">
|
||||
{(chats ?? []).map((chat) => (
|
||||
<button
|
||||
type="button"
|
||||
key={chat.id}
|
||||
data-testid={`practice-session-${chat.id}`}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
|
||||
selectedId === chat.id ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{chat.title}</span>
|
||||
{selectedId === chat.id && <span className="ml-2 text-xs text-muted-foreground">selected</span>}
|
||||
</button>
|
||||
))}
|
||||
{(chats ?? []).length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">No sessions yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The three dials (SettingSlider's markup, anchors as display). */}
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{PRACTICE_DIALS.map((dial) => (
|
||||
<label key={dial.key} className="flex flex-col gap-1">
|
||||
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
{dial.label}
|
||||
<span className="text-muted-foreground/80">{dial.anchors}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={values[dial.key]}
|
||||
data-testid={`dial-${dial.key}`}
|
||||
onChange={(e) => onDial(dial.key, Number(e.target.value))}
|
||||
className="h-1.5 w-full accent-primary"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{failure && (
|
||||
<div data-testid="practice-dialog-error" className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{failure.code === "empty_selection" ? "This session has no propositions yet" : failure.error}
|
||||
{failure.status === 503 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="practice-dialog-open-settings"
|
||||
onClick={onOpenSettings}
|
||||
className="ml-2 underline underline-offset-2"
|
||||
>
|
||||
Open settings
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="practice-dialog-create"
|
||||
onClick={onRun}
|
||||
disabled={!canRun}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The stateful wrapper: fetches the session list (generic mode), runs
|
||||
* the create POST, navigates on 202. Null `preselected` = generic. */
|
||||
export function PracticeDialog({
|
||||
preselected,
|
||||
onClose,
|
||||
}: {
|
||||
preselected: { chatId: string; title: string } | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { open: openSettings } = useSettingsDialog();
|
||||
const [values, setValues] = useState<Record<(typeof PRACTICE_DIALS)[number]["key"], number>>({
|
||||
searchSolutionRatio: 0.5,
|
||||
cueDistance: 0.5,
|
||||
presentation: 0.5,
|
||||
});
|
||||
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failure, setFailure] = useState<PracticeFailure | null>(null);
|
||||
|
||||
// Generic mode: the picker's truth is the chat list (fresh on open).
|
||||
useEffect(() => {
|
||||
if (preselected) return;
|
||||
let alive = true;
|
||||
fetch("/api/chats", { cache: "no-store" })
|
||||
.then((res) => res.json() as Promise<{ chats: ChatListItem[] }>)
|
||||
.then((d) => {
|
||||
if (alive) setChats(d.chats);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setChats([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [preselected]);
|
||||
|
||||
// Esc closes (claimed, the delete-session discipline).
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const chatId = preselected?.chatId ?? selectedId;
|
||||
if (!chatId || busy) return;
|
||||
setBusy(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
const res = await fetch("/api/practice/sets", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ chatId, params: values }),
|
||||
});
|
||||
const body = (await res.json()) as { id?: string; error?: string; code?: string };
|
||||
if (!res.ok || !body.id) {
|
||||
setFailure({ status: res.status, error: body.error ?? `Create failed (${res.status})`, code: body.code });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
router.push(`/practice/${body.id}`);
|
||||
} catch {
|
||||
setFailure({ status: 0, error: "Create failed — network error. Try again." });
|
||||
setBusy(false);
|
||||
}
|
||||
}, [preselected, selectedId, busy, values, router]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||
onClick={() => {
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
>
|
||||
<PracticeDialogBody
|
||||
values={values}
|
||||
onDial={(key, v) => setValues((prev) => ({ ...prev, [key]: v }))}
|
||||
chats={preselected ? null : (chats ?? [])}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
lockedTitle={preselected?.title ?? null}
|
||||
busy={busy}
|
||||
failure={failure}
|
||||
onOpenSettings={() => openSettings()}
|
||||
onClose={onClose}
|
||||
onRun={() => void run()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The exam page (practice slice 5): one problem set, numbered problems,
|
||||
* markdown statements, a textarea + dictation per problem (the bar
|
||||
* renders only on the FOCUSED box — thinking out loud is the primary
|
||||
* answering mode), one submit for the set, and the post-grade reveal
|
||||
* (verdict chip, feedback, model solution, rubric) under the box.
|
||||
*
|
||||
* DISPLAY-SEAM MATH HEALING: statements/solutions/rubrics/feedback ride
|
||||
* the same streaming-args parse as quiz/ask strings — restoreMathEscapes
|
||||
* heals them at render (the same helper the tool-part seam uses).
|
||||
*
|
||||
* POLLING: 2s while the set isn't ready (stubs → filled), and while a
|
||||
* grading run is live for this set (activity feed's practice rows) with
|
||||
* answered-but-ungraded problems outstanding. A settled page goes quiet
|
||||
* (a dropped grade terminates honestly: no live row → no poll).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { GradeVerdict, Problem, ProblemSet } from "@/src/core";
|
||||
import { restoreMathEscapes } from "@/src/core";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||
import { DictationBar, useDictation } from "@/components/dictation";
|
||||
|
||||
/** The verdict chip's tone — plain, no invented color language. */
|
||||
export function verdictClass(verdict: GradeVerdict): string {
|
||||
if (verdict === "solid") return "bg-green-500/15 text-green-500";
|
||||
if (verdict === "partial") return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
|
||||
return "bg-destructive/15 text-destructive";
|
||||
}
|
||||
|
||||
/** One answerable problem: textarea + focus-gated dictation bar. */
|
||||
export function ProblemBox({
|
||||
problem,
|
||||
draft,
|
||||
onDraft,
|
||||
}: {
|
||||
problem: Pick<Problem, "id">;
|
||||
draft: string;
|
||||
onDraft: (text: string) => void;
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
// The dictation appends into THIS box only (per-box instance).
|
||||
const dictation = useDictation({
|
||||
append: (text) => onDraft(text),
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid={`problem-box-${problem.id}`}>
|
||||
<textarea
|
||||
data-testid={`answer-${problem.id}`}
|
||||
value={draft}
|
||||
rows={5}
|
||||
placeholder="Think out loud — dictation or typing, both fine."
|
||||
onChange={(e) => onDraft(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
className="w-full resize-y rounded-lg border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
{focused && <DictationBar dictation={dictation} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The exam page's pure body — exported for SSR pins. */
|
||||
export function PracticeExamBody({
|
||||
set,
|
||||
drafts,
|
||||
onDraft,
|
||||
onSubmit,
|
||||
submitting,
|
||||
submitError,
|
||||
grading,
|
||||
}: {
|
||||
set: ProblemSet;
|
||||
drafts: Record<string, string>;
|
||||
onDraft: (problemId: string, text: string) => void;
|
||||
onSubmit: () => void;
|
||||
submitting: boolean;
|
||||
submitError: string | null;
|
||||
grading: boolean;
|
||||
}) {
|
||||
const answerable = set.problems.filter((p) => !p.grade);
|
||||
const anyDraft = answerable.some((p) => (drafts[p.id] ?? p.answer ?? "") !== "");
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6" data-testid="practice-exam">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="font-title text-3xl tracking-tight">{set.title}</h1>
|
||||
<p className="text-sm text-muted-foreground" data-testid="practice-exam-status">
|
||||
{set.status === "partitioning" && "Partitioning the chains…"}
|
||||
{set.status === "generating" && `Generating — ${set.problems.filter((p) => p.status === "ready").length}/${set.problems.length} problems ready`}
|
||||
{set.status === "error" && (set.error ?? "Something went wrong")}
|
||||
{set.status === "ready" &&
|
||||
(grading ? "Grading your answers…" : `${set.problems.length} problems · ${set.problems.filter((p) => p.grade).length} graded`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{set.problems.map((problem, i) => (
|
||||
<section key={problem.id} className="rounded-xl border bg-card p-4" data-testid={`problem-${problem.id}`}>
|
||||
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem {i + 1}</h2>
|
||||
{problem.status === "generating" ? (
|
||||
// The stub: propositions NEVER surface (never-announce law).
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" data-testid={`generating-${problem.id}`}>
|
||||
<BrailleSpinner className="size-4" />
|
||||
Generating problem…
|
||||
</div>
|
||||
) : problem.status === "error" ? (
|
||||
<p className="text-sm text-destructive" data-testid={`problem-error-${problem.id}`}>
|
||||
This problem failed to generate{problem.error ? ` — ${problem.error}` : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="lh-prose text-sm">
|
||||
<MessageResponse mode="static">{restoreMathEscapes(problem.statement)}</MessageResponse>
|
||||
</div>
|
||||
{problem.grade ? (
|
||||
// Graded: the answer read-only, the reveal below.
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<textarea
|
||||
data-testid={`answer-${problem.id}`}
|
||||
value={problem.answer ?? ""}
|
||||
readOnly
|
||||
rows={5}
|
||||
className="w-full resize-y rounded-lg border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"
|
||||
/>
|
||||
<div className="rounded-lg border bg-background p-3" data-testid={`reveal-${problem.id}`}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium uppercase ${verdictClass(problem.grade.verdict)}`}>
|
||||
{problem.grade.verdict}
|
||||
</span>
|
||||
</div>
|
||||
<div className="lh-prose text-sm" data-testid={`feedback-${problem.id}`}>
|
||||
<MessageResponse mode="static">{restoreMathEscapes(problem.grade.feedback)}</MessageResponse>
|
||||
</div>
|
||||
<div className="mt-3 text-xs uppercase tracking-wide text-muted-foreground">Model solution</div>
|
||||
<div className="lh-prose text-sm" data-testid={`solution-${problem.id}`}>
|
||||
<MessageResponse mode="static">{restoreMathEscapes(problem.solution)}</MessageResponse>
|
||||
</div>
|
||||
<div className="mt-3 text-xs uppercase tracking-wide text-muted-foreground">Rubric</div>
|
||||
<div className="lh-prose text-sm" data-testid={`rubric-${problem.id}`}>
|
||||
<MessageResponse mode="static">{restoreMathEscapes(problem.rubric)}</MessageResponse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Answerable: the box (graded problems lock above).
|
||||
<div className="mt-4">
|
||||
<ProblemBox problem={problem} draft={drafts[problem.id] ?? problem.answer ?? ""} onDraft={(text) => onDraft(problem.id, text)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{set.status === "ready" && answerable.length > 0 && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="practice-submit"
|
||||
onClick={onSubmit}
|
||||
disabled={!anyDraft || submitting}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit answers"}
|
||||
</button>
|
||||
{submitError && (
|
||||
<p data-testid="practice-submit-error" className="text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PracticeExam({ initial }: { initial: ProblemSet }) {
|
||||
const [set, setSet] = useState<ProblemSet>(initial);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [gradingLive, setGradingLive] = useState(false);
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/practice/sets/${initial.id}`, { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const d = (await res.json()) as { set: ProblemSet };
|
||||
if (mounted.current) setSet(d.set);
|
||||
} catch {
|
||||
// transient poll failures keep the last snapshot
|
||||
}
|
||||
}, [initial.id]);
|
||||
|
||||
// The poll: while not ready (generation), or while a grading run is
|
||||
// live for this set with outstanding ungraded answers. The activity
|
||||
// feed is the grading-liveness truth — a dropped grade (no live row)
|
||||
// settles the page instead of polling forever.
|
||||
const pendingGrade = set.problems.some((p) => p.answer && !p.grade);
|
||||
const needsPoll = set.status !== "ready" || (gradingLive && pendingGrade);
|
||||
useEffect(() => {
|
||||
if (!needsPoll) return;
|
||||
let stopped = false;
|
||||
const tick = async () => {
|
||||
await refetch();
|
||||
try {
|
||||
const res = await fetch("/api/activity", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const feed = (await res.json()) as { practice?: Array<{ id: string; kind: string }> };
|
||||
if (!stopped && mounted.current) {
|
||||
setGradingLive((feed.practice ?? []).some((row) => row.id === initial.id));
|
||||
}
|
||||
} catch {
|
||||
// keep the last liveness read
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const t = setInterval(() => void tick(), 2000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [needsPoll, refetch, initial.id]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (submitting) return;
|
||||
const answers = set.problems
|
||||
.filter((p) => !p.grade)
|
||||
.map((p) => ({ problemId: p.id, text: drafts[p.id] ?? p.answer ?? "" }))
|
||||
.filter((a) => a.text.trim() !== "");
|
||||
if (answers.length === 0) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/practice/sets/${set.id}/answers`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setSubmitError(body.error ?? `Submit failed (${res.status})`);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setSubmitting(false);
|
||||
setGradingLive(true); // grades land via the poll
|
||||
await refetch();
|
||||
} catch {
|
||||
setSubmitError("Submit failed — network error. Try again.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [submitting, set, drafts, refetch]);
|
||||
|
||||
return (
|
||||
<PracticeExamBody
|
||||
set={set}
|
||||
drafts={drafts}
|
||||
onDraft={(problemId, text) => setDrafts((prev) => ({ ...prev, [problemId]: text }))}
|
||||
onSubmit={() => void submit()}
|
||||
submitting={submitting}
|
||||
submitError={submitError}
|
||||
grading={gradingLive && pendingGrade}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The problem-set list (practice slice 5): rows of sets — title, date,
|
||||
* status, problem/graded counts — click through to the exam page; a
|
||||
* delete affordance in the delete-session dialog's shape; "New problem
|
||||
* set" opens the creation dialog. Polls /api/practice/sets (2s, the
|
||||
* graph-view reveal-poll idiom) while ANY set is still generating, so
|
||||
* statuses live-update; a settled list stops polling.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPortal } from "react-dom";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import type { ProblemSet } from "@/src/core";
|
||||
import { PracticeDialog } from "@/components/practice-dialog";
|
||||
|
||||
/** A set's status word — plain and honest, no invented progress. */
|
||||
export function setStatusWord(set: Pick<ProblemSet, "status">): string {
|
||||
return set.status === "partitioning" ? "partitioning" : set.status === "generating" ? "generating" : set.status;
|
||||
}
|
||||
|
||||
/** The list's pure body — exported for SSR pins. Rows are buttons
|
||||
* (click-through); the delete affordance is a nested icon button. */
|
||||
export function PracticeListBody({
|
||||
sets,
|
||||
onOpen,
|
||||
onDelete,
|
||||
onNew,
|
||||
}: {
|
||||
sets: ProblemSet[];
|
||||
onOpen: (id: string) => void;
|
||||
onDelete: (set: ProblemSet) => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 p-6" data-testid="practice-list">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-title text-3xl tracking-tight">Practice</h1>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="practice-new"
|
||||
onClick={onNew}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
New problem set
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sets.length === 0 && (
|
||||
<div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground" data-testid="practice-empty">
|
||||
No problem sets yet — create one from a session.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{sets.map((set) => {
|
||||
const ready = set.problems.filter((p) => p.status === "ready").length;
|
||||
const graded = set.problems.filter((p) => p.grade).length;
|
||||
return (
|
||||
<div key={set.id} className="flex items-stretch gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`practice-row-${set.id}`}
|
||||
onClick={() => onOpen(set.id)}
|
||||
className="flex flex-1 items-center justify-between gap-4 rounded-xl border bg-card px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{set.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{set.createdAt.slice(0, 10)} · {set.problems.length > 0 ? `${ready}/${set.problems.length} problems` : "no problems yet"}
|
||||
{graded > 0 ? ` · ${graded} graded` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
data-testid={`practice-status-${set.id}`}
|
||||
className={`shrink-0 text-xs uppercase tracking-wide ${
|
||||
set.status === "error" ? "text-destructive" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{setStatusWord(set)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`practice-delete-${set.id}`}
|
||||
aria-label={`Delete ${set.title}`}
|
||||
onClick={() => onDelete(set)}
|
||||
className="flex items-center rounded-xl border px-3 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The delete confirm — the delete-session dialog's shape (no mode
|
||||
* checkbox: a set has no preserve-graph question). Pure body + portal
|
||||
* wrapper. */
|
||||
export function DeleteSetBody({
|
||||
title,
|
||||
busy,
|
||||
failure,
|
||||
onClose,
|
||||
onRun,
|
||||
}: {
|
||||
title: string;
|
||||
busy: boolean;
|
||||
failure: string | null;
|
||||
onClose: () => void;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Delete problem set"
|
||||
data-testid="delete-set-dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
|
||||
>
|
||||
<h2 className="font-title text-2xl tracking-tight text-destructive">Delete problem set?</h2>
|
||||
<p className="mt-2 font-mono text-sm">{title}</p>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
The set and its problems are removed. Nothing in the graph or your sessions changes.
|
||||
</p>
|
||||
{failure && (
|
||||
<p data-testid="delete-set-error" className="mt-3 text-sm text-destructive">
|
||||
{failure}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="delete-set-run"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:opacity-40"
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PracticeListPage({ initialSets }: { initialSets: ProblemSet[] }) {
|
||||
const router = useRouter();
|
||||
const [sets, setSets] = useState<ProblemSet[]>(initialSets);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleting, setDeleting] = useState<ProblemSet | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/practice/sets", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const d = (await res.json()) as { sets: ProblemSet[] };
|
||||
setSets(d.sets);
|
||||
} catch {
|
||||
// a transient failure on the poll keeps the last snapshot
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The reveal poll: while any set is partitioning/generating, statuses
|
||||
// live-update; a settled list goes quiet.
|
||||
const anyRunning = sets.some((s) => s.status === "partitioning" || s.status === "generating");
|
||||
useEffect(() => {
|
||||
if (!anyRunning) return;
|
||||
const t = setInterval(() => void refresh(), 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [anyRunning, refresh]);
|
||||
|
||||
const runDelete = useCallback(async () => {
|
||||
if (!deleting || deleteBusy) return;
|
||||
setDeleteBusy(true);
|
||||
setDeleteFailure(null);
|
||||
try {
|
||||
const res = await fetch(`/api/practice/sets/${deleting.id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setDeleteFailure(body.error ?? `Delete failed (${res.status})`);
|
||||
setDeleteBusy(false);
|
||||
return;
|
||||
}
|
||||
setDeleting(null);
|
||||
setDeleteBusy(false);
|
||||
await refresh();
|
||||
} catch {
|
||||
setDeleteFailure("Delete failed — network error. Try again.");
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}, [deleting, deleteBusy, refresh]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PracticeListBody
|
||||
sets={sets}
|
||||
onOpen={(id) => router.push(`/practice/${id}`)}
|
||||
onDelete={(set) => setDeleting(set)}
|
||||
onNew={() => setCreating(true)}
|
||||
/>
|
||||
{creating && <PracticeDialog preselected={null} onClose={() => setCreating(false)} />}
|
||||
{deleting &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||
onClick={() => {
|
||||
if (!deleteBusy) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<DeleteSetBody
|
||||
title={deleting.title}
|
||||
busy={deleteBusy}
|
||||
failure={deleteFailure}
|
||||
onClose={() => setDeleting(null)}
|
||||
onRun={() => void runDelete()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+40
-1
@@ -6,9 +6,10 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
||||
import type { ChatDeleteMode } from "@/src/core";
|
||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
|
||||
import { GraduationCapIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
|
||||
import { ActivitySection } from "@/components/activity-dock";
|
||||
import { CoverageMenuBlock } from "@/components/extraction-coverage";
|
||||
import { PracticeDialog } from "@/components/practice-dialog";
|
||||
import { LhMark } from "@/components/lh-mark";
|
||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
|
||||
@@ -397,6 +398,9 @@ function SessionsSidebar() {
|
||||
const ctxAnchorRef = useRef<HTMLElement | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<ChatListItem | null>(null);
|
||||
// Practice slice 5 (2026-09-29): the right-click "Practice this session's
|
||||
// propositions" opens the creation dialog PRESELECTED on that session.
|
||||
const [practiceChat, setPracticeChat] = useState<{ chatId: string; title: string } | null>(null);
|
||||
|
||||
const openContext = (e: React.MouseEvent<HTMLButtonElement>, chat: ChatListItem) => {
|
||||
e.preventDefault(); // suppress the native menu
|
||||
@@ -591,6 +595,21 @@ function SessionsSidebar() {
|
||||
<span className="sr-only">create a new session</span>
|
||||
</button>
|
||||
|
||||
{/* Practice (slice 5, 2026-09-29): the set list's entry — the same
|
||||
quiet row language as New session, active-state lit while any
|
||||
/practice route is open. GraduationCapIcon to match the dock. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="nav-practice"
|
||||
onClick={() => router.push("/practice")}
|
||||
className={`mx-3 mb-1 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider transition-colors hover:bg-accent hover:text-foreground ${
|
||||
pathname.startsWith("/practice") ? "bg-accent text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<GraduationCapIcon className="size-3" />
|
||||
Practice
|
||||
</button>
|
||||
|
||||
<div className="mx-3 mb-1 flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
|
||||
<Hint label="Search sessions" action="sessions-search" side="bottom">
|
||||
@@ -717,6 +736,21 @@ function SessionsSidebar() {
|
||||
Delete…
|
||||
</button>
|
||||
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
|
||||
{/* Practice (slice 5): the session's propositions → a problem set.
|
||||
Sits ABOVE the coverage block — an action like Extract, not
|
||||
management like Rename/Delete. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ctx-practice"
|
||||
onClick={() => {
|
||||
const target = chats?.find((c) => c.id === ctxChatId);
|
||||
if (target) setPracticeChat({ chatId: target.id, title: target.title });
|
||||
setCtxChatId(null);
|
||||
}}
|
||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Practice this session's propositions
|
||||
</button>
|
||||
<CoverageMenuBlock chatId={ctxChatId} />
|
||||
</MenuShell>
|
||||
)}
|
||||
@@ -728,6 +762,11 @@ function SessionsSidebar() {
|
||||
onClose={() => setDeleting(null)}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
{/* The preselected practice dialog (slice 5) — portaled by the
|
||||
component itself, here for state locality like the delete dialog. */}
|
||||
{practiceChat && (
|
||||
<PracticeDialog preselected={practiceChat} onClose={() => setPracticeChat(null)} />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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";
|
||||
import { PracticeExamBody } from "../components/practice-exam.tsx";
|
||||
import type { ChatListItem, Problem, ProblemSet } from "../src/core";
|
||||
|
||||
/**
|
||||
* The practice UI surfaces (slice 5, 2026-09-29): creation dialog (dials
|
||||
* + session picker + preselected lock), 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 = { searchSolutionRatio: 0.5, cueDistance: 0.5, presentation: 0.5 };
|
||||
|
||||
const dialValues = {
|
||||
searchSolutionRatio: 0.7,
|
||||
cueDistance: 0.5,
|
||||
presentation: 0.3,
|
||||
};
|
||||
|
||||
function chats(): ChatListItem[] {
|
||||
return [
|
||||
{ id: "chat_1", title: "Ethics lesson", updatedAt: "2026-09-29T10:00:00.000Z", createdAt: "2026-09-29T09:00:00.000Z", busy: false } as ChatListItem,
|
||||
{ id: "chat_2", title: "Physics", updatedAt: "2026-09-28T10:00:00.000Z", createdAt: "2026-09-28T09:00:00.000Z", busy: false } as ChatListItem,
|
||||
];
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const base = {
|
||||
values: dialValues,
|
||||
onDial: () => {},
|
||||
chats: chats(),
|
||||
selectedId: null,
|
||||
onSelect: () => {},
|
||||
lockedTitle: null,
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
it("session picker: titled rows, selection lights up, Create gated until picked", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
|
||||
assert.ok(html.includes('data-testid="practice-dialog-sessions"'));
|
||||
assert.ok(html.includes("Ethics lesson"));
|
||||
assert.ok(html.includes('disabled=""'), "no session picked — Create disabled");
|
||||
|
||||
const picked = renderToStaticMarkup(<PracticeDialogBody {...base} selectedId="chat_2" />);
|
||||
assert.ok(picked.includes('data-testid="practice-session-chat_2"'));
|
||||
assert.match(picked, /Physics<\/span>.*selected/s);
|
||||
});
|
||||
|
||||
it("preselected mode: picker hidden, the session locked", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PracticeDialogBody {...base} chats={null} lockedTitle="Ethics lesson" />,
|
||||
);
|
||||
assert.ok(html.includes('data-testid="practice-dialog-locked"'));
|
||||
assert.ok(!html.includes('data-testid="practice-dialog-sessions"'));
|
||||
assert.ok(!html.includes('disabled=""'), "preselected — Create enabled");
|
||||
});
|
||||
|
||||
it("empty selection renders the honest inline line", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PracticeDialogBody {...base} 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("This session has no propositions yet"));
|
||||
});
|
||||
|
||||
it("503 renders the server's line + the settings deep link", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PracticeDialogBody {...base} lockedTitle="Ethics lesson" 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"'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user