# 05e 第 2 關複查(第二輪):聽力練習模式(2026-10-04) **一句話:第一輪 14 項(合約 7 條+壞味道高/中 7 項)中,已修好 11 項、只修一半 3 項(第 15 條、第 37 條、server 重建/還原時的合併);另外找到 3 個新問題(都屬低);第一輪列為「其他風險」的同瀏覽器兩分頁問題仍在;沒有發現把原本對的條文改壞的回歸。** > 複查者:獨立審查(沒參與製作,也沒做第一輪)。方法:逐行讀目前的 js/plan.js、js/store.js、js/ui.js、server.py、index.html、app.css、tests/。 > 實際執行:`node --test tests/plan.test.js` 27/27 通過;`python tests/server_test.py` 7/7 通過。沒有跑 e2e.js。 > 另外寫了一支暫存腳本,直接呼叫 `Plan.merge` 重現下面的情境 A、C、D(結果寫在各情境裡)。 ## 對照表 | 項目 | 判定 | 證據(file:line) | 白話說明 | |---|---|---|---| | 合約 2(說明卡會自己再展開) | 已修好 | ui.js:27、ui.js:287、ui.js:440、ui.js:441 | 「重看方法」現在只改畫面上暫時的 `methodOpen`,不再動存起來的 `methodSeen`。合併時 `methodSeen` 也只會從 false 變 true(plan.js:221),不會被改回去。 | | 合約 4(打開時停在全部題目) | 已修好 | ui.js:26、ui.js:489、ui.js:484 | `view` 不再存進進度,開機固定 `setView("session")`。合併後重畫用的是記憶體裡目前的畫面,不影響下次打開。 | | 合約 15(點字要排進複習) | 只修一半 | plan.js:79-84(第 81、82 行)、ui.js:101、plan.test.js:281 | 大部分路徑修好了:新學區、全部題目、第 1~5 組的題目,點字後都會在下一次到期。還有兩條路沒照合約:①已經「學會」的句子(mastered)點字,不會排進複習(第 81 行直接返回),但畫面一樣顯示「它會排進之後的複習」;②已經在複習裡、但要隔 3 或 7 次才到期的句子,點了新的字,到期日不變(第 82 行 `if (!s.review[q])`),不會「在下一次到期」。 | | 合約 23(切到別的程式還在計時) | 已修好 | ui.js:466 | 計時條件多了 `document.hasFocus()`,切到別的程式就停。 | | 合約 27(方法表文字) | 已修好 | ui.js:304-311 對照 02-preview.md:75-83 | 7 列逐字比對,全部和預告頁相同。 | | 合約 35(打開時連不上 server) | 已修好 | store.js:61-72、store.js:91-102、store.js:185-191 | 從 http 打開時,只有 API 回 404 或使用者選「先不用」才進本機模式;連不上或 5xx 都走「離線開始」:顯示「連不上 server」、用暫存或上次同步的副本、定時重試、連上後合併補存。 | | 合約 37(兩台裝置改到同一個東西) | 只修一半 | plan.js:196-209(已修);plan.js:184、plan.js:220、store.js:115-121(沒修) | 點的字已改成「每個字分開合併」,兩台點的字都會留下,有測試(plan.test.js:290)。但其他欄位(複習排程、作答、語速、星號)兩邊都改時,還是「正在存檔的這台贏」,不看哪邊比較新。開頁時補送的舊暫存(可能是好幾天前的)也算「這台」,所以**舊的會蓋掉新的**(見情境 C)。另外「學會」一律壓過複習,會吃掉另一台後來按的「還是有沒聽出來的」(見情境 D)。 | | 第 4 關 高:救援版本很快被洗光 | 已修好 | server.py:30-31、server.py:61-67、server.py:94;store.js:139-141;ui.js:469;server_test.py:91 | 保留最近 200 版+每天最後一版保留一年;內容沒變(不計 updatedAt)就不送;計時存檔改成 60 秒一次。切分頁時的重複送出,第二次會因內容相同被略過。 | | 第 4 關 中:補存失敗仍顯示已同步 | 已修好 | store.js:85-87、store.js:107-123 | 有暫存時,狀態交給 resolvePending/pushNow 決定,不再一律顯示「已同步」。 | | 第 4 關 中:server 重建或還原成舊版時掉資料 | 只修一半 | 已修:store.js:153-160。沒修:store.js:79-89(特別是第 84 行 `remember()`)、store.js:115-121、store.js:155-158 | 只修了「頁面一直開著、存檔時才發現 server 倒退」這一條路。另外三條路還會掉資料:①開頁時有暫存,會拿重建後的空資料去合併,學過的題目、點的字、複習排程幾乎全清(見情境 A,已實測);②開頁時沒有暫存,會直接改用 server 的舊資料,並在第 84 行把這台手上較新的副本蓋掉;③「server 倒退」分支本身是整份覆蓋,另一台在還原後做的進度會被蓋掉(見情境 B)。另外,判斷條件只看版本號變小;還原後另一台存了夠多次、版本號追過去,就不會被認出來,改走一般合併,一樣掉資料。 | | 第 4 關 中:http 下出錯會偷偷變本機模式 | 已修好 | store.js:70-72、store.js:91-102 | 同合約 35。 | | 第 4 關 中:切字規則寫了兩份 | 已修好 | plan.js:92-113;ui.js:76-93 | 畫面改用 `Plan.sentenceParts`/`Plan.tokenSpans`。ui.js:10 的 BLANK 只剩播放「空格版」時的備用朗讀在用,跟切字無關。 | | 第 4 關 中:欄位清單三處 | 已修好 | plan.js:14、plan.js:24、plan.js:214;plan.test.js:300 | 一張 `MAP_FIELDS` 同時管預設值和合併,有測試。小提醒:以後如果加的是「不是每題一筆」的頂層欄位,merge 仍會預設取對方的,要自己記得處理。 | | 第 4 關 中:iPhone 需要分段讀取(Range) | 已修好 | server.py:161-185;server_test.py:82 | 有 206/416 回應,測試通過。iPhone 實機還沒驗證過(程式面已修)。 | | 新:離線時會短暫顯示「已同步」 | 新問題(低) | store.js:139-140、store.js:94、ui.js:472 | 這是第一輪修「內容沒變就不送」時帶出來的。斷線中切走分頁(會先存一份內容沒變的暫存),或離線開始時的暫存剛好跟上次同步的內容一樣,pushNow 會直接當成「已同步」,紅色「連不上 server」橫幅被清掉。資料不會不見,但合約 35 要求斷線時要明顯顯示。 | | 新:重複合併讓耳朵缺口次數和已練分鐘變多 | 新問題(低) | plan.js:228、plan.js:240、store.js:110-121 | 關頁時送出的存檔其實已到 server、但頁面沒收到回覆;之後另一台又存過一次。這台下次打開時,會把「已經在 server 上的那次改動」再加一次:缺口次數多 1、已練分鐘多算一次。點的字本身不受影響。 | | 新:API 回 200 但不是 JSON 時,開頁卡在「載入中…」 | 新問題(低) | store.js:70、store.js:81;index.html:18 | `probe.json` 是 null 時,`json.version` 直接出錯,整頁停在「載入中…」,也沒有「連不上 server」。只有中間的轉址/代理回了 HTML 錯誤頁才會遇到,機率低。 | | 第一輪「其他風險」:同一瀏覽器兩個分頁+斷線 | 沒修好(不在必修範圍) | store.js:128、store.js:9 | 兩個分頁共用同一份暫存 `tq-app-pending`,後存的蓋掉先存的。斷線時關掉其中一頁,那頁做的事就沒了(見情境 E)。 | ## 建議修正 ### 1. 合約 15(只修一半) **情況:** 已學會的句子、和排在 3/7 次後才到期的句子,點字後不會在下一次到期。 **建議:** - 在 `syncFirstReview`(plan.js:79-84)裡:有點字時,如果這句已經在複習裡、而且到期日比下一次晚,把 `due` 改成 `current + 1`、`stage` 回到 0。 - 已學會(mastered)的句子點了字:拿掉 mastered,排成下一次到期。點字本身就代表「這次又沒聽出來」,跟按「還是有沒聽出來的」是同一件事。 - 補一條測試:「已學會的句子,在全部題目裡點字,下一次會出現在複習」。 - 另外提醒:第一輪建議 A(只拿掉提示)、說 B(全部都排進複習)要先問主理人。這次做的是 B。B 其實就是第 15 條的字面意思,我認為合理,但 05b-diff.md 沒記這個選擇,建議補一行,帶到拍板卡讓主理人知道「在全部題目裡點第 1~5 組的字,也會排進複習」。 ### 2. 合約 37(只修一半) **情境 C:手機的舊暫存蓋掉電腦較新的複習排程(已用 Plan.merge 實測)** 1. 兩台都在第 12 次學習。手機在捷運上斷線,複習句 6-3 按「還是有沒聽出來的」→ 6-3 排成第 13 次到期。手機關掉,暫存留在手機裡。 2. 隔天在電腦上,同一句 6-3 按「這次都聽出來了」,之後又學了第 13、14 次;到第 15 次,6-3 已經是「隔 7 次、第 22 次到期」。 3. 幾天後手機連上網打開網頁。resolvePending(store.js:119)把手機的舊暫存當成「這台」去合併。 4. 兩邊都改過 6-3 的排程 → plan.js:184 一律取「這台」→ 6-3 變回「第 13 次到期、從頭開始」。作答紀錄也一樣被舊的蓋掉。 5. 實測結果:`review["6-3"] = {stage:0, due:13}`,電腦上較新的 `{stage:2, due:22}` 不見了。 **情境 D:同一句複習,後按的「還是有沒聽出來的」被吃掉(已實測)** 1. 兩台都在同一次學習,6-4 在複習清單裡,已經是最後一輪。 2. 電腦先按「這次都聽出來了」→ 6-4 學會,從複習移除,存到 server。 3. 稍後在手機上重聽,覺得其實沒聽出來,按「還是有沒聽出來的」→ 存檔時撞到 409 → 合併。 4. 複習欄位取了手機的(下一次到期),但 plan.js:220 接著把「已學會」的句子從複習裡刪掉。 5. 實測結果:6-4 是「學會」、不在複習裡。後按的那一下沒有作用,這句以後不會再出現。 **建議:** - 兩邊都改了同一筆時,改成「時間比較新的贏」,不要固定「這台贏」。最小做法:每次改動時,在 state 裡記一張 `changedAt`(例如 `changedAt["review:6-3"] = 現在時間`),merge 的 `pick` 兩邊都改時比這個時間。 - 如果先不做到這麼細,至少在 resolvePending(開頁補送舊暫存)這條路,兩邊衝突時改成取 server 的(pending 的 `updatedAt` 比 server 的舊時)。 - plan.js:220 改成:只有「學會」比那句的複習改動還晚,才刪掉複習;否則保留複習、拿掉學會。 - 補兩條測試:情境 C、情境 D。 ### 3. 第 4 關 中:server 重建或還原成舊版(只修一半) **情境 A:搬電腦沒帶 data/,手機上有一筆沒送出的暫存(已用 Plan.merge 實測)** 1. 手機已學到第 20 次,學過 6-1~6-10,6-2 點過字、在複習中,6-5 已學會。 2. 手機最後學了 7-1,還沒送出就關掉(暫存留著)。 3. 主理人把 server 搬到新電腦,沒帶 data/,資料庫是空的。 4. 手機打開:startServer 拿到空資料,`state = freshLocal()`;resolvePending 用「暫存裡的 base」當共同起點去合併(store.js:119)。 5. 共同起點有、手機沒改、server 沒有 → 被當成「server 刪掉了」。 6. 實測結果:學過的題目只剩 7-1;點的字、複習排程、已學會全部清空;次數仍是 20。接著這份殘缺的進度被送上 server,並在 store.js:147 蓋掉手機的同步副本。 **情境 A2:同上,但手機沒有暫存** 1. 手機打開,startServer 拿到空資料 → 從空白開始,第 84 行立刻把手機上「上次同步的副本」也蓋成空的。手機原本保有的完整進度不見。 **情境 B:server 還原成兩週前的備份,另一台先存了幾次** 1. 電腦記得的版本是 80。server 被還原成版本 50。 2. 手機打開,拿到版本 50 的進度,學了 3 題新題 → server 到版本 53。 3. 電腦那頁一直開著,存檔時收到 409、版本 53 < 80 → 走「server 倒退」分支(store.js:155-158),**整份**用電腦的進度覆蓋。手機那 3 題從 server 上消失。 4. 手機之後存檔撞到 409 → 合併:那 3 題在手機的共同起點裡、手機沒再改、server(電腦的)沒有 → 被當成「刪掉了」,手機上也消失。 5. 如果第 2 步手機存了 30 次以上(版本追過 80),第 3 步就認不出 server 倒退,改走一般合併,換成電腦在還原前多學的部分被丟掉。 **建議:** - server 資料庫建立時產生一個隨機的「資料庫編號」,GET/PUT/409 都回傳;前端把它跟 base、version 一起記在暫存與同步副本裡。 - 編號不同、或 version 比自己記得的小 → 一律當成「不是同一條歷史」:先把這台的進度下載成備份檔,再用 `Plan.merge(null, 這台, server)` 合併(共同起點用空白,兩邊有的都留下),不要整份覆蓋,也不要用舊的 base。 - 這個判斷要放在三個地方:startServer(開頁、有沒有暫存都要)、resolvePending、pushNow 的 409。 - startServer 的 `remember()`(store.js:84)要等上面的判斷做完才呼叫,否則手機上唯一較新的副本會先被蓋掉。 - 補測試:「server 資料是空的、這台有暫存」「server 版本比同步副本舊」兩種開頁情況,學過的題目都要留著。 ### 4. 新問題(低):離線時短暫顯示「已同步」 - store.js:139-140 的「內容沒變就略過」只代表「沒有新東西要送」,不代表連得上。 - 建議:這條路只在目前狀態不是 offline 時才設 synced;或略過時保留原本的狀態,只刪暫存。 ### 5. 新問題(低):重複合併讓缺口次數和已練分鐘變多 - 開頁時如果 server 的內容「包含」暫存裡的改動(關頁時那次其實送到了),就不要再把同一份差異加一次。 - 簡單做法:PUT 時帶一個隨機的存檔編號,server 記在進度裡;開頁時看到 server 上最近幾次存檔有自己暫存的那個編號,就把暫存當成已送達,直接用 server 的資料。 ### 6. 新問題(低):API 回 200 但不是 JSON 時開頁卡住 - store.js:70 改成 `probe.code === 200 && probe.json && "version" in probe.json` 才走 startServer;否則當成連不上,走 startOffline。 ### 7. 第一輪其他風險:同一瀏覽器兩分頁+斷線(沒修好) **情境 E** 1. 同一台電腦開了兩個分頁,網路斷掉。 2. 分頁 1 學了 6-1 → 暫存寫成分頁 1 的進度。 3. 分頁 2 學了 6-2 → 暫存被分頁 2 的進度整份蓋掉(裡面沒有 6-1)。 4. 關掉分頁 1,網路恢復,分頁 2 補送 → 6-1 不見。 **建議:** 暫存 key 加上分頁編號(例如 `tq-app-pending:<隨機 id>`),開頁時把所有分頁留下的暫存都拿出來依序合併;或用 `storage` 事件提醒「另一個分頁也開著」。 --- 本報告的每一項判斷都來自實際查證(逐行讀程式碼、跑 plan 27/27 與 server 7/7 兩組測試、用暫存腳本呼叫 Plan.merge 重現情境 A、C、D),不是推測。情境 B、E 是照程式碼一步一步推出來的,沒有實際跑過。