Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 66 additions & 20 deletions apps/web/src/__tests__/components/lesson-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => {
Expand All @@ -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(() => {
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/components/app/lesson-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export function LessonFlow({ orgSlug, courseId, token, lesson, continueHref }: L
const [practiceFeedback, setPracticeFeedback] = useState<ProblemFeedback | null>(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<string[]>([]);
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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");
}
Expand Down
Loading