Merge pull request #204 from CoderLambert/product/179-assessment-session-review

feat: add Assessment session review loop
This commit was merged in pull request #204.
This commit is contained in:
CoderLambert
2026-09-14 18:46:19 +08:00
committed by GitHub
8 changed files with 477 additions and 4 deletions
@@ -0,0 +1,49 @@
function clone(value) {
return structuredClone(value);
}
function latestAttemptsByQuestion(attempts = []) {
const byQuestion = new Map();
for (const attempt of attempts) {
const current = byQuestion.get(attempt.questionId);
if (!current || String(attempt.submittedAt) >= String(current.submittedAt)) {
byQuestion.set(attempt.questionId, attempt);
}
}
return byQuestion;
}
export function createAssessmentSessionReview({ session, attempts = [] }) {
if (!session || session.status !== "completed") return null;
const byQuestion = latestAttemptsByQuestion(attempts);
const items = session.items
.map((item, index) => {
const attempt = byQuestion.get(item.questionId);
if (!attempt) return null;
return {
index,
questionId: item.questionId,
revision: item.revision,
snapshot: clone(item.snapshot),
answer: clone(attempt.answer),
correct: attempt.correct,
submittedAt: attempt.submittedAt,
};
})
.filter(Boolean)
.sort((left, right) => Number(left.correct) - Number(right.correct) || left.index - right.index);
const correctCount = items.filter((item) => item.correct).length;
return Object.freeze({
sessionId: session.id,
learningUnitId: session.learningUnitId,
startedAt: session.startedAt,
completedAt: session.completedAt,
total: session.items.length,
answered: items.length,
correctCount,
incorrectCount: items.length - correctCount,
items: Object.freeze(items),
});
}
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getActiveAssessmentRuntime } from "../composition/assessmentRuntime.js";
const EMPTY_STATE = Object.freeze({ history: [], review: null, loading: false, error: null, storageNotice: null });
export function useAssessmentReview({ learningUnitId, activeSessionId = null }) {
const [state, setState] = useState(EMPTY_STATE);
const requestRef = useRef(0);
const load = useCallback(async (sessionId = activeSessionId) => {
const requestId = ++requestRef.current;
if (!learningUnitId) {
setState(EMPTY_STATE);
return;
}
const runtime = getActiveAssessmentRuntime();
if (!runtime) return;
setState((current) => ({ ...current, loading: true, error: null, storageNotice: runtime.storageNotice ?? null }));
try {
const history = await runtime.sessionLifecycle.listCompletedReviews({ learningUnitId });
const selectedId = sessionId && history.some((entry) => entry?.sessionId === sessionId)
? sessionId
: history[0]?.sessionId ?? null;
const review = selectedId
? await runtime.sessionLifecycle.review({ learningUnitId, sessionId: selectedId })
: null;
if (requestRef.current !== requestId) return;
setState({
history: history.filter(Boolean),
review,
loading: false,
error: null,
storageNotice: runtime.storageNotice ?? null,
});
} catch (error) {
if (requestRef.current !== requestId) return;
setState((current) => ({
...current,
loading: false,
error: error?.message || "无法加载评测回顾",
storageNotice: runtime.storageNotice ?? null,
}));
}
}, [activeSessionId, learningUnitId]);
useEffect(() => {
void load(activeSessionId);
return () => {
requestRef.current += 1;
};
}, [activeSessionId, load]);
const selectSession = useCallback((sessionId) => {
void load(sessionId);
}, [load]);
return { ...state, selectSession, reload: load };
}
@@ -1,4 +1,5 @@
import { AssessmentService } from "../application/AssessmentService.js";
import { createAssessmentSessionReview } from "../application/assessmentReview.js";
import { createAssessmentCapabilities } from "../ai/assessmentTools.js";
import {
createIndexedDbAssessmentRepository,
@@ -72,6 +73,19 @@ export async function createAssessmentRuntime({
const currentIndex = Math.max(0, session.items.findIndex((item) => !answeredQuestionIds.has(item.questionId)));
return { session, attempts, currentIndex };
},
async review({ learningUnitId, sessionId }) {
const session = await repository.getSession({ learningUnitId, sessionId });
if (!session || session.status !== "completed") return null;
const attempts = await repository.listAttempts({ sessionId: session.id });
return createAssessmentSessionReview({ session, attempts });
},
async listCompletedReviews({ learningUnitId }) {
const sessions = await repository.listSessions({ learningUnitId, status: "completed" });
return Promise.all(sessions.map(async (session) => {
const attempts = await repository.listAttempts({ sessionId: session.id });
return createAssessmentSessionReview({ session, attempts });
}));
},
});
const runtime = Object.freeze({
+20 -1
View File
@@ -1,24 +1,43 @@
import { useAssessmentReview } from "../application/useAssessmentReview.js";
import { AssessmentPracticePane } from "./AssessmentPracticePane.jsx";
import { AssessmentQuestionManager } from "./AssessmentQuestionManager.jsx";
import { AssessmentReviewPanel } from "./AssessmentReviewPanel.jsx";
export function AssessmentPane(props) {
const session = props.session ?? null;
const questions = props.questions ?? [];
const learningUnitId = session?.learningUnitId ?? questions[0]?.learningUnitId ?? null;
const completedSessionId = session?.status === "completed" ? session.id : null;
const review = useAssessmentReview({ learningUnitId, activeSessionId: completedSessionId });
const reviewOwnsCompletedExplanation = Boolean(
completedSessionId && review.review?.sessionId === completedSessionId,
);
return (
<div className="min-w-0">
<AssessmentQuestionManager
session={session}
questions={props.questions ?? []}
questions={questions}
commands={props.managementCommands ?? null}
/>
<AssessmentPracticePane
{...props}
session={session}
feedback={session ? props.feedback : null}
showFeedbackExplanation={!reviewOwnsCompletedExplanation}
startError={session ? null : props.startError}
starting={Boolean(props.starting)}
onStart={props.onStart}
/>
<AssessmentReviewPanel
history={review.history}
review={review.review}
loading={review.loading}
error={review.error}
storageNotice={review.storageNotice}
onSelectSession={review.selectSession}
onOpenEvidence={props.onOpenEvidence}
/>
</div>
);
}
+3 -3
View File
@@ -17,7 +17,7 @@ function EvidenceButton({ evidence, index, onOpen }) {
return <Button variant="outline" size="sm" onClick={() => onOpen?.(evidence)} aria-label={`查看依据 ${index + 1}`} className="max-w-full justify-start"><span aria-hidden="true"></span><span className="truncate">{label}</span></Button>;
}
export function AssessmentPracticePane({ session = null, currentIndex = 0, answer = null, feedback = null, startError = null, starting = false, submitError = null, submitting = false, onStart, onAnswerChange, onSubmit, onNext, onOpenEvidence, onRequestAiQuestions }) {
export function AssessmentPracticePane({ session = null, currentIndex = 0, answer = null, feedback = null, showFeedbackExplanation = true, startError = null, starting = false, submitError = null, submitting = false, onStart, onAnswerChange, onSubmit, onNext, onOpenEvidence, onRequestAiQuestions }) {
const view = deriveAssessmentView({ session, currentIndex, feedback });
if (view.kind === "empty") {
const canStart = typeof onStart === "function";
@@ -32,7 +32,7 @@ export function AssessmentPracticePane({ session = null, currentIndex = 0, answe
const conceptTags = Array.isArray(question.conceptTags) ? question.conceptTags.slice(0, 3) : [];
const handleSubmit = (event) => { event.preventDefault(); if (!hasAnswer || submitting || hasFeedback) return; onSubmit?.({ questionId: view.questionId, revision: view.revision, answer }); };
return <section className="min-w-0 p-4 sm:p-5" aria-labelledby="assessment-question-heading"><Card className="overflow-hidden"><CardHeader className="space-y-3 border-b border-[var(--border-subtle)]"><div className="flex items-start justify-between gap-4"><div className="min-w-0"><p className="m-0 text-xs font-semibold uppercase tracking-[0.12em] text-[var(--text-subtle)]">知识点评测</p><h3 id="assessment-question-heading" className="mt-1 mb-0 text-base font-bold text-[var(--text-main)]">检查你的理解</h3></div><span className="shrink-0 rounded-full bg-[var(--bg-surface-secondary)] px-2.5 py-1 text-xs font-semibold tabular-nums text-[var(--text-muted)]" aria-label={`评测进度 ${formatAssessmentProgress(view)}`}>{formatAssessmentProgress(view)}</span></div><Progress value={progressValue} aria-label={positionLabel} /></CardHeader><CardContent className="space-y-5 pt-5"><div className="flex flex-wrap gap-2" aria-label="题目信息"><Badge variant="outline">{TYPE_LABELS[question.type] ?? question.type}</Badge>{question.difficulty && <Badge variant={DIFFICULTY_VARIANTS[question.difficulty] ?? "secondary"}>{DIFFICULTY_LABELS[question.difficulty] ?? question.difficulty}</Badge>}{conceptTags.map((tag) => <Badge key={tag} variant="secondary">{tag}</Badge>)}</div>{submitError && <div role="alert" className="rounded-xl border border-[var(--color-danger-border)] bg-[var(--color-danger-light)] p-4 text-sm leading-6 text-[var(--color-danger-text)]"><strong className="font-bold">提交失败可重试</strong><p className="mt-1 mb-0">{submitError}</p></div>}<form className="space-y-5" onSubmit={handleSubmit}><QuestionRenderer question={question} value={answer} feedback={view.feedback} onChange={onAnswerChange} disabled={submitting || hasFeedback} />{!hasFeedback && <Button type="submit" disabled={!hasAnswer || submitting} className="w-full">{submitting ? "提交中…" : "提交答案"}</Button>}</form>{hasFeedback && <div role="status" aria-live="polite" className={view.feedback.correct ? "rounded-xl border border-[var(--color-success-border)] bg-[var(--color-success-light)] p-4" : "rounded-xl border border-[var(--color-danger-border)] bg-[var(--color-danger-light)] p-4"}><div className="flex items-start gap-3"><span className={view.feedback.correct ? "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-success)] text-sm font-bold text-white shadow-sm" : "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-danger)] text-sm font-bold text-white shadow-sm"} aria-hidden="true">{view.feedback.correct ? "✓" : "✕"}</span><div className="min-w-0 flex-1"><strong className={view.feedback.correct ? "text-sm font-bold text-[var(--color-success-text)]" : "text-sm font-bold text-[var(--color-danger-text)]"}>{view.feedback.correct ? "回答正确" : "再想一想"}</strong><div id={`${question.id}-explanation`} className="mt-2 text-sm leading-6 text-[var(--text-main)]"><MarkdownRender content={view.feedback.explanation ?? question.content.explanation ?? ""} final htmlPolicy="escape" /></div></div></div>{question.evidenceRefs?.length > 0 && <div className="mt-4 flex flex-wrap gap-2 border-t border-black/5 pt-3" aria-label="答案依据">{question.evidenceRefs.map((evidence, index) => <EvidenceButton key={`${evidence.kind}-${evidence.fileName ?? evidence.sectionId ?? index}-${index}`} evidence={evidence} index={index} onOpen={onOpenEvidence} />)}</div>}</div>}{hasFeedback && !view.isLast && <Button onClick={onNext} className="w-full">下一题 <span aria-hidden="true"></span></Button>}{hasFeedback && view.isLast && <div className="rounded-lg border border-[var(--border-color)] bg-[var(--bg-surface-secondary)] p-4 text-center"><p className="m-0 text-sm font-bold text-[var(--text-main)]">本轮评测已完成</p><p className="mt-1 mb-0 text-xs leading-5 text-[var(--text-muted)]">已完成 {view.total} / {view.total} 道题本轮结果已保存</p></div>}</CardContent></Card></section>;
return <section className="min-w-0 p-4 sm:p-5" aria-labelledby="assessment-question-heading"><Card className="overflow-hidden"><CardHeader className="space-y-3 border-b border-[var(--border-subtle)]"><div className="flex items-start justify-between gap-4"><div className="min-w-0"><p className="m-0 text-xs font-semibold uppercase tracking-[0.12em] text-[var(--text-subtle)]">知识点评测</p><h3 id="assessment-question-heading" className="mt-1 mb-0 text-base font-bold text-[var(--text-main)]">检查你的理解</h3></div><span className="shrink-0 rounded-full bg-[var(--bg-surface-secondary)] px-2.5 py-1 text-xs font-semibold tabular-nums text-[var(--text-muted)]" aria-label={`评测进度 ${formatAssessmentProgress(view)}`}>{formatAssessmentProgress(view)}</span></div><Progress value={progressValue} aria-label={positionLabel} /></CardHeader><CardContent className="space-y-5 pt-5"><div className="flex flex-wrap gap-2" aria-label="题目信息"><Badge variant="outline">{TYPE_LABELS[question.type] ?? question.type}</Badge>{question.difficulty && <Badge variant={DIFFICULTY_VARIANTS[question.difficulty] ?? "secondary"}>{DIFFICULTY_LABELS[question.difficulty] ?? question.difficulty}</Badge>}{conceptTags.map((tag) => <Badge key={tag} variant="secondary">{tag}</Badge>)}</div>{submitError && <div role="alert" className="rounded-xl border border-[var(--color-danger-border)] bg-[var(--color-danger-light)] p-4 text-sm leading-6 text-[var(--color-danger-text)]"><strong className="font-bold">提交失败可重试</strong><p className="mt-1 mb-0">{submitError}</p></div>}<form className="space-y-5" onSubmit={handleSubmit}><QuestionRenderer question={question} value={answer} feedback={view.feedback} onChange={onAnswerChange} disabled={submitting || hasFeedback} />{!hasFeedback && <Button type="submit" disabled={!hasAnswer || submitting} className="w-full">{submitting ? "提交中…" : "提交答案"}</Button>}</form>{hasFeedback && <div role="status" aria-live="polite" className={view.feedback.correct ? "rounded-xl border border-[var(--color-success-border)] bg-[var(--color-success-light)] p-4" : "rounded-xl border border-[var(--color-danger-border)] bg-[var(--color-danger-light)] p-4"}><div className="flex items-start gap-3"><span className={view.feedback.correct ? "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-success)] text-sm font-bold text-white shadow-sm" : "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-danger)] text-sm font-bold text-white shadow-sm"} aria-hidden="true">{view.feedback.correct ? "✓" : "✕"}</span><div className="min-w-0 flex-1"><strong className={view.feedback.correct ? "text-sm font-bold text-[var(--color-success-text)]" : "text-sm font-bold text-[var(--color-danger-text)]"}>{view.feedback.correct ? "回答正确" : "再想一想"}</strong>{showFeedbackExplanation && <div id={`${question.id}-explanation`} className="mt-2 text-sm leading-6 text-[var(--text-main)]"><MarkdownRender content={view.feedback.explanation ?? question.content.explanation ?? ""} final htmlPolicy="escape" /></div>}</div></div>{question.evidenceRefs?.length > 0 && <div className="mt-4 flex flex-wrap gap-2 border-t border-black/5 pt-3" aria-label="答案依据">{question.evidenceRefs.map((evidence, index) => <EvidenceButton key={`${evidence.kind}-${evidence.fileName ?? evidence.sectionId ?? index}-${index}`} evidence={evidence} index={index} onOpen={onOpenEvidence} />)}</div>}</div>}{hasFeedback && !view.isLast && <Button onClick={onNext} className="w-full">下一题 <span aria-hidden="true"></span></Button>}{hasFeedback && view.isLast && <div className="rounded-lg border border-[var(--border-color)] bg-[var(--bg-surface-secondary)] p-4 text-center"><p className="m-0 text-sm font-bold text-[var(--text-main)]">本轮评测已完成</p><p className="mt-1 mb-0 text-xs leading-5 text-[var(--text-muted)]">已完成 {view.total} / {view.total} 道题本轮结果已保存</p></div>}</CardContent></Card></section>;
}
export default AssessmentPracticePane;
export default AssessmentPracticePane;
+106
View File
@@ -0,0 +1,106 @@
import { Badge } from "../../components/ui/badge.jsx";
import { Button } from "../../components/ui/button.jsx";
import { Card, CardContent, CardHeader } from "../../components/ui/card.jsx";
import MarkdownRender from "markstream-react";
function formatAnswer(snapshot, answer) {
if (snapshot?.type === "single_choice") {
return snapshot.content?.options?.find((option) => option.id === answer)?.text ?? String(answer ?? "未作答");
}
if (snapshot?.type === "true_false") {
if (answer === true) return "正确";
if (answer === false) return "错误";
}
return String(answer ?? "未作答");
}
function formatCorrectAnswer(snapshot) {
if (snapshot?.type === "single_choice") {
return snapshot.content?.options?.find((option) => option.id === snapshot.content?.correctOptionId)?.text ?? "";
}
if (snapshot?.type === "true_false") return snapshot.content?.correct ? "正确" : "错误";
return "";
}
function EvidenceButton({ evidence, index, onOpen }) {
const label = evidence?.kind === "source"
? `${evidence.fileName} · L${evidence.startLine}${evidence.endLine !== evidence.startLine ? `${evidence.endLine}` : ""}`
: `查看依据 ${index + 1}`;
return <Button variant="outline" size="sm" onClick={() => onOpen?.(evidence)}>{label}</Button>;
}
export function AssessmentReviewPanel({ history = [], review = null, loading = false, error = null, storageNotice = null, onSelectSession, onOpenEvidence }) {
if (!loading && !error && history.length === 0) return null;
return (
<section className="min-w-0 px-4 pb-5 sm:px-5" aria-labelledby="assessment-review-title">
<Card>
<CardHeader className="space-y-2 border-b border-[var(--border-subtle)]">
<div className="flex items-start justify-between gap-3">
<div>
<p className="m-0 text-xs font-semibold uppercase tracking-[0.12em] text-[var(--text-subtle)]">Session Review</p>
<h3 id="assessment-review-title" className="mt-1 mb-0 text-base font-bold text-[var(--text-main)]">评测回顾</h3>
</div>
{review && <Badge variant={review.incorrectCount > 0 ? "warning" : "success"}>{review.correctCount} / {review.total} 正确</Badge>}
</div>
{storageNotice && <p className="m-0 text-xs leading-5 text-[var(--text-muted)]">{storageNotice} 历史回顾仅限当前浏览器会话</p>}
</CardHeader>
<CardContent className="space-y-4 pt-5">
{loading && <p role="status" className="m-0 text-sm text-[var(--text-muted)]">正在加载历史评测</p>}
{error && <div role="alert" className="rounded-lg border border-[var(--color-danger-border)] bg-[var(--color-danger-light)] p-3 text-sm text-[var(--color-danger-text)]">{error}</div>}
{history.length > 0 && (
<div className="space-y-2">
<p className="m-0 text-xs font-semibold text-[var(--text-subtle)]">当前知识点的已完成评测</p>
<div className="flex flex-wrap gap-2" aria-label="历史评测 Session">
{history.map((entry, index) => (
<Button
key={entry.sessionId}
variant={review?.sessionId === entry.sessionId ? "default" : "outline"}
size="sm"
onClick={() => onSelectSession?.(entry.sessionId)}
>
{history.length - index} · {entry.correctCount}/{entry.total}
</Button>
))}
</div>
</div>
)}
{review && (
<div className="space-y-3">
{review.incorrectCount > 0 && <p className="m-0 text-sm font-semibold text-[var(--color-danger-text)]">先看错题 {review.incorrectCount} 道需要回顾</p>}
{review.incorrectCount === 0 && <p className="m-0 text-sm font-semibold text-[var(--color-success-text)]">本轮全部答对可继续查看历史答案与依据</p>}
<div className="space-y-3">
{review.items.map((item) => {
const question = item.snapshot;
const correctAnswer = formatCorrectAnswer(question);
return (
<article key={`${review.sessionId}-${item.questionId}`} className="rounded-xl border border-[var(--border-color)] p-4">
<div className="flex items-start justify-between gap-3">
<p className="m-0 text-sm font-semibold leading-6 text-[var(--text-main)]">{question.content?.prompt}</p>
<Badge variant={item.correct ? "success" : "destructive"}>{item.correct ? "正确" : "错误"}</Badge>
</div>
<dl className="mt-3 grid gap-2 text-sm">
<div><dt className="inline font-semibold text-[var(--text-subtle)]">你的答案</dt><dd className="inline text-[var(--text-main)]">{formatAnswer(question, item.answer)}</dd></div>
{!item.correct && correctAnswer && <div><dt className="inline font-semibold text-[var(--text-subtle)]">正确答案</dt><dd className="inline text-[var(--text-main)]">{correctAnswer}</dd></div>}
</dl>
{question.content?.explanation && <div className="mt-3 text-sm leading-6 text-[var(--text-main)]"><MarkdownRender content={question.content.explanation} final htmlPolicy="escape" /></div>}
{question.evidenceRefs?.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2 border-t border-[var(--border-subtle)] pt-3" aria-label="回顾依据">
{question.evidenceRefs.map((evidence, index) => <EvidenceButton key={`${item.questionId}-${index}`} evidence={evidence} index={index} onOpen={onOpenEvidence} />)}
</div>
)}
</article>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
</section>
);
}
export default AssessmentReviewPanel;
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createAssessmentRuntime } from "../src/assessment/composition/assessmentRuntime.js";
function question(prompt, correctOptionId = "a") {
return {
type: "single_choice",
content: {
prompt,
options: [
{ id: "a", text: "A" },
{ id: "b", text: "B" },
],
correctOptionId,
explanation: `Explain ${prompt}`,
},
difficulty: "medium",
conceptTags: ["review"],
};
}
async function createRuntime() {
let sequence = 0;
return createAssessmentRuntime({
indexedDb: undefined,
clock: () => "2026-09-14T08:00:00.000Z",
idFactory: (prefix) => `${prefix}-${++sequence}`,
});
}
async function seedQuestions(runtime, learningUnitId, questions, mutationId) {
await runtime.service.createQuestions({
trusted: {
learningUnitId,
mutationId,
actor: { type: "application" },
provenance: { source: "assessment-review-test" },
},
questions,
});
}
test("completed review is learning-unit scoped, snapshot-based, and wrong-first", async () => {
const runtime = await createRuntime();
assert.equal(runtime.mode, "memory");
assert.match(runtime.storageNotice, /本次会话存储/);
await seedQuestions(runtime, "unit-a", [question("Historical prompt A"), question("Historical prompt B")], "seed-a");
await seedQuestions(runtime, "unit-b", [question("Other unit prompt")], "seed-b");
const sessionA = await runtime.sessionLifecycle.start({ learningUnitId: "unit-a" });
await runtime.service.updateQuestion({
trusted: {
learningUnitId: "unit-a",
mutationId: "mutate-current-bank",
actor: { type: "application" },
provenance: { source: "assessment-review-test" },
},
questionId: sessionA.items[0].questionId,
expectedRevision: 1,
patch: { content: { prompt: "Current-bank prompt changed after session start" } },
});
await runtime.sessionLifecycle.submit({
learningUnitId: "unit-a",
sessionId: sessionA.id,
questionId: sessionA.items[0].questionId,
answer: "a",
});
await runtime.sessionLifecycle.submit({
learningUnitId: "unit-a",
sessionId: sessionA.id,
questionId: sessionA.items[1].questionId,
answer: "b",
});
const sessionB = await runtime.sessionLifecycle.start({ learningUnitId: "unit-b" });
await runtime.sessionLifecycle.submit({
learningUnitId: "unit-b",
sessionId: sessionB.id,
questionId: sessionB.items[0].questionId,
answer: "a",
});
const historyA = await runtime.sessionLifecycle.listCompletedReviews({ learningUnitId: "unit-a" });
assert.equal(historyA.length, 1);
assert.equal(historyA[0].learningUnitId, "unit-a");
assert.equal(historyA[0].incorrectCount, 1);
assert.equal(historyA[0].correctCount, 1);
assert.equal(historyA[0].items[0].correct, false, "wrong answers are ordered first");
const historicalPrompt = historyA[0].items.find((item) => item.questionId === sessionA.items[0].questionId).snapshot.content.prompt;
assert.equal(historicalPrompt, "Historical prompt A", "review must use the session snapshot instead of the current question revision");
const historyB = await runtime.sessionLifecycle.listCompletedReviews({ learningUnitId: "unit-b" });
assert.equal(historyB.length, 1);
assert.equal(historyB[0].learningUnitId, "unit-b");
assert.notEqual(historyB[0].sessionId, historyA[0].sessionId);
assert.equal(await runtime.sessionLifecycle.review({ learningUnitId: "unit-b", sessionId: sessionA.id }), null, "cross-unit session review must fail closed");
});
+125
View File
@@ -1,5 +1,108 @@
import { expect, test } from "@playwright/test";
const ASSESSMENT_DB = "react-learning-assessment";
async function seedCompletedReview(page) {
await page.evaluate((dbName) => new Promise((resolve, reject) => {
const open = indexedDB.open(dbName);
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction(["questions", "sessions", "attempts"], "readwrite");
const questions = tx.objectStore("questions");
const sessions = tx.objectStore("sessions");
const attempts = tx.objectStore("attempts");
const baseQuestion = {
learningUnitId: "props",
type: "single_choice",
status: "active",
revision: 2,
provenance: { source: "e2e" },
createdAt: "2026-09-14T08:00:00.000Z",
updatedAt: "2026-09-14T08:10:00.000Z",
difficulty: "medium",
conceptTags: ["review"],
};
const q1 = {
...baseQuestion,
id: "review-q1",
content: {
prompt: "CURRENT bank prompt A",
options: [{ id: "a", text: "A" }, { id: "b", text: "B" }],
correctOptionId: "a",
explanation: "Historical explanation A",
},
};
const q2 = {
...baseQuestion,
id: "review-q2",
content: {
prompt: "CURRENT bank prompt B",
options: [{ id: "a", text: "A" }, { id: "b", text: "B" }],
correctOptionId: "a",
explanation: "Historical explanation B",
},
};
questions.put(q1);
questions.put(q2);
const snapshot = (question, prompt) => ({
...question,
revision: 1,
content: { ...question.content, prompt },
updatedAt: "2026-09-14T08:00:00.000Z",
});
sessions.put({
id: "review-session-current",
learningUnitId: "props",
items: [
{ questionId: q1.id, revision: 1, snapshot: snapshot(q1, "HISTORICAL snapshot prompt A") },
{ questionId: q2.id, revision: 1, snapshot: snapshot(q2, "HISTORICAL snapshot prompt B") },
],
status: "completed",
startedAt: "2026-09-14T08:00:00.000Z",
completedAt: "2026-09-14T08:05:00.000Z",
});
attempts.put({
id: "review-attempt-correct",
sessionId: "review-session-current",
questionId: q1.id,
questionRevision: 1,
answer: "a",
correct: true,
submittedAt: "2026-09-14T08:03:00.000Z",
});
attempts.put({
id: "review-attempt-wrong",
sessionId: "review-session-current",
questionId: q2.id,
questionRevision: 1,
answer: "b",
correct: false,
submittedAt: "2026-09-14T08:04:00.000Z",
});
sessions.put({
id: "review-session-other-unit",
learningUnitId: "state-snapshot-queue",
items: [{ questionId: "other-q", revision: 1, snapshot: { ...snapshot(q1, "OTHER UNIT SECRET"), id: "other-q", learningUnitId: "state-snapshot-queue" } }],
status: "completed",
startedAt: "2026-09-14T09:00:00.000Z",
completedAt: "2026-09-14T09:05:00.000Z",
});
attempts.put({
id: "review-attempt-other",
sessionId: "review-session-other-unit",
questionId: "other-q",
questionRevision: 1,
answer: "b",
correct: false,
submittedAt: "2026-09-14T09:04:00.000Z",
});
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
}), ASSESSMENT_DB);
}
test("assessment tab exposes the new runtime-backed question manager", async ({ page }) => {
await page.goto("?demo=props");
await page.getByRole("tab", { name: "评测" }).click();
@@ -17,3 +120,25 @@ test("assessment manager keeps practice UI in the same tab", async ({ page }) =>
await expect(panel.getByText("题库管理", { exact: true })).toBeVisible();
await expect(panel.getByText("知识点评测", { exact: true })).toBeVisible();
});
test("completed session review is scoped, snapshot-correct, reloadable, and wrong-first", async ({ page }) => {
await page.goto("?demo=props");
await page.getByRole("tab", { name: "评测" }).click();
await expect(page.getByRole("heading", { name: "当前知识点题目" })).toBeVisible();
await seedCompletedReview(page);
await page.reload();
await page.getByRole("tab", { name: "评测" }).click();
const review = page.locator('section[aria-labelledby="assessment-review-title"]');
await expect(review.getByRole("heading", { name: "评测回顾" })).toBeVisible();
await expect(review.getByText("HISTORICAL snapshot prompt A", { exact: true })).toBeVisible();
await expect(review.getByText("HISTORICAL snapshot prompt B", { exact: true })).toBeVisible();
await expect(review.getByText("OTHER UNIT SECRET", { exact: true })).toHaveCount(0);
await expect(review.getByText("先看错题:共 1 道需要回顾。", { exact: true })).toBeVisible();
const reviewedQuestions = review.locator("article");
await expect(reviewedQuestions).toHaveCount(2);
await expect(reviewedQuestions.first().getByText("错误", { exact: true })).toBeVisible();
await expect(reviewedQuestions.first().getByText("HISTORICAL snapshot prompt B", { exact: true })).toBeVisible();
});