diff --git a/apps/web/src/__tests__/components/lesson-flow.test.tsx b/apps/web/src/__tests__/components/lesson-flow.test.tsx index 35d5a5e..dcdda17 100644 --- a/apps/web/src/__tests__/components/lesson-flow.test.tsx +++ b/apps/web/src/__tests__/components/lesson-flow.test.tsx @@ -128,7 +128,19 @@ describe("LessonFlow", () => { }); it("advances to next KP after completing practice", async () => { - mockApiClientFetch.mockResolvedValueOnce({ correct: true, feedback: "Correct!" }); + // The backend returns a nextProblemHint telling the frontend to advance + // to kp2 after the learner passes kp1 (stream-driven practice loop). + mockApiClientFetch.mockResolvedValueOnce({ + correct: true, + feedback: "Correct!", + nextProblemHint: { + targetKPId: "kp2", + nextProblemId: "p2", + reopenWorkedExample: false, + retryDelayMs: 0, + lessonComplete: false, + }, + }); renderFlow(); // KP1: instruction -> worked example -> practice @@ -137,33 +149,50 @@ describe("LessonFlow", () => { fireEvent.click(screen.getByRole("radio", { name: "Heat" })); fireEvent.click(screen.getByRole("button", { name: /submit answer/i })); + // The hint advances directly to kp2's practice — no "Practice complete" + // intermediate for the old KP since the stream keeps flowing. await waitFor(() => { - expect(screen.getByText("Practice complete")).toBeTruthy(); + expect(screen.getByText("Flashover is best described as:")).toBeTruthy(); }, { timeout: 2500 }); - fireEvent.click(screen.getByRole("button", { name: /^continue$/i })); - expect(screen.getByText("Flashover occurs when all surfaces in a room ignite simultaneously.")).toBeTruthy(); expect(screen.getByText(/2 of 2/)).toBeTruthy(); }); it("shows Complete Lesson button on last KP after practice is done", async () => { mockApiClientFetch - .mockResolvedValueOnce({ correct: true, feedback: "Correct!" }) - .mockResolvedValueOnce({ correct: true, feedback: "Correct!" }); + .mockResolvedValueOnce({ + correct: true, + feedback: "Correct!", + nextProblemHint: { + targetKPId: "kp2", + nextProblemId: "p2", + reopenWorkedExample: false, + retryDelayMs: 0, + lessonComplete: false, + }, + }) + .mockResolvedValueOnce({ + correct: true, + feedback: "Correct!", + nextProblemHint: { + targetKPId: "kp2", + nextProblemId: null, + reopenWorkedExample: false, + retryDelayMs: 0, + lessonComplete: true, + }, + }); renderFlow(); - // KP1: instruction -> worked example -> practice -> answer -> continue + // KP1: instruction -> worked example -> practice -> answer (advances to kp2) fireEvent.click(screen.getByRole("button", { name: /continue/i })); fireEvent.click(screen.getByRole("button", { name: /continue/i })); fireEvent.click(screen.getByRole("radio", { name: "Heat" })); fireEvent.click(screen.getByRole("button", { name: /submit answer/i })); await waitFor(() => { - expect(screen.getByText("Practice complete")).toBeTruthy(); + expect(screen.getByText("Flashover is best described as:")).toBeTruthy(); }, { timeout: 2500 }); - fireEvent.click(screen.getByRole("button", { name: /^continue$/i })); - // KP2: instruction -> worked example -> practice -> answer - fireEvent.click(screen.getByRole("button", { name: /continue/i })); - fireEvent.click(screen.getByRole("button", { name: /continue/i })); + // KP2: already in practice (stream-driven) -> answer -> lessonComplete fireEvent.click(screen.getByRole("radio", { name: /all surfaces igniting in a room/i })); fireEvent.click(screen.getByRole("button", { name: /submit answer/i })); await waitFor(() => { @@ -174,24 +203,41 @@ describe("LessonFlow", () => { it("calls complete API and redirects on completion", async () => { mockApiClientFetch - .mockResolvedValueOnce({ correct: true, feedback: "Correct!" }) - .mockResolvedValueOnce({ correct: true, feedback: "Correct!" }) + .mockResolvedValueOnce({ + correct: true, + feedback: "Correct!", + nextProblemHint: { + targetKPId: "kp2", + nextProblemId: "p2", + reopenWorkedExample: false, + retryDelayMs: 0, + lessonComplete: false, + }, + }) + .mockResolvedValueOnce({ + correct: true, + feedback: "Correct!", + nextProblemHint: { + targetKPId: "kp2", + nextProblemId: null, + reopenWorkedExample: false, + retryDelayMs: 0, + lessonComplete: true, + }, + }) .mockResolvedValueOnce({ conceptId: "c1", status: "lesson_complete" }); renderFlow(); - // KP1: instruction -> worked example -> practice -> answer -> continue + // KP1: instruction -> worked example -> practice -> answer (advances to kp2) fireEvent.click(screen.getByRole("button", { name: /continue/i })); fireEvent.click(screen.getByRole("button", { name: /continue/i })); fireEvent.click(screen.getByRole("radio", { name: "Heat" })); fireEvent.click(screen.getByRole("button", { name: /submit answer/i })); await waitFor(() => { - expect(screen.getByText("Practice complete")).toBeTruthy(); + expect(screen.getByText("Flashover is best described as:")).toBeTruthy(); }, { timeout: 2500 }); - fireEvent.click(screen.getByRole("button", { name: /^continue$/i })); - // KP2: instruction -> worked example -> practice -> answer -> complete - fireEvent.click(screen.getByRole("button", { name: /continue/i })); - fireEvent.click(screen.getByRole("button", { name: /continue/i })); + // KP2: already in practice -> answer -> lessonComplete -> complete fireEvent.click(screen.getByRole("radio", { name: /all surfaces igniting in a room/i })); fireEvent.click(screen.getByRole("button", { name: /submit answer/i })); await waitFor(() => { diff --git a/apps/web/src/components/app/lesson-flow.tsx b/apps/web/src/components/app/lesson-flow.tsx index 8d36c30..1e4c039 100644 --- a/apps/web/src/components/app/lesson-flow.tsx +++ b/apps/web/src/components/app/lesson-flow.tsx @@ -60,6 +60,10 @@ export function LessonFlow({ orgSlug, courseId, token, lesson, continueHref }: L const [practiceFeedback, setPracticeFeedback] = useState(null); const [practiceSubmitting, setPracticeSubmitting] = useState(false); const [workedExampleOpen, setWorkedExampleOpen] = useState(true); + // True when the current KP's practice is done — either via lessonComplete + // hint, KP advancement hint, or fallback exhaustion. Distinguishes "no + // problem selected yet" (initial) from "all done" (post-practice). + const [kpPracticeDone, setKpPracticeDone] = useState(false); // Problems the learner has seen in this lesson session — sent to the backend // so the selector can prefer unseen problems and apply retry delays. const seenProblemIdsRef = useRef([]); @@ -81,8 +85,8 @@ export function LessonFlow({ orgSlug, courseId, token, lesson, continueHref }: L const isLast = currentKP === lesson.knowledgePoints.length - 1; const currentProblem = problems.find((p) => p.id === currentProblemId) ?? - (currentProblemId === null ? problems[0] ?? null : null); - const practiceComplete = problems.length === 0 || currentProblem === null; + (currentProblemId === null && !kpPracticeDone ? problems[0] ?? null : null); + const practiceComplete = problems.length === 0 || kpPracticeDone; // Progress accounts for 3 phases per KP const totalPhases = lesson.knowledgePoints.length * 3; @@ -124,6 +128,7 @@ export function LessonFlow({ orgSlug, courseId, token, lesson, continueHref }: L // The backend hint will take over after the first submission. const first = kp.problems?.[0]?.id ?? null; setCurrentProblemId(first); + setKpPracticeDone(false); setPracticeFeedback(null); setPracticeSubmitting(false); practiceStartRef.current = Date.now(); @@ -146,6 +151,7 @@ export function LessonFlow({ orgSlug, courseId, token, lesson, continueHref }: L setWorkedExampleOpen(true); // Let the next effect pick the first problem for the new KP setCurrentProblemId(null); + setKpPracticeDone(false); } } } @@ -196,11 +202,15 @@ export function LessonFlow({ orgSlug, courseId, token, lesson, continueHref }: L // Fallback to legacy behavior: advance within the current KP's problem list. const idx = problems.findIndex((p) => p.id === currentProblemId); const next = idx >= 0 ? problems[idx + 1] ?? null : null; + if (!next) { + setKpPracticeDone(true); + } setCurrentProblemId(next?.id ?? null); return; } if (hint.lessonComplete) { + setKpPracticeDone(true); setCurrentProblemId(null); return; } @@ -218,6 +228,7 @@ export function LessonFlow({ orgSlug, courseId, token, lesson, continueHref }: L (k) => k.id === hint.targetKPId, ); if (targetIdx >= 0) { + setKpPracticeDone(false); setCurrentKP(targetIdx); setPhase("practice"); }