Skip to content
Open
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
59 changes: 59 additions & 0 deletions frontend/src/features/canvas/components/CallStack.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import CallStack from "./CallStack";

function renderCallStack(
overrides: Partial<React.ComponentProps<typeof CallStack>> = {}
) {
return render(
<svg>
<CallStack
frames={[]}
selected={null}
onSelect={jest.fn()}
onReorder={jest.fn()}
{...overrides}
/>
</svg>
);
}

describe("CallStack help icon", () => {
it("shows a help icon labelled for the MemoryViz 'Call Stack' title", () => {
renderCallStack({ visualStyle: "memoryviz" });

expect(
screen.getByRole("button", { name: "Help: Call Stack" })
).toBeInTheDocument();
});

it("shows a help icon labelled for the Python Tutor 'Frames' title", () => {
renderCallStack({ visualStyle: "pythonTutor" });

expect(
screen.getByRole("button", { name: "Help: Frames" })
).toBeInTheDocument();
});

it("opens a dialog explaining the call stack on click", () => {
renderCallStack({ visualStyle: "memoryviz" });

fireEvent.click(screen.getByRole("button", { name: "Help: Call Stack" }));

expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Call Stack", { selector: "h4" })).toBeInTheDocument();
expect(
screen.getByText(/currently active function calls/i)
).toBeInTheDocument();
});

it("closes the dialog when the close button is clicked", () => {
renderCallStack({ visualStyle: "memoryviz" });

fireEvent.click(screen.getByRole("button", { name: "Help: Call Stack" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
14 changes: 14 additions & 0 deletions frontend/src/features/canvas/components/CallStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../../shared/types";
import { BoxDimensions } from "../utils/box.types";
import CanvasBox from "./CanvasBox";
import HelpIcon from "../../shared/components/HelpIcon";
import styles from "./CallStack.module.css";
import {
DEFAULT_BOX_WIDTH,
Expand Down Expand Up @@ -389,6 +390,19 @@ const CallStack: React.FC<CallStackProps> = ({
{visualStyle === "pythonTutor" ? "Frames" : "Call Stack"}
</text>

<foreignObject
x={x + columnWidth - 26}
y={yPosition + (HEADER_HEIGHT - 18) / 2}
width={18}
height={18}
style={{ overflow: "visible" }}
>
<HelpIcon
title={visualStyle === "pythonTutor" ? "Frames" : "Call Stack"}
text="Shows the currently active function calls, most recent on top. Each frame lists that function's local variables and their values. Reorder frames by dragging to change which one is on top."
/>
</foreignObject>

<clipPath id={clipPathId}>
<rect
x={x - horizontalPadding}
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/features/canvasControls/CanvasControls.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@
}

.title {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
font-size: 0.85rem;
font-weight: 700;
Expand Down Expand Up @@ -148,6 +152,13 @@
transition: color 200ms ease;
}

.labelGroup {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}

.scaleValue {
font-size: 0.9rem;
font-weight: 600;
Expand All @@ -170,6 +181,22 @@
margin-bottom: 0;
}

.buttonWrapperRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}

.buttonWrapperRow:last-child {
margin-bottom: 0;
}

.buttonWrapperRow > *:first-child {
flex: 1;
min-width: 0;
}

.feedbackWrapper {
margin-top: 16px;
}
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/features/canvasControls/CanvasControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ function openSettingsTab() {
fireEvent.click(screen.getByRole("button", { name: /settings/i }));
}

function openViewTab() {
fireEvent.click(screen.getByRole("button", { name: /^view$/i }));
}

describe("CanvasControls", () => {
beforeEach(() => {
localStorage.clear();
Expand Down Expand Up @@ -93,3 +97,92 @@ describe("CanvasControls", () => {
expect(handleReferenceArrowChange).toHaveBeenCalledWith(true);
});
});

describe("CanvasControls help icons", () => {
beforeEach(() => {
localStorage.clear();
});

it("shows a help icon for the panel title explaining the tabs", () => {
renderControls();

fireEvent.click(
screen.getByRole("button", { name: "Help: Canvas Controls" })
);

expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText(/Actions to undo/i)).toBeInTheDocument();
});

it("shows help icons for Clear and Download without triggering their actions", () => {
const handleClear = jest.fn();
renderControls({ onClear: handleClear, elements: [] });

fireEvent.click(screen.getByRole("button", { name: "Help: Clear" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(handleClear).not.toHaveBeenCalled();

fireEvent.click(screen.getByRole("button", { name: "Close" }));

fireEvent.click(screen.getByRole("button", { name: "Help: Download" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
});

it("shows help icons for all three zoom controls in the View tab", () => {
renderControls({
onScaleChange: jest.fn(),
onEditorScaleChange: jest.fn(),
onFontScaleChange: jest.fn(),
});
openViewTab();

expect(
screen.getByRole("button", { name: "Help: Canvas Zoom" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Help: Editor Zoom" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Help: Question Zoom" })
).toBeInTheDocument();
});

it("shows a help icon for the practice/test mode toggle without flipping it", () => {
const handleModeToggle = jest.fn();
renderControls({ onModeToggle: handleModeToggle, isSandboxMode: true });
openSettingsTab();

fireEvent.click(
screen.getByRole("button", { name: "Help: Practice / Test Mode" })
);

expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(handleModeToggle).not.toHaveBeenCalled();
});

it("shows a help icon for the Python Tutor Style toggle", () => {
renderControls();
openSettingsTab();

fireEvent.click(
screen.getByRole("button", { name: "Help: Python Tutor Style" })
);

expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(
screen.getByText(/look like PythonTutor's visualizer/i)
).toBeInTheDocument();
});

it("shows help icons for Standalone Primitives and Reference Arrows in Python Tutor mode", () => {
renderControls({ visualStyle: "pythonTutor" });
openSettingsTab();

expect(
screen.getByRole("button", { name: "Help: Standalone Primitives" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Help: Reference Arrows" })
).toBeInTheDocument();
});
});
81 changes: 54 additions & 27 deletions frontend/src/features/canvasControls/CanvasControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, { useState } from "react";
import { CanvasElement, VisualStyle } from "../shared/types";
import { ClearCanvasButton, DownloadButton, ZoomControls, UndoButton, RedoButton, FeedbackButton } from "../canvas/components/CanvasButtons";
import { useTheme } from "../../contexts/ThemeContext";
import HelpIcon from "../shared/components/HelpIcon";
import styles from "./CanvasControls.module.css";

interface CanvasControlsProps {
Expand Down Expand Up @@ -97,7 +98,10 @@ export default function CanvasControls({
</nav>

<div className={styles.tabBody} role="tabpanel">
<h3 className={styles.title}>Canvas Controls</h3>
<h3 className={styles.title}>
Canvas Controls
<HelpIcon title="Canvas Controls" text="Tools for your canvas: Actions to undo/clear/export, View to zoom, and Settings to change modes and visual style." />
</h3>

{/* Actions Tab */}
{activeTab === "actions" && (
Expand All @@ -110,13 +114,15 @@ export default function CanvasControls({
)}

{onClear && (
<div className={styles.buttonWrapper}>
<div className={styles.buttonWrapperRow}>
<ClearCanvasButton onClick={onClear} />
<HelpIcon title="Clear" text="Permanently removes every box from the canvas. This can't be undone by anything other than Undo, right after." />
</div>
)}

<div className={styles.buttonWrapper}>
<div className={styles.buttonWrapperRow}>
<DownloadButton elements={elements} />
<HelpIcon title="Download" text="Export your diagram as a PNG image or the raw canvas data as JSON." />
</div>
</div>
)}
Expand All @@ -127,7 +133,10 @@ export default function CanvasControls({
{onScaleChange && (
<>
<div className={styles.controlItem}>
<label className={styles.controlLabel}>Canvas Zoom</label>
<span className={styles.labelGroup}>
<label className={styles.controlLabel}>Canvas Zoom</label>
<HelpIcon title="Canvas Zoom" text="Zooms the whole diagram — the boxes and arrows on the canvas." />
</span>
<span className={styles.scaleValue}>{Math.round(scale * 100)}%</span>
</div>
<div className={styles.buttonWrapper}>
Expand All @@ -139,7 +148,10 @@ export default function CanvasControls({
{onEditorScaleChange && (
<>
<div className={styles.controlItem}>
<label className={styles.controlLabel}>Editor Zoom</label>
<span className={styles.labelGroup}>
<label className={styles.controlLabel}>Editor Zoom</label>
<HelpIcon title="Editor Zoom" text="Zooms the popup editor that opens when you click a box to edit its value — separate from the canvas zoom." />
</span>
<span className={styles.scaleValue}>{Math.round(editorScale * 100)}%</span>
</div>
<div className={styles.buttonWrapper}>
Expand All @@ -151,7 +163,10 @@ export default function CanvasControls({
{onFontScaleChange && (
<>
<div className={styles.controlItem}>
<label className={styles.controlLabel}>Question Zoom</label>
<span className={styles.labelGroup}>
<label className={styles.controlLabel}>Question Zoom</label>
<HelpIcon title="Question Zoom" text="Resizes the question text and code in the info panel — doesn't affect the canvas or editor." />
</span>
<span className={styles.scaleValue}>{Math.round(fontScale * 100)}%</span>
</div>
<div className={styles.buttonWrapper}>
Expand Down Expand Up @@ -189,9 +204,12 @@ export default function CanvasControls({
{/* Mode Toggle */}
{onModeToggle && (
<div className={styles.controlItem}>
<label className={styles.controlLabel}>
{isSandboxMode ? "Practice" : "Test"}
</label>
<span className={styles.labelGroup}>
<label className={styles.controlLabel}>
{isSandboxMode ? "Practice" : "Test"}
</label>
<HelpIcon title="Practice / Test Mode" text="Practice mode only shows the boxes needed for the current question. Test mode gives you the full palette, simulating exam conditions. Switching modes clears the canvas." />
</span>
<button
type="button"
role="switch"
Expand Down Expand Up @@ -223,12 +241,15 @@ export default function CanvasControls({

{onVisualStyleChange && (
<div className={styles.controlItem}>
<label
className={styles.controlLabel}
htmlFor="python-tutor-style-toggle"
>
Python Tutor Style
</label>
<span className={styles.labelGroup}>
<label
className={styles.controlLabel}
htmlFor="python-tutor-style-toggle"
>
Python Tutor Style
</label>
<HelpIcon title="Python Tutor Style" text="Redraws the diagram to look like PythonTutor's visualizer instead of the default MemoryViz style." />
</span>
<button
id="python-tutor-style-toggle"
type="button"
Expand All @@ -255,12 +276,15 @@ export default function CanvasControls({
<div
className={`${styles.controlItem} ${styles.nestedControlItem}`}
>
<label
className={styles.controlLabel}
htmlFor="python-tutor-standalone-primitives-toggle"
>
Standalone Primitives
</label>
<span className={styles.labelGroup}>
<label
className={styles.controlLabel}
htmlFor="python-tutor-standalone-primitives-toggle"
>
Standalone Primitives
</label>
<HelpIcon title="Standalone Primitives" text="When on, primitive values (int, str, bool, etc.) are drawn as their own boxes with pointers. When off, they're shown inline inside their container, matching PythonTutor's default." />
</span>
<button
id="python-tutor-standalone-primitives-toggle"
type="button"
Expand All @@ -287,12 +311,15 @@ export default function CanvasControls({
<div
className={`${styles.controlItem} ${styles.nestedControlItem}`}
>
<label
className={styles.controlLabel}
htmlFor="python-tutor-reference-arrows-toggle"
>
Reference Arrows
</label>
<span className={styles.labelGroup}>
<label
className={styles.controlLabel}
htmlFor="python-tutor-reference-arrows-toggle"
>
Reference Arrows
</label>
<HelpIcon title="Reference Arrows" text="Draws arrows from variables and containers to the objects they reference, instead of just showing the referenced ID." />
</span>
<button
id="python-tutor-reference-arrows-toggle"
type="button"
Expand Down
Loading