# 05j 第 2 關複查(第六輪):聽力練習模式——IndexedDB 版(2026-10-05) **一句話:43 條中一樣 41 條、不一樣 2 條(第 13 條只差排列順序;第 30 條在一個少見操作下缺口次數會少算)、沒做到 0 條。照一般用法(一個人、每天約 1.5 小時、偶爾在電腦和手機之間換),這輪沒有找到高或中的問題;新問題 0 高、0 中、5 低。05i 的 11 項建議:已修好 7 項、修好但帶出一個新的小問題 1 項、刻意不做 3 項(2 項理由站得住;1 項結論可以接受,但理由不成立)。** > 複查者:獨立審查(沒參與製作,也沒做第一~五輪)。 > 範圍:照主理人 10/5 的指示,只看一般用法。極端情況(時鐘不準、手動蓋回資料庫、多台離線做完同一次、很舊的快取、第 2 冊題號、怪的 HTTP 輸入、上千筆的亂數測試)不列為問題,只在最後一節各寫一行。 > 方法:逐行讀目前的 js/plan.js、js/store.js、js/ui.js、server.py。 > 實際執行(在 `C:\D槽\英文學習資料`):`node --test app/tests/plan.test.js` 49/49、`node --test app/tests/sync.test.js` 8/8、`python app/tests/server_test.py` 11/11、e2e.js 71/71,全部通過,和 04 寫的數字一樣。 > 攻擊腳本放在 `C:\Users\user\AppData\Local\Temp\claude\c--D--------\88ca62cf-98c4-42b3-987f-5328619d3ad4\scratchpad\audit6\`: > - `n1_daily_use.js`(一台、每天一次、連續 40 次) > - `n2_allview_unmark.js`、`n2b_shared_word.js`(複習中途到「看全部題目」點字、又取消) > - `n3_review_mistap.js`(複習卡誤點一個字、再取消) > - `n4_switch.js`、`n4b_switch_diag.js`(真的 Chrome+真的 server.py:電腦和手機輪流做同一次,再做下一次) > - `n5_close_debug.js`、`n6_close_loop.js`、`n6b_close_loop_diag.js`(作答後立刻關分頁) > - `patched/`(攻擊 9 那個不做的改法,套在副本上跑測試) > > n1~n3 用 `app/tests/sim.js`(載入真正的 plan.js+store.js,走的是 localStorage 那條路)。n4~n6 用 puppeteer-core 開真的 Chrome,走的是 IndexedDB 那條路,server 用隨機的 19xxx port。 ## 先講結論:這輪做對了什麼 - **一般用法下,換裝置可以接著做。**(n4) - 電腦做 3 題後關掉;手機打開,停在同一次,從 6-4 接著做。 - 手機一直開著。電腦按「這次完成」後,手機 23 秒內換到第 2 次,並顯示「已載入另一台裝置的新進度」。 - 手機在第 2 次的複習答案,電腦拉取後都看得到,排程正確:6-1、6-3 是 `{stage:1,due:6}`,6-4 是 `{stage:0,due:3}`。 - 兩邊頁面都沒有 JavaScript 錯誤。 - **IndexedDB(瀏覽器內建的資料庫)加「還沒寫完」便條,做到了它說的事。**(n5、n6b) - 作答後立刻關掉分頁,重開後答案還在,server 上也有。 - n6 重複 10 次,看起來掉了 2 次。逐次查證後,那 2 次是 puppeteer 的點擊落在手機底部條上,作答根本沒有發生,不是存檔掉資料。n6b 有記下每次點到哪裡。 - 真的點到選項的 8 次,8 次都在。 - **D6 的新間隔照拍板的做。** 連續 2 次「都聽出來」就算學會(plan.js:9、plan.js:213-215;e2e 第 16 項 PASS)。 - **每天一次、連續 40 次都正常。**(n1) - 每一次都能做完:三區都完成,`allDone` 一直是 true。 - 新學每次 10 題,第 11 次起全部學完。 - 複習每次最多 10 句,第 40 次時已學會 68 句。 ## 05i 建議修正的現況 | 05i 編號 | 現況 | 證據 | 白話 | |---|---|---|---| | 1 `--restore` 先檢查、先備份 | 已修好 | server.py:67-83;server_test test_10 | 不是這個程式的資料庫就拒絕;覆蓋前先存一份 `.before-restore-時間`。「server 還開著就拒絕」沒做,DEPLOY.md 有寫要先停(屬極端情況)。 | | 2 晚到的複習答案用事件自帶的快照 | 已修好 | plan.js:328 | 和 05i 建議的一行改法一樣。 | | 3 流水帳太大、每按一下都要讀寫整份 | 已修好(短期) | store.js:68-89、104-110、132-148;e2e r5-idb、r5-close PASS;n5、n6b | 改存 IndexedDB,每個動作只寫一筆。新裝置第一次打開仍要下載整份(約 10 個月 5 MB),之後只拿新的。這是一次性的成本,可以接受。04-implementation.md:108 的「一年 2~4 MB、2 年後做快照」沒有改成實測值(見低 5)。 | | 4 server 一次拿到事件與最大流水號 | 已修好 | server.py:90-95;server_test test_12 | 先查最大號,再只拿到那一號為止。中間別台寫入的那筆,下次一定拿得到。 | | 5 流水號倒退也當成資料庫換了 | 已修好(主要情況) | store.js:230-241;sync.test 第 3 個 | 手動蓋回資料庫屬極端情況;剩下的破口列在最後一節。 | | 6 合併清單只留答過的 | 刻意改做法,理由站得住 | plan.js:91、95 | 改成合併時守 10+10 上限。一般用法(一次只在一台做)碰不到合併。 | | 7 合約 15 反過來的順序 | 已修好,但帶出一個新的小問題 | plan.js:314-318;n2 | 在「看全部題目」點今天複習句的字,複習卡會看到那個字,「這次都聽出來了」也會變成不能按,這部分對了。但「點了又取消」這條路沒有對應處理,見低 1。r4 情況 C 刻意不做:「多複習一次比漏掉安全」,理由站得住。 | | 8 晚到的複習答案不要蓋掉比較新的排程 | 已修好 | plan.js:327-329;plan.test「a very old device's late review…」 | 屬極端情況。 | | 9 匯入後,另一台的次數比較大 | 刻意不做:結論可以接受,理由不成立 | `patched/`:把 plan.js:275 改成 `Math.min(e.ses \|\| current(s), current(s))` 後,plan 49/49、sync 8/8 全過,包括那個 5 秒時間差的亂數測試 | 製作者說這個改法「時鐘差幾秒就會出錯,fuzz 測試就有 5 秒差」。實測沒有出錯。讀程式也看得出原因:store.js:339 讓每一筆新事件的時間一定晚於這頁已經有的所有事件,所以只有「匯入」會讓次數倒退。不過匯入很少用,後果只是晚幾次複習,不做可以接受。04 的理由建議改寫成「匯入很少用」,不要寫成時鐘問題。 | | 10 不認識的題號 | 已修好 | plan.js:41、273;plan.test | 屬極端情況。 | | 11 server 小毛病 | 大部分已修好 | server.py:137、148、170 | 18 位數上限、Content-Length 檢查都做了。剩一個上標數字的漏洞,屬極端情況。「收到 400 就把批次砍半」刻意不做:一批最多 0.53 MB,離 2 MB 上限很遠,理由站得住。 | ## 對照表(只列不一樣的條;其餘 41 條一樣) 第 1~12、14~29、31~43 條一樣。這輪特別確認過的: - 第 7、31、34、35、37、38 條:n4、n5、n6b、e2e。 - 第 15~17 條:D6 的新間隔,n1、e2e 15~17。 - 第 21 條:只複習的晚上按「下一題」,會先到「今天只複習」框(e2e r4-M3 PASS,ui.js:348-358)。 - 第 22 條:面板改成白話字眼(ui.js:370,e2e r4-M4)。 D1~D6 都照 05b 寫的做。 | 條號 | 判定 | 證據 | 白話說明 | |---|---|---|---| | 13 | 不一樣(只差順序) | plan.js:48 | 合約說「先到期的排前面」。D6 只改了「超過 10 句時先排最難的」。但程式**不管有沒有超過 10 句**,都照「點過的字多寡」排。到期 10 句以內時,該出現的句子一句不少,只是排列順序和合約不同。n4 的例子:四句同一次到期,顯示順序是 6-3、6-1、6-4、6-8(6-3 點過 2 個字,排第一)。 | | 30 | 不一樣(少見操作) | plan.js:311-318、plan.js:197-206;n2、n2b | 今天複習清單裡的句子,到「看全部題目」點一個字、再點一下取消。之後複習卡上那個字還是紅的,「這次都聽出來了」也不能按。照畫面把它點掉,缺口清單會**多扣一次**。如果同一個字在別句也點過,那句會從缺口清單消失。n2b 的例子:「the」在 6-2 還標著,但缺口清單已經沒有「the」。 | ## 攻擊情境(只列一般用法碰得到的) ### 低 1. 複習中途到「看全部題目」點字又取消:複習卡卡住、缺口清單少算(腳本實證 n2、n2b) - **步驟(第 2 次學習,6-1 在今天的複習裡):** 1. 切到「看全部題目」,誤點 6-1 的一個字,再點一下取消。 2. 回到複習卡。 - **應該:** 這句和沒點過一樣,可以按「這次都聽出來了」。 - **實際:** - 取消時,只處理了 `marks`,`reviewMarks` 留著那個字。程式只在「點上」時把字寫進這次的 reviewMarks(plan.js:314),「取消」沒有對應的處理。 - 所以複習卡把那個字畫成紅的,「這次都聽出來了」不能按(ui.js:132-136、152),結果停在「還是有沒聽出來的」。 - 使用者照畫面把那個字點掉,會走 remark 的取消(plan.js:203),再扣一次缺口次數。 - n2b:「the」在 6-2 仍標著,缺口清單卻變成沒有「the」。 - **影響:** 要先在全部題目點字、又在同一次取消,才會發生。不會丟學習進度:把字點掉後仍可以按「都聽出來了」,n2 最後是 `{stage:1,due:6}`。會錯的只有缺口清單的次數。 - **這是這輪新加的「複習中點字=這次的 reviewMarks」帶進來的。** ### 低 2. 第 13 條的排列順序(讀程式+n4) - 見對照表。到期 10 句以內時,照 D6 的字面應該維持「先到期的排前面」。改法:只在 `due.length > REVIEW_CAP` 時才照難度排。 ### 低 3. 複習會先堆起來,等全部學完才消化(模擬 n1,屬於 D6 拍板後的行為,提供給主理人參考) - 模擬的假設(推估,不是實測主理人的習慣): - 新題有 70% 會點 1~3 個字。 - 複習有 60% 按「都聽出來了」。 - 結果: - 第 5~25 次,每次到期的句子都超過 10 句,最多 44 句(第 11 次)。 - 「只點過 1 個字」的句子會一直被比較難的擠到後面,最多等了 16 次才輪到(`oldestOverdue` 欄)。 - 第 26 次(全部學完後約 15 次)就清空。 - 每一次都還是 10+10、做得完,不會卡住。 - 這是「超過上限先排最難」的必然結果,D6 已經接受。這裡只是把實際的樣子量出來:面板的「之後還會再出現 N 句」大約會漲到 50~60 句才開始下降。 ### 低 4. 複習卡誤點一個字再取消,這句仍然算「還是有沒聽出來的」(腳本實證 n3、n4) - 在複習卡誤點一個字,馬上取消。這句的結果仍是 `miss`、排到下一次,複習區也算這句做完了。 - 原因:plan.js:303 點字時自動改成 miss;取消時沒有改回「還沒作答」。 - 畫面上「還是有沒聽出來的」會亮起來(n4:`["miss",true]`)。使用者看得到,也可以改按「這次都聽出來了」(n4 改按後是 `{stage:1,due:6}`)。只是如果沒注意,這句會多複習一次。 - 不是這輪新改的。列出來是因為手機上誤觸很常見。 ### 低 5. 04 的「已知限制」還是舊的數字 - 04-implementation.md:108 仍寫「一年約 2~4 MB,約 2 年後做快照」。 - 05i 的實測是約 10 個月 5 MB。這輪改用 IndexedDB 後,空間的問題已經解決,剩下的只有「新裝置第一次打開要下載整份」。 - 建議把這段改成目前的實況,免得下一位照舊數字判斷。 ### 其他確認過、沒有問題的 - **換裝置接著做、另一台按完成、手機一直開著**:都正常(n4,見開頭)。 - **作答後立刻關分頁**:8/8 都保留,server 也有(n6b)。n5 試了四種關法(立刻關、等 0.3 秒、等 1.5 秒、重新整理),都保留。 - **測試工具的提醒(不是程式問題)**:手機寬度下,puppeteer 有時會點到底部條,而不是被它蓋住的選項(n6b 記下點到的是 `bar`)。真人會先捲一下,不算問題。但之後寫 e2e 時,手機寬度要先把題目捲到畫面中間再點。 - **每天一次、40 次**:每次 10+10 的上限都守住,沒有卡死(n1)。 ## 12 條壞味道掃描(只看這輪改過的部分) | 條號 | 位置 | 白話 | 建議 | |---|---|---|---| | 1 重複程式碼 | plan.js:314-318 和 plan.js:197-206 | 「把字記進這次的 reviewMarks」寫了兩份:mark 一份、remark 一份。mark 那份只寫了「點上」,沒寫「取消」,低 1 就是這樣來的。 | mark 遇到今天複習的句子時,直接改呼叫 `remark(s, book, q, i, on)`,點上和取消都走同一條路。 | | 1 重複程式碼 | plan.js:306、plan.js:328 | `((s.plans[ses] \|\| {}).reviewSnap \|\| {})[e.q]` 出現兩次。 | 抽成 `snapAt(s, ses, q)`。 | | 10 過度耦合的訊息鏈 | plan.js:306 | 一行裡串了五層 `\|\| {}` 再取 `.marks`,很難看出在判斷什麼。 | 同上,抽成小函式並取名,例如 `wasMarkedBefore(s, ses, q, i)`。 | | 8 發散式變化/隱藏副作用 | store.js:59-62 對照 store.js:84 | 兩種儲存的 `put` 同名但做的事不一樣:localStorage 版不看傳進來的清單,而是偷偷把整份 `events`(模組全域變數)和儲存區合併再整份寫回;IndexedDB 版只寫傳進來的那幾筆。takeServer 要特地先 `keepOnly`,就是為了繞過前者的偷偷合併(store.js:233 的註解)。 | 讓兩種 `put(list)` 都只做「寫入這幾筆」;localStorage 版要合併的話,在 `listen` 收到時合併。這樣 store.js:233-236 的特例和註解都可以拿掉。 | | 7 霰彈式修改 | store.js:116、134、135 | 用 `cache !== lsCache` 判斷「現在是哪一種儲存」,散在三處。之後若加第三種,要找齊每一處。 | 把便條和搬家的工作放進 idbCache 自己的方法(例如 `cache.beforeWrite(list)`),lsCache 給空的版本。 | | 2 過長函式 | store.js:230-251 `takeServer` | 一個函式做了五件事:判斷流水號倒退、判斷 epoch 換了、重新整份下載、合併、決定「完整」標記。 | 先把「資料庫是不是被換過」抽成 `serverWasReplaced(json)`,回傳 true/false。 | | 12 注釋當除臭劑 | store.js:233 | 「不能合併,否則確認標記會被合併回來」是在幫第 8 條的怪介面解釋。修掉介面,這句就不需要了。 | 同第 8 條。 | | 9 基本型別偏執 | plan.js:9、plan.js:213-214 | 「學會」藏在 `stage >= INTERVALS.length` 這個比較裡,沒有名字。D6 一改間隔長度,「學會」的定義就跟著變。現在是對的,但要讀兩處才看得懂。 | 加 `const MASTER_STAGE = INTERVALS.length`,或寫成 `isMastered(stage)`。 | 其餘幾條(3 過大類別、4 過長參數列、5 資料泥團、6 依戀情結、11 冗贅類別)在這輪改動裡沒有看到。ui.js 的 `reviewOnlyStop`/`nextTarget` 短而清楚。server.py 的 `restore_backup` 和 `read_events` 也乾淨。 ## 建議修正(依重要性) 1. **mark 遇到今天的複習句時,「取消」也要處理(低 1、壞味道 1)。** - plan.js:314-318 改成:今天的複習句不論點上或取消,都交給 `remark()`;只有不在今天複習清單的句子才走 `markWord()`。 - 要補的測試:n2b 的序列跑完後,「the」的缺口次數仍是 1、6-2 仍在清單裡,6-1 可以按「都聽出來了」。 2. **到期 10 句以內時照到期先後排(低 2、第 13 條)。** plan.js:48 只在 `due.length > REVIEW_CAP` 時照難度排。 3. **複習卡誤點又取消時,結果要退回(低 4)。** plan.js:303:取消後如果這次的 reviewMarks 已經空了,而且 `miss` 是自動設的(不是使用者按的),就刪掉 `reviewResult[q]`,回到還沒作答。如果覺得要分辨「是誰設的」太麻煩,可以維持現狀:畫面上看得到,也能改按。 4. **把 04 的「已知限制」和攻擊 9 的理由改成實況(低 5、05i 表第 9 項)。** 5. **壞味道的整理(第 8、7、2、12 條)**:等功能定稿後當成獨立的整理工作做,不要和修 bug 混在一起。 ## 極端情況(主理人說先不處理) - 手動把資料庫檔蓋回去之後,如果別台先上線補送、讓流水號追過這台記得的號碼,這台就偵測不到被蓋回(store.js:232 只比號碼大小;讀程式+推理)。 - 同一個瀏覽器開兩個看得見的分頁、同時拉取時,比較晚回來的舊回應可能被當成「資料庫被蓋回」,觸發一次整份重新下載(store.js:232;推理)。 - 多台離線、各自開始同一次時,review 事件會把答過、但不在合併清單裡的句子加回來,複習清單可能超過 10 句(plan.js:322;讀程式)。 - `after=²`(上標數字)會通過 `isdigit()`,但 `int()` 會出錯,連線直接斷;Content-Length 也一樣(server.py:137、148;讀程式+Python 語意)。 - IndexedDB 超過 3 秒還沒打開時,會改用 localStorage,那一次看起來像沒有進度。等下次正常打開時會合回去(store.js:93、117-121;讀程式)。 - `--restore` 沒有檢查 server 是不是還開著(server.py:67-83);也只試讀一行,沒有做完整性檢查。 - 匯入舊進度檔時另一台還在學,另一台的複習會晚幾次才出現(05i 攻擊 9,刻意不做)。 --- 本報告的每一項判斷都來自實際查證:逐行讀程式碼;跑 plan 49/49、sync 8/8、server 11/11、e2e 71/71;audit6 的 node 腳本用 sim.js 載入真正的 plan.js+store.js;n4~n6 用真的 Chrome 和真的 server.py;05i 第 9 項另在 `patched/` 副本上跑過全部測試。標「讀程式」或「推理」的是:極端情況一節的大部分項目、低 3 的使用者模型(70%/60% 是假設,不是主理人的實際資料),以及壞味道的判斷。