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:
ameer
2026-05-04 17:31:12 +08:00
parent 464c6ee1cb
commit 168cffea8b
9 changed files with 158 additions and 30 deletions

View File

@@ -372,37 +372,43 @@ function renderQuestion(message) {
<div class="qbar"><span id="qbar-fill"></span></div>
<h1 class="question-text">${escapeText(message.text)}</h1>
<div class="answer-grid">
${["A","B","C","D"].map((k) => `
<button class="answer-btn" data-answer="${k}">
<span class="answer-key">${k}</span>
<span class="answer-text">${escapeText(message.options[k] || "")}</span>
</button>
`).join("")}
${["A","B","C","D"].map((k) => {
const text = message.options[k] || "";
return `
<button class="answer-btn" data-option="${escapeText(k)}" data-answer-text="${escapeText(text)}">
<span class="answer-text">${escapeText(text)}</span>
</button>
`;
}).join("")}
</div>
</article>
`);
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer));
document.querySelectorAll("[data-option]").forEach((btn) => {
btn.addEventListener("click", () => submitAnswer(btn.dataset.option, btn.dataset.answerText));
});
startCountdown();
}
function submitAnswer(answer) {
function submitAnswer(optionKey, optionText) {
if (!store.currentQuestion || store.submitted || store.pickedAnswer) return;
// 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
// for the same qidx, which re-renders the card with buttons re-enabled,
// so the student just clicks again.
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) return;
store.pickedAnswer = answer;
document.querySelectorAll("[data-answer]").forEach((btn) => {
store.pickedAnswer = optionKey;
document.querySelectorAll("[data-option]").forEach((btn) => {
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({
type: "submit",
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>
</div>
${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) => {
const isCorrect = k === correct;
const isYours = k === yourAnswer;
@@ -445,7 +451,6 @@ function renderReveal(message) {
if (isYours) cls += " yours";
return `
<li class="${cls}">
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</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>
</li>