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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user