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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AxeBuilder } from "@axe-core/playwright";

import { gotoApp } from "../fixtures/appNav";
import { findGradableQuestion, gotoSplitView } from "../fixtures/grader";
import { expect, test } from "../fixtures/scratch";

const scan = (page: import("@playwright/test").Page) =>
Expand Down Expand Up @@ -40,7 +42,7 @@ test(
"reports assignment list axe violations",
{ tag: ["@p2", "@a11y", "@assignments"] },
async ({ page }, testInfo) => {
await page.goto("/builder");
await gotoApp(page, "/builder");
await expect(page.getByRole("heading", { level: 1, name: "Assignments" })).toBeVisible();

const results = await scan(page);
Expand All @@ -53,11 +55,55 @@ test(
"reports grader assignment list axe violations",
{ tag: ["@p2", "@a11y", "@grader"] },
async ({ page }, testInfo) => {
await page.goto("/grader");
await gotoApp(page, "/grader");
await expect(page.getByRole("heading", { level: 1, name: "Assignment grader" })).toBeVisible();
await expect(page.getByRole("heading", { level: 2, name: "Assignments" })).toBeAttached();
await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toContainText("Assignments");
await expect(page.getByRole("region", { name: "Assignments" })).toBeVisible();
await expect(page.locator('[data-tour="grader-assignment-picker"]')).toBeVisible();

const results = await scan(page);

await reportViolations(testInfo, results);
}
);

test(
"reports grader question list axe violations",
{ tag: ["@p2", "@a11y", "@grader"] },
async ({ page }, testInfo) => {
const target = await findGradableQuestion(page.request);

await gotoApp(page, `/grader/${target.assignmentId}`);
await expect(page.getByRole("region", { name: "Questions" })).toBeVisible();

const results = await scan(page);

await reportViolations(testInfo, results);
}
);

test(
"reports grader split view axe violations",
{ tag: ["@p2", "@a11y", "@grader"] },
async ({ page }, testInfo) => {
const target = await findGradableQuestion(page.request);

await gotoSplitView(page, target);
await expect(page.getByRole("complementary", { name: "Students" })).toBeVisible();
await expect(page.locator('[data-tour="grader-grade-panel"]')).toBeVisible();

const results = await scan(page);

await reportViolations(testInfo, results);
}
);

test(
"reports grader gradebook axe violations",
{ tag: ["@p2", "@a11y", "@grader", "@gradebook"] },
async ({ page }, testInfo) => {
await gotoApp(page, "/gradebook");
await expect(page.getByRole("heading", { level: 2, name: "Gradebook" })).toBeVisible();

const results = await scan(page);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,9 @@
color: var(--rs-text-muted);
margin-bottom: 4px;
}
.historyItem.active .historyMeta {
color: var(--rs-brand-800);
}
.historyAnswer {
font-size: 11px;
color: var(--rs-text-strong);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,18 @@ export const DeadlineExceptionDialog: React.FC<DeadlineExceptionDialogProps> = (
</Radio.Group>
<div className={styles.numberFieldRow}>
<div>
<label className={styles.fieldHint}>Extend deadline by (days)</label>
<NumberInput
label="Extend deadline by (days)"
classNames={{ label: styles.fieldHint }}
value={extraDays ?? ""}
onChange={(v) => setExtraDays(typeof v === "number" ? v : 0)}
min={0}
/>
</div>
<div>
<label className={styles.fieldHint}>Time-limit multiplier</label>
<NumberInput
label="Time-limit multiplier"
classNames={{ label: styles.fieldHint }}
value={timeMultiplier ?? ""}
onChange={(v) => setTimeMultiplier(typeof v === "number" ? v : 1)}
min={0}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { GraderStudentAnswer } from "@store/grader/grader.logic.api";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";

import { renderWithMantine, screen } from "@/test/renderWithMantine";

import { GradePanel } from "./GradePanel";

const student: GraderStudentAnswer = {
sid: "ada",
first_name: "Ada",
last_name: "Lovelace",
answer: "",
attempts: 2,
score: 4,
comment: "Good work",
max_points: 10
};

const renderPanel = () => {
const onAutoAdvanceChange = vi.fn();

renderWithMantine(
<GradePanel
student={student}
maxPoints={10}
score={4}
comment="Good work"
status="idle"
hasPrev
hasNext
positionLabel="1 / 3"
onScoreChange={vi.fn()}
onCommentChange={vi.fn()}
onCommentBlur={vi.fn()}
onScoreBlur={vi.fn()}
onPrev={vi.fn()}
onNext={vi.fn()}
onRetry={vi.fn()}
autoAdvance={false}
onAutoAdvanceChange={onAutoAdvanceChange}
/>
);

return { onAutoAdvanceChange };
};

describe("GradePanel", () => {
it("associates grading controls with their accessible labels", () => {
renderPanel();

expect(screen.getByLabelText("Points (max 10)")).toBeInTheDocument();
expect(screen.getByLabelText("Comment")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Ada Lovelace");
});

it("labels and updates the auto-advance switch", async () => {
const { onAutoAdvanceChange } = renderPanel();

await userEvent.click(screen.getByLabelText("Auto-advance after save"));

expect(onAutoAdvanceChange).toHaveBeenCalledWith(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ export const GradePanel = forwardRef<GradePanelHandle, Props>(function GradePane
<div className={styles.gradePane} data-tour="grader-grade-panel">
<div className={styles.gradePaneHeader}>
<Icon name="user" size={18} className={styles.gradePaneAvatar} />
<div className={styles.gradePaneIdentity}>
<div
className={styles.gradePaneIdentity}
role="status"
aria-live="polite"
aria-atomic="true"
>
<div className={styles.gradePaneName}>{studentName}</div>
<div className={styles.gradePaneMeta}>
{student ? (
Expand All @@ -98,7 +103,7 @@ export const GradePanel = forwardRef<GradePanelHandle, Props>(function GradePane
</div>

{onAutoAdvanceChange && (
<label
<div
data-tour="grader-auto-advance"
className={styles.autoAdvanceToggle}
title="When on, jumps to the next ungraded student a moment after saving. You can always undo from the toast."
Expand All @@ -107,10 +112,14 @@ export const GradePanel = forwardRef<GradePanelHandle, Props>(function GradePane
checked={!!autoAdvance}
onChange={(e) => onAutoAdvanceChange(e.currentTarget.checked)}
size="xs"
label={
<>
<Icon name="forward" />
<span>Auto-advance after save</span>
</>
}
/>
<Icon name="forward" />
<span>Auto-advance after save</span>
</label>
</div>
)}

<div className={styles.gradePaneNav}>
Expand Down Expand Up @@ -142,26 +151,27 @@ export const GradePanel = forwardRef<GradePanelHandle, Props>(function GradePane
</div>

<div>
<label className={styles.fieldLabel}>Points (max {maxPoints})</label>
<div data-tour="grader-points-input" className={styles.scoreRow}>
<NumberInput
ref={scoreInputRef}
label={`Points (max ${maxPoints})`}
classNames={{ label: styles.fieldLabel, input: styles.scoreInput }}
value={score}
onChange={(v) => onScoreChange(typeof v === "number" ? v : 0)}
onBlur={onScoreBlur}
min={0}
max={maxPoints}
clampBehavior="strict"
disabled={isDisabled}
classNames={{ input: styles.scoreInput }}
/>
</div>
</div>

<div>
<label className={styles.fieldLabel}>Comment</label>
<Textarea
ref={commentRef}
label="Comment"
classNames={{ label: styles.fieldLabel }}
data-tour="grader-comment-input"
value={comment}
onChange={(e) => onCommentChange(e.currentTarget.value)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,22 @@ describe("GraderPageSection", () => {

expect(screen.getByRole("heading", { level: 2, name: "Gradebook" })).toHaveFocus();
});

it("does not steal focus when navigating between students on the same question", () => {
const { rerender } = render(
<GraderPageSection heading="Question" pathname="/grader/1/questions/2/students/s1">
<button type="button">Student row</button>
</GraderPageSection>
);
const studentRow = screen.getByRole("button", { name: "Student row" });

studentRow.focus();
rerender(
<GraderPageSection heading="Question" pathname="/grader/1/questions/2/students/s2">
<button type="button">Student row</button>
</GraderPageSection>
);

expect(studentRow).toHaveFocus();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ export const GraderPageSection: React.FC<GraderPageSectionProps> = ({
pathname
}) => {
const headingRef = useRef<HTMLHeadingElement>(null);
const pagePathname = pathname.replace(/\/students\/[^/]+\/?$/, "");

useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
}, [pagePathname]);

return (
<section className={styles.body} aria-labelledby="grader-page-heading">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,19 +307,19 @@ export const MultiGradeDialog: React.FC<MultiGradeDialogProps> = ({
<div className={styles.fieldStack}>
<div className={styles.applyRow}>
<div>
<label className={styles.fieldHint}>
Score (applied to every student × question)
</label>
<NumberInput
label="Score (applied to every student × question)"
classNames={{ label: styles.fieldHint }}
value={sameScore ?? ""}
onChange={(v) => setSameScore(typeof v === "number" ? v : null)}
min={0}
max={maxOfSelection || undefined}
/>
</div>
<div className={styles.grow}>
<label className={styles.fieldHint}>Comment (optional)</label>
<Textarea
label="Comment (optional)"
classNames={{ label: styles.fieldHint }}
value={sameComment}
onChange={(e) => setSameComment(e.currentTarget.value)}
autosize
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("SaveStatusPill", () => {
renderWithMantine(<SaveStatusPill status="saved" />);

expect(screen.getByRole("status")).toHaveTextContent("Saved");
expect(screen.getByRole("status")).toHaveAttribute("aria-atomic", "true");
expect(screen.getByTestId("save-pill-checkmark")).toBeInTheDocument();
});

Expand All @@ -38,8 +39,9 @@ describe("SaveStatusPill", () => {
<SaveStatusPill status="error" onRetry={onRetry} errorMessage="Network down" />
);

expect(screen.getByRole("status")).toHaveTextContent("Save failed");
expect(screen.getByRole("status")).toHaveAttribute("title", "Network down");
expect(screen.getByRole("alert")).toHaveTextContent("Save failed");
expect(screen.getByRole("alert")).toHaveAttribute("aria-live", "assertive");
expect(screen.getByRole("alert")).toHaveAttribute("title", "Network down");

await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetry).toHaveBeenCalledTimes(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export const SaveStatusPill: React.FC<Props> = ({ status, onRetry, errorMessage
return (
<span
className={`${styles.savePill} ${PILL_CLASS[status]}`}
role="status"
aria-live="polite"
role={status === "error" ? "alert" : "status"}
aria-live={status === "error" ? "assertive" : "polite"}
aria-atomic="true"
title={status === "error" ? errorMessage : undefined}
>
{status === "saving" && <Loader size={12} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ describe("StudentListSidebar", () => {
expect(screen.getByText("No students match the filter.")).toBeInTheDocument();
});

it("announces filtered result counts and exposes keyboard instructions", async () => {
renderSidebar();
const filter = screen.getByRole("textbox", { name: "Search students" });
const listbox = screen.getByRole("listbox", { name: "Students" });
const instructionsId = listbox.getAttribute("aria-describedby");

expect(screen.getByTestId("student-filter-status")).toHaveTextContent("3 of 3 students shown.");
expect(instructionsId).toBeTruthy();
expect(document.getElementById(instructionsId!)).toHaveTextContent(/Up and Down Arrow keys/);

await userEvent.type(filter, "Ada");

expect(screen.getByTestId("student-filter-status")).toHaveTextContent("1 of 3 students shown.");
});

it("calls onSelect when a row is clicked", async () => {
const { onSelect } = renderSidebar();

Expand Down Expand Up @@ -145,6 +160,22 @@ describe("StudentListSidebar", () => {
expect(onToggleHideUnanswered).toHaveBeenCalledWith(true);
});

it("closes student filters with Escape and returns focus to the trigger", async () => {
renderSidebar();
const trigger = screen.getByRole("button", { name: "Student filters, 0 active" });

await userEvent.click(trigger);
const checkbox = screen.getByRole("checkbox", { name: "Hide students with full credit" });

checkbox.focus();
await userEvent.keyboard("{Escape}");

expect(
screen.queryByRole("checkbox", { name: "Hide students with full credit" })
).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});

it("shows the number of active filters on the trigger", () => {
renderSidebar({ hideFullCredit: true, hideUnanswered: true });

Expand Down
Loading
Loading