From 0b7ccea21cdf76c1edbaa9d4d896981cc4829a13 Mon Sep 17 00:00:00 2001 From: anbu1504 Date: Wed, 17 Jun 2026 17:36:50 -0400 Subject: [PATCH] Step By Step Question For Practice Question 1 The purpose of this PR is to introduce the notion of having a segmented step by step mode for questions (in this case, just practice question 1). The state from each line is saved and the user cannot switch id's that refer to different variables, which better models how python actually works. --- .../questionTab/QuestionTab.module.css | 81 ++++++ .../questionTab/QuestionTab.test.ts | 171 +++++++++++ .../questionTab/QuestionTab.test.tsx | 237 ++++++++++++++- .../questionTab/QuestionTab.tsx | 269 +++++++++++++++++- .../components/QuestionSelector.module.css | 11 + .../components/QuestionSelector.tsx | 4 +- .../memoryModelEditor/utils/localStorage.ts | 3 +- 7 files changed, 761 insertions(+), 15 deletions(-) diff --git a/frontend/src/features/informationTabs/questionTab/QuestionTab.module.css b/frontend/src/features/informationTabs/questionTab/QuestionTab.module.css index 257b6ad..29ebb33 100644 --- a/frontend/src/features/informationTabs/questionTab/QuestionTab.module.css +++ b/frontend/src/features/informationTabs/questionTab/QuestionTab.module.css @@ -416,6 +416,87 @@ border-color: #2d4a6a; } +/* ── Divider between selector groups ── */ +.selectorDivider { + height: 1px; + background: var(--border-primary); + margin: 0.25rem 0; +} + +/* ── Step-by-step indicator ── */ +.stepIndicator { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1rem; + padding: 0.6rem 0.85rem; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 10px; + transition: background-color 200ms ease, border-color 200ms ease; +} + +.stepDots { + display: flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +} + +.stepDot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + flex-shrink: 0; + transition: background 200ms ease, box-shadow 200ms ease; +} + +.stepDotDone { + background: #10b981; +} + +.stepDotActive { + background: #3b82f6; + box-shadow: 0 0 0 2.5px rgba(59, 130, 246, 0.25); +} + +.stepDotPending { + background: var(--border-secondary); +} + +.stepText { + font-size: calc(0.82rem * var(--font-scale, 1)); + font-weight: 600; + color: var(--text-secondary); + transition: color 200ms ease; +} + +.stepConsistencyError { + margin-top: 0.65rem; + padding: 0.55rem 0.85rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #dc2626; + font-size: calc(0.82rem * var(--font-scale, 1)); + font-weight: 500; + line-height: 1.45; +} + +:root[data-theme="dark"] .stepConsistencyError { + background: #3a1c1c; + border-color: #7f1d1d; + color: #f87171; +} + +.stepHint { + margin: 0.4rem 0 0 0; + font-size: calc(0.75rem * var(--font-scale, 1)); + color: var(--text-muted); + font-style: italic; + transition: color 200ms ease; +} + /* Auto-advance toolbar */ .autoToolbar { display: flex; diff --git a/frontend/src/features/informationTabs/questionTab/QuestionTab.test.ts b/frontend/src/features/informationTabs/questionTab/QuestionTab.test.ts index 47c3e71..2e94c80 100644 --- a/frontend/src/features/informationTabs/questionTab/QuestionTab.test.ts +++ b/frontend/src/features/informationTabs/questionTab/QuestionTab.test.ts @@ -5,10 +5,13 @@ jest.mock("react-markdown", () => ({ })); import { buildLineIterations, + checkStepConsistency, + extractStepAssignments, getCheckableLines, getNextCheckableLine, sortCheckableLines, QuestionData, + StepAssignments, } from "./QuestionTab"; describe("QuestionTab helper functions", () => { @@ -50,3 +53,171 @@ describe("QuestionTab helper functions", () => { expect(getNextCheckableLine([1, 2, 4], null)).toBeNull(); }); }); + +// ─── Helper canvas element factories ──────────────────────────────────────── + +function makeFrame(params: { name: string; targetId: number | null }[]) { + return { + boxId: 0, + id: "_", + x: 0, + y: 0, + kind: { + name: "function", + type: "function", + value: null, + functionName: "__main__", + params, + }, + }; +} + +function makePrimitive(id: number, type: string, value: string) { + return { boxId: id, id, x: 0, y: 0, kind: { name: "primitive", type, value } }; +} + +function makeClass(classVariables: { name: string; targetId: number | null }[]) { + return { + boxId: 99, + id: "_", + x: 0, + y: 0, + kind: { + name: "class", + type: "class", + value: null, + className: "MyClass", + classVariables, + }, + }; +} + +// ─── extractStepAssignments ────────────────────────────────────────────────── + +describe("extractStepAssignments", () => { + it("returns empty maps for an empty element list", () => { + expect(extractStepAssignments([])).toEqual({ variableToId: {}, idToType: {} }); + }); + + it("extracts variable→id mappings from a function frame's params", () => { + const result = extractStepAssignments([ + makeFrame([{ name: "a", targetId: 1 }, { name: "b", targetId: 2 }]), + ]); + expect(result.variableToId).toEqual({ a: 1, b: 2 }); + expect(result.idToType).toEqual({}); + }); + + it("skips params whose targetId is null", () => { + const result = extractStepAssignments([ + makeFrame([{ name: "a", targetId: null }, { name: "b", targetId: 3 }]), + ]); + expect(result.variableToId).toEqual({ b: 3 }); + }); + + it("extracts idToType from primitive elements with numeric ids", () => { + const result = extractStepAssignments([ + makePrimitive(1, "int", "5"), + makePrimitive(2, "str", "hello"), + ]); + expect(result.idToType).toEqual({ 1: "int", 2: "str" }); + expect(result.variableToId).toEqual({}); + }); + + it("ignores non-frame elements whose id is not a number", () => { + const result = extractStepAssignments([ + { boxId: 0, id: "_", kind: { name: "primitive", type: "int", value: "5" } }, + ]); + expect(result.idToType).toEqual({}); + }); + + it("combines frame variable mappings and primitive type mappings", () => { + const result = extractStepAssignments([ + makeFrame([{ name: "x", targetId: 7 }]), + makePrimitive(7, "float", "3.14"), + ]); + expect(result.variableToId).toEqual({ x: 7 }); + expect(result.idToType).toEqual({ 7: "float" }); + }); + + it("extracts classVariables from class elements", () => { + const result = extractStepAssignments([ + makeClass([{ name: "attr", targetId: 5 }]), + ]); + expect(result.variableToId).toEqual({ attr: 5 }); + }); + + it("skips class classVariables with null targetId", () => { + const result = extractStepAssignments([ + makeClass([{ name: "attr", targetId: null }, { name: "other", targetId: 9 }]), + ]); + expect(result.variableToId).toEqual({ other: 9 }); + }); +}); + +// ─── checkStepConsistency ──────────────────────────────────────────────────── + +describe("checkStepConsistency", () => { + const empty: StepAssignments = { variableToId: {}, idToType: {} }; + + it("returns null when committed state is empty", () => { + const current: StepAssignments = { variableToId: { a: 1 }, idToType: { 1: "int" } }; + expect(checkStepConsistency(empty, current)).toBeNull(); + }); + + it("returns null when variable still maps to the same id", () => { + const committed: StepAssignments = { variableToId: { a: 1 }, idToType: {} }; + const current: StepAssignments = { variableToId: { a: 1, b: 2 }, idToType: {} }; + expect(checkStepConsistency(committed, current)).toBeNull(); + }); + + it("returns an error naming the variable and both ids when variable→id changes", () => { + const committed: StepAssignments = { variableToId: { a: 1 }, idToType: {} }; + const current: StepAssignments = { variableToId: { a: 2 }, idToType: {} }; + const error = checkStepConsistency(committed, current); + expect(error).not.toBeNull(); + expect(error).toMatch(/variable "a"/i); + expect(error).toMatch(/id 1/); + expect(error).toMatch(/id 2/); + }); + + it("returns null when an id still has the same type", () => { + const committed: StepAssignments = { variableToId: {}, idToType: { 1: "int" } }; + const current: StepAssignments = { variableToId: {}, idToType: { 1: "int", 2: "str" } }; + expect(checkStepConsistency(committed, current)).toBeNull(); + }); + + it("returns an error naming the id and both types when type changes", () => { + const committed: StepAssignments = { variableToId: {}, idToType: { 1: "int" } }; + const current: StepAssignments = { variableToId: {}, idToType: { 1: "str" } }; + const error = checkStepConsistency(committed, current); + expect(error).not.toBeNull(); + expect(error).toMatch(/id 1/i); + expect(error).toMatch(/int/); + expect(error).toMatch(/str/); + }); + + it("returns null when a committed variable is absent from the current state", () => { + // Absence is a backend concern (missing element), not a consistency violation + const committed: StepAssignments = { variableToId: { a: 1 }, idToType: {} }; + expect(checkStepConsistency(committed, empty)).toBeNull(); + }); + + it("returns null when a committed id is absent from the current state", () => { + const committed: StepAssignments = { variableToId: {}, idToType: { 1: "int" } }; + expect(checkStepConsistency(committed, empty)).toBeNull(); + }); + + it("checks variable mappings before id types", () => { + // Both a variable AND a type are wrong; the variable error should be reported first + const committed: StepAssignments = { variableToId: { a: 1 }, idToType: { 1: "int" } }; + const current: StepAssignments = { variableToId: { a: 2 }, idToType: { 1: "str" } }; + const error = checkStepConsistency(committed, current); + expect(error).not.toBeNull(); + expect(error).toMatch(/variable "a"/i); + }); + + it("returns null for two steps where nothing changed", () => { + const state: StepAssignments = { variableToId: { a: 1, b: 2 }, idToType: { 1: "int", 2: "int" } }; + expect(checkStepConsistency(state, state)).toBeNull(); + }); +}); diff --git a/frontend/src/features/informationTabs/questionTab/QuestionTab.test.tsx b/frontend/src/features/informationTabs/questionTab/QuestionTab.test.tsx index a3fda02..cc58105 100644 --- a/frontend/src/features/informationTabs/questionTab/QuestionTab.test.tsx +++ b/frontend/src/features/informationTabs/questionTab/QuestionTab.test.tsx @@ -65,13 +65,13 @@ describe("QuestionTab component", () => { await screen.findByText(/draw the memory model/i); await waitFor(() => expect(mockedFetchQuestion).toHaveBeenCalledTimes(1)); - const checkbox = screen.getByRole("checkbox", { + const toggle = screen.getByRole("switch", { name: /auto advance to next checkable line/i, }); - expect(checkbox).toBeInTheDocument(); + expect(toggle).toBeInTheDocument(); - await user.click(checkbox); - expect(checkbox).toBeChecked(); + await user.click(toggle); + expect(toggle).toBeChecked(); const line1 = await screen.findByTitle("Check answer at line 1"); await user.click(line1); @@ -115,7 +115,7 @@ describe("QuestionTab component", () => { await screen.findByText(/draw the memory model/i); await waitFor(() => expect(mockedFetchQuestion).toHaveBeenCalledTimes(1)); - await user.click(screen.getByRole("checkbox", { + await user.click(screen.getByRole("switch", { name: /auto advance to next checkable line/i, })); @@ -150,7 +150,7 @@ describe("QuestionTab component", () => { await waitFor(() => expect(mockedFetchQuestion).toHaveBeenCalledTimes(1)); await user.click( - screen.getByRole("checkbox", { name: /auto advance to next checkable line/i }) + screen.getByRole("switch", { name: /auto advance to next checkable line/i }) ); // Select line 3 then pick iteration 1 @@ -179,7 +179,7 @@ describe("QuestionTab component", () => { await waitFor(() => expect(mockedFetchQuestion).toHaveBeenCalledTimes(1)); await user.click( - screen.getByRole("checkbox", { name: /auto advance to next checkable line/i }) + screen.getByRole("switch", { name: /auto advance to next checkable line/i }) ); await user.click(screen.getByTitle("Check answer at line 1")); @@ -191,4 +191,227 @@ describe("QuestionTab component", () => { screen.getByRole("button", { name: /check answer at line 1/i }) ).toBeInTheDocument(); }); + + // ─── Step-by-step mode ──────────────────────────────────────────────────── + + describe("Step-by-step mode", () => { + const sbsQuestion: QuestionData = { + id: 1, + question: "Draw the memory model after all the code has executed.", + code: ["a = 5", "b = 4"], + answer: null, + steps: [ + { lineNumber: 1, answer: null }, + { lineNumber: 2, answer: null }, + ], + description: null, + topics: null, + canvasConfig: null, + }; + + // Canvas state helpers — plain objects matching the shape CanvasElement reads + const step1Canvas = { + elements: [ + { + boxId: 0, id: "_", x: 0, y: 0, + kind: { name: "function", type: "function", value: null, functionName: "__main__", params: [{ name: "a", targetId: 1 }] }, + }, + { boxId: 1, id: 1, x: 100, y: 0, kind: { name: "primitive", type: "int", value: "5" } }, + ], + ids: [1], classes: [], + }; + + // a now maps to id 2 instead of id 1 — violates consistency + const step2WrongIdCanvas = { + elements: [ + { + boxId: 0, id: "_", x: 0, y: 0, + kind: { name: "function", type: "function", value: null, functionName: "__main__", params: [{ name: "a", targetId: 2 }, { name: "b", targetId: 1 }] }, + }, + { boxId: 1, id: 1, x: 100, y: 0, kind: { name: "primitive", type: "int", value: "4" } }, + { boxId: 2, id: 2, x: 200, y: 0, kind: { name: "primitive", type: "int", value: "5" } }, + ], + ids: [1, 2], classes: [], + }; + + // id 1 changed from int to str — violates consistency + const step2WrongTypeCanvas = { + elements: [ + { + boxId: 0, id: "_", x: 0, y: 0, + kind: { name: "function", type: "function", value: null, functionName: "__main__", params: [{ name: "a", targetId: 1 }] }, + }, + { boxId: 1, id: 1, x: 100, y: 0, kind: { name: "primitive", type: "str", value: "hello" } }, + ], + ids: [1], classes: [], + }; + + // Correct cumulative state for step 2: a→1 preserved, b→2 added + const step2CorrectCanvas = { + elements: [ + { + boxId: 0, id: "_", x: 0, y: 0, + kind: { name: "function", type: "function", value: null, functionName: "__main__", params: [{ name: "a", targetId: 1 }, { name: "b", targetId: 2 }] }, + }, + { boxId: 1, id: 1, x: 100, y: 0, kind: { name: "primitive", type: "int", value: "5" } }, + { boxId: 2, id: 2, x: 200, y: 0, kind: { name: "primitive", type: "int", value: "4" } }, + ], + ids: [1, 2], classes: [], + }; + + // Props that start in root view (no question pre-loaded) + const sbsBaseProps = { + ...baseProps, + questionView: "root" as const, + currentCanvasState: { elements: [] as any[], ids: [] as number[], classes: [] as string[] }, + }; + + beforeEach(() => { + mockedFetchQuestion.mockResolvedValue(sbsQuestion); + }); + + /** Click "Step-by-Step Questions" and wait for the first step to appear. */ + async function enterStepByStep(canvasState = sbsBaseProps.currentCanvasState) { + const onSubmitAtLine = jest.fn().mockResolvedValue(true); + const utils = render( + + ); + await userEvent.click(screen.getByRole("button", { name: /step-by-step questions/i })); + await screen.findByText(/step 1 of 2/i); + return { ...utils, onSubmitAtLine }; + } + + it("shows step indicator and Check button after entering step-by-step mode", async () => { + await enterStepByStep(); + expect(screen.getByText(/step 1 of 2/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /check line 1/i })).toBeInTheDocument(); + expect(screen.getByText(/draw the memory model after executing line 1/i)).toBeInTheDocument(); + }); + + it("advances to the next step when the check is correct", async () => { + const { onSubmitAtLine } = await enterStepByStep(step1Canvas); + + await userEvent.click(screen.getByRole("button", { name: /check line 1/i })); + + expect(onSubmitAtLine).toHaveBeenCalledWith(1, undefined); + await screen.findByText(/step 2 of 2/i); + expect(screen.getByRole("button", { name: /check line 2/i })).toBeInTheDocument(); + }); + + it("stays on the same step when the check is incorrect", async () => { + const onSubmitAtLine = jest.fn().mockResolvedValue(false); + render(); + await userEvent.click(screen.getByRole("button", { name: /step-by-step questions/i })); + await screen.findByText(/step 1 of 2/i); + + await userEvent.click(screen.getByRole("button", { name: /check line 1/i })); + + expect(onSubmitAtLine).toHaveBeenCalledWith(1, undefined); + expect(screen.getByText(/step 1 of 2/i)).toBeInTheDocument(); + }); + + it("shows a consistency error and skips the backend when variable→id mapping changes", async () => { + const { onSubmitAtLine, rerender } = await enterStepByStep(step1Canvas); + + // Pass step 1 — commits a→1, id1=int + await userEvent.click(screen.getByRole("button", { name: /check line 1/i })); + await screen.findByText(/step 2 of 2/i); + + // Switch to canvas where a now points to id 2 + rerender(); + + await userEvent.click(screen.getByRole("button", { name: /check line 2/i })); + + expect(screen.getByText(/variable "a" must point to id 1/i)).toBeInTheDocument(); + // Backend should NOT have been called for step 2 + expect(onSubmitAtLine).toHaveBeenCalledTimes(1); + }); + + it("shows a consistency error and skips the backend when an id's type changes", async () => { + const { onSubmitAtLine, rerender } = await enterStepByStep(step1Canvas); + + // Pass step 1 — commits id1=int + await userEvent.click(screen.getByRole("button", { name: /check line 1/i })); + await screen.findByText(/step 2 of 2/i); + + // Switch to canvas where id1 is now a str + rerender(); + + await userEvent.click(screen.getByRole("button", { name: /check line 2/i })); + + expect(screen.getByText(/id 1.*int.*str|id 1 was a int/i)).toBeInTheDocument(); + expect(onSubmitAtLine).toHaveBeenCalledTimes(1); + }); + + it("clears the error and calls the backend when the canvas is fixed", async () => { + const { onSubmitAtLine, rerender } = await enterStepByStep(step1Canvas); + + // Pass step 1 + await userEvent.click(screen.getByRole("button", { name: /check line 1/i })); + await screen.findByText(/step 2 of 2/i); + + // Wrong canvas → error appears + rerender(); + await userEvent.click(screen.getByRole("button", { name: /check line 2/i })); + expect(screen.getByText(/variable "a" must point to id 1/i)).toBeInTheDocument(); + + // Correct canvas → error clears, backend called + rerender(); + await userEvent.click(screen.getByRole("button", { name: /check line 2/i })); + + expect(screen.queryByText(/must point to id/i)).not.toBeInTheDocument(); + expect(onSubmitAtLine).toHaveBeenCalledTimes(2); + }); + + it("shows completion message and Try Again button after all steps pass", async () => { + const { onSubmitAtLine, rerender } = await enterStepByStep(step1Canvas); + + // Step 1 + await userEvent.click(screen.getByRole("button", { name: /check line 1/i })); + await screen.findByText(/step 2 of 2/i); + + // Step 2 + rerender(); + await userEvent.click(screen.getByRole("button", { name: /check line 2/i })); + + await screen.findByText(/all steps complete!/i); + expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /check line/i })).not.toBeInTheDocument(); + }); + + it("Reset restores canvas to initial state and returns to Step 1", async () => { + const onRestoreCanvas = jest.fn(); + const onSubmitAtLine = jest.fn().mockResolvedValue(true); + render( + + ); + + await userEvent.click(screen.getByRole("button", { name: /step-by-step questions/i })); + await screen.findByText(/step 1 of 2/i); + + // Advance to step 2 + await userEvent.click(screen.getByRole("button", { name: /check line 1/i })); + await screen.findByText(/step 2 of 2/i); + + // Reset + await userEvent.click(screen.getByRole("button", { name: /^reset$/i })); + + await screen.findByText(/step 1 of 2/i); + expect(onRestoreCanvas).toHaveBeenCalled(); + }); + + it("Back button returns to the root category view", async () => { + await enterStepByStep(); + + await userEvent.click(screen.getByRole("button", { name: /back/i })); + + await screen.findByRole("button", { name: /practice questions/i }); + expect(screen.queryByText(/step \d+ of/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/features/informationTabs/questionTab/QuestionTab.tsx b/frontend/src/features/informationTabs/questionTab/QuestionTab.tsx index 62b6372..80f7af1 100644 --- a/frontend/src/features/informationTabs/questionTab/QuestionTab.tsx +++ b/frontend/src/features/informationTabs/questionTab/QuestionTab.tsx @@ -23,7 +23,7 @@ import { import { normalizeQuestionCanvasData } from "../../memoryModelEditor/utils/questionFrames"; import ConfirmationModal from "../../memoryModelEditor/components/ConfirmationModal"; -type View = "root" | "loading" | "test" | "list" | "question" | "practice" | "prep" | "experiment"; +type View = "root" | "loading" | "test" | "list" | "question" | "practice" | "prep" | "experiment" | "stepbystep"; type QuestionType = "test" | "practice" | "prep" | "experiment"; type QuestionStatus = "unattempted" | "attempted" | "completed"; @@ -37,6 +37,7 @@ const VALID_VIEWS: View[] = [ "practice", "prep", "experiment", + "stepbystep", ]; interface QuestionStatusMap { @@ -129,6 +130,55 @@ export function getNextCheckableLine(sortedLines: number[], line: number | null) return idx >= 0 && idx + 1 < sortedLines.length ? sortedLines[idx + 1] : null; } +export interface StepAssignments { + variableToId: Record; // "a" → 1 + idToType: Record; // 1 → "int" +} + +export function extractStepAssignments(elements: any[]): StepAssignments { + const variableToId: Record = {}; + const idToType: Record = {}; + + for (const el of elements) { + if (el.kind?.name === "function") { + for (const p of (el.kind.params ?? [])) { + if (typeof p.targetId === "number") { + variableToId[p.name] = p.targetId; + } + } + } else if (el.kind?.name === "class") { + for (const p of (el.kind.classVariables ?? [])) { + if (typeof p.targetId === "number") { + variableToId[p.name] = p.targetId; + } + } + } else { + if (typeof el.id === "number") { + idToType[el.id] = el.kind?.type ?? "unknown"; + } + } + } + + return { variableToId, idToType }; +} + +export function checkStepConsistency(committed: StepAssignments, current: StepAssignments): string | null { + for (const [varName, committedId] of Object.entries(committed.variableToId)) { + const currentId = current.variableToId[varName]; + if (currentId !== undefined && currentId !== committedId) { + return `Variable "${varName}" must point to id ${committedId} (assigned in a previous step). You cannot reassign it to id ${currentId}.`; + } + } + for (const [idStr, committedType] of Object.entries(committed.idToType)) { + const id = parseInt(idStr, 10); + const currentType = current.idToType[id]; + if (currentType !== undefined && currentType !== committedType) { + return `Id ${id} was a ${committedType} in a previous step and cannot be changed to a ${currentType}.`; + } + } + return null; +} + export default function QuestionTab({ questionIndex, setQuestionIndex, @@ -146,9 +196,10 @@ export default function QuestionTab({ isSandboxMode, fontScale = 1, }: QuestionTabProps) { - const [view, setView] = useState( - () => (VALID_VIEWS.includes(questionViewProp as View) && questionViewProp !== "loading" ? (questionViewProp as View) : "root") - ); + const [view, setView] = useState(() => { + const v = questionViewProp as View; + return VALID_VIEWS.includes(v) && v !== "loading" && v !== "stepbystep" ? v : "root"; + }); const [questionCount, setQuestionCount] = useState(0); const [questionData, setQuestionData] = useState(null); const [questionStatus, setQuestionStatus] = useState(() => @@ -172,6 +223,9 @@ export default function QuestionTab({ [selectedTopic, topicMap, questionCount] ); const [autoAdvance, setAutoAdvance] = useState(false); + const [stepByStepIndex, setStepByStepIndex] = useState(0); + const [committedAssignments, setCommittedAssignments] = useState(null); + const [stepConsistencyError, setStepConsistencyError] = useState(null); const checkableLines = useMemo(() => getCheckableLines(questionData), [questionData]); @@ -519,6 +573,9 @@ export default function QuestionTab({ ]); const getHeading = (): string => { + if (view === "stepbystep" && questionIndex !== null) { + return `Step-by-Step · Q${questionIndex}`; + } if (view === "question" && questionIndex !== null) { return `Question ${questionIndex}`; } @@ -586,14 +643,113 @@ export default function QuestionTab({ setShowResetModal(false); }; + const loadStepByStepQuestion = async (): Promise => { + onClearCanvas(); + setStepByStepIndex(0); + setCommittedAssignments(null); + setStepConsistencyError(null); + setSubmissionResults(null); + setSelectedLine(null); + setSelectedIteration(undefined); + setView("loading"); + + try { + const data = await fetchQuestion(1, "practice"); + setQuestionType("practice"); + setQuestionIndex(1); + setQuestionData(data); + if (onQuestionDataChange) onQuestionDataChange(data); + setView("stepbystep"); + + setTimeout(() => { + const resolvedCanvas = normalizeQuestionCanvasData( + resolveQuestionCanvasData("practice", 1, data.canvasConfig ?? null) + ); + onRestoreCanvas(resolvedCanvas.elements, resolvedCanvas.ids, resolvedCanvas.classes); + }, 0); + } catch (error) { + console.error("Failed to load step-by-step question:", error); + setView("root"); + } + }; + + const navigateStepByStepToRoot = (): void => { + deleteQuestionCanvasData("practice", 1); + setStepByStepIndex(0); + setCommittedAssignments(null); + setStepConsistencyError(null); + setQuestionIndex(null); + setQuestionData(null); + setSelectedLine(null); + setSelectedIteration(undefined); + setSubmissionResults(null); + if (onQuestionDataChange) onQuestionDataChange(null); + onRestoreCanvas([], [], []); + setView("root"); + }; + + const handleStepCheck = async (): Promise => { + if (!questionData?.steps || stepByStepIndex >= questionData.steps.length) return; + + // Snapshot canvas before the async call to avoid stale closure issues + const snapshotElements = currentCanvasState.elements; + const currentAssignments = extractStepAssignments(snapshotElements); + + // Enforce ID consistency with what was committed in earlier steps + if (committedAssignments) { + const consistencyError = checkStepConsistency(committedAssignments, currentAssignments); + if (consistencyError) { + setStepConsistencyError(consistencyError); + return; + } + } + setStepConsistencyError(null); + + const currentStep = questionData.steps[stepByStepIndex]; + const success = await handleSubmitAtLine(currentStep.lineNumber, currentStep.iterationNumber); + if (success) { + // Merge the new assignments into the committed state + setCommittedAssignments((prev) => ({ + variableToId: { ...prev?.variableToId, ...currentAssignments.variableToId }, + idToType: { ...prev?.idToType, ...currentAssignments.idToType }, + })); + setStepByStepIndex((prev) => prev + 1); + } + }; + + const handleStepByStepReset = async (): Promise => { + try { + const data = await fetchQuestion(1, "practice"); + setQuestionData(data); + if (onQuestionDataChange) onQuestionDataChange(data); + setStepByStepIndex(0); + setCommittedAssignments(null); + setStepConsistencyError(null); + setSubmissionResults(null); + deleteQuestionCanvasData("practice", 1); + const resolvedCanvas = normalizeQuestionCanvasData( + resolveQuestionCanvasData("practice", 1, data.canvasConfig ?? null) + ); + onRestoreCanvas(resolvedCanvas.elements, resolvedCanvas.ids, resolvedCanvas.classes); + } catch (error) { + console.error("Failed to reset step-by-step question:", error); + } + }; + return ( <>
- {(view === "list" || view === "question") && ( + {(view === "list" || view === "question" || view === "stepbystep") && ( + {!allDone && ( + + )} +
+
+ ); + })()} {showCanvasClearModal && ( diff --git a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css index 8b4c641..4c39bd9 100644 --- a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css +++ b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.module.css @@ -100,6 +100,17 @@ color: #93c5fd; } +.categoryIcon.stepbystep { + background: #dcfce7; + color: #16a34a; + transition: background-color 200ms ease, color 200ms ease; +} + +:root[data-theme="dark"] .categoryIcon.stepbystep { + background: #1c3a2d; + color: #4ade80; +} + .categoryLabel { display: flex; flex-direction: column; diff --git a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx index 5dfc2c9..d546dde 100644 --- a/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx +++ b/frontend/src/features/informationTabs/questionTab/components/QuestionSelector.tsx @@ -9,7 +9,7 @@ interface QuestionSelectorProps { variant?: "category" | "pill"; icon?: string; subtitle?: string; - categoryType?: "practice" | "test" | "prep" | "experiment"; + categoryType?: "practice" | "test" | "prep" | "experiment" | "stepbystep"; } export default function QuestionSelector({ @@ -50,6 +50,8 @@ export default function QuestionSelector({ ? styles.prep : categoryType === "experiment" ? styles.experiment + : categoryType === "stepbystep" + ? styles.stepbystep : "" }`} > diff --git a/frontend/src/features/memoryModelEditor/utils/localStorage.ts b/frontend/src/features/memoryModelEditor/utils/localStorage.ts index ea4247a..29558e1 100644 --- a/frontend/src/features/memoryModelEditor/utils/localStorage.ts +++ b/frontend/src/features/memoryModelEditor/utils/localStorage.ts @@ -62,7 +62,8 @@ export type QuestionView = | "question" | "practice" | "prep" - | "experiment"; + | "experiment" + | "stepbystep"; export interface UIState { activeTab: Tab;