Day 8 的隨堂測驗是「讀完一課,確認一下」——一題、立即回饋、不計分。它很成功,但有個明顯的缺口:讀完一整章之後,我沒辦法確認自己整章都懂了。
章末總測驗需要的東西完全不同:
| 隨堂測驗(Day 8) | 章末總測驗(今天) | |
|---|---|---|
| 題數 | 1 | 5–8 |
| 回饋 | 立即 | 交卷後 |
| 計分 | 無 | 有,需要通過門檻 |
| 選項順序 | 固定 | 亂序(避免記位置) |
| 錯題 | 看詳解就結束 | 要能回連到對應課文 |
而「亂序」會引爆一個 Day 8 留下的設計債。
data-answer 是位置索引Day 8 的測驗格式:
<div class="quiz" data-answer="1">
<button class="quiz-opt">選項 A</button>
<button class="quiz-opt">選項 B</button> <!-- 正解,索引 1 -->
<button class="quiz-opt">選項 C</button>
</div>
正解是用位置表示的。 一旦亂序,索引 1 就不再是原來那個選項。
而 Day 8 還提到過另一個相關問題:207 課裡有 141 課的詳解直接指名選項位置(「選項二誤把 entropy 當成…」)。那時的結論是「不重排」。今天要做亂序,這兩件事必須一起解決。
新的題目資料格式(章末測驗是資料,不寫在課文裡):
/* js/models/exams.js */
const EXAMS = {
ch09: {
title: "第 9 章 假設檢定 · 章末總測驗",
pass: 0.7, // 通過門檻
questions: [
{
id: "q1",
lesson: "ch09-2", // ← 錯題回連的目標
q: "檢定結果「不拒絕 H₀」的正確解讀是?",
options: [
{ id: "a", text: "已證明 H₀ 為真" },
{ id: "b", text: "現有證據不足以推翻 H₀" },
{ id: "c", text: "H₀ 與 H₁ 同時成立" },
{ id: "d", text: "樣本數一定不足" },
],
answer: "b", // ← id,不是索引
exp: "假設檢定不會證明 H₀;不拒絕只代表證據不足。「已證明」是最常見的誤讀,"
+ "而「樣本數不足」只是其中一種可能原因,不是必然。",
},
/* … */
],
},
};
三個關鍵欄位:
answer: "b" — 選項 id。亂序之後 b 還是 b,位置無關。lesson: "ch09-2" — 這題考的是哪一課,錯題回顧直接連過去。exp — 詳解不准提到選項位置。改用內容指稱(「『已證明』是最常見的誤讀」),這樣亂序後依然通順。第三點是規格層面的約束,會被驗證器強制執行(見文末)。
/* 可重現的亂序:同一份考卷在同一次作答中順序固定 */
function seededShuffle(arr, seed) {
const a = [...arr];
let s = seed >>> 0;
const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
為什麼要種子? 如果每次渲染都重新亂序,那麼「交卷後顯示結果」時選項會跳位置,使用者完全對不上自己剛才選了什麼。用一個在作答開始時決定的種子,整場考試的順序就固定;重考時換新種子。
Math.random() 不能滿足這個需求,所以用線性同餘產生器(LCG)。這裡不需要密碼學強度——只是要「看起來隨機且可重現」。乘數 1664525 / 增量 1013904223 是 Numerical Recipes 的經典參數。
題目本身也亂序,同一個種子:
const buildPaper = (exam, seed) => ({
...exam,
questions: seededShuffle(exam.questions, seed).map((q, i) => ({
...q,
options: seededShuffle(q.options, seed + i * 7919), // 每題不同但可重現的子種子
})),
});
+ i * 7919(一個質數)讓每題的選項順序不同,否則所有題目的選項會用同樣的排列——那就很明顯是假亂序。
考試有三個明確的狀態,用狀態機管理比一堆布林旗標乾淨:
idle ──開始作答──▶ answering ──交卷──▶ reviewing ──重考──▶ answering
│ │
(可改答案) (唯讀 + 詳解 + 錯題回連)
const Exam = {
KEY: "learnpath-exam-v1",
state: "idle", // idle | answering | reviewing
paper: null,
picked: {}, // { qid: optionId }
seed: 0,
start(chId, { resume = true } = {}) {
const exam = EXAMS[chId];
if (!exam) return false;
const saved = resume ? this.loadAttempt(chId) : null;
this.seed = saved ? saved.seed : this.newSeed();
this.picked = saved ? saved.picked : {};
this.paper = buildPaper(exam, this.seed);
this.state = saved && saved.submitted ? "reviewing" : "answering";
this.chId = chId;
return true;
},
pick(qid, optId) {
if (this.state !== "answering") return;
this.picked[qid] = optId;
this.saveAttempt(); // 每次作答就存,關掉分頁不會全丟
},
submit() {
if (this.state !== "answering") return null;
this.state = "reviewing";
const result = this.score();
this.saveAttempt({ submitted: true, result });
return result;
},
score() {
const qs = this.paper.questions;
const detail = qs.map(q => ({
q, picked: this.picked[q.id] || null, ok: this.picked[q.id] === q.answer,
}));
const correct = detail.filter(d => d.ok).length;
return {
correct, total: qs.length,
rate: correct / qs.length,
passed: correct / qs.length >= this.paper.pass,
detail,
wrong: detail.filter(d => !d.ok),
};
},
newSeed() { return Math.floor(Math.random() * 2 ** 31); },
};
pick() 每次都存進 localStorage。使用者做到第 5 題不小心關掉分頁,回來還能接續——考試中的資料遺失是最不能接受的失敗,這個成本(每次點擊一次 localStorage 寫入)完全值得。
state 檢查放在每個動作的第一行(if (this.state !== "answering") return)。這樣「交卷後還能改答案」這類 bug 在結構上就不可能發生,不需要靠 UI 把按鈕 disable 來保證(UI 只是視覺提示,狀態機才是真正的守門員)。
saveAttempt(extra = {}) {
const all = this.loadAll();
all[this.chId] = { seed: this.seed, picked: this.picked, at: Date.now(), ...extra };
try { localStorage.setItem(this.KEY, JSON.stringify(all)); } catch {}
},
loadAll() {
try { const v = JSON.parse(localStorage.getItem(this.KEY));
return (v && typeof v === "object" && !Array.isArray(v)) ? v : {}; }
catch { return {}; }
},
loadAttempt(chId) {
const a = this.loadAll()[chId];
if (!a || typeof a.seed !== "number" || typeof a.picked !== "object") return null; // 淨化
return a;
},
第三個 localStorage key:statmaster-progress-v1(進度)、learnpath-gamify-v1(遊戲化)、learnpath-exam-v1(考試)。
原則跟 Day 9 一樣:生命週期不同的資料分開存。考試紀錄可能想重置(「我要重新考」),學習進度絕對不能被連帶清掉。而 loadAttempt 的型別檢查也是同一個習慣——任何從 localStorage 讀出來的東西都當作不可信輸入。
const ExamView = {
render(root, exam) {
const reviewing = exam.state === "reviewing";
const result = reviewing ? exam.score() : null;
root.innerHTML = `
${reviewing ? this.scoreCard(result) : ""}
<ol class="exam-list">
${exam.paper.questions.map((q, i) => this.questionHTML(q, i, exam, reviewing)).join("")}
</ol>
<div class="exam-foot">
${reviewing
? `<button class="btn" id="exam-retry">重新作答(換一份順序)</button>`
: `<button class="btn primary" id="exam-submit">交卷(${Object.keys(exam.picked).length}/${exam.paper.questions.length} 已作答)</button>`}
</div>`;
},
questionHTML(q, i, exam, reviewing) {
const picked = exam.picked[q.id];
return `
<li class="exam-q ${reviewing ? (picked === q.answer ? "ok" : "ng") : ""}">
<p class="eq-text"><b>${i + 1}.</b> ${q.q}</p>
<div class="eq-opts">
${q.options.map(o => {
const cls = [
picked === o.id ? "picked" : "",
reviewing && o.id === q.answer ? "correct" : "",
reviewing && picked === o.id && o.id !== q.answer ? "wrong" : "",
].filter(Boolean).join(" ");
return `<button class="eq-opt ${cls}" data-q="${q.id}" data-o="${o.id}"
${reviewing ? "disabled" : ""}>${o.text}</button>`;
}).join("")}
</div>
${reviewing ? `
<div class="eq-exp">✔ ${q.exp}</div>
${picked !== q.answer ? `
<a class="eq-back" href="chapter.html?ch=${q.lesson.split("-")[0]}&l=${q.lesson.split("-")[1]}">
↩ 回去複習:這題出自本章第 ${q.lesson.split("-")[1]} 課</a>` : ""}
` : ""}
</li>`;
},
一個 View、兩種模式,靠 reviewing 布林切換。不要寫兩個 View——那會導致「作答時的樣式改了、檢討模式忘記改」這類不一致。
錯題回連(.eq-back)是這個功能最有價值的部分。 分數本身沒什麼用,真正有用的是「你錯的這題出自第 2 課,去複習」。這需要 q.lesson 欄位,而那個欄位是我在設計資料結構時就想好的——先想清楚使用者拿到結果後要做什麼,再決定資料要存什麼。
分數卡:
scoreCard(r) {
return `
<div class="exam-score ${r.passed ? "pass" : "fail"}">
<div class="es-num">${r.correct}<span>/${r.total}</span></div>
<div class="es-main">
<div class="es-verdict">${r.passed ? "🎉 通過!" : "再加強一下"}</div>
<div class="es-rate">正確率 ${(r.rate * 100).toFixed(0)}%(通過門檻 ${(this.paper.pass * 100).toFixed(0)}%)</div>
${r.wrong.length ? `<div class="es-wrong">錯了 ${r.wrong.length} 題,往下看有回連課文的連結</div>` : ""}
</div>
</div>`;
},
/* 交卷後 */
const r = Exam.submit();
if (r.passed && window.Gamify) Gamify.onExamPassed(Exam.chId, r);
/* gamify.js 新增 */
XP_EXAM: 80,
onExamPassed(chId, r) {
const s = this.load();
if (s.exams[chId]) return; // 同一章只給一次
s.exams[chId] = { at: this.today(), rate: r.rate };
this.touchStreak(s);
s.xp += this.XP_EXAM;
/* 滿分額外加給 */
if (r.rate === 1) s.xp += 40;
const badges = this.checkBadges(s);
this.save(s);
this.toast(`章末測驗通過!+${r.rate === 1 ? 120 : 80} XP`, "🏆");
badges.forEach(b => this.toast(`解鎖徽章:${b.label}`, b.icon));
this.renderHUD();
},
80 XP(相當於 4 課)反映章末測驗的難度。滿分再加 40 是掌握度獎勵——鼓勵重考到滿分,而重考不會重複拿基本分(if (s.exams[chId]) return),所以不能刷。
新徽章:
{ id: "exam1", icon: "🏆", label: "首次通過章末測驗", test: (p, s) => Object.keys(s.exams || {}).length >= 1 },
{ id: "exam10", icon: "👑", label: "十章全通", test: (p, s) => Object.keys(s.exams || {}).length >= 10 },
新資料層就要有新斷言,加進 scripts/verify.js:
/* 章末測驗結構檢查 */
for (const [chId, exam] of Object.entries(EXAMS)) {
assert(Boolean(findChapter(chId)), `EXAMS[${chId}]: 對應的章不存在`);
assert(exam.questions.length >= 3, `${chId}: 題數過少`);
assert(exam.pass > 0 && exam.pass <= 1, `${chId}: pass 門檻不合法`);
const qids = exam.questions.map(q => q.id);
assert(duplicates(qids).length === 0, `${chId}: 重複的題目 id`);
for (const q of exam.questions) {
const oids = q.options.map(o => o.id);
assert(duplicates(oids).length === 0, `${chId}/${q.id}: 重複的選項 id`);
assert(oids.includes(q.answer), `${chId}/${q.id}: answer "${q.answer}" 不在選項中`);
assert(q.options.length >= 3, `${chId}/${q.id}: 選項少於 3 個`);
/* 回連的課必須存在 */
assert(Boolean(LESSONS[q.lesson]), `${chId}/${q.id}: lesson "${q.lesson}" 不存在`);
/* 回連的課必須屬於本章 */
assert(q.lesson.startsWith(chId + "-"), `${chId}/${q.id}: lesson 不屬於本章`);
/* 詳解不得指名選項位置——亂序會讓這種寫法失效 */
assert(!/選項[一二三四五六1-6]|第[一二三四]個選項|選項\s*[A-D]/.test(q.exp),
`${chId}/${q.id}: 詳解指名了選項位置,亂序後會對不上`);
}
}
最後那條 regex 是今天最重要的一條斷言。它把 Day 8 那個「141 課詳解指名位置」的教訓變成機械強制——以後我再手滑寫「選項二」,CI 直接紅燈。
這是 Day 10 那條原則的另一面:會靜默失敗的東西要有自動檢查。詳解指名位置不會報錯、不會當掉,只會讓使用者讀到對不上的解釋。所以要驗。
種子存了、picked 存了,但忘記存「已交卷」。 第一版重整之後回到 answering 狀態,使用者可以改答案再交一次。修法是 saveAttempt({ submitted: true, result }),而 start() 依 saved.submitted 決定進哪個狀態。狀態機的狀態本身也是要持久化的資料,這點很容易漏。
disabled 按鈕的 CSS。 檢討模式的選項是 disabled,而瀏覽器預設會把 disabled 按鈕變灰——連帶把「正解綠色」「你選錯的紅色」也一起灰掉。要明確覆蓋:
.eq-opt:disabled { opacity: 1; cursor: default; } /* 別讓瀏覽器灰掉語意色 */
.eq-opt.correct { border-color: var(--green); background: var(--green-soft); }
.eq-opt.wrong { border-color: var(--red); background: var(--red-soft); }
只用顏色表達對錯是無障礙問題。 紅綠色盲使用者看不出差別。加上 icon:
.eq-opt.correct::before { content: "✓ "; }
.eq-opt.wrong::before { content: "✗ "; }
(Day 19 會系統性處理無障礙,但這種一行就能解決的地方,發現時就順手修。)
node scripts/verify.js # 含新增的 EXAMS 斷言
python3 -m http.server 8901
手動檢查:
learnpath-exam-v1 → 不當掉、當成沒有紀錄。今天的重點:
明天做名詞中英對照頁,主題是讓內容自己長出頁面:課文裡的 dl.terms 已經散布在兩百多課裡,我要用一支腳本把它們收集成一個完整的 glossary,包含「這個詞出現在哪幾課」的反向索引。