feat(options): letterless student/projector UI + text-on-wire submit
Both student-facing surfaces (the per-device join page and the
front-of-room projector) now render options as text only, no A/B/C/D
chips, no numeric prefixes. The letter-namespace remains internal:
canonical A..D in pool.json + submissions storage; canonical position
1..4 in the CSV export. Admin dashboard keeps letters because it is the
instructor's private console.
Why this lands now: the discussion of per-student option shuffling
flagged that A/B/C/D as discussion handles ("the answer is B") is itself
a leaky channel. Removing the labels closes that channel for the
non-shuffled case and keeps it closed if shuffling is added later.
Wire protocol: the submit message carries the option's full text
("answer": "Pipelining"). Server's submit_answer resolves text -> letter
via app.pool.resolve_option_key, which also accepts a canonical letter
so internal callers and tests stay readable. A non-matching string is
recorded as a zero-score submission with answer=NULL, locked in via the
PK + existing_submit_ack short-circuit. So an attempted UI bypass that
posts a fabricated string just produces a wrong answer; no retry.
CSV: A=1 .. D=2 .. C=3 .. D=4 in the answer column. Empty when no option
matched. Header unchanged so downstream pandas readers don't break, but
the value type is int instead of letter.
Histogram: the failsafe "submitted-but-no-match" row buckets into
"missed" alongside genuine misses — both yield zero credit and the
instructor cares about the same thing.
Tests:
- test_submit_accepts_option_text_resolves_to_canonical: production
wire format produces correct grading + canonical-letter storage.
- test_submit_failsafe_locks_in_zero_score_on_garbage_text: a non-
matching string is recorded at score=0 and a follow-up correct
submission cannot overwrite it.
71/71 green.
This commit is contained in:
@@ -110,6 +110,38 @@ def test_post_recovery_old_cookie_is_dead(client, sid):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_submit_accepts_option_text_resolves_to_canonical(client, sid):
|
||||
"""The wire format is letterless: the student sends the option's
|
||||
full text. Server resolves to the canonical letter for storage and
|
||||
grading. CSV export shows the canonical position (1..4)."""
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 5)
|
||||
# Q0 in conftest: A=Alpha, B=Beta, C=Gamma, D=Delta, correct=B.
|
||||
ack = client.portal.call(rooms.submit_answer, sid, "s1", 0, "Beta")
|
||||
assert ack["type"] == "submit_ack"
|
||||
assert ack["answer"] == "B" # server resolved to canonical letter
|
||||
assert ack["score"] > 0
|
||||
|
||||
|
||||
def test_submit_failsafe_locks_in_zero_score_on_garbage_text(client, sid):
|
||||
"""Sending a string that isn't one of the four option texts records
|
||||
a zero-score 'submitted' row and locks the student in (PK constraint
|
||||
+ existing_submit_ack short-circuit). A second attempt — even with
|
||||
the correct text — returns the original zero-score ack."""
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 5)
|
||||
first = client.portal.call(rooms.submit_answer, sid, "s1", 0, "not-an-option")
|
||||
assert first["type"] == "submit_ack"
|
||||
assert first["answer"] is None
|
||||
assert first["score"] == 0
|
||||
# Locked in: a follow-up retry returns the original zero ack.
|
||||
second = client.portal.call(rooms.submit_answer, sid, "s1", 0, "Beta")
|
||||
assert second["answer"] is None
|
||||
assert second["score"] == 0
|
||||
|
||||
|
||||
def test_submit_lockout_is_server_enforced(client, sid):
|
||||
"""Server-side: a second submit for the same (sid, student_id, qidx)
|
||||
returns the *original* ack rather than overwriting the answer. The
|
||||
|
||||
@@ -14,7 +14,11 @@ def test_csv_export_contains_one_row_per_submission(client, sid):
|
||||
lines = response.text.strip().splitlines()
|
||||
assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status,blur_count,hidden_count,duplicate_join_attempts"
|
||||
assert len(lines) == 2
|
||||
assert ",s1,Student One,0,B," in lines[1]
|
||||
# The CSV stores the canonical 1-indexed position of the chosen
|
||||
# option (A=1, B=2, C=3, D=4) rather than the letter — the student
|
||||
# UI is letterless and a number is unambiguous for downstream
|
||||
# analysis.
|
||||
assert ",s1,Student One,0,2," in lines[1]
|
||||
# Default audit-event counts are 0 for a clean run (no blur events,
|
||||
# no duplicate-join attempts).
|
||||
assert lines[1].endswith(",0,0,0")
|
||||
|
||||
Reference in New Issue
Block a user