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:
@@ -6,6 +6,7 @@ import csv
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from app.db import connect
|
from app.db import connect
|
||||||
|
from app.pool import CANONICAL_POSITION
|
||||||
|
|
||||||
|
|
||||||
async def export_session_csv(db_path: str, sid: str) -> str:
|
async def export_session_csv(db_path: str, sid: str) -> str:
|
||||||
@@ -17,6 +18,9 @@ async def export_session_csv(db_path: str, sid: str) -> str:
|
|||||||
"student_id",
|
"student_id",
|
||||||
"name",
|
"name",
|
||||||
"question_idx",
|
"question_idx",
|
||||||
|
# Canonical 1-indexed position of the chosen option in the
|
||||||
|
# pool's option list (A=1, B=2, C=3, D=4). Empty when the
|
||||||
|
# student didn't submit anything that matched an option.
|
||||||
"answer",
|
"answer",
|
||||||
"elapsed_ms",
|
"elapsed_ms",
|
||||||
"score",
|
"score",
|
||||||
@@ -53,13 +57,14 @@ async def export_session_csv(db_path: str, sid: str) -> str:
|
|||||||
counts.setdefault(row["student_id"], {})[row["kind"]] = int(row["c"])
|
counts.setdefault(row["student_id"], {})[row["kind"]] = int(row["c"])
|
||||||
for row in rows:
|
for row in rows:
|
||||||
per = counts.get(row["student_id"], {})
|
per = counts.get(row["student_id"], {})
|
||||||
|
answer_pos = CANONICAL_POSITION.get(row["answer"]) if row["answer"] else None
|
||||||
writer.writerow(
|
writer.writerow(
|
||||||
[
|
[
|
||||||
row["sid"],
|
row["sid"],
|
||||||
row["student_id"],
|
row["student_id"],
|
||||||
row["name"],
|
row["name"],
|
||||||
"" if row["question_idx"] is None else row["question_idx"],
|
"" if row["question_idx"] is None else row["question_idx"],
|
||||||
row["answer"] or "",
|
"" if answer_pos is None else answer_pos,
|
||||||
"" if row["elapsed_ms"] is None else row["elapsed_ms"],
|
"" if row["elapsed_ms"] is None else row["elapsed_ms"],
|
||||||
"" if row["score"] is None else row["score"],
|
"" if row["score"] is None else row["score"],
|
||||||
row["status"] or "",
|
row["status"] or "",
|
||||||
|
|||||||
31
app/pool.py
31
app/pool.py
@@ -97,6 +97,37 @@ def public_question_payload(pool: dict[str, Any], question_idx: int) -> dict[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_option_key(question: dict[str, Any], answer: Any) -> str | None:
|
||||||
|
"""Map a submitted answer back to its canonical letter (A..D).
|
||||||
|
|
||||||
|
Accepts either:
|
||||||
|
- a canonical letter (legacy + internal callers)
|
||||||
|
- the option's full text (production wire format — students send
|
||||||
|
what they saw on the button, never a letter, so even if a leaked
|
||||||
|
"answer is B" message arrives via chat the recipient's button is
|
||||||
|
labelled with text only and the correlation is lost).
|
||||||
|
|
||||||
|
Returns the canonical letter on match, or None when nothing matches.
|
||||||
|
None is the failsafe: callers turn it into a recorded submission with
|
||||||
|
score=0 (locked in via PK), so attempted circumvention by sending a
|
||||||
|
different string just produces a wrong answer.
|
||||||
|
"""
|
||||||
|
if not isinstance(answer, str):
|
||||||
|
return None
|
||||||
|
if answer in OPTION_KEYS:
|
||||||
|
return answer
|
||||||
|
for key in ("A", "B", "C", "D"):
|
||||||
|
if question["options"].get(key) == answer:
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Canonical 1-indexed position used in the CSV export and any downstream
|
||||||
|
# analysis. The pool's option keys are fixed at A..D, so the mapping is
|
||||||
|
# stable across pools and across re-runs of the same pool.
|
||||||
|
CANONICAL_POSITION = {"A": 1, "B": 2, "C": 3, "D": 4}
|
||||||
|
|
||||||
|
|
||||||
def _validate_question(question: dict[str, Any], index: int, default_limit: int) -> dict[str, Any]:
|
def _validate_question(question: dict[str, Any], index: int, default_limit: int) -> dict[str, Any]:
|
||||||
qid = question.get("id")
|
qid = question.get("id")
|
||||||
if not isinstance(qid, str) or not qid.strip():
|
if not isinstance(qid, str) or not qid.strip():
|
||||||
|
|||||||
51
app/room.py
51
app/room.py
@@ -17,7 +17,14 @@ from fastapi import WebSocket, WebSocketDisconnect
|
|||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db import connect
|
from app.db import connect
|
||||||
from app.pool import get_question, parse_pool_json, public_question_payload, question_count, question_time_limit
|
from app.pool import (
|
||||||
|
get_question,
|
||||||
|
parse_pool_json,
|
||||||
|
public_question_payload,
|
||||||
|
question_count,
|
||||||
|
question_time_limit,
|
||||||
|
resolve_option_key,
|
||||||
|
)
|
||||||
from app.scoring import SCORE_FNS
|
from app.scoring import SCORE_FNS
|
||||||
|
|
||||||
|
|
||||||
@@ -525,12 +532,20 @@ class RoomManager:
|
|||||||
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
|
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
|
||||||
|
|
||||||
async def submit_answer(self, sid: str, student_id: str, question_idx: Any, answer: Any) -> dict[str, Any]:
|
async def submit_answer(self, sid: str, student_id: str, question_idx: Any, answer: Any) -> dict[str, Any]:
|
||||||
|
"""Record a student's answer and grade it.
|
||||||
|
|
||||||
|
`answer` accepts either the option's full text (production wire
|
||||||
|
format from the letterless student UI) or a canonical letter
|
||||||
|
(internal callers + tests). Anything that doesn't resolve to one
|
||||||
|
of the four options is recorded as a zero-score submission and
|
||||||
|
locked in via the PK — circumvention attempts can't retry.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
qidx = int(question_idx)
|
qidx = int(question_idx)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return {"type": "error", "code": "bad_question", "message": "Invalid question index"}
|
return {"type": "error", "code": "bad_question", "message": "Invalid question index"}
|
||||||
if not isinstance(answer, str) or answer not in {"A", "B", "C", "D"}:
|
if not isinstance(answer, str):
|
||||||
return {"type": "error", "code": "bad_answer", "message": "Answer must be A, B, C, or D"}
|
return {"type": "error", "code": "bad_answer", "message": "Answer must be a string"}
|
||||||
async with self.locks[sid]:
|
async with self.locks[sid]:
|
||||||
session = await self.get_session(sid)
|
session = await self.get_session(sid)
|
||||||
if session["state"] != "question_open" or session["current_question_idx"] != qidx:
|
if session["state"] != "question_open" or session["current_question_idx"] != qidx:
|
||||||
@@ -546,9 +561,20 @@ class RoomManager:
|
|||||||
return {"type": "error", "code": "time_expired", "message": "Question time has expired"}
|
return {"type": "error", "code": "time_expired", "message": "Question time has expired"}
|
||||||
pool = await self.get_pool_for_session(sid)
|
pool = await self.get_pool_for_session(sid)
|
||||||
question = get_question(pool, qidx)
|
question = get_question(pool, qidx)
|
||||||
correct = answer == question["correct"]
|
resolved = resolve_option_key(question, answer)
|
||||||
score_fn = SCORE_FNS[pool["score_fn"]]
|
if resolved is None:
|
||||||
score = score_fn(correct, elapsed_ms, time_limit_ms)
|
# Failsafe: option didn't match any of the four texts.
|
||||||
|
# Lock in a zero-score submission rather than erroring,
|
||||||
|
# so an attempt to circumvent the UI by sending arbitrary
|
||||||
|
# text doesn't get a free retry.
|
||||||
|
score = 0.0
|
||||||
|
stored_answer: str | None = None
|
||||||
|
correct = False
|
||||||
|
else:
|
||||||
|
correct = resolved == question["correct"]
|
||||||
|
score_fn = SCORE_FNS[pool["score_fn"]]
|
||||||
|
score = score_fn(correct, elapsed_ms, time_limit_ms)
|
||||||
|
stored_answer = resolved
|
||||||
submitted_at = iso_now()
|
submitted_at = iso_now()
|
||||||
async with connect(self.settings.db_path) as db:
|
async with connect(self.settings.db_path) as db:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -557,13 +583,13 @@ class RoomManager:
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'submitted')
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'submitted')
|
||||||
ON CONFLICT(sid, student_id, question_idx) DO NOTHING
|
ON CONFLICT(sid, student_id, question_idx) DO NOTHING
|
||||||
""",
|
""",
|
||||||
(sid, student_id, qidx, answer, submitted_at, elapsed_ms, score),
|
(sid, student_id, qidx, stored_answer, submitted_at, elapsed_ms, score),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
|
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
|
||||||
await self.broadcast_presence(sid)
|
await self.broadcast_presence(sid)
|
||||||
await self.broadcast_projectors(sid)
|
await self.broadcast_projectors(sid)
|
||||||
return {"type": "submit_ack", "question_idx": qidx, "answer": answer, "score": score, "elapsed_ms": elapsed_ms}
|
return {"type": "submit_ack", "question_idx": qidx, "answer": stored_answer, "score": score, "elapsed_ms": elapsed_ms}
|
||||||
|
|
||||||
def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None:
|
def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None:
|
||||||
previous = self.autoclose_tasks.pop((sid, question_idx), None)
|
previous = self.autoclose_tasks.pop((sid, question_idx), None)
|
||||||
@@ -715,9 +741,16 @@ class RoomManager:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
if row["status"] == "missed":
|
if row["status"] == "missed":
|
||||||
result["missed"] += row["count"]
|
result["missed"] += row["count"]
|
||||||
elif row["answer"] in result:
|
elif row["answer"] in {"A", "B", "C", "D"}:
|
||||||
result[row["answer"]] += row["count"]
|
result[row["answer"]] += row["count"]
|
||||||
submitted += row["count"]
|
submitted += row["count"]
|
||||||
|
else:
|
||||||
|
# status='submitted' but answer didn't match any option
|
||||||
|
# (failsafe path in submit_answer). For aggregate display
|
||||||
|
# we bucket alongside legitimate "missed" — both yield
|
||||||
|
# zero credit and the instructor cares about the same
|
||||||
|
# thing: this student didn't pick a real option.
|
||||||
|
result["missed"] += row["count"]
|
||||||
if pending:
|
if pending:
|
||||||
result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"])
|
result["pending"] = max(0, int(total_row["count"]) - submitted - result["missed"])
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -541,6 +541,11 @@
|
|||||||
opacity 280ms ease,
|
opacity 280ms ease,
|
||||||
color 280ms ease;
|
color 280ms ease;
|
||||||
}
|
}
|
||||||
|
/* Letterless variant — drop the leading key column, give the text more
|
||||||
|
* room. Histogram bars + counts continue to anchor each row visually. */
|
||||||
|
.big-options.letterless li {
|
||||||
|
grid-template-columns: 1fr clamp(110px, 14vw, 200px) clamp(74px, 9vw, 110px);
|
||||||
|
}
|
||||||
.big-options li::before {
|
.big-options li::before {
|
||||||
/* tiny "this is a row" tick on the left, like an editorial bullet */
|
/* tiny "this is a row" tick on the left, like an editorial bullet */
|
||||||
content: "";
|
content: "";
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ function renderQuestion(s, revealed) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol class="big-options ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}">
|
<ol class="big-options letterless ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}">
|
||||||
${["A","B","C","D"].map((k) => {
|
${["A","B","C","D"].map((k) => {
|
||||||
const v = hist[k] || 0;
|
const v = hist[k] || 0;
|
||||||
const pct = Math.round(100 * v / total);
|
const pct = Math.round(100 * v / total);
|
||||||
@@ -395,7 +395,6 @@ function renderQuestion(s, revealed) {
|
|||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
return `
|
return `
|
||||||
<li class="${cls}">
|
<li class="${cls}">
|
||||||
<span class="opt-key">${k}</span>
|
|
||||||
<span class="opt-text">${escapeText(q.options?.[k] || "")}</span>
|
<span class="opt-text">${escapeText(q.options?.[k] || "")}</span>
|
||||||
<span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span>
|
<span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span>
|
||||||
<span class="opt-count">${v}<small>${pct}%</small></span>
|
<span class="opt-count">${v}<small>${pct}%</small></span>
|
||||||
|
|||||||
@@ -372,37 +372,43 @@ function renderQuestion(message) {
|
|||||||
<div class="qbar"><span id="qbar-fill"></span></div>
|
<div class="qbar"><span id="qbar-fill"></span></div>
|
||||||
<h1 class="question-text">${escapeText(message.text)}</h1>
|
<h1 class="question-text">${escapeText(message.text)}</h1>
|
||||||
<div class="answer-grid">
|
<div class="answer-grid">
|
||||||
${["A","B","C","D"].map((k) => `
|
${["A","B","C","D"].map((k) => {
|
||||||
<button class="answer-btn" data-answer="${k}">
|
const text = message.options[k] || "";
|
||||||
<span class="answer-key">${k}</span>
|
return `
|
||||||
<span class="answer-text">${escapeText(message.options[k] || "")}</span>
|
<button class="answer-btn" data-option="${escapeText(k)}" data-answer-text="${escapeText(text)}">
|
||||||
</button>
|
<span class="answer-text">${escapeText(text)}</span>
|
||||||
`).join("")}
|
</button>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`);
|
`);
|
||||||
document.querySelectorAll("[data-answer]").forEach((btn) => {
|
document.querySelectorAll("[data-option]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer));
|
btn.addEventListener("click", () => submitAnswer(btn.dataset.option, btn.dataset.answerText));
|
||||||
});
|
});
|
||||||
startCountdown();
|
startCountdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitAnswer(answer) {
|
function submitAnswer(optionKey, optionText) {
|
||||||
if (!store.currentQuestion || store.submitted || store.pickedAnswer) return;
|
if (!store.currentQuestion || store.submitted || store.pickedAnswer) return;
|
||||||
// Drop the click silently if the WS isn't open right now (mid-reconnect
|
// Drop the click silently if the WS isn't open right now (mid-reconnect
|
||||||
// or already torn down). On reconnect the server replays question_open
|
// or already torn down). On reconnect the server replays question_open
|
||||||
// for the same qidx, which re-renders the card with buttons re-enabled,
|
// for the same qidx, which re-renders the card with buttons re-enabled,
|
||||||
// so the student just clicks again.
|
// so the student just clicks again.
|
||||||
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return;
|
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return;
|
||||||
store.pickedAnswer = answer;
|
store.pickedAnswer = optionKey;
|
||||||
document.querySelectorAll("[data-answer]").forEach((btn) => {
|
document.querySelectorAll("[data-option]").forEach((btn) => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
if (btn.dataset.answer === answer) btn.classList.add("picked");
|
if (btn.dataset.option === optionKey) btn.classList.add("picked");
|
||||||
});
|
});
|
||||||
|
// The wire format carries the option's full text. The server resolves
|
||||||
|
// it back to the canonical letter; if the text doesn't match (e.g. a
|
||||||
|
// student tries to circumvent the UI and send a fabricated string)
|
||||||
|
// the submission is recorded with score=0 and locked in.
|
||||||
store.ws.send(JSON.stringify({
|
store.ws.send(JSON.stringify({
|
||||||
type: "submit",
|
type: "submit",
|
||||||
question_idx: store.currentQuestion.question_idx,
|
question_idx: store.currentQuestion.question_idx,
|
||||||
answer,
|
answer: optionText,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +441,7 @@ function renderReveal(message) {
|
|||||||
<span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span>
|
<span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span>
|
||||||
</div>
|
</div>
|
||||||
${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""}
|
${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""}
|
||||||
<ol class="options reveal student-reveal">
|
<ol class="options reveal student-reveal letterless">
|
||||||
${["A","B","C","D"].map((k) => {
|
${["A","B","C","D"].map((k) => {
|
||||||
const isCorrect = k === correct;
|
const isCorrect = k === correct;
|
||||||
const isYours = k === yourAnswer;
|
const isYours = k === yourAnswer;
|
||||||
@@ -445,7 +451,6 @@ function renderReveal(message) {
|
|||||||
if (isYours) cls += " yours";
|
if (isYours) cls += " yours";
|
||||||
return `
|
return `
|
||||||
<li class="${cls}">
|
<li class="${cls}">
|
||||||
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</span>
|
|
||||||
<span class="opt-text">${escapeText(q?.options?.[k] || "")}</span>
|
<span class="opt-text">${escapeText(q?.options?.[k] || "")}</span>
|
||||||
<span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span>
|
<span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1061,7 +1061,9 @@ h2.question-text.small {
|
|||||||
}
|
}
|
||||||
.answer-btn {
|
.answer-btn {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px 1fr;
|
/* Letterless: option text fills the row. Generous padding compensates
|
||||||
|
* for the missing letter chip so the button still reads as a tile. */
|
||||||
|
grid-template-columns: 1fr;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -1127,10 +1129,22 @@ h2.question-text.small {
|
|||||||
.answer-btn .answer-text {
|
.answer-btn .answer-text {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 1.1rem;
|
font-size: 1.18rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Student-reveal letterless variant: drop the 32px key column from the
|
||||||
|
* options row. The "Your pick" ribbon (.options.student-reveal li.yours)
|
||||||
|
* and correct/wrong tinting still work because they target the <li>. */
|
||||||
|
.options.letterless {
|
||||||
|
/* options li uses display:grid; redefine the columns when letterless. */
|
||||||
|
}
|
||||||
|
.options.letterless li {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.big-score {
|
.big-score {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 4.2rem;
|
font-size: 4.2rem;
|
||||||
|
|||||||
@@ -110,6 +110,38 @@ def test_post_recovery_old_cookie_is_dead(client, sid):
|
|||||||
assert response.status_code == 401
|
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):
|
def test_submit_lockout_is_server_enforced(client, sid):
|
||||||
"""Server-side: a second submit for the same (sid, student_id, qidx)
|
"""Server-side: a second submit for the same (sid, student_id, qidx)
|
||||||
returns the *original* ack rather than overwriting the answer. The
|
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()
|
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 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 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,
|
# Default audit-event counts are 0 for a clean run (no blur events,
|
||||||
# no duplicate-join attempts).
|
# no duplicate-join attempts).
|
||||||
assert lines[1].endswith(",0,0,0")
|
assert lines[1].endswith(",0,0,0")
|
||||||
|
|||||||
Reference in New Issue
Block a user