Skip to content

Commit aa1702a

Browse files
committed
feat(gradebook): level contribution percent mode + parseable-formula guard
Render level contribution in percent mode, and enforce that an enabled level contribution always has a parseable formula: - Model: validate on :formula (not the hidden :formula_ast) that an enabled config with a formula carries a non-blank ast — the error surfaces on the field the user edits, and an enabled config can no longer silently evaluate to nothing. - Dialog: parse the formula whenever level is enabled, so clearing it blocks Save and flags the field instead of round-tripping to a confusing 422. - Controller: wrap the tab-weight and level-config writes in one transaction so a rejected formula rolls back the tab weights instead of a partial save.
1 parent f23726d commit aa1702a

8 files changed

Lines changed: 163 additions & 15 deletions

File tree

app/controllers/course/gradebook_controller.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ def index
2222
def update_weights
2323
authorize! :manage_gradebook_weights, current_course
2424
updates = (update_weights_params[:weights] || []).map { |entry| parse_weight_entry(entry) }
25-
Course::Gradebook::TabContribution.bulk_update(course: current_course, updates: updates)
26-
level_config = persist_level_contribution
25+
level_config = nil
26+
# One transaction so a rejected level formula rolls back the tab-weight writes too,
27+
# rather than leaving a partial save.
28+
ActiveRecord::Base.transaction do
29+
Course::Gradebook::TabContribution.bulk_update(course: current_course, updates: updates)
30+
level_config = persist_level_contribution
31+
end
2732
response_body = { weights: serialize_weight_updates(updates) }
2833
response_body[:levelContribution] = serialize_level_contribution(level_config) if level_config
2934
render json: response_body

app/models/course/gradebook/level_config.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ class Course::Gradebook::LevelConfig < ApplicationRecord # rubocop:disable Metri
44

55
validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
66
validates :formula, presence: true, if: :enabled
7-
validates :formula_ast, presence: true, if: :enabled
87
validates :course_id, uniqueness: true
98
validate :formula_ast_structure
9+
validate :enabled_formula_must_parse
1010

1111
# Upserts the singleton Level config for a course from a normalized attrs hash
1212
# (symbol keys, snake_case). Raises ActiveRecord::RecordInvalid on validation failure.
@@ -88,6 +88,18 @@ def formula_ast_structure
8888
errors.add(:formula_ast, 'has an invalid structure') unless self.class.valid_ast?(formula_ast)
8989
end
9090

91+
# An enabled contribution must carry a usable parse. formula_ast is derived client-side
92+
# (there is no server-side parser), so a present formula with a blank ast means the formula
93+
# did not parse — reject it on :formula (the field the user edits) rather than silently
94+
# persisting an enabled config that evaluate_for returns nil for. The blank-formula case is
95+
# already covered by the presence validation above, so guard on formula.present? to avoid a
96+
# duplicate error.
97+
def enabled_formula_must_parse
98+
return unless enabled && formula.present?
99+
100+
errors.add(:formula, 'could not be parsed') if formula_ast.blank?
101+
end
102+
91103
def evaluate_node(node, level)
92104
case node['type']
93105
when 'num' then node['value'].to_f

client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,19 @@ describe('<ConfigureWeightsPrompt />', () => {
202202
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
203203
});
204204

205+
it('disables Save and flags the formula when Level contribution is enabled but the formula is cleared', () => {
206+
setup({
207+
gamificationEnabled: true,
208+
levelContribution: enabledLevel(),
209+
students,
210+
});
211+
fireEvent.change(screen.getByLabelText(/formula/i), {
212+
target: { value: '' },
213+
});
214+
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
215+
expect(screen.getByText('Enter a formula.')).toBeInTheDocument();
216+
});
217+
205218
it('shows the running assessment-weight sum against the tab total', () => {
206219
setup();
207220
fireEvent.click(

client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,6 +1780,71 @@ describe('level contribution columns', () => {
17801780
).not.toBeInTheDocument();
17811781
});
17821782

1783+
// Percent lens: the Level Contribution column is stored in points but, like every
1784+
// other category column, renders as "% of this component earned" in percent mode —
1785+
// i.e. contribution / weight × 100, NOT the raw points with a stray "%" suffix.
1786+
it('renders the level contribution as a percentage of its weight in percent mode', async () => {
1787+
const user = userEvent.setup();
1788+
renderWeighted({
1789+
gamificationEnabled: true,
1790+
levelContribution: { ...levelOn, formula: 'level', weight: 30 },
1791+
tabs: [makeTab(10, 'Tab 1', 1, 100)],
1792+
assessments: [makeAssessment(100, 'Quiz 1', 10, 10)],
1793+
// BE-supplied contribution: 15 pts out of a 30-pt budget.
1794+
students: [
1795+
{ ...makeStudent(1, 'Alice'), level: 8, levelContribution: 15 },
1796+
],
1797+
submissions: [makeSub(1, 100, 8)],
1798+
});
1799+
await user.click(screen.getByRole('radio', { name: /percentage/i }));
1800+
const aliceRow = screen.getByText('Alice').closest('tr')!;
1801+
// 15 / 30 → 50%, mirroring how a tab cell shows the fraction of that tab earned.
1802+
expect(within(aliceRow).getByText('50%')).toBeInTheDocument();
1803+
});
1804+
1805+
it('renders the level breakdown row as a percentage of the weight in percent mode', async () => {
1806+
const user = userEvent.setup();
1807+
renderWeighted({
1808+
gamificationEnabled: true,
1809+
levelContribution: { ...levelOn, formula: 'level', weight: 30 },
1810+
tabs: [makeTab(10, 'Tab 1', 1, 100)],
1811+
assessments: [makeAssessment(100, 'Quiz 1', 10, 10)],
1812+
students: [
1813+
{ ...makeStudent(1, 'Alice'), level: 8, levelContribution: 15 },
1814+
],
1815+
submissions: [makeSub(1, 100, 8)],
1816+
});
1817+
await user.click(screen.getByRole('radio', { name: /percentage/i }));
1818+
await user.click(screen.getByRole('button', { name: /expand Alice/i }));
1819+
const levelRow = (await screen.findAllByTestId(/^breakdown-row-1--1-/))[0];
1820+
// The breakdown's level cell mirrors the summary cell: 15 / 30 → 50%.
1821+
expect(within(levelRow).getByText('50%')).toBeInTheDocument();
1822+
});
1823+
1824+
it('lets an over-budget level contribution read past 100% in percent mode', async () => {
1825+
const user = userEvent.setup();
1826+
renderWeighted({
1827+
gamificationEnabled: true,
1828+
// clamp off + a formula that overshoots: BE supplies the raw 45 pts.
1829+
levelContribution: {
1830+
...levelOn,
1831+
formula: 'level',
1832+
weight: 30,
1833+
clamp: false,
1834+
},
1835+
tabs: [makeTab(10, 'Tab 1', 1, 100)],
1836+
assessments: [makeAssessment(100, 'Quiz 1', 10, 10)],
1837+
students: [
1838+
{ ...makeStudent(1, 'Alice'), level: 45, levelContribution: 45 },
1839+
],
1840+
submissions: [makeSub(1, 100, 8)],
1841+
});
1842+
await user.click(screen.getByRole('radio', { name: /percentage/i }));
1843+
const aliceRow = screen.getByText('Alice').closest('tr')!;
1844+
// 45 / 30 → 150%: the weight is a suggested max, never a cap.
1845+
expect(within(aliceRow).getByText('150%')).toBeInTheDocument();
1846+
});
1847+
17831848
// Over-budget warning: the level weight is a suggested maximum, so a formula can
17841849
// push a student's contribution past it or below 0. The Level Contribution subheader
17851850
// shows a bound-aware message (above-only, below-only, or both).

client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,8 +385,11 @@ const ConfigureWeightsPrompt: FC<Props> = ({
385385
const effectiveWeight = (tabId: number): number =>
386386
isAllExcluded(tabId) ? 0 : weights[tabId] ?? 0;
387387

388-
const parsedLevel: ParsedFormula | null =
389-
levelEnabled && levelFormula ? parseFormula(levelFormula) : null;
388+
// Parse whenever enabled — including an empty string, which parseFormula reports as
389+
// { ok: false, error: 'Enter a formula.' } so Save is blocked and the field flags it.
390+
const parsedLevel: ParsedFormula | null = levelEnabled
391+
? parseFormula(levelFormula)
392+
: null;
390393
const levelParseError =
391394
parsedLevel && !parsedLevel.ok ? parsedLevel.error : null;
392395
// Students whose contribution falls outside [0, levelWeight], split by bound —

client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,18 @@ const WeightedGradebookTable = ({
309309
return displayMode === 'percent' ? sub * 100 : sub * weight;
310310
};
311311

312+
// The Level Contribution is stored in points (already weight-scaled). Points mode
313+
// shows it verbatim; percent mode shows the fraction of the level budget earned
314+
// (points / weight × 100), mirroring how a tab cell reads in percent — a value can
315+
// exceed 100% since the weight is a suggested max, never a cap. Weight 0 → the
316+
// component carries no grade, so there's nothing meaningful to normalise against.
317+
const levelDisplayValue = (points: number | null): number | null => {
318+
if (points === null) return null;
319+
if (displayMode !== 'percent') return points;
320+
const weight = levelContribution.weight;
321+
return weight > 0 ? (points / weight) * 100 : null;
322+
};
323+
312324
const allExcludedTabIds = useMemo(() => {
313325
const byTab = new Map<number, boolean>();
314326
assessments.forEach((a) => {
@@ -632,9 +644,11 @@ const WeightedGradebookTable = ({
632644
return {
633645
tabs: tabPrecs,
634646
total: columnPrecision(rows.map((r) => totalDisplayValue(r.total))),
635-
level: columnPrecision(rows.map((r) => r.levelContribution ?? null)),
647+
level: columnPrecision(
648+
rows.map((r) => levelDisplayValue(r.levelContribution ?? null)),
649+
),
636650
};
637-
}, [rows, resolvedTabs, displayMode, totalWeight]);
651+
}, [rows, resolvedTabs, displayMode, totalWeight, levelContribution.weight]);
638652

639653
const hasExternalIds = useMemo(
640654
() => students.some((s) => s.externalId != null && s.externalId !== ''),
@@ -689,9 +703,13 @@ const WeightedGradebookTable = ({
689703
cols.push({
690704
id: 'levelContribution',
691705
title: t(translations.levelContributionHeader),
692-
accessorFn: (row) => fmtCsv(row.levelContribution ?? null),
706+
accessorFn: (row) =>
707+
fmtCsv(levelDisplayValue(row.levelContribution ?? null)),
693708
cell: (row) =>
694-
fmtDisplay(row.levelContribution ?? null, columnPrecisions.level),
709+
fmtDisplay(
710+
levelDisplayValue(row.levelContribution ?? null),
711+
columnPrecisions.level,
712+
),
695713
csvDownloadable: true,
696714
});
697715
}
@@ -1267,7 +1285,9 @@ const WeightedGradebookTable = ({
12671285
{showLevelContributionCol &&
12681286
((): React.JSX.Element => {
12691287
const valueText = fmtDisplay(
1270-
row.original.levelContribution ?? null,
1288+
levelDisplayValue(
1289+
row.original.levelContribution ?? null,
1290+
),
12711291
columnPrecisions.level,
12721292
);
12731293
const info = levelClampByStudent.get(
@@ -1479,7 +1499,7 @@ const WeightedGradebookTable = ({
14791499
<TableCell align="right">
14801500
{tb.tabId === LEVEL_TAB_ID
14811501
? fmtDisplay(
1482-
a.points,
1502+
levelDisplayValue(a.points),
14831503
columnPrecisions.level,
14841504
)
14851505
: ''}

spec/controllers/course/gradebook_controller_spec.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,17 @@ def weight_for(tab)
378378
expect(weight_for(tab1)).to eq(10)
379379
end
380380

381+
it 'rolls back tab weights when the enabled level formula is invalid' do
382+
create(:course_gradebook_tab_contribution, tab: tab1, course: course, weight: 10)
383+
patch :update_weights, params: {
384+
course_id: course.id,
385+
weights: [tabId: tab1.id, weight: 50],
386+
levelContribution: { enabled: true, formula: '', weight: 5 }
387+
}, as: :json
388+
expect(response).to have_http_status(:unprocessable_content)
389+
expect(weight_for(tab1)).to eq(10)
390+
end
391+
381392
it 'rejects >100 with 422' do
382393
patch :update_weights,
383394
params: { course_id: course.id, weights: [tabId: tab1.id, weight: 101] },

spec/models/course/gradebook/level_config_spec.rb

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
expect(config).to be_valid
2424
end
2525

26+
it 'rejects an enabled config whose formula did not parse (blank ast), erroring on :formula' do
27+
config = build(:course_gradebook_level_config, course: course,
28+
enabled: true, formula: 'level', formula_ast: nil)
29+
expect(config).not_to be_valid
30+
expect(config.errors[:formula]).to be_present
31+
end
32+
2633
it 'rejects a negative weight' do
2734
config = build(:course_gradebook_level_config, course: course, weight: -1)
2835
expect(config).not_to be_valid
@@ -43,7 +50,8 @@
4350
it 'creates a config from a camelCase-free attrs hash' do
4451
config = described_class.upsert_for(
4552
course: course,
46-
attrs: { enabled: true, formula: 'min(level, 30) * 0.05', weight: 8, show: true }
53+
attrs: { enabled: true, formula: 'min(level, 30) * 0.05',
54+
formula_ast: { 'type' => 'var', 'name' => 'level' }, weight: 8, show: true }
4755
)
4856
expect(config.enabled).to eq(true)
4957
expect(config.formula).to eq('min(level, 30) * 0.05')
@@ -52,7 +60,9 @@
5260
end
5361

5462
it 'updates the existing singleton on a second call' do
55-
described_class.upsert_for(course: course, attrs: { enabled: true, formula: 'level', weight: 2 })
63+
described_class.upsert_for(course: course,
64+
attrs: { enabled: true, formula: 'level',
65+
formula_ast: { 'type' => 'var', 'name' => 'level' }, weight: 2 })
5666
described_class.upsert_for(course: course, attrs: { enabled: false, formula: '', weight: 0 })
5767
expect(described_class.where(course_id: course.id).count).to eq(1)
5868
expect(course.reload.gradebook_level_config.enabled).to eq(false)
@@ -253,14 +263,23 @@
253263
expect(config.formula_ast).to eq(valid_ast)
254264
end
255265

256-
it 'stores nil formula_ast when not provided' do
266+
it 'stores nil formula_ast for a disabled config' do
257267
config = described_class.upsert_for(
258268
course: course,
259-
attrs: { enabled: true, formula: 'level', weight: 5 }
269+
attrs: { enabled: false, formula: '', weight: 0 }
260270
)
261271
expect(config.formula_ast).to be_nil
262272
end
263273

274+
it 'raises RecordInvalid when enabled with a formula but no parseable ast' do
275+
expect do
276+
described_class.upsert_for(
277+
course: course,
278+
attrs: { enabled: true, formula: 'level', weight: 5 }
279+
)
280+
end.to raise_error(ActiveRecord::RecordInvalid)
281+
end
282+
264283
it 'raises RecordInvalid for a malformed formula_ast' do
265284
expect do
266285
described_class.upsert_for(

0 commit comments

Comments
 (0)