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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user