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
4 changes: 3 additions & 1 deletion app/controllers/course/gradebook_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def parse_weight_entry(entry)
tab_id: entry[:tabId].to_i,
weight: entry[:weight].to_f,
weight_mode: entry[:weightMode] || 'equal',
keep_highest: entry[:keepHighest].to_i,
excluded_assessment_ids: (entry[:excludedAssessmentIds] || []).map(&:to_i),
assessment_weights: (entry[:assessmentWeights] || []).map do |aw|
{ assessment_id: aw[:assessmentId].to_i, weight: aw[:weight].to_f }
Expand All @@ -56,14 +57,15 @@ def parse_weight_entry(entry)

def update_weights_params
params.permit(
weights: [:tabId, :weight, :weightMode,
weights: [:tabId, :weight, :weightMode, :keepHighest,
excludedAssessmentIds: [], assessmentWeights: [:assessmentId, :weight]]
)
end

def serialize_weight_updates(updates)
updates.map do |u|
entry = { tabId: u[:tab_id], weight: u[:weight], weightMode: u[:weight_mode].to_s,
keepHighest: u[:keep_highest],
excludedAssessmentIds: u[:excluded_assessment_ids] }
if u[:weight_mode].to_s == 'custom'
entry[:assessmentWeights] = u[:assessment_weights].map do |aw|
Expand Down
1 change: 1 addition & 0 deletions app/views/course/gradebook/index.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ json.tabs @tabs do |tab|
contribution = @tab_contributions[tab.id]
json.gradebookWeight (contribution&.weight || 0).to_f
json.weightMode(contribution&.weight_mode || 'equal')
json.keepHighest(contribution&.keep_highest || 0)
end
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ describe('<ConfigureWeightsPrompt />', () => {
tabId: 10,
weight: 50,
weightMode: 'custom',
keepHighest: 0,
excludedAssessmentIds: [],
assessmentWeights: [
{ assessmentId: 101, weight: 25 },
Expand All @@ -157,6 +158,7 @@ describe('<ConfigureWeightsPrompt />', () => {
tabId: 11,
weight: 50,
weightMode: 'equal',
keepHighest: 0,
excludedAssessmentIds: [],
},
]);
Expand Down Expand Up @@ -404,6 +406,7 @@ describe('per-assessment exclusion', () => {
expect(arg[0]).toMatchObject({
tabId: 10,
weight: 50,
keepHighest: 0,
excludedAssessmentIds: [101, 102],
});
});
Expand Down Expand Up @@ -477,4 +480,190 @@ describe('per-assessment exclusion', () => {
expect(screen.queryByText(/no weights set yet/i)).not.toBeInTheDocument();
});
});

it('shows assessment weights sum footer in custom mode', () => {
setup();
fireEvent.click(
within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }),
);
// Expand to see the footer
// custom mode auto-expands, so just check the footer
expect(screen.getByText(/Assessment weights:/i)).toBeInTheDocument();
});
});

describe('per-assessment exclusion (extended)', () => {
beforeEach(() => jest.clearAllMocks());

it('shows "Excluded" label in custom mode for excluded assessment', async () => {
setup({
assessments: [
{
id: 101,
title: A1,
tabId: 10,
maxGrade: 100,
gradebookExcluded: true,
},
{ id: 102, title: A2, tabId: 10, maxGrade: 50 },
],
tabs: [
{
id: 10,
title: 'Assignments',
categoryId: 1,
gradebookWeight: 50,
weightMode: 'custom',
},
],
});
fireEvent.click(screen.getAllByRole('button', { name: '' })[0]);
expect(await screen.findByText('Excluded')).toBeInTheDocument();
});

it('does not show "Excluded" label in equal mode for excluded assessment', async () => {
setup({
assessments: [
{
id: 101,
title: A1,
tabId: 10,
maxGrade: 100,
gradebookExcluded: true,
},
{ id: 102, title: A2, tabId: 10, maxGrade: 50 },
],
});
fireEvent.click(screen.getAllByRole('button', { name: '' })[0]);
// In equal mode excluded shows "Excluded" text too
expect(await screen.findByText('Excluded')).toBeInTheDocument();
});
});

describe('keep-highest control', () => {
const TOGGLE = 'Enable keep highest for Assignments';
const INPUT = 'Keep highest for Assignments';
const three = [
{ id: 101, title: A1, tabId: 10, maxGrade: 100 },
{ id: 102, title: A2, tabId: 10, maxGrade: 50 },
{ id: 103, title: 'Assignment 3', tabId: 10, maxGrade: 80 },
];

beforeEach(() => jest.clearAllMocks());

it('renders a keep-highest checkbox; number field hidden until checked', () => {
setup({ assessments: three });
expect(screen.getByRole('checkbox', { name: TOGGLE })).toBeInTheDocument();
expect(
screen.queryByRole('spinbutton', { name: INPUT }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE }));
expect(screen.getByRole('spinbutton', { name: INPUT })).toBeInTheDocument();
});

it('shows a visible "Keep highest" text label next to the checkbox', () => {
setup({ assessments: three });
expect(screen.getByText('Keep highest')).toBeInTheDocument();
});

it('defaults the count to included − 1 when checked', () => {
setup({ assessments: three }); // 3 assessments -> default to 2
fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE }));
expect(screen.getByRole('spinbutton', { name: INPUT })).toHaveValue(2);
});

it('hides checkbox + field in custom mode', () => {
setup({ assessments: three });
fireEvent.click(
within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }),
);
expect(
screen.queryByRole('checkbox', { name: TOGGLE }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('spinbutton', { name: INPUT }),
).not.toBeInTheDocument();
});

it('seeds the field (checkbox pre-checked) from tab.keepHighest', () => {
setup({
assessments: three,
tabs: [
{
id: 10,
title: 'Assignments',
categoryId: 1,
gradebookWeight: 50,
keepHighest: 2,
},
{ id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 },
],
});
expect(screen.getByRole('checkbox', { name: TOGGLE })).toBeChecked();
expect(screen.getByRole('spinbutton', { name: INPUT })).toHaveValue(2);
});

it('disables the checkbox when only one assessment is included', () => {
setup({
assessments: [{ id: 101, title: A1, tabId: 10, maxGrade: 100 }],
});
expect(screen.getByRole('checkbox', { name: TOGGLE })).toBeDisabled();
});

it('sends keepHighest in the save payload', async () => {
setup({ assessments: three });
fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE }));
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() =>
expect(operations.updateGradebookWeights).toHaveBeenCalled(),
);
const arg = (operations.updateGradebookWeights as jest.Mock).mock
.calls[0][0];
const tab10 = arg.find((e: { tabId: number }) => e.tabId === 10);
expect(tab10.keepHighest).toBe(2);
});

it('unchecking sends keepHighest 0', async () => {
setup({
assessments: three,
tabs: [
{
id: 10,
title: 'Assignments',
categoryId: 1,
gradebookWeight: 50,
keepHighest: 2,
},
{ id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 },
],
});
// checkbox is pre-checked; uncheck it
fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE }));
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() =>
expect(operations.updateGradebookWeights).toHaveBeenCalled(),
);
const arg = (operations.updateGradebookWeights as jest.Mock).mock
.calls[0][0];
const tab10 = arg.find((e: { tabId: number }) => e.tabId === 10);
expect(tab10.keepHighest).toBe(0);
});

it('blocks saving on non-integer input but not on keep > included (overflow)', async () => {
setup({ assessments: three });
fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE }));
const input = screen.getByRole('spinbutton', { name: INPUT });

// overflow: keep=5 > included=3 -> warning but save NOT blocked
fireEvent.change(input, { target: { value: '5' } });
expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled();
expect(
screen.getByText(/keeps more assessments than it contains/i),
).toBeInTheDocument();

// non-integer (0): should block saving
fireEvent.change(input, { target: { value: '0' } });
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
expect(screen.getByText(/keep at least 1/i)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1116,4 +1116,145 @@ describe('GradebookWeightedTable', () => {
expect(cells[cells.length - 1]).toHaveTextContent('90%');
});
});

describe('single-open accordion', () => {
it('keeps only one student expanded at a time (opening another collapses the first)', async () => {
const user = userEvent.setup();
renderWeighted({
tabs: [makeTab(10, 'Missions', 1, 100)],
assessments: [makeAssessment(1, 'Mission 1', 10, 100)],
students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')],
submissions: [makeSub(1, 1, 80), makeSub(2, 1, 60)],
});

await user.click(screen.getByRole('button', { name: /expand Alice/i }));
expect(
await screen.findByTestId(breakdownRowId(1, 10, 1)),
).toBeInTheDocument();

// Opening Bob must collapse Alice.
await user.click(screen.getByRole('button', { name: /expand Bob/i }));
expect(
await screen.findByTestId(breakdownRowId(2, 10, 1)),
).toBeInTheDocument();
await waitFor(() =>
expect(
screen.queryByTestId(breakdownRowId(1, 10, 1)),
).not.toBeInTheDocument(),
);
});

it('keeps focus on the toggle after expanding (keyboard users stay put)', async () => {
const user = userEvent.setup();
renderWeighted({
tabs: [makeTab(10, 'Missions', 1, 100)],
assessments: [makeAssessment(1, 'Mission 1', 10, 100)],
students: [makeStudent(1, 'Alice')],
submissions: [makeSub(1, 1, 80)],
});
const toggle = screen.getByRole('button', { name: /expand Alice/i });
await user.click(toggle);
// Same DOM button (label flips expand→collapse), so focus is retained.
expect(
screen.getByRole('button', { name: /collapse Alice/i }),
).toHaveFocus();
});
});

describe('auto-scroll on expand', () => {
const one = {
tabs: [makeTab(10, 'Missions', 1, 100)],
assessments: [makeAssessment(1, 'Mission 1', 10, 100)],
students: [makeStudent(1, 'Alice')],
submissions: [makeSub(1, 1, 80)],
};

afterEach(() => {
// Restore the scrollTo we stub in (jsdom doesn't implement it).
delete (Element.prototype as unknown as { scrollTo?: unknown }).scrollTo;
});

it('smooth-scrolls the expanded row into view by default', async () => {
const scrollSpy = jest.fn();
(Element.prototype as unknown as { scrollTo: unknown }).scrollTo =
scrollSpy;
const user = userEvent.setup();
renderWeighted(one);
await user.click(screen.getByRole('button', { name: /expand Alice/i }));
await waitFor(() => expect(scrollSpy).toHaveBeenCalled());
expect(scrollSpy.mock.calls[0][0]).toMatchObject({ behavior: 'smooth' });
});

it('jumps instantly when the user prefers reduced motion', async () => {
const scrollSpy = jest.fn();
(Element.prototype as unknown as { scrollTo: unknown }).scrollTo =
scrollSpy;
const originalMatchMedia = window.matchMedia;
window.matchMedia = ((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addEventListener: (): void => {},
removeEventListener: (): void => {},
addListener: (): void => {},
removeListener: (): void => {},
dispatchEvent: (): boolean => false,
})) as unknown as typeof window.matchMedia;

try {
const user = userEvent.setup();
renderWeighted(one);
await user.click(screen.getByRole('button', { name: /expand Alice/i }));
await waitFor(() => expect(scrollSpy).toHaveBeenCalled());
expect(scrollSpy.mock.calls[0][0]).toMatchObject({ behavior: 'auto' });
} finally {
window.matchMedia = originalMatchMedia;
}
});
});

describe('drop-lowest breakdown marker', () => {
// One equal tab, keepHighest=1, three graded assessments. The lowest (a1) drops.
const dropConfig = {
tabs: [{ ...makeTab(10, 'Missions', 1, 60), keepHighest: 1 }],
assessments: [
makeAssessment(1, 'Mission 1', 10, 100),
makeAssessment(2, 'Mission 2', 10, 100),
makeAssessment(3, 'Mission 3', 10, 100),
],
students: [makeStudent(1, 'Alice')],
submissions: [makeSub(1, 1, 30), makeSub(1, 2, 60), makeSub(1, 3, 90)],
};

it('labels the dropped assessment "Dropped (lowest)" (not "Excluded")', async () => {
const user = userEvent.setup();
renderWeighted(dropConfig);
await user.click(screen.getByRole('button', { name: /expand Alice/i }));
const detail = await screen.findByTestId(breakdownRowId(1, 10, 1));
expect(
within(detail).getByText(/Dropped \(lowest\)/),
).toBeInTheDocument();
expect(within(detail).queryByText(/Excluded/i)).not.toBeInTheDocument();
});

it('shows 0 (not —) in the dropped assessment cell in points mode', async () => {
const user = userEvent.setup();
renderWeighted(dropConfig);
await user.click(screen.getByRole('button', { name: /expand Alice/i }));
const detail = await screen.findByTestId(breakdownRowId(1, 10, 1));
// Dropped contributes 0 points — distinct from excluded's em dash.
expect(within(detail).getByText('0')).toBeInTheDocument();
expect(within(detail).queryByText('—')).not.toBeInTheDocument();
});

it("shows the dropped assessment's own grade % in percentage mode", async () => {
const user = userEvent.setup();
renderWeighted(dropConfig);
await user.click(screen.getByRole('radio', { name: /percentage/i }));
await user.click(screen.getByRole('button', { name: /expand Alice/i }));
const detail = await screen.findByTestId(breakdownRowId(1, 10, 1));
// a1 = 30/100 = 30% — visible so the instructor sees the dropped grade.
expect(within(detail).getByText('30%')).toBeInTheDocument();
});
});
});
Loading