iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 14

Day 14 — 章末總測驗:從一題 check-in 到多題計分

  • 分享至 

  • xImage
  •  

今天要解的問題

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 當成…」)。那時的結論是「不重排」。今天要做亂序,這兩件事必須一起解決。

解法:正解用 id,不用位置

新的題目資料格式(章末測驗是資料,不寫在課文裡):

/* 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 — 詳解不准提到選項位置。改用內容指稱(「『已證明』是最常見的誤讀」),這樣亂序後依然通順。

第三點是規格層面的約束,會被驗證器強制執行(見文末)。

亂序:Fisher–Yates + 種子

/* 可重現的亂序:同一份考卷在同一次作答中順序固定 */
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 只是視覺提示,狀態機才是真正的守門員)。

儲存:與既有 key 分家

  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 讀出來的東西都當作不可信輸入

UI:兩種模式共用一個 View

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

手動檢查:

  • 重整頁面 → 選項順序不變(種子有效);點「重新作答」→ 順序改變。
  • 做到一半關掉分頁再回來 → 已選的答案還在。
  • 交卷 → 分數卡出現、選項鎖定、詳解展開、錯題有回連。
  • 錯題的回連 → 真的跳到對應課文。
  • 通過 → toast 顯示 +80 XP;重考滿分 → 再 +40,但不重複給基本分。
  • 用 console 塞垃圾進 learnpath-exam-v1 → 不當掉、當成沒有紀錄。

小結與明天預告

今天的重點:

  1. 正解用 id 不用位置索引——這是支援亂序的前提,也是 Day 8 設計債的正式清償。
  2. 可重現的亂序:LCG + 種子,讓「重整不變、重考才變」。
  3. 狀態機比布林旗標乾淨,而且狀態本身要持久化。
  4. 錯題回連比分數有價值:先想使用者拿到結果後要做什麼,再決定資料存什麼。
  5. 新資料層一定要有新斷言——包括「詳解不得指名選項位置」這種內容政策。

明天做名詞中英對照頁,主題是讓內容自己長出頁面:課文裡的 dl.terms 已經散布在兩百多課裡,我要用一支腳本把它們收集成一個完整的 glossary,包含「這個詞出現在哪幾課」的反向索引。


上一篇
Day 13 — 統計數值表:不查表,直接算
下一篇
Day 15 — 名詞中英對照頁:讓內容自己長出頁面
系列文
知識圖譜 : 技能樹式學習歷程19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言