Skip to content

Commit 7300ee0

Browse files
author
Nils Bars
committed
Add public scoreboard with switchable views and ranking strategies
Introduce a public scoreboard ported from the raid/raid prototype, rebuilt on the current dev codebase as two independent extension points: - Scoring policy per ExerciseConfig — linear (with configurable raw bounds), threshold, or tiered — applied server-side when the submissions API serves transformed scores. - Ranking strategy — f1_time_weighted (Formula 1 time-weighted points) or best_sum (sum of each team's best per challenge). Selected at runtime via SCOREBOARD_RANKING_MODE; adding a third strategy is one JS module plus one entry in ref/core/scoring.py. - Visual view — default (retro-terminal dark theme with Chart.js highscore panels, ranking table, points-over-time chart, per-challenge plots, live countdowns) or minimal (Bootstrap ranking table). Selected via SCOREBOARD_VIEW; adding a third view is one template plus one entry module. Only assignments whose exercises are actually online (a built default Exercise row exists) are exposed by /api/scoreboard/config. The default view auto-selects the assignment whose submission window is currently open, and preserves user assignment/challenge tab selections across the 5-second auto-refresh polls. Team identity is group-aware: ranking rows show the UserGroup name when GROUPS_ENABLED, otherwise the student's full name. Site-wide navigation is unified via a new _navbar.html partial included from student_base.html and admin_base.html, with Registration / Restore Key / Scoreboard entries always visible and Admin / Grading / System dropdowns gated on the user's authorization. Adds a landing chooser page (SCOREBOARD_LANDING_PAGE = "chooser") that shows the registration/restore buttons plus a live ranking preview. Closes #12 Closes #13 Closes #15
1 parent 72dfe97 commit 7300ee0

31 files changed

Lines changed: 2841 additions & 102 deletions

tests/unit/test_scoring.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""Unit tests for ref/core/scoring.py.
2+
3+
Covers the scoring policy transform, the policy validator, the ranking
4+
strategy and view resolvers, and team_identity's group-aware behavior.
5+
"""
6+
7+
from unittest.mock import MagicMock, patch
8+
9+
import pytest
10+
11+
from ref.core.scoring import (
12+
DEFAULT_RANKING_STRATEGY,
13+
DEFAULT_SCOREBOARD_VIEW,
14+
RANKING_STRATEGIES,
15+
SCOREBOARD_VIEWS,
16+
apply_scoring,
17+
resolve_ranking_mode,
18+
resolve_scoreboard_view,
19+
team_identity,
20+
validate_scoring_policy,
21+
)
22+
23+
24+
@pytest.mark.offline
25+
class TestApplyScoring:
26+
def test_none_policy_passes_through(self):
27+
assert apply_scoring(0.42, None) == pytest.approx(0.42)
28+
29+
def test_empty_policy_passes_through(self):
30+
assert apply_scoring(0.42, {}) == pytest.approx(0.42)
31+
32+
def test_none_score_becomes_zero(self):
33+
assert apply_scoring(None, {"mode": "linear", "max_points": 100}) == 0.0
34+
35+
def test_mode_none_passes_through(self):
36+
assert apply_scoring(0.5, {"mode": "none"}) == pytest.approx(0.5)
37+
38+
def test_linear_scales_raw_score(self):
39+
policy = {"mode": "linear", "max_points": 100}
40+
assert apply_scoring(0.0, policy) == 0.0
41+
assert apply_scoring(0.5, policy) == pytest.approx(50.0)
42+
assert apply_scoring(1.0, policy) == pytest.approx(100.0)
43+
44+
def test_linear_clamps_to_unit_interval(self):
45+
policy = {"mode": "linear", "max_points": 100}
46+
assert apply_scoring(-0.1, policy) == 0.0
47+
assert apply_scoring(1.5, policy) == pytest.approx(100.0)
48+
49+
def test_linear_respects_custom_lower_bound(self):
50+
policy = {"mode": "linear", "max_points": 100, "min_raw": 0.2}
51+
# below the lower bound → zero points
52+
assert apply_scoring(0.1, policy) == 0.0
53+
assert apply_scoring(0.2, policy) == 0.0
54+
# halfway between lower bound and upper default (0.6) → 50 points
55+
assert apply_scoring(0.6, policy) == pytest.approx(50.0)
56+
# at the upper bound → full points
57+
assert apply_scoring(1.0, policy) == pytest.approx(100.0)
58+
59+
def test_linear_respects_custom_upper_bound(self):
60+
policy = {
61+
"mode": "linear",
62+
"max_points": 100,
63+
"min_raw": 0.1,
64+
"max_raw": 0.6,
65+
}
66+
assert apply_scoring(0.1, policy) == 0.0
67+
assert apply_scoring(0.35, policy) == pytest.approx(50.0)
68+
assert apply_scoring(0.6, policy) == pytest.approx(100.0)
69+
# above upper bound clamps to full points
70+
assert apply_scoring(0.9, policy) == pytest.approx(100.0)
71+
72+
def test_threshold_binary(self):
73+
policy = {"mode": "threshold", "threshold": 0.5, "points": 100}
74+
assert apply_scoring(0.49, policy) == 0.0
75+
assert apply_scoring(0.50, policy) == pytest.approx(100.0)
76+
assert apply_scoring(0.99, policy) == pytest.approx(100.0)
77+
78+
def test_tiered_picks_highest_met(self):
79+
policy = {
80+
"mode": "tiered",
81+
"tiers": [
82+
{"above": 0.3, "points": 25},
83+
{"above": 0.6, "points": 50},
84+
{"above": 0.9, "points": 100},
85+
],
86+
}
87+
assert apply_scoring(0.2, policy) == 0.0
88+
assert apply_scoring(0.35, policy) == pytest.approx(25.0)
89+
assert apply_scoring(0.70, policy) == pytest.approx(50.0)
90+
assert apply_scoring(0.95, policy) == pytest.approx(100.0)
91+
92+
def test_tiered_ignores_malformed_entries(self):
93+
policy = {
94+
"mode": "tiered",
95+
"tiers": [
96+
{"above": 0.3, "points": 25},
97+
{"oops": True},
98+
{"above": "not-a-number", "points": 9999},
99+
],
100+
}
101+
assert apply_scoring(0.5, policy) == pytest.approx(25.0)
102+
103+
def test_baseline_field_ignored_by_transform(self):
104+
policy = {"mode": "linear", "max_points": 10, "baseline": 0.013}
105+
assert apply_scoring(0.5, policy) == pytest.approx(5.0)
106+
107+
def test_unknown_mode_passes_through(self):
108+
assert apply_scoring(0.7, {"mode": "bogus"}) == pytest.approx(0.7)
109+
110+
111+
@pytest.mark.offline
112+
class TestValidateScoringPolicy:
113+
def test_none_is_valid(self):
114+
assert validate_scoring_policy(None) == []
115+
116+
def test_empty_is_valid(self):
117+
assert validate_scoring_policy({}) == []
118+
119+
def test_mode_none_is_valid(self):
120+
assert validate_scoring_policy({"mode": "none"}) == []
121+
122+
def test_linear_requires_max_points(self):
123+
errs = validate_scoring_policy({"mode": "linear"})
124+
assert any("max_points" in e for e in errs)
125+
126+
def test_linear_max_points_must_be_positive(self):
127+
assert validate_scoring_policy({"mode": "linear", "max_points": 0})
128+
assert validate_scoring_policy({"mode": "linear", "max_points": -1})
129+
assert validate_scoring_policy({"mode": "linear", "max_points": 10}) == []
130+
131+
def test_linear_max_points_must_be_numeric(self):
132+
errs = validate_scoring_policy({"mode": "linear", "max_points": "foo"})
133+
assert any("number" in e for e in errs)
134+
135+
def test_threshold_requires_both_fields(self):
136+
errs = validate_scoring_policy({"mode": "threshold", "threshold": 0.5})
137+
assert any("points" in e for e in errs)
138+
errs = validate_scoring_policy({"mode": "threshold", "points": 10})
139+
assert any("threshold" in e for e in errs)
140+
assert (
141+
validate_scoring_policy(
142+
{"mode": "threshold", "threshold": 0.5, "points": 10}
143+
)
144+
== []
145+
)
146+
147+
def test_tiered_requires_non_empty_list(self):
148+
assert validate_scoring_policy({"mode": "tiered"})
149+
assert validate_scoring_policy({"mode": "tiered", "tiers": []})
150+
151+
def test_tiered_validates_each_entry(self):
152+
errs = validate_scoring_policy({"mode": "tiered", "tiers": [{"above": 0.3}]})
153+
assert any("points" in e for e in errs)
154+
errs = validate_scoring_policy(
155+
{
156+
"mode": "tiered",
157+
"tiers": [{"above": "bad", "points": 10}],
158+
}
159+
)
160+
assert any("number" in e for e in errs)
161+
assert (
162+
validate_scoring_policy(
163+
{
164+
"mode": "tiered",
165+
"tiers": [
166+
{"above": 0.3, "points": 25},
167+
{"above": 0.9, "points": 100},
168+
],
169+
}
170+
)
171+
== []
172+
)
173+
174+
def test_unknown_mode(self):
175+
errs = validate_scoring_policy({"mode": "bogus"})
176+
assert any("unknown" in e for e in errs)
177+
178+
def test_baseline_must_be_numeric(self):
179+
errs = validate_scoring_policy({"mode": "none", "baseline": "foo"})
180+
assert any("baseline" in e for e in errs)
181+
assert validate_scoring_policy({"mode": "none", "baseline": 0.5}) == []
182+
183+
184+
@pytest.mark.offline
185+
class TestResolveMode:
186+
def test_resolve_ranking_mode_valid(self):
187+
for key in RANKING_STRATEGIES:
188+
assert resolve_ranking_mode(key) == key
189+
190+
def test_resolve_ranking_mode_invalid_falls_back(self):
191+
assert resolve_ranking_mode(None) == DEFAULT_RANKING_STRATEGY
192+
assert resolve_ranking_mode("") == DEFAULT_RANKING_STRATEGY
193+
assert resolve_ranking_mode("nope") == DEFAULT_RANKING_STRATEGY
194+
195+
def test_resolve_scoreboard_view_valid(self):
196+
for key in SCOREBOARD_VIEWS:
197+
assert resolve_scoreboard_view(key) == key
198+
199+
def test_resolve_scoreboard_view_invalid_falls_back(self):
200+
assert resolve_scoreboard_view(None) == DEFAULT_SCOREBOARD_VIEW
201+
assert resolve_scoreboard_view("what") == DEFAULT_SCOREBOARD_VIEW
202+
203+
204+
@pytest.mark.offline
205+
class TestTeamIdentity:
206+
@staticmethod
207+
def _make_user(first, last, group_name):
208+
user = MagicMock()
209+
user.first_name = first
210+
user.surname = last
211+
if group_name is None:
212+
user.group = None
213+
else:
214+
user.group = MagicMock()
215+
user.group.name = group_name
216+
return user
217+
218+
def test_fallback_to_full_name_when_groups_disabled(self):
219+
user = self._make_user("Ada", "Lovelace", "Analysts")
220+
with patch("ref.model.SystemSettingsManager") as ssm:
221+
ssm.GROUPS_ENABLED.value = False
222+
assert team_identity(user) == "Ada Lovelace"
223+
224+
def test_uses_group_name_when_enabled(self):
225+
user = self._make_user("Ada", "Lovelace", "Analysts")
226+
with patch("ref.model.SystemSettingsManager") as ssm:
227+
ssm.GROUPS_ENABLED.value = True
228+
assert team_identity(user) == "Analysts"
229+
230+
def test_groups_on_but_no_group_falls_back_to_name(self):
231+
user = self._make_user("Ada", "Lovelace", None)
232+
with patch("ref.model.SystemSettingsManager") as ssm:
233+
ssm.GROUPS_ENABLED.value = True
234+
assert team_identity(user) == "Ada Lovelace"

webapp/ref/core/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,16 @@
1414
from .util import failsafe as failsafe
1515
from .util import unavailable_during_maintenance as unavailable_during_maintenance
1616
from .util import datetime_transmute_into_local as datetime_transmute_into_local
17+
from .scoring import (
18+
DEFAULT_RANKING_STRATEGY as DEFAULT_RANKING_STRATEGY,
19+
DEFAULT_SCOREBOARD_VIEW as DEFAULT_SCOREBOARD_VIEW,
20+
RANKING_STRATEGIES as RANKING_STRATEGIES,
21+
RANKING_STRATEGY_CHOICES as RANKING_STRATEGY_CHOICES,
22+
SCOREBOARD_VIEWS as SCOREBOARD_VIEWS,
23+
SCOREBOARD_VIEW_CHOICES as SCOREBOARD_VIEW_CHOICES,
24+
apply_scoring as apply_scoring,
25+
resolve_ranking_mode as resolve_ranking_mode,
26+
resolve_scoreboard_view as resolve_scoreboard_view,
27+
team_identity as team_identity,
28+
validate_scoring_policy as validate_scoring_policy,
29+
)

0 commit comments

Comments
 (0)