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;