diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9849a25c..409a7e4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,14 +26,55 @@ jobs: with: python-version: '3.x' - - name: Rebuild JS bundles and check for drift + - name: Install Playwright (Chromium) + run: | + npm ci --prefix developer + python -m pip install --upgrade pip + python -m pip install playwright==1.56.0 + python -m playwright install --with-deps chromium + + - name: Check JS bundle drift # The committed files under js/bundles/ must match what the build script # produces from the committed sources. If this step fails, a source file # was edited without rebuilding its bundle (run `node scripts/build-bundles.mjs`). - run: node scripts/build-bundles.mjs && git diff --exit-code -- js/bundles/ + run: node scripts/build-bundles.mjs --check - name: Run JS test (practiceTimerPreferences) run: node developer/tests/js/practiceTimerPreferences.test.js + - name: Run DataKernel v2 local protocol tests + run: node developer/tests/js/dataKernelV2.test.js local + + - name: Run AppData v2 domain tests + run: node developer/tests/js/appDataV2.test.js + + - name: Run legacy migration brick regression + run: node developer/tests/js/legacyMigrationBrickRegression.test.js + + - name: Run v2 data-loss baseline tests + run: node --test developer/tests/js/dataLossBaseline.test.js + + - name: Run practice completion / persistence regressions + run: | + node developer/tests/js/practiceCompletionFlow.test.js + node developer/tests/js/practiceRecordPersistence.test.js + node developer/tests/js/practiceRecorder.test.js + node developer/tests/js/examPlaceholderReplay.test.js + + - name: Run security protocol regression tests + run: | + node developer/tests/js/listeningRecordBridgeParser.test.js + node developer/tests/js/listeningRecordBridgeProtocol.test.js + node developer/tests/js/readingAnnotationHostProtocol.test.js + node developer/tests/js/reviewHighlightDictionaryProtocol.test.js + node developer/tests/js/legacyViewReadStatus.test.js + node developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js + node developer/tests/js/suiteInlineFallback.test.js + node developer/tests/js/suiteModeRegression.test.js + node developer/tests/js/suiteSessionRecoveryV2.test.js + - name: Run Python tests (listening asset quality) run: python -m unittest developer.tests.py.test_listening_generate_assets_quality + + - name: Run unified E2E suite (file:// capable) + run: python developer/tests/e2e/e2e_runner.py diff --git a/README.md b/README.md index 53f65a2b..18c5d111 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ assets/generated/listening-exams/listening-index.compat.js 系统管理能力: -- 清除缓存:清理部分运行缓存并刷新状态。 +- 清除全部本地数据:删除浏览器中的练习、题库、词汇、设置、应用内备份和本地文件夹绑定,刷新后回到首次启动并重新显示 GPL 协议;外部文件夹中的 JSON 备份不会删除。 - 加载题库:导入阅读或听力题库目录。 - 主题切换:切换当前界面的背景与视觉主题。 - 题库配置切换:查看、切换或管理题库配置。 @@ -349,6 +349,7 @@ assets/generated/listening-exams/listening-index.compat.js ```bash python developer/tests/ci/run_static_suite.py +python developer/tests/e2e/full_reset_flow.py python developer/tests/e2e/suite_practice_flow.py ``` @@ -534,4 +535,3 @@ ReadingPractice/ 代码许可证见 [LICENSE](LICENSE)。使用、修改和再分发代码时,应遵守许可证条款。 题源、文章、音频、PDF、图片和解析材料可能来自第三方或原始考试资料,版权归原权利人所有。本项目不授予这些内容的商业使用权或公开传播权。使用者应自行承担因复制、部署、传播或商业化使用相关内容产生的法律和平台风险。 - diff --git a/assets/generated/reading-exams/manifest.js b/assets/generated/reading-exams/manifest.js index b38a1cdf..61490cec 100644 --- a/assets/generated/reading-exams/manifest.js +++ b/assets/generated/reading-exams/manifest.js @@ -5,3486 +5,3517 @@ "listening": "ListeningPractice/" }; const manifest = { - "p1-high-01": { - "examId": "p1-high-01", - "dataKey": "p1-high-01", - "script": "./p1-high-01.js", - "title": "A Brief History of Tea 茶叶简史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/", - "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-02": { - "examId": "p1-low-02", - "dataKey": "p1-low-02", - "script": "./p1-low-02.js", - "title": "Maori Fish Hooks 毛利鱼钩", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/", - "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-03": { - "examId": "p3-high-03", - "dataKey": "p3-high-03", - "script": "./p3-high-03.js", - "title": "What makes a musical expert_ 音乐天赋", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", - "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-04": { - "examId": "p3-high-04", - "dataKey": "p3-high-04", - "script": "./p3-high-04.js", - "title": "Yawning 打呵欠", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", - "filename": "101. P3 - Yawning 打呵欠【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-05": { - "examId": "p1-high-05", - "dataKey": "p1-high-05", - "script": "./p1-high-05.js", - "title": "Katherine Mansfield 新西兰作家", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/", - "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-06": { - "examId": "p2-low-06", - "dataKey": "p2-low-06", - "script": "./p2-low-06.js", - "title": "Biomimicry 仿生学", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", - "filename": "103. P2 - Biomimicry 仿生学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-07": { - "examId": "p3-low-07", - "dataKey": "p3-low-07", - "script": "./p3-low-07.js", - "title": "Star Performers 明星员工", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", - "filename": "104. P3 - Star Performers 明星员工.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-08": { - "examId": "p2-low-08", - "dataKey": "p2-low-08", - "script": "./p2-low-08.js", - "title": "How the Petri dish supports scientific advances 培养皿", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/", - "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-09": { - "examId": "p2-high-09", - "dataKey": "p2-high-09", - "script": "./p2-high-09.js", - "title": "Early Approaches to Organisational Design 组织设计", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", - "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-10": { - "examId": "p2-medium-10", - "dataKey": "p2-medium-10", - "script": "./p2-medium-10.js", - "title": "A study of western celebrity 西方名人", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/", - "filename": "107. P2 - A study of western celebrity 西方名人【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-11": { - "examId": "p1-low-11", - "dataKey": "p1-low-11", - "script": "./p1-low-11.js", - "title": "Bovids 牛科动物", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/", - "filename": "108. P1 - Bovids 牛科动物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-12": { - "examId": "p3-low-12", - "dataKey": "p3-low-12", - "script": "./p3-low-12.js", - "title": "Humanities and the health professional 人文医学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/", - "filename": "109. P3 - Humanities and the health professional 人文医学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-13": { - "examId": "p1-low-13", - "dataKey": "p1-low-13", - "script": "./p1-low-13.js", - "title": "Report on a university drama project 大学戏剧项目报告", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", - "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-14": { - "examId": "p2-high-14", - "dataKey": "p2-high-14", - "script": "./p2-high-14.js", - "title": "Should space be explored by robots or by humans 人机太空探索", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", - "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-15": { - "examId": "p3-high-15", - "dataKey": "p3-high-15", - "script": "./p3-high-15.js", - "title": "Whale Culture 鲸鱼文化", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", - "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-16": { - "examId": "p2-high-16", - "dataKey": "p2-high-16", - "script": "./p2-high-16.js", - "title": "The Importance of Law 法律的意义", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/", - "filename": "112. P2 - The Importance of Law 法律的意义【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-17": { - "examId": "p2-high-17", - "dataKey": "p2-high-17", - "script": "./p2-high-17.js", - "title": "Herbal Medicines 新西兰草药", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/", - "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-18": { - "examId": "p3-medium-18", - "dataKey": "p3-medium-18", - "script": "./p3-medium-18.js", - "title": "Unlocking the mystery of dreams 梦的解析", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/", - "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-19": { - "examId": "p2-high-19", - "dataKey": "p2-high-19", - "script": "./p2-high-19.js", - "title": "Mind Music 脑海中的音乐(心灵音乐)", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", - "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-20": { - "examId": "p1-medium-20", - "dataKey": "p1-medium-20", - "script": "./p1-medium-20.js", - "title": "The Development of Plastics 塑料的发展史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/", - "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-21": { - "examId": "p2-high-21", - "dataKey": "p2-high-21", - "script": "./p2-high-21.js", - "title": "Stress Less 工作压力", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", - "filename": "117. P2 - Stress Less 工作压力【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-22": { - "examId": "p3-medium-22", - "dataKey": "p3-medium-22", - "script": "./p3-medium-22.js", - "title": "Neanderthal Technology 尼安德特人的生存技艺", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", - "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-23": { - "examId": "p2-high-23", - "dataKey": "p2-high-23", - "script": "./p2-high-23.js", - "title": "The Constant Evolution of the Humble Tomato 番茄的演化", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", - "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-24": { - "examId": "p1-high-24", - "dataKey": "p1-high-24", - "script": "./p1-high-24.js", - "title": "Rubber 橡胶", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/", - "filename": "12. P1 - Rubber 橡胶【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-25": { - "examId": "p2-high-25", - "dataKey": "p2-high-25", - "script": "./p2-high-25.js", - "title": "Will Eating Less Make You Live Longer 节食与长寿", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", - "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-27": { - "examId": "p1-high-27", - "dataKey": "p1-high-27", - "script": "./p1-high-27.js", - "title": "Footprints in the Mud 恐龙脚印", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", - "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-28": { - "examId": "p3-low-28", - "dataKey": "p3-low-28", - "script": "./p3-low-28.js", - "title": "Images and Places 风景与印记", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/", - "filename": "123. P3 - Images and Places 风景与印记.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-29": { - "examId": "p1-medium-29", - "dataKey": "p1-medium-29", - "script": "./p1-medium-29.js", - "title": "The extinction of the cave bear 洞熊的灭绝", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/", - "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-30": { - "examId": "p1-low-30", - "dataKey": "p1-low-30", - "script": "./p1-low-30.js", - "title": "Investing in the Future 投资未来", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", - "filename": "125. P1 - Investing in the Future 投资未来.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-31": { - "examId": "p1-high-31", - "dataKey": "p1-high-31", - "script": "./p1-high-31.js", - "title": "Dolls through the ages 玩偶的变迁史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/", - "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-32": { - "examId": "p3-high-32", - "dataKey": "p3-high-32", - "script": "./p3-high-32.js", - "title": "Science and Filmmaking 电影科学(CGI)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/", - "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-33": { - "examId": "p1-medium-33", - "dataKey": "p1-medium-33", - "script": "./p1-medium-33.js", - "title": "The Pyramid of Cestius 罗马金字塔", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", - "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-34": { - "examId": "p1-low-34", - "dataKey": "p1-low-34", - "script": "./p1-low-34.js", - "title": "The Slow Food Organization 慢食运动组织", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/", - "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-35": { - "examId": "p1-low-35", - "dataKey": "p1-low-35", - "script": "./p1-low-35.js", - "title": "Sweet Trouble 澳洲制糖产业", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/", - "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-36": { - "examId": "p3-low-36", - "dataKey": "p3-low-36", - "script": "./p3-low-36.js", - "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/", - "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-37": { - "examId": "p2-low-37", - "dataKey": "p2-low-37", - "script": "./p2-low-37.js", - "title": "Keeping the water away 洪水防控", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", - "filename": "131. P2 - Keeping the water away 洪水防控.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-38": { - "examId": "p3-low-38", - "dataKey": "p3-low-38", - "script": "./p3-low-38.js", - "title": "Research into the effects of different teaching styles 教学风格研究", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/", - "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-39": { - "examId": "p2-low-39", - "dataKey": "p2-low-39", - "script": "./p2-low-39.js", - "title": "How to be Happy 如何获得幸福", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/", - "filename": "133. P2 - How to be Happy 如何获得幸福.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-40": { - "examId": "p1-low-40", - "dataKey": "p1-low-40", - "script": "./p1-low-40.js", - "title": "Dyes and fabric dyeing 染料的历史", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/", - "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-41": { - "examId": "p2-low-41", - "dataKey": "p2-low-41", - "script": "./p2-low-41.js", - "title": "The Myth of the Eight-hour Sleep 八小时睡眠", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/", - "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-42": { - "examId": "p3-low-42", - "dataKey": "p3-low-42", - "script": "./p3-low-42.js", - "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", - "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-43": { - "examId": "p3-low-43", - "dataKey": "p3-low-43", - "script": "./p3-low-43.js", - "title": "What is social history 社会史", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", - "filename": "137. P3 - What is social history 社会史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-44": { - "examId": "p3-low-44", - "dataKey": "p3-low-44", - "script": "./p3-low-44.js", - "title": "Conformity 从众心理", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/", - "filename": "138. P3 - Conformity 从众心理.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-45": { - "examId": "p1-low-45", - "dataKey": "p1-low-45", - "script": "./p1-low-45.js", - "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", - "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-46": { - "examId": "p1-low-46", - "dataKey": "p1-low-46", - "script": "./p1-low-46.js", - "title": "Sydney Opera House 悉尼歌剧院", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", - "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-47": { - "examId": "p1-low-47", - "dataKey": "p1-low-47", - "script": "./p1-low-47.js", - "title": "The Burgess Shale fossils 伯吉斯页岩", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/", - "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-48": { - "examId": "p1-low-48", - "dataKey": "p1-low-48", - "script": "./p1-low-48.js", - "title": "The history of the guitar 吉他的历史", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", - "filename": "141. P1 - The history of the guitar 吉他的历史.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p2-low-49": { - "examId": "p2-low-49", - "dataKey": "p2-low-49", - "script": "./p2-low-49.js", - "title": "Born to Trade 交易的本能", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/", - "filename": "142. P2 - Born to Trade 交易的本能.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-50": { - "examId": "p2-low-50", - "dataKey": "p2-low-50", - "script": "./p2-low-50.js", - "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", - "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-51": { - "examId": "p2-low-51", - "dataKey": "p2-low-51", - "script": "./p2-low-51.js", - "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/", - "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-52": { - "examId": "p1-low-52", - "dataKey": "p1-low-52", - "script": "./p1-low-52.js", - "title": "Caral an ancient South American city 卡拉尔古城", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", - "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-53": { - "examId": "p1-low-53", - "dataKey": "p1-low-53", - "script": "./p1-low-53.js", - "title": "The Early History of Olive Oil 橄榄油的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/", - "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-54": { - "examId": "p3-low-54", - "dataKey": "p3-low-54", - "script": "./p3-low-54.js", - "title": "Movement Underwater 水下运动", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", - "filename": "147. P3 - Movement Underwater 水下运动.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-55": { - "examId": "p3-low-55", - "dataKey": "p3-low-55", - "script": "./p3-low-55.js", - "title": "Improving Patient Safety 药品包装设计", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/", - "filename": "148. P3 - Improving Patient Safety 药品包装设计.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-56": { - "examId": "p3-low-56", - "dataKey": "p3-low-56", - "script": "./p3-low-56.js", - "title": "Learning to be bilingual 双语学习", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/", - "filename": "149. P3 - Learning to be bilingual 双语学习.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-57": { - "examId": "p1-medium-57", - "dataKey": "p1-medium-57", - "script": "./p1-medium-57.js", - "title": "The Blockbuster Phenomenon 博物馆爆款现象", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/", - "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-58": { - "examId": "p2-medium-58", - "dataKey": "p2-medium-58", - "script": "./p2-medium-58.js", - "title": "Insect Decision-Making 昆虫决策", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/", - "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-59": { - "examId": "p3-low-59", - "dataKey": "p3-low-59", - "script": "./p3-low-59.js", - "title": "Inside the mind of a fan 观赛心境", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/", - "filename": "151. P3 - Inside the mind of a fan 观赛心境.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-60": { - "examId": "p1-medium-60", - "dataKey": "p1-medium-60", - "script": "./p1-medium-60.js", - "title": "Sorry—who are you 脸盲症", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/", - "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-61": { - "examId": "p1-low-61", - "dataKey": "p1-low-61", - "script": "./p1-low-61.js", - "title": "Carnivorous plants 食虫植物", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/", - "filename": "153. P1 - Carnivorous plants 食虫植物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-62": { - "examId": "p2-low-62", - "dataKey": "p2-low-62", - "script": "./p2-low-62.js", - "title": "The purpose of facial expressions 面部表情", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/", - "filename": "154. P2 - The purpose of facial expressions 面部表情.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-63": { - "examId": "p1-medium-63", - "dataKey": "p1-medium-63", - "script": "./p1-medium-63.js", - "title": "A Brief History of Humans and Food 人类食物的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/", - "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-64": { - "examId": "p2-low-64", - "dataKey": "p2-low-64", - "script": "./p2-low-64.js", - "title": "New filter promises clean water for millions 新型泥土净水器", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/", - "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-65": { - "examId": "p2-low-65", - "dataKey": "p2-low-65", - "script": "./p2-low-65.js", - "title": "Boring Buildings 无聊建筑", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/", - "filename": "157. P2 - Boring Buildings 无聊建筑.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-66": { - "examId": "p3-medium-66", - "dataKey": "p3-medium-66", - "script": "./p3-medium-66.js", - "title": "Mercator - The Map Maker 地理制图师", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/", - "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-67": { - "examId": "p1-low-67", - "dataKey": "p1-low-67", - "script": "./p1-low-67.js", - "title": "Scented Plants 植物的味道", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/", - "filename": "159. P1 - Scented Plants 植物的味道.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-68": { - "examId": "p1-low-68", - "dataKey": "p1-low-68", - "script": "./p1-low-68.js", - "title": "The Clipper Races 帆船竞速", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", - "filename": "16. P1 - The Clipper Races 帆船竞速.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-69": { - "examId": "p1-low-69", - "dataKey": "p1-low-69", - "script": "./p1-low-69.js", - "title": "An important language development 楔形文字", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/", - "filename": "160. P1 - An important language development 楔形文字.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-70": { - "examId": "p1-low-70", - "dataKey": "p1-low-70", - "script": "./p1-low-70.js", - "title": "Fluorescence Deep sea discovery深海发光生物研究", - "category": "P1", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", - "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-71": { - "examId": "p3-low-71", - "dataKey": "p3-low-71", - "script": "./p3-low-71.js", - "title": "Sea Change for Salinity 土地盐碱化", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/", - "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-72": { - "examId": "p1-low-72", - "dataKey": "p1-low-72", - "script": "./p1-low-72.js", - "title": "How to find your way out of a food desert 城市食物荒漠", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/", - "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-73": { - "examId": "p2-low-73", - "dataKey": "p2-low-73", - "script": "./p2-low-73.js", - "title": "The Power of Smell 嗅觉的力量", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/", - "filename": "164. P2 - The Power of Smell 嗅觉的力量.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-74": { - "examId": "p3-low-74", - "dataKey": "p3-low-74", - "script": "./p3-low-74.js", - "title": "The Placebo Effect5 安慰剂效应", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/", - "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-75": { - "examId": "p2-low-75", - "dataKey": "p2-low-75", - "script": "./p2-low-75.js", - "title": "Lean Production Innovation 精益生产", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", - "filename": "166. P2 - Lean Production Innovation 精益生产.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-76": { - "examId": "p3-low-76", - "dataKey": "p3-low-76", - "script": "./p3-low-76.js", - "title": "Sign, Baby, Sign! 美国手语", - "category": "P3", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/", - "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-77": { - "examId": "p2-low-77", - "dataKey": "p2-low-77", - "script": "./p2-low-77.js", - "title": "Mammoth Kill 猛犸象的灭绝", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", - "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-78": { - "examId": "p3-low-78", - "dataKey": "p3-low-78", - "script": "./p3-low-78.js", - "title": "The Costs of Brand Loyalty 品牌忠诚的代价", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/", - "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-79": { - "examId": "p1-high-79", - "dataKey": "p1-high-79", - "script": "./p1-high-79.js", - "title": "The Development of The Silk Industry 丝绸产业发展", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/", - "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-80": { - "examId": "p1-low-80", - "dataKey": "p1-low-80", - "script": "./p1-low-80.js", - "title": "The unsung sense 被低估的嗅觉", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", - "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-81": { - "examId": "p1-low-81", - "dataKey": "p1-low-81", - "script": "./p1-low-81.js", - "title": "Salt 盐的历史", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/", - "filename": "171. P1 - Salt 盐的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-82": { - "examId": "p1-high-82", - "dataKey": "p1-high-82", - "script": "./p1-high-82.js", - "title": "Think Small 微观科学", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", - "filename": "172. P1 - Think Small 微观科学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-83": { - "examId": "p3-low-83", - "dataKey": "p3-low-83", - "script": "./p3-low-83.js", - "title": "1018纸笔 Looking for inspiration 寻找灵感", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/", - "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-84": { - "examId": "p1-low-84", - "dataKey": "p1-low-84", - "script": "./p1-low-84.js", - "title": "Why good ideas fail TF公司", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", - "filename": "174. P1 - Why good ideas fail TF公司.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-85": { - "examId": "p3-low-85", - "dataKey": "p3-low-85", - "script": "./p3-low-85.js", - "title": "Music soothes and awes 音乐疗愈", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/", - "filename": "175. P3 - Music soothes and awes 音乐疗愈.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-86": { - "examId": "p2-medium-86", - "dataKey": "p2-medium-86", - "script": "./p2-medium-86.js", - "title": "Urban Regeneration 柏林公园改造", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", - "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-87": { - "examId": "p2-low-87", - "dataKey": "p2-low-87", - "script": "./p2-low-87.js", - "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/", - "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-88": { - "examId": "p3-low-88", - "dataKey": "p3-low-88", - "script": "./p3-low-88.js", - "title": "1025纸笔Translating a key to international understanding 翻译的艺术", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/", - "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-89": { - "examId": "p3-high-89", - "dataKey": "p3-high-89", - "script": "./p3-high-89.js", - "title": "Looking at daily life in ancient Rome 古罗马的日常", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/", - "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-90": { - "examId": "p1-high-90", - "dataKey": "p1-high-90", - "script": "./p1-high-90.js", - "title": "The History of Tea 茶叶的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/", - "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-91": { - "examId": "p2-high-91", - "dataKey": "p2-high-91", - "script": "./p2-high-91.js", - "title": "Australia’s camouflaged creatures 澳洲伪装生物", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", - "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-92": { - "examId": "p1-high-92", - "dataKey": "p1-high-92", - "script": "./p1-high-92.js", - "title": "Dust and the American West 美国西部尘埃", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", - "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-93": { - "examId": "p2-medium-93", - "dataKey": "p2-medium-93", - "script": "./p2-medium-93.js", - "title": "Antarctic research 南极考察", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", - "filename": "182. P2 - Antarctic research 南极考察.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-94": { - "examId": "p2-low-94", - "dataKey": "p2-low-94", - "script": "./p2-low-94.js", - "title": "The importance of being playful 玩耍的重要性", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/", - "filename": "183. P2 - The importance of being playful 玩耍的重要性.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-95": { - "examId": "p3-low-95", - "dataKey": "p3-low-95", - "script": "./p3-low-95.js", - "title": "The strange world of sight 奇异的视觉世界", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", - "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-96": { - "examId": "p2-low-96", - "dataKey": "p2-low-96", - "script": "./p2-low-96.js", - "title": "[Pretest] Why Do We Need Sleep 睡眠的目的", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/", - "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-97": { - "examId": "p3-low-97", - "dataKey": "p3-low-97", - "script": "./p3-low-97.js", - "title": "Saving languages 拯救濒危语言", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/", - "filename": "186. P3 - Saving languages 拯救濒危语言.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-98": { - "examId": "p3-low-98", - "dataKey": "p3-low-98", - "script": "./p3-low-98.js", - "title": "Petrol power an eco-revolution 交通的革命", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/", - "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-99": { - "examId": "p1-low-99", - "dataKey": "p1-low-99", - "script": "./p1-low-99.js", - "title": "The history of the bar code 条形码的历史", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", - "filename": "188. P1 - The history of the bar code 条形码的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-100": { - "examId": "p3-low-100", - "dataKey": "p3-low-100", - "script": "./p3-low-100.js", - "title": "Mirror 镜子研究", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/", - "filename": "189. P3 - Mirror 镜子研究.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-101": { - "examId": "p1-high-101", - "dataKey": "p1-high-101", - "script": "./p1-high-101.js", - "title": "The Impact of the Potato 土豆的影响", - "category": "P1", - "frequency": "高频", - "difficultyScore": 1, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/", - "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-102": { - "examId": "p2-low-102", - "dataKey": "p2-low-102", - "script": "./p2-low-102.js", - "title": "The power of music 音乐的力量", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/", - "filename": "190. P2 - The power of music 音乐的力量.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-103": { - "examId": "p2-low-103", - "dataKey": "p2-low-103", - "script": "./p2-low-103.js", - "title": "The economic effect of climate 气候对经济的影响", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", - "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-104": { - "examId": "p2-low-104", - "dataKey": "p2-low-104", - "script": "./p2-low-104.js", - "title": "1115纸笔Should we stop eating meat 是否应该吃素", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", - "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-105": { - "examId": "p1-high-105", - "dataKey": "p1-high-105", - "script": "./p1-high-105.js", - "title": "A survivor’s story 新西兰猫头鹰", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/", - "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-106": { - "examId": "p1-low-106", - "dataKey": "p1-low-106", - "script": "./p1-low-106.js", - "title": "The Importance of Business Cards 名片的重要性", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/", - "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-107": { - "examId": "p1-low-107", - "dataKey": "p1-low-107", - "script": "./p1-low-107.js", - "title": "The life of Beatrix Potter 彼得兔作家", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", - "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-108": { - "examId": "p1-low-108", - "dataKey": "p1-low-108", - "script": "./p1-low-108.js", - "title": "The nature of Yawning 打哈欠的本质", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/", - "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-109": { - "examId": "p1-low-109", - "dataKey": "p1-low-109", - "script": "./p1-low-109.js", - "title": "The Origin of Paper 造纸术起源", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", - "filename": "23. P1 - The Origin of Paper 造纸术起源.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-110": { - "examId": "p1-high-110", - "dataKey": "p1-high-110", - "script": "./p1-high-110.js", - "title": "The Pearls 珍珠", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/", - "filename": "24. P1 - The Pearls 珍珠【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-111": { - "examId": "p1-low-111", - "dataKey": "p1-low-111", - "script": "./p1-low-111.js", - "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/", - "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-112": { - "examId": "p1-low-112", - "dataKey": "p1-low-112", - "script": "./p1-low-112.js", - "title": "The Tuatara of New Zealand 新西兰蜥蜴", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/", - "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-113": { - "examId": "p1-low-113", - "dataKey": "p1-low-113", - "script": "./p1-low-113.js", - "title": "Thomas Young The last man who knew everything 托马斯·杨", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", - "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-114": { - "examId": "p1-low-114", - "dataKey": "p1-low-114", - "script": "./p1-low-114.js", - "title": "Triumph of the City 城市的胜利", - "category": "P1", - "frequency": "高频", - "difficultyScore": 1.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", - "filename": "28. P1 - Triumph of the City 城市的胜利.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-115": { - "examId": "p1-medium-115", - "dataKey": "p1-medium-115", - "script": "./p1-medium-115.js", - "title": "Tunnelling under the Thames 泰晤士河隧道", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", - "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-116": { - "examId": "p1-low-116", - "dataKey": "p1-low-116", - "script": "./p1-low-116.js", - "title": "Advertising Needs Attention 广告的吸引力", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/", - "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-117": { - "examId": "p1-medium-117", - "dataKey": "p1-medium-117", - "script": "./p1-medium-117.js", - "title": "What Lucy Taught Us 露西化石", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", - "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-118": { - "examId": "p1-high-118", - "dataKey": "p1-high-118", - "script": "./p1-high-118.js", - "title": "William Gilbert and Magnetism 电磁学之父", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/", - "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-119": { - "examId": "p1-medium-119", - "dataKey": "p1-medium-119", - "script": "./p1-medium-119.js", - "title": "Wood 新西兰木材产业", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", - "filename": "32. P1 - Wood 新西兰木材产业【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-120": { - "examId": "p2-high-120", - "dataKey": "p2-high-120", - "script": "./p2-high-120.js", - "title": "A new look for Talbot Park 奥克兰社区改造", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/", - "filename": "ai_studio_code (9).html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-121": { - "examId": "p2-medium-121", - "dataKey": "p2-medium-121", - "script": "./p2-medium-121.js", - "title": "A unique golden textile 蜘蛛丝", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/", - "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-122": { - "examId": "p2-low-122", - "dataKey": "p2-low-122", - "script": "./p2-low-122.js", - "title": "Biophilic Design 亲自然设计", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", - "filename": "35. P2 - Biophilic Design 亲自然设计.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-123": { - "examId": "p2-high-123", - "dataKey": "p2-high-123", - "script": "./p2-high-123.js", - "title": "Bird Migration 鸟类迁徙", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/", - "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-124": { - "examId": "p2-high-124", - "dataKey": "p2-high-124", - "script": "./p2-high-124.js", - "title": "Corporate Social Responsibility 企业社会责任", - "category": "P2", - "frequency": "次高频", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", - "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-125": { - "examId": "p2-low-125", - "dataKey": "p2-low-125", - "script": "./p2-low-125.js", - "title": "Egypt’s ancient boat-builders 古埃及造船", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/", - "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-126": { - "examId": "p2-medium-126", - "dataKey": "p2-medium-126", - "script": "./p2-medium-126.js", - "title": "How are deserts formed 沙漠成因", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/", - "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-127": { - "examId": "p1-low-127", - "dataKey": "p1-low-127", - "script": "./p1-low-127.js", - "title": "Ambergris 龙涎香", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", - "filename": "4. P1 - Ambergris 龙涎香.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-128": { - "examId": "p2-high-128", - "dataKey": "p2-high-128", - "script": "./p2-high-128.js", - "title": "How Well Do We Concentrate_ 多任务处理", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/", - "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-129": { - "examId": "p2-medium-129", - "dataKey": "p2-medium-129", - "script": "./p2-medium-129.js", - "title": "Intelligent behaviour in birds 鸟类智慧行为", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", - "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-130": { - "examId": "p2-high-130", - "dataKey": "p2-high-130", - "script": "./p2-high-130.js", - "title": "Investment in shares versus investment in other assets 回报数据分析", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", - "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-131": { - "examId": "p2-high-131", - "dataKey": "p2-high-131", - "script": "./p2-high-131.js", - "title": "Learning from the Romans 罗马混凝土", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/", - "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-132": { - "examId": "p2-low-132", - "dataKey": "p2-low-132", - "script": "./p2-low-132.js", - "title": "Orientation of Birds 鸟类的定位能力", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/", - "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-133": { - "examId": "p2-high-133", - "dataKey": "p2-high-133", - "script": "./p2-high-133.js", - "title": "Playing soccer 街头足球", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/", - "filename": "45. P2 - Playing soccer 街头足球【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-134": { - "examId": "p2-high-134", - "dataKey": "p2-high-134", - "script": "./p2-high-134.js", - "title": "Roller coaster 过山车", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/", - "filename": "46. P2 - Roller coaster 过山车【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-135": { - "examId": "p2-low-135", - "dataKey": "p2-low-135", - "script": "./p2-low-135.js", - "title": "Skyscraper Farming 摩天大楼种植", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", - "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-136": { - "examId": "p2-high-136", - "dataKey": "p2-high-136", - "script": "./p2-high-136.js", - "title": "Solving the problem of waste disposal 垃圾处理", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/", - "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-137": { - "examId": "p2-high-137", - "dataKey": "p2-high-137", - "script": "./p2-high-137.js", - "title": "Surviving city life 动物适应城市", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", - "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-138": { - "examId": "p1-low-138", - "dataKey": "p1-low-138", - "script": "./p1-low-138.js", - "title": "Australian artist Margaret Preston 澳大利亚艺术家", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/", - "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-139": { - "examId": "p2-high-139", - "dataKey": "p2-high-139", - "script": "./p2-high-139.js", - "title": "The conquest of malaria in Italy 意大利疟疾防治", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", - "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-140": { - "examId": "p2-low-140", - "dataKey": "p2-low-140", - "script": "./p2-low-140.js", - "title": "The dingo debate 澳洲野犬", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/", - "filename": "51. P2 - The dingo debate 澳洲野犬.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-141": { - "examId": "p2-high-141", - "dataKey": "p2-high-141", - "script": "./p2-high-141.js", - "title": "The fascinating world of attine ants 切叶蚁", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/", - "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-142": { - "examId": "p2-low-142", - "dataKey": "p2-low-142", - "script": "./p2-low-142.js", - "title": "The fashion industry 时尚产业", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", - "filename": "53. P2 - The fashion industry 时尚产业.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-143": { - "examId": "p2-low-143", - "dataKey": "p2-low-143", - "script": "./p2-low-143.js", - "title": "The impact of invasive species 入侵物种的影响", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", - "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-144": { - "examId": "p2-medium-144", - "dataKey": "p2-medium-144", - "script": "./p2-medium-144.js", - "title": "The plan to bring an asteroid to Earth 捕获小行星", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/", - "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-145": { - "examId": "p2-high-145", - "dataKey": "p2-high-145", - "script": "./p2-high-145.js", - "title": "The return of monkey life 猴群回归", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", - "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-146": { - "examId": "p2-medium-146", - "dataKey": "p2-medium-146", - "script": "./p2-medium-146.js", - "title": "The Tasmanian Tiger 袋狼", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/", - "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-147": { - "examId": "p2-low-147", - "dataKey": "p2-low-147", - "script": "./p2-low-147.js", - "title": "Who wrote Shakespeare's plays 莎士比亚", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", - "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-148": { - "examId": "p2-low-148", - "dataKey": "p2-low-148", - "script": "./p2-low-148.js", - "title": "Why do we need the arts_ 艺术的意义", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/", - "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-149": { - "examId": "p1-low-149", - "dataKey": "p1-low-149", - "script": "./p1-low-149.js", - "title": "Categorizing societies 社会分类", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/", - "filename": "6. P1 - Categorizing societies 社会分类html.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-150": { - "examId": "p3-high-150", - "dataKey": "p3-high-150", - "script": "./p3-high-150.js", - "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/", - "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-151": { - "examId": "p3-low-151", - "dataKey": "p3-low-151", - "script": "./p3-low-151.js", - "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/", - "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-152": { - "examId": "p3-medium-152", - "dataKey": "p3-medium-152", - "script": "./p3-medium-152.js", - "title": "Charles Darwin and Evolutionary Psychology 进化心理学", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", - "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-153": { - "examId": "p3-low-153", - "dataKey": "p3-low-153", - "script": "./p3-low-153.js", - "title": "Crossing the Threshold 奥克兰美术馆", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/", - "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-154": { - "examId": "p3-medium-154", - "dataKey": "p3-medium-154", - "script": "./p3-medium-154.js", - "title": "Decisions, Decisions 决策之间", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/", - "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-155": { - "examId": "p3-medium-155", - "dataKey": "p3-medium-155", - "script": "./p3-medium-155.js", - "title": "Does class size matter_ 课堂规模", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", - "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-156": { - "examId": "p3-high-156", - "dataKey": "p3-high-156", - "script": "./p3-high-156.js", - "title": "Elephant Communication 大象交流", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/", - "filename": "66. P3 - Elephant Communication 大象交流【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-157": { - "examId": "p3-high-157", - "dataKey": "p3-high-157", - "script": "./p3-high-157.js", - "title": "Flower Power 鲜花的力量(花之力)", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", - "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf", - "sourceKind": "generated-reading" - }, - "p3-low-158": { - "examId": "p3-low-158", - "dataKey": "p3-low-158", - "script": "./p3-low-158.js", - "title": "Game theory 博弈论", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/", - "filename": "68. P3 - Game theory 博弈论.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-159": { - "examId": "p3-high-159", - "dataKey": "p3-high-159", - "script": "./p3-high-159.js", - "title": "Grimm’s Fairy Tales 格林童话", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/", - "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-160": { - "examId": "p1-low-160", - "dataKey": "p1-low-160", - "script": "./p1-low-160.js", - "title": "Chili peppers 辣椒的历史", - "category": "P1", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/", - "filename": "7. P1 - Chili peppers 辣椒的历史.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-161": { - "examId": "p3-high-161", - "dataKey": "p3-high-161", - "script": "./p3-high-161.js", - "title": "Insect-inspired robots 昆虫机器人", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/", - "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-162": { - "examId": "p3-medium-162", - "dataKey": "p3-medium-162", - "script": "./p3-medium-162.js", - "title": "Jean Piaget (1896–1980) 让·皮亚杰", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", - "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-163": { - "examId": "p3-low-163", - "dataKey": "p3-low-163", - "script": "./p3-low-163.js", - "title": "Keeping the Fun in Funfairs 游乐场设计科学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/", - "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-164": { - "examId": "p3-high-164", - "dataKey": "p3-high-164", - "script": "./p3-high-164.js", - "title": "Language Strategy in Multinational Companies 跨国公司语言策略", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/", - "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-165": { - "examId": "p3-low-165", - "dataKey": "p3-low-165", - "script": "./p3-low-165.js", - "title": "Let’s teach them how to teach 教他们如何教学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/", - "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-166": { - "examId": "p3-low-166", - "dataKey": "p3-low-166", - "script": "./p3-low-166.js", - "title": "Life on Mars_ 火星地球化改造", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", - "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-167": { - "examId": "p3-high-167", - "dataKey": "p3-high-167", - "script": "./p3-high-167.js", - "title": "Living dunes 流动沙丘", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/", - "filename": "76. P3 - Living dunes 流动沙丘【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-168": { - "examId": "p3-medium-168", - "dataKey": "p3-medium-168", - "script": "./p3-medium-168.js", - "title": "Marketing and the information age 信息时代营销", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", - "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-169": { - "examId": "p3-medium-169", - "dataKey": "p3-medium-169", - "script": "./p3-medium-169.js", - "title": "(无题目) Music Language We All Speak 音乐语言", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", - "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-170": { - "examId": "p3-high-170", - "dataKey": "p3-high-170", - "script": "./p3-high-170.js", - "title": "Pacific Navigation and Voyaging 太平洋航海", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/", - "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-171": { - "examId": "p1-high-171", - "dataKey": "p1-high-171", - "script": "./p1-high-171.js", - "title": "Fishbourne Roman Palace 罗马宫殿", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/", - "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-172": { - "examId": "p3-low-172", - "dataKey": "p3-low-172", - "script": "./p3-low-172.js", - "title": "Rebranding art museums 博物馆品牌重塑", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/", - "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-173": { - "examId": "p3-high-173", - "dataKey": "p3-high-173", - "script": "./p3-high-173.js", - "title": "Robert Louis Stevenson 苏格兰作家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", - "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-174": { - "examId": "p3-high-174", - "dataKey": "p3-high-174", - "script": "./p3-high-174.js", - "title": "Some views on the use of headphones 耳机使用", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/", - "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-175": { - "examId": "p3-low-175", - "dataKey": "p3-low-175", - "script": "./p3-low-175.js", - "title": "Termite Mounds 白蚁丘", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/", - "filename": "83. P3 - Termite Mounds 白蚁丘.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-176": { - "examId": "p3-medium-176", - "dataKey": "p3-medium-176", - "script": "./p3-medium-176.js", - "title": "The Analysis of Fear 猴子恐惧实验", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", - "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-177": { - "examId": "p3-medium-177", - "dataKey": "p3-medium-177", - "script": "./p3-medium-177.js", - "title": "The Art of Deception 欺骗的艺术", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/", - "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-178": { - "examId": "p3-high-178", - "dataKey": "p3-high-178", - "script": "./p3-high-178.js", - "title": "The benefits of learning an instrument 学乐器的好处", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/", - "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-179": { - "examId": "p3-medium-179", - "dataKey": "p3-medium-179", - "script": "./p3-medium-179.js", - "title": "The Exploration of Mars 火星探索", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/", - "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-180": { - "examId": "p3-high-180", - "dataKey": "p3-high-180", - "script": "./p3-high-180.js", - "title": "The fluoridation controversy 氟化水争议", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/", - "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-181": { - "examId": "p3-high-181", - "dataKey": "p3-high-181", - "script": "./p3-high-181.js", - "title": "The Fruit Book 果实之书", - "category": "P3", - "frequency": "高频", - "difficultyScore": 5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/", - "filename": "89. P3 - The Fruit Book 果实之书【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf", - "sourceKind": "generated-reading" - }, - "p1-medium-182": { - "examId": "p1-medium-182", - "dataKey": "p1-medium-182", - "script": "./p1-medium-182.js", - "title": "Listening to the Ocean 海洋探测", - "category": "P1", - "frequency": "高频", - "difficultyScore": 3, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", - "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-183": { - "examId": "p3-medium-183", - "dataKey": "p3-medium-183", - "script": "./p3-medium-183.js", - "title": "The hazards of multitasking 多任务处理", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", - "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-184": { - "examId": "p3-high-184", - "dataKey": "p3-high-184", - "script": "./p3-high-184.js", - "title": "The New Zealand writer Margaret Mahy 新西兰女作家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/", - "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-185": { - "examId": "p3-medium-185", - "dataKey": "p3-medium-185", - "script": "./p3-medium-185.js", - "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/", - "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-186": { - "examId": "p3-low-186", - "dataKey": "p3-low-186", - "script": "./p3-low-186.js", - "title": "The Robbers Cave Study (山洞)群体行为实验", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", - "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-187": { - "examId": "p3-low-187", - "dataKey": "p3-low-187", - "script": "./p3-low-187.js", - "title": "The science of sleep 睡眠的科学", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/", - "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-188": { - "examId": "p3-medium-188", - "dataKey": "p3-medium-188", - "script": "./p3-medium-188.js", - "title": "The Significant Role of Mother Tongue in Education 母语教育", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/", - "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-189": { - "examId": "p3-high-189", - "dataKey": "p3-high-189", - "script": "./p3-high-189.js", - "title": "The tuatara – past and future 新西兰蜥蜴", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/", - "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-190": { - "examId": "p3-low-190", - "dataKey": "p3-low-190", - "script": "./p3-low-190.js", - "title": "The value of literary prizes 文学奖项的价值", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/", - "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-191": { - "examId": "p3-medium-191", - "dataKey": "p3-medium-191", - "script": "./p3-medium-191.js", - "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/", - "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-192": { - "examId": "p3-high-192", - "dataKey": "p3-high-192", - "script": "./p3-high-192.js", - "title": "Voynich Manuscript 伏尼契手稿", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/", - "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-200": { - "examId": "p1-high-200", - "dataKey": "p1-high-200", - "script": "./p1-high-200.js", - "title": "Australia’s Airborne Dentists 澳洲飞行牙医", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "三月/1.P1 高频/", - "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-211": { - "examId": "p1-high-211", - "dataKey": "p1-high-211", - "script": "./p1-high-211.js", - "title": "Ahead of its time 新西兰头骨", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "三月/1.P1 高频/", - "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-216": { - "examId": "p1-high-216", - "dataKey": "p1-high-216", - "script": "./p1-high-216.js", - "title": "Australia’s cane toad problem 澳洲蟾蜍", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "三月/1.P1 高频/", - "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-194": { - "examId": "p1-high-194", - "dataKey": "p1-high-194", - "script": "./p1-high-194.js", - "title": "The history of the British wool industry 英国羊毛产业的历史", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 2.5, - "path": "三月/2.P1 次高频/", - "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-222": { - "examId": "p2-low-222", - "dataKey": "p2-low-222", - "script": "./p2-low-222.js", - "title": "Ideal Homes 理想居所", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/", - "filename": "222. P2 - Ideal Homes 理想居所.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf", - "sourceKind": "generated-reading" - }, - "p1-low-223": { - "examId": "p1-low-223", - "dataKey": "p1-low-223", - "script": "./p1-low-223.js", - "title": "Effect and Cause 湖泊海啸研究", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3.5, - "path": "三月/", - "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-201": { - "examId": "p2-high-201", - "dataKey": "p2-high-201", - "script": "./p2-high-201.js", - "title": "Multi-tasking and the brain 大脑与多任务处理", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "三月/3.P2 高频/", - "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-217": { - "examId": "p2-medium-217", - "dataKey": "p2-medium-217", - "script": "./p2-medium-217.js", - "title": "A mechanical friend for children 孩子的机器人朋友", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/3.P2 高频/", - "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-192": { - "examId": "p2-high-192", - "dataKey": "p2-high-192", - "script": "./p2-high-192.js", - "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "三月/4.P2 次高频/", - "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-209": { - "examId": "p2-medium-209", - "dataKey": "p2-medium-209", - "script": "./p2-medium-209.js", - "title": "Decision Fatigue 决策疲劳", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-213": { - "examId": "p2-medium-213", - "dataKey": "p2-medium-213", - "script": "./p2-medium-213.js", - "title": "Growing more for less 卫星农业", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "三月/4.P2 次高频/", - "filename": "213. P2 - Growing more for less 卫星农业【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-051": { - "examId": "p2-low-051", - "dataKey": "p2-low-051", - "script": "./p2-low-051.js", - "title": "The dingo debate 澳洲野犬_澳洲野狗", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", - "sourceKind": "generated-reading" - }, - "p2-medium-058": { - "examId": "p2-medium-058", - "dataKey": "p2-medium-058", - "script": "./p2-medium-058.js", - "title": "Who wrote Shakespeare's plays 莎士比亚", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "三月/4.P2 次高频/", - "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-204": { - "examId": "p3-high-204", - "dataKey": "p3-high-204", - "script": "./p3-high-204.js", - "title": "When people are ‘deaf’ to music 失乐症", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-206": { - "examId": "p3-high-206", - "dataKey": "p3-high-206", - "script": "./p3-high-206.js", - "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-212": { - "examId": "p3-high-212", - "dataKey": "p3-high-212", - "script": "./p3-high-212.js", - "title": "Children’s literature studies today 儿童文学", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-218": { - "examId": "p3-high-218", - "dataKey": "p3-high-218", - "script": "./p3-high-218.js", - "title": "The Causes of Linguistic Change 语音的演变", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "三月/5.P3 高频/", - "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-219": { - "examId": "p3-low-219", - "dataKey": "p3-low-219", - "script": "./p3-low-219.js", - "title": "The origin of language 语言的起源", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "219. P3 - The origin of language 语言的起源.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-999": { - "examId": "p3-low-999", - "dataKey": "p3-low-999", - "script": "./p3-low-999.js", - "title": "Risk taking", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "三月/5.P3 高频/", - "filename": "P3 - Risk taking.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p3-medium-197": { - "examId": "p3-medium-197", - "dataKey": "p3-medium-197", - "script": "./p3-medium-197.js", - "title": "Australia’s Megafauna Controversy 巨兽灭绝", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "三月/6.P3 次高频/", - "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-198": { - "examId": "p3-low-198", - "dataKey": "p3-low-198", - "script": "./p3-low-198.js", - "title": "Child’s Play in Medieval England 中世纪的游戏", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4, - "path": "三月/6.P3 次高频/", - "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-078": { - "examId": "p3-low-078", - "dataKey": "p3-low-078", - "script": "./p3-low-078.js", - "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", - "category": "P3", - "frequency": "次高频", - "difficultyScore": 4.5, - "path": "三月/6.P3 次高频/", - "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-227": { - "examId": "p1-high-227", - "dataKey": "p1-high-227", - "script": "./p1-high-227.js", - "title": "The Whale Goes to Court 鲸鱼油", - "category": "P1", - "frequency": "次高频", - "difficultyScore": 3, - "path": "ReadingPractice/PDF/", - "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-225": { - "examId": "p2-high-225", - "dataKey": "p2-high-225", - "script": "./p2-high-225.js", - "title": "The problem of graffiti 涂鸦之困", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3.5, - "path": "ReadingPractice/PDF/", - "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-228": { - "examId": "p3-high-228", - "dataKey": "p3-high-228", - "script": "./p3-high-228.js", - "title": "On art and artists 艺术与艺术家", - "category": "P3", - "frequency": "高频", - "difficultyScore": 4.5, - "path": "ReadingPractice/PDF/", - "filename": "228. P3 - On art and artists 艺术与艺术家.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-229": { - "examId": "p1-high-229", - "dataKey": "p1-high-229", - "script": "./p1-high-229.js", - "title": "New Understanding of Giraffes in the Wild 野生长颈鹿", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2.5, - "path": "ReadingPractice/PDF/", - "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-230": { - "examId": "p1-high-230", - "dataKey": "p1-high-230", - "script": "./p1-high-230.js", - "title": "The History of the Pencil 铅笔的历史", - "category": "P1", - "frequency": "高频", - "difficultyScore": 1.5, - "path": "ReadingPractice/PDF/", - "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf", - "sourceKind": "generated-reading" - }, - "p1-high-231": { - "examId": "p1-high-231", - "dataKey": "p1-high-231", - "script": "./p1-high-231.js", - "title": "The History of the Pencil 铅笔的历史(流程图版)", - "category": "P1", - "frequency": "高频", - "difficultyScore": 2, - "path": "ReadingPractice/PDF/", - "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", - "sourceKind": "generated-reading" - }, - "p2-high-232": { - "examId": "p2-high-232", - "dataKey": "p2-high-232", - "script": "./p2-high-232.js", - "title": "The origin and development of applause 掌声的历史", - "category": "P2", - "frequency": "次高频", - "difficultyScore": 4, - "path": "ReadingPractice/PDF/", - "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-233": { - "examId": "p2-high-233", - "dataKey": "p2-high-233", - "script": "./p2-high-233.js", - "title": "Why don’t we sleep 失眠的原因", - "category": "P2", - "frequency": "高频", - "difficultyScore": 3, - "path": "ReadingPractice/PDF/", - "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-234": { - "examId": "p2-high-234", - "dataKey": "p2-high-234", - "script": "./p2-high-234.js", - "title": "How do plants talk to each other 植物交流", - "category": "P2", - "frequency": "高频", - "difficultyScore": 4, - "path": "ReadingPractice/PDF/", - "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf", - "sourceKind": "generated-reading" - }, - "p3-high-221": { - "examId": "p3-high-221", - "dataKey": "p3-high-221", - "script": "./p3-high-221.js", - "title": "The Animal Connection 动物联结", - "category": "P3", - "frequency": "次高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "221. P3 - The Animal Connection 动物联结.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-235": { - "examId": "p2-high-235", - "dataKey": "p2-high-235", - "script": "./p2-high-235.js", - "title": "The return of the black-footed ferret 黑足鼬", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-236": { - "examId": "p2-high-236", - "dataKey": "p2-high-236", - "script": "./p2-high-236.js", - "title": "War of the Plants 植物的战争", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "", - "filename": "", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p3-high-229": { - "examId": "p3-high-229", - "dataKey": "p3-high-229", - "script": "./p3-high-229.js", - "title": "All in the family 兄弟姐妹的影响", - "category": "P3", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf", - "sourceKind": "generated-reading" - }, - "p2-high-239": { - "examId": "p2-high-239", - "dataKey": "p2-high-239", - "script": "./p2-high-239.js", - "title": "Nanotechnology: the science of the very small 纳米科技", - "category": "P2", - "frequency": "高频", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-240": { - "examId": "p2-low-240", - "dataKey": "p2-low-240", - "script": "./p2-low-240.js", - "title": "Coins - the first form of money 硬币起源", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "assets/generated/reading-exams/", - "filename": "reading-practice-unified.html", - "hasHtml": true, - "hasPdf": false, - "pdfFilename": "", - "sourceKind": "generated-reading" - }, - "p1-high-240": { - "examId": "p1-high-240", - "dataKey": "p1-high-240", - "script": "./p1-high-240.js", - "title": "The Origins of Weather Forecasting 天气预报", - "category": "P1", - "frequency": "high", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf", - "sourceKind": "generated-reading" - }, - "p2-low-242": { - "examId": "p2-low-242", - "dataKey": "p2-low-242", - "script": "./p2-low-242.js", - "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", - "category": "P2", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", - "sourceKind": "generated-reading" - }, - "p3-low-240": { - "examId": "p3-low-240", - "dataKey": "p3-low-240", - "script": "./p3-low-240.js", - "title": "How a prehistoric predator took to the skies 翼龙飞行", - "category": "P3", - "frequency": "low", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - How a prehistoric predator took to the skies.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", - "sourceKind": "generated-reading" - }, - "p3-medium-241": { - "examId": "p3-medium-241", - "dataKey": "p3-medium-241", - "script": "./p3-medium-241.js", - "title": "Who looks after the children in today's Britain? 育儿分工", - "category": "P3", - "frequency": "medium", - "difficultyScore": null, - "path": "ReadingPractice/PDF/", - "filename": "P3 - Who looks after the children in today's Britain.pdf", - "hasHtml": true, - "hasPdf": true, - "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", - "sourceKind": "generated-reading" - } + "p1-high-01": { + "examId": "p1-high-01", + "dataKey": "p1-high-01", + "script": "./p1-high-01.js", + "title": "A Brief History of Tea 茶叶简史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/1. P1 - A Brief History of Tea 茶叶简史【高】/", + "filename": "1. P1 - A Brief History of Tea 茶叶简史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/1. P1 - A Brief History of Tea 茶叶简史.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-02": { + "examId": "p1-low-02", + "dataKey": "p1-low-02", + "script": "./p1-low-02.js", + "title": "Maori Fish Hooks 毛利鱼钩", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/10. P1 - Maori Fish Hooks 毛利鱼钩/", + "filename": "10. P1 - Maori Fish Hooks 毛利鱼钩.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/10. P1 - Maori Fish Hooks 毛利鱼钩.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-03": { + "examId": "p3-high-03", + "dataKey": "p3-high-03", + "script": "./p3-high-03.js", + "title": "What makes a musical expert_ 音乐天赋", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/100. P3 - What makes a musical expert_ 音乐天赋【高】/", + "filename": "100. P3 - What makes a musical expert_ 音乐天赋【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/100. P3 - What makes a musical expert_ 音乐天赋.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-04": { + "examId": "p3-high-04", + "dataKey": "p3-high-04", + "script": "./p3-high-04.js", + "title": "Yawning 打呵欠", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/101. P3 - Yawning 打呵欠【高】/", + "filename": "101. P3 - Yawning 打呵欠【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/101. P3 - Yawning 打哈欠.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-05": { + "examId": "p1-high-05", + "dataKey": "p1-high-05", + "script": "./p1-high-05.js", + "title": "Katherine Mansfield 新西兰作家", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/102. P1 - Katherine Mansfield 新西兰作家【高】/", + "filename": "102. P1 - Katherine Mansfield 新西兰作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/102. P1 - Katherine Mansfield 新西兰作家.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-06": { + "examId": "p2-low-06", + "dataKey": "p2-low-06", + "script": "./p2-low-06.js", + "title": "Biomimicry 仿生学", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/103. P2 - Biomimicry 仿生学/", + "filename": "103. P2 - Biomimicry 仿生学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/103. P2 - Biomimicry 仿生学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-07": { + "examId": "p3-low-07", + "dataKey": "p3-low-07", + "script": "./p3-low-07.js", + "title": "Star Performers 明星员工", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/104. P3 - Star Performers 明星员工/", + "filename": "104. P3 - Star Performers 明星员工.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/104. P3 - Star Performers 明星员工.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-08": { + "examId": "p2-low-08", + "dataKey": "p2-low-08", + "script": "./p2-low-08.js", + "title": "How the Petri dish supports scientific advances 培养皿", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/105. P2 - How the Petri dish supports scientific advances 培养皿/", + "filename": "105. P2 - How the Petri dish supports scientific advances 培养皿.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/105. P2 - How the Petri dish supports scientific advances 培养皿.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-09": { + "examId": "p2-high-09", + "dataKey": "p2-high-09", + "script": "./p2-high-09.js", + "title": "Early Approaches to Organisational Design 组织设计", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/106. P2 - Early Approaches to Organisational Design 组织设计【高】/", + "filename": "106. P2 - Early Approaches to Organisational Design 组织设计【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/106. P2 - Early Approaches to Organisational Design 组织设计.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-10": { + "examId": "p2-medium-10", + "dataKey": "p2-medium-10", + "script": "./p2-medium-10.js", + "title": "A study of western celebrity 西方名人", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/107. P2 - A study of western celebrity 西方名人【次】/", + "filename": "107. P2 - A study of western celebrity 西方名人【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/107. P2 - A study of western celebrity 西方名人.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-11": { + "examId": "p1-low-11", + "dataKey": "p1-low-11", + "script": "./p1-low-11.js", + "title": "Bovids 牛科动物", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/108. P1 - Bovids 牛科动物/", + "filename": "108. P1 - Bovids 牛科动物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/108. P1 - Bovids 牛科动物.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-12": { + "examId": "p3-low-12", + "dataKey": "p3-low-12", + "script": "./p3-low-12.js", + "title": "Humanities and the health professional 人文医学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/109. P3 - Humanities and the health professional 人文医学/", + "filename": "109. P3 - Humanities and the health professional 人文医学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/109. P3 - Humanities and the health professional 人文医学.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-13": { + "examId": "p1-low-13", + "dataKey": "p1-low-13", + "script": "./p1-low-13.js", + "title": "Report on a university drama project 大学戏剧项目报告", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/11. P1 - Report on a university drama project 大学戏剧项目报告/", + "filename": "11. P1 - Report on a university drama project 大学戏剧项目报告.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/11. P1 - Report on a university drama project 大学戏剧项目报告.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-14": { + "examId": "p2-high-14", + "dataKey": "p2-high-14", + "script": "./p2-high-14.js", + "title": "Should space be explored by robots or by humans 人机太空探索", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/110. P2 - Should space be explored by robots or by humans 人机太空探索【高】/", + "filename": "110. P2 - Should space be explored by robots or by humans 人机太空探索【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/110. P2 - Should space be explored by robots or by humans 人机太空探索.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-15": { + "examId": "p3-high-15", + "dataKey": "p3-high-15", + "script": "./p3-high-15.js", + "title": "Whale Culture 鲸鱼文化", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/111. P3 - Whale Culture 鲸鱼文化【高】/", + "filename": "111. P3 - Whale Culture 鲸鱼文化【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/111. P3 - Whale Culture 鲸鱼文化.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-16": { + "examId": "p2-high-16", + "dataKey": "p2-high-16", + "script": "./p2-high-16.js", + "title": "The Importance of Law 法律的意义", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/112. P2 - The Importance of Law 法律的意义【高】/", + "filename": "112. P2 - The Importance of Law 法律的意义【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/112. P2 - The Importance of Law 法律的意义.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-17": { + "examId": "p2-high-17", + "dataKey": "p2-high-17", + "script": "./p2-high-17.js", + "title": "Herbal Medicines 新西兰草药", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/113. P2 - Herbal Medicines 新西兰草药【高】/", + "filename": "113. P2 - Herbal Medicines 新西兰草药【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/113. P2 - Herbal Medicines 新西兰草药.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-18": { + "examId": "p3-medium-18", + "dataKey": "p3-medium-18", + "script": "./p3-medium-18.js", + "title": "Unlocking the mystery of dreams 梦的解析", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/114. P3 - Unlocking the mystery of dreams 梦的解析【次】/", + "filename": "114. P3 - Unlocking the mystery of dreams 梦的解析【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/114. P3 - Unlocking the mystery of dreams 梦的解析.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-19": { + "examId": "p2-high-19", + "dataKey": "p2-high-19", + "script": "./p2-high-19.js", + "title": "Mind Music 脑海中的音乐(心灵音乐)", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】/", + "filename": "115. P2 - Mind Music 脑海中的音乐(心灵音乐)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/115. P2 - Mind Music 脑海中的音乐(心灵音乐).pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-20": { + "examId": "p1-medium-20", + "dataKey": "p1-medium-20", + "script": "./p1-medium-20.js", + "title": "The Development of Plastics 塑料的发展史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/116. P1 - The Development of Plastics 塑料的发展史【次】/", + "filename": "116. P1 - The Development of Plastics 塑料的发展史【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/116. P1 - The Development of Plastics 塑料的发展史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-21": { + "examId": "p2-high-21", + "dataKey": "p2-high-21", + "script": "./p2-high-21.js", + "title": "Stress Less 工作压力", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/117. P2 - Stress Less 工作压力【高】/", + "filename": "117. P2 - Stress Less 工作压力【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/117. P2 - Stress Less 工作压力.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-22": { + "examId": "p3-medium-22", + "dataKey": "p3-medium-22", + "script": "./p3-medium-22.js", + "title": "Neanderthal Technology 尼安德特人的生存技艺", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】/", + "filename": "118. P3 - Neanderthal Technology 尼安德特人的生存技艺【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/118. P3 - Neanderthal Technology 尼安德特人的生存技艺.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-23": { + "examId": "p2-high-23", + "dataKey": "p2-high-23", + "script": "./p2-high-23.js", + "title": "The Constant Evolution of the Humble Tomato 番茄的演化", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】/", + "filename": "119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/119. P2 - The Constant Evolution of the Humble Tomato 番茄的演化.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-24": { + "examId": "p1-high-24", + "dataKey": "p1-high-24", + "script": "./p1-high-24.js", + "title": "Rubber 橡胶", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/12. P1 - Rubber 橡胶【高】/", + "filename": "12. P1 - Rubber 橡胶【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/12. P1 - Rubber 橡胶.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-25": { + "examId": "p2-high-25", + "dataKey": "p2-high-25", + "script": "./p2-high-25.js", + "title": "Will Eating Less Make You Live Longer 节食与长寿", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】/", + "filename": "120. P2 - Will Eating Less Make You Live Longer 节食与长寿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/120. P2 - Will Eating Less Make You Live Longer 节食与长寿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-27": { + "examId": "p1-high-27", + "dataKey": "p1-high-27", + "script": "./p1-high-27.js", + "title": "Footprints in the Mud 恐龙脚印", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/122. P1 - Footprints in the Mud 恐龙脚印【高】/", + "filename": "122. P1 - Footprints in the Mud 恐龙脚印【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/122. P1 - Footprints in the Mud 恐龙脚印.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-28": { + "examId": "p3-low-28", + "dataKey": "p3-low-28", + "script": "./p3-low-28.js", + "title": "Images and Places 风景与印记", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/123. P3 - Images and Places 风景与印记/", + "filename": "123. P3 - Images and Places 风景与印记.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/123. P3 - Images and Places 风景与印记.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-29": { + "examId": "p1-medium-29", + "dataKey": "p1-medium-29", + "script": "./p1-medium-29.js", + "title": "The extinction of the cave bear 洞熊的灭绝", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/124. P1 - The extinction of the cave bear 洞熊的灭绝【次】/", + "filename": "124. P1 - The extinction of the cave bear 洞熊的灭绝【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/124. P1 - The extinction of the cave bear 洞熊的灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-30": { + "examId": "p1-low-30", + "dataKey": "p1-low-30", + "script": "./p1-low-30.js", + "title": "Investing in the Future 投资未来", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/125. P1 - Investing in the Future 投资未来/", + "filename": "125. P1 - Investing in the Future 投资未来.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/125. P1 - Investing in the Future 投资未来.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-31": { + "examId": "p1-high-31", + "dataKey": "p1-high-31", + "script": "./p1-high-31.js", + "title": "Dolls through the ages 玩偶的变迁史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/126. P1 - Dolls through the ages 玩偶的变迁史【高】/", + "filename": "126. P1 - Dolls through the ages 玩偶的变迁史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/126. P1 - Dolls through the ages 玩偶的变迁史.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-32": { + "examId": "p3-high-32", + "dataKey": "p3-high-32", + "script": "./p3-high-32.js", + "title": "Science and Filmmaking 电影科学(CGI)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/127. P3 - Science and Filmmaking 电影科学(CGI)【高】/", + "filename": "127. P3 - Science and Filmmaking 电影科学(CGI)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/127. P3 - Science and Filmmaking 电影科学(CGI).pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-33": { + "examId": "p1-medium-33", + "dataKey": "p1-medium-33", + "script": "./p1-medium-33.js", + "title": "The Pyramid of Cestius 罗马金字塔", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/128. P1 - The Pyramid of Cestius 罗马金字塔【次】/", + "filename": "128. P1 - The Pyramid of Cestius 罗马金字塔【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/128. P1 - The Pyramid of Cestius 罗马金字塔.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-34": { + "examId": "p1-low-34", + "dataKey": "p1-low-34", + "script": "./p1-low-34.js", + "title": "The Slow Food Organization 慢食运动组织", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/129. P1 - The Slow Food Organization 慢食运动组织/", + "filename": "129. P1 - The Slow Food Organization 慢食运动组织.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/129. P1 - The Slow Food Organization 慢食运动组织.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-35": { + "examId": "p1-low-35", + "dataKey": "p1-low-35", + "script": "./p1-low-35.js", + "title": "Sweet Trouble 澳洲制糖产业", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/13. P1 - Sweet Trouble 澳洲制糖产业/", + "filename": "13. P1 - Sweet Trouble 澳洲制糖产业.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/13. P1 - Sweet Trouble 澳洲制糖产业.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-36": { + "examId": "p3-low-36", + "dataKey": "p3-low-36", + "script": "./p3-low-36.js", + "title": "Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA/", + "filename": "130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/130. P3 - Tasmania’s Museum of Old and New Art 塔斯马尼亚古今艺术博物馆 MONA.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-37": { + "examId": "p2-low-37", + "dataKey": "p2-low-37", + "script": "./p2-low-37.js", + "title": "Keeping the water away 洪水防控", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/131. P2 - Keeping the water away 洪水防控/", + "filename": "131. P2 - Keeping the water away 洪水防控.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/131. P2 - Keeping the water away 洪水防控.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-38": { + "examId": "p3-low-38", + "dataKey": "p3-low-38", + "script": "./p3-low-38.js", + "title": "Research into the effects of different teaching styles 教学风格研究", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/132. P3 - Research into the effects of different teaching styles 教学风格研究/", + "filename": "132. P3 - Research into the effects of different teaching styles 教学风格研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/132. P3 - Research into the effects of different teaching styles 教学风格研究.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-39": { + "examId": "p2-low-39", + "dataKey": "p2-low-39", + "script": "./p2-low-39.js", + "title": "How to be Happy 如何获得幸福", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/133. P2 - How to be Happy 如何获得幸福/", + "filename": "133. P2 - How to be Happy 如何获得幸福.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/133. P2 - How to be Happy 如何获得幸福.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-40": { + "examId": "p1-low-40", + "dataKey": "p1-low-40", + "script": "./p1-low-40.js", + "title": "Dyes and fabric dyeing 染料的历史", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/134. P1 - Dyes and fabric dyeing 染料的历史/", + "filename": "134. P1 - Dyes and fabric dyeing 染料的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/134. P1 - Dyes and fabric dyeing 染料的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-41": { + "examId": "p2-low-41", + "dataKey": "p2-low-41", + "script": "./p2-low-41.js", + "title": "The Myth of the Eight-hour Sleep 八小时睡眠", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠/", + "filename": "135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/135. P2 - The Myth of the Eight-hour Sleep 八小时睡眠.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-42": { + "examId": "p3-low-42", + "dataKey": "p3-low-42", + "script": "./p3-low-42.js", + "title": "The peopling of Patagonia 巴塔哥尼亚的人类迁徙", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙/", + "filename": "136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/136. P3 - The peopling of Patagonia 巴塔哥尼亚的人类迁徙.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-43": { + "examId": "p3-low-43", + "dataKey": "p3-low-43", + "script": "./p3-low-43.js", + "title": "What is social history 社会史", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/137. P3 - What is social history 社会史/", + "filename": "137. P3 - What is social history 社会史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/137. P3 - What is social history 社会史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-44": { + "examId": "p3-low-44", + "dataKey": "p3-low-44", + "script": "./p3-low-44.js", + "title": "Conformity 从众心理", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/138. P3 - Conformity 从众心理/", + "filename": "138. P3 - Conformity 从众心理.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/138. P3 - Conformity 从众心理.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-45": { + "examId": "p1-low-45", + "dataKey": "p1-low-45", + "script": "./p1-low-45.js", + "title": "Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究/", + "filename": "139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/139. P1 - Sleep Study on Modern-Day Hunter-Gatherers Dispels Popular Notions 部落睡眠研究.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-46": { + "examId": "p1-low-46", + "dataKey": "p1-low-46", + "script": "./p1-low-46.js", + "title": "Sydney Opera House 悉尼歌剧院", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/14. P1 - Sydney Opera House 悉尼歌剧院/", + "filename": "14. P1 - Sydney Opera House 悉尼歌剧院.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/14. P1 - Sydney Opera House 悉尼歌剧院.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-47": { + "examId": "p1-low-47", + "dataKey": "p1-low-47", + "script": "./p1-low-47.js", + "title": "The Burgess Shale fossils 伯吉斯页岩", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/140. P1 - The Burgess Shale fossils 伯吉斯页岩/", + "filename": "140. P1 - The Burgess Shale fossils 伯吉斯页岩.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/140. P1 - The Burgess Shale fossils 伯吉斯页岩.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-48": { + "examId": "p1-low-48", + "dataKey": "p1-low-48", + "script": "./p1-low-48.js", + "title": "The history of the guitar 吉他的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/141. P1 - The history of the guitar 吉他的历史/", + "filename": "141. P1 - The history of the guitar 吉他的历史.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p2-low-49": { + "examId": "p2-low-49", + "dataKey": "p2-low-49", + "script": "./p2-low-49.js", + "title": "Born to Trade 交易的本能", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/142. P2 - Born to Trade 交易的本能/", + "filename": "142. P2 - Born to Trade 交易的本能.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/142. P2 - Born to Trade 交易的本能.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-50": { + "examId": "p2-low-50", + "dataKey": "p2-low-50", + "script": "./p2-low-50.js", + "title": "Jellyfish – The Dominant Species 水母·海洋中的优势物种", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种/", + "filename": "143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/143. P2 - Jellyfish – The Dominant Species 水母·海洋中的优势物种.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-51": { + "examId": "p2-low-51", + "dataKey": "p2-low-51", + "script": "./p2-low-51.js", + "title": "The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异/", + "filename": "144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/144. P2 - The gender gap in New Zealand’s high school examination results 新西兰考试成绩的性别差异.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-52": { + "examId": "p1-low-52", + "dataKey": "p1-low-52", + "script": "./p1-low-52.js", + "title": "Caral an ancient South American city 卡拉尔古城", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/145. P1 - Caral an ancient South American city 卡拉尔古城/", + "filename": "145. P1 - Caral an ancient South American city 卡拉尔古城.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/145. P1 - Caral an ancient South American city 卡拉尔古城.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-53": { + "examId": "p1-low-53", + "dataKey": "p1-low-53", + "script": "./p1-low-53.js", + "title": "The Early History of Olive Oil 橄榄油的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/146. P1 - The Early History of Olive Oil 橄榄油的历史/", + "filename": "146. P1 - The Early History of Olive Oil 橄榄油的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/146. P1 - The Early History of Olive Oil 橄榄油的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-54": { + "examId": "p3-low-54", + "dataKey": "p3-low-54", + "script": "./p3-low-54.js", + "title": "Movement Underwater 水下运动", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/147. P3 - Movement Underwater 水下运动/", + "filename": "147. P3 - Movement Underwater 水下运动.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/147. P3 - Movement Underwater 水下运动.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-55": { + "examId": "p3-low-55", + "dataKey": "p3-low-55", + "script": "./p3-low-55.js", + "title": "Improving Patient Safety 药品包装设计", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/148. P3 - Improving Patient Safety 药品包装设计/", + "filename": "148. P3 - Improving Patient Safety 药品包装设计.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/148. P3 - Improving Patient Safety 药品包装设计.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-56": { + "examId": "p3-low-56", + "dataKey": "p3-low-56", + "script": "./p3-low-56.js", + "title": "Learning to be bilingual 双语学习", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/149. P3 - Learning to be bilingual 双语学习/", + "filename": "149. P3 - Learning to be bilingual 双语学习.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/149. P3 - Learning to be bilingual 双语学习.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-57": { + "examId": "p1-medium-57", + "dataKey": "p1-medium-57", + "script": "./p1-medium-57.js", + "title": "The Blockbuster Phenomenon 博物馆爆款现象", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】/", + "filename": "15. P1 - The Blockbuster Phenomenon 博物馆爆款现象【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/15. P1 - The Blockbuster Phenomenon 博物馆爆款现象.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-58": { + "examId": "p2-medium-58", + "dataKey": "p2-medium-58", + "script": "./p2-medium-58.js", + "title": "Insect Decision-Making 昆虫决策", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/150. P2 - Insect Decision-Making 昆虫决策【次】/", + "filename": "150. P2 - Insect Decision-Making 昆虫决策【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/150. P2 - Insect Decision-Making 昆虫决策.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-59": { + "examId": "p3-low-59", + "dataKey": "p3-low-59", + "script": "./p3-low-59.js", + "title": "Inside the mind of a fan 观赛心境", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/151. P3 - Inside the mind of a fan 观赛心境/", + "filename": "151. P3 - Inside the mind of a fan 观赛心境.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/151. P3 - Inside the mind of a fan 观赛心境.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-60": { + "examId": "p1-medium-60", + "dataKey": "p1-medium-60", + "script": "./p1-medium-60.js", + "title": "Sorry—who are you 脸盲症", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/152. P1 - Sorry—who are you 脸盲症【次】/", + "filename": "152. P1 - Sorry—who are you 脸盲症【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/152. P1 - Sorry—who are you 脸盲症.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-61": { + "examId": "p1-low-61", + "dataKey": "p1-low-61", + "script": "./p1-low-61.js", + "title": "Carnivorous plants 食虫植物", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/153. P1 - Carnivorous plants 食虫植物/", + "filename": "153. P1 - Carnivorous plants 食虫植物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/153. P1 - Carnivorous plants 食虫植物.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-62": { + "examId": "p2-low-62", + "dataKey": "p2-low-62", + "script": "./p2-low-62.js", + "title": "The purpose of facial expressions 面部表情", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/154. P2 - The purpose of facial expressions 面部表情/", + "filename": "154. P2 - The purpose of facial expressions 面部表情.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/154. P2 - The purpose of facial expressions 面部表情.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-63": { + "examId": "p1-medium-63", + "dataKey": "p1-medium-63", + "script": "./p1-medium-63.js", + "title": "A Brief History of Humans and Food 人类食物的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/155. P1 - A Brief History of Humans and Food 人类食物的历史【次】/", + "filename": "155. P1 - A Brief History of Humans and Food 人类食物的历史【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/155. P1 - A Brief History of Humans and Food 人类食物的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-64": { + "examId": "p2-low-64", + "dataKey": "p2-low-64", + "script": "./p2-low-64.js", + "title": "New filter promises clean water for millions 新型泥土净水器", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/156. P2 - New filter promises clean water for millions 新型泥土净水器/", + "filename": "156. P2 - New filter promises clean water for millions 新型泥土净水器.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/156. P2 - New filter promises clean water for millions 新型泥土净水器.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-65": { + "examId": "p2-low-65", + "dataKey": "p2-low-65", + "script": "./p2-low-65.js", + "title": "Boring Buildings 无聊建筑", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/157. P2 - Boring Buildings 无聊建筑/", + "filename": "157. P2 - Boring Buildings 无聊建筑.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/157. P2 - Boring Buildings 无聊建筑.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-66": { + "examId": "p3-medium-66", + "dataKey": "p3-medium-66", + "script": "./p3-medium-66.js", + "title": "Mercator - The Map Maker 地理制图师", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/158. P3 - Mercator - The Map Maker 地理制图师【次】/", + "filename": "158. P3 - Mercator - The Map Maker 地理制图师【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/158. P3 - Mercator - The Map Maker 地理制图师.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-67": { + "examId": "p1-low-67", + "dataKey": "p1-low-67", + "script": "./p1-low-67.js", + "title": "Scented Plants 植物的味道", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/159. P1 - Scented Plants 植物的味道/", + "filename": "159. P1 - Scented Plants 植物的味道.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/159. P1 - Scented Plants 植物的味道.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-68": { + "examId": "p1-low-68", + "dataKey": "p1-low-68", + "script": "./p1-low-68.js", + "title": "The Clipper Races 帆船竞速", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/16. P1 - The Clipper Races 帆船竞速/", + "filename": "16. P1 - The Clipper Races 帆船竞速.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/16. P1 - The Clipper Races 帆船竞速.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-69": { + "examId": "p1-low-69", + "dataKey": "p1-low-69", + "script": "./p1-low-69.js", + "title": "An important language development 楔形文字", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/160. P1 - An important language development 楔形文字/", + "filename": "160. P1 - An important language development 楔形文字.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/160. P1 - An important language development 楔形文字.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-70": { + "examId": "p1-low-70", + "dataKey": "p1-low-70", + "script": "./p1-low-70.js", + "title": "Fluorescence Deep sea discovery深海发光生物研究", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/161. P1 - Fluorescence Deep sea discovery深海发光生物研究/", + "filename": "161. P1 - Fluorescence Deep sea discovery深海发光生物研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/161. P1 - Deep sea discovery 深海发光生物研究.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-71": { + "examId": "p3-low-71", + "dataKey": "p3-low-71", + "script": "./p3-low-71.js", + "title": "Sea Change for Salinity 土地盐碱化", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/162. P3 - Sea Change for Salinity 土地盐碱化/", + "filename": "162. P3 - Sea Change for Salinity 土地盐碱化.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/162. P3 - Sea Change for Salinity 土地盐碱化.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-72": { + "examId": "p1-low-72", + "dataKey": "p1-low-72", + "script": "./p1-low-72.js", + "title": "How to find your way out of a food desert 城市食物荒漠", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/163. P1 - How to find your way out of a food desert 城市食物荒漠/", + "filename": "163. P1 - How to find your way out of a food desert 城市食物荒漠.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/163. P1 - How to find your way out of a food desert 城市食物荒漠.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-73": { + "examId": "p2-low-73", + "dataKey": "p2-low-73", + "script": "./p2-low-73.js", + "title": "The Power of Smell 嗅觉的力量", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/164. P2 - The Power of Smell 嗅觉的力量/", + "filename": "164. P2 - The Power of Smell 嗅觉的力量.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/164. P2 - The Power of Smell 嗅觉的力量.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-74": { + "examId": "p3-low-74", + "dataKey": "p3-low-74", + "script": "./p3-low-74.js", + "title": "The Placebo Effect5 安慰剂效应", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/165. P3 - The Placebo Effect5 安慰剂效应/", + "filename": "165. P3 - The Placebo Effect5 安慰剂效应.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/165. P3 - The Placebo Effect5 安慰剂效应.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-75": { + "examId": "p2-low-75", + "dataKey": "p2-low-75", + "script": "./p2-low-75.js", + "title": "Lean Production Innovation 精益生产", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/166. P2 - Lean Production Innovation 精益生产/", + "filename": "166. P2 - Lean Production Innovation 精益生产.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/166. P2 - Lean Production Innovation 精益生产.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-76": { + "examId": "p3-low-76", + "dataKey": "p3-low-76", + "script": "./p3-low-76.js", + "title": "Sign, Baby, Sign! 美国手语", + "category": "P3", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/167. P3 - Sign, Baby, Sign! 美国手语/", + "filename": "167. P3 - Sign, Baby, Sign! 美国手语.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/167. P3 - Sign, Baby, Sign! 美国手语.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-77": { + "examId": "p2-low-77", + "dataKey": "p2-low-77", + "script": "./p2-low-77.js", + "title": "Mammoth Kill 猛犸象的灭绝", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/168. P2 - Mammoth Kill 猛犸象的灭绝/", + "filename": "168. P2 - Mammoth Kill 猛犸象的灭绝.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/168. P2 - Mammoth Kill 猛犸象的灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-78": { + "examId": "p3-low-78", + "dataKey": "p3-low-78", + "script": "./p3-low-78.js", + "title": "The Costs of Brand Loyalty 品牌忠诚的代价", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价/", + "filename": "169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/169. P3 - The Costs of Brand Loyalty 品牌忠诚的代价.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-79": { + "examId": "p1-high-79", + "dataKey": "p1-high-79", + "script": "./p1-high-79.js", + "title": "The Development of The Silk Industry 丝绸产业发展", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/17. P1 - The Development of The Silk Industry 丝绸产业发展【高】/", + "filename": "17. P1 - The Development of The Silk Industry 丝绸产业发展【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/17. P1 - The Development of The Silk Industry 丝绸产业发展.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-80": { + "examId": "p1-low-80", + "dataKey": "p1-low-80", + "script": "./p1-low-80.js", + "title": "The unsung sense 被低估的嗅觉", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/170. P1 - The unsung sense 被低估的嗅觉/", + "filename": "170. P1 - The unsung sense 被低估的嗅觉.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/170. P1 - The unsung sense 被低估的嗅觉.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-81": { + "examId": "p1-low-81", + "dataKey": "p1-low-81", + "script": "./p1-low-81.js", + "title": "Salt 盐的历史", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/171. P1 - Salt 盐的历史/", + "filename": "171. P1 - Salt 盐的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/171. P1 - Salt 盐的历史.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-82": { + "examId": "p1-high-82", + "dataKey": "p1-high-82", + "script": "./p1-high-82.js", + "title": "Think Small 微观科学", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/172. P1 - Think Small 微观科学【高】/", + "filename": "172. P1 - Think Small 微观科学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/172. P1 - Think Small 微观科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-83": { + "examId": "p3-low-83", + "dataKey": "p3-low-83", + "script": "./p3-low-83.js", + "title": "1018纸笔 Looking for inspiration 寻找灵感", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/173. 1018纸笔 P3 - Looking for inspiration 寻找灵感/", + "filename": "173. 1018纸笔 P3 - Looking for inspiration 寻找灵感.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/173. P3(1018纸笔 ) - Looking for inspiration 寻找灵感.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-84": { + "examId": "p1-low-84", + "dataKey": "p1-low-84", + "script": "./p1-low-84.js", + "title": "Why good ideas fail TF公司", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", + "filename": "174. P1 - Why good ideas fail TF公司.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/174. P1 - Why good ideas fail TF公司.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-85": { + "examId": "p3-low-85", + "dataKey": "p3-low-85", + "script": "./p3-low-85.js", + "title": "Music soothes and awes 音乐疗愈", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/175. P3 - Music soothes and awes 音乐疗愈/", + "filename": "175. P3 - Music soothes and awes 音乐疗愈.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/175. P3 - Music soothes and awes 音乐疗愈.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-86": { + "examId": "p2-medium-86", + "dataKey": "p2-medium-86", + "script": "./p2-medium-86.js", + "title": "Urban Regeneration 柏林公园改造", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/176. P2 - Urban Regeneration 柏林公园改造【次】/", + "filename": "176. P2 - Urban Regeneration 柏林公园改造.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/176. P2 - Urban Regeneration 柏林公园改造.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-87": { + "examId": "p2-low-87", + "dataKey": "p2-low-87", + "script": "./p2-low-87.js", + "title": "1025纸笔Speaking of Nothing [Pretest] 闲聊的意义", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义/", + "filename": "177. 1025纸笔P2 - Speaking of Nothing [Pretest] 闲聊的意义.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/177. P2(1025纸笔)[Pretest] - Speaking of Nothing 闲聊的意义.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-88": { + "examId": "p3-low-88", + "dataKey": "p3-low-88", + "script": "./p3-low-88.js", + "title": "1025纸笔Translating a key to international understanding 翻译的艺术", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术/", + "filename": "178. 1025纸笔P3 - Translating a key to international understanding 翻译的艺术.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/178. P3(1025纸笔)[Pretest] - Translating a key to international understanding 翻译的艺术.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-89": { + "examId": "p3-high-89", + "dataKey": "p3-high-89", + "script": "./p3-high-89.js", + "title": "Looking at daily life in ancient Rome 古罗马的日常", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/179. P3 - Looking at daily life in ancient Rome 古罗马的日常【高】/", + "filename": "179. P3 - Looking at daily life in ancient Rome 古罗马的日常.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/179. P3 - Looking at daily life in ancient Rome 古罗马的日常.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-90": { + "examId": "p1-high-90", + "dataKey": "p1-high-90", + "script": "./p1-high-90.js", + "title": "The History of Tea 茶叶的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/18. P1 - The History of Tea 茶叶的历史【高】/", + "filename": "18. P1 - The History of Tea 茶叶的历史【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/18. P1 - The History of Tea 茶叶的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-91": { + "examId": "p2-high-91", + "dataKey": "p2-high-91", + "script": "./p2-high-91.js", + "title": "Australia’s camouflaged creatures 澳洲伪装生物", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物【高】/", + "filename": "180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/180. P2 - Australia’s camouflaged creatures 澳洲伪装生物.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-92": { + "examId": "p1-high-92", + "dataKey": "p1-high-92", + "script": "./p1-high-92.js", + "title": "Dust and the American West 美国西部尘埃", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/181. P1 - Dust and the American West 美国西部尘埃【高】/", + "filename": "181. P1 - Dust and the American West 美国西部尘埃.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/181. P1 - Dust and the American West 美国西部尘埃.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-93": { + "examId": "p2-medium-93", + "dataKey": "p2-medium-93", + "script": "./p2-medium-93.js", + "title": "Antarctic research 南极考察", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/182. P2 - Antarctic research 南极考察【次】/", + "filename": "182. P2 - Antarctic research 南极考察.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/182. P2 - Antarctic research 南极考察.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-94": { + "examId": "p2-low-94", + "dataKey": "p2-low-94", + "script": "./p2-low-94.js", + "title": "The importance of being playful 玩耍的重要性", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/183. P2 - The importance of being playful 玩耍的重要性/", + "filename": "183. P2 - The importance of being playful 玩耍的重要性.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/183. P2 - The importance of being playful 玩耍的重要性.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-95": { + "examId": "p3-low-95", + "dataKey": "p3-low-95", + "script": "./p3-low-95.js", + "title": "The strange world of sight 奇异的视觉世界", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/184. P3 - The strange world of sight 奇异的视觉世界/", + "filename": "184. P3 - The strange world of sight 奇异的视觉世界.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/184. P3 - The strange world of sight 奇异的视觉世界.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-96": { + "examId": "p2-low-96", + "dataKey": "p2-low-96", + "script": "./p2-low-96.js", + "title": "[Pretest] Why Do We Need Sleep 睡眠的目的", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的/", + "filename": "185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/185. [Pretest] P2 - Why Do We Need Sleep 睡眠的目的.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-97": { + "examId": "p3-low-97", + "dataKey": "p3-low-97", + "script": "./p3-low-97.js", + "title": "Saving languages 拯救濒危语言", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/186. P3 - Saving languages 拯救濒危语言/", + "filename": "186. P3 - Saving languages 拯救濒危语言.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/186. P3 - Saving languages 拯救濒危语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-98": { + "examId": "p3-low-98", + "dataKey": "p3-low-98", + "script": "./p3-low-98.js", + "title": "Petrol power an eco-revolution 交通的革命", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/187. P3 - Petrol power an eco-revolution 交通的革命/", + "filename": "187. P3 - Petrol power an eco-revolution 交通的革命.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/187. P3 - Petrol power an eco-revolution 交通的革命.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-99": { + "examId": "p1-low-99", + "dataKey": "p1-low-99", + "script": "./p1-low-99.js", + "title": "The history of the bar code 条形码的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/188. P1 - The history of the bar code 条形码的历史/", + "filename": "188. P1 - The history of the bar code 条形码的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/188. P1 - The history of the bar code 条形码的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-100": { + "examId": "p3-low-100", + "dataKey": "p3-low-100", + "script": "./p3-low-100.js", + "title": "Mirror 镜子研究", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/189. P3 - Mirror 镜子研究/", + "filename": "189. P3 - Mirror 镜子研究.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/189. P3 - Mirror 镜子研究.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-101": { + "examId": "p1-high-101", + "dataKey": "p1-high-101", + "script": "./p1-high-101.js", + "title": "The Impact of the Potato 土豆的影响", + "category": "P1", + "frequency": "高频", + "difficultyScore": 1, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/19. P1 - The Impact of the Potato 土豆的影响【高】/", + "filename": "19. P1 - The Impact of the Potato 土豆的影响【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/19. P1 - The Impact of the Potato 土豆的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-102": { + "examId": "p2-low-102", + "dataKey": "p2-low-102", + "script": "./p2-low-102.js", + "title": "The power of music 音乐的力量", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/190. P2 - The power of music 音乐的力量/", + "filename": "190. P2 - The power of music 音乐的力量.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/190. P2 - The power of music 音乐的力量.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-103": { + "examId": "p2-low-103", + "dataKey": "p2-low-103", + "script": "./p2-low-103.js", + "title": "The economic effect of climate 气候对经济的影响", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/191. P2 - The economic effect of climate 气候对经济的影响/", + "filename": "191. P2 - The economic effect of climate 气候对经济的影响.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/191. P2 - The economic effect of climate 气候对经济的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-104": { + "examId": "p2-low-104", + "dataKey": "p2-low-104", + "script": "./p2-low-104.js", + "title": "1115纸笔Should we stop eating meat 是否应该吃素", + "category": "P2", + "frequency": "low", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素/", + "filename": "192. 1115纸笔P2 - Should we stop eating meat 是否应该吃素.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-105": { + "examId": "p1-high-105", + "dataKey": "p1-high-105", + "script": "./p1-high-105.js", + "title": "A survivor’s story 新西兰猫头鹰", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/2. P1 - A survivor’s story 新西兰猫头鹰【高】/", + "filename": "2. P1 - A survivor’s story 新西兰猫头鹰【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/2. P1 - A survivor’s story 新西兰猫头鹰.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-106": { + "examId": "p1-low-106", + "dataKey": "p1-low-106", + "script": "./p1-low-106.js", + "title": "The Importance of Business Cards 名片的重要性", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/20. P1 - The Importance of Business Cards 名片的重要性/", + "filename": "20. P1 - The Importance of Business Cards 名片的重要性.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/20. P1 - The Importance of Business Cards 名片的重要性.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-107": { + "examId": "p1-low-107", + "dataKey": "p1-low-107", + "script": "./p1-low-107.js", + "title": "The life of Beatrix Potter 彼得兔作家", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/21. P1 - The life of Beatrix Potter 彼得兔作家/", + "filename": "21. P1 - The life of Beatrix Potter 彼得兔作家.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/21. P1 - The life of Beatrix Potter 彼得兔作家.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-108": { + "examId": "p1-low-108", + "dataKey": "p1-low-108", + "script": "./p1-low-108.js", + "title": "The nature of Yawning 打哈欠的本质", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/22. P1 - The nature of Yawning 打哈欠的本质/", + "filename": "22. P1 - The nature of Yawning 打哈欠的本质.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/22. P1 - The nature of Yawning 打哈欠的本质.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-109": { + "examId": "p1-low-109", + "dataKey": "p1-low-109", + "script": "./p1-low-109.js", + "title": "The Origin of Paper 造纸术起源", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/23. P1 - The Origin of Paper 造纸术起源/", + "filename": "23. P1 - The Origin of Paper 造纸术起源.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/23. P1 - The Origin of Paper 造纸术起源.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-110": { + "examId": "p1-high-110", + "dataKey": "p1-high-110", + "script": "./p1-high-110.js", + "title": "The Pearls 珍珠", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/24. P1 - The Pearls 珍珠【高】/", + "filename": "24. P1 - The Pearls 珍珠【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/24. P1 - The Pearls 珍珠.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-111": { + "examId": "p1-low-111", + "dataKey": "p1-low-111", + "script": "./p1-low-111.js", + "title": "The Rise and Fall of Detective Stories 侦探小说的兴衰", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰/", + "filename": "25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/25. P1 - The Rise and Fall of Detective Stories 侦探小说的兴衰.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-112": { + "examId": "p1-low-112", + "dataKey": "p1-low-112", + "script": "./p1-low-112.js", + "title": "The Tuatara of New Zealand 新西兰蜥蜴", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴/", + "filename": "26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-113": { + "examId": "p1-low-113", + "dataKey": "p1-low-113", + "script": "./p1-low-113.js", + "title": "Thomas Young The last man who knew everything 托马斯·杨", + "category": "P1", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/27. P1 - Thomas Young The last man who knew everything 托马斯·杨/", + "filename": "27. P1 - Thomas Young The last man who knew everything 托马斯·杨.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/27. P1 - Thomas Young The last man who knew everything 托马斯·杨.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-114": { + "examId": "p1-low-114", + "dataKey": "p1-low-114", + "script": "./p1-low-114.js", + "title": "Triumph of the City 城市的胜利", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 1.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/28. P1 - Triumph of the City 城市的胜利/", + "filename": "28. P1 - Triumph of the City 城市的胜利.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/28. P1 - Triumph of the City 城市的胜利.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-115": { + "examId": "p1-medium-115", + "dataKey": "p1-medium-115", + "script": "./p1-medium-115.js", + "title": "Tunnelling under the Thames", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", + "filename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/29. P1 - Tunnelling under the Thames 泰晤士河隧道.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-116": { + "examId": "p1-low-116", + "dataKey": "p1-low-116", + "script": "./p1-low-116.js", + "title": "Advertising Needs Attention 广告的吸引力", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/3. P1 - Advertising Needs Attention 广告的吸引力/", + "filename": "3. P1 - Advertising Needs Attention 广告的吸引力.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/3. P1 - Advertising Needs Attention 广告的吸引力.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-117": { + "examId": "p1-medium-117", + "dataKey": "p1-medium-117", + "script": "./p1-medium-117.js", + "title": "What Lucy Taught Us 露西化石", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/30. P1 - What Lucy Taught Us 露西化石【次】/", + "filename": "30. P1 - What Lucy Taught Us 露西化石【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/30. P1 - What Lucy Taught Us 露西化石.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-118": { + "examId": "p1-high-118", + "dataKey": "p1-high-118", + "script": "./p1-high-118.js", + "title": "William Gilbert and Magnetism 电磁学之父", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/31. P1 - William Gilbert and Magnetism 电磁学之父【高】/", + "filename": "31. P1 - William Gilbert and Magnetism 电磁学之父【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/31. P1 - William Gilbert and Magnetism 电磁学之父.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-119": { + "examId": "p1-medium-119", + "dataKey": "p1-medium-119", + "script": "./p1-medium-119.js", + "title": "Wood 新西兰木材产业", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/32. P1 - Wood 新西兰木材产业【次】/", + "filename": "32. P1 - Wood 新西兰木材产业【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/32. P1 - Wood 新西兰木材产业.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-120": { + "examId": "p2-high-120", + "dataKey": "p2-high-120", + "script": "./p2-high-120.js", + "title": "A new look for Talbot Park 奥克兰社区改造", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/33. P2 - A new look for Talbot Park 奥克兰社区改造【高】/", + "filename": "ai_studio_code (9).html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/33. P2 - A new look for Talbot Park 奥克兰社区改造.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-121": { + "examId": "p2-medium-121", + "dataKey": "p2-medium-121", + "script": "./p2-medium-121.js", + "title": "A unique golden textile 蜘蛛丝", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/34. P2 - A unique golden textile 蜘蛛丝【次】/", + "filename": "34. P2 - A unique golden textile 蜘蛛丝【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/34. P2 - A unique golden textile 蜘蛛丝.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-122": { + "examId": "p2-low-122", + "dataKey": "p2-low-122", + "script": "./p2-low-122.js", + "title": "Biophilic Design 亲自然设计", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/35. P2 - Biophilic Design 亲自然设计/", + "filename": "35. P2 - Biophilic Design 亲自然设计.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/35. P2 - Biophilic Design 亲自然设计.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-123": { + "examId": "p2-high-123", + "dataKey": "p2-high-123", + "script": "./p2-high-123.js", + "title": "Bird Migration 鸟类迁徙", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/36. P2 - Bird Migration 鸟类迁徙【高】/", + "filename": "36. P2 - Bird Migration 鸟类迁徙【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/36. P2 - Bird Migration 鸟类迁徙.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-124": { + "examId": "p2-high-124", + "dataKey": "p2-high-124", + "script": "./p2-high-124.js", + "title": "Corporate Social Responsibility 企业社会责任", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/37. P2 - Corporate Social Responsibility 企业社会责任【高】/", + "filename": "37. P2 - Corporate Social Responsibility 企业社会责任【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/37. P2 - Corporate Social Responsibility 企业社会责任.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-125": { + "examId": "p2-low-125", + "dataKey": "p2-low-125", + "script": "./p2-low-125.js", + "title": "Egypt’s ancient boat-builders 古埃及造船", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/38. P2 - Egypt’s ancient boat-builders 古埃及造船/", + "filename": "38. P2 - Egypt’s ancient boat-builders 古埃及造船.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/38. P2 - Egypt’s ancient boat-builders 古埃及造船.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-126": { + "examId": "p2-medium-126", + "dataKey": "p2-medium-126", + "script": "./p2-medium-126.js", + "title": "How are deserts formed 沙漠成因", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/39. P2 - How are deserts formed 沙漠成因【次】/", + "filename": "39. P2 - How are deserts formed 沙漠成因【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/39. P2 - How are deserts formed 沙漠成因.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-127": { + "examId": "p1-low-127", + "dataKey": "p1-low-127", + "script": "./p1-low-127.js", + "title": "Ambergris 龙涎香", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/4. P1 - Ambergris 龙涎香/", + "filename": "4. P1 - Ambergris 龙涎香.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/4. P1 - Ambergris 龙涎香.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-128": { + "examId": "p2-high-128", + "dataKey": "p2-high-128", + "script": "./p2-high-128.js", + "title": "How Well Do We Concentrate_ 多任务处理", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/40. P2 - How Well Do We Concentrate_ 多任务处理【高】/", + "filename": "40. P2 - How Well Do We Concentrate_ 多任务处理【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/40. P2 - How Well Do We Concentrate_ 多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-129": { + "examId": "p2-medium-129", + "dataKey": "p2-medium-129", + "script": "./p2-medium-129.js", + "title": "Intelligent behaviour in birds 鸟类智慧行为", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】/", + "filename": "41. P2 - Intelligent behaviour in birds 鸟类智慧行为【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/41. P2 - Intelligent behaviour in birds 鸟类智慧行为.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-130": { + "examId": "p2-high-130", + "dataKey": "p2-high-130", + "script": "./p2-high-130.js", + "title": "Investment in shares versus investment in other assets 回报数据分析", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】/", + "filename": "42. P2 - Investment in shares versus investment in other assets 回报数据分析【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/42. P2 - Investment in shares versus investment in other assets 回报数据分析.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-131": { + "examId": "p2-high-131", + "dataKey": "p2-high-131", + "script": "./p2-high-131.js", + "title": "Learning from the Romans 罗马混凝土", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/43. P2 - Learning from the Romans 罗马混凝土【高】/", + "filename": "43. P2 - Learning from the Romans 罗马混凝土【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/43. P2 - Learning from the Romans 罗马混凝土.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-132": { + "examId": "p2-low-132", + "dataKey": "p2-low-132", + "script": "./p2-low-132.js", + "title": "Orientation of Birds 鸟类的定位能力", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/44. P2 - Orientation of Birds 鸟类的定位能力/", + "filename": "44. P2 - Orientation of Birds 鸟类的定位能力.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/44. P2 - Orientation of Birds 鸟类的定位能力.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-133": { + "examId": "p2-high-133", + "dataKey": "p2-high-133", + "script": "./p2-high-133.js", + "title": "Playing soccer 街头足球", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/45. P2 - Playing soccer 街头足球【高】/", + "filename": "45. P2 - Playing soccer 街头足球【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/45. P2 - Playing soccer 街头足球.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-134": { + "examId": "p2-high-134", + "dataKey": "p2-high-134", + "script": "./p2-high-134.js", + "title": "Roller coaster 过山车", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/46. P2 - Roller coaster 过山车【高】/", + "filename": "46. P2 - Roller coaster 过山车【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/46. P2 - Roller coaster 过山车.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-135": { + "examId": "p2-low-135", + "dataKey": "p2-low-135", + "script": "./p2-low-135.js", + "title": "Skyscraper Farming 摩天大楼种植", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/47. P2 - Skyscraper Farming 摩天大楼种植/", + "filename": "47. P2 - Skyscraper Farming 摩天大楼种植.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/47. P2 - Skyscraper Farming 摩天大楼种植.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-136": { + "examId": "p2-high-136", + "dataKey": "p2-high-136", + "script": "./p2-high-136.js", + "title": "Solving the problem of waste disposal 垃圾处理", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/48. P2 - Solving the problem of waste disposal 垃圾处理【高】/", + "filename": "48. P2 - Solving the problem of waste disposal 垃圾处理【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/48. P2 - Solving the problem of waste disposal 垃圾处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-137": { + "examId": "p2-high-137", + "dataKey": "p2-high-137", + "script": "./p2-high-137.js", + "title": "Surviving city life 动物适应城市", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/49. P2 - Surviving city life 动物适应城市【高】/", + "filename": "49. P2 - Surviving city life 动物适应城市【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/49. P2 - Surviving city life 动物适应城市.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-138": { + "examId": "p1-low-138", + "dataKey": "p1-low-138", + "script": "./p1-low-138.js", + "title": "Australian artist Margaret Preston 澳大利亚艺术家", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家/", + "filename": "5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/5. P1 - Australian artist Margaret Preston 澳大利亚艺术家.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-139": { + "examId": "p2-high-139", + "dataKey": "p2-high-139", + "script": "./p2-high-139.js", + "title": "The conquest of malaria in Italy 意大利疟疾防治", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】/", + "filename": "50. P2 - The conquest of malaria in Italy 意大利疟疾防治【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/50. P2 - The conquest of malaria in Italy 意大利疟疾防治.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-140": { + "examId": "p2-low-140", + "dataKey": "p2-low-140", + "script": "./p2-low-140.js", + "title": "The dingo debate 澳洲野犬", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/51. P2 - The dingo debate 澳洲野犬/", + "filename": "51. P2 - The dingo debate 澳洲野犬.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-141": { + "examId": "p2-high-141", + "dataKey": "p2-high-141", + "script": "./p2-high-141.js", + "title": "The fascinating world of attine ants 切叶蚁", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/52. P2 - The fascinating world of attine ants 切叶蚁【高】/", + "filename": "52. P2 - The fascinating world of attine ants 切叶蚁【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/52. P2 - The fascinating world of attine ants 切叶蚁.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-142": { + "examId": "p2-low-142", + "dataKey": "p2-low-142", + "script": "./p2-low-142.js", + "title": "The fashion industry 时尚产业", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/53. P2 - The fashion industry 时尚产业/", + "filename": "53. P2 - The fashion industry 时尚产业.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/53. P2 - The fashion industry 时尚产业.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-143": { + "examId": "p2-low-143", + "dataKey": "p2-low-143", + "script": "./p2-low-143.js", + "title": "The impact of invasive species 入侵物种的影响", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/54. P2 - The impact of invasive species 入侵物种的影响/", + "filename": "54. P2 - The impact of invasive species 入侵物种的影响.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/54. P2 - The impact of invasive species 入侵物种的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-144": { + "examId": "p2-medium-144", + "dataKey": "p2-medium-144", + "script": "./p2-medium-144.js", + "title": "The plan to bring an asteroid to Earth 捕获小行星", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】/", + "filename": "55. P2 - The plan to bring an asteroid to Earth 捕获小行星【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/55. P2 - The plan to bring an asteroid to Earth 捕获小行星.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-145": { + "examId": "p2-high-145", + "dataKey": "p2-high-145", + "script": "./p2-high-145.js", + "title": "The return of monkey life 猴群回归", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/56. P2 - The return of monkey life 猴群回归【高】/", + "filename": "56. P2 - The return of monkey life 猴群回归【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/56. P2 - The return of monkey life 猴群回归.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-146": { + "examId": "p2-medium-146", + "dataKey": "p2-medium-146", + "script": "./p2-medium-146.js", + "title": "The Tasmanian Tiger 袋狼", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/57. P2 - The Tasmanian Tiger 袋狼【次】/", + "filename": "57. P2 - The Tasmanian Tiger 袋狼【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/57. P2 - The Tasmanian Tiger 袋狼.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-147": { + "examId": "p2-low-147", + "dataKey": "p2-low-147", + "script": "./p2-low-147.js", + "title": "Who wrote Shakespeare's plays 莎士比亚", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/58. P2 - Who wrote Shakespeare's plays 莎士比亚/", + "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-148": { + "examId": "p2-low-148", + "dataKey": "p2-low-148", + "script": "./p2-low-148.js", + "title": "Why do we need the arts_ 艺术的意义", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/59. P2 - Why do we need the arts_ 艺术的意义/", + "filename": "59. P2 - Why do we need the arts_ 艺术的意义.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/59. P2 - Why do we need the arts_ 艺术的意义.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-149": { + "examId": "p1-low-149", + "dataKey": "p1-low-149", + "script": "./p1-low-149.js", + "title": "Categorizing societies 社会分类", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/6. P1 - Categorizing societies 社会分类/", + "filename": "6. P1 - Categorizing societies 社会分类html.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/6. P1 - Categorizing societies 社会分类.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-150": { + "examId": "p3-high-150", + "dataKey": "p3-high-150", + "script": "./p3-high-150.js", + "title": "A closer examination of a study on verbal and non-verbal messages 语言表达研究", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】/", + "filename": "60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/60. P3 - A closer examination of a study on verbal and non-verbal messages 语言表达研究.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-151": { + "examId": "p3-low-151", + "dataKey": "p3-low-151", + "script": "./p3-low-151.js", + "title": "Book Review The Discovery of Slowness 富兰克林(慢的发现)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现)/", + "filename": "61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/61. P3 - Book Review The Discovery of Slowness 富兰克林(慢的发现).pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-152": { + "examId": "p3-medium-152", + "dataKey": "p3-medium-152", + "script": "./p3-medium-152.js", + "title": "Charles Darwin and Evolutionary Psychology 进化心理学", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】/", + "filename": "62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/62. P3 - Charles Darwin and Evolutionary Psychology 进化心理学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-153": { + "examId": "p3-low-153", + "dataKey": "p3-low-153", + "script": "./p3-low-153.js", + "title": "Crossing the Threshold 奥克兰美术馆", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/63. P3 - Crossing the Threshold 奥克兰美术馆/", + "filename": "63. P3 - Crossing the Threshold 奥克兰美术馆.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/63. P3 - Crossing the Threshold 奥克兰美术馆.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-154": { + "examId": "p3-medium-154", + "dataKey": "p3-medium-154", + "script": "./p3-medium-154.js", + "title": "Decisions, Decisions 决策之间", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/64. P3 - Decisions, Decisions 决策之间【次】/", + "filename": "64. P3 - Decisions, Decisions 决策之间【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/64. P3 - Decisions, Decisions 决策之间.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-155": { + "examId": "p3-medium-155", + "dataKey": "p3-medium-155", + "script": "./p3-medium-155.js", + "title": "Does class size matter_ 课堂规模", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/65. P3 - Does class size matter_ 课堂规模【次】/", + "filename": "65. P3 - Does class size matter_ 课堂规模【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/65. P3 - Does class size matter 课堂规模.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-156": { + "examId": "p3-high-156", + "dataKey": "p3-high-156", + "script": "./p3-high-156.js", + "title": "Elephant Communication 大象交流", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/66. P3 - Elephant Communication 大象交流【高】/", + "filename": "66. P3 - Elephant Communication 大象交流【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/66. P3 - Elephant Communication 大象交流.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-157": { + "examId": "p3-high-157", + "dataKey": "p3-high-157", + "script": "./p3-high-157.js", + "title": "Flower Power 鲜花的力量(花之力)", + "category": "P3", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/67. P3 - Flower Power 鲜花的力量(花之力)【高】/", + "filename": "67. P3 - Flower Power 鲜花的力量(花之力)【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/67. P3 - Flower Power 鲜花的力量(花之力).pdf", + "sourceKind": "generated-reading" + }, + "p3-low-158": { + "examId": "p3-low-158", + "dataKey": "p3-low-158", + "script": "./p3-low-158.js", + "title": "Game theory 博弈论", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/68. P3 - Game theory 博弈论/", + "filename": "68. P3 - Game theory 博弈论.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/68. P3 - Game theory 博弈论.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-159": { + "examId": "p3-high-159", + "dataKey": "p3-high-159", + "script": "./p3-high-159.js", + "title": "Grimm’s Fairy Tales 格林童话", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/69. P3 - Grimm’s Fairy Tales 格林童话【高】/", + "filename": "69. P3 - Grimm’s Fairy Tales 格林童话【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/69. P3 - Grimm’s Fairy Tales 格林童话.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-160": { + "examId": "p1-low-160", + "dataKey": "p1-low-160", + "script": "./p1-low-160.js", + "title": "Chili peppers 辣椒的历史", + "category": "P1", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/7. P1 - Chili peppers 辣椒的历史/", + "filename": "7. P1 - Chili peppers 辣椒的历史.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/7. P1 - Chili peppers 辣椒的历史.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-161": { + "examId": "p3-high-161", + "dataKey": "p3-high-161", + "script": "./p3-high-161.js", + "title": "Insect-inspired robots 昆虫机器人", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/70. P3 - Insect-inspired robots 昆虫机器人【高】/", + "filename": "70. P3 - Insect-inspired robots 昆虫机器人【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/70. P3 - Insect-inspired robots 昆虫机器人.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-162": { + "examId": "p3-medium-162", + "dataKey": "p3-medium-162", + "script": "./p3-medium-162.js", + "title": "Jean Piaget (1896–1980) 让·皮亚杰", + "category": "P3", + "frequency": "low", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】/", + "filename": "71. P3 - Jean Piaget (1896–1980) 让·皮亚杰【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/71. P3 - Jean Piaget (1896–1980) 让·皮亚杰.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-163": { + "examId": "p3-low-163", + "dataKey": "p3-low-163", + "script": "./p3-low-163.js", + "title": "Keeping the Fun in Funfairs 游乐场设计科学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学/", + "filename": "72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/72. P3 - Keeping the Fun in Funfairs 游乐场设计科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-164": { + "examId": "p3-high-164", + "dataKey": "p3-high-164", + "script": "./p3-high-164.js", + "title": "Language Strategy in Multinational Companies 跨国公司语言策略", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】/", + "filename": "73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/73. P3 - Language Strategy in Multinational Companies 跨国公司语言策略.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-165": { + "examId": "p3-low-165", + "dataKey": "p3-low-165", + "script": "./p3-low-165.js", + "title": "Let’s teach them how to teach 教他们如何教学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/74. P3 - Let’s teach them how to teach 教他们如何教学/", + "filename": "74. P3 - Let’s teach them how to teach 教他们如何教学.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/74. P3 - Let’s teach them how to teach 教他们如何教学.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-166": { + "examId": "p3-low-166", + "dataKey": "p3-low-166", + "script": "./p3-low-166.js", + "title": "Life on Mars_ 火星地球化改造", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/75. P3 - Life on Mars_ 火星地球化改造/", + "filename": "75. P3 - Life on Mars_ 火星地球化改造.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/75. P3 - Life on Mars 火星地球化改造.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-167": { + "examId": "p3-high-167", + "dataKey": "p3-high-167", + "script": "./p3-high-167.js", + "title": "Living dunes 流动沙丘", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/76. P3 - Living dunes 流动沙丘【高】/", + "filename": "76. P3 - Living dunes 流动沙丘【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/76. P3 - Living dunes 流动沙丘.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-168": { + "examId": "p3-medium-168", + "dataKey": "p3-medium-168", + "script": "./p3-medium-168.js", + "title": "Marketing and the information age 信息时代营销", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/77. P3 - Marketing and the information age 信息时代营销【次】/", + "filename": "77. P3 - Marketing and the information age 信息时代营销【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/77. P3 - Marketing and the information age 信息时代营销.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-169": { + "examId": "p3-medium-169", + "dataKey": "p3-medium-169", + "script": "./p3-medium-169.js", + "title": "(无题目) Music Language We All Speak 音乐语言", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "睡着过项目组/1.11月高频文章[94篇+18背景]/P3 (29高+6次高)/2. P3次高频 (6篇)/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】/", + "filename": "78. P3 (仅原文无题) - Music Language We All Speak 音乐语言【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-170": { + "examId": "p3-high-170", + "dataKey": "p3-high-170", + "script": "./p3-high-170.js", + "title": "Pacific Navigation and Voyaging 太平洋航海", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】/", + "filename": "79. P3 - Pacific Navigation and Voyaging 太平洋航海【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/79. P3 - Pacific Navigation and Voyaging 太平洋航海.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-171": { + "examId": "p1-high-171", + "dataKey": "p1-high-171", + "script": "./p1-high-171.js", + "title": "Fishbourne Roman Palace 罗马宫殿", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/8. P1 - Fishbourne Roman Palace 罗马宫殿【高】/", + "filename": "8. P1 - Fishbourne Roman Palace 罗马宫殿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/8. P1 - Fishbourne Roman Palace 罗马宫殿.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-172": { + "examId": "p3-low-172", + "dataKey": "p3-low-172", + "script": "./p3-low-172.js", + "title": "Rebranding art museums 博物馆品牌重塑", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/80. P3 - Rebranding art museums 博物馆品牌重塑/", + "filename": "80. P3 - Rebranding art museums 博物馆品牌重塑.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/80. P3 - Rebranding art museums 博物馆品牌重塑.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-173": { + "examId": "p3-high-173", + "dataKey": "p3-high-173", + "script": "./p3-high-173.js", + "title": "Robert Louis Stevenson", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", + "filename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/81. P3 - Robert Louis Stevenson 苏格兰作家.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-174": { + "examId": "p3-high-174", + "dataKey": "p3-high-174", + "script": "./p3-high-174.js", + "title": "Some views on the use of headphones 耳机使用", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/82. P3 - Some views on the use of headphones 耳机使用【高】/", + "filename": "82. P3 - Some views on the use of headphones 耳机使用【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/82. P3 - Some views on the use of headphones 耳机使用.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-175": { + "examId": "p3-low-175", + "dataKey": "p3-low-175", + "script": "./p3-low-175.js", + "title": "Termite Mounds 白蚁丘", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/83. P3 - Termite Mounds 白蚁丘/", + "filename": "83. P3 - Termite Mounds 白蚁丘.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/83. P3 - Termite Mounds 白蚁丘.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-176": { + "examId": "p3-medium-176", + "dataKey": "p3-medium-176", + "script": "./p3-medium-176.js", + "title": "The Analysis of Fear 猴子恐惧实验", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/84. P3 - The Analysis of Fear 猴子恐惧实验【次】/", + "filename": "84. P3 - The Analysis of Fear 猴子恐惧实验【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/84. P3 - The Analysis of Fear 猴子恐惧实验.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-177": { + "examId": "p3-medium-177", + "dataKey": "p3-medium-177", + "script": "./p3-medium-177.js", + "title": "The Art of Deception 欺骗的艺术", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/85. P3 - The Art of Deception 欺骗的艺术【次】/", + "filename": "85. P3 - The Art of Deception 欺骗的艺术【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/85. P3 - The Art of Deception 欺骗的艺术.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-178": { + "examId": "p3-high-178", + "dataKey": "p3-high-178", + "script": "./p3-high-178.js", + "title": "The benefits of learning an instrument 学乐器的好处", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/86. P3 - The benefits of learning an instrument 学乐器的好处【高】/", + "filename": "86. P3 - The benefits of learning an instrument 学乐器的好处【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/86. P3 - The benefits of learning an instrument 学乐器的好处.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-179": { + "examId": "p3-medium-179", + "dataKey": "p3-medium-179", + "script": "./p3-medium-179.js", + "title": "The Exploration of Mars 火星探索", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/87. P3 - The Exploration of Mars 火星探索【次】/", + "filename": "87. P3 - The Exploration of Mars 火星探索【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/87. P3 - The Exploration of Mars 火星探索.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-180": { + "examId": "p3-high-180", + "dataKey": "p3-high-180", + "script": "./p3-high-180.js", + "title": "The fluoridation controversy 氟化水争议", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/88. P3 - The fluoridation controversy 氟化水争议【高】/", + "filename": "88. P3 - The fluoridation controversy 氟化水争议【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/88. P3 - The fluoridation controversy 氟化水争议.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-181": { + "examId": "p3-high-181", + "dataKey": "p3-high-181", + "script": "./p3-high-181.js", + "title": "The Fruit Book 果实之书", + "category": "P3", + "frequency": "高频", + "difficultyScore": 5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/89. P3 - The Fruit Book 果实之书【高】/", + "filename": "89. P3 - The Fruit Book 果实之书【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/89. P3 - The Fruit Book 果实之书.pdf", + "sourceKind": "generated-reading" + }, + "p1-medium-182": { + "examId": "p1-medium-182", + "dataKey": "p1-medium-182", + "script": "./p1-medium-182.js", + "title": "Listening to the Ocean 海洋探测", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/9. P1 - Listening to the Ocean 海洋探测【次】/", + "filename": "9. P1 - Listening to the Ocean 海洋探测【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/9. P1 - Listening to the Ocean 海洋探测.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-183": { + "examId": "p3-medium-183", + "dataKey": "p3-medium-183", + "script": "./p3-medium-183.js", + "title": "The hazards of multitasking 多任务处理", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/90. P3 - The hazards of multitasking 多任务处理【次】/", + "filename": "90. P3 - The hazards of multitasking 多任务处理【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/90. P3 - The hazards of multitasking 多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-184": { + "examId": "p3-high-184", + "dataKey": "p3-high-184", + "script": "./p3-high-184.js", + "title": "The New Zealand writer Margaret Mahy 新西兰女作家", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】/", + "filename": "91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/91. P3 - The New Zealand writer Margaret Mahy 新西兰女作家.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-185": { + "examId": "p3-medium-185", + "dataKey": "p3-medium-185", + "script": "./p3-medium-185.js", + "title": "The Pirahã people of Brazil 巴西皮拉罕部落语言", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】/", + "filename": "92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/92. P3 - The Pirahã people of Brazil 巴西皮拉罕部落语言.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-186": { + "examId": "p3-low-186", + "dataKey": "p3-low-186", + "script": "./p3-low-186.js", + "title": "The Robbers Cave Study (山洞)群体行为实验", + "category": "P3", + "frequency": "low", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/93. P3 - The Robbers Cave Study (山洞)群体行为实验/", + "filename": "93. P3 - The Robbers Cave Study (山洞)群体行为实验.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/93. P3 - The Robbers Cave Study (山洞)群体行为实验.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-187": { + "examId": "p3-low-187", + "dataKey": "p3-low-187", + "script": "./p3-low-187.js", + "title": "The science of sleep 睡眠的科学", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/94. P3 - The science of sleep 睡眠的科学/", + "filename": "94. P3 - The science of sleep 睡眠的科学.pdf.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/94. P3 - The science of sleep 睡眠的科学.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-188": { + "examId": "p3-medium-188", + "dataKey": "p3-medium-188", + "script": "./p3-medium-188.js", + "title": "The Significant Role of Mother Tongue in Education 母语教育", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】/", + "filename": "95. P3 - The Significant Role of Mother Tongue in Education 母语教育【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/95. P3 - The Significant Role of Mother Tongue in Education 母语教育.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-189": { + "examId": "p3-high-189", + "dataKey": "p3-high-189", + "script": "./p3-high-189.js", + "title": "The tuatara – past and future 新西兰蜥蜴", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/96. P3 - The tuatara – past and future 新西兰蜥蜴【高】/", + "filename": "96. P3 - The tuatara – past and future 新西兰蜥蜴【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/96. P3 - The tuatara – past and future 新西兰蜥蜴.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-190": { + "examId": "p3-low-190", + "dataKey": "p3-low-190", + "script": "./p3-low-190.js", + "title": "The value of literary prizes 文学奖项的价值", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/97. P3 - The value of literary prizes 文学奖项的价值/", + "filename": "97. P3 - The value of literary prizes 文学奖项的价值.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/97. P3 - The value of literary prizes 文学奖项的价值.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-191": { + "examId": "p3-medium-191", + "dataKey": "p3-medium-191", + "script": "./p3-medium-191.js", + "title": "Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】/", + "filename": "98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处【次】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/98. P3 - Video Games’ Unexpected Benefits to the Human Brain 电子游戏的好处.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-192": { + "examId": "p3-high-192", + "dataKey": "p3-high-192", + "script": "./p3-high-192.js", + "title": "Voynich Manuscript 伏尼契手稿", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "睡着过项目组/2. 所有文章(11.20)[192篇]/99. P3 - Voynich Manuscript 伏尼契手稿【高】/", + "filename": "99. P3 - Voynich Manuscript 伏尼契手稿【高】.html", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/99. P3 - Voynich Manuscript 伏尼契手稿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-200": { + "examId": "p1-high-200", + "dataKey": "p1-high-200", + "script": "./p1-high-200.js", + "title": "Australia’s Airborne Dentists 澳洲飞行牙医", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "三月/1.P1 高频/", + "filename": "200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/200. P1 - Australia’s Airborne Dentists 澳洲飞行牙医.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-211": { + "examId": "p1-high-211", + "dataKey": "p1-high-211", + "script": "./p1-high-211.js", + "title": "Ahead of its time 新西兰头骨", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "三月/1.P1 高频/", + "filename": "211. P1 - Ahead of its time 新西兰头骨【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/211. P1 - Ahead of its time 新西兰头骨.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-216": { + "examId": "p1-high-216", + "dataKey": "p1-high-216", + "script": "./p1-high-216.js", + "title": "Australia’s cane toad problem 澳洲蟾蜍", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "三月/1.P1 高频/", + "filename": "216. P1 - Australia’s cane toad problem 澳洲蟾蜍【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/216. P1 - Australia’s cane toad problem 澳洲蟾蜍.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-194": { + "examId": "p1-high-194", + "dataKey": "p1-high-194", + "script": "./p1-high-194.js", + "title": "The history of the British wool industry 英国羊毛产业的历史", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 2.5, + "path": "三月/2.P1 次高频/", + "filename": "194. P1 - The history of the British wool industry 英国羊毛产业的历史【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/194. P1 - The history of the British wool industry 英国羊毛产业的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-222": { + "examId": "p2-low-222", + "dataKey": "p2-low-222", + "script": "./p2-low-222.js", + "title": "Ideal Homes 理想居所", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "三月/", + "filename": "222. P2 - Ideal Homes 理想居所.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/222. P2 - Ideal Homes 理想居所.pdf", + "sourceKind": "generated-reading" + }, + "p1-low-223": { + "examId": "p1-low-223", + "dataKey": "p1-low-223", + "script": "./p1-low-223.js", + "title": "Effect and Cause 湖泊海啸研究", + "category": "P1", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "三月/", + "filename": "223. P1 - Effect and Cause 湖泊海啸研究.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/223. P1 - Effect and Cause 湖泊海啸研究.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-201": { + "examId": "p2-high-201", + "dataKey": "p2-high-201", + "script": "./p2-high-201.js", + "title": "Multi-tasking and the brain 大脑与多任务处理", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "三月/3.P2 高频/", + "filename": "201. P2 - Multi-tasking and the brain 大脑与多任务处理【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/201. P2 - Multi-tasking and the brain 大脑与多任务处理.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-217": { + "examId": "p2-medium-217", + "dataKey": "p2-medium-217", + "script": "./p2-medium-217.js", + "title": "A mechanical friend for children 孩子的机器人朋友", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/3.P2 高频/", + "filename": "217. P2 - A mechanical friend for children 孩子的机器人朋友【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/217. P2 - A mechanical friend for children 孩子的机器人朋友.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-192": { + "examId": "p2-high-192", + "dataKey": "p2-high-192", + "script": "./p2-high-192.js", + "title": "P2(1115纸笔) - Should we stop eating meat 是否应该吃素", + "category": "P2", + "frequency": "low", + "difficultyScore": 3.5, + "path": "三月/4.P2 次高频/", + "filename": "192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/192. P2(1115纸笔) - Should we stop eating meat 是否应该吃素.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-209": { + "examId": "p2-medium-209", + "dataKey": "p2-medium-209", + "script": "./p2-medium-209.js", + "title": "Decision Fatigue 决策疲劳", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "209. P2 - Decision Fatigue 决策疲劳【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/209. P2 - Decision Fatigue 决策疲劳.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-213": { + "examId": "p2-medium-213", + "dataKey": "p2-medium-213", + "script": "./p2-medium-213.js", + "title": "Growing more for less 卫星农业", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "三月/4.P2 次高频/", + "filename": "213. P2 - Growing more for less 卫星农业【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/213. P2 - Growing more for less 卫星农业.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-051": { + "examId": "p2-low-051", + "dataKey": "p2-low-051", + "script": "./p2-low-051.js", + "title": "The dingo debate 澳洲野犬_澳洲野狗", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "51. P2 - The dingo debate 澳洲野犬_澳洲野狗.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/51. P2 - The dingo debate 澳洲野犬_澳洲野狗.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-058": { + "examId": "p2-medium-058", + "dataKey": "p2-medium-058", + "script": "./p2-medium-058.js", + "title": "Who wrote Shakespeare's plays 莎士比亚", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "三月/4.P2 次高频/", + "filename": "58. P2 - Who wrote Shakespeare's plays 莎士比亚【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/58. P2 - Who wrote Shakespeare's plays 莎士比亚.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-204": { + "examId": "p3-high-204", + "dataKey": "p3-high-204", + "script": "./p3-high-204.js", + "title": "When people are ‘deaf’ to music 失乐症", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "204. P3 - When people are ‘deaf’ to music 失乐症【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/204. P3 - When people are ‘deaf’ to music 失乐症.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-206": { + "examId": "p3-high-206", + "dataKey": "p3-high-206", + "script": "./p3-high-206.js", + "title": "200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 亚洲风景展【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/206. P3 - 200 Years of Australian Landscapes at the Royal Academy in London 澳洲风景展.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-212": { + "examId": "p3-high-212", + "dataKey": "p3-high-212", + "script": "./p3-high-212.js", + "title": "Children’s literature studies today 儿童文学", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "212. P3 - Children’s literature studies today 儿童文学【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/212. P3 - Children’s literature studies today 儿童文学.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-218": { + "examId": "p3-high-218", + "dataKey": "p3-high-218", + "script": "./p3-high-218.js", + "title": "The Causes of Linguistic Change 语音的演变", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/5.P3 高频/", + "filename": "218. P3 - The Causes of Linguistic Change 语音的演变【高】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/218. P3 - The Causes of Linguistic Change 语音的演变.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-219": { + "examId": "p3-low-219", + "dataKey": "p3-low-219", + "script": "./p3-low-219.js", + "title": "The origin of language 语言的起源", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "219. P3 - The origin of language 语言的起源.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/219. P3 - The origin of language 语言的起源.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-999": { + "examId": "p3-low-999", + "dataKey": "p3-low-999", + "script": "./p3-low-999.js", + "title": "Risk taking", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4, + "path": "三月/5.P3 高频/", + "filename": "P3 - Risk taking.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-medium-197": { + "examId": "p3-medium-197", + "dataKey": "p3-medium-197", + "script": "./p3-medium-197.js", + "title": "Australia’s Megafauna Controversy 巨兽灭绝", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "三月/6.P3 次高频/", + "filename": "197. P3 - Australia’s Megafauna Controversy 巨兽灭绝【次】.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/197. P3 - Australia’s Megafauna Controversy 巨兽灭绝.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-198": { + "examId": "p3-low-198", + "dataKey": "p3-low-198", + "script": "./p3-low-198.js", + "title": "Child’s Play in Medieval England 中世纪的游戏", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "三月/6.P3 次高频/", + "filename": "198. P3 - Child’s Play in Medieval England 中世纪的游戏.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/198. P3 - Child’s Play in Medieval England 中世纪的游戏.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-078": { + "examId": "p3-low-078", + "dataKey": "p3-low-078", + "script": "./p3-low-078.js", + "title": "P3 (ds做出来的) - Music Language We All Speak 音乐语言", + "category": "P3", + "frequency": "low", + "difficultyScore": 4.5, + "path": "三月/6.P3 次高频/", + "filename": "78. P3 (ds做出来的) - Music Language We All Speak 音乐语言.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "ReadingPractice/PDF/78. P3 (仅原文无题) - Music Language We All Speak 音乐语言.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-227": { + "examId": "p1-high-227", + "dataKey": "p1-high-227", + "script": "./p1-high-227.js", + "title": "The Whale Goes to Court 鲸鱼油", + "category": "P1", + "frequency": "高频", + "difficultyScore": 3, + "path": "ReadingPractice/PDF/", + "filename": "227. P1 - The Whale Goes to Court 鲸鱼油.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/227. P1 - The Whale Goes to Court 鲸鱼油.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-225": { + "examId": "p2-high-225", + "dataKey": "p2-high-225", + "script": "./p2-high-225.js", + "title": "The problem of graffiti 涂鸦之困", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3.5, + "path": "ReadingPractice/PDF/", + "filename": "225. P2 - The problem of graffiti 涂鸦之困.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/225. P2 - The problem of graffiti 涂鸦之困.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-228": { + "examId": "p3-high-228", + "dataKey": "p3-high-228", + "script": "./p3-high-228.js", + "title": "On art and artists 艺术与艺术家", + "category": "P3", + "frequency": "高频", + "difficultyScore": 4.5, + "path": "ReadingPractice/PDF/", + "filename": "228. P3 - On art and artists 艺术与艺术家.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/228. P3 - On art and artists 艺术与艺术家.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-229": { + "examId": "p1-high-229", + "dataKey": "p1-high-229", + "script": "./p1-high-229.js", + "title": "New Understanding of Giraffes in the Wild 野生长颈鹿", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2.5, + "path": "ReadingPractice/PDF/", + "filename": "229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/229. P1 - New Understanding of Giraffes in the Wild 野生长颈鹿.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-230": { + "examId": "p1-high-230", + "dataKey": "p1-high-230", + "script": "./p1-high-230.js", + "title": "The History of the Pencil 铅笔的历史", + "category": "P1", + "frequency": "高频", + "difficultyScore": 1.5, + "path": "ReadingPractice/PDF/", + "filename": "230. P1 - The History of the Pencil 铅笔的历史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/230. P1 - The History of the Pencil 铅笔的历史.pdf", + "sourceKind": "generated-reading" + }, + "p1-high-231": { + "examId": "p1-high-231", + "dataKey": "p1-high-231", + "script": "./p1-high-231.js", + "title": "The History of the Pencil 铅笔的历史(流程图版)", + "category": "P1", + "frequency": "高频", + "difficultyScore": 2, + "path": "ReadingPractice/PDF/", + "filename": "231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/231. P1 - The History of the Pencil 铅笔的历史(流程图版).pdf", + "sourceKind": "generated-reading" + }, + "p2-high-232": { + "examId": "p2-high-232", + "dataKey": "p2-high-232", + "script": "./p2-high-232.js", + "title": "The origin and development of applause 掌声的历史", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "ReadingPractice/PDF/", + "filename": "232. P2 - The origin and development of applause 掌声的历史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/232. P2 - The origin and development of applause 掌声的历史.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-233": { + "examId": "p2-high-233", + "dataKey": "p2-high-233", + "script": "./p2-high-233.js", + "title": "Why don’t we sleep 失眠的原因", + "category": "P2", + "frequency": "高频", + "difficultyScore": 3, + "path": "ReadingPractice/PDF/", + "filename": "233. P2 - Why don’t we sleep 失眠的原因.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/233. P2 - Why don’t we sleep 失眠的原因.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-234": { + "examId": "p2-high-234", + "dataKey": "p2-high-234", + "script": "./p2-high-234.js", + "title": "How do plants talk to each other 植物交流", + "category": "P2", + "frequency": "高频", + "difficultyScore": 4, + "path": "ReadingPractice/PDF/", + "filename": "234. P2 - The Secret Language of Plants 植物交流.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/234. P2 - The Secret Language of Plants 植物交流.pdf", + "sourceKind": "generated-reading" + }, + "p3-high-221": { + "examId": "p3-high-221", + "dataKey": "p3-high-221", + "script": "./p3-high-221.js", + "title": "The Animal Connection 动物联结", + "category": "P3", + "frequency": "次高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "221. P3 - The Animal Connection 动物联结.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/221. P3 - The Animal Connection 动物联结.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-235": { + "examId": "p2-high-235", + "dataKey": "p2-high-235", + "script": "./p2-high-235.js", + "title": "The return of the black-footed ferret 黑足鼬", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "235. P2 - The return of the black-footed ferret 黑足鼬.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/235. P2 - The return of the black-footed ferret 黑足鼬.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-236": { + "examId": "p2-high-236", + "dataKey": "p2-high-236", + "script": "./p2-high-236.js", + "title": "War of the Plants 植物的战争", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "", + "filename": "", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-high-229": { + "examId": "p3-high-229", + "dataKey": "p3-high-229", + "script": "./p3-high-229.js", + "title": "All in the family 兄弟姐妹的影响", + "category": "P3", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "237. P3 - All in the family 兄弟姐妹的影响.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/237. P3 - All in the family 兄弟姐妹的影响.pdf", + "sourceKind": "generated-reading" + }, + "p2-high-239": { + "examId": "p2-high-239", + "dataKey": "p2-high-239", + "script": "./p2-high-239.js", + "title": "Nanotechnology: the science of the very small 纳米科技", + "category": "P2", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/239. P2 - Nanotechnology the science of the very small 纳米科技.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-240": { + "examId": "p2-low-240", + "dataKey": "p2-low-240", + "script": "./p2-low-240.js", + "title": "Coins - the first form of money 硬币起源", + "category": "P2", + "frequency": "次高频", + "difficultyScore": null, + "path": "assets/generated/reading-exams/", + "filename": "reading-practice-unified.html", + "hasHtml": true, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p1-high-240": { + "examId": "p1-high-240", + "dataKey": "p1-high-240", + "script": "./p1-high-240.js", + "title": "The Origins of Weather Forecasting 天气预报", + "category": "P1", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "240. P1 - The Origins of Weather Forecasting 天气预报.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/240. P1 - The Origins of Weather Forecasting 天气预报.pdf", + "sourceKind": "generated-reading" + }, + "p2-low-242": { + "examId": "p2-low-242", + "dataKey": "p2-low-242", + "script": "./p2-low-242.js", + "title": "Walking and shoes in eighteenth-century London 伦敦鞋子的发展史", + "category": "P2", + "frequency": "高频", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/242. P2 - Walking and shoes in eighteenth-century London 伦敦鞋子的发展史.pdf", + "sourceKind": "generated-reading" + }, + "p3-low-240": { + "examId": "p3-low-240", + "dataKey": "p3-low-240", + "script": "./p3-low-240.js", + "title": "How a prehistoric predator took to the skies 翼龙飞行", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - How a prehistoric predator took to the skies.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - How a prehistoric predator took to the skies.pdf", + "sourceKind": "generated-reading" + }, + "p3-medium-241": { + "examId": "p3-medium-241", + "dataKey": "p3-medium-241", + "script": "./p3-medium-241.js", + "title": "Who looks after the children in today's Britain? 育儿分工", + "category": "P3", + "frequency": "low", + "difficultyScore": null, + "path": "ReadingPractice/PDF/", + "filename": "P3 - Who looks after the children in today's Britain.pdf", + "hasHtml": true, + "hasPdf": true, + "pdfFilename": "ReadingPractice/PDF/P3 - Who looks after the children in today's Britain.pdf", + "sourceKind": "generated-reading" + }, + "p2-medium-243": { + "examId": "p2-medium-243", + "dataKey": "p2-medium-243", + "script": "./p2-medium-243.js", + "title": "The internal body clock", + "category": "P2", + "frequency": "次高频", + "difficultyScore": 3.5, + "path": "", + "filename": "", + "hasHtml": false, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + }, + "p3-medium-244": { + "examId": "p3-medium-244", + "dataKey": "p3-medium-244", + "script": "./p3-medium-244.js", + "title": "Look who was talking", + "category": "P3", + "frequency": "次高频", + "difficultyScore": 4, + "path": "", + "filename": "", + "hasHtml": false, + "hasPdf": false, + "pdfFilename": "", + "sourceKind": "generated-reading" + } + }; function clonePathRoot() { diff --git a/assets/generated/reading-exams/p1-high-05.js b/assets/generated/reading-exams/p1-high-05.js index 1a90b1ff..9fb4096f 100644 --- a/assets/generated/reading-exams/p1-high-05.js +++ b/assets/generated/reading-exams/p1-high-05.js @@ -20,7 +20,7 @@ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n \n

Katherine Mansfield

\n

Katherine Mansfield was a modernist writer of short fiction who was born and brought up in New Zealand.

\n \n

Katherine Mansfield Beauchamp Murry was born in 1888, into a prominent family in Wellington, New Zealand. She became one of New Zealand’s best-known writers, using the pen name of Katherine Mansfield. The daughter of a banker, and born into a middle-class family, she was also a first cousin of Countess Elizabeth von Arnim, a distinguished novelist in her time. Mansfield had two older sisters and a younger brother. Her father, Harold Beauchamp, went on to become the chairman of the Bank of New Zealand. In 1893, the Mansfield family moved to Karori, a suburb of Wellington, where Mansfield would spend the happiest years of her childhood; she later used her memories of this time as an inspiration for her Prelude story.

\n \n

Her first published stories appeared in the High School Reporter and the Wellington Girls’ High School magazine in 1898 and 1899. In 1902, she developed strong feelings for a musician who played the cello, Arnold Trowell, although her feelings were not, for the most part, returned. Mansfield herself was an accomplished cellist, having received lessons from Trowell’s father. Mansfield wrote in her journals of feeling isolated to some extent in New Zealand, and, in general terms, of her interest in the Maori people (New Zealand’s native people), who were often portrayed in a sympathetic light in her later stories, such as How Pearl Button Was Kidnapped.

\n\n

She moved to London in 1903, where she attended Queen’s College, along with her two sisters. Mansfield recommenced playing the cello, an occupation that she believed, during her time at Queen’s, she would take up professionally. She also began contributing to the college newspaper, with such a dedication to it that she eventually became its editor. She was particularly interested in the works of the French writers of this period and in the 19th-century British writer, Oscar Wilde, and she was appreciated amongst fellow students at Queen’s for her lively and charismatic approach to life and work. She met fellow writer Ida Baker, a South African, at the college, and the pair became lifelong friends. Mansfield did not actively support the suffragette movement in the UK. Women in New Zealand had gained the right to vote in 1893.

\n\n

Mansfield first began journeying into other parts of Europe in the period 1903–1906, mainly to Belgium and Germany. After finishing her schooling in England, she returned to her New Zealand home in 1906, only then beginning to write short stories in a serious way. She had several works published in Australia in a magazine called The Native Companion, which was her first paid writing work, and by this time she had her mind set on becoming a professional writer. It was also the first occasion on which she used the pseudonym “K. Mansfield”.

\n\n

Mansfield rapidly grew discontented with the provincial New Zealand lifestyle, and with her family. Two years later she headed again to London. Her father sent her an annual subsidy of £100 for the rest of her life. In later years, she would express both admiration and disdain for New Zealand in her journals.

\n\n

In 1911, Mansfield met John Middleton Murry, the Oxford scholar and editor of the literary magazine Rhythm. They were later to marry in 1918. Mansfield became a co-editor of Rhythm, which was subsequently called The Blue Review, in which more of her works were published. She and Murry lived in various houses in England and briefly in Paris. The Blue Review failed to gain enough readers and was no longer published. Their attempt to set up as writers in Paris was cut short by Murry’s bankruptcy, which resulted from the failure of this and other journals. Life back in England meant frequently changed addresses and very limited funds.

\n\n

Between 1915 and 1918, Mansfield moved between England and Bandol, France. She and Murry developed close contact with other well-known writers of the time such as D. H. Lawrence, Bertrand Russell and Aldous Huxley. By October 1918 Mansfield had become seriously ill; she had been diagnosed with tuberculosis and was advised to enter a sanatorium. She could no longer spend winters in London. In the autumn of 1918 she was so ill that she decided to go to Ospedaletti in Italy. It was the publication of Bliss and Other Stories in 1920 that was to solidify Mansfield’s reputation as a writer.

\n\n

Mansfield also spent time in Menton, France, as the tenant of her father’s cousin at “The Villa Isola Bella”. There she wrote eight stories including Miss Brill and The Daughters of the Late Colonel, the latter of which she pronounced to be “…the only story that satisfies me to any extent”.

\n\n

Mansfield produced a great deal of work in the final years of her life, and much of her prose and poetry remained unpublished at her death in 1923. After her death, her husband, Murry, took on the task of editing and publishing her works. His efforts resulted in two additional volumes of short stories, The Doves’ Nest and Something Childish, published in 1923 and 1924 respectively; the publication of her Poems; as well as a collection of critical writings (Novels and Novelists) and a number of editions of Mansfield’s previously unpublished letters and journals.

\n \n
\n \n \n
" + "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n \n

Katherine Mansfield

\n

Katherine Mansfield was a modernist writer of short fiction who was born and brought up in New Zealand.

\n \n

Katherine Mansfield Beauchamp Murry was born in 1888, into a prominent family in Wellington, New Zealand. She became one of New Zealand’s best-known writers, using the pen name of Katherine Mansfield. The daughter of a banker, and born into a middle-class family, she was also a first cousin of Countess Elizabeth von Arnim, a distinguished novelist in her time. Mansfield had two older sisters and a younger brother. Her father, Harold Beauchamp, went on to become the chairman of the Bank of New Zealand. In 1893 the Mansfield family moved to Karori, a suburb of Wellington, where Mansfield would spend the happiest years of her childhood; she later used her memories of this time as an inspiration for her Prelude story.

\n \n

Her first published stories appeared in the High School Reporter and the Wellington Girls’ High School magazine in 1898 and 1899. In 1902 she developed strong feelings for a musician who played the cello, Arnold Trowell, although her feelings were not, for the most part, returned. Mansfield herself was an accomplished cellist, having received lessons from Trowell’s father. Mansfield wrote in her journals of feeling isolated to some extent in New Zealand, and, in general terms, of her interest in the Maori people (New Zealand’s native people), who were often portrayed in a sympathetic light in her later stories, such as How Pearl Button Was Kidnapped.

\n\n

She moved to London in 1903, where she attended Queen’s College, along with her two sisters. Mansfield recommenced playing the cello, an occupation that she believed, during her time at Queen’s, she would take up professionally. She also began contributing to the college newspaper, with such a dedication to it that she eventually became its editor. She was particularly interested in the works of the French writers of this period and in the 19th-century British writer, Oscar Wilde, and she was appreciated amongst fellow students at Queen’s for her lively and charismatic approach to life and work. She met fellow writer Ida Baker, a South African, at the college, and the pair became lifelong friends. Mansfield did not become involved in much political activity when she lived in London. For example, although at that time in London many women were demonstrating for votes for women, Mansfield did not actively support the suffragette movement in the UK. Women in New Zealand had gained the right to vote in 1893.

\n\n

Mansfield first began journeying into other parts of Europe in the period 1903–1906, mainly to Belgium and Germany. After finishing her schooling in England, she returned to her New Zealand home in 1906, only then beginning to write short stories in a serious way. She had several works published in Australia in a magazine called Native Companion, which was her first paid writing work, and by this time she had her mind set on becoming a professional writer. It was also the first occasion on which she used the pseudonym ‘K. Mansfield’.

\n\n

Mansfield rapidly grew discontented with the provincial New Zealand lifestyle, and with her family. Two years later she headed again for London. Her father sent her an annual subsidy of £100 for the rest of her life. In later years, she would express both admiration and disdain for New Zealand in her journals.

\n\n

In 1911, Mansfield met John Middleton Murry, the Oxford scholar and editor of the literary magazine Rhythm. They were later to marry, in 1918. Mansfield became a co-editor of Rhythm, which was subsequently called The Blue Review, in which more of her works were published. She and Murry lived in various houses in England and briefly in Paris. The Blue Review failed to gain enough readers and was no longer published. Their attempt to set up as writers in Paris was cut short by Murry’s bankruptcy, which resulted from the failure of this and other journals. Life back in England meant frequently changed addresses and very limited funds.

\n\n

Between 1915 and 1918, Mansfield moved between England and Bandol, France. She and Murry developed close contact with other well-known writers of the time such as D.H. Lawrence, Bertrand Russell and Aldous Huxley. By October 1918 Mansfield had become seriously ill; she had been diagnosed with tuberculosis, and was advised to enter a sanatorium. She could no longer spend winters in London. In the autumn of 1918 she was so ill that she decided to go to Ospedaletti in Italy. It was the publication of Bliss and Other Stories in 1920 that was to solidify Mansfield’s reputation as a writer.

\n\n

Mansfield also spent time in Menton, France, as the tenant of her father’s cousin at ‘The Villa Isola Bella’. There she wrote eight stories including Miss Brill and The Daughters of the Late Colonel, the latter of which she pronounced to be ‘…the only story that satisfies me to any extent’.

\n\n

Mansfield produced a great deal of work in the final years of her life, and much of her prose and poetry remained unpublished at her death in 1923. After her death her husband, Murry, took on the task of editing and publishing her works. His efforts resulted in two additional volumes of short stories, The Dove’s Nest and Something Childish, published in 1923 and 1924 respectively, the publication of her Poems, as well as a collection of critical writings (Novels and Novelists) and a number of editions of Mansfield’s previously unpublished letters and journals.

\n \n
\n \n \n
" } ] }, @@ -36,7 +36,7 @@ "q5", "q6" ], - "bodyHtml": "
\n

Questions 1–6

\n

Do the following statements agree with the information given in Reading Passage 1?

\n

In boxes 1–6 on your answer sheet, write:

\n \n \n
\n

1. The name Katherine Mansfield, which appears on the writer’s books, was exactly the same as her original name.

\n
\n \n \n \n
\n
\n \n
\n

2. Mansfield won a prize for a story she wrote for the High School Reporter.

\n
\n \n \n \n
\n
\n \n
\n

3. How Pearl Button Was Kidnapped portrayed Maori people in a favourable way.

\n
\n \n \n \n
\n
\n \n
\n

4. When Mansfield was at Queen’s College, she planned to be a professional writer.

\n
\n \n \n \n
\n
\n \n
\n

5. Mansfield was unpopular with the other students at Queen’s College.

\n
\n \n \n \n
\n
\n \n
\n

6. In London, Mansfield showed little interest in politics.

\n
\n \n \n \n
\n
\n
", + "bodyHtml": "
\n

Questions 1–6

\n

Do the following statements agree with the information given in Reading Passage 1?

\n

In boxes 1–6 on your answer sheet, write:

\n \n \n
\n

1. The name Katherine Mansfield, that appears on the writer’s books, was exactly the same as her original name.

\n
\n \n \n \n
\n
\n \n
\n

2. Mansfield won a prize for a story she wrote for the High School Reporter.

\n
\n \n \n \n
\n
\n \n
\n

3. How Pearl Button Was Kidnapped portrayed Maori people in a favourable way.

\n
\n \n \n \n
\n
\n \n
\n

4. When Mansfield was at Queen’s College, she planned to be a professional writer.

\n
\n \n \n \n
\n
\n \n
\n

5. Mansfield was unpopular with the other students at Queen’s College.

\n
\n \n \n \n
\n
\n \n
\n

6. In London, Mansfield showed little interest in politics.

\n
\n \n \n \n
\n
\n
", "leadHtml": "

Questions

" }, { @@ -51,7 +51,7 @@ "q12", "q13" ], - "bodyHtml": "
\n

Questions 7–13

\n

Complete the notes below

\n

Choose ONE WORD AND/OR A NUMBER from the passage for each answer

\n

Write your answers in boxes 7–13 on your answer sheet

\n \n
\n

Katherine Mansfield’s adult years

\n


– moved from England back to New Zealand

\n

– first paid writing work was in a publication based in 8

\n

– her 9 and the New Zealand way of life made her feel dissatisfied

\n \n

• 1908
– returned to London

\n \n

• 1911–1919
– Met John Middleton Murry in 1911

\n

– 10 prevented Mansfield and Murry from staying together in Paris

\n

– spent time with distinguished 11

\n

– from 1916, tuberculosis restricted the time she spent in London

\n\n

• 1920
– her 12 was consolidated when Bliss and Other Stories was published

\n

– wrote several stories at “Villa Isola Bella”

\n\n

• 1923–1924
– Mansfield’s 13 published more of her works after her death

\n
\n
" + "bodyHtml": "
\n

Questions 7–13

\n

Complete the notes below

\n

Choose ONE WORD AND/OR A NUMBER from the passage for each answer

\n

Write your answers in boxes 7–13 on your answer sheet

\n \n
\n

Katherine Mansfield’s adult years

\n


– moved from England back to New Zealand

\n

– first paid writing work was in a publication based in 8

\n

– her 9 and the New Zealand way of life made her feel dissatisfied

\n \n

• 1908
– returned to London

\n \n

• 1911–1919
– met John Middleton Murry in 1911

\n

– 10 prevented Mansfield and Murry from staying longer in Paris

\n

– spent time with distinguished 11

\n

– from 1918, tuberculosis restricted the time she spent in London

\n\n

• 1920
– her 12 was consolidated when Bliss and Other Stories was published

\n

– wrote several stories at ‘The Villa Isola Bella’

\n\n

• 1923–1924
– Mansfield’s 13 published more of her works after her death

\n
\n
" } ], "answerKey": { @@ -60,7 +60,7 @@ "q3": "TRUE", "q4": "FALSE", "q5": "FALSE", - "q6": "NOT GIVEN", + "q6": "TRUE", "q7": "1906", "q8": "Australia", "q9": "family", diff --git a/assets/generated/reading-exams/p1-low-84.js b/assets/generated/reading-exams/p1-low-84.js index 60c025f0..976ba586 100644 --- a/assets/generated/reading-exams/p1-low-84.js +++ b/assets/generated/reading-exams/p1-low-84.js @@ -9,7 +9,7 @@ "meta": { "title": "Why good ideas fail TF公司", "category": "P1", - "frequency": "low", + "frequency": "次高频", "pdfFilename": "174. P1 - Why good ideas fail TF公司.pdf", "legacyPath": "睡着过项目组/2. 所有文章(11.20)[192篇]/174. P1 - Why good ideas fail TF公司/", "legacyFilename": "174. P1 - Why good ideas fail TF公司.html", diff --git a/assets/generated/reading-exams/p1-medium-115.js b/assets/generated/reading-exams/p1-medium-115.js index 8da9bd38..2e03ffdf 100644 --- a/assets/generated/reading-exams/p1-medium-115.js +++ b/assets/generated/reading-exams/p1-medium-115.js @@ -7,20 +7,20 @@ "schemaVersion": "ReadingExamSourceV1", "examId": "p1-medium-115", "meta": { - "title": "Tunnelling under the Thames 泰晤士河隧道", + "title": "Tunnelling under the Thames", "category": "P1", "frequency": "medium", "pdfFilename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.pdf", "legacyPath": "睡着过项目组/2. 所有文章(11.20)[192篇]/29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】/", "legacyFilename": "29. P1 - Tunnelling under the Thames 泰晤士河隧道【次】.html", - "questionIntroHtml": "

Questions 1–8

" + "questionIntroHtml": "

Questions 1–13

" }, "passage": { "blocks": [ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n \n

Tunnelling under the Thames

\n \n

The first tunnel ever to be built under a major river was the tunnel under London's River Thames.

\n

At the beginning of the 19th century, the port of London was the busiest in the world. Cargoes that had travelled thousands of miles and survived all the hazards of the sea were unloaded on the banks of the Thames, only for their owners to discover that the most frustrating portion of their journey lay ahead. Consignments intended for the southern parts of Britain had to be lifted onto horse carts, pulled through the docks and across London Bridge, built in the 12th century and as impractical as its early date implies. By 1820, London Bridge had become the centre of the world's largest traffic jam.

\n

It was an intolerable situation, and it was clear that if private enterprise could build another crossing closer to the docks, there would be good money to be made in tolls paid by users. Another bridge was out of the question, as this would deny sailing ships access to the city centre, and ambitious men turned their thoughts to tunnelling beneath the Thames instead. This was not such an obvious idea as it might appear. Although increasing demand for coal had meant a great many tunnels had been dug in mines in Britain, working methods remained primitive; tunnels were dug by men with simple tools, by candlelight.

\n

However, in 1807 a group of businessmen set themselves up as the Thames Archway Company. Their ambition was to tunnel below the Thames, but there was little to guide them as there had been no previous attempt to do this. Their chief engineer was Richard Trevithick, designer of the world's first high-pressure steam engine. His men made progress at the beginning, but then things began to go disastrously wrong, with muddy soil pouring into the tunnel. Eventually, the Thames Archway Company had had enough. Its funds were exhausted, Trevithick was sick from exposure to the river water, and its efforts had proved only that a passage under the river exceeded the limits of contemporary mining technology.

\n

At that time, the only machines used in mines were pumps. It took a man of genius to recognise that a different sort of machine was needed—a machine that could prevent the roof and walls of a tunnel from collapsing. This man was Marc Brunel, a Frenchman who had become one of the most prominent engineers in Britain. Not long after the failure of the Thames Archway Company, Brunel saw a rotten piece of wood lying on the river bank. Examining the wood through a magnifying glass, he observed it was infested with creatures that looked like worms. Brunel realised that, as they tunnelled through the wood, they pushed chewed fibres into their mouths, digested them, then excreted a hard substance that lined the new tunnel. He saw that the worm's digging technique could be adapted to a new way of tunnelling.

\n

His insight led him to invent a device that has been used, in one form or another, in most major tunnels built since—the tunnelling shield. It consisted of a heavy iron frame that could be pushed forward a few inches at a time. The front of the frame was made up of a series of iron plates that could be folded back to allow miners to dig the ground ahead. Behind these plates was a wall of further iron plates pressed against the tunnel face and supported on horizontal wooden planks to prevent collapse. It was a complex and rather cumbersome machine and not easy to use, but it promised to protect the miners from the worst of the river's water. Brunel's team carefully examined earth samples taken from beneath the riverbed and decided to dig the tunnel close to the muddy river bottom, where they expected to find clay—a more solid and safer substance to dig through than the sand found deeper down.

\n

Brunel began work on his tunnel in 1825, but the problems of such an operation soon became apparent. Although the shield itself worked well, water began to drip into the tunnel. This was more of an annoyance than a danger while the pump was working, but the machine proved unreliable and sometimes failed altogether. When the pump broke down, work had to stop as the tunnel quickly flooded. There were occasions when the miners had to abandon their tools and flee for their lives. Even when Brunel's men were able to work, they faced the constant risk of the pumps failing. They also complained of frequent headaches, caused by poor air quality. The air underground was dirty and stale, contaminated because of the lack of an adequate ventilation system. There were lighting problems too. Illuminating the tunnels by candlelight was a constant challenge: lamps gave off only a very weak glow, and there were a number of accidents because the miners could not see what they were doing. Lastly, several of Brunel's miners walked off the job because they could not tolerate the excessive temperatures that developed in the cramped underground conditions.

\n

Despite all these setbacks, the tunnel finally emerged on the opposite river bank on 12 August 1841. Brunel's triumph, however, was only partial. The small payment per person made by the thousands of visitors who flocked to see the marvel hardly covered even a penny per foot of the tunnel's construction costs. Brunel had gone bankrupt long before the project was completed, and the government loan he had required to finish the work had to be paid back with interest. As a result, there was not enough funding to make the tunnel accessible to horse-drawn vehicles, as intended. Instead, the passageways were filled with souvenir sellers and entertainers. In the end, the tunnel was closed two years later and was used only at night before it was finally closed entirely and fell into dereliction for decades.

\n

It was only when the underground railway came to London in the 1880s that the Thames Tunnel achieved real usefulness. It was bought in 1869 by the East London Railway, which found it to be in such excellent condition that it was immediately pressed into service as a route for passenger trains heading east. The tunnel became, and remains, part of the London Underground network.

\n\n
\n \n \n
" + "html": "

READING PASSAGE 1

\n

You should spend about 20 minutes on Questions 1–13, which are based on Reading Passage 1 below.

\n

Tunnelling under the Thames

\n

The first tunnel ever to be built under a major river was the tunnel under London’s River Thames

\n

At the beginning of the 19th century, the port of London was the busiest in the world. Cargoes that had travelled thousands of miles and survived all the hazards of the sea were unloaded on the banks of the Thames, only for their owners to discover that the most frustrating portion of their journey lay ahead. Consignments intended for the southern parts of Britain had to be lifted onto horse carts, pulled through the docks and across London Bridge, built in the 12th century and as impractical as its early date implies. By 1820, London Bridge had become the centre of the world’s largest traffic jam.

\n

It was an intolerable situation, and it was clear that if private enterprise could build another crossing closer to the docks, there would be good money to be made in tolls paid by users. Another bridge was out of the question, as this would deny sailing ships access to the city centre, and ambitious men turned their thoughts to tunnelling beneath the Thames instead. This was not such an obvious idea as it might appear. Although increasing demand for coal had meant a great many tunnels had been dug in mines in Britain, working methods remained primitive. Tunnels were dug by men with simple tools, by candlelight. However, in 1807, a group of businessmen got themselves up as the Thames Archway Company. Their ambition was to tunnel below the Thames, but there was little to guide them as there had been no previous attempt to do this. Their chief engineer was Richard Trevithick, designer of the world’s first high-pressure steam engine. His men made progress at the beginning, but then things began to go disastrously wrong, with muddy soil pouring into the tunnel. Eventually the Thames Archway Company had had enough. Its funds were exhausted, Trevithick was sick from exposure to the river water, and its efforts had proved only that a passage under the river exceeded the limits of contemporary mining technology.

\n

At that time, the only machines used in mines were pumps. It took a man of genius to recognise that a different sort of machine was needed, a machine that could prevent the roof and walls of a tunnel from collapsing. This man was Marc Brunel, a Frenchman who had become one of the most prominent engineers in Britain. Not long after the failure of the Thames Archway Company, Brunel saw a rotten piece of wood lying on the river bank. Examining the wood through a magnifying glass, he observed it was infested with something that looked like a worm. Brunel realised that as it tunnelled through the wood, it would push chewed wood into its mouth and digest it, then excrete a hard substance that lined the new tunnel. Brunel realised that the worm’s digging technique could be adapted to produce a new way of tunnelling. His realisation led him to invent a device that has been used in one form or another in most major tunnels built since: the tunnelling shield. It consisted of a series of iron frames that could be pressed against the tunnel face and supported on a set of horizontal wooden planks that would prevent the face from collapsing. It was a complex machine and not easy to use, but it seemed that it would protect the miners. Brunel’s team examined earth samples taken from beneath the riverbed, and subsequently decided to dig the tunnel close to the muddy river bottom, where he could expect to find clay. This would be a more solid and safe substance to dig through than the sand that was found deeper down.

\n

Brunel began work on his tunnel in 1825, but the problems of such an operation soon became apparent. Although the shield itself worked well, water began to drip into the tunnel. This was more of an annoyance than a danger while the pump was working, but this machine proved unreliable and sometimes failed altogether, meaning that floods began to occur with increasing regularity. The miners often had to run for their lives. Even when Brunel’s men were able to work, they complained of frequent headaches, because the air underground was dirty and contaminated due to the lack of an adequate ventilation system. There were other problems too. Illuminating the tunnels was a constant challenge. Lamps gave off only a weak glow, and there were a number of accidents because the miners could not see what they were doing. Lastly, a number of Brunel’s miners walked off the job because they could not tolerate the excessive temperatures that developed in the cramped conditions underground.

\n

Despite all the setbacks, the tunnel finally emerged on the opposite river bank on August 12, 1841. Brunel’s triumph, however, was only partial. The small payment of a penny per person made by the thousands of visitors hardly paid the interest on the government loan he had required to complete the project. As a result, there was never enough funding to make it accessible to horse-drawn vehicles, as intended. Instead, the passageways were filled with souvenir-sellers by day and the homeless at night, before it was finally closed.

\n

It was only when the underground railway came to London in the 1860s that the Thames Tunnel achieved a measure of real usefulness. It was bought in 1869 by the East London Railway, who found it to be in such excellent condition that it was immediately pressed into service as a route for passenger trains heading east. The tunnel became, and remains, part of the London Underground network.

" } ] }, @@ -38,8 +38,8 @@ "q7", "q8" ], - "bodyHtml": "
\n

Do the following statements agree with the information in Reading Passage 1?

\n

In boxes 1–8 on your answer sheet, write:

\n \n \n
\n

1 In the early 19th century, the port of London was considered a safer destination than other ports.

\n
\n \n \n \n
\n
\n
\n

2 London Bridge provided quick access for cargo being sent to southern Britain.

\n
\n \n \n \n
\n
\n
\n

3 It was generally believed that a new river crossing would be profitable.

\n
\n \n \n \n
\n
\n
\n

4 Building a second bridge crossing was initially considered to be the best solution.

\n
\n \n \n \n
\n
\n
\n

5 It was believed that coal could be found under the River Thames.

\n
\n \n \n \n
\n
\n
\n

6 The Thames Archway Company was the first group to try tunnelling below the Thames.

\n
\n \n \n \n
\n
\n
\n

7 Some of Trevithick's men were injured during a mudslide at his tunnel.

\n
\n \n \n \n
\n
\n
\n

8 The Thames Archway Company ran out of money to finance the tunnel project.

\n
\n \n \n \n
\n
\n
", - "leadHtml": "

Questions 1–8

" + "bodyHtml": "
\n

Do the following statements agree with the information in Reading Passage 1?

\n

In boxes 1–7 on your answer sheet, write

\n \n \n
\n

1 In the early 19th century, the port of London was considered a safer destination than other ports.

\n
\n \n \n \n
\n
\n
\n

2 London Bridge provided quick access for cargo being sent to southern Britain.

\n
\n \n \n \n
\n
\n
\n

3 It was generally believed that a new river crossing would be profitable.

\n
\n \n \n \n
\n
\n
\n

4 Building a second bridge crossing was initially considered to be the best solution.

\n
\n \n \n \n
\n
\n
\n

5 It was believed that coal could be found under the River Thames.

\n
\n \n \n \n
\n
\n
\n

6 The Thames Archway Company were the first group to try tunnelling below the Thames.

\n
\n \n \n \n
\n
\n
\n

7 Some of Trevithick’s men were injured during a mudslide at his tunnel.

\n
\n \n \n \n
\n
\n
\n

8 The Thames Archway Company ran out of money to finance the tunnel project.

\n
\n \n \n \n
\n
\n
", + "leadHtml": "

Questions 1–7

" }, { "groupId": "group-2", @@ -51,7 +51,7 @@ "q12", "q13" ], - "bodyHtml": "
\n

Complete the notes below.

\n

Choose ONE WORD ONLY from the passage for each answer.

\n \n
\n

Marc Brunel's tunnel

\n

Preparing to build the tunnel

\n \n

Problems faced by miners

\n \n

After the tunnel was finished

\n \n
\n
" + "bodyHtml": "
\n

Questions 9–13

\n

Complete the notes below.

\n

Choose ONE WORD ONLY from the passage for each answer.

\n

Write your answers in boxes 9–13 on your answer sheet.

\n \n
\n

Marc Brunel’s tunnel

\n

Preparing to build the tunnel

\n \n

Problems faced by miners

\n \n

After the tunnel was finished

\n \n
\n
" } ], "answerKey": { @@ -77,8 +77,8 @@ "audit": { "matchStatus": "matched", "matchConfidence": 1, - "verifiedAt": "2026-03-08T16:24:30.706Z", - "notes": "signature:radio,text,textarea,dragdrop,table" + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the source DOCX passage, question text, and embedded answer-key images." }, "questionOrder": [ "q1", diff --git a/assets/generated/reading-exams/p2-low-142.js b/assets/generated/reading-exams/p2-low-142.js index e7a1ffbb..42bdaef3 100644 --- a/assets/generated/reading-exams/p2-low-142.js +++ b/assets/generated/reading-exams/p2-low-142.js @@ -20,7 +20,7 @@ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 2

\n

You should spend about 20 minutes on Questions 14–26, which are based on Reading Passage 2 below.

\n \n

The fashion industry

\n \n

A The fashion industry is a multibillion-dollar global enterprise devoted to the business of making and selling clothes. It encompasses all types of garments, from designer fashions to ordinary everyday clothing. Because data on the industry are typically reported for national economies and expressed in terms of its many separate sectors, total figures for world production of textiles and clothing are difficult to obtain. However, by any measure, the industry accounts for a significant share of world economic output.

\n

B The fashion industry is a product of the modern age. Prior to the mid-19th century, virtually all clothing was handmade for individuals, either as home production or on order from dressmakers and tailors. By the beginning of the 20th century, with the development of new technologies such as the sewing machine, the factory system of production, and the growth of department stores and other retail outlets, clothing had increasingly come to be mass-produced in standard sizes and sold at fixed prices. Although the fashion industry developed first in Europe, today it is highly globalised, with garments often designed in one country, manufactured in another, and sold in a third. For example, an American fashion company might source fabric in China, have the clothes manufactured in Vietnam, finished in Italy, and shipped to a warehouse in the United States for distribution to retail outlets internationally.

\n

C One of the first accomplishments of the Industrial Revolution in the 18th century was the partial automation of the spinning and weaving of wool, cotton, silk and other natural fibres. Today, these processes are highly automated and carried out by computer-controlled, high-speed machinery, and fabrics made from both natural fibres and synthetic fibres (such as nylon, acrylic and polyester) are produced. A growing interest in sustainable fashion (or ‘eco-fashion') has led to greater use of environmentally friendly fibres, such as hemp. In addition, high-tech synthetic fabrics confer such properties as moisture absorption, stain resistance, retention or dissipation of body heat, and protection against fire, weapons, cold, ultraviolet radiation and other hazards. Fabrics are also produced with a wide range of visual effects through dyeing, weaving, printing and other processes. Together with fashion forecasters, fabric manufacturers work well in advance of the clothing production cycle to create fabrics with colours, textures and other qualities that anticipate consumer demand.

\n

D Historically, very few fashion designers have become famous—brands such as Coco Chanel or Calvin Klein—who have been responsible for prestigious high-fashion collections. These designers are influential in the fashion world, but, contrary to popular belief, they do not dictate new fashions; rather, they endeavour to design clothes that will meet consumer demand. The vast majority of designers work anonymously for manufacturers, as part of design teams, adapting designs into marketable garments for average consumers. They draw inspiration from a wide range of sources, including film and television costumes, street clothing and active sportswear.

\n

E An important stage in garment production is the translation of the clothing design into templates, in a range of sizes, for cutting the cloth. Because the proportions of the human body change with increases or decreases in weight, templates cannot simply be scaled up or down. Template-making was traditionally a highly skilled profession. Today, despite innovations in computer programming, designs in larger sizes are difficult to adjust for every body shape. Whatever the size, the template—whether drawn on paper or programmed as a set of computer instructions—determines how fabric is cut into the pieces that will be joined to make a garment. For all but the most expensive clothing, fabric cutting is accomplished by computer-guided knives or high-intensity lasers that can cut many layers of fabric at once.

\n

F The next stage of production is the assembly process. Some companies use their own production facilities for some or all of the manufacturing process, but the majority rely on separately owned manufacturing firms or contractors to produce garments to their specifications. In the field of women's clothing, manufacturers typically produce several product lines a year, which they deliver to retailers on predetermined dates. Technological innovation, including the development of computer-guided machinery, has resulted in the automation of some stages of assembly. Nevertheless, the fundamental process of sewing remains labour-intensive. In the late 20th century, China emerged as the world's largest producer of clothing because of its low labour costs and highly disciplined workforce. Assembled items then go through various processes collectively known as 'finishing'. These include the addition of decorative elements, fasteners, brand-name labels and other labels (often legally required) specifying fibre content, laundry instructions and country of manufacture. Finished items are then pressed and packed for shipment.

\n

G For much of the period following World War II, trade in textiles and garments was strictly regulated by purchasing countries, which imposed quotas and tariffs. Since the 1980s, these protectionist measures, which were intended (ultimately without success) to prevent textile and clothing production from moving from high-wage to low-wage countries, have gradually been abandoned. They have been replaced by a free-trade approach, under the regulatory control of global organisations. The advent of metal shipping containers and relatively inexpensive air freight has also made it possible for production to be closely tied to market conditions, even across globe-spanning distances.

\n \n
\n \n \n
" + "html": "

READING PASSAGE 2

\n

You should spend about 20 minutes on Questions 14–26, which are based on Reading Passage 2 below.

\n \n

The fashion industry

\n \n

A The fashion industry is a multibillion-dollar global enterprise devoted to the business of making and selling clothes. It encompasses all types of garments, from designer fashions to ordinary everyday clothing. Because data on the industry are typically reported for national economies and expressed in terms of its many separate sectors, total figures for world production of textiles and clothing are difficult to obtain. However, by any measure, the industry accounts for a significant share of world economic output.

\n

B The fashion industry is a product of the modern age. Prior to the mid-19th century, virtually all clothing was handmade for individuals, either as home production or on order from dressmakers and tailors. By the beginning of the 20th century, with the development of new technologies such as the sewing machine, the factory system of production, and the growth of department stores and other retail outlets, clothing had increasingly come to be mass-produced in standard sizes and sold at fixed prices. Although the fashion industry developed first in Europe, today it is highly globalised, with garments often designed in one country, manufactured in another, and sold in a third. For example, an American fashion company might source fabric in China, have the clothes manufactured in Vietnam, finished in Italy, and shipped to a warehouse in the United States for distribution to retail outlets internationally.

\n

C One of the first accomplishments of the Industrial Revolution in the 18th century was the partial automation of the spinning and weaving of wool, cotton, silk and other natural fibres. Today, these processes are highly automated and carried out by computer-controlled, high-speed machinery, and fabrics made from both natural fibres and synthetic fibres (such as nylon, acrylic and polyester) are produced. A growing interest in sustainable fashion (or ‘eco-fashion') has led to greater use of environmentally friendly fibres, such as hemp. In addition, high-tech synthetic fabrics confer such properties as moisture absorption, stain resistance, retention or dissipation of body heat, and protection against fire, weapons, cold, ultraviolet radiation and other hazards. Fabrics are also produced with a wide range of visual effects through dyeing, weaving, printing and other processes. Together with fashion forecasters, fabric manufacturers work well in advance of the clothing production cycle to create fabrics with colours, textures and other qualities that anticipate consumer demand.

\n

D Historically, very few fashion designers have become famous—brands such as Coco Chanel or Calvin Klein—who have been responsible for prestigious high-fashion collections. These designers are influential in the fashion world, but, contrary to popular belief, they do not dictate new fashions; rather, they endeavour to design clothes that will meet consumer demand. The vast majority of designers work anonymously for manufacturers, as part of design teams, adapting designs into marketable garments for average consumers. They draw inspiration from a wide range of sources, including film and television costumes, street clothing and active sportswear.

\n

The fashion industry’s traditional design methods, such as paper sketches and the draping of fabric on mannequins, have been supplemented or replaced by computer-assisted design techniques. These allow designers to rapidly make changes to a proposed design and instantaneously share the proposed changes with colleagues—whether they are in the next room or on another continent.

\n

E An important stage in garment production is the translation of the clothing design into templates, in a range of sizes, for cutting the cloth. Because the proportions of the human body change with increases or decreases in weight, templates cannot simply be scaled up or down. Template-making was traditionally a highly skilled profession. Today, despite innovations in computer programming, designs in larger sizes are difficult to adjust for every body shape. Whatever the size, the template—whether drawn on paper or programmed as a set of computer instructions—determines how fabric is cut into the pieces that will be joined to make a garment. For all but the most expensive clothing, fabric cutting is accomplished by computer-guided knives or high-intensity lasers that can cut many layers of fabric at once.

\n

F The next stage of production is the assembly process. Some companies use their own production facilities for some or all of the manufacturing process, but the majority rely on separately owned manufacturing firms or contractors to produce garments to their specifications. In the field of women's clothing, manufacturers typically produce several product lines a year, which they deliver to retailers on predetermined dates. Technological innovation, including the development of computer-guided machinery, has resulted in the automation of some stages of assembly. Nevertheless, the fundamental process of sewing remains labour-intensive. In the late 20th century, China emerged as the world's largest producer of clothing because of its low labour costs and highly disciplined workforce. Assembled items then go through various processes collectively known as 'finishing'. These include the addition of decorative elements, fasteners, brand-name labels and other labels (often legally required) specifying fibre content, laundry instructions and country of manufacture. Finished items are then pressed and packed for shipment.

\n

G For much of the period following World War II, trade in textiles and garments was strictly regulated by purchasing countries, which imposed quotas and tariffs. Since the 1980s, these protectionist measures, which were intended (ultimately without success) to prevent textile and clothing production from moving from high-wage to low-wage countries, have gradually been abandoned. They have been replaced by a free-trade approach, under the regulatory control of global organisations. The advent of metal shipping containers and relatively inexpensive air freight has also made it possible for production to be closely tied to market conditions, even across globe-spanning distances.

\n \n
\n \n \n
" } ] }, diff --git a/assets/generated/reading-exams/p2-low-49.js b/assets/generated/reading-exams/p2-low-49.js index 44816373..dbb82178 100644 --- a/assets/generated/reading-exams/p2-low-49.js +++ b/assets/generated/reading-exams/p2-low-49.js @@ -75,7 +75,7 @@ "q9": "I", "q10": "F", "q11": "A", - "q12": "D", + "q12": "J", "q13": "H" }, "sourceRefs": { diff --git a/assets/generated/reading-exams/p2-medium-243.js b/assets/generated/reading-exams/p2-medium-243.js new file mode 100644 index 00000000..8b6c1c69 --- /dev/null +++ b/assets/generated/reading-exams/p2-medium-243.js @@ -0,0 +1,126 @@ +(function registerReadingExamData(global) { + 'use strict'; + if (!global.__READING_EXAM_DATA__ || typeof global.__READING_EXAM_DATA__.register !== "function") { + throw new Error("reading_exam_registry_missing"); + } + global.__READING_EXAM_DATA__.register("p2-medium-243", { + "schemaVersion": "ReadingExamSourceV1", + "examId": "p2-medium-243", + "meta": { + "title": "The internal body clock", + "category": "P2", + "frequency": "次高频", + "pdfFilename": "", + "legacyPath": "", + "legacyFilename": "", + "questionIntroHtml": "

Questions 14–26

" + }, + "passage": { + "blocks": [ + { + "blockId": "passage-main", + "kind": "html", + "html": "

READING PASSAGE 2

\n

You should spend about 20 minutes on Questions 14–26, which are based on Reading Passage 2 below.

\n

The internal body clock

\n

Each of our cells has an internal 'clock' which dictates our daily rhythms. But why?

\n

From buffalo to bacteria, oaks to algae, all life follows the same relentless 24-hour cycle, which is driven by the rising and setting of the Sun. Or is it? Over 250 years ago, a French scientist performed a simple experiment that blew apart the idea that all life on Earth does the bidding of the Sun. Intrigued by the way some flowers open and close their leaves each day, Jean-Jacques de Mairan put a heliotrope in a darkened room and observed the effect. He was expecting the plant, robbed of sunlight, to cease its daily routine. To his astonishment, its leaves continued to open and close as if in response to some invisible timekeeper.

\n

But what – and where – is this internal timekeeper? And how does it achieve such astonishing regularity? These are the mysteries at the forefront of chronobiology, the study of the effect of time in living organisms. The search for answers is about more than just tying up some scientific loose ends. Internal ‘clocks’ clearly cause a lot of hardship. When an individual’s ‘clock’ is knocked out of synchronization through having to work at night or go on long-haul flights, that person can be left utterly unable to think or act. Yet even when these ‘clocks’ work correctly, the natural rhythms of alertness they generate prove dangerous: traffic accident statistics show two deadly peaks, at 4 a.m. and again twelve hours later when people are at their least alert.

\n

During the 1970s, best-selling books began to emerge claiming such phenomena were manifestations of so-called biorhythms, a set of three cycles governing physical, emotional and intellectual traits. Said to start from the moment of birth and repeat every 23, 28 and 33 days respectively, they were supposed to result in ‘critical days’ when one or more cycles led to sub-optimal performance, with unfortunate consequences.

\n

Scientists have since dismissed biorhythms as pseudoscience, insisting the existence of the three cycles has no basis in fact. In 1998, Dr Terence Hines of Pace University, New York State published the most comprehensive study of the claims made for biorhythms, reviewing the results of over 130 investigations. He found that three-quarters of them failed to provide any support for biorhythms. Most of the remainder contained blunders ranging from faulty mathematics to basic errors in statistics, while the handful of positive studies were explicable as flukes.

\n

But there is no doubting the existence of 24-hour biological cycles – or, rather, ones roughly 24 hours long. Following de Mairan’s pioneering work, other researchers found that when deprived of the cues provided by sunlight, organisms settle down to ‘free-running’ cycles that are close to, but rarely exactly, 24 hours long. In the case of humans, the cycle is typically around 24.5 hours long. This is the so-called ‘circadian’ cycle (from the Latin meaning ‘about a day’), the length of which is generated by the mysterious internal ‘clock’.

\n

During the late 1960s, chronobiologists believed they had found the ‘clock’, in the form of the suprachiasmatic nucleus (SCN), a collection of nerves in a region of the brain known as the hypothalamus. Linked to photosensitive cells in the eye, the SCN senses daylight and triggers the release of hormones like melatonin, which keep body functions in synchronization with the time of day. For a few years the SCN was regarded as the ultimate pacemaker – at least in higher organisms such as ourselves. But in 1971, scientists at the California Institute of Technology working with fruit flies found evidence for something truly amazing. Fruit flies appeared to have genes affecting the daily rhythm of their behavior – suggesting that there are ‘clocks’ inside each of their cells. Further evidence for this emerged in 1995, when researchers at Massachusetts General Hospital isolated nerve cells from the SCN, and found they kept up a circadian cycle without help from daylight. Finally in 1997, scientists at Northwestern University, Illinois, found a gene that regulates the daily rhythms of cellular activity in mammals.

\n

The details of how this gene, known as CLOCK, actually works are still being investigated, but they could culminate in better ways of coping with shift-work and jet-lag. Professor Takahashi, who led the research, says that now that we know that circadian clocks exist throughout our bodies, we will need new strategies and therapeutics to reset all of our cells. But how do all the biochemical ‘clocks’ inside their cells stay in synchronization and why did organisms bother to acquire a link with sunlight? So far, no-one knows, though there are several theories. For example, sunlight might be useful in keeping the myriad cellular ‘clocks’ in lockstep.

\n

In the search for answers, researchers are studying the behavior of a microbe called cyanobacterium. In 2005, a team at Nagoya University, Japan, showed that chemical reactions between three proteins produced by this bacterium ebbed and flowed on a 24-hour cycle. Cyanobacterium is the oldest form of life on Earth, so this suggests that cellular ‘clocks’ have existed for over three billion years. All subsequent organisms have followed suit, says Professor Johnson of Vanderbilt University, Tennessee. ‘Bacteria, fungi, plants and animals all appear to have evolved clock systems independently from each other.’ Quite why is still unknown. But one thing is clear: the daily routine of life is certainly not a modern invention.

" + } + ] + }, + "questionGroups": [ + { + "groupId": "group-1", + "kind": "matching", + "questionIds": [ + "q1", + "q2", + "q3", + "q4", + "q5" + ], + "bodyHtml": "
\n

Questions 14–18

\n

Reading Passage 2 has eight paragraphs, A–H.

\n

Which paragraph contains the following information?

\n

Write the correct letter, A–H, in boxes 14–18 on your answer sheet.

\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ABCDEFGH
14 support for an earlier finding from experiments on insects
15 criticism of the way some research data was analysed
16 reference to a basic test on a plant
17a statement that the length of natural cycles varies slightly between living things
18 reference to a theory that became popular with the public
\n
\n
", + "allowOptionReuse": true + }, + { + "groupId": "group-2", + "kind": "summary_completion", + "questionIds": [ + "q6", + "q7", + "q8", + "q9" + ], + "bodyHtml": "
\n

Questions 19–22

\n

Complete the summary below.

\n

Choose NO MORE THAN TWO WORDS from the passage for each answer.

\n

Write your answers in boxes 19–22 on your answer sheet.

\n

An overview of research into the existence of the internal ‘clock’

\n

Towards the end of the 1960s, research into the SCN, which is located in the hypothalamus, led chronobiologists to believe they had found the internal ‘clock’ at last. Their theory was based on the fact that certain hormones that regulate the timing of physical functions are controlled by the SCN. The SCN is able to perceive 19 as a result of connections to the eye.

\n

Several years later, research involving 20 showed that the timing of their normal actions was regulated by certain 21 , or ‘clocks’, in their cells. Thus the internal ‘clock’ is not an individual mechanism, as previously thought, but exists within each cell. Other scientists went on to conduct experiments in the dark that proved that a 22 was maintained by individual SCN cells.

\n
" + }, + { + "groupId": "group-3", + "kind": "multi_choice", + "questionIds": [ + "q10", + "q11" + ], + "bodyHtml": "
\n

Questions 23 and 24

\n

Choose TWO letters, A–E.

\n

Write the correct letters in boxes 23 and 24 on your answer sheet.

\n

Which TWO of the following problems are stated as being linked with the internal ‘clock’?

\n
\n\n\n\n\n\n
\n
", + }, + { + "groupId": "group-4", + "kind": "multi_choice", + "questionIds": [ + "q12", + "q13" + ], + "bodyHtml": "
\n

Questions 25 and 26

\n

Choose TWO letters, A–E.

\n

Write the correct letters in boxes 25 and 26 on your answer sheet.

\n

Which TWO of the following issues relating to internal ‘clocks’ remain to be solved, according to information in the passage?

\n
\n\n\n\n\n\n
\n
", + } + ], + "answerKey": { + "q1": "F", + "q2": "D", + "q3": "A", + "q4": "E", + "q5": "C", + "q6": "daylight", + "q7": "fruit flies", + "q8": "genes", + "q9": "circadian cycle", + "q10": "B", + "q11": "C", + "q12": "C", + "q13": "D" + }, + "sourceRefs": { + "pdf": "" + }, + "audit": { + "matchStatus": "matched", + "matchConfidence": 1, + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the embedded DOCX answer-key images and source question text." + }, + "questionOrder": [ + "q1", + "q2", + "q3", + "q4", + "q5", + "q6", + "q7", + "q8", + "q9", + "q10", + "q11", + "q12", + "q13" + ], + "questionDisplayMap": { + "q1": "14", + "q2": "15", + "q3": "16", + "q4": "17", + "q5": "18", + "q6": "19", + "q7": "20", + "q8": "21", + "q9": "22", + "q10": "23", + "q11": "24", + "q12": "25", + "q13": "26" + } +}); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/assets/generated/reading-exams/p3-high-173.js b/assets/generated/reading-exams/p3-high-173.js index e06132d8..2567111d 100644 --- a/assets/generated/reading-exams/p3-high-173.js +++ b/assets/generated/reading-exams/p3-high-173.js @@ -7,20 +7,20 @@ "schemaVersion": "ReadingExamSourceV1", "examId": "p3-high-173", "meta": { - "title": "Robert Louis Stevenson 苏格兰作家", + "title": "Robert Louis Stevenson", "category": "P3", "frequency": "high", "pdfFilename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.pdf", "legacyPath": "睡着过项目组/2. 所有文章(11.20)[192篇]/81. P3 - Robert Louis Stevenson 苏格兰作家【高】/", "legacyFilename": "81. P3 - Robert Louis Stevenson 苏格兰作家【高】.html", - "questionIntroHtml": "

Questions

" + "questionIntroHtml": "

Questions 27–31

" }, "passage": { "blocks": [ { "blockId": "passage-main", "kind": "html", - "html": "

READING PASSAGE 3

\n

You should spend about 20 minutes on Questions 27–40, which are based on Reading Passage 3 below.

\n \n

Robert Louis Stevenson

\n

The writer of some of the best-known stories in the English language, including Treasure Island and The Strange Case of Dr. Jekyll and Mr. Hyde

\n \n

It is more than 100 years since the death of the Scottish writer Robert Louis Stevenson on the South Pacific island of Samoa. And it seems that time has not been kind to Stevenson's memory. Immediately after his death, his family and friends set to work to fashion the legend of Robert Louis Stevenson, or R.L.S. as he became known—one of the few writers familiar from his initials alone. Subsequent works of biography then turned him into a writer of almost religious importance. One example was critic Balfour, who in 1901 portrayed Stevenson's family as ministering angels to the dying genius during his final illness. Similarly, the biographer Crouch absurdly overstated Stevenson's significance by placing him in the same company as those most revered names in English literature, Shakespeare and Keats. The reaction to this nonsense was a number of highly critical assessments of Stevenson's legacy in the 1920s.

\n

Normally, the critical pendulum can be relied on to swing back again, but there are several aspects of Stevenson's work that have until recently acted against a more balanced appraisal. First is the allegation that Stevenson was a mere master of linguistic fireworks who lacked moral depth. Some critics accused him of being a literary charlatan, juggling words very prettily to strike effects that overawed an ignorant public and served to distract from the inadequacy of his ideas.

\n

Then there has been a prejudice against the adventure story as the proper medium for deep moral seriousness, a prejudice which is still extremely influential today. It seems that we can accept that an adventure film can successfully express profound moral truths, but we reject the same idea for a book. The absurdity of this becomes apparent when we think of writers like Joseph Conrad and Graham Greene, but it is no use pretending that this bias against adventure stories is not part of our high culture. A further problem is that Stevenson has often not found favour in the land of his birth because his conservatism so often collides with the strong radical tradition in Scotland. His many escapist stories and preference for living abroad have led to accusations that he camouflaged Scotland's real problems. Lastly, the high adventure of Stevenson's own lifestyle has sometimes obscured his output. His globe-trotting, and above all the final phase of his life in Samoa, tended to make his own life a greater story than any he could devise. This was precisely what his friends feared would happen towards the end of his short life: his art might be overwhelmed by the drama of life in Samoa.

\n

One consequence of this has been that Stevenson's influence on other writers has too often been neglected. The writer and poet Oscar Wilde was deeply influenced by Stevenson, even though he declared that Stevenson would have produced better work if he had lived in London rather than Samoa. Stevenson tends to stick in the throat even of those writers who would like to spit him out, such as Shaw, who claimed to have learned from him that the romantic hero is always mocked by reality. Likewise, the writer Galsworthy, who was a determined critic, later changed his mind and said that the superiority of Stevenson over the novelist Hardy was that Stevenson was all life and Hardy, all death. The influence on the novelist Chesterton would also repay detailed study, for it was through him that Stevenson has managed to cross the ages, emerging as an influence on the modernist movement and our own contemporary Latin American school of magical realism.

\n

When making an assessment of his life and work, one question must inevitably be asked: was Robert Louis Stevenson Scotland's greatest writer of English prose? For most commentators this honour falls to Sir Walter Scott, author of Ivanhoe among many other classic novels, and it is true that in terms of craftsmanship, precision and the ability to minutely regulate language to create the desired effect, Scott takes the prize. However, this is not the same thing at all as inherent talent: by way of comparison one may take the example of the two great Russian composers Shostakovich and Prokofiev, of whom the former had learned more precise skills of execution but the latter's intrinsic genius was greater, and so it seems to be with Scott and Stevenson. Admittedly, Scott's detailed style does permit his stories to explore levels of tragedy that are beyond Stevenson's reach, but in this regard they have the musty smell of the museum, somehow artificial and removed from modern-day reality. On the other hand, Stevenson's skill with plotting and narrative give his books a timeless quality, so that they still live today. And Stevenson was also the shrewder judge of behaviour and psychology. For example, his compelling description of a man with a split personality in The Strange Case of Dr. Jekyll and Mr. Hyde has proved so accessible and accurate that the expression “Jekyll and Hyde” has entered common English usage. Even if we do not see a revival of critical interest in this great Scottish writer, it is to be hoped that readers go back to Robert Louis Stevenson's magnificent stories and reassess this neglected genius.

\n\n
\n \n \n
" + "html": "

READING PASSAGE 3

\n

You should spend about 20 minutes on Questions 27-40, which are based on Reading Passage 3 below.

\n

Robert Louis Stevenson

\n

The writer of some of the best-known stories in the English language, including Treasure Island and The Strange Case of Dr. Jekyll and Mr. Hyde

\n

It is more than 100 years since the death of the Scottish writer Robert Louis Stevenson on the South Pacific island of Samoa, and it seems that time has not been kind to Stevenson’s memory. Immediately after his death, his family and friends set to work to fashion the legend of Robert Louis Stevenson, or R. L. S., as he became known, one of the few writers familiar from his initials alone. Subsequent works of biography then turned him into a writer of almost religious importance. One example was literary critic Balfour, who in 1901 portrayed Stevenson’s family as ministering angels to the dying genius during his final illness. Similarly, the biographer Crouch absurdly overstated Stevenson’s significance by placing him in the same company as those most revered names in English literature: Shakespeare and Keats. The reaction to this nonsense was a number of highly critical assessments of Stevenson’s legacy in the 1920s.

\n

Normally, the critical pendulum can be relied on to swing back again, but there are several aspects of Stevenson’s work that have, until recently, acted against a more balanced appraisal. First is the allegation that Stevenson was a mere master of linguistic fireworks, who lacked moral depth. Some critics accused him of being a literary charlatan, of juggling words very prettily to strike effects which overawed an ignorant public, and served to distract from the inadequacy of his ideas.

\n

Then there has long been a prejudice against the adventure story as the proper medium for deep moral seriousness, a prejudice which is still extremely influential today. It seems that we can accept that an adventure film can successfully express profound moral truths, but we reject the same idea for a book. The absurdity of this becomes apparent when we think of writers like Joseph Conrad and Graham Greene, but it is no use pretending that this bias against adventure stories is not part of our high culture. A further problem is that Stevenson has often not found favour in the land of his birth because his conservatism so often collides with the strong radical tradition in Scotland. His many escapist stories and preference for living abroad have led to accusations that he camouflaged Scotland’s problems. Lastly, the high adventure of Stevenson’s own lifestyle has sometimes obscured his output, his life a greater story than any he could devise. This was precisely what his friends feared would happen towards the end of his short life: his art might be overwhelmed by the drama of life in Samoa.

\n

One consequence of this has been that Stevenson’s influence on other writers has too often been neglected. The writer and poet Oscar Wilde was deeply influenced by Stevenson, even though he declared that Stevenson would have produced better work if he had lived in London rather than Samoa. Stevenson tends to stick in the throat even of those writers who would like to spit him out, such as Shaw, who claimed to have learned from him that the romantic hero is always mocked by reality. Likewise, the writer Galsworthy, who began as a determined critic, later changed his mind and said that the superiority of Stevenson over the novelist Hardy was that Stevenson was all life and Hardy all death. The influence on the novelist Chesterton would also repay detailed study, for it was through him that Stevenson has managed to cross the ages, emerging as an influence on the modernist movement and our own contemporary Latin American school of “magical realism”.

\n

When making an assessment of his life and work one question must inevitably be asked: was Robert Louis Stevenson Scotland’s greatest writer of English prose? For most commentators this honour falls to Sir Walter Scott, author of Ivanhoe among many other classic novels, and it is true that in terms of craftsmanship, precision and the ability to minutely regulate language to create the desired effect, Scott takes the prize. However, this is not the same thing at all as inherent talent; by way of comparison one may take the example of the two great Russian composers Shostakovich and Prokofiev, of whom the former had learned more precise skills of execution but the latter’s intrinsic genius was greater, and so it seems to be with Scott and Stevenson. Admittedly, Scott’s detailed style does permit his stories to explore levels of tragedy that are beyond Stevenson’s reach, but in this regard they have the musty smell of the museum, somehow artificial and removed from modern day reality. On the other hand, Stevenson’s skill with plotting and narrative gives his books a timeless quality, so that they still live today, and Stevenson was also the shrewder judge of behaviour and psychology. For example, his compelling descriptions of a man with a split personality in The Strange Case of Dr. Jekyll and Mr. Hyde have proved so accessible and accurate that the expression “Jekyll and Hyde” has entered common English usage. Even if we do not see a revival of critical interest in this great Scottish writer, it is to be hoped that readers go back to Robert Louis Stevenson’s magnificent stories and reassess this neglected genius.

" } ] }, @@ -35,8 +35,8 @@ "q4", "q5" ], - "bodyHtml": "
\n

Questions 27–31

\n

Choose the correct letter, A, B, C or D.

\n
\n

27 In the opinion of the writer, the biographers Balfour and Crouch

\n \n
\n
\n

28 What is the writer's main point about Stevenson in the second paragraph?

\n \n
\n
\n

29 According to the writer, the adventure story

\n \n
\n
\n

30 What point does the writer make about Stevenson and Scotland?

\n \n
\n
\n

31 According to the writer, Stevenson's own lifestyle

\n \n
\n
", - "leadHtml": "

Questions

" + "bodyHtml": "
\n

Questions 27–31

\n

Choose the correct letter A, B, C or D.

\n

Write the correct letter in boxes 27–31 on your answer sheet.

\n
\n

27\tIn the opinion of the writer, the biographers Balfour and Crouch

\n
\n\n\n\n\n
\n
\n
\n

28\tWhat is the writer’s main point about Stevenson in the second paragraph?

\n
\n\n\n\n\n
\n
\n
\n

29\tAccording to the writer, the adventure story

\n
\n\n\n\n\n
\n
\n
\n

30\tWhat point does the writer make about Stevenson and Scotland?

\n
\n\n\n\n\n
\n
\n
\n

31\tAccording to the writer, Stevenson’s own lifestyle

\n
\n\n\n\n\n
\n
\n
", + "leadHtml": "

Questions 27–31

" }, { "groupId": "group-2", @@ -47,7 +47,7 @@ "q8", "q9" ], - "bodyHtml": "
\n

Questions 32–35

\n

Do the following statements agree with the claims of the writer in Reading Passage 3?

\n

In boxes 32–35 on your answer sheet, write:

\n \n
\n

32 Although Oscar Wilde admired Robert Louis Stevenson very much, he believed Stevenson could have written greater works.

\n
\n \n \n \n
\n
\n
\n

33 Robert Louis Stevenson encouraged Oscar Wilde to start writing in the first place.

\n
\n \n \n \n
\n
\n
\n

34 Galsworthy respected Hardy's works more than Stevenson's.

\n
\n \n \n \n
\n
\n
\n

35 There is a need to study in detail Stevenson's influence on Chesterton.

\n
\n \n \n \n
\n
\n
" + "bodyHtml": "
\n

Questions 32–35

\n

Do the following statements agree with the views of the writer in Reading Passage 3?

\n

In boxes 32–35 on your answer sheet write

\n

YES\tif the statement agrees with the views of the writer
NO\tif the statement contradicts the views of the writer
NOT GIVEN\tif it is impossible to say what the writer thinks about this

\n

32\tAlthough Oscar Wilde admired Stevenson’s work, he believed Stevenson could have written something better.

\n

33\tStevenson encouraged Oscar Wilde to start writing.

\n

34\tGalsworthy had greater respect for Hardy than Stevenson.

\n

35\tMore research is needed regarding Stevenson’s influence on Chesterton.

\n
", }, { "groupId": "group-3", @@ -59,7 +59,7 @@ "q13", "q14" ], - "bodyHtml": "
\n

Questions 36–40

\n

Complete the summary using the list of words, A–I, below.

\n
\n
Sir Walter Scott and Robert Louis Stevenson
\n

A lot of people believe that Sir Walter Scott and Robert Louis Stevenson are the most influential writers in the history of Scotland, but Sir Walter Scott is more proficient in, while Stevenson has better. Scott's books illustrate, especially in terms of tragedy, but many readers prefer Stevenson's. What's more, Stevenson's understanding of gave his works a unique expression of the Scottish people.

\n
\n
\n A natural ability\n B romance\n C colorful language\n D critical acclaim\n E humor\n F technical control\n G storytelling\n H depth\n I human nature\n
\n
" + "bodyHtml": "
\n

Questions 36–40

\n

Complete the summary using the list of words and phrases, A–I below.

\n

Write the correct letter, A–I, in boxes 36–40 on your answer sheet.

\n
\n
Robert Louis Stevenson and Sir Walter Scott
\n

Opinions differ as to whether Robert Louis Stevenson or Sir Walter Scott should be considered Scotland’s best writer. Scott had greater , but Stevenson had more , and the same distinction can be made between the two composers Shostakovich and Prokofiev. It is true that Scott’s books showed more when it came to tragedy, though in an old-fashioned way, while Stevenson’s books are still popular because of his . And Stevenson’s understanding of has resulted in the widespread use of an expression from one of his books.

\n
\n
\nA\tnatural ability\nB\tcritical acclaim\nC\thumour\nD\tromance\nE\tcolourful language\nF\ttechnical control\nG\tstorytelling\nH\tdepth\nI\thuman nature\n
\n
", } ], "answerKey": { @@ -86,8 +86,8 @@ "audit": { "matchStatus": "matched", "matchConfidence": 1, - "verifiedAt": "2026-03-08T16:24:30.800Z", - "notes": "signature:radio,text,textarea,dragdrop,table" + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the embedded DOCX answer-key images and source question text." }, "questionOrder": [ "q1", diff --git a/assets/generated/reading-exams/p3-medium-244.js b/assets/generated/reading-exams/p3-medium-244.js new file mode 100644 index 00000000..9d3a9b25 --- /dev/null +++ b/assets/generated/reading-exams/p3-medium-244.js @@ -0,0 +1,124 @@ +(function registerReadingExamData(global) { + 'use strict'; + if (!global.__READING_EXAM_DATA__ || typeof global.__READING_EXAM_DATA__.register !== "function") { + throw new Error("reading_exam_registry_missing"); + } + global.__READING_EXAM_DATA__.register("p3-medium-244", { + "schemaVersion": "ReadingExamSourceV1", + "examId": "p3-medium-244", + "meta": { + "title": "Look who was talking", + "category": "P3", + "frequency": "次高频", + "pdfFilename": "", + "legacyPath": "", + "legacyFilename": "", + "questionIntroHtml": "

Questions 27–32

" + }, + "passage": { + "blocks": [ + { + "blockId": "passage-main", + "kind": "html", + "html": "

READING PASSAGE 3

\n

You should spend about 20 minutes on Questions 27-40, which are based on Reading Passage 3 below.

\n

Look who was talking

\n

Stephen Oppenheimer explores the origins of human speech

\n

When did we start talking to each other and how long did it take us to become so good at it? One view of language development, held by linguists such as Noam Chomsky and anthropologists such as Richard Klein, is that language, specifically the spoken word, appeared suddenly among modern humans a mere 35,000 to 50,000 years ago and that the ability to speak words and use syntax was recently genetically hard-wired into our brains in a kind of language organ. This view of language is associated with the old idea that logical thought is dependent on words, a concept originating with Plato and much in vogue in the 19th century – that is, animals do not speak because they do not think. However, the abstract thought demonstrated in 20th-century experiments with chimpanzees and bonobos put this theory in doubt.

\n

An alternative to the Chomskian theory is that language developed as a series of inventions. This was first suggested by the 18th-century philosopher Etienne Bonnot de Condillac. He argued that spoken language had developed out of gesture language (langage d’action) and that both were inventions arising initially from the simple association between action and object. The theory sees gesture language as arising originally among apes as sounds accompanying gestures, with these sounds gradually becoming coded into ‘words’ as the new skill drove its own evolution. Subsequently, coded words developed into deliberate, complex communication. Evolutionary pressures promoted the development of an anatomy geared to speech – the larynx, vocal muscles and a specific part of the brain immediately next to the part responsible for gestures.

\n

The view that spoken language was ultimately a cultural invention like tool-making, which then drove the biological evolution of the brain and vocal apparatus, seems obvious when you think of the development of different languages. The unique features of a language such as French clearly do not result from any physiological aspect of being French but are the cultural possessions of the French-speaking community. Each language evolves from one generation to the next, constantly adapting itself to cope with the learning biases of each new set of young, immature minds.

\n

Those anthropologists and fossil experts who accept that speech started early still tend to think of language evolution as a gradual 2-million-year process, with our own modern human species (Homo sapiens) way out at the top. A major reason for this is the perception that brain growth among humans was gradual over a similar period. Several recent changes in the fossil evidence, though, bring this into doubt.

\n

The first of these is a re-dating of soil layers from the famous Olduvai Gorge in East Africa, where many key fossil remains have been found. A number of big-brained human species appear to be much older than previously thought, with several specimens dating from over a million years ago. When brain sizes for all available skulls are plotted against time using the revised dates, the result is startling: the bulk of increases in brain size was over by around 1.2 million years ago, with some African human species having brain volumes easily within the modern human range by that time.

\n

So, we have the paradox that over the period when our brain was growing most rapidly, our material cultural development, as measured by stone tools, advanced only marginally; then, over a million years later, when the development of anatomically modern humans finally started to accelerate, artistically and technologically, our brains were actually getting smaller.

\n

The additional piece of evidence that makes this paradox all the more significant is that brain size did not just leap between human species in a direct line of ascent towards ourselves. Over the period from 2.5 to 1.5 million years ago, brains were growing more rapidly than at any time since, within all the different human species. The logical conclusion is that there must have been a unique new behaviour driving brain growth, shared between all species of humans.

\n

So, what was driving rapid brain growth right at the beginning, 2.5 million years ago? The answer may have been staring us in the face. Namely, that not only early humans but their ancestors had started the trend in the very useful skill of verbal communication. Around 2.5 million years ago, the weather took a decided turn for the worse, becoming more variable and colder and drier. The search for food became more taxing, and there would have been a real need to communicate more effectively and cope with the worsening environment in a co-operative way. The near maximum in brain size achieved by 1.2 million years ago indicates that those early ancestors could already have been talking perfectly well. Our brain, which had developed to manipulate and organise complex symbolic aspects of speech internally, could now be turned to a variety of other tasks.

\n

So what happened in the million-year gap after that? Why did we take so long to get to the moon? Cultural evolution aided by communication and teaching is a cumulative interactive process. If each new generation invented just one new skill or idea and passed it on with the rest to their children and cousins, you could predict exactly the same curve of cultural advance as we see from the archaeological and historical record – first very slow, then faster and faster.

" + } + ] + }, + "questionGroups": [ + { + "groupId": "group-1", + "kind": "matching", + "questionIds": [ + "q1", + "q2", + "q3", + "q4", + "q5", + "q6" + ], + "bodyHtml": "
\n

Questions 27–32

\n

Look at the following statements (Questions 27–32) and the list of people below.

\n

Match each statement with the correct person, A, B, or C.

\n

Write the correct letter, A, B, or C, in boxes 27–32 on your answer sheet.

\n

NB You may use any letter more than once.

\n

List of People

\n\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ABC
27 The development of human speech can be traced back to apes.
28 Animal research is essential for understanding the development of human speech.
29 Language emerged relatively late in human evolution.
30 Non-verbal language was essential in the development of verbal language.
31 The development of different languages is related more to environmental than biological factors.
32 The ability to think rationally is linked to the ability to speak.
\n
\n
", + "allowOptionReuse": true + }, + { + "groupId": "group-2", + "kind": "yes_no_not_given", + "questionIds": [ + "q7", + "q8", + "q9", + "q10", + "q11" + ], + "bodyHtml": "
\n

Questions 33–37

\n

Do the following statements agree with the claims of the writer in Reading Passage 3?

\n

In boxes 33–37 on your answer sheet, write

\n

YES if the statement agrees with the claims of the writer
NO if the statement contradicts the claims of the writer
NOT GIVEN if it is impossible to say what the writer thinks about this

\n

33 Anthropologists now agree on the point in time when speech began.

\n

34 The rate of human technological development in ancient times was directly related to brain size.

\n

35 The period when the brain was growing most quickly has now been identified.

\n

36 The development of agriculture influenced language development.

\n

37 Cultural development has been seen to follow a particular pattern throughout human history.

\n
" + }, + { + "groupId": "group-3", + "kind": "matching", + "questionIds": [ + "q12", + "q13", + "q14" + ], + "bodyHtml": "
\n

Questions 38–40

\n

Look at the following statements (Questions 38–40) and the list of dates below.

\n

Match each statement with the correct date, A, B, C or D.

\n

Write the correct letter, A, B, C or D, in boxes 38–40 on your answer sheet.

\n

NB You may use any letter more than once.

\n

List of Dates

\n\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ABCD
38 Revised fossil evidence indicates most brain growth was complete.
39 The writer suggests that language development was complete.
40 An external change may have influenced language development.
\n
\n
", + "allowOptionReuse": true + } + ], + "answerKey": { + "q1": "B", + "q2": "C", + "q3": "A", + "q4": "B", + "q5": "C", + "q6": "A", + "q7": "NO", + "q8": "NO", + "q9": "YES", + "q10": "NOT GIVEN", + "q11": "YES", + "q12": "C", + "q13": "C", + "q14": "A" + }, + "sourceRefs": { + "pdf": "" + }, + "audit": { + "matchStatus": "matched", + "matchConfidence": 1, + "verifiedAt": "2026-08-03T00:00:00.000Z", + "notes": "Verified against the embedded DOCX answer-key images and source question text." + }, + "questionOrder": [ + "q1", + "q2", + "q3", + "q4", + "q5", + "q6", + "q7", + "q8", + "q9", + "q10", + "q11", + "q12", + "q13", + "q14" + ], + "questionDisplayMap": { + "q1": "27", + "q2": "28", + "q3": "29", + "q4": "30", + "q5": "31", + "q6": "32", + "q7": "33", + "q8": "34", + "q9": "35", + "q10": "36", + "q11": "37", + "q12": "38", + "q13": "39", + "q14": "40" + } +}); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/assets/generated/reading-explanations/p1-high-05.js b/assets/generated/reading-explanations/p1-high-05.js index e75ccd8c..4a3e3804 100644 --- a/assets/generated/reading-explanations/p1-high-05.js +++ b/assets/generated/reading-explanations/p1-high-05.js @@ -25,7 +25,7 @@ }, { "label": "第三段", - "text": "1903年,她和两个姐姐一起搬到伦敦,就读于女王学院。曼斯菲尔德重新开始拉大提琴,这是她在女王学院期间相信自己会专业从事的职业。她也开始为学院报纸撰稿,并对此投入了极大的热情,最终成为了报纸的编辑。她对这个时期的法国作家和19世纪英国作家奥斯卡·王尔德的作品特别感兴趣,并且因其活泼、有魅力的人生态度和工作方式而受到女王学院同学们的赞赏。她在学院结识了来自南非的作家艾达·贝克,两人成为了一生的朋友。曼斯菲尔德并未积极支持英国的妇女参政运动。新西兰的妇女在1893年就获得了投票权。" + "text": "1903年,她和两个姐姐一起搬到伦敦,就读于女王学院。曼斯菲尔德重新开始拉大提琴,这是她在女王学院期间相信自己会专业从事的职业。她也开始为学院报纸撰稿,并对此投入了极大的热情,最终成为了报纸的编辑。她对这个时期的法国作家和19世纪英国作家奥斯卡·王尔德的作品特别感兴趣,并且因其活泼、有魅力的人生态度和工作方式而受到女王学院同学们的赞赏。她在学院结识了来自南非的作家艾达·贝克,两人成为了一生的朋友。曼斯菲尔德在伦敦生活时并没有参与太多政治活动。例如,当时伦敦有许多女性为女性投票权示威,但曼斯菲尔德并未积极支持英国的妇女参政运动。新西兰的妇女在1893年就获得了投票权。" }, { "label": "第四段", @@ -84,7 +84,7 @@ }, { "questionNumber": 6, - "text": "(6) 题目 6:In London, Mansfield showed little interest in politics. (在伦敦,曼斯菲尔德对政治表现出很少的兴趣。)\n答案:NOT GIVEN\n解析:第三段只提到她没有积极支持妇女参政运动,但这不足以推断她对所有政治都“兴趣不大”。", + "text": "(6) 题目 6:In London, Mansfield showed little interest in politics. (在伦敦,曼斯菲尔德对政治表现出很少的兴趣。)\n答案:TRUE\n解析:第三段明确提到“Mansfield did not become involved in much political activity when she lived in London”,即她在伦敦生活时没有参与太多政治活动,与题目意思一致。", "questionId": "q6" } ], @@ -92,7 +92,7 @@ "start": 1, "end": 6 }, - "text": "答案:FALSE\n解析:第一段明确指出“Katherine Mansfield”是她的笔名(pen name),而她的原名是“Katherine Mansfield Beauchamp Murry”,两者并不完全相同。\n答案:NOT GIVEN\n解析:第二段只提到她的故事“appeared in”(发表在)该杂志上,并未提及是否获奖。\n答案:TRUE\n解析:第二段提到毛利人“were often portrayed in a sympathetic light in her later stories”,即以同情的眼光描绘,这与“favourable way”(有利的方式)意思相符。\n答案:FALSE\n解析:第三段说她在女王学院时,相信自己会专业从事大提琴演奏(take up professionally);而第四段指出,她是在回到新西兰后才下定决心成为职业作家的。\n答案:FALSE\n解析:第三段说她因其“lively and charismatic approach”而受到同学们的“appreciated”(欣赏),与“unpopular”(不受欢迎)相反。\n答案:NOT GIVEN\n解析:第三段只提到她没有积极支持妇女参政运动,但这不足以推断她对所有政治都“兴趣不大”。" + "text": "答案:FALSE\n解析:第一段明确指出“Katherine Mansfield”是她的笔名(pen name),而她的原名是“Katherine Mansfield Beauchamp Murry”,两者并不完全相同。\n答案:NOT GIVEN\n解析:第二段只提到她的故事“appeared in”(发表在)该杂志上,并未提及是否获奖。\n答案:TRUE\n解析:第二段提到毛利人“were often portrayed in a sympathetic light in her later stories”,即以同情的眼光描绘,这与“favourable way”(有利的方式)意思相符。\n答案:FALSE\n解析:第三段说她在女王学院时,相信自己会专业从事大提琴演奏(take up professionally);而第四段指出,她是在回到新西兰后才下定决心成为职业作家的。\n答案:FALSE\n解析:第三段说她因其“lively and charismatic approach”而受到同学们的“appreciated”(欣赏),与“unpopular”(不受欢迎)相反。\n答案:TRUE\n解析:第三段明确提到“Mansfield did not become involved in much political activity when she lived in London”,即她在伦敦生活时没有参与太多政治活动,与题目意思一致。" }, { "sectionTitle": "2. 笔记填空(Questions 7–13)", @@ -105,7 +105,7 @@ }, { "questionNumber": 8, - "text": "(2) 题目 8:first paid writing work was in a publication based in \\___\\___ (第一次有报酬的写作工作是在一份总部位于_\\___\\__的出版物上)\n答案:Australia\n解析:第四段提到她“published in Australia in a magazine called The Native Companion, which was her first paid writing work”。", + "text": "(2) 题目 8:first paid writing work was in a publication based in \\___\\___ (第一次有报酬的写作工作是在一份总部位于_\\___\\__的出版物上)\n答案:Australia\n解析:第四段提到她“published in Australia in a magazine called Native Companion, which was her first paid writing work”。", "questionId": "q8" }, { @@ -115,7 +115,7 @@ }, { "questionNumber": 10, - "text": "(4) 题目 10:\\___\\___ prevented Mansfield and Murry from staying together in Paris (\\___\\___阻止了曼斯菲尔德和穆里在巴黎待在一起)\n答案:bankruptcy\n解析:第六段提到他们留在巴黎的尝试因穆里的“bankruptcy”(破产)而中断。", + "text": "(4) 题目 10:\\___\\___ prevented Mansfield and Murry from staying longer in Paris (\\___\\___阻止了曼斯菲尔德和穆里在巴黎待更久)\n答案:bankruptcy\n解析:第六段提到他们留在巴黎的尝试因穆里的“bankruptcy”(破产)而中断。", "questionId": "q10" }, { diff --git a/assets/generated/reading-explanations/p1-high-194.js b/assets/generated/reading-explanations/p1-high-194.js index 167604be..2a58760d 100644 --- a/assets/generated/reading-explanations/p1-high-194.js +++ b/assets/generated/reading-explanations/p1-high-194.js @@ -25,27 +25,27 @@ }, { "label": "Paragraph 3", - "text": "1331年,国王爱德华三世鼓励来自佛兰德(今比利时地区)的熟练织工到英格兰定居。这些佛兰德织工及其后代在英格兰布料的最终发展中发挥了作用。原毛出口贸易得以恢复,十四世纪上半叶是该产业日益繁荣的时期。随后,1347年至1350年间,欧洲爆发了一场严重的腺鼠疫疫情,这场疫情因贸易中断而严重打击了羊毛产业,且优先级也随之改变。然而,在英格兰,大规模的养羊业从十四世纪中叶起反而有所增长。" + "text": "1331年,国王爱德华三世鼓励来自佛兰德(今比利时地区)的熟练织工到英格兰定居。这些佛兰德织工及其后代推动了英格兰布料的发展。原毛出口贸易恢复,十四世纪上半叶英格兰养羊农户较为繁荣;但长期对法战争和1349年的黑死病造成严重影响,许多村庄人口大量死亡。由于劳动力不足,土地无法继续大量种植农作物,羊群数量反而增加。" }, { "label": "Paragraph 4", - "text": "十五世纪,英格兰从原毛出口国转变为羊毛布料的制造国和出口国。对外国客户而言,从英格兰购买羊毛布料比购买原毛并在自己国内支付可观的布料加工成本更为便宜。英格兰的西郡和东盎格利亚地区——最初是通过普利茅斯和金斯林港口从事原毛贸易的中心——开始生产羊毛布料;随后,英格兰其他地区也加入其中。" + "text": "尽管经历挫折,原毛出口继续扩大,羊毛织物制造也随之发展,并逐渐呈现专业化和地域化。西郡拥有大片牧羊草地、可用于洗毛和染色的软水,以及可驱动机器的水力;约克郡和兰开夏的丘陵地区也有软水和急流,可为缩绒磨坊提供动力。" }, { "label": "Paragraph 5", - "text": "十六世纪期间,许多欧洲织工为逃避宗教迫害而移民英格兰。除了这些新工人的技艺之外,导致布料生产增长的其他因素还包括水力资源的便利获取(因为英格兰东部大部分地区地势平坦,水力可以用于驱动生产布料的机器)。该产业的进步还得益于英格兰境内丰富的煤炭供应。" + "text": "东盎格利亚有软水,但缺少丘陵和急流,不能为缩绒磨坊提供动力。当地利用本地绵羊产出的长而细的羊毛,生产一种不需要缩绒工艺的布料,也就是后来以 Worstead 村命名的 worsted(精纺毛布)。东盎格利亚凭借1331年佛兰德移民传承下来的技术,主导精纺毛布贸易长达四百年。" }, { "label": "Paragraph 6", - "text": "羊毛布料的生产直到十八世纪末工业革命之前,始终是英格兰最重要的产业。到十七世纪末,羊毛产品约占英格兰出口总额的三分之二。1730年至1740年间,一种新型羊毛布料被开发出来:此后,“精纺毛布”的产量不断增加。精纺毛布在英格兰北部的约克郡得到了特别的发展,直至十八世纪末,约克郡的五个主要制造业城镇生产了全英格兰55%的精纺毛布。" + "text": "英国羊毛布料很快获得国际声誉。十四、十五世纪,英格兰从以出口原毛为主转向制造并出口布料;十五世纪末,英格兰被认为很大程度上是一个由养羊农户和布料制造者组成的国家。十六世纪,受宗教迫害的法国织工来到英格兰并带来技术;到十七世纪末,羊毛制造业已占英格兰出口价值的三分之二。到1770年,约克郡的精纺毛布产量追平东盎格利亚,利兹、布拉德福德等主要城镇推动了当地布料制造区的发展。" }, { "label": "Paragraph 7", - "text": "1750年以后,羊毛产业发生了巨大变化。机器——最初用于生产棉布(比生产羊毛简单得多),随后越来越多地用于羊毛——被发明出来。机器的引入给羊毛产业带来了危机:1820年至1830年间,英格兰手工业男女工匠的人数减少了一半以上。1812年,抗议者在约克郡的利兹镇摧毁了机器。尽管如此,机械化的发展意味着十九世纪成为羊毛生产的第二个伟大时期。在约克郡,由于廉价煤炭的可获得性——这是蒸汽驱动机械所必需的——扩张尤为显著。" + "text": "1750年至1850年的工业革命带来了新的变化。源自兰开夏棉纺织业的新发明使纺纱和织布过程机械化并大幅加速,长期未变的生产方法被取代。机械化曾遭到反对,1812年的大规模动乱导致抗议者破坏设备,但最终机器仍然取代了旧方式。" }, { "label": "Paragraph 8", - "text": "另一方面,机器使用的增加导致了羊毛布料制造的专业化程度提高。约克郡专门生产一种布料——精纺毛布;苏格兰专门生产另一种——粗花呢;而西英格兰则专门生产第三种——地毯。因此,到1900年,英格兰的布料产业已与两百年前具有完全不同的特征。" + "text": "十九世纪期间,东盎格利亚等老工业区永久衰落,约克郡因更容易接受机械化而赶超并保持领先。约克郡的发展得到廉价煤炭供应的支持,这些煤炭可用于蒸汽以及后来的电力。其他地区形成专门化生产:苏格兰以粗花呢闻名,西郡则专注生产高质量机织地毯。" } ], "questionExplanations": [ @@ -70,12 +70,12 @@ }, { "questionNumber": 4, - "text": "(4)题目 4:An outbreak of bubonic plague led to a sharp fall in sheep numbers.\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “there was a serious epidemic of bubonic plague... which hit the wool industry badly... In England, however, large-scale sheep farming increased from the mid-fourteenth century onwards”。原文明确说鼠疫严重打击了羊毛产业,但在英格兰,大规模的养羊业反而从十四世纪中叶起有所增长。绵羊数量并未急剧下降,反而增加了,因此题干与原文矛盾,答案为 FALSE。", + "text": "(4)题目 4:An outbreak of bubonic plague led to a sharp fall in sheep numbers.\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “bubonic plague (the Black Death), which in 1349 caused devastation” 以及 “This led to an increase of the sheep flocks”。原文说明黑死病造成大量人口死亡,但其结果是羊群数量增加,因为剩余劳动力不足以继续耕种农作物。因此题干所说“绵羊数量急剧下降”与原文相反,答案为 FALSE。", "questionId": "q4" }, { "questionNumber": 5, - "text": "(5)题目 5:Worsted cloth was cheaper to produce than other types of woollen fabric.\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 6–8,原文多次提及精纺毛布(worsted),但只描述了它的开发时间(1730–1740年间)、产地(约克郡)和专业化发展,未在任何地方将其生产成本与其他类型的羊毛布料进行比较,因此题干信息在文中未被提及,答案为 NOT GIVEN。", + "text": "(5)题目 5:Worsted cloth was cheaper to produce than other types of woollen fabric.\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 5 中 “produce a cloth which did not require the fulling process” 以及 Paragraph 6 中 “output of worsted from Yorkshire equalled that of East Anglia”。原文说明精纺毛布不需要缩绒工艺,并提到约克郡产量追平东盎格利亚,但没有比较精纺毛布与其他羊毛布料的生产成本,因此答案为 NOT GIVEN。", "questionId": "q5" } ], @@ -83,7 +83,7 @@ "start": 1, "end": 5 }, - "text": "题目翻译:将羊毛制成布料的工艺是由罗马人传入英国的。\n答案:FALSE\n解析:定位 Paragraph 1 中 “By the time the Romans invaded in 55 BC the Britons had developed a wool industry”。原文明确说明在公元前55年罗马人入侵时,不列颠人已经发展出了羊毛产业,也就是说,在罗马人到来之前,不列颠人已经掌握了将羊毛制成布料的工艺,而非罗马人传入的,因此题干与原文矛盾,答案为 FALSE。\n题目翻译:在十二世纪,出口羊毛布料不如出口原毛利润高。\n答案:TRUE\n解析:定位 Paragraph 1 中 “By the twelfth century... cloth making was widespread... But the greatest wealth came from exports of raw wool”。原文指出在十二世纪,虽然布料制作已普及,但最大的财富来自原毛出口。这说明原毛出口比布料出口更赚钱,即布料出口不如原毛出口利润高,与题干表述一致,答案为 TRUE。\n题目翻译:统治者对羊毛产业的成功有经济利益。\n答案:TRUE\n解析:定位 Paragraph 2 中 “Kings and their ministers welcomed the revenue that resulted from exports and export taxes”。国王及其大臣们乐于接受出口和出口税所带来的财政收入,这说明统治者的收入直接来自羊毛产业的出口收益,他们对羊毛产业的成功有着明确的经济利益,与题干完全一致,答案为 TRUE。\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “there was a serious epidemic of bubonic plague... which hit the wool industry badly... In England, however, large-scale sheep farming increased from the mid-fourteenth century onwards”。原文明确说鼠疫严重打击了羊毛产业,但在英格兰,大规模的养羊业反而从十四世纪中叶起有所增长。绵羊数量并未急剧下降,反而增加了,因此题干与原文矛盾,答案为 FALSE。\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 6–8,原文多次提及精纺毛布(worsted),但只描述了它的开发时间(1730–1740年间)、产地(约克郡)和专业化发展,未在任何地方将其生产成本与其他类型的羊毛布料进行比较,因此题干信息在文中未被提及,答案为 NOT GIVEN。" + "text": "题目翻译:将羊毛制成布料的工艺是由罗马人传入英国的。\n答案:FALSE\n解析:定位 Paragraph 1 中 “By the time the Romans invaded in 55 BC the Britons had developed a wool industry”。原文明确说明在公元前55年罗马人入侵时,不列颠人已经发展出了羊毛产业,也就是说,在罗马人到来之前,不列颠人已经掌握了将羊毛制成布料的工艺,而非罗马人传入的,因此题干与原文矛盾,答案为 FALSE。\n题目翻译:在十二世纪,出口羊毛布料不如出口原毛利润高。\n答案:TRUE\n解析:定位 Paragraph 1 中 “By the twelfth century... cloth making was widespread... But the greatest wealth came from exports of raw wool”。原文指出在十二世纪,虽然布料制作已普及,但最大的财富来自原毛出口。这说明原毛出口比布料出口更赚钱,即布料出口不如原毛出口利润高,与题干表述一致,答案为 TRUE。\n题目翻译:统治者对羊毛产业的成功有经济利益。\n答案:TRUE\n解析:定位 Paragraph 2 中 “Kings and their ministers welcomed the revenue that resulted from exports and export taxes”。国王及其大臣们乐于接受出口和出口税所带来的财政收入,这说明统治者的收入直接来自羊毛产业的出口收益,他们对羊毛产业的成功有着明确的经济利益,与题干完全一致,答案为 TRUE。\n题目翻译:一场腺鼠疫的爆发导致绵羊数量急剧下降。\n答案:FALSE\n解析:定位 Paragraph 3 中 “bubonic plague (the Black Death), which in 1349 caused devastation” 以及 “This led to an increase of the sheep flocks”。原文说明黑死病造成大量人口死亡,但其结果是羊群数量增加,因为剩余劳动力不足以继续耕种农作物。因此题干所说“绵羊数量急剧下降”与原文相反,答案为 FALSE。\n题目翻译:精纺毛布的生产成本比其他类型的羊毛布料更低。\n答案:NOT GIVEN\n解析:定位 Paragraph 5 中 “produce a cloth which did not require the fulling process” 以及 Paragraph 6 中 “output of worsted from Yorkshire equalled that of East Anglia”。原文说明精纺毛布不需要缩绒工艺,并提到约克郡产量追平东盎格利亚,但没有比较精纺毛布与其他羊毛布料的生产成本,因此答案为 NOT GIVEN。" }, { "sectionTitle": "2. 笔记填空题(Questions 6–13)", @@ -91,42 +91,42 @@ "items": [ { "questionNumber": 6, - "text": "(1)题目 6:16th century: skilled ________ emigrated to England\n题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 5 中 “During the sixteenth century, many European weavers fled from religious persecution and emigrated to England”。十六世纪,许多欧洲织工为逃避宗教迫害而移民英格兰,与题干时间(16世纪)、事件(emigrated to England)完全对应,修饰词 “skilled” 对应原文中 “weavers” 的职业特性,答案为 weavers。", + "text": "(1)题目 6:16th century: skilled ________ emigrated to England\n题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 6 中 “In the sixteenth century, French weavers, persecuted for their Protestant religion, sought refuge in England and took their skills with them”。原文说明十六世纪法国织工因宗教迫害来到英格兰,并把技术带了过去;题干中的 skilled 对应 “took their skills with them”,空格应填职业 weavers。", "questionId": "q6" }, { "questionNumber": 7, - "text": "(2)题目 7:end 17th century: majority of English ________ were wool products\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “By the late seventeenth century woollen products accounted for about two-thirds of English exports”。到十七世纪末,羊毛产品占英国出口总额的三分之二,即英国出口的大部分是羊毛产品。题干中 “majority” 对应原文的 “about two-thirds”,答案为 exports。", + "text": "(2)题目 7:end 17th century: majority of English ________ were wool products\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “by the end of the seventeenth century it comprised two-thirds of the value of its exports”。这里 it 指前文的羊毛制造业,说明到十七世纪末,羊毛产品占英格兰出口价值的三分之二,即英国出口的大部分是羊毛产品,答案为 exports。", "questionId": "q7" }, { "questionNumber": 8, - "text": "(3)题目 8:18th century: production of worsted cloth increased in Yorkshire – growth of five key manufacturing ________\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “until the end of the eighteenth century the five main manufacturing towns in Yorkshire produced 55% of all English worsteds”。十八世纪末前,约克郡五个主要制造业城镇生产了全英格兰55%的精纺毛布,与题干中 “five key manufacturing ________” 完全对应,答案为 towns。", + "text": "(3)题目 8:18th century: production of worsted cloth increased in Yorkshire – growth of five key manufacturing ________\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “By 1770, output of worsted from Yorkshire equalled that of East Anglia” 以及 “with the expansion of major towns: Leeds, Bradford, Halifax, Huddersfield, and Wakefield”。1770年属于十八世纪,原文说明约克郡精纺毛布产量增长,并列出五个主要城镇,因此答案为 towns。", "questionId": "q8" }, { "questionNumber": 9, - "text": "(4)题目 9:1750–1850: new machinery was developed – initially for the production of ________\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “Machines - first for producing cotton (much simpler than producing wool), then increasingly for wool – were invented”。机器最初用于生产棉布,之后才越来越多地用于羊毛生产,与题干中 “initially for the production of” 完全对应,答案为 cotton。", + "text": "(4)题目 9:1750–1850: new machinery was developed – initially for the production of ________\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “new inventions stemming from the Lancashire cotton industry, to mechanize and speed dramatically the processes of spinning and weaving”。原文说明工业革命带来的新发明源自兰开夏棉纺织业,之后才推动纺纱和织布机械化;题干问这些新机械最初用于生产什么,答案为 cotton。", "questionId": "q9" }, { "questionNumber": 10, - "text": "(5)题目 10:1812: protests resulted in the ________ of machinery\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “Machinery was destroyed by protesters in the Yorkshire town of Leeds in 1812”。1812年,抗议者在约克郡的利兹镇摧毁了机器,与题干时间(1812)、事件(protests resulted in...)完全对应,抗议导致的结果是机器的 destruction(摧毁),答案为 destruction。", + "text": "(5)题目 10:1812: protests resulted in the ________ of machinery\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “The widespread unrest of 1812 led to the destruction of equipment by bands of rioters”。原文说明1812年的大规模动乱导致暴动者破坏设备;题干中的 machinery 对应 equipment,空格应填 destruction。", "questionId": "q10" }, { "questionNumber": 11, - "text": "(6)题目 11:19th century: in Yorkshire mechanisation increased, aided by the availability of cheap ________\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 7 中 “In Yorkshire, the expansion was particularly great because of the availability of cheap coal, which was necessary for the steam-driven machines”。约克郡的扩张尤为显著,原因在于廉价煤炭的可获得性——这是蒸汽驱动机械所必需的,与题干中 “availability of cheap ________” 完全对应,答案为 coal。", + "text": "(6)题目 11:19th century: in Yorkshire mechanisation increased, aided by the availability of cheap ________\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 8 中 “They were overtaken by Yorkshire, where machinery was more readily accepted” 以及 “supported by abundant supplies of inexpensive coal”。原文说明约克郡更容易接受机械化,并受到充足廉价煤炭供应的支持;cheap 对应 inexpensive,答案为 coal。", "questionId": "q11" }, { "questionNumber": 12, - "text": "(7)题目 12:Scotland – specialised in ________\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Scotland specialised in another type – tweeds”。苏格兰专门生产粗花呢,与题干 “Scotland – specialised in” 完全对应,答案为 tweeds。", + "text": "(7)题目 12:Scotland – specialised in ________\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Other specialised types of manufacturing developed in Scotland, famed for its tweeds”。原文说明苏格兰发展出专门化制造,并以 tweeds 闻名,因此答案为 tweeds。", "questionId": "q12" }, { "questionNumber": 13, - "text": "(8)题目 13:West Country – specialised in ________\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West of England in a third type – carpets”。西英格兰专门生产地毯,与题干 “West Country – specialised in” 完全对应,答案为 carpets。", + "text": "(8)题目 13:West Country – specialised in ________\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West Country, which focused on the production of high-quality, woven carpets”。原文说明西郡专注生产高质量机织地毯,因此答案为 carpets。", "questionId": "q13" } ], @@ -134,7 +134,7 @@ "start": 6, "end": 13 }, - "text": "题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 5 中 “During the sixteenth century, many European weavers fled from religious persecution and emigrated to England”。十六世纪,许多欧洲织工为逃避宗教迫害而移民英格兰,与题干时间(16世纪)、事件(emigrated to England)完全对应,修饰词 “skilled” 对应原文中 “weavers” 的职业特性,答案为 weavers。\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “By the late seventeenth century woollen products accounted for about two-thirds of English exports”。到十七世纪末,羊毛产品占英国出口总额的三分之二,即英国出口的大部分是羊毛产品。题干中 “majority” 对应原文的 “about two-thirds”,答案为 exports。\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “until the end of the eighteenth century the five main manufacturing towns in Yorkshire produced 55% of all English worsteds”。十八世纪末前,约克郡五个主要制造业城镇生产了全英格兰55%的精纺毛布,与题干中 “five key manufacturing ________” 完全对应,答案为 towns。\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “Machines - first for producing cotton (much simpler than producing wool), then increasingly for wool – were invented”。机器最初用于生产棉布,之后才越来越多地用于羊毛生产,与题干中 “initially for the production of” 完全对应,答案为 cotton。\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “Machinery was destroyed by protesters in the Yorkshire town of Leeds in 1812”。1812年,抗议者在约克郡的利兹镇摧毁了机器,与题干时间(1812)、事件(protests resulted in...)完全对应,抗议导致的结果是机器的 destruction(摧毁),答案为 destruction。\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 7 中 “In Yorkshire, the expansion was particularly great because of the availability of cheap coal, which was necessary for the steam-driven machines”。约克郡的扩张尤为显著,原因在于廉价煤炭的可获得性——这是蒸汽驱动机械所必需的,与题干中 “availability of cheap ________” 完全对应,答案为 coal。\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Scotland specialised in another type – tweeds”。苏格兰专门生产粗花呢,与题干 “Scotland – specialised in” 完全对应,答案为 tweeds。\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West of England in a third type – carpets”。西英格兰专门生产地毯,与题干 “West Country – specialised in” 完全对应,答案为 carpets。" + "text": "题目翻译:16世纪:熟练的________移民到英格兰\n答案:weavers(织工)\n解析:定位 Paragraph 6 中 “In the sixteenth century, French weavers, persecuted for their Protestant religion, sought refuge in England and took their skills with them”。原文说明十六世纪法国织工因宗教迫害来到英格兰,并把技术带了过去;题干中的 skilled 对应 “took their skills with them”,空格应填职业 weavers。\n题目翻译:17世纪末:英国________的大部分是羊毛产品\n答案:exports(出口)\n解析:定位 Paragraph 6 中 “by the end of the seventeenth century it comprised two-thirds of the value of its exports”。这里 it 指前文的羊毛制造业,说明到十七世纪末,羊毛产品占英格兰出口价值的三分之二,即英国出口的大部分是羊毛产品,答案为 exports。\n题目翻译:18世纪:约克郡精纺毛布产量增加——五个关键制造业________的增长\n答案:towns(城镇)\n解析:定位 Paragraph 6 中 “By 1770, output of worsted from Yorkshire equalled that of East Anglia” 以及 “with the expansion of major towns: Leeds, Bradford, Halifax, Huddersfield, and Wakefield”。1770年属于十八世纪,原文说明约克郡精纺毛布产量增长,并列出五个主要城镇,因此答案为 towns。\n题目翻译:1750–1850年:新机器被开发出来——最初用于生产________\n答案:cotton(棉布)\n解析:定位 Paragraph 7 中 “new inventions stemming from the Lancashire cotton industry, to mechanize and speed dramatically the processes of spinning and weaving”。原文说明工业革命带来的新发明源自兰开夏棉纺织业,之后才推动纺纱和织布机械化;题干问这些新机械最初用于生产什么,答案为 cotton。\n题目翻译:1812年:抗议导致了机器的________\n答案:destruction(摧毁)\n解析:定位 Paragraph 7 中 “The widespread unrest of 1812 led to the destruction of equipment by bands of rioters”。原文说明1812年的大规模动乱导致暴动者破坏设备;题干中的 machinery 对应 equipment,空格应填 destruction。\n题目翻译:19世纪:约克郡机械化程度提高,得益于廉价________的可获得性\n答案:coal(煤炭)\n解析:定位 Paragraph 8 中 “They were overtaken by Yorkshire, where machinery was more readily accepted” 以及 “supported by abundant supplies of inexpensive coal”。原文说明约克郡更容易接受机械化,并受到充足廉价煤炭供应的支持;cheap 对应 inexpensive,答案为 coal。\n题目翻译:苏格兰——专门生产________\n答案:tweeds(粗花呢)\n解析:定位 Paragraph 8 中 “Other specialised types of manufacturing developed in Scotland, famed for its tweeds”。原文说明苏格兰发展出专门化制造,并以 tweeds 闻名,因此答案为 tweeds。\n题目翻译:西郡——专门生产________\n答案:carpets(地毯)\n解析:定位 Paragraph 8 中 “the West Country, which focused on the production of high-quality, woven carpets”。原文说明西郡专注生产高质量机织地毯,因此答案为 carpets。" } ] } diff --git a/css/main.css b/css/main.css index 6b83746c..d446c41e 100644 --- a/css/main.css +++ b/css/main.css @@ -4505,9 +4505,14 @@ body.blue-dark-mode .theme-modal-close:hover { background: rgba(28, 28, 28, 0.45); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - -webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 100%); - mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 100%); + /* 末段在 92%→100% 渐隐为透明,使深色 glass 在贴到卡片圆角之前淡出, + 避免在卡片底边留下直边 silhouette 把父级 18px 圆角撑成方角。 */ + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 92%, transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 1) 36%, rgba(0, 0, 0, 1) 92%, transparent 100%); color: #fff; + /* 自身底角圆角与 .theme-card 的 18px 对齐,让 glass layer 不再用直边顶到父级圆角处。 */ + border-radius: 0 0 18px 18px; + overflow: hidden; } .theme-card-header { @@ -7152,6 +7157,229 @@ body.theme-blue ::-webkit-scrollbar-thumb { min-width: 96px; } +.vocab-list-modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(92, 70, 46, 0.35); + backdrop-filter: blur(6px); + z-index: 1200; + padding: 24px; +} + +.vocab-list-modal[hidden] { + display: none; +} + +.vocab-list-modal__backdrop { + position: absolute; + inset: 0; + z-index: 0; +} + +.vocab-list-modal__dialog { + width: min(1120px, 100%); + max-height: min(82vh, 760px); + display: flex; + flex-direction: column; + background: #fffaf2; + border-radius: 18px; + border: 1px solid var(--vocab-line); + box-shadow: var(--vocab-shadow-strong); + color: var(--vocab-ink); + position: relative; + z-index: 1; + overflow: hidden; +} + +.vocab-list-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 22px 14px; + border-bottom: 1px solid rgba(120, 90, 60, 0.16); +} + +.vocab-list-modal__header h3 { + margin: 0; + font-size: 1.25rem; + font-family: var(--vocab-font-title); +} + +.vocab-list-modal__subtitle { + margin: 6px 0 0; + color: var(--vocab-muted); + font-size: 0.9rem; +} + +.vocab-list-modal__toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto auto; + align-items: center; + gap: 12px; + padding: 14px 22px; + border-bottom: 1px solid rgba(120, 90, 60, 0.12); +} + +.vocab-list-modal__toolbar input[type="search"] { + width: 100%; + min-width: 0; + border: 1px solid var(--vocab-line); + border-radius: 10px; + background: var(--vocab-paper-bright); + color: var(--vocab-ink); + padding: 10px 12px; + font-size: 0.95rem; +} + +.vocab-list-modal__toolbar input[type="search"]:focus { + outline: none; + border-color: var(--vocab-accent); + box-shadow: 0 0 0 3px rgba(196, 104, 75, 0.18); +} + +.vocab-list-modal__filter { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + color: var(--vocab-ink); + font-size: 0.9rem; +} + +.vocab-list-modal__filter input { + width: 17px; + height: 17px; +} + +.vocab-list-modal__stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 22px 0; +} + +.vocab-list-modal__stats span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(196, 104, 75, 0.1); + color: var(--vocab-accent-strong); + font-size: 0.86rem; + font-weight: 600; +} + +.vocab-list-modal__body { + flex: 1 1 220px; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 14px 22px 22px; +} + +.vocab-list-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 180px; + color: var(--vocab-muted); + border: 1px dashed var(--vocab-line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.5); +} + +.vocab-list-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid rgba(120, 90, 60, 0.16); + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); +} + +.vocab-list-pagination { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 40px; + padding-top: 8px; + color: var(--vocab-muted); + font-size: 0.86rem; +} + +.vocab-list-pagination .btn { + width: 32px; + height: 32px; + min-height: 32px; +} + +.vocab-list-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + font-size: 0.9rem; +} + +.vocab-list-table th, +.vocab-list-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(120, 90, 60, 0.12); + text-align: left; + vertical-align: top; +} + +.vocab-list-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f7ebdc; + color: var(--vocab-muted); + font-weight: 700; +} + +.vocab-list-table tbody tr:hover { + background: rgba(196, 104, 75, 0.08); +} + +.vocab-list-status { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; +} + +.vocab-list-status--mastered { + background: rgba(44, 143, 98, 0.14); + color: #257a55; +} + +.vocab-list-status--due { + background: rgba(191, 82, 72, 0.14); + color: #a54841; +} + +.vocab-list-status--reviewing { + background: rgba(196, 104, 75, 0.14); + color: var(--vocab-accent-strong); +} + +.vocab-list-status--new { + background: rgba(92, 70, 46, 0.09); + color: var(--vocab-muted); +} + @media (max-width: 768px) { .vocab-settings-modal { padding: 16px; @@ -7161,6 +7389,30 @@ body.theme-blue ::-webkit-scrollbar-thumb { width: 100%; padding: 20px; } + + .vocab-list-modal { + padding: 12px; + align-items: stretch; + } + + .vocab-list-modal__dialog { + max-height: none; + height: 100%; + border-radius: 14px; + } + + .vocab-list-modal__toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .vocab-list-modal__toolbar .btn { + width: 100%; + } + + .vocab-list-modal__body { + padding: 12px; + } } /* Developer Easter Egg - Connected Heart */ @@ -8063,4 +8315,3 @@ body.blue-dark-mode .practice-settings-block__head .hero-panel__muted { } - diff --git a/developer/docs/tmp-feature-migration-audit.md b/developer/docs/tmp-feature-migration-audit.md new file mode 100644 index 00000000..c24ca6ac --- /dev/null +++ b/developer/docs/tmp-feature-migration-audit.md @@ -0,0 +1,205 @@ +# `tmp` 功能迁移审计 + +> 审计分支:`codex/audit-tmp-migration` +> 当前主线基准:`b39cec8` +> `tmp` 历史基线:`eb4af2f`(2026-06-19,数据层重构前) +> 审计日期:2026-07-16 + +## 结论 + +`tmp` 是旧发布目录,不是可直接合并的源码分支。它包含 502 个文件,其中 471 个与 `eb4af2f` 完全一致;`index.html`、`heroui-bridge.css`、`onboarding.css` 和大多数 bundle 都只是旧基线,`css/main.css` 只有换行差异。 + +禁止把 `tmp` 整体覆盖到当前工作树,也禁止直接编辑或复制其中的 generated bundles。真正需要迁移的功能应还原到当前 `js/...` 源文件,并重新运行 `scripts/build-bundles.mjs`。 + +## 真正的功能增量 + +### 1. 结构化阅读笔记 + +旧分支在 `tmp/js/bundles/reading-page.bundle.js` 中实现了: + +- 从原文高亮创建结构化笔记; +- `note = { id, title, body, quote, outlineId, order, createdAt, updatedAt }`; +- Notes drawer、outline 新建/改名/折叠/删除; +- 笔记拖拽归组与排序; +- 可拖动的笔记编辑器; +- 点击笔记回跳原文锚点; +- highlight 快照携带 `noteId`。 + +目标源码: + +- `js/runtime/unifiedReadingPage.js` +- `js/runtime/readingHighlightShared.js` + +### 2. 草稿与提交后注释持久化 + +旧分支保存以下阅读上下文: + +- `answers` +- `highlights` +- `noteText` +- `notes` +- `noteOutlines` +- `markedQuestions` +- `scrollY` + +并在 `pagehide` / `visibilitychange` 时强制 flush。Review 模式会尝试按原记录 id 更新笔记和标注。 + +该行为值得迁移,但旧实现把草稿和 autosaved record 直接写入 `practice_records`,不符合当前架构。阅读练习运行在 iframe 内,不能假设其中存在主窗口的 `PracticeRecordAPI`;迁移后应通过带 window/session token 校验的父子窗口消息分流: + +- 未完成草稿:发送 draft/annotation sync 消息,由父窗口写入独立 draft/meta 存储; +- 已完成记录:沿用 `PRACTICE_COMPLETE`,由父窗口调用 `PracticeRecordAPI.saveCompletion`; +- Review 纯注释更新:新增受校验的 annotation sync 消息,由父窗口 `getById` 后合并,并以原 id 调用 `saveRecord({ updateStats: false })`。 + +### 3. 结果题号回跳原文证据 + +旧分支把结果表题号变为跳转按钮,并根据 explanation locator snippet 或段落标签建立原文定位;无法精确匹配时使用 overlap fallback。 + +该功能可独立于数据层迁移,但锚点必须在文本或 DOM 改动时无损降级,不能错误绑定到其他段落。 + +### 4. 阅读显示控制 + +旧分支在 header 中动态加入: + +- 定位、笔记、高亮显隐; +- 题号导航折叠/展开; +- UI 偏好持久化。 + +这些属于 settings/UI preference,不应写入练习记录。可继续走当前设置仓库或明确的 UI preference key。 + +### 5. 旧分支的补偿逻辑 + +旧分支还实现了: + +- 隐藏 autosaved practice record; +- 按标题、秒级时间、分数等字段对历史记录去重; +- localStorage 与 IndexedDB 的 `practice_records` 合并迁移。 + +这些是旧写入模型造成的补偿,不应原样迁移。当前数据层已经集中处理 canonical record 和 id 去重;应修复生产端,不应依赖展示层掩盖重复记录。 + +## 数据接口重定向 + +当前硬规则:`practice_records` 和 `user_stats` 只能通过 `PracticeRecordAPI` 写入。`dataRepositories.practice` 已不再公开,`PracticeCore.store` 也是只读 public store,raw storage 与 `simpleStorageWrapper` 对受保护 key 的写入会抛错。 + +| 旧接口或行为 | 当前目标接口 | 迁移要求 | +| --- | --- | --- | +| `dataRepositories.practice.list()` | `PracticeRecordAPI.list()` / `listSummary()` | UI 列表优先 summary | +| `practice.getById(id)` | `PracticeRecordAPI.getById(id)` | 直接替换 | +| `practice.upsert(record)` / `PracticeCore.store.savePracticeRecord` | `PracticeRecordAPI.saveRecord(record, options)` | 必须含 canonical `examId` | +| completion payload 自拼记录再保存 | `PracticeRecordAPI.saveCompletion(...)` | 优先让当前 ingestor 建 canonical record | +| overwrite / raw `storage.set('practice_records')` | `PracticeRecordAPI.replace(...)` | 导入时控制 `updateStats` | +| remove / clear | `deleteById`、`deleteMany`、`clear` | suite 仅在明确场景按 sessionId 删除 | +| raw `user_stats` 读写 | `readStats`、`writeStats`、`mergeStats`、`resetStats` | 不迁旧浅合并算法 | +| 旧手写备份接口 | `BackupAPI` | 使用当前 normalize/restore 语义 | +| raw 题库配置和 path map 写入 | `LibraryManager` + `ResourceCore` | 保证配置、内存索引、事件和路径同步 | +| raw 词表写入 | `VocabStore` / `VocabDataIO` | 不绕过 active list 与缓存 | + +### `tmp` 中必须删除的写入路径 + +`tmp/js/bundles/reading-page.bundle.js` 的 `saveLocalReadingRecord` 会先执行 fallback,同时写: + +- `exam_system_practice_records` localStorage; +- `ExamSystemDB` IndexedDB; +- 随后再尝试旧 `PracticeStore` / `PracticeCore`。 + +这会绕过当前单写入口并造成 split-brain、重复记录或覆盖。迁移时必须删除整个 raw IndexedDB/localStorage fallback,不得包装成兼容层。 + +旧页面提交时还会向父窗口发送 `PRACTICE_COMPLETE`,父窗口再执行一次 canonical save,因此原实现存在双写路径。完成记录必须只保留父窗口这一条写入链路。 + +## Canonical schema 需要先扩展 + +当前 `PracticeCore.contracts.standardizeRecord` 尚未完整声明结构化 `notes` / `noteOutlines`。UI 迁移前,应在 canonical contract 中明确以下字段,并同步 completion、suite entry、replay 和 recorder 路径: + +- `highlights: array` +- `markedQuestions: array` +- `noteText: string` +- `notes: array` +- `noteOutlines: array` +- `scrollY: number` + +建议把完整 replay 数据保存在 `realData`,顶层只保留当前消费者确实需要的轻量镜像。`listSummary()` 必须继续剔除完整笔记正文、原文 quote 和重型 replay 数据。 + +Review 更新必须保留原 `id`、`examId`、score、timing 和 completion 状态,并使用 `updateStats: false`,避免编辑笔记时重复累计统计。 + +## 题库内容审计 + +旧分支 manifest 新增 5 篇题目,其中 4 篇当前已经以更新后的 ID/频率存在: + +| `tmp` ID | 当前 ID | 结论 | +| --- | --- | --- | +| `p1-high-242` | `p1-high-240` | 已存在,不重复迁移 | +| `p2-high-243` | `p2-low-240` | 已存在,不重复迁移 | +| `p2-high-244` | `p2-low-242` | 已存在,不重复迁移 | +| `p3-high-241` | `p3-medium-241` | 已存在,不重复迁移 | +| `p3-high-240` Songs of Ourselves | 无 | 旧分支独有内容,不属于功能迁移范围 | + +题库以当前远端主线为唯一权威来源。`Songs of Ourselves` 及其他旧分支独有内容不迁移;也不能批量覆盖 `tmp` explanations,因为审计发现旧解释中存在答案字段与解析结论冲突的样本。 + +以下内容明确拒绝迁移: + +- `tmp` 中未进入 manifest 的孤儿副本; +- 旧分支把本地图改为 postimg CDN 的变更; +- 已在当前主线存在但 ID/难度已修正的 4 篇重复题; +- 缺少对应文件的旧 manifest 项 `p2-high-26`。 + +## 不应迁移的旧版本回退 + +- 不迁 `session.bundle` 中删除 `suiteTimerMode` / `suiteTimerLimitSeconds` 的改动; +- 不迁阅读页删除倒计时警告、自动交卷和当前 timer preference contract 的改动; +- 不整体替换 reading template、`index.html` 或 CSS; +- 不恢复 `dataRepositories.practice`、`PracticeCore` public write methods 或 raw protected-key adapter; +- 不把 generated bundle 当 source of truth。 + +## 推荐实施顺序 + +1. 扩充 canonical annotation/draft schema,并给 `PracticeRecordAPI` 增加对应契约测试。 +2. 在父窗口建立局部 `readingDraftGateway`:草稿走独立受控 draft key,completed/review 走 `PracticeRecordAPI`;iframe 只发送受 token 校验的消息。 +3. 迁移结构化 notes 与 `noteId` highlight snapshot/restore。 +4. 迁移结果定位与显示控制,保持它们与记录存储解耦。 +5. 重建 bundles,执行单元、静态和浏览器回归测试。 + +## 测试门禁 + +至少覆盖以下场景: + +- canonical normalize/save/reload 后 `notes`、`noteOutlines`、`noteId` 不丢失; +- in-progress draft 不出现在 practice history,也不更新统计; +- completed record 只保存一次,reload 后可 replay; +- Review 编辑笔记更新同一 id,score、duration、date 和 stats 不变; +- suite 三段草稿隔离且 timer contract 不回退; +- note quote 找不到或 DOM 变化时不误绑、不丢正文; +- Notes drawer outline 增删改、拖拽排序和键盘操作; +- locator 精确匹配、fallback、无匹配三种路径; +- 伪造或过期 window/session token 的 annotation sync 被拒绝; +- 窄屏 Notes drawer/editor 不被 `z-index: 2000` 的底部 practice nav 遮挡; +- 旧 `#notes-panel` / `#note-btn` 与新 Notes drawer 只有一个权威状态和入口; +- bundle 重建后与源码一致; +- 当前远端题库的 exam/explanation/manifest 内容保持不变。 + +建议复用并扩展: + +- `developer/tests/js/practiceCore.test.js` +- `developer/tests/js/practiceRecorder.test.js` +- `developer/tests/js/practiceRecordPersistence.test.js` +- `developer/tests/js/storageManagerRecords.test.js` +- `developer/tests/js/unifiedReadingCoreRegression.test.js` +- `developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js` +- `developer/tests/e2e/reading_single_flow.node.js` +- `developer/tests/e2e/simulation_roundtrip_restore_regression.py` +- `developer/tests/ci/check_reading_data_integrity.py` + +建议验证命令: + +```powershell +node scripts/build-bundles.mjs +node developer/tests/js/practiceCore.test.js +node developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js +node developer/tests/js/suiteModeRegression.test.js +node developer/tests/js/practiceCompletionFlow.test.js +node developer/tests/js/unifiedReadingCoreRegression.test.js +py developer/tests/ci/run_static_suite.py +node developer/tests/e2e/reading_single_flow.node.js +``` + +审计时的只读基线:`practiceCore.test.js` 13/13、inline suite、suite mode 和 completion flow 均通过;`unifiedReadingCoreRegression.test.js` 在当前主线已有一项失败(`submit should notify host before explanation rendering completes`,实际通知数 `0`、预期 `1`)。迁移前需先把该既有红灯登记或修复,否则无法用它判断迁移回归。 + +本审计所列的结构化笔记、受控草稿、回顾注释同步与数据契约迁移已在本分支实现;旧分支题库内容未迁移。 diff --git a/developer/release.ps1 b/developer/release.ps1 index 2f87a6ef..fc5ad60e 100644 --- a/developer/release.ps1 +++ b/developer/release.ps1 @@ -300,6 +300,7 @@ try { Require-ZipEntry $zipEntries 'index.html' Require-ZipEntry $zipEntries 'css/main.css' Require-ZipEntry $zipEntries 'css/heroui-bridge.css' +Require-ZipEntry $zipEntries 'css/theme-switcher-scroll.css' Require-ZipEntry $zipEntries 'css/onboarding.css' Require-ZipEntry $zipEntries 'assets/vendor/three.min.js' Require-ZipEntry $zipEntries 'assets/generated/reading-exams/manifest.js' @@ -311,13 +312,13 @@ Require-ZipEntry $zipEntries 'js/bundles/legacy-app.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/browse.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/practice.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/session.bundle.js' -Require-ZipEntry $zipEntries 'js/bundles/settings.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/diagnostics.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/more.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/theme.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/reading-page.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/practice-page-enhancer.bundle.js' Require-ZipEntry $zipEntries 'js/bundles/listening-record-bridge.bundle.js' +Require-ZipEntry $zipEntries 'js/bundles/listening-wrapper.bundle.js' if ($IncludeLocalListening -and (Test-Path -LiteralPath (Join-Path $ProjectRoot 'assets/generated/listening-exams/manifest.js'))) { Require-ZipEntry $zipEntries 'assets/generated/listening-exams/manifest.js' diff --git a/developer/release.sh b/developer/release.sh index 672b7bfc..0c53fd58 100644 --- a/developer/release.sh +++ b/developer/release.sh @@ -156,6 +156,7 @@ reject_entry_pattern() { require_entry "index.html" require_entry "css/main.css" require_entry "css/heroui-bridge.css" +require_entry "css/theme-switcher-scroll.css" require_entry "css/onboarding.css" require_entry "assets/vendor/three.min.js" require_entry "assets/generated/reading-exams/manifest.js" @@ -167,13 +168,13 @@ require_entry "js/bundles/legacy-app.bundle.js" require_entry "js/bundles/browse.bundle.js" require_entry "js/bundles/practice.bundle.js" require_entry "js/bundles/session.bundle.js" -require_entry "js/bundles/settings.bundle.js" require_entry "js/bundles/diagnostics.bundle.js" require_entry "js/bundles/more.bundle.js" require_entry "js/bundles/theme.bundle.js" require_entry "js/bundles/reading-page.bundle.js" require_entry "js/bundles/practice-page-enhancer.bundle.js" require_entry "js/bundles/listening-record-bridge.bundle.js" +require_entry "js/bundles/listening-wrapper.bundle.js" if [ "${INCLUDE_LOCAL_LISTENING:-0}" = "1" ] && [ -f "assets/generated/listening-exams/manifest.js" ]; then require_entry "assets/generated/listening-exams/manifest.js" diff --git a/developer/tests/baseline/phase0_baseline_playwright.py b/developer/tests/baseline/phase0_baseline_playwright.py index c4c6c753..d53f7c01 100644 --- a/developer/tests/baseline/phase0_baseline_playwright.py +++ b/developer/tests/baseline/phase0_baseline_playwright.py @@ -145,11 +145,11 @@ async def test_exam_index_loaded(self) -> bool: try: # 检查 examIndex 是否加载 exam_index_loaded = await self.page.evaluate( - "() => window.examIndex && Array.isArray(window.examIndex)" + "async () => Array.isArray(await window.resolveActiveLibraryIndex())" ) if exam_index_loaded: - exam_count = await self.page.evaluate("() => window.examIndex.length") + exam_count = await self.page.evaluate("async () => (await window.resolveActiveLibraryIndex()).length") self.log_result("examIndex 加载", True, f"已加载 {exam_count} 个题目") else: self.log_result("examIndex 加载", False, "examIndex 未加载或格式错误") diff --git a/developer/tests/ci/run_static_suite.py b/developer/tests/ci/run_static_suite.py index dafb7f6b..1eb2024c 100755 --- a/developer/tests/ci/run_static_suite.py +++ b/developer/tests/ci/run_static_suite.py @@ -200,10 +200,11 @@ def _check_index_css_convergence(index_path: Path) -> Tuple[bool, dict]: allowed = { "css/main.css", "css/heroui-bridge.css", + "css/theme-switcher-scroll.css", "css/onboarding.css", } unexpected = sorted([href for href in css_hrefs if href not in allowed]) - missing_required = sorted([href for href in ("css/main.css",) if href not in css_hrefs]) + missing_required = sorted([href for href in allowed if href not in css_hrefs]) passed = not unexpected and not missing_required return passed, { @@ -221,6 +222,18 @@ def _check_build_bundles_no_deleted_refs(build_script: Path) -> Tuple[bool, dict "js/features/overview/overview-runtime.js", "js/runtime/mainRuntime.js", "js/runtime/legacyPublicAPI.js", + "js/utils/storage.js", + "js/core/storageProviderRegistry.js", + "js/data/dataSources/storageDataSource.js", + "js/data/repositories/", + "js/data/index.js", + "js/core/practiceRecordAPI.js", + "js/core/backupAPI.js", + "js/core/practiceStore.js", + "js/utils/stateSerializer.js", + "js/utils/simpleStorageWrapper.js", + "js/core/scoreStorage.js", + "js/patches/runtime-fixes.js", ] try: source = build_script.read_text(encoding="utf-8") @@ -233,6 +246,312 @@ def _check_build_bundles_no_deleted_refs(build_script: Path) -> Tuple[bool, dict "staleRefs": stale_refs, } + +def _check_bundle_outputs_current(build_script: Path) -> Tuple[bool, dict]: + try: + completed = subprocess.run( + ["node", str(build_script), "--check"], + cwd=REPO_ROOT, + check=False, + capture_output=True, + text=True, + timeout=60, + encoding="utf-8", + ) + except Exception as exc: # pragma: no cover - defensive guard + return False, {"error": f"bundle check 执行失败:{exc}"} + output = ((completed.stdout or "") + (completed.stderr or "")).strip() + return completed.returncode == 0, { + "exitCode": completed.returncode, + "output": output, + } + + +def _check_v2_data_architecture() -> Tuple[bool, dict]: + v2_modules = ( + "js/data/v2/dataCatalog.js", + "js/data/v2/dataKernel.js", + "js/data/v2/appData.js", + ) + v2_bundle_names = { + "core-foundation.bundle.js", + "reading-page.bundle.js", + "practice-page-enhancer.bundle.js", + "listening-record-bridge.bundle.js", + "listening-wrapper.bundle.js", + } + removed_v1_markers = ( + "js/utils/storage.js", + "js/core/storageProviderRegistry.js", + "js/data/dataSources/storageDataSource.js", + "js/data/repositories/", + "js/data/index.js", + "js/core/practiceRecordAPI.js", + "js/core/backupAPI.js", + "js/core/practiceStore.js", + "js/utils/stateSerializer.js", + "js/utils/simpleStorageWrapper.js", + "js/core/scoreStorage.js", + "js/patches/runtime-fixes.js", + ) + + bundle_errors: List[str] = [] + for bundle_path in sorted((REPO_ROOT / "js" / "bundles").glob("*.bundle.js")): + source = bundle_path.read_text(encoding="utf-8") + expected = bundle_path.name in v2_bundle_names + for module in v2_modules: + count = source.count(f"/* ===== {module} ===== */") + if count != (1 if expected else 0): + bundle_errors.append(f"{bundle_path.name}:{module}:count={count}") + for marker in removed_v1_markers: + if marker in source: + bundle_errors.append(f"{bundle_path.name}:v1={marker}") + + raw_storage_pattern = re.compile( + r"(?['\"`])(?:{legacy_key_alternatives}|" + r"(?:exam_system_)?(?:(?:exam_index_|exam_path_map__|ielts_sim_draft::|vocab_list_)[^'\"`]*|practice_record_(?!recovery['\"`])[^'\"`]*))" + r"(?P=quote)" + ) + patterns = ( + ("raw-storage", raw_storage_pattern), + ("old-global", old_global_pattern), + ("legacy-state", legacy_state_pattern), + ("legacy-key", legacy_key_pattern), + ) + + def in_region(source: str, offset: int, start_marker: str, end_marker: str) -> bool: + start = source.rfind(start_marker, 0, offset + 1) + if start < 0: + return False + end = source.find(end_marker, start + len(start_marker)) + return end < 0 or offset < end + + def is_allowed(relative: str, label: str, match: re.Match[str], line_text: str, source: str) -> bool: + token = match.group(0) + offset = match.start() + if relative == "js/data/v2/dataKernel.js": + if label == "raw-storage": + return True + if label == "legacy-key": + return in_region( + source, + offset, + "const LEGACY_UNPREFIXED_WEB_KEYS", + "function clone", + ) + if relative == "js/core/externalBackupService.js" and label == "raw-storage": + return True + if relative == "js/core/externalBackupService.js" and label == "old-global" and "ExternalBackupService" in token: + return True + if relative == "js/core/siteDataReset.js" and label == "raw-storage": + return True + if relative == "js/core/siteDataReset.js" and label == "old-global" and "ExternalBackupService" in token: + return True + if relative == "js/presentation/indexInteractions.js" and label == "old-global" and "ExternalBackupService" in token: + return "openModal" in line_text + if relative == "js/data/v2/appData.js": + if label == "raw-storage" and "sessionStorage" in token: + return in_region(source, offset, "const windowSession", "async function readRecovery") + if label == "legacy-key": + return in_region( + source, + offset, + "function extractLegacyPracticeRecords", + "function entityRowFromLayer", + ) or in_region( + source, + offset, + "const LEGACY_DOCUMENT_ALIASES", + "const ready = kernel.initialize", + ) + + if relative == "developer/tests/js/dataKernelV2.test.js" and label == "raw-storage": + return True + if relative == "developer/tests/js/dataKernelV2.test.js" and label == "legacy-key": + return in_region(source, offset, "async function main()", "\nmain().catch") + if relative == "developer/tests/js/legacyMigrationBrickRegression.test.js": + if label == "raw-storage": + return in_region(source, offset, "function harness", "async function run") + if label == "legacy-key": + return in_region(source, offset, "function harness", "\nrun().catch") + if relative == "developer/tests/js/libraryManagerImportConfig.test.js" and label == "legacy-key": + return in_region( + source, + offset, + "async function testBrokenLegacyActiveLibraryFallsBackToReadingManifest", + "\nasync function test", + ) + if relative == "developer/tests/js/appDataV2.test.js" and label == "raw-storage": + return True + if relative == "developer/tests/js/dataLossBaseline.test.js" and label == "raw-storage": + # This harness executes the real AppData/LegacyMigration boundary in a VM. Raw browser + # storage is test-fixture infrastructure here; production and ordinary business tests + # remain subject to the guard. + return True + if relative == "developer/tests/js/suiteSessionRecoveryV2.test.js" and label == "raw-storage": + # The recovery contract deliberately installs throwing Web Storage traps. Any access + # fails the test; persistence itself must go through AppData.recovery.windowSession. + return True + if relative == "developer/tests/js/externalBackupServiceV2.test.js" and label == "raw-storage": + return True + if ( + relative == "developer/tests/js/practiceLightProjectionRenderContract.test.js" + and label == "raw-storage" + ): + # This contract test boots the real dataCatalog/dataKernel/appData stack in a VM so the + # light projection runs as production code. The in-memory localStorage/sessionStorage + # stubs are the kernel's backing store fixture, not business access to raw storage. + return True + if relative == "developer/tests/js/siteDataReset.test.js" and label == "raw-storage": + return True + if relative == "developer/tests/e2e/full_reset_flow.py" and label == "raw-storage": + return True + if relative == "developer/tests/e2e/full_reset_flow.py" and label == "old-global" and "ExternalBackupService" in token: + return True + if relative == "developer/tests/js/appDataV2.test.js" and label == "legacy-key": + return in_region( + source, + offset, + "async function testLegacyImportAdapterNormalizesLibraryProvenance", + "\nasync function test", + ) or in_region( + source, + offset, + "async function testBackupBoundaryAndRestoreReplace", + "\nasync function test", + ) or "VALIDATION" in line_text or ( + "add(" in line_text + and token.strip("'\"`") in {"practice_records", "system_settings"} + ) + if relative == "developer/tests/js/practiceRecorder.test.js" and label == "legacy-key": + return "practice_records" in line_text + if relative == "developer/tests/e2e/fixtures/data-integrity-import-sample.json" and label == "legacy-key": + return token.strip("'\"`") in {"practice_records", "system_settings"} + + if relative == "developer/tests/js/practiceCore.guard.test.js" and label in {"raw-storage", "old-global", "legacy-key"}: + return True + if relative == "developer/tests/js/unifiedReadingNotesMigration.test.js" and label == "raw-storage": + return "doesNotMatch" in line_text + if relative == "developer/tests/js/practiceCustomCard.test.js" and label == "legacy-key": + return "assertNotContains" in line_text + if relative == "developer/tests/e2e/suite_practice_flow.py" and label == "old-global": + return "console_errors" in line_text.lower() or "storagefacade" in line_text.lower() + return False + + candidate_paths = set((REPO_ROOT / "js").rglob("*.js")) + tests_root = REPO_ROOT / "developer" / "tests" + candidate_paths.update( + path for path in tests_root.rglob("*") + if path.is_file() and path.suffix.lower() in {".js", ".py", ".html", ".json"} + and "reports" not in path.relative_to(tests_root).parts + ) + candidate_paths.add(REPO_ROOT / "index.html") + candidate_paths.update((REPO_ROOT / "templates").rglob("*.html")) + generated_root = REPO_ROOT / "assets" / "generated" + if generated_root.exists(): + candidate_paths.update(generated_root.rglob("*.html")) + candidate_paths.discard(Path(__file__).resolve()) + + bundle_marker_pattern = re.compile(r"/\* ===== ([^=]+?) ===== \*/") + source_errors: List[str] = [] + test_state_errors: List[str] = [] + html_errors: List[str] = [] + seen_errors = set() + ordered_candidates = sorted( + candidate_paths, + key=lambda path: ("bundles" in path.parts, path.as_posix()), + ) + for source_path in ordered_candidates: + if not source_path.exists() or not source_path.is_file(): + continue + relative_path = source_path.relative_to(REPO_ROOT) + relative = relative_path.as_posix() + source = source_path.read_text(encoding="utf-8", errors="replace") + bundle_markers = list(bundle_marker_pattern.finditer(source)) if "bundles" in relative_path.parts else [] + for label, pattern in patterns: + for match in pattern.finditer(source): + effective_relative = relative + if bundle_markers: + preceding = next((marker for marker in reversed(bundle_markers) if marker.start() <= match.start()), None) + if preceding: + effective_relative = preceding.group(1).strip() + line = source.count("\n", 0, match.start()) + 1 + line_start = source.rfind("\n", 0, match.start()) + 1 + line_end = source.find("\n", match.end()) + if line_end < 0: + line_end = len(source) + line_text = source[line_start:line_end] + if is_allowed(effective_relative, label, match, line_text, source): + continue + error_key = (effective_relative, label, match.group(0), line_text.strip()) + if error_key in seen_errors: + continue + seen_errors.add(error_key) + mapped = f"=>{effective_relative}" if effective_relative != relative else "" + detail = f"{relative}:{line}{mapped}:{label}:{match.group(0)}" + if relative.startswith("developer/tests/"): + test_state_errors.append(detail) + elif source_path.suffix.lower() == ".html": + html_errors.append(detail) + else: + source_errors.append(detail) + + forbidden_html_scripts = v2_modules + removed_v1_markers + html_candidates = sorted(path for path in candidate_paths if path.suffix.lower() == ".html") + for html_path in html_candidates: + source = html_path.read_text(encoding="utf-8", errors="replace") + for marker in forbidden_html_scripts: + if marker in source: + html_errors.append(f"{html_path.relative_to(REPO_ROOT)}:{marker}") + + injection_source = (REPO_ROOT / "js" / "app" / "examSessionMixin.js").read_text(encoding="utf-8") + listening_wrapper_guard = "doc.documentElement.dataset.listeningWrapper === 'true'" in injection_source + passed = not bundle_errors and not source_errors and not test_state_errors and not html_errors and listening_wrapper_guard + return passed, { + "bundleErrors": bundle_errors[:100], + "sourceErrors": source_errors[:100], + "testStateErrors": test_state_errors[:100], + "htmlErrors": html_errors[:100], + "errorCounts": { + "bundle": len(bundle_errors), + "source": len(source_errors), + "tests": len(test_state_errors), + "html": len(html_errors), + }, + "listeningWrapperGuard": listening_wrapper_guard, + } + def _check_optional_listening_assets_not_bundled(build_script: Path, core_bundle: Path) -> Tuple[bool, dict]: try: build_source = build_script.read_text(encoding="utf-8") @@ -442,15 +761,16 @@ def _check_release_zip_runtime_payload() -> Tuple[bool, dict]: "js/bundles/browse.bundle.js", "js/bundles/practice.bundle.js", "js/bundles/session.bundle.js", - "js/bundles/settings.bundle.js", "js/bundles/diagnostics.bundle.js", "js/bundles/more.bundle.js", "js/bundles/theme.bundle.js", "js/bundles/reading-page.bundle.js", "js/bundles/practice-page-enhancer.bundle.js", "js/bundles/listening-record-bridge.bundle.js", + "js/bundles/listening-wrapper.bundle.js", ]) missing_bundles = sorted(set(expected_bundles) - set(bundled_scripts)) + unexpected_bundles = sorted(set(bundled_scripts) - set(expected_bundles)) forbidden_templates = sorted([name for name in name_set if name == "templates" or name.startswith("templates/")]) forbidden_listening = sorted([ @@ -510,6 +830,7 @@ def _check_release_zip_runtime_payload() -> Tuple[bool, dict]: passed = ( not missing_bundles + and not unexpected_bundles and not forbidden_templates and not forbidden_listening and not unexpected_listening_roots @@ -525,6 +846,7 @@ def _check_release_zip_runtime_payload() -> Tuple[bool, dict]: "zip": str(archive_path.relative_to(REPO_ROOT)).replace("\\", "/"), "bundleCount": len(bundled_scripts), "missingBundles": missing_bundles, + "unexpectedBundles": unexpected_bundles, "forbiddenTemplates": forbidden_templates, "forbiddenListeningPractice": forbidden_listening, "unexpectedListeningRoots": unexpected_listening_roots, @@ -556,13 +878,17 @@ def _check_release_script_runtime_guards(release_script: Path) -> Tuple[bool, di 'require_entry "js/bundles/browse.bundle.js"', 'require_entry "js/bundles/practice.bundle.js"', 'require_entry "js/bundles/session.bundle.js"', - 'require_entry "js/bundles/settings.bundle.js"', 'require_entry "js/bundles/diagnostics.bundle.js"', 'require_entry "js/bundles/more.bundle.js"', 'require_entry "js/bundles/theme.bundle.js"', 'require_entry "js/bundles/reading-page.bundle.js"', 'require_entry "js/bundles/practice-page-enhancer.bundle.js"', 'require_entry "js/bundles/listening-record-bridge.bundle.js"', + 'require_entry "js/bundles/listening-wrapper.bundle.js"', + 'require_entry "css/main.css"', + 'require_entry "css/heroui-bridge.css"', + 'require_entry "css/theme-switcher-scroll.css"', + 'require_entry "css/onboarding.css"', 'LISTENING_EXCLUDE_PATTERNS=("assets/generated/listening-exams/" "assets/generated/listening-exams/*" "ListeningPractice/" "ListeningPractice/*")', 'if [ "${INCLUDE_LOCAL_LISTENING:-0}" = "1" ]; then', 'INCLUDE_LOCAL_LISTENING=1 requires both assets/generated/listening-exams/manifest.js and listening-index.compat.js', @@ -876,20 +1202,25 @@ def _check_settings_tools_split() -> Tuple[bool, dict]: return False, {"error": f"读取失败:{exc}"} required = { - "buildSettingsBundle": "'js/bundles/settings.bundle.js'" in build_source, - "settingsHasDataIntegrityManager": "'js/components/DataIntegrityManager.js'" in build_source, - "settingsHasDataBackupManager": "'js/utils/dataBackupManager.js'" in build_source, - "loaderManifest": "manifest['settings-tools']" in loader_source, + "loaderCompatibilityGroup": "manifest['settings-tools'] = [];" in loader_source, "loaderDependency": "dependencies['settings-tools'] = ['state-core'];" in loader_source, - "moreDependsOnSettings": "dependencies['more-tools'] = ['state-core', 'settings-tools'];" in loader_source, - "diagnosticsDependsOnSettings": "dependencies['diagnostics-tools'] = ['state-core', 'settings-tools'];" in loader_source, - "fallbackUsesSettingsTools": "ensureGroup('settings-tools')" in fallback_source, - "examActionsUsesSettingsTools": "ensureGroup('settings-tools')" in actions_source, + "moreUsesStateCore": "dependencies['more-tools'] = ['state-core'];" in loader_source, + "diagnosticsUsesStateCore": "dependencies['diagnostics-tools'] = ['state-core'];" in loader_source, + "fallbackUsesAppDataBackups": "AppData.backups" in fallback_source, + "fallbackImportBackupAfterPreview": ( + fallback_source.find("const preview = await window.AppData.backups.previewImport") + < fallback_source.find("const backup = await window.AppData.backups.create({ type: 'pre-import' })") + < fallback_source.find("const result = await window.AppData.backups.commitImport") + ), + "examActionsUsesAppDataBackups": "AppData.backups" in actions_source, } forbidden = { - "fallbackLoadsBrowseForDataIntegrity": "ensureGroup('browse-runtime')" in fallback_source, - "examActionsLoadsDiagnosticsForDataIntegrity": "ensureGroup('diagnostics-tools')" in actions_source, + "buildSettingsBundle": "'js/bundles/settings.bundle.js'" in build_source, + "dataIntegrityManager": "DataIntegrityManager" in (build_source + fallback_source + actions_source), + "dataBackupManager": "DataBackupManager" in (build_source + fallback_source + actions_source), + "moreDependsOnSettings": "dependencies['more-tools'] = ['state-core', 'settings-tools'];" in loader_source, + "diagnosticsDependsOnSettings": "dependencies['diagnostics-tools'] = ['state-core', 'settings-tools'];" in loader_source, } passed = all(required.values()) and not any(forbidden.values()) @@ -916,12 +1247,13 @@ def _check_practice_recorder_synthetic_guard(recorder_path: Path) -> Tuple[bool, return True, "已检测到生产环境 synthetic 会话保护逻辑" -def _collect_mixin_methods(app_dir: Path) -> List[str]: +def _collect_exam_app_methods(app_dir: Path) -> List[str]: pattern = re.compile(r"^\s{8}(?:async\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.MULTILINE) reserved = {"if", "for", "while", "switch", "catch", "function", "return"} methods = set() - for mixin_path in sorted(app_dir.glob("*Mixin.js")): + source_paths = [app_dir.parent / "app.js", *sorted(app_dir.glob("*Mixin.js"))] + for mixin_path in source_paths: try: content = mixin_path.read_text(encoding="utf-8") except Exception: # pragma: no cover - defensive guard @@ -1443,6 +1775,12 @@ def run_checks() -> Tuple[List[dict], bool]: build_ref_passed, build_ref_detail = _check_build_bundles_no_deleted_refs(build_script) results.append(_format_result("build-bundles 删除脚本引用守卫", build_ref_passed, build_ref_detail)) all_passed &= build_ref_passed + bundle_current_passed, bundle_current_detail = _check_bundle_outputs_current(build_script) + results.append(_format_result("bundle 产物原文同步守卫", bundle_current_passed, bundle_current_detail)) + all_passed &= bundle_current_passed + v2_arch_passed, v2_arch_detail = _check_v2_data_architecture() + results.append(_format_result("v2 数据架构唯一入口守卫", v2_arch_passed, v2_arch_detail)) + all_passed &= v2_arch_passed optional_listening_bundle_passed, optional_listening_bundle_detail = _check_optional_listening_assets_not_bundled( build_script, REPO_ROOT / "js" / "bundles" / "core-foundation.bundle.js", @@ -1497,11 +1835,12 @@ def run_checks() -> Tuple[List[dict], bool]: results.append(_format_result("E2E 导入样例数据", fixture_passed, fixture_detail)) all_passed &= fixture_passed - app_e2e_script = REPO_ROOT / "developer" / "tests" / "js" / "e2e" / "appE2ETest.js" - if app_e2e_script.exists(): - snippet_passed, snippet_detail = _check_contains(app_e2e_script, "testDataIntegrityImportFlow") - results.append(_format_result("E2E 导入测试存在性", snippet_passed, snippet_detail)) - all_passed &= snippet_passed + app_data_test = REPO_ROOT / "developer" / "tests" / "js" / "appDataV2.test.js" + if app_data_test.exists(): + for api_name in ("previewImport", "commitImport", "restore"): + snippet_passed, snippet_detail = _check_contains(app_data_test, api_name) + results.append(_format_result(f"AppData v2 导入恢复契约: {api_name}", snippet_passed, snippet_detail)) + all_passed &= snippet_passed interaction_path = REPO_ROOT / "developer" / "tests" / "js" / "e2e" / "interactionTargets.js" targets, detail = _load_interaction_targets(interaction_path) @@ -1562,7 +1901,7 @@ def run_checks() -> Tuple[List[dict], bool]: browse_toggle_checks = { "校验触发按钮": "browse-title-trigger", "校验红点显隐": "browse-title-dot", - "校验偏好写回": "browse_view_preferences_v2", + "校验偏好写回": "AppData.preferences.getBrowse", } for label, snippet in browse_toggle_checks.items(): check_passed, check_detail = _check_contains(browse_toggle_e2e, snippet) @@ -1600,36 +1939,55 @@ def run_checks() -> Tuple[List[dict], bool]: results.append(_format_result(f"Unified E2E Runner: {label}", check_passed, check_detail)) all_passed &= check_passed - data_layer_files = [ - REPO_ROOT / "js" / "data" / "dataSources" / "storageDataSource.js", + v2_data_layer_files = [ + REPO_ROOT / "js" / "data" / "v2" / name + for name in ("dataCatalog.js", "dataKernel.js", "appData.js") ] - data_layer_files.extend( - REPO_ROOT / "js" / "data" / "repositories" / name - for name in ( - "baseRepository.js", - "dataRepositoryRegistry.js", - "practiceRepository.js", - "settingsRepository.js", - "backupRepository.js", - "metaRepository.js", - ) - ) - data_layer_files.append(REPO_ROOT / "js" / "data" / "index.js") - for path in data_layer_files: + removed_migration = REPO_ROOT / "js" / "data" / "v2" / "legacyMigration.js" + if removed_migration.exists(): + results.append(_format_result( + "v2 不得保留 legacyMigration.js", + False, + {"path": str(removed_migration.relative_to(REPO_ROOT))} + )) + all_passed = False + else: + results.append(_format_result("v2 不得保留 legacyMigration.js", True, {"absent": True})) + for path in v2_data_layer_files: file_passed, file_detail = _ensure_exists(path) - results.append(_format_result(f"数据层资产 {path.name}", file_passed, file_detail)) + results.append(_format_result(f"v2 数据层资产 {path.name}", file_passed, file_detail)) all_passed &= file_passed - registry_path = REPO_ROOT / "js" / "core" / "storageProviderRegistry.js" - registry_exists, registry_detail = _ensure_exists(registry_path) - results.append(_format_result("StorageProviderRegistry 存在性", registry_exists, registry_detail)) - all_passed &= registry_exists - - simple_wrapper = REPO_ROOT / "js" / "utils" / "simpleStorageWrapper.js" - wrapper_passed, wrapper_detail = _check_contains(simple_wrapper, "dataRepositories") - results.append(_format_result("simpleStorageWrapper 适配数据仓库", wrapper_passed, wrapper_detail)) - all_passed &= wrapper_passed - + removed_v1_data_files = [ + REPO_ROOT / relative_path + for relative_path in ( + "js/utils/storage.js", + "js/core/storageProviderRegistry.js", + "js/data/dataSources/storageDataSource.js", + "js/data/repositories/baseRepository.js", + "js/data/repositories/dataRepositoryRegistry.js", + "js/data/repositories/practiceRepository.js", + "js/data/repositories/settingsRepository.js", + "js/data/repositories/backupRepository.js", + "js/data/repositories/metaRepository.js", + "js/data/index.js", + "js/core/practiceRecordAPI.js", + "js/core/backupAPI.js", + "js/core/practiceStore.js", + "js/utils/stateSerializer.js", + "js/utils/simpleStorageWrapper.js", + "js/core/scoreStorage.js", + "js/patches/runtime-fixes.js", + ) + ] + for path in removed_v1_data_files: + file_passed = not path.exists() + results.append(_format_result( + f"v1 数据层已清退 {path.name}", + file_passed, + "文件不存在" if file_passed else f"旧架构文件仍存在: {path}", + )) + all_passed &= file_passed on_demand_ok, on_demand_detail = _check_main_entry_on_demand(main_entry) results.append(_format_result("main-entry 严格按需启动策略", on_demand_ok, on_demand_detail)) all_passed &= on_demand_ok @@ -1783,7 +2141,7 @@ def run_checks() -> Tuple[List[dict], bool]: results.append(_format_result("Mixin 方法契约覆盖", False, f"无法解析契约:{exc}")) all_passed = False else: - actual_methods = set(_collect_mixin_methods(REPO_ROOT / "js" / "app")) + actual_methods = set(_collect_exam_app_methods(REPO_ROOT / "js" / "app")) missing = sorted(expected_set - actual_methods) extras = sorted(actual_methods - expected_set) coverage_passed = len(missing) == 0 @@ -1850,34 +2208,42 @@ def run_checks() -> Tuple[List[dict], bool]: suite_regression_test = REPO_ROOT / "developer" / "tests" / "js" / "suiteModeRegression.test.js" if suite_regression_test.exists(): - try: - completed_suite_regression = subprocess.run( - ["node", str(suite_regression_test)], - check=True, - capture_output=True, - text=True, - encoding="utf-8" - ) - except subprocess.CalledProcessError as exc: - output_text = (exc.stdout or "") + (exc.stderr or "") + str(exc) + suite_regression_ok, suite_regression_payload = _run_json_subprocess( + ["node", str(suite_regression_test)], + timeout=60, + parse_mode="last-line", + ) + if not suite_regression_ok: suite_regression_passed = False - suite_regression_detail = f"执行失败: {output_text.strip()}" + suite_regression_detail = suite_regression_payload else: - raw_suite_regression = (completed_suite_regression.stdout or "").strip() or (completed_suite_regression.stderr or "").strip() - try: - suite_regression_payload = json.loads(raw_suite_regression or "{}") - except json.JSONDecodeError as parse_error: - suite_regression_passed = False - suite_regression_detail = f"输出解析失败: {parse_error}" - else: - suite_regression_passed = suite_regression_payload.get("status") == "pass" - suite_regression_detail = suite_regression_payload.get("detail", suite_regression_payload) + suite_regression_passed = suite_regression_payload.get("status") == "pass" + suite_regression_detail = suite_regression_payload.get("detail", suite_regression_payload) results.append(_format_result("套题模式状态机回归测试", suite_regression_passed, suite_regression_detail)) all_passed &= suite_regression_passed else: results.append(_format_result("套题模式状态机回归测试", False, "测试脚本缺失")) all_passed = False + suite_recovery_test = REPO_ROOT / "developer" / "tests" / "js" / "suiteSessionRecoveryV2.test.js" + if suite_recovery_test.exists(): + suite_recovery_ok, suite_recovery_payload = _run_json_subprocess( + ["node", str(suite_recovery_test)], + timeout=60, + parse_mode="last-line", + ) + if not suite_recovery_ok: + suite_recovery_passed = False + suite_recovery_detail = suite_recovery_payload + else: + suite_recovery_passed = suite_recovery_payload.get("status") == "pass" + suite_recovery_detail = suite_recovery_payload.get("detail", suite_recovery_payload) + results.append(_format_result("套题 v2 恢复状态机回归测试", suite_recovery_passed, suite_recovery_detail)) + all_passed &= suite_recovery_passed + else: + results.append(_format_result("套题 v2 恢复状态机回归测试", False, "测试脚本缺失")) + all_passed = False + simulation_nb_drag_test = REPO_ROOT / "developer" / "tests" / "e2e" / "simulation_nb_drag_regression.py" if simulation_nb_drag_test.exists(): try: @@ -2071,11 +2437,111 @@ def run_checks() -> Tuple[List[dict], bool]: results.append(_format_result("全量题库记录匹配测试", False, "测试脚本缺失")) all_passed = False - practice_core_test = REPO_ROOT / "developer" / "tests" / "js" / "practiceCore.test.js" - if practice_core_test.exists(): + v2_data_tests = [ + ("DataKernel v2 行为测试", REPO_ROOT / "developer" / "tests" / "js" / "dataKernelV2.test.js"), + ("AppData v2 领域测试", REPO_ROOT / "developer" / "tests" / "js" / "appDataV2.test.js"), + ("v2 本地磁盘备份测试", REPO_ROOT / "developer" / "tests" / "js" / "externalBackupServiceV2.test.js"), + ("站点全量重置测试", REPO_ROOT / "developer" / "tests" / "js" / "siteDataReset.test.js"), + ("v2 数据丢失底线测试", REPO_ROOT / "developer" / "tests" / "js" / "dataLossBaseline.test.js"), + ( + "练习记录 light 投影与渲染过滤契约测试", + REPO_ROOT / "developer" / "tests" / "js" / "practiceLightProjectionRenderContract.test.js", + ), + ] + for test_name, test_path in v2_data_tests: + if not test_path.exists(): + results.append(_format_result(test_name, False, "测试脚本缺失")) + all_passed = False + continue + try: + completed_v2 = subprocess.run( + ["node", str(test_path)], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + timeout=180, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + output_text = (getattr(exc, "stdout", "") or "") + (getattr(exc, "stderr", "") or "") + str(exc) + v2_passed = False + v2_detail = f"执行失败: {output_text.strip()}" + else: + v2_passed = True + output_text = (completed_v2.stdout or "").strip() or (completed_v2.stderr or "").strip() + v2_detail = output_text.splitlines()[-1] if output_text else "通过" + results.append(_format_result(test_name, v2_passed, v2_detail)) + all_passed &= v2_passed + + security_regression_tests = [ + ( + "听力 bridge 纯数据解析安全测试", + REPO_ROOT / "developer" / "tests" / "js" / "listeningRecordBridgeParser.test.js", + ), + ( + "听力 bridge INIT 前提交与 ACK 重试协议测试", + REPO_ROOT / "developer" / "tests" / "js" / "listeningRecordBridgeProtocol.test.js", + ), + ( + "阅读高亮生词 requestId ACK/FAILED 测试", + REPO_ROOT / "developer" / "tests" / "js" / "reviewHighlightDictionaryProtocol.test.js", + ), + ( + "自动化 UA 不得隐式开启 synthetic 测试环境", + REPO_ROOT / "developer" / "tests" / "js" / "environmentDetector.test.js", + ), + ( + "宿主窗口消息 origin/source/token 负向测试", + REPO_ROOT / "developer" / "tests" / "js" / "readingAnnotationHostProtocol.test.js", + ), + ( + "统一阅读子窗控制消息负向测试", + REPO_ROOT / "developer" / "tests" / "js" / "unifiedReadingPageInlineSuiteRegression.test.js", + ), + ] + for test_name, test_path in security_regression_tests: + if not test_path.exists(): + results.append(_format_result(test_name, False, "测试脚本缺失")) + all_passed = False + continue + test_passed, test_detail = _run_json_subprocess( + ["node", str(test_path)], + timeout=30, + parse_mode="last-line", + ) + results.append(_format_result(test_name, test_passed, test_detail)) + all_passed &= test_passed + + full_reset_browser_test = REPO_ROOT / "developer" / "tests" / "e2e" / "full_reset_flow.py" + if full_reset_browser_test.exists(): try: - completed_practice_core = subprocess.run( - ["node", str(practice_core_test)], + completed_full_reset = subprocess.run( + [sys.executable, str(full_reset_browser_test)], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + output_text = (getattr(exc, "stdout", "") or "") + (getattr(exc, "stderr", "") or "") + str(exc) + full_reset_passed = False + full_reset_detail = f"执行失败: {output_text.strip()}" + else: + full_reset_passed = True + output_text = (completed_full_reset.stdout or "").strip() or (completed_full_reset.stderr or "").strip() + full_reset_detail = output_text.splitlines()[-1] if output_text else "通过" + results.append(_format_result("浏览器全量重置与 GPL 回归测试", full_reset_passed, full_reset_detail)) + all_passed &= full_reset_passed + else: + results.append(_format_result("浏览器全量重置与 GPL 回归测试", False, "测试脚本缺失")) + all_passed = False + + practice_stress_test = REPO_ROOT / "developer" / "tests" / "js" / "practiceRecordStress.test.js" + if practice_stress_test.exists(): + try: + completed_practice_stress = subprocess.run( + ["node", str(practice_stress_test), "--ci"], check=True, capture_output=True, text=True, @@ -2083,22 +2549,22 @@ def run_checks() -> Tuple[List[dict], bool]: ) except subprocess.CalledProcessError as exc: output_text = (exc.stdout or "") + (exc.stderr or "") + str(exc) - practice_core_passed = False - practice_core_detail = f"执行失败: {output_text.strip()}" + practice_stress_passed = False + practice_stress_detail = f"执行失败: {output_text.strip()}" else: - raw_practice_core_output = (completed_practice_core.stdout or "").strip() or (completed_practice_core.stderr or "").strip() + raw_practice_stress_output = (completed_practice_stress.stdout or "").strip() or (completed_practice_stress.stderr or "").strip() try: - practice_core_payload = json.loads(raw_practice_core_output or "{}") + practice_stress_payload = json.loads(raw_practice_stress_output or "{}") except json.JSONDecodeError as parse_error: - practice_core_passed = False - practice_core_detail = f"输出解析失败: {parse_error}" + practice_stress_passed = False + practice_stress_detail = f"输出解析失败: {parse_error}" else: - practice_core_passed = practice_core_payload.get("status") == "pass" - practice_core_detail = practice_core_payload.get("detail", practice_core_payload) - results.append(_format_result("PracticeCore 单元测试", practice_core_passed, practice_core_detail)) - all_passed &= practice_core_passed + practice_stress_passed = practice_stress_payload.get("status") == "pass" + practice_stress_detail = practice_stress_payload.get("detail", practice_stress_payload) + results.append(_format_result("练习记录与批注压力测试", practice_stress_passed, practice_stress_detail)) + all_passed &= practice_stress_passed else: - results.append(_format_result("PracticeCore 单元测试", False, "测试脚本缺失")) + results.append(_format_result("练习记录与批注压力测试", False, "测试脚本缺失")) all_passed = False practice_recorder_test = REPO_ROOT / "developer" / "tests" / "js" / "practiceRecorder.test.js" @@ -2334,36 +2800,6 @@ def run_checks() -> Tuple[List[dict], bool]: results.append(_format_result("按需入口回归测试", False, "测试脚本缺失")) all_passed = False - service_facade_test = REPO_ROOT / "developer" / "tests" / "js" / "serviceFacade.test.js" - if service_facade_test.exists(): - try: - completed_service_facade = subprocess.run( - ["node", str(service_facade_test)], - check=True, - capture_output=True, - text=True, - encoding="utf-8" - ) - except subprocess.CalledProcessError as exc: - output_text = (exc.stdout or "") + (exc.stderr or "") + str(exc) - service_facade_passed = False - service_facade_detail = f"执行失败: {output_text.strip()}" - else: - raw_service_facade_output = (completed_service_facade.stdout or "").strip() or (completed_service_facade.stderr or "").strip() - try: - service_facade_payload = json.loads(raw_service_facade_output or "{}") - except json.JSONDecodeError as parse_error: - service_facade_passed = False - service_facade_detail = f"输出解析失败: {parse_error}" - else: - service_facade_passed = service_facade_payload.get("status") == "pass" - service_facade_detail = service_facade_payload.get("detail", service_facade_payload) - results.append(_format_result("服务门面回归测试", service_facade_passed, service_facade_detail)) - all_passed &= service_facade_passed - else: - results.append(_format_result("服务门面回归测试", False, "测试脚本缺失")) - all_passed = False - exam_filter_service_test = REPO_ROOT / "developer" / "tests" / "js" / "examFilterService.test.js" if exam_filter_service_test.exists(): try: @@ -2472,41 +2908,10 @@ def run_checks() -> Tuple[List[dict], bool]: results.append(_format_result("阅读高亮本地词典测试", False, "测试脚本缺失")) all_passed = False - practice_core_app_state_sync_test = REPO_ROOT / "developer" / "tests" / "js" / "practiceCoreAppStateSync.test.js" - if practice_core_app_state_sync_test.exists(): - try: - completed_practice_core_app_state_sync = subprocess.run( - ["node", str(practice_core_app_state_sync_test)], - check=True, - capture_output=True, - text=True, - encoding="utf-8" - ) - except subprocess.CalledProcessError as exc: - output_text = (exc.stdout or "") + (exc.stderr or "") + str(exc) - practice_core_app_state_sync_passed = False - practice_core_app_state_sync_detail = f"执行失败: {output_text.strip()}" - else: - raw_app_state_output = (completed_practice_core_app_state_sync.stdout or "").strip() or (completed_practice_core_app_state_sync.stderr or "").strip() - try: - practice_core_app_state_sync_payload = json.loads(raw_app_state_output or "{}") - except json.JSONDecodeError as parse_error: - practice_core_app_state_sync_passed = False - practice_core_app_state_sync_detail = f"输出解析失败: {parse_error}" - else: - practice_core_app_state_sync_passed = practice_core_app_state_sync_payload.get("status") == "pass" - practice_core_app_state_sync_detail = practice_core_app_state_sync_payload.get("detail", practice_core_app_state_sync_payload) - results.append(_format_result("PracticeCore app.state 同步测试", practice_core_app_state_sync_passed, practice_core_app_state_sync_detail)) - all_passed &= practice_core_app_state_sync_passed - else: - results.append(_format_result("PracticeCore app.state 同步测试", False, "测试脚本缺失")) - all_passed = False - # Integration tests deprecated_reading_source_dir = REPO_ROOT / "developer" / "reading-exams" integration_tests = [ ("Reading migration snapshot integration test", REPO_ROOT / "developer" / "tests" / "js" / "integration" / "readingMigrationSnapshot.test.js"), - ("多套题提交流程集成测试", REPO_ROOT / "developer" / "tests" / "js" / "integration" / "multiSuiteSubmission.test.js"), ("拼写错误收集流程集成测试", REPO_ROOT / "developer" / "tests" / "js" / "integration" / "spellingErrorCollection.test.js"), ("词表切换流程集成测试", REPO_ROOT / "developer" / "tests" / "js" / "integration" / "vocabListSwitching.test.js"), ("Vocab session view flow integration test", REPO_ROOT / "developer" / "tests" / "js" / "integration" / "vocabSessionView.test.js"), diff --git a/developer/tests/e2e/README_LISTENING_PRACTICE.md b/developer/tests/e2e/README_LISTENING_PRACTICE.md index 10f7938c..e60705b5 100644 --- a/developer/tests/e2e/README_LISTENING_PRACTICE.md +++ b/developer/tests/e2e/README_LISTENING_PRACTICE.md @@ -131,10 +131,9 @@ E2E测试完成 └── suite_practice_flow.py (现有) ↑ 集成测试 (Node.js) - ├── multiSuiteSubmission.test.js ├── spellingErrorCollection.test.js ├── vocabListSwitching.test.js - └── performance.benchmark.js + └── vocabSessionView.test.js ↑ 单元测试 (Node.js) ├── answerSanitizer.test.js diff --git a/developer/tests/e2e/browse_preference_toggle_flow.py b/developer/tests/e2e/browse_preference_toggle_flow.py index fa4244c2..85568d8b 100644 --- a/developer/tests/e2e/browse_preference_toggle_flow.py +++ b/developer/tests/e2e/browse_preference_toggle_flow.py @@ -31,25 +31,20 @@ async def _dismiss_overlays(page) -> None: - try: - await page.evaluate( - """ - () => { - try { - localStorage.setItem('hasSeenGplLicense', 'true'); - } catch (_) {} - if (typeof window.acceptGplLicense === 'function') { - try { window.acceptGplLicense(); } catch (_) {} - } - const modal = document.getElementById('license-modal'); - if (modal) { - modal.classList.remove('show'); - } + accepted = await page.evaluate( + """async () => { + if (!window.LicenseModal || typeof window.LicenseModal.accept !== 'function') { + throw new Error('LicenseModal.accept is unavailable'); } - """ - ) - except Exception: - pass + return window.LicenseModal.accept(); + }""" + ) + if not accepted: + raise RuntimeError("GPL license consent was not committed") + await page.wait_for_function( + "() => !document.getElementById('license-modal')?.classList.contains('show')", + timeout=5000, + ) overlay = page.locator("#library-loader-overlay") if await overlay.count(): @@ -73,10 +68,9 @@ async def run() -> None: page = await browser.new_page() await page.goto(INDEX_URL) await page.wait_for_load_state("load") - await page.wait_for_function( - "() => window.app && window.app.isInitialized && window.storage && typeof window.storage.get === 'function'", - timeout=60000, - ) + await page.wait_for_function("() => !!window.AppData", timeout=60000) + await page.evaluate("async () => { await window.AppData.ready; }") + await page.wait_for_function("() => window.app?.isInitialized === true", timeout=60000) await _dismiss_overlays(page) await page.locator("nav button[data-view='browse']").click() @@ -98,22 +92,21 @@ async def run() -> None: async def read_state() -> dict: return await page.evaluate( """ - () => { + async () => { const trigger = document.getElementById('browse-title-trigger'); const dot = trigger ? trigger.querySelector('.browse-title-dot') : null; const style = dot ? window.getComputedStyle(dot) : null; - let pref = null; - try { - const raw = localStorage.getItem('browse_view_preferences_v2'); - pref = raw ? JSON.parse(raw).autoScrollEnabled : null; - } catch (_) { pref = null; } + if (typeof window.flushBrowsePreferenceWrites === 'function') { + await window.flushBrowsePreferenceWrites(); + } + const pref = await window.AppData.preferences.getBrowse(); return { hasTrigger: !!trigger, hasDot: !!dot, activeClass: !!(trigger && trigger.classList.contains('active')), ariaPressed: trigger ? trigger.getAttribute('aria-pressed') : null, dotOpacity: style ? style.opacity : null, - autoScrollEnabled: pref, + autoScrollEnabled: pref?.autoScrollEnabled ?? null, }; } """ @@ -123,12 +116,28 @@ async def read_state() -> dict: report["steps"].append({"name": "before", "state": before}) await page.click("#browse-title-trigger") - await page.wait_for_timeout(120) + await page.wait_for_function( + """() => { + const trigger = document.getElementById('browse-title-trigger'); + const dot = trigger?.querySelector('.browse-title-dot'); + return trigger?.getAttribute('aria-pressed') === 'false' + && Number.parseFloat(getComputedStyle(dot).opacity || '1') < 0.2; + }""", + timeout=2000, + ) after_first = await read_state() report["steps"].append({"name": "after_first_click", "state": after_first}) await page.click("#browse-title-trigger") - await page.wait_for_timeout(120) + await page.wait_for_function( + """() => { + const trigger = document.getElementById('browse-title-trigger'); + const dot = trigger?.querySelector('.browse-title-dot'); + return trigger?.getAttribute('aria-pressed') === 'true' + && Number.parseFloat(getComputedStyle(dot).opacity || '0') > 0.8; + }""", + timeout=2000, + ) after_second = await read_state() report["steps"].append({"name": "after_second_click", "state": after_second}) diff --git a/developer/tests/e2e/dynamic_library_loading_optional.py b/developer/tests/e2e/dynamic_library_loading_optional.py index a3f40b02..a0b87fb3 100644 --- a/developer/tests/e2e/dynamic_library_loading_optional.py +++ b/developer/tests/e2e/dynamic_library_loading_optional.py @@ -67,24 +67,28 @@ async def launch_browser(playwright) -> Browser: async def ensure_app_ready(page: Page) -> None: await page.wait_for_load_state("load") + await page.wait_for_function("() => !!window.AppData", timeout=60000) + await page.evaluate("async () => { await window.AppData.ready; }") await page.wait_for_function( - "() => window.app && window.app.isInitialized && window.storage && window.LibraryDiscovery && typeof window.handleLibraryUpload === 'function'", + "() => window.app?.isInitialized === true && window.LibraryDiscovery && typeof window.handleLibraryUpload === 'function'", timeout=60000, ) async def dismiss_overlays(page: Page) -> None: - await page.evaluate( - """ - () => { - try { localStorage.setItem('hasSeenGplLicense', 'true'); } catch (_) {} - if (typeof window.acceptGplLicense === 'function') { - try { window.acceptGplLicense(); } catch (_) {} + accepted = await page.evaluate( + """async () => { + if (!window.LicenseModal || typeof window.LicenseModal.accept !== 'function') { + throw new Error('LicenseModal.accept is unavailable'); } - const modal = document.getElementById('license-modal'); - if (modal) modal.classList.remove('show'); - } - """ + return window.LicenseModal.accept(); + }""" + ) + if not accepted: + raise RuntimeError("GPL license consent was not committed") + await page.wait_for_function( + "() => !document.getElementById('license-modal')?.classList.contains('show')", + timeout=5000, ) @@ -120,11 +124,12 @@ async def run_dynamic_import(page: Page) -> dict[str, Any]: makeFile('Teacher Pack/loose/nested/readme.html', 'plain page', 'text/html') ]; - const beforeActive = await window.storage.get('active_exam_index_key', 'exam_index'); const fullReport = await window.handleLibraryUpload({ type: 'listening', mode: 'full' }, files); - const activeKey = await window.storage.get('active_exam_index_key', ''); - const configs = await window.storage.get('exam_index_configurations', []); - const dataset = await window.storage.get(activeKey, []); + const activeKey = await window.AppData.library.getActive(); + const [configs, dataset] = await Promise.all([ + window.AppData.library.listConfigurations(), + window.AppData.library.resolveIndex() + ]); const customRows = Array.isArray(dataset) ? dataset.filter(row => row && row.type === 'listening' && row.sourceKind === 'file-picker') : []; @@ -141,10 +146,12 @@ async def run_dynamic_import(page: Page) -> dict[str, Any]: window.prompt = () => 'optional-dup'; const incrementalReport = await window.handleLibraryUpload({ type: 'listening', mode: 'incremental' }, files); - const incrementalActiveKey = await window.storage.get('active_exam_index_key', ''); - const configsAfterIncremental = await window.storage.get('exam_index_configurations', []); - const afterDataset = await window.storage.get(incrementalActiveKey, []); - const fullDatasetAfterIncremental = await window.storage.get(activeKey, []); + const incrementalActiveKey = await window.AppData.library.getActive(); + const [configsAfterIncremental, afterDataset, fullDatasetAfterIncremental] = await Promise.all([ + window.AppData.library.listConfigurations(), + window.AppData.library.resolveIndex(), + window.AppData.library.getIndex(activeKey) + ]); const afterCustomRows = Array.isArray(afterDataset) ? afterDataset.filter(row => row && row.type === 'listening' && row.sourceKind === 'file-picker') : []; @@ -155,9 +162,10 @@ async def run_dynamic_import(page: Page) -> dict[str, Any]: window.renderLibraryUploadReport(fullReport, reportHost); return { - beforeActive, activeKey, incrementalActiveKey, + configIds: configs.map((item) => item && (item.id || item.key)).filter(Boolean), + configIdsAfterIncremental: configsAfterIncremental.map((item) => item && (item.id || item.key)).filter(Boolean), configCount: Array.isArray(configs) ? configs.length : -1, configCountAfterIncremental: Array.isArray(configsAfterIncremental) ? configsAfterIncremental.length : -1, datasetCount: Array.isArray(dataset) ? dataset.length : -1, @@ -191,24 +199,30 @@ async def delete_inactive_config_via_ui(page: Page, config_key: str) -> dict[str host.style.maxWidth = 'calc(100vw - 48px)'; host.style.zIndex = '3500'; document.body.appendChild(host); - const sentinelRecords = [{ + const sentinelRecord = { id: 'library-delete-sentinel', examId: 'reading-a', title: 'Sentinel Record', metadata: { examType: 'reading', category: 'P1' } - }]; - await window.storage.set('practice_records', sentinelRecords); + }; + await window.AppData.practice.completeAttempt({ + record: sentinelRecord, + operationId: 'e2e-library-delete-sentinel' + }); await window.renderLibraryConfigList({ allowDelete: true, containerId: 'dynamic-library-delete-test-host' }); - const dataset = await window.storage.get(configKey, null); - const pathMap = await window.storage.get('exam_path_map__' + configKey, null); + const [configs, dataset, records] = await Promise.all([ + window.AppData.library.listConfigurations(), + window.AppData.library.getIndex(configKey), + window.AppData.practice.list() + ]); return { - activeKey: await window.storage.get('active_exam_index_key', ''), - datasetExists: Array.isArray(dataset), - pathMapExists: !!pathMap, - records: await window.storage.get('practice_records', []) + activeKey: await window.AppData.library.getActive(), + configExists: configs.some(cfg => cfg && (cfg.id === configKey || cfg.key === configKey)), + indexCount: dataset.length, + records }; } """, @@ -224,7 +238,15 @@ async def delete_inactive_config_via_ui(page: Page, config_key: str) -> dict[str confirm_text = await confirm_overlay.inner_text() await page.locator('.library-config-confirm-overlay [data-confirm-action="confirm"]').click() await page.wait_for_function( - "configKey => window.storage.get(configKey, null).then(value => value === null)", + """async (configKey) => { + const [configs, index] = await Promise.all([ + window.AppData.library.listConfigurations(), + window.AppData.library.getIndex(configKey) + ]); + return !configs.some(item => item && (item.id === configKey || item.key === configKey)) + && Array.isArray(index) + && index.length === 0; + }""", arg=config_key, timeout=10000, ) @@ -233,19 +255,28 @@ async def delete_inactive_config_via_ui(page: Page, config_key: str) -> dict[str after = await page.evaluate( """ async (configKey) => { - const configs = await window.storage.get('exam_index_configurations', []); + const [configs, dataset, records, activeKey] = await Promise.all([ + window.AppData.library.listConfigurations(), + window.AppData.library.getIndex(configKey), + window.AppData.practice.list(), + window.AppData.library.getActive() + ]); return { - activeKey: await window.storage.get('active_exam_index_key', ''), - dataset: await window.storage.get(configKey, null), - pathMap: await window.storage.get('exam_path_map__' + configKey, null), - configStillListed: Array.isArray(configs) && configs.some(cfg => cfg && cfg.key === configKey), - records: await window.storage.get('practice_records', []), - panelText: (document.querySelector('#dynamic-library-delete-test-host .library-config-panel') || {}).textContent || '' + activeKey, + indexCount: dataset.length, + configStillListed: configs.some(cfg => cfg && (cfg.id === configKey || cfg.key === configKey)), + records }; } """, config_key, ) + await page.evaluate( + """async () => window.AppData.practice.delete({ + recordId: 'library-delete-sentinel', + operationId: 'e2e-library-delete-sentinel-cleanup' + })""" + ) return { "before": before, @@ -277,7 +308,7 @@ async def run() -> int: result = await run_dynamic_import(page) delete_result = await delete_inactive_config_via_ui(page, result.get("activeKey") or "") custom_rows = result.get("customRows") or [] - assert result.get("activeKey") and result.get("activeKey") != "exam_index", "全量导入未创建新的自定义题库配置" + assert result.get("activeKey") and result.get("activeKey") in (result.get("configIds") or []), "全量导入未创建并激活新的自定义题库配置" assert len(custom_rows) == 1, f"应只识别一个有效听力 HTML,实际: {len(custom_rows)}" custom = custom_rows[0] assert custom.get("category") == "Custom", f"任意目录导入不应硬编码 P1-P4: {custom}" @@ -301,11 +332,10 @@ async def run() -> int: rendered_text = result.get("renderedReportText") or "" assert "导入完成" in rendered_text and "缺少答案或评分链路" in rendered_text, f"导入报告 UI 未展示关键结果: {rendered_text}" assert "当前会话 Blob URL" in rendered_text, f"导入报告 UI 未展示 file:// 会话边界: {rendered_text}" - assert (delete_result.get("before") or {}).get("datasetExists") is True, f"删除前旧配置数据集不存在: {delete_result}" - assert (delete_result.get("before") or {}).get("pathMapExists") is True, f"删除前旧配置 path map 不存在: {delete_result}" + assert (delete_result.get("before") or {}).get("configExists") is True, f"删除前旧配置不存在: {delete_result}" + assert (delete_result.get("before") or {}).get("indexCount", 0) > 0, f"删除前旧配置数据集不存在: {delete_result}" assert "删除题库配置" in (delete_result.get("confirmText") or ""), f"删除配置未出现项目内确认层: {delete_result}" - assert (delete_result.get("after") or {}).get("dataset") is None, f"删除后配置数据集仍残留: {delete_result}" - assert (delete_result.get("after") or {}).get("pathMap") is None, f"删除后 path map 仍残留: {delete_result}" + assert (delete_result.get("after") or {}).get("indexCount") == 0, f"删除后配置数据集仍残留: {delete_result}" assert (delete_result.get("after") or {}).get("configStillListed") is False, f"删除后配置列表仍残留: {delete_result}" assert (delete_result.get("after") or {}).get("activeKey") == result.get("incrementalActiveKey"), f"删除非活动配置不应改变当前配置: {delete_result}" assert (delete_result.get("after") or {}).get("records") == (delete_result.get("before") or {}).get("records"), f"删除题库配置不应改写练习记录: {delete_result}" diff --git a/developer/tests/e2e/e2e_runner.py b/developer/tests/e2e/e2e_runner.py index dd9d4824..03a1e480 100644 --- a/developer/tests/e2e/e2e_runner.py +++ b/developer/tests/e2e/e2e_runner.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Unified E2E runner: execute reading + suite flows in one entrypoint.""" +"""Unified E2E runner: reading / listening / suite / file:// submit / export-import flows.""" from __future__ import annotations @@ -13,6 +13,19 @@ REPORT_DIR = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" REPORT_PATH = REPORT_DIR / "e2e-unified-report.json" +# Keep this list the single source of truth for "full e2e" in CI and local runs. +# Prefer file://-capable scripts; do not require a temporary HTTP host. +E2E_CASES = [ + "browse_preference_toggle_flow.py", + "reading_single_flow.py", + "listening_practice_flow.py", + "suite_practice_flow.py", + "practice_submit_file_flow.py", + "file_init_referrer_trap.py", + "ui_export_import_click.py", + "unified_submit_readonly_regression.py", +] + def _run_case(script_name: str) -> dict: script_path = REPO_ROOT / "developer" / "tests" / "e2e" / script_name @@ -34,8 +47,8 @@ def _run_case(script_name: str) -> dict: "name": script_name, "status": "pass" if completed.returncode == 0 else "fail", "exitCode": completed.returncode, - "stdout": (completed.stdout or "").strip(), - "stderr": (completed.stderr or "").strip(), + "stdout": (completed.stdout or "").strip()[-4000:], + "stderr": (completed.stderr or "").strip()[-2000:], } @@ -43,11 +56,7 @@ def main() -> int: REPORT_DIR.mkdir(parents=True, exist_ok=True) started_at = datetime.now(timezone.utc) - cases = [ - _run_case("browse_preference_toggle_flow.py"), - _run_case("reading_single_flow.py"), - _run_case("suite_practice_flow.py"), - ] + cases = [_run_case(name) for name in E2E_CASES] all_passed = all(item["status"] == "pass" for item in cases) report = { diff --git a/developer/tests/e2e/file_init_referrer_trap.py b/developer/tests/e2e/file_init_referrer_trap.py new file mode 100644 index 00000000..d3a04b7a --- /dev/null +++ b/developer/tests/e2e/file_init_referrer_trap.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Force expectedParentOrigin='file://' on a real file:// reading child and prove INIT still binds. + +Regression for the Chromium file-origin trap where URL.origin is "file://" but +postMessage event.origin is opaque "null". Invoked by e2e_runner.py and CI. +""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime +from pathlib import Path + +from playwright.async_api import async_playwright + +REPO = Path(__file__).resolve().parents[3] +INDEX = (REPO / "index.html").as_uri() + "?view=practice" +REPORT = REPO / "developer" / "tests" / "e2e" / "reports" / "file-init-referrer-trap.json" + + +def log(msg: str) -> None: + ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{ts}] {msg}", flush=True) + + +async def main() -> int: + report: dict = {"ok": False, "steps": [], "errors": [], "console": []} + + def step(name: str, **extra) -> None: + report["steps"].append({"step": name, **extra}) + log(f"{name} {json.dumps(extra, ensure_ascii=False)[:400]}") + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context() + await context.add_init_script( + "window.__IELTS_READING_PAGE_TEST_HOOKS__ = true;" + ) + + page = await context.new_page() + page.on( + "pageerror", + lambda err: report["errors"].append({"page": "host", "error": str(err)}), + ) + page.on( + "console", + lambda msg: report["console"].append( + {"page": "host", "type": msg.type, "text": msg.text} + ), + ) + + def on_page(np) -> None: + np.on( + "pageerror", + lambda err: report["errors"].append( + {"page": "child", "error": str(err)} + ), + ) + np.on( + "console", + lambda msg: report["console"].append( + {"page": "child", "type": msg.type, "text": msg.text} + ) + or ( + log(f"C [{msg.type}] {msg.text[:220]}") + if any( + k in msg.text + for k in ( + "INIT", + "SESSION", + "reject", + "origin", + "ExamMessage", + "DataCollection", + ) + ) + or msg.type == "error" + else None + ), + ) + + context.on("page", on_page) + + log(f"goto {INDEX}") + await page.goto(INDEX, wait_until="domcontentloaded", timeout=90000) + await page.wait_for_function( + "() => window.app && window.app.isInitialized === true", + timeout=120000, + ) + await page.evaluate( + """async () => { + if (window.LicenseModal && typeof window.LicenseModal.accept === 'function') { + await window.LicenseModal.accept(); + } + document.querySelectorAll('.modal.show, #license-modal, #library-loader-overlay') + .forEach((el) => { + el.classList.remove('show'); + if (el.style) el.style.display = 'none'; + }); + }""" + ) + await page.wait_for_function( + """() => { + const r = window.app && window.app.components && window.app.components.practiceRecorder; + return r && r.constructor && r.constructor.name === 'PracticeRecorder' && !r.isFallback; + }""", + timeout=60000, + ) + step( + "host_ready", + protocol=await page.evaluate("() => location.protocol"), + origin=await page.evaluate("() => location.origin"), + ) + + exam_id = await page.evaluate( + """async () => { + const index = await window.resolveActiveLibraryIndex(); + const list = Array.isArray(index) ? index : (index && index.exams) || []; + const exam = list.find((item) => item && item.hasHtml !== false + && String(item.type || '').toLowerCase().includes('read')) || list[0]; + return exam && exam.id; + }""" + ) + step("picked_exam", examId=exam_id) + if not exam_id: + report["reason"] = "no exam" + REPORT.write_text( + json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8" + ) + await browser.close() + return 2 + + async with context.expect_page(timeout=30000) as popup_info: + open_result = await page.evaluate( + """async (examId) => { + try { + await window.app.openExam(examId, { practiceMode: 'single' }); + return { ok: true }; + } catch (error) { + return { ok: false, error: String(error && error.message || error) }; + } + }""", + exam_id, + ) + step("openExam", result=open_result) + child = await popup_info.value + await child.wait_for_load_state("domcontentloaded", timeout=90000) + await child.wait_for_function( + """() => !!(window.__IELTS_UNIFIED_READING_PAGE_TEST__ + && typeof window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState === 'function' + && typeof window.__IELTS_UNIFIED_READING_PAGE_TEST__.setTestState === 'function')""", + timeout=60000, + ) + + natural = None + for _ in range(40): + natural = await child.evaluate( + """() => { + const st = window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState(); + return { + parentOrigin: st.parentOrigin, + parentOriginIsOpaque: st.parentOriginIsOpaque, + sessionReadySent: st.sessionReadySent, + windowSessionToken: st.windowSessionToken || '', + sessionId: st.sessionId || '', + expectedParentOrigin: st.expectedParentOrigin || '' + }; + }""" + ) + if natural and natural.get("sessionReadySent") and natural.get("windowSessionToken"): + break + await asyncio.sleep(0.25) + step("natural_bind", **(natural or {})) + + # Poison: old Chromium-file bug pins expectedParentOrigin to "file://", + # then rejects INIT because event.origin is opaque "null". + poisoned = await child.evaluate( + """() => { + const hooks = window.__IELTS_UNIFIED_READING_PAGE_TEST__; + hooks.setTestState({ + expectedParentOrigin: 'file://', + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', + sessionReadySent: false, + sessionId: '', + lastInitSignature: '' + }); + try { + if (window.opener) { + window.opener.postMessage({ + type: 'REQUEST_INIT', + source: 'practice_page', + data: { + examId: (hooks.getTestState().examId) || null, + reason: 'referrer-trap-rebind' + } + }, '*'); + } + } catch (error) { + return { requestError: String(error && error.message || error) }; + } + return hooks.getTestState(); + }""" + ) + step( + "poisoned", + expectedParentOrigin=(poisoned or {}).get("expectedParentOrigin"), + parentOrigin=(poisoned or {}).get("parentOrigin"), + sessionReadySent=(poisoned or {}).get("sessionReadySent"), + requestError=(poisoned or {}).get("requestError"), + ) + + rebound = None + for _ in range(40): + rebound = await child.evaluate( + """() => { + const st = window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState(); + return { + expectedParentOrigin: st.expectedParentOrigin || '', + parentOrigin: st.parentOrigin, + parentOriginIsOpaque: st.parentOriginIsOpaque, + sessionReadySent: st.sessionReadySent, + windowSessionToken: st.windowSessionToken || '', + sessionId: st.sessionId || '' + }; + }""" + ) + if ( + rebound + and rebound.get("sessionReadySent") + and rebound.get("windowSessionToken") + and rebound.get("parentOriginIsOpaque") is True + and rebound.get("parentOrigin") == "null" + ): + break + await asyncio.sleep(0.25) + step("rebound", **(rebound or {})) + + host_snap = await page.evaluate( + """(examId) => { + const info = window.app.examWindows && window.app.examWindows.get(examId); + return { + lastMessageType: info && info.lastMessageType, + expectedOrigin: info && info.expectedOrigin, + allowOpaqueOrigin: info && info.allowOpaqueOrigin, + observedOrigin: info && info.observedOrigin, + hasToken: !!(info && info.windowSessionToken), + rejections: window.app._examMessageRejectionCounts + ? Array.from(window.app._examMessageRejectionCounts.entries()) + : null + }; + }""", + exam_id, + ) + step("host_after_rebind", **(host_snap or {})) + + matrix = await child.evaluate( + """() => { + function wouldAccept(expectedParentOrigin, incomingOrigin, declaredOrigin, protocol) { + const expected = expectedParentOrigin + && expectedParentOrigin !== 'file://' + && !String(expectedParentOrigin).startsWith('file:') + ? expectedParentOrigin + : ''; + if (expected) { + return incomingOrigin === expected && declaredOrigin === expected; + } + if (protocol === 'file:') { + return (incomingOrigin === 'null' || incomingOrigin === 'file://') + && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://'); + } + return Boolean(incomingOrigin) + && incomingOrigin !== 'null' + && incomingOrigin !== 'file://' + && declaredOrigin === incomingOrigin; + } + const protocol = location.protocol; + return { + protocol, + fixedAcceptsNullDeclared: wouldAccept('file://', 'null', 'null', protocol), + fixedAcceptsEmptyDeclared: wouldAccept('file://', 'null', '', protocol), + fixedAcceptsFileDeclared: wouldAccept('file://', 'null', 'file://', protocol), + fixedAcceptsFileEvent: wouldAccept('file://', 'file://', 'file://', protocol), + fixedRejectsWebSpoofOnFile: wouldAccept( + 'file://', + 'https://evil.example', + 'https://evil.example', + protocol + ), + cleanFileAccepts: wouldAccept('', 'null', 'null', protocol), + httpStillWorks: wouldAccept( + 'http://127.0.0.1:8765', + 'http://127.0.0.1:8765', + 'http://127.0.0.1:8765', + 'http:' + ) + }; + }""" + ) + step("accept_matrix", **matrix) + + natural_ok = bool( + natural + and natural.get("sessionReadySent") + and natural.get("windowSessionToken") + and natural.get("parentOriginIsOpaque") is True + ) + rebound_ok = bool( + rebound + and rebound.get("sessionReadySent") + and rebound.get("windowSessionToken") + and rebound.get("parentOriginIsOpaque") is True + and rebound.get("parentOrigin") == "null" + ) + matrix_ok = bool( + matrix.get("fixedAcceptsNullDeclared") + and matrix.get("fixedAcceptsEmptyDeclared") + and matrix.get("fixedAcceptsFileDeclared") + and matrix.get("fixedAcceptsFileEvent") + and not matrix.get("fixedRejectsWebSpoofOnFile") + and matrix.get("cleanFileAccepts") + and matrix.get("httpStillWorks") + ) + report["ok"] = natural_ok and rebound_ok and matrix_ok + report["checks"] = { + "natural_ok": natural_ok, + "rebound_ok": rebound_ok, + "matrix_ok": matrix_ok, + } + step("done", ok=report["ok"], checks=report["checks"]) + REPORT.write_text( + json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8" + ) + log(f"report -> {REPORT}") + await browser.close() + return 0 if report["ok"] else 2 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/developer/tests/e2e/full_reset_flow.py b/developer/tests/e2e/full_reset_flow.py new file mode 100644 index 00000000..a8d6840f --- /dev/null +++ b/developer/tests/e2e/full_reset_flow.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Browser regression for the destructive settings reset.""" + +from __future__ import annotations + +import json +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from playwright.sync_api import sync_playwright + + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +class QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, _format: str, *_args: object) -> None: + return + + +def main() -> None: + handler = partial(QuietHandler, directory=str(REPO_ROOT)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + url = f"http://127.0.0.1:{server.server_address[1]}/index.html" + + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + context = browser.new_context() + page = context.new_page() + requested_urls: list[str] = [] + page.on("request", lambda request: requested_urls.append(request.url)) + page.on("dialog", lambda dialog: dialog.accept()) + + page.goto(url, wait_until="load", timeout=60_000) + page.wait_for_function("() => !!window.AppData && !!window.LicenseModal", timeout=60_000) + page.evaluate("async () => { await window.AppData.ready; }") + page.wait_for_function("() => window.app && window.app.isInitialized === true", timeout=60_000) + page.wait_for_selector("#license-modal.show", timeout=15_000) + page.evaluate("async () => { await window.LicenseModal.accept(); }") + page.wait_for_function("() => !document.getElementById('license-modal').classList.contains('show')") + + assert page.evaluate("() => typeof window.clearCache") == "function" + assert not any("/js/bundles/browse.bundle.js" in item for item in requested_urls) + + page.evaluate( + """async () => { + const payload = 'x'.repeat(600); + const index = Array.from({ length: 800 }, (_, i) => ({ + id: `full-reset-${i}`, + path: `reading/full-reset-${i}.html`, + payload + })); + await window.AppData.library.import({ + id: 'full-reset-probe', + configuration: { name: 'full-reset-probe' }, + index + }); + await window.AppData.backups.create({ + id: 'full-reset-probe-backup', + type: 'manual' + }); + await window.AppData.preferences.setTheme('full-reset-theme'); + localStorage.setItem('hasSeenGplLicense', 'true'); + localStorage.setItem('full-reset-legacy-local', 'present'); + sessionStorage.setItem('full-reset-legacy-session', 'present'); + + await new Promise((resolve, reject) => { + const request = indexedDB.open('ExamSystemDB', 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains('keyValueStore')) { + request.result.createObjectStore('keyValueStore'); + } + }; + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('keyValueStore', 'readwrite'); + tx.objectStore('keyValueStore').put({ stale: true }, 'full-reset-probe'); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => reject(tx.error); + }; + }); + + await new Promise((resolve, reject) => { + const request = indexedDB.open('IELTSAtlasExternalBackupV2', 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains('binding')) { + request.result.createObjectStore('binding'); + } + }; + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('binding', 'readwrite'); + tx.objectStore('binding').put({ directoryName: 'stale-binding' }, 'metadata'); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => reject(tx.error); + }; + }); + }""" + ) + + page.locator("nav.main-nav button[data-view='settings']").click() + page.wait_for_selector("#settings-view.active", timeout=10_000) + with page.expect_navigation(timeout=30_000): + page.locator("#clear-cache-btn").click() + + page.wait_for_function("() => !!window.AppData && !!window.LicenseModal", timeout=60_000) + page.evaluate("async () => { await window.AppData.ready; }") + page.wait_for_selector("#license-modal.show", timeout=15_000) + + result = page.evaluate( + """async () => { + const databaseNames = indexedDB.databases + ? (await indexedDB.databases()).map((entry) => entry.name) + : []; + return { + consent: await window.AppData.preferences.getConsent(), + theme: await window.AppData.preferences.getTheme(), + importedCount: (await window.AppData.library.getIndex('full-reset-probe')).length, + backupCount: (await window.AppData.backups.list()).length, + legacyLocal: localStorage.getItem('full-reset-legacy-local'), + legacySession: sessionStorage.getItem('full-reset-legacy-session'), + externalBound: window.ExternalBackupService.getStatus().bound, + databaseNames + }; + }""" + ) + + assert result["consent"].get("hasSeenGplLicense") is not True + assert result["theme"] is None + assert result["importedCount"] == 0 + assert result["backupCount"] == 0 + assert result["legacyLocal"] is None + assert result["legacySession"] is None + assert result["externalBound"] is False + assert "ExamSystemDB" not in result["databaseNames"] + assert not any("/js/bundles/browse.bundle.js" in item for item in requested_urls) + + print(json.dumps({"status": "pass", "result": result}, ensure_ascii=False)) + browser.close() + finally: + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/developer/tests/e2e/listening_bridge_optional.py b/developer/tests/e2e/listening_bridge_optional.py index bd3162a2..61210462 100644 --- a/developer/tests/e2e/listening_bridge_optional.py +++ b/developer/tests/e2e/listening_bridge_optional.py @@ -20,6 +20,7 @@ INDEX_URL = f"{(REPO_ROOT / 'index.html').as_uri()}?test_env=1&optional_listening_e2e=1" BRIDGE_PATH = REPO_ROOT / "js" / "listeningRecordBridge.js" SPELLING_COLLECTOR_PATH = REPO_ROOT / "js" / "app" / "spellingErrorCollector.js" +SAFE_OBJECT_LITERAL_PARSER_PATH = REPO_ROOT / "js" / "utils" / "safeObjectLiteralParser.js" REPORT_DIR = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" REPORT_FILE = REPORT_DIR / "listening-bridge-optional-report.json" @@ -60,6 +61,7 @@ def handler(msg: ConsoleMessage) -> None: async def inject_bridge_fixture_scripts(page: Page) -> None: await page.add_script_tag(path=str(SPELLING_COLLECTOR_PATH)) + await page.add_script_tag(path=str(SAFE_OBJECT_LITERAL_PARSER_PATH)) await page.add_script_tag(path=str(BRIDGE_PATH)) @@ -84,17 +86,19 @@ async def ensure_app_ready(page: Page) -> None: async def dismiss_overlays(page: Page) -> None: - await page.evaluate( - """ - () => { - try { localStorage.setItem('hasSeenGplLicense', 'true'); } catch (_) {} - if (typeof window.acceptGplLicense === 'function') { - try { window.acceptGplLicense(); } catch (_) {} + accepted = await page.evaluate( + """async () => { + if (!window.LicenseModal || typeof window.LicenseModal.accept !== 'function') { + throw new Error('LicenseModal.accept is unavailable'); } - const modal = document.getElementById('license-modal'); - if (modal) modal.classList.remove('show'); - } - """ + return window.LicenseModal.accept(); + }""" + ) + if not accepted: + raise RuntimeError("GPL license consent was not committed") + await page.wait_for_function( + "() => !document.getElementById('license-modal')?.classList.contains('show')", + timeout=5000, ) @@ -108,9 +112,10 @@ async def validate_listening_index(page: Page) -> dict[str, Any]: const invalidPaths = rows .filter(row => !row || !/^P[1-4]\\//.test(String(row.path || '')) || String(row.path || '').startsWith('ListeningPractice/')) .map(row => row && { id: row.examId || row.id, path: row.path }); - const activeIndex = window.appStateService && typeof window.appStateService.getExamIndex === 'function' - ? window.appStateService.getExamIndex() - : (window.app && window.app.state ? window.app.state.examIndex : []); + if (typeof window.resolveActiveLibraryIndex !== 'function') { + throw new Error('resolveActiveLibraryIndex is unavailable'); + } + const activeIndex = await window.resolveActiveLibraryIndex(); const activeListeningCount = Array.isArray(activeIndex) ? activeIndex.filter(row => row && row.type === 'listening').length : 0; @@ -221,13 +226,6 @@ async def validate_bridge_completion(page: Page) -> dict[str, Any]: """ () => { window.__BRIDGE_MESSAGES = []; - window.storage = { - ready: Promise.resolve(), - setNamespace() {}, - async get() { return null; }, - async set() { return true; }, - async remove() { return true; } - }; Object.defineProperty(window, 'opener', { configurable: true, value: { @@ -617,8 +615,8 @@ async def validate_real_parent_listening_persistence(page: Page, console_log: Li if (!window.app || typeof window.app.openExam !== 'function') { throw new Error('app_open_exam_missing_for_real_parent_probe'); } - if (!window.storage || typeof window.storage.get !== 'function') { - throw new Error('storage_missing_for_real_parent_probe'); + if (!window.AppData || !window.AppData.practice || !window.AppData.vocab || !window.AppData.backups) { + throw new Error('app_data_missing_for_real_parent_probe'); } if (!window.spellingErrorCollector || typeof window.spellingErrorCollector.saveErrors !== 'function') { throw new Error('parent_spelling_collector_missing_after_browse_runtime'); @@ -632,23 +630,6 @@ async def validate_real_parent_listening_persistence(page: Page, console_log: Li throw new Error('no_real_listening_target'); } - const backupKeys = ['practice_records', 'vocab_list_p1_errors', 'vocab_list_master_errors']; - window.__REAL_LISTENING_PERSISTENCE_BACKUP = {}; - for (const key of backupKeys) { - window.__REAL_LISTENING_PERSISTENCE_BACKUP[key] = await window.storage.get(key, null); - } - const previousRecords = window.__REAL_LISTENING_PERSISTENCE_BACKUP.practice_records; - const filteredRecords = Array.isArray(previousRecords) - ? previousRecords.filter(record => record && record.examId !== target.id) - : []; - await window.storage.set('practice_records', filteredRecords); - for (const key of ['vocab_list_p1_errors', 'vocab_list_master_errors']) { - if (typeof window.storage.remove === 'function') { - await window.storage.remove(key); - } else { - await window.storage.set(key, null); - } - } return { id: target.id, title: target.title, @@ -661,7 +642,44 @@ async def validate_real_parent_listening_persistence(page: Page, console_log: Li ) child_page = None + isolation_backup_id = None try: + isolation_backup_id = await page.evaluate( + """ + async (target) => { + const backup = await window.AppData.backups.create({ + type: 'e2e-isolation', + label: 'real-listening-parent' + }); + try { + const [records, collections] = await Promise.all([ + window.AppData.practice.list(), + window.AppData.vocab.listCollections() + ]); + const recordIds = records + .filter(record => record && record.examId === target.id) + .map(record => record.id) + .filter(Boolean); + if (recordIds.length) { + await window.AppData.practice.deleteMany({ + recordIds, + operationId: `e2e-listening-clear:${target.id}` + }); + } + await window.AppData.vocab.saveCollections({ + 'spelling-errors-p1': Object.assign({}, collections['spelling-errors-p1'] || {}, { id: 'spelling-errors-p1', words: [] }), + 'spelling-errors-master': Object.assign({}, collections['spelling-errors-master'] || {}, { id: 'spelling-errors-master', words: [] }) + }, { operationId: `e2e-listening-vocab-clear:${target.id}` }); + return backup.id; + } catch (error) { + await window.AppData.backups.restore(backup.id); + await window.AppData.backups.delete(backup.id); + throw error; + } + } + """, + target, + ) async with page.expect_popup(timeout=20000) as popup_wait: await page.evaluate("(examId) => window.app.openExam(examId)", target["id"]) child_page = await popup_wait.value @@ -741,9 +759,11 @@ async def validate_real_parent_listening_persistence(page: Page, console_log: Li """ async ({ examId, expectedWord, expectedInput }) => { const normalize = value => String(value || '').trim().toLowerCase(); - const records = await window.storage.get('practice_records', []); - const p1 = await window.storage.get('vocab_list_p1_errors', null); - const master = await window.storage.get('vocab_list_master_errors', null); + const [records, p1, master] = await Promise.all([ + window.AppData.practice.list(), + window.AppData.vocab.readList('spelling-errors-p1'), + window.AppData.vocab.readList('spelling-errors-master') + ]); const record = Array.isArray(records) ? records.find(item => item && item.examId === examId) : null; @@ -804,24 +824,14 @@ async def validate_real_parent_listening_persistence(page: Page, console_log: Li finally: if child_page and not child_page.is_closed(): await child_page.close() - await page.evaluate( - """ - async () => { - const backup = window.__REAL_LISTENING_PERSISTENCE_BACKUP || {}; - for (const [key, value] of Object.entries(backup)) { - if (value === null || value === undefined) { - if (typeof window.storage.remove === 'function') { - await window.storage.remove(key); - } else { - await window.storage.set(key, null); - } - } else { - await window.storage.set(key, value); - } - } - } - """ - ) + if isolation_backup_id: + await page.evaluate( + """async (backupId) => { + await window.AppData.backups.restore(backupId); + await window.AppData.backups.delete(backupId); + }""", + isolation_backup_id, + ) async def validate_file_origin_static_bridge_p2_persistence(page: Page, console_log: List[ConsoleEntry]) -> dict[str, Any]: @@ -836,8 +846,8 @@ async def validate_file_origin_static_bridge_p2_persistence(page: Page, console_ if (!window.app || typeof window.app.openExam !== 'function') { throw new Error('app_open_exam_missing_for_file_origin_probe'); } - if (!window.storage || typeof window.storage.get !== 'function') { - throw new Error('storage_missing_for_file_origin_probe'); + if (!window.AppData || !window.AppData.practice) { + throw new Error('app_data_missing_for_file_origin_probe'); } const rows = Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : []; const target = rows.find(row => row && row.id && /8\\. P2 Plan of Community Center \\(VIP\\)/i.test(String(row.path || row.filename || row.title || ''))) @@ -846,13 +856,6 @@ async def validate_file_origin_static_bridge_p2_persistence(page: Page, console_ throw new Error('p2_community_center_target_missing'); } - window.__FILE_ORIGIN_STATIC_BRIDGE_BACKUP = await window.storage.get('practice_records', null); - const previousRecords = window.__FILE_ORIGIN_STATIC_BRIDGE_BACKUP; - const filteredRecords = Array.isArray(previousRecords) - ? previousRecords.filter(record => record && record.examId !== target.id) - : []; - await window.storage.set('practice_records', filteredRecords); - return { id: target.id, title: target.title, @@ -864,7 +867,25 @@ async def validate_file_origin_static_bridge_p2_persistence(page: Page, console_ ) child_page = None + practice_snapshot = None try: + practice_snapshot = await page.evaluate("async () => window.AppData.backups.export({ domains: ['practice'] })") + await page.evaluate( + """async (examId) => { + const records = await window.AppData.practice.list(); + const recordIds = records + .filter(record => record && record.examId === examId) + .map(record => record.id) + .filter(Boolean); + if (recordIds.length) { + await window.AppData.practice.deleteMany({ + recordIds, + operationId: `e2e-file-origin-clear:${examId}` + }); + } + }""", + target["id"], + ) async with page.expect_popup(timeout=20000) as popup_wait: await page.evaluate("(examId) => window.app.openExam(examId)", target["id"]) child_page = await popup_wait.value @@ -901,7 +922,7 @@ async def validate_file_origin_static_bridge_p2_persistence(page: Page, console_ result = await page.evaluate( """ async (examId) => { - const records = await window.storage.get('practice_records', []); + const records = await window.AppData.practice.list(); const record = Array.isArray(records) ? records.find(item => item && item.examId === examId) : null; @@ -953,22 +974,16 @@ async def validate_file_origin_static_bridge_p2_persistence(page: Page, console_ finally: if child_page and not child_page.is_closed(): await child_page.close() - await page.evaluate( - """ - async () => { - const value = window.__FILE_ORIGIN_STATIC_BRIDGE_BACKUP; - if (value === null || value === undefined) { - if (typeof window.storage.remove === 'function') { - await window.storage.remove('practice_records'); - } else { - await window.storage.set('practice_records', null); - } - } else { - await window.storage.set('practice_records', value); - } - } - """ - ) + if practice_snapshot is not None: + await page.evaluate( + """async (snapshot) => { + const preview = await window.AppData.backups.previewImport(snapshot, { practiceMode: 'replace' }); + await window.AppData.backups.commitImport(preview.id, { + operationId: 'e2e-file-origin-restore' + }); + }""", + practice_snapshot, + ) async def run() -> int: diff --git a/developer/tests/e2e/listening_practice_flow.py b/developer/tests/e2e/listening_practice_flow.py index 59d35874..286422e5 100644 --- a/developer/tests/e2e/listening_practice_flow.py +++ b/developer/tests/e2e/listening_practice_flow.py @@ -23,8 +23,13 @@ REPO_ROOT = Path(__file__).resolve().parents[3] INDEX_PATH = REPO_ROOT / "index.html" -INDEX_URL = f"{INDEX_PATH.as_uri()}?test_env=1" +INDEX_URL = INDEX_PATH.as_uri() REPORT_DIR = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" +CHROME_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/138.0.0.0 Safari/537.36" +) @dataclass @@ -37,10 +42,9 @@ class ConsoleEntry: async def _ensure_app_ready(page: Page) -> None: """等待应用初始化完成""" await page.wait_for_load_state("load") - await page.wait_for_function( - "() => window.app && window.app.isInitialized && window.storage", - timeout=60000, - ) + await page.wait_for_function("() => !!window.AppData", timeout=60000) + await page.evaluate("async () => { await window.AppData.ready; }") + await page.wait_for_function("() => window.app?.isInitialized === true", timeout=60000) async def _click_nav(page: Page, view: str) -> None: @@ -51,6 +55,21 @@ async def _click_nav(page: Page, view: str) -> None: async def _dismiss_overlays(page: Page) -> None: """关闭可能的遮罩层""" + accepted = await page.evaluate( + """async () => { + if (!window.LicenseModal || typeof window.LicenseModal.accept !== 'function') { + throw new Error('LicenseModal.accept is unavailable'); + } + return window.LicenseModal.accept(); + }""" + ) + if not accepted: + raise RuntimeError("GPL license consent was not committed") + await page.wait_for_function( + "() => !document.getElementById('license-modal')?.classList.contains('show')", + timeout=5000, + ) + overlay = page.locator("#library-loader-overlay") if await overlay.count(): try: @@ -91,10 +110,10 @@ async def _get_exam_titles(page: Page) -> List[str]: async def _get_filter_buttons_state(page: Page) -> List[Dict[str, Any]]: """收集筛选按钮状态""" return await page.evaluate( - "() => Array.from(document.querySelectorAll('#type-filter-buttons button')).map(btn => ({" + "() => Array.from(document.querySelectorAll('#browse-frequency-filter-buttons button')).map(btn => ({" " text: (btn.textContent || '').trim()," - " filterId: btn.dataset.filterId || null," - " active: btn.classList.contains('active')" + " filterId: btn.dataset.frequencyFilter || null," + " active: btn.getAttribute('aria-pressed') === 'true'" "}))" ) @@ -143,14 +162,19 @@ async def _click_filter_and_wait( "low": "低频", } - button = page.locator( - f"#type-filter-buttons button[data-filter-id='{filter_id}']" - ) + if filter_id == "all": + button = page.locator( + "#browse-frequency-filter-buttons button[aria-pressed='true']" + ) + else: + button = page.locator( + f"#browse-frequency-filter-buttons button[data-frequency-filter='{filter_id}']" + ) if not await button.count(): label = text_map.get(filter_id, "") if label: - button = page.locator("#type-filter-buttons button", has_text=label) + button = page.locator("#browse-frequency-filter-buttons button", has_text=label) if not await button.count(): raise AssertionError(f"未找到筛选按钮: {filter_id}") @@ -173,7 +197,7 @@ async def _click_filter_and_wait( " if (titles.length !== prev.length) return true;\n" " return titles.some((t, i) => t !== prev[i]);\n" "}", - previous_titles, + arg=previous_titles, timeout=15000, ) @@ -190,13 +214,32 @@ async def test_complete_practice_flow(browser: Browser, console_log: List[Consol 4. 查看练习记录 5. 验证套题详情展示 """ - context = await browser.new_context() + context = await browser.new_context(user_agent=CHROME_UA) context.on("page", lambda pg: _collect_console(pg, console_log)) page = await context.new_page() await page.goto(INDEX_URL) await _ensure_app_ready(page) await _dismiss_overlays(page) + await page.evaluate("async () => window.AppLazyLoader.ensureGroup('practice-suite')") + await page.wait_for_function( + "() => window.app?.components?.practiceRecorder?.constructor?.name === 'PracticeRecorder'" + " && window.app.components.practiceRecorder.isFallback !== true", + timeout=10000, + ) + production_state = await page.evaluate( + """() => ({ + testEnvironment: !!(window.EnvironmentDetector + && window.EnvironmentDetector.isInTestEnvironment()), + recorderName: window.app?.components?.practiceRecorder?.constructor?.name || '', + recorderFallback: !!window.app?.components?.practiceRecorder?.isFallback, + userAgent: navigator.userAgent + })""" + ) + if production_state["testEnvironment"]: + raise AssertionError("Listening E2E must not run in test_env") + if production_state["recorderName"] != "PracticeRecorder" or production_state["recorderFallback"]: + raise AssertionError(f"Listening E2E requires the real PracticeRecorder: {production_state}") # 步骤1: 留在Overview视图,等待加载完成 await page.wait_for_selector("#overview-view.active", timeout=10000) @@ -211,44 +254,103 @@ async def test_complete_practice_flow(browser: Browser, console_log: List[Consol await page.wait_for_selector("#browse-view.active", timeout=15000) await page.wait_for_timeout(1000) - # 步骤3: 点击第一个题目(100 P1) - # 注意:频率筛选按钮功能未实现,跳过验证 - first_exam = page.locator(".exam-card, .exam-item").first - await first_exam.wait_for(state="visible", timeout=10000) - - # 获取题目标题 - title_locator = first_exam.locator(".exam-title, h4") - exam_title = await title_locator.first.text_content() - - # 打开题目 + # 步骤3: 选择一个具备统一 HTML 练习页的 P1 题目。题库列表可能同时 + # 包含 PDF-only 条目,不能用列表第一项来推断练习页能力。 + exam = await page.evaluate( + """async () => { + const index = await window.resolveActiveLibraryIndex(); + const list = Array.isArray(index) ? index : (index && index.exams) || []; + return list.find((item) => item?.id === 'p1-high-01' && item.hasHtml !== false) + || list.find((item) => item?.category === 'P1' && item.hasHtml !== false) + || null; + }""" + ) + if not exam or not exam.get("id"): + raise AssertionError("P1 题库中没有可用于完整流程的 HTML 题目") + exam_id = exam["id"] + exam_title = exam.get("title") or exam_id + + start_button = page.locator( + f"[data-exam-id='{exam_id}'] button[data-action='start']" + ).first async with page.expect_popup() as popup_wait: - await first_exam.locator("button[data-action='start']").click() + if await start_button.count(): + await start_button.evaluate("node => node.click()") + else: + await page.evaluate( + "examId => window.app.openExam(examId, { practiceMode: 'single' })", + exam_id, + ) practice_page = await popup_wait.value _collect_console(practice_page, console_log) # 步骤5: 等待练习页面加载 await practice_page.wait_for_load_state("load") - await practice_page.wait_for_selector("#complete-exam-btn", timeout=20000) + await practice_page.wait_for_function( + "() => location.href.includes('reading-practice-unified.html')", + timeout=20000, + ) + await page.wait_for_function( + "examId => window.app?.examWindows?.get?.(examId)?.dataCollectorReady === true", + arg=exam_id, + timeout=20000, + ) + await practice_page.wait_for_selector("#timer", state="visible", timeout=15000) + await practice_page.wait_for_selector("#submit-btn", timeout=20000) + await practice_page.wait_for_timeout(1100) # 步骤6: 填写答案(模拟) # 注意:这里需要根据实际HTML结构填写答案 # 暂时跳过实际填写,直接提交 # 步骤7: 提交答案 - submit_btn = practice_page.locator("#complete-exam-btn") + submit_btn = practice_page.locator("#submit-btn") await submit_btn.wait_for(state="visible", timeout=10000) await submit_btn.click() - - # 等待提交完成 - await practice_page.wait_for_timeout(2000) + # 给子页桥接消息与父页 IndexedDB 事务留出完整的提交窗口。仅看到 + # receipt 还不等于事务已经可供历史视图读取。 + await page.wait_for_timeout(1800) + synthetic_hits = [ + entry.text + for entry in console_log + if "合成数据保存" in entry.text + or "测试环境启用合成" in entry.text + or "synthetic session" in entry.text.lower() + ] + if synthetic_hits: + raise AssertionError(f"Listening E2E detected synthetic persistence: {synthetic_hits[0]}") # 关闭练习页面 - await practice_page.close() + if not practice_page.is_closed(): + await practice_page.close() # 步骤8: 查看练习记录 await _click_nav(page, "practice") - await page.wait_for_timeout(2000) + await page.wait_for_function( + """async (targetExamId) => { + const records = await window.AppData.practice.list({ projection: 'light' }); + return records.some(record => String(record?.examId || '') === targetExamId); + }""", + arg=exam_id, + timeout=30000, + ) + persistence = await page.evaluate( + """async (targetExamId) => { + const records = await window.AppData.practice.list({ projection: 'light' }); + return { + recordCount: records.length, + targetCount: records.filter(record => String(record?.examId || '') === targetExamId).length, + hasReceipt: Array.from(window.app?.examWindows?.values?.() || []) + .some((info) => info && info.practiceSubmitReceipts + && Object.keys(info.practiceSubmitReceipts).length > 0) + }; + }""", + exam_id, + ) + await page.evaluate( + "async () => window.syncPracticeRecords({ forceRender: true, mode: 'summary' })" + ) # 验证记录列表 await page.wait_for_selector("#history-list .history-record-item", timeout=20000) @@ -261,7 +363,7 @@ async def test_complete_practice_flow(browser: Browser, console_log: List[Consol first_record = page.locator("#history-list .history-record-item").first record_id = await first_record.get_attribute("data-record-id") - details_btn = first_record.locator("button[data-history-action='details']") + details_btn = first_record.locator("[data-record-action='details']") await details_btn.click() # 等待详情弹窗 @@ -278,6 +380,9 @@ async def test_complete_practice_flow(browser: Browser, console_log: List[Consol "status": "pass", "examTitle": exam_title, "recordId": record_id, + "productionState": production_state, + "persistence": persistence, + "syntheticHits": synthetic_hits, "screenshots": [str(list_path), str(detail_path)] } @@ -291,7 +396,7 @@ async def test_vocab_practice_flow(browser: Browser, console_log: List[ConsoleEn 4. 背诵单词并标记 5. 验证复习箱移动 """ - context = await browser.new_context() + context = await browser.new_context(user_agent=CHROME_UA) context.on("page", lambda pg: _collect_console(pg, console_log)) page = await context.new_page() @@ -334,17 +439,17 @@ async def test_vocab_practice_flow(browser: Browser, console_log: List[ConsoleEn async def test_frequency_filter_flow(browser: Browser, console_log: List[ConsoleEntry]) -> dict: """ 测试频率筛选流程 - 1. 点击P1/P4入口 + 1. 点击P1入口 2. 验证筛选按钮显示 3. 应用不同频率筛选 4. 验证题目列表更新 - 5. 测试P4"全部"按钮 + 5. 清除筛选并验证完整列表恢复 """ debug_report_path = REPORT_DIR / "frequency-filter-debug.json" if debug_report_path.exists(): debug_report_path.unlink() - context = await browser.new_context() + context = await browser.new_context(user_agent=CHROME_UA) context.on("page", lambda pg: _collect_console(pg, console_log)) page = await context.new_page() @@ -371,9 +476,14 @@ async def test_frequency_filter_flow(browser: Browser, console_log: List[Console await p1_button.click() await page.wait_for_selector("#browse-view.active", timeout=15000) - await page.wait_for_selector("#type-filter-buttons button", timeout=15000) + await page.wait_for_function( + "() => window.getCurrentCategory?.() === 'P1'" + " && window.getCurrentExamType?.() === 'reading'", + timeout=15000, + ) + await page.wait_for_selector("#browse-frequency-filter-buttons button", timeout=15000) await page.wait_for_selector(".exam-card, .exam-item", timeout=20000) - await page.wait_for_timeout(500) + await page.wait_for_timeout(800) # 记录默认题目数 default_titles = await _get_exam_titles(page) @@ -407,57 +517,22 @@ async def test_frequency_filter_flow(browser: Browser, console_log: List[Console changes.append({"filter": filter_id, "count": len(new_titles)}) previous_titles = new_titles - # 步骤5: 切换到P4 - await _click_nav(page, "overview") - await page.wait_for_timeout(500) - - p4_button = page.locator("button[data-category='P4'][data-action='browse-category']") - await p4_button.click() - - await page.wait_for_selector("#browse-view.active", timeout=15000) - await page.wait_for_selector("#type-filter-buttons button", timeout=10000) - await page.wait_for_selector(".exam-card, .exam-item", timeout=20000) - await page.wait_for_timeout(500) - - p4_default_titles = await _get_exam_titles(page) - if not p4_default_titles: - raise AssertionError("P4 列表为空") - - p4_filters = await _get_filter_buttons_state(page) - p4_filter_ids = [] - for btn in p4_filters: - fid = btn.get("filterId") or text_to_filter_id.get(btn.get("text", "")) - if fid: - p4_filter_ids.append(fid) - - if "all" not in p4_filter_ids: - raise AssertionError("未找到 P4 的全部筛选按钮") - - non_all_sequence = [ - fid for fid in ["high", "medium", "ultra-high", "low"] if fid in p4_filter_ids - ] - - if not non_all_sequence: - raise AssertionError("P4 缺少可用的频率筛选按钮") - - filtered_titles = await _click_filter_and_wait( - page, non_all_sequence[0], p4_default_titles - ) - all_titles = await _click_filter_and_wait(page, "all", filtered_titles) + # 当前 UI 通过再次点击激活的频率 chip 表示“全部”。 + all_titles = await _click_filter_and_wait(page, "all", previous_titles) - if len(all_titles) < len(filtered_titles): + if len(all_titles) < len(previous_titles) or set(all_titles) != set(default_titles): raise AssertionError("点击全部后题目数未恢复") - p4_all_path = REPORT_DIR / "frequency-filter-p4-all.png" - await page.locator("#browse-view").screenshot(path=str(p4_all_path)) + all_path = REPORT_DIR / "frequency-filter-all.png" + await page.locator("#browse-view").screenshot(path=str(all_path)) return { "name": "频率筛选流程", "status": "pass", "defaultCount": default_count, - "p4Total": len(all_titles), + "total": len(all_titles), "changes": changes, - "screenshots": [str(default_path), str(p4_all_path)], + "screenshots": [str(default_path), str(all_path)], } except Exception as exc: await _write_failure_report(page, console_log, debug_report_path, str(exc)) @@ -466,12 +541,13 @@ async def test_frequency_filter_flow(browser: Browser, console_log: List[Console await context.close() -async def run() -> None: - """运行所有E2E测试""" +async def run() -> bool: + """运行所有E2E测试,返回是否全部通过""" REPORT_DIR.mkdir(parents=True, exist_ok=True) console_log: List[ConsoleEntry] = [] results = [] + all_passed = False try: async with async_playwright() as p: browser: Browser = await p.chromium.launch(headless=True) @@ -516,8 +592,8 @@ async def run() -> None: for entry in console_log: print(f"[{entry.type.upper()}] {entry.page_title}: {entry.text}") - # 输出测试结果 - all_passed = all(r["status"] == "pass" for r in results) + # 输出测试结果(无结果视为失败,避免 all([]) == True 的假绿) + all_passed = bool(results) and all(r["status"] == "pass" for r in results) print(f"\n{'='*60}") print(f"E2E测试完成") print(f"{'='*60}") @@ -531,7 +607,8 @@ async def run() -> None: print(f"通过: {sum(1 for r in results if r['status'] == 'pass')} 个") print(f"失败: {sum(1 for r in results if r['status'] == 'fail')} 个") print(f"{'='*60}\n") + return all_passed if __name__ == "__main__": - asyncio.run(run()) + raise SystemExit(0 if asyncio.run(run()) else 1) diff --git a/developer/tests/e2e/playwright_index_clickthrough.py b/developer/tests/e2e/playwright_index_clickthrough.py index 26b18317..eb99268d 100644 --- a/developer/tests/e2e/playwright_index_clickthrough.py +++ b/developer/tests/e2e/playwright_index_clickthrough.py @@ -43,10 +43,9 @@ def _summarize_results() -> None: async def _ensure_app_ready(page: Page) -> None: await page.wait_for_load_state("load") - await page.wait_for_function( - "() => window.app && window.app.isInitialized && window.storage && typeof window.storage.get === 'function'", - timeout=60000, - ) + await page.wait_for_function("() => !!window.AppData", timeout=60000) + await page.evaluate("async () => { await window.AppData.ready; }") + await page.wait_for_function("() => window.app?.isInitialized === true", timeout=60000) async def _accept_dialog(dialog: Dialog) -> None: @@ -75,9 +74,8 @@ async def _open_practice_popup(page: Page, button, name: str) -> None: global _AUTO_CLOSE_POPUPS before_records = await page.evaluate( "async () => {" - " if (!window.PracticeRecordAPI?.list) return 0;" - " const records = await window.PracticeRecordAPI.list();" - " return Array.isArray(records) ? records.length : 0;" + " const records = await window.AppData.practice.list({ projection: 'light' });" + " return records.length;" "}" ) popup = None @@ -95,11 +93,10 @@ async def _open_practice_popup(page: Page, button, name: str) -> None: failure=f"未找到 data-exam-id, exam_id={exam_id}", ) - records_after = await popup.evaluate( + records_after = await page.evaluate( "async () => {" - " if (!window.PracticeRecordAPI?.list) return 0;" - " const records = await window.PracticeRecordAPI.list();" - " return Array.isArray(records) ? records.length : 0;" + " const records = await window.AppData.practice.list({ projection: 'light' });" + " return records.length;" "}" ) _assert_and_record( @@ -118,7 +115,16 @@ async def _open_practice_popup(page: Page, button, name: str) -> None: async def _get_backups(page: Page) -> list: backups = await page.evaluate( - "() => (window.storage?.get && window.storage.get('manual_backups')) || []" + """async () => { + const isolationId = window.__E2E_ISOLATION_BACKUP_ID || null; + const items = await window.AppData.backups.list(); + return items + .filter((item) => item && item.id !== isolationId) + .map((item) => ({ + ...item, + timestamp: Date.parse(item.createdAt || item.updatedAt || item.timestamp || 0) / 1000 + })); + }""" ) return backups if isinstance(backups, list) else [] @@ -441,15 +447,41 @@ async def exercise_index_interactions(page: Page) -> None: await page.goto(INDEX_PATH.resolve().as_uri()) await _ensure_app_ready(page) + isolation = await page.evaluate( + """async () => { + const baselineIds = (await window.AppData.backups.list()).map((item) => item.id); + const backup = await window.AppData.backups.create({ type: 'e2e-isolation', label: 'index-clickthrough' }); + window.__E2E_ISOLATION_BACKUP_ID = backup.id; + return { backupId: backup.id, baselineIds }; + }""" + ) + page.on("dialog", lambda dialog: asyncio.ensure_future(_accept_dialog(dialog))) page.on("popup", lambda popup: asyncio.ensure_future(_close_popup_if_enabled(popup))) - await _exercise_developer_links(page) - await _exercise_overview(page) - await _exercise_browse(page) - await _exercise_practice(page) - await _exercise_settings(page) - await _exercise_developer_links(page) + try: + await _exercise_developer_links(page) + await _exercise_overview(page) + await _exercise_browse(page) + await _exercise_practice(page) + await _exercise_settings(page) + await _exercise_developer_links(page) + finally: + await _ensure_app_ready(page) + await page.evaluate( + """async ({ backupId, baselineIds }) => { + await window.AppData.backups.restore(backupId); + const baseline = new Set(baselineIds); + const current = await window.AppData.backups.list(); + for (const backup of current) { + if (backup && !baseline.has(backup.id)) { + await window.AppData.backups.delete(backup.id); + } + } + delete window.__E2E_ISOLATION_BACKUP_ID; + }""", + isolation, + ) async def run_e2e_suite(page: Page) -> None: @@ -480,10 +512,6 @@ async def main() -> None: async with async_playwright() as p: browser: Browser = await p.chromium.launch() context = await browser.new_context() - await context.add_init_script( - "(() => { try { localStorage.removeItem('preferred_theme_portal'); sessionStorage.removeItem('preferred_theme_skip_session'); } catch (_) {} })();" - ) - page = await context.new_page() await exercise_index_interactions(page) diff --git a/developer/tests/e2e/practice_record_indexeddb_stress.node.js b/developer/tests/e2e/practice_record_indexeddb_stress.node.js new file mode 100644 index 00000000..54a2363e --- /dev/null +++ b/developer/tests/e2e/practice_record_indexeddb_stress.node.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { chromium } from 'playwright'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const origin = 'http://localhost/'; +const scriptPaths = [ + 'js/data/practiceRecordSource.js', + 'js/data/v2/dataCatalog.js', + 'js/data/v2/dataKernel.js', + 'js/data/v2/appData.js' +]; + +async function bootstrap(page) { + await page.goto(origin); + for (const relativePath of scriptPaths) { + await page.addScriptTag({ path: path.join(repoRoot, relativePath) }); + } + await page.waitForFunction(() => Boolean(window.AppData)); + await page.evaluate(async () => { await window.AppData.ready; }); +} + +function makeRecords(prefix, count, heavy = false) { + return Array.from({ length: count }, (_, index) => { + const noteCount = heavy ? 40 : 8; + const highlightCount = heavy ? 80 : 16; + const notes = Array.from({ length: noteCount }, (_, noteIndex) => ({ + id: `${prefix}-note-${index}-${noteIndex}`, + body: `note ${index}/${noteIndex} ${'n'.repeat(64)}`, + quote: `quote ${index}/${noteIndex}` + })); + const highlights = Array.from({ length: highlightCount }, (_, highlightIndex) => ({ + id: `${prefix}-highlight-${index}-${highlightIndex}`, + noteId: notes[highlightIndex % notes.length].id, + text: `highlight ${index}/${highlightIndex} ${'h'.repeat(48)}`, + start: highlightIndex * 5, + end: highlightIndex * 5 + 12 + })); + const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, index % 60)).toISOString(); + return { + id: `${prefix}-record-${index}`, + sessionId: `${prefix}-session-${index}`, + examId: `${prefix}-exam-${index}`, + title: `${prefix} ${index}`, + type: 'reading', + date: timestamp, + startTime: timestamp, + endTime: timestamp, + duration: 1800 + index, + totalQuestions: 40, + correctAnswers: 36, + accuracy: 0.9, + answers: { q1: 'A', q2: 'B' }, + correctAnswerMap: { q1: 'A', q2: 'C' }, + highlights, + notes, + noteOutlines: [{ id: `${prefix}-outline-${index}`, title: `Outline ${index}`, order: 0 }], + metadata: { examTitle: `${prefix} ${index}`, category: 'P3', frequency: 'high', type: 'reading' } + }; + }); +} + +function launchOptions() { + const candidates = [ + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' + ]; + const executablePath = candidates.find((candidate) => fs.existsSync(candidate)); + return executablePath ? { headless: true, executablePath } : { headless: true }; +} + +const browser = await chromium.launch(launchOptions()); +const context = await browser.newContext(); +await context.route(`${origin}**`, async (route) => { + await route.fulfill({ status: 200, contentType: 'text/html', body: 'v2 stress' }); +}); + +try { + const page = await context.newPage(); + await bootstrap(page); + await page.evaluate(() => window.AppData.practice.clear({ operationId: 'browser-stress-clear' })); + + const records = makeRecords('browser', 48, true); + const replaceStarted = performance.now(); + await page.evaluate(async (items) => { + await Promise.all(items.map((record) => window.AppData.practice.completeAttempt({ + operationId: `browser-stress-${record.id}`, + record + }))); + }, records); + const writeMs = performance.now() - replaceStarted; + + const listStarted = performance.now(); + const listed = await page.evaluate(() => window.AppData.practice.list({ projection: 'full' })); + const listMs = performance.now() - listStarted; + assert.strictEqual(listed.length, records.length, 'v2 IndexedDB write changed record count'); + assert(listed.every((record) => record && record.highlights.length === 80 && record.notes.length === 40), 'full v2 projection lost annotation data'); + + const light = await page.evaluate(() => window.AppData.practice.list({ projection: 'light' })); + assert.strictEqual(light.length, records.length, 'light projection changed record count'); + assert(light.every((record) => !Object.prototype.hasOwnProperty.call(record, 'highlights')), 'light projection leaked annotations'); + + await page.evaluate(() => window.AppData.practice.delete({ recordId: 'browser-record-0', operationId: 'browser-stress-delete' })); + const afterDelete = await page.evaluate(() => window.AppData.practice.list({ projection: 'full' })); + assert.strictEqual(afterDelete.length, records.length - 1, 'deleted v2 record remained in full list'); + assert(afterDelete.every(Boolean), 'full v2 list returned a null row after deletion'); + + await page.reload(); + await bootstrap(page); + const reloaded = await page.evaluate(() => window.AppData.practice.list({ projection: 'full' })); + assert.strictEqual(reloaded.length, afterDelete.length, 'reload changed v2 record count'); + assert.strictEqual(reloaded.find((record) => record.id === 'browser-record-1').highlights.length, 80, 'reload changed annotation contents'); + + const secondPage = await context.newPage(); + await bootstrap(secondPage); + const crossTabStarted = performance.now(); + await Promise.all([ + page.evaluate(async (items) => Promise.all(items.map((record) => window.AppData.practice.completeAttempt({ + operationId: `browser-tab-a-${record.id}`, record + }))), makeRecords('tab-a', 12)), + secondPage.evaluate(async (items) => Promise.all(items.map((record) => window.AppData.practice.completeAttempt({ + operationId: `browser-tab-b-${record.id}`, record + }))), makeRecords('tab-b', 12)) + ]); + const crossTabMs = performance.now() - crossTabStarted; + const [firstTab, secondTab] = await Promise.all([ + page.evaluate(() => window.AppData.practice.list({ projection: 'light' })), + secondPage.evaluate(() => window.AppData.practice.list({ projection: 'light' })) + ]); + await secondPage.close(); + assert.strictEqual(firstTab.length, afterDelete.length + 24, 'first v2 tab lost cross-tab records'); + assert.strictEqual(secondTab.length, afterDelete.length + 24, 'second v2 tab lost cross-tab records'); + + const maxMs = 10000; + assert(writeMs < maxMs, `v2 IndexedDB writes exceeded ${maxMs}ms`); + assert(listMs < maxMs, `v2 full projection exceeded ${maxMs}ms`); + assert(crossTabMs < maxMs, `v2 cross-tab writes exceeded ${maxMs}ms`); + process.stdout.write(JSON.stringify({ + status: 'pass', + detail: { + records: records.length, + highlightsPerRecord: 80, + notesPerRecord: 40, + writeMs: Math.round(writeMs * 100) / 100, + listMs: Math.round(listMs * 100) / 100, + crossTabRecords: firstTab.length, + crossTabMs: Math.round(crossTabMs * 100) / 100 + } + })); +} finally { + await context.close(); + await browser.close(); +} diff --git a/developer/tests/e2e/practice_submit_file_flow.py b/developer/tests/e2e/practice_submit_file_flow.py new file mode 100644 index 00000000..a18c7bdc --- /dev/null +++ b/developer/tests/e2e/practice_submit_file_flow.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""file:// practice open + INIT + submit settlement (no local server). + +Canonical E2E case for Chromium file-protocol host/child postMessage and +practice settlement. Invoked by e2e_runner.py and CI. +""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime +from pathlib import Path + +from playwright.async_api import async_playwright + +REPO = Path(__file__).resolve().parents[3] +INDEX = (REPO / "index.html").as_uri() + "?view=practice" +REPORT = REPO / "developer" / "tests" / "e2e" / "reports" / "practice-submit-file-flow.json" +CHROME_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/138.0.0.0 Safari/537.36" +) + + +def log(msg: str) -> None: + ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{ts}] {msg}", flush=True) + + +async def main() -> int: + report = {"ok": False, "steps": [], "console": [], "errors": []} + + def step(name: str, **extra) -> None: + report["steps"].append({"step": name, **extra}) + log(f"{name} {json.dumps(extra, ensure_ascii=False)[:360]}") + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context(user_agent=CHROME_UA) + page = await context.new_page() + page.on( + "console", + lambda msg: report["console"].append({"page": "host", "type": msg.type, "text": msg.text}) + or ( + log(f"H [{msg.type}] {msg.text[:220]}") + if any( + k in msg.text + for k in ( + "Practice", "Session", "DataCollection", "保存", "提交", "INIT", + "rejected", "origin", "ExamMessage", "合成", "活动", "升级", "ACK", + ) + ) + or msg.type == "error" + else None + ), + ) + page.on("pageerror", lambda err: report["errors"].append({"page": "host", "error": str(err)})) + + def on_page(np) -> None: + np.on( + "console", + lambda msg: report["console"].append({"page": "child", "type": msg.type, "text": msg.text}) + or log(f"C [{msg.type}] {msg.text[:200]}"), + ) + np.on("pageerror", lambda err: report["errors"].append({"page": "child", "error": str(err)})) + + context.on("page", on_page) + + log(f"goto {INDEX}") + await page.goto(INDEX, wait_until="domcontentloaded", timeout=90000) + await page.wait_for_function( + "() => window.app && window.app.isInitialized === true", + timeout=120000, + ) + await page.evaluate( + """async () => { + if (window.LicenseModal && typeof window.LicenseModal.accept === 'function') { + await window.LicenseModal.accept(); + } + document.querySelectorAll('.modal.show, #license-modal, #library-loader-overlay') + .forEach((el) => { + el.classList.remove('show'); + if (el.style) el.style.display = 'none'; + }); + }""" + ) + await page.wait_for_function( + """() => { + const r = window.app && window.app.components && window.app.components.practiceRecorder; + return r && r.constructor && r.constructor.name === 'PracticeRecorder' && !r.isFallback; + }""", + timeout=60000, + ) + host_meta = await page.evaluate( + """() => ({ + protocol: location.protocol, + origin: location.origin, + href: location.href, + userAgent: navigator.userAgent, + testEnvironment: !!(window.EnvironmentDetector + && window.EnvironmentDetector.isInTestEnvironment()) + })""" + ) + if host_meta["testEnvironment"]: + raise AssertionError("file submit E2E must not run in test_env") + step("host_ready", **host_meta) + + exam_id = await page.evaluate( + """async () => { + const index = await window.resolveActiveLibraryIndex(); + const list = Array.isArray(index) ? index : (index && index.exams) || []; + const exam = list.find((item) => item && item.hasHtml !== false + && String(item.type || '').toLowerCase().includes('read')) || list[0]; + return exam && exam.id; + }""" + ) + step("picked_exam", examId=exam_id) + + async with context.expect_page(timeout=30000) as popup_info: + open_result = await page.evaluate( + """async (examId) => { + try { + await window.app.openExam(examId, { practiceMode: 'single' }); + return { ok: true }; + } catch (error) { + return { ok: false, error: String(error && error.message || error) }; + } + }""", + exam_id, + ) + step("openExam", result=open_result) + child = await popup_info.value + await child.wait_for_load_state("domcontentloaded", timeout=90000) + child_meta = await child.evaluate( + """() => ({ + protocol: location.protocol, + origin: location.origin, + href: location.href, + hasOpener: !!window.opener, + referrer: document.referrer + })""" + ) + step("child_loaded", **child_meta) + + host_snap = None + for _ in range(50): + host_snap = await page.evaluate( + """(examId) => { + const info = window.app.examWindows && window.app.examWindows.get(examId); + const r = window.app.components.practiceRecorder; + const session = r && r.activeSessions && r.activeSessions.get(examId); + return { + lastMessageType: info && info.lastMessageType, + expectedOrigin: info && info.expectedOrigin, + allowOpaqueOrigin: info && info.allowOpaqueOrigin, + observedOrigin: info && info.observedOrigin, + expectedSessionId: info && info.expectedSessionId, + recorderSessionId: session && session.sessionId, + hasToken: !!(info && info.windowSessionToken), + rejections: window.app._examMessageRejectionCounts + ? Array.from(window.app._examMessageRejectionCounts.entries()) + : null + }; + }""", + exam_id, + ) + if host_snap and host_snap.get("lastMessageType") == "SESSION_READY": + break + await asyncio.sleep(0.25) + step("handshake", **(host_snap or {})) + + child_bind = await child.evaluate( + """() => { + // Prefer test hooks if present; otherwise infer from ability to post. + const hooks = window.__IELTS_UNIFIED_READING_PAGE_TEST__; + if (hooks && typeof hooks.getTestState === 'function') { + const st = hooks.getTestState(); + return { + parentOrigin: st.parentOrigin, + parentOriginIsOpaque: st.parentOriginIsOpaque, + windowSessionToken: st.windowSessionToken, + sessionId: st.sessionId, + sessionReadySent: st.sessionReadySent, + submissionStatus: st.submissionStatus + }; + } + return { + title: document.title, + submit: !!document.querySelector('#submit-btn') + }; + }""" + ) + step("child_bind", **child_bind) + + await child.evaluate( + """() => { + Array.from(document.querySelectorAll( + 'input[type="text"], input:not([type]), textarea, select, input[type="radio"]' + )).slice(0, 10).forEach((el, index) => { + try { + if (el.tagName === 'SELECT' && el.options.length > 1) { + el.selectedIndex = 1; + el.dispatchEvent(new Event('change', { bubbles: true })); + } else if (el.type === 'radio') { + el.checked = true; + el.dispatchEvent(new Event('change', { bubbles: true })); + } else { + el.value = 'a' + index; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + } catch (_) {} + }); + }""" + ) + click = await child.evaluate( + """() => { + const button = document.querySelector('#submit-btn'); + if (!button) return { clicked: false, reason: 'missing' }; + if (button.disabled) return { clicked: false, reason: 'disabled' }; + button.click(); + return { clicked: true }; + }""" + ) + step("submit_click", **click) + + final = None + child_ui = None + for attempt in range(40): + await asyncio.sleep(0.4) + final = await page.evaluate( + """async (examId) => { + const info = window.app.examWindows.get(examId); + let records = []; + try { + records = await window.AppData.practice.list({ projection: 'light' }); + } catch (error) { + return { error: String(error && error.message || error) }; + } + return { + status: info && info.status, + lastMessageType: info && info.lastMessageType, + submittedRecordId: info && info.submittedRecordId || null, + hasReceipts: !!(info && info.practiceSubmitReceipts + && Object.keys(info.practiceSubmitReceipts).length), + observedOrigin: info && info.observedOrigin, + expectedOrigin: info && info.expectedOrigin, + recordCount: records.length, + rejections: window.app._examMessageRejectionCounts + ? Array.from(window.app._examMessageRejectionCounts.entries()) + : null + }; + }""", + exam_id, + ) + child_ui = await child.evaluate( + """() => { + const results = document.getElementById('results'); + return { + bodyClass: document.body.className, + readonly: document.body.classList.contains('review-readonly-mode'), + resultsDisplay: results ? results.style.display : null, + resultsText: results ? (results.innerText || '').trim().slice(0, 120) : null, + submitDisabled: document.querySelector('#submit-btn') + ? document.querySelector('#submit-btn').disabled + : null + }; + }""" + ) + if ( + final + and final.get("recordCount", 0) > 0 + and final.get("hasReceipts") + and child_ui + and child_ui.get("readonly") + ): + step("post_submit", host=final, child=child_ui, attempt=attempt) + report["ok"] = True + break + if attempt == 39: + step("post_submit_timeout", host=final, child=child_ui) + + report["host"] = final + report["child"] = child_ui + report["interestingConsole"] = [ + c for c in report["console"] + if any( + k.lower() in c["text"].lower() + for k in ( + "reject", "origin", "session", "保存", "DataCollection", "合成", + "INIT", "ACK", "Practice", "失败", + ) + ) + ][-80:] + step("done", ok=report["ok"]) + REPORT.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8") + log(f"report -> {REPORT}") + await browser.close() + return 0 if report["ok"] else 2 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/developer/tests/e2e/reading_question_audit.py b/developer/tests/e2e/reading_question_audit.py index 284451c3..7d570258 100644 --- a/developer/tests/e2e/reading_question_audit.py +++ b/developer/tests/e2e/reading_question_audit.py @@ -25,6 +25,62 @@ SCREENSHOT_DIR = REPORT_DIR / "reading-question-audit-failures" NODE_EXPORTER = REPO_ROOT / "developer" / "tests" / "tools" / "reading-json" / "export_generated_reading_dataset.node.js" +READING_AUDIT_HOST_HTML = """ + + + + + +""" + EXIT_OK = 0 EXIT_FAIL = 1 EXIT_ERROR = 2 @@ -153,19 +209,21 @@ def _parse_frequency(exam_id: str) -> str: return match.group(1) if match else "" -def _collect_manifest_entries() -> List[Dict[str, Any]]: - payload = _run_cmd_json(["node", str(NODE_EXPORTER), "--list"]) +def _collect_export_bundle() -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]: + payload = _run_cmd_json(["node", str(NODE_EXPORTER), "--all"]) entries = payload.get("entries") if not isinstance(entries, list): raise RuntimeError("manifest_entries_missing") - return [entry for entry in entries if isinstance(entry, dict) and entry.get("examId")] - - -def _load_dataset(exam_id: str) -> Dict[str, Any]: - payload = _run_cmd_json(["node", str(NODE_EXPORTER), "--exam-id", exam_id]) - if not isinstance(payload, dict) or not payload.get("examId"): - raise RuntimeError(f"dataset_invalid:{exam_id}") - return payload + datasets = payload.get("datasets") + if not isinstance(datasets, dict): + raise RuntimeError("manifest_datasets_missing") + normalized_entries = [entry for entry in entries if isinstance(entry, dict) and entry.get("examId")] + normalized_datasets = { + str(exam_id): dataset + for exam_id, dataset in datasets.items() + if isinstance(dataset, dict) and dataset.get("examId") + } + return normalized_entries, normalized_datasets def _extract_html_fragments(dataset: Dict[str, Any]) -> List[Tuple[str, str]]: @@ -785,12 +843,31 @@ async def _audit_single_exam_ui( page.set_default_timeout(30000) _collect_console(page, console_entries) - exam_url = f"{UNIFIED_READING_PATH.as_uri()}?examId={exam_id}&test_env=1" - await page.goto(exam_url, wait_until="load") - await page.wait_for_selector("#question-groups", timeout=30000) - await page.wait_for_selector("#submit-btn", timeout=30000) + exam_url = f"{UNIFIED_READING_PATH.as_uri()}?examId={exam_id}&dataKey={exam_id}&test_env=1" + await page.goto(INDEX_URL, wait_until="load") + await page.set_content(READING_AUDIT_HOST_HTML, wait_until="load") + await page.evaluate( + "config => window.__readingAuditConfigure(config)", + { + "url": exam_url, + "examId": exam_id, + "sessionId": f"reading-audit::{exam_id}", + "token": f"reading-audit-token::{exam_id}", + }, + ) + await page.wait_for_selector("#practice-frame") + frame = page.frame(name="practice") + if frame is None: + raise RuntimeError("practice_frame_missing") + await frame.wait_for_selector("#question-groups .unified-group", timeout=30000) + await frame.wait_for_selector("#submit-btn", timeout=30000) await page.wait_for_function( - "() => document.querySelectorAll('#question-groups .unified-group').length > 0", + """(expectedExamId) => window.__readingAuditMessages.some((message) => { + if (!message || String(message.type || '').toUpperCase() !== 'SESSION_READY') return false; + const data = message.data || {}; + return !data.examId || data.examId === expectedExamId; + })""", + arg=exam_id, timeout=30000, ) @@ -803,7 +880,7 @@ async def _audit_single_exam_ui( if not normalized_id: continue answer = answer_key.get(normalized_id) - response = await page.evaluate( + response = await frame.evaluate( JS_APPLY_ANSWER, { "questionId": normalized_id, @@ -818,12 +895,12 @@ async def _audit_single_exam_ui( "detail": response, }) - await page.click("#submit-btn") - await page.wait_for_function( + await frame.click("#submit-btn") + await frame.wait_for_function( "() => { const el = document.getElementById('results'); return !!(el && el.style.display !== 'none' && el.querySelectorAll('tbody tr').length > 0); }", - timeout=30000, + timeout=5000, ) - ui_result = await page.evaluate(JS_READ_RESULTS) + ui_result = await frame.evaluate(JS_READ_RESULTS) if not isinstance(ui_result, dict): raise RuntimeError("ui_result_invalid") @@ -957,22 +1034,22 @@ async def run_audit(mode: str) -> int: SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) log_step("读取 manifest 并收集题目列表") - entries = _collect_manifest_entries() + entries, datasets = _collect_export_bundle() entries_by_exam: Dict[str, Dict[str, Any]] = {str(entry["examId"]): entry for entry in entries} all_exam_ids = sorted(entries_by_exam.keys(), key=lambda item: item) ui_exam_ids = _ui_exam_ids(entries, mode) log_step(f"manifest 题目数: {len(all_exam_ids)}") log_step(f"UI审计题目数({mode}): {len(ui_exam_ids)}") - datasets: Dict[str, Dict[str, Any]] = {} static_failures: List[Dict[str, Any]] = [] static_results: List[Dict[str, Any]] = [] log_step("阶段A: 静态结构审计开始") for index, exam_id in enumerate(all_exam_ids, start=1): try: - dataset = _load_dataset(exam_id) - datasets[exam_id] = dataset + dataset = datasets.get(exam_id) + if not isinstance(dataset, dict): + raise RuntimeError(f"dataset_invalid:{exam_id}") script_rel = str(dataset.get("script") or entries_by_exam[exam_id].get("script") or "") script_path = GENERATED_READING_DIR / script_rel.replace("./", "") if not script_path.exists(): @@ -1021,20 +1098,16 @@ async def run_audit(mode: str) -> int: for index, exam_id in enumerate(ui_exam_ids, start=1): dataset = datasets.get(exam_id) if not dataset: - try: - dataset = _load_dataset(exam_id) - datasets[exam_id] = dataset - except Exception as exc: - fail_result = { - "examId": exam_id, - "status": "fail", - "error": f"dataset_load_failed:{exc}", - "failureType": "dataset_load_failed", - "durationSec": 0.0, - } - ui_results.append(fail_result) - ui_failures.append(fail_result) - continue + fail_result = { + "examId": exam_id, + "status": "fail", + "error": "dataset_load_failed:missing_from_batch_export", + "failureType": "dataset_load_failed", + "durationSec": 0.0, + } + ui_results.append(fail_result) + ui_failures.append(fail_result) + continue log_step(f"UI审计 {index}/{len(ui_exam_ids)}: {exam_id}") result = await _audit_single_exam_ui(context, exam_id, dataset, SCREENSHOT_DIR) diff --git a/developer/tests/e2e/reading_single_flow.node.js b/developer/tests/e2e/reading_single_flow.node.js index 540de9da..64a81730 100644 --- a/developer/tests/e2e/reading_single_flow.node.js +++ b/developer/tests/e2e/reading_single_flow.node.js @@ -56,27 +56,24 @@ function collectConsole(page, store) { async function ensureAppReady(page) { await page.waitForLoadState('load'); - await page.waitForFunction( - () => window.app && window.app.isInitialized && window.storage && typeof window.storage.get === 'function', - { timeout: 60_000 } - ); + await page.waitForFunction(() => !!window.AppData, null, { timeout: 60_000 }); + await page.evaluate(async () => { await window.AppData.ready; }); + await page.waitForFunction(() => window.app?.isInitialized === true, null, { timeout: 60_000 }); } async function dismissOverlays(page) { - try { - await page.evaluate(() => { - try { - localStorage.setItem('hasSeenGplLicense', 'true'); - } catch (_) { } - if (typeof window.acceptGplLicense === 'function') { - try { window.acceptGplLicense(); } catch (_) { } - } - const modal = document.getElementById('license-modal'); - if (modal) { - modal.classList.remove('show'); - } - }); - } catch (_) { } + const accepted = await page.evaluate(async () => { + if (!window.LicenseModal || typeof window.LicenseModal.accept !== 'function') { + throw new Error('LicenseModal.accept is unavailable'); + } + return window.LicenseModal.accept(); + }); + if (!accepted) throw new Error('GPL license consent was not committed'); + await page.waitForFunction( + () => !document.getElementById('license-modal')?.classList.contains('show'), + null, + { timeout: 5_000 } + ); const overlay = page.locator('#library-loader-overlay'); if (await overlay.count()) { @@ -98,21 +95,13 @@ async function clickNav(page, view) { async function waitExamIndexReady(page) { await page.waitForFunction( - () => { - const candidates = []; - if (window.appStateService && typeof window.appStateService.getExamIndex === 'function') { - candidates.push(window.appStateService.getExamIndex()); - } - if (typeof window.getExamIndexState === 'function') { - candidates.push(window.getExamIndexState()); - } - if (window.app && window.app.state && Array.isArray(window.app.state.examIndex)) { - candidates.push(window.app.state.examIndex); - } - if (Array.isArray(window.examIndex)) { - candidates.push(window.examIndex); + async () => { + try { + const index = await window.resolveActiveLibraryIndex(); + return Array.isArray(index) && index.length > 0; + } catch (_) { + return false; } - return candidates.some((item) => Array.isArray(item) && item.length > 0); }, { timeout: 60_000 } ); @@ -229,7 +218,16 @@ async function openManualPdfExam(page, examId) { if (!window.app || typeof window.app.openExam !== 'function') { throw new Error('openExam_missing'); } - await window.app.openExam(targetExamId, { target: 'tab' }); + await window.app.openExam(targetExamId, { + target: 'tab', + examDefinition: { + id: targetExamId, + type: 'reading', + title: 'Manual PDF regression fixture', + hasHtml: false, + pdfFilename: 'ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf', + }, + }); }, examId); await page.waitForTimeout(300); const popupUrl = await page.evaluate( @@ -347,22 +345,14 @@ async function run() { await clickNav(page, 'practice'); await page.waitForFunction( - (targetExamId) => { - const recordsFromState = window.app?.state?.practice?.records; - if (Array.isArray(recordsFromState) && - recordsFromState.some((r) => String(r?.examId || '') === targetExamId)) { - return true; + async (targetExamId) => { + try { + const records = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(records) && + records.some((r) => String(r?.examId || '') === targetExamId); + } catch (_) { + return false; } - if (typeof window.getPracticeRecordsState === 'function') { - try { - const records = window.getPracticeRecordsState(); - return Array.isArray(records) && - records.some((r) => String(r?.examId || '') === targetExamId); - } catch (_) { - return false; - } - } - return false; }, examId, { timeout: 30_000 } diff --git a/developer/tests/e2e/reading_single_flow.py b/developer/tests/e2e/reading_single_flow.py index 93b95084..696f6a72 100644 --- a/developer/tests/e2e/reading_single_flow.py +++ b/developer/tests/e2e/reading_single_flow.py @@ -94,32 +94,26 @@ async def _launch_chromium(p) -> Browser: async def _ensure_app_ready(page: Page) -> None: await page.wait_for_load_state("load") - await page.wait_for_function( - "() => window.app && window.app.isInitialized && window.storage && typeof window.storage.get === 'function'", - timeout=60000, - ) + await page.wait_for_function("() => !!window.AppData", timeout=60000) + await page.evaluate("async () => { await window.AppData.ready; }") + await page.wait_for_function("() => window.app?.isInitialized === true", timeout=60000) async def _dismiss_overlays(page: Page) -> None: - try: - await page.evaluate( - """ - () => { - try { - localStorage.setItem('hasSeenGplLicense', 'true'); - } catch (_) {} - if (typeof window.acceptGplLicense === 'function') { - try { window.acceptGplLicense(); } catch (_) {} - } - const modal = document.getElementById('license-modal'); - if (modal) { - modal.classList.remove('show'); - } + accepted = await page.evaluate( + """async () => { + if (!window.LicenseModal || typeof window.LicenseModal.accept !== 'function') { + throw new Error('LicenseModal.accept is unavailable'); } - """ - ) - except Exception: - pass + return window.LicenseModal.accept(); + }""" + ) + if not accepted: + raise RuntimeError("GPL license consent was not committed") + await page.wait_for_function( + "() => !document.getElementById('license-modal')?.classList.contains('show')", + timeout=5000, + ) overlay = page.locator("#library-loader-overlay") if await overlay.count(): @@ -141,21 +135,13 @@ async def _click_nav(page: Page, view: str) -> None: async def _wait_exam_index_ready(page: Page) -> None: await page.wait_for_function( """ - () => { - const candidates = []; - if (window.appStateService && typeof window.appStateService.getExamIndex === 'function') { - candidates.push(window.appStateService.getExamIndex()); - } - if (typeof window.getExamIndexState === 'function') { - candidates.push(window.getExamIndexState()); - } - if (window.app && window.app.state && Array.isArray(window.app.state.examIndex)) { - candidates.push(window.app.state.examIndex); - } - if (Array.isArray(window.examIndex)) { - candidates.push(window.examIndex); + async () => { + try { + const index = await window.resolveActiveLibraryIndex(); + return Array.isArray(index) && index.length > 0; + } catch (_) { + return false; } - return candidates.some((item) => Array.isArray(item) && item.length > 0); } """, timeout=60000, @@ -280,7 +266,16 @@ async def _open_manual_pdf_exam(page: Page, exam_id: str) -> tuple[str, bool]: if (!window.app || typeof window.app.openExam !== 'function') { throw new Error('openExam_missing'); } - await window.app.openExam(targetExamId, { target: 'tab' }); + await window.app.openExam(targetExamId, { + target: 'tab', + examDefinition: { + id: targetExamId, + type: 'reading', + title: 'Manual PDF regression fixture', + hasHtml: false, + pdfFilename: 'ReadingPractice/PDF/26. P1 - The Tuatara of New Zealand 新西兰蜥蜴.pdf' + } + }); } """, exam_id, @@ -371,54 +366,31 @@ async def run() -> None: await practice_page.close() await _click_nav(page, "practice") - await page.wait_for_function( + semantic_handle = await page.wait_for_function( """ - (targetExamId) => { - const recordsFromState = window.app?.state?.practice?.records; - if (Array.isArray(recordsFromState) && recordsFromState.some(r => String(r?.examId || '') === targetExamId)) { - return true; - } - if (typeof window.getPracticeRecordsState === 'function') { - try { - const records = window.getPracticeRecordsState(); - return Array.isArray(records) && records.some(r => String(r?.examId || '') === targetExamId); - } catch (_) { - return false; - } + async (targetExamId) => { + try { + const records = await window.AppData.practice.list({ projection: 'light' }); + const matched = Array.isArray(records) + ? records.filter(r => String(r?.examId || '') === targetExamId) + : []; + const record = matched.length ? matched[matched.length - 1] : null; + return record ? { + duration: Number(record.duration) || 0, + startTimeMs: record.startTime ? new Date(record.startTime).getTime() : null, + endTimeMs: record.endTime ? new Date(record.endTime).getTime() : null, + } : null; + } catch (_) { + return null; } - return false; } """, arg=exam_id, timeout=30000, ) + semantic = await semantic_handle.json_value() log_step("练习记录已落地", "SUCCESS") - semantic = await page.evaluate( - """ - ({ targetExamId, targetSessionId }) => { - const records = window.getPracticeRecordsState?.() - || window.app?.state?.practice?.records - || []; - const matched = Array.isArray(records) - ? records - .filter(r => String(r?.examId || '') === String(targetExamId)) - .filter(r => !targetSessionId || String(r?.sessionId || '') === String(targetSessionId)) - : []; - const record = matched.length ? matched[matched.length - 1] : null; - if (!record) { - return { found: false }; - } - return { - found: true, - duration: Number(record.duration) || 0, - startTimeMs: record.startTime ? new Date(record.startTime).getTime() : null, - endTimeMs: record.endTime ? new Date(record.endTime).getTime() : null, - }; - } - """, - {"targetExamId": exam_id, "targetSessionId": session_id}, - ) - if not semantic.get("found"): + if not semantic: raise AssertionError("未找到目标练习记录用于时间语义断言") duration_sec = float(semantic.get("duration") or 0) end_time_ms = int(semantic.get("endTimeMs") or 0) diff --git a/developer/tests/e2e/simulation_nb_drag_regression.py b/developer/tests/e2e/simulation_nb_drag_regression.py index 7d092e53..9157d247 100644 --- a/developer/tests/e2e/simulation_nb_drag_regression.py +++ b/developer/tests/e2e/simulation_nb_drag_regression.py @@ -4,7 +4,7 @@ Covers: - NB clone drag question status should become answered right after drop. - Simulation draft replay should restore dragged answer and answered status. -- Simulation draft mirror should exist in sessionStorage. +- Simulation draft should be available through AppData recovery window-session state. - Highlight replay should restore at least one highlight when a record is available. """ @@ -39,6 +39,7 @@ ".pool-items .drag-item, #word-options .draggable-word, #word-options .drag-item, " ".draggable-word, .drag-item" ) +CLONE_GROUP_SELECTOR = '#question-groups .unified-group[data-allow-option-reuse="true"]' def collect_nb_exam_ids() -> List[str]: @@ -66,9 +67,13 @@ async def run_case(page, exam_id: str, base_url: str) -> Dict[str, Any]: await page.goto(url, wait_until="load") await page.wait_for_selector("#question-groups .unified-group", timeout=30000) - target = page.locator(".match-dropzone[data-question]").first - target_count = await page.locator(".match-dropzone[data-question]").count() - source_count = await page.locator(SOURCE_SELECTOR).count() + clone_group = page.locator(CLONE_GROUP_SELECTOR).filter( + has=page.locator(".match-dropzone[data-question]") + ).first + target = clone_group.locator(".match-dropzone[data-question]").first + source = clone_group.locator(SOURCE_SELECTOR).first + target_count = await clone_group.locator(".match-dropzone[data-question]").count() + source_count = await clone_group.locator(SOURCE_SELECTOR).count() if target_count == 0 or source_count == 0: result["status"] = "fail" @@ -76,7 +81,7 @@ async def run_case(page, exam_id: str, base_url: str) -> Dict[str, Any]: return result question_id = await target.get_attribute("data-question") - await page.locator(SOURCE_SELECTOR).first.drag_to(target) + await source.drag_to(target) await page.wait_for_timeout(250) after_drop = await page.evaluate( @@ -156,10 +161,28 @@ async def run_case(page, exam_id: str, base_url: str) -> Dict[str, Any]: "scrollY": 0, } + session_token = f"nb-drag-regression::{exam_id}" await page.evaluate( """(payload) => { + window.postMessage({ + type: 'INIT_SESSION', + source: 'exam_host', + data: { + examId: payload.examId, + sessionId: 'nb-drag-regression', + suiteSessionId: 'nb-drag-regression', + practiceMode: 'suite', + suiteFlowMode: 'simulation', + suiteSequenceIndex: 0, + suiteSequenceTotal: 3, + parentOrigin: 'file://', + windowSessionToken: payload.sessionToken, + messageIssuedAtMs: Date.now() + } + }, '*'); window.postMessage({ type: 'SIMULATION_CONTEXT', + source: 'exam_host', data: { flowMode: 'simulation', examId: payload.examId, @@ -170,11 +193,13 @@ async def run_case(page, exam_id: str, base_url: str) -> Dict[str, Any]: canPrev: false, canNext: true, elapsed: 0, + windowSessionToken: payload.sessionToken, + messageIssuedAtMs: Date.now(), draft: payload.draft } }, '*'); }""", - {"examId": exam_id, "draft": draft}, + {"examId": exam_id, "draft": draft, "sessionToken": session_token}, ) await page.wait_for_timeout(400) @@ -223,11 +248,10 @@ async def run_case(page, exam_id: str, base_url: str) -> Dict[str, Any]: await page.wait_for_timeout(1300) mirror_ok = await page.evaluate( """(examId) => { - const key = 'ielts_sim_draft::nb-drag-regression::' + examId; - const raw = sessionStorage.getItem(key); - if (!raw) return false; try { - const parsed = JSON.parse(raw); + const parsed = window.AppData?.recovery?.windowSession?.get( + 'simulation-draft:nb-drag-regression:' + examId + ); return Boolean(parsed && parsed.draft && parsed.draft.answers); } catch (_) { return false; diff --git a/developer/tests/e2e/simulation_roundtrip_restore_regression.py b/developer/tests/e2e/simulation_roundtrip_restore_regression.py index 67606087..e47853a1 100644 --- a/developer/tests/e2e/simulation_roundtrip_restore_regression.py +++ b/developer/tests/e2e/simulation_roundtrip_restore_regression.py @@ -212,11 +212,8 @@ async def ensure_fixed_suite_index(page) -> None: if (window.EnvironmentDetector && typeof window.EnvironmentDetector.disableTestEnvironment === 'function') { window.EnvironmentDetector.disableTestEnvironment(); } - if (window.localStorage) { - window.localStorage.removeItem('__ielts_test_env__'); - } } catch (_) { - // ignore storage errors + // ignore environment detector reset errors } const app = window.app; if (!app || typeof app._fetchSuiteExamIndex !== 'function') { @@ -249,28 +246,20 @@ async def ensure_fixed_suite_index(page) -> None: async def dismiss_license_modal_if_present(page) -> None: - await page.evaluate( - """() => { - try { - if (typeof window.acceptGplLicense === 'function') { - window.acceptGplLicense(); - return; - } - if (window.localStorage) { - window.localStorage.setItem('hasSeenGplLicense', 'true'); - } - } catch (_) { - // ignore storage errors - } - const modal = document.getElementById('license-modal'); - if (modal) { - modal.classList.remove('show'); + accepted = await page.evaluate( + """async () => { + if (!window.LicenseModal || typeof window.LicenseModal.accept !== 'function') { + throw new Error('LicenseModal.accept is unavailable'); } + return window.LicenseModal.accept(); }""" ) - modal = page.locator("#license-modal.show") - if await modal.count() > 0: - await page.wait_for_timeout(150) + if not accepted: + raise RuntimeError("GPL license consent was not committed") + await page.wait_for_function( + "() => !document.getElementById('license-modal')?.classList.contains('show')", + timeout=5000, + ) async def run() -> Dict[str, Any]: diff --git a/developer/tests/e2e/suite_practice_flow.node.js b/developer/tests/e2e/suite_practice_flow.node.js index ad33882d..4fdc9f02 100644 --- a/developer/tests/e2e/suite_practice_flow.node.js +++ b/developer/tests/e2e/suite_practice_flow.node.js @@ -32,10 +32,9 @@ function ensureDir(dirPath) { async function ensureAppReady(page) { await page.waitForLoadState('load'); - await page.waitForFunction( - () => window.app && window.app.isInitialized && window.storage && typeof window.storage.get === 'function', - { timeout: 60_000 } - ); + await page.waitForFunction(() => !!window.AppData, null, { timeout: 60_000 }); + await page.evaluate(async () => { await window.AppData.ready; }); + await page.waitForFunction(() => window.app?.isInitialized === true, null, { timeout: 60_000 }); } async function clickNav(page, view) { @@ -194,14 +193,15 @@ async function run() { await clickNav(page, 'practice'); await page.waitForTimeout(2000); - await page.waitForFunction(() => { - return window.app && window.app.state && window.app.state.practice && - Array.isArray(window.app.state.practice.records) && - window.app.state.practice.records.length > 0; + await page.waitForFunction(async () => { + const records = await window.AppData.practice.list({ projection: 'light' }); + return Array.isArray(records) && records.length > 0; }, { timeout: 30_000 }); - await page.evaluate(() => { - if (typeof window.updatePracticeView === 'function') window.updatePracticeView(); + await page.evaluate(async () => { + if (typeof window.syncPracticeRecords === 'function') { + await window.syncPracticeRecords({ forceRender: true }); + } }); await page.waitForTimeout(500); @@ -212,9 +212,7 @@ async function run() { const recordId = await suiteRecord.getAttribute('data-record-id'); if (!recordId) throw new Error('Suite practice record not found in history list'); const suiteDuration = await page.evaluate(async (id) => { - if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.list !== 'function') return -1; - const records = await window.PracticeRecordAPI.list(); - const target = Array.isArray(records) ? records.find((item) => item && item.id === id) : null; + const target = await window.AppData.practice.get(id); return target && Number.isFinite(Number(target.duration)) ? Number(target.duration) : -1; }, recordId); if (suiteDuration < 2) { @@ -222,11 +220,8 @@ async function run() { } const recordCountBefore = await page.evaluate(async () => { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records.length : 0; - } - return document.querySelectorAll('#history-list .history-record-item').length; + const records = await window.AppData.practice.list({ projection: 'light' }); + return records.length; }); const titleText = await page.evaluate((id) => { @@ -337,11 +332,8 @@ async function run() { await page.waitForTimeout(800); const recordCountAfter = await page.evaluate(async () => { - if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') { - const records = await window.PracticeRecordAPI.list(); - return Array.isArray(records) ? records.length : 0; - } - return document.querySelectorAll('#history-list .history-record-item').length; + const records = await window.AppData.practice.list({ projection: 'light' }); + return records.length; }); if (recordCountAfter !== recordCountBefore) { throw new Error(`Replay should not create new records: before=${recordCountBefore}, after=${recordCountAfter}`); diff --git a/developer/tests/e2e/suite_practice_flow.py b/developer/tests/e2e/suite_practice_flow.py index 461d5642..fc9025ff 100755 --- a/developer/tests/e2e/suite_practice_flow.py +++ b/developer/tests/e2e/suite_practice_flow.py @@ -90,16 +90,15 @@ async def _ensure_app_ready(page: Page) -> None: log_step("页面加载完成,等待应用初始化...") try: - await page.wait_for_function( - "() => window.app && window.app.isInitialized && window.storage && typeof window.storage.get === 'function'", - timeout=60000, - ) + await page.wait_for_function("() => !!window.AppData", timeout=60000) + await page.evaluate("async () => { await window.AppData.ready; }") + await page.wait_for_function("() => window.app?.isInitialized === true", timeout=60000) log_step("应用初始化完成", "SUCCESS") except PlaywrightTimeoutError: log_step("应用初始化超时", "ERROR") # 打印调试信息 app_state = await page.evaluate( - "() => ({ hasApp: !!window.app, isInit: window.app?.isInitialized, hasStorage: !!window.storage })" + "() => ({ hasApp: !!window.app, isInit: window.app?.isInitialized, hasAppData: !!window.AppData })" ) log_step(f"应用状态: {app_state}", "DEBUG") raise @@ -111,7 +110,7 @@ async def _click_nav(page: Page, view: str) -> None: try: await _dismiss_overlays(page) - await page.locator(f"nav button[data-view='{view}']").click() + await page.locator(f"nav button[data-view='{view}']").evaluate("node => node.click()") await page.wait_for_selector(f"#{view}-view.active", timeout=15000) log_step(f"视图 {view} 已激活", "SUCCESS") except PlaywrightTimeoutError: @@ -162,23 +161,13 @@ async def _preset_suite_preferences( auto_advance_after_submit = normalized_mode != "stationary" normalized_auto_advance = bool(auto_advance_after_submit) await page.evaluate( - """(payload) => { - try { - localStorage.setItem('suite_flow_mode', payload.mode); - localStorage.setItem('suite_frequency_scope', payload.scope); - localStorage.setItem('suite_auto_advance_after_submit', payload.autoAdvanceAfterSubmit ? 'true' : 'false'); - } catch (_) { - // ignore storage failures - } - if (!window.practiceConfig || typeof window.practiceConfig !== 'object') { - window.practiceConfig = {}; - } - if (!window.practiceConfig.suite || typeof window.practiceConfig.suite !== 'object') { - window.practiceConfig.suite = {}; - } - window.practiceConfig.suite.flowMode = payload.mode; - window.practiceConfig.suite.frequencyScope = payload.scope; - window.practiceConfig.suite.autoAdvanceAfterSubmit = payload.autoAdvanceAfterSubmit; + """async (payload) => { + await window.SuitePreferenceUtils.ready(); + window.SuitePreferenceUtils.persistSuitePreference({ + flowMode: payload.mode, + frequencyScope: payload.scope, + autoAdvanceAfterSubmit: payload.autoAdvanceAfterSubmit + }); }""", { "mode": normalized_mode, @@ -186,46 +175,52 @@ async def _preset_suite_preferences( "autoAdvanceAfterSubmit": normalized_auto_advance, } ) + await page.wait_for_function( + """async (expected) => { + const suite = await window.AppData.preferences.getSuite(); + return suite?.flowMode === expected.mode + && suite?.frequencyScope === expected.scope + && suite?.autoAdvanceAfterSubmit === expected.autoAdvanceAfterSubmit; + }""", + arg={"mode": normalized_mode, "scope": normalized_scope, "autoAdvanceAfterSubmit": normalized_auto_advance}, + timeout=10000, + ) async def _dismiss_overlays(page: Page) -> None: """关闭可能阻塞交互的覆盖层""" log_step("检查并关闭覆盖层...") - try: - await page.evaluate( - """() => { - try { - localStorage.setItem('hasSeenGplLicense', 'true'); - } catch (_) { - // ignore storage errors - } - if (typeof window.acceptGplLicense === 'function') { - try { window.acceptGplLicense(); } catch (_) {} - } - const modal = document.getElementById('license-modal'); - if (modal) { - modal.classList.remove('show'); - } - }""" - ) - except Exception as e: - log_step(f"预清理 GPL 弹窗失败: {e}", "WARNING") + has_visible_overlay = await page.evaluate( + """() => { + const visible = (node) => !!node && getComputedStyle(node).display !== 'none' + && getComputedStyle(node).visibility !== 'hidden' + && node.getBoundingClientRect().width > 0 + && node.getBoundingClientRect().height > 0; + return visible(document.getElementById('license-modal')) + || visible(document.getElementById('library-loader-overlay')) + || Array.from(document.querySelectorAll('.backup-modal-close')).some(visible); + }""" + ) + if not has_visible_overlay: + return # 关闭 GPL 许可弹窗 - license_modal = page.locator("#license-modal.show") - if await license_modal.count(): - try: - acknowledge = license_modal.locator("button.lm-btn") - if await acknowledge.count(): - await acknowledge.first.click() - await page.wait_for_function( - "() => !document.getElementById('license-modal')?.classList.contains('show')", - timeout=5000, - ) - log_step("已关闭 GPL 许可弹窗", "SUCCESS") - except Exception as e: - log_step(f"关闭 GPL 许可弹窗失败: {e}", "WARNING") + license_modal = page.locator("#license-modal") + try: + await license_modal.wait_for(state="visible", timeout=1500) + acknowledge = license_modal.locator("button.lm-btn") + if await acknowledge.count(): + await acknowledge.first.evaluate("node => node.click()") + await page.wait_for_function( + "() => !document.getElementById('license-modal')?.classList.contains('show')", + timeout=5000, + ) + log_step("已关闭 GPL 许可弹窗", "SUCCESS") + except PlaywrightTimeoutError: + pass + except Exception as e: + log_step(f"关闭 GPL 许可弹窗失败: {e}", "WARNING") # 关闭题库加载器覆盖层 @@ -235,7 +230,7 @@ async def _dismiss_overlays(page: Page) -> None: await overlay.wait_for(state="visible", timeout=2000) close_btn = overlay.locator("[data-library-action='close']") if await close_btn.count(): - await close_btn.first.click() + await close_btn.first.evaluate("node => node.click()") await overlay.wait_for(state="detached", timeout=5000) log_step("已关闭题库加载器覆盖层", "SUCCESS") except Exception as e: @@ -243,7 +238,7 @@ async def _dismiss_overlays(page: Page) -> None: # 关闭备份模态框 backup_modal = page.locator(".backup-modal-close") - if await backup_modal.count(): + if await backup_modal.count() and await backup_modal.first.is_visible(): try: await backup_modal.first.click() log_step("已关闭备份模态框", "SUCCESS") @@ -307,18 +302,47 @@ async def _complete_passage(suite_page: Page, total_count: int, index: int) -> b log_step(f"当前题目 ID: {current_exam_id}", "DEBUG") log_step(f"提交前计时器: {timer_before_submit}s", "DEBUG") - await suite_page.click("#complete-exam-btn") + # Native DOM click avoids Playwright's actionability retry racing a same-URL + # history marker inserted by SuiteBackGuard between passages. + await suite_page.locator("#complete-exam-btn").evaluate("node => node.click()") log_step("已点击完成按钮") - await suite_page.wait_for_function( - "(initialId) => {\n" - " const btn = document.getElementById('complete-exam-btn');\n" - " const examId = document.body.dataset.examId || '';\n" - " return !!(btn && btn.disabled) || examId !== String(initialId || '');\n" - "}", - arg=current_exam_id, - timeout=20000, - ) + try: + await suite_page.wait_for_function( + "(initialId) => {\n" + " const btn = document.getElementById('complete-exam-btn');\n" + " const examId = document.body.dataset.examId || '';\n" + " return !!(btn && btn.disabled) || examId !== String(initialId || '');\n" + "}", + arg=current_exam_id, + timeout=20000, + ) + except PlaywrightTimeoutError: + try: + transition_state = await suite_page.evaluate( + """async () => ({ + url: window.location.href, + examId: document.body?.dataset?.examId || '', + examState: document.body?.dataset?.examState || '', + eventLog: document.getElementById('event-log')?.innerText || '', + buttonDisabled: Boolean(document.getElementById('complete-exam-btn')?.disabled), + openerSession: window.opener?.app?.currentSuiteSession + ? { + activeExamId: window.opener.app.currentSuiteSession.activeExamId || '', + currentIndex: window.opener.app.currentSuiteSession.currentIndex, + sequence: (window.opener.app.currentSuiteSession.sequence || []).map((item) => item?.examId || ''), + handlerKeys: Array.from(window.opener.app.messageHandlers?.keys?.() || []), + targetInActiveIndex: window.opener.resolveActiveLibraryIndex + ? (await window.opener.resolveActiveLibraryIndex()).some((item) => item?.id === window.opener.app.currentSuiteSession.sequence?.[2]?.examId) + : null + } + : null + })""" + ) + log_step(f"提交后切题诊断: {transition_state}", "ERROR") + except Exception as diagnostic_error: + log_step(f"提交后切题诊断失败: {diagnostic_error}", "WARNING") + raise if index + 1 < total_count: log_step(f"等待下一篇练习加载...") @@ -339,6 +363,29 @@ async def _complete_passage(suite_page: Page, total_count: int, index: int) -> b if suite_page.is_closed(): log_step("套题页面在切换时关闭", "WARNING") return False + try: + transition_state = await suite_page.evaluate( + """async () => ({ + url: window.location.href, + examId: document.body?.dataset?.examId || '', + examState: document.body?.dataset?.examState || '', + buttonDisabled: Boolean(document.getElementById('complete-exam-btn')?.disabled), + openerSession: window.opener?.app?.currentSuiteSession + ? { + activeExamId: window.opener.app.currentSuiteSession.activeExamId || '', + currentIndex: window.opener.app.currentSuiteSession.currentIndex, + sequence: (window.opener.app.currentSuiteSession.sequence || []).map((item) => item?.examId || ''), + handlerKeys: Array.from(window.opener.app.messageHandlers?.keys?.() || []), + targetInActiveIndex: window.opener.resolveActiveLibraryIndex + ? (await window.opener.resolveActiveLibraryIndex()).some((item) => item?.id === window.opener.app.currentSuiteSession.sequence?.[2]?.examId) + : null + } + : null + })""" + ) + log_step(f"切题超时诊断: {transition_state}", "ERROR") + except Exception as diagnostic_error: + log_step(f"切题超时诊断失败: {diagnostic_error}", "WARNING") # 兼容提交后先进入回看页,再由 next 导航到下一篇 log_step("自动切换未发生,尝试通过回看导航 next 切题...", "WARNING") nav_next_selector = await suite_page.evaluate( @@ -424,11 +471,8 @@ async def _verify_popstate_back_guard(suite_page: Page) -> bool: async def _count_practice_records(page: Page) -> int: return await page.evaluate( "async () => {\n" - " if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {\n" - " const records = await window.PracticeRecordAPI.list();\n" - " return Array.isArray(records) ? records.length : 0;\n" - " }\n" - " return document.querySelectorAll('#history-list .history-record-item').length;\n" + " const records = await window.AppData.practice.list({ projection: 'light' });\n" + " return records.length;\n" "}" ) @@ -464,11 +508,7 @@ async def _suite_record_stats(page: Page) -> Dict[str, int]: " toFiniteNumber(record.timestamp)\n" " );\n" " };\n" - " if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.list !== 'function') {\n" - " const fallbackCount = document.querySelectorAll('#history-list .history-record-item').length;\n" - " return { count: Number(fallbackCount) || 0, latestTs: 0 };\n" - " }\n" - " const records = await window.PracticeRecordAPI.list();\n" + " const records = await window.AppData.practice.list();\n" " if (!Array.isArray(records)) {\n" " return { count: 0, latestTs: 0 };\n" " }\n" @@ -556,8 +596,8 @@ def _handler(msg: ConsoleMessage) -> None: page.on("console", _handler) -async def run() -> None: - """运行套题练习流程测试""" +async def run() -> bool: + """运行套题练习流程测试,返回是否通过""" REPORT_DIR.mkdir(parents=True, exist_ok=True) console_log: List[ConsoleEntry] = [] start_time = datetime.now() @@ -607,7 +647,7 @@ async def run() -> None: log_step("查找套题练习按钮...") start_button = page.locator("button[data-action='start-suite-mode']") - await start_button.scroll_into_view_if_needed() + await start_button.wait_for(state="visible", timeout=15000) log_step("套题练习按钮已就绪", "SUCCESS") await _preset_suite_preferences( page, @@ -622,6 +662,15 @@ async def run() -> None: await _select_suite_flow_mode(page, "simulation") suite_page = await popup_wait.value _collect_console(suite_page, console_log) + await suite_page.wait_for_load_state("load") + placeholder_state = await suite_page.evaluate( + """() => ({ + suiteTest: new URLSearchParams(window.location.search).get('suite_test'), + examState: document.body?.dataset?.examState || '' + })""" + ) + if placeholder_state.get("suiteTest") != "1" or placeholder_state.get("examState") == "blocked": + raise AssertionError(f"suite placeholder test mode was not propagated: {placeholder_state}") log_step("套题练习窗口已打开", "SUCCESS") await page.wait_for_function( @@ -666,29 +715,23 @@ async def run() -> None: log_step("等待练习记录加载...") await page.wait_for_function( - "() => {\n" - " const appRecords = window.app && window.app.state && window.app.state.practice\n" - " ? window.app.state.practice.records\n" - " : null;\n" - " if (Array.isArray(appRecords) && appRecords.length > 0) {\n" - " return true;\n" - " }\n" - " if (typeof window.getPracticeRecordsState === 'function') {\n" - " try {\n" - " const records = window.getPracticeRecordsState();\n" - " if (Array.isArray(records) && records.length > 0) {\n" - " return true;\n" - " }\n" - " } catch (_) {}\n" - " }\n" - " return false;\n" + "async () => {\n" + " const records = await window.AppData.practice.list({ projection: 'light' });\n" + " return Array.isArray(records) && records.length > 0;\n" "}", timeout=30000, ) log_step("练习记录已加载", "SUCCESS") await page.evaluate( - "if (typeof window.updatePracticeView === 'function') { window.updatePracticeView(); }" + """async () => { + if (typeof window.updatePracticeView !== 'function') return; + const records = await window.AppData.practice.list({ projection: 'light' }); + const examIndex = typeof window.resolveActiveLibraryIndex === 'function' + ? await window.resolveActiveLibraryIndex() + : []; + window.updatePracticeView(records, examIndex); + }""" ) await page.wait_for_timeout(500) @@ -707,9 +750,7 @@ async def run() -> None: raise AssertionError("Suite practice record not found in history list") suite_duration = await page.evaluate( "async (id) => {\n" - " if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.list !== 'function') return -1;\n" - " const records = await window.PracticeRecordAPI.list();\n" - " const target = Array.isArray(records) ? records.find((item) => item && item.id === id) : null;\n" + " const target = await window.AppData.practice.get(id);\n" " const value = target && Number.isFinite(Number(target.duration)) ? Number(target.duration) : -1;\n" " return value;\n" "}", @@ -892,23 +933,10 @@ async def run() -> None: manual_suite_latest_ts_before = int(manual_suite_stats_before.get("latestTs") or 0) log_step("开始手动回看模式专项验证...") await _click_nav(page, "overview") - await start_button.scroll_into_view_if_needed() - await page.evaluate( - "() => {\n" - " try {\n" - " localStorage.setItem('suite_flow_mode', 'stationary');\n" - " localStorage.setItem('suite_auto_advance_after_submit', 'false');\n" - " } catch (_) {}\n" - " if (!window.practiceConfig || typeof window.practiceConfig !== 'object') {\n" - " window.practiceConfig = {};\n" - " }\n" - " if (!window.practiceConfig.suite || typeof window.practiceConfig.suite !== 'object') {\n" - " window.practiceConfig.suite = {};\n" - " }\n" - " window.practiceConfig.suite.flowMode = 'stationary';\n" - " window.practiceConfig.suite.autoAdvanceAfterSubmit = false;\n" - "}" - ) + await _preset_suite_preferences(page, mode="stationary", auto_advance_after_submit=False) + # 偏好写入会触发 overview 重绘,定位器必须在重绘完成后重新获取。 + start_button = page.locator("button[data-action='start-suite-mode']") + await start_button.wait_for(state="visible", timeout=15000) async with page.expect_popup(timeout=20000) as manual_popup_wait: await start_button.click() await _select_suite_flow_mode(page, "stationary") @@ -916,7 +944,14 @@ async def run() -> None: _collect_console(manual_suite_page, console_log) await manual_suite_page.wait_for_load_state("load") await page.wait_for_function( - "() => !!(window.practiceConfig && window.practiceConfig.suite && window.practiceConfig.suite.autoAdvanceAfterSubmit === false)", + """async () => { + const persisted = await window.AppData.preferences.getSuite(); + const runtime = await window.SuitePreferenceUtils.resolveSuitePreference(); + return persisted?.flowMode === 'stationary' + && persisted?.autoAdvanceAfterSubmit === false + && runtime?.flowMode === 'stationary' + && runtime?.autoAdvanceAfterSubmit === false; + }""", timeout=10000, ) log_step("已切换到手动回看模式", "SUCCESS") @@ -958,7 +993,15 @@ async def run() -> None: raise AssertionError("manual mode P2 should be answering state, not settled review state") nav_on_p2_answering = await _review_nav_state(manual_suite_page) if not nav_on_p2_answering or nav_on_p2_answering.get("hidden"): - raise AssertionError("manual mode P2 answering should keep review nav visible") + nav_debug = await manual_suite_page.evaluate( + "() => ({url: location.href, pageType: document.body?.dataset?.pageType || '', " + "examId: document.body?.dataset?.examId || '', " + "reviewNav: document.getElementById('review-nav-bar')?.outerHTML || " + "document.getElementById('practice-review-nav')?.outerHTML || null, " + "suiteAttrs: {suiteSessionId: document.body?.dataset?.suiteSessionId || '', " + "reviewMode: document.body?.dataset?.reviewMode || ''}})" + ) + raise AssertionError(f"manual mode P2 answering should keep review nav visible: {nav_debug}") await manual_suite_page.click("#complete-exam-btn") await manual_suite_page.wait_for_function( @@ -1115,7 +1158,8 @@ async def run() -> None: else: log_step(f"❌ 测试失败 (耗时: {duration:.2f}秒)", "ERROR") log_step("=" * 80) + return test_passed if __name__ == "__main__": - asyncio.run(run()) + raise SystemExit(0 if asyncio.run(run()) else 1) diff --git a/developer/tests/e2e/ui_export_import_click.py b/developer/tests/e2e/ui_export_import_click.py new file mode 100644 index 00000000..88ba3531 --- /dev/null +++ b/developer/tests/e2e/ui_export_import_click.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Real-click Playwright smoke for settings export/import against live AppData v2.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from playwright.async_api import async_playwright + +ROOT = Path(__file__).resolve().parents[3] +# Prefer file:// so CI and local runs do not depend on a temporary HTTP host. +URL = (ROOT / "index.html").as_uri() +OUT_DIR = Path(__file__).resolve().parent / "reports" +OUT = OUT_DIR / "ui-export-import-click.json" + + +async def dismiss_overlay(page) -> str: + """Close license / backup-list / import-mode overlays that intercept clicks.""" + closed = [] + for selector in ("#license-modal", "#backup-list-modal", ".import-mode-modal", ".backup-modal-overlay"): + loc = page.locator(selector) + count = await loc.count() + if not count: + continue + for index in range(count): + el = loc.nth(index) + try: + visible = await el.is_visible() + except Exception: + visible = False + if not visible: + continue + close = el.locator( + "button[aria-label='关闭'], button.theme-modal-close, " + ".shui-secondary-modal__close, button:has-text('关闭'), " + "button:has-text('取消'), .close-btn-lite" + ) + if await close.count(): + await close.first.click(force=True) + else: + await page.keyboard.press("Escape") + await page.wait_for_timeout(200) + if await el.is_visible(): + await el.evaluate( + "node => { node.classList.remove('show'); " + "node.style.display = 'none'; node.setAttribute('aria-hidden', 'true'); }" + ) + closed.append(selector) + # license modal uses display:flex even after failed dismiss in some paths + license_modal = page.locator("#license-modal") + if await license_modal.count(): + await license_modal.evaluate( + "el => { el.style.display = 'none'; el.classList.remove('show'); }" + ) + closed.append("#license-modal:force") + return ",".join(closed) or "none" + + +async def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + result: dict = {"steps": [], "ok": True} + + def step(name: str, **kw) -> None: + entry = {"name": name, **kw} + result["steps"].append(entry) + print(json.dumps(entry, ensure_ascii=False), flush=True) + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context(accept_downloads=True) + page = await context.new_page() + page.set_default_timeout(30000) + console: list[str] = [] + page.on("console", lambda msg: console.append(f"{msg.type}: {msg.text}")) + page.on("pageerror", lambda err: console.append(f"pageerror: {err}")) + + await page.goto(URL, wait_until="load") + await page.wait_for_function("() => !!window.AppData") + await page.evaluate("async () => { await window.AppData.ready; }") + await page.wait_for_function("() => window.app?.isInitialized === true", timeout=60000) + step("app-ready", url=page.url) + + modal = page.locator("#license-modal") + if await modal.count(): + display = await modal.evaluate("el => getComputedStyle(el).display") + if display != "none": + await page.locator('button[data-index-action="accept-license"]').click(force=True) + await page.wait_for_timeout(400) + step("overlays-after-license", closed=await dismiss_overlay(page)) + + await page.locator("nav.main-nav button[data-view='settings']").click() + await page.wait_for_selector("#settings-view.active", timeout=10000) + step("settings-open") + + seed = await page.evaluate( + """async () => { + await window.AppData.ready; + const before = (await window.AppData.practice.list({projection:'light'})).length; + const receipt = await window.AppData.practice.completeAttempt({ + operationId: 'ui-seed-1', + record: { + id: 'ui-seed-1', sessionId: 'ui-seed-1', examId: 'reading-ui-seed', + title: 'UI Seed Passage', type: 'reading', totalQuestions: 4, correctAnswers: 3, + answers: {q1:'A', q2:'B', q3:'C', q4:'D'}, + highlights: [{text:'seed highlight'}], dataSource: 'real', + metadata: {examTitle:'UI Seed Passage', category:'P1'} + } + }); + const after = await window.AppData.practice.list({projection:'light'}); + return {before, after: after.length, committed: receipt.committed, ids: after.map(r=>r.id)}; + }""" + ) + step("seed-record", **seed) + assert seed["committed"] and "ui-seed-1" in seed["ids"] + + await page.locator("#create-backup-btn").click() + await page.wait_for_timeout(800) + backups_after_create = await page.evaluate( + "async () => (await window.AppData.backups.list()).length" + ) + step("create-backup-click", backupCount=backups_after_create) + assert backups_after_create >= 1 + + step("overlays-after-create", closed=await dismiss_overlay(page)) + + async with page.expect_download(timeout=15000) as dl_info: + await page.locator("#export-data-btn").click() + download = await dl_info.value + dest = OUT_DIR / download.suggested_filename + await download.save_as(str(dest)) + payload = json.loads(dest.read_text(encoding="utf-8")) + step( + "export-download", + filename=download.suggested_filename, + path=str(dest), + format=payload.get("format"), + schemaVersion=payload.get("schemaVersion"), + entityStores=sorted((payload.get("entities") or {}).keys()), + summaryCount=len((payload.get("entities") or {}).get("practiceSummaries") or []), + ) + assert payload.get("format") == "ielts-atlas-data-v2" + assert "practiceSummaries" in (payload.get("entities") or {}) + + await page.locator("#backup-list-btn").click() + await page.wait_for_timeout(600) + step("backup-list-click") + step("overlays-after-list", closed=await dismiss_overlay(page)) + + # File chooser is exercised with a real v1 JSON payload (historical export shape). + v1_file = OUT_DIR / "v1-practice-export.json" + v1_file.write_text( + json.dumps( + { + "exportDate": "2026-07-28T00:00:00.000Z", + "version": "0.6.2-form", + "practiceRecords": [ + { + "id": "v1-file-import", + "examId": "reading-v1-file", + "title": "V1 File Import", + "type": "reading", + "totalQuestions": 2, + "correctAnswers": 1, + "answers": {"q1": "FILE"}, + "highlights": [{"text": "from-file"}], + "dataSource": "real", + "metadata": {"examTitle": "V1 File Import", "category": "P1"}, + } + ], + "userStats": {"totalPractices": 1}, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + await page.locator("#import-data-btn").click() + await page.wait_for_timeout(500) + + # Mode modal then opens a programmatic file input; intercept the chooser. + mode_option = page.locator(".import-mode-option-lite, .import-mode-option").filter( + has_text="合并" + ) + file_import = None + mode_label = None + if await mode_option.count(): + mode_label = "merge" + async with page.expect_file_chooser(timeout=10000) as fc_info: + await mode_option.first.click() + chooser = await fc_info.value + await chooser.set_files(str(v1_file)) + await page.wait_for_timeout(1500) + file_import = await page.evaluate( + """async () => { + const record = await window.AppData.practice.get('v1-file-import', { projection: 'full' }); + return record && { id: record.id, answers: record.answers, highlights: record.highlights }; + }""" + ) + else: + # Fallback: click any non-cancel mode button under an import overlay. + mode_any = page.locator("button, .import-mode-option-lite").filter( + has_text="导入" + ) + if await mode_any.count(): + mode_label = await mode_any.first.inner_text() + async with page.expect_file_chooser(timeout=10000) as fc_info: + await mode_any.first.click() + chooser = await fc_info.value + await chooser.set_files(str(v1_file)) + await page.wait_for_timeout(1500) + file_import = await page.evaluate( + """async () => { + const record = await window.AppData.practice.get('v1-file-import', { projection: 'full' }); + return record && { id: record.id, answers: record.answers, highlights: record.highlights }; + }""" + ) + step("import-button-click", mode=mode_label, imported=file_import) + if file_import: + assert file_import["answers"]["q1"] == "FILE" + + v1 = await page.evaluate( + """async () => { + const payload = { + exportDate: new Date().toISOString(), + version: '0.6.2-form', + practiceRecords: [{ + id: 'v1-click-import', examId: 'reading-v1', title: 'V1 Click Import', type: 'reading', + totalQuestions: 2, correctAnswers: 1, answers: {q1:'YES'}, + highlights: [{text:'from-v1'}], dataSource: 'real', + metadata: {examTitle:'V1 Click Import', category:'P1'} + }], + userStats: { totalPractices: 1 } + }; + const preview = await window.AppData.backups.previewImport(payload, { practiceMode: 'merge' }); + const commit = await window.AppData.backups.commitImport(preview.id); + const imported = await window.AppData.practice.get('v1-click-import', { projection: 'full' }); + const seed = await window.AppData.practice.get('ui-seed-1', { projection: 'full' }); + return { + previewFormat: preview.format, + practice: preview.practice, + committed: commit.committed, + imported: imported && { id: imported.id, answers: imported.answers, highlights: imported.highlights }, + seedKept: !!(seed && seed.answers) + }; + }""" + ) + step("v1-import", **v1) + assert v1["previewFormat"] == "v1" + assert v1["imported"] and v1["imported"]["answers"]["q1"] == "YES" + assert v1["seedKept"] is True + + await page.locator("nav.main-nav button[data-view='practice']").click() + await page.wait_for_selector("#practice-view.active", timeout=10000) + await page.wait_for_timeout(1000) + history = await page.evaluate( + """() => { + const items = Array.from(document.querySelectorAll( + '#practice-history .practice-record, .practice-history-item, [data-record-id], .history-item' + )); + const texts = items.map(el => (el.textContent || '').replace(/\\s+/g,' ').trim()) + .filter(Boolean).slice(0, 10); + const root = document.querySelector('#practice-history') + || document.querySelector('#practice-view') + || document.body; + const body = root.innerText || ''; + return { + itemCount: items.length, + sample: texts, + hasSeed: /UI Seed|ui-seed/i.test(body), + hasV1: /V1 Click|v1-click/i.test(body), + bodySnippet: body.slice(0, 500) + }; + }""" + ) + step("practice-history", **history) + + reimport = await page.evaluate( + """(payload) => { + return (async () => { + const preview = await window.AppData.backups.previewImport(payload, { replace: true }); + const commit = await window.AppData.backups.commitImport(preview.id, { + confirmDestructive: preview.destructive === true + }); + const list = await window.AppData.practice.list({ projection: 'light' }); + return { format: preview.format, committed: commit.committed, ids: list.map(r => r.id).sort() }; + })(); + }""", + payload, + ) + step("v2-reimport-replace", **reimport) + assert reimport["format"] == "v2" + assert "ui-seed-1" in reimport["ids"] + + await page.screenshot(path=str(OUT_DIR / "ui-export-import-final.png"), full_page=True) + result["console_tail"] = console[-30:] + result["ok"] = True + OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print("WROTE", OUT, flush=True) + await browser.close() + return 0 if result.get("ok") else 2 + + +if __name__ == "__main__": + try: + raise SystemExit(asyncio.run(main())) + except AssertionError as error: + OUT_DIR.mkdir(parents=True, exist_ok=True) + fail = {"ok": False, "error": str(error)} + OUT.write_text(json.dumps(fail, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(fail, ensure_ascii=False), flush=True) + raise SystemExit(2) diff --git a/developer/tests/e2e/unified_submit_readonly_regression.py b/developer/tests/e2e/unified_submit_readonly_regression.py index 54b0bda7..491603f6 100644 --- a/developer/tests/e2e/unified_submit_readonly_regression.py +++ b/developer/tests/e2e/unified_submit_readonly_regression.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Unified reading submit/readOnly/highlight regression (file:// compatible).""" +"""Unified reading reliable-submit regression (file:// compatible).""" from __future__ import annotations @@ -12,10 +12,72 @@ REPO_ROOT = Path(__file__).resolve().parents[3] UNIFIED_HTML = REPO_ROOT / "assets" / "generated" / "reading-exams" / "reading-practice-unified.html" +HOST_FIXTURE = REPO_ROOT / "developer" / "tests" / "e2e" / "fixtures" / "index.html" TARGET_EXAM = "p1-low-67" +HOST_HTML = """ + + + + + +""" + try: - from playwright.async_api import async_playwright # type: ignore[import-untyped] + from playwright.async_api import Page, TimeoutError as PlaywrightTimeoutError, async_playwright # type: ignore[import-untyped] except ModuleNotFoundError: venv_dir = (REPO_ROOT / ".venv").resolve() venv_python = REPO_ROOT / ".venv" / "bin" / "python" @@ -26,142 +88,303 @@ raise SystemExit(json.dumps({"status": "fail", "detail": "playwright_python_missing"}, ensure_ascii=False)) -async def run() -> Dict[str, Any]: - url = f"{UNIFIED_HTML.as_uri()}?examId={TARGET_EXAM}&dataKey={TARGET_EXAM}&test_env=1" - async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=True, args=["--allow-file-access-from-files"]) - context = await browser.new_context() - page = await context.new_page() - await page.goto(url, wait_until="load") - await page.wait_for_selector("#left", timeout=30000) - await page.wait_for_selector("#question-groups .unified-group", timeout=30000) - await page.wait_for_selector("#submit-btn", timeout=30000) - await page.wait_for_timeout(600) - - await page.evaluate( - """() => { - const input = document.querySelector('input[type="text"], textarea'); - if (input) { - input.value = 'readonly_probe'; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - } - }""" - ) +def require(condition: bool, detail: str) -> None: + if not condition: + raise RuntimeError(detail) - before = await page.evaluate( - """() => { - const left = document.getElementById('left'); - if (!left) return { count: 0, texts: [] }; - const collectTextNodes = (minLen) => { - const walker = document.createTreeWalker(left, NodeFilter.SHOW_TEXT); - const nodes = []; - let n = null; - while ((n = walker.nextNode())) { - const t = String(n.textContent || '').replace(/\\s+/g, ' ').trim(); - if (t.length >= minLen) nodes.push(n); - } - return nodes; - }; - const nodes = collectTextNodes(16); - const wrap = (node, start, end) => { - if (!node) return false; - const raw = String(node.textContent || ''); - if (raw.length <= end) return false; - const range = document.createRange(); - range.setStart(node, start); - range.setEnd(node, end); - const span = document.createElement('span'); - span.className = 'hl'; - try { - range.surroundContents(span); - return true; - } catch (_) { - return false; - } - }; - let seeded = 0; - for (const node of nodes) { - if (seeded >= 2) break; - const raw = String(node.textContent || ''); - const first = Math.max(1, raw.search(/\\S/)); - const second = Math.min(raw.length - 1, first + 6); - if (wrap(node, first, second)) { - seeded += 1; - } - } - const items = Array.from(document.querySelectorAll('#left .hl')); - return { - count: items.length, - texts: items.map((n) => String(n.textContent || '').trim()).filter(Boolean) - }; - }""" - ) - if int(before.get("count") or 0) < 2: - raise RuntimeError(f"highlight_seed_failed:{before}") - await page.click("#submit-btn") - await page.wait_for_function( - "() => !!(document.getElementById('results') && document.getElementById('results').style.display !== 'none')", +async def open_practice(page: Page, session_id: str, token: str): + url = f"{UNIFIED_HTML.as_uri()}?examId={TARGET_EXAM}&dataKey={TARGET_EXAM}&test_env=1" + await page.goto(HOST_FIXTURE.as_uri(), wait_until="load") + await page.set_content(HOST_HTML, wait_until="load") + await page.evaluate( + "config => window.__hostConfigure(config)", + {"url": url, "examId": TARGET_EXAM, "sessionId": session_id, "token": token}, + ) + await page.wait_for_selector("#practice-frame") + frame = page.frame(name="practice") + require(frame is not None, "practice_frame_missing") + await frame.wait_for_selector("#question-groups .unified-group", timeout=30000) + await frame.wait_for_selector("#submit-btn", timeout=30000) + try: + await frame.wait_for_function( + """() => { + const hooks = window.__IELTS_UNIFIED_READING_PAGE_TEST__; + return !!(hooks && hooks.getTestState().sessionReadySent); + }""", timeout=30000, ) + except PlaywrightTimeoutError as error: + diagnostic = { + "host": await page.evaluate("() => ({ trace: window.__hostTrace })"), + "child": await frame.evaluate( + """() => ({ + href: location.href, + origin: location.origin, + referrer: document.referrer, + state: window.__IELTS_UNIFIED_READING_PAGE_TEST__?.getTestState?.() || null + })""" + ), + } + raise RuntimeError(f"handshake_ready_timeout:{json.dumps(diagnostic, ensure_ascii=False)}") from error + return frame - after_submit = await page.evaluate( - """() => { - const leftItems = Array.from(document.querySelectorAll('#left .hl')); - const leftTexts = leftItems.map((n) => String(n.textContent || '').trim()).filter(Boolean); - const input = document.querySelector('input[type="text"], textarea, input[type="radio"], input[type="checkbox"], select'); - const submit = document.getElementById('submit-btn'); - const reset = document.getElementById('reset-btn'); - const exit = document.getElementById('exit-btn'); - return { - leftCount: leftItems.length, - leftTexts, - readOnlyClass: document.body.classList.contains('review-readonly-mode'), - inputDisabled: !input || input.disabled === true, - submitDisabled: !!(submit && submit.disabled), - resetDisabled: !!(reset && reset.disabled), - exitVisible: !!(exit && getComputedStyle(exit).display !== 'none') - }; - }""" - ) - await page.click("#reset-btn") - await page.wait_for_function( - "() => !document.body.classList.contains('review-readonly-mode')", - timeout=30000, - ) +async def get_state(frame) -> Dict[str, Any]: + return await frame.evaluate( + """() => { + const state = window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState(); + const input = document.querySelector('input[type="text"], textarea'); + const submit = document.getElementById('submit-btn'); + const results = document.getElementById('results'); + const exit = document.getElementById('exit-btn'); + const highlights = Array.from(document.querySelectorAll('#left .hl')); + return { + submissionStatus: state.submissionStatus, + submissionId: state.submissionId, + submitted: state.submitted, + readOnly: state.readOnly, + readOnlyClass: document.body.classList.contains('review-readonly-mode'), + inputDisabled: !input || input.disabled === true, + inputValue: input ? String(input.value || '') : '', + submitDisabled: !!(submit && submit.disabled), + resultsVisible: !!(results && getComputedStyle(results).display !== 'none'), + exitVisible: !!(exit && getComputedStyle(exit).display !== 'none'), + highlightCount: highlights.length, + highlightTexts: highlights.map((node) => String(node.textContent || '').trim()).filter(Boolean) + }; + }""" + ) - after_reset = await page.evaluate( - """() => { - const input = document.querySelector('input[type="text"], textarea, input[type="radio"], input[type="checkbox"], select'); - const textInput = document.querySelector('input[type="text"], textarea'); - const submit = document.getElementById('submit-btn'); - const reset = document.getElementById('reset-btn'); - const exit = document.getElementById('exit-btn'); - const results = document.getElementById('results'); - return { - readOnlyClass: document.body.classList.contains('review-readonly-mode'), - inputDisabled: !input || input.disabled === true, - inputValue: textInput && 'value' in textInput ? String(textInput.value || '') : '', - submitDisabled: !!(submit && submit.disabled), - resetDisabled: !!(reset && reset.disabled), - exitVisible: !!(exit && getComputedStyle(exit).display !== 'none'), - resultsVisible: !!(results && getComputedStyle(results).display !== 'none') - }; - }""" - ) - await browser.close() - return { - "status": "pass", - "detail": "unified submit readonly regression passed", - "data": { - "before": before, - "afterSubmit": after_submit, - "afterReset": after_reset +async def seed_answer_and_highlights(frame) -> Dict[str, Any]: + seeded = await frame.evaluate( + """() => { + const input = document.querySelector('input[type="text"], textarea'); + if (!input) return { count: 0, texts: [], input: false }; + input.value = 'submit_probe'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + const left = document.getElementById('left'); + const walker = document.createTreeWalker(left, NodeFilter.SHOW_TEXT); + const candidates = []; + let node = null; + while ((node = walker.nextNode())) { + const text = String(node.textContent || ''); + if (text.trim().length >= 16) candidates.push(node); } - } + for (const candidate of candidates) { + if (document.querySelectorAll('#left .hl').length >= 2) break; + const raw = String(candidate.textContent || ''); + const start = Math.max(0, raw.search(/\\S/)); + const end = Math.min(raw.length, start + 6); + if (end <= start) continue; + const range = document.createRange(); + range.setStart(candidate, start); + range.setEnd(candidate, end); + const span = document.createElement('span'); + span.className = 'hl'; + try { range.surroundContents(span); } catch (_) {} + } + const items = Array.from(document.querySelectorAll('#left .hl')); + return { + count: items.length, + texts: items.map((item) => String(item.textContent || '').trim()).filter(Boolean), + input: true + }; + }""" + ) + require(bool(seeded.get("input")), "text_input_missing") + require(int(seeded.get("count") or 0) >= 2, f"highlight_seed_failed:{seeded}") + return seeded + + +async def submissions(page: Page): + return await page.evaluate( + "() => window.__hostMessages.filter(message => message && message.type === 'PRACTICE_COMPLETE')" + ) + + +async def wait_for_submission_count(page: Page, count: int) -> None: + await page.wait_for_function( + "count => window.__hostMessages.filter(message => message && message.type === 'PRACTICE_COMPLETE').length >= count", + arg=count, + timeout=10000, + ) + + +async def send_host(page: Page, message_type: str, data: Dict[str, Any], **overrides: Any) -> None: + request: Dict[str, Any] = {"type": message_type, "data": data} + request.update(overrides) + await page.evaluate("request => window.__hostSend(request)", request) + + +def correlation(message: Dict[str, Any]) -> Dict[str, Any]: + data = message.get("data") or {} + return { + "submissionId": data.get("submissionId"), + "sessionId": data.get("sessionId"), + "examId": data.get("examId"), + "suiteSessionId": data.get("suiteSessionId"), + } + + +async def assert_pending(frame, detail: str) -> Dict[str, Any]: + await frame.wait_for_timeout(80) + state = await get_state(frame) + require(state.get("submissionStatus") == "submitting", f"{detail}:not_submitting:{state}") + require(not state.get("submitted"), f"{detail}:submitted_before_ack:{state}") + require(not state.get("readOnly"), f"{detail}:readonly_before_ack:{state}") + require(not state.get("readOnlyClass"), f"{detail}:readonly_class_before_ack:{state}") + require(not state.get("inputDisabled"), f"{detail}:input_disabled_before_ack:{state}") + require(bool(state.get("submitDisabled")), f"{detail}:submit_not_guarded:{state}") + require(not state.get("resultsVisible"), f"{detail}:results_visible_before_ack:{state}") + return state + + +async def run_ack_and_nack_scenario(context) -> Dict[str, Any]: + page = await context.new_page() + frame = await open_practice(page, "session-submit-contract", "token-submit-contract") + seeded = await seed_answer_and_highlights(frame) + + await frame.click("#submit-btn") + await wait_for_submission_count(page, 1) + first = (await submissions(page))[0] + corr = correlation(first) + require(all(corr.get(key) for key in ("submissionId", "sessionId", "examId")), f"missing_correlation:{corr}") + require(corr.get("suiteSessionId") is None, f"unexpected_suite_session:{corr}") + pending = await assert_pending(frame, "initial_delivery") + + await frame.evaluate( + """() => { + const input = document.querySelector('input[type="text"], textarea'); + input.value = 'edited_while_submitting'; + input.dispatchEvent(new Event('input', { bubbles: true })); + }""" + ) + pending_after_edit = await assert_pending(frame, "editable_while_submitting") + require(pending_after_edit.get("inputValue") == "edited_while_submitting", "input_not_editable_while_submitting") + + await send_host(page, "PRACTICE_SUBMIT_ACK", corr, token="forged-token") + await assert_pending(frame, "forged_token_ack") + for field, value in ( + ("submissionId", "submission-mismatch"), + ("sessionId", "session-mismatch"), + ("examId", "exam-mismatch"), + ("suiteSessionId", "suite-mismatch"), + ): + mismatch = dict(corr) + mismatch[field] = value + await send_host(page, "PRACTICE_SUBMIT_ACK", mismatch) + await assert_pending(frame, f"mismatched_{field}_ack") + + await send_host(page, "PRACTICE_SUBMIT_FAILED", corr) + await frame.wait_for_function( + "() => window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState().submissionStatus === 'draft'" + ) + after_nack = await get_state(frame) + require(not after_nack.get("readOnly"), f"nack_locked_page:{after_nack}") + require(not after_nack.get("inputDisabled"), f"nack_disabled_input:{after_nack}") + require(not after_nack.get("submitDisabled"), f"nack_not_retryable:{after_nack}") + require(not after_nack.get("resultsVisible"), f"nack_showed_results:{after_nack}") + + await send_host(page, "PRACTICE_SUBMIT_ACK", corr) + await frame.wait_for_timeout(120) + late_after_nack = await get_state(frame) + require(late_after_nack.get("submissionStatus") == "draft", f"late_ack_after_nack_accepted:{late_after_nack}") + require(not late_after_nack.get("readOnly"), f"late_ack_after_nack_locked:{late_after_nack}") + + await frame.click("#submit-btn") + await wait_for_submission_count(page, 2) + retry = (await submissions(page))[1] + require( + retry.get("data", {}).get("submissionId") == corr.get("submissionId"), + f"retry_changed_idempotency_key:{retry.get('data')}", + ) + await assert_pending(frame, "retry_delivery") + await send_host(page, "PRACTICE_SUBMIT_ACK", correlation(retry)) + await frame.wait_for_function( + "() => window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState().submissionStatus === 'submitted'" + ) + await frame.wait_for_function( + "() => { const node = document.getElementById('results'); return !!node && getComputedStyle(node).display !== 'none'; }" + ) + after_ack = await get_state(frame) + require(after_ack.get("submitted"), f"valid_ack_not_submitted:{after_ack}") + require(after_ack.get("readOnly") and after_ack.get("readOnlyClass"), f"valid_ack_not_readonly:{after_ack}") + require(after_ack.get("inputDisabled"), f"valid_ack_input_enabled:{after_ack}") + require(after_ack.get("submitDisabled"), f"valid_ack_submit_enabled:{after_ack}") + require(after_ack.get("resultsVisible"), f"valid_ack_results_hidden:{after_ack}") + require(after_ack.get("exitVisible"), f"valid_ack_exit_hidden:{after_ack}") + require(int(after_ack.get("highlightCount") or 0) >= int(seeded.get("count") or 0), "highlight_count_decreased") + require( + all(text in (after_ack.get("highlightTexts") or []) for text in (seeded.get("texts") or [])), + "highlight_text_not_preserved", + ) + await page.close() + return {"pending": pending, "afterNack": after_nack, "afterAck": after_ack} + + +async def run_timeout_scenario(context) -> Dict[str, Any]: + page = await context.new_page() + frame = await open_practice(page, "session-submit-timeout", "token-submit-timeout") + await seed_answer_and_highlights(frame) + await frame.click("#submit-btn") + await wait_for_submission_count(page, 1) + first = (await submissions(page))[0] + corr = correlation(first) + await assert_pending(frame, "timeout_initial_delivery") + + await frame.wait_for_function( + "() => window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState().submissionStatus === 'draft'", + timeout=15000, + ) + after_timeout = await get_state(frame) + require(not after_timeout.get("readOnly"), f"timeout_locked_page:{after_timeout}") + require(not after_timeout.get("inputDisabled"), f"timeout_disabled_input:{after_timeout}") + require(not after_timeout.get("submitDisabled"), f"timeout_not_retryable:{after_timeout}") + require(not after_timeout.get("resultsVisible"), f"timeout_showed_results:{after_timeout}") + + await send_host(page, "PRACTICE_SUBMIT_ACK", corr) + await frame.wait_for_timeout(120) + late_after_timeout = await get_state(frame) + require(late_after_timeout.get("submissionStatus") == "draft", f"late_ack_after_timeout_accepted:{late_after_timeout}") + require(not late_after_timeout.get("readOnly"), f"late_ack_after_timeout_locked:{late_after_timeout}") + + await frame.click("#submit-btn") + await wait_for_submission_count(page, 2) + retry = (await submissions(page))[1] + require( + retry.get("data", {}).get("submissionId") == corr.get("submissionId"), + f"timeout_retry_changed_idempotency_key:{retry.get('data')}", + ) + await assert_pending(frame, "timeout_retry_delivery") + await send_host(page, "PRACTICE_SUBMIT_ACK", correlation(retry)) + await frame.wait_for_function( + "() => window.__IELTS_UNIFIED_READING_PAGE_TEST__.getTestState().submissionStatus === 'submitted'" + ) + after_retry_ack = await get_state(frame) + require(after_retry_ack.get("readOnly"), f"timeout_retry_ack_not_readonly:{after_retry_ack}") + await page.close() + return {"afterTimeout": after_timeout, "lateAfterTimeout": late_after_timeout, "afterRetryAck": after_retry_ack} + + +async def run() -> Dict[str, Any]: + async with async_playwright() as playwright: + browser = await playwright.chromium.launch(headless=True, args=["--allow-file-access-from-files"]) + context = await browser.new_context(viewport={"width": 1440, "height": 1000}) + await context.add_init_script(script="window.__IELTS_READING_PAGE_TEST_HOOKS__ = true;") + try: + ack_and_nack = await run_ack_and_nack_scenario(context) + timeout = await run_timeout_scenario(context) + finally: + await context.close() + await browser.close() + return { + "status": "pass", + "detail": "unified reliable submit acknowledgement regression passed", + "data": {"ackAndNack": ack_and_nack, "timeout": timeout}, + } async def main() -> int: @@ -170,45 +393,10 @@ async def main() -> int: except Exception as error: print(json.dumps({ "status": "fail", - "detail": "unified submit readonly regression failed", - "error": str(error) + "detail": "unified reliable submit acknowledgement regression failed", + "error": str(error), }, ensure_ascii=False)) return 1 - - before = payload.get("data", {}).get("before", {}) - after_submit = payload.get("data", {}).get("afterSubmit", {}) - after_reset = payload.get("data", {}).get("afterReset", {}) - before_count = int(before.get("count") or 0) - before_texts = [str(v) for v in (before.get("texts") or []) if str(v).strip()] - after_count = int(after_submit.get("leftCount") or 0) - after_texts = [str(v) for v in (after_submit.get("leftTexts") or []) if str(v).strip()] - - checks = [ - (after_count >= before_count, "highlight_count_decreased"), - (all(text in after_texts for text in before_texts), "highlight_text_not_preserved"), - (bool(after_submit.get("readOnlyClass")), "readonly_class_missing"), - (bool(after_submit.get("inputDisabled")), "input_not_disabled"), - (bool(after_submit.get("submitDisabled")), "submit_not_disabled"), - (not bool(after_submit.get("resetDisabled")), "reset_disabled_after_submit"), - (bool(after_submit.get("exitVisible")), "exit_not_visible"), - (not bool(after_reset.get("readOnlyClass")), "reset_readonly_class_still_present"), - (not bool(after_reset.get("inputDisabled")), "reset_input_still_disabled"), - (not bool(after_reset.get("submitDisabled")), "reset_submit_still_disabled"), - (not bool(after_reset.get("resetDisabled")), "reset_button_still_disabled"), - (not bool(after_reset.get("exitVisible")), "reset_exit_still_visible"), - (not bool(after_reset.get("resultsVisible")), "reset_results_still_visible"), - (str(after_reset.get("inputValue") or "") == "", "reset_input_not_cleared"), - ] - failed = [name for ok, name in checks if not ok] - if failed: - print(json.dumps({ - "status": "fail", - "detail": "unified submit readonly regression assertions failed", - "failed": failed, - "data": payload.get("data", {}) - }, ensure_ascii=False)) - return 1 - print(json.dumps(payload, ensure_ascii=False)) return 0 diff --git a/developer/tests/js/annotationDataContract.test.js b/developer/tests/js/annotationDataContract.test.js new file mode 100644 index 00000000..67c85963 --- /dev/null +++ b/developer/tests/js/annotationDataContract.test.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import assert from 'assert'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function loadScript(relativePath, context) { + vm.runInContext(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'), context, { filename: relativePath }); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +async function main() { + const windowStub = { console }; + const sandbox = { window: windowStub, console, Date, Math, JSON, setTimeout, clearTimeout, setInterval, clearInterval }; + sandbox.globalThis = windowStub; + const context = vm.createContext(sandbox); + + loadScript('js/core/practiceCore.js', context); + const PracticeCore = windowStub.PracticeCore; + const annotationFixture = { + highlights: [{ id: 'hl-1', noteId: 'note-1', text: 'quoted evidence' }], + markedQuestions: ['q2'], + noteText: 'legacy free-form note', + notes: [{ id: 'note-1', outlineId: 'outline-1', body: 'structured note', quote: 'quoted evidence' }], + noteOutlines: [{ id: 'outline-1', title: 'Evidence', order: 1 }], + scrollY: 432 + }; + const baseRecord = { + id: 'annotation-record', + examId: 'reading-annotation', + sessionId: 'annotation-session', + title: 'Annotation contract', + startTime: '2026-07-16T00:00:00.000Z', + endTime: '2026-07-16T00:10:00.000Z', + duration: 600, + answers: { q1: 'A' }, + correctAnswerMap: { q1: 'A' }, + score: 1, + totalQuestions: 1, + correctAnswers: 1, + ...annotationFixture + }; + + const canonical = PracticeCore.contracts.standardizeRecord(baseRecord); + for (const field of ['highlights', 'markedQuestions', 'noteText', 'notes', 'noteOutlines', 'scrollY']) { + assert.deepStrictEqual(plain(canonical[field]), plain(annotationFixture[field]), `canonical root must preserve ${field}`); + assert.deepStrictEqual(plain(canonical.realData[field]), plain(annotationFixture[field]), `realData must authoritatively preserve ${field}`); + } + assert.strictEqual(canonical.highlights[0].noteId, 'note-1', 'highlight noteId must survive canonical normalization'); + canonical.notes[0].body = 'mutated mirror'; + assert.strictEqual(canonical.realData.notes[0].body, 'structured note', 'root and realData annotation snapshots must not alias'); + + const completion = PracticeCore.ingestor.fromCompletion({ + type: 'practice_complete', + data: { ...baseRecord, id: 'completion-annotation-record' } + }); + assert.deepStrictEqual(plain(completion.notes), plain(annotationFixture.notes), 'completion ingestion must preserve structured notes'); + assert.deepStrictEqual(plain(completion.realData.noteOutlines), plain(annotationFixture.noteOutlines), 'completion realData must preserve outlines'); + assert.strictEqual(completion.realData.highlights[0].noteId, 'note-1', 'completion must preserve highlight-note linkage'); + + const suiteEntries = PracticeCore.contracts.standardizeSuiteEntries([{ + examId: 'reading-suite-p2', + answers: { q1: 'A' }, + realData: annotationFixture + }]); + assert.deepStrictEqual(plain(suiteEntries[0].notes), plain(annotationFixture.notes), 'suite entry must recover notes from realData'); + assert.deepStrictEqual(plain(suiteEntries[0].markedQuestions), ['q2'], 'suite entry must recover marked questions'); + + const replay = PracticeCore.contracts.buildReplayResultSnapshot({ + answers: { q1: 'A' }, + correctAnswerMap: { q1: 'A' }, + realData: annotationFixture + }); + assert.deepStrictEqual(plain(replay.noteOutlines), plain(annotationFixture.noteOutlines), 'replay snapshot must include note outlines'); + assert.strictEqual(replay.highlights[0].noteId, 'note-1', 'replay snapshot must preserve noteId'); + + const saveCalls = []; + windowStub.AppData = { + ready: Promise.resolve(true), + practice: { + async completeAttempt(command) { + saveCalls.push(plain(command)); + return { committed: true, record: plain(command.record) }; + }, + async getStats() { return {}; } + }, + recovery: { + async listActiveSessions() { return []; }, + async listDrafts() { return []; } + } + }; + loadScript('js/core/practiceRecorder.js', context); + const recorder = Object.create(windowStub.PracticeRecorder.prototype); + recorder.practiceTypeCache = new Map(); + recorder.wait = async () => {}; + recorder.verifyRecordSaved = async () => true; + await recorder.savePracticeRecord(baseRecord, { updateStats: false }); + assert.strictEqual(saveCalls.length, 1, 'recorder must submit one AppData practice command'); + assert.deepStrictEqual(saveCalls[0].record.realData.notes, annotationFixture.notes, 'recorder AppData path must preserve realData notes'); + + console.log(JSON.stringify({ status: 'pass', detail: 'annotation canonical/completion/suite/replay/AppData recorder contract passed' }, null, 2)); +} + +main().catch((error) => { + console.error(JSON.stringify({ status: 'fail', detail: error.message, stack: error.stack }, null, 2)); + process.exit(1); +}); diff --git a/developer/tests/js/answerMatchCoreRegression.test.js b/developer/tests/js/answerMatchCoreRegression.test.js index 2394f5ab..62799fe3 100644 --- a/developer/tests/js/answerMatchCoreRegression.test.js +++ b/developer/tests/js/answerMatchCoreRegression.test.js @@ -21,6 +21,28 @@ function run() { 'A', 'explicit option label with punctuation should still normalize to the letter' ); + assert.strictEqual( + core.normalizeToken('D effects'), + 'D effects', + 'unpunctuated labels should retain their text outside comparison context' + ); + assert.strictEqual( + core.normalizeToken('A panoramic camera'), + 'A panoramic camera', + 'capitalized article phrases must not collapse to an option letter' + ); + [ + ['D effects', 'D'], + ['G feelings', 'G'], + ['A facial expressions', 'A'], + ['E word meanings', 'E'] + ].forEach(([storedLabel, answerKey]) => { + assert.strictEqual( + core.compareAnswers(storedLabel, answerKey), + true, + `${storedLabel} should match its canonical option letter` + ); + }); assert.strictEqual( core.compareAnswers('panoramic camera', ['a panoramic camera', 'panoramic camera']), true, @@ -34,7 +56,7 @@ function run() { process.stdout.write(JSON.stringify({ status: 'pass', - detail: 'answerMatchCore accepted-answer regression checks passed' + detail: 'answerMatchCore accepted-answer and drag-label regression checks passed' })); } diff --git a/developer/tests/js/appDataV2.test.js b/developer/tests/js/appDataV2.test.js new file mode 100644 index 00000000..30a914a7 --- /dev/null +++ b/developer/tests/js/appDataV2.test.js @@ -0,0 +1,1061 @@ +#!/usr/bin/env node + +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const appDataSource = fs.readFileSync(path.join(root, 'js/data/v2/appData.js'), 'utf8'); +const catalogSource = fs.readFileSync(path.join(root, 'js/data/v2/dataCatalog.js'), 'utf8'); +const recordSource = fs.readFileSync(path.join(root, 'js/data/practiceRecordSource.js'), 'utf8'); +const clone = (value) => value === undefined ? undefined : structuredClone(value); +function stable(value) { if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; return JSON.stringify(value); } +function checksum(value) { let hash = 0x811c9dc5; for (const char of stable(value)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 0x01000193); } return `fnv1a-${(hash >>> 0).toString(16)}`; } +function parseLegacyValue(value) { let parsed = clone(value); for (let depth = 0; depth < 3; depth += 1) { if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed); } catch { break; } } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data') && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) parsed = parsed.data; else break; } return clone(parsed); } +class AppDataError extends Error { constructor(code, message) { super(message); this.code = code; } } + +function harness() { + const catalogSandbox = { structuredClone }; catalogSandbox.globalThis = catalogSandbox; + vm.runInContext(catalogSource, vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' }); + const catalog = catalogSandbox.__AppDataV2Catalog; + const shared = { docs: new Map(), entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), reads: [], lists: [], mutations: [], counter: 0, failEntityStore: null, lastInstallOptions: null }; + const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({ schemaVersion: 2, revision, operationId, updatedAt: new Date().toISOString(), state, data: state === 'cleared' ? null : clone(data), checksum: checksum(state === 'cleared' ? null : data) }); + class Kernel { + async initialize() { this.state = 'ready'; this.backend = 'memory'; return this; } + async read(key, options = {}) { const entry = catalog.get(key); const value = shared.docs.get(key) || null; const data = !value || value.state === 'cleared' ? entry.defaultValue() : value.data; return options.withMeta ? { data: clone(data), envelope: clone(value) } : clone(data); } + async mutate(changes, options = {}) { + const op = String(options.operationId || `doc-${++shared.counter}`); + if (typeof options.commitGuard === 'function') { + let allowed = false; + try { allowed = options.commitGuard() === true; } catch (_) { + throw new AppDataError('PRECONDITION_FAILED', 'commit guard threw'); + } + if (!allowed) throw new AppDataError('PRECONDITION_FAILED', 'commit guard rejected mutation'); + } + const revisions = {}; + for (const change of changes) { + const old = shared.docs.get(change.logicalKey); + if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'document revision'); + const revision = Number(old && old.revision || 0) + 1; + shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, op)); + revisions[change.logicalKey] = revision; + } + return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; + } + async journalNoop(options = {}) { return { committed: true, operationId: options.operationId || `noop-${++shared.counter}`, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; } + async readEntity(store, recordId, options = {}) { shared.reads.push(store); const row = shared.entities.get(store).get(String(recordId)) || null; return options.withMeta ? clone(row) : row && clone(row.data); } + async listEntities(store, options = {}) { if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable'); shared.lists.push(store); const rows = Array.from(shared.entities.get(store).values()); return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); } + async readPracticeSnapshot(recordIds = null, options = {}) { const ids = recordIds === null ? null : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String)); const stores = options.stores || ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; const result = {}; for (const store of stores) { if (ids) shared.reads.push(store); const rows = Array.from(shared.entities.get(store).values()).filter((row) => !ids || ids.has(String(row.recordId))); result[store] = options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); } return result; } + async mutateEntities(operations, options = {}) { const op = String(options.operationId || `entity-${++shared.counter}`); const revisions = {}; const next = new Map(Array.from(shared.entities, ([store, rows]) => [store, new Map(rows)])); for (const item of operations) { if (shared.failEntityStore === item.store) throw new AppDataError('IO', `forced entity failure: ${item.store}`); const rows = next.get(item.store); if (item.type === 'clear') { rows.clear(); revisions[`${item.store}/*`] = 0; continue; } const old = rows.get(String(item.recordId)); if (item.expectedRevision !== undefined && item.expectedRevision !== null && Number(item.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'entity revision'); if (item.type === 'delete') { rows.delete(String(item.recordId)); revisions[`${item.store}/${item.recordId}`] = Number(old && old.revision || 0) + 1; } else { const row = { recordId: String(item.recordId), revision: Number(old && old.revision || 0) + 1, operationId: op, updatedAt: new Date().toISOString(), data: clone(item.data), checksum: checksum(item.data) }; rows.set(row.recordId, row); revisions[`${item.store}/${item.recordId}`] = row.revision; } } shared.entities = next; shared.mutations.push(clone(operations)); return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; } + async exportSnapshot(options = {}) { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys) : null; + const selectedEntities = Array.isArray(options.entityStores) ? new Set(options.entityStores) : null; + const envelopes = {}; + for (const entry of catalog.list()) { + const key = entry.logicalKey; + if (entry.export !== true || (selected && !selected.has(key))) continue; + envelopes[key] = shared.docs.has(key) + ? clone(shared.docs.get(key)) + : envelope(key, null, 'cleared', 1, 'snapshot-default'); + } + const entities = {}; + for (const [store, rows] of shared.entities) { + if (selectedEntities && !selectedEntities.has(store)) continue; + entities[store] = Array.from(rows.values()).map(clone); + } + const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: 2, scope: selected ? 'partial' : 'full', envelopes, entities }; + snapshot.checksum = checksum({ envelopes, entities }); + return snapshot; + } + async installSnapshot(snapshot, options = {}) { + if (snapshot.checksum !== checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities })) throw new AppDataError('VALIDATION', 'snapshot checksum'); + const token = options.expectedRevisionToken || {}; + for (const [key, expected] of Object.entries(token.documents || {})) { + const actual = shared.docs.get(key) || null; + if (Number(actual && actual.revision || 0) !== Number(expected || 0)) { + throw new AppDataError('CONFLICT', `document revision: ${key}`); + } + } + for (const [store, expectedRows] of Object.entries(token.entities || {})) { + const actualRows = shared.entities.get(store) || new Map(); + const ids = new Set([...actualRows.keys(), ...Object.keys(expectedRows || {})]); + for (const id of ids) { + const actual = actualRows.get(id) || null; + const expected = expectedRows[id] || 0; + if (Number(actual && actual.revision || 0) !== Number(expected || 0)) { + throw new AppDataError('CONFLICT', `entity revision: ${store}/${id}`); + } + } + } + const nextDocs = new Map(shared.docs); + const nextEntities = new Map(Array.from(shared.entities, ([store, rows]) => [store, new Map(rows)])); + for (const [key, value] of Object.entries(snapshot.envelopes)) nextDocs.set(key, clone(value)); + for (const [store, rows] of Object.entries(snapshot.entities)) nextEntities.set(store, new Map(rows.map((row) => [String(row.recordId), clone(row)]))); + shared.docs = nextDocs; shared.entities = nextEntities; shared.lastInstallOptions = clone(options); + return { committed: true, operationId: options.operationId || `install-${++shared.counter}`, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; + } + onCommitted() { return () => {}; } + status() { return { state: this.state, backend: this.backend, failure: null }; } + } + const internals = { DataKernel: Kernel, AppDataError, catalog, clone, checksum, parseLegacyValue, randomId: (prefix) => `${prefix}-${++shared.counter}`, nowIso: () => new Date().toISOString(), makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId), validateEnvelope: (entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data)) }; + const sandbox = { console, Date, JSON, Math, Map, Set, Promise, structuredClone, __AppDataV2Internals: internals, sessionStorage: { getItem() { return null; }, setItem() {}, removeItem() {} } }; sandbox.window = sandbox; sandbox.globalThis = sandbox; + const context = vm.createContext(sandbox); vm.runInContext(recordSource, context, { filename: 'practiceRecordSource.js' }); vm.runInContext(appDataSource, context, { filename: 'appData.js' }); return { app: sandbox.AppData, shared, envelope }; +} + +async function run() { + const { app, shared, envelope } = harness(); await app.ready; + const huge = 'x'.repeat(20000); + const completed = await app.practice.completeAttempt({ operationId: 'complete', record: { id: 'r1', examId: 'reading-1', type: 'reading', title: 'Test', totalQuestions: 2, correctAnswers: 1, answers: { 1: 'A' }, answerMap: { 2: 'B' }, answerList: [{ questionId: '3', answer: 'C' }], correctAnswerMap: { 1: 'B' }, answerDetails: huge, scoreInfo: { band: 7 }, markedQuestions: ['q1'], highlights: [{ text: huge }], notes: { q1: huge }, interactions: [{ type: 'click' }], metadata: { examId: 'reading-1', examTitle: 'Reading 1', category: 'academic', frequency: 4, libraryConfigurationId: 'library-1', privatePayload: huge }, realData: { rawData: { token: huge }, answers: { 1: 'wrong', 4: 'D' }, answerMap: { 5: 'E' } }, rawData: { shouldNotPersist: huge, answers: { 6: 'F' }, answerMap: { 7: 'G' }, realData: { answers: { 8: 'H' } } } } }); + assert.strictEqual(completed.record.answers[1], 'A'); + const overloadedScore = await app.practice.completeAttempt({ + operationId: 'complete-overloaded-score', + record: { + id: 'r-overloaded-score', + examId: 'reading-overloaded', + type: 'reading', + correctAnswers: { q1: 'A', q2: 'B' }, + correctAnswerMap: {}, + scoreInfo: { correct: 1, total: 2, accuracy: 0.5 } + } + }); + assert.strictEqual(overloadedScore.record.correctAnswers, 1, 'object answer map must not replace the numeric score'); + assert.strictEqual(overloadedScore.record.totalQuestions, 2, 'scoreInfo.total must supply the canonical question count'); + assert.deepStrictEqual(overloadedScore.record.correctAnswerMap, { q1: 'A', q2: 'B' }, 'the overloaded answer map must be preserved in detail'); + const zeroScore = await app.practice.completeAttempt({ + operationId: 'complete-zero-score', + record: { id: 'r-zero-score', type: 'listening', correctAnswers: -1, scoreInfo: { correct: 0, total: 1 } } + }); + assert.strictEqual(zeroScore.record.correctAnswers, 0, 'a valid zero score must survive fallback selection'); + await assert.rejects( + () => app.practice.completeAttempt({ operationId: 'complete-invalid-score', record: { id: 'r-invalid-score', type: 'reading', correctAnswers: -1 } }), + { code: 'VALIDATION' } + ); + const summary = shared.entities.get('practiceSummaries').get('r1').data; + const detail = shared.entities.get('practiceDetails').get('r1').data; + const annotations = shared.entities.get('practiceAnnotations').get('r1').data; + assert(!Object.prototype.hasOwnProperty.call(summary, 'answers')); assert(!Object.prototype.hasOwnProperty.call(summary, 'answerDetails')); assert(!Object.prototype.hasOwnProperty.call(summary, 'notes')); assert(!Object.prototype.hasOwnProperty.call(summary.metadata, 'privatePayload')); assert.deepStrictEqual(summary.metadata, { examId: 'reading-1', examTitle: 'Reading 1', category: 'academic', frequency: 4, libraryConfigurationId: 'library-1' }); assert(JSON.stringify(summary).length < 3000, 'large fields must not enter summary'); + assert.deepStrictEqual(detail.answers, { 1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E', 6: 'F', 7: 'G', 8: 'H' }, 'all compatibility aliases must converge on Detail.answers with canonical values winning conflicts'); + assert(!Object.prototype.hasOwnProperty.call(detail, 'answerMap')); + assert(!Object.prototype.hasOwnProperty.call(detail, 'answerList')); + assert(!Object.prototype.hasOwnProperty.call(annotations, 'answers')); + assert(!JSON.stringify(detail).includes('realData') && !JSON.stringify(detail).includes('rawData')); assert(!JSON.stringify(annotations).includes('realData') && !JSON.stringify(annotations).includes('rawData')); + shared.reads = []; shared.lists = []; await app.practice.list({ projection: 'light' }); assert.deepStrictEqual(shared.lists, ['practiceSummaries']); assert.deepStrictEqual(shared.reads, []); + shared.reads = []; await app.practice.get('r1', { projection: 'detail' }); assert.deepStrictEqual(shared.reads, ['practiceSummaries', 'practiceDetails']); + shared.reads = []; const full = await app.practice.get('r1'); assert.deepStrictEqual(shared.reads, ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']); assert.strictEqual(full.notes.q1, huge); + assert.deepStrictEqual(full.answers, detail.answers); + const writes = shared.mutations.at(-1); assert.strictEqual(writes.length, 3); assert.deepStrictEqual(new Set(writes.map((item) => item.store)), new Set(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'])); + const summaryRevision = shared.entities.get('practiceSummaries').get('r1').revision; + const detailRevision = shared.entities.get('practiceDetails').get('r1').revision; + const annotationRevision = shared.entities.get('practiceAnnotations').get('r1').revision; + await app.practice.updateAnnotations({ recordId: 'r1', examId: 'reading-1', expectedRevision: annotationRevision, patch: { reviewed: true } }); + assert.deepStrictEqual(shared.mutations.at(-1).map((item) => item.store), ['practiceAnnotations'], 'annotation edits must write only the Annotation layer'); + assert.strictEqual(shared.entities.get('practiceSummaries').get('r1').revision, summaryRevision); + assert.strictEqual(shared.entities.get('practiceDetails').get('r1').revision, detailRevision); + assert.strictEqual(shared.entities.get('practiceAnnotations').get('r1').revision, annotationRevision + 1); + const suiteLight = app.practice.projectLight({ + id: 'suite-light', + type: 'reading-suite', + suiteEntries: [{ + examId: 'reading-child', + title: 'Child', + correctAnswers: { q1: 'A', q2: 'B' }, + scoreInfo: { correct: 1, total: 2 }, + answers: { 1: 'A' }, + notes: { 1: 'private' }, + replay: { html: '' } + }] + }); + assert.deepStrictEqual(suiteLight.suiteEntrySummaries.map((entry) => entry.examId), ['reading-child']); + assert.strictEqual(suiteLight.suiteEntrySummaries[0].correctAnswers, 1); + assert.strictEqual(suiteLight.suiteEntrySummaries[0].totalQuestions, 2); + assert.strictEqual(suiteLight.suiteEntrySummaries[0].accuracy, 0.5); + assert.strictEqual(suiteLight.suiteEntrySummaries[0].type, 'reading'); + assert(!JSON.stringify(suiteLight.suiteEntrySummaries).includes('answers')); + assert(!JSON.stringify(suiteLight.suiteEntrySummaries).includes('private')); + const insightRecord = app.practice.projectLight({ + id: 'reading-insight', + type: 'reading', + questionTypePerformance: { + true_false_not_given: { total: 3, correct: 1 }, + short_answer: { totalQuestions: 2, correctAnswers: 1 } + } + }); + assert.deepStrictEqual( + insightRecord.questionTypeErrorCounts, + { true_false_not_given: 2, short_answer: 1 }, + 'light projection must retain compact error counts without answer content' + ); + const insightSuite = app.practice.projectLight({ + id: 'suite-insight', + type: 'suite', + suiteEntries: [{ + examId: 'reading-suite-entry', + type: 'reading', + scoreInfo: { + details: { + q1: { isCorrect: false, questionType: 'matching_headings' }, + q2: { isCorrect: true, questionType: 'matching_headings' } + } + } + }] + }); + assert.deepStrictEqual( + insightSuite.suiteEntrySummaries[0].questionTypeErrorCounts, + { matching_headings: 1 }, + 'suite entry light projection must retain compact error counts after child deletion' + ); + const suiteHarness = harness(); + await suiteHarness.app.ready; + await suiteHarness.app.practice.completeAttempt({ + operationId: 'suite-child', + record: { id: 'record_suite-child-session', sessionId: 'suite-child-session', type: 'reading', answers: { q1: 'A' } } + }); + await suiteHarness.app.practice.finalizeSuite({ + operationId: 'suite-finalize', + childSessionIds: ['suite-child-session'], + record: { id: 'suite-parent', sessionId: 'suite-parent', type: 'reading', suiteEntries: [{ examId: 'reading-child' }] } + }); + for (const store of ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']) { + assert.strictEqual(suiteHarness.shared.entities.get(store).has('record_suite-child-session'), false, `${store} child row must be removed by sessionId`); + assert.strictEqual(suiteHarness.shared.entities.get(store).has('suite-parent'), true, `${store} aggregate row must remain`); + } + const historicalHarness = harness(); + historicalHarness.shared.entities.get('practiceSummaries').set('historical-insight', { + recordId: 'historical-insight', + revision: 1, + operationId: 'historical-summary', + updatedAt: '2026-01-01T00:00:00.000Z', + data: { + id: 'historical-insight', + sessionId: 'historical-insight', + type: 'reading', + date: '2026-01-01T00:00:00.000Z' + }, + checksum: checksum({ + id: 'historical-insight', + sessionId: 'historical-insight', + type: 'reading', + date: '2026-01-01T00:00:00.000Z' + }) + }); + historicalHarness.shared.entities.get('practiceDetails').set('historical-insight', { + recordId: 'historical-insight', + revision: 1, + operationId: 'historical-detail', + updatedAt: '2026-01-01T00:00:00.000Z', + data: { + recordId: 'historical-insight', + questionTypePerformance: { + matching_information: { total: 2, correct: 1 } + } + }, + checksum: checksum({ + recordId: 'historical-insight', + questionTypePerformance: { + matching_information: { total: 2, correct: 1 } + } + }) + }); + historicalHarness.shared.entities.get('practiceAnnotations').set('historical-insight', { + recordId: 'historical-insight', + revision: 1, + operationId: 'historical-annotations', + updatedAt: '2026-01-01T00:00:00.000Z', + data: { recordId: 'historical-insight' }, + checksum: checksum({ recordId: 'historical-insight' }) + }); + historicalHarness.shared.reads = []; + const historicalInsights = await historicalHarness.app.practice.listInsights({ limit: 10 }); + const historicalInsight = historicalInsights.find((record) => record.id === 'historical-insight'); + assert.deepStrictEqual( + historicalInsight.questionTypeErrorCounts, + { matching_information: 1 }, + 'historical summaries must receive a bounded detail-backed insight projection' + ); + assert.deepStrictEqual( + historicalHarness.shared.reads, + ['practiceDetails'], + 'insight backfill may read only the bounded missing detail, never annotations' + ); + shared.reads = []; shared.lists = []; await app.practice.getStats(); assert.deepStrictEqual(shared.lists, ['practiceSummaries']); assert.deepStrictEqual(shared.reads, []); + shared.reads = []; shared.lists = []; await app.achievements.getAll(); assert.deepStrictEqual(shared.lists, ['practiceSummaries']); assert.deepStrictEqual(shared.reads, []); + const durableAchievements = harness(); + await durableAchievements.app.practice.completeAttempt({ + operationId: 'durable-achievement-record', + record: { + id: 'achievement-record', + type: 'reading', + completedAt: '2026-01-02T00:00:00.000Z', + totalQuestions: 1, + correctAnswers: 1 + } + }); + const firstUnlock = await durableAchievements.app.achievements.getAll(); + assert.strictEqual(firstUnlock.first_step.unlockedAt, '2026-01-02T00:00:00.000Z'); + await durableAchievements.app.practice.clear(); + const retainedUnlock = await durableAchievements.app.achievements.getAll(); + assert.strictEqual( + retainedUnlock.first_step.unlockedAt, + firstUnlock.first_step.unlockedAt, + 'deleting source records must not relock a persisted achievement' + ); + assert(durableAchievements.shared.docs.has('achievements.progress'), 'achievement progress must be durable'); + const backup = await app.backups.create({ id: 'b1' }); assert.deepStrictEqual(Object.keys(backup.data.entities).sort(), ['practiceAnnotations', 'practiceDetails', 'practiceSummaries']); + const exported = await app.backups.export(); assert.deepStrictEqual(Object.keys(exported.entities).sort(), ['practiceAnnotations', 'practiceDetails', 'practiceSummaries']); + await app.practice.clear(); assert.strictEqual(shared.mutations.at(-1).length, 3); + const importPlan = await app.backups.previewImport(exported, { replace: true }); + await app.backups.commitImport(importPlan.id, { confirmDestructive: true }); + assert.strictEqual(shared.lastInstallOptions.resetJournal, true, 'full replacement imports must reset stale operation-journal entries'); + assert.strictEqual((await app.practice.get('r1')).answers[1], 'A'); + const stalePlan = await app.backups.previewImport(exported, { replace: true }); + const annotationRevisionBeforeStaleCommit = shared.entities.get('practiceAnnotations').get('r1').revision; + await app.practice.updateAnnotations({ + recordId: 'r1', + examId: 'reading-1', + expectedRevision: annotationRevisionBeforeStaleCommit, + patch: { createdDuringImportConfirmation: true } + }); + await assert.rejects( + () => app.backups.commitImport(stalePlan.id, { confirmDestructive: true }), + { code: 'CONFLICT' }, + 'commitImport must reject a plan built before a concurrent practice edit' + ); + assert.strictEqual( + shared.entities.get('practiceAnnotations').get('r1').data.createdDuringImportConfirmation, + true, + 'a stale import must not erase the concurrent practice edit' + ); + const partial = { format: 'ielts-atlas-data-v2', schemaVersion: 2, scope: 'partial', envelopes: {}, entities: { practiceSummaries: [] } }; partial.checksum = checksum({ envelopes: partial.envelopes, entities: partial.entities }); + await assert.rejects(() => app.backups.previewImport(partial, { replace: true }), { code: 'VALIDATION' }); + const orphanMerge = clone(partial); + orphanMerge.entities.practiceSummaries = [{ + recordId: 'orphan', + revision: 1, + operationId: 'orphan-import', + updatedAt: new Date().toISOString(), + data: { id: 'orphan', type: 'reading' }, + checksum: checksum({ id: 'orphan', type: 'reading' }) + }]; + orphanMerge.checksum = checksum({ envelopes: orphanMerge.envelopes, entities: orphanMerge.entities }); + await assert.rejects(() => app.backups.previewImport(orphanMerge, { practiceMode: 'merge' }), { code: 'VALIDATION' }); + await app.settings.patch({ lateSetting: true }); + await app.vocab.saveWords([{ id: 'late-word', word: 'late-word' }]); + await app.goals.save({ id: 'late-goal', title: 'Late goal' }); + await app.preferences.setTheme('late-theme'); + await app.backups.restore('b1'); + assert.strictEqual((await app.practice.get('r1')).answers[1], 'A'); + assert.deepStrictEqual(await app.settings.getAll(), {}); + assert.deepStrictEqual(await app.vocab.listWords(), []); + assert.deepStrictEqual(await app.goals.list(), []); + assert.deepStrictEqual(await app.preferences.getAll(), {}); + assert.strictEqual(shared.lastInstallOptions.resetJournal, true); + assert.deepStrictEqual( + new Set(Array.from(shared.entities, ([store, rows]) => `${store}:${Array.from(rows.keys()).sort().join(',')}`)), + new Set([ + 'practiceSummaries:r-overloaded-score,r-zero-score,r1', + 'practiceDetails:r-overloaded-score,r-zero-score,r1', + 'practiceAnnotations:r-overloaded-score,r-zero-score,r1' + ]) + ); + await Promise.all([ + app.vocab.upsertCollectionWord('highlights', { word: 'alpha' }), + app.vocab.upsertCollectionWord('highlights', { word: 'beta' }) + ]); + assert.deepStrictEqual( + (await app.vocab.readList('highlights')).words.map((word) => word.word).sort(), + ['alpha', 'beta'] + ); + shared.failEntityStore = 'practiceDetails'; await assert.rejects(() => app.practice.completeAttempt({ record: { id: 'r2', type: 'reading' } }), { code: 'IO' }); assert.strictEqual(shared.entities.get('practiceSummaries').has('r2'), false); assert.strictEqual(shared.entities.get('practiceDetails').has('r2'), false); assert.strictEqual(shared.entities.get('practiceAnnotations').has('r2'), false); + await assert.rejects(() => app.practice.delete('r1'), { code: 'IO' }); assert.strictEqual(shared.entities.get('practiceSummaries').has('r1'), true); assert.strictEqual(shared.entities.get('practiceDetails').has('r1'), true); assert.strictEqual(shared.entities.get('practiceAnnotations').has('r1'), true); shared.failEntityStore = null; + await Promise.all([ + app.preferences.setTheme('dark'), + app.preferences.setConsent({ accepted: true }), + app.preferences.setBrowse({ category: 'reading' }), + app.preferences.setOnboarding({ completed: true }), + app.preferences.setThreeBackground('aurora'), + app.preferences.setThemePortal({ open: false }), + app.preferences.setPracticeWidget('compact'), + app.preferences.setLogConfig({ level: 'warn' }), + app.preferences.setCandidateCode({ mode: 'auto' }), + app.preferences.setReadingDisplay({ fontSize: 18 }), + app.preferences.setSuite({ autoAdvance: true }), + app.preferences.setResourceBasePrefix('./') + ]); + const concurrentPreferences = await app.preferences.getAll(); + assert.strictEqual(concurrentPreferences.theme, 'dark'); + assert.strictEqual(concurrentPreferences.consent.accepted, true); + assert.strictEqual(concurrentPreferences.browse.category, 'reading'); + assert.strictEqual(concurrentPreferences.logConfig.level, 'warn'); + await Promise.all(Array.from({ length: 8 }, (_, index) => app.recovery.saveActiveSession({ + id: `active-${index}`, + sessionId: `session-${index}`, + examId: `exam-${index}` + }))); + assert.deepStrictEqual( + (await app.recovery.listActiveSessions()).map((item) => item.id).sort(), + Array.from({ length: 8 }, (_, index) => `active-${index}`) + ); + let rejectedDraftGuardCalls = 0; + const rejectedDraft = await app.recovery.saveDraft({ + id: 'reading-draft:guarded', + examId: 'reading-guarded', + kind: 'reading_draft' + }, { + commitGuard() { + rejectedDraftGuardCalls += 1; + return false; + } + }); + assert.strictEqual(rejectedDraft.committed, false); + assert.strictEqual(rejectedDraft.code, 'STALE_RECOVERY_WRITE'); + assert.strictEqual(rejectedDraft.reason, 'COMMIT_GUARD_REJECTED'); + assert.strictEqual(rejectedDraftGuardCalls, 1); + assert.strictEqual(await app.recovery.getDraft('reading-draft:guarded'), null, 'rejected commit guards must not write recovery drafts'); + const acceptedDraft = await app.recovery.saveDraft({ + id: 'reading-draft:guarded', + examId: 'reading-guarded', + kind: 'reading_draft' + }, { commitGuard: () => true }); + assert.strictEqual(acceptedDraft.committed, true); + assert(await app.recovery.getDraft('reading-draft:guarded')); + await app.recovery.discardDraft('reading-draft:guarded'); + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence('active-never-persisted')) }, + { id: 'active-never-persisted', exists: false, tombstoned: false, revision: 0 } + ); + assert.strictEqual((await app.recovery.saveActiveSession({ + id: 'active-discard-guard', + revision: 1, + marker: 'guarded-owner' + }, { expectedEntityRevision: 0 })).committed, true); + let rejectedDiscardGuardCalls = 0; + const rejectedDiscard = await app.recovery.discardActiveSession('active-discard-guard', { + expectedEntityRevision: 1, + commitGuard() { + rejectedDiscardGuardCalls += 1; + return false; + } + }); + assert.strictEqual(rejectedDiscard.committed, false); + assert.strictEqual(rejectedDiscard.code, 'STALE_RECOVERY_WRITE'); + assert.strictEqual(rejectedDiscard.reason, 'COMMIT_GUARD_REJECTED'); + assert.strictEqual(rejectedDiscardGuardCalls, 1); + assert(await app.recovery.getActiveSession('active-discard-guard'), 'a rejected discard guard must not write a tombstone'); + const acceptedDiscard = await app.recovery.discardActiveSession('active-discard-guard', { + expectedEntityRevision: 1, + commitGuard: () => true + }); + assert.strictEqual(acceptedDiscard.committed, true); + assert.strictEqual(await app.recovery.getActiveSession('active-discard-guard'), null); + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence('active-discard-guard')) }, + { id: 'active-discard-guard', exists: true, tombstoned: true, revision: 2 } + ); + const casFirst = await app.recovery.saveActiveSession({ + id: 'active-cas', + revision: 1, + lastUpdate: 100, + marker: 'first' + }, { expectedEntityRevision: 0 }); + assert.strictEqual(casFirst.committed, true); + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence('active-cas')) }, + { id: 'active-cas', exists: true, tombstoned: false, revision: 1 }, + 'the public recovery fence must expose the live raw first owner revision' + ); + const casSecond = await app.recovery.saveActiveSession({ + id: 'active-cas', + revision: 2, + lastUpdate: 200, + marker: 'second' + }, { expectedEntityRevision: 1 }); + assert.strictEqual(casSecond.committed, true); + await assert.rejects(() => app.recovery.saveActiveSession({ + id: 'active-cas', + revision: 2, + lastUpdate: 250, + marker: 'non-advancing' + }, { expectedEntityRevision: 2 }), { code: 'VALIDATION' }); + for (const invalidRevision of [1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const corruptId = `active-cas-invalid-${String(invalidRevision)}`; + const corruptSave = await app.recovery.saveActiveSession({ + id: corruptId, + revision: invalidRevision, + marker: 'corrupt-revision' + }); + assert.strictEqual(corruptSave.committed, true); + const repairedSave = await app.recovery.saveActiveSession({ + id: corruptId, + revision: 1, + marker: 'repaired-revision' + }, { expectedEntityRevision: 0 }); + assert.strictEqual(repairedSave.committed, true, 'invalid actual recovery revisions must normalize to zero'); + assert.strictEqual((await app.recovery.getActiveSession(corruptId)).marker, 'repaired-revision'); + await assert.rejects(() => app.recovery.saveActiveSession({ + id: `${corruptId}-invalid-expected`, + revision: 1 + }, { expectedEntityRevision: invalidRevision }), { code: 'VALIDATION' }); + await app.recovery.discardActiveSession(corruptId, { expectedEntityRevision: 1 }); + } + const maxSafeId = 'active-cas-max-safe'; + assert.strictEqual((await app.recovery.saveActiveSession({ + id: maxSafeId, + revision: Number.MAX_SAFE_INTEGER + }, { expectedEntityRevision: 0 })).committed, true); + assert.strictEqual((await app.recovery.discardActiveSession(maxSafeId, { + expectedEntityRevision: Number.MAX_SAFE_INTEGER + })).committed, true); + const delayedAfterMaxSafeDiscard = await app.recovery.saveActiveSession({ + id: maxSafeId, + revision: 1 + }, { expectedEntityRevision: 0 }); + assert.strictEqual(delayedAfterMaxSafeDiscard.committed, false); + assert.strictEqual(delayedAfterMaxSafeDiscard.actualEntityRevision, Number.MAX_SAFE_INTEGER); + const staleSave = await app.recovery.saveActiveSession({ + id: 'active-cas', + revision: 99, + lastUpdate: 999, + marker: 'stale-tab' + }, { expectedEntityRevision: 1 }); + assert.strictEqual(staleSave.committed, false, 'stale tabs must not overwrite a newer active-session entity'); + assert.strictEqual(staleSave.code, 'STALE_RECOVERY_WRITE'); + assert.strictEqual(staleSave.item, undefined, 'stale receipt must not expose another tab current entity as the saved item'); + assert.strictEqual((await app.recovery.getActiveSession('active-cas')).marker, 'second'); + const staleDiscard = await app.recovery.discardActiveSession('active-cas', { expectedEntityRevision: 1 }); + assert.strictEqual(staleDiscard.committed, false, 'stale tabs must not discard a newer active-session entity'); + assert(await app.recovery.getActiveSession('active-cas')); + const currentDiscard = await app.recovery.discardActiveSession('active-cas', { expectedEntityRevision: 2 }); + assert.strictEqual(currentDiscard.committed, true); + assert.strictEqual(await app.recovery.getActiveSession('active-cas'), null); + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence('active-cas')) }, + { id: 'active-cas', exists: true, tombstoned: true, revision: 3 }, + 'active-session reads hide a CAS tombstone while the fence preserves its completion proof' + ); + const delayedFirstSave = await app.recovery.saveActiveSession({ + id: 'active-cas', + revision: 1, + lastUpdate: 50, + marker: 'delayed-first-save' + }, { expectedEntityRevision: 0 }); + assert.strictEqual(delayedFirstSave.committed, false, 'discard tombstone must reject a delayed cross-realm first save'); + assert.strictEqual(delayedFirstSave.code, 'STALE_RECOVERY_WRITE'); + assert.strictEqual(await app.recovery.getActiveSession('active-cas'), null); + const tombstoneCleanup = await app.recovery.cleanupForRetry({ + discardable: { activeSession: ['active-cas'] } + }); + assert.deepStrictEqual(Array.from(tombstoneCleanup.removedByKind.activeSession), [], 'cleanup must retain CAS tombstones'); + const delayedAfterCleanup = await app.recovery.saveActiveSession({ + id: 'active-cas', + revision: 1, + lastUpdate: 60, + marker: 'delayed-after-cleanup' + }, { expectedEntityRevision: 0 }); + assert.strictEqual(delayedAfterCleanup.committed, false, 'cleanup must not reopen a discarded entity revision'); + const recoveryKey = 'recovery.activeSessions'; + const mutateRawActiveSessions = (operationId, mutate) => { + const current = shared.docs.get(recoveryKey); + const items = clone(current && current.data || []); + mutate(items); + shared.docs.set(recoveryKey, envelope( + recoveryKey, + items, + 'present', + Number(current && current.revision || 0) + 1, + operationId + )); + return items; + }; + const rawActiveSessionsFor = (id) => clone(shared.docs.get(recoveryKey)?.data || []) + .filter((item) => String(item && (item.id || item.sessionId || item.recordId) || '') === String(id)); + const shadowedByTombstoneId = 'active-shadowed-by-tombstone'; + assert.strictEqual((await app.recovery.saveActiveSession({ + id: shadowedByTombstoneId, + revision: 1, + marker: 'original-owner' + }, { expectedEntityRevision: 0 })).committed, true); + assert.strictEqual((await app.recovery.discardActiveSession(shadowedByTombstoneId, { + expectedEntityRevision: 1 + })).committed, true); + const duplicateOwnerDocument = shared.docs.get(recoveryKey); + const duplicateOwnerItems = clone(duplicateOwnerDocument.data); + duplicateOwnerItems.push({ + id: shadowedByTombstoneId, + revision: 9, + marker: 'later-imported-duplicate', + updatedAt: new Date().toISOString() + }); + shared.docs.set(recoveryKey, envelope( + recoveryKey, + duplicateOwnerItems, + 'present', + duplicateOwnerDocument.revision + 1, + 'append-shadowed-recovery-duplicate' + )); + assert.strictEqual( + (await app.recovery.listActiveSessions()).some((item) => item.id === shadowedByTombstoneId), + false, + 'a later duplicate must not become visible when the raw first owner is a tombstone' + ); + assert.strictEqual(await app.recovery.getActiveSession(shadowedByTombstoneId), null); + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence(shadowedByTombstoneId)) }, + { id: shadowedByTombstoneId, exists: true, tombstoned: true, revision: 2 }, + 'the fence must keep the raw first tombstone authoritative over a later duplicate' + ); + const shadowedDuplicateSave = await app.recovery.saveActiveSession({ + id: shadowedByTombstoneId, + revision: 10, + marker: 'must-not-revive' + }, { expectedEntityRevision: 9 }); + assert.strictEqual(shadowedDuplicateSave.committed, false); + assert.strictEqual(shadowedDuplicateSave.actualEntityRevision, 2, 'CAS must still target the hidden first tombstone'); + const visibleMarkerOwnerId = 'active-visible-shadow-marker'; + assert.strictEqual((await app.recovery.saveActiveSession({ + id: visibleMarkerOwnerId, + revision: 1, + schema: 'suite-session-v2', + marker: 'first-visible-owner' + }, { expectedEntityRevision: 0 })).committed, true); + const visibleMarkerDocument = shared.docs.get(recoveryKey); + const visibleMarkerItems = clone(visibleMarkerDocument.data); + visibleMarkerItems.push({ + id: visibleMarkerOwnerId, + revision: 8, + schema: 'multi-suite-sessions-v2', + version: 2, + sessions: [{ id: 'shadow-marker', baseExamId: 'shadow-marker-base' }], + updatedAt: new Date().toISOString() + }); + shared.docs.set(recoveryKey, envelope( + recoveryKey, + visibleMarkerItems, + 'present', + visibleMarkerDocument.revision + 1, + 'append-visible-recovery-marker' + )); + assert.strictEqual( + (await app.recovery.listActiveSessions()).filter((item) => item.id === visibleMarkerOwnerId).length, + 2, + 'non-tombstone shadow entries must remain visible for raw recovery marker reconciliation' + ); + await app.recovery.discardActiveSession(visibleMarkerOwnerId); + const recoveryDocument = shared.docs.get(recoveryKey); + const expiredRecoveryItems = clone(recoveryDocument.data); + const expiredTombstone = expiredRecoveryItems.find((item) => item && item.id === 'active-cas'); + expiredTombstone.updatedAt = new Date(Date.now() - (31 * 24 * 60 * 60 * 1000)).toISOString(); + shared.docs.set(recoveryKey, envelope( + recoveryKey, + expiredRecoveryItems, + 'present', + recoveryDocument.revision + 1, + 'expire-recovery-tombstone' + )); + const expiredTombstoneCleanup = await app.recovery.cleanupForRetry(); + assert.deepStrictEqual( + Array.from(expiredTombstoneCleanup.removedByKind.activeSession), + ['active-cas'], + 'existing recovery TTL must remove tombstones older than 30 days' + ); + const recreatedAfterTtl = await app.recovery.saveActiveSession({ + id: 'active-cas', + revision: 1, + marker: 'recreated-after-ttl' + }, { expectedEntityRevision: 0 }); + assert.strictEqual(recreatedAfterTtl.committed, true); + + // A. Exclusive suite ownership is derived from the raw first owner only. + const saveSingleSuiteClaim = (id, revision = 1, expectedEntityRevision = 0, marker = '') => ( + app.recovery.saveActiveSession({ + id, + revision, + schema: 'suite-session-v2', + version: 2, + marker + }, { expectedEntityRevision, exclusiveGroup: 'suite-practice' }) + ); + const saveMultiSuiteRecovery = (id, marker = '') => app.recovery.saveActiveSession({ + id, + revision: 1, + schema: 'multi-suite-sessions-v2', + version: 2, + sessions: [], + marker + }, { expectedEntityRevision: 0 }); + + const legacyUntaggedSuiteId = 'suite-legacy-untagged-first-owner'; + mutateRawActiveSessions('append-legacy-untagged-suite-owner', (items) => { + items.push({ + id: legacyUntaggedSuiteId, + revision: 1, + schema: 'suite-session-v2', + version: 2, + marker: 'legacy-untagged-first-owner', + updatedAt: new Date().toISOString() + }); + }); + const blockedByLegacyOwner = await saveSingleSuiteClaim('suite-blocked-by-legacy-owner'); + assert.strictEqual(blockedByLegacyOwner.committed, false, 'legacy untagged suite-session-v2 must occupy the singleton suite group'); + assert.strictEqual(blockedByLegacyOwner.code, 'RECOVERY_GROUP_CONFLICT'); + assert.strictEqual(blockedByLegacyOwner.conflictingEntityId, legacyUntaggedSuiteId); + await app.recovery.discardActiveSession(legacyUntaggedSuiteId); + + const multiBeforeSingleId = 'multi-suite-before-single'; + const singleAfterMultiId = 'single-suite-after-multi'; + assert.strictEqual((await saveMultiSuiteRecovery(multiBeforeSingleId, 'multi-first')).committed, true); + assert.strictEqual( + (await saveSingleSuiteClaim(singleAfterMultiId, 1, 0, 'single-second')).committed, + true, + 'multi-suite schema must not occupy the singleton suite-practice group' + ); + assert(await app.recovery.getActiveSession(multiBeforeSingleId)); + assert(await app.recovery.getActiveSession(singleAfterMultiId)); + await app.recovery.discardActiveSession(multiBeforeSingleId); + await app.recovery.discardActiveSession(singleAfterMultiId); + + const singleBeforeMultiId = 'single-suite-before-multi'; + const multiAfterSingleId = 'multi-suite-after-single'; + assert.strictEqual((await saveSingleSuiteClaim(singleBeforeMultiId, 1, 0, 'single-first')).committed, true); + assert.strictEqual( + (await saveMultiSuiteRecovery(multiAfterSingleId, 'multi-second')).committed, + true, + 'an active singleton suite must not block an independent multi-suite recovery' + ); + assert(await app.recovery.getActiveSession(singleBeforeMultiId)); + assert(await app.recovery.getActiveSession(multiAfterSingleId)); + await app.recovery.discardActiveSession(singleBeforeMultiId); + await app.recovery.discardActiveSession(multiAfterSingleId); + + const sameIdSuiteOwner = 'suite-same-id-raw-duplicate'; + assert.strictEqual((await saveSingleSuiteClaim(sameIdSuiteOwner, 1, 0, 'raw-first')).committed, true); + mutateRawActiveSessions('append-same-id-suite-duplicate', (items) => { + items.push({ + id: sameIdSuiteOwner, + revision: 9, + schema: 'suite-session-v2', + version: 2, + _recoveryExclusiveGroup: 'suite-practice', + marker: 'raw-shadow', + updatedAt: new Date().toISOString() + }); + }); + assert.strictEqual( + (await saveSingleSuiteClaim(sameIdSuiteOwner, 2, 1, 'updated-first-owner')).committed, + true, + 'a raw duplicate of the same logical id must not conflict with its own suite update' + ); + + // B. The public fence must keep reporting the raw first owner, never its shadow. + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence(sameIdSuiteOwner)) }, + { id: sameIdSuiteOwner, exists: true, tombstoned: false, revision: 2 }, + 'a later high-revision duplicate must not replace the live raw first-owner fence' + ); + await app.recovery.discardActiveSession(sameIdSuiteOwner); + + const tombstonedSuiteOwner = 'suite-tombstoned-first-owner-with-shadow'; + assert.strictEqual((await saveSingleSuiteClaim(tombstonedSuiteOwner, 1, 0, 'tombstone-source')).committed, true); + assert.strictEqual((await app.recovery.discardActiveSession(tombstonedSuiteOwner, { + expectedEntityRevision: 1 + })).committed, true); + mutateRawActiveSessions('append-suite-shadow-after-tombstone', (items) => { + items.push({ + id: tombstonedSuiteOwner, + revision: 11, + schema: 'suite-session-v2', + version: 2, + _recoveryExclusiveGroup: 'suite-practice', + marker: 'must-remain-shadowed', + updatedAt: new Date().toISOString() + }); + }); + const claimAfterShadowedTombstone = await saveSingleSuiteClaim('suite-after-shadowed-tombstone'); + assert.strictEqual( + claimAfterShadowedTombstone.committed, + true, + 'a suite duplicate shadowed by its first tombstone must not block a different suite id' + ); + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence(tombstonedSuiteOwner)) }, + { id: tombstonedSuiteOwner, exists: true, tombstoned: true, revision: 2 }, + 'the tombstone must remain the fence owner when a later live suite duplicate exists' + ); + await app.recovery.discardActiveSession(tombstonedSuiteOwner); + await app.recovery.discardActiveSession('suite-after-shadowed-tombstone'); + + // C. TTL and retry cleanup operate on whole first-owner logical-id groups. + const expiredFirstOwnerAt = new Date(Date.now() - (31 * 24 * 60 * 60 * 1000)).toISOString(); + const seedExpiredTombstoneWithFreshShadow = async (id, operationId) => { + assert.strictEqual((await app.recovery.saveActiveSession({ + id, + revision: 1, + marker: 'ttl-first-owner' + }, { expectedEntityRevision: 0 })).committed, true); + assert.strictEqual((await app.recovery.discardActiveSession(id, { + expectedEntityRevision: 1 + })).committed, true); + mutateRawActiveSessions(operationId, (items) => { + const firstOwner = items.find((item) => item && item.id === id); + firstOwner.updatedAt = expiredFirstOwnerAt; + items.push({ + id, + revision: 19, + marker: 'fresh-shadow-must-not-promote', + updatedAt: new Date().toISOString() + }); + }); + }; + + const ttlFirstOwnerGroupId = 'active-ttl-first-owner-group'; + await seedExpiredTombstoneWithFreshShadow(ttlFirstOwnerGroupId, 'seed-ttl-first-owner-group'); + assert.deepStrictEqual( + { ...(await app.recovery.getActiveSessionFence(ttlFirstOwnerGroupId)) }, + { id: ttlFirstOwnerGroupId, exists: false, tombstoned: false, revision: 0 }, + 'TTL pruning must remove an expired first tombstone and every fresh shadow with the same logical id' + ); + assert.deepStrictEqual(rawActiveSessionsFor(ttlFirstOwnerGroupId), [], 'TTL pruning must not promote a fresh shadow duplicate'); + + const cleanupExpiredGroupId = 'active-cleanup-expired-first-owner-group'; + await seedExpiredTombstoneWithFreshShadow(cleanupExpiredGroupId, 'seed-cleanup-first-owner-group'); + const expiredFirstOwnerGroupCleanup = await app.recovery.cleanupForRetry(); + assert.deepStrictEqual( + Array.from(expiredFirstOwnerGroupCleanup.removedByKind.activeSession) + .filter((id) => id === cleanupExpiredGroupId), + [cleanupExpiredGroupId], + 'retry cleanup must report an expired logical group exactly once' + ); + assert.deepStrictEqual(rawActiveSessionsFor(cleanupExpiredGroupId), [], 'retry cleanup must remove every shadow of an expired first owner'); + + const preservedLogicalGroupId = 'active-cleanup-preserved-logical-group'; + assert.strictEqual((await app.recovery.saveActiveSession({ + id: preservedLogicalGroupId, + revision: 1, + marker: 'preserved-first-owner' + }, { expectedEntityRevision: 0 })).committed, true); + mutateRawActiveSessions('append-preserved-logical-shadow', (items) => { + items.push({ + id: preservedLogicalGroupId, + revision: 7, + marker: 'preserved-shadow', + updatedAt: new Date().toISOString() + }); + }); + const preservedLogicalGroupCleanup = await app.recovery.cleanupForRetry({ + preserve: { activeSession: [preservedLogicalGroupId] }, + discardable: { activeSession: [preservedLogicalGroupId] } + }); + assert.strictEqual( + Array.from(preservedLogicalGroupCleanup.removedByKind.activeSession).includes(preservedLogicalGroupId), + false, + 'preserve must win for the complete logical-id group' + ); + assert.strictEqual(rawActiveSessionsFor(preservedLogicalGroupId).length, 2, 'preserve must retain the first owner and every raw shadow'); + await app.recovery.discardActiveSession(preservedLogicalGroupId); + + const discardableLogicalGroupId = 'active-cleanup-discardable-logical-group'; + assert.strictEqual((await app.recovery.saveActiveSession({ + id: discardableLogicalGroupId, + revision: 1, + marker: 'discardable-first-owner' + }, { expectedEntityRevision: 0 })).committed, true); + mutateRawActiveSessions('append-discardable-logical-shadow', (items) => { + items.push({ + id: discardableLogicalGroupId, + revision: 8, + marker: 'discardable-shadow', + updatedAt: new Date().toISOString() + }); + }); + const discardableLogicalGroupCleanup = await app.recovery.cleanupForRetry({ + discardable: { activeSession: [discardableLogicalGroupId] } + }); + assert.deepStrictEqual( + Array.from(discardableLogicalGroupCleanup.removedByKind.activeSession) + .filter((id) => id === discardableLogicalGroupId), + [discardableLogicalGroupId], + 'discardable cleanup must report one removal per logical id instead of one per raw duplicate' + ); + assert.deepStrictEqual(rawActiveSessionsFor(discardableLogicalGroupId), [], 'discardable cleanup must remove the entire logical-id group'); + + const suiteClaimA = await app.recovery.saveActiveSession({ + id: 'suite-claim-a', + revision: 1, + schema: 'suite-session-v2' + }, { expectedEntityRevision: 0, exclusiveGroup: 'suite-practice' }); + assert.strictEqual(suiteClaimA.committed, true); + const suiteClaimBConflict = await app.recovery.saveActiveSession({ + id: 'suite-claim-b', + revision: 1, + schema: 'suite-session-v2' + }, { expectedEntityRevision: 0, exclusiveGroup: 'suite-practice' }); + assert.strictEqual(suiteClaimBConflict.committed, false, 'only one suite claim may exist across tabs'); + assert.strictEqual(suiteClaimBConflict.code, 'RECOVERY_GROUP_CONFLICT'); + await app.recovery.discardActiveSession('suite-claim-a', { expectedEntityRevision: 1 }); + const suiteClaimBAfterDiscard = await app.recovery.saveActiveSession({ + id: 'suite-claim-b', + revision: 1, + schema: 'suite-session-v2' + }, { expectedEntityRevision: 0, exclusiveGroup: 'suite-practice' }); + assert.strictEqual(suiteClaimBAfterDiscard.committed, true, 'a new suite may claim the group after durable discard'); + const preferencesBeforeRecoveryCleanup = await app.preferences.getAll(); + const practiceBeforeRecoveryCleanup = await app.practice.get('r1'); + const cleanupReceipt = await app.recovery.cleanupForRetry({ + preserve: { activeSession: ['active-0'] }, + discardable: { activeSession: ['active-0', 'active-1'] } + }); + assert.strictEqual(cleanupReceipt.committed, true); + assert.strictEqual(cleanupReceipt.removedCount, 1, 'cleanup should remove only explicitly disposable recovery'); + assert.deepStrictEqual(Array.from(cleanupReceipt.removedByKind.activeSession), ['active-1']); + assert(await app.recovery.getActiveSession('active-0'), 'preserve must win over discardable'); + assert.strictEqual(await app.recovery.getActiveSession('active-1'), null); + assert.deepStrictEqual(await app.preferences.getAll(), preferencesBeforeRecoveryCleanup, 'recovery cleanup must not touch preferences'); + assert.deepStrictEqual(await app.practice.get('r1'), practiceBeforeRecoveryCleanup, 'recovery cleanup must not touch business records'); + await assert.rejects(() => app.backups.previewImport({ records: [] }), { code: 'VALIDATION' }); + const invalidStore = { format: 'ielts-atlas-data-v2', schemaVersion: 2, scope: 'partial', envelopes: {}, entities: { practiceRecords: [] } }; invalidStore.checksum = checksum({ envelopes: invalidStore.envelopes, entities: invalidStore.entities }); await assert.rejects(() => app.backups.previewImport(invalidStore), { code: 'VALIDATION' }); + + // A real-world v2 export produced by the broken whole-IDB-row migration: + // salvage safe wrappers, quarantine the inconsistent library domain, and + // never let an empty practice replace commit without a second confirmation. + await app.library.import({ + id: 'current-library', + configuration: { name: 'Current library' }, + index: [{ id: 'reading-current' }] + }); + await app.library.activate('current-library'); + await app.settings.patch({ currentOnly: true }); + const poisoned = { + format: 'ielts-atlas-data-v2', + schemaVersion: 2, + scope: 'full', + envelopes: { + 'achievements.manual': envelope('achievements.manual', { + key: 'exam_system_user_achievements', + value: '{}', + timestamp: 1 + }), + 'library.activeConfigurationId': envelope('library.activeConfigurationId', '[object Object]'), + 'library.configurations': envelope('library.configurations', []), + 'preferences.values': envelope('preferences.values', { + key: 'exam_system_settings', + value: JSON.stringify({ theme: 'must-not-cross-domains' }), + timestamp: 1 + }), + 'settings.values': envelope('settings.values', { + key: 'exam_system_settings', + value: JSON.stringify({ theme: 'light', notifications: true }), + timestamp: 1, + postMigrationFlag: true + }), + 'vocab.userConfig': envelope('vocab.userConfig', { + key: 'exam_system_vocab_user_config', + value: JSON.stringify({ dailyNew: 20, reviewLimit: 100 }), + timestamp: 1 + }) + }, + entities: { + practiceSummaries: [], + practiceDetails: [], + practiceAnnotations: [] + } + }; + poisoned.checksum = checksum({ envelopes: poisoned.envelopes, entities: poisoned.entities }); + const safePlan = await app.backups.previewImport(poisoned, { practiceMode: 'merge' }); + assert.strictEqual(safePlan.destructive, false); + assert.strictEqual(safePlan.diagnostics.trust, 'degraded-partial'); + assert(safePlan.diagnostics.repairedKeys.includes('settings.values')); + assert(safePlan.diagnostics.ignoredKeys.includes('library.activeConfigurationId')); + assert(safePlan.diagnostics.ignoredKeys.includes('preferences.values')); + assert(safePlan.diagnostics.missingKeys.includes('library.importedIndexes')); + assert.strictEqual(safePlan.practice.removedCount, 0); + await app.backups.commitImport(safePlan.id); + assert.strictEqual(await app.library.getActive(), 'current-library'); + assert.strictEqual((await app.library.getIndex('current-library'))[0].id, 'reading-current'); + assert.strictEqual((await app.settings.getAll()).theme, 'light'); + assert.strictEqual((await app.settings.getAll()).currentOnly, true); + assert.strictEqual((await app.settings.getAll()).postMigrationFlag, true); + + const partialLibrary = { + format: 'ielts-atlas-data-v2', + schemaVersion: 2, + scope: 'partial', + envelopes: { + 'library.activeConfigurationId': envelope('library.activeConfigurationId', null) + }, + entities: {} + }; + partialLibrary.checksum = checksum({ envelopes: partialLibrary.envelopes, entities: partialLibrary.entities }); + const partialLibraryPlan = await app.backups.previewImport(partialLibrary, { practiceMode: 'merge' }); + assert(partialLibraryPlan.keys.includes('library.activeConfigurationId'), 'valid partial library keys must not be quarantined'); + assert.deepStrictEqual(partialLibraryPlan.diagnostics.ignoredKeys, []); + + const destructivePlan = await app.backups.previewImport(poisoned, { practiceMode: 'replace' }); + assert.strictEqual(destructivePlan.destructive, true); + assert(destructivePlan.practice.existingCount > 0); + assert.strictEqual(destructivePlan.practice.finalCount, 0); + assert.strictEqual(destructivePlan.practice.removedCount, destructivePlan.practice.existingCount); + await assert.rejects(() => app.backups.commitImport(destructivePlan.id), { code: 'VALIDATION' }); + + // Historical v1 export recognition (opensource practiceRecorder / DataBackupManager shapes). + await app.practice.completeAttempt({ + operationId: 'keep-existing', + record: { id: 'keep-me', examId: 'reading-keep', type: 'reading', title: 'Keep', totalQuestions: 1, correctAnswers: 1, answers: { 1: 'Z' } } + }); + const v1Export = { + exportDate: '2026-01-01T00:00:00.000Z', + version: '0.6.2-form', + practiceRecords: [{ + id: 'legacy-1', + examId: 'reading-legacy', + type: 'reading', + title: 'Legacy Passage', + metadata: { examTitle: 'Legacy Passage', category: 'P1' }, + realData: { + answers: { q1: 'A', q2: 'B' }, + scoreInfo: { correct: 1, total: 2, accuracy: 50 }, + highlights: [{ text: 'real-highlight' }], + notes: { q1: 'real-note' }, + noteText: 'real-note-text', + interactions: [{ type: 'real-click' }] + }, + rawData: { + highlights: [{ text: 'raw-highlight' }], + noteOutlines: { q1: ['raw-outline'] }, + noteText: 'raw-note-text', + interactions: [{ type: 'raw-click' }] + } + }], + userStats: { totalPractices: 99 } + }; + const v1Plan = await app.backups.previewImport(v1Export, { practiceMode: 'merge' }); + assert.strictEqual(v1Plan.format, 'v1'); + assert.strictEqual(v1Plan.practice.importedCount, 1); + const v1Receipt = await app.backups.commitImport(v1Plan.id); + assert.strictEqual(v1Receipt.importedCount, 1); + const legacy = await app.practice.get('legacy-1'); + assert.strictEqual(legacy.answers.q1, 'A'); + assert.strictEqual(legacy.correctAnswers, 1); + assert.strictEqual(legacy.totalQuestions, 2); + assert.strictEqual(legacy.accuracy, 0.5); + assert.strictEqual(legacy.percentage, 50); + assert.strictEqual(legacy.highlights[0].text, 'real-highlight'); + assert.strictEqual(legacy.notes.q1, 'real-note'); + assert.deepStrictEqual(legacy.noteOutlines.q1, ['raw-outline']); + assert.strictEqual(legacy.noteText, 'real-note-text'); + assert.strictEqual(legacy.interactions[0].type, 'real-click'); + assert.strictEqual((await app.practice.get('keep-me')).answers[1], 'Z', 'merge must retain existing practice rows'); + + const snakePlan = await app.backups.previewImport({ + practice_records: [{ id: 'snake-1', type: 'listening', title: 'Snake', totalQuestions: 3, correctAnswers: 2, answers: { 1: 'yes' } }] + }, { practiceMode: 'replace' }); + assert.strictEqual(snakePlan.format, 'v1'); + assert.strictEqual(snakePlan.destructive, true); + await app.backups.commitImport(snakePlan.id, { confirmDestructive: true }); + assert.strictEqual(await app.practice.get('keep-me'), null, 'practiceMode replace must clear prior practice rows'); + assert.strictEqual(await app.practice.get('legacy-1'), null); + assert.strictEqual((await app.practice.get('snake-1')).answers[1], 'yes'); + + console.log(JSON.stringify({ status: 'pass', tests: 47 })); +} +run().catch((error) => { console.error(error.stack || error); process.exitCode = 1; }); diff --git a/developer/tests/js/backupAPI.test.js b/developer/tests/js/backupAPI.test.js deleted file mode 100644 index 5e74f019..00000000 --- a/developer/tests/js/backupAPI.test.js +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function loadScript(relativePath, context) { - const fullPath = path.join(repoRoot, relativePath); - const source = fs.readFileSync(fullPath, 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function clone(value) { - return value == null ? value : JSON.parse(JSON.stringify(value)); -} - -function createHarness() { - const records = [{ - id: 'existing-1', - examId: 'reading-p1', - title: 'Existing', - startTime: '2026-05-20T10:00:00.000Z', - endTime: '2026-05-20T10:10:00.000Z', - duration: 600, - score: 1, - totalQuestions: 1, - correctAnswers: 1 - }]; - const meta = new Map(); - meta.set('exam_index', [{ id: 'exam-A', title: 'A' }]); - meta.set('storage_version', '0.6.2-form'); - let userStats = { totalPractices: 1, totalTimeSpent: 600 }; - const calls = []; - - const storage = { - async get(key, fallback = null) { - calls.push({ type: 'storage.get', key }); - return meta.has(key) ? clone(meta.get(key)) : clone(fallback); - }, - async set(key, value) { - calls.push({ type: 'storage.set', key, value: clone(value) }); - meta.set(key, clone(value)); - return true; - } - }; - - const quietConsole = { - log() {}, warn() {}, error() {}, info() {}, debug() {} - }; - - const sandboxWindow = { - console: quietConsole, - PracticeRecordAPI: { - async list() { - calls.push({ type: 'api.list' }); - return clone(records); - }, - async restoreRecords(nextRecords, options = {}) { - calls.push({ type: 'api.restoreRecords', options: clone(options) }); - records.splice(0, records.length, ...(Array.isArray(nextRecords) ? clone(nextRecords) : [])); - if (options && options.stats && typeof options.stats === 'object') { - userStats = clone(options.stats); - } - return { restoredCount: records.length, statsRestored: Boolean(options && options.stats) }; - }, - async readStats() { - calls.push({ type: 'api.readStats' }); - return clone(userStats); - }, - async resetStats(stats = null) { - calls.push({ type: 'api.resetStats' }); - userStats = stats ? clone(stats) : {}; - return clone(userStats); - } - } - }; - - const sandbox = { - window: sandboxWindow, - storage, - console: quietConsole, - setInterval: () => 0, - clearInterval: () => {}, - setTimeout, - clearTimeout, - Date, - Math, - JSON - }; - sandboxWindow.storage = storage; - sandbox.globalThis = sandboxWindow; - const context = vm.createContext(sandbox); - loadScript('js/core/backupAPI.js', context); - - return { - BackupAPI: sandboxWindow.BackupAPI, - records, - calls, - meta, - getUserStats: () => clone(userStats), - setUserStats: (next) => { userStats = clone(next); } - }; -} - -async function testCreateCapturesDualSchemaAndExamIndex() { - const harness = createHarness(); - const backupId = await harness.BackupAPI.create({ type: 'manual' }); - assert(backupId, 'create 应返回 backup id'); - - const backups = harness.meta.get('manual_backups'); - assert.strictEqual(backups.length, 1); - assert.strictEqual(backups[0].type, 'manual'); - const data = backups[0].data; - assert.ok(Array.isArray(data.practice_records), '应写 practice_records'); - assert.ok(Array.isArray(data.practiceRecords), '应写 practiceRecords 双 schema'); - assert.ok(data.user_stats && data.userStats, '应写 user_stats 双 schema'); - assert.ok(Array.isArray(data.exam_index) && data.exam_index[0].id === 'exam-A', '应抓 exam_index'); - assert.strictEqual(data.storage_version, '0.6.2-form'); - assert(harness.calls.some(c => c.type === 'api.list'), 'create 应经 PracticeRecordAPI.list'); - assert(harness.calls.some(c => c.type === 'api.readStats'), 'create 应经 PracticeRecordAPI.readStats'); -} - -async function testRestoreRestoresRecordsStatsAndExamIndex() { - const harness = createHarness(); - const backupId = await harness.BackupAPI.create({ type: 'pre_import' }); - - harness.records.splice(0, harness.records.length, { - id: 'mutated', - examId: 'x', - startTime: '2026-05-21T00:00:00.000Z', - duration: 1 - }); - harness.setUserStats({ totalPractices: 99 }); - harness.meta.set('exam_index', [{ id: 'exam-B', title: 'B' }]); - - await harness.BackupAPI.restore(backupId); - - assert.deepStrictEqual(harness.records.map(r => r.id), ['existing-1']); - assert.strictEqual(harness.getUserStats().totalPractices, 1); - assert.strictEqual(harness.meta.get('exam_index')[0].id, 'exam-A'); - assert(harness.calls.some(c => c.type === 'api.restoreRecords'), 'restore 应走 PracticeRecordAPI.restoreRecords'); -} - -async function testPruneKeepsNewestFirst() { - const harness = createHarness(); - await harness.BackupAPI.create({ id: 'b1', type: 'manual' }); - await harness.BackupAPI.create({ id: 'b2', type: 'manual' }); - await harness.BackupAPI.create({ id: 'b3', type: 'manual' }); - await harness.BackupAPI.prune(2); - const backups = harness.meta.get('manual_backups'); - assert.strictEqual(backups.length, 2); - assert.strictEqual(backups[0].id, 'b3', '最新应在头部'); - assert.strictEqual(backups[1].id, 'b2'); -} - -async function testScoreStorageStyleCreateViaApi() { - const harness = createHarness(); - const id = await harness.BackupAPI.create({ - id: 'score_backup_1', - type: 'score_storage', - data: { - practiceRecords: [{ id: 'r1', examId: 'e1' }], - userStats: { totalPractices: 3 }, - storageVersion: '1.0.0' - } - }); - assert.strictEqual(id, 'score_backup_1'); - const backup = await harness.BackupAPI.getById(id); - assert.ok(Array.isArray(backup.data.practice_records)); - assert.ok(backup.data.user_stats); - assert.strictEqual(backup.data.storage_version, '1.0.0'); -} - -async function main() { - try { - await testCreateCapturesDualSchemaAndExamIndex(); - await testRestoreRestoresRecordsStatsAndExamIndex(); - await testPruneKeepsNewestFirst(); - await testScoreStorageStyleCreateViaApi(); - process.stdout.write(JSON.stringify({ - status: 'pass', - detail: 'BackupAPI 统一 capture/create/restore/prune;双 schema + exam_index' - })); - } catch (error) { - process.stdout.write(JSON.stringify({ - status: 'fail', - detail: error && error.stack ? error.stack : String(error) - })); - process.exit(1); - } -} - -main(); diff --git a/developer/tests/js/backupRestoreTest.js b/developer/tests/js/backupRestoreTest.js deleted file mode 100644 index fd321862..00000000 --- a/developer/tests/js/backupRestoreTest.js +++ /dev/null @@ -1,657 +0,0 @@ -/** - * 备份恢复验证测试 - * 验证备份和恢复功能的完整性 - */ - -class BackupRestoreTest { - constructor() { - this.testResults = []; - this.simpleStorage = window.simpleStorageWrapper; - this.practiceRecordAPI = window.PracticeRecordAPI; - this.dataBackupManager = window.DataBackupManager ? new DataBackupManager() : null; - this.testData = { - originalPracticeRecords: [], - originalUserSettings: {}, - originalBackups: [], - testBackups: [] - }; - } - - getPracticeRecordAPI(requiredMethods = []) { - const api = this.practiceRecordAPI || window.PracticeRecordAPI; - if (!api) { - throw new Error('PracticeRecordAPI不可用'); - } - requiredMethods.forEach(method => { - if (typeof api[method] !== 'function') { - throw new Error(`PracticeRecordAPI.${method}不可用`); - } - }); - return api; - } - - async replacePracticeRecords(records) { - const api = this.getPracticeRecordAPI(['replace']); - await api.replace(Array.isArray(records) ? records : [], { updateStats: true }); - return true; - } - - async savePracticeRecord(record) { - const api = this.getPracticeRecordAPI(['saveRecord']); - await api.saveRecord(record, { updateStats: true }); - return true; - } - - // 运行所有备份恢复测试 - async runAllTests() { - console.log('💾 开始备份恢复验证测试...'); - - this.testResults = []; - - // 1. 备份原始数据 - await this.backupOriginalData(); - - // 2. 测试备份管理器连接 - this.testBackupManagerConnection(); - - // 3. 测试创建备份 - await this.testCreateBackup(); - - // 4. 测试获取备份列表 - await this.testGetBackups(); - - // 5. 测试备份内容验证 - await this.testBackupContentValidation(); - - // 6. 测试恢复备份 - await this.testRestoreBackup(); - - // 7. 测试删除备份 - await this.testDeleteBackup(); - - // 8. 测试备份大小限制 - await this.testBackupSizeLimit(); - - // 9. 测试并发备份 - await this.testConcurrentBackups(); - - // 10. 测试数据完整性验证 - await this.testDataIntegrityValidation(); - - // 11. 恢复原始数据 - await this.restoreOriginalData(); - - this.printResults(); - return this.testResults; - } - - // 备份原始数据 - async backupOriginalData() { - const testName = '备份原始数据'; - - try { - if (this.simpleStorage) { - this.testData.originalPracticeRecords = await this.simpleStorage.getPracticeRecords(); - this.testData.originalUserSettings = await this.simpleStorage.getUserSettings(); - this.testData.originalBackups = await this.simpleStorage.getBackups(); - - console.log(`[BackupRestoreTest] 备份了 ${this.testData.originalPracticeRecords.length} 条练习记录`); - console.log(`[BackupRestoreTest] 备份了 ${Object.keys(this.testData.originalUserSettings).length} 项用户设置`); - console.log(`[BackupRestoreTest] 备份了 ${this.testData.originalBackups.length} 个现有备份`); - - this.recordTest(testName, true, { - practiceRecordsCount: this.testData.originalPracticeRecords.length, - userSettingsCount: Object.keys(this.testData.originalUserSettings).length, - existingBackupsCount: this.testData.originalBackups.length, - timestamp: new Date().toISOString() - }); - } else { - this.recordTest(testName, false, { error: 'simpleStorageWrapper不可用' }); - } - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试备份管理器连接 - testBackupManagerConnection() { - const testName = '备份管理器连接测试'; - - try { - const checks = [ - { name: 'DataBackupManager类存在', exists: typeof DataBackupManager !== 'undefined' }, - { name: 'dataBackupManager实例存在', exists: !!this.dataBackupManager }, - { name: 'createBackup方法存在', exists: typeof this.dataBackupManager?.createBackup === 'function' }, - { name: 'getBackups方法存在', exists: typeof this.dataBackupManager?.getBackups === 'function' }, - { name: 'restoreBackup方法存在', exists: typeof this.dataBackupManager?.restoreBackup === 'function' }, - { name: 'deleteBackup方法存在', exists: typeof this.dataBackupManager?.deleteBackup === 'function' } - ]; - - const allConnected = checks.every(check => check.exists); - - this.recordTest(testName, allConnected, { - checks, - totalChecks: checks.length, - passedChecks: checks.filter(c => c.exists).length - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试创建备份 - async testCreateBackup() { - const testName = '创建备份测试'; - - try { - if (!this.dataBackupManager) { - this.recordTest(testName, false, { error: 'dataBackupManager不可用' }); - return; - } - - const backupName = `test_backup_${Date.now()}`; - const backupId = await this.dataBackupManager.createBackup(backupName); - - const backupCreated = backupId !== null && backupId !== undefined; - - if (backupCreated) { - this.testData.testBackups.push({ - id: backupId, - name: backupName, - timestamp: new Date().toISOString() - }); - } - - this.recordTest(testName, backupCreated, { - backupName, - backupId, - backupCreated, - timestamp: new Date().toISOString() - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试获取备份列表 - async testGetBackups() { - const testName = '获取备份列表测试'; - - try { - if (!this.dataBackupManager) { - this.recordTest(testName, false, { error: 'dataBackupManager不可用' }); - return; - } - - const backups = await this.dataBackupManager.getBackups(); - const hasBackups = Array.isArray(backups) && backups.length > 0; - - // 验证测试备份是否在列表中 - const testBackupInList = this.testData.testBackups.some(testBackup => - backups.some(backup => backup.id === testBackup.id) - ); - - const backupsHaveRequiredFields = backups.every(backup => - backup.hasOwnProperty('id') && - backup.hasOwnProperty('timestamp') && - backup.hasOwnProperty('data') - ); - - this.recordTest(testName, hasBackups && testBackupInList && backupsHaveRequiredFields, { - totalBackups: backups.length, - hasBackups, - testBackupInList, - backupsHaveRequiredFields, - testBackupsCount: this.testData.testBackups.length, - backupListSample: backups.slice(0, 3).map(b => ({ id: b.id, timestamp: b.timestamp })) - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试备份内容验证 - async testBackupContentValidation() { - const testName = '备份内容验证测试'; - - try { - if (!this.dataBackupManager || this.testData.testBackups.length === 0) { - this.recordTest(testName, false, { error: '没有可用的备份进行验证' }); - return; - } - - const testBackup = this.testData.testBackups[0]; - const backupDetails = await this.dataBackupManager.getBackups(); - const currentBackup = backupDetails.find(b => b.id === testBackup.id); - - if (!currentBackup || !currentBackup.data) { - this.recordTest(testName, false, { error: '无法获取备份数据' }); - return; - } - - const backupData = currentBackup.data; - const contentValidation = { - hasPracticeRecords: backupData.hasOwnProperty('practice_records'), - hasUserSettings: backupData.hasOwnProperty('user_settings'), - practiceRecordsIsArray: Array.isArray(backupData.practice_records), - userSettingsIsObject: typeof backupData.user_settings === 'object' && backupData.userSettings !== null - }; - - const allContentValid = Object.values(contentValidation).every(Boolean); - - this.recordTest(testName, allContentValid, { - backupId: testBackup.id, - contentValidation, - backupDataKeys: Object.keys(backupData), - practiceRecordsCount: backupData.practice_records?.length || 0, - userSettingsCount: Object.keys(backupData.user_settings || {}).length - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试恢复备份 - async testRestoreBackup() { - const testName = '恢复备份测试'; - - try { - if (!this.dataBackupManager || this.testData.testBackups.length === 0) { - this.recordTest(testName, false, { error: '没有可用的备份进行恢复' }); - return; - } - - // 记录恢复前的数据 - const practiceRecordsBefore = await this.simpleStorage.getPracticeRecords(); - const userSettingsBefore = await this.simpleStorage.getUserSettings(); - - // 创建一些新的测试数据来模拟数据变化 - const newTestRecord = { - id: 'restore_test_record', - title: '恢复测试记录', - type: 'reading', - score: 95, - date: '2024-01-10' - }; - - await this.savePracticeRecord(newTestRecord); - await this.simpleStorage.setUserSetting('restore_test_setting', 'test_value'); - - const practiceRecordsModified = await this.simpleStorage.getPracticeRecords(); - const userSettingsModified = await this.simpleStorage.getUserSettings(); - - // 执行恢复 - const testBackup = this.testData.testBackups[0]; - const restoreSuccess = await this.dataBackupManager.restoreBackup(testBackup.id); - - // 验证恢复结果 - const practiceRecordsAfterRestore = await this.simpleStorage.getPracticeRecords(); - const userSettingsAfterRestore = await this.simpleStorage.getUserSettings(); - - const restoreVerified = restoreSuccess && - practiceRecordsAfterRestore.length === practiceRecordsBefore.length && - JSON.stringify(userSettingsAfterRestore) === JSON.stringify(userSettingsBefore); - - this.recordTest(testName, restoreSuccess && restoreVerified, { - backupId: testBackup.id, - restoreSuccess, - dataChanges: { - practiceRecordsBefore: practiceRecordsBefore.length, - practiceRecordsModified: practiceRecordsModified.length, - practiceRecordsAfterRestore: practiceRecordsAfterRestore.length, - userSettingsBefore: Object.keys(userSettingsBefore).length, - userSettingsModified: Object.keys(userSettingsModified).length, - userSettingsAfterRestore: Object.keys(userSettingsAfterRestore).length - }, - restoreVerified - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试删除备份 - async testDeleteBackup() { - const testName = '删除备份测试'; - - try { - if (!this.dataBackupManager || this.testData.testBackups.length === 0) { - this.recordTest(testName, false, { error: '没有可用的备份进行删除' }); - return; - } - - const testBackup = this.testData.testBackups[0]; - const backupsBeforeDelete = await this.dataBackupManager.getBackups(); - - // 执行删除 - const deleteSuccess = await this.dataBackupManager.deleteBackup(testBackup.id); - - // 验证删除结果 - const backupsAfterDelete = await this.dataBackupManager.getBackups(); - const backupStillExists = backupsAfterDelete.some(b => b.id === testBackup.id); - - const deleteVerified = deleteSuccess && !backupStillExists; - - if (deleteVerified) { - // 从测试数据中移除已删除的备份 - this.testData.testBackups = this.testData.testBackups.filter(b => b.id !== testBackup.id); - } - - this.recordTest(testName, deleteVerified, { - backupId: testBackup.id, - deleteSuccess, - backupsBeforeDelete: backupsBeforeDelete.length, - backupsAfterDelete: backupsAfterDelete.length, - backupStillExists, - deleteVerified - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试备份大小限制 - async testBackupSizeLimit() { - const testName = '备份大小限制测试'; - - try { - if (!this.dataBackupManager) { - this.recordTest(testName, false, { error: 'dataBackupManager不可用' }); - return; - } - - // 创建大量测试数据 - const largeDataSet = { - practice_records: Array.from({ length: 1000 }, (_, i) => ({ - id: `large_test_${i}`, - title: `大量测试数据记录${i}`, - type: 'reading', - score: Math.floor(Math.random() * 100), - date: '2024-01-11', - largeData: 'x'.repeat(1000) // 每条记录1KB的额外数据 - })), - user_settings: { - largeSetting: 'y'.repeat(10000) // 10KB的设置数据 - } - }; - - // 临时保存大数据集 - const originalRecords = await this.simpleStorage.getPracticeRecords(); - const originalSettings = await this.simpleStorage.getUserSettings(); - - await this.replacePracticeRecords(largeDataSet.practice_records); - await this.simpleStorage.saveUserSettings(largeDataSet.user_settings); - - // 尝试创建大数据备份 - const largeBackupId = await this.dataBackupManager.createBackup('large_test_backup'); - const largeBackupCreated = largeBackupId !== null; - - // 清理大数据 - await this.replacePracticeRecords(originalRecords); - await this.simpleStorage.saveUserSettings(originalSettings); - - // 如果大数据备份创建成功,删除它 - if (largeBackupCreated) { - await this.dataBackupManager.deleteBackup(largeBackupId); - } - - this.recordTest(testName, true, { - largeDataSize: JSON.stringify(largeDataSet).length, - largeBackupCreated, - sizeLimitTest: largeBackupCreated ? '通过' : '大数据备份被限制(预期行为)' - }); - - } catch (error) { - // 大数据失败可能是预期的(存储限制) - this.recordTest(testName, true, { - error: error.message, - note: '大数据备份失败可能是由于存储限制,这是预期的行为' - }); - } - } - - // 测试并发备份 - async testConcurrentBackups() { - const testName = '并发备份测试'; - - try { - if (!this.dataBackupManager) { - this.recordTest(testName, false, { error: 'dataBackupManager不可用' }); - return; - } - - // 创建多个并发备份 - const concurrentBackupPromises = []; - for (let i = 0; i < 3; i++) { - const promise = this.dataBackupManager.createBackup(`concurrent_test_${i}_${Date.now()}`); - concurrentBackupPromises.push(promise); - } - - const backupIds = await Promise.all(concurrentBackupPromises); - const allBackupsCreated = backupIds.every(id => id !== null && id !== undefined); - - // 清理并发测试备份 - for (const backupId of backupIds) { - if (backupId) { - try { - await this.dataBackupManager.deleteBackup(backupId); - } catch (error) { - console.warn('[BackupRestoreTest] 清理并发备份失败:', error); - } - } - } - - this.recordTest(testName, allBackupsCreated, { - concurrentAttempts: concurrentBackupPromises.length, - successfulBackups: backupIds.filter(id => id !== null).length, - allBackupsCreated - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试数据完整性验证 - async testDataIntegrityValidation() { - const testName = '数据完整性验证测试'; - - try { - // 创建标准测试数据 - const standardTestData = { - practice_records: [ - { - id: 'integrity_test_1', - title: '完整性测试1', - type: 'reading', - score: 85, - date: '2024-01-12' - }, - { - id: 'integrity_test_2', - title: '完整性测试2', - type: 'listening', - score: 90, - date: '2024-01-13' - } - ], - user_settings: { - theme: 'dark', - language: 'zh-CN', - notifications: true - } - }; - - // 保存测试数据 - await this.replacePracticeRecords(standardTestData.practice_records); - await this.simpleStorage.saveUserSettings(standardTestData.user_settings); - - // 创建备份 - const backupId = await this.dataBackupManager.createBackup('integrity_test_backup'); - - if (!backupId) { - this.recordTest(testName, false, { error: '无法创建完整性测试备份' }); - return; - } - - // 修改数据 - await this.savePracticeRecord({ - id: 'integrity_test_modified', - title: '修改后的记录', - type: 'reading', - score: 75, - date: '2024-01-14' - }); - - // 恢复备份 - const restoreSuccess = await this.dataBackupManager.restoreBackup(backupId); - - // 验证数据完整性 - const finalPracticeRecords = await this.simpleStorage.getPracticeRecords(); - const finalUserSettings = await this.simpleStorage.getUserSettings(); - - const integrityVerified = restoreSuccess && - finalPracticeRecords.length === standardTestData.practice_records.length && - JSON.stringify(finalUserSettings) === JSON.stringify(standardTestData.user_settings); - - // 清理测试备份 - await this.dataBackupManager.deleteBackup(backupId); - - this.recordTest(testName, integrityVerified, { - originalDataSize: standardTestData.practice_records.length, - finalDataSize: finalPracticeRecords.length, - settingsMatch: JSON.stringify(finalUserSettings) === JSON.stringify(standardTestData.user_settings), - restoreSuccess, - integrityVerified - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 恢复原始数据 - async restoreOriginalData() { - const testName = '恢复原始数据'; - - try { - // 恢复原始练习记录 - await this.replacePracticeRecords(this.testData.originalPracticeRecords); - - // 恢复原始用户设置 - await this.simpleStorage.saveUserSettings(this.testData.originalUserSettings); - - // 恢复原始备份列表(移除测试备份) - const remainingBackups = this.testData.originalBackups.filter(backup => - !this.testData.testBackups.some(testBackup => testBackup.id === backup.id) - ); - - await this.simpleStorage.saveBackups(remainingBackups); - - // 验证恢复 - const finalPracticeRecords = await this.simpleStorage.getPracticeRecords(); - const finalUserSettings = await this.simpleStorage.getUserSettings(); - const finalBackups = await this.simpleStorage.getBackups(); - - const restoreSuccess = finalPracticeRecords.length === this.testData.originalPracticeRecords.length && - JSON.stringify(finalUserSettings) === JSON.stringify(this.testData.originalUserSettings) && - finalBackups.length === remainingBackups.length; - - this.recordTest(testName, restoreSuccess, { - originalPracticeRecordsCount: this.testData.originalPracticeRecords.length, - finalPracticeRecordsCount: finalPracticeRecords.length, - originalUserSettingsCount: Object.keys(this.testData.originalUserSettings).length, - finalUserSettingsCount: Object.keys(finalUserSettings).length, - originalBackupsCount: this.testData.originalBackups.length, - finalBackupsCount: finalBackups.length, - restoreSuccess, - timestamp: new Date().toISOString() - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 记录测试结果 - recordTest(testName, passed, details) { - this.testResults.push({ - name: testName, - passed, - details, - timestamp: new Date().toISOString() - }); - - const status = passed ? '✅' : '❌'; - console.log(`${status} ${testName}`); - if (!passed && details.error) { - console.error(' 错误:', details.error); - } - } - - // 打印测试结果 - printResults() { - const totalTests = this.testResults.length; - const passedTests = this.testResults.filter(r => r.passed).length; - const failedTests = totalTests - passedTests; - - console.log('\n📊 备份恢复测试结果汇总:'); - console.log(`总测试数: ${totalTests}`); - console.log(`通过: ${passedTests} ✅`); - console.log(`失败: ${failedTests} ❌`); - console.log(`成功率: ${((passedTests / totalTests) * 100).toFixed(1)}%`); - - if (failedTests > 0) { - console.log('\n❌ 失败的测试:'); - this.testResults - .filter(r => !r.passed) - .forEach(r => { - console.log(` - ${r.name}: ${r.details.error || '测试条件不满足'}`); - }); - } - - console.log('\n📈 备份统计:'); - console.log(`创建的测试备份数: ${this.testData.testBackups.length}`); - console.log(`原始练习记录数: ${this.testData.originalPracticeRecords.length}`); - console.log(`原始用户设置数: ${Object.keys(this.testData.originalUserSettings).length}`); - } - - // 生成测试报告 - generateReport() { - const totalTests = this.testResults.length; - const passedTests = this.testResults.filter(r => r.passed).length; - const successRate = ((passedTests / totalTests) * 100).toFixed(1); - - return { - summary: { - totalTests, - passedTests, - failedTests: totalTests - passedTests, - successRate: `${successRate}%`, - timestamp: new Date().toISOString() - }, - backupStats: { - testBackupsCreated: this.testData.testBackups.length, - originalPracticeRecords: this.testData.originalPracticeRecords.length, - originalUserSettings: Object.keys(this.testData.originalUserSettings).length, - originalBackups: this.testData.originalBackups.length - }, - failedTests: this.testResults.filter(r => !r.passed).map(r => ({ - name: r.name, - error: r.details.error || '测试条件不满足', - details: r.details - })) - }; - } -} - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = BackupRestoreTest; -} diff --git a/developer/tests/js/browseController.test.js b/developer/tests/js/browseController.test.js index c7a0da86..d00ff9d8 100644 --- a/developer/tests/js/browseController.test.js +++ b/developer/tests/js/browseController.test.js @@ -19,7 +19,8 @@ const __dirname = path.dirname(__filename); // 模拟 DOM 环境 global.window = { __browseFilterMode: null, - __browsePath: null + __browsePath: null, + filterByType() {} }; global.document = { @@ -59,6 +60,8 @@ const controllerCode = fs.readFileSync(controllerPath, 'utf-8'); // 执行代码 eval(controllerCode); +const activeListeningIndex = [{ id: 'active-listening', type: 'listening', path: 'ListeningPractice/' }]; + // 测试套件 function testBrowseModeConfiguration() { console.log('测试: BROWSE_MODES 配置结构'); @@ -111,13 +114,13 @@ function testModeSwitch() { const controller = new window.BrowseController(); // 切换到 P1 模式 - controller.setMode('frequency-p1'); + controller.setMode('frequency-p1', activeListeningIndex); assert.strictEqual(controller.currentMode, 'frequency-p1', '应该切换到 P1 模式'); assert.strictEqual(controller.activeFilter, 'all', '切换模式后应该重置筛选器'); assert.strictEqual(window.__browseFilterMode, 'frequency-p1', '应该保存模式到全局状态'); // 切换到 P4 模式 - controller.setMode('frequency-p4'); + controller.setMode('frequency-p4', activeListeningIndex); assert.strictEqual(controller.currentMode, 'frequency-p4', '应该切换到 P4 模式'); assert.strictEqual(window.__browseFilterMode, 'frequency-p4', '应该更新全局状态'); @@ -136,7 +139,7 @@ function testModeConfig() { let config = controller.getCurrentModeConfig(); assert.strictEqual(config.id, 'default', '应该返回 default 配置'); - controller.setMode('frequency-p1'); + controller.setMode('frequency-p1', activeListeningIndex); config = controller.getCurrentModeConfig(); assert.strictEqual(config.id, 'frequency-p1', '应该返回 P1 配置'); assert.strictEqual(config.basePath, 'ListeningPractice/100 P1', 'P1 配置应该有正确的 basePath'); @@ -150,12 +153,12 @@ function testStatePersistence() { const controller = new window.BrowseController(); // 设置模式 - controller.setMode('frequency-p4'); + controller.setMode('frequency-p4', activeListeningIndex); assert.strictEqual(window.__browseFilterMode, 'frequency-p4', '应该保存到全局状态'); // 创建新实例并恢复状态 const newController = new window.BrowseController(); - newController.restoreMode(); + newController.restoreMode(activeListeningIndex); assert.strictEqual(newController.currentMode, 'frequency-p4', '应该从全局状态恢复模式'); console.log(' ✓ 状态持久化功能正常'); @@ -186,19 +189,19 @@ function testButtonRendering() { // 测试默认模式按钮 buttons = []; mockContainer.innerHTML = ''; - controller.setMode('default'); + controller.setMode('default', activeListeningIndex); assert.strictEqual(buttons.length, 3, 'default 模式应该渲染 3 个按钮'); // 测试 P1 模式按钮 buttons = []; mockContainer.innerHTML = ''; - controller.setMode('frequency-p1'); + controller.setMode('frequency-p1', activeListeningIndex); assert.strictEqual(buttons.length, 3, 'P1 模式应该渲染 3 个按钮'); // 测试 P4 模式按钮 buttons = []; mockContainer.innerHTML = ''; - controller.setMode('frequency-p4'); + controller.setMode('frequency-p4', activeListeningIndex); assert.strictEqual(buttons.length, 4, 'P4 模式应该渲染 4 个按钮(包括全部)'); console.log(' ✓ 按钮渲染功能正常'); @@ -217,13 +220,10 @@ function testFrequencyFiltering() { { id: '4', path: 'ListeningPractice/100 P4/P4 超高频(51)/exam4.html', title: 'Exam 4' }, { id: '5', path: 'ListeningPractice/100 P4/1-10/exam5.html', title: 'Exam 5' }, { id: '6', path: 'ListeningPractice/100 P4/51-60/exam6.html', title: 'Exam 6' } - ]; - - // Mock getExamIndex - controller.getExamIndex = () => mockExamIndex; + ].map((exam) => Object.assign({ type: 'listening' }, exam)); // 测试 P1 超高频筛选 - controller.setMode('frequency-p1'); + controller.setMode('frequency-p1', mockExamIndex); const p1Config = controller.getCurrentModeConfig(); const ultraHighFolders = p1Config.folderMap['ultra-high']; @@ -265,12 +265,10 @@ function testP4AllButtonLogic() { { id: '4', path: 'ListeningPractice/100 P4/91-100/exam4.html', title: 'Exam 4' }, { id: '5', path: 'ListeningPractice/100 P4/P4 超高频(51)/exam5.html', title: 'Exam 5' }, { id: '6', path: 'ListeningPractice/100 P4/P4 高频(52)/exam6.html', title: 'Exam 6' } - ]; - - controller.getExamIndex = () => mockExamIndex; + ].map((exam) => Object.assign({ type: 'listening' }, exam)); // 切换到 P4 模式 - controller.setMode('frequency-p4'); + controller.setMode('frequency-p4', mockExamIndex); const p4Config = controller.getCurrentModeConfig(); // 测试 "全部" 按钮:应该包含 1-100 编号文件夹 @@ -326,8 +324,8 @@ function testFilterButtonStates() { controller.buttonContainer = mockContainer; // 渲染按钮 - controller.setMode('frequency-p1'); - controller.renderFilterButtons(); + controller.setMode('frequency-p1', activeListeningIndex); + controller.renderFilterButtons(activeListeningIndex); // 验证初始激活状态 const activeButtons = buttons.filter(btn => diff --git a/developer/tests/js/browsePreferencesRecords.test.js b/developer/tests/js/browsePreferencesRecords.test.js index da14b564..38c76070 100644 --- a/developer/tests/js/browsePreferencesRecords.test.js +++ b/developer/tests/js/browsePreferencesRecords.test.js @@ -14,39 +14,40 @@ function loadScript(relativePath, context) { vm.runInContext(source, context, { filename: relativePath }); } -function createHarness({ examIndex = [], records = [] } = {}) { - const localStorageState = new Map(); +function createHarness({ examIndex = [], records = [], initialBrowse = null, browseReadGate = null } = {}) { + let persistedBrowse = initialBrowse ? structuredClone(initialBrowse) : null; + let failNextWrite = false; const documentStub = { addEventListener() {}, getElementById() { return null; }, querySelector() { return null; } }; const windowStub = { - localStorage: { - getItem(key) { - return localStorageState.has(key) ? localStorageState.get(key) : null; - }, - setItem(key, value) { - localStorageState.set(key, String(value)); - }, - removeItem(key) { - localStorageState.delete(key); + AppData: { + ready: Promise.resolve(), + preferences: { + async getBrowse() { + if (browseReadGate) await browseReadGate; + return persistedBrowse ? structuredClone(persistedBrowse) : null; + }, + async patchBrowse(value) { + if (failNextWrite) { + failNextWrite = false; + throw new Error('injected preference commit failure'); + } + persistedBrowse = structuredClone(value); + return { committed: true }; + } } }, addEventListener() {}, - getExamIndexState() { - return examIndex.slice(); - }, - getPracticeRecordsState() { - return records.slice(); - } + failNextBrowsePreferenceWrite() { failNextWrite = true; } }; const sandbox = { window: windowStub, globalThis: windowStub, document: documentStub, - localStorage: windowStub.localStorage, console: { log() {}, warn() {}, error() {}, info() {} }, Date, Math, @@ -64,7 +65,7 @@ function createHarness({ examIndex = [], records = [] } = {}) { }; const context = vm.createContext(sandbox); loadScript('js/utils/BrowsePreferencesUtils.js', context); - return { window: windowStub, localStorageState }; + return { window: windowStub, examIndex, records }; } const results = []; @@ -73,11 +74,11 @@ function recordResult(name, detail) { } function readPreferences(windowStub) { - return JSON.parse(windowStub.localStorage.getItem('browse_view_preferences_v2') || '{}'); + return windowStub.getBrowseViewPreferences(); } -function testRecordMetadataBuildsAnchorWithoutCurrentExamIndex() { - const { window } = createHarness({ +async function testRecordMetadataBuildsAnchorWithoutCurrentExamIndex() { + const { window, examIndex } = createHarness({ examIndex: [{ id: 'unrelated-current-config-exam', title: 'Unrelated Current Config', @@ -96,7 +97,8 @@ function testRecordMetadataBuildsAnchorWithoutCurrentExamIndex() { endTime: '2026-05-22T08:00:00.000Z' }; - window.updateBrowseAnchorsFromRecords([record]); + window.updateBrowseAnchorsFromRecords([record], examIndex); + await window.flushBrowsePreferenceWrites(); const prefs = readPreferences(window); assert(prefs.listAnchors, '应写入浏览锚点'); assert(prefs.listAnchors['P1|reading'], '历史阅读记录应在当前题库不含该题时仍产生 P1|reading 锚点'); @@ -106,8 +108,8 @@ function testRecordMetadataBuildsAnchorWithoutCurrentExamIndex() { recordResult('历史记录不依赖当前题库也能生成浏览锚点', prefs.listAnchors['P1|reading']); } -function testExplicitMetadataOutranksCurrentExamIndex() { - const { window } = createHarness({ +async function testExplicitMetadataOutranksCurrentExamIndex() { + const { window, examIndex } = createHarness({ examIndex: [{ id: 'recorded-exam', title: 'Wrong Current Index Match', @@ -127,11 +129,12 @@ function testExplicitMetadataOutranksCurrentExamIndex() { timestamp: 1770000000000 }; - const info = window.resolveRecordExamInfo(record, window.getExamIndexState()); + const info = window.resolveRecordExamInfo(record, examIndex); assert.strictEqual(info.category, 'P2', '记录自身 metadata.category 必须优先于当前题库索引'); assert.strictEqual(info.type, 'reading', '记录自身 metadata.examType 必须优先于当前题库索引'); - window.updateBrowseAnchorsFromRecords([record]); + window.updateBrowseAnchorsFromRecords([record], examIndex); + await window.flushBrowsePreferenceWrites(); const prefs = readPreferences(window); assert(prefs.listAnchors['P2|reading'], '锚点 key 应来自历史记录自身 metadata'); assert(!prefs.listAnchors['P4|listening'], '当前活动题库的同 id 元数据不能污染历史记录锚点'); @@ -139,8 +142,8 @@ function testExplicitMetadataOutranksCurrentExamIndex() { recordResult('历史记录 metadata 优先于当前题库索引', info); } -function testLatestTimestampWinsPerFilter() { - const { window } = createHarness(); +async function testLatestTimestampWinsPerFilter() { + const { window, examIndex } = createHarness(); window.updateBrowseAnchorsFromRecords([ { id: 'old', @@ -156,7 +159,8 @@ function testLatestTimestampWinsPerFilter() { metadata: { category: 'P3', examType: 'reading' }, timestamp: 1800000000000 } - ]); + ], examIndex); + await window.flushBrowsePreferenceWrites(); const prefs = readPreferences(window); assert.strictEqual(prefs.listAnchors['P3|reading'].examId, 'new-p3', '同一筛选下应保留最新练习记录锚点'); @@ -164,11 +168,64 @@ function testLatestTimestampWinsPerFilter() { recordResult('浏览锚点按时间保留最新记录', prefs.listAnchors['P3|reading']); } -function main() { +async function testFailedPreferenceWriteDoesNotReplaceCommittedCache() { + const { window } = createHarness(); + await window.flushBrowsePreferenceWrites(); + assert.strictEqual(window.getBrowseViewPreferences().autoScrollEnabled, true); + + window.failNextBrowsePreferenceWrite(); + const preview = window.saveBrowseViewPreferences({ autoScrollEnabled: false }); + assert.strictEqual(preview.autoScrollEnabled, false, 'UI preview may reflect the requested value'); + await window.flushBrowsePreferenceWrites(); + assert.strictEqual(window.getBrowseViewPreferences().autoScrollEnabled, true, 'failed commit must not become the cached fact'); + recordResult('偏好提交失败不会污染已提交缓存', { autoScrollEnabled: true }); +} + +async function testFirstReadCanAwaitPersistedPreferences() { + let releaseRead; + const browseReadGate = new Promise((resolve) => { releaseRead = resolve; }); + const { window } = createHarness({ + initialBrowse: { + autoScrollEnabled: false, + lastFilter: { category: 'P2', type: 'reading' }, + listAnchors: { + 'P2|reading': { examId: 'saved-reading', title: 'Saved Reading', timestamp: 1 } + } + }, + browseReadGate + }); + + assert.strictEqual( + window.getBrowseViewPreferences().autoScrollEnabled, + true, + '同步兼容读取在 hydration 前仍可返回默认值' + ); + let settled = false; + const ready = window.whenBrowseViewPreferencesReady().then((preferences) => { + settled = true; + return preferences; + }); + await Promise.resolve(); + assert.strictEqual(settled, false, '首次 UI 读取必须等待 AppData hydration'); + releaseRead(); + const hydrated = await ready; + assert.strictEqual(hydrated.autoScrollEnabled, false, 'hydration 后必须返回持久化开关'); + assert.deepStrictEqual( + structuredClone(hydrated.lastFilter), + { category: 'P2', type: 'reading' }, + '首次筛选恢复必须使用持久化值' + ); + assert.strictEqual(hydrated.listAnchors['P2|reading'].examId, 'saved-reading'); + recordResult('首次浏览状态等待 AppData hydration', hydrated); +} + +async function main() { try { - testRecordMetadataBuildsAnchorWithoutCurrentExamIndex(); - testExplicitMetadataOutranksCurrentExamIndex(); - testLatestTimestampWinsPerFilter(); + await testRecordMetadataBuildsAnchorWithoutCurrentExamIndex(); + await testExplicitMetadataOutranksCurrentExamIndex(); + await testLatestTimestampWinsPerFilter(); + await testFailedPreferenceWriteDoesNotReplaceCommittedCache(); + await testFirstReadCanAwaitPersistedPreferences(); console.log(JSON.stringify({ status: 'pass', detail: `${results.length}/${results.length} 测试通过`, @@ -187,4 +244,4 @@ function main() { } } -main(); +await main(); diff --git a/developer/tests/js/dataBackupManagerRecords.test.js b/developer/tests/js/dataBackupManagerRecords.test.js deleted file mode 100644 index 6617d264..00000000 --- a/developer/tests/js/dataBackupManagerRecords.test.js +++ /dev/null @@ -1,691 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function loadScript(relativePath, context) { - const fullPath = path.join(repoRoot, relativePath); - const source = fs.readFileSync(fullPath, 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function clone(value) { - return value == null ? value : JSON.parse(JSON.stringify(value)); -} - -function createTestInterval(callback, delay) { - const timer = setInterval(callback, delay); - if (timer && typeof timer.unref === 'function') { - timer.unref(); - } - return timer; -} - -function createHarness() { - const records = [{ - id: 'existing-1', - examId: 'reading-p1', - title: 'Existing', - startTime: '2026-05-20T10:00:00.000Z', - endTime: '2026-05-20T10:10:00.000Z', - duration: 600, - score: 1, - totalQuestions: 1, - correctAnswers: 1 - }]; - const meta = new Map(); - let userStats = { totalPractices: 1 }; - const calls = []; - - const storage = { - async get(key, fallback = null) { - calls.push({ type: 'storage.get', key }); - return meta.has(key) ? clone(meta.get(key)) : clone(fallback); - }, - async set(key, value) { - calls.push({ type: 'storage.set', key, value: clone(value) }); - meta.set(key, clone(value)); - return true; - } - }; - - const quietConsole = { - log() {}, - warn() {}, - error() {}, - info() {}, - debug() {} - }; - const sandboxWindow = { - console: quietConsole, - PracticeRecordAPI: { - async list() { - calls.push({ type: 'api.list' }); - return clone(records); - }, - async replace(nextRecords, options = {}) { - calls.push({ type: 'api.replace', records: clone(nextRecords), options: clone(options) }); - records.splice( - 0, - records.length, - ...(Array.isArray(nextRecords) ? clone(nextRecords) : []) - ); - if (options.updateStats === true) { - await this.recalculateStats(); - } - return clone(records); - }, - async mergeRecords(nextRecords, options = {}) { - const incoming = Array.isArray(nextRecords) - ? nextRecords.map((record, index) => this.normalizeRecord(record, { index })).filter(Boolean) - : []; - const mergeMode = options.mergeMode || 'merge'; - if (mergeMode === 'replace') { - await this.replace(incoming, { updateStats: options.updateStats !== false }); - return { - importedCount: incoming.length, - updatedCount: 0, - skippedCount: 0, - finalCount: records.length, - records: clone(records) - }; - } - - const existing = await this.list(); - const byId = new Map(existing.map((record) => [String(record.id), record])); - let importedCount = 0; - let updatedCount = 0; - incoming.forEach((record) => { - const id = String(record.id); - if (byId.has(id)) { - byId.set(id, { ...byId.get(id), ...record }); - updatedCount += 1; - } else { - byId.set(id, record); - importedCount += 1; - } - }); - const merged = Array.from(byId.values()); - await this.replace(merged, { updateStats: options.updateStats !== false }); - return { - importedCount, - updatedCount, - skippedCount: 0, - finalCount: records.length, - records: clone(records) - }; - }, - async restoreRecords(nextRecords, options = {}) { - await this.replace(Array.isArray(nextRecords) ? nextRecords : [], { updateStats: false }); - if (options && options.stats && typeof options.stats === 'object') { - await this.resetStats(options.stats); - } else if (options.updateStats !== false) { - await this.recalculateStats(); - } - return { - restoredCount: records.length, - statsRestored: Boolean(options && options.stats && typeof options.stats === 'object') - }; - }, - prepareStats(stats) { - const source = stats && typeof stats === 'object' ? clone(stats) : {}; - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [], - ...source, - categoryStats: source.categoryStats && typeof source.categoryStats === 'object' ? clone(source.categoryStats) : {}, - questionTypeStats: source.questionTypeStats && typeof source.questionTypeStats === 'object' ? clone(source.questionTypeStats) : {}, - practiceDays: Array.isArray(source.practiceDays) ? source.practiceDays.slice() : [], - achievements: Array.isArray(source.achievements) ? source.achievements.slice() : [] - }; - }, - normalizeRecord(record, options = {}) { - const core = sandboxWindow.PracticeCore; - if (!core || !core.contracts || typeof core.contracts.standardizeRecord !== 'function') { - throw new Error('PracticeCore.contracts.standardizeRecord should be ready'); - } - return core.contracts.standardizeRecord(record, options); - }, - async readStats() { - calls.push({ type: 'api.readStats' }); - return clone(userStats); - }, - async mergeStats(stats, options = {}) { - calls.push({ type: 'api.mergeStats', stats: clone(stats), options: clone(options) }); - userStats = { ...userStats, ...clone(stats) }; - return clone(userStats); - }, - async resetStats(stats = null) { - calls.push({ type: 'api.resetStats', stats: clone(stats) }); - userStats = stats ? clone(stats) : {}; - return clone(userStats); - }, - async recalculateStats() { - calls.push({ type: 'api.recalculateStats' }); - userStats = { - totalPractices: records.length, - totalTimeSpent: records.reduce((sum, record) => sum + (Number(record.duration) || 0), 0) - }; - return clone(userStats); - } - }, - PracticeStore: { - async list() { - throw new Error('PracticeStore.list should not be called'); - }, - async replace() { - throw new Error('PracticeStore.replace should not be called'); - } - }, - simpleStorageWrapper: { - async getPracticeRecords() { - throw new Error('simpleStorageWrapper.getPracticeRecords should not be called'); - }, - async savePracticeRecords() { - throw new Error('simpleStorageWrapper.savePracticeRecords should not be called'); - } - }, - scoreStorage: { - async importData() { - throw new Error('scoreStorage.importData should not be called'); - }, - async getPracticeRecords() { - throw new Error('scoreStorage.getPracticeRecords should not be called'); - } - }, - practiceRecorder: { - async createBackup() { - throw new Error('practiceRecorder.createBackup should not be called'); - }, - async restoreBackup() { - throw new Error('practiceRecorder.restoreBackup should not be called'); - }, - scoreStorage: { - async importData() { - throw new Error('practiceRecorder.scoreStorage.importData should not be called'); - } - } - } - }; - - const sandbox = { - window: sandboxWindow, - storage, - console: quietConsole, - setInterval: createTestInterval, - clearInterval, - setTimeout, - clearTimeout, - Date, - Math, - JSON - }; - sandboxWindow.storage = storage; - sandbox.globalThis = sandbox.window; - const context = vm.createContext(sandbox); - loadScript('js/core/practiceCore.js', context); - sandboxWindow.PracticeCore.store = { - async listPracticeRecords() { - throw new Error('PracticeCore.store.listPracticeRecords should not be called'); - }, - async replacePracticeRecords() { - throw new Error('PracticeCore.store.replacePracticeRecords should not be called'); - } - }; - loadScript('js/core/backupAPI.js', context); - loadScript('js/utils/dataBackupManager.js', context); - - return { - DataBackupManager: sandbox.window.DataBackupManager, - records, - calls, - meta, - getUserStats: () => clone(userStats), - setUserStats: (nextStats) => { - userStats = clone(nextStats); - } - }; -} - -async function testImportUsesPracticeRecordApiOnly() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - await manager.importPracticeData({ - practice_records: [{ - id: 'incoming-1', - examId: 'reading-p2', - title: 'Incoming', - startTime: '2026-05-21T10:00:00.000Z', - endTime: '2026-05-21T10:05:00.000Z', - duration: 300, - score: 1, - totalQuestions: 2, - correctAnswers: 1 - }], - user_stats: { - totalPractices: 2 - } - }, { - createBackup: false - }); - - assert.deepStrictEqual(harness.records.map(record => record.id), ['existing-1', 'incoming-1']); - assert(harness.calls.some(call => call.type === 'api.list'), '导入合并前应通过 PracticeRecordAPI 读取现有记录'); - assert(harness.calls.some(call => call.type === 'api.replace'), '导入合并后应通过 PracticeRecordAPI 替换记录'); - assert.strictEqual( - harness.calls.some(call => call.type === 'scoreStorage.importData'), - false, - '导入不应绕到 ScoreStorage.importData' - ); - assert.strictEqual(harness.getUserStats().totalPractices, 2, '导入携带 user_stats 时仍应通过 PracticeRecordAPI 保留统计兼容'); - assert(harness.calls.some(call => call.type === 'api.mergeStats'), '导入携带 user_stats 时应通过 PracticeRecordAPI.mergeStats'); - assert.strictEqual( - harness.calls.some(call => call.key === 'user_stats'), - false, - 'DataBackupManager 不应直接读写 raw user_stats' - ); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testImportWithoutStatsRecalculatesThroughPracticeRecordApi() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - harness.setUserStats({ totalPractices: 99, totalTimeSpent: 9999 }); - - await manager.importPracticeData({ - practice_records: [{ - id: 'incoming-replace-1', - examId: 'reading-replace', - title: 'Incoming Replace', - startTime: '2026-05-23T10:00:00.000Z', - endTime: '2026-05-23T10:05:00.000Z', - duration: 300, - score: 1, - totalQuestions: 2, - correctAnswers: 1 - }] - }, { - createBackup: false, - mergeMode: 'replace' - }); - - assert.deepStrictEqual(harness.records.map(record => record.id), ['incoming-replace-1']); - assert( - harness.calls.some(call => call.type === 'api.replace' && call.options && call.options.updateStats === true), - '导入替换 records 时应要求 PracticeRecordAPI 同步统计' - ); - assert(harness.calls.some(call => call.type === 'api.recalculateStats'), '导入不带 user_stats 时应通过 PracticeRecordAPI 重算统计'); - assert.strictEqual(harness.getUserStats().totalPractices, 1, '导入替换后统计应跟随 canonical records'); - assert.strictEqual(harness.getUserStats().totalTimeSpent, 300, '导入替换后统计时长应按 canonical records 重算'); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testBackupRestoreUsesPracticeRecordApiOnly() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - const backupId = await manager.createPreImportBackup(); - assert(backupId, '应能创建预导入备份'); - - harness.records.splice(0, harness.records.length, { - id: 'mutated-1', - examId: 'reading-mutated', - title: 'Mutated', - startTime: '2026-05-22T10:00:00.000Z', - endTime: '2026-05-22T10:01:00.000Z', - duration: 60, - score: 0, - totalQuestions: 1, - correctAnswers: 0 - }); - harness.setUserStats({ totalPractices: 99 }); - - await manager.restoreBackup(backupId); - - assert.deepStrictEqual(harness.records.map(record => record.id), ['existing-1'], '恢复备份应通过 PracticeRecordAPI.replace 还原 records'); - assert.strictEqual(harness.getUserStats().totalPractices, 1, '恢复备份应通过 PracticeRecordAPI.resetStats 还原 user_stats'); - assert(harness.calls.some(call => call.type === 'api.readStats'), '创建备份应通过 PracticeRecordAPI.readStats'); - assert(harness.calls.some(call => call.type === 'api.resetStats'), '恢复备份应通过 PracticeRecordAPI.resetStats'); - assert.strictEqual( - harness.calls.some(call => call.key === 'user_stats'), - false, - '备份恢复链路不应直接读写 raw user_stats' - ); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testClearPracticeRecordsRecalculatesStats() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - harness.setUserStats({ totalPractices: 99, totalTimeSpent: 9999 }); - - const result = await manager.clearData({ - clearPracticeRecords: true, - clearUserStats: false, - createBackup: false - }); - - assert.deepStrictEqual(harness.records, [], '清空 records 应通过 PracticeRecordAPI.replace 清空 canonical records'); - assert( - harness.calls.some(call => call.type === 'api.replace' && call.options && call.options.updateStats === true), - '清空 records 但未显式清 stats 时应要求 PracticeRecordAPI 同步统计' - ); - assert(harness.calls.some(call => call.type === 'api.recalculateStats'), '清空 records 后应通过 PracticeRecordAPI 重算统计'); - assert.strictEqual(harness.getUserStats().totalPractices, 0, '清空 records 后 stats 不应保留旧 totalPractices'); - assert(result.clearedItems.includes('user_stats'), 'records 清空引发统计同步时应报告 user_stats 已更新'); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testNormalizeImportPayloadStatsUsesExplicitStatsKeyOnly() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - const normalized = manager.normalizeImportPayload({ - totalPractices: 99, - totalTimeSpent: 9999, - stats: { - totalPractices: 2, - totalTimeSpent: 300 - }, - practice_records: [{ - id: 'stats-root-guard', - examId: 'reading-stats-root', - startTime: '2026-05-24T10:00:00.000Z', - endTime: '2026-05-24T10:05:00.000Z', - duration: 300, - score: 1, - totalQuestions: 2, - correctAnswers: 1 - }] - }); - - assert.strictEqual(normalized.userStats.totalPractices, 2, '导入 stats 必须来自显式 stats/user_stats key,不能把根对象误判成 stats'); - assert.strictEqual(normalized.userStats.totalTimeSpent, 300); - assert.strictEqual(normalized.practiceRecords.length, 1, 'practice_records 仍应正常提取'); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testNormalizeRecordCanonicalCorrectAnswerMapWins() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - const normalized = manager.normalizeRecord({ - id: 'correct-map-conflict', - examId: 'reading-correct-map', - answers: { q1: 'A', q2: 'D', q3: 'E' }, - correctAnswerMap: { q1: 'A' }, - correctAnswers: { q1: 'B', q2: 'C', q3: 'F' }, - correctAnswersCount: 2, - scoreInfo: { correct: 3, total: 3 }, - realData: { - correctAnswerMap: { q2: 'D' }, - correctAnswers: { q3: 'E' } - } - }); - - assert.strictEqual(normalized.correctAnswers, 2, '对象型 correctAnswers 不能污染数字答对数'); - assert.strictEqual(normalized.correctAnswerMap.q1, 'A', '导入记录应优先使用 canonical correctAnswerMap'); - assert.strictEqual(normalized.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应先于 legacy correctAnswers 补缺'); - assert.strictEqual(normalized.correctAnswerMap.q3, 'F', '顶层 legacy correctAnswers 可作为补缺来源'); - assert.strictEqual(normalized.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical map'); - assert.strictEqual(normalized.realData.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应镜像 canonical map'); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testNormalizeRecordKeepsCoreAccuracyScale() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - const normalized = manager.normalizeRecord({ - id: 'accuracy-scale', - examId: 'reading-accuracy-scale', - startTime: '2026-05-24T10:00:00.000Z', - endTime: '2026-05-24T10:05:00.000Z', - durationSeconds: 300, - answers: { q1: 'A', q2: 'B' }, - correctAnswerMap: { q1: 'A', q2: 'C' }, - correctAnswers: 1, - totalQuestions: 2, - accuracy: 0.5, - percentage: 50 - }); - - assert.strictEqual(normalized.score, 1, 'DataBackupManager 不能把 percentage/accuracy 当成 score'); - assert.strictEqual(normalized.accuracy, 0.5, 'DataBackupManager 不能把 Core 的 0..1 accuracy 改成 0..100'); - assert.strictEqual(normalized.duration, 300, 'durationSeconds 外部别名应由 Core 标准化为秒'); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testNormalizeImportPayloadDoesNotDeepWalkNestedSnapshots() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - const normalized = manager.normalizeImportPayload({ - archive: { - nested: { - practice_records: [{ - id: 'nested-should-not-import', - examId: 'reading-nested', - startTime: '2026-05-24T10:00:00.000Z', - duration: 60 - }] - } - } - }); - - assert.strictEqual( - normalized.practiceRecords.length, - 0, - 'DataBackupManager 只能读取显式导入 schema,不能深挖任意嵌套 practice_records' - ); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testNormalizeImportPayloadDedupesDualSchemaAliases() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - const shared = [{ - id: 'dual-alias-1', - examId: 'reading-dual', - startTime: '2026-05-25T10:00:00.000Z', - endTime: '2026-05-25T10:05:00.000Z', - duration: 300, - score: 1, - totalQuestions: 2, - correctAnswers: 1 - }]; - const normalized = manager.normalizeImportPayload({ - practice_records: shared, - practiceRecords: shared, - data: { - practice_records: shared, - practiceRecords: shared - } - }); - - assert.strictEqual( - normalized.practiceRecords.length, - 1, - 'app 备份双 schema 别名不应在 replace 导入前拼成重复记录' - ); - assert.strictEqual(normalized.practiceRecords[0].id, 'dual-alias-1'); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testPreImportBackupKeepsNewestAndRestoresExamIndex() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - manager.maxBackupHistory = 2; - - try { - // 通过 storage 预置一条用户备份 + exam_index - harness.meta.set('manual_backups', [{ - id: 'user-manual-1', - timestamp: '2026-05-20T00:00:00.000Z', - type: 'manual', - data: { practice_records: [], user_stats: {} } - }]); - harness.meta.set('exam_index', [{ id: 'exam-A', title: 'A' }]); - - const backupId = await manager.createPreImportBackup(); - assert(backupId, '应能创建预导入备份'); - assert(String(backupId).startsWith('pre_import_'), 'pre_import 备份 id 应带前缀'); - - const backups = harness.meta.get('manual_backups'); - assert.strictEqual(backups.length, 2, '达到 max 时应裁剪到 maxBackupHistory'); - assert.strictEqual(backups[0].id, backupId, '新 pre_import 备份应 unshift 到头部(最新)'); - assert.strictEqual(backups[0].type, 'pre_import'); - assert.strictEqual(backups[1].id, 'user-manual-1', '应保留原有较新的用户备份,而不是误删头部'); - assert.ok( - backups[0].data.practice_records || backups[0].data.practiceRecords, - '备份应包含 practice_records' - ); - assert.ok( - Array.isArray(backups[0].data.exam_index) || Array.isArray(backups[0].data.examIndex), - '备份应包含 exam_index' - ); - - // 污染 exam_index 后恢复 - harness.meta.set('exam_index', [{ id: 'exam-B', title: 'B' }]); - harness.records.splice(0, harness.records.length, { - id: 'mutated-1', - examId: 'reading-mutated', - startTime: '2026-05-22T10:00:00.000Z', - endTime: '2026-05-22T10:01:00.000Z', - duration: 60, - score: 0, - totalQuestions: 1, - correctAnswers: 0 - }); - harness.setUserStats({ totalPractices: 99 }); - - await manager.restoreBackup(backupId); - - assert.deepStrictEqual(harness.records.map(r => r.id), ['existing-1'], '恢复应还原 records'); - assert.strictEqual(harness.getUserStats().totalPractices, 1, '恢复应还原 user_stats'); - const restoredIndex = harness.meta.get('exam_index'); - assert.strictEqual(restoredIndex[0].id, 'exam-A', '恢复应还原 exam_index'); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function testExportIncludeBackupsReadsFullManualList() { - const harness = createHarness(); - const manager = new harness.DataBackupManager(); - - try { - harness.meta.set('manual_backups', [ - { id: 'manual-1', type: 'manual', timestamp: '2026-05-21T00:00:00.000Z', data: {} }, - { id: 'pre-1', type: 'pre_import', timestamp: '2026-05-20T00:00:00.000Z', data: {} }, - { id: 'score-1', type: 'score_storage', timestamp: '2026-05-19T00:00:00.000Z', data: {} } - ]); - const result = await manager.exportPracticeRecords({ - includeBackups: true, - includeStats: false - }); - assert.strictEqual(result.mimeType, 'application/json'); - const parsed = JSON.parse(result.data); - assert.strictEqual(parsed.backups.length, 3, 'includeBackups 应导出全部 manual_backups,而非仅 score_storage'); - assert.deepStrictEqual( - parsed.backups.map(b => b.id).sort(), - ['manual-1', 'pre-1', 'score-1'] - ); - } finally { - if (manager.cleanupTimer) { - clearInterval(manager.cleanupTimer); - } - } -} - -async function main() { - try { - await testImportUsesPracticeRecordApiOnly(); - await testImportWithoutStatsRecalculatesThroughPracticeRecordApi(); - await testBackupRestoreUsesPracticeRecordApiOnly(); - await testClearPracticeRecordsRecalculatesStats(); - await testNormalizeImportPayloadStatsUsesExplicitStatsKeyOnly(); - await testNormalizeRecordCanonicalCorrectAnswerMapWins(); - await testNormalizeRecordKeepsCoreAccuracyScale(); - await testNormalizeImportPayloadDoesNotDeepWalkNestedSnapshots(); - await testNormalizeImportPayloadDedupesDualSchemaAliases(); - await testPreImportBackupKeepsNewestAndRestoresExamIndex(); - await testExportIncludeBackupsReadsFullManualList(); - process.stdout.write(JSON.stringify({ - status: 'pass', - detail: 'DataBackupManager 导入/合并/清空/备份恢复只走 PracticeRecordAPI 并同步统计,记录语义交给 Core 标准化;pre_import 裁剪、双 schema 去重与 exam_index/includeBackups 已覆盖' - })); - } catch (error) { - process.stdout.write(JSON.stringify({ - status: 'fail', - detail: error && error.stack ? error.stack : String(error) - })); - process.exit(1); - } -} - -main(); diff --git a/developer/tests/js/dataIntegrityManagerRegression.test.js b/developer/tests/js/dataIntegrityManagerRegression.test.js deleted file mode 100644 index 297c2d1e..00000000 --- a/developer/tests/js/dataIntegrityManagerRegression.test.js +++ /dev/null @@ -1,248 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function loadManagerModule() { - const source = fs.readFileSync(path.join(repoRoot, 'js/components/DataIntegrityManager.js'), 'utf8'); - const sandbox = { - console, - window: {}, - module: { exports: {} }, - exports: {}, - setInterval, - clearInterval, - Blob - }; - sandbox.globalThis = sandbox; - vm.runInContext(source, vm.createContext(sandbox), { filename: 'js/components/DataIntegrityManager.js' }); - return sandbox.module.exports; -} - -function createRepositories(options = {}) { - const { - initialPractice = [], - initialSettings = {}, - backupsById = new Map(), - failSettingsSaveOnce = false - } = options; - - const state = { - practice: initialPractice.map((record) => ({ ...record })), - settings: { ...initialSettings } - }; - let failNextSettingsSave = failSettingsSaveOnce; - const createdBackups = []; - - const repositories = { - practice: { - async list() { - return state.practice.map((record) => ({ ...record })); - }, - async overwrite(records) { - state.practice = Array.isArray(records) ? records.map((record) => ({ ...record })) : []; - return true; - } - }, - settings: { - async getAll() { - return { ...state.settings }; - }, - async saveAll(settings) { - if (failNextSettingsSave) { - failNextSettingsSave = false; - throw new Error('settings_save_failed'); - } - state.settings = { ...settings }; - return true; - } - }, - backups: { - async getById(id) { - return backupsById.get(id) || null; - }, - async add(backup) { - createdBackups.push(backup); - backupsById.set(backup.id, backup); - return backup; - }, - async list() { - return Array.from(backupsById.values()); - }, - async prune() { - return true; - } - }, - async transaction(_names, handler) { - return handler({ practice: repositories.practice, settings: repositories.settings }, {}); - } - }; - - return { repositories, state, createdBackups }; -} - -async function testRestoreBackupDoesNotWipePracticeRecordsForSettingsOnlyBackup() { - const { DataIntegrityManager } = loadManagerModule(); - const settingsOnlyBackup = { - id: 'backup-settings-only', - data: { - system_settings: { - language: 'zh-CN' - } - } - }; - const { repositories, state } = createRepositories({ - initialPractice: [{ id: 'record-1', type: 'practice', score: 1, date: '2026-01-01T00:00:00.000Z' }], - initialSettings: { theme: 'dark' }, - backupsById: new Map([[settingsOnlyBackup.id, settingsOnlyBackup]]) - }); - - const manager = new DataIntegrityManager({ registry: { onProvidersReady() { return () => {}; } } }); - manager.repositories = repositories; - manager.isInitialized = true; - manager.startAutoBackup = () => {}; - - await manager.restoreBackup(settingsOnlyBackup.id); - - assert.deepStrictEqual( - state.practice.map((record) => record.id), - ['record-1'], - 'settings-only backup restore 不应清空现有 practice records' - ); - assert.deepStrictEqual( - state.settings, - { theme: 'dark', language: 'zh-CN' }, - 'settings-only backup restore 应合并设置而不是覆盖空 practice 数据' - ); -} - -async function testImportDataRestoresPreImportBackupAfterPartialFailure() { - const { DataIntegrityManager } = loadManagerModule(); - const { repositories, state, createdBackups } = createRepositories({ - initialPractice: [{ id: 'record-old', type: 'practice', score: 2, date: '2026-01-01T00:00:00.000Z' }], - initialSettings: { theme: 'dark' }, - failSettingsSaveOnce: true - }); - - const manager = new DataIntegrityManager({ registry: { onProvidersReady() { return () => {}; } } }); - manager.repositories = repositories; - manager.isInitialized = true; - manager.startAutoBackup = () => {}; - - let failed = false; - try { - await manager.importData({ - data: { - practice_records: [ - { id: 'record-new', examId: 'reading-p1', title: 'Imported', type: 'practice', score: 3, totalQuestions: 3, correctAnswers: 3, date: '2026-02-01T00:00:00.000Z', startTime: '2026-02-01T00:00:00.000Z', endTime: '2026-02-01T00:05:00.000Z', duration: 300 } - ], - system_settings: { - language: 'fr-FR' - } - } - }); - } catch (error) { - failed = true; - assert.match(String(error.message || error), /settings_save_failed/, '导入失败应保留原始错误'); - } - - assert.strictEqual(failed, true, 'settings 保存失败时 importData 应抛错'); - assert.ok(createdBackups.length >= 1, '导入前应创建 pre_import 备份'); - assert.deepStrictEqual( - state.practice.map((record) => record.id), - ['record-old'], - '导入部分失败后应恢复原有 practice records,不能停在半导入状态' - ); - assert.deepStrictEqual( - { - theme: state.settings.theme, - language: state.settings.language, - autoSave: state.settings.autoSave, - notifications: state.settings.notifications - }, - { - theme: 'dark', - language: undefined, - autoSave: undefined, - notifications: undefined - }, - '导入部分失败后应恢复原有 settings' - ); -} - -async function testRestoreBackupRollsBackWhenSettingsSaveFailsAfterPracticeOverwrite() { - const { DataIntegrityManager } = loadManagerModule(); - const restoreTargetBackup = { - id: 'backup-restore-target', - data: { - practice_records: [{ id: 'record-new', type: 'practice', score: 5, date: '2026-02-01T00:00:00.000Z' }], - system_settings: { language: 'ja-JP' } - } - }; - const { repositories, state } = createRepositories({ - initialPractice: [{ id: 'record-old', type: 'practice', score: 2, date: '2026-01-01T00:00:00.000Z' }], - initialSettings: { theme: 'dark' }, - backupsById: new Map([[restoreTargetBackup.id, restoreTargetBackup]]), - failSettingsSaveOnce: true - }); - - const manager = new DataIntegrityManager({ registry: { onProvidersReady() { return () => {}; } } }); - manager.repositories = repositories; - manager.isInitialized = true; - manager.startAutoBackup = () => {}; - - let failed = false; - try { - await manager.restoreBackup(restoreTargetBackup.id); - } catch (error) { - failed = true; - assert.match(String(error.message || error), /settings_save_failed/, 'restoreBackup 应暴露原始 settings 写入错误'); - } - - assert.strictEqual(failed, true, 'restoreBackup 在 settings 保存失败时应抛错'); - assert.deepStrictEqual( - state.practice.map((record) => record.id), - ['record-old'], - 'restoreBackup 部分失败后应回滚 practice records' - ); - assert.deepStrictEqual( - { - theme: state.settings.theme, - language: state.settings.language, - autoSave: state.settings.autoSave, - notifications: state.settings.notifications - }, - { - theme: 'dark', - language: undefined, - autoSave: undefined, - notifications: undefined - }, - 'restoreBackup 部分失败后应回滚 settings' - ); -} - -async function main() { - try { - await testRestoreBackupDoesNotWipePracticeRecordsForSettingsOnlyBackup(); - await testImportDataRestoresPreImportBackupAfterPartialFailure(); - await testRestoreBackupRollsBackWhenSettingsSaveFailsAfterPracticeOverwrite(); - console.log(JSON.stringify({ - status: 'pass', - detail: 'data integrity manager backup/rollback regressions covered' - }, null, 2)); - } catch (error) { - console.log(JSON.stringify({ - status: 'fail', - detail: error.message - }, null, 2)); - process.exit(1); - } -} - -main(); diff --git a/developer/tests/js/dataIntegrityRecords.test.js b/developer/tests/js/dataIntegrityRecords.test.js deleted file mode 100644 index cfcd8561..00000000 --- a/developer/tests/js/dataIntegrityRecords.test.js +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function loadScript(relativePath, context) { - const fullPath = path.join(repoRoot, relativePath); - const source = fs.readFileSync(fullPath, 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function clone(value) { - return value == null ? value : JSON.parse(JSON.stringify(value)); -} - -function createHarness() { - const practiceState = []; - const metaState = new Map(); - const settingsState = {}; - const backupState = new Map([ - ['backup-1', { - id: 'backup-1', - data: { - practice_records: [{ - id: 'restored-1', - examId: 'reading-p1', - type: 'reading', - date: '2026-05-20T10:00:00.000Z', - startTime: '2026-05-20T09:50:00.000Z', - endTime: '2026-05-20T10:00:00.000Z', - duration: 600, - score: 8, - totalQuestions: 10, - correctAnswers: 8, - accuracy: 0.8, - metadata: { category: 'P1' } - }] - } - }] - ]); - const stateRecords = []; - - const repositories = { - async runConsistencyChecks() { - return { ok: true }; - }, - practice: { - async list() { - return clone(practiceState); - }, - async overwrite(records) { - practiceState.splice(0, practiceState.length, ...(Array.isArray(records) ? clone(records) : [])); - return true; - } - }, - backups: { - async list() { - return Array.from(backupState.values()).map(clone); - }, - async getById(id) { - return clone(backupState.get(id) || null); - }, - async add(backup) { - backupState.set(backup.id, clone(backup)); - return true; - }, - async prune() { - return true; - } - }, - settings: { - async getAll() { - return clone(settingsState); - }, - async saveAll(next) { - Object.assign(settingsState, clone(next)); - return true; - } - }, - meta: { - async get(key, fallback = null) { - return metaState.has(key) ? clone(metaState.get(key)) : clone(fallback); - }, - async set(key, value) { - metaState.set(key, clone(value)); - return true; - } - } - }; - - const quietConsole = { - log() {}, - warn() {}, - error() {}, - info() {}, - debug() {} - }; - const publicRepositories = { - async runConsistencyChecks() { - return repositories.runConsistencyChecks(); - }, - backups: repositories.backups, - settings: repositories.settings, - meta: repositories.meta - }; - const sandboxWindow = { - console: quietConsole, - dataRepositories: publicRepositories, - practiceRecords: [], - app: { state: { practice: { records: [] } } }, - setPracticeRecordsState(records) { - stateRecords.splice(0, stateRecords.length, ...(Array.isArray(records) ? clone(records) : [])); - return clone(stateRecords); - } - }; - - const sandbox = { - window: sandboxWindow, - console: quietConsole, - setInterval, - clearInterval, - setTimeout, - clearTimeout, - Date, - Math, - JSON, - TextDecoder - }; - sandbox.globalThis = sandbox.window; - const context = vm.createContext(sandbox); - loadScript('js/core/practiceCore.js', context); - sandbox.window.PracticeCore.__installInternalRepositories(repositories); - loadScript('js/core/practiceRecordAPI.js', context); - loadScript('js/core/practiceStore.js', context); - loadScript('js/components/DataIntegrityManager.js', context); - - return { - DataIntegrityManager: sandbox.window.DataIntegrityManager, - repositories, - practiceState, - metaState, - stateRecords, - app: sandbox.window.app - }; -} - -async function testRestoreRecalculatesStatsAndSyncsState() { - const harness = createHarness(); - const manager = new harness.DataIntegrityManager(); - manager.stopAutoBackup(); - - await manager.restoreBackup('backup-1'); - manager.stopAutoBackup(); - - assert.deepStrictEqual(harness.practiceState.map(record => record.id), ['restored-1']); - assert.deepStrictEqual(harness.stateRecords.map(record => record.id), ['restored-1'], '统一 API 替换后应同步 window state'); - assert.deepStrictEqual(harness.app.state.practice.records.map(record => record.id), ['restored-1'], '统一 API 替换后应同步 app.state'); - - const stats = harness.metaState.get('user_stats'); - assert(stats, '恢复记录后应写入 user_stats'); - assert.strictEqual(stats.totalPractices, 1, '未携带 stats 的备份应按记录重算 totalPractices'); - assert.strictEqual(stats.totalTimeSpent, 600, '未携带 stats 的备份应按记录重算 totalTimeSpent'); - assert.strictEqual(stats.averageScore, 0.8, '未携带 stats 的备份应按记录重算 averageScore'); -} - -async function testImportUsesProvidedStats() { - const harness = createHarness(); - const manager = new harness.DataIntegrityManager(); - manager.stopAutoBackup(); - - await manager.importData({ - data: { - practice_records: [{ - id: 'imported-1', - examId: 'reading-p2', - type: 'reading', - date: '2026-05-21T10:00:00.000Z', - startTime: '2026-05-21T09:55:00.000Z', - endTime: '2026-05-21T10:00:00.000Z', - duration: 300, - score: 1, - totalQuestions: 2, - correctAnswers: 1, - accuracy: 0.5 - }], - user_stats: { - totalPractices: 9, - totalTimeSpent: 1234, - averageScore: 0.77, - categoryStats: {}, - questionTypeStats: {}, - practiceDays: ['2026-05-21'], - achievements: ['imported'] - } - } - }); - manager.stopAutoBackup(); - - assert.deepStrictEqual(harness.practiceState.map(record => record.id), ['imported-1']); - const stats = harness.metaState.get('user_stats'); - assert.strictEqual(stats.totalPractices, 9, '导入文件携带 user_stats 时应优先使用导入统计'); - assert.strictEqual(stats.totalTimeSpent, 1234); - assert.deepStrictEqual(stats.achievements, ['imported']); -} - -async function testImportCanonicalCorrectAnswerMapWins() { - const harness = createHarness(); - const manager = new harness.DataIntegrityManager(); - manager.stopAutoBackup(); - - await manager.importData({ - data: { - practice_records: [{ - id: 'imported-correct-map', - examId: 'reading-correct-map', - type: 'reading', - date: '2026-05-22T10:00:00.000Z', - startTime: '2026-05-22T09:55:00.000Z', - endTime: '2026-05-22T10:00:00.000Z', - duration: 300, - answers: { q1: 'A', q2: 'D', q3: 'E' }, - correctAnswerMap: { q1: 'A' }, - correctAnswers: { q1: 'B', q2: 'C', q3: 'F' }, - correctAnswersCount: 2, - scoreInfo: { correct: 3, total: 3 }, - realData: { - correctAnswerMap: { q2: 'D' }, - correctAnswers: { q3: 'E' } - } - }] - } - }); - manager.stopAutoBackup(); - - assert.deepStrictEqual(harness.practiceState.map(record => record.id), ['imported-correct-map']); - const imported = harness.practiceState[0]; - assert.strictEqual(imported.correctAnswers, 2, '对象型 correctAnswers 不能污染数字答对数字段'); - assert.strictEqual(imported.correctAnswerMap.q1, 'A', 'DataIntegrity 导入应优先使用 canonical correctAnswerMap'); - assert.strictEqual(imported.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应先于 legacy correctAnswers 补缺'); - assert.strictEqual(imported.correctAnswerMap.q3, 'F', '顶层 legacy correctAnswers 只作为补缺来源'); - assert.strictEqual(imported.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical map'); - assert.strictEqual(imported.realData.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应镜像 canonical map'); -} - -async function testImportDoesNotNormalizeRecordsLocally() { - const harness = createHarness(); - const manager = new harness.DataIntegrityManager(); - manager.stopAutoBackup(); - - await manager.importData({ - data: { - practice_records: [{ - id: 'imported-core-only', - exam_id: 'reading-core-only', - title: 'Core Only Import', - start_time: '2026-05-23T09:55:00.000Z', - completedAt: '2026-05-23T10:00:00.000Z', - durationSeconds: 300, - answers: { q1: 'A', q2: 'B' }, - correctAnswerMap: { q1: 'A', q2: 'C' }, - correctAnswers: 1, - totalQuestions: 2, - percentage: 50, - accuracy: 0.5 - }] - } - }); - manager.stopAutoBackup(); - - const imported = harness.practiceState[0]; - assert.strictEqual(imported.id, 'imported-core-only'); - assert.strictEqual(imported.examId, 'reading-core-only', '外部 exam_id 别名应由 Core/API 统一标准化'); - assert.strictEqual(imported.startTime, '2026-05-23T09:55:00.000Z'); - assert.strictEqual(imported.endTime, '2026-05-23T10:00:00.000Z'); - assert.strictEqual(imported.duration, 300); - assert.strictEqual(imported.score, 1, 'DataIntegrity 不能把 percentage/accuracy 当成 score'); - assert.strictEqual(imported.accuracy, 0.5, 'DataIntegrity 不能把 0..1 accuracy 改成百分数'); -} - -async function main() { - try { - await testRestoreRecalculatesStatsAndSyncsState(); - await testImportUsesProvidedStats(); - await testImportCanonicalCorrectAnswerMapWins(); - await testImportDoesNotNormalizeRecordsLocally(); - process.stdout.write(JSON.stringify({ - status: 'pass', - detail: 'DataIntegrityManager 替换记录后同步 app/window state,同步或重算 user_stats,记录语义交给 Core/API' - })); - } catch (error) { - process.stdout.write(JSON.stringify({ - status: 'fail', - detail: error && error.stack ? error.stack : String(error) - })); - process.exit(1); - } -} - -main(); diff --git a/developer/tests/js/dataIntegrityValidator.js b/developer/tests/js/dataIntegrityValidator.js deleted file mode 100644 index 60873eaa..00000000 --- a/developer/tests/js/dataIntegrityValidator.js +++ /dev/null @@ -1,458 +0,0 @@ -/** - * 数据完整性验证器 - * 验证序列化/反序列化过程的数据完整性 - */ - -class DataIntegrityValidator { - constructor() { - this.validationResults = []; - this.testCases = this.createTestCases(); - } - - // 创建测试用例 - createTestCases() { - return { - // 基础类型测试 - basicTypes: { - simpleSet: new Set([1, 2, 3]), - simpleMap: new Map([['a', 1], ['b', 2]]), - emptySet: new Set(), - emptyMap: new Map(), - mixedSet: new Set(['string', 42, true, null]), - mixedMap: new Map([['key1', 'value1'], [42, 'number key'], [null, 'null key']]) - }, - - // 复杂嵌套测试 - complexNesting: { - level1: { - level2: { - level3: { - deepSet: new Set(['deep1', 'deep2', 'deep3']), - deepMap: new Map([['deepKey1', { nested: 'object' }]]) - } - }, - arrayWithCollections: [ - new Set(['set in array']), - new Map([['map', 'in array']]), - { normal: 'object' } - ] - } - }, - - // 实际应用状态模拟 - appStateSimulation: { - exam: { - index: ['exam1', 'exam2', 'exam3'], - currentCategory: 'reading', - currentExamType: 'academic', - filteredExams: ['exam1', 'exam3'], - configurations: new Map([ - ['exam1', { timeLimit: 60, difficulty: 'medium' }], - ['exam2', { timeLimit: 80, difficulty: 'hard' }] - ]) - }, - practice: { - records: [ - { id: 'r1', score: 85, type: 'reading', date: '2024-01-01' }, - { id: 'r2', score: 92, type: 'listening', date: '2024-01-02' } - ], - selectedRecords: new Set(['r1', 'r2']), - bulkDeleteMode: false - }, - ui: { - browseFilter: { category: 'reading', type: 'academic' }, - loading: false, - currentVirtualScroller: null - }, - system: { - processedSessions: new Set(['session1', 'session2', 'session3']), - fallbackExamSessions: new Map([ - ['fallback1', { examId: 'exam1', data: 'backup1' }], - ['fallback2', { examId: 'exam2', data: 'backup2' }] - ]), - failedScripts: new Set(['script1']) - } - }, - - // 边界情况测试 - edgeCases: { - largeSet: new Set(Array.from({ length: 1000 }, (_, i) => `item${i}`)), - largeMap: new Map(Array.from({ length: 1000 }, (_, i) => [`key${i}`, { data: `value${i}` }])), - specialChars: new Set(['特殊字符', 'emoji 🚀', 'quotes "test"', "apostrophe 'test'"]), - unicodeKeys: new Map([['键1', '值1'], ['🔑', '🔒'], ['café', 'résumé']]), - cyclicReference: null // 将在运行时设置 - } - }; - } - - // 设置循环引用测试 - setupCyclicReference() { - const obj = { name: 'cyclic' }; - obj.self = obj; - this.testCases.edgeCases.cyclicReference = { - container: obj, - normalSet: new Set([1, 2, 3]) - }; - } - - // 运行所有验证测试 - async runAllValidations() { - console.log('🔍 开始数据完整性验证...'); - - this.validationResults = []; - - // 设置循环引用测试 - this.setupCyclicReference(); - - // 基础类型验证 - await this.validateBasicTypes(); - - // 复杂嵌套验证 - await this.validateComplexNesting(); - - // 应用状态验证 - await this.validateAppState(); - - // 边界情况验证 - await this.validateEdgeCases(); - - // 性能测试 - await this.validatePerformance(); - - // 存储往返测试 - await this.validateStorageRoundTrip(); - - this.printValidationResults(); - return this.validationResults; - } - - // 验证基础类型 - async validateBasicTypes() { - const category = '基础类型验证'; - - for (const [name, value] of Object.entries(this.testCases.basicTypes)) { - const result = await this.validateSingleValue(name, value, category); - this.validationResults.push(result); - } - } - - // 验证复杂嵌套 - async validateComplexNesting() { - const category = '复杂嵌套验证'; - - for (const [name, value] of Object.entries(this.testCases.complexNesting)) { - const result = await this.validateSingleValue(name, value, category); - this.validationResults.push(result); - } - } - - // 验证应用状态 - async validateAppState() { - const category = '应用状态验证'; - - const result = await this.validateSingleValue('appState', this.testCases.appStateSimulation, category); - this.validationResults.push(result); - - // 详细验证应用状态的各个部分 - const detailedChecks = [ - { path: 'exam.configurations', value: this.testCases.appStateSimulation.exam.configurations }, - { path: 'practice.selectedRecords', value: this.testCases.appStateSimulation.practice.selectedRecords }, - { path: 'system.processedSessions', value: this.testCases.appStateSimulation.system.processedSessions }, - { path: 'system.fallbackExamSessions', value: this.testCases.appStateSimulation.system.fallbackExamSessions } - ]; - - for (const check of detailedChecks) { - const detailedResult = await this.validateSingleValue(check.path, check.value, `${category} - 详细检查`); - this.validationResults.push(detailedResult); - } - } - - // 验证边界情况 - async validateEdgeCases() { - const category = '边界情况验证'; - - for (const [name, value] of Object.entries(this.testCases.edgeCases)) { - const result = await this.validateSingleValue(name, value, category); - this.validationResults.push(result); - } - } - - // 验证性能 - async validatePerformance() { - const category = '性能验证'; - - // 测试大Set的序列化性能 - const largeSet = this.testCases.edgeCases.largeSet; - const iterations = 10; - - const startTime = performance.now(); - - for (let i = 0; i < iterations; i++) { - const serialized = StateSerializer.serialize(largeSet); - const deserialized = StateSerializer.deserialize(serialized); - } - - const endTime = performance.now(); - const avgTime = (endTime - startTime) / iterations; - - this.validationResults.push({ - category, - name: '大Set序列化性能', - passed: avgTime < 100, // 100ms阈值 - details: { - iterations, - totalTime: endTime - startTime, - avgTime: avgTime.toFixed(2), - setSize: largeSet.size - } - }); - - // 测试大Map的序列化性能 - const largeMap = this.testCases.edgeCases.largeMap; - const mapStartTime = performance.now(); - - for (let i = 0; i < iterations; i++) { - const serialized = StateSerializer.serialize(largeMap); - const deserialized = StateSerializer.deserialize(serialized); - } - - const mapEndTime = performance.now(); - const mapAvgTime = (mapEndTime - mapStartTime) / iterations; - - this.validationResults.push({ - category, - name: '大Map序列化性能', - passed: mapAvgTime < 100, // 100ms阈值 - details: { - iterations, - totalTime: mapEndTime - mapStartTime, - avgTime: mapAvgTime.toFixed(2), - mapSize: largeMap.size - } - }); - } - - // 验证存储往返 - async validateStorageRoundTrip() { - const category = '存储往返验证'; - - const testData = this.testCases.appStateSimulation; - - try { - // 序列化并存储 - const serialized = StateSerializer.serialize(testData); - await storage.set('integrity_test_data', serialized); - - // 从存储读取并反序列化 - const stored = await storage.get('integrity_test_data'); - const deserialized = StateSerializer.deserialize(stored); - - // 验证数据完整性 - const isIntact = this.validateDataEquality(testData, deserialized); - - // 清理测试数据 - await storage.remove('integrity_test_data'); - - this.validationResults.push({ - category, - name: 'LocalStorage往返', - passed: isIntact, - details: { - storageSuccess: true, - dataIntegrity: isIntact, - originalSize: JSON.stringify(serialized).length, - restoredSize: JSON.stringify(stored).length - } - }); - - } catch (error) { - this.validationResults.push({ - category, - name: 'LocalStorage往返', - passed: false, - details: { error: error.message } - }); - } - } - - // 验证单个值 - async validateSingleValue(name, value, category) { - try { - const startTime = performance.now(); - - // 序列化 - const serialized = StateSerializer.serialize(value); - - // 反序列化 - const deserialized = StateSerializer.deserialize(serialized); - - const endTime = performance.now(); - - // 验证数据相等性 - const isEqual = this.validateDataEquality(value, deserialized); - - return { - category, - name, - passed: isEqual, - details: { - processingTime: (endTime - startTime).toFixed(2), - serializedSize: JSON.stringify(serialized).length, - type: value?.constructor?.name || typeof value, - ...(value instanceof Set && { setSize: value.size }), - ...(value instanceof Map && { mapSize: value.size }) - } - }; - - } catch (error) { - return { - category, - name, - passed: false, - details: { error: error.message } - }; - } - } - - // 验证数据相等性 - validateDataEquality(original, restored) { - // 处理null/undefined - if (original === null || original === undefined) { - return original === restored; - } - - // 处理Set - if (original instanceof Set) { - if (!(restored instanceof Set) || original.size !== restored.size) { - return false; - } - const origArray = Array.from(original).sort(); - const restArray = Array.from(restored).sort(); - return JSON.stringify(origArray) === JSON.stringify(restArray); - } - - // 处理Map - if (original instanceof Map) { - if (!(restored instanceof Map) || original.size !== restored.size) { - return false; - } - const origArray = Array.from(original.entries()).sort(); - const restArray = Array.from(restored.entries()).sort(); - return JSON.stringify(origArray) === JSON.stringify(restArray); - } - - // 处理Date - if (original instanceof Date) { - return restored instanceof Date && original.getTime() === restored.getTime(); - } - - // 处理数组 - if (Array.isArray(original)) { - if (!Array.isArray(restored) || original.length !== restored.length) { - return false; - } - return original.every((item, index) => this.validateDataEquality(item, restored[index])); - } - - // 处理普通对象 - if (typeof original === 'object') { - if (typeof restored !== 'object' || original === null || restored === null) { - return false; - } - - const origKeys = Object.keys(original).sort(); - const restKeys = Object.keys(restored).sort(); - - if (JSON.stringify(origKeys) !== JSON.stringify(restKeys)) { - return false; - } - - return origKeys.every(key => this.validateDataEquality(original[key], restored[key])); - } - - // 处理基本类型 - return original === restored; - } - - // 打印验证结果 - printValidationResults() { - const totalTests = this.validationResults.length; - const passedTests = this.validationResults.filter(r => r.passed).length; - const failedTests = totalTests - passedTests; - - console.log('\n📊 数据完整性验证结果:'); - console.log(`总验证数: ${totalTests}`); - console.log(`通过: ${passedTests} ✅`); - console.log(`失败: ${failedTests} ❌`); - console.log(`成功率: ${((passedTests / totalTests) * 100).toFixed(1)}%`); - - // 按分类显示结果 - const categories = [...new Set(this.validationResults.map(r => r.category))]; - - categories.forEach(category => { - const categoryResults = this.validationResults.filter(r => r.category === category); - const categoryPassed = categoryResults.filter(r => r.passed).length; - const categoryTotal = categoryResults.length; - - console.log(`\n📁 ${category}: ${categoryPassed}/${categoryTotal} 通过`); - - const failedInCategory = categoryResults.filter(r => !r.passed); - if (failedInCategory.length > 0) { - console.log(' ❌ 失败项目:'); - failedInCategory.forEach(r => { - console.log(` - ${r.name}: ${r.details.error || '数据不匹配'}`); - }); - } - }); - - // 性能汇总 - const performanceResults = this.validationResults.filter(r => - r.category === '性能验证' && r.details.avgTime - ); - - if (performanceResults.length > 0) { - console.log('\n⚡ 性能汇总:'); - performanceResults.forEach(r => { - console.log(` ${r.name}: ${r.details.avgTime}ms`); - }); - } - } - - // 生成验证报告 - generateReport() { - const totalTests = this.validationResults.length; - const passedTests = this.validationResults.filter(r => r.passed).length; - const successRate = ((passedTests / totalTests) * 100).toFixed(1); - - return { - summary: { - totalTests, - passedTests, - failedTests: totalTests - passedTests, - successRate: `${successRate}%`, - timestamp: new Date().toISOString() - }, - categories: [...new Set(this.validationResults.map(r => r.category))].map(category => { - const categoryResults = this.validationResults.filter(r => r.category === category); - const passed = categoryResults.filter(r => r.passed).length; - return { - name: category, - total: categoryResults.length, - passed, - failed: categoryResults.length - passed, - successRate: `${((passed / categoryResults.length) * 100).toFixed(1)}%` - }; - }), - failedTests: this.validationResults.filter(r => !r.passed).map(r => ({ - category: r.category, - name: r.name, - error: r.details.error || '数据不匹配' - })) - }; - } -} - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = DataIntegrityValidator; -} \ No newline at end of file diff --git a/developer/tests/js/dataKernelV2.test.js b/developer/tests/js/dataKernelV2.test.js new file mode 100644 index 00000000..086bf3cd --- /dev/null +++ b/developer/tests/js/dataKernelV2.test.js @@ -0,0 +1,562 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const catalogSource = fs.readFileSync(path.join(repoRoot, 'js/data/v2/dataCatalog.js'), 'utf8'); +const kernelSource = fs.readFileSync(path.join(repoRoot, 'js/data/v2/dataKernel.js'), 'utf8'); +const recordSource = fs.readFileSync(path.join(repoRoot, 'js/data/practiceRecordSource.js'), 'utf8'); +const appDataSource = fs.readFileSync(path.join(repoRoot, 'js/data/v2/appData.js'), 'utf8'); + +function withTimeout(promise, label, timeoutMs = 15000) { + let timer; + return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); })]) + .finally(() => clearTimeout(timer)); +} +async function origin() { + const server = http.createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + response.end(''); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + return { server, url: `http://127.0.0.1:${server.address().port}/` }; +} +function launchOptions() { + const candidates = [ + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' + ]; + const executablePath = candidates.find((candidate) => fs.existsSync(candidate)); + return executablePath ? { headless: true, executablePath } : { headless: true }; +} +async function loadAppData(page) { + await page.addScriptTag({ content: catalogSource }); + await page.addScriptTag({ content: kernelSource }); + await page.addScriptTag({ content: recordSource }); + await page.addScriptTag({ content: appDataSource }); + await page.evaluate(async () => window.AppData.ready); +} +async function persistedBusinessState(page) { + return page.evaluate(() => new Promise((resolve, reject) => { + const request = indexedDB.open('IELTSAtlasDataV2'); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const stores = ['documents', 'practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const tx = db.transaction(stores, 'readonly'); + const reads = Object.fromEntries(stores.map((store) => [store, tx.objectStore(store).getAll()])); + tx.onerror = () => { db.close(); reject(tx.error); }; + tx.onabort = () => { db.close(); reject(tx.error || new Error('business state read aborted')); }; + tx.oncomplete = () => { + const result = {}; + for (const [store, read] of Object.entries(reads)) { + result[store] = Object.fromEntries(read.result.map((row) => [ + row.logicalKey || row.recordId, + { revision: row.revision, checksum: row.checksum } + ])); + } + db.close(); + resolve(result); + }; + }; + })); +} + +async function main() { + const { server, url } = await origin(); + let browser; + try { + browser = await withTimeout(chromium.launch(launchOptions()), 'launch'); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(url); + await page.evaluate(() => new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase('IELTSAtlasDataV2'); + request.onsuccess = resolve; request.onerror = () => reject(request.error); request.onblocked = () => reject(new Error('database reset blocked')); + })); + await page.evaluate(() => new Promise((resolve, reject) => { + const request = indexedDB.open('IELTSAtlasDataV2', 1); + request.onupgradeneeded = () => { + for (const store of ['authoritative', 'derived', 'system']) request.result.createObjectStore(store, { keyPath: 'logicalKey' }); + }; + request.onsuccess = () => { request.result.close(); resolve(); }; + request.onerror = () => reject(request.error); + })); + await page.evaluate(() => new Promise((resolve, reject) => { + const remove = indexedDB.deleteDatabase('ExamSystemDB'); + remove.onerror = () => reject(remove.error); + remove.onblocked = () => reject(new Error('legacy database reset blocked')); + remove.onsuccess = () => { + const request = indexedDB.open('ExamSystemDB', 1); + request.onupgradeneeded = () => request.result.createObjectStore('keyValueStore', { keyPath: 'key' }); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('keyValueStore', 'readwrite'); + const store = tx.objectStore('keyValueStore'); + const stored = (data, timestamp = Date.now()) => JSON.stringify({ data, timestamp, version: '1.0.0', compressed: false }); + [ + ['exam_system_practice_records', stored([{ id: 'legacy-idb-record', correctAnswers: 1 }])], + ['exam_system_settings', stored({ theme: 'light', notifications: true }, 100)], + ['exam_system_exam_index_configurations', stored([{ id: 'exam_index_1700000000000', key: 'exam_index_1700000000000' }])], + ['exam_system_active_exam_index_key', stored('exam_index_1700000000000')], + ['exam_system_exam_index_1700000000000', stored([{ id: 'legacy-custom-exam', type: 'reading' }])] + ].forEach(([key, value]) => store.put({ key, value, timestamp: key === 'exam_system_settings' ? 100 : Date.now() })); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error || new Error('legacy seed aborted')); + }; + }; + })); + await page.evaluate(() => { + localStorage.setItem('practice_records', JSON.stringify([{ id: 'legacy-web-record', correctAnswers: 0 }])); + localStorage.setItem('vocab_user_config', JSON.stringify({ dailyNew: 12 })); + localStorage.setItem('user_achievements', JSON.stringify({ first_practice: { unlockedAt: '2026-01-01T00:00:00.000Z' } })); + localStorage.setItem('theme', 'legacy-unprefixed-theme-must-be-rejected'); + localStorage.setItem('exam_system_settings', JSON.stringify({ data: { theme: 'dark', notifications: true, source: 'local-newer' }, timestamp: 200, version: '1.0.0', compressed: false })); + sessionStorage.setItem('exam_system_settings', '{malformed-newer-candidate'); + }); + await page.addScriptTag({ content: catalogSource }); + await page.addScriptTag({ content: kernelSource }); + const result = await page.evaluate(async () => { + const { DataKernel, readLegacyValues, readLegacyExternalBackup } = window.__AppDataV2Internals; + const legacyValues = await readLegacyValues(); + const webOnlyLegacyValues = await readLegacyValues(null, localStorage, sessionStorage); + const externalReadCalls = []; + const externalPayload = { practice_records: [{ id: 'legacy-external-record' }] }; + const legacyDirectory = { + async queryPermission(options) { + externalReadCalls.push(`permission:${options.mode}`); + return 'granted'; + }, + async getFileHandle(filename, options) { + externalReadCalls.push(`file:${filename}:${options.create}`); + return { async getFile() { return { async text() { return JSON.stringify({ data: externalPayload }); } }; } }; + } + }; + const fakeLegacyIndexedDb = (directoryHandle) => ({ + open(name) { + externalReadCalls.push(`open:${name}`); + const request = {}; + queueMicrotask(() => { + request.result = { + objectStoreNames: { contains(storeName) { return storeName === 'handles'; } }, + transaction() { + return { objectStore() { return { get(key) { + externalReadCalls.push(`get:${key}`); + const getRequest = {}; + queueMicrotask(() => { getRequest.result = directoryHandle; getRequest.onsuccess(); }); + return getRequest; + } }; } }; + }, + close() { externalReadCalls.push('close'); } + }; + request.onsuccess(); + }); + return request; + } + }); + const externalBackup = await readLegacyExternalBackup(fakeLegacyIndexedDb(legacyDirectory)); + let promptFileRead = false; + const promptBackup = await readLegacyExternalBackup(fakeLegacyIndexedDb({ + async queryPermission() { return 'prompt'; }, + async getFileHandle() { promptFileRead = true; } + })); + const unavailable = new DataKernel({ indexedDB: null }); + let unavailableCode = null; + try { await unavailable.initialize(); } catch (error) { unavailableCode = error.code; } + + const kernel = new DataKernel(); + await kernel.initialize(); + const remoteKernel = new DataKernel(); + await remoteKernel.initialize(); + const stores = Array.from(kernel.driver.db.objectStoreNames); + const realDb = kernel.driver.db; + const quotaTransaction = { + error: null, + abort() { + if (typeof this.onabort === 'function') this.onabort(); + } + }; + kernel.driver.db = { transaction() { return quotaTransaction; } }; + let quotaTransactionErrorName = null; + try { + await kernel.driver._transaction(['documents'], 'readwrite', 'quota-probe', (tx) => { + queueMicrotask(() => { + tx.onerror({ target: tx }); + tx.error = new DOMException('quota full', 'QuotaExceededError'); + tx.onabort(); + }); + }, true); + } catch (error) { + quotaTransactionErrorName = error && error.name; + } finally { + kernel.driver.db = realDb; + } + const firefoxQuotaCode = kernel._latch({ + name: 'NS_ERROR_DOM_QUOTA_REACHED', + code: 1014, + message: 'Firefox quota full' + }).code; + const statusAfterFirefoxQuota = kernel.status(); + const remoteCommitPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('cross-realm commit notification timed out')), 3000); + const unsubscribe = remoteKernel.onCommitted((event) => { + if (event.operationId !== 'cross-realm-commit') return; + clearTimeout(timer); + unsubscribe(); + resolve(event); + }); + }); + await kernel.mutate([{ logicalKey: 'settings.values', data: { remote: true }, expectedRevision: 0 }], { + operationId: 'cross-realm-commit' + }); + const remoteCommit = await remoteCommitPromise; + const first = await kernel.mutate([{ logicalKey: 'preferences.values', data: { theme: 'dark' }, expectedRevision: 0 }], { operationId: 'document-1' }); + const retry = await kernel.mutate([{ logicalKey: 'preferences.values', data: { theme: 'dark' }, expectedRevision: 0 }], { operationId: 'document-1' }); + let conflict = null; + try { await kernel.mutate([{ logicalKey: 'preferences.values', data: { theme: 'light' }, expectedRevision: 1 }], { operationId: 'document-1' }); } catch (error) { conflict = error.code; } + let commitGuardCalls = 0; + let commitGuardCode = null; + try { + await kernel.mutate([{ + logicalKey: 'goals.items', + data: [{ id: 'must-not-commit' }], + expectedRevision: 0 + }], { + operationId: 'document-commit-guard-rejected', + commitGuard() { + commitGuardCalls += 1; + return false; + } + }); + } catch (error) { commitGuardCode = error.code; } + const guardedDocumentAfterReject = await kernel.read('goals.items', { withMeta: true }); + const statusAfterCommitGuardReject = kernel.status(); + const acceptedGuardReceipt = await kernel.mutate([{ + logicalKey: 'goals.items', + data: [{ id: 'committed' }], + expectedRevision: 0 + }], { + operationId: 'document-commit-guard-accepted', + commitGuard: () => true + }); + let replayGuardCode = null; + try { + await kernel.mutate([{ + logicalKey: 'goals.items', + data: [{ id: 'committed' }], + expectedRevision: 0 + }], { + operationId: 'document-commit-guard-accepted', + commitGuard: () => false + }); + } catch (error) { replayGuardCode = error.code; } + + const entities = await kernel.mutateEntities([ + { type: 'upsert', store: 'practiceSummaries', recordId: 'r1', data: { title: 'Reading', score: 7 }, expectedRevision: 0 }, + { type: 'upsert', store: 'practiceDetails', recordId: 'r1', data: { answers: ['A'], correctAnswers: ['B'] }, expectedRevision: 0 }, + { type: 'upsert', store: 'practiceAnnotations', recordId: 'r1', data: { notes: ['review'] }, expectedRevision: 0 } + ], { operationId: 'entity-1' }); + const entityRetry = await kernel.mutateEntities([ + { type: 'upsert', store: 'practiceSummaries', recordId: 'r1', data: { title: 'Reading', score: 7 }, expectedRevision: 0 }, + { type: 'upsert', store: 'practiceDetails', recordId: 'r1', data: { answers: ['A'], correctAnswers: ['B'] }, expectedRevision: 0 }, + { type: 'upsert', store: 'practiceAnnotations', recordId: 'r1', data: { notes: ['review'] }, expectedRevision: 0 } + ], { operationId: 'entity-1' }); + const intent = { command: 'practice-suite', payload: { recordId: 'intent-r1' } }; + const intentFirst = await kernel.mutateEntities([{ + type: 'upsert', store: 'practiceSummaries', recordId: 'intent-r1', + data: { title: 'Intent replay' }, expectedRevision: 0 + }], { operationId: 'entity-intent-replay', intent }); + const intentReplay = await kernel.mutateEntities([{ + type: 'upsert', store: 'practiceSummaries', recordId: 'intent-r1', + data: { title: 'Intent replay' }, expectedRevision: 1 + }], { operationId: 'entity-intent-replay', intent }); + let intentConflict = null; + try { + await kernel.mutateEntities([{ + type: 'upsert', store: 'practiceSummaries', recordId: 'intent-r1', + data: { title: 'Different intent' }, expectedRevision: 1 + }], { + operationId: 'entity-intent-replay', + intent: { command: 'practice-suite', payload: { recordId: 'different' } } + }); + } catch (error) { intentConflict = error.code; } + await kernel.mutateEntities([{ + type: 'delete', store: 'practiceSummaries', recordId: 'intent-r1', expectedRevision: 1 + }], { operationId: 'entity-intent-cleanup' }); + const practiceProjection = await kernel.readPracticeSnapshot(['r1']); + let detailsListCode = null; + try { await kernel.listEntities('practiceDetails'); } catch (error) { detailsListCode = error.code; } + const snapshot = await kernel.exportSnapshot({ includeSystem: true }); + const settingsBeforeImport = await kernel.read('settings.values', { withMeta: true }); + const practiceBeforeImport = await kernel.readPracticeSnapshot(null, { withMeta: true }); + const expectedRevisionToken = { + documents: { + 'settings.values': settingsBeforeImport.envelope.revision + }, + entities: Object.fromEntries(Object.entries(practiceBeforeImport).map(([store, rows]) => [ + store, + Object.fromEntries(rows.map((row) => [row.recordId, row.revision])) + ])) + }; + await kernel.mutateEntities([{ + type: 'upsert', store: 'practiceSummaries', recordId: 'late-import-row', + data: { title: 'created while import was waiting' }, expectedRevision: 0 + }], { operationId: 'late-import-row' }); + let staleImportCode = null; + try { + await kernel.installSnapshot(snapshot, { operationId: 'stale-import', expectedRevisionToken }); + } catch (error) { staleImportCode = error.code; } + const lateImportRow = await kernel.readEntity('practiceSummaries', 'late-import-row'); + const invalidRevisionSnapshot = structuredClone(snapshot); + invalidRevisionSnapshot.entities.practiceDetails[0].revision = -1; + invalidRevisionSnapshot.checksum = window.__AppDataV2Internals.checksum({ + envelopes: invalidRevisionSnapshot.envelopes, + entities: invalidRevisionSnapshot.entities + }); + let invalidRevisionCode = null; + try { await kernel.installSnapshot(invalidRevisionSnapshot, { operationId: 'invalid-revision' }); } catch (error) { invalidRevisionCode = error.code; } + const exportableKeys = window.__AppDataV2Catalog.list() + .filter((entry) => entry.export === true) + .map((entry) => entry.logicalKey) + .sort(); + await kernel.mutateEntities([{ type: 'clear', store: 'practiceSummaries' }], { operationId: 'clear-summary' }); + await kernel.installSnapshot(snapshot, { operationId: 'restore-1', resetJournal: true }); + const restored = await kernel.readEntity('practiceSummaries', 'r1', { withMeta: true }); + const journal = await kernel.read('system.operationJournal'); + await new Promise((resolve, reject) => { + const tx = kernel.driver.db.transaction(['practiceSummaries'], 'readwrite'); + tx.objectStore('practiceSummaries').put({ + recordId: 'corrupt', + revision: 1, + operationId: 'corrupt-seed', + updatedAt: new Date().toISOString(), + data: { id: 'corrupt', type: 'reading' }, + checksum: 'broken-checksum' + }); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error || new Error('unable to seed corrupt entity')); + tx.onabort = () => reject(tx.error || new Error('corrupt entity transaction aborted')); + }); + let corruptCode = null; + try { await kernel.readEntity('practiceSummaries', 'corrupt'); } catch (error) { corruptCode = error.code; } + const statusAfterCorruption = kernel.status(); + const summariesAfterCorruption = await kernel.listEntities('practiceSummaries'); + await kernel.installSnapshot(snapshot, { operationId: 'restore-after-corrupt', resetJournal: true }); + const corruptAfterRestore = await kernel.readEntity('practiceSummaries', 'corrupt'); + const restoredAfterCorruption = await kernel.readEntity('practiceSummaries', 'r1'); + const journalAfterCorruption = await kernel.read('system.operationJournal'); + remoteKernel.close(); + kernel.close(); + return { + unavailableCode, + legacyValues, + webOnlyLegacyValues, + externalBackup, + externalReadCalls, + promptBackup, + promptFileRead, + status: kernel.status(), + stores, + quotaTransactionErrorName, + firefoxQuotaCode, + statusAfterFirefoxQuota, + remoteCommit, + first, + retry, + conflict, + commitGuardCalls, + commitGuardCode, + guardedDocumentAfterReject, + statusAfterCommitGuardReject, + acceptedGuardReceipt, + replayGuardCode, + entities, + entityRetry, + intentFirst, + intentReplay, + intentConflict, + practiceProjection, + detailsListCode, + snapshot, + staleImportCode, + lateImportRow, + invalidRevisionCode, + exportableKeys, + restored, + journal, + corruptCode, + statusAfterCorruption, + summariesAfterCorruption, + corruptAfterRestore, + restoredAfterCorruption, + journalAfterCorruption + }; + }); + assert.equal(result.unavailableCode, 'BACKEND_UNAVAILABLE'); + assert.deepEqual(result.legacyValues.practice_records, [{ id: 'legacy-idb-record', correctAnswers: 1 }]); + assert.deepEqual(result.legacyValues.settings, { theme: 'dark', notifications: true, source: 'local-newer' }); + assert.deepEqual(result.legacyValues.exam_index_configurations, [{ id: 'exam_index_1700000000000', key: 'exam_index_1700000000000' }]); + assert.equal(result.legacyValues.active_exam_index_key, 'exam_index_1700000000000'); + assert.deepEqual(result.legacyValues.exam_index_1700000000000, [{ id: 'legacy-custom-exam', type: 'reading' }]); + assert.deepEqual(result.webOnlyLegacyValues.practice_records, [{ id: 'legacy-web-record', correctAnswers: 0 }]); + assert.deepEqual(result.webOnlyLegacyValues.settings, { theme: 'dark', notifications: true, source: 'local-newer' }); + assert.deepEqual(result.webOnlyLegacyValues.vocab_user_config, { dailyNew: 12 }); + assert.deepEqual(result.webOnlyLegacyValues.user_achievements, { first_practice: { unlockedAt: '2026-01-01T00:00:00.000Z' } }); + assert.equal(result.webOnlyLegacyValues.theme, undefined, 'unprefixed preference keys must stay rejected'); + assert.deepEqual(result.externalBackup, { practice_records: [{ id: 'legacy-external-record' }] }); + assert.ok(result.externalReadCalls.includes('open:ExamSystemExternalBackup')); + assert.ok(result.externalReadCalls.includes('get:backup_directory')); + assert.ok(result.externalReadCalls.includes('permission:read')); + assert.ok(result.externalReadCalls.includes('file:practice-backup-latest.json:false')); + assert.equal(result.externalReadCalls.some((call) => /put|delete|write/i.test(call)), false, + 'legacy backup reader must leave the old handle and file untouched'); + assert.equal(result.promptBackup, null); + assert.equal(result.promptFileRead, false, 'startup migration must never prompt for or read without permission'); + assert.deepEqual(result.stores, ['documents', 'practiceAnnotations', 'practiceDetails', 'practiceSummaries', 'system']); + assert.equal(result.quotaTransactionErrorName, 'QuotaExceededError', 'transaction abort must preserve the real quota cause'); + assert.equal(result.firefoxQuotaCode, 'QUOTA_EXCEEDED'); + assert.equal(result.statusAfterFirefoxQuota.state, 'ready', 'Firefox quota must not permanently latch the backend'); + assert.equal(result.first.revision, 1); + assert.equal(result.remoteCommit.operationId, 'cross-realm-commit'); + assert.equal(result.remoteCommit.remote, true); + assert.equal(result.remoteCommit.targets[0].logicalKey, 'settings.values'); + assert.deepEqual(result.retry, result.first); + assert.equal(result.conflict, 'CONFLICT'); + assert.equal(result.commitGuardCalls, 1, 'commit guard must run at the transaction write boundary'); + assert.equal(result.commitGuardCode, 'PRECONDITION_FAILED'); + assert.equal(result.guardedDocumentAfterReject.envelope, null, 'a rejected guard must not advance the document revision'); + assert.deepEqual(result.guardedDocumentAfterReject.data, []); + assert.equal(result.statusAfterCommitGuardReject.state, 'ready', 'an expected guard rejection must not latch the backend'); + assert.equal(result.acceptedGuardReceipt.committed, true); + assert.equal(result.replayGuardCode, 'PRECONDITION_FAILED', 'journal replay must not bypass the current commit guard'); + assert.deepEqual(result.entityRetry, result.entities); + assert.deepEqual(result.intentReplay, result.intentFirst, 'semantic intent must replay after derived operations change'); + assert.equal(result.intentConflict, 'CONFLICT', 'different semantic intent must still conflict'); + assert.deepEqual(Object.keys(result.practiceProjection).sort(), ['practiceAnnotations', 'practiceDetails', 'practiceSummaries']); + assert.equal(result.practiceProjection.practiceSummaries[0].title, 'Reading'); + assert.equal(result.practiceProjection.practiceDetails[0].answers[0], 'A'); + assert.equal(result.practiceProjection.practiceAnnotations[0].notes[0], 'review'); + assert.equal(result.detailsListCode, 'VALIDATION'); + assert.equal(result.staleImportCode, 'CONFLICT'); + assert.equal(result.lateImportRow.title, 'created while import was waiting'); + assert.equal(result.invalidRevisionCode, 'VALIDATION'); + assert.deepEqual(result.snapshot.entities.practiceSummaries.map((row) => row.recordId), ['r1']); + assert.deepEqual( + result.exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(result.snapshot.envelopes, key)), + [], + 'snapshot must explicitly materialize every exportable catalog envelope' + ); + assert.equal(Object.prototype.hasOwnProperty.call(result.snapshot.envelopes, 'exam_index'), false); + assert.equal(result.snapshot.envelopes['library.configurations'].state, 'cleared'); + assert.equal(result.snapshot.envelopes['library.importedIndexes'].state, 'cleared'); + assert.equal(result.snapshot.envelopes['library.activeConfigurationId'].state, 'cleared'); + assert.equal(result.snapshot.entities.practiceDetails[0].data.answers[0], 'A'); + assert.equal(result.snapshot.entities.practiceAnnotations[0].data.notes[0], 'review'); + assert.equal(result.restored.revision, 1); + assert.equal(result.restored.data.score, 7); + assert.deepEqual(Object.keys(result.journal), ['restore-1']); + assert.equal(result.corruptCode, 'CORRUPT_RECORD'); + assert.equal(result.statusAfterCorruption.state, 'ready'); + assert.deepEqual(result.summariesAfterCorruption.map((row) => row.title), ['Reading']); + assert.equal(result.corruptAfterRestore, null); + assert.equal(result.restoredAfterCorruption.score, 7); + assert.deepEqual(Object.keys(result.journalAfterCorruption), ['restore-after-corrupt']); + assert.equal(result.status.state, 'closed'); + await page.evaluate(() => new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase('IELTSAtlasDataV2'); + request.onsuccess = resolve; + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('migration target reset blocked')); + })); + await page.addScriptTag({ content: recordSource }); + await page.addScriptTag({ content: appDataSource }); + const migrated = await page.evaluate(async () => { + await window.AppData.ready; + const activeId = await window.AppData.library.getActive(); + return { + practice: await window.AppData.practice.get('legacy-idb-record'), + settings: await window.AppData.settings.getAll(), + vocab: await window.AppData.vocab.getConfig(), + achievements: await window.AppData.achievements.getManualState(), + activeId, + activeIndex: await window.AppData.library.getIndex(activeId) + }; + }); + assert.equal(migrated.practice.id, 'legacy-idb-record'); + assert.equal(migrated.practice.correctAnswers, 1); + assert.equal(migrated.settings.theme, 'dark'); + assert.equal(migrated.settings.source, 'local-newer'); + assert.equal(migrated.vocab.dailyNew, 12); + assert.equal(migrated.achievements.first_practice.unlockedAt, '2026-01-01T00:00:00.000Z'); + assert.match(migrated.activeId, /^legacy-library-/); + assert.equal(migrated.activeIndex[0].id, 'legacy-custom-exam'); + + // The completion marker makes v1 a one-time source. A later v1 write must + // not resurrect data after the user has moved on to v2. + await page.evaluate(async () => { + await window.AppData.practice.completeAttempt({ + operationId: 'persistent-v2-only', + record: { + id: 'persistent-v2-only', + sessionId: 'persistent-v2-only', + type: 'reading', + title: 'Persistent v2 only', + totalQuestions: 1, + correctAnswers: 1, + answers: { q1: 'V2' } + } + }); + }); + await page.evaluate(() => new Promise((resolve, reject) => { + const request = indexedDB.open('ExamSystemDB', 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction('keyValueStore', 'readwrite'); + const data = [ + { id: 'legacy-idb-record', correctAnswers: 1 }, + { id: 'legacy-added-after-marker', type: 'reading', totalQuestions: 1, correctAnswers: 1 } + ]; + tx.objectStore('keyValueStore').put({ + key: 'exam_system_practice_records', + value: JSON.stringify({ data, timestamp: Date.now(), version: '1.0.0', compressed: false }), + timestamp: Date.now() + }); + tx.onerror = () => { db.close(); reject(tx.error); }; + tx.onabort = () => { db.close(); reject(tx.error || new Error('legacy update aborted')); }; + tx.oncomplete = () => { db.close(); resolve(); }; + }; + })); + await page.reload(); + await loadAppData(page); + const secondBoot = await page.evaluate(async () => ({ + legacyAdded: await window.AppData.practice.get('legacy-added-after-marker'), + v2Only: await window.AppData.practice.get('persistent-v2-only'), + migration: (await window.AppData.backups.export({ scope: 'partial', logicalKeys: [] })).schemaVersion + })); + assert.equal(secondBoot.legacyAdded, null); + assert.equal(secondBoot.v2Only.answers.q1, 'V2'); + assert.equal(secondBoot.migration, 2); + + // Later boots remain stable and do not churn v2 business state. + const secondBusinessState = await persistedBusinessState(page); + await page.reload(); + await loadAppData(page); + const thirdBusinessState = await persistedBusinessState(page); + assert.deepEqual(thirdBusinessState, secondBusinessState); + console.log('DataKernel v2 IndexedDB-only behavior tests passed'); + } finally { + if (browser) await browser.close(); + await new Promise((resolve) => server.close(resolve)); + } +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/developer/tests/js/dataLossBaseline.test.js b/developer/tests/js/dataLossBaseline.test.js new file mode 100644 index 00000000..bc991a87 --- /dev/null +++ b/developer/tests/js/dataLossBaseline.test.js @@ -0,0 +1,482 @@ +#!/usr/bin/env node + +// dataLossBaseline.test.js — anti-data-loss baselines for the IDB-only entity practice model. +// Uses a memory FakeKernel so baselines do not depend on real IndexedDB. + +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; +import { test } from 'node:test'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../..'); +const catalogSource = fs.readFileSync(path.join(repoRoot, 'js/data/v2/dataCatalog.js'), 'utf8'); +const appDataSource = fs.readFileSync(path.join(repoRoot, 'js/data/v2/appData.js'), 'utf8'); +const practiceRecordSourceSource = fs.readFileSync(path.join(repoRoot, 'js/data/practiceRecordSource.js'), 'utf8'); + +function clone(value) { + return value === undefined ? undefined : structuredClone(value); +} + +function stable(value) { + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function checksum(value) { + const source = stable(value); + let hash = 0x811c9dc5; + for (let index = 0; index < source.length; index += 1) { + hash ^= source.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; +} + +class AppDataError extends Error { + constructor(code, message, details = {}) { + super(message); + this.code = code; + this.details = details; + this.committed = false; + } +} + +function createHarness() { + const catalogSandbox = { structuredClone }; + catalogSandbox.globalThis = catalogSandbox; + vm.runInContext(catalogSource, vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' }); + const catalog = catalogSandbox.__AppDataV2Catalog; + + let idCounter = 0; + const shared = { + docs: new Map(), + entities: new Map([ + ['practiceSummaries', new Map()], + ['practiceDetails', new Map()], + ['practiceAnnotations', new Map()] + ]), + journal: new Map(), + mutations: [], + conflicts: new Map(), + injectedMutateFailures: [], + failEntityStore: null + }; + + function envelope(key, data, state = 'present', revision = 1, operationId = 'seed') { + const payload = { + schemaVersion: 2, + revision, + operationId, + updatedAt: new Date().toISOString(), + state, + data: state === 'cleared' ? null : clone(data) + }; + payload.checksum = checksum(payload.data); + return payload; + } + + class FakeKernel { + constructor() { + this.state = 'created'; + this.backend = 'memory-v2'; + } + + async initialize() { + this.state = 'ready'; + return this; + } + + async read(logicalKey, options = {}) { + const entry = catalog.get(logicalKey); + const value = shared.docs.get(logicalKey) || null; + const data = !value || value.state === 'cleared' ? entry.defaultValue() : value.data; + return options.withMeta ? { data: clone(data), envelope: clone(value) } : clone(data); + } + + async mutate(changes, options = {}) { + const operationId = String(options.operationId || `mutation-${++idCounter}`); + const fingerprint = checksum({ changes, warnings: options.warnings || [] }); + if (shared.journal.has(operationId)) { + const prior = shared.journal.get(operationId); + if (prior.fingerprint !== fingerprint) throw new AppDataError('CONFLICT', `operationId conflict: ${operationId}`); + return clone(prior.receipt); + } + const injected = shared.injectedMutateFailures.find((entry) => entry.match(operationId, changes)); + if (injected) { + shared.injectedMutateFailures.splice(shared.injectedMutateFailures.indexOf(injected), 1); + throw injected.error; + } + for (const change of changes) { + const current = shared.docs.get(change.logicalKey); + const actualRevision = current ? current.revision : 0; + if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== actualRevision) { + throw new AppDataError('CONFLICT', `Revision conflict for ${change.logicalKey}`, { actualRevision }); + } + const failures = shared.conflicts.get(change.logicalKey) || 0; + if (failures > 0) { + shared.conflicts.set(change.logicalKey, failures - 1); + throw new AppDataError('CONFLICT', `Injected conflict for ${change.logicalKey}`); + } + } + const revisions = {}; + for (const change of changes) { + const current = shared.docs.get(change.logicalKey); + const revision = current ? current.revision + 1 : 1; + revisions[change.logicalKey] = revision; + shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, operationId)); + } + const receipt = { + committed: true, + revisions, + operationId, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + if (Object.keys(revisions).length === 1) receipt.revision = revisions[Object.keys(revisions)[0]]; + shared.journal.set(operationId, { fingerprint, receipt: clone(receipt) }); + shared.mutations.push({ operationId, keys: changes.map((c) => c.logicalKey) }); + return clone(receipt); + } + + async journalNoop(options = {}) { + return { + committed: true, + operationId: options.operationId || `noop-${++idCounter}`, + revisions: {}, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + } + + async readEntity(store, recordId, options = {}) { + const row = shared.entities.get(store).get(String(recordId)) || null; + return options.withMeta ? clone(row) : row && clone(row.data); + } + + async listEntities(store, options = {}) { + if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable'); + const rows = Array.from(shared.entities.get(store).values()); + return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); + } + + async readPracticeSnapshot(recordIds = null, options = {}) { + const ids = recordIds == null ? null : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String)); + const stores = options.stores || ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + return Object.fromEntries(stores.map((store) => [store, Array.from(shared.entities.get(store).values()) + .filter((row) => !ids || ids.has(String(row.recordId))) + .map((row) => options.withMeta ? clone(row) : clone(row.data))])); + } + + async mutateEntities(operations, options = {}) { + const operationId = String(options.operationId || `entity-${++idCounter}`); + const fingerprint = checksum({ operations, warnings: options.warnings || [] }); + if (shared.journal.has(operationId)) { + const prior = shared.journal.get(operationId); + if (prior.fingerprint !== fingerprint) throw new AppDataError('CONFLICT', `operationId conflict: ${operationId}`); + return clone(prior.receipt); + } + for (const item of operations) { + if (shared.failEntityStore === item.store) { + throw new AppDataError('IO', `forced entity failure: ${item.store}`); + } + } + const revisions = {}; + const next = new Map(Array.from(shared.entities, ([store, rows]) => [store, new Map(rows)])); + for (const item of operations) { + const rows = next.get(item.store); + if (item.type === 'clear') { + rows.clear(); + revisions[`${item.store}/*`] = 0; + continue; + } + const old = rows.get(String(item.recordId)); + if (item.expectedRevision !== undefined && item.expectedRevision !== null + && Number(item.expectedRevision) !== Number(old && old.revision || 0)) { + throw new AppDataError('CONFLICT', 'entity revision'); + } + if (item.type === 'delete') { + rows.delete(String(item.recordId)); + revisions[`${item.store}/${item.recordId}`] = Number(old && old.revision || 0) + 1; + } else { + const row = { + recordId: String(item.recordId), + revision: Number(old && old.revision || 0) + 1, + operationId, + updatedAt: new Date().toISOString(), + data: clone(item.data), + checksum: checksum(item.data) + }; + rows.set(row.recordId, row); + revisions[`${item.store}/${item.recordId}`] = row.revision; + } + } + shared.entities = next; + const receipt = { + committed: true, + operationId, + revisions, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + shared.journal.set(operationId, { fingerprint, receipt: clone(receipt) }); + shared.mutations.push({ operationId, operations: clone(operations) }); + return clone(receipt); + } + + async exportSnapshot(options = {}) { + const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys) : null; + const selectedEntities = Array.isArray(options.entityStores) ? new Set(options.entityStores) : null; + const envelopes = {}; + for (const [key, value] of shared.docs) { + if (selected && !selected.has(key)) continue; + if (catalog.get(key).export === true) envelopes[key] = clone(value); + } + const entities = {}; + for (const [store, rows] of shared.entities) { + if (selectedEntities && !selectedEntities.has(store)) continue; + entities[store] = Array.from(rows.values()).map(clone); + } + const snapshot = { + format: 'ielts-atlas-data-v2', + schemaVersion: 2, + scope: selected ? 'partial' : 'full', + envelopes, + entities + }; + snapshot.checksum = checksum({ envelopes, entities }); + return snapshot; + } + + async installSnapshot(snapshot, options = {}) { + if (snapshot.checksum !== checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities })) { + throw new AppDataError('VALIDATION', 'snapshot checksum'); + } + for (const [key, value] of Object.entries(snapshot.envelopes || {})) { + shared.docs.set(key, clone(value)); + } + for (const [store, rows] of Object.entries(snapshot.entities || {})) { + shared.entities.set(store, new Map(rows.map((row) => [String(row.recordId), clone(row)]))); + } + return { + committed: true, + operationId: options.operationId || `install-${++idCounter}`, + revisions: {}, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + } + + onCommitted() { return () => {}; } + status() { return { state: this.state, backend: this.backend, failure: null }; } + } + + const internals = { + DataKernel: FakeKernel, + AppDataError, + catalog, + clone, + checksum, + randomId: (prefix) => `${prefix}-${++idCounter}`, + nowIso: () => new Date().toISOString(), + makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId), + validateEnvelope: (_entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data)) + }; + + const sandbox = { + console, + Date, + JSON, + Math, + Map, + Set, + Promise, + structuredClone, + __AppDataV2Internals: internals, + sessionStorage: { getItem() { return null; }, setItem() {}, removeItem() {} } + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + const context = vm.createContext(sandbox); + vm.runInContext(practiceRecordSourceSource, context, { filename: 'practiceRecordSource.js' }); + vm.runInContext(appDataSource, context, { filename: 'appData.js' }); + + return { app: sandbox.AppData, shared }; +} + +async function expectCode(promise, code) { + await assert.rejects(promise, (error) => { + assert.strictEqual(error.code, code); + return true; + }); +} + +test('baseline 1: failed document write leaves previous value readable', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.settings.patch({ theme: 'keep-me' }, { operationId: 'seed-settings' }); + harness.shared.injectedMutateFailures.push({ + match: (operationId) => operationId === 'fail-write', + error: new AppDataError('IO', 'forced write failure') + }); + await expectCode(app.settings.patch({ theme: 'lost' }, { operationId: 'fail-write' }), 'IO'); + assert.deepStrictEqual(await app.settings.getAll(), { theme: 'keep-me' }); +}); + +test('baseline 2: entity write failure does not partially create practice layers', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + harness.shared.failEntityStore = 'practiceDetails'; + await expectCode(app.practice.completeAttempt({ + operationId: 'partial-fail', + record: { id: 'r-fail', type: 'reading', answers: { 1: 'A' } } + }), 'IO'); + assert.strictEqual(harness.shared.entities.get('practiceSummaries').has('r-fail'), false); + assert.strictEqual(harness.shared.entities.get('practiceDetails').has('r-fail'), false); + assert.strictEqual(harness.shared.entities.get('practiceAnnotations').has('r-fail'), false); +}); + +test('baseline 3: clear removes all practice entity layers', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.practice.completeAttempt({ + operationId: 'seed-clear', + record: { id: 'r1', type: 'reading', answers: { 1: 'A' }, notes: { q1: 'n' } } + }); + await app.practice.clear({ operationId: 'clear-all' }); + assert.strictEqual(harness.shared.entities.get('practiceSummaries').size, 0); + assert.strictEqual(harness.shared.entities.get('practiceDetails').size, 0); + assert.strictEqual(harness.shared.entities.get('practiceAnnotations').size, 0); + assert.strictEqual((await app.practice.list()).length, 0); +}); + +test('baseline 4: backup restore recovers practice entities after delete', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.practice.completeAttempt({ + operationId: 'seed-backup', + record: { id: 'rec-1', examId: 'reading-1', type: 'reading', answers: { 1: 'A' } } + }); + await app.backups.create({ id: 'manual-backup-a', operationId: 'manual-backup-create-a' }); + await app.practice.delete('rec-1', { operationId: 'practice-delete-1' }); + assert.strictEqual((await app.practice.list()).length, 0); + const backups = await app.backups.list(); + assert.ok(backups.some((item) => item.id === 'manual-backup-a')); + await app.backups.restore('manual-backup-a', { operationId: 'restore-a' }); + assert.ok((await app.practice.list()).some((item) => item.id === 'rec-1')); + assert.strictEqual((await app.practice.get('rec-1')).answers[1], 'A'); +}); + +test('baseline 5: light projection excludes full-only fields', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.practice.completeAttempt({ + operationId: 'seed-light', + record: { + id: 'light-1', + type: 'reading', + title: 'Light', + answers: { 1: 'A' }, + answerDetails: 'huge detail', + notes: { q1: 'note' }, + highlights: [{ text: 'h' }], + metadata: { examId: 'e1', libraryConfigurationId: 'lib-1', privatePayload: 'secret' } + } + }); + const light = (await app.practice.list({ projection: 'light' }))[0]; + assert.strictEqual(light.id, 'light-1'); + assert.ok(!Object.prototype.hasOwnProperty.call(light, 'answers')); + assert.ok(!Object.prototype.hasOwnProperty.call(light, 'answerDetails')); + assert.ok(!Object.prototype.hasOwnProperty.call(light, 'notes')); + assert.ok(!Object.prototype.hasOwnProperty.call(light, 'highlights')); + assert.ok(!Object.prototype.hasOwnProperty.call(light.metadata, 'privatePayload')); + assert.strictEqual(light.metadata.libraryConfigurationId, 'lib-1'); +}); + +test('baseline 6: read returns are clones', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.settings.patch({ theme: 'clone-me' }, { operationId: 'seed-clone' }); + const first = await app.settings.getAll(); + first.theme = 'mutated'; + assert.deepStrictEqual(await app.settings.getAll(), { theme: 'clone-me' }); +}); + +test('baseline 7: concurrent CAS surfaces CONFLICT', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.settings.patch({ theme: 'first' }, { operationId: 'cas-1' }); + const baselineRevision = harness.shared.docs.get('settings.values').revision; + await app.settings.patch({ theme: 'second' }, { operationId: 'cas-2', expectedRevision: baselineRevision }); + await expectCode( + app.settings.patch({ theme: 'third' }, { operationId: 'cas-late', expectedRevision: baselineRevision }), + 'CONFLICT' + ); + assert.deepStrictEqual(await app.settings.getAll(), { theme: 'second' }); +}); + +test('baseline 8: failed delete does not drop other layers', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.practice.completeAttempt({ + operationId: 'seed-delete', + record: { id: 'keep-me', type: 'reading', answers: { 1: 'A' }, notes: { q1: 'n' } } + }); + harness.shared.failEntityStore = 'practiceDetails'; + await expectCode(app.practice.delete('keep-me', { operationId: 'delete-fail' }), 'IO'); + assert.strictEqual(harness.shared.entities.get('practiceSummaries').has('keep-me'), true); + assert.strictEqual(harness.shared.entities.get('practiceDetails').has('keep-me'), true); + assert.strictEqual(harness.shared.entities.get('practiceAnnotations').has('keep-me'), true); +}); + +test('baseline 9: fault injection can force one conflict then recover', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.settings.patch({ theme: 'baseline-9' }, { operationId: 'baseline-9-seed' }); + harness.shared.conflicts.set('settings.values', 1); + const retried = await app.settings.patch({ theme: 'retry' }, { operationId: 'baseline-9-conflict' }); + assert.equal(retried.committed, true); + assert.deepStrictEqual(await app.settings.getAll(), { theme: 'retry' }); +}); + +test('baseline 10: export/import checksum and entity round-trip', async () => { + const harness = createHarness(); + const app = harness.app; + await app.ready; + await app.practice.completeAttempt({ + operationId: 'seed-export', + record: { id: 'rec-a', examId: 'reading-1', type: 'reading', totalQuestions: 4, correctAnswers: 2, answers: { 1: 'A' } } + }); + await app.settings.patch({ theme: 'dark' }, { operationId: 'seed-export-settings' }); + const snapshot = await app.backups.export(); + assert.strictEqual(snapshot.format, 'ielts-atlas-data-v2'); + assert.strictEqual(Number(snapshot.schemaVersion), 2); + assert.ok(snapshot.entities.practiceSummaries.some((row) => row.recordId === 'rec-a')); + assert.ok(snapshot.envelopes['settings.values']); + + const corrupted = clone(snapshot); + corrupted.checksum = 'fnv1a-deadbeef'; + await expectCode(app.backups.previewImport(corrupted), 'VALIDATION'); + + await app.practice.clear({ operationId: 'clear-before-import' }); + const preview = await app.backups.previewImport(snapshot, { replace: true }); + const receipt = await app.backups.commitImport(preview.id, { operationId: 'baseline-10-import' }); + assert.strictEqual(receipt.committed, true); + assert.strictEqual((await app.practice.get('rec-a')).answers[1], 'A'); + assert.deepStrictEqual(await app.settings.getAll(), { theme: 'dark' }); +}); diff --git a/developer/tests/js/e2e/appE2ETest.js b/developer/tests/js/e2e/appE2ETest.js index 73e2f8d9..2d518fa4 100644 --- a/developer/tests/js/e2e/appE2ETest.js +++ b/developer/tests/js/e2e/appE2ETest.js @@ -6,6 +6,7 @@ const DEFAULT_INTERACTION_TARGETS = Object.freeze({ 'practice-settings-entry-btn', 'library-manager-btn', 'show-onboarding-btn', + 'external-backup-entry-btn', 'create-backup-btn', 'backup-list-btn', 'export-data-btn', @@ -60,6 +61,27 @@ const SETTINGS_BUTTON_TESTS = { stubbed: ['createManualBackup'], stubImplementation: () => Promise.resolve({ id: 'stub-backup' }) }, + 'external-backup-entry-btn': { + name: '设置 - 本地磁盘备份按钮', + expectInvocation: false, + waitForSelector: '#external-backup-modal.show', + waitDescription: '本地磁盘备份弹窗显示', + waitForCondition: ({ doc, win }) => { + const modal = doc.getElementById('external-backup-modal'); + const serviceReady = !!(win.ExternalBackupService + && win.ExternalBackupService.__v2 === true + && typeof win.ExternalBackupService.openModal === 'function'); + const actionsReady = [ + 'external-backup-bind-btn', + 'external-backup-write-btn', + 'external-backup-restore-btn', + 'external-backup-unbind-btn' + ].every((id) => !!doc.getElementById(id)); + if (modal && serviceReady && actionsReady) modal.classList.remove('show'); + return serviceReady && actionsReady; + }, + waitTimeout: 500 + }, 'backup-list-btn': { name: '设置 - 查看备份列表按钮', expectInvocation: false, @@ -93,7 +115,7 @@ class AppE2ETestSuite { this.results = []; this.win = null; this.doc = null; - this.originalPracticeRecords = null; + this.isolationBackupId = null; } setStatus(message) { @@ -125,6 +147,18 @@ class AppE2ETestSuite { }); } + async waitForAsync(condition, { timeout = 8000, interval = 50, description = '等待条件成立' } = {}) { + const start = performance.now(); + while (performance.now() - start < timeout) { + const result = await condition(); + if (result) { + return result; + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + throw new Error(`${description} 超时 (${timeout}ms)`); + } + recordResult(name, passed, details) { const result = { name, passed, details }; this.results.push(result); @@ -178,26 +212,32 @@ class AppE2ETestSuite { timeout: 15000, description: '主应用完成初始化' }); - - if (this.win.simpleStorageWrapper && typeof this.win.simpleStorageWrapper.getPracticeRecords === 'function') { - try { - this.originalPracticeRecords = await this.win.simpleStorageWrapper.getPracticeRecords(); - } catch (error) { - console.warn('[E2E] 备份练习记录失败:', error); - this.originalPracticeRecords = null; - } - } + await this.waitFor(() => this.win.AppData && this.win.AppData.backups, { + timeout: 15000, + description: 'AppData v2 就绪' + }); + await this.win.AppData.ready; + const backup = await this.win.AppData.backups.create({ + type: 'e2e-isolation', + operationId: `app-e2e-isolation-${Date.now()}` + }); + this.isolationBackupId = backup.id; } async teardown() { - if (this.originalPracticeRecords && this.win?.simpleStorageWrapper) { + if (this.isolationBackupId && this.win?.AppData?.backups) { try { - await this.win.simpleStorageWrapper.savePracticeRecords(this.originalPracticeRecords); + await this.win.AppData.backups.restore(this.isolationBackupId, { + operationId: `app-e2e-restore-${this.isolationBackupId}` + }); if (typeof this.win.syncPracticeRecords === 'function') { await this.win.syncPracticeRecords(); } + await this.win.AppData.backups.delete(this.isolationBackupId, { + operationId: `app-e2e-cleanup-${this.isolationBackupId}` + }); } catch (error) { - console.warn('[E2E] 恢复原始练习记录失败:', error); + console.warn('[E2E] 恢复 v2 隔离备份失败,备份已保留:', error); } } if (this.win && typeof this.win.showView === 'function') { @@ -219,12 +259,10 @@ class AppE2ETestSuite { await this.testSearchFunction(); await this.testExamActionButtons(); await this.testExamEmptyStateAction(); - await this.testLegacyBridgeSynchronization(); await this.testPracticeRecordsFlow(); await this.testPracticeHistoryBulkDelete(); await this.testPracticeSubmissionMessageFlow(); await this.testSettingsControlButtons(); - await this.testDataIntegrityImportFlow(); await this.testThemePortals(); this.setStatus('全部测试执行完毕'); @@ -567,83 +605,6 @@ class AppE2ETestSuite { } } - async testDataIntegrityImportFlow() { - const name = '数据导入执行'; - let backupId = null; - try { - const manager = this.win.dataIntegrityManager; - if (!manager || typeof manager.importData !== 'function') { - this.recordResult(name, false, '缺少 dataIntegrityManager.importData'); - return; - } - - const fixtureUrl = './fixtures/data-integrity-import-sample.json'; - const response = await fetch(fixtureUrl, { cache: 'no-store' }); - if (!response.ok) { - this.recordResult(name, false, `获取样例失败: ${response.status}`); - return; - } - - const text = await response.text(); - const blobSource = typeof File === 'function' - ? new File([text], 'data-integrity-import-sample.json', { type: 'application/json' }) - : new Blob([text], { type: 'application/json' }); - - const before = await manager.getCriticalData(); - const result = await manager.importData(blobSource); - backupId = result?.backupId || null; - const after = await manager.getCriticalData(); - - const records = Array.isArray(after.practice_records) ? after.practice_records : []; - const imported = records.find((entry) => entry && entry.id === 'fixture_record_1'); - const settings = after.system_settings || {}; - - const passed = !!imported - && imported.examId === 'listening-fixture-01' - && settings.language === 'zh-CN' - && typeof result?.importedCount === 'number' - && result.importedCount >= 1; - - this.recordResult(name, passed, { - importedCount: result?.importedCount ?? null, - backupId, - language: settings.language || null, - recordExamId: imported?.examId || null - }); - - if (backupId) { - try { - await manager.restoreBackup(backupId); - } catch (error) { - console.warn('[E2E] 恢复导入前备份失败:', error); - } - return; - } - - if (manager.repositories && typeof manager.repositories.transaction === 'function') { - try { - await manager.repositories.transaction(['practice', 'settings'], async (repos, tx) => { - if (Array.isArray(before.practice_records)) { - await repos.practice.overwrite(before.practice_records, { transaction: tx }); - } - if (before.system_settings && typeof repos.settings?.saveAll === 'function') { - await repos.settings.saveAll(before.system_settings, { transaction: tx }); - } - }); - } catch (error) { - console.warn('[E2E] 回滚导入数据失败:', error); - } - } - } catch (error) { - this.recordResult(name, false, error?.message || String(error)); - if (backupId) { - try { - await this.win.dataIntegrityManager.restoreBackup(backupId); - } catch (_) {} - } - } - } - async testThemePortals() { const name = '主题偏好持久化'; try { @@ -711,10 +672,14 @@ class AppE2ETestSuite { } async waitForExamIndex() { - await this.waitFor(() => Array.isArray(this.win.examIndex) && this.win.examIndex.length > 0, { + await this.waitFor(() => typeof this.win.resolveActiveLibraryIndex === 'function', { timeout: 15000, - description: '题库索引加载完成' + description: '题库索引解析器加载完成' }); + return this.waitForAsync(async () => { + const index = await this.win.resolveActiveLibraryIndex(); + return Array.isArray(index) && index.length > 0 ? index : null; + }, { timeout: 15000, description: '题库索引加载完成' }); } async testExamFiltering() { @@ -963,7 +928,7 @@ class AppE2ETestSuite { try { const exams = Array.isArray(this.win.filteredExams) && this.win.filteredExams.length ? this.win.filteredExams - : (Array.isArray(this.win.examIndex) ? this.win.examIndex : []); + : await this.win.resolveActiveLibraryIndex(); this.win.displayExams(exams); } catch (restoreError) { console.warn('[E2E] 无法恢复题库列表', restoreError); @@ -972,90 +937,11 @@ class AppE2ETestSuite { } } - async testLegacyBridgeSynchronization() { - const name = 'AppStateService 状态同步'; - try { - const stateService = this.win.appStateService; - if (!stateService) { - this.recordResult(name, false, 'appStateService 未加载'); - return; - } - - const originalExamIndex = Array.isArray(this.win.examIndex) ? this.win.examIndex.slice() : []; - const originalPractice = Array.isArray(this.win.practiceRecords) ? this.win.practiceRecords.slice() : []; - - const examSampleSize = originalExamIndex.length > 1 ? 1 : originalExamIndex.length; - const examSample = examSampleSize > 0 ? originalExamIndex.slice(0, examSampleSize) : []; - - stateService.setExamIndex(examSample); - await this.waitFor(() => { - const state = this.win.app?.getState?.('exam.index'); - return Array.isArray(state) && state.length === examSample.length; - }, { description: 'App exam state 接收状态服务更新' }); - const examState = this.win.app.getState('exam.index') || []; - const examSynced = Array.isArray(examState) && examState.length === examSample.length; - - let practiceSynced = true; - let practiceSampleSize = 0; - if (originalPractice.length > 0) { - practiceSampleSize = 1; - const practiceSample = originalPractice.slice(0, practiceSampleSize); - stateService.setPracticeRecords(practiceSample); - await this.waitFor(() => { - const state = this.win.app?.getState?.('practice.records'); - return Array.isArray(state) && state.length === practiceSample.length; - }, { description: 'App practice state 接收状态服务更新' }); - const practiceState = this.win.app.getState('practice.records') || []; - practiceSynced = Array.isArray(practiceState) && practiceState.length === practiceSample.length; - } - - if (typeof this.win.filterByType === 'function') { - this.win.filterByType('reading'); - } else { - throw new Error('filterByType 不可用'); - } - await this.waitFor(() => { - const filter = this.win.app?.getState?.('ui.browseFilter'); - return filter && filter.type === 'reading'; - }, { description: '浏览筛选通过桥接层同步' }); - const filter = this.win.app.getState('ui.browseFilter') || {}; - const filterSynced = filter.type === 'reading' && (!filter.category || filter.category === 'all'); - - stateService.setExamIndex(originalExamIndex); - await this.waitFor(() => { - const state = this.win.app?.getState?.('exam.index'); - return Array.isArray(state) && state.length === originalExamIndex.length; - }, { description: 'App exam state 恢复原始数据' }); - - stateService.setPracticeRecords(originalPractice); - await this.waitFor(() => { - const state = this.win.app?.getState?.('practice.records'); - return Array.isArray(state) && state.length === originalPractice.length; - }, { description: 'App practice state 恢复原始数据' }); - - if (typeof this.win.filterByType === 'function') { - this.win.filterByType('all'); - } - await this.waitFor(() => { - const restored = this.win.app?.getState?.('ui.browseFilter'); - return restored && restored.type === 'all'; - }, { description: '浏览筛选恢复默认' }); - - this.recordResult(name, examSynced && practiceSynced && filterSynced, { - examSampleSize, - practiceSampleSize, - filterSnapshot: filter - }); - } catch (error) { - this.recordResult(name, false, error.message || String(error)); - } - } - async testPracticeRecordsFlow() { const name = '练习记录同步流程'; try { - if (!this.win.simpleStorageWrapper || typeof this.win.simpleStorageWrapper.savePracticeRecords !== 'function') { - this.recordResult(name, false, 'simpleStorageWrapper 不可用'); + if (!this.win.AppData?.practice) { + this.recordResult(name, false, 'AppData.practice 不可用'); return; } @@ -1067,7 +953,7 @@ class AppE2ETestSuite { description: '切换到练习记录视图' }); - await this.win.simpleStorageWrapper.savePracticeRecords([]); + await this.win.AppData.practice.clear({ operationId: `e2e-practice-clear-${Date.now()}` }); if (typeof this.win.syncPracticeRecords === 'function') { await this.win.syncPracticeRecords(); } @@ -1078,8 +964,10 @@ class AppE2ETestSuite { const sampleRecord = { id: 'e2e-record', + examId: 'e2e-reading', title: 'E2E 阅读测试题', type: 'reading', + status: 'completed', score: 85, percentage: 85, totalQuestions: 40, @@ -1089,7 +977,10 @@ class AppE2ETestSuite { dataSource: 'real' }; - await this.win.simpleStorageWrapper.savePracticeRecords([sampleRecord]); + await this.win.AppData.practice.completeAttempt({ + record: sampleRecord, + operationId: `e2e-practice-seed-${Date.now()}` + }); if (typeof this.win.syncPracticeRecords === 'function') { await this.win.syncPracticeRecords(); } @@ -1165,8 +1056,8 @@ class AppE2ETestSuite { let originalConfirm = null; try { - if (!this.win.simpleStorageWrapper || typeof this.win.simpleStorageWrapper.savePracticeRecords !== 'function') { - this.recordResult(name, false, 'simpleStorageWrapper 不可用'); + if (!this.win.AppData?.practice) { + this.recordResult(name, false, 'AppData.practice 不可用'); return; } @@ -1179,7 +1070,13 @@ class AppE2ETestSuite { description: '切换到练习视图' }); - await this.win.simpleStorageWrapper.savePracticeRecords(sampleRecords); + await this.win.AppData.practice.clear({ operationId: `e2e-bulk-delete-clear-${Date.now()}` }); + for (const [index, record] of sampleRecords.entries()) { + await this.win.AppData.practice.completeAttempt({ + record, + operationId: `e2e-bulk-delete-seed-${index}-${Date.now()}` + }); + } if (typeof this.win.syncPracticeRecords === 'function') { await this.win.syncPracticeRecords(); } @@ -1217,7 +1114,7 @@ class AppE2ETestSuite { description: '等待列表刷新到剩余记录' }); - const remaining = await this.win.simpleStorageWrapper.getPracticeRecords(); + const remaining = await this.win.AppData.practice.list({ projection: 'full' }); const remainingIds = Array.isArray(remaining) ? remaining.map(record => record.id) : []; const passed = remainingIds.length === 1 && remainingIds[0] === 'bulk-c'; @@ -1255,24 +1152,19 @@ class AppE2ETestSuite { this.recordResult(name, false, 'app.openExam 不可用'); return; } - if (!this.win.simpleStorageWrapper || typeof this.win.simpleStorageWrapper.savePracticeRecords !== 'function') { - this.recordResult(name, false, 'simpleStorageWrapper 不可用'); + if (!this.win.AppData?.practice) { + this.recordResult(name, false, 'AppData.practice 不可用'); return; } - await this.waitFor(() => { - const index = this.win.app?.getState?.('exam.index'); - return Array.isArray(index) && index.length > 0; - }, { timeout: 8000, description: '加载题库索引' }); - - const examIndex = this.win.app?.getState?.('exam.index') || this.win.examIndex || []; + const examIndex = await this.waitForExamIndex(); const targetExam = examIndex.find((exam) => exam && typeof exam.title === 'string' && exam.title.includes('Analysis of Fear')); if (!targetExam) { this.recordResult(name, false, '未找到目标练习题'); return; } - await this.win.simpleStorageWrapper.savePracticeRecords([]); + await this.win.AppData.practice.clear({ operationId: `e2e-submission-clear-${Date.now()}` }); if (typeof this.win.syncPracticeRecords === 'function') { await this.win.syncPracticeRecords(); } @@ -1381,6 +1273,22 @@ class AppE2ETestSuite { timeout: 12000, description: '练习页面会话握手' }); + const hostSession = this.win.app.examWindows && this.win.app.examWindows.get(targetExam.id); + if (!hostSession || !hostSession.windowSessionToken) { + throw new Error('练习页面握手未生成 windowSessionToken'); + } + const originalNotifyParent = fixtureWin.practicePageEnhancer.notifyParent.bind(fixtureWin.practicePageEnhancer); + let submissionId = ''; + fixtureWin.practicePageEnhancer.notifyParent = (type, payload) => { + const correlated = Object.assign({}, payload || {}, { + windowSessionToken: hostSession.windowSessionToken + }); + if (type === 'PRACTICE_COMPLETE') { + submissionId = submissionId || `e2e-inline-submit-${Date.now()}`; + correlated.submissionId = correlated.submissionId || submissionId; + } + return originalNotifyParent(type, correlated); + }; await this.waitFor(() => fixtureDoc.getElementById('submit-btn'), { timeout: 12000, @@ -1395,83 +1303,16 @@ class AppE2ETestSuite { submitBtn.click(); - const waitForRecordReady = async (timeout, description) => { - await this.waitFor(() => { - const records = this.win.app?.getState?.('practice.records'); - if (!Array.isArray(records) || records.length === 0) { - return false; - } - const record = records[0]; - return record && record.dataSource === 'real' && typeof record.title === 'string' && record.title.includes('Fear'); - }, { timeout, description }); - }; - - try { - await waitForRecordReady(15000, '等待练习记录写入'); - } catch (waitError) { - const enhancer = fixtureWin.practicePageEnhancer || {}; - try { - const score = typeof enhancer.extractScore === 'function' ? enhancer.extractScore() : (enhancer.scoreInfo || null); - const answers = enhancer.answers || {}; - const correctAnswers = enhancer.correctAnswers || {}; - const interactions = enhancer.interactions || []; - const resolvedAccuracy = typeof score?.accuracy === 'number' ? score.accuracy : (typeof score?.percentage === 'number' ? score.percentage / 100 : 0.85); - const resolvedPercentage = typeof score?.percentage === 'number' ? score.percentage : Math.round(resolvedAccuracy * 100); - const resolvedTotal = typeof score?.total === 'number' ? score.total : (Object.keys(answers).length || 40); - const resolvedCorrect = typeof score?.correct === 'number' ? score.correct : Math.round(resolvedTotal * resolvedAccuracy); - const resolvedDuration = typeof score?.duration === 'number' ? score.duration : 1800; - const fallbackSessionId = enhancer.sessionId || `e2e-fallback-${Date.now()}`; - const fallbackRecord = { - id: fallbackSessionId, - examId: targetExam.id, - title: targetExam.title || 'E2E Practice Fallback', - category: targetExam.category || 'P1', - frequency: targetExam.frequency || 'high', - status: 'completed', - accuracy: resolvedAccuracy, - percentage: resolvedPercentage, - score: resolvedCorrect, - type: targetExam.type || 'reading', - duration: resolvedDuration, - correctAnswers: resolvedCorrect, - totalQuestions: resolvedTotal, - date: new Date().toISOString(), - dataSource: 'real', - sessionId: fallbackSessionId, - timestamp: Date.now(), - realData: { - score: resolvedCorrect, - totalQuestions: resolvedTotal, - accuracy: resolvedAccuracy, - percentage: resolvedPercentage, - duration: resolvedDuration, - answers, - correctAnswers, - interactions, - isRealData: true, - source: 'e2e-fallback' - }, - scoreInfo: { - correct: resolvedCorrect, - total: resolvedTotal, - accuracy: resolvedAccuracy, - percentage: resolvedPercentage, - source: 'e2e-fallback' - } - }; - - await this.win.simpleStorageWrapper.savePracticeRecords([fallbackRecord]); - if (typeof this.win.syncPracticeRecords === 'function') { - await this.win.syncPracticeRecords(); - } - await waitForRecordReady(8000, '等待练习记录写入(回退)'); - } catch (fallbackError) { - throw fallbackError instanceof Error ? fallbackError : waitError; - } - } - - const records = this.win.app.getState('practice.records') || []; - const latest = records[0] || null; + const records = await this.waitForAsync(async () => { + const current = await this.win.AppData.practice.list({ projection: 'full' }); + const committed = current.find((record) => record + && record.dataSource === 'real' + && record.realData?.isRealData === true); + return committed ? current : null; + }, { timeout: 15000, description: '等待练习记录写入' }); + const latest = records.find((record) => record + && record.dataSource === 'real' + && record.realData?.isRealData === true) || null; await this.waitFor(() => { const item = this.doc.querySelector('#history-list .history-item'); diff --git a/developer/tests/js/e2e/interactionTargets.js b/developer/tests/js/e2e/interactionTargets.js index 99b426b8..e923629a 100644 --- a/developer/tests/js/e2e/interactionTargets.js +++ b/developer/tests/js/e2e/interactionTargets.js @@ -7,6 +7,7 @@ "practice-settings-entry-btn", "library-manager-btn", "show-onboarding-btn", + "external-backup-entry-btn", "create-backup-btn", "backup-list-btn", "export-data-btn", diff --git a/developer/tests/js/environmentDetector.test.js b/developer/tests/js/environmentDetector.test.js new file mode 100644 index 00000000..c743922e --- /dev/null +++ b/developer/tests/js/environmentDetector.test.js @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../../..'); +const source = fs.readFileSync(path.join(repoRoot, 'js/utils/environmentDetector.js'), 'utf8'); + +function loadDetector({ search = '', hash = '', force = false } = {}) { + const windowStub = { + location: { search, hash }, + navigator: { + userAgent: 'Mozilla/5.0 HeadlessChrome/140.0 Playwright' + }, + __IELTS_FORCE_TEST_ENV__: force + }; + vm.runInContext(source, vm.createContext({ + window: windowStub, + globalThis: windowStub + }), { filename: 'js/utils/environmentDetector.js' }); + return windowStub.EnvironmentDetector; +} + +assert.strictEqual( + loadDetector().isInTestEnvironment(), + false, + 'automation user agents must run production synthetic-session policy by default' +); +assert.strictEqual( + loadDetector({ search: '?test_env=1' }).isInTestEnvironment(), + true, + 'explicit test_env query remains an opt-in' +); +assert.strictEqual( + loadDetector({ force: true }).isInTestEnvironment(), + true, + 'explicit runtime test flag remains an opt-in' +); + +console.log(JSON.stringify({ + status: 'pass', + detail: 'test environment activation is explicit and not inferred from automation UA' +})); diff --git a/developer/tests/js/examAutomationTest.js b/developer/tests/js/examAutomationTest.js index defdbd03..fb565e1d 100644 --- a/developer/tests/js/examAutomationTest.js +++ b/developer/tests/js/examAutomationTest.js @@ -53,7 +53,7 @@ class ExamAutomationTest { const checks = [ { name: 'COMPLETE_EXAM_DATA', exists: typeof window.COMPLETE_EXAM_DATA !== 'undefined' }, { name: 'LISTENING_EXAM_DATA', exists: typeof window.LISTENING_EXAM_DATA !== 'undefined' }, - { name: 'examIndex', exists: typeof window.examIndex !== 'undefined' }, + { name: 'resolveActiveLibraryIndex', exists: typeof window.resolveActiveLibraryIndex === 'function' }, { name: 'currentCategory', exists: typeof window.currentCategory !== 'undefined' }, { name: 'currentExamType', exists: typeof window.currentExamType !== 'undefined' } ]; @@ -375,12 +375,13 @@ class ExamAutomationTest { try { const consistencyChecks = []; + const activeIndex = await window.resolveActiveLibraryIndex(); // 检查考试索引一致性 - if (window.examIndex && Array.isArray(window.examIndex)) { - const indexConsistent = window.examIndex.every(examId => { - const existsInComplete = this.testData.completeExamData?.some(exam => exam.id === examId); - const existsInListening = this.testData.listeningExamData?.some(exam => exam.id === examId); + if (Array.isArray(activeIndex)) { + const indexConsistent = activeIndex.every(entry => { + const existsInComplete = this.testData.completeExamData?.some(exam => exam.id === entry.id); + const existsInListening = this.testData.listeningExamData?.some(exam => exam.id === entry.id); return existsInComplete || existsInListening; }); @@ -388,7 +389,7 @@ class ExamAutomationTest { name: 'examIndex一致性', passed: indexConsistent, details: { - totalInIndex: window.examIndex.length, + totalInIndex: activeIndex.length, consistent: indexConsistent } }); @@ -398,11 +399,10 @@ class ExamAutomationTest { const categoriesFromData = this.testData.loadedCategories; const categoriesFromIndex = new Set(); - if (window.examIndex && this.testData.completeExamData) { - window.examIndex.forEach(examId => { - const exam = this.testData.completeExamData.find(e => e.id === examId); - if (exam && exam.category) { - categoriesFromIndex.add(exam.category); + if (Array.isArray(activeIndex) && this.testData.completeExamData) { + activeIndex.forEach(entry => { + if (entry && entry.category) { + categoriesFromIndex.add(entry.category); } }); } @@ -532,4 +532,4 @@ class ExamAutomationTest { // 导出供使用 if (typeof module !== 'undefined' && module.exports) { module.exports = ExamAutomationTest; -} \ No newline at end of file +} diff --git a/developer/tests/js/examFilterService.test.js b/developer/tests/js/examFilterService.test.js index 6cccbb1a..7f564f39 100644 --- a/developer/tests/js/examFilterService.test.js +++ b/developer/tests/js/examFilterService.test.js @@ -86,9 +86,6 @@ function createExamActionsHarness() { document: documentStub, setupBrowseControls() { setupCalls += 1; - }, - getExamIndexState() { - return []; } }; const silentConsole = { log() {}, warn() {}, error() {}, info() {} }; @@ -169,7 +166,7 @@ function testLoadExamListBindsBrowseControls() { assert(harness.window.ExamActions, 'ExamActions 应该挂到 window'); assert.strictEqual(typeof harness.window.ExamActions.loadExamList, 'function', 'loadExamList 应为函数'); - harness.window.ExamActions.loadExamList(); + harness.window.ExamActions.loadExamList([]); assert.strictEqual(harness.getSetupCalls(), 1, 'ExamActions.loadExamList 必须先绑定浏览控件'); } diff --git a/developer/tests/js/examPlaceholderReplay.test.js b/developer/tests/js/examPlaceholderReplay.test.js index e92d54b6..1d403c29 100644 --- a/developer/tests/js/examPlaceholderReplay.test.js +++ b/developer/tests/js/examPlaceholderReplay.test.js @@ -101,6 +101,7 @@ function createHarness() { const listeners = new Map(); const documentStub = { body: new ElementStub('body'), + referrer: '', getElementById(id) { return ensureElement(id); }, @@ -119,7 +120,8 @@ function createHarness() { const windowStub = { location: { search: '?test_env=1&examId=reading-p1&title=Passage%201&category=P1&suiteSessionId=suite-1', - href: '' + href: 'https://child.example/templates/exam-placeholder.html', + protocol: 'https:' }, opener: { postMessage(message) { @@ -127,11 +129,6 @@ function createHarness() { } }, parent: null, - localStorage: { - getItem() { - return null; - } - }, EnvironmentDetector: { isInTestEnvironment() { return true; @@ -173,17 +170,36 @@ function createHarness() { filename: 'templates/exam-placeholder.html:inline' }); + const dispatchHostMessage = (message) => { + const handler = listeners.get('message'); + if (!handler) throw new Error('message handler was not registered'); + const data = Object.assign({}, message.data || {}, { + windowSessionToken: 'placeholder-test-token' + }); + handler({ + data: Object.assign({}, message, { source: 'exam_host', data }), + source: windowStub.opener, + origin: 'https://host.example' + }); + }; + dispatchHostMessage({ + type: 'INIT_SESSION', + data: { + examId: 'reading-p1', + sessionId: 'placeholder-session', + suiteSessionId: 'suite-1', + parentOrigin: 'https://host.example' + } + }); + openerMessages.length = 0; + return { document: documentStub, body: documentStub.body, elements, openerMessages, sendMessage(message) { - const handler = listeners.get('message'); - if (!handler) { - throw new Error('message handler was not registered'); - } - handler({ data: message }); + dispatchHostMessage(message); } }; } @@ -303,11 +319,23 @@ function testNonLastSimulationSubmitNavigatesInsteadOfFinalSubmit() { assert.strictEqual(harness.body.dataset.examState, '已提交本篇,等待下一篇...', '非末篇提交后应等待父页切篇'); } +function testPracticeCompleteCarriesSubmissionContract() { + const harness = createHarness(); + harness.elements.get('complete-exam-btn').click(); + + const message = harness.openerMessages.find((item) => item && item.type === 'PRACTICE_COMPLETE'); + assert(message, '占位页普通提交必须发送 PRACTICE_COMPLETE'); + assert.strictEqual(message.data.sessionId, 'placeholder-session'); + assert.strictEqual(message.data.windowSessionToken, 'placeholder-test-token'); + assert.match(message.data.submissionId, /^placeholder-submit-/); +} + function run() { testReplayUsesCanonicalCorrectAnswerMap(); testReplayRefusesLegacyCorrectAnswerFallbacks(); testReplayKeepsSuitePrefixedQuestionNumbers(); testNonLastSimulationSubmitNavigatesInsteadOfFinalSubmit(); + testPracticeCompleteCarriesSubmissionContract(); process.stdout.write(JSON.stringify({ status: 'pass', diff --git a/developer/tests/js/externalBackupServiceV2.test.js b/developer/tests/js/externalBackupServiceV2.test.js new file mode 100644 index 00000000..65578a05 --- /dev/null +++ b/developer/tests/js/externalBackupServiceV2.test.js @@ -0,0 +1,535 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const serviceSource = fs.readFileSync(path.join(repoRoot, 'js/core/externalBackupService.js'), 'utf8'); + +function createIndexedDb() { + const values = new Map(); + const db = { + objectStoreNames: { contains: () => true }, + createObjectStore() {}, + transaction() { + const tx = { + error: null, + objectStore() { + return { + get(key) { + const request = {}; + queueMicrotask(() => { + request.result = values.get(key); + if (request.onsuccess) request.onsuccess({ target: request }); + }); + return request; + }, + put(value, key) { + values.set(key, value); + return {}; + }, + delete(key) { + values.delete(key); + return {}; + } + }; + } + }; + queueMicrotask(() => { + if (tx.oncomplete) tx.oncomplete(); + }); + return tx; + }, + close() {} + }; + return { + open() { + const request = {}; + queueMicrotask(() => { + request.result = db; + if (request.onsuccess) request.onsuccess({ target: request }); + }); + return request; + }, + values + }; +} + +function createDirectory(name = 'atlas-backups') { + const files = new Map(); + const state = { + permission: 'granted', + permissionRequests: 0, + failWrites: false, + beforeClose: null, + onClose: null + }; + return { + kind: 'directory', + name, + files, + state, + async queryPermission() { + return state.permission; + }, + async requestPermission() { + state.permissionRequests += 1; + return state.permission; + }, + async getFileHandle(filename, options = {}) { + if (!files.has(filename) && options.create !== true) { + const error = new Error('not found'); + error.name = 'NotFoundError'; + throw error; + } + if (!files.has(filename)) files.set(filename, ''); + return { + async createWritable() { + let pending = ''; + return { + async write(text) { + if (state.failWrites) throw new Error('disk full'); + pending = String(text); + }, + async close() { + if (typeof state.beforeClose === 'function') await state.beforeClose(filename); + files.set(filename, pending); + if (typeof state.onClose === 'function') state.onClose(filename); + }, + async abort() {} + }; + }, + async getFile() { + return { + name: filename, + async text() { + return files.get(filename); + } + }; + } + }; + } + }; +} + +function makeSnapshot(checksum, theme = 'dark') { + return { + format: 'ielts-atlas-data-v2', + schemaVersion: 2, + scope: 'full', + createdAt: '2026-07-26T00:00:00.000Z', + backend: 'indexeddb-v2', + envelopes: { + 'preferences.values': { + schemaVersion: 2, + logicalKey: 'preferences.values', + state: 'present', + revision: 1, + updatedAt: '2026-07-26T00:00:00.000Z', + operationId: 'test', + checksum: 'envelope', + data: { theme } + } + }, + checksum + }; +} + +function createHarness(options = {}) { + const indexedDB = options.indexedDB || createIndexedDb(); + const directory = options.directory || createDirectory(); + const testConsole = Object.assign({}, console, { error() {}, warn() {} }); + const timers = []; + const calls = { + preview: [], + create: [], + commit: [], + history: [] + }; + let snapshot = options.snapshot || makeSnapshot('fnv1a-first'); + let committedListener = null; + let pickerResult = directory; + + const backups = { + onDataCommitted(listener) { + committedListener = listener; + return () => { committedListener = null; }; + }, + async export() { + return JSON.parse(JSON.stringify(snapshot)); + }, + async previewImport(payload, options) { + calls.preview.push({ payload, options }); + return { + id: 'plan-1', + format: payload.format === 'ielts-atlas-data-v2' ? 'v2' : 'legacy', + keys: ['preferences.values', 'practice.records'], + clearedKeys: [], + warnings: [], + practice: { existingCount: 5, finalCount: 3, removedCount: 2 }, + destructive: true + }; + }, + async create(options) { + calls.create.push(options); + return { id: 'pre-backup' }; + }, + async commitImport(planId, options) { + calls.commit.push({ planId, options }); + return { committed: true }; + }, + async recordImport(entry) { + calls.history.push(entry); + } + }; + + const windowStub = { + console: testConsole, + indexedDB, + isSecureContext: true, + showDirectoryPicker: async () => pickerResult, + navigator: { + storage: { + async persisted() { return true; }, + async persist() { return true; } + } + }, + AppData: { ready: options.appDataReady || Promise.resolve(true), backups }, + crypto: { randomUUID: () => 'uuid-1' }, + confirm: () => true, + setTimeout(callback, delay) { + const timer = { callback, delay, cancelled: false }; + timers.push(timer); + return timer; + }, + clearTimeout(timer) { + if (timer) timer.cancelled = true; + }, + document: null + }; + const context = vm.createContext({ + window: windowStub, + globalThis: windowStub, + console: testConsole, + Promise, + Date, + JSON, + Math + }); + vm.runInContext(serviceSource, context, { filename: 'js/core/externalBackupService.js' }); + + return { + service: windowStub.ExternalBackupService, + directory, + indexedDB, + calls, + async ready() { + await windowStub.ExternalBackupService.ensureReady(); + await Promise.resolve(); + }, + setSnapshot(next) { + snapshot = next; + }, + setPickerResult(next) { + pickerResult = next; + }, + emitCommitted(event = {}) { + assert.equal(typeof committedListener, 'function'); + committedListener({ + operationId: event.operationId || 'domain-commit', + targets: Array.isArray(event.targets) ? event.targets : [], + remote: event.remote === true + }); + }, + async flushTimers() { + while (timers.length) { + const timer = timers.shift(); + if (!timer.cancelled) await timer.callback(); + } + } + }; +} + +async function testBindingWritesVerifiedV2Snapshots() { + const harness = createHarness(); + await harness.ready(); + + const result = await harness.service.bindDirectory({ writeNow: true }); + assert.equal(result.directoryName, 'atlas-backups'); + assert.equal(result.writeResult.success, true); + assert.ok(harness.directory.files.has('ielts-atlas-backup-latest.json')); + assert.ok(Array.from(harness.directory.files.keys()).some((name) => /^ielts-atlas-backup-\d{4}-\d{2}-\d{2}\.json$/.test(name))); + + const latest = JSON.parse(harness.directory.files.get('ielts-atlas-backup-latest.json')); + assert.equal(latest.format, 'ielts-atlas-data-v2'); + assert.equal(latest.checksum, 'fnv1a-first'); + assert.equal(harness.service.getStatus().lastChecksum, 'fnv1a-first'); + assert.ok(harness.indexedDB.values.get('directory-handle'), 'directory handle must persist outside AppData JSON'); +} + +async function testCommittedDataDebouncesSilentWriteWithoutPrompt() { + const harness = createHarness(); + await harness.ready(); + await harness.service.bindDirectory({ writeNow: true }); + + harness.directory.state.permission = 'granted'; + harness.setSnapshot(makeSnapshot('fnv1a-second', 'light')); + harness.emitCommitted({ + operationId: 'child-realm-commit', + targets: [{ logicalKey: 'vocab.collection.reading-highlights' }], + remote: true + }); + assert.equal(harness.service.getStatus().dirty, true); + await harness.flushTimers(); + assert.equal(harness.service.getStatus().dirty, false); + assert.equal(harness.service.getStatus().lastChecksum, 'fnv1a-second'); + assert.equal(harness.directory.state.permissionRequests, 0, 'silent writes must never request permission'); + + harness.directory.state.permission = 'prompt'; + harness.setSnapshot(makeSnapshot('fnv1a-third', 'blue')); + harness.emitCommitted(); + await harness.flushTimers(); + assert.equal(harness.service.getStatus().dirty, true); + assert.equal(harness.directory.state.permissionRequests, 0); +} + +async function testRestoreUsesPreviewSafetyBackupAndAtomicCommit() { + const harness = createHarness(); + await harness.ready(); + await harness.service.bindDirectory({ writeNow: true }); + + const restored = await harness.service.restoreFromLatest(); + assert.equal(restored.success, true); + assert.equal(harness.calls.preview.length, 1); + assert.deepEqual(JSON.parse(JSON.stringify(harness.calls.preview[0].options)), { + replace: true, + practiceMode: 'replace', + applyClears: true, + fullRestore: true + }); + assert.equal(harness.calls.create.length, 1); + assert.equal(harness.calls.create[0].type, 'pre-external-restore'); + assert.equal(harness.calls.commit.length, 1); + assert.equal(harness.calls.commit[0].planId, 'plan-1'); + assert.equal(harness.calls.commit[0].options.confirmDestructive, true); + assert.equal(harness.calls.history.length, 1); + assert.equal(harness.calls.history[0].source, 'external-backup'); +} + +async function testCommitDuringWriteKeepsDirtyAndWritesFollowup() { + const harness = createHarness(); + await harness.ready(); + await harness.service.bindDirectory({ writeNow: true }); + harness.setSnapshot(makeSnapshot('fnv1a-race-start', 'race-start')); + harness.service.markDirty(); + + let injected = false; + harness.directory.state.onClose = (filename) => { + if (injected || filename === 'ielts-atlas-backup-latest.json') return; + injected = true; + harness.setSnapshot(makeSnapshot('fnv1a-race-latest', 'race-latest')); + harness.emitCommitted(); + }; + const firstWrite = await harness.service.writeNow(); + harness.directory.state.onClose = null; + assert.equal(firstWrite.success, true); + assert.equal(firstWrite.followupPending, true); + assert.equal(harness.service.getStatus().dirty, true, + 'a commit that lands during disk I/O must not be cleared by the older write'); + + await harness.flushTimers(); + assert.equal(harness.service.getStatus().dirty, false); + assert.equal(harness.service.getStatus().lastChecksum, 'fnv1a-race-latest'); +} + +async function testReloadDetectsStaleDiskSnapshot() { + const first = createHarness(); + await first.ready(); + await first.service.bindDirectory({ writeNow: true }); + + const second = createHarness({ + indexedDB: first.indexedDB, + directory: first.directory, + snapshot: makeSnapshot('fnv1a-after-reload', 'after-reload') + }); + await second.ready(); + assert.equal(second.service.getStatus().bound, true); + assert.equal(second.service.getStatus().dirty, true, + 'startup must compare the current AppData snapshot with the last disk checksum'); + await second.flushTimers(); + assert.equal(second.service.getStatus().dirty, false); + assert.equal(second.service.getStatus().lastChecksum, 'fnv1a-after-reload'); +} + +async function testWriteFailureIsReportedWithoutClearingDirtyState() { + const harness = createHarness(); + await harness.ready(); + await harness.service.bindDirectory({ writeNow: true }); + harness.directory.state.failWrites = true; + harness.setSnapshot(makeSnapshot('fnv1a-failed', 'failed')); + harness.service.markDirty(); + + const result = await harness.service.writeNow(); + assert.equal(result.success, false); + assert.equal(result.reason, 'write_error'); + assert.equal(harness.service.getStatus().dirty, true); + assert.match(harness.service.getStatus().lastWriteError, /disk full/); +} + +async function testUnbindClearsOnlyLocalBinding() { + const harness = createHarness(); + await harness.ready(); + await harness.service.bindDirectory({ writeNow: true }); + const diskCopy = harness.directory.files.get('ielts-atlas-backup-latest.json'); + await harness.service.unbindDirectory(); + + assert.equal(harness.service.getStatus().bound, false); + assert.equal(harness.indexedDB.values.has('directory-handle'), false); + assert.equal(harness.directory.files.get('ielts-atlas-backup-latest.json'), diskCopy, 'unbind must not delete disk files'); +} + +async function testOtherTabUnbindStopsStaleHandleWrites() { + const first = createHarness(); + await first.ready(); + await first.service.bindDirectory({ writeNow: true }); + const second = createHarness({ + indexedDB: first.indexedDB, + directory: first.directory + }); + await second.ready(); + await second.service.unbindDirectory(); + + const staleTabWrite = await first.service.writeNow(); + assert.equal(staleTabWrite.success, false); + assert.equal(staleTabWrite.reason, 'unbound', + 'each disk write must re-read the shared binding after another tab unbinds'); +} + +async function testFullResetSuspendsWritesAndPreservesDiskFiles() { + const harness = createHarness(); + await harness.ready(); + await harness.service.bindDirectory({ writeNow: true }); + const diskCopy = new Map(harness.directory.files); + + harness.setSnapshot(makeSnapshot('fnv1a-pending-reset', 'pending-reset')); + harness.emitCommitted(); + const result = await harness.service.prepareForFullReset(); + await harness.flushTimers(); + + assert.equal(result.success, true); + assert.equal(result.diskFilesPreserved, true); + assert.equal(harness.service.getStatus().bound, false); + assert.equal(harness.service.getStatus().suspended, true); + assert.equal(harness.indexedDB.values.has('directory-handle'), false); + assert.deepEqual(harness.directory.files, diskCopy, + 'full reset preparation must cancel pending writes and preserve every disk file'); + + harness.service.markDirty(); + const writeAfterReset = await harness.service.writeNow(); + assert.equal(writeAfterReset.success, false); + assert.equal(writeAfterReset.reason, 'suspended'); + assert.deepEqual(harness.directory.files, diskCopy); +} + +async function testBindingExistingBackupRequiresRestoreBeforeAnyWrite() { + const directory = createDirectory(); + const originalText = JSON.stringify(makeSnapshot('fnv1a-existing', 'existing')); + directory.files.set('ielts-atlas-backup-latest.json', originalText); + directory.files.set('ielts-atlas-backup-2026-07-26.json', originalText); + const harness = createHarness({ directory }); + await harness.ready(); + + const bound = await harness.service.bindDirectory({ writeNow: true }); + assert.equal(bound.existingBackupFound, true); + assert.equal(bound.writeResult, null); + assert.equal(harness.service.getStatus().awaitingRestore, true); + assert.equal(directory.files.get('ielts-atlas-backup-latest.json'), originalText); + assert.equal(directory.files.get('ielts-atlas-backup-2026-07-26.json'), originalText); + + const blockedWrite = await harness.service.writeNow(); + assert.equal(blockedWrite.success, false); + assert.equal(blockedWrite.reason, 'restore_required'); + assert.equal(directory.files.get('ielts-atlas-backup-latest.json'), originalText); + + const restored = await harness.service.restoreFromLatest({ confirmed: true }); + assert.equal(restored.success, true); + assert.equal(harness.service.getStatus().awaitingRestore, false); + assert.equal(directory.files.get('ielts-atlas-backup-latest.json'), originalText); +} + +async function testFullResetWorksWhenAppDataReadyRejects() { + const harness = createHarness({ + appDataReady: Promise.reject(new Error('corrupt AppData initialization')) + }); + const result = await harness.service.prepareForFullReset(); + assert.equal(result.success, true); + assert.equal(harness.service.getStatus().suspended, true); + assert.equal(harness.indexedDB.values.size, 0); +} + +async function testFullResetWaitsForInFlightPreResetWrite() { + const harness = createHarness(); + await harness.ready(); + await harness.service.bindDirectory({ writeNow: true }); + harness.setSnapshot(makeSnapshot('fnv1a-in-flight-before-reset', 'in-flight')); + harness.service.markDirty(); + + let releaseWrite; + let announceWriteStarted; + const writeGate = new Promise((resolve) => { releaseWrite = resolve; }); + const writeStarted = new Promise((resolve) => { announceWriteStarted = resolve; }); + let gated = false; + harness.directory.state.beforeClose = async (filename) => { + if (gated || filename === 'ielts-atlas-backup-latest.json') return; + gated = true; + announceWriteStarted(); + await writeGate; + }; + + const writePromise = harness.service.writeNow(); + await writeStarted; + let resetSettled = false; + const resetPromise = harness.service.prepareForFullReset().then((result) => { + resetSettled = true; + return result; + }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(harness.service.getStatus().suspended, true); + assert.equal(resetSettled, false, 'full reset must wait for the in-flight disk write lock'); + + releaseWrite(); + const writeResult = await writePromise; + const resetResult = await resetPromise; + harness.directory.state.beforeClose = null; + assert.equal(writeResult.success, true); + assert.equal(resetResult.success, true); + assert.equal(harness.service.getStatus().bound, false); + const latest = JSON.parse(harness.directory.files.get('ielts-atlas-backup-latest.json')); + assert.equal(latest.checksum, 'fnv1a-in-flight-before-reset', + 'only the pre-reset snapshot may finish writing before the binding is cleared'); +} + +async function main() { + await testBindingWritesVerifiedV2Snapshots(); + await testCommittedDataDebouncesSilentWriteWithoutPrompt(); + await testRestoreUsesPreviewSafetyBackupAndAtomicCommit(); + await testCommitDuringWriteKeepsDirtyAndWritesFollowup(); + await testReloadDetectsStaleDiskSnapshot(); + await testWriteFailureIsReportedWithoutClearingDirtyState(); + await testUnbindClearsOnlyLocalBinding(); + await testOtherTabUnbindStopsStaleHandleWrites(); + await testFullResetSuspendsWritesAndPreservesDiskFiles(); + await testBindingExistingBackupRequiresRestoreBeforeAnyWrite(); + await testFullResetWorksWhenAppDataReadyRejects(); + await testFullResetWaitsForInFlightPreResetWrite(); + console.log('ExternalBackupService v2 tests passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/developer/tests/js/fullLibraryRecordMatching.test.js b/developer/tests/js/fullLibraryRecordMatching.test.js index 59aab365..f45785d0 100644 --- a/developer/tests/js/fullLibraryRecordMatching.test.js +++ b/developer/tests/js/fullLibraryRecordMatching.test.js @@ -1,8 +1,8 @@ 'use strict'; /** - * 全量题库记录匹配测试 - * 确保 AnswerComparisonUtils 的全量题库匹配与 metadata 填充逻辑在 Node 环境下可被静态校验。 + * 记录元数据补全契约测试 + * AnswerComparisonUtils 只能使用记录快照和调用方显式解析出的 exam definition。 */ import path from 'path'; @@ -17,22 +17,6 @@ const AnswerComparisonUtils = require(path.join(__dirname, '..', '..', '..', 'js const results = []; -function resetGlobalIndexes() { - delete global.getReadingExamIndex; - delete global.__READING_EXAM_INDEX__; - delete global.examIndex; - delete global.readingExamIndex; - delete global.listeningExamIndex; - delete global.fullExamIndex; - delete global.practiceExamIndex; -} - -function setReadingManifestIndex(items) { - const index = Array.isArray(items) ? items : []; - global.__READING_EXAM_INDEX__ = index; - global.getReadingExamIndex = () => index.map((entry) => Object.assign({}, entry)); -} - function recordResult(name, passed, detail) { results.push({ name, passed, detail, timestamp: new Date().toISOString() }); } @@ -49,31 +33,22 @@ function assertTruthy(value, message) { } } -async function testUrlPathMatching() { - const testName = 'URL 路径匹配填充 metadata'; - resetGlobalIndexes(); - - setReadingManifestIndex([ - { - id: 'custom_listening_1699999999_0', - title: 'City Development', - category: 'P4', - type: 'listening', - path: 'ListeningPractice/P4/2. PART4 City Development/', - frequency: 'high' - } - ]); - +async function testExplicitExamDefinitionFillsMissingMetadata() { + const testName = '显式 exam definition 填充缺失 metadata'; const record = { - examId: 'p4-city-development', - url: 'file:///tmp/ListeningPractice/P4/2.%20PART4%20City%20Development/2.%20PART4%20City%20Development.html', - title: 'City Development' + examId: 'source-exam' }; + const examDefinition = { + id: 'source-exam', + title: 'Source title', + category: 'P4', + type: 'listening', + frequency: 'high' + }; + const enriched = AnswerComparisonUtils.withEnrichedMetadata(record, examDefinition); - const enriched = AnswerComparisonUtils.withEnrichedMetadata(record); - - assertStrictEqual(enriched.metadata.examTitle, 'City Development', '应通过路径匹配补齐 examTitle'); - assertStrictEqual(enriched.metadata.category, 'P4', '应通过路径匹配补齐 category'); + assertStrictEqual(enriched.metadata.examTitle, 'Source title', '应从显式 exam definition 补齐 examTitle'); + assertStrictEqual(enriched.metadata.category, 'P4', '应从显式 exam definition 补齐 category'); assertStrictEqual(enriched.metadata.frequency, 'high', '应透传频次信息'); assertStrictEqual(enriched.category, 'P4', '记录应同步更新 category'); assertTruthy(enriched.metadata.__enrichedMetadata, 'metadata 应标记为已填充'); @@ -81,40 +56,34 @@ async function testUrlPathMatching() { recordResult(testName, true, { enriched }); } -async function testFuzzyTitleMatching() { - const testName = '模糊标题匹配带标签前缀'; - resetGlobalIndexes(); - - setReadingManifestIndex([ - { - id: 'custom_listening_1699999999_1', - title: '[听力全量-2024-11-13] City Development', - category: 'P4', - type: 'listening', - path: 'ListeningPractice/P4/2. PART4 City Development/' - } - ]); - +async function testSavedMetadataWinsOverResolvedExam() { + const testName = '保存的 metadata 优先于解析出的题库条目'; const record = { - examId: 'unknown_id', - title: 'City Development' + examId: 'shared-id', + title: 'Saved title', + metadata: { + examTitle: 'Saved title', + category: 'P1', + frequency: 'saved-frequency' + } }; + const conflictingExam = { + id: 'shared-id', + title: 'Current library title', + category: 'P4', + frequency: 'current-frequency' + }; + const enriched = AnswerComparisonUtils.withEnrichedMetadata(record, conflictingExam); - const enriched = AnswerComparisonUtils.withEnrichedMetadata(record); - - assertStrictEqual(enriched.metadata.category, 'P4', '应通过模糊标题匹配补齐 category'); - assertStrictEqual( - enriched.metadata.examTitle, - '[听力全量-2024-11-13] City Development', - '应保留索引中的带标签标题' - ); + assertStrictEqual(enriched.metadata.category, 'P1', '显式题库条目不得覆盖历史 category'); + assertStrictEqual(enriched.metadata.examTitle, 'Saved title', '显式题库条目不得覆盖历史 title'); + assertStrictEqual(enriched.metadata.frequency, 'saved-frequency', '显式题库条目不得覆盖历史 frequency'); recordResult(testName, true, { enriched }); } async function testCategoryInferenceFromId() { const testName = 'examId 中的类别推断'; - resetGlobalIndexes(); const record = { examId: 'custom_reading_p2_section1', @@ -131,7 +100,6 @@ async function testCategoryInferenceFromId() { async function testEnrichedMetadataGuard() { const testName = '__enrichedMetadata 重入保护'; - resetGlobalIndexes(); const record = { examId: 'p3-saved-record', @@ -155,7 +123,6 @@ async function testEnrichedMetadataGuard() { async function testUnknownFallbacks() { const testName = '无索引信息时的兜底'; - resetGlobalIndexes(); const record = {}; @@ -170,8 +137,8 @@ async function testUnknownFallbacks() { async function runAllTests() { const suite = [ - testUrlPathMatching, - testFuzzyTitleMatching, + testExplicitExamDefinitionFillsMissingMetadata, + testSavedMetadataWinsOverResolvedExam, testCategoryInferenceFromId, testEnrichedMetadataGuard, testUnknownFallbacks diff --git a/developer/tests/js/indexInteractionsSearchEnter.test.js b/developer/tests/js/indexInteractionsSearchEnter.test.js index 953b0018..58292fd0 100644 --- a/developer/tests/js/indexInteractionsSearchEnter.test.js +++ b/developer/tests/js/indexInteractionsSearchEnter.test.js @@ -57,11 +57,16 @@ function createHarness() { matchMedia() { return { matches: false }; }, - localStorage: { - getItem() { - return 'true'; - }, - setItem() {} + AppData: { + ready: Promise.resolve(true), + preferences: { + async getConsent() { + return { hasSeenGplLicense: true }; + }, + async setConsent() { + return { committed: true }; + } + } } }; const sandbox = { diff --git a/developer/tests/js/integration/README.md b/developer/tests/js/integration/README.md index e135ba02..749abf89 100644 --- a/developer/tests/js/integration/README.md +++ b/developer/tests/js/integration/README.md @@ -1,38 +1,28 @@ # 集成测试 -本目录包含听力练习P1/P4集成功能的集成测试和性能基准测试。 +本目录包含词汇收集、词表切换和词汇会话视图的领域集成测试。 ## 测试文件 -### 1. multiSuiteSubmission.test.js -测试多套题提交流程,包括: -- 单套题提交 -- 多套题聚合 -- 记录保存 -- 拼写错误收集 - -### 2. spellingErrorCollection.test.js +### 1. spellingErrorCollection.test.js 测试拼写错误收集流程,包括: - 错误检测 - 词表保存 - 词表同步 - 综合词表更新 -### 3. vocabListSwitching.test.js +### 2. vocabListSwitching.test.js 测试词表切换流程,包括: - 词表加载 - UI更新 - 数据持久化 - 空词表处理 -### 4. performance.benchmark.js -性能基准测试,包括: -- 不同数据量的保存性能(10/100/1000个单词) -- 词表加载性能(小/大词表) -- 拼写错误检测性能 -- 多套题聚合性能 -- 编辑距离计算性能 -- 性能评分和评级 +### 3. vocabSessionView.test.js +测试词汇会话视图流程,包括: +- 词表导入和切换 +- 会话状态更新 +- AppData 领域提交 ## 运行测试 @@ -40,18 +30,15 @@ ```bash # 功能测试 -node developer/tests/js/integration/multiSuiteSubmission.test.js node developer/tests/js/integration/spellingErrorCollection.test.js node developer/tests/js/integration/vocabListSwitching.test.js - -# 性能测试 -node developer/tests/js/integration/performance.benchmark.js +node developer/tests/js/integration/vocabSessionView.test.js ``` ### 批量运行 ```bash -# Windows - 运行所有集成测试和性能测试 +# Windows - 运行所有集成测试 developer\tests\run-integration-tests.bat # 或通过CI脚本运行 @@ -73,48 +60,12 @@ python developer\tests\ci\run_static_suite.py } ``` -### 性能测试输出 - -```json -{ - "status": "pass", - "timestamp": "2025-01-01T00:00:00.000Z", - "benchmarks": [ - { - "name": "保存10个单词", - "iterations": 10, - "avgMs": "15.23", - "minMs": "12.45", - "maxMs": "18.90", - "totalMs": "152.30" - } - ], - "summary": { - "totalTests": 13, - "performanceScore": 85, - "rating": "良好" - } -} -``` - ## 测试覆盖 ### 功能测试 -- ✅ 多套题提交流程(8个测试场景) -- ✅ 拼写错误收集流程(12个测试场景) -- ✅ 词表切换流程(12个测试场景) - -总计:32个测试场景 - -### 性能测试 -- ✅ 数据保存性能(3个场景) -- ✅ 数据加载性能(2个场景) -- ✅ 错误检测性能(2个场景) -- ✅ 算法性能(2个场景) -- ✅ 聚合性能(2个场景) -- ✅ 其他操作(2个场景) - -总计:13个性能基准 +- 拼写错误收集流程 +- 词表切换流程 +- 词汇会话视图流程 ## CI/CD集成 @@ -130,7 +81,6 @@ python developer\tests\ci\run_static_suite.py 2. 测试使用Mock环境,不依赖浏览器 3. 测试相互独立,可以单独运行 4. 测试失败会输出详细的错误信息 -5. 性能测试结果可能因系统负载而波动 ## 相关文档 diff --git a/developer/tests/js/integration/multiSuiteSubmission.test.js b/developer/tests/js/integration/multiSuiteSubmission.test.js deleted file mode 100644 index e833b38a..00000000 --- a/developer/tests/js/integration/multiSuiteSubmission.test.js +++ /dev/null @@ -1,458 +0,0 @@ -#!/usr/bin/env node -/** - * 集成测试:多套题提交流程 - * - * 测试场景: - * 1. 单套题提交 - * 2. 多套题聚合 - * 3. 记录保存 - * 4. 拼写错误收集 - */ - -import assert from 'assert'; - -const originalConsoleLog = (console && typeof console.log === 'function') - ? console.log.bind(console) - : null; - -function emitResult(payload) { - const text = JSON.stringify(payload, null, 2); - try { - if (process && process.stdout && typeof process.stdout.write === 'function') { - process.stdout.write(text + '\n'); - return; - } - } catch (_) {} - if (typeof originalConsoleLog === 'function') { - originalConsoleLog(text); - } -} - -// Mock浏览器环境 -global.window = global; -global.document = { - querySelectorAll: () => [], - querySelector: () => null, - addEventListener: () => {}, - createElement: () => ({ - addEventListener: () => {}, - appendChild: () => {}, - setAttribute: () => {}, - classList: { add: () => {}, remove: () => {} } - }) -}; - -global.console = { - log: () => {}, - warn: () => {}, - error: () => {}, - info: () => {} -}; - -// Mock存储系统 -const mockStorage = new Map(); -global.storage = { - ready: Promise.resolve(), - setNamespace: () => {}, - get: async (key) => mockStorage.get(key), - set: async (key, value) => mockStorage.set(key, value), - remove: async (key) => mockStorage.delete(key) -}; - -// 测试数据 -const testData = { - examId: 'listening-p1-test-001', - suiteResults: [ - { - suiteId: 'set1', - examId: 'listening-p1-test-001', - answers: { q1: 'answer1', q2: 'answer2' }, - correctAnswers: { q1: 'answer1', q2: 'correct2' }, - answerComparison: { - q1: { userAnswer: 'answer1', correctAnswer: 'answer1', isCorrect: true }, - q2: { userAnswer: 'answer2', correctAnswer: 'correct2', isCorrect: false } - }, - scoreInfo: { correct: 1, total: 2, accuracy: 0.5, percentage: 50 }, - spellingErrors: [ - { - word: 'correct2', - userInput: 'answer2', - questionId: 'q2', - suiteId: 'set1', - examId: 'listening-p1-test-001', - timestamp: Date.now(), - errorCount: 1, - source: 'p1' - } - ], - timestamp: Date.now(), - duration: 300 - }, - { - suiteId: 'set2', - examId: 'listening-p1-test-001', - answers: { q1: 'answer1', q2: 'answer2' }, - correctAnswers: { q1: 'answer1', q2: 'answer2' }, - answerComparison: { - q1: { userAnswer: 'answer1', correctAnswer: 'answer1', isCorrect: true }, - q2: { userAnswer: 'answer2', correctAnswer: 'answer2', isCorrect: true } - }, - scoreInfo: { correct: 2, total: 2, accuracy: 1.0, percentage: 100 }, - spellingErrors: [], - timestamp: Date.now(), - duration: 250 - } - ] -}; - -// Mock suitePracticeMixin -const mockMixin = { - multiSuiteSessionsMap: new Map(), - - getOrCreateMultiSuiteSession(examId) { - if (!this.multiSuiteSessionsMap.has(examId)) { - const session = { - id: `multi_suite_${Date.now()}`, - baseExamId: examId, - startTime: Date.now(), - suiteResults: [], - expectedSuiteCount: 2, - status: 'active', - metadata: { source: 'p1' } - }; - this.multiSuiteSessionsMap.set(examId, session); - } - return this.multiSuiteSessionsMap.get(examId); - }, - - isMultiSuiteComplete(session) { - if (!session.expectedSuiteCount) { - return false; - } - return session.suiteResults.length >= session.expectedSuiteCount; - }, - - aggregateScores(suiteResults) { - let totalCorrect = 0; - let totalQuestions = 0; - - suiteResults.forEach(result => { - const scoreInfo = result.scoreInfo || {}; - totalCorrect += Number(scoreInfo.correct) || 0; - totalQuestions += Number(scoreInfo.total) || 0; - }); - - const accuracy = totalQuestions > 0 ? totalCorrect / totalQuestions : 0; - const percentage = Math.round(accuracy * 100); - - return { - correct: totalCorrect, - total: totalQuestions, - accuracy: accuracy, - percentage: percentage, - source: 'multi_suite_aggregated' - }; - }, - - aggregateAnswers(suiteResults) { - const aggregated = {}; - - suiteResults.forEach(result => { - const suiteId = result.suiteId || 'unknown'; - const answers = result.answers || {}; - - Object.entries(answers).forEach(([questionId, answer]) => { - const key = `${suiteId}::${questionId}`; - aggregated[key] = answer; - }); - }); - - return aggregated; - }, - - aggregateAnswerComparisons(suiteResults) { - const aggregated = {}; - - suiteResults.forEach(result => { - const suiteId = result.suiteId || 'unknown'; - const comparison = result.answerComparison || {}; - - Object.entries(comparison).forEach(([questionId, comparisonData]) => { - const key = `${suiteId}::${questionId}`; - aggregated[key] = comparisonData; - }); - }); - - return aggregated; - }, - - aggregateSpellingErrors(suiteResults) { - const aggregated = []; - const errorMap = new Map(); - - suiteResults.forEach(result => { - const errors = result.spellingErrors || []; - - errors.forEach(error => { - if (!error || !error.word) { - return; - } - - const key = error.word.toLowerCase(); - - if (errorMap.has(key)) { - const existing = errorMap.get(key); - const existingTimestamp = Number(existing.timestamp) || 0; - const incomingTimestamp = Number(error.timestamp) || 0; - existing.errorCount = (existing.errorCount || 1) + 1; - - if (incomingTimestamp >= existingTimestamp) { - existing.userInput = error.userInput; - existing.questionId = error.questionId || existing.questionId; - existing.suiteId = error.suiteId || result.suiteId || existing.suiteId; - existing.examId = error.examId || result.examId || existing.examId; - existing.acceptedAnswers = Array.isArray(error.acceptedAnswers) - ? error.acceptedAnswers.slice() - : existing.acceptedAnswers; - existing.canonicalAnswer = error.canonicalAnswer || existing.canonicalAnswer; - existing.reasonCode = error.reasonCode || existing.reasonCode; - existing.confidence = typeof error.confidence === 'number' ? error.confidence : existing.confidence; - existing.tokenIndex = Number.isFinite(error.tokenIndex) ? error.tokenIndex : existing.tokenIndex; - existing.metadata = error.metadata || existing.metadata; - } - existing.timestamp = Math.max(existingTimestamp, incomingTimestamp); - } else { - errorMap.set(key, { - word: error.word, - userInput: error.userInput, - questionId: error.questionId, - suiteId: error.suiteId || result.suiteId, - examId: error.examId || result.examId, - timestamp: error.timestamp || Date.now(), - errorCount: error.errorCount || 1, - source: error.source || this._detectMultiSuiteSource(result.examId), - acceptedAnswers: Array.isArray(error.acceptedAnswers) ? error.acceptedAnswers.slice() : undefined, - canonicalAnswer: error.canonicalAnswer, - reasonCode: error.reasonCode, - confidence: typeof error.confidence === 'number' ? error.confidence : undefined, - tokenIndex: Number.isFinite(error.tokenIndex) ? error.tokenIndex : undefined, - metadata: error.metadata - }); - } - }); - }); - - errorMap.forEach(error => aggregated.push(error)); - return aggregated; - }, - - async _saveSuitePracticeRecord(record) { - await global.storage.set(`practice_record_${record.id}`, record); - return true; - }, - - _formatSuiteDateLabel(timestamp) { - const date = new Date(timestamp); - return `${date.getMonth() + 1}月${date.getDate()}日`; - }, - - _detectMultiSuiteSource(examId) { - const lowerExamId = (examId || '').toLowerCase(); - if (lowerExamId.includes('p1')) return 'p1'; - if (lowerExamId.includes('p4')) return 'p4'; - return 'other'; - } -}; - -// 测试套件 -async function runTests() { - const results = []; - - try { - // 测试1: 单套题提交 - console.log('测试1: 单套题提交'); - const session1 = mockMixin.getOrCreateMultiSuiteSession(testData.examId); - session1.suiteResults.push(testData.suiteResults[0]); - - assert.strictEqual(session1.suiteResults.length, 1, '应该有1个套题结果'); - assert.strictEqual(session1.suiteResults[0].suiteId, 'set1', '套题ID应该是set1'); - assert.strictEqual(mockMixin.isMultiSuiteComplete(session1), false, '单套题不应该完成'); - - results.push({ name: '单套题提交', status: 'pass' }); - - // 测试2: 多套题聚合 - console.log('测试2: 多套题聚合'); - session1.suiteResults.push(testData.suiteResults[1]); - - assert.strictEqual(session1.suiteResults.length, 2, '应该有2个套题结果'); - assert.strictEqual(mockMixin.isMultiSuiteComplete(session1), true, '两套题应该完成'); - - const aggregatedScores = mockMixin.aggregateScores(session1.suiteResults); - assert.strictEqual(aggregatedScores.correct, 3, '总正确数应该是3'); - assert.strictEqual(aggregatedScores.total, 4, '总题数应该是4'); - assert.strictEqual(aggregatedScores.percentage, 75, '正确率应该是75%'); - - results.push({ name: '多套题聚合', status: 'pass' }); - - // 测试3: 答案聚合 - console.log('测试3: 答案聚合'); - const aggregatedAnswers = mockMixin.aggregateAnswers(session1.suiteResults); - - assert.ok(aggregatedAnswers['set1::q1'], '应该包含set1的q1答案'); - assert.ok(aggregatedAnswers['set2::q1'], '应该包含set2的q1答案'); - assert.strictEqual(Object.keys(aggregatedAnswers).length, 4, '应该有4个答案'); - - results.push({ name: '答案聚合', status: 'pass' }); - - // 测试4: 答案比较聚合 - console.log('测试4: 答案比较聚合'); - const aggregatedComparison = mockMixin.aggregateAnswerComparisons(session1.suiteResults); - - assert.ok(aggregatedComparison['set1::q1'], '应该包含set1的q1比较'); - assert.ok(aggregatedComparison['set1::q2'], '应该包含set1的q2比较'); - assert.strictEqual(aggregatedComparison['set1::q1'].isCorrect, true, 'set1::q1应该正确'); - assert.strictEqual(aggregatedComparison['set1::q2'].isCorrect, false, 'set1::q2应该错误'); - - results.push({ name: '答案比较聚合', status: 'pass' }); - - // 测试5: 拼写错误聚合 - console.log('测试5: 拼写错误聚合'); - const aggregatedErrors = mockMixin.aggregateSpellingErrors(session1.suiteResults); - - assert.strictEqual(aggregatedErrors.length, 1, '应该有1个拼写错误'); - assert.strictEqual(aggregatedErrors[0].word, 'correct2', '错误单词应该是correct2'); - assert.strictEqual(aggregatedErrors[0].userInput, 'answer2', '用户输入应该是answer2'); - - results.push({ name: '拼写错误聚合', status: 'pass' }); - - // 测试5b: 重复错词保留最新输入和听力候选答案元数据 - console.log('测试5b: 重复错词保留最新输入和听力候选答案元数据'); - const duplicateSpellingErrors = mockMixin.aggregateSpellingErrors([ - { - suiteId: 'set1', - examId: testData.examId, - spellingErrors: [{ - word: 'accommodation', - userInput: 'accomodation', - questionId: 'q1', - suiteId: 'set1', - examId: testData.examId, - timestamp: 1000, - errorCount: 1, - source: 'p1', - acceptedAnswers: ['accommodation', 'lodging'], - canonicalAnswer: 'accommodation', - reasonCode: 'edit', - confidence: 0.92, - tokenIndex: 0, - metadata: { comparisonMode: 'single-token' } - }] - }, - { - suiteId: 'set2', - examId: testData.examId, - spellingErrors: [{ - word: 'accommodation', - userInput: 'acommodation', - questionId: 'q9', - suiteId: 'set2', - examId: testData.examId, - timestamp: 2000, - errorCount: 1, - source: 'p1', - acceptedAnswers: ['accommodation', 'hotel room'], - canonicalAnswer: 'accommodation', - reasonCode: 'edit', - confidence: 0.95, - tokenIndex: 1, - metadata: { comparisonMode: 'phrase-token' } - }] - } - ]); - - assert.strictEqual(duplicateSpellingErrors.length, 1, '重复错词应该合并'); - assert.strictEqual(duplicateSpellingErrors[0].errorCount, 2, '错误次数应该累加'); - assert.strictEqual(duplicateSpellingErrors[0].userInput, 'acommodation', '应该保留最新用户输入'); - assert.strictEqual(duplicateSpellingErrors[0].questionId, 'q9', '应该保留最新题号'); - assert.deepStrictEqual(duplicateSpellingErrors[0].acceptedAnswers, ['accommodation', 'hotel room']); - assert.strictEqual(duplicateSpellingErrors[0].metadata.comparisonMode, 'phrase-token'); - - results.push({ name: '重复错词保留最新输入和候选答案元数据', status: 'pass' }); - - // 测试6: 记录保存 - console.log('测试6: 记录保存'); - const record = { - id: session1.id, - examId: testData.examId, - title: '测试记录', - type: 'listening', - multiSuite: true, - scoreInfo: aggregatedScores, - answers: aggregatedAnswers, - answerComparison: aggregatedComparison, - spellingErrors: aggregatedErrors, - suiteEntries: session1.suiteResults - }; - - await mockMixin._saveSuitePracticeRecord(record); - - const savedRecord = await global.storage.get(`practice_record_${record.id}`); - assert.ok(savedRecord, '记录应该被保存'); - assert.strictEqual(savedRecord.multiSuite, true, '应该标记为多套题'); - assert.strictEqual(savedRecord.suiteEntries.length, 2, '应该有2个套题条目'); - - results.push({ name: '记录保存', status: 'pass' }); - - // 测试7: 重复提交检测 - console.log('测试7: 重复提交检测'); - const session2 = mockMixin.getOrCreateMultiSuiteSession(testData.examId); - const initialLength = session2.suiteResults.length; - - // 模拟重复提交检测 - const alreadyRecorded = session2.suiteResults.some( - result => result.suiteId === 'set1' - ); - - assert.strictEqual(alreadyRecorded, true, '应该检测到重复提交'); - - results.push({ name: '重复提交检测', status: 'pass' }); - - // 测试8: 预期套题数量检测 - console.log('测试8: 预期套题数量检测'); - const newSession = mockMixin.getOrCreateMultiSuiteSession('listening-p4-test-002'); - assert.strictEqual(newSession.expectedSuiteCount, 2, '应该设置预期套题数量'); - - results.push({ name: '预期套题数量检测', status: 'pass' }); - - } catch (error) { - results.push({ - name: '测试执行', - status: 'fail', - error: error.message, - stack: error.stack - }); - } - - // 输出结果 - const allPassed = results.every(r => r.status === 'pass'); - const output = { - status: allPassed ? 'pass' : 'fail', - total: results.length, - passed: results.filter(r => r.status === 'pass').length, - failed: results.filter(r => r.status === 'fail').length, - results: results, - detail: allPassed ? '所有测试通过' : '部分测试失败' - }; - - emitResult(output); - return allPassed ? 0 : 1; -} - -runTests().then(code => process.exit(code)).catch(err => { - emitResult({ - status: 'fail', - error: err.message, - stack: err.stack - }); - process.exit(1); -}); diff --git a/developer/tests/js/integration/performance.benchmark.js b/developer/tests/js/integration/performance.benchmark.js deleted file mode 100644 index bd655795..00000000 --- a/developer/tests/js/integration/performance.benchmark.js +++ /dev/null @@ -1,338 +0,0 @@ -#!/usr/bin/env node -/** - * 性能基准测试 - * - * 测试场景: - * 1. 大量单词加载性能 - * 2. 词表切换性能 - * 3. 拼写错误检测性能 - * 4. 多套题聚合性能 - */ - -const assert = require('assert'); - -// Mock浏览器环境 -global.window = global; -global.console = { - log: () => {}, - warn: () => {}, - error: () => {}, - info: () => {} -}; - -// Mock存储系统 -const mockStorage = new Map(); -global.storage = { - ready: Promise.resolve(), - setNamespace: () => {}, - get: async (key) => mockStorage.get(key), - set: async (key, value) => mockStorage.set(key, value), - remove: async (key) => mockStorage.delete(key) -}; - -// 加载SpellingErrorCollector -eval(require('fs').readFileSync('js/app/spellingErrorCollector.js', 'utf8')); - -// 性能测试工具 -class PerformanceBenchmark { - constructor() { - this.results = []; - } - - async measure(name, fn, iterations = 1) { - const times = []; - - for (let i = 0; i < iterations; i++) { - const start = process.hrtime.bigint(); - await fn(); - const end = process.hrtime.bigint(); - const duration = Number(end - start) / 1000000; // 转换为毫秒 - times.push(duration); - } - - const avg = times.reduce((a, b) => a + b, 0) / times.length; - const min = Math.min(...times); - const max = Math.max(...times); - - this.results.push({ - name, - iterations, - avgMs: avg.toFixed(2), - minMs: min.toFixed(2), - maxMs: max.toFixed(2), - totalMs: (avg * iterations).toFixed(2) - }); - - return { avg, min, max }; - } - - getResults() { - return this.results; - } -} - -// 生成测试数据 -function generateWords(count) { - const words = []; - const baseWords = [ - 'accommodation', 'receive', 'environment', 'government', 'development', - 'necessary', 'separate', 'definitely', 'occurrence', 'maintenance', - 'privilege', 'conscience', 'embarrass', 'harass', 'millennium' - ]; - - for (let i = 0; i < count; i++) { - const baseWord = baseWords[i % baseWords.length]; - words.push({ - word: `${baseWord}${i}`, - userInput: `${baseWord}${i}_wrong`, - questionId: `q${i}`, - suiteId: `set${Math.floor(i / 10)}`, - examId: 'test-exam', - timestamp: Date.now(), - errorCount: 1, - source: i % 2 === 0 ? 'p1' : 'p4' - }); - } - - return words; -} - -function generateSuiteResults(suiteCount, questionsPerSuite) { - const results = []; - - for (let i = 0; i < suiteCount; i++) { - const answers = {}; - const correctAnswers = {}; - const answerComparison = {}; - - for (let j = 0; j < questionsPerSuite; j++) { - const qId = `q${j}`; - const isCorrect = Math.random() > 0.3; - - answers[qId] = `answer${j}`; - correctAnswers[qId] = `correct${j}`; - answerComparison[qId] = { - userAnswer: `answer${j}`, - correctAnswer: `correct${j}`, - isCorrect: isCorrect - }; - } - - results.push({ - suiteId: `set${i}`, - examId: 'test-exam', - answers, - correctAnswers, - answerComparison, - scoreInfo: { - correct: Math.floor(questionsPerSuite * 0.7), - total: questionsPerSuite, - accuracy: 0.7, - percentage: 70 - }, - spellingErrors: [], - timestamp: Date.now(), - duration: 300 - }); - } - - return results; -} - -// 运行基准测试 -async function runBenchmarks() { - const benchmark = new PerformanceBenchmark(); - const collector = new window.SpellingErrorCollector(); - await collector.ensureInitialized(); - - console.log('开始性能基准测试...\n'); - - // 测试1: 小量单词保存(10个) - const smallWords = generateWords(10); - await benchmark.measure('保存10个单词', async () => { - await collector.saveErrors(smallWords); - }, 10); - - // 测试2: 中量单词保存(100个) - const mediumWords = generateWords(100); - await benchmark.measure('保存100个单词', async () => { - await collector.saveErrors(mediumWords); - }, 5); - - // 测试3: 大量单词保存(1000个) - const largeWords = generateWords(1000); - await benchmark.measure('保存1000个单词', async () => { - await collector.saveErrors(largeWords); - }, 3); - - // 测试4: 词表加载(小) - await benchmark.measure('加载小词表(10个单词)', async () => { - await collector.loadVocabList('p1'); - }, 20); - - // 测试5: 词表加载(大) - await collector.saveErrors(largeWords); - await benchmark.measure('加载大词表(1000个单词)', async () => { - await collector.loadVocabList('master'); - }, 10); - - // 测试6: 拼写错误检测(小) - const smallComparison = {}; - for (let i = 0; i < 10; i++) { - smallComparison[`q${i}`] = { - userAnswer: `answer${i}`, - correctAnswer: `correct${i}`, - isCorrect: false - }; - } - await benchmark.measure('检测10个答案的拼写错误', () => { - collector.detectErrors(smallComparison, 'set1', 'test-exam'); - }, 20); - - // 测试7: 拼写错误检测(大) - const largeComparison = {}; - for (let i = 0; i < 100; i++) { - largeComparison[`q${i}`] = { - userAnswer: `answer${i}`, - correctAnswer: `correct${i}`, - isCorrect: false - }; - } - await benchmark.measure('检测100个答案的拼写错误', () => { - collector.detectErrors(largeComparison, 'set1', 'test-exam'); - }, 10); - - // 测试8: 编辑距离计算 - await benchmark.measure('计算编辑距离(短单词)', () => { - collector.levenshteinDistance('cat', 'bat'); - }, 1000); - - await benchmark.measure('计算编辑距离(长单词)', () => { - collector.levenshteinDistance('accommodation', 'accomodation'); - }, 1000); - - // 测试9: 多套题聚合(小) - const smallSuites = generateSuiteResults(3, 10); - await benchmark.measure('聚合3套题(每套10题)', () => { - // Mock聚合逻辑 - const aggregated = {}; - smallSuites.forEach(suite => { - Object.entries(suite.answers).forEach(([qId, answer]) => { - aggregated[`${suite.suiteId}::${qId}`] = answer; - }); - }); - }, 20); - - // 测试10: 多套题聚合(大) - const largeSuites = generateSuiteResults(10, 40); - await benchmark.measure('聚合10套题(每套40题)', () => { - const aggregated = {}; - largeSuites.forEach(suite => { - Object.entries(suite.answers).forEach(([qId, answer]) => { - aggregated[`${suite.suiteId}::${qId}`] = answer; - }); - }); - }, 10); - - // 测试11: 词表计数 - await benchmark.measure('获取词表单词数', async () => { - await collector.getWordCount('p1'); - }, 50); - - // 测试12: 重复单词处理 - const duplicateWords = generateWords(50); - await collector.saveErrors(duplicateWords); - await benchmark.measure('处理50个重复单词', async () => { - await collector.saveErrors(duplicateWords); - }, 5); - - // 输出结果 - const results = benchmark.getResults(); - - // 计算性能评分 - const performanceScore = calculatePerformanceScore(results); - - const output = { - status: 'pass', - timestamp: new Date().toISOString(), - benchmarks: results, - summary: { - totalTests: results.length, - performanceScore: performanceScore, - rating: getPerformanceRating(performanceScore) - }, - thresholds: { - '保存10个单词': { max: 50, unit: 'ms' }, - '保存100个单词': { max: 200, unit: 'ms' }, - '保存1000个单词': { max: 1000, unit: 'ms' }, - '加载小词表(10个单词)': { max: 20, unit: 'ms' }, - '加载大词表(1000个单词)': { max: 100, unit: 'ms' }, - '检测10个答案的拼写错误': { max: 10, unit: 'ms' }, - '检测100个答案的拼写错误': { max: 50, unit: 'ms' }, - '计算编辑距离(短单词)': { max: 1, unit: 'ms' }, - '计算编辑距离(长单词)': { max: 2, unit: 'ms' }, - '聚合3套题(每套10题)': { max: 5, unit: 'ms' }, - '聚合10套题(每套40题)': { max: 20, unit: 'ms' }, - '获取词表单词数': { max: 10, unit: 'ms' }, - '处理50个重复单词': { max: 100, unit: 'ms' } - } - }; - - console.log(JSON.stringify(output, null, 2)); - return 0; -} - -function calculatePerformanceScore(results) { - // 基于阈值计算性能评分(0-100) - const thresholds = { - '保存10个单词': 50, - '保存100个单词': 200, - '保存1000个单词': 1000, - '加载小词表(10个单词)': 20, - '加载大词表(1000个单词)': 100, - '检测10个答案的拼写错误': 10, - '检测100个答案的拼写错误': 50, - '计算编辑距离(短单词)': 1, - '计算编辑距离(长单词)': 2, - '聚合3套题(每套10题)': 5, - '聚合10套题(每套40题)': 20, - '获取词表单词数': 10, - '处理50个重复单词': 100 - }; - - let totalScore = 0; - let count = 0; - - results.forEach(result => { - const threshold = thresholds[result.name]; - if (threshold) { - const avgMs = parseFloat(result.avgMs); - const score = Math.max(0, Math.min(100, (1 - avgMs / threshold) * 100)); - totalScore += score; - count++; - } - }); - - return count > 0 ? Math.round(totalScore / count) : 0; -} - -function getPerformanceRating(score) { - if (score >= 90) return '优秀'; - if (score >= 75) return '良好'; - if (score >= 60) return '及格'; - return '需要优化'; -} - -// 运行测试 -if (require.main === module) { - runBenchmarks().then(code => process.exit(code)).catch(err => { - console.error(JSON.stringify({ - status: 'fail', - error: err.message, - stack: err.stack - }, null, 2)); - process.exit(1); - }); -} - -module.exports = { runBenchmarks }; diff --git a/developer/tests/js/integration/spellingErrorCollection.test.js b/developer/tests/js/integration/spellingErrorCollection.test.js index 6d872d87..803bb747 100644 --- a/developer/tests/js/integration/spellingErrorCollection.test.js +++ b/developer/tests/js/integration/spellingErrorCollection.test.js @@ -65,14 +65,31 @@ global.__EMBEDDED_WORDLISTS__ = { ] }; -// Mock存储系统 -const mockStorage = new Map(); -global.storage = { +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +const vocabCollections = {}; +let rejectCollectionWrites = false; +global.AppData = { ready: Promise.resolve(), - setNamespace: () => {}, - get: async (key) => mockStorage.get(key), - set: async (key, value) => mockStorage.set(key, value), - remove: async (key) => mockStorage.delete(key) + vocab: { + async listCollections() { + return clone(vocabCollections); + }, + async saveCollection(id, value) { + if (rejectCollectionWrites) throw new Error('injected commit failure'); + vocabCollections[id] = clone(value); + return { committed: true }; + }, + async saveCollections(values) { + if (rejectCollectionWrites) throw new Error('injected commit failure'); + Object.entries(values).forEach(([id, value]) => { + vocabCollections[id] = clone(value); + }); + return { committed: true }; + } + } }; // 加载SpellingErrorCollector @@ -208,6 +225,7 @@ async function runTests() { await collector.saveErrors(errors); const p1List = await collector.loadVocabList('p1'); + assert.ok(vocabCollections['spelling-errors-p1'], '必须写入 canonical P1 collection'); assert.ok(p1List, 'P1词表应该存在'); assert.strictEqual(p1List.words.length, 2, 'P1词表应该有2个单词'); const savedAccommodationInitial = p1List.words.find( @@ -259,6 +277,7 @@ async function runTests() { console.log('测试7: 综合词表同步'); const masterList = await collector.loadVocabList('master'); assert.ok(masterList, '综合词表应该存在'); + assert.ok(vocabCollections['spelling-errors-master'], '必须写入 canonical master collection'); assert.ok(masterList.words.length >= 2, '综合词表应该包含所有错误'); results.push({ name: '综合词表同步', status: 'pass' }); @@ -341,6 +360,23 @@ async function runTests() { assert.strictEqual(savedGarden.reasonCode, candidateErrors[0].reasonCode); results.push({ name: '候选答案字段保存到错词词表', status: 'pass' }); + + // 测试14: 权威提交失败不得伪成功或污染 committed state + const snapshotBeforeFailure = clone(vocabCollections); + rejectCollectionWrites = true; + const failed = await collector.saveErrors([{ + word: 'receive', + userInput: 'recieve', + questionId: 'q-failure', + examId: testData.examId, + timestamp: Date.now(), + errorCount: 1, + source: 'p1' + }]); + rejectCollectionWrites = false; + assert.strictEqual(failed, false, '提交失败必须明确返回失败'); + assert.deepStrictEqual(vocabCollections, snapshotBeforeFailure, '提交失败不得污染权威 collection'); + results.push({ name: '提交失败不伪成功', status: 'pass' }); } catch (error) { results.push({ diff --git a/developer/tests/js/integration/vocabListSwitching.test.js b/developer/tests/js/integration/vocabListSwitching.test.js index 6cfe22b4..a48eea61 100644 --- a/developer/tests/js/integration/vocabListSwitching.test.js +++ b/developer/tests/js/integration/vocabListSwitching.test.js @@ -1,387 +1,203 @@ #!/usr/bin/env node -/** - * 集成测试:词表切换流程 - * - * 测试场景: - * 1. 词表加载 - * 2. UI更新(模拟) - * 3. 数据持久化 - * 4. 空词表处理 - */ - import assert from 'assert'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -const originalConsoleLog = (console && typeof console.log === 'function') - ? console.log.bind(console) - : null; - +const originalConsoleLog = console.log.bind(console); function emitResult(payload) { - const text = JSON.stringify(payload, null, 2); - try { - if (process && process.stdout && typeof process.stdout.write === 'function') { - process.stdout.write(text + '\n'); - return; - } - } catch (_) {} - if (typeof originalConsoleLog === 'function') { - originalConsoleLog(text); - } + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); +} + +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } -// Mock浏览器环境 global.window = global; global.document = { querySelectorAll: () => [], querySelector: () => null, - addEventListener: () => {}, - createElement: () => ({ - addEventListener: () => {}, - appendChild: () => {}, - setAttribute: () => {}, - classList: { add: () => {}, remove: () => {} }, - innerHTML: '' - }) + addEventListener: () => {} }; +global.console = { log() {}, warn() {}, error() {}, info() {} }; -global.console = { - log: () => {}, - warn: () => {}, - error: () => {}, - info: () => {} +const state = { + words: [{ id: 'core-seed', word: 'atlas', meaning: 'n. 地图集' }], + collections: {}, + config: { activeListId: 'default' }, + rejectActivation: false }; -// Mock存储系统 -const mockStorage = new Map(); -global.storage = { +global.AppData = { ready: Promise.resolve(), - setNamespace: () => {}, - get: async (key) => mockStorage.get(key), - set: async (key, value) => mockStorage.set(key, value), - remove: async (key) => mockStorage.delete(key) + vocab: { + async getConfig() { + return clone(state.config); + }, + async listWords() { + return clone(state.words); + }, + async listCollections() { + return clone(state.collections); + }, + async replaceListWords({ listId = 'default', words = [] }) { + if (listId === 'default') { + state.words = clone(words); + } else { + state.collections[listId] = { + ...(state.collections[listId] || {}), + id: listId, + words: clone(words) + }; + } + return { committed: true }; + }, + async saveCollection(id, value) { + state.collections[id] = clone(value); + return { committed: true }; + }, + async saveCollections(values) { + Object.entries(values).forEach(([id, value]) => { + state.collections[id] = clone(value); + }); + return { committed: true }; + }, + async patchConfig(patch = {}) { + state.config = { ...state.config, ...clone(patch) }; + return { committed: true }; + }, + async activateList(listId) { + if (state.rejectActivation) throw new Error('injected activation failure'); + state.config = { ...state.config, activeListId: listId }; + return { committed: true }; + }, + async patchWord({ listId = 'default', wordId, patch = {} }) { + const words = listId === 'default' ? state.words : (state.collections[listId]?.words || []); + const index = words.findIndex((word) => (word.id || word.word) === wordId); + if (index < 0) throw new Error(`Unknown word: ${wordId}`); + words[index] = { ...words[index], ...clone(patch) }; + return { committed: true, word: clone(words[index]) }; + }, + async replaceProgress({ listId = 'default', words = [], config = {} }) { + await this.replaceListWords({ listId, words }); + state.config = { ...state.config, ...clone(config), activeListId: listId }; + return { committed: true }; + } + } }; -// 加载SpellingErrorCollector +global.__EMBEDDED_WORDLISTS__ = { ielts_core: clone(state.words) }; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '../../../..'); eval(fs.readFileSync(path.join(repoRoot, 'js/app/spellingErrorCollector.js'), 'utf8')); +eval(fs.readFileSync(path.join(repoRoot, 'js/core/vocabStore.js'), 'utf8')); -// Mock VocabStore -class MockVocabStore { - constructor() { - this.activeList = null; - this.collector = new window.SpellingErrorCollector(); - } - - async loadList(listId) { - return await this.collector.loadVocabList(listId); - } - - setActiveList(list) { - this.activeList = list; - } - - async getListWordCount(listId) { - return await this.collector.getWordCount(listId); - } - - refreshView() { - // Mock UI refresh - } -} - -// Mock VocabListSwitcher -class MockVocabListSwitcher { - constructor(vocabStore) { - this.vocabStore = vocabStore; - this.currentListId = 'master'; - this.listCounts = {}; - } - - async switchList(listId) { - const list = await this.vocabStore.loadList(listId); - - if (!list) { - throw new Error(`词表不存在: ${listId}`); - } - - this.currentListId = listId; - this.vocabStore.setActiveList(list); - - // 保存用户偏好 - await global.storage.set('vocab_active_list', listId); - - return true; - } - - async updateListCounts() { - const listIds = ['p1', 'p4', 'master', 'custom']; - - for (const listId of listIds) { - const count = await this.vocabStore.getListWordCount(listId); - this.listCounts[listId] = count; - } - } - - getCurrentListId() { - return this.currentListId; - } - - getListCount(listId) { - return this.listCounts[listId] || 0; - } -} - -// 测试数据 const testData = { - p1Errors: [ - { - word: 'accommodation', - userInput: 'accomodation', - questionId: 'q1', - suiteId: 'set1', - examId: 'listening-p1-test-001', - timestamp: Date.now(), - errorCount: 1, - source: 'p1' - }, - { - word: 'receive', - userInput: 'recieve', - questionId: 'q2', - suiteId: 'set1', - examId: 'listening-p1-test-001', - timestamp: Date.now(), - errorCount: 1, - source: 'p1' - } + p1: [ + { word: 'accommodation', userInput: 'accomodation', questionId: 'q1', examId: 'listening-p1', timestamp: 1, errorCount: 1, source: 'p1' }, + { word: 'receive', userInput: 'recieve', questionId: 'q2', examId: 'listening-p1', timestamp: 2, errorCount: 1, source: 'p1' } ], - p4Errors: [ - { - word: 'environment', - userInput: 'enviroment', - questionId: 'q1', - suiteId: 'set1', - examId: 'listening-p4-test-001', - timestamp: Date.now(), - errorCount: 1, - source: 'p4' - }, - { - word: 'government', - userInput: 'goverment', - questionId: 'q2', - suiteId: 'set1', - examId: 'listening-p4-test-001', - timestamp: Date.now(), - errorCount: 1, - source: 'p4' - }, - { - word: 'development', - userInput: 'developement', - questionId: 'q3', - suiteId: 'set1', - examId: 'listening-p4-test-001', - timestamp: Date.now(), - errorCount: 1, - source: 'p4' - } + p4: [ + { word: 'environment', userInput: 'enviroment', questionId: 'q1', examId: 'listening-p4', timestamp: 3, errorCount: 1, source: 'p4' }, + { word: 'government', userInput: 'goverment', questionId: 'q2', examId: 'listening-p4', timestamp: 4, errorCount: 1, source: 'p4' }, + { word: 'development', userInput: 'developement', questionId: 'q3', examId: 'listening-p4', timestamp: 5, errorCount: 1, source: 'p4' } ] }; -// 测试套件 async function runTests() { const results = []; - + const record = async (name, test) => { + await test(); + results.push({ name, status: 'pass' }); + }; + try { const collector = new window.SpellingErrorCollector(); await collector.ensureInitialized(); - - const vocabStore = new MockVocabStore(); - const switcher = new MockVocabListSwitcher(vocabStore); - - // 准备测试数据 - await collector.saveErrors(testData.p1Errors); - await collector.saveErrors(testData.p4Errors); - - // 测试1: 词表加载 - console.log('测试1: 词表加载'); - const p1List = await vocabStore.loadList('p1'); - assert.ok(p1List, 'P1词表应该存在'); - assert.strictEqual(p1List.words.length, 2, 'P1词表应该有2个单词'); - - const p4List = await vocabStore.loadList('p4'); - assert.ok(p4List, 'P4词表应该存在'); - assert.strictEqual(p4List.words.length, 3, 'P4词表应该有3个单词'); - - results.push({ name: '词表加载', status: 'pass' }); - - // 测试2: 词表切换 - console.log('测试2: 词表切换'); - await switcher.switchList('p1'); - assert.strictEqual(switcher.getCurrentListId(), 'p1', '当前词表应该是p1'); - assert.ok(vocabStore.activeList, '应该设置活动词表'); - assert.strictEqual(vocabStore.activeList.id, 'p1', '活动词表ID应该是p1'); - - results.push({ name: '词表切换', status: 'pass' }); - - // 测试3: 用户偏好保存 - console.log('测试3: 用户偏好保存'); - const savedPreference = await global.storage.get('vocab_active_list'); - assert.strictEqual(savedPreference, 'p1', '应该保存用户偏好'); - - results.push({ name: '用户偏好保存', status: 'pass' }); - - // 测试4: 切换到P4词表 - console.log('测试4: 切换到P4词表'); - await switcher.switchList('p4'); - assert.strictEqual(switcher.getCurrentListId(), 'p4', '当前词表应该是p4'); - assert.strictEqual(vocabStore.activeList.id, 'p4', '活动词表ID应该是p4'); - assert.strictEqual(vocabStore.activeList.words.length, 3, 'P4词表应该有3个单词'); - - results.push({ name: '切换到P4词表', status: 'pass' }); - - // 测试5: 切换到综合词表 - console.log('测试5: 切换到综合词表'); - await switcher.switchList('master'); - assert.strictEqual(switcher.getCurrentListId(), 'master', '当前词表应该是master'); - assert.ok(vocabStore.activeList.words.length >= 5, '综合词表应该包含所有单词'); - - results.push({ name: '切换到综合词表', status: 'pass' }); - - // 测试6: 词表计数更新 - console.log('测试6: 词表计数更新'); - await switcher.updateListCounts(); - - assert.strictEqual(switcher.getListCount('p1'), 2, 'P1词表计数应该是2'); - assert.strictEqual(switcher.getListCount('p4'), 3, 'P4词表计数应该是3'); - assert.ok(switcher.getListCount('master') >= 5, '综合词表计数应该>=5'); - assert.strictEqual(switcher.getListCount('custom'), 0, '自定义词表计数应该是0'); - - results.push({ name: '词表计数更新', status: 'pass' }); - - // 测试7: 空词表处理 - console.log('测试7: 空词表处理'); - try { - await switcher.switchList('custom'); - // 如果词表不存在,应该抛出错误 - // 但如果自动创建了空词表,也是可以的 - const customList = await vocabStore.loadList('custom'); - if (customList) { - assert.strictEqual(customList.words.length, 0, '自定义词表应该为空'); - } - results.push({ name: '空词表处理', status: 'pass' }); - } catch (error) { - // 如果抛出错误,检查错误消息 - if (error.message.includes('词表不存在')) { - results.push({ name: '空词表处理', status: 'pass' }); - } else { - throw error; + await window.VocabStore.init(); + assert.strictEqual(await collector.saveErrors(testData.p1), true); + assert.strictEqual(await collector.saveErrors(testData.p4), true); + + await record('canonical collections are created', async () => { + assert.ok(state.collections['spelling-errors-p1']); + assert.ok(state.collections['spelling-errors-p4']); + assert.ok(state.collections['spelling-errors-master']); + assert.strictEqual(state.collections['spelling-errors-p1'].words.length, 2); + assert.strictEqual(state.collections['spelling-errors-p4'].words.length, 3); + assert.strictEqual(state.collections['spelling-errors-master'].words.length, 5); + }); + + await record('loads canonical list projections', async () => { + const p1 = await window.VocabStore.loadList('spelling-errors-p1'); + const p4 = await window.VocabStore.loadList('spelling-errors-p4'); + assert.strictEqual(p1.words.length, 2); + assert.strictEqual(p4.words.length, 3); + assert.strictEqual(p1.id, 'spelling-errors-p1'); + }); + + await record('switch persists active list through AppData', async () => { + assert.strictEqual(await window.VocabStore.setActiveList('spelling-errors-p1'), true); + assert.strictEqual(window.VocabStore.getActiveListId(), 'spelling-errors-p1'); + assert.strictEqual(state.config.activeListId, 'spelling-errors-p1'); + assert.strictEqual(window.VocabStore.getWords().length, 2); + }); + + await record('list counts come from the domain data', async () => { + assert.strictEqual(await window.VocabStore.getListWordCount('spelling-errors-p1'), 2); + assert.strictEqual(await window.VocabStore.getListWordCount('spelling-errors-p4'), 3); + assert.strictEqual(await window.VocabStore.getListWordCount('spelling-errors-master'), 5); + assert.strictEqual(await window.VocabStore.getListWordCount('custom'), 0); + }); + + await record('empty custom list is a valid switch target', async () => { + const custom = await window.VocabStore.loadList('custom'); + assert.deepStrictEqual(custom.words, []); + assert.strictEqual(await window.VocabStore.setActiveList(custom), true); + assert.strictEqual(window.VocabStore.getActiveListId(), 'custom'); + assert.deepStrictEqual(window.VocabStore.getWords(), []); + }); + + await record('unknown list cannot replace the active list', async () => { + const before = state.config.activeListId; + assert.strictEqual(await window.VocabStore.setActiveList('nonexistent'), false); + assert.strictEqual(state.config.activeListId, before); + }); + + await record('activation failure does not update in-memory state', async () => { + const before = window.VocabStore.getActiveListId(); + state.rejectActivation = true; + assert.strictEqual(await window.VocabStore.setActiveList('spelling-errors-p4'), false); + state.rejectActivation = false; + assert.strictEqual(window.VocabStore.getActiveListId(), before); + assert.strictEqual(state.config.activeListId, before); + }); + + await record('repeated switches remain stable', async () => { + for (let index = 0; index < 3; index += 1) { + assert.strictEqual(await window.VocabStore.setActiveList('spelling-errors-p1'), true); + assert.strictEqual(await window.VocabStore.setActiveList('spelling-errors-p4'), true); + assert.strictEqual(await window.VocabStore.setActiveList('spelling-errors-master'), true); } - } - - // 测试8: 切换到不存在的词表 - console.log('测试8: 切换到不存在的词表'); - try { - await switcher.switchList('nonexistent'); - results.push({ - name: '切换到不存在的词表', - status: 'fail', - error: '应该抛出错误' - }); - } catch (error) { - assert.ok(error.message.includes('词表不存在'), '应该抛出词表不存在错误'); - results.push({ name: '切换到不存在的词表', status: 'pass' }); - } - - // 测试9: 词表数据完整性 - console.log('测试9: 词表数据完整性'); - await switcher.switchList('p1'); - const p1Data = vocabStore.activeList; - - assert.ok(p1Data.id, '词表应该有ID'); - assert.ok(p1Data.name, '词表应该有名称'); - assert.ok(p1Data.source, '词表应该有来源'); - assert.ok(Array.isArray(p1Data.words), '词表应该有单词数组'); - assert.ok(p1Data.createdAt, '词表应该有创建时间'); - assert.ok(p1Data.updatedAt, '词表应该有更新时间'); - assert.ok(p1Data.stats, '词表应该有统计信息'); - - results.push({ name: '词表数据完整性', status: 'pass' }); - - // 测试10: 词表单词数据完整性 - console.log('测试10: 词表单词数据完整性'); - const firstWord = p1Data.words[0]; - - assert.ok(firstWord.word, '单词应该有word字段'); - assert.ok(firstWord.userInput, '单词应该有userInput字段'); - assert.ok(firstWord.questionId, '单词应该有questionId字段'); - assert.ok(firstWord.examId, '单词应该有examId字段'); - assert.ok(firstWord.timestamp, '单词应该有timestamp字段'); - assert.ok(firstWord.errorCount, '单词应该有errorCount字段'); - assert.ok(firstWord.source, '单词应该有source字段'); - - results.push({ name: '词表单词数据完整性', status: 'pass' }); - - // 测试11: 多次切换稳定性 - console.log('测试11: 多次切换稳定性'); - for (let i = 0; i < 5; i++) { - await switcher.switchList('p1'); - await switcher.switchList('p4'); - await switcher.switchList('master'); - } - - assert.strictEqual(switcher.getCurrentListId(), 'master', '最终应该在master词表'); - const finalList = vocabStore.activeList; - assert.ok(finalList, '应该有活动词表'); - assert.ok(finalList.words.length > 0, '词表应该有单词'); - - results.push({ name: '多次切换稳定性', status: 'pass' }); - - // 测试12: 词表统计信息更新 - console.log('测试12: 词表统计信息更新'); - await switcher.switchList('p1'); - const p1Stats = vocabStore.activeList.stats; - - assert.strictEqual(p1Stats.totalWords, 2, '总单词数应该是2'); - assert.ok(p1Stats.hasOwnProperty('masteredWords'), '应该有已掌握单词数'); - assert.ok(p1Stats.hasOwnProperty('reviewingWords'), '应该有复习中单词数'); - - results.push({ name: '词表统计信息更新', status: 'pass' }); - - } catch (error) { - results.push({ - name: '测试执行', - status: 'fail', - error: error.message, - stack: error.stack + assert.strictEqual(window.VocabStore.getActiveListId(), 'spelling-errors-master'); + assert.strictEqual(window.VocabStore.getWords().length, 5); }); + } catch (error) { + results.push({ name: 'test execution', status: 'fail', error: error.message, stack: error.stack }); + } finally { + global.console = { ...global.console, log: originalConsoleLog }; } - - // 输出结果 - const allPassed = results.every(r => r.status === 'pass'); - const output = { + + const allPassed = results.every((result) => result.status === 'pass'); + emitResult({ status: allPassed ? 'pass' : 'fail', total: results.length, - passed: results.filter(r => r.status === 'pass').length, - failed: results.filter(r => r.status === 'fail').length, - results: results, - detail: allPassed ? '所有测试通过' : '部分测试失败' - }; - - emitResult(output); + passed: results.filter((result) => result.status === 'pass').length, + failed: results.filter((result) => result.status === 'fail').length, + results + }); return allPassed ? 0 : 1; } -runTests().then(code => process.exit(code)).catch(err => { - emitResult({ - status: 'fail', - error: err.message, - stack: err.stack - }); - process.exit(1); -}); +runTests().then((code) => process.exit(code)); diff --git a/developer/tests/js/integration/vocabSessionView.test.js b/developer/tests/js/integration/vocabSessionView.test.js index 65e51cec..e91d162e 100644 --- a/developer/tests/js/integration/vocabSessionView.test.js +++ b/developer/tests/js/integration/vocabSessionView.test.js @@ -12,6 +12,8 @@ const repoRoot = path.resolve(__dirname, '../../../..'); const originalConsoleLog = (console && typeof console.log === 'function') ? console.log.bind(console) : null; +let activeDocumentStub = null; +const modalFocusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; function emitResult(payload) { const text = JSON.stringify(payload, null, 2); @@ -65,6 +67,12 @@ function patchVocabSessionView(source) { closeMenu, bindEvents, toggleMenu, + getWordStatus, + analyzeListWords, + openListModal, + closeListModal, + exportCurrentList, + renderListBrowser, setElements: (elements) => { state.elements = elements || {}; }, setStore: (store) => { state.store = store; }, setScheduler: (scheduler) => { state.scheduler = scheduler; }, @@ -134,6 +142,7 @@ function createElementStub(tag = 'div', overrides = {}) { textContent: '', value: '', hidden: false, + disabled: false, appendChild(child) { if (!child) { return child; @@ -214,6 +223,9 @@ function createElementStub(tag = 'div', overrides = {}) { }, focus() { this._focused = true; + if (activeDocumentStub) { + activeDocumentStub.activeElement = this; + } }, click() { this._clicked = true; @@ -231,7 +243,7 @@ function createDocumentStub() { const listeners = new Map(); const body = createElementStub('body'); - return { + const documentStub = { body, activeElement: null, addEventListener(type, handler) { @@ -271,6 +283,8 @@ function createDocumentStub() { return element; } }; + activeDocumentStub = documentStub; + return documentStub; } function createWindowStub(documentStub) { @@ -333,8 +347,8 @@ function createMockStore(words = [], config = {}) { ...word })), config: { ...baseConfig, ...config }, - reviewQueue: [], setConfigCalls: 0, + replaceProgressCalls: [], init: async () => true, getWords() { return this.words; @@ -346,12 +360,39 @@ function createMockStore(words = [], config = {}) { this.config = { ...this.config, ...next }; this.setConfigCalls += 1; }, - async setWords(next) { + async mergeWords(incoming) { + const indexByWord = new Map(this.words.map((word, index) => [String(word.word || '').trim().toLowerCase(), index])); + let addedCount = 0; + let updatedCount = 0; + for (const entry of incoming) { + const key = String(entry.word || '').trim().toLowerCase(); + if (!key) continue; + if (indexByWord.has(key)) { + const index = indexByWord.get(key); + this.words[index] = { ...this.words[index], ...entry }; + updatedCount += 1; + } else { + const word = { id: entry.id || `word-${this.words.length + 1}`, ...entry }; + indexByWord.set(key, this.words.length); + this.words.push(word); + addedCount += 1; + } + } + return { words: this.words, addedCount, updatedCount }; + }, + async replaceProgress(next, nextConfig = {}, listId = null) { this.words = next.map((word, index) => ({ id: word.id || `word-${index + 1}`, ...word })); - return true; + const activeListId = listId || nextConfig.activeListId || this.config.activeListId || 'default'; + this.config = { ...this.config, ...nextConfig, activeListId }; + this.replaceProgressCalls.push({ + listId: activeListId, + words: this.words.map((word) => ({ ...word })), + config: { ...this.config } + }); + return { words: this.words, config: this.config }; }, async updateWord(id, patch) { const idx = this.words.findIndex((word) => word.id === id); @@ -362,9 +403,6 @@ function createMockStore(words = [], config = {}) { this.words[idx] = updated; return updated; }, - setReviewQueue(queue) { - this.reviewQueue = Array.isArray(queue) ? queue.slice() : []; - }, getDueWords(now) { const nowTime = now instanceof Date ? now.getTime() : Date.now(); return this.words.filter((word) => { @@ -380,7 +418,7 @@ function createMockStore(words = [], config = {}) { return items.slice(0, limit); }, getActiveListId() { - return 'default'; + return this.config.activeListId || 'default'; }, getAvailableLists() { return []; @@ -447,6 +485,7 @@ function createSessionElements() { const settingsModal = createElementStub('div'); const settingsDialog = createElementStub('div'); + const settingsClose = createElementStub('button'); const settingsError = createElementStub('div'); const settingsForm = createElementStub('form'); @@ -458,6 +497,28 @@ function createSessionElements() { const listSwitcher = createElementStub('div'); const menuButton = createElementStub('button'); const menu = createElementStub('div'); + const listModal = createElementStub('div'); + const listDialog = createElementStub('div'); + const listSubtitle = createElementStub('p'); + const listSearch = createElementStub('input'); + const listLearnedOnly = createElementStub('input'); + const listStats = createElementStub('div'); + const listBody = createElementStub('div'); + const listClose = createElementStub('button'); + + settingsDialog.appendChild(dailyField); + settingsDialog.appendChild(settingsClose); + settingsDialog.__queryMap = { + 'input, button, select, textarea': dailyField + }; + settingsDialog.__queryListMap = { + [modalFocusableSelector]: [dailyField, settingsClose] + }; + listDialog.appendChild(listSearch); + listDialog.appendChild(listClose); + listDialog.__queryListMap = { + [modalFocusableSelector]: [listSearch, listClose] + }; return { root: createElementStub('div'), @@ -485,7 +546,16 @@ function createSessionElements() { }, listSwitcher, menuButton, - menu + menu, + listModal, + listDialog, + listSubtitle, + listSearch, + listLearnedOnly, + listStats, + listBody, + settingsClose, + listClose }; } @@ -581,6 +651,10 @@ async function run() { 'data-action="toggle-side-panel"', 'data-action="save-note"', 'data-vocab-role="import-input"', + 'data-action="menu-view-list"', + 'data-vocab-role="list-modal"', + 'data-vocab-role="list-dialog"', + 'data-action="export-current-list"', 'data-vocab-role="settings-modal"' ]; markers.forEach((marker) => { @@ -588,6 +662,22 @@ async function run() { }); }); + await record('list modal body owns the table scroll', () => { + const css = readSource('css/main.css'); + const bodyRule = css.match(/\.vocab-list-modal__body\s*\{([^}]*)\}/); + const tableWrapRule = css.match(/\.vocab-list-table-wrap\s*\{([^}]*)\}/); + assert.ok(bodyRule, 'Missing list modal body rule'); + assert.ok(tableWrapRule, 'Missing table wrapper rule'); + assert.match(bodyRule[1], /flex:\s*1 1 220px/); + assert.match(bodyRule[1], /min-height:\s*0/); + assert.match(bodyRule[1], /overflow:\s*hidden/); + assert.match(tableWrapRule[1], /flex:\s*1/); + assert.match(tableWrapRule[1], /min-height:\s*0/); + assert.match(tableWrapRule[1], /overflow:\s*auto/); + assert.doesNotMatch(css, /@media\s*\(max-height:\s*480px\)/); + assert.doesNotMatch(css, /@media\s*\(max-height:\s*300px\)/); + }); + const vocabContext = createVocabContext(); const hooks = vocabContext.hooks; const windowStub = vocabContext.window; @@ -901,7 +991,7 @@ async function run() { assert.strictEqual(elements.noteInput.value, 'Note text'); }); - await record('save note writes to store', () => { + await record('save note writes to store', async () => { const store = createMockStore([ { id: 'w-5', @@ -915,7 +1005,7 @@ async function run() { hooks.state.session.currentWord = store.words[0]; elements.noteInput.value = 'remember this'; - hooks.saveCurrentNote(); + await hooks.saveCurrentNote(); assert.strictEqual(store.words[0].note, 'remember this'); assert.ok(elements.noteStatus.textContent.length > 0); }); @@ -962,6 +1052,7 @@ async function run() { }; hooks.state.session.batchSize = 1; + hooks.openSettingsModal(elements.menuButton); await hooks.handleSettingsSubmit({ preventDefault() {}, currentTarget: elements.settingsForm @@ -984,6 +1075,287 @@ async function run() { assert.ok(elements.menu.hidden); }); + await record('settings modal restores focus to visible menu trigger', () => { + const hiddenMenuItem = createElementStub('button'); + const trigger = createElementStub('button'); + trigger.dataset.action = 'menu-settings'; + documentStub.activeElement = hiddenMenuItem; + elements.menuButton._focused = false; + + elements.menu.dispatchEvent({ + type: 'click', + target: { + closest(selector) { + return selector === 'button[data-action]' ? trigger : null; + } + } + }); + hooks.closeSettingsModal(); + + assert.strictEqual(elements.menuButton._focused, true); + assert.ok(!hiddenMenuItem._focused); + }); + + await record('list status keeps due mastered words in the review queue', () => { + const config = { masteryCount: 4 }; + const dueMastered = hooks.getWordStatus({ + correctCount: 4, + nextReview: new Date(Date.now() - 60_000).toISOString() + }, config); + const futureMastered = hooks.getWordStatus({ + correctCount: 4, + nextReview: new Date(Date.now() + 60_000).toISOString() + }, config); + + assert.strictEqual(dueMastered.tone, 'due'); + assert.strictEqual(futureMastered.tone, 'mastered'); + + hooks.setStore(createMockStore([ + { word: 'alpha', meaning: 'A', correctCount: 4, nextReview: new Date(Date.now() - 60_000).toISOString() } + ], config)); + const analysis = hooks.analyzeListWords(); + assert.strictEqual(analysis.masteredCount, 1); + assert.strictEqual(analysis.dueCount, 1); + }); + + await record('list modal restores focus to visible menu trigger', async () => { + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + const hiddenMenuItem = createElementStub('button'); + const trigger = createElementStub('button'); + trigger.dataset.action = 'menu-view-list'; + hooks.setStore(store); + documentStub.activeElement = hiddenMenuItem; + elements.menuButton._focused = false; + + elements.menu.dispatchEvent({ + type: 'click', + target: { + closest(selector) { + return selector === 'button[data-action]' ? trigger : null; + } + } + }); + await flushPromises(); + hooks.closeListModal(); + + assert.strictEqual(elements.menuButton._focused, true); + assert.ok(!hiddenMenuItem._focused); + }); + + await record('Escape cancels a pending list modal open', async () => { + let resolveInit; + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + store.init = () => new Promise((resolve) => { + resolveInit = resolve; + }); + hooks.setStore(store); + const opening = hooks.openListModal(elements.menuButton); + let prevented = false; + + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Escape', + preventDefault() { + prevented = true; + } + }); + resolveInit(true); + await opening; + + assert.strictEqual(prevented, true); + assert.notStrictEqual(elements.listModal.dataset.open, 'true'); + assert.ok(elements.listModal.hidden); + }); + + await record('list and settings modals remain mutually exclusive', async () => { + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + hooks.setStore(store); + + await hooks.openListModal(elements.menuButton); + hooks.openSettingsModal(elements.menuButton); + assert.strictEqual(elements.settingsModal.dataset.open, 'true'); + assert.strictEqual(elements.listModal.dataset.open, 'false'); + + await hooks.openListModal(elements.menuButton); + assert.strictEqual(elements.listModal.dataset.open, 'true'); + assert.strictEqual(elements.settingsModal.dataset.open, 'false'); + hooks.closeListModal(); + }); + + await record('settings writes keep the latest submitted values', async () => { + const pendingSaves = []; + const store = createMockStore(); + store.setConfig = (config) => new Promise((resolve) => { + pendingSaves.push(() => { + store.config = { ...store.config, ...config }; + resolve(true); + }); + }); + hooks.setStore(store); + elements.settingsForm.__fields = { + dailyNew: '10', + reviewLimit: '50', + masteryCount: '3' + }; + hooks.openSettingsModal(elements.menuButton); + const pendingSave = hooks.handleSettingsSubmit({ + preventDefault() {}, + currentTarget: elements.settingsForm + }); + await flushPromises(); + + hooks.closeSettingsModal(); + hooks.openSettingsModal(elements.menuButton); + elements.settingsForm.__fields = { + dailyNew: '30', + reviewLimit: '80', + masteryCount: '5', + notify: '1' + }; + const latestSave = hooks.handleSettingsSubmit({ + preventDefault() {}, + currentTarget: elements.settingsForm + }); + assert.strictEqual(pendingSaves.length, 1); + + pendingSaves.shift()(); + await pendingSave; + await flushPromises(); + assert.strictEqual(pendingSaves.length, 1); + pendingSaves.shift()(); + await latestSave; + + assert.strictEqual(store.config.dailyNew, 30); + assert.strictEqual(store.config.reviewLimit, 80); + assert.strictEqual(store.config.masteryCount, 5); + assert.strictEqual(store.config.notify, true); + assert.strictEqual(elements.settingsModal.dataset.open, 'false'); + }); + + await record('Tab stays inside the active modal', async () => { + let prevented = false; + hooks.openSettingsModal(elements.menuButton); + elements.settingsClose.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: false, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(prevented, true); + assert.strictEqual(documentStub.activeElement, elements.settingsFields.dailyNew); + + prevented = false; + elements.settingsFields.dailyNew.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: true, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(prevented, true); + assert.strictEqual(documentStub.activeElement, elements.settingsClose); + hooks.closeSettingsModal(); + + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + hooks.setStore(store); + await hooks.openListModal(elements.menuButton); + elements.listClose.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: false, + preventDefault() {} + }); + assert.strictEqual(documentStub.activeElement, elements.listSearch); + hooks.closeListModal(); + }); + + await record('list rendering is paged and resets after search', async () => { + const words = Array.from({ length: 401 }, (_, index) => ({ + word: `word-${String(index + 1).padStart(3, '0')}`, + meaning: `Meaning ${index + 1}` + })); + const store = createMockStore(words); + let getWordsCalls = 0; + const originalGetWords = store.getWords.bind(store); + store.getWords = () => { + getWordsCalls += 1; + return originalGetWords(); + }; + hooks.setStore(store); + hooks.state.ui.listBrowserQuery = ''; + hooks.state.ui.listBrowserLearnedOnly = false; + hooks.state.ui.listBrowserPage = 1; + + hooks.renderListBrowser(); + const firstBody = elements.listBody.innerHTML.match(/([\s\S]*?)<\/tbody>/)[1]; + assert.strictEqual((firstBody.match(//g) || []).length, 200); + assert.strictEqual(getWordsCalls, 1); + + hooks.state.ui.listBrowserPage = 2; + hooks.renderListBrowser(); + assert.match(elements.listBody.innerHTML, /201<\/td>/); + elements.listSearch.dispatchEvent({ type: 'input', target: { value: 'word-401' } }); + assert.strictEqual(hooks.state.ui.listBrowserPage, 1); + await new Promise((resolve) => setTimeout(resolve, 220)); + assert.match(elements.listBody.innerHTML, /word-401/); + }); + + await record('filtered list export remains a complete mergeable word list', async () => { + const store = createMockStore([ + { word: 'alpha', meaning: 'A', example: 'First', freq: 0.8, correctCount: 2 }, + { word: 'beta', meaning: 'B', note: 'private note', questionId: 'internal-id' } + ]); + hooks.setStore(store); + hooks.state.ui.listBrowserQuery = 'alpha'; + hooks.state.ui.listBrowserLearnedOnly = true; + windowStub.URL.created.length = 0; + const anchor = createElementStub('a'); + vocabContext.document.createElement = () => anchor; + + hooks.exportCurrentList(); + + const download = windowStub.URL.created.at(-1); + assert.ok(download, 'Expected an exported blob'); + const payload = JSON.parse(await download.blob.text()); + assert.strictEqual(payload.type, 'wordlist'); + assert.strictEqual(payload.category, 'external'); + assert.strictEqual(payload.entries.length, 2); + assert.deepStrictEqual(Array.from(payload.entries, (entry) => entry.word), ['alpha', 'beta']); + assert.ok(!Object.prototype.hasOwnProperty.call(payload, 'version')); + assert.ok(!Object.prototype.hasOwnProperty.call(payload, 'words')); + assert.ok(!Object.prototype.hasOwnProperty.call(payload.entries[0], 'correctCount')); + assert.ok(!Object.prototype.hasOwnProperty.call(payload.entries[1], 'questionId')); + assert.match(windowStub.messages.at(-1).text, /可分享词表/); + }); + + await record('card actions are ignored while list modal is open', () => { + hooks.state.session.stage = 'recognition'; + elements.listModal.dataset.open = 'true'; + let prevented = false; + hooks.handleCardAction({ + target: { + closest() { + return { dataset: { action: 'reveal-meaning' } }; + } + }, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(hooks.state.session.stage, 'recognition'); + assert.strictEqual(prevented, false); + elements.listModal.dataset.open = 'false'; + }); + await record('import request triggers input', () => { const store = createMockStore(); hooks.setStore(store); @@ -1018,11 +1390,11 @@ async function run() { windowStub.VocabDataIO = { importWordList: async () => ({ type: 'progress', - entries: [{ word: 'theta', meaning: 'T' }], + entries: [{ word: 'theta', meaning: 'T', nextReview: '2026-07-25T00:00:00.000Z' }], meta: { category: 'user', - config: { dailyNew: 5, reviewLimit: 10, masteryCount: 2, notify: false }, - reviewQueue: ['x'] + listId: 'spelling-errors-p1', + config: { dailyNew: 5, reviewLimit: 10, masteryCount: 2, notify: false } } }) }; @@ -1030,7 +1402,9 @@ async function run() { await hooks.performImport({ name: 'progress.json' }); assert.strictEqual(store.words.length, 1); assert.strictEqual(store.config.dailyNew, 5); - assert.strictEqual(store.reviewQueue.length, 1); + assert.strictEqual(store.config.activeListId, 'spelling-errors-p1'); + assert.strictEqual(store.replaceProgressCalls[0].listId, 'spelling-errors-p1'); + assert.strictEqual(store.replaceProgressCalls[0].words[0].nextReview, '2026-07-25T00:00:00.000Z'); }); await record('export progress triggers download', async () => { diff --git a/developer/tests/js/legacyMigrationBrickRegression.test.js b/developer/tests/js/legacyMigrationBrickRegression.test.js new file mode 100644 index 00000000..2829700f --- /dev/null +++ b/developer/tests/js/legacyMigrationBrickRegression.test.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// Regression: a single malformed v1 practice record (e.g. negative duration) must NOT +// reject AppData's module-level `ready` promise. Before the fix, migrateLegacyData ran +// unguarded inside the ready chain, so one bad record threw VALIDATION -> ready rejected +// as INITIALIZATION_BLOCKED -> every browse read awaiting ready failed -> #browse-view +// loading overlay never cleared (browse "won't open / freezes"), and with zero summaries +// written the idempotency guard never tripped, so reload re-hit the same record forever. +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const appDataSource = fs.readFileSync(path.join(root, 'js/data/v2/appData.js'), 'utf8'); +const catalogSource = fs.readFileSync(path.join(root, 'js/data/v2/dataCatalog.js'), 'utf8'); +const recordSource = fs.readFileSync(path.join(root, 'js/data/practiceRecordSource.js'), 'utf8'); +const clone = (value) => value === undefined ? undefined : structuredClone(value); +function stable(value) { if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; return JSON.stringify(value); } +function checksum(value) { let hash = 0x811c9dc5; for (const char of stable(value)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 0x01000193); } return `fnv1a-${(hash >>> 0).toString(16)}`; } +class AppDataError extends Error { constructor(code, message) { super(message); this.code = code; } } + +function harness(legacyValues, options = {}) { + const catalogSandbox = { structuredClone }; catalogSandbox.globalThis = catalogSandbox; + vm.runInContext(catalogSource, vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' }); + const catalog = catalogSandbox.__AppDataV2Catalog; + const shared = options.shared || { docs: new Map(), entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), counter: 0, legacyReads: 0, externalReads: 0 }; + const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({ schemaVersion: 2, revision, operationId, updatedAt: new Date().toISOString(), state, data: state === 'cleared' ? null : clone(data), checksum: checksum(state === 'cleared' ? null : data) }); + class Kernel { + async initialize() { this.state = 'ready'; this.backend = 'memory'; return this; } + async getEnvelope(key) { return shared.docs.get(key) || null; } + async read(key, options = {}) { const entry = catalog.get(key); const value = shared.docs.get(key) || null; const data = !value || value.state === 'cleared' ? entry.defaultValue() : value.data; return options.withMeta ? { data: clone(data), envelope: clone(value) } : clone(data); } + async mutate(changes, options = {}) { const op = String(options.operationId || `doc-${++shared.counter}`); const revisions = {}; for (const change of changes) { const old = shared.docs.get(change.logicalKey); if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== Number(old && old.revision || 0)) throw new AppDataError('CONFLICT', 'document revision'); const revision = Number(old && old.revision || 0) + 1; shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, op)); revisions[change.logicalKey] = revision; } return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; } + async readEntity(store, recordId) { const row = shared.entities.get(store).get(String(recordId)); return row ? clone(row.data) : null; } + async listEntities(store) { if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable'); return Array.from(shared.entities.get(store).values()).map((row) => clone(row.data)); } + async mutateEntities(operations, options = {}) { const op = String(options.operationId || `entity-${++shared.counter}`); for (const item of operations) { const rows = shared.entities.get(item.store); const old = rows.get(String(item.recordId)); rows.set(String(item.recordId), { recordId: String(item.recordId), revision: Number(old && old.revision || 0) + 1, operationId: op, data: clone(item.data) }); } return { committed: true, operationId: op, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; } + status() { return { state: this.state, backend: this.backend, failure: null }; } + } + const internals = { DataKernel: Kernel, AppDataError, catalog, clone, checksum, randomId: (prefix) => `${prefix}-${++shared.counter}`, nowIso: () => new Date().toISOString(), makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId), validateEnvelope: (entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data)), readLegacyValues: async () => { shared.legacyReads += 1; return clone(legacyValues); }, readLegacyExternalBackup: async () => { shared.externalReads += 1; return clone(options.externalBackup || null); } }; + const sandbox = { console: { log() {}, warn() {}, error() {} }, Date, JSON, Math, Map, Set, Promise, structuredClone, __AppDataV2Internals: internals, sessionStorage: { getItem() { return null; }, setItem() {}, removeItem() {} } }; sandbox.window = sandbox; sandbox.globalThis = sandbox; + const context = vm.createContext(sandbox); vm.runInContext(recordSource, context, { filename: 'practiceRecordSource.js' }); vm.runInContext(appDataSource, context, { filename: 'appData.js' }); return { app: sandbox.AppData, shared }; +} + +async function run() { + // One valid record + one malformed (negative duration -> canonicalizeRecord throws VALIDATION). + const legacy = { practice_records: [ + { id: 'good-1', type: 'reading', duration: 120, totalQuestions: 10, correctAnswers: 8, accuracy: 0.8 }, + { id: 'bad-1', type: 'reading', duration: -5, totalQuestions: 10, correctAnswers: 8, accuracy: 0.8 } + ] }; + const { app } = harness(legacy); + + // Core regression: ready must resolve; browse's data read must not throw INITIALIZATION_BLOCKED. + const summaries = await app.practice.list({ projection: 'light' }); + + // Good record migrated; malformed record skipped rather than bricking the whole migration. + assert.strictEqual(summaries.length, 1, 'exactly the one valid record should migrate'); + assert.strictEqual(summaries[0].id, 'good-1', 'the valid record must survive'); + assert.deepStrictEqual(await app.settings.getAll(), {}, 'settings read after migration must not reject'); + + // Empty legacy set is also a clean resolve (no records path). + const empty = harness({}); + assert.deepStrictEqual(await empty.app.practice.list({ projection: 'light' }), [], 'empty legacy migrates to empty list'); + + const merged = harness({ practice_records: [ + { id: 'idb-only', type: 'reading', title: 'IDB only', totalQuestions: 1, correctAnswers: 1 }, + { id: 'shared', type: 'reading', title: 'IDB wins', totalQuestions: 1, correctAnswers: 1 } + ] }, { externalBackup: { practiceRecords: [ + { id: 'external-only', type: 'reading', title: 'External only', totalQuestions: 1, correctAnswers: 1 }, + { id: 'shared', type: 'reading', title: 'External loses', totalQuestions: 1, correctAnswers: 0 } + ] } }); + const mergedSummaries = await merged.app.practice.list({ projection: 'light' }); + assert.deepStrictEqual(mergedSummaries.map((record) => record.id).sort(), ['external-only', 'idb-only', 'shared']); + assert.strictEqual(mergedSummaries.find((record) => record.id === 'shared').title, 'IDB wins'); + assert.strictEqual(merged.shared.docs.get('system.migrations').data.externalBackupV1.status, 'consumed'); + + const secondBoot = harness({ practice_records: [ + { id: 'must-not-resurrect', type: 'reading', totalQuestions: 1, correctAnswers: 1 } + ] }, { shared: merged.shared, externalBackup: { practiceRecords: [ + { id: 'must-not-reimport', type: 'reading', totalQuestions: 1, correctAnswers: 1 } + ] } }); + await secondBoot.app.ready; + assert.strictEqual(merged.shared.legacyReads, 1, 'completed migration must not rescan v1'); + assert.strictEqual(merged.shared.externalReads, 1, 'consumed external JSON must not be read again'); + assert.strictEqual((await secondBoot.app.practice.list({ projection: 'light' })).length, 3); + + const partialShared = { docs: new Map(), entities: new Map([['practiceSummaries', new Map()], ['practiceDetails', new Map()], ['practiceAnnotations', new Map()]]), counter: 0, legacyReads: 0, externalReads: 0 }; + partialShared.entities.get('practiceSummaries').set('partial', { + recordId: 'partial', revision: 1, operationId: 'seed', data: { id: 'partial', type: 'reading', title: 'Keep v2 summary' } + }); + const partial = harness({ practice_records: [{ + id: 'partial', type: 'reading', title: 'Legacy summary', totalQuestions: 1, correctAnswers: 1, + answers: { 1: 'A' }, notes: { 1: 'Recovered note' } + }] }, { shared: partialShared }); + await partial.app.ready; + assert.strictEqual(partial.shared.entities.get('practiceSummaries').get('partial').data.title, 'Keep v2 summary'); + assert.strictEqual(partial.shared.entities.get('practiceDetails').get('partial').data.answers[1], 'A'); + assert.strictEqual(partial.shared.entities.get('practiceAnnotations').get('partial').data.notes[1], 'Recovered note'); + + const customLibrary = harness({ + active_exam_index_key: 'exam_index_1700000000000', + exam_index_configurations: [{ id: 'exam_index_1700000000000', name: 'Legacy custom' }], + exam_index_1700000000000: [{ id: 'legacy-exam', type: 'reading' }] + }); + await customLibrary.app.ready; + const activeId = await customLibrary.app.library.getActive(); + assert.match(activeId, /^legacy-library-/); + assert.strictEqual((await customLibrary.app.library.getIndex(activeId))[0].id, 'legacy-exam'); + assert.strictEqual((await customLibrary.app.library.listConfigurations())[0].id, activeId); + + console.log('PASS legacyMigrationBrickRegression'); +} + +run().catch((error) => { console.error('FAIL legacyMigrationBrickRegression'); console.error(error); process.exit(1); }); diff --git a/developer/tests/js/legacyViewReadStatus.test.js b/developer/tests/js/legacyViewReadStatus.test.js index 60c7f155..9cdeeab1 100644 --- a/developer/tests/js/legacyViewReadStatus.test.js +++ b/developer/tests/js/legacyViewReadStatus.test.js @@ -89,7 +89,7 @@ describe('LegacyExamListView._getCompletionStatus', () => { title: 'Passage 2', path: 'Reading/P2/passage-2.html' }; - windowStub.getPracticeRecordsState = () => ([ + const records = [ { id: 'suite-record-1', examId: 'suite-suite-record-1', @@ -104,7 +104,8 @@ describe('LegacyExamListView._getCompletionStatus', () => { } ] } - ]); + ]; + windowStub.rebuildBrowseCompletionIndex(records); const status = view._getCompletionStatus(exam); @@ -113,37 +114,37 @@ describe('LegacyExamListView._getCompletionStatus', () => { assert.strictEqual(status.date, '2026-07-01T09:58:00.000Z', '应优先读取 suiteEntries 子条目的时间'); }); - it('uses suite child scoreInfo and parent timestamp fallback for lightweight summaries', () => { + it('uses suiteEntrySummaries score and parent timestamp fallback for light records', () => { const { windowStub, LegacyExamListView } = loadLegacyExamListView(); const view = new LegacyExamListView(); const exam = { id: 'reading-p3', title: 'Passage 3' }; - windowStub.getPracticeRecordsState = () => ([ + const records = [ { id: 'suite-record-2', examId: 'suite-suite-record-2', title: '2026-07-02 套题', date: '2026-07-02T12:30:00.000Z', - suiteEntries: [ + suiteEntrySummaries: [ { examId: 'reading-p3', title: 'Passage 3', - scoreInfo: { - correct: 9, - total: 10, - percentage: 90 - } + correctAnswers: 9, + totalQuestions: 10, + accuracy: 0.9, + percentage: 90 } ] } - ]); + ]; + windowStub.rebuildBrowseCompletionIndex(records); const status = view._getCompletionStatus(exam); - assert(status, '轻量 suiteEntries 子条目也应产生完成状态'); - assert.strictEqual(status.percentage, 90, '应从 suiteEntries.scoreInfo 读取分数'); + assert(status, 'light.suiteEntrySummaries 子条目也应产生完成状态'); + assert.strictEqual(status.percentage, 90, '应从 suiteEntrySummaries 读取分数'); assert.strictEqual(status.date, '2026-07-02T12:30:00.000Z', '子条目缺失时间时应回退到父记录时间'); }); }); diff --git a/developer/tests/js/libraryManagerImportConfig.test.js b/developer/tests/js/libraryManagerImportConfig.test.js index 93b719a1..870575f2 100644 --- a/developer/tests/js/libraryManagerImportConfig.test.js +++ b/developer/tests/js/libraryManagerImportConfig.test.js @@ -22,42 +22,63 @@ function clone(value) { } function createHarness(seed = {}) { - const storageState = new Map(); - const localStorageState = new Map(); - const appState = { examIndex: [] }; + const librarySeed = seed.library && typeof seed.library === 'object' ? seed.library : {}; + const indexes = new Map(Object.entries(librarySeed.importedIndexes || {}).map(([id, value]) => [String(id), clone(value)])); + let activeId = typeof librarySeed.activeConfigurationId === 'string' && librarySeed.activeConfigurationId.trim() + ? librarySeed.activeConfigurationId.trim() + : null; + let configurations = (Array.isArray(librarySeed.configurations) ? librarySeed.configurations : []) + .filter((config) => config && (config.id || config.key)) + .map((config) => Object.assign({}, clone(config), { id: config.id || config.key })); + const practiceRecords = clone(seed.records || []); const defaultReadingIndex = clone(seed.readingExamIndex || []); - Object.entries(seed.storage || {}).forEach(([key, value]) => { - storageState.set(key, clone(value)); - }); - - const localStorage = { - getItem(key) { - return localStorageState.has(key) ? localStorageState.get(key) : null; + let resourceBasePrefix = ''; + + const AppData = { + ready: Promise.resolve(), + library: { + async getActive() { return activeId; }, + async activate(id) { + activeId = typeof id === 'string' && id.trim() ? id.trim() : null; + return { committed: true }; + }, + async listConfigurations() { return clone(configurations); }, + async updateConfiguration(config) { + const id = String(config?.id || config?.key || '').trim(); + if (!id) throw new Error('configuration id required'); + const next = Object.assign({}, clone(config), { id, key: id }); + const index = configurations.findIndex((item) => item && (item.id === id || item.key === id)); + if (index >= 0) configurations[index] = next; + else configurations.push(next); + return { committed: true }; + }, + async getIndex(id) { return clone(indexes.get(String(id || '')) || []); }, + async resolveIndex() { return activeId ? clone(indexes.get(activeId) || []) : []; }, + async import({ id, configuration, index }) { + const normalizedId = String(id || '').trim(); + indexes.set(normalizedId, clone(Array.isArray(index) ? index : [])); + await this.updateConfiguration(Object.assign({}, configuration || {}, { id: normalizedId, key: normalizedId })); + return { committed: true }; + }, + async remove(id) { + const normalizedId = String(id || '').trim(); + indexes.delete(normalizedId); + configurations = configurations.filter((item) => item && item.id !== normalizedId && item.key !== normalizedId); + return { committed: true }; + } }, - setItem(key, value) { - localStorageState.set(key, String(value)); + practice: { + async list() { return clone(practiceRecords); } }, - removeItem(key) { - localStorageState.delete(key); + preferences: { + async getResourceBasePrefix() { return resourceBasePrefix; }, + async setResourceBasePrefix(value) { resourceBasePrefix = String(value || ''); } } }; const windowStub = { console: { log() {}, warn() {}, error() {}, info() {} }, - localStorage, - storage: { - async get(key, fallback = null) { - return storageState.has(key) ? clone(storageState.get(key)) : clone(fallback); - }, - async set(key, value) { - storageState.set(key, clone(value)); - return true; - }, - async remove(key) { - storageState.delete(key); - return true; - } - }, + AppData, __READING_EXAM_INDEX__: clone(defaultReadingIndex), getReadingExamIndex() { return clone(defaultReadingIndex); @@ -67,13 +88,6 @@ function createHarness(seed = {}) { __defaultListeningLibraryAvailable: typeof seed.defaultListeningAvailable === 'boolean' ? seed.defaultListeningAvailable : undefined, - getExamIndexState() { - return clone(appState.examIndex); - }, - setExamIndexState(next) { - appState.examIndex = clone(Array.isArray(next) ? next : []); - return clone(appState.examIndex); - }, assignExamSequenceNumbers(list) { (Array.isArray(list) ? list : []).forEach((exam, index) => { if (exam && typeof exam === 'object') { @@ -100,7 +114,6 @@ function createHarness(seed = {}) { window: windowStub, globalThis: windowStub, console: windowStub.console, - localStorage, CustomEvent: class CustomEvent { constructor(type, init = {}) { this.type = type; @@ -127,7 +140,7 @@ function createHarness(seed = {}) { loadScript('js/core/resourceCore.js', context); loadScript('js/services/libraryDiscovery.js', context); loadScript('js/services/libraryManager.js', context); - return { window: windowStub, storageState, appState }; + return { window: windowStub, indexes, getActiveId: () => activeId, getConfigurations: () => clone(configurations) }; } function baseSeed() { @@ -161,16 +174,14 @@ function baseSeed() { readingA, listeningOld, records, - storage: { - active_exam_index_key: 'custom_active', - custom_active: [readingA, listeningOld], - exam_index: [readingA, listeningOld], - exam_index_configurations: [ - { name: '当前题库', key: 'custom_active', examCount: 2, timestamp: 1 }, - { name: '默认题库', key: 'exam_index', examCount: 2, timestamp: 1 } - ], - practice_records: records + library: { + activeConfigurationId: 'custom_active', + importedIndexes: { custom_active: [readingA, listeningOld] }, + configurations: [ + { id: 'custom_active', name: '当前题库', key: 'custom_active', examCount: 2, timestamp: 1 } + ] }, + records, readingExamIndex: [readingA], listeningExamIndex: [listeningOld] }; @@ -205,14 +216,14 @@ async function testFullListeningCreatesSnapshotAndKeepsReading() { }); assert.notStrictEqual(created.key, 'custom_active', '导入必须创建新配置,不能污染当前配置'); - assert.strictEqual(await window.storage.get('active_exam_index_key'), created.key, '新配置应成为活动配置'); - const oldConfig = await window.storage.get('custom_active'); + assert.strictEqual(await window.AppData.library.getActive(), created.key, '新配置应成为活动配置'); + const oldConfig = await window.AppData.library.getIndex('custom_active'); assert.deepStrictEqual(oldConfig, [seed.readingA, seed.listeningOld], '旧配置索引必须原样保留'); - const next = await window.storage.get(created.key); + const next = await window.AppData.library.getIndex(created.key); assert(next.some((exam) => exam.id === 'reading-a'), '听力全量导入必须继承阅读索引'); assert(next.some((exam) => exam.id === 'listening-new'), '新听力题必须进入新配置'); assert(!next.some((exam) => exam.id === 'listening-old'), '听力全量导入应替换旧听力索引'); - assert.deepStrictEqual(await window.storage.get('practice_records'), seed.records, '导入配置不能修改练习记录'); + assert.deepStrictEqual(await window.AppData.practice.list(), seed.records, '导入配置不能修改练习记录'); assert.strictEqual(created.counts.reading, 1); assert.strictEqual(created.counts.listening, 1); @@ -241,12 +252,12 @@ async function testFullReadingCreatesSnapshotAndKeepsListening() { activate: true }); - const next = await window.storage.get(created.key); + const next = await window.AppData.library.getIndex(created.key); assert(next.some((exam) => exam.id === 'reading-new'), '阅读全量导入必须进入新配置'); assert(!next.some((exam) => exam.id === 'reading-a'), '阅读全量导入应替换旧阅读索引'); assert(next.some((exam) => exam.id === 'listening-old'), '阅读全量导入必须继承听力索引'); - assert.deepStrictEqual(await window.storage.get('custom_active'), [seed.readingA, seed.listeningOld], '旧配置不能被阅读全量改写'); - assert.deepStrictEqual(await window.storage.get('practice_records'), seed.records, '阅读导入不能修改练习记录'); + assert.deepStrictEqual(await window.AppData.library.getIndex('custom_active'), [seed.readingA, seed.listeningOld], '旧配置不能被阅读全量改写'); + assert.deepStrictEqual(await window.AppData.practice.list(), seed.records, '阅读导入不能修改练习记录'); recordResult('阅读全量导入创建新配置并继承听力', { key: created.key, counts: created.counts }); } @@ -280,20 +291,20 @@ async function testIncrementalCreatesSnapshotAndDedupes() { assert.notStrictEqual(created.key, 'custom_active', '增量导入也必须创建新配置'); assert.strictEqual(created.merge.updated, 1, '同 importKey 的题源应更新'); assert.strictEqual(created.merge.added, 1, '新 importKey 的题源应追加'); - const next = await window.storage.get(created.key); + const next = await window.AppData.library.getIndex(created.key); assert(next.some((exam) => exam.id === 'reading-a'), '增量导入必须保留阅读索引'); assert(next.some((exam) => exam.title === 'P2 Listening Updated'), '增量导入应更新旧题'); assert(next.some((exam) => exam.id === 'listening-extra'), '增量导入应追加新题'); assert(!next.some((exam) => exam.id === 'listening-old'), '同 importKey 旧题不应重复保留'); - assert.deepStrictEqual(await window.storage.get('custom_active'), [seed.readingA, seed.listeningOld], '增量导入不能改写原活动配置'); - assert.deepStrictEqual(await window.storage.get('practice_records'), seed.records, '增量导入不能修改练习记录'); + assert.deepStrictEqual(await window.AppData.library.getIndex('custom_active'), [seed.readingA, seed.listeningOld], '增量导入不能改写原活动配置'); + assert.deepStrictEqual(await window.AppData.practice.list(), seed.records, '增量导入不能修改练习记录'); recordResult('增量导入创建新配置并按 importKey 去重更新', { key: created.key, merge: created.merge }); } async function testSwitchConfigurationDoesNotTouchPracticeRecords() { const seed = baseSeed(); - seed.storage.alt_config = [{ + seed.library.importedIndexes.alt_config = [{ id: 'listening-alt', examId: 'listening-alt', type: 'listening', @@ -302,23 +313,23 @@ async function testSwitchConfigurationDoesNotTouchPracticeRecords() { path: 'Alt/', filename: 'alt.html' }]; - seed.storage.exam_index_configurations.push({ name: 'Alt', key: 'alt_config', examCount: 1, timestamp: 2 }); + seed.library.configurations.push({ id: 'alt_config', name: 'Alt', key: 'alt_config', examCount: 1, timestamp: 2 }); const { window } = createHarness(seed); const manager = window.LibraryManager.getInstance(); - const before = await window.storage.get('practice_records'); + const before = await window.AppData.practice.list(); const applied = await manager.applyLibraryConfiguration('alt_config'); assert.strictEqual(applied, true, '配置切换应该成功'); - assert.strictEqual(await window.storage.get('active_exam_index_key'), 'alt_config', '活动配置应切换到目标配置'); - assert.deepStrictEqual(await window.storage.get('practice_records'), before, '配置切换不能触碰练习记录'); + assert.strictEqual(await window.AppData.library.getActive(), 'alt_config', '活动配置应切换到目标配置'); + assert.deepStrictEqual(await window.AppData.practice.list(), before, '配置切换不能触碰练习记录'); recordResult('切换题库配置不触碰练习记录', { activeKey: 'alt_config' }); } async function testDeleteInactiveConfigurationCleansDatasetAndPathMap() { const seed = baseSeed(); - seed.storage.delete_me = [{ + seed.library.importedIndexes.delete_me = [{ id: 'delete-me-listening', examId: 'delete-me-listening', type: 'listening', @@ -327,24 +338,19 @@ async function testDeleteInactiveConfigurationCleansDatasetAndPathMap() { path: 'DeleteMe/', filename: 'delete.html' }]; - seed.storage['exam_path_map__delete_me'] = { - reading: { root: 'ReadingCustom/', exceptions: {} }, - listening: { root: 'DeleteMe/', exceptions: {} } - }; - seed.storage.exam_index_configurations.push({ name: 'Delete Me', key: 'delete_me', examCount: 1, timestamp: 3 }); + seed.library.configurations.push({ id: 'delete_me', name: 'Delete Me', key: 'delete_me', examCount: 1, timestamp: 3 }); const { window } = createHarness(seed); const manager = window.LibraryManager.getInstance(); - const before = await window.storage.get('practice_records'); + const before = await window.AppData.practice.list(); const result = await manager.deleteLibraryConfiguration('delete_me'); assert.strictEqual(result.deleted, true, '非活动自定义配置应允许删除'); - assert.strictEqual(await window.storage.get('delete_me', null), null, '删除配置时必须删除对应题库数据集'); - assert.strictEqual(await window.storage.get('exam_path_map__delete_me', null), null, '删除配置时必须清理对应 path map'); - const configs = await window.storage.get('exam_index_configurations', []); + assert.deepStrictEqual(await window.AppData.library.getIndex('delete_me'), [], '删除配置时必须删除对应题库数据集'); + const configs = await window.AppData.library.listConfigurations(); assert(!configs.some((config) => config && config.key === 'delete_me'), '配置列表中不应残留已删除配置'); - assert.deepStrictEqual(await window.storage.get('practice_records'), before, '删除题库配置不能删除练习记录'); - assert.strictEqual(await window.storage.get('active_exam_index_key'), 'custom_active', '删除非活动配置不能改变当前活动配置'); + assert.deepStrictEqual(await window.AppData.practice.list(), before, '删除题库配置不能删除练习记录'); + assert.strictEqual(await window.AppData.library.getActive(), 'custom_active', '删除非活动配置不能改变当前活动配置'); recordResult('删除非活动配置会清理数据集和 path map 且保留练习记录', { key: 'delete_me' }); } @@ -353,18 +359,18 @@ async function testDeleteConfigurationGuardsDefaultAndActive() { const seed = baseSeed(); const { window } = createHarness(seed); const manager = window.LibraryManager.getInstance(); - const beforeConfigs = await window.storage.get('exam_index_configurations'); - const beforeRecords = await window.storage.get('practice_records'); + const beforeConfigs = await window.AppData.library.listConfigurations(); + const beforeRecords = await window.AppData.practice.list(); - const defaultResult = await manager.deleteLibraryConfiguration('exam_index'); + const defaultResult = await manager.deleteLibraryConfiguration(''); const activeResult = await manager.deleteLibraryConfiguration('custom_active'); assert.strictEqual(defaultResult.deleted, false, '默认配置不能删除'); - assert.strictEqual(defaultResult.reason, 'default-config', '默认配置删除应返回明确原因'); + assert.strictEqual(defaultResult.reason, 'invalid-key', 'nullable 默认配置没有可删除实体,应返回 invalid-key'); assert.strictEqual(activeResult.deleted, false, '当前活动配置不能删除'); assert.strictEqual(activeResult.reason, 'active-config', '活动配置删除应返回明确原因'); - assert.deepStrictEqual(await window.storage.get('exam_index_configurations'), beforeConfigs, '受保护配置删除不应改写配置列表'); - assert.deepStrictEqual(await window.storage.get('practice_records'), beforeRecords, '受保护配置删除不应改写练习记录'); + assert.deepStrictEqual(await window.AppData.library.listConfigurations(), beforeConfigs, '受保护配置删除不应改写配置列表'); + assert.deepStrictEqual(await window.AppData.practice.list(), beforeRecords, '受保护配置删除不应改写练习记录'); recordResult('删除配置保护默认和当前活动配置', { defaultReason: defaultResult.reason, @@ -392,7 +398,6 @@ async function testDefaultLibrarySkipsListeningWithoutManifest() { filename: 'listening.html' }; const { window } = createHarness({ - storage: { active_exam_index_key: 'exam_index' }, readingExamIndex: [readingDefault], listeningExamIndex: [listeningDefault], defaultListeningAvailable: false @@ -427,7 +432,6 @@ async function testDefaultLibraryKeepsListeningWithManifest() { filename: 'listening.html' }; const { window } = createHarness({ - storage: { active_exam_index_key: 'exam_index' }, readingExamIndex: [readingDefault], listeningExamIndex: [listeningDefault], listeningManifest: { 'default-listening': { examId: 'default-listening' } }, @@ -443,6 +447,58 @@ async function testDefaultLibraryKeepsListeningWithManifest() { recordResult('manifest 存在时默认题库加载内置听力', { loadedCount: loaded.length }); } +async function testBrokenLegacyActiveLibraryFallsBackToReadingManifest() { + const readingDefault = { + id: 'default-reading-after-repair', + examId: 'default-reading-after-repair', + type: 'reading', + title: 'Default Reading After Repair', + category: 'P1', + path: 'Reading/default-repair/', + filename: 'reading.html' + }; + const { window, getActiveId } = createHarness({ + library: { + activeConfigurationId: 'exam_index', + configurations: [{ id: 'exam_index', key: 'exam_index', name: '错误迁移的默认题库' }], + importedIndexes: {} + }, + readingExamIndex: [readingDefault], + defaultListeningAvailable: false + }); + const manager = window.LibraryManager.getInstance(); + const loaded = await manager.loadActiveLibrary(true); + + assert.deepStrictEqual(loaded.map((exam) => exam.id), ['default-reading-after-repair'], 'a broken v1 active key must not hide the generated Reading manifest'); + assert.strictEqual(getActiveId(), 'exam_index', 'display fallback must not rewrite persistent selection state'); + recordResult('错误迁移的 v1 活动题库回退 Reading manifest', { loadedCount: loaded.length }); +} + +async function testForceReloadKeepsHealthyCustomLibraryActive() { + const customExam = { + id: 'healthy-custom-reading', + examId: 'healthy-custom-reading', + type: 'reading', + title: 'Healthy Custom Reading', + category: 'P1', + path: 'Imported/healthy/', + filename: 'reading.html' + }; + const { window, getActiveId } = createHarness({ + library: { + activeConfigurationId: 'healthy-custom', + configurations: [{ id: 'healthy-custom', key: 'healthy-custom', name: '健康自定义题库' }], + importedIndexes: { 'healthy-custom': [customExam] } + }, + readingExamIndex: [{ id: 'default-must-not-replace-custom', type: 'reading' }] + }); + const loaded = await window.LibraryManager.getInstance().loadActiveLibrary(true); + + assert.deepStrictEqual(loaded.map((exam) => exam.id), ['healthy-custom-reading']); + assert.strictEqual(getActiveId(), 'healthy-custom', 'force reload must not switch a healthy custom library to default'); + recordResult('强制刷新保留健康自定义题库', { loadedCount: loaded.length }); +} + async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing() { const readingNew = { id: 'reading-new-default', @@ -464,7 +520,6 @@ async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing() filename: 'hidden.html' }; const { window } = createHarness({ - storage: { active_exam_index_key: 'exam_index', exam_index: [] }, readingExamIndex: [], listeningExamIndex: [defaultListening], defaultListeningAvailable: false @@ -476,7 +531,7 @@ async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing() additions: [readingNew], activate: true }); - const next = await window.storage.get(created.key); + const next = await window.AppData.library.getIndex(created.key); assert(next.some((exam) => exam.id === 'reading-new-default'), '阅读导入应保存新阅读'); assert(!next.some((exam) => exam.id === 'default-listening-hidden'), 'manifest 缺失时阅读全量导入不能补回默认听力'); @@ -485,6 +540,78 @@ async function testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing() recordResult('阅读全量导入不会补回缺 manifest 的默认听力', { key: created.key, counts: created.counts }); } +async function testRecordResolverUsesStoredLibraryProvenance() { + const defaultExam = { + id: 'shared-exam-id', + examId: 'shared-exam-id', + type: 'reading', + title: 'Default source', + category: 'P1', + path: 'Reading/default-source/' + }; + const importedExam = Object.assign({}, defaultExam, { + title: 'Imported source', + category: 'P4', + path: 'Reading/imported-source/' + }); + const { window } = createHarness({ + library: { + activeConfigurationId: 'library_imported_source', + configurations: [{ id: 'library_imported_source', key: 'library_imported_source', name: 'Imported source' }], + importedIndexes: { library_imported_source: [importedExam] } + }, + readingExamIndex: [defaultExam] + }); + + const defaultResolved = await window.resolveExamForPracticeRecord({ + examId: 'shared-exam-id', + metadata: { libraryConfigurationId: null } + }); + const importedResolved = await window.resolveExamForPracticeRecord({ + examId: 'shared-exam-id', + metadata: { libraryConfigurationId: 'library_imported_source' } + }); + const unknownResolved = await window.resolveExamForPracticeRecord({ examId: 'shared-exam-id' }); + + assert.strictEqual(defaultResolved.title, 'Default source', 'default provenance must not use the active imported library'); + assert.strictEqual(importedResolved.title, 'Imported source', 'imported provenance must resolve its own authoritative index'); + assert.strictEqual(unknownResolved, null, 'with custom libraries present, a provenance-less legacy record must not guess the active library and risk resolving a shared examId to the wrong exam'); + recordResult('历史记录按保存的题库 provenance 解析', { + defaultTitle: defaultResolved.title, + importedTitle: importedResolved.title + }); +} + +// Conditional-downgrade contract: a v1-migrated record that never got a +// libraryConfigurationId must still be replayable when the user has NO custom +// libraries — the examId can only mean the one default-library exam, so falling +// back to the active index is safe and restores v1 behavior. This guards against +// the v2 hard gate that made every provenance-less legacy record fail to open. +async function testRecordResolverFallsBackForLegacyRecordsWhenNoCustomLibraries() { + const defaultExam = { + id: 'legacy-exam-id', + examId: 'legacy-exam-id', + type: 'reading', + title: 'Default source', + category: 'P1', + path: 'Reading/default-source/' + }; + const { window } = createHarness({ + library: { + activeConfigurationId: null, + configurations: [], + importedIndexes: {} + }, + readingExamIndex: [defaultExam] + }); + + const resolved = await window.resolveExamForPracticeRecord({ examId: 'legacy-exam-id' }); + + assert.ok(resolved, 'a provenance-less legacy record must still resolve when no custom library can cause ambiguity'); + assert.strictEqual(resolved.title, 'Default source', 'the fallback must resolve against the active (default) library'); + recordResult('无自定义题库时旧记录回退当前题库解析', { title: resolved.title }); +} + async function main() { try { await testFullListeningCreatesSnapshotAndKeepsReading(); @@ -495,7 +622,11 @@ async function main() { await testDeleteConfigurationGuardsDefaultAndActive(); await testDefaultLibrarySkipsListeningWithoutManifest(); await testDefaultLibraryKeepsListeningWithManifest(); + await testBrokenLegacyActiveLibraryFallsBackToReadingManifest(); + await testForceReloadKeepsHealthyCustomLibraryActive(); await testFullReadingDoesNotReAddDefaultListeningWhenManifestMissing(); + await testRecordResolverUsesStoredLibraryProvenance(); + await testRecordResolverFallsBackForLegacyRecordsWhenNoCustomLibraries(); console.log(JSON.stringify({ status: 'pass', detail: `${results.length}/${results.length} 测试通过`, diff --git a/developer/tests/js/listeningRecordBridgeParser.test.js b/developer/tests/js/listeningRecordBridgeParser.test.js new file mode 100644 index 00000000..f05ccd4f --- /dev/null +++ b/developer/tests/js/listeningRecordBridgeParser.test.js @@ -0,0 +1,115 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import '../../../js/utils/safeObjectLiteralParser.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const parser = globalThis.SafeObjectLiteralParser; + +function assertRejected(source, pattern) { + assert.throws( + () => parser.parse(source), + pattern || /SafeObjectLiteralParseError/, + `应拒绝: ${source}` + ); +} + +function testLegacyDataSyntax() { + const value = parser.parse(` + { + answerKey: { + text: { q1: 'accommodation', 2: "library", }, + matching: { q2: 'B' } + }, + sections: [true, false, null, -1.5e2,], + // 兼容题库行注释 + title: 'Listening \\\\ Practice', + /* 兼容块注释 */ + } + `); + + assert.strictEqual(Object.getPrototypeOf(value), null); + assert.strictEqual(Object.getPrototypeOf(value.answerKey), null); + assert.strictEqual(value.answerKey.text.q1, 'accommodation'); + assert.strictEqual(value.answerKey.text['2'], 'library'); + assert.deepStrictEqual(value.sections, [true, false, null, -150]); + assert.strictEqual(value.title, 'Listening \\ Practice'); +} + +function testParseAtStopsAfterLiteral() { + const source = " /* config */ { answerKey: { text: { q31: 'accommodation' } } }; runLater()"; + const result = parser.parseAt(source, 0); + assert.strictEqual(result.value.answerKey.text.q31, 'accommodation'); + assert.strictEqual(source.slice(result.endIndex).trim(), '; runLater()'); +} + +function testExecutableSyntaxIsRejected() { + global.__listeningParserSentinel = 0; + const attacks = [ + "{ value: (global.__listeningParserSentinel = 1) }", + "{ value: global.__listeningParserSentinel }", + "{ value: (() => 1)() }", + "{ get value() { global.__listeningParserSentinel = 1; } }", + "{ ...global.__payload }", + "{ [global.__key]: 1 }", + "{ value: `template` }", + "{ value: /regex/ }", + "{ value: undefined }", + "{ value: NaN }", + "{ value: Infinity }", + "{ shorthand }" + ]; + attacks.forEach((source) => assertRejected(source)); + assert.strictEqual(global.__listeningParserSentinel, 0, '恶意输入不得执行'); + delete global.__listeningParserSentinel; +} + +function testPrototypePollutionKeysAreRejected() { + assertRejected("{ __proto__: { polluted: true } }", /forbidden object key/); + assertRejected("{ constructor: { prototype: { polluted: true } } }", /forbidden object key/); + assertRejected("{ 'prototype': {} }", /forbidden object key/); + assert.strictEqual({}.polluted, undefined); +} + +function testLimitsAndMalformedInput() { + assert.throws( + () => parser.parse('{ a: { b: { c: 1 } } }', { maxDepth: 2 }), + /maximum nesting depth/ + ); + assertRejected("{ value: 'unterminated }", /unterminated string/); + assertRejected('{ value: 1 /* unterminated }', /unterminated block comment/); + assert.throws( + () => parser.parse('{ a: 1, b: 2 }', { maxProperties: 1 }), + /maximum property count/ + ); +} + +function testBridgeHasNoDynamicCodeExecution() { + const bridgePath = path.join(__dirname, '../../../js/listeningRecordBridge.js'); + const source = fs.readFileSync(bridgePath, 'utf8'); + assert.ok(!/\bnew\s+Function\s*\(/.test(source), 'bridge 不得使用 new Function'); + assert.ok(!/\beval\s*\(/.test(source), 'bridge 不得使用 eval'); + assert.ok(source.includes('SafeObjectLiteralParser.parseAt'), 'bridge 应使用纯数据解析器'); +} + +function run() { + testLegacyDataSyntax(); + testParseAtStopsAfterLiteral(); + testExecutableSyntaxIsRejected(); + testPrototypePollutionKeysAreRejected(); + testLimitsAndMalformedInput(); + testBridgeHasNoDynamicCodeExecution(); + console.log(JSON.stringify({ + status: 'pass', + detail: 'listening bridge safe object-literal parser regression checks passed' + })); +} + +try { + run(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/developer/tests/js/listeningRecordBridgeProtocol.test.js b/developer/tests/js/listeningRecordBridgeProtocol.test.js new file mode 100644 index 00000000..a4f82216 --- /dev/null +++ b/developer/tests/js/listeningRecordBridgeProtocol.test.js @@ -0,0 +1,208 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../..'); +const bridgeSource = fs.readFileSync(path.join(repoRoot, 'js/listeningRecordBridge.js'), 'utf8'); + +function createHarness() { + const posted = []; + const messageListeners = []; + const timers = []; + let nextTimerId = 1; + + const parentWindow = { + postMessage(message, targetOrigin) { + posted.push({ message, targetOrigin }); + } + }; + const answerInput = { value: 'accommodation' }; + const document = { + readyState: 'complete', + title: 'Listening protocol test', + referrer: '', + body: null, + documentElement: {}, + activeElement: null, + addEventListener() {}, + querySelector(selector) { + return selector === '[name="q1"]' ? answerInput : null; + }, + querySelectorAll() { + return []; + }, + createElement() { + return { + innerHTML: '', + querySelector() { return null; }, + querySelectorAll() { return []; } + }; + } + }; + const window = { + document, + opener: parentWindow, + parent: null, + location: { + protocol: 'file:', + href: 'file:///fixtures/listening.html?examId=listening-protocol', + pathname: '/fixtures/listening.html' + }, + crypto: { + randomUUID() { + return 'fixed-submission'; + } + }, + App: { + state: { isReviewing: true }, + config: { + questionList: [1], + answerKey: { + text: { q1: 'accommodation' } + } + } + }, + addEventListener(type, listener) { + if (type === 'message') messageListeners.push(listener); + } + }; + window.parent = window; + + const sandbox = { + window, + document, + URL, + Uint8Array, + console: { + log() {}, + warn() {}, + error() {} + }, + setInterval(callback, delay) { + const timer = { id: nextTimerId++, callback, delay, interval: true, cancelled: false }; + timers.push(timer); + return timer.id; + }, + clearInterval(id) { + const timer = timers.find((item) => item.id === id); + if (timer) timer.cancelled = true; + }, + setTimeout(callback, delay) { + const timer = { id: nextTimerId++, callback, delay, interval: false, cancelled: false }; + timers.push(timer); + return timer.id; + }, + clearTimeout(id) { + const timer = timers.find((item) => item.id === id); + if (timer) timer.cancelled = true; + } + }; + sandbox.globalThis = sandbox; + vm.runInContext(bridgeSource, vm.createContext(sandbox), { + filename: 'js/listeningRecordBridge.js' + }); + + assert.strictEqual(messageListeners.length, 1, 'bridge must register one host message listener'); + return { + window, + parentWindow, + posted, + timers, + dispatch(type, data, overrides = {}) { + messageListeners[0]({ + source: overrides.source || parentWindow, + origin: overrides.origin === undefined ? 'null' : overrides.origin, + data: { + type, + source: overrides.messageSource || 'exam_host', + data + } + }); + } + }; +} + +function messagesOf(harness, type) { + return harness.posted.filter((entry) => entry.message && entry.message.type === type); +} + +function run() { + const harness = createHarness(); + const state = harness.window.__listeningBridgeGetState(); + + assert.strictEqual(harness.window.__listeningBridgeComplete(), true); + assert(state.pendingCompletion, 'pre-INIT completion must be retained'); + assert.strictEqual(state.pendingCompletion.submissionId, 'listening-submit-fixed-submission'); + assert.strictEqual(state.completed, false, 'completion cannot settle before persistence ACK'); + assert.strictEqual(messagesOf(harness, 'PRACTICE_COMPLETE').length, 0, 'pre-INIT must not emit completion'); + assert(messagesOf(harness, 'REQUEST_INIT').length >= 1, 'pre-INIT completion must request initialization'); + assert(harness.posted.every((entry) => entry.targetOrigin === '*'), 'file bridge sends must use wildcard targetOrigin'); + + harness.dispatch('INIT_SESSION', { + examId: 'listening-protocol', + sessionId: 'host-session', + windowSessionToken: 'host-token', + parentOrigin: 'null', + startTime: 1000 + }); + + const firstCompletion = messagesOf(harness, 'PRACTICE_COMPLETE').at(-1); + assert(firstCompletion, 'trusted INIT must flush pending completion'); + assert.strictEqual(firstCompletion.message.data.submissionId, 'listening-submit-fixed-submission'); + assert.strictEqual(firstCompletion.message.data.sessionId, 'host-session'); + assert.strictEqual(firstCompletion.message.data.windowSessionToken, 'host-token'); + assert.strictEqual(state.completed, false, 'emission alone must not mark completion'); + + const retryTimer = harness.timers.find((timer) => !timer.interval && timer.delay === 400 && !timer.cancelled); + assert(retryTimer, 'completion must schedule a persistence retry'); + retryTimer.callback(); + const completionMessages = messagesOf(harness, 'PRACTICE_COMPLETE'); + assert.strictEqual(completionMessages.length, 2, 'timeout must resend completion'); + assert.strictEqual( + completionMessages[0].message.data.submissionId, + completionMessages[1].message.data.submissionId, + 'retry must reuse the same submissionId' + ); + + harness.dispatch('PRACTICE_SUBMIT_ACK', { + submissionId: 'forged-submission', + sessionId: 'host-session', + windowSessionToken: 'host-token' + }); + assert.strictEqual(state.completed, false, 'mismatched submission ACK must be ignored'); + + harness.dispatch('PRACTICE_SUBMIT_ACK', { + submissionId: 'listening-submit-fixed-submission', + sessionId: 'host-session', + windowSessionToken: 'host-token' + }, { origin: 'https://forged.example' }); + assert.strictEqual(state.completed, false, 'wrong-origin ACK must be ignored'); + + harness.dispatch('PRACTICE_SUBMIT_ACK', { + submissionId: 'listening-submit-fixed-submission', + sessionId: 'host-session', + windowSessionToken: 'host-token' + }); + assert.strictEqual(state.completed, true, 'trusted persisted ACK must settle completion'); + assert.strictEqual(state.pendingCompletion, null, 'settled completion must release pending payload'); + assert( + harness.timers.filter((timer) => !timer.interval).every((timer) => timer.cancelled), + 'trusted ACK must cancel every completion retry' + ); + + console.log(JSON.stringify({ + status: 'pass', + detail: 'listening pre-INIT completion, same-submission retry and persisted ACK checks passed' + })); +} + +try { + run(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/developer/tests/js/markdownExporter.test.js b/developer/tests/js/markdownExporter.test.js index 4f556dc6..18312db0 100644 --- a/developer/tests/js/markdownExporter.test.js +++ b/developer/tests/js/markdownExporter.test.js @@ -12,8 +12,11 @@ const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '../../..'); const results = []; -function loadExporter() { +function loadExporter(options = {}) { const windowStub = {}; + if (typeof options.resolveExamForPracticeRecord === 'function') { + windowStub.resolveExamForPracticeRecord = options.resolveExamForPracticeRecord; + } const sandbox = { window: windowStub, document: { @@ -143,11 +146,44 @@ async function testCanonicalCorrectAnswerMapWins() { }); } +async function testHistoricalMetadataWinsDuringExport() { + const exporter = loadExporter({ + async resolveExamForPracticeRecord() { + return { + id: 'shared-id', + title: 'Current library title', + category: 'P4', + frequency: 'current' + }; + } + }); + const grouped = await exporter.groupRecordsByDateAsync([{ + id: 'history-1', + examId: 'shared-id', + date: '2026-07-25T10:00:00.000Z', + title: 'Saved title', + metadata: { + libraryConfigurationId: 'library_original', + category: 'P1', + frequency: 'saved' + } + }]); + const exported = grouped['2026-07-25'][0]; + assert.strictEqual(exported.title, 'Saved title', '导出不得用解析出的题目覆盖历史标题'); + assert.strictEqual(exported.category, 'P1', '导出不得用解析出的题目覆盖历史分类'); + assert.strictEqual(exported.frequency, 'saved', '导出不得用解析出的题目覆盖历史频次'); + recordResult('historical metadata wins over resolved library metadata during export', true, { + title: exported.title, + category: exported.category + }); +} + async function runAllTests() { const tests = [ testNumericCorrectAnswersAreNotAnswerMaps, testPlainCorrectAnswerMapsStillWork, - testCanonicalCorrectAnswerMapWins + testCanonicalCorrectAnswerMapWins, + testHistoricalMetadataWinsDuringExport ]; for (const testFn of tests) { try { diff --git a/developer/tests/js/onDemandEntrypoints.test.js b/developer/tests/js/onDemandEntrypoints.test.js index b5546758..92e5c2d9 100644 --- a/developer/tests/js/onDemandEntrypoints.test.js +++ b/developer/tests/js/onDemandEntrypoints.test.js @@ -37,6 +37,9 @@ function createDocumentStub(inputState, buttonState) { } return null; }, + querySelectorAll() { + return []; + }, getElementById(id) { if (id === 'exam-search-input') { return inputState; @@ -68,11 +71,18 @@ function createHarness() { showMessage(message, type) { messages.push({ message, type }); }, - getExamIndexState() { + async resolveActiveLibraryIndex() { return [ { id: 'reading-1', title: 'Ocean Passage', type: 'reading', hasHtml: true, path: 'Reading/set-1' } ]; }, + AppData: { + ready: Promise.resolve(), + preferences: { + async getCandidateCode() { return null; }, + async setCandidateCode(value) { return value; } + } + }, AppLazyLoader: { ensureGroup(name) { ensureCalls.push(name); @@ -118,9 +128,7 @@ async function testRandomPracticeEnsuresBrowseRuntime(harness) { loadScript('js/app/main-entry.js', harness.context); loadScript('js/presentation/app-actions.js', harness.context); - harness.windowStub.AppActions.startRandomPractice('all', 'reading'); - await Promise.resolve(); - await Promise.resolve(); + await harness.windowStub.AppActions.startRandomPractice('all', 'reading'); assert(harness.ensureCalls.includes('browse-runtime'), '随机练习应主动确保 browse-runtime 已加载'); assert.strictEqual(harness.windowStub.__openedExamId, 'reading-1', '随机练习应在严格按需模式下仍能打开题目'); diff --git a/developer/tests/js/performanceBaseline.js b/developer/tests/js/performanceBaseline.js deleted file mode 100644 index 45effbb2..00000000 --- a/developer/tests/js/performanceBaseline.js +++ /dev/null @@ -1,597 +0,0 @@ -/** - * 性能基线测量工具 - * 测量FPS、内存使用、交互延迟等性能指标 - */ - -class PerformanceBaseline { - constructor() { - this.metrics = { - fps: [], - memory: [], - interactionDelay: [], - renderTime: [], - loadTime: null - }; - this.isMonitoring = false; - this.frameCount = 0; - this.lastFrameTime = performance.now(); - this.monitoringInterval = null; - this.baselineData = null; - } - - // 开始性能监控 - startMonitoring(duration = 10000) { - console.log('📊 开始性能基线测量...'); - this.isMonitoring = true; - this.frameCount = 0; - this.lastFrameTime = performance.now(); - - // 清空之前的测量数据 - this.metrics = { - fps: [], - memory: [], - interactionDelay: [], - renderTime: [], - loadTime: performance.now() - }; - - // 开始FPS监控 - this.startFPSMonitoring(); - - // 开始内存监控 - this.startMemoryMonitoring(); - - // 设置监控持续时间 - setTimeout(() => { - this.stopMonitoring(); - }, duration); - - console.log(`⏱️ 性能监控已启动,将持续 ${duration}ms`); - } - - // 停止性能监控 - stopMonitoring() { - if (!this.isMonitoring) return; - - this.isMonitoring = false; - - if (this.monitoringInterval) { - cancelAnimationFrame(this.monitoringInterval); - this.monitoringInterval = null; - } - - // 生成基线报告 - this.generateBaselineReport(); - - console.log('📊 性能基线测量完成'); - } - - // 开始FPS监控 - startFPSMonitoring() { - const measureFPS = () => { - if (!this.isMonitoring) return; - - const currentTime = performance.now(); - const deltaTime = currentTime - this.lastFrameTime; - const currentFPS = 1000 / deltaTime; - - this.metrics.fps.push(currentFPS); - this.frameCount++; - this.lastFrameTime = currentTime; - - this.monitoringInterval = requestAnimationFrame(measureFPS); - }; - - this.monitoringInterval = requestAnimationFrame(measureFPS); - } - - // 开始内存监控 - startMemoryMonitoring() { - const memoryInterval = setInterval(() => { - if (!this.isMonitoring) { - clearInterval(memoryInterval); - return; - } - - // 使用performance.memory API(Chrome支持) - if (performance.memory) { - const memoryInfo = { - used: performance.memory.usedJSHeapSize, - total: performance.memory.totalJSHeapSize, - limit: performance.memory.jsHeapSizeLimit, - timestamp: performance.now() - }; - this.metrics.memory.push(memoryInfo); - } else { - // 降级方案:使用估算 - const estimatedMemory = this.estimateMemoryUsage(); - this.metrics.memory.push({ - used: estimatedMemory, - total: estimatedMemory * 1.5, - limit: estimatedMemory * 4, - timestamp: performance.now(), - estimated: true - }); - } - }, 1000); // 每秒测量一次 - } - - // 估算内存使用(降级方案) - estimateMemoryUsage() { - // 简单的内存估算:基于localStorage使用量 + DOM元素数量 - let storageSize = 0; - for (let key in localStorage) { - if (localStorage.hasOwnProperty(key)) { - storageSize += localStorage[key].length + key.length; - } - } - - const domElements = document.getElementsByTagName('*').length; - const estimatedDOMMemory = domElements * 200; // 每个DOM元素估算200字节 - - return storageSize + estimatedDOMMemory + 1024 * 1024; // 基础1MB - } - - // 测量交互延迟 - async measureInteractionDelay(element, action) { - const startTime = performance.now(); - - // 执行交互 - if (typeof action === 'function') { - await action(); - } else if (element && typeof element[action] === 'function') { - element[action](); - } - - // 等待下一个渲染帧 - await new Promise(resolve => { - requestAnimationFrame(resolve); - }); - - const endTime = performance.now(); - const delay = endTime - startTime; - - this.metrics.interactionDelay.push({ - action: action, - delay, - timestamp: performance.now() - }); - - return delay; - } - - // 测量渲染时间 - measureRenderTime(element, content) { - const startTime = performance.now(); - - // 修改内容 - if (typeof content === 'string') { - element.innerHTML = content; - } else if (typeof content === 'function') { - content(element); - } - - // 等待渲染完成 - return new Promise(resolve => { - requestAnimationFrame(() => { - const endTime = performance.now(); - const renderTime = endTime - startTime; - - this.metrics.renderTime.push({ - elementType: element.tagName, - renderTime, - timestamp: performance.now() - }); - - resolve(renderTime); - }); - }); - } - - // 测量大量数据渲染性能 - async measureLargeDataRendering(dataArray, containerElement) { - console.log(`📏 测量渲染 ${dataArray.length} 条数据的性能...`); - - const startTime = performance.now(); - - // 清空容器 - containerElement.innerHTML = ''; - - // 分批渲染以避免阻塞 - const batchSize = 100; - for (let i = 0; i < dataArray.length; i += batchSize) { - const batch = dataArray.slice(i, i + batchSize); - const fragment = document.createDocumentFragment(); - - batch.forEach(item => { - const div = document.createElement('div'); - div.className = 'test-item'; - div.textContent = item.title || JSON.stringify(item); - fragment.appendChild(div); - }); - - containerElement.appendChild(fragment); - - // 让出控制权 - if (i % (batchSize * 10) === 0) { - await new Promise(resolve => setTimeout(resolve, 0)); - } - } - - const endTime = performance.now(); - const totalTime = endTime - startTime; - const itemsPerSecond = dataArray.length / (totalTime / 1000); - - return { - totalTime, - itemsPerSecond, - itemCount: dataArray.length - }; - } - - // 生成基线报告 - generateBaselineReport() { - if (this.metrics.fps.length === 0) { - console.warn('⚠️ 没有足够的性能数据生成报告'); - return; - } - - // 计算FPS统计 - const fpsStats = this.calculateStats(this.metrics.fps); - - // 计算内存统计 - const memoryStats = this.calculateMemoryStats(); - - // 计算交互延迟统计 - const interactionStats = this.calculateInteractionStats(); - - // 计算渲染时间统计 - const renderStats = this.calculateRenderStats(); - - this.baselineData = { - timestamp: new Date().toISOString(), - measurementDuration: performance.now() - this.metrics.loadTime, - fps: fpsStats, - memory: memoryStats, - interaction: interactionStats, - render: renderStats, - systemInfo: this.getSystemInfo() - }; - - console.log('📊 性能基线报告:', this.baselineData); - this.displayBaselineReport(); - } - - // 计算统计数据 - calculateStats(values) { - if (values.length === 0) return null; - - const sorted = [...values].sort((a, b) => a - b); - const sum = values.reduce((acc, val) => acc + val, 0); - - return { - min: sorted[0], - max: sorted[sorted.length - 1], - average: sum / values.length, - median: sorted[Math.floor(sorted.length / 2)], - p95: sorted[Math.floor(sorted.length * 0.95)], - p99: sorted[Math.floor(sorted.length * 0.99)], - sampleCount: values.length - }; - } - - // 计算内存统计 - calculateMemoryStats() { - if (this.metrics.memory.length === 0) return null; - - const usedMemory = this.metrics.memory.map(m => m.used); - const totalMemory = this.metrics.memory.map(m => m.total); - const limitMemory = this.metrics.memory.map(m => m.limit); - - return { - used: this.calculateStats(usedMemory), - total: this.calculateStats(totalMemory), - limit: this.calculateStats(limitMemory), - utilizationRate: { - average: (usedMemory.reduce((a, b) => a + b, 0) / totalMemory.reduce((a, b) => a + b, 0)) * 100, - peak: (Math.max(...usedMemory) / Math.max(...limitMemory)) * 100 - }, - estimated: this.metrics.memory.some(m => m.estimated) - }; - } - - // 计算交互延迟统计 - calculateInteractionStats() { - if (this.metrics.interactionDelay.length === 0) return null; - - const delays = this.metrics.interactionDelay.map(i => i.delay); - const stats = this.calculateStats(delays); - - return { - ...stats, - samples: this.metrics.interactionDelay.map(i => ({ - action: i.action, - delay: i.delay, - timestamp: i.timestamp - })) - }; - } - - // 计算渲染时间统计 - calculateRenderStats() { - if (this.metrics.renderTime.length === 0) return null; - - const renderTimes = this.metrics.renderTime.map(r => r.renderTime); - const stats = this.calculateStats(renderTimes); - - return { - ...stats, - samples: this.metrics.renderTime.map(r => ({ - elementType: r.elementType, - renderTime: r.renderTime, - timestamp: r.timestamp - })) - }; - } - - // 获取系统信息 - getSystemInfo() { - return { - userAgent: navigator.userAgent, - platform: navigator.platform, - language: navigator.language, - cookieEnabled: navigator.cookieEnabled, - onLine: navigator.onLine, - screen: { - width: screen.width, - height: screen.height, - colorDepth: screen.colorDepth, - pixelDepth: screen.pixelDepth - }, - viewport: { - width: window.innerWidth, - height: window.innerHeight - }, - devicePixelRatio: window.devicePixelRatio || 1, - hardwareConcurrency: navigator.hardwareConcurrency || 'unknown' - }; - } - - // 显示基线报告 - displayBaselineReport() { - const report = this.baselineData; - if (!report) return; - - console.log('\n🎯 性能基线测量结果'); - console.log('='.repeat(50)); - - // FPS报告 - if (report.fps) { - console.log('\n📺 FPS (帧率):'); - console.log(` 平均: ${report.fps.average.toFixed(2)} FPS`); - console.log(` 最低: ${report.fps.min.toFixed(2)} FPS`); - console.log(` 最高: ${report.fps.max.toFixed(2)} FPS`); - console.log(` P95: ${report.fps.p95.toFixed(2)} FPS`); - console.log(` P99: ${report.fps.p99.toFixed(2)} FPS`); - } - - // 内存报告 - if (report.memory) { - console.log('\n💾 内存使用:'); - console.log(` 平均使用: ${(report.memory.used.average / 1024 / 1024).toFixed(2)} MB`); - console.log(` 峰值使用: ${(report.memory.used.max / 1024 / 1024).toFixed(2)} MB`); - console.log(` 利用率: ${report.memory.utilizationRate.average.toFixed(2)}% (峰值: ${report.memory.utilizationRate.peak.toFixed(2)}%)`); - if (report.memory.estimated) { - console.log(' ⚠️ 基于估算,不是精确测量'); - } - } - - // 交互延迟报告 - if (report.interaction) { - console.log('\n⚡ 交互延迟:'); - console.log(` 平均: ${report.interaction.average.toFixed(2)} ms`); - console.log(` 最快: ${report.interaction.min.toFixed(2)} ms`); - console.log(` 最慢: ${report.interaction.max.toFixed(2)} ms`); - console.log(` P95: ${report.interaction.p95.toFixed(2)} ms`); - } - - // 渲染时间报告 - if (report.render) { - console.log('\n🎨 渲染时间:'); - console.log(` 平均: ${report.render.average.toFixed(2)} ms`); - console.log(` 最快: ${report.render.min.toFixed(2)} ms`); - console.log(` 最慢: ${report.render.max.toFixed(2)} ms`); - console.log(` P95: ${report.render.p95.toFixed(2)} ms`); - } - - console.log('\n⏱️ 测量时长: ' + (report.measurementDuration / 1000).toFixed(2) + ' 秒'); - console.log('🕐 测量时间: ' + report.timestamp); - console.log('='.repeat(50)); - } - - // 运行完整的性能基准测试 - async runFullBenchmark() { - console.log('🚀 开始完整性能基准测试...'); - - // 1. 基础性能监控 - this.startMonitoring(5000); // 5秒基础监控 - - // 2. 等待基础监控完成 - await new Promise(resolve => setTimeout(resolve, 6000)); - - // 3. DOM操作性能测试 - await this.testDOMPerformance(); - - // 4. 数据处理性能测试 - await this testDataProcessingPerformance(); - - // 5. UI交互性能测试 - await this.testUIInteractionPerformance(); - - console.log('✅ 完整性能基准测试完成'); - return this.baselineData; - } - - // DOM操作性能测试 - async testDOMPerformance() { - console.log('🏗️ 测试DOM操作性能...'); - - const container = document.createElement('div'); - container.id = 'performance-test-container'; - container.style.cssText = 'position: absolute; left: -9999px; top: -9999px; visibility: hidden;'; - document.body.appendChild(container); - - // 测试大量元素创建 - const elementCount = 1000; - const elements = Array.from({ length: elementCount }, (_, i) => ({ - title: `测试元素 ${i}`, - content: `内容 ${i}` - })); - - const domResult = await this.measureLargeDataRendering(elements, container); - - console.log(` DOM渲染: ${elementCount} 个元素用时 ${domResult.totalTime.toFixed(2)}ms`); - console.log(` 渲染速度: ${domResult.itemsPerSecond.toFixed(2)} 元素/秒`); - - // 清理测试元素 - document.body.removeChild(container); - } - - // 数据处理性能测试 - async testDataProcessingPerformance() { - console.log('📊 测试数据处理性能...'); - - // 创建大量测试数据 - const largeDataSet = Array.from({ length: 10000 }, (_, i) => ({ - id: `item_${i}`, - title: `测试数据项 ${i}`, - category: ['reading', 'listening', 'writing', 'speaking'][i % 4], - score: Math.floor(Math.random() * 100), - date: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - metadata: { - tags: Array.from({ length: 5 }, (_, j) => `tag_${i}_${j}`), - priority: Math.floor(Math.random() * 10), - status: ['active', 'completed', 'pending'][i % 3] - } - })); - - // 测试排序性能 - const sortStart = performance.now(); - const sortedData = [...largeDataSet].sort((a, b) => b.score - a.score); - const sortTime = performance.now() - sortStart; - - // 测试过滤性能 - const filterStart = performance.now(); - const filteredData = largeDataSet.filter(item => item.category === 'reading' && item.score > 70); - const filterTime = performance.now() - filterStart; - - // 测试搜索性能 - const searchStart = performance.now(); - const searchResults = largeDataSet.filter(item => - item.title.includes('测试数据项') && item.metadata.tags.some(tag => tag.includes('tag_')) - ); - const searchTime = performance.now() - searchStart; - - console.log(` 数据排序: ${largeDataSet.length} 项用时 ${sortTime.toFixed(2)}ms`); - console.log(` 数据过滤: 找到 ${filteredData.length} 项用时 ${filterTime.toFixed(2)}ms`); - console.log(` 数据搜索: 找到 ${searchResults.length} 项用时 ${searchTime.toFixed(2)}ms`); - } - - // UI交互性能测试 - async testUIInteractionPerformance() { - console.log('🖱️ 测试UI交互性能...'); - - // 创建测试按钮 - const testButton = document.createElement('button'); - testButton.id = 'performance-test-button'; - testButton.textContent = '性能测试按钮'; - testButton.style.cssText = 'position: absolute; left: -9999px; top: -9999px; visibility: hidden;'; - document.body.appendChild(testButton); - - // 测试点击延迟 - const clickDelays = []; - for (let i = 0; i < 10; i++) { - const delay = await this.measureInteractionDelay(testButton, 'click'); - clickDelays.push(delay); - } - - // 测试输入延迟 - const testInput = document.createElement('input'); - testInput.id = 'performance-test-input'; - testInput.style.cssText = 'position: absolute; left: -9999px; top: -9999px; visibility: hidden;'; - document.body.appendChild(testInput); - - const inputDelays = []; - for (let i = 0; i < 10; i++) { - const delay = await this.measureInteractionDelay(testInput, () => { - testInput.value = `测试输入 ${i}`; - }); - inputDelays.push(delay); - } - - const avgClickDelay = clickDelays.reduce((a, b) => a + b, 0) / clickDelays.length; - const avgInputDelay = inputDelays.reduce((a, b) => a + b, 0) / inputDelays.length; - - console.log(` 点击延迟: 平均 ${avgClickDelay.toFixed(2)}ms`); - console.log(` 输入延迟: 平均 ${avgInputDelay.toFixed(2)}ms`); - - // 清理测试元素 - document.body.removeChild(testButton); - document.body.removeChild(testInput); - } - - // 获取性能建议 - getPerformanceRecommendations() { - if (!this.baselineData) return null; - - const recommendations = []; - const report = this.baselineData; - - // FPS建议 - if (report.fps && report.fps.average < 30) { - recommendations.push({ - category: 'FPS', - severity: 'high', - message: `平均FPS只有 ${report.fps.average.toFixed(1)},建议优化动画和渲染`, - suggestions: ['减少DOM操作频率', '使用CSS动画代替JavaScript动画', '优化重绘和回流'] - }); - } else if (report.fps && report.fps.p95 < 45) { - recommendations.push({ - category: 'FPS', - severity: 'medium', - message: `P95 FPS只有 ${report.fps.p95.toFixed(1)},存在性能波动`, - suggestions: ['检查是否有阻塞主线程的操作', '优化复杂计算', '使用Web Workers处理重任务'] - }); - } - - // 内存建议 - if (report.memory && report.memory.utilizationRate.peak > 80) { - recommendations.push({ - category: '内存', - severity: 'high', - message: `内存利用率峰值达到 ${report.memory.utilizationRate.peak.toFixed(1)}%`, - suggestions: ['检查内存泄漏', '优化数据结构', '及时清理不需要的对象'] - }); - } - - // 交互延迟建议 - if (report.interaction && report.interaction.p95 > 100) { - recommendations.push({ - category: '交互延迟', - severity: 'medium', - message: `P95交互延迟达到 ${report.interaction.p95.toFixed(1)}ms`, - suggestions: ['减少事件处理器的复杂度', '使用防抖和节流', '优化DOM操作'] - }); - } - - return recommendations; - } -} - -// 创建全局实例 -window.performanceBaseline = new PerformanceBaseline(); - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = PerformanceBaseline; -} \ No newline at end of file diff --git a/developer/tests/js/practiceCompletionFlow.test.js b/developer/tests/js/practiceCompletionFlow.test.js index d584f01c..981604b3 100644 --- a/developer/tests/js/practiceCompletionFlow.test.js +++ b/developer/tests/js/practiceCompletionFlow.test.js @@ -32,6 +32,7 @@ function createAppHarness(options = {}) { const savedCompletions = []; const statusCalls = []; const cleanupCalls = []; + const completionClaims = new Map(); const examIndex = [{ id: 'reading-p1', title: 'Passage 1', @@ -41,17 +42,6 @@ function createAppHarness(options = {}) { }]; let releaseSync = null; - const storage = { - async get(key, fallback = null) { - if (key === 'exam_index' || key === 'active_exam_index_key') { - return key === 'exam_index' ? examIndex : 'exam_index'; - } - return fallback; - }, - async set() { - return true; - } - }; const quietConsole = { log() {}, warn() {}, @@ -61,7 +51,6 @@ function createAppHarness(options = {}) { }; const sandboxWindow = { console: quietConsole, - storage, location: { href: 'http://localhost/' }, document: { addEventListener() {}, removeEventListener() {} }, addEventListener() {}, @@ -76,22 +65,42 @@ function createAppHarness(options = {}) { } : async (syncOptions = {}) => { syncCalls.push(syncOptions); + if (syncCalls.length > 1) { + return true; + } await new Promise((resolve) => { releaseSync = resolve; }); return true; }, - PracticeRecordAPI: { - async saveCompletion(payload, context) { + resolveActiveLibraryIndex: async () => examIndex.map((entry) => ({ ...entry })), + AppData: { + ready: Promise.resolve(), + practice: { + async completeAttempt(command) { if (options.saveRejects) { throw new Error('save failed'); } - savedCompletions.push({ payload, context }); - return { + if (completionClaims.has(command.operationId)) { + return completionClaims.get(command.operationId); + } + const record = { + ...command.record, id: 'saved-1', - examId: context.examId, - sessionId: context.sessionId + examId: command.record.examId, + sessionId: command.record.sessionId }; + savedCompletions.push({ command: JSON.parse(JSON.stringify(command)), record }); + const receipt = { committed: true, revision: 1, operationId: command.operationId || 'test-operation', record }; + completionClaims.set(command.operationId, receipt); + return receipt; + }, + async get(recordId) { + return savedCompletions.find((entry) => entry.record.id === recordId)?.record || null; + }, + async list() { + return savedCompletions.map((entry) => ({ ...entry.record })); + } } }, AchievementManager: { @@ -104,7 +113,6 @@ function createAppHarness(options = {}) { const sandbox = { window: sandboxWindow, document: sandboxWindow.document, - storage, console: quietConsole, setTimeout, clearTimeout, @@ -118,10 +126,23 @@ function createAppHarness(options = {}) { const context = vm.createContext(sandbox); loadScript('js/app/examSessionMixin.js', context); + const examWindow = { + closed: false, + postMessage() {} + }; const app = { components: {}, examWindows: new Map([ - ['reading-p1', { expectedSessionId: 'session-p1', sessionId: 'session-p1' }] + ['reading-p1', { + window: examWindow, + expectedSessionId: 'session-p1', + sessionId: 'session-p1', + windowSessionToken: 'token-p1', + windowSessionTokenSessionId: 'session-p1', + sessionGeneration: 1, + suiteSessionId: null, + expectedOrigin: 'http://localhost' + }] ]), suiteExamMap: new Map(), currentSuiteSession: null, @@ -151,14 +172,16 @@ function createAppHarness(options = {}) { async function testCompletionWaitsForSyncAndDedupes() { const harness = createAppHarness(); const completion = harness.app.handlePracticeComplete('reading-p1', { + sessionId: 'session-p1', scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, duration: 120, + endTime: '2026-07-28T00:00:00.000Z', answers: { q1: 'A' } }); await waitForCondition( - () => harness.savedCompletions.length === 1, - '第一次完成应保存一次' + () => harness.savedCompletions.length === 1 && harness.syncCalls.length === 1, + '第一次完成应保存并进入同步阶段' ); assert.strictEqual(harness.savedCompletions.length, 1, '第一次完成应保存一次'); assert.strictEqual(harness.statusCalls.length, 0, 'syncPracticeRecords 未完成前不应先标记完成'); @@ -173,7 +196,8 @@ async function testCompletionWaitsForSyncAndDedupes() { await harness.app.handlePracticeComplete('reading-p1', { sessionId: 'session-p1', scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, - duration: 120 + duration: 120, + endTime: '2026-07-28T00:00:00.000Z' }); assert.strictEqual(harness.savedCompletions.length, 1, '相同 sessionId 的重复完成事件不应重复保存'); } diff --git a/developer/tests/js/practiceCore.guard.test.js b/developer/tests/js/practiceCore.guard.test.js index e7e2006e..5cb54d04 100644 --- a/developer/tests/js/practiceCore.guard.test.js +++ b/developer/tests/js/practiceCore.guard.test.js @@ -1,840 +1,62 @@ #!/usr/bin/env node +import assert from 'assert'; import fs from 'fs'; import path from 'path'; +import vm from 'vm'; import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const jsRoot = path.join(repoRoot, 'js'); - -const DIRECT_WRITE_ALLOWLIST = new Set([ - path.join('js', 'utils', 'storage.js') -]); - -const PROTOCOL_ALIAS_ALLOWLIST = new Set([ - path.join('js', 'core', 'practiceCore.js') -]); - -const PATH_CORE_ALLOWLIST = new Set([ - path.join('js', 'core', 'resourceCore.js') -]); - -const STATE_CORE_ALLOWLIST = new Set([ - path.join('js', 'app', 'state-service.js') -]); - -const directWritePatterns = [ - /(?:^|[^\w.])storage\.set\s*\(\s*['"]practice_records['"]/g, - /(?:^|[^\w.])window\.storage\.set\s*\(\s*['"]practice_records['"]/g, - /(?:^|[^\w.])store\.set\s*\(\s*['"]practice_records['"]/g, - /(?:^|[^\w.])storage\.remove\s*\(\s*['"]practice_records['"]/g, - /(?:^|[^\w.])window\.storage\.remove\s*\(\s*['"]practice_records['"]/g, - /(?:^|[^\w.])store\.remove\s*\(\s*['"]practice_records['"]/g, - /(?:^|[^\w.])this\.set\s*\(\s*['"]practice_records['"]/g, - /['"]practice\.records['"]\s*:\s*['"]practice_records['"]/g -]; - -const practiceRecordsShadowPatterns = [ - /(?:window|global|globalThis|globalRef)\.practiceRecords\s*=/g, - /practiceRecords\s*:\s*cloneArray\s*\(\s*(?:window|global|globalThis|globalRef)\.practiceRecords\s*\)/g -]; - -const protocolAliasPatterns = [ - /practice_complete'\s*:\s*'PRACTICE_COMPLETE'/g, - /practice_completed'\s*:\s*'PRACTICE_COMPLETE'/g, - /REQUEST_SESSION_INIT/g, - /PRACTICE_COMPLETE_TYPES/g -]; - -const pathDefinitionPatterns = [ - /(?:^|\n)\s*function\s+buildResourcePath\s*\(/g, - /(?:^|\n)\s*function\s+derivePathMapFromIndex\s*\(/g, - /(?:^|\n)\s*function\s+getResourceAttempts\s*\(/g, - /(?:^|\n)\s*function\s+resolveResource\s*\(/g, - /(?:^|\n)\s*(?:async\s+)?buildResourcePath\s*\(/g, - /(?:^|\n)\s*(?:async\s+)?derivePathMapFromIndex\s*\(/g, - /(?:^|\n)\s*(?:async\s+)?getResourceAttempts\s*\(/g, - /(?:^|\n)\s*(?:async\s+)?resolveResource\s*\(/g, - /(?:const|let|var)\s+buildResourcePath\s*=/g, - /(?:const|let|var)\s+derivePathMapFromIndex\s*=/g, - /(?:const|let|var)\s+getResourceAttempts\s*=/g, - /(?:const|let|var)\s+resolveResource\s*=/g -]; - -const stateDefinitionPatterns = [ - /function\s+getExamIndexState\s*\(/g, - /function\s+setExamIndexState\s*\(/g, - /function\s+getPracticeRecordsState\s*\(/g, - /function\s+setPracticeRecordsState\s*\(/g, - /function\s+getFilteredExamsState\s*\(/g, - /function\s+setFilteredExamsState\s*\(/g, - /function\s+setBrowseFilterState\s*\(/g, - /function\s+getSelectedRecordsState\s*\(/g, - /function\s+assignExamSequenceNumbers\s*\(/g, - /Object\.defineProperty\(\s*window\s*,\s*['"]fallbackExamSessions['"]/g, - /Object\.defineProperty\(\s*window\s*,\s*['"]processedSessions['"]/g -]; - -const legacyMarkers = [ - /LegacyStateBridge/g, - /LegacyStateAdapter/g, - /LegacyStateAdapterBridge/g, - /LegacyStorageFacade/g, - /legacy-storage/g, - /deprecated persistent write via storage facade/g -]; - -const bannedRecordFallbackChecks = [ - { - file: path.join('js', 'core', 'practiceRecordAPI.js'), - patterns: [ - /getScoreStorageInstance/g, - /scoreStorage/g, - /recalculateUserStats/g, - /getDefaultUserStats/g, - /getUserStats/g, - /global\.dataRepositories/g, - /readPersistentValue/g, - /writePersistentValue/g, - /createStorageInternalAccessOptions/g - ] - }, - { - file: path.join('js', 'core', 'scoreStorage.js'), - patterns: [ - /this\.storage\.get\s*\(\s*this\.storageKeys\.practiceRecords/g, - /this\.storage\.set\s*\(\s*this\.storageKeys\.practiceRecords/g, - /this\.storage\.get\s*\(\s*this\.storageKeys\.userStats/g, - /this\.storage\.set\s*\(\s*this\.storageKeys\.userStats/g, - /(?:const|let|var)\s+practiceRepo\s*=/g, - /practiceRepo\.(?:list|overwrite|clear)\s*\(/g, - /metaRepo\.(?:get|set|remove)\s*\(\s*['"]user_stats['"]/g, - /compatSchema(?:Read|Write)/g, - /window\.PracticeStore\.(?:list|replace|save)/g, - /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g, - /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /继续使用底层兼容/g, - /继续使用底层/g - ] - }, - { - file: path.join('js', 'core', 'practiceStore.js'), - patterns: [ - /PRACTICE_RECORDS_KEY/g, - /getCoreStore/g, - /getLegacyWrapper/g, - /getStorage/g, - /PracticeCore\.store/g, - /simpleStorageWrapper/g, - /storage\.(?:get|set)\s*\(/g - ] - }, - { - file: path.join('js', 'core', 'practiceRecorder.js'), - patterns: [ - /fallbackSavePracticeRecord/g, - /standardizeRecordForFallback/g, - /savedBy\s*:\s*['"]fallback['"]/g, - /fallbackReason/g, - /suiteFallback/g, - /scoreStorage\.savePracticeRecord/g, - /scoreStorage\.getPracticeRecords/g, - /window\.PracticeStore\.(?:list|replace|save)/g, - /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g, - /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /practiceRepo\.list\s*\(/g, - /继续使用底层兼容/g, - /继续使用底层/g - ] - }, - { - file: path.join('js', 'utils', 'simpleStorageWrapper.js'), - patterns: [ - /practiceRepo/g, - /repos\.practice/g, - /api\.(?:replace|saveRecord|deleteById|deleteMany|clear|writeStats|resetStats)\s*\(/g, - /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g - ] - }, - { - file: path.join('js', 'utils', 'storage.js'), - patterns: [ - /window\.PracticeStore\.(?:list|replace|save)/g, - /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g, - /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /继续使用底层兼容/g, - /继续使用底层/g - ] - }, - { - file: path.join('js', 'main.js'), - patterns: [ - /savePracticeRecordFallback/g, - /\[Fallback\]\s*收到练习完成/g, - /\[Fallback\]\s*真实数据/g, - /\[Fallback\]\s*保存练习记录失败/g, - /PracticeRecordAPI 保存失败,继续/g, - /window\.PracticeStore\.(?:list|replace|save|clear)/g, - /window\.simpleStorageWrapper\.(?:savePracticeRecords|addPracticeRecord|getPracticeRecords)/g, - /PracticeCore\.store\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /practiceCoreStore\.(?:listPracticeRecords|replacePracticeRecords|savePracticeRecord)/g, - /storage\.get\s*\(\s*\[['"]practice['"]\s*,\s*['"]records['"]\]\.join/g, - /storage\.set\s*\(\s*\[['"]practice['"]\s*,\s*['"]records['"]\]\.join/g - ] - }, - { - file: path.join('js', 'app', 'suitePracticeMixin.js'), - patterns: [ - /_listPracticeRecordsWithFallback/g, - /_replacePracticeRecordsWithFallback/g, - /_saveSinglePracticeRecordWithFallback/g, - /_saveSuitePracticeRecordFallback/g, - /includeRecorder/g - ] - }, - { - file: path.join('js', 'app', 'examSessionMixin.js'), - patterns: [ - /suiteFallback/g - ] - }, - { - file: path.join('js', 'components', 'onboardingTour.js'), - patterns: [ - /dataRepositories\.practice/g, - /repositories\.practice/g, - /repos\.practice/g - ] - } -]; - -const bannedStatsBypassChecks = [ - { - file: path.join('js', 'app.js'), - patterns: [ - /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g, - /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g - ] - }, - { - file: path.join('js', 'main.js'), - patterns: [ - /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g, - /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g - ] - }, - { - file: path.join('js', 'app', 'suitePracticeMixin.js'), - patterns: [ - /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g, - /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g - ] - }, - { - file: path.join('js', 'components', 'DataIntegrityManager.js'), - patterns: [ - /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g, - /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g - ] - }, - { - file: path.join('js', 'utils', 'dataBackupManager.js'), - patterns: [ - /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g, - /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats|createBackup|restoreBackup)/g, - /practiceRecorder\.(?:getUserStats|createBackup|restoreBackup)/g - ] - }, - { - file: path.join('js', 'core', 'practiceRecorder.js'), - patterns: [ - /(?:^|[^\w.])metaRepo\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])this\.metaRepo\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats|exportData|importData|createBackup|restoreBackup)/g, - /this\.scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats|exportData|importData|createBackup|restoreBackup)/g - ] - }, - { - file: path.join('js', 'components', 'practiceHistoryEnhancer.js'), - patterns: [ - /window\.practiceStats/g, - /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g - ] - }, - { - file: path.join('js', 'services', 'achievementManager.js'), - patterns: [ - /(?:^|[^\w.])storage\.get\s*\(\s*['"]user_stats['"]/g, - /(?:^|[^\w.])storage\.set\s*\(\s*['"]user_stats['"]/g, - /window\.storage\.(?:get|set)\s*\(\s*['"]user_stats['"]/g, - /PracticeCore\.store\.writeMeta\s*\(\s*['"]user_stats['"]/g, - /scoreStorage\.(?:recalculateUserStats|getDefaultUserStats|getUserStats)/g - ] - } -]; - -const bannedImportNormalizerForkChecks = [ - { - file: path.join('js', 'components', 'DataIntegrityManager.js'), - patterns: [ - /_normalizePracticeRecord/g, - /_standardizePracticeRecord/g, - /_mergeAnswerMaps/g, - /_detailsToCorrectMap/g, - /_comparisonToCorrectMap/g, - /_resolveCorrectAnswerMap/g, - /_pickNumber/g, - /_pickInteger/g, - /_pickDuration/g, - /_normalizeDate/g, - /_stringify/g - ] - }, - { - file: path.join('js', 'utils', 'dataBackupManager.js'), - patterns: [ - /validateNormalizedRecords/g, - /looksLikePracticeRecord/g, - /isPracticeRecordPath/g, - /extractRecordsFromCommonShapes/g, - /parseNumber/g, - /parseInteger/g, - /firstParsedInteger/g, - /payload\.records/g, - /record\.percentage/g, - /record\.accuracy/g - ] - } -]; - -const productionContractBypassPatterns = [ - /compatSchema(?:Read|Write)/g, - /(?:^|\n)\s*(?:async\s+)?createInternalAccessOptions\s*\(/g, - /(?:^|\n)\s*(?:async\s+)?hasInternalAccess\s*\(/g, - /persistentStore\.createInternalAccessOptions/g, - /persistentStore\.hasInternalAccess/g, - /__recordStore/g -]; - -const replayCorrectAnswerFallbackChecks = [ - { - file: path.join('js', 'runtime', 'unifiedReadingPage.js'), - functionNames: ['buildReplayResults'], - patterns: [ - /(?:entry|realData|rawData|rawRealData)\.correctAnswers/g, - /(?:entry|realData|rawData|rawRealData)\.answerComparison/g, - /scoreInfo\.details/g, - /detailSources/g - ] - }, - { - file: path.join('js', 'practice-page-enhancer.js'), - functionNames: ['buildReplayResultsFromEntry'], - patterns: [ - /(?:entry|realData|rawData|rawRealData)\.correctAnswers/g, - /(?:entry|realData|rawData|rawRealData)\.answerComparison/g, - /scoreInfo\.details/g, - /detailSources/g - ] - }, - { - file: path.join('js', 'app', 'examSessionMixin.js'), - functionNames: ['_resolveReplayCorrectAnswerMap'], - patterns: [ - /(?:entry|realData|rawData|rawRealData|source)\.correctAnswers/g, - /(?:entry|realData|rawData|rawRealData|source)\.answerComparison/g, - /scoreInfo\.details/g, - /detailSources/g - ] - }, - { - file: path.join('js', 'components', 'practiceRecordModal.js'), - functionNames: ['getLegacyCorrectAnswers', 'mergeComparisonWithCorrections'], - patterns: [ - /(?:record|realData)\.correctAnswers/g, - /scoreInfo\.details/g, - /detailSources/g - ] - }, - { - file: path.join('js', 'utils', 'storage.js'), - functionNames: ['compressRealData'], - patterns: [ - /correctAnswers\s*:\s*realData\.correctAnswers/g, - /correctAnswer\s*:\s*comparison\.correctAnswer/g - ] - }, - { - file: path.join('templates', 'exam-placeholder.html'), - functionNames: ['buildReplaySnapshot'], - patterns: [ - /(?:source|entry|realData|rawData)\.correctAnswers/g, - /deriveCorrectMapFromDetails/g - ] - } -]; - -const replayCorrectAnswerGlobalPatterns = [ - /extractReplayCorrectAnswersFrom(?:Comparison|Details)/g, - /_hydrateReplayCorrectAnswersFromDetails/g, - /fallbackCorrectSources/g -]; - -function walk(dir, bucket = []) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - entries.forEach((entry) => { - const fullPath = path.join(dir, entry.name); - const relativePath = path.relative(repoRoot, fullPath); - if (entry.isDirectory()) { - if (relativePath === path.join('js', 'bundles')) { - return; - } - walk(fullPath, bucket); - return; - } - if (entry.isFile() && entry.name.endsWith('.js')) { - bucket.push(fullPath); - } - }); - return bucket; -} - -function formatLine(source, index) { - return source.slice(0, index).split('\n').length; -} - -function collectMatches(files, patterns, allowlist) { - const findings = []; - files.forEach((fullPath) => { - const relativePath = path.relative(repoRoot, fullPath); - if (allowlist.has(relativePath)) { - return; - } - const source = fs.readFileSync(fullPath, 'utf8'); - patterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(source); - while (match) { - findings.push({ - file: relativePath, - line: formatLine(source, match.index), - snippet: match[0].trim() - }); - match = pattern.exec(source); - } - }); - }); - return findings; -} - -function collectTargetedFallbackMatches() { - const findings = []; - bannedRecordFallbackChecks.forEach((check) => { - const fullPath = path.join(repoRoot, check.file); - if (!fs.existsSync(fullPath)) { - return; - } - const source = fs.readFileSync(fullPath, 'utf8'); - check.patterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(source); - while (match) { - findings.push({ - file: check.file, - line: formatLine(source, match.index), - snippet: match[0].trim() - }); - match = pattern.exec(source); - } - }); - }); - return findings; -} - -function collectTargetedStatsBypassMatches() { - const findings = []; - bannedStatsBypassChecks.forEach((check) => { - const fullPath = path.join(repoRoot, check.file); - if (!fs.existsSync(fullPath)) { - return; - } - const source = fs.readFileSync(fullPath, 'utf8'); - check.patterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(source); - while (match) { - findings.push({ - file: check.file, - line: formatLine(source, match.index), - snippet: match[0].trim() - }); - match = pattern.exec(source); - } - }); - }); - return findings; -} - -function collectImportNormalizerForkMatches() { - const findings = []; - bannedImportNormalizerForkChecks.forEach((check) => { - const fullPath = path.join(repoRoot, check.file); - if (!fs.existsSync(fullPath)) { - return; - } - const source = fs.readFileSync(fullPath, 'utf8'); - check.patterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(source); - while (match) { - findings.push({ - file: check.file, - line: formatLine(source, match.index), - snippet: match[0].trim() - }); - match = pattern.exec(source); - } - }); - }); - return findings; -} - -function collectProductionContractBypassMatches(files) { - return collectMatches(files, productionContractBypassPatterns, new Set()); -} - -function collectPracticeDataPublicSurfaceMatches() { - const findings = []; - const corePath = path.join('js', 'core', 'practiceCore.js'); - const coreFullPath = path.join(repoRoot, corePath); - if (fs.existsSync(coreFullPath)) { - const source = fs.readFileSync(coreFullPath, 'utf8'); - const publicStoreMatch = /const\s+publicStore\s*=\s*Object\.freeze\(\{([\s\S]*?)\}\);/m.exec(source); - if (!publicStoreMatch) { - findings.push({ - file: corePath, - line: 0, - snippet: 'publicStore missing' - }); - } else { - const banned = [ - 'replacePracticeRecords', - 'savePracticeRecord', - 'routeStorageSet', - 'routeStorageRemove', - 'writeMeta', - 'removeMeta' - ]; - banned.forEach((name) => { - const index = publicStoreMatch[1].indexOf(name); - if (index >= 0) { - findings.push({ - file: corePath, - line: formatLine(source, publicStoreMatch.index + index), - snippet: `publicStore exposes ${name}` - }); - } - }); - } - } - - const dataIndexPath = path.join('js', 'data', 'index.js'); - const dataIndexFullPath = path.join(repoRoot, dataIndexPath); - if (fs.existsSync(dataIndexFullPath)) { - const source = fs.readFileSync(dataIndexFullPath, 'utf8'); - const apiStart = source.indexOf('const api = {', source.indexOf('const metaFacade')); - const apiEnd = apiStart >= 0 ? source.indexOf('};', apiStart) : -1; - if (apiStart < 0 || apiEnd < 0) { - findings.push({ - file: dataIndexPath, - line: 0, - snippet: 'public dataRepositories api missing' - }); - } else { - const publicApiBody = source.slice(apiStart, apiEnd); - const patterns = [ - /get\s+practice\s*\(/g, - /practiceRepo/g, - /user_stats[\s\S]{0,80}(?:metaRepo\.set|metaRepo\.get)/g - ]; - patterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(publicApiBody); - while (match) { - findings.push({ - file: dataIndexPath, - line: formatLine(source, apiStart + match.index), - snippet: match[0].trim() - }); - match = pattern.exec(publicApiBody); - } - }); - } - } - return findings; -} - -function collectReplayCorrectAnswerFallbackMatches(files) { - const findings = []; - replayCorrectAnswerFallbackChecks.forEach((check) => { - const fullPath = path.join(repoRoot, check.file); - if (!fs.existsSync(fullPath)) { - return; - } - const source = fs.readFileSync(fullPath, 'utf8'); - check.functionNames.forEach((functionName) => { - const extracted = extractFunctionBody(source, functionName); - if (!extracted) { - findings.push({ - file: check.file, - line: 0, - snippet: `${functionName} missing` - }); - return; - } - check.patterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(extracted.body); - while (match) { - findings.push({ - file: check.file, - line: formatLine(source, extracted.startIndex + match.index), - snippet: `${functionName}: ${match[0].trim()}` - }); - match = pattern.exec(extracted.body); - } - }); - }); - }); - - const targetFiles = new Set(replayCorrectAnswerFallbackChecks.map((check) => check.file)); - files.forEach((fullPath) => { - const relativePath = path.relative(repoRoot, fullPath); - if (!targetFiles.has(relativePath)) { - return; - } - const source = fs.readFileSync(fullPath, 'utf8'); - replayCorrectAnswerGlobalPatterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(source); - while (match) { - findings.push({ - file: relativePath, - line: formatLine(source, match.index), - snippet: match[0].trim() - }); - match = pattern.exec(source); - } - }); - }); - return findings; -} - -function collectUnsafeCompatSchemaWrites() { - const findings = []; - [ - path.join('js', 'core', 'scoreStorage.js'), - path.join('js', 'utils', 'storage.js') - ].forEach((relativePath) => { - const fullPath = path.join(repoRoot, relativePath); - if (!fs.existsSync(fullPath)) { - return; - } - const source = fs.readFileSync(fullPath, 'utf8'); - const rawWritePattern = /(?:this\.storage\.set\s*\(\s*this\.storageKeys\.practiceRecords|this\.set\s*\(\s*['"]practice_records['"])/g; - rawWritePattern.lastIndex = 0; - let match = rawWritePattern.exec(source); - while (match) { - const windowStart = Math.max(0, match.index - 450); - const windowEnd = Math.min(source.length, match.index + 220); - const localContext = source.slice(windowStart, windowEnd); - if (!/compatSchemaWrite/.test(localContext)) { - findings.push({ - file: relativePath, - line: formatLine(source, match.index), - snippet: match[0].trim() - }); - } - match = rawWritePattern.exec(source); - } - }); - return findings; -} - -function extractFunctionBody(source, functionName) { - const signature = new RegExp(`(?:async\\s+)?${functionName}\\s*\\(`, 'g'); - const match = signature.exec(source); - if (!match) { - return null; - } - const openBrace = source.indexOf('{', match.index); - if (openBrace < 0) { - return null; - } - - let depth = 0; - for (let index = openBrace; index < source.length; index += 1) { - const char = source[index]; - if (char === '{') { - depth += 1; - } else if (char === '}') { - depth -= 1; - if (depth === 0) { - return { - body: source.slice(openBrace, index + 1), - startIndex: openBrace - }; - } - } - } - return null; -} - -function collectRuntimeCompatSchemaWrites() { - const findings = []; - const relativePath = path.join('js', 'utils', 'storage.js'); - const fullPath = path.join(repoRoot, relativePath); - if (!fs.existsSync(fullPath)) { - return findings; - } - const source = fs.readFileSync(fullPath, 'utf8'); - ['restoreFromBackup', 'importData'].forEach((functionName) => { - const extracted = extractFunctionBody(source, functionName); - if (!extracted) { - findings.push({ - file: relativePath, - line: 0, - snippet: `${functionName} missing` - }); - return; - } - const pattern = /compatSchemaWrite\s*:\s*true/g; - pattern.lastIndex = 0; - let match = pattern.exec(extracted.body); - while (match) { - findings.push({ - file: relativePath, - line: formatLine(source, extracted.startIndex + match.index), - snippet: `${functionName}: ${match[0]}` - }); - match = pattern.exec(extracted.body); - } - }); - return findings; -} - -function collectE2ERawPracticeRecordReads() { - const findings = []; - const relativePath = path.join('developer', 'tests', 'e2e', 'suite_practice_flow.py'); - const fullPath = path.join(repoRoot, relativePath); - if (!fs.existsSync(fullPath)) { - return findings; - } - const source = fs.readFileSync(fullPath, 'utf8'); - const patterns = [ - /window\.storage\.get\(['"]practice_records['"]/g, - /storage\.get\(['"]practice_records['"]/g, - /window\.storage\.set\(['"]practice_records['"]/g, - /storage\.set\(['"]practice_records['"]/g, - /window\.storage\.remove\(['"]practice_records['"]/g, - /storage\.remove\(['"]practice_records['"]/g - ]; - patterns.forEach((pattern) => { - pattern.lastIndex = 0; - let match = pattern.exec(source); - while (match) { - findings.push({ - file: relativePath, - line: formatLine(source, match.index), - snippet: match[0].trim() - }); - match = pattern.exec(source); - } - }); - return findings; -} - -function main() { - const files = walk(jsRoot); - const directWrites = collectMatches(files, directWritePatterns, DIRECT_WRITE_ALLOWLIST); - const practiceRecordShadows = collectMatches(files, practiceRecordsShadowPatterns, new Set()); - const recordFallbacks = collectTargetedFallbackMatches(); - const statsBypasses = collectTargetedStatsBypassMatches(); - const importNormalizerForks = collectImportNormalizerForkMatches(); - const productionContractBypasses = collectProductionContractBypassMatches(files); - const practiceDataPublicSurfaceBypasses = collectPracticeDataPublicSurfaceMatches(); - const replayCorrectAnswerFallbacks = collectReplayCorrectAnswerFallbackMatches(files); - const unsafeCompatSchemaWrites = collectUnsafeCompatSchemaWrites(); - const runtimeCompatSchemaWrites = collectRuntimeCompatSchemaWrites(); - const e2eRawPracticeRecordReads = collectE2ERawPracticeRecordReads(); - const aliasCopies = collectMatches(files, protocolAliasPatterns, PROTOCOL_ALIAS_ALLOWLIST); - const pathCopies = collectMatches(files, pathDefinitionPatterns, PATH_CORE_ALLOWLIST); - const stateCopies = collectMatches(files, stateDefinitionPatterns, STATE_CORE_ALLOWLIST); - const legacyCopies = collectMatches(files, legacyMarkers, new Set()); - const passed = directWrites.length === 0 - && practiceRecordShadows.length === 0 - && recordFallbacks.length === 0 - && statsBypasses.length === 0 - && importNormalizerForks.length === 0 - && productionContractBypasses.length === 0 - && practiceDataPublicSurfaceBypasses.length === 0 - && replayCorrectAnswerFallbacks.length === 0 - && unsafeCompatSchemaWrites.length === 0 - && runtimeCompatSchemaWrites.length === 0 - && e2eRawPracticeRecordReads.length === 0 - && aliasCopies.length === 0 - && pathCopies.length === 0 - && stateCopies.length === 0 - && legacyCopies.length === 0; - - console.log(JSON.stringify({ - status: passed ? 'pass' : 'fail', - detail: passed - ? '无多余 practice_records 直写、记录旧 fallback、导入层 record normalizer 分叉、公开 internal token、回放正确答案兜底、运行期 compat schema 写入、E2E raw records 读取、协议别名复制、路径实现复制、状态实现复制或 legacy bridge 残留' - : `发现 ${directWrites.length} 处直写、${practiceRecordShadows.length} 处 practiceRecords 影子事实源、${recordFallbacks.length} 处记录旧 fallback、${statsBypasses.length} 处统计旧入口绕行、${importNormalizerForks.length} 处导入层 record normalizer 分叉、${productionContractBypasses.length} 处生产契约绕行、${practiceDataPublicSurfaceBypasses.length} 处练习数据公开写面、${replayCorrectAnswerFallbacks.length} 处回放正确答案兜底、${unsafeCompatSchemaWrites.length} 处非显式 compat schema 写入、${runtimeCompatSchemaWrites.length} 处运行期 compat schema 写入、${e2eRawPracticeRecordReads.length} 处 E2E raw records 读取、${aliasCopies.length} 处协议别名复制、${pathCopies.length} 处路径实现复制、${stateCopies.length} 处状态实现复制、${legacyCopies.length} 处 legacy 残留`, - directWrites, - practiceRecordShadows, - recordFallbacks, - statsBypasses, - importNormalizerForks, - productionContractBypasses, - practiceDataPublicSurfaceBypasses, - replayCorrectAnswerFallbacks, - unsafeCompatSchemaWrites, - runtimeCompatSchemaWrites, - e2eRawPracticeRecordReads, - aliasCopies, - pathCopies, - stateCopies, - legacyCopies - }, null, 2)); - - if (!passed) { - process.exit(1); - } -} - -main(); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const sourcePath = path.join(repoRoot, 'js', 'core', 'practiceCore.js'); +const source = fs.readFileSync(sourcePath, 'utf8'); + +const forbidden = [ + 'STORAGE_KEYS', + 'persistentStore', + 'global.storage', + 'dataRepositories', + 'repository', + '__installRecordAPI', + '__installInternalRepositories', + 'readMeta(', + 'writeMeta(', + 'routeStorageSet', + 'routeStorageRemove' +]; + +for (const marker of forbidden) { + assert.strictEqual(source.includes(marker), false, `PracticeCore must not contain v1 marker: ${marker}`); +} + +const windowStub = { console }; +const context = vm.createContext({ window: windowStub, globalThis: windowStub, console, Date, Math, JSON }); +vm.runInContext(source, context, { filename: 'js/core/practiceCore.js' }); + +const core = windowStub.PracticeCore; +assert.ok(core && core.__stable === true, 'PracticeCore must initialize'); +assert.deepStrictEqual( + Object.keys(core).sort(), + ['__stable', 'contracts', 'ingestor', 'protocol', 'version'].sort(), + 'PracticeCore public surface must stay persistence-free' +); +assert.strictEqual(Object.isFrozen(core), true, 'PracticeCore public surface must be frozen'); +assert.strictEqual(Object.prototype.hasOwnProperty.call(core, 'store'), false, 'PracticeCore.store must not exist'); + +const suiteEntry = JSON.parse(JSON.stringify(core.contracts.standardizeSuiteEntries([{ + examId: 'suite-annotation-fallback', + markedQuestions: [], + metadata: { markedQuestions: ['q2'] } +}])[0])); +assert.deepStrictEqual(suiteEntry.markedQuestions, ['q2'], + 'suite normalization must skip an empty root annotation array when metadata has saved values'); +assert.deepStrictEqual(suiteEntry.metadata.markedQuestions, ['q2']); +const explicitEmpty = JSON.parse(JSON.stringify(core.contracts.resolveAnnotationState( + { markedQuestions: [] }, + [{ markedQuestions: ['stale'] }] +))); +assert.deepStrictEqual(explicitEmpty.markedQuestions, [], + 'normal record annotation edits must retain explicit-empty semantics'); + +console.log(JSON.stringify({ + status: 'pass', + detail: 'PracticeCore exposes only contracts, protocol and ingestor' +}, null, 2)); diff --git a/developer/tests/js/practiceCore.test.js b/developer/tests/js/practiceCore.test.js deleted file mode 100644 index 8f643bbd..00000000 --- a/developer/tests/js/practiceCore.test.js +++ /dev/null @@ -1,692 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function loadScript(relativePath, context) { - const fullPath = path.join(repoRoot, relativePath); - const source = fs.readFileSync(fullPath, 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function createRepositoryHarness() { - const practiceState = []; - const metaState = new Map(); - - return { - repositories: { - practice: { - async list() { - return practiceState.map((record) => JSON.parse(JSON.stringify(record))); - }, - async overwrite(records) { - practiceState.splice(0, practiceState.length, ...records.map((record) => JSON.parse(JSON.stringify(record)))); - return true; - }, - async upsert(record) { - const clone = JSON.parse(JSON.stringify(record)); - const index = practiceState.findIndex((entry) => entry && String(entry.id) === String(clone.id)); - if (index >= 0) { - practiceState[index] = clone; - } else { - practiceState.unshift(clone); - } - return clone; - } - }, - meta: { - async get(key, fallback = null) { - return metaState.has(key) - ? JSON.parse(JSON.stringify(metaState.get(key))) - : JSON.parse(JSON.stringify(fallback)); - }, - async set(key, value) { - metaState.set(key, JSON.parse(JSON.stringify(value))); - return true; - }, - async remove(key) { - metaState.delete(key); - return true; - } - } - }, - practiceState, - metaState - }; -} - -const results = []; - -function recordResult(name, passed, detail) { - results.push({ name, passed, detail, timestamp: new Date().toISOString() }); -} - -async function testProtocolNormalization(PracticeCore) { - const normalized = PracticeCore.protocol.normalizeMessage({ - type: 'practice_completed', - data: { examId: 'reading-p1' }, - source: 'practice_page' - }); - - assert(normalized, '消息应被正确解析'); - assert.strictEqual(normalized.type, 'PRACTICE_COMPLETE', '消息类型应折叠为 PRACTICE_COMPLETE'); - assert.strictEqual(PracticeCore.protocol.normalizeMessageType('request_init'), 'REQUEST_INIT', 'REQUEST_INIT 别名应归一'); - recordResult('PracticeCore 协议归一化', true, normalized); -} - -async function testCompletionIngestion(PracticeCore) { - const record = PracticeCore.ingestor.fromCompletion({ - type: 'practice_complete', - data: { - examId: 'reading-p1', - sessionId: 'session-reading-p1', - title: 'Passage 1', - duration: 1800, - answers: { 1: 'A', 2: 'B' }, - correctAnswers: { 1: 'A', 2: 'C' }, - scoreInfo: { correct: 1, total: 2, accuracy: 0.5, percentage: 50 }, - metadata: { category: 'P1', frequency: 'high', type: 'reading' } - } - }, { - examId: 'reading-p1', - metadata: { examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' } - }, { - id: 'reading-p1', - title: 'Passage 1', - category: 'P1', - frequency: 'high', - type: 'reading' - }); - - assert(record, '完成负载应成功转换为记录'); - assert.strictEqual(record.examId, 'reading-p1'); - assert.strictEqual(record.type, 'reading'); - assert.strictEqual(record.totalQuestions, 2); - assert.strictEqual(record.correctAnswers, 1); - assert.strictEqual(record.answers.length, 2); - assert.strictEqual(record.correctAnswerMap.q1, 'A'); - assert.strictEqual(record.metadata.category, 'P1'); - recordResult('PracticeCore 完成负载入站', true, { id: record.id, metadata: record.metadata }); -} - -async function testPracticeRecordApiWritePath(sandbox, PracticeCore, practiceState, metaState) { - assert.strictEqual(typeof PracticeCore.store.savePracticeRecord, 'undefined', '公开 PracticeCore.store 不能暴露记录写入口'); - assert.strictEqual(typeof PracticeCore.store.replacePracticeRecords, 'undefined', '公开 PracticeCore.store 不能暴露批量替换入口'); - assert.strictEqual(typeof PracticeCore.store.writeMeta, 'undefined', '公开 PracticeCore.store 不能暴露 meta 写入口'); - assert.strictEqual(typeof PracticeCore.store.routeStorageSet, 'undefined', '公开 PracticeCore.store 不能暴露 storage 写路由'); - assert.strictEqual(typeof PracticeCore.__installRecordAPI, 'undefined', 'PracticeRecordAPI 初始化后必须删除内部 store 安装钩子'); - const api = sandbox.window.PracticeRecordAPI; - - const first = await api.saveRecord({ - id: 'record-reading-p1', - examId: 'reading-p1', - sessionId: 'session-reading-p1', - title: 'Passage 1', - type: 'reading', - date: new Date().toISOString(), - score: 1, - totalQuestions: 2, - correctAnswers: 1, - accuracy: 0.5, - answers: { q1: 'A', q2: 'B' }, - correctAnswerMap: { q1: 'A', q2: 'C' }, - metadata: { examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' } - }, { maxRecords: 1000 }); - - const second = await api.saveRecord({ - id: 'record-reading-p1-retry', - examId: 'reading-p1', - sessionId: 'session-reading-p1', - title: 'Passage 1 retry', - type: 'reading', - date: new Date().toISOString(), - score: 2, - totalQuestions: 2, - correctAnswers: 2, - accuracy: 1, - answers: { q1: 'A', q2: 'C' }, - correctAnswerMap: { q1: 'A', q2: 'C' }, - metadata: { examTitle: 'Passage 1 retry', category: 'P1', frequency: 'high', type: 'reading' } - }, { maxRecords: 1000 }); - - assert(first, '第一次保存应成功'); - assert(second, '第二次保存应成功'); - assert.strictEqual(practiceState.length, 1, '相同 sessionId 的记录应只保留一条'); - assert.strictEqual(practiceState[0].id, 'record-reading-p1-retry', '应保留最新记录'); - assert.strictEqual(metaState.get('user_stats').totalPractices, 1, 'saveRecord 默认应重算 user_stats'); - - await api.writeStats({ totalPractices: 1 }); - assert.strictEqual(metaState.get('user_stats').totalPractices, 1, 'user_stats 应走统一 PracticeRecordAPI 写路径'); - - await assert.rejects( - () => api.saveRecord({ - id: 'record-without-exam-id', - sessionId: 'session-without-exam-id', - title: 'Legacy title only', - type: 'reading', - date: new Date().toISOString(), - score: 1, - totalQuestions: 1, - correctAnswers: 1, - accuracy: 1 - }, { maxRecords: 1000 }), - /canonical examId/, - '缺少 canonical examId 的记录不能用 title/sessionId 兜底保存' - ); - - recordResult('PracticeRecordAPI 统一写路径', true, { - savedRecordId: practiceState[0].id, - totalRecords: practiceState.length - }); -} - -async function testPracticeRecordApiContract(sandbox, PracticeCore, practiceState, metaState) { - const api = sandbox.window.PracticeRecordAPI; - assert(api, 'PracticeRecordAPI 应挂载到 window'); - assert.strictEqual(typeof api.saveCompletion, 'function', 'PracticeRecordAPI 应提供 saveCompletion'); - assert.strictEqual(typeof api.getById, 'function', 'PracticeRecordAPI 应提供 getById'); - assert.strictEqual(typeof api.toSummaryMetrics, 'function', 'PracticeRecordAPI 应提供 toSummaryMetrics'); - - const saved = await api.saveCompletion({ - type: 'PRACTICE_COMPLETE', - data: { - examId: 'reading-p2', - sessionId: 'session-reading-p2', - title: 'Passage 2', - duration: 900, - answers: { q1: 'TRUE', q2: 'FALSE' }, - correctAnswerMap: { q1: 'TRUE', q2: 'NOT GIVEN' }, - answerComparison: { - q1: { userAnswer: 'TRUE', correctAnswer: 'TRUE', isCorrect: true }, - q2: { userAnswer: 'FALSE', correctAnswer: 'NOT GIVEN', isCorrect: false } - }, - scoreInfo: { correct: 1, total: 2, accuracy: 0.5, percentage: 50 }, - metadata: { category: 'P2', frequency: 'high', type: 'reading' } - } - }, { - examId: 'reading-p2', - metadata: { examTitle: 'Passage 2', category: 'P2', frequency: 'high', type: 'reading' } - }, { - id: 'reading-p2', - title: 'Passage 2', - category: 'P2', - frequency: 'high', - type: 'reading' - }, { currentVersion: '0.6.2-fix', maxRecords: 1000 }); - - assert(saved, 'saveCompletion 应返回已保存记录'); - assert.strictEqual(saved.examId, 'reading-p2'); - assert.strictEqual(saved.correctAnswerMap.q2, 'NOT GIVEN'); - assert.strictEqual(saved.answerComparison.q2.isCorrect, false); - assert.strictEqual(practiceState[0].id, saved.id, 'saveCompletion 应写入统一 PracticeCore store'); - - const hit = await api.getById(saved.id); - assert(hit, 'getById 应能按 record id 找回记录'); - assert.strictEqual(hit.sessionId, 'session-reading-p2'); - - const sessionHit = await api.getById('session-reading-p2'); - assert(sessionHit, 'getById 应能按 sessionId 找回记录'); - assert.strictEqual(sessionHit.id, saved.id); - - const metrics = api.toSummaryMetrics({ accuracy: 0.75, totalQuestions: 4, correctAnswers: 3 }); - assert.strictEqual(metrics.percentage, 75, 'summary metrics 应从 0..1 accuracy 推导 percentage'); - const importedMetrics = api.toSummaryMetrics({ accuracy: 85, totalQuestions: 20, correctAnswers: 17 }); - assert.strictEqual(importedMetrics.accuracy, 0.85, 'summary metrics 应把 0..100 accuracy 归一到 0..1'); - - await api.replace([ - { - id: 'record-delete-a', - examId: 'reading-delete-a', - sessionId: 'session-delete-a', - title: 'Delete A', - type: 'reading', - score: 1, - totalQuestions: 1, - correctAnswers: 1, - accuracy: 1 - }, - { - id: 'record-delete-b', - examId: 'reading-delete-b', - sessionId: 'session-delete-b', - title: 'Delete B', - type: 'reading', - score: 0, - totalQuestions: 1, - correctAnswers: 0, - accuracy: 0 - } - ], { maxRecords: 1000 }); - assert.strictEqual(practiceState.length, 2, 'replace 应写入统一 PracticeCore store'); - - const deletedBySession = await api.deleteMany(['session-delete-a'], { maxRecords: 1000, updateStats: true, matchBy: 'sessionId' }); - assert.strictEqual(deletedBySession.deletedCount, 1, 'deleteMany 应支持按 sessionId 删除 (matchBy: sessionId)'); - assert.deepStrictEqual( - practiceState.map((record) => record.id), - ['record-delete-b'], - 'deleteMany 应通过统一 replace 更新 canonical store' - ); - assert.strictEqual(metaState.get('user_stats').totalPractices, 1, 'deleteMany(updateStats) 后统计应按剩余 canonical 记录重算'); - - const deletedById = await api.deleteById('record-delete-b', { maxRecords: 1000, updateStats: true }); - assert.strictEqual(deletedById.deleted, true, 'deleteById 应支持按 record id 删除'); - assert.strictEqual(practiceState.length, 0, 'deleteById 删除后 canonical store 应为空'); - assert.strictEqual(metaState.get('user_stats').totalPractices, 0, 'deleteById(updateStats) 删除最后一条后统计应归零'); - - await api.saveRecord({ - id: 'record-clear-a', - examId: 'reading-clear-a', - sessionId: 'session-clear-a', - title: 'Clear A', - type: 'reading', - score: 1, - totalQuestions: 1, - correctAnswers: 1, - accuracy: 1 - }, { maxRecords: 1000 }); - assert.strictEqual(practiceState.length, 1, 'saveRecord 应在 clear 前写入记录'); - await api.clear({ maxRecords: 1000, updateStats: true }); - assert.strictEqual(practiceState.length, 0, 'clear 应清空 canonical store'); - assert.strictEqual(metaState.get('user_stats').totalPractices, 0, 'clear(updateStats) 后统计应归零'); - - recordResult('PracticeRecordAPI 统一记录门面', true, { - savedRecordId: saved.id, - metrics - }); -} - -async function testPracticeRecordApiStatsIdempotency(sandbox, practiceState, metaState) { - const api = sandbox.window.PracticeRecordAPI; - let recalculateCalls = 0; - sandbox.window.scoreStorage = { - get currentVersion() { - throw new Error('PracticeRecordAPI must not read scoreStorage.currentVersion'); - }, - get maxRecords() { - throw new Error('PracticeRecordAPI must not read scoreStorage.maxRecords'); - }, - async recalculateUserStats() { - recalculateCalls += 1; - throw new Error('PracticeRecordAPI must not delegate stats to scoreStorage'); - } - }; - - await api.clear({ maxRecords: 1000 }); - await api.writeStats({ - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0 - }); - - const payload = { - type: 'PRACTICE_COMPLETE', - data: { - examId: 'reading-idempotent', - sessionId: 'session-idempotent', - title: 'Idempotent Passage', - duration: 300, - answers: { q1: 'A', q2: 'B' }, - correctAnswerMap: { q1: 'A', q2: 'C' }, - scoreInfo: { correct: 1, total: 2, accuracy: 0.5, percentage: 50 }, - metadata: { category: 'P1', frequency: 'high', type: 'reading' } - } - }; - const context = { - examId: 'reading-idempotent', - sessionId: 'session-idempotent', - metadata: { examTitle: 'Idempotent Passage', category: 'P1', frequency: 'high', type: 'reading' } - }; - const examEntry = { - id: 'reading-idempotent', - title: 'Idempotent Passage', - category: 'P1', - frequency: 'high', - type: 'reading' - }; - - await api.saveCompletion(payload, context, examEntry, { currentVersion: '0.6.2-fix', maxRecords: 1000, updateStats: true }); - await api.saveCompletion(payload, context, examEntry, { currentVersion: '0.6.2-fix', maxRecords: 1000, updateStats: true }); - - assert.strictEqual(practiceState.length, 1, '重复 completion 只能保留一条 canonical 记录'); - const stats = metaState.get('user_stats'); - assert(stats, '重复 completion 后应写入 user_stats'); - assert.strictEqual(stats.totalPractices, 1, '重复 completion 不能让 user_stats.totalPractices 翻倍'); - assert.strictEqual(stats.totalTimeSpent, 300, '重复 completion 不能让 user_stats.totalTimeSpent 翻倍'); - assert.strictEqual(stats.averageScore, 0.5, '重复 completion 后平均分应基于唯一 canonical 记录'); - assert.strictEqual(recalculateCalls, 0, 'PracticeRecordAPI 不应调用 scoreStorage.recalculateUserStats'); - - delete sandbox.window.scoreStorage; - recordResult('PracticeRecordAPI 统计幂等', true, { - totalRecords: practiceState.length, - totalPractices: stats.totalPractices - }); -} - -async function testNumericCorrectAnswersRemainScoreCount(PracticeCore) { - const standardized = PracticeCore.contracts.standardizeRecord({ - id: 'record-numeric-correct-answers', - examId: 'reading-numeric-correct-answers', - sessionId: 'session-numeric-correct-answers', - title: 'Numeric Correct Answers', - type: 'reading', - date: '2026-05-24T00:00:00.000Z', - score: 7, - totalQuestions: 10, - correctAnswers: 7, - answers: { q1: 'A', q2: 'B' }, - realData: { - answers: { q1: 'A', q2: 'B' }, - correctAnswers: 7 - } - }, { - currentVersion: '0.6.2-fix', - generateRecordId: () => 'record-generated-should-not-be-used' - }); - - assert.strictEqual(standardized.correctAnswers, 7, 'top-level correctAnswers 数字应表示答对数量'); - assert(standardized.realData, 'standardizeRecord 应保留 realData'); - assert.strictEqual(typeof standardized.realData.correctAnswers, 'object', 'realData.correctAnswers 只能是答案表对象'); - assert(!Array.isArray(standardized.realData.correctAnswers), 'realData.correctAnswers 不能是数组'); - assert.strictEqual( - Object.keys(standardized.realData.correctAnswers).length, - 0, - 'numeric realData.correctAnswers 不能被伪造成正确答案表' - ); - - recordResult('PracticeCore numeric correctAnswers 契约', true, { - correctAnswers: standardized.correctAnswers, - realDataCorrectAnswerKeys: Object.keys(standardized.realData.correctAnswers) - }); -} - -async function testObjectCorrectAnswersBecomeCorrectAnswerMap(PracticeCore) { - const standardized = PracticeCore.contracts.standardizeRecord({ - id: 'record-object-correct-answers', - examId: 'reading-object-correct-answers', - sessionId: 'session-object-correct-answers', - title: 'Object Correct Answers', - type: 'reading', - date: '2026-05-24T00:00:00.000Z', - totalQuestions: 2, - answers: { 1: 'A', 2: 'B' }, - correctAnswers: { 1: 'A', 2: 'C' }, - metadata: { examTitle: 'Object Correct Answers', category: 'P1', frequency: 'low', type: 'reading' } - }, { - currentVersion: '0.6.2-fix', - generateRecordId: () => 'record-generated-should-not-be-used' - }); - - assert.strictEqual(standardized.correctAnswers, 1, '对象型 correctAnswers 应作为答案表推导答对数量'); - assert.strictEqual(standardized.correctAnswerMap.q1, 'A', '对象型 correctAnswers 应迁移到 correctAnswerMap'); - assert.strictEqual(standardized.correctAnswerMap.q2, 'C', '对象型 correctAnswers 不应丢失后续题目'); - assert.strictEqual(standardized.answers.length, 2, '答案列表应带正确答案表一起归一化'); - assert.strictEqual(standardized.answers[1].correctAnswer, 'C', 'answerList 应保留正确答案'); - - recordResult('PracticeCore object correctAnswers 兼容答案表', true, { - correctAnswers: standardized.correctAnswers, - correctAnswerMap: standardized.correctAnswerMap - }); -} - -async function testSuiteEntryCorrectAnswerMapSurvives(PracticeCore) { - const standardized = PracticeCore.contracts.standardizeRecord({ - id: 'record-suite-entry-correct-map', - examId: 'suite-parent', - sessionId: 'session-suite-entry-correct-map', - title: 'Suite Parent', - type: 'reading', - date: '2026-05-24T00:00:00.000Z', - suiteMode: true, - suiteSessionId: 'suite-correct-map', - suiteEntries: [ - { - examId: 'reading-p1', - title: 'Passage 1', - answers: { 1: 'A', 2: 'B' }, - correctAnswers: { 1: 'A', 2: 'C' }, - scoreInfo: { correct: 1, total: 2, accuracy: 0.5 } - } - ], - metadata: { examTitle: 'Suite Parent', category: 'suite', frequency: 'suite', type: 'reading' } - }, { - currentVersion: '0.6.2-fix', - generateRecordId: () => 'record-generated-should-not-be-used' - }); - - const entry = standardized.suiteEntries[0]; - assert(entry, 'suite entry 应存在'); - assert.strictEqual(entry.correctAnswerMap.q1, 'A', 'suite entry 应保留对象型 correctAnswers'); - assert.strictEqual(entry.correctAnswerMap.q2, 'C', 'suite entry correctAnswerMap 不应丢失'); - - recordResult('PracticeCore suite entry correctAnswerMap 保留', true, { - correctAnswerMap: entry.correctAnswerMap - }); -} - -async function testCanonicalCorrectAnswerMapWinsOverLegacyObjects(PracticeCore) { - const standardized = PracticeCore.contracts.standardizeRecord({ - id: 'record-canonical-correct-map', - examId: 'reading-canonical-correct-map', - sessionId: 'session-canonical-correct-map', - title: 'Canonical Correct Map', - type: 'reading', - date: '2026-05-24T00:00:00.000Z', - totalQuestions: 2, - answers: { q1: 'A', q2: 'B' }, - correctAnswerMap: { q1: 'A', q2: 'D' }, - correctAnswers: { q1: 'B', q2: 'C' }, - realData: { - answers: { q1: 'A', q2: 'B' }, - correctAnswerMap: { q2: 'D' }, - correctAnswers: { q1: 'B', q2: 'C' } - }, - metadata: { examTitle: 'Canonical Correct Map', category: 'P1', frequency: 'low', type: 'reading' } - }, { - currentVersion: '0.6.2-fix', - generateRecordId: () => 'record-generated-should-not-be-used' - }); - - assert.strictEqual(standardized.correctAnswerMap.q1, 'A', 'canonical correctAnswerMap 不能被 legacy correctAnswers 覆盖'); - assert.strictEqual(standardized.correctAnswerMap.q2, 'D', 'canonical correctAnswerMap 后续题目也不能被 legacy correctAnswers 覆盖'); - assert.strictEqual(standardized.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical correctAnswerMap'); - assert.strictEqual(standardized.realData.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应镜像 canonical correctAnswerMap'); - assert.strictEqual(standardized.correctAnswers, 1, '答对数量应基于 canonical correctAnswerMap 计算'); - - recordResult('PracticeCore canonical correctAnswerMap 优先级', true, { - correctAnswerMap: standardized.correctAnswerMap, - correctAnswers: standardized.correctAnswers - }); -} - -async function testSuiteEntryDerivesCorrectAnswerMapFromComparison(PracticeCore) { - const standardized = PracticeCore.contracts.standardizeRecord({ - id: 'record-suite-entry-derived-map', - examId: 'suite-parent-derived', - sessionId: 'session-suite-entry-derived-map', - title: 'Suite Parent Derived', - type: 'reading', - date: '2026-05-24T00:00:00.000Z', - suiteMode: true, - suiteSessionId: 'suite-derived-map', - suiteEntries: [ - { - examId: 'reading-p2', - title: 'Passage 2', - answers: { q1: 'A', q2: 'B' }, - answerComparison: { - q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true }, - q2: { userAnswer: 'B', correctAnswer: 'C', isCorrect: false } - } - }, - { - examId: 'reading-p3', - title: 'Passage 3', - answers: { q1: 'TRUE' }, - realData: { - correctAnswerMap: { q1: 'FALSE' } - } - } - ], - metadata: { examTitle: 'Suite Parent Derived', category: 'suite', frequency: 'suite', type: 'reading' } - }, { - currentVersion: '0.6.2-fix', - generateRecordId: () => 'record-generated-should-not-be-used' - }); - - assert.strictEqual(standardized.suiteEntries[0].correctAnswerMap.q1, 'A', 'suite entry 应能从 answerComparison 派生正确答案'); - assert.strictEqual(standardized.suiteEntries[0].correctAnswerMap.q2, 'C', 'suite entry 派生 correctAnswerMap 不应漏后续题目'); - assert.strictEqual(standardized.suiteEntries[1].correctAnswerMap.q1, 'FALSE', 'suite entry 应读取 realData.correctAnswerMap'); - - recordResult('PracticeCore suite entry 派生 correctAnswerMap', true, { - firstEntry: standardized.suiteEntries[0].correctAnswerMap, - secondEntry: standardized.suiteEntries[1].correctAnswerMap - }); -} - -async function testReplayResultSnapshotCanonicalOnly(PracticeCore) { - const replay = PracticeCore.contracts.buildReplayResultSnapshot({ - answers: { q1: 'A', q2: 'D' }, - correctAnswerMap: { q1: 'A', q2: 'D' }, - correctAnswers: { q1: 'B', q2: 'C' }, - answerComparison: { - q1: { userAnswer: 'A', correctAnswer: 'B', isCorrect: false }, - q2: { userAnswer: 'D', correctAnswer: 'C', isCorrect: false } - }, - scoreInfo: { correct: 0, total: 2, accuracy: 0, percentage: 0 }, - realData: { - correctAnswerMap: { q2: 'D' }, - correctAnswers: { q1: 'B', q2: 'C' } - } - }); - - assert.strictEqual(replay.correctAnswers.q1, 'A', 'replay 应以 canonical correctAnswerMap 为唯一正确答案表'); - assert.strictEqual(replay.correctAnswers.q2, 'D', 'realData canonical map 只能补缺,不能被 legacy 覆盖'); - assert.strictEqual(replay.correctAnswerMap.q2, 'D', 'replay 应同时输出 canonical correctAnswerMap 别名'); - assert.strictEqual(replay.answerComparison.q1.correctAnswer, 'A', 'comparison 正确答案必须按 canonical map 重建'); - assert.strictEqual(replay.answerComparison.q2.correctAnswer, 'D', 'comparison 后续题目也必须按 canonical map 重建'); - assert.strictEqual(replay.answerComparison.q1.isCorrect, true, 'isCorrect 必须按 canonical map 重算'); - assert.strictEqual(replay.scoreInfo.correct, 2, '完整 canonical map 时必须重算正确数'); - assert.strictEqual(replay.scoreInfo.total, 2, '完整 canonical map 时必须重算总题数'); - assert.strictEqual(replay.scoreInfo.percentage, 100, '完整 canonical map 时必须重算百分比'); - - recordResult('PracticeCore replay canonical correctAnswerMap 契约', true, { - correctAnswerMap: replay.correctAnswerMap, - scoreInfo: replay.scoreInfo - }); -} - -async function testReplayResultSnapshotRefusesLegacyCorrectAnswerFallback(PracticeCore) { - const replay = PracticeCore.contracts.buildReplayResultSnapshot({ - answers: { q1: 'A' }, - correctAnswers: 7, - answerComparison: { - q1: { userAnswer: 'A', correctAnswer: 'B', isCorrect: false } - }, - scoreInfo: { correct: 5, total: 7, accuracy: 5 / 7, percentage: 71 } - }); - - assert.strictEqual(Object.keys(replay.correctAnswers).length, 0, '数字型 correctAnswers 不能被当成 replay 答案表'); - assert.strictEqual(replay.answerComparison.q1.correctAnswer, '', 'comparison.correctAnswer 不能作为 replay 正确答案 fallback'); - assert.strictEqual(replay.answerComparison.q1.isCorrect, null, '缺 canonical map 时不能猜测 isCorrect'); - assert.strictEqual(replay.scoreInfo.correct, 5, '缺 canonical map 时保留来源分数'); - assert.strictEqual(replay.scoreInfo.total, 7, '缺 canonical map 时保留来源总题数'); - - recordResult('PracticeCore replay 拒绝 legacy 正确答案兜底', true, { - correctAnswers: replay.correctAnswers, - scoreInfo: replay.scoreInfo - }); -} - -async function testReplayResultSnapshotSuitePrefixedKeys(PracticeCore) { - const replay = PracticeCore.contracts.buildReplayResultSnapshot({ - answers: { - q1: 'A', - 'reading-p1::q17': 'B' - }, - correctAnswerMap: { - q1: 'A', - 'reading-p1::q17': 'C' - }, - allQuestionIds: ['q1', 'reading-p1::q17'] - }); - - assert.strictEqual(replay.answers.q1, 'A', '普通 q1 应保留'); - assert.strictEqual(replay.answers.q17, 'B', 'suite 前缀题号应只取 :: 后的问题段'); - assert.strictEqual(Object.keys(replay.answers).length, 2, 'suite 前缀题号不能和 q1 撞键'); - assert.strictEqual(replay.answerComparison.q17.correctAnswer, 'C', 'suite 前缀正确答案应归一到 q17'); - assert.strictEqual(replay.scoreInfo.total, 2, 'suite 前缀题号应计入总题数'); - - recordResult('PracticeCore replay suite 前缀题号归一', true, { - answers: replay.answers, - correctAnswerMap: replay.correctAnswerMap - }); -} - -async function main() { - const { repositories, practiceState, metaState } = createRepositoryHarness(); - - const windowStub = { - console, - practiceRecords: [] - }; - - const sandbox = { - window: windowStub, - console, - setTimeout, - clearTimeout, - setInterval, - clearInterval, - Date, - Math, - JSON - }; - sandbox.globalThis = sandbox.window; - - const context = vm.createContext(sandbox); - loadScript('js/core/practiceCore.js', context); - sandbox.window.PracticeCore.__installInternalRepositories(repositories); - loadScript('js/core/practiceRecordAPI.js', context); - loadScript('js/core/practiceStore.js', context); - const PracticeCore = sandbox.window.PracticeCore; - - try { - await testProtocolNormalization(PracticeCore); - await testCompletionIngestion(PracticeCore); - await testPracticeRecordApiWritePath(sandbox, PracticeCore, practiceState, metaState); - await testPracticeRecordApiContract(sandbox, PracticeCore, practiceState, metaState); - await testPracticeRecordApiStatsIdempotency(sandbox, practiceState, metaState); - await testNumericCorrectAnswersRemainScoreCount(PracticeCore); - await testObjectCorrectAnswersBecomeCorrectAnswerMap(PracticeCore); - await testSuiteEntryCorrectAnswerMapSurvives(PracticeCore); - await testCanonicalCorrectAnswerMapWinsOverLegacyObjects(PracticeCore); - await testSuiteEntryDerivesCorrectAnswerMapFromComparison(PracticeCore); - await testReplayResultSnapshotCanonicalOnly(PracticeCore); - await testReplayResultSnapshotRefusesLegacyCorrectAnswerFallback(PracticeCore); - await testReplayResultSnapshotSuitePrefixedKeys(PracticeCore); - - const summary = { - status: 'pass', - detail: `${results.length}/${results.length} 测试通过`, - passed: results.length, - total: results.length - }; - console.log(JSON.stringify(summary, null, 2)); - } catch (error) { - recordResult('PracticeCore 测试执行失败', false, { error: error.message }); - console.log(JSON.stringify({ - status: 'fail', - detail: error.message, - results - }, null, 2)); - process.exit(1); - } -} - -main(); diff --git a/developer/tests/js/practiceCoreAppStateSync.test.js b/developer/tests/js/practiceCoreAppStateSync.test.js deleted file mode 100644 index f8ada1d4..00000000 --- a/developer/tests/js/practiceCoreAppStateSync.test.js +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function loadScript(relativePath, context) { - const fullPath = path.join(repoRoot, relativePath); - const source = fs.readFileSync(fullPath, 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function createHarness() { - const practiceState = []; - const stateServiceRecords = []; - const quietConsole = { - log() {}, - warn() {}, - error() {}, - info() {}, - debug() {} - }; - - const repositories = { - practice: { - async list() { - return practiceState.map((record) => ({ ...record })); - }, - async overwrite(records) { - practiceState.splice(0, practiceState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : [])); - return true; - } - }, - meta: { - async get(_key, fallback = null) { - return fallback; - }, - async set() { - return true; - }, - async remove() { - return true; - } - } - }; - - const sandbox = { - console: quietConsole, - Date, - Math, - JSON, - app: { - state: { - practice: { - records: [] - } - } - }, - practiceRecords: [{ id: 'legacy-shadow' }], - setPracticeRecordsState(records) { - stateServiceRecords.splice(0, stateServiceRecords.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : [])); - return stateServiceRecords.map((record) => ({ ...record })); - } - }; - - sandbox.window = sandbox; - sandbox.globalThis = sandbox; - const context = vm.createContext(sandbox); - loadScript('js/core/practiceCore.js', context); - sandbox.PracticeCore.__installInternalRepositories(repositories); - loadScript('js/core/practiceRecordAPI.js', context); - - return { - sandbox, - practiceState, - stateServiceRecords - }; -} - -function createStateServiceHarness() { - let enrichCalls = 0; - const quietConsole = { - log() {}, - warn() {}, - error() {}, - info() {}, - debug() {} - }; - - const sandbox = { - console: quietConsole, - Date, - Math, - JSON, - Set, - Map, - Array, - Object, - String, - Number, - Boolean, - structuredClone, - app: { - state: { - exam: {}, - practice: { records: [] }, - ui: {}, - system: {} - } - }, - DataConsistencyManager: class DataConsistencyManager { - enrichRecordData() { - enrichCalls += 1; - throw new Error('state-service must not create display projections'); - } - - ensureConsistency() { - enrichCalls += 1; - throw new Error('state-service must not create display projections'); - } - } - }; - - sandbox.window = sandbox; - sandbox.globalThis = sandbox; - const context = vm.createContext(sandbox); - loadScript('js/app/state-service.js', context); - sandbox.appStateService.connectApp(sandbox.app); - - return { - sandbox, - getEnrichCalls() { - return enrichCalls; - } - }; -} - -async function testPracticeCoreSyncsAppState() { - const harness = createHarness(); - const record = { - id: 'record-1', - sessionId: 'session-1', - type: 'reading', - score: 8, - correctAnswers: 8, - totalQuestions: 10, - accuracy: 0.8, - percentage: 80, - duration: 100, - date: '2026-03-09T10:00:00.000Z', - startTime: '2026-03-09T09:58:20.000Z', - endTime: '2026-03-09T10:00:00.000Z', - title: 'Record 1', - metadata: { - examTitle: 'Record 1', - category: 'P1', - frequency: 'high', - type: 'reading', - examType: 'reading' - } - }; - - assert.strictEqual( - typeof harness.sandbox.PracticeCore.store.replacePracticeRecords, - 'undefined', - '公开 PracticeCore.store 不能暴露批量写入口' - ); - - await harness.sandbox.PracticeRecordAPI.replace([record]); - - assert.deepStrictEqual( - harness.stateServiceRecords.map((item) => item.id), - ['record-1'], - 'replacePracticeRecords 后应同步 state-service 练习记录状态' - ); - assert.deepStrictEqual( - harness.sandbox.app.state.practice.records.map((item) => item.id), - ['record-1'], - 'replacePracticeRecords 后应同步 app.state.practice.records' - ); - - assert.deepStrictEqual( - harness.sandbox.practiceRecords.map((item) => item.id), - ['legacy-shadow'], - 'replacePracticeRecords 不应写回 legacy global.practiceRecords 影子事实源' - ); - - await harness.sandbox.PracticeRecordAPI.replace([]); - - assert.strictEqual(harness.practiceState.length, 0, 'replacePracticeRecords([]) 应清空 canonical store'); - assert.strictEqual(harness.stateServiceRecords.length, 0, 'replacePracticeRecords([]) 应清空 state-service 练习记录状态'); - assert.strictEqual(harness.sandbox.app.state.practice.records.length, 0, 'replacePracticeRecords([]) 应同步清空 app.state.practice.records'); -} - -async function testStateServiceKeepsCanonicalPracticeRecordsOnly() { - const harness = createStateServiceHarness(); - const record = { - id: 'canonical-record', - correctAnswers: 7, - totalQuestions: 10, - score: 7, - realData: { - answers: { q1: 'A' } - } - }; - - const returned = harness.sandbox.appStateService.setPracticeRecords([record]); - assert.strictEqual(harness.getEnrichCalls(), 0, 'setPracticeRecords 不应调用 DataConsistencyManager 生成显示投影'); - assert.strictEqual(returned[0].correctAnswers, 7, '返回值应保留数字型 correctAnswers'); - assert.strictEqual(returned[0].answerComparison, undefined, 'canonical state 不应补 answerComparison 显示字段'); - assert.strictEqual(returned[0].realData.correctAnswers, undefined, 'canonical state 不应补 realData.correctAnswers 显示字段'); - - const appRecord = harness.sandbox.app.state.practice.records[0]; - assert.strictEqual(appRecord.correctAnswers, 7, 'app.state.practice.records 应保留 canonical 数字型 correctAnswers'); - assert.strictEqual(appRecord.answerComparison, undefined, 'app.state.practice.records 不应存显示投影字段'); - assert.strictEqual(appRecord.realData.correctAnswers, undefined, 'app.state.practice.records 不应存 realData.correctAnswers 投影'); - - const getterRecord = harness.sandbox.getPracticeRecordsState()[0]; - getterRecord.correctAnswers = { q1: 'A' }; - getterRecord.realData.answers.q1 = 'MUTATED'; - const afterGetterMutation = harness.sandbox.appStateService.getPracticeRecords()[0]; - assert.strictEqual(afterGetterMutation.correctAnswers, 7, '全局 getter 返回 clone,不能污染内部 correctAnswers'); - assert.strictEqual(afterGetterMutation.realData.answers.q1, 'A', '全局 getter 返回 clone,不能污染内部 nested realData'); - - returned[0].realData.answers.q1 = 'RETURNED_MUTATION'; - const afterReturnedMutation = harness.sandbox.appStateService.getPracticeRecords()[0]; - assert.strictEqual(afterReturnedMutation.realData.answers.q1, 'A', 'setPracticeRecords 返回值也必须是 clone'); - - record.realData.answers.q1 = 'INPUT_MUTATION'; - const afterInputMutation = harness.sandbox.appStateService.getPracticeRecords()[0]; - assert.strictEqual(afterInputMutation.realData.answers.q1, 'A', '输入 record 后续变更不能污染 state-service'); -} - -async function main() { - try { - await testPracticeCoreSyncsAppState(); - await testStateServiceKeepsCanonicalPracticeRecordsOnly(); - console.log(JSON.stringify({ - status: 'pass', - detail: 'PracticeCore replacePracticeRecords 与 AppStateService canonical 记录同步契约通过' - }, null, 2)); - } catch (error) { - console.log(JSON.stringify({ - status: 'fail', - detail: error.message - }, null, 2)); - process.exit(1); - } -} - -main(); diff --git a/developer/tests/js/practiceCustomCard.test.js b/developer/tests/js/practiceCustomCard.test.js index 42f9b923..f77bcb65 100644 --- a/developer/tests/js/practiceCustomCard.test.js +++ b/developer/tests/js/practiceCustomCard.test.js @@ -33,6 +33,7 @@ function loadCustomCardCalculators(source) { [ 'global.__testCalculateReadingRadarData = calculateReadingRadarData;', 'global.__testCalculatePracticeHeatmapData = calculatePracticeHeatmapData;', + 'global.__testFilterByExamType = filterByExamType;', '})(window);' ].join('\n') ); @@ -54,7 +55,8 @@ function loadCustomCardCalculators(source) { vm.runInContext(injected, context, { filename: 'legacyViewBundle.js' }); return { calculateReadingRadarData: sandboxWindow.__testCalculateReadingRadarData, - calculatePracticeHeatmapData: sandboxWindow.__testCalculatePracticeHeatmapData + calculatePracticeHeatmapData: sandboxWindow.__testCalculatePracticeHeatmapData, + filterByExamType: sandboxWindow.__testFilterByExamType }; } @@ -106,7 +108,9 @@ try { assertContains(source, 'function loadPersistedPracticeWidget()', '自定义卡片应提供读取持久化组件的函数'); assertContains(source, 'function persistPracticeWidget(widget)', '自定义卡片应提供写入持久化组件的函数'); assertContains(source, "persistPracticeWidget(widget);", '切换组件时应写回持久化,刷新后才能保持选中'); - assertContains(source, "var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget';", '持久化组件应使用固定的 localStorage 键'); + assertContains(source, 'window.AppData.preferences.getPracticeWidget()', '自定义卡片应通过 AppData.preferences 读取组件偏好'); + assertContains(source, 'window.AppData.preferences.setPracticeWidget(widget)', '自定义卡片应通过 AppData.preferences 保存组件偏好'); + assertNotContains(source, 'practice_custom_widget', '运行期组件偏好不能继续持有 v1 物理 key'); assertContains(source, 'function calculatePracticeHeatmapData(records, monthDate)', '热力图数据聚合函数应存在'); assertContains(source, 'aggregatePracticeHeatmapSets(records, monthStart)', '热力图应按套数聚合练习记录'); assertContains(source, "event.target.closest('[data-practice-heatmap-month]')", '月份按钮事件应单独绑定'); @@ -127,7 +131,7 @@ try { assertContains(source, 'event.stopPropagation();', '整卡其他区域点击应阻止冒泡且不翻转'); record('自定义卡片业务逻辑守卫'); - const { calculateReadingRadarData, calculatePracticeHeatmapData } = loadCustomCardCalculators(source); + const { calculateReadingRadarData, calculatePracticeHeatmapData, filterByExamType } = loadCustomCardCalculators(source); assert.strictEqual(typeof calculatePracticeHeatmapData, 'function', '热力图聚合函数应可被测试提取'); const heatmapData = calculatePracticeHeatmapData([ { id: 'h1', date: '2026-05-01T08:00:00', totalQuestions: 13 }, @@ -207,12 +211,69 @@ try { assert.strictEqual(radarData.totalErrors, 2, '雷达应只统计两道错题'); record('雷达题型映射回归守卫'); + const lightRadarData = calculateReadingRadarData([{ + id: 'light-reading', + type: 'reading', + date: '2026-05-25T00:00:00.000Z', + questionTypeErrorCounts: { + true_false_not_given: 2, + sentence_completion: 1 + } + }, { + id: 'light-suite', + type: 'suite', + date: '2026-05-26T00:00:00.000Z', + suiteEntrySummaries: [{ + examId: 'suite-reading-child', + type: 'reading', + date: '2026-05-26T00:00:00.000Z', + questionTypeErrorCounts: { short_answer: 3 } + }, { + examId: 'suite-listening-child', + type: 'listening', + date: '2026-05-26T00:00:00.000Z', + questionTypeErrorCounts: { other: 9 } + }] + }]); + const lightRadarCounts = Object.fromEntries(lightRadarData.dataPoints.map((point) => [point.label, point.value])); + assert.strictEqual(lightRadarCounts['判断题'], 2, 'light summary 的判断题错题应进入雷达'); + assert.strictEqual(lightRadarCounts['句子填空'], 1, 'light summary 的句子填空错题应进入雷达'); + assert.strictEqual(lightRadarCounts['简答题'], 3, '套题 reading entry 的轻量错题应进入雷达'); + assert.strictEqual(lightRadarData.totalErrors, 6, 'listening entry 不得污染阅读雷达'); + record('雷达消费 v2 轻量错题投影回归守卫'); + + const suiteFilterRecords = [{ + id: 'reading-only-suite', + type: 'suite', + suiteEntrySummaries: [{ type: 'reading' }] + }, { + id: 'listening-only-suite', + type: 'suite', + suiteEntrySummaries: [{ type: 'listening' }] + }, { + id: 'mixed-suite', + type: 'suite', + suiteEntrySummaries: [{ type: 'reading' }, { type: 'listening' }] + }]; + assert.deepStrictEqual( + filterByExamType(suiteFilterRecords, [], 'reading').map((record) => record.id), + ['reading-only-suite', 'mixed-suite'], + '类型筛选必须直接消费 suiteEntrySummaries 的 reading 类型' + ); + assert.deepStrictEqual( + filterByExamType(suiteFilterRecords, [], 'listening').map((record) => record.id), + ['listening-only-suite', 'mixed-suite'], + '类型筛选必须直接消费 suiteEntrySummaries 的 listening 类型' + ); + record('套题类型筛选消费 v2 轻量 entry 投影'); + [ "this.activeWidget = loadPersistedPracticeWidget() || options.defaultWidget || 'heatmap'", 'function loadPersistedPracticeWidget()', 'function persistPracticeWidget(widget)', 'persistPracticeWidget(widget);', - "var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget';", + 'window.AppData.preferences.getPracticeWidget()', + 'window.AppData.preferences.setPracticeWidget(widget)', 'function calculatePracticeHeatmapData(records, monthDate)', 'aggregatePracticeHeatmapSets(records, monthStart)', 'averageSetsPerActiveDay', diff --git a/developer/tests/js/practiceLightProjectionRenderContract.test.js b/developer/tests/js/practiceLightProjectionRenderContract.test.js new file mode 100644 index 00000000..1b16a3cd --- /dev/null +++ b/developer/tests/js/practiceLightProjectionRenderContract.test.js @@ -0,0 +1,1317 @@ +#!/usr/bin/env node +/** + * 练习记录 light 投影 <-> 渲染过滤 的跨文件契约测试。 + * + * 复现的线上 bug:做完题后记录已写入,控制台打印「已从 AppData 加载 1 条练习摘要」, + * 但练习记录页面一条都不显示。 + * + * - js/data/v2/appData.js `lightFromCanonical` 曾把缺失的 dataSource 回退成 `null`; + * - js/main.js `updatePracticeView` 渲染前按 `dataSource === 'real' || === undefined` 过滤; + * - `null` 两个都不匹配 => 记录被整条过滤掉 => 界面空白。 + * + * 本文件用三层防线锁住这个语义: + * 1. 投影层契约:light 投影产出的 dataSource 必须能通过"最严格的历史过滤条件"。 + * 断言写成"能否通过渲染过滤",而不是硬编码 'real' 还是 undefined, + * 这样投影侧无论选哪个合法值都算通过,只有 null 之类的哨兵值会失败。 + * 2. 跨文件端到端:真实 AppData 写入 -> 真实 light 投影读出 -> 真实 + * js/main.js `updatePracticeView` 渲染,断言记录真的到达了 renderer。 + * 无论未来 light 投影或过滤条件怎么改,只要存进去的记录显示不出来就失败。 + * 3. 同类隐患静态守卫:light 投影里其他 `|| null` 回退字段,不允许出现 + * 只认 `=== undefined` 的下游消费方(反向同理)。 + */ + +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +const APP_DATA_SOURCE = 'js/data/v2/appData.js'; +const MAIN_SOURCE = 'js/main.js'; +const RECORD_SOURCE_MODULE = 'js/data/practiceRecordSource.js'; +const ONBOARDING_SOURCE = 'js/components/onboardingTour.js'; + +/** + * 历史上最严格的渲染过滤条件(js/main.js updatePracticeView 引爆此 bug 时的原文)。 + * + * 故意保留这个"窄"版本作为投影层的契约:light 投影不允许产出任何需要下游放宽 + * 判断才能显示的哨兵值。放宽 main.js 的过滤只是补救,投影侧本身必须是干净的。 + */ +const passesRenderFilter = (record) => Boolean(record) + && (record.dataSource === 'real' || record.dataSource === undefined); + +function readSource(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); +} + +function createMemoryStorage() { + const values = new Map(); + return { + get length() { return values.size; }, + key(index) { return Array.from(values.keys())[index] ?? null; }, + getItem(key) { return values.has(String(key)) ? values.get(String(key)) : null; }, + setItem(key, value) { values.set(String(key), String(value)); }, + removeItem(key) { values.delete(String(key)); } + }; +} + +/** + * 加载真实 AppData 领域代码 + 内存 FakeKernel。 + * IDB-only 内核在 Node 没有 IndexedDB;FakeKernel 只替换持久层,light 投影 / completeAttempt + * 仍走生产 appData.js,保证投影契约可在 CI 无浏览器时验证。 + */ +function loadRealAppData() { + const catalogSource = readSource('js/data/v2/dataCatalog.js'); + const appDataSource = readSource(APP_DATA_SOURCE); + const recordSource = readSource(RECORD_SOURCE_MODULE); + const clone = (value) => (value === undefined ? undefined : structuredClone(value)); + function stable(value) { + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); + } + function checksum(value) { + let hash = 0x811c9dc5; + for (const char of stable(value)) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16)}`; + } + class AppDataError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + } + + const catalogSandbox = { structuredClone }; + catalogSandbox.globalThis = catalogSandbox; + vm.runInContext(catalogSource, vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' }); + const catalog = catalogSandbox.__AppDataV2Catalog; + const shared = { + docs: new Map(), + entities: new Map([ + ['practiceSummaries', new Map()], + ['practiceDetails', new Map()], + ['practiceAnnotations', new Map()] + ]), + counter: 0 + }; + const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({ + schemaVersion: 2, + revision, + operationId, + updatedAt: new Date().toISOString(), + state, + data: state === 'cleared' ? null : clone(data), + checksum: checksum(state === 'cleared' ? null : data) + }); + + class Kernel { + async initialize() { + this.state = 'ready'; + this.backend = 'memory'; + return this; + } + async read(key, options = {}) { + const entry = catalog.get(key); + const value = shared.docs.get(key) || null; + const data = !value || value.state === 'cleared' ? entry.defaultValue() : value.data; + return options.withMeta ? { data: clone(data), envelope: clone(value) } : clone(data); + } + async mutate(changes, options = {}) { + const op = String(options.operationId || `doc-${++shared.counter}`); + const revisions = {}; + for (const change of changes) { + const old = shared.docs.get(change.logicalKey); + if (change.expectedRevision !== undefined + && Number(change.expectedRevision) !== Number(old && old.revision || 0)) { + throw new AppDataError('CONFLICT', 'document revision'); + } + const revision = Number(old && old.revision || 0) + 1; + shared.docs.set(change.logicalKey, envelope(change.logicalKey, change.data, change.state, revision, op)); + revisions[change.logicalKey] = revision; + } + return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; + } + async journalNoop(options = {}) { + return { + committed: true, + operationId: options.operationId || `noop-${++shared.counter}`, + revisions: {}, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + } + async readEntity(store, recordId, options = {}) { + const row = shared.entities.get(store).get(String(recordId)) || null; + return options.withMeta ? clone(row) : row && clone(row.data); + } + async listEntities(store, options = {}) { + if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'details are not listable'); + const rows = Array.from(shared.entities.get(store).values()); + return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); + } + async readPracticeSnapshot(recordIds = null, options = {}) { + const ids = recordIds == null ? null : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String)); + const stores = options.stores || ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + return Object.fromEntries(stores.map((store) => [store, Array.from(shared.entities.get(store).values()) + .filter((row) => !ids || ids.has(String(row.recordId))) + .map((row) => options.withMeta ? clone(row) : clone(row.data))])); + } + async mutateEntities(operations, options = {}) { + const op = String(options.operationId || `entity-${++shared.counter}`); + const revisions = {}; + for (const item of operations) { + const rows = shared.entities.get(item.store); + if (item.type === 'clear') { + rows.clear(); + revisions[`${item.store}/*`] = 0; + continue; + } + const old = rows.get(String(item.recordId)); + if (item.expectedRevision !== undefined && item.expectedRevision !== null + && Number(item.expectedRevision) !== Number(old && old.revision || 0)) { + throw new AppDataError('CONFLICT', 'entity revision'); + } + if (item.type === 'delete') { + rows.delete(String(item.recordId)); + revisions[`${item.store}/${item.recordId}`] = Number(old && old.revision || 0) + 1; + } else { + const row = { + recordId: String(item.recordId), + revision: Number(old && old.revision || 0) + 1, + operationId: op, + updatedAt: new Date().toISOString(), + data: clone(item.data), + checksum: checksum(item.data) + }; + rows.set(row.recordId, row); + revisions[`${item.store}/${item.recordId}`] = row.revision; + } + } + return { committed: true, operationId: op, revisions, derived: { status: 'ready', pending: [] }, warnings: [] }; + } + async exportSnapshot() { + const envelopes = {}; + for (const [key, value] of shared.docs) { + if (catalog.get(key).export === true) envelopes[key] = clone(value); + } + const entities = Object.fromEntries( + Array.from(shared.entities, ([store, rows]) => [store, Array.from(rows.values()).map(clone)]) + ); + const snapshot = { + format: 'ielts-atlas-data-v2', + schemaVersion: 2, + scope: 'full', + envelopes, + entities + }; + snapshot.checksum = checksum({ envelopes, entities }); + return snapshot; + } + async installSnapshot(snapshot, options = {}) { + for (const [key, value] of Object.entries(snapshot.envelopes || {})) shared.docs.set(key, clone(value)); + for (const [store, rows] of Object.entries(snapshot.entities || {})) { + shared.entities.set(store, new Map(rows.map((row) => [String(row.recordId), clone(row)]))); + } + return { + committed: true, + operationId: options.operationId || `install-${++shared.counter}`, + revisions: {}, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + } + onCommitted() { return () => {}; } + status() { return { state: this.state, backend: this.backend, failure: null }; } + } + + const internals = { + DataKernel: Kernel, + AppDataError, + catalog, + clone, + checksum, + randomId: (prefix) => `${prefix}-${++shared.counter}`, + nowIso: () => new Date().toISOString(), + makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId), + validateEnvelope: (_entry, value) => Boolean(value && value.schemaVersion === 2 && value.checksum === checksum(value.data)) + }; + const sandbox = { + console: { log() {}, warn() {}, error() {} }, + Date, + JSON, + Math, + Map, + Set, + Promise, + structuredClone, + setTimeout, + clearTimeout, + __AppDataV2Internals: internals, + localStorage: createMemoryStorage(), + sessionStorage: createMemoryStorage() + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + const context = vm.createContext(sandbox); + vm.runInContext(recordSource, context, { filename: RECORD_SOURCE_MODULE }); + vm.runInContext(appDataSource, context, { filename: APP_DATA_SOURCE }); + return sandbox.AppData; +} + +/** + * 在 VM 里加载真实的 js/main.js 并暴露真实的 updatePracticeView。 + * 只 stub 渲染出口(PracticeHistoryRenderer)和最小 DOM,过滤逻辑本身是生产代码。 + * + * 同时加载 js/data/practiceRecordSource.js —— 这是"什么算真实练习记录"的唯一权威判定, + * 线上由 browse.bundle.js 与 main.js 同批提供(core-foundation 也内联同一份供投影器使用)。 + * 不加载它,main.js 会走"分类器缺失"的保底分支并放行全部记录,演示记录过滤将测不到。 + */ +function loadRealPracticeView() { + const renderedBatches = []; + const summaries = []; + const historyContainer = { + id: 'history-list', + innerHTML: '', + addEventListener() {}, + contains() { return false; } + }; + const quietConsole = { log() {}, warn() {}, error() {}, info() {}, debug() {} }; + const sandbox = { + console: quietConsole, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + Date, + Math, + JSON, + // updatePracticeView 用到的少量顶层协作函数(真实实现在其他 bundle 成员里)。 + getBulkDeleteModeState: () => false, + getSelectedRecordsState: () => new Set(), + document: { + addEventListener() {}, + getElementById(id) { + return id === 'history-list' ? historyContainer : null; + }, + querySelector() { return null; }, + querySelectorAll() { return []; }, + createElement() { + return { + style: {}, + classList: { add() {}, remove() {} }, + appendChild() {}, + setAttribute() {} + }; + } + } + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + sandbox.window.location = { origin: 'http://localhost' }; + sandbox.window.addEventListener = () => {}; + sandbox.window.PracticeHistoryRenderer = { + renderView(options) { + // Array.from:records 来自 VM realm,跨 realm 数组的原型不同, + // 直接 slice() 会让 deepStrictEqual 因原型不一致而误报。 + renderedBatches.push(Array.from(options && Array.isArray(options.records) ? options.records : [])); + return { scroller: null }; + } + }; + sandbox.window.PracticeDashboardView = null; + // 汇总卡片(已练题数等)也读同一份过滤结果,一起观测。 + sandbox.window.PracticeStats = { + calculateSummary(records) { + summaries.push(Array.from(records)); + return { totalPracticed: records.length, averageScore: 0, totalStudyMinutes: 0, streak: 0 }; + }, + sortByDateDesc(records) { + return records.slice().sort((left, right) => new Date(right.date) - new Date(left.date)); + } + }; + + const context = vm.createContext(sandbox); + vm.runInContext(readSource(RECORD_SOURCE_MODULE), context, { filename: RECORD_SOURCE_MODULE }); + vm.runInContext(readSource(MAIN_SOURCE), context, { filename: MAIN_SOURCE }); + assert.strictEqual(typeof sandbox.updatePracticeView, 'function', + 'js/main.js 必须暴露顶层 updatePracticeView,否则本测试的观测点已失效'); + assert.strictEqual(typeof sandbox.PracticeRecordSource?.isRealPracticeRecord, 'function', + 'PracticeRecordSource 必须已安装,否则 main.js 会走保底分支放行全部记录,过滤断言形同虚设'); + + return { + updatePracticeView: sandbox.updatePracticeView, + recordSource: sandbox.PracticeRecordSource, + renderedBatches, + summaries, + lastRenderedIds() { + const last = renderedBatches.at(-1) || []; + return Array.from(last, (record) => String(record && record.id || '')).sort(); + }, + lastSummaryIds() { + const last = summaries.at(-1) || []; + return Array.from(last, (record) => String(record && record.id || '')).sort(); + } + }; +} + +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createManualClock() { + let now = 0; + let nextId = 1; + const timers = new Map(); + return { + setTimeout(callback, delay = 0) { + const id = nextId++; + timers.set(id, { callback, dueAt: now + Number(delay || 0) }); + return id; + }, + clearTimeout(id) { timers.delete(id); }, + advance(milliseconds) { + const target = now + milliseconds; + while (true) { + const due = Array.from(timers.entries()) + .filter(([, timer]) => timer.dueAt <= target) + .sort((left, right) => left[1].dueAt - right[1].dueAt || left[0] - right[0])[0]; + if (!due) break; + const [id, timer] = due; + timers.delete(id); + now = timer.dueAt; + timer.callback(); + } + now = target; + }, + pendingDelay(delay) { + return Array.from(timers.values()).filter((timer) => timer.dueAt - now === delay).length; + } + }; +} + +function createOnboardingSandbox({ resolveIndex, completeAttempt, clock = null }) { + const previewIds = new Set(); + const deletedIds = []; + let refreshCount = 0; + let queryCount = 0; + const elementsById = new Map(); + const makeClassList = () => { + const values = new Set(); + return { + add(...names) { names.forEach((name) => values.add(name)); }, + remove(...names) { names.forEach((name) => values.delete(name)); }, + contains(name) { return values.has(name); } + }; + }; + const makeElement = (tagName = 'div') => { + const element = { + tagName: String(tagName).toUpperCase(), + style: {}, + dataset: {}, + classList: makeClassList(), + children: [], + innerHTML: '', + offsetWidth: 320, + offsetHeight: 160, + appendChild(child) { + this.children.push(child); + child.parentNode = this; + if (child.id) elementsById.set(child.id, child); + return child; + }, + remove() { + if (this.parentNode) { + const index = this.parentNode.children.indexOf(this); + if (index >= 0) this.parentNode.children.splice(index, 1); + this.parentNode = null; + } + if (this.id) elementsById.delete(this.id); + }, + addEventListener() {}, + removeEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + getBoundingClientRect() { + return { top: 100, left: 100, right: 200, bottom: 140, width: 100, height: 40 }; + }, + scrollIntoView() {} + }; + return element; + }; + const body = makeElement('body'); + const documentElement = makeElement('html'); + documentElement.scrollTop = 0; + const document = { + body, + documentElement, + createElement: makeElement, + getElementById(id) { return elementsById.get(id) || null; }, + querySelector() { queryCount += 1; return null; }, + querySelectorAll() { return []; }, + addEventListener() {}, + removeEventListener() {} + }; + const sandbox = { + console: { log() {}, warn() {}, error() {} }, + document, + setTimeout: clock ? clock.setTimeout : setTimeout, + clearTimeout: clock ? clock.clearTimeout : clearTimeout, + requestAnimationFrame(callback) { callback(); }, + CustomEvent: class CustomEvent { constructor(type, options) { this.type = type; this.detail = options?.detail; } }, + innerWidth: 1280, + innerHeight: 800, + scrollY: 0, + scrollTo() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() {}, + resolveActiveLibraryIndex: resolveIndex, + syncPracticeRecords: async () => { refreshCount += 1; }, + PracticeRecordSource: { + allowPreviewRecordId(id) { previewIds.add(String(id)); }, + clearPreviewRecordId(id) { previewIds.delete(String(id)); } + }, + AppData: { + ready: Promise.resolve(), + preferences: { + async getOnboarding() { return {}; }, + async setOnboarding() {} + }, + practice: { + completeAttempt, + async delete({ recordId }) { deletedIds.push(String(recordId)); } + } + } + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + const context = vm.createContext(sandbox); + vm.runInContext(readSource(ONBOARDING_SOURCE), context, { filename: ONBOARDING_SOURCE }); + sandbox.OnboardingTour.registerSteps([{ + id: 'review', + activateView: null, + subSteps: [{ id: 'inject', action: 'injectDemoRecord', target: null }] + }]); + return { + api: sandbox.OnboardingTour, + previewIds, + deletedIds, + body, + getRefreshCount: () => refreshCount, + getQueryCount: () => queryCount + }; +} + +async function flushAsyncWork(turns = 4) { + for (let index = 0; index < turns; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +async function flushPromiseWork(turns = 16) { + for (let index = 0; index < turns; index += 1) await Promise.resolve(); +} + +/** 提取 appData.js 里 lightFromCanonical 的函数体,用于静态字段分析。 */ +function extractLightProjectionBody(source) { + const match = source.match(/function lightFromCanonical\(source\)\s*\{[\s\S]*?\n \}/); + assert(match, 'appData.js 中必须存在 lightFromCanonical,静态守卫依赖它定位投影字段'); + return match[0]; +} + +/** 找出 light 投影里"取不到值就落到某个哨兵"的字段。 */ +function classifyProjectionFallbacks(body) { + const nullFallback = []; + const undefinedFallback = []; + for (const line of body.split('\n')) { + const field = line.match(/^\s*([A-Za-z0-9_]+):\s*(.*)$/); + if (!field) continue; + const [, name, expression] = field; + if (/(\|\||\?\?)\s*null\b/.test(expression) || /==\s*null\s*\?\s*null\b/.test(expression)) { + nullFallback.push(name); + } + if (/(\|\||\?\?)\s*undefined\b/.test(expression)) { + undefinedFallback.push(name); + } + } + return { nullFallback, undefinedFallback }; +} + +function collectSourceFiles(directory, files = []) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + // bundles 是构建产物,源码守住即可,避免同一处问题重复报告。 + if (entry.name === 'node_modules' || entry.name === 'bundles') continue; + collectSourceFiles(fullPath, files); + continue; + } + if (entry.name.endsWith('.js')) files.push(fullPath); + } + return files; +} + +// --------------------------------------------------------------------------- +// 1. 投影层契约:dataSource 语义 +// --------------------------------------------------------------------------- + +async function testLightProjectionDataSourcePassesRenderFilter() { + const app = loadRealAppData(); + await app.ready; + + // 三种真实形态:显式 dataSource / 完全缺失 / 只在 metadata 里。 + await app.practice.completeAttempt({ + operationId: 'light-ds-explicit', + record: { + id: 'ds-explicit', sessionId: 'ds-explicit', examId: 'reading-explicit', type: 'reading', + dataSource: 'real', totalQuestions: 4, correctAnswers: 3, duration: 90, + date: '2026-07-20T10:00:00.000Z' + } + }); + await app.practice.completeAttempt({ + operationId: 'light-ds-absent', + record: { + id: 'ds-absent', sessionId: 'ds-absent', examId: 'reading-absent', type: 'reading', + totalQuestions: 4, correctAnswers: 2, duration: 80, + date: '2026-07-20T11:00:00.000Z' + } + }); + await app.practice.completeAttempt({ + operationId: 'light-ds-metadata', + record: { + id: 'ds-metadata', sessionId: 'ds-metadata', examId: 'reading-metadata', type: 'reading', + metadata: { dataSource: 'real', examTitle: 'metadata only' }, + totalQuestions: 4, correctAnswers: 1, duration: 70, + date: '2026-07-20T12:00:00.000Z' + } + }); + + const listed = await app.practice.list({ projection: 'light' }); + const byId = new Map(listed.map((record) => [String(record.id), record])); + + const explicit = byId.get('ds-explicit'); + assert(explicit, 'practice.list light 必须包含显式带 dataSource 的记录'); + assert.strictEqual(explicit.dataSource, 'real', + 'light 投影必须原样保留记录自带的 dataSource'); + assert(passesRenderFilter(explicit), + '带 dataSource: "real" 的记录必须能通过练习记录渲染过滤'); + + const metadataOnly = byId.get('ds-metadata'); + assert(metadataOnly, 'practice.list light 必须包含仅 metadata 带 dataSource 的记录'); + assert.strictEqual(metadataOnly.dataSource, 'real', + 'light 投影必须从 metadata.dataSource 取出 dataSource'); + assert(passesRenderFilter(metadataOnly), + '仅 metadata.dataSource 带值的记录必须能通过练习记录渲染过滤'); + + const absent = byId.get('ds-absent'); + assert(absent, 'practice.list light 必须包含不带 dataSource 的记录'); + // 这是本 bug 的核心断言:不硬编码期望值,只要求"能被渲染"。 + // `|| null` 回退会在这里失败(null 既不是 'real' 也不是 undefined)。 + assert(passesRenderFilter(absent), + 'dataSource 缺失时 light 投影的回退值必须能通过渲染过滤' + + `('real' 或 undefined 均可,禁止 null 等哨兵值),实际得到 ${JSON.stringify(absent.dataSource)}`); + assert.notStrictEqual(absent.dataSource, null, + 'light 投影禁止把缺失的 dataSource 写成 null:消费方按 === undefined 判缺失,null 会让记录整条消失'); + + // practice.get 与 detail 投影共用同一条回退链,必须同样安全。 + const fetchedLight = await app.practice.get('ds-absent', { projection: 'light' }); + assert(passesRenderFilter(fetchedLight), + 'practice.get light 投影的 dataSource 回退必须与 practice.list 一致且可渲染'); + const fetchedDetail = await app.practice.get('ds-absent', { projection: 'detail' }); + assert(passesRenderFilter(fetchedDetail), + 'detail 投影复用 lightFromCanonical,dataSource 回退同样不得阻断渲染'); + + // 全量投影不做回退,保持原样(缺失即缺失),确认没有反向污染权威数据。 + const canonical = await app.practice.get('ds-absent'); + assert(passesRenderFilter(canonical), + '全量 canonical 记录本来就没有 dataSource,必须仍然可渲染'); +} + +async function testFalsyDataSourcesPreserveProjectionAndJudgement() { + const app = loadRealAppData(); + await app.ready; + const classifier = loadRealPracticeView().recordSource; + const fixtures = [ + { id: 'top-false', record: { dataSource: false }, expected: false }, + { id: 'top-zero', record: { dataSource: 0 }, expected: false }, + { id: 'top-empty', record: { dataSource: '' }, expected: true }, + { id: 'top-null', record: { dataSource: null }, expected: true }, + { id: 'metadata-false', record: { metadata: { dataSource: false } }, expected: false }, + { id: 'metadata-zero', record: { metadata: { dataSource: 0 } }, expected: false }, + { id: 'metadata-empty', record: { metadata: { dataSource: '' } }, expected: true }, + { id: 'metadata-null', record: { metadata: { dataSource: null } }, expected: true }, + { id: 'top-demo', record: { dataSource: 'demo' }, expected: false }, + { id: 'top-e2e', record: { dataSource: 'e2e-seed' }, expected: false }, + { id: 'metadata-onboarding', record: { metadata: { source: 'onboarding-demo' } }, expected: false }, + { + id: 'top-null-wins', + record: { dataSource: null, metadata: { dataSource: 'demo' } }, + expected: true + } + ]; + + for (const [index, fixture] of fixtures.entries()) { + await app.practice.completeAttempt({ + operationId: `falsy-source-${fixture.id}`, + record: buildFixtureRecord(fixture, index) + }); + } + + for (const fixture of fixtures) { + const projections = await Promise.all(['full', 'light', 'detail'].map((projection) => ( + app.practice.get(fixture.id, { projection }) + ))); + const hasTopDataSource = Object.prototype.hasOwnProperty.call(fixture.record, 'dataSource'); + const metadataHasDataSource = Object.prototype.hasOwnProperty.call(fixture.record.metadata || {}, 'dataSource'); + const expectedValue = hasTopDataSource + ? fixture.record.dataSource + : (metadataHasDataSource ? fixture.record.metadata.dataSource : undefined); + + for (const [index, projection] of projections.entries()) { + const projectionName = ['full', 'light', 'detail'][index]; + assert.strictEqual(classifier.isRealPracticeRecord(projection), fixture.expected, + `${fixture.id} 的 ${projectionName} isReal 结果必须一致`); + if (hasTopDataSource || (metadataHasDataSource && projectionName !== 'full')) { + assert(Object.prototype.hasOwnProperty.call(projection, 'dataSource'), + `${fixture.id} 的 ${projectionName} 投影必须保留显式 dataSource 属性`); + assert.strictEqual(projection.dataSource, expectedValue, + `${fixture.id} 的 ${projectionName} 投影不得吞掉或改写 falsy dataSource`); + } + if (metadataHasDataSource) { + assert.strictEqual(projection.metadata.dataSource, fixture.record.metadata.dataSource, + `${fixture.id} 的 ${projectionName} metadata.dataSource 必须原样保留`); + } + } + } +} + +// --------------------------------------------------------------------------- +// 2. 跨文件端到端:存进去的记录必须显示出来 +// --------------------------------------------------------------------------- + +async function testStoredRecordsReachRenderedHistoryList() { + const app = loadRealAppData(); + await app.ready; + + // 一条真实形态的练习记录:走 examSessionMixin / practiceRecorder 的字段布局。 + const realShapedRecord = { + id: 'session-real-shape', + sessionId: 'session-real-shape', + examId: 'reading-p1-real', + title: 'Reading P1 Real Shape', + type: 'reading', + category: 'P1', + startTime: '2026-07-21T09:00:00.000Z', + endTime: '2026-07-21T09:20:00.000Z', + date: '2026-07-21T09:20:00.000Z', + duration: 1200, + totalQuestions: 13, + correctAnswers: 11, + accuracy: 11 / 13, + percentage: 84.6, + dataSource: 'real', + isRealData: true, + scoreInfo: { correct: 11, total: 13, accuracy: 11 / 13, percentage: 84.6, source: 'data_collector' }, + realData: { isRealData: true, answers: { q1: 'A' }, scoreInfo: { correct: 11, total: 13 } }, + metadata: { examTitle: 'Reading P1 Real Shape', category: 'P1' } + }; + // 同批再放一条历史形态:没人写过 dataSource(迁移记录 / 老版本落库)。 + const unlabelledRecord = { + id: 'session-unlabelled', + sessionId: 'session-unlabelled', + examId: 'reading-p2-unlabelled', + title: 'Reading P2 Unlabelled', + type: 'reading', + date: '2026-07-21T10:00:00.000Z', + duration: 900, + totalQuestions: 13, + correctAnswers: 7, + percentage: 53.8 + }; + + await app.practice.completeAttempt({ operationId: 'e2e-real-shape', record: realShapedRecord }); + await app.practice.completeAttempt({ operationId: 'e2e-unlabelled', record: unlabelledRecord }); + + const summaries = await app.practice.list({ projection: 'light' }); + assert.strictEqual(summaries.length, 2, + '两条记录都必须落进 practice.summaries light 投影'); + + // 2a. 投影结果本身必须能通过渲染过滤(与 main.js 解耦的独立断言)。 + for (const summary of summaries) { + assert(passesRenderFilter(summary), + `存入的记录 ${summary.id} 的 light 投影必须能通过渲染过滤,` + + `实际 dataSource=${JSON.stringify(summary.dataSource)}`); + } + + // 2b. 真正跑一遍生产渲染入口:AppData 存进去的东西必须出现在历史列表里。 + // 这一层不复刻任何过滤条件,是"存了就必须能看见"的本质保护。 + const view = loadRealPracticeView(); + view.updatePracticeView(summaries, [ + { id: 'reading-p1-real', title: 'Reading P1 Real Shape', type: 'reading', category: 'P1' }, + { id: 'reading-p2-unlabelled', title: 'Reading P2 Unlabelled', type: 'reading', category: 'P2' } + ]); + + assert.strictEqual(view.renderedBatches.length, 1, + 'updatePracticeView 必须把过滤后的记录交给 PracticeHistoryRenderer 渲染一次'); + assert.deepStrictEqual( + view.lastRenderedIds(), + ['session-real-shape', 'session-unlabelled'], + '经 AppData.practice.completeAttempt 存入并以 light 投影读出的记录,必须全部出现在渲染列表中' + + '(一条都不显示 = 用户线上遇到的空白练习记录页)' + ); + assert.deepStrictEqual( + view.lastSummaryIds(), + ['session-real-shape', 'session-unlabelled'], + '汇总卡片(已练题数/正确率)必须与历史列表看到同一批记录,不能被同一个过滤条件吃掉' + ); +} + +async function testRenderFilterKeepsUnlabelledRecordsAfterProjectionChange() { + // 防止过滤条件被再次收窄:直接把 light 投影可能产出的每种 dataSource 形态 + // 喂给真实 updatePracticeView,只允许"明确的非真实来源"被排除。 + const view = loadRealPracticeView(); + const app = loadRealAppData(); + await app.ready; + + await app.practice.completeAttempt({ + operationId: 'filter-shape-probe', + record: { id: 'probe', sessionId: 'probe', examId: 'reading-probe', type: 'reading', date: '2026-07-22T09:00:00.000Z' } + }); + const [projected] = await app.practice.list({ projection: 'light' }); + assert(projected, 'light 投影必须产出探针记录'); + + // 用真实投影产出的形态克隆出多条记录,逐一确认它们都能显示。 + const shapes = [ + { id: 'shape-projected', record: { ...projected, id: 'shape-projected' } }, + { id: 'shape-real', record: { ...projected, id: 'shape-real', dataSource: 'real' } }, + { id: 'shape-absent', record: (() => { const next = { ...projected, id: 'shape-absent' }; delete next.dataSource; return next; })() } + ]; + view.updatePracticeView(shapes.map((shape) => shape.record), []); + assert.deepStrictEqual( + view.lastRenderedIds(), + ['shape-absent', 'shape-projected', 'shape-real'], + 'light 投影实际产出的 dataSource 形态(含缺失)必须全部通过 updatePracticeView 的渲染过滤' + ); +} + +// --------------------------------------------------------------------------- +// 2b. 演示/种子记录:UI、stats、achievements 三处判定必须一致 +// +// 修复的 bug:判定被复制成两套语义不同的实现 —— +// - js/main.js 只看顶层 dataSource(排除 'demo' / 'e2e-seed'); +// - appData.js 投影器只看 metadata.source === 'onboarding-demo'。 +// 于是 demo / e2e-seed 记录"在练习记录页看不见,却计入成绩统计和成就解锁", +// 用户会看到自己没做过的题影响了正确率与成就。 +// +// 本节的核心断言不是"某个具体值被排除",而是**三处结论逐条一致**: +// 只要 UI 与两个投影器对同一条记录给出不同结论,测试就失败。 +// --------------------------------------------------------------------------- + +/** 演示/种子记录的各种真实形态(含两个维度的组合)。 */ +const DEMO_RECORD_FIXTURES = Object.freeze([ + { + id: 'demo-datasource', + why: '顶层 dataSource: "demo"', + record: { dataSource: 'demo' } + }, + { + id: 'demo-e2e-seed', + why: '顶层 dataSource: "e2e-seed"(测试/夹具种子数据)', + record: { dataSource: 'e2e-seed' } + }, + { + id: 'demo-onboarding-metadata', + why: 'metadata.source: "onboarding-demo"(js/components/onboardingTour.js 注入的引导演示记录)', + record: { metadata: { source: 'onboarding-demo', examTitle: '示例练习' } } + }, + { + id: 'demo-metadata-datasource', + why: 'metadata.dataSource: "demo"(light 投影会把它提到顶层)', + record: { metadata: { dataSource: 'demo' } } + }, + { + id: 'demo-both-dimensions', + why: '两个维度同时标注为演示数据', + record: { dataSource: 'demo', metadata: { source: 'onboarding-demo' } } + } +]); + +/** 必须被当作真实练习的形态 —— 尤其包含"dataSource 缺失"(曾被收窄导致整页空白)。 */ +const REAL_RECORD_FIXTURES = Object.freeze([ + { + id: 'real-explicit', + why: '显式 dataSource: "real"', + record: { dataSource: 'real' } + }, + { + id: 'real-absent', + why: 'dataSource 完全缺失(迁移记录 / 套题聚合 / 听力桥接从不写该字段)', + record: {} + }, + { + id: 'real-metadata-only', + why: '仅 metadata.dataSource: "real"', + record: { metadata: { dataSource: 'real' } } + }, + { + id: 'real-suite-source-label', + why: 'metadata.source: "listening" —— 该字段被复用为内容类型标签,不得当成演示标记', + record: { metadata: { source: 'listening' } } + }, + { + id: 'real-collector-source-label', + why: 'metadata.source: "practice_page" —— 采集方式标签,不得当成演示标记', + record: { metadata: { source: 'practice_page' } } + }, + { + id: 'real-empty-datasource', + why: 'dataSource 为空串(历史脏数据),按缺失处理', + record: { dataSource: '' } + } +]); + +function buildFixtureRecord(fixture, index) { + const base = { + id: fixture.id, + sessionId: fixture.id, + examId: `reading-${fixture.id}`, + title: fixture.id, + type: 'reading', + // 每条给足 1 题 1 对:这样"是否计入"在 stats 上体现为可观测的数值差异。 + totalQuestions: 1, + correctAnswers: 1, + accuracy: 1, + duration: 60, + // 时间各不相同,保证成就解锁时间戳有确定顺序。 + date: `2026-07-2${index % 9}T09:00:00.000Z`, + completedAt: `2026-07-2${index % 9}T09:00:00.000Z` + }; + const merged = Object.assign(base, fixture.record); + if (fixture.record.metadata) { + merged.metadata = Object.assign({}, fixture.record.metadata); + } + return merged; +} + +async function testDemoRecordsAreExcludedFromViewStatsAndAchievements() { + const app = loadRealAppData(); + await app.ready; + const view = loadRealPracticeView(); + + const fixtures = [...REAL_RECORD_FIXTURES, ...DEMO_RECORD_FIXTURES]; + for (const [index, fixture] of fixtures.entries()) { + await app.practice.completeAttempt({ + operationId: `demo-contract-${fixture.id}`, + record: buildFixtureRecord(fixture, index) + }); + } + + const realIds = REAL_RECORD_FIXTURES.map((fixture) => fixture.id).sort(); + const summaries = await app.practice.list({ projection: 'light' }); + assert.strictEqual(summaries.length, fixtures.length, + '演示记录同样是权威数据,必须完整落库;排除只发生在展示与派生统计层'); + + // --- 1) UI:练习记录列表与汇总卡片都不能出现演示记录 --- + view.updatePracticeView(summaries, []); + assert.deepStrictEqual(view.lastRenderedIds(), realIds, + '练习记录列表必须只渲染真实记录:演示/种子记录要被排除,而所有真实形态' + + '(含 dataSource 缺失)必须保留'); + assert.deepStrictEqual(view.lastSummaryIds(), realIds, + '汇总卡片必须与历史列表看到同一批记录'); + + // --- 2) stats 投影器:演示记录不得计入成绩统计 --- + const stats = await app.practice.getStats(); + assert.strictEqual(stats.totalPractices, REAL_RECORD_FIXTURES.length, + `practice.stats 只能统计真实记录:期望 ${REAL_RECORD_FIXTURES.length} 条,` + + `实际 ${stats.totalPractices} 条(多出来的就是被计入的演示/种子记录 —— ` + + '用户会看到"我没做这些题,为什么成绩变了")'); + assert.strictEqual(stats.totalQuestions, REAL_RECORD_FIXTURES.length, + 'practice.stats 的题目数同样不得包含演示记录的题目'); + assert.strictEqual(stats.correctAnswers, REAL_RECORD_FIXTURES.length, + 'practice.stats 的正确数同样不得包含演示记录的作答'); + + // --- 3) achievements 投影器:演示记录不得推进成就解锁 --- + // 6 条真实记录只够解锁 first_step;若把 5 条演示记录也算进去就是 11 条, + // 会额外解锁 practice_bronze(累计 10 次练习)。用它当"是否混入"的探针。 + const achievements = await app.achievements.getAll(); + assert(achievements.first_step && achievements.first_step.unlockedAt, + '真实记录必须能正常解锁成就(first_step)'); + assert.strictEqual(Object.prototype.hasOwnProperty.call(achievements, 'practice_bronze'), false, + 'achievements.progress 不得把演示/种子记录计入练习次数:' + + `只有 ${REAL_RECORD_FIXTURES.length} 条真实记录时 practice_bronze(10 次)必须仍未解锁`); +} + +async function testDemoJudgementIsIdenticalAcrossViewStatsAndAchievements() { + // 本次修复的核心契约:三处判定必须逐条一致。 + // 做法是对每条 fixture 单独观测三个消费方的结论,再互相比对 —— 不比对某个硬编码期望值, + // 这样无论未来判定规则怎么变,只要三处出现分歧就立刻失败(正是当前 bug 的形态)。 + const view = loadRealPracticeView(); + const fixtures = [...REAL_RECORD_FIXTURES, ...DEMO_RECORD_FIXTURES]; + const disagreements = []; + + for (const [index, fixture] of fixtures.entries()) { + // 每条 fixture 用独立的 AppData 实例,避免相互影响统计阈值。 + const app = loadRealAppData(); + await app.ready; + await app.practice.completeAttempt({ + operationId: `judgement-${fixture.id}`, + record: buildFixtureRecord(fixture, index) + }); + + const summaries = await app.practice.list({ projection: 'light' }); + assert.strictEqual(summaries.length, 1, `${fixture.id} 必须落库为唯一一条权威记录`); + + view.updatePracticeView(summaries, []); + const inView = view.lastRenderedIds().includes(fixture.id); + + const stats = await app.practice.getStats(); + const inStats = stats.totalPractices === 1; + + const achievements = await app.achievements.getAll(); + const inAchievements = Boolean(achievements.first_step && achievements.first_step.unlockedAt); + + if (!(inView === inStats && inStats === inAchievements)) { + disagreements.push( + `${fixture.id}(${fixture.why}): 列表=${inView} stats=${inStats} achievements=${inAchievements}` + ); + } + } + + assert.deepStrictEqual(disagreements, [], + '“什么算真实练习记录”必须只有一份判定:练习记录列表、practice.stats、' + + 'achievements.progress 对同一条记录的结论必须完全相同。\n' + + '出现分歧意味着判定又被复制成了多套实现(这正是"记录看不见却计入统计"的成因):\n' + + disagreements.join('\n')); +} + +async function testDemoJudgementHasSingleImplementation() { + // 静态守卫:防止任何一方悄悄写回本地副本。 + // 判定实现必须只存在于 js/data/practiceRecordSource.js。 + const classifierSource = readSource(RECORD_SOURCE_MODULE); + assert(classifierSource.includes('function isRealPracticeRecord'), + `${RECORD_SOURCE_MODULE} 必须是判定的唯一实现处`); + + for (const [relativePath, mustReference] of [[MAIN_SOURCE, 'PracticeRecordSource'], [APP_DATA_SOURCE, 'PracticeRecordSource']]) { + assert(readSource(relativePath).includes(mustReference), + `${relativePath} 必须通过 ${mustReference} 复用统一判定,不得自建副本`); + } + + // 旧的本地实现名不得复活(appData.js 的 isDemoRecord 只看 metadata.source)。 + assert(!readSource(APP_DATA_SOURCE).includes('function isDemoRecord'), + 'appData.js 不得恢复本地 isDemoRecord:它只看 metadata.source,与 UI 侧语义不一致'); + + // main.js 不得再内联 dataSource 的逐值比较(这是旧 UI 判定的形态)。 + const mainSource = readSource(MAIN_SOURCE); + const inlinedDataSourceComparison = mainSource + .split('\n') + .map((line, index) => ({ line: line.trim(), lineNumber: index + 1 })) + .filter(({ line }) => !line.startsWith('//') && !line.startsWith('*')) + .filter(({ line }) => /dataSource\s*===\s*['"]real['"]/.test(line)); + assert.deepStrictEqual(inlinedDataSourceComparison, [], + 'js/main.js 不得内联 `dataSource === "real"` 判定:必须走 PracticeRecordSource,' + + '否则会与 stats/achievements 投影器再次分叉:\n' + + inlinedDataSourceComparison.map((item) => ` ${MAIN_SOURCE}:${item.lineNumber} ${item.line}`).join('\n')); +} + +async function testOnboardingPreviewIsViewOnly() { + // 引导演示记录是唯一需要"看得见但不计入"的例外: + // 它必须能在列表里渲染(引导要教用户认识那一行),但绝不能进 stats/achievements。 + // 例外只存在于视图层白名单,投影器读不到 —— 判定本身仍然只有一份。 + const app = loadRealAppData(); + await app.ready; + const view = loadRealPracticeView(); + const DEMO_ID = 'demo-onboarding-record'; + + await app.practice.completeAttempt({ + operationId: 'onboarding-preview-real', + record: buildFixtureRecord({ id: 'real-alongside-demo', record: {} }, 1) + }); + await app.practice.completeAttempt({ + operationId: 'onboarding-preview-demo', + record: buildFixtureRecord({ + id: DEMO_ID, + record: { metadata: { source: 'onboarding-demo', examTitle: '示例练习' } } + }, 2) + }); + + const summaries = await app.practice.list({ projection: 'light' }); + + // 未登记预览:演示记录必须不可见。 + view.updatePracticeView(summaries, []); + assert.deepStrictEqual(view.lastRenderedIds(), ['real-alongside-demo'], + '未登记预览许可时,引导演示记录必须与其他演示记录一样被排除'); + + // 登记预览后:仅该 id 可见,其它演示记录仍被排除。 + view.recordSource.allowPreviewRecordId(DEMO_ID); + assert.strictEqual(view.recordSource.isPreviewRecord({ + id: DEMO_ID, + dataSource: 'demo', + metadata: { source: 'demo' } + }), false, '仅 id 匹配但没有 onboarding marker 的演示记录不得借用引导预览许可'); + assert.strictEqual(view.recordSource.isPreviewRecord({ + id: 'different-record', + sessionId: DEMO_ID, + metadata: { source: 'onboarding-demo' } + }), false, '仅 sessionId 命中不得越权放行其它非真实记录'); + assert.strictEqual(view.recordSource.isPreviewRecord({ + id: DEMO_ID, + metadata: { source: 'onboarding-demo' } + }), true, 'preview 必须只放行 id 与 onboarding marker 同时匹配的记录'); + view.updatePracticeView(summaries, []); + assert.deepStrictEqual(view.lastRenderedIds(), [DEMO_ID, 'real-alongside-demo'].sort(), + '登记预览许可后,引导演示记录必须能在练习记录列表中渲染(引导步骤依赖这一行)'); + + // 关键:预览许可绝不能泄漏到 stats / achievements。 + const stats = await app.practice.getStats(); + assert.strictEqual(stats.totalPractices, 1, + '引导预览许可只影响渲染:practice.stats 必须仍然只统计那 1 条真实记录'); + assert.strictEqual(view.recordSource.isRealPracticeRecord( + summaries.find((record) => String(record.id) === DEMO_ID) + ), false, '预览许可不得改变"是否真实记录"的判定本身,否则投影器也会被污染'); + + // 撤销许可后立即恢复排除(引导结束/跳过时调用)。 + view.recordSource.clearPreviewRecordId(DEMO_ID); + view.updatePracticeView(summaries, []); + assert.deepStrictEqual(view.lastRenderedIds(), ['real-alongside-demo'], + '撤销预览许可后,引导演示记录必须立刻从练习记录列表消失'); +} + +async function testOnboardingStopCancelsPendingInjectionLifecycle() { + const indexDeferred = createDeferred(); + let completeCalls = 0; + const harness = createOnboardingSandbox({ + resolveIndex: () => indexDeferred.promise, + completeAttempt: async () => { completeCalls += 1; } + }); + + harness.api.start(true); + harness.api.stop(); + indexDeferred.resolve([]); + await flushAsyncWork(); + + assert.strictEqual(completeCalls, 0, + 'stop 后尚未开始的异步注入不得继续落库'); + assert.strictEqual(harness.previewIds.size, 0, + 'stop 必须立即撤销 onboarding preview 许可'); + assert(harness.deletedIds.includes('demo-onboarding-record'), + 'stop 必须走演示记录清理路径'); +} + +async function testOnboardingStopCompensatesInFlightWriteWithoutInjectionRefresh() { + const writeDeferred = createDeferred(); + let completeCalls = 0; + const harness = createOnboardingSandbox({ + resolveIndex: async () => [], + completeAttempt: async () => { + completeCalls += 1; + return writeDeferred.promise; + } + }); + + harness.api.start(true); + await flushAsyncWork(); + assert.strictEqual(completeCalls, 1, '测试前置条件:注入写入必须已经在途'); + + harness.api.stop(); + await flushAsyncWork(); + const refreshCountAfterStopCleanup = harness.getRefreshCount(); + writeDeferred.resolve(); + await flushAsyncWork(8); + + assert.strictEqual(harness.previewIds.size, 0, + '在途写入完成后也不得恢复 preview 许可'); + assert(harness.deletedIds.filter((id) => id === 'demo-onboarding-record').length >= 2, + 'stop 清理早于在途写入完成时,写入链必须再做一次删除补偿'); + assert.strictEqual(harness.getRefreshCount(), refreshCountAfterStopCleanup, + 'tour 停止后,旧注入链不得继续刷新练习历史'); +} + +async function testOnboardingRapidRestartIsolatesRendererAndCancelsOldPolling() { + const clock = createManualClock(); + const restartedIndex = createDeferred(); + let indexCalls = 0; + const harness = createOnboardingSandbox({ + clock, + resolveIndex: () => { + indexCalls += 1; + return indexCalls === 1 ? Promise.resolve([]) : restartedIndex.promise; + }, + completeAttempt: async () => {} + }); + + harness.api.start(true); + await flushPromiseWork(); + assert.strictEqual(clock.pendingDelay(120), 1, + '测试前置条件:旧 lifecycle 必须已经进入 selector 轮询'); + + const oldOverlay = harness.body.children.find((element) => element.className === 'onboarding-overlay'); + const oldTooltip = harness.body.children.find((element) => element.className === 'onboarding-tooltip'); + harness.api.stop(); + await flushPromiseWork(); + harness.api.start(true); + await flushPromiseWork(); + + const newOverlay = harness.body.children.find((element) => + element.className === 'onboarding-overlay' && element !== oldOverlay); + const newTooltip = harness.body.children.find((element) => + element.className === 'onboarding-tooltip' && element !== oldTooltip); + assert(oldOverlay && oldTooltip && newOverlay && newTooltip, + 'rapid restart 必须同时保留可区分的新旧 renderer 节点直到退出动画结束'); + assert.strictEqual(clock.pendingDelay(120), 0, + 'stop 必须立即取消旧 lifecycle 的 selector 定时器'); + + const queriesAfterRestart = harness.getQueryCount(); + const refreshesAfterRestart = harness.getRefreshCount(); + clock.advance(300); + await flushPromiseWork(); + + assert(!harness.body.children.includes(oldOverlay) && !harness.body.children.includes(oldTooltip), + '退出动画结束后必须删除旧 renderer 节点'); + assert(harness.body.children.includes(newOverlay) && harness.body.children.includes(newTooltip), + '旧 destroy timer 绝不能删除 restart 创建的新 renderer 节点'); + + clock.advance(5000); + await flushPromiseWork(); + assert.strictEqual(harness.getQueryCount(), queriesAfterRestart, + '旧 selector 轮询在 rapid restart 后不得继续查询 DOM'); + assert.strictEqual(harness.getRefreshCount(), refreshesAfterRestart, + '旧注入链在 rapid restart 后不得继续刷新练习历史'); +} + +// --------------------------------------------------------------------------- +// 3. 同类隐患:其他 `|| null` 回退字段 +// --------------------------------------------------------------------------- + +async function testLightProjectionNullFallbacksHaveNoStrictUndefinedConsumers() { + const body = extractLightProjectionBody(readSource(APP_DATA_SOURCE)); + const { nullFallback, undefinedFallback } = classifyProjectionFallbacks(body); + + assert(nullFallback.length > 0, + '静态守卫必须至少识别出一个 null 回退字段,否则解析规则已与 appData.js 脱节'); + assert( + !nullFallback.includes('dataSource'), + 'dataSource 不得回退为 null:消费方按 `=== undefined` 判缺失,null 会让记录整条不显示' + ); + + const files = collectSourceFiles(path.join(repoRoot, 'js')); + const mismatches = []; + for (const file of files) { + const relative = path.relative(repoRoot, file).split(path.sep).join('/'); + const lines = readSource(relative).split('\n'); + lines.forEach((line, index) => { + // null 回退字段 + 只认 undefined 的消费方 = 本 bug 的同构形态。 + for (const field of nullFallback) { + if (new RegExp(`\\.${field}\\s*(===|!==)\\s*undefined`).test(line)) { + mismatches.push(`${relative}:${index + 1} [${field} 回退 null,但此处只判 undefined] ${line.trim()}`); + } + } + // 反向形态:undefined 回退字段遇到只认 null 的消费方。 + for (const field of undefinedFallback) { + if (new RegExp(`\\.${field}\\s*(===|!==)\\s*null`).test(line)) { + mismatches.push(`${relative}:${index + 1} [${field} 回退 undefined,但此处只判 null] ${line.trim()}`); + } + } + }); + } + + assert.deepStrictEqual(mismatches, [], + 'light 投影的缺省哨兵与下游严格判等不一致,会重演"记录存进去但不显示":\n' + + mismatches.join('\n')); +} + +// --------------------------------------------------------------------------- +// 4. bundle 同步:线上跑的是 bundle,不是源码 +// --------------------------------------------------------------------------- + +async function testBundledCopiesMatchSourceContract() { + const sourceProjection = extractLightProjectionBody(readSource(APP_DATA_SOURCE)); + const bundleDirectory = path.join(repoRoot, 'js', 'bundles'); + const bundles = fs.readdirSync(bundleDirectory).filter((name) => name.endsWith('.bundle.js')); + + const projectionDrift = []; + for (const name of bundles) { + const source = fs.readFileSync(path.join(bundleDirectory, name), 'utf8'); + if (!source.includes('function lightFromCanonical')) continue; + const bundled = extractLightProjectionBody(source); + // bundle 内缩进不同,比较去掉行首空白后的语义文本。 + const strip = (text) => text.split('\n').map((line) => line.trim()).join('\n'); + if (strip(bundled) !== strip(sourceProjection)) { + projectionDrift.push(`js/bundles/${name} 的 lightFromCanonical 与 ${APP_DATA_SOURCE} 不一致`); + } + } + assert(bundles.some((name) => fs.readFileSync(path.join(bundleDirectory, name), 'utf8').includes('function lightFromCanonical')), + '至少一个 bundle 必须内联 lightFromCanonical,否则本检查形同虚设'); + assert.deepStrictEqual(projectionDrift, [], + 'bundle 未重建:应用运行的是 bundle,源码修好但 bundle 仍是旧投影会继续丢记录:\n' + + projectionDrift.join('\n')); + + // updatePracticeView 的过滤块同样必须与 main.js 同步。 + const mainFilter = readSource(MAIN_SOURCE) + .match(/function updatePracticeView\([\s\S]*?\n const stats = window\.PracticeStats;/); + assert(mainFilter, 'js/main.js 中必须能定位 updatePracticeView 的过滤段落'); + const normalizedMainFilter = mainFilter[0].split('\n').map((line) => line.trim()).join('\n'); + const filterDrift = []; + for (const name of bundles) { + const source = fs.readFileSync(path.join(bundleDirectory, name), 'utf8'); + if (!source.includes('function updatePracticeView')) continue; + const bundled = source.match(/function updatePracticeView\([\s\S]*?\n const stats = window\.PracticeStats;/); + if (!bundled) { + filterDrift.push(`js/bundles/${name} 的 updatePracticeView 结构与 ${MAIN_SOURCE} 不同,无法比对`); + continue; + } + if (bundled[0].split('\n').map((line) => line.trim()).join('\n') !== normalizedMainFilter) { + filterDrift.push(`js/bundles/${name} 的 updatePracticeView 渲染过滤与 ${MAIN_SOURCE} 不一致`); + } + } + assert.deepStrictEqual(filterDrift, [], + 'bundle 未重建:练习记录渲染过滤与源码不一致:\n' + filterDrift.join('\n')); +} + +const tests = [ + ['light 投影的 dataSource 必须能通过渲染过滤', testLightProjectionDataSourcePassesRenderFilter], + ['falsy dataSource 在 full/light/detail 中必须保值且 isReal 一致', testFalsyDataSourcesPreserveProjectionAndJudgement], + ['存入的练习记录必须出现在渲染后的历史列表', testStoredRecordsReachRenderedHistoryList], + ['light 投影产出的所有 dataSource 形态都不被渲染过滤吃掉', testRenderFilterKeepsUnlabelledRecordsAfterProjectionChange], + ['演示/种子记录必须同时不显示、不计入统计、不计入成就', testDemoRecordsAreExcludedFromViewStatsAndAchievements], + ['列表/统计/成就对每条记录的来源判定必须完全一致', testDemoJudgementIsIdenticalAcrossViewStatsAndAchievements], + ['来源判定只允许有一份实现', testDemoJudgementHasSingleImplementation], + ['引导演示记录的预览例外只影响渲染,不影响统计与成就', testOnboardingPreviewIsViewOnly], + ['stop 必须取消尚未落库的 onboarding 异步注入', testOnboardingStopCancelsPendingInjectionLifecycle], + ['stop 必须补偿在途写入且旧注入链不得继续刷新', testOnboardingStopCompensatesInFlightWriteWithoutInjectionRefresh], + ['rapid stop/start 必须隔离 renderer 并取消旧 selector 轮询', testOnboardingRapidRestartIsolatesRendererAndCancelsOldPolling], + ['light 投影的 null 回退字段不得遇到只认 undefined 的消费方', testLightProjectionNullFallbacksHaveNoStrictUndefinedConsumers], + ['bundle 内联的投影与渲染过滤必须与源码同步', testBundledCopiesMatchSourceContract] +]; + +const results = []; +for (const [name, test] of tests) { + try { + await test(); + results.push({ name, status: 'pass' }); + } catch (error) { + results.push({ name, status: 'fail', error: error.stack || error.message }); + console.log(JSON.stringify({ status: 'fail', detail: `${name} 失败`, results }, null, 2)); + process.exit(1); + } +} + +console.log(JSON.stringify({ + status: 'pass', + detail: `${results.length}/${results.length} tests passed`, + results +}, null, 2)); diff --git a/developer/tests/js/practicePageEnhancerReplay.test.js b/developer/tests/js/practicePageEnhancerReplay.test.js index 352a7071..269d2c94 100644 --- a/developer/tests/js/practicePageEnhancerReplay.test.js +++ b/developer/tests/js/practicePageEnhancerReplay.test.js @@ -196,6 +196,39 @@ async function testPracticeEnhancerSubmissionPayloadCarriesCorrectAnswerMap() { }); } +async function testPracticeEnhancerCompletionAddsSubmissionContract() { + const enhancer = loadEnhancer(); + const messages = []; + enhancer.parentWindow = { + postMessage(message, targetOrigin) { + messages.push({ message, targetOrigin }); + } + }; + enhancer.parentOrigin = 'https://host.example'; + enhancer.sessionId = 'enhancer-session'; + enhancer.examId = 'enhancer-exam'; + enhancer.windowSessionToken = 'enhancer-token'; + + const payload = { answers: { q1: 'A' } }; + assert.strictEqual(enhancer.sendMessage('PRACTICE_COMPLETE', payload), true); + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].targetOrigin, 'https://host.example'); + assert.strictEqual(messages[0].message.data.sessionId, 'enhancer-session'); + assert.strictEqual(messages[0].message.data.windowSessionToken, 'enhancer-token'); + assert.match(messages[0].message.data.submissionId, /^practice-submit-/); + assert.strictEqual(payload.submissionId, messages[0].message.data.submissionId); + + enhancer.sendMessage('PRACTICE_COMPLETE', payload); + assert.strictEqual( + messages[1].message.data.submissionId, + messages[0].message.data.submissionId, + 'retrying the same payload must reuse its submissionId' + ); + recordResult('practice enhancer completion adds submission correlation', true, { + submissionId: payload.submissionId + }); +} + async function testUnifiedReadingReplayRefusesComparisonCorrectAnswerFallback() { const windowStub = { __IELTS_READING_PAGE_TEST_HOOKS__: true, @@ -205,7 +238,6 @@ async function testUnifiedReadingReplayRefusesComparisonCorrectAnswerFallback() CSS: { escape(value) { return String(value); } }, addEventListener() {}, removeEventListener() {}, - localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} }, AnswerMatchCore: { compareAnswers(userAnswer, correctAnswer) { return String(userAnswer == null ? '' : userAnswer).trim().toLowerCase() @@ -266,7 +298,6 @@ async function testUnifiedReadingReplayCanonicalMapWins() { CSS: { escape(value) { return String(value); } }, addEventListener() {}, removeEventListener() {}, - localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} }, AnswerMatchCore: { compareAnswers(userAnswer, correctAnswer) { return String(userAnswer == null ? '' : userAnswer).trim().toLowerCase() @@ -332,6 +363,7 @@ async function runAllTests() { testPracticeEnhancerReplayCanonicalMapWins, testPracticeEnhancerReplayIgnoresNumericCorrectAnswersAsMap, testPracticeEnhancerSubmissionPayloadCarriesCorrectAnswerMap, + testPracticeEnhancerCompletionAddsSubmissionContract, testUnifiedReadingReplayCanonicalMapWins, testUnifiedReadingReplayRefusesComparisonCorrectAnswerFallback ]; diff --git a/developer/tests/js/practiceRecordPersistence.test.js b/developer/tests/js/practiceRecordPersistence.test.js index 455c20da..dd00b6a6 100644 --- a/developer/tests/js/practiceRecordPersistence.test.js +++ b/developer/tests/js/practiceRecordPersistence.test.js @@ -15,26 +15,11 @@ function loadScript(relativePath, context) { vm.runInContext(source, context, { filename: relativePath }); } -function createWebStorage() { - const map = new Map(); - return { - getItem(key) { - return map.has(key) ? map.get(key) : null; - }, - setItem(key, value) { - map.set(key, String(value)); - }, - removeItem(key) { - map.delete(key); - }, - dump() { - return new Map(map); - } - }; -} - function createHarness(options = {}) { - const { syncUiOnReplace = false, saveCompletionImpl = null, forceCompletionNotice = false } = options; + const { + saveCompletionImpl = null, + forceCompletionNotice = false + } = options; const practiceState = [ { id: 'record-1', @@ -51,11 +36,18 @@ function createHarness(options = {}) { duration: 90 } ]; - const uiState = practiceState.map((record) => ({ ...record })); - const localStorage = createWebStorage(); - const sessionStorage = createWebStorage(); const listeners = new Map(); const savedSpellingErrors = []; + const renderedSnapshots = []; + const browseSnapshots = []; + const examIndex = [{ + id: 'listening-p1-fallback', + title: 'Listening P1 Fallback', + category: 'P1', + path: 'assets/generated/listening-exams/listening-p1-fallback.html', + frequency: 'fallback', + type: 'listening' + }]; const quietConsole = { log() {}, warn() {}, @@ -64,33 +56,14 @@ function createHarness(options = {}) { debug() {} }; - const storageStub = { - mode: 'indexeddb', - getKey(key) { - return `exam_system_${key}`; - }, - async get() { - return practiceState.map((record) => ({ ...record })); - }, - async set(key, value) { - if (key === 'practice_records') { - practiceState.splice(0, practiceState.length, ...(Array.isArray(value) ? value.map((record) => ({ ...record })) : [])); - } - return true; - }, - async writePersistentValue(key, value) { - return this.set(key, value); - } - }; - const messageLog = []; - let renderCount = 0; + const deleteCommands = []; + const deleteManyCommands = []; + const clearCommands = []; + const completionCommands = []; const sandbox = { console: quietConsole, - localStorage, - sessionStorage, - storage: storageStub, confirm: () => true, showMessage: (message, type) => { messageLog.push({ message, type }); @@ -105,35 +78,23 @@ function createHarness(options = {}) { processedSessions: { clear() {} }, - getPracticeRecordsState() { - return uiState.map((record) => ({ ...record })); - }, - setPracticeRecordsState(records) { - uiState.splice(0, uiState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : [])); - return uiState.map((record) => ({ ...record })); - }, getSelectedRecordsState() { return new Set(); }, clearSelectedRecordsState() {}, setBulkDeleteModeState() {}, refreshBulkDeleteButton() {}, - refreshBrowseProgressFromRecords() {}, - updatePracticeView() {}, + refreshBrowseProgressFromRecords(records, index) { + browseSnapshots.push({ records: structuredClone(records), index: structuredClone(index) }); + }, + updatePracticeView(records, index) { + renderedSnapshots.push({ records: structuredClone(records), index: structuredClone(index) }); + }, normalizeRecordId(id) { return id == null ? '' : String(id); }, - getExamIndexState() { - return [ - { - id: 'listening-p1-fallback', - title: 'Listening P1 Fallback', - category: 'P1', - path: 'assets/generated/listening-exams/listening-p1-fallback.html', - frequency: 'fallback', - type: 'listening' - } - ]; + async resolveActiveLibraryIndex() { + return structuredClone(examIndex); }, document: { addEventListener() {}, @@ -149,9 +110,9 @@ function createHarness(options = {}) { }, window: { console: quietConsole, - storage: storageStub, - location: { - origin: 'http://localhost' + location: options.location || { + origin: 'http://localhost', + protocol: 'http:' }, addEventListener(type, handler) { if (!listeners.has(type)) { @@ -175,67 +136,97 @@ function createHarness(options = {}) { return true; } }, - app: { - state: { - practice: { - records: uiState.map((record) => ({ ...record })) - } - } - }, - PracticeRecordAPI: { - async list() { - return practiceState.map((record) => ({ ...record })); - }, - async getById(recordId) { - const targetId = recordId == null ? '' : String(recordId); - return practiceState.find((record) => ( - String(record.id) === targetId || String(record.sessionId || '') === targetId - )) || null; - }, - async saveCompletion(realData = {}, context = {}, exam = {}) { - if (typeof saveCompletionImpl === 'function') { - return await saveCompletionImpl(realData, context, exam); + AppData: { + ready: Promise.resolve(), + practice: { + async list() { + return structuredClone(practiceState); + }, + async listInsights() { + return structuredClone(practiceState); + }, + async get(recordId) { + const target = String(recordId || ''); + const record = practiceState.find((item) => item && ( + String(item.id || '') === target || String(item.sessionId || '') === target + )); + return structuredClone(record || null); + }, + async delete(command = {}) { + deleteCommands.push(structuredClone(command)); + const index = practiceState.findIndex((record) => String(record.id) === String(command.recordId)); + if (index >= 0) practiceState.splice(index, 1); + return { + committed: true, + revision: deleteCommands.length, + operationId: command.operationId || `delete-${deleteCommands.length}`, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + }, + async deleteMany(command = {}) { + deleteManyCommands.push(structuredClone(command)); + const ids = new Set((command.recordIds || []).map(String)); + for (let index = practiceState.length - 1; index >= 0; index -= 1) { + if (ids.has(String(practiceState[index].id))) practiceState.splice(index, 1); + } + return { + committed: true, + revision: deleteManyCommands.length, + operationId: command.operationId || `delete-many-${deleteManyCommands.length}`, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + }, + async clear(command = {}) { + clearCommands.push(structuredClone(command)); + practiceState.splice(0, practiceState.length); + return { + committed: true, + revision: clearCommands.length, + operationId: command.operationId || `clear-${clearCommands.length}`, + derived: { status: 'ready', pending: [] }, + warnings: [] + }; + }, + async completeAttempt(command = {}) { + completionCommands.push(structuredClone(command)); + if (typeof saveCompletionImpl === 'function') { + return await saveCompletionImpl(command); + } + const input = command.record && typeof command.record === 'object' + ? structuredClone(command.record) + : {}; + const record = { + ...input, + id: input.id || `record-${practiceState.length + 1}`, + examId: input.examId || '', + sessionId: input.sessionId || '', + title: input.title || '', + endTime: input.endTime || input.date || '2026-03-09T12:00:00.000Z', + date: input.endTime || input.date || '2026-03-09T12:00:00.000Z' + }; + practiceState.unshift(structuredClone(record)); + return { + committed: true, + revision: completionCommands.length, + operationId: command.operationId || `completion-${completionCommands.length}`, + derived: { status: 'ready', pending: [] }, + warnings: [], + record: structuredClone(record) + }; } - const record = { - id: `record-${practiceState.length + 1}`, - examId: context && context.examId ? context.examId : (exam.id || realData.examId || ''), - sessionId: realData.sessionId || '', - title: exam.title || realData.title || '', - date: realData.endTime || '2026-03-09T12:00:00.000Z', - percentage: Number(realData?.scoreInfo?.percentage) || 0, - duration: Number(realData?.duration ?? realData?.scoreInfo?.duration) || 0 - }; - practiceState.unshift({ ...record }); - return { ...record }; }, - async replace(records) { - practiceState.splice(0, practiceState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : [])); - if (syncUiOnReplace) { - uiState.splice(0, uiState.length, ...(Array.isArray(records) ? records.map((record) => ({ ...record })) : [])); + settings: { + async reset() { + return { + committed: true, + revision: 1, + operationId: 'settings-reset', + derived: { status: 'ready', pending: [] }, + warnings: [] + }; } - return practiceState.map((record) => ({ ...record })); - }, - async deleteById(recordId) { - const targetId = recordId == null ? '' : String(recordId); - const next = []; - let deleted = null; - practiceState.forEach((record) => { - if (!deleted && (String(record.id) === targetId || String(record.sessionId || '') === targetId)) { - deleted = { ...record }; - return; - } - next.push(record); - }); - await this.replace(next); - return { - deleted: Boolean(deleted), - record: deleted, - records: practiceState.map((record) => ({ ...record })) - }; - }, - async clear() { - await this.replace([]); - return true; } } } @@ -243,37 +234,39 @@ function createHarness(options = {}) { sandbox.globalThis = sandbox.window; sandbox.fallbackExamSessions = new Map(); - sandbox.window.localStorage = localStorage; - sandbox.window.sessionStorage = sessionStorage; sandbox.window.fallbackExamSessions = sandbox.fallbackExamSessions; + sandbox.window.resolveActiveLibraryIndex = sandbox.resolveActiveLibraryIndex; loadScript('js/main.js', vm.createContext(sandbox)); if (forceCompletionNotice) { sandbox.shouldAnnounceCompletion = () => true; } - sandbox.updatePracticeView = function updatePracticeViewSpy() { - renderCount += 1; + sandbox.updatePracticeView = function updatePracticeViewSpy(records, index) { + renderedSnapshots.push({ records: structuredClone(records), index: structuredClone(index) }); + }; + sandbox.refreshBrowseProgressFromRecords = function refreshBrowseProgressFromRecordsSpy(records, index) { + browseSnapshots.push({ records: structuredClone(records), index: structuredClone(index) }); }; sandbox.window.updatePracticeView = sandbox.updatePracticeView; + sandbox.window.refreshBrowseProgressFromRecords = sandbox.refreshBrowseProgressFromRecords; return { sandbox, practiceState, - localStorage, - sessionStorage, + examIndex, + renderedSnapshots, + browseSnapshots, + deleteCommands, + deleteManyCommands, + clearCommands, + completionCommands, messageLog, - savedSpellingErrors, - getRenderCount() { - return renderCount; - } + savedSpellingErrors }; } -async function testDeleteRecordPersistsAndCleansLegacyKeys() { +async function testDeleteRecordCommitsAndRefreshesCanonicalSnapshot() { const harness = createHarness(); - harness.localStorage.setItem('practice_records', JSON.stringify([{ id: 'legacy-record' }])); - harness.localStorage.setItem('old_prefix_practice_records', JSON.stringify([{ id: 'legacy-old' }])); - harness.localStorage.setItem('exam_system_practice_records', 'stale-shadow'); await harness.sandbox.deleteRecord('record-1'); @@ -282,51 +275,55 @@ async function testDeleteRecordPersistsAndCleansLegacyKeys() { ['record-2'], 'deleteRecord 应删除 canonical store 中的目标记录' ); - assert.strictEqual( - harness.localStorage.getItem('practice_records'), - null, - 'deleteRecord 后应清理 legacy practice_records,避免删除记录被影子键回灌' + assert.deepStrictEqual( + harness.deleteCommands[0], + { recordId: 'record-1' }, + 'deleteRecord 应通过 AppData.practice.delete 提交目标 identity' + ); + assert.deepStrictEqual( + harness.renderedSnapshots.at(-1).records.map((record) => record.id), + ['record-2'], + 'deleteRecord 后应以 AppData 回读结果刷新练习视图' ); assert.deepStrictEqual( - harness.sandbox.window.app.state.practice.records.map((record) => record.id), + harness.browseSnapshots.at(-1).records.map((record) => record.id), ['record-2'], - 'deleteRecord 后应同步 app.state.practice.records,避免页面销毁时把旧记录写回去' + 'deleteRecord 后应以同一 AppData 快照重建浏览完成索引' ); - assert.strictEqual(harness.localStorage.getItem('old_prefix_practice_records'), null, 'deleteRecord 后应清理 old_prefix 影子键'); - assert.strictEqual(harness.localStorage.getItem('exam_system_practice_records'), null, 'deleteRecord 后应清理 indexeddb shadow key'); + assert.deepStrictEqual(harness.renderedSnapshots.at(-1).index, harness.examIndex, '练习视图应接收活动题库快照'); + assert.deepStrictEqual(harness.browseSnapshots.at(-1).index, harness.examIndex, '浏览索引应接收同一活动题库快照'); } -async function testClearPracticeDataPersistsAndClearsLegacyKeys() { +async function testClearPracticeDataCommitsAndRefreshesCanonicalSnapshot() { const harness = createHarness(); - harness.localStorage.setItem('practice_records', JSON.stringify([{ id: 'legacy-record' }])); - harness.sessionStorage.setItem('practice_records', JSON.stringify([{ id: 'legacy-record' }])); await harness.sandbox.clearPracticeData(); assert.strictEqual(harness.practiceState.length, 0, 'clearPracticeData 应清空 canonical store'); - assert.strictEqual(harness.sandbox.window.app.state.practice.records.length, 0, 'clearPracticeData 后应同步清空 app.state.practice.records'); - assert.strictEqual(harness.localStorage.getItem('practice_records'), null, 'clearPracticeData 后应删除 localStorage legacy 键'); - assert.strictEqual(harness.sessionStorage.getItem('practice_records'), null, 'clearPracticeData 后应删除 sessionStorage legacy 键'); -} - -async function testDeleteRecordForcesUiRefreshWhenPracticeCoreAlreadySyncedState() { - const harness = createHarness({ syncUiOnReplace: true }); - - await harness.sandbox.deleteRecord('record-1'); - - assert.ok( - harness.getRenderCount() > 0, - 'deleteRecord 后即使全局状态已被 PracticeCore 提前同步,仍应强制刷新 UI,避免残留旧卡片' - ); + assert.strictEqual(harness.clearCommands.length, 1, 'clearPracticeData 应通过 AppData.practice.clear 写入 tombstone'); + assert.deepStrictEqual(harness.renderedSnapshots.at(-1).records, [], 'clearPracticeData 后练习视图应接收空快照'); + assert.deepStrictEqual(harness.browseSnapshots.at(-1).records, [], 'clearPracticeData 后浏览完成索引应接收空快照'); } async function testFallbackCompletionPersistsNormalizedSpellingErrors() { - const harness = createHarness({ forceCompletionNotice: true }); - const childWindow = { closed: false, postMessage() {} }; + const harness = createHarness({ + forceCompletionNotice: true, + location: { origin: 'file://', protocol: 'file:' } + }); + const outcomes = []; + const targetOrigins = []; + const childWindow = { + closed: false, + postMessage(message, targetOrigin) { + outcomes.push(message); + targetOrigins.push(targetOrigin); + } + }; harness.sandbox.window.fallbackExamSessions.set('parent-session-1', { examId: 'listening-p1-fallback', sessionId: 'parent-session-1', win: childWindow, + windowSessionToken: 'fallback-token-1', initPayload: { examId: 'listening-p1-fallback', sessionId: 'parent-session-1' @@ -336,13 +333,33 @@ async function testFallbackCompletionPersistsNormalizedSpellingErrors() { harness.sandbox.setupMessageListener(); await harness.sandbox.window.__dispatchWindowEvent('message', { - origin: 'http://localhost', + origin: 'null', + source: childWindow, + data: { + type: 'REQUEST_INIT', + source: 'listening_record_bridge', + data: { + examId: 'listening-p1-fallback', + sessionId: 'child-temp-session' + } + } + }); + const initMessage = outcomes.find((message) => message && message.type === 'INIT_SESSION'); + assert(initMessage, 'file fallback REQUEST_INIT must receive INIT_SESSION'); + assert.strictEqual(initMessage.data.parentOrigin, 'null', 'file fallback must declare opaque null origin'); + assert(targetOrigins.every((origin) => origin === '*'), 'file fallback INIT must use wildcard targetOrigin'); + + await harness.sandbox.window.__dispatchWindowEvent('message', { + origin: 'null', source: childWindow, data: { type: 'PRACTICE_COMPLETE', + source: 'listening_record_bridge', + windowSessionToken: 'fallback-token-1', realData: { examId: 'listening-unknown', sessionId: 'child-temp-session', + submissionId: 'listening-submit-fallback-1', type: 'listening', practiceType: 'listening', answers: { q1: 'acommodatio' }, @@ -380,6 +397,12 @@ async function testFallbackCompletionPersistsNormalizedSpellingErrors() { const savedRecord = harness.practiceState.find((record) => record && record.examId === 'listening-p1-fallback'); assert(savedRecord, 'fallback PRACTICE_COMPLETE 应保存父页题源练习记录'); assert.strictEqual(savedRecord.sessionId, 'parent-session-1', 'fallback 记录 sessionId 应归一到父页会话'); + const acknowledgement = outcomes.find((message) => message && message.type === 'PRACTICE_SUBMIT_ACK'); + assert(acknowledgement, 'fallback 持久化成功后必须回传 PRACTICE_SUBMIT_ACK'); + assert.strictEqual(acknowledgement.data.submissionId, 'listening-submit-fallback-1'); + assert.strictEqual(acknowledgement.data.sessionId, 'parent-session-1'); + assert.strictEqual(acknowledgement.data.windowSessionToken, 'fallback-token-1'); + assert(targetOrigins.every((origin) => origin === '*'), 'file fallback ACK must use wildcard targetOrigin'); assert.strictEqual(harness.savedSpellingErrors.length, 1, 'fallback 应保存 bridge 带回的 spellingErrors'); assert.deepStrictEqual( @@ -435,13 +458,36 @@ async function testCanonicalCompletionSaveRejectsWhenPersistenceFails() { ); } +async function testSubmissionIdProducesStableCompletionOperationId() { + const harness = createHarness(); + const payload = { + examId: 'listening-p1-fallback', + sessionId: 'session-stable-submit', + submissionId: 'submission-stable-submit', + type: 'listening', + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; + await harness.sandbox.savePracticeCompletionRecord(payload.examId, payload); + await harness.sandbox.savePracticeCompletionRecord(payload.examId, payload); + assert.strictEqual(harness.completionCommands.length, 2); + assert.strictEqual( + harness.completionCommands[0].operationId, + 'practice-complete:listening-p1-fallback:session-stable-submit:submission-stable-submit' + ); + assert.strictEqual( + harness.completionCommands[1].operationId, + harness.completionCommands[0].operationId, + 'retrying the same submission must reuse the canonical idempotency operationId' + ); +} + async function main() { try { - await testDeleteRecordPersistsAndCleansLegacyKeys(); - await testClearPracticeDataPersistsAndClearsLegacyKeys(); - await testDeleteRecordForcesUiRefreshWhenPracticeCoreAlreadySyncedState(); + await testDeleteRecordCommitsAndRefreshesCanonicalSnapshot(); + await testClearPracticeDataCommitsAndRefreshesCanonicalSnapshot(); await testFallbackCompletionPersistsNormalizedSpellingErrors(); await testCanonicalCompletionSaveRejectsWhenPersistenceFails(); + await testSubmissionIdProducesStableCompletionOperationId(); console.log(JSON.stringify({ status: 'pass', detail: 'practice record persistence and fallback completion regressions are covered' diff --git a/developer/tests/js/practiceRecordStress.test.js b/developer/tests/js/practiceRecordStress.test.js new file mode 100644 index 00000000..a9c776aa --- /dev/null +++ b/developer/tests/js/practiceRecordStress.test.js @@ -0,0 +1,345 @@ +#!/usr/bin/env node +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { performance } from 'perf_hooks'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const ciProfile = process.argv.includes('--ci'); +const profile = ciProfile + ? { concurrentRecords: 36, heavyRecords: 32, highlights: 60, notes: 30, outlines: 8 } + : { concurrentRecords: 72, heavyRecords: 64, highlights: 180, notes: 80, outlines: 16 }; + +const clone = (value) => value === undefined ? undefined : structuredClone(value); +const source = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); +const stable = (value) => { + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +}; +const checksum = (value) => { + let hash = 0x811c9dc5; + for (const char of stable(value)) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16)}`; +}; + +function createHarness(seed = null) { + const catalogSandbox = { structuredClone }; + catalogSandbox.globalThis = catalogSandbox; + vm.runInContext(source('js/data/v2/dataCatalog.js'), vm.createContext(catalogSandbox), { filename: 'dataCatalog.js' }); + const catalog = catalogSandbox.__AppDataV2Catalog; + const shared = { + docs: new Map(), + entities: new Map([ + ['practiceSummaries', new Map()], + ['practiceDetails', new Map()], + ['practiceAnnotations', new Map()] + ]), + snapshotReads: 0, + mutations: 0, + counter: 0 + }; + if (seed && seed.entities) { + for (const [store, rows] of Object.entries(seed.entities)) { + if (!shared.entities.has(store)) shared.entities.set(store, new Map()); + for (const row of rows || []) shared.entities.get(store).set(String(row.recordId), clone(row)); + } + } + + const envelope = (key, data, state = 'present', revision = 1, operationId = 'seed') => ({ + schemaVersion: catalog.version, + revision, + operationId, + updatedAt: new Date().toISOString(), + state, + data: state === 'cleared' ? null : clone(data), + checksum: checksum(state === 'cleared' ? null : data) + }); + const defaultValue = (key) => { + const entry = catalog.get(key); + return entry && typeof entry.defaultValue === 'function' ? entry.defaultValue() : null; + }; + class AppDataError extends Error { + constructor(code, message) { + super(message); + this.code = code; + this.committed = false; + } + } + class Kernel { + async initialize() { this.state = 'ready'; this.backend = 'memory'; return this; } + async read(key, options = {}) { + const row = shared.docs.get(key) || null; + const data = row && row.state !== 'cleared' ? row.data : defaultValue(key); + return options.withMeta ? { data: clone(data), envelope: clone(row) } : clone(data); + } + async mutate(changes, options = {}) { + const operationId = String(options.operationId || `document-${++shared.counter}`); + for (const change of changes) { + const current = shared.docs.get(change.logicalKey) || null; + const currentRevision = Number(current && current.revision || 0); + if (change.expectedRevision !== undefined && Number(change.expectedRevision) !== currentRevision) { + throw new AppDataError('CONFLICT', 'document revision conflict'); + } + shared.docs.set(change.logicalKey, envelope( + change.logicalKey, + change.data, + change.state, + currentRevision + 1, + operationId + )); + } + return { committed: true, operationId, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; + } + async journalNoop(options = {}) { + return { committed: true, operationId: options.operationId || `noop-${++shared.counter}`, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; + } + async readEntity(store, recordId, options = {}) { + const row = shared.entities.get(store).get(String(recordId)) || null; + return options.withMeta ? clone(row) : row && clone(row.data); + } + async listEntities(store, options = {}) { + if (store !== 'practiceSummaries') throw new AppDataError('VALIDATION', 'only practice summaries are listable'); + const rows = Array.from(shared.entities.get(store).values()); + return options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); + } + async readPracticeSnapshot(recordIds = null, options = {}) { + shared.snapshotReads += 1; + const requested = recordIds === null || recordIds === undefined + ? null + : new Set((Array.isArray(recordIds) ? recordIds : [recordIds]).map(String)); + const stores = Array.isArray(options.stores) && options.stores.length + ? options.stores + : ['practiceSummaries', 'practiceDetails', 'practiceAnnotations']; + const result = {}; + for (const store of stores) { + const rows = Array.from(shared.entities.get(store).values()) + .filter((row) => !requested || requested.has(String(row.recordId))); + result[store] = options.withMeta ? clone(rows) : rows.map((row) => clone(row.data)); + } + return result; + } + async mutateEntities(operations, options = {}) { + const operationId = String(options.operationId || `entity-${++shared.counter}`); + const next = new Map(Array.from(shared.entities, ([store, rows]) => [store, new Map(rows)])); + for (const item of operations) { + const rows = next.get(item.store); + if (item.type === 'clear') { + rows.clear(); + continue; + } + const id = String(item.recordId); + const current = rows.get(id) || null; + const currentRevision = Number(current && current.revision || 0); + if (item.expectedRevision !== undefined && item.expectedRevision !== null + && Number(item.expectedRevision) !== currentRevision) { + throw new AppDataError('CONFLICT', `entity revision conflict: ${item.store}/${id}`); + } + if (item.type === 'delete') { + rows.delete(id); + continue; + } + rows.set(id, { + recordId: id, + revision: currentRevision + 1, + operationId, + updatedAt: new Date().toISOString(), + data: clone(item.data), + checksum: checksum(item.data) + }); + } + shared.entities = next; + shared.mutations += 1; + return { committed: true, operationId, revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; + } + async exportSnapshot() { + return { + format: 'ielts-atlas-data-v2', + schemaVersion: catalog.version, + scope: 'full', + envelopes: {}, + entities: Object.fromEntries(Array.from(shared.entities, ([store, rows]) => [store, Array.from(rows.values()).map(clone)])) + }; + } + async installSnapshot() { return { committed: true, operationId: 'install', revisions: {}, derived: { status: 'ready', pending: [] }, warnings: [] }; } + onCommitted() { return () => {}; } + status() { return { state: this.state, backend: this.backend, failure: null }; } + } + + const internals = { + DataKernel: Kernel, + AppDataError, + catalog, + clone, + checksum, + randomId: (prefix) => `${prefix}-${++shared.counter}`, + nowIso: () => new Date().toISOString(), + makeEnvelope: (entry, data, options = {}) => envelope(entry.logicalKey, data, options.state, options.revision, options.operationId) + }; + const sandbox = { + console: { log() {}, warn() {}, error() {}, info() {}, debug() {} }, + Date, JSON, Math, Map, Set, Promise, structuredClone, Reflect, + Object, Array, Number, String, Boolean, RegExp, Error, TypeError, + __AppDataV2Internals: internals + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + const context = vm.createContext(sandbox); + vm.runInContext(source('js/data/practiceRecordSource.js'), context, { filename: 'practiceRecordSource.js' }); + vm.runInContext(source('js/data/v2/appData.js'), context, { filename: 'appData.js' }); + return { + app: sandbox.AppData, + shared, + snapshot() { + return { + entities: Object.fromEntries(Array.from(shared.entities, ([store, rows]) => [store, Array.from(rows.values()).map(clone)])) + }; + } + }; +} + +function makeRecord(index, options = {}) { + const prefix = options.prefix || 'stress'; + const highlights = Array.from({ length: options.highlightCount || 0 }, (_, highlightIndex) => ({ + id: `${prefix}-highlight-${index}-${highlightIndex}`, + noteId: options.noteCount ? `${prefix}-note-${index}-${highlightIndex % options.noteCount}` : '', + text: `highlight ${index}/${highlightIndex} ${'h'.repeat(48)}`, + start: highlightIndex * 5, + end: highlightIndex * 5 + 12 + })); + const notes = Array.from({ length: options.noteCount || 0 }, (_, noteIndex) => ({ + id: `${prefix}-note-${index}-${noteIndex}`, + body: `note ${index}/${noteIndex} ${'n'.repeat(64)}`, + quote: `quote ${index}/${noteIndex}` + })); + const noteOutlines = Array.from({ length: options.outlineCount || 0 }, (_, outlineIndex) => ({ + id: `${prefix}-outline-${index}-${outlineIndex}`, + title: `Outline ${index}/${outlineIndex}`, + order: outlineIndex + })); + const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, index % 60)).toISOString(); + return { + id: `${prefix}-record-${index}`, + sessionId: `${prefix}-session-${index}`, + examId: `${prefix}-exam-${index}`, + title: `Stress record ${index}`, + type: 'reading', + date: timestamp, + startTime: timestamp, + endTime: timestamp, + duration: 1800 + index, + totalQuestions: 40, + correctAnswers: 36, + accuracy: 0.9, + answers: { q1: 'A', q2: 'B' }, + correctAnswerMap: { q1: 'A', q2: 'C' }, + highlights, + notes, + noteOutlines, + metadata: { examTitle: `Stress record ${index}`, category: 'P3', frequency: 'high', type: 'reading' } + }; +} + +function verifyRecord(record, expected) { + assert(record, 'record must exist after v2 persistence round-trip'); + assert.strictEqual(record.highlights.length, expected.highlights, `${record.id}: highlights changed`); + assert.strictEqual(record.notes.length, expected.notes, `${record.id}: notes changed`); + assert.strictEqual(record.noteOutlines.length, expected.outlines, `${record.id}: outlines changed`); + if (expected.highlights && expected.notes) { + assert.strictEqual(record.highlights[0].noteId, record.notes[0].id, `${record.id}: annotation link changed`); + } +} + +async function main() { + const metrics = {}; + const harness = createHarness(); + await harness.app.ready; + const concurrent = Array.from({ length: profile.concurrentRecords }, (_, index) => makeRecord(index, { + prefix: 'concurrent', highlightCount: 12, noteCount: 6, outlineCount: 3 + })); + let started = performance.now(); + await Promise.all(concurrent.map((record) => harness.app.practice.completeAttempt({ + operationId: `stress-concurrent-${record.id}`, + record + }))); + metrics.concurrentSaveMs = Math.round((performance.now() - started) * 100) / 100; + const concurrentFull = await harness.app.practice.list({ projection: 'full' }); + assert.strictEqual(concurrentFull.length, concurrent.length, 'concurrent v2 saves lost records'); + concurrentFull.forEach((record) => verifyRecord(record, { highlights: 12, notes: 6, outlines: 3 })); + + const readsBeforeSnapshotCheck = harness.shared.snapshotReads; + const snapshotChecked = await harness.app.practice.get('concurrent-record-0', { projection: 'full' }); + assert.strictEqual(harness.shared.snapshotReads, readsBeforeSnapshotCheck + 1, 'full get must use one projection snapshot'); + verifyRecord(snapshotChecked, { highlights: 12, notes: 6, outlines: 3 }); + const light = await harness.app.practice.list({ projection: 'light' }); + assert(light.every((record) => !Object.prototype.hasOwnProperty.call(record, 'highlights')), 'light projection leaked annotations'); + + const heavy = Array.from({ length: profile.heavyRecords }, (_, index) => makeRecord(index, { + prefix: 'heavy', highlightCount: profile.highlights, noteCount: profile.notes, outlineCount: profile.outlines + })); + started = performance.now(); + await Promise.all(heavy.map((record) => harness.app.practice.completeAttempt({ + operationId: `stress-heavy-${record.id}`, + record + }))); + metrics.heavySaveMs = Math.round((performance.now() - started) * 100) / 100; + started = performance.now(); + const heavyFull = await harness.app.practice.list({ projection: 'full' }); + metrics.fullListMs = Math.round((performance.now() - started) * 100) / 100; + assert.strictEqual(heavyFull.length, concurrent.length + heavy.length, 'full v2 list changed record count'); + heavyFull.filter((record) => record.id.startsWith('heavy-')).forEach((record) => verifyRecord(record, { + highlights: profile.highlights, notes: profile.notes, outlines: profile.outlines + })); + + await harness.app.practice.updateAnnotations({ + recordId: 'heavy-record-0', + examId: 'heavy-exam-0', + patch: { reviewed: true }, + operationId: 'stress-annotation-update' + }); + const updated = await harness.app.practice.get('heavy-record-0', { projection: 'full' }); + assert.strictEqual(updated.reviewed, true, 'annotation update was not visible in full projection'); + + await harness.app.practice.delete({ recordId: 'heavy-record-1', operationId: 'stress-delete' }); + const afterDelete = await harness.app.practice.list({ projection: 'full' }); + assert(afterDelete.every(Boolean), 'full list must not contain null rows after deletion'); + assert(!afterDelete.some((record) => record.id === 'heavy-record-1'), 'deleted record survived full list'); + + const reloaded = createHarness(harness.snapshot()); + await reloaded.app.ready; + const reloadedRecords = await reloaded.app.practice.list({ projection: 'full' }); + assert.strictEqual(reloadedRecords.length, afterDelete.length, 'reload changed v2 record count'); + metrics.serializedBytes = JSON.stringify(harness.snapshot()).length; + + const generousLimitMs = ciProfile ? 10000 : 30000; + for (const [name, value] of Object.entries(metrics)) { + if (name.endsWith('Ms')) assert(value < generousLimitMs, `${name} exceeded ${generousLimitMs}ms`); + } + process.stdout.write(JSON.stringify({ + status: 'pass', + detail: { + profile: ciProfile ? 'ci' : 'full', + concurrentRecords: concurrent.length, + heavyRecords: heavy.length, + highlightsPerHeavyRecord: profile.highlights, + notesPerHeavyRecord: profile.notes, + outlinesPerHeavyRecord: profile.outlines, + snapshotReads: harness.shared.snapshotReads, + ...metrics + } + })); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/developer/tests/js/practiceRecordTest.js b/developer/tests/js/practiceRecordTest.js deleted file mode 100644 index bb98b429..00000000 --- a/developer/tests/js/practiceRecordTest.js +++ /dev/null @@ -1,667 +0,0 @@ -/** - * Practice记录增删测试 - * 验证练习记录管理功能的完整性 - */ - -class PracticeRecordTest { - constructor() { - this.testResults = []; - this.testRecords = []; - this.originalRecords = []; - this.simpleStorage = window.simpleStorageWrapper; - this.practiceRecordAPI = window.PracticeRecordAPI; - } - - getPracticeRecordAPI(requiredMethods = []) { - const api = this.practiceRecordAPI || window.PracticeRecordAPI; - if (!api) { - throw new Error('PracticeRecordAPI不可用'); - } - requiredMethods.forEach(method => { - if (typeof api[method] !== 'function') { - throw new Error(`PracticeRecordAPI.${method}不可用`); - } - }); - return api; - } - - async listPracticeRecords() { - const api = this.getPracticeRecordAPI(['list']); - return await api.list(); - } - - async getPracticeRecordById(id) { - const api = this.getPracticeRecordAPI(['getById']); - return await api.getById(id); - } - - async savePracticeRecord(record) { - const api = this.getPracticeRecordAPI(['saveRecord']); - await api.saveRecord(record, { updateStats: true }); - return true; - } - - async replacePracticeRecords(records) { - const api = this.getPracticeRecordAPI(['replace']); - await api.replace(Array.isArray(records) ? records : [], { updateStats: true }); - return true; - } - - async deletePracticeRecord(id) { - const api = this.getPracticeRecordAPI(['deleteById']); - const result = await api.deleteById(id, { updateStats: true }); - return Boolean(result && result.deleted); - } - - async deletePracticeRecords(ids) { - const api = this.getPracticeRecordAPI(['deleteMany']); - const result = await api.deleteMany(ids, { updateStats: true }); - return Number(result && result.deletedCount) || 0; - } - - // 运行所有Practice记录测试 - async runAllTests() { - console.log('📝 开始Practice记录增删测试...'); - - this.testResults = []; - - // 1. 备份原始数据 - await this.backupOriginalData(); - - // 2. 测试存储连接 - this.testStorageConnection(); - - // 3. 测试创建记录 - await this.testCreateRecord(); - - // 4. 测试获取记录 - await this.testGetRecords(); - - // 5. 测试更新记录 - await this.testUpdateRecord(); - - // 6. 测试删除记录 - await this.testDeleteRecord(); - - // 7. 测试批量操作 - await this.testBatchOperations(); - - // 8. 测试数据验证 - await this.testDataValidation(); - - // 9. 测试边界情况 - await this.testEdgeCases(); - - // 10. 恢复原始数据 - await this.restoreOriginalData(); - - this.printResults(); - return this.testResults; - } - - // 备份原始数据 - async backupOriginalData() { - const testName = '备份原始数据'; - - try { - if (this.practiceRecordAPI) { - this.originalRecords = await this.listPracticeRecords(); - console.log(`[PracticeRecordTest] 备份了 ${this.originalRecords.length} 条原始记录`); - this.recordTest(testName, true, { - backupCount: this.originalRecords.length, - timestamp: new Date().toISOString() - }); - } else { - this.recordTest(testName, false, { error: 'PracticeRecordAPI不可用' }); - } - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试存储连接 - testStorageConnection() { - const testName = '存储连接测试'; - - try { - const checks = [ - { name: 'simpleStorageWrapper只读兼容存在', exists: !!this.simpleStorage }, - { name: 'getPracticeRecords只读方法存在', exists: typeof this.simpleStorage?.getPracticeRecords === 'function' }, - { name: 'PracticeRecordAPI存在', exists: !!this.practiceRecordAPI }, - { name: 'PracticeRecordAPI.saveRecord方法存在', exists: typeof this.practiceRecordAPI?.saveRecord === 'function' }, - { name: 'PracticeRecordAPI.deleteById方法存在', exists: typeof this.practiceRecordAPI?.deleteById === 'function' } - ]; - - const allConnected = checks.every(check => check.exists); - - this.recordTest(testName, allConnected, { - checks, - totalChecks: checks.length, - passedChecks: checks.filter(c => c.exists).length - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试创建记录 - async testCreateRecord() { - const testName = '创建记录测试'; - - try { - const newRecords = [ - { - id: 'test_record_1', - title: '雅思阅读测试1', - type: 'reading', - score: 85, - totalQuestions: 40, - correctAnswers: 34, - date: '2024-01-01', - timeSpent: 1800, // 30分钟 - difficulty: 'medium' - }, - { - id: 'test_record_2', - title: '雅思听力测试1', - type: 'listening', - score: 92, - totalQuestions: 40, - correctAnswers: 37, - date: '2024-01-02', - timeSpent: 1500, // 25分钟 - difficulty: 'hard' - }, - { - id: 'test_record_3', - title: '雅思阅读测试2', - type: 'reading', - score: 78, - totalQuestions: 40, - correctAnswers: 31, - date: '2024-01-03', - timeSpent: 2000, // 33分钟 - difficulty: 'easy' - } - ]; - - const createResults = []; - - for (const record of newRecords) { - try { - const success = await this.savePracticeRecord(record); - createResults.push({ - recordId: record.id, - success, - record: record - }); - - if (success) { - this.testRecords.push(record); - } - } catch (error) { - createResults.push({ - recordId: record.id, - success: false, - error: error.message - }); - } - } - - const allCreated = createResults.every(r => r.success); - - this.recordTest(testName, allCreated, { - createResults, - totalRecords: newRecords.length, - successfulCreates: createResults.filter(r => r.success).length - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试获取记录 - async testGetRecords() { - const testName = '获取记录测试'; - - try { - // 获取所有记录 - const allRecords = await this.listPracticeRecords(); - - // 验证测试记录是否被正确保存 - const foundTestRecords = this.testRecords.filter(testRecord => - allRecords.some(savedRecord => savedRecord.id === testRecord.id) - ); - - // 测试单个记录获取 - const singleRecordResults = []; - for (const testRecord of this.testRecords) { - try { - const foundRecord = await this.getPracticeRecordById(testRecord.id); - singleRecordResults.push({ - recordId: testRecord.id, - success: foundRecord !== null && foundRecord.id === testRecord.id - }); - } catch (error) { - singleRecordResults.push({ - recordId: testRecord.id, - success: false, - error: error.message - }); - } - } - - const allFound = foundTestRecords.length === this.testRecords.length; - const allSinglesFound = singleRecordResults.every(r => r.success); - - this.recordTest(testName, allFound && allSinglesFound, { - totalRecordsInStorage: allRecords.length, - testRecordsCreated: this.testRecords.length, - foundTestRecords: foundTestRecords.length, - singleRecordResults, - getAllSuccess: allFound, - getByIdSuccess: allSinglesFound - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试更新记录 - async testUpdateRecord() { - const testName = '更新记录测试'; - - try { - if (this.testRecords.length === 0) { - this.recordTest(testName, false, { error: '没有测试记录可供更新' }); - return; - } - - const updateRecord = this.testRecords[0]; - const originalScore = updateRecord.score; - - // 更新数据 - const updates = { - score: 90, - timeSpent: 1600, - notes: '更新后的笔记' - }; - - const updateSuccess = await this.savePracticeRecord({ ...updateRecord, ...updates }); - - // 验证更新 - const updatedRecord = await this.getPracticeRecordById(updateRecord.id); - const updateVerified = updatedRecord !== null && - updatedRecord.score === updates.score && - updatedRecord.timeSpent === updates.timeSpent && - updatedRecord.notes === updates.notes; - - // 恢复原始数据(用于其他测试) - if (updateSuccess) { - await this.savePracticeRecord({ ...updatedRecord, score: originalScore }); - } - - this.recordTest(testName, updateSuccess && updateVerified, { - recordId: updateRecord.id, - originalScore, - updates, - updateSuccess, - updateVerified, - updatedScore: updatedRecord?.score - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试删除记录 - async testDeleteRecord() { - const testName = '删除记录测试'; - - try { - if (this.testRecords.length < 2) { - this.recordTest(testName, false, { error: '测试记录不足,无法进行删除测试' }); - return; - } - - const recordToDelete = this.testRecords.pop(); // 删除最后一个测试记录 - const recordsBeforeDelete = (await this.listPracticeRecords()).length; - - // 执行删除 - const deleteSuccess = await this.deletePracticeRecord(recordToDelete.id); - - // 验证删除 - const recordsAfterDelete = (await this.listPracticeRecords()).length; - const deleteVerified = recordsAfterDelete === recordsBeforeDelete - 1; - - // 确认记录确实被删除 - const deletedRecordExists = (await this.getPracticeRecordById(recordToDelete.id)) !== null; - - const fullyDeleted = deleteSuccess && deleteVerified && !deletedRecordExists; - - this.recordTest(testName, fullyDeleted, { - deletedRecordId: recordToDelete.id, - recordsBeforeDelete, - recordsAfterDelete, - deleteSuccess, - deleteVerified, - recordStillExists: deletedRecordExists - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试批量操作 - async testBatchOperations() { - const testName = '批量操作测试'; - - try { - // 创建更多测试记录用于批量操作 - const batchRecords = [ - { - id: 'batch_test_1', - title: '批量测试1', - type: 'reading', - score: 75, - date: '2024-01-04' - }, - { - id: 'batch_test_2', - title: '批量测试2', - type: 'listening', - score: 88, - date: '2024-01-05' - } - ]; - - // 批量添加 - let batchAddSuccess = true; - const addedIds = []; - for (const record of batchRecords) { - const success = await this.savePracticeRecord(record); - if (success) { - addedIds.push(record.id); - } else { - batchAddSuccess = false; - } - } - - // 批量删除 - const recordsBeforeBatchDelete = (await this.listPracticeRecords()).length; - const deletedCount = await this.deletePracticeRecords(addedIds); - const batchDeleteSuccess = deletedCount === addedIds.length; - const recordsAfterBatchDelete = (await this.listPracticeRecords()).length; - - const batchDeleteVerified = recordsAfterBatchDelete === recordsBeforeBatchDelete - addedIds.length; - - this.recordTest(testName, batchAddSuccess && batchDeleteSuccess && batchDeleteVerified, { - batchRecordsCount: batchRecords.length, - batchAddSuccess, - addedIds: addedIds.length, - batchDeleteSuccess, - recordsBeforeBatchDelete, - recordsAfterBatchDelete, - batchDeleteVerified - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试数据验证 - async testDataValidation() { - const testName = '数据验证测试'; - - try { - const validationResults = []; - - // 测试有效记录 - const validRecord = { - id: 'validation_test_valid', - title: '有效记录测试', - type: 'reading', - score: 85, - date: '2024-01-06' - }; - - const validValidation = this.simpleStorage.validatePracticeRecord(validRecord); - validationResults.push({ - type: 'valid_record', - success: validValidation.isValid, - details: validValidation - }); - - // 测试无效记录(缺少必需字段) - const invalidRecords = [ - { - // 缺少id - title: '无效记录测试1', - type: 'reading', - score: 85, - date: '2024-01-06' - }, - { - id: 'invalid_test_2', - // 缺少type - title: '无效记录测试2', - score: 85, - date: '2024-01-06' - }, - { - id: 'invalid_test_3', - title: '无效记录测试3', - type: 'reading', - // 缺少score - date: '2024-01-06' - }, - { - id: 'invalid_test_4', - title: '无效记录测试4', - type: 'reading', - score: 85 - // 缺少date - } - ]; - - invalidRecords.forEach((record, index) => { - const validation = this.simpleStorage.validatePracticeRecord(record); - validationResults.push({ - type: `invalid_record_${index + 1}`, - success: !validation.isValid, // 应该验证失败 - details: validation - }); - }); - - const allValidationsCorrect = validationResults.every(r => r.success); - - this.recordTest(testName, allValidationsCorrect, { - validationResults, - totalValidations: validationResults.length, - correctValidations: validationResults.filter(r => r.success).length - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试边界情况 - async testEdgeCases() { - const testName = '边界情况测试'; - - try { - const edgeCaseResults = []; - - // 测试空记录 - const emptyRecord = { id: 'empty_test', title: '', type: '', score: 0, date: '' }; - const emptyValidation = this.simpleStorage.validatePracticeRecord(emptyRecord); - edgeCaseResults.push({ - case: 'empty_record', - success: !emptyValidation.isValid, - details: emptyValidation - }); - - // 测试极值分数 - const extremeScoreRecord = { - id: 'extreme_score_test', - title: '极值分数测试', - type: 'reading', - score: 150, // 超过100 - date: '2024-01-07' - }; - const extremeScoreValidation = this.simpleStorage.validatePracticeRecord(extremeScoreRecord); - edgeCaseResults.push({ - case: 'extreme_score', - success: extremeScoreValidation.isValid, // 结构有效,值是否合理由业务逻辑处理 - details: extremeScoreValidation - }); - - // 测试特殊字符 - const specialCharRecord = { - id: 'special_char_test', - title: '特殊字符测试 🚀', - type: 'reading', - score: 85, - date: '2024-01-08' - }; - const specialCharValidation = this.simpleStorage.validatePracticeRecord(specialCharRecord); - edgeCaseResults.push({ - case: 'special_characters', - success: specialCharValidation.isValid, - details: specialCharValidation - }); - - // 测试超长标题 - const longTitleRecord = { - id: 'long_title_test', - title: '这是一个非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的标题测试', - type: 'reading', - score: 85, - date: '2024-01-09' - }; - const longTitleValidation = this.simpleStorage.validatePracticeRecord(longTitleRecord); - edgeCaseResults.push({ - case: 'long_title', - success: longTitleValidation.isValid, - details: longTitleValidation - }); - - const allEdgeCasesPassed = edgeCaseResults.every(r => r.success); - - this.recordTest(testName, allEdgeCasesPassed, { - edgeCaseResults, - totalEdgeCases: edgeCaseResults.length, - passedEdgeCases: edgeCaseResults.filter(r => r.success).length - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 恢复原始数据 - async restoreOriginalData() { - const testName = '恢复原始数据'; - - try { - // 清理所有测试记录 - await this.replacePracticeRecords(this.originalRecords); - - // 验证恢复 - const finalRecords = await this.listPracticeRecords(); - const expectedCount = this.originalRecords.length; - const restoreSuccess = finalRecords.length === expectedCount; - - this.recordTest(testName, restoreSuccess, { - originalCount: this.originalRecords.length, - finalCount: finalRecords.length, - expectedCount, - restoreSuccess, - timestamp: new Date().toISOString() - }); - - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 记录测试结果 - recordTest(testName, passed, details) { - this.testResults.push({ - name: testName, - passed, - details, - timestamp: new Date().toISOString() - }); - - const status = passed ? '✅' : '❌'; - console.log(`${status} ${testName}`); - if (!passed && details.error) { - console.error(' 错误:', details.error); - } - } - - // 打印测试结果 - printResults() { - const totalTests = this.testResults.length; - const passedTests = this.testResults.filter(r => r.passed).length; - const failedTests = totalTests - passedTests; - - console.log('\n📊 Practice记录测试结果汇总:'); - console.log(`总测试数: ${totalTests}`); - console.log(`通过: ${passedTests} ✅`); - console.log(`失败: ${failedTests} ❌`); - console.log(`成功率: ${((passedTests / totalTests) * 100).toFixed(1)}%`); - - if (failedTests > 0) { - console.log('\n❌ 失败的测试:'); - this.testResults - .filter(r => !r.passed) - .forEach(r => { - console.log(` - ${r.name}: ${r.details.error || '测试条件不满足'}`); - }); - } - - console.log('\n📈 操作统计:'); - console.log(`测试创建记录数: ${this.testRecords.length}`); - console.log(`原始记录备份数: ${this.originalRecords.length}`); - } - - // 生成测试报告 - generateReport() { - const totalTests = this.testResults.length; - const passedTests = this.testResults.filter(r => r.passed).length; - const successRate = ((passedTests / totalTests) * 100).toFixed(1); - - return { - summary: { - totalTests, - passedTests, - failedTests: totalTests - passedTests, - successRate: `${successRate}%`, - timestamp: new Date().toISOString() - }, - operationStats: { - testRecordsCreated: this.testRecords.length, - originalRecordsBackedUp: this.originalRecords.length - }, - failedTests: this.testResults.filter(r => !r.passed).map(r => ({ - name: r.name, - error: r.details.error || '测试条件不满足', - details: r.details - })) - }; - } -} - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = PracticeRecordTest; -} diff --git a/developer/tests/js/practiceRecorder.test.js b/developer/tests/js/practiceRecorder.test.js index 57264fb2..608c7573 100644 --- a/developer/tests/js/practiceRecorder.test.js +++ b/developer/tests/js/practiceRecorder.test.js @@ -1,158 +1,181 @@ #!/usr/bin/env node +import assert from 'assert'; import fs from 'fs'; import path from 'path'; import vm from 'vm'; -import assert from 'assert'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const repoRoot = path.resolve(__dirname, '../../..'); -function loadScript(relativePath, context) { - const fullPath = path.join(repoRoot, relativePath); - const source = fs.readFileSync(fullPath, 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } -const results = []; - -function recordResult(name, detail) { - results.push({ name, detail, timestamp: new Date().toISOString() }); -} - -function createPrototypeRecorder(PracticeRecorder) { - const recorder = Object.create(PracticeRecorder.prototype); - recorder.scoreStorage = null; - recorder.practiceTypeCache = new Map(); - recorder.lookupExamIndexEntry = () => null; - recorder.generateRecordId = () => 'record-generated'; - return recorder; +function loadScript(relativePath, context) { + vm.runInContext(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'), context, { filename: relativePath }); } -async function testCanonicalCorrectAnswerMapWins(PracticeRecorder) { - const recorder = createPrototypeRecorder(PracticeRecorder); - const record = { - id: 'record-canonical-correct-map', - examId: 'reading-canonical', - sessionId: 'session-canonical', - title: 'Canonical correct map', - type: 'reading', - date: '2026-05-10T00:00:00.000Z', - startTime: '2026-05-10T00:00:00.000Z', - endTime: '2026-05-10T00:10:00.000Z', - duration: 600, - score: 2, - totalQuestions: 2, - correctAnswers: { q1: 'B', q2: 'C' }, - correctAnswersCount: 2, - accuracy: 1, - answers: { q1: 'A', q2: 'D' }, - correctAnswerMap: { q1: 'A', q2: 'D' }, - realData: { - correctAnswerMap: { q2: 'D', q3: 'E' }, - correctAnswers: { q1: 'B', q2: 'C', q3: 'F' }, - answers: { q1: 'A', q2: 'D', q3: 'E' } - }, - scoreInfo: { - correct: 2, - details: { - q1: { userAnswer: 'A', correctAnswer: 'B' }, - q3: { userAnswer: 'E', correctAnswer: 'G' } - } - }, - metadata: { examTitle: 'Canonical', category: 'P1', frequency: 'high', type: 'reading' } +function createHarness() { + const state = { + records: [], + drafts: [{ + id: 'reading-draft:reading-p2', + kind: 'reading_draft', + examId: 'reading-p2', + sessionId: 'reading-session', + answers: { q1: 'A' }, + updatedAt: '2026-07-26T00:00:00.000Z' + }], + commands: [], + backupCalls: [], + discardedActiveSessions: [], + failCompleteAttempts: 0 }; - - const storageReady = recorder.prepareRecordForStorage(record); - assert.strictEqual(storageReady.correctAnswerMap.q1, 'A', 'canonical correctAnswerMap 应压过对象型 correctAnswers'); - assert.strictEqual(storageReady.correctAnswerMap.q2, 'D', 'realData legacy 不应覆盖顶层 canonical'); - assert.strictEqual(storageReady.correctAnswerMap.q3, 'E', 'realData.correctAnswerMap 应先于 legacy correctAnswers 补缺'); - assert.strictEqual(storageReady.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical map'); - assert.strictEqual(storageReady.realData.correctAnswerMap.q3, 'E', 'realData.correctAnswerMap 应镜像 canonical map'); - - const standardized = recorder.normalizeRecordForPracticeRecordApi(record); - assert.strictEqual(standardized.correctAnswers, 2, '对象型 correctAnswers 不能污染数字答对数'); - assert.strictEqual(standardized.correctAnswerMap.q1, 'A', '重试标准化也必须 canonical 优先'); - assert.strictEqual(standardized.correctAnswerMap.q3, 'E', '重试标准化应保留 realData canonical 补缺'); - assert.strictEqual(standardized.realData.correctAnswers.q1, 'A', '重试标准化 realData.correctAnswers 应镜像 canonical'); - assert.strictEqual(standardized.realData.correctAnswerMap.q2, 'D', '重试标准化 realData.correctAnswerMap 应存在'); - recordResult('PracticeRecorder 正确答案表 canonical 优先', { correctAnswerMap: standardized.correctAnswerMap }); -} - -async function testCompletionPayloadCanonicalCorrectAnswerMapWins(PracticeRecorder) { - const recorder = createPrototypeRecorder(PracticeRecorder); - const shaped = recorder.normalizePracticeCompletePayload({ - examId: 'reading-completion-canonical', - sessionId: 'session-completion-canonical', - answers: { q1: 'A', q2: 'D' }, - correctAnswerMap: { q1: 'A', q2: 'D' }, - correctAnswers: { q1: 'B', q2: 'C' }, - correctAnswersCount: 2, - scoreInfo: { - correct: 2, - total: 2, - details: { - q1: { userAnswer: 'A', correctAnswer: 'B' } + const quietConsole = { log() {}, warn() {}, error() {}, info() {}, debug() {} }; + const appData = { + ready: Promise.resolve(), + practice: { + async completeAttempt(command) { + state.commands.push(clone(command)); + if (state.failCompleteAttempts > 0) { + state.failCompleteAttempts -= 1; + const error = new Error('transient write failure'); + error.code = 'IO'; + throw error; + } + const existing = state.records.find((record) => record.id === command.record.id); + if (existing) return { committed: true, operationId: command.operationId, revision: 1, record: clone(existing) }; + const record = clone(command.record); + state.records.unshift(record); + return { committed: true, operationId: command.operationId, revision: 1, record: clone(record) }; + }, + async get(id) { + return clone(state.records.find((record) => record.id === id) || null); + }, + async list() { + return clone(state.records); + }, + async getStats() { + return { totalPractices: state.records.length, totalTimeSpent: 600, averageScore: 0.5 }; } }, - realData: { - correctAnswerMap: { q2: 'D' }, - correctAnswers: { q1: 'B', q2: 'C' } - } - }); - - assert(shaped, 'completion payload 应能标准化'); - assert.strictEqual(shaped.results.correctAnswers, 2, 'completion 数字 correctAnswers 应来自 count 字段'); - assert.strictEqual(shaped.results.correctAnswerMap.q1, 'A', 'completion canonical map 应压过 legacy map'); - assert.strictEqual(shaped.results.correctAnswerMap.q2, 'D', 'completion canonical map 应保留 q2'); - assert.strictEqual(shaped.results.realData.correctAnswers.q1, 'A', 'completion realData.correctAnswers 应镜像 canonical'); - assert.strictEqual(shaped.results.realData.correctAnswerMap.q2, 'D', 'completion realData.correctAnswerMap 应镜像 canonical'); - recordResult('completion payload 正确答案表 canonical 优先', { correctAnswerMap: shaped.results.correctAnswerMap }); -} - -async function testRetrySaveUsesPracticeRecordApi(PracticeRecorder, windowStub) { - const savedRecords = []; - const tempRecords = []; - const saveOptions = []; - const recorder = Object.create(PracticeRecorder.prototype); - - recorder.scoreStorage = null; - recorder.practiceTypeCache = new Map(); - recorder.metaRepo = { - async get(key, fallback) { - if (key === 'temp_practice_records') { - return tempRecords.map((record) => JSON.parse(JSON.stringify(record))); + recovery: { + async listDrafts() { + return clone(state.drafts); + }, + async saveDraft(value) { + const draft = { ...clone(value), updatedAt: '2026-07-26T01:00:00.000Z' }; + const index = state.drafts.findIndex((entry) => entry.id === draft.id); + if (index >= 0) state.drafts[index] = draft; + else state.drafts.push(draft); + return { committed: true, item: clone(draft) }; + }, + async discardDraft(id) { + state.drafts = state.drafts.filter((entry) => entry.id !== id); + return { committed: true }; + }, + async discardActiveSession(id) { + state.discardedActiveSessions.push(String(id)); + return { committed: true }; } - return fallback; }, - async set(key, value) { - if (key === 'temp_practice_records') { - tempRecords.splice(0, tempRecords.length, ...value.map((record) => JSON.parse(JSON.stringify(record)))); + backups: { + async export(options) { + state.backupCalls.push({ method: 'export', options: clone(options) }); + return { + format: 'ielts-atlas-data-v2', + schemaVersion: 2, + scope: 'partial', + envelopes: {}, + entities: { + practiceSummaries: state.records.map((record) => ({ + recordId: record.id, + revision: 1, + operationId: 'export', + updatedAt: '2026-07-26T00:00:00.000Z', + data: clone(record), + checksum: `sum-${record.id}` + })), + practiceDetails: [], + practiceAnnotations: [] + }, + checksum: 'snapshot-checksum' + }; + }, + async create(options) { + state.backupCalls.push({ method: 'create', options: clone(options) }); + return { id: options.id || 'backup-before-import' }; + }, + async previewImport(payload, options) { + state.backupCalls.push({ method: 'previewImport', payload: clone(payload), options: clone(options) }); + const format = payload && payload.format === 'ielts-atlas-data-v2' ? 'v2' : 'v1'; + return { + id: 'import-plan-1', + format, + keys: [], + practice: { accepted: 1, importedCount: 1, skippedCount: 0 } + }; + }, + async commitImport(id, options) { + state.backupCalls.push({ method: 'commitImport', id, options: clone(options) }); + return { + committed: true, + operationId: options.operationId || 'import-operation', + revisions: { 'practiceSummaries/legacy-import': 1 }, + importedCount: 1, + practice: { accepted: 1, importedCount: 1, skippedCount: 0 } + }; + }, + async recordImport(entry) { + state.backupCalls.push({ method: 'recordImport', entry: clone(entry) }); + return { committed: true }; + }, + async restore(id) { + state.backupCalls.push({ method: 'restore', id }); + return { committed: true, operationId: `restore:${id}` }; + }, + async list() { + return [{ id: 'backup-before-import' }]; } - return true; } }; - recorder.updateUserStats = async () => { - throw new Error('retrySaveWithStandardizedRecord should let PracticeRecordAPI own stats'); + const windowStub = { + console: quietConsole, + AppData: appData, + resolveActiveLibraryIndex: async () => [{ id: 'reading-p1', title: 'Passage 1', type: 'reading', category: 'P1', frequency: 'high' }] }; - windowStub.PracticeRecordAPI = { - async saveRecord(record, options = {}) { - savedRecords.unshift(JSON.parse(JSON.stringify(record))); - saveOptions.push(JSON.parse(JSON.stringify(options))); - return record; - } + const sandbox = { + window: windowStub, + console: quietConsole, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + Date, + Math, + JSON }; + sandbox.globalThis = windowStub; + const context = vm.createContext(sandbox); + loadScript('js/core/practiceCore.js', context); + loadScript('js/core/practiceRecorder.js', context); + const recorder = Object.create(windowStub.PracticeRecorder.prototype); + recorder.wait = async () => {}; + return { recorder, state, windowStub }; +} - const saved = await recorder.retrySaveWithStandardizedRecord({ - id: 'record-api-retry', +function makeRecord(id = 'record-v2') { + return { + id, examId: 'reading-p1', - sessionId: 'session-api-retry', - title: 'Retry should use PracticeRecordAPI', + sessionId: `session-${id}`, + title: 'Passage 1', type: 'reading', - date: '2026-05-07T00:00:00.000Z', - startTime: '2026-05-07T00:00:00.000Z', - endTime: '2026-05-07T00:10:00.000Z', + date: '2026-07-26', + startTime: '2026-07-26T00:00:00.000Z', + endTime: '2026-07-26T00:10:00.000Z', duration: 600, score: 1, totalQuestions: 2, @@ -160,264 +183,358 @@ async function testRetrySaveUsesPracticeRecordApi(PracticeRecorder, windowStub) accuracy: 0.5, answers: { q1: 'A', q2: 'B' }, correctAnswerMap: { q1: 'A', q2: 'C' }, - metadata: { examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' } - }); - - assert.strictEqual(saved.id, 'record-api-retry', '标准化重试保存应返回已保存记录'); - assert.strictEqual(savedRecords.length, 1, '主记录应通过 PracticeRecordAPI 落库'); - assert.strictEqual(saveOptions[0].updateStats, true, '标准化重试保存应让统一 API 负责统计更新'); - assert.strictEqual(tempRecords.length, 0, 'API 保存成功不应写入临时恢复队列'); - assert.strictEqual(Object.prototype.hasOwnProperty.call(savedRecords[0], 'savedBy'), false, '标准化重试不应写入 fallback 标记'); - assert.strictEqual(Object.prototype.hasOwnProperty.call(savedRecords[0], 'fallbackReason'), false, '标准化重试不应写入 fallback 原因'); - recordResult('标准化重试保存走统一 PracticeRecordAPI', { savedRecordId: saved.id }); -} - -async function testPrimarySaveUsesPracticeRecordApi(PracticeRecorder, windowStub) { - const savedRecords = []; - const saveOptions = []; - const recorder = Object.create(PracticeRecorder.prototype); - - recorder.scoreStorage = {}; - Object.defineProperties(recorder.scoreStorage, { - currentVersion: { - get() { - throw new Error('savePracticeRecord must not read ScoreStorage.currentVersion'); - } - }, - maxRecords: { - get() { - throw new Error('savePracticeRecord must not read ScoreStorage.maxRecords'); - } - }, - savePracticeRecord: { - value: async () => { - throw new Error('savePracticeRecord must not call ScoreStorage directly'); - } - } - }); - recorder.practiceTypeCache = new Map(); - recorder.wait = async () => {}; - recorder.isCriticalError = PracticeRecorder.prototype.isCriticalError; - recorder.prepareRecordForStorage = PracticeRecorder.prototype.prepareRecordForStorage; - recorder.restoreRecordAnswerState = PracticeRecorder.prototype.restoreRecordAnswerState; - recorder.buildRecordLogSummary = PracticeRecorder.prototype.buildRecordLogSummary; - recorder.inferExamId = PracticeRecorder.prototype.inferExamId; - recorder.lookupExamIndexEntry = () => null; - recorder.normalizePracticeType = PracticeRecorder.prototype.normalizePracticeType; - recorder.normalizeAnswerMap = PracticeRecorder.prototype.normalizeAnswerMap; - recorder.normalizeAnswerComparison = PracticeRecorder.prototype.normalizeAnswerComparison; - recorder.convertAnswerArrayToMap = PracticeRecorder.prototype.convertAnswerArrayToMap; - recorder.extractCorrectAnswerMap = PracticeRecorder.prototype.extractCorrectAnswerMap; - recorder.buildAnswerDetails = PracticeRecorder.prototype.buildAnswerDetails; - recorder.saveToTemporaryStorage = async () => { - throw new Error('API primary save success should not write temp queue'); - }; - - windowStub.PracticeRecordAPI = { - async saveRecord(record, options = {}) { - savedRecords.unshift(JSON.parse(JSON.stringify(record))); - saveOptions.push(JSON.parse(JSON.stringify(options))); - return Object.assign({}, record, { savedBy: 'api-primary' }); + realData: { + questionTypeMap: { q1: 'true-false-not-given' }, + interactions: [{ type: 'answer', questionId: 'q1' }] }, - async getById(recordId) { - return savedRecords.find((record) => record.id === recordId || record.sessionId === recordId) || null; - } + metadata: { examId: 'reading-p1', examTitle: 'Passage 1', category: 'P1', frequency: 'high', type: 'reading' } }; - - const saved = await recorder.savePracticeRecord({ - id: 'record-api-primary', - examId: 'reading-p2', - sessionId: 'session-api-primary', - title: 'Primary should use PracticeRecordAPI', - type: 'reading', - date: '2026-05-08T00:00:00.000Z', - startTime: '2026-05-08T00:00:00.000Z', - endTime: '2026-05-08T00:12:00.000Z', - duration: 720, - score: 2, - totalQuestions: 3, - correctAnswers: 2, - accuracy: 2 / 3, - answers: { q1: 'A', q2: 'B', q3: 'C' }, - correctAnswerMap: { q1: 'A', q2: 'B', q3: 'D' }, - metadata: { examTitle: 'Passage 2', category: 'P2', frequency: 'medium', type: 'reading' } - }); - - assert.strictEqual(saved.id, 'record-api-primary', '正常保存应返回 API 保存记录'); - assert.strictEqual(saved.savedBy, 'api-primary', '正常保存应使用 PracticeRecordAPI 返回值'); - assert.strictEqual(savedRecords.length, 1, '正常保存应通过 PracticeRecordAPI 落库'); - assert.strictEqual(saveOptions[0].updateStats, true, '正常保存应让统一 API 负责统计更新'); - assert.strictEqual(Object.prototype.hasOwnProperty.call(saveOptions[0], 'currentVersion'), false, '正常保存不应从 ScoreStorage 透传版本'); - assert.strictEqual(Object.prototype.hasOwnProperty.call(saveOptions[0], 'maxRecords'), false, '正常保存不应从 ScoreStorage 透传容量'); - recordResult('正常保存走统一 PracticeRecordAPI', { savedRecordId: saved.id }); } -async function testLegacyDataMethodsUseUnifiedAdapters(PracticeRecorder, windowStub) { - const recorder = Object.create(PracticeRecorder.prototype); - const calls = []; - const records = [ - { - id: 'record-export-a', - examId: 'reading-export-a', - sessionId: 'session-export-a', - title: 'Export Passage', - type: 'reading', - date: '2026-05-09T00:00:00.000Z', - startTime: '2026-05-09T00:00:00.000Z', - endTime: '2026-05-09T00:05:00.000Z', - duration: 300, - score: 1, - totalQuestions: 2, - correctAnswers: 1, - accuracy: 0.5, - metadata: { category: 'P1', frequency: 'high', examTitle: 'Export Passage' } - } - ]; - const stats = { - totalPractices: 1, - totalTimeSpent: 300, - averageScore: 0.5, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 1, - practiceDays: ['2026-05-09'], - lastPracticeDate: '2026-05-09', - achievements: [] - }; - - recorder.scoreStorage = { - async savePracticeRecord() { - throw new Error('legacy data methods must not save through ScoreStorage'); - }, - async getUserStats() { - throw new Error('getUserStats must use PracticeRecordAPI.readStats'); - }, - exportData() { - throw new Error('exportData must use PracticeRecordAPI'); - }, - importData() { - throw new Error('importData must use DataBackupManager'); - }, - createBackup() { - throw new Error('createBackup must use DataBackupManager'); - }, - restoreBackup() { - throw new Error('restoreBackup must use DataBackupManager'); - } +async function main() { + const results = []; + const record = async (name, test) => { + await test(); + results.push({ name, status: 'pass' }); }; - windowStub.PracticeRecordAPI = { - async list() { - calls.push({ method: 'list' }); - return JSON.parse(JSON.stringify(records)); - }, - async readStats(options = {}) { - calls.push({ method: 'readStats', fallback: options.fallback }); - return JSON.parse(JSON.stringify(stats)); - }, - getDefaultStats() { - return { - totalPractices: 0, - totalTimeSpent: 0, - averageScore: 0, - categoryStats: {}, - questionTypeStats: {}, - streakDays: 0, - practiceDays: [], - lastPracticeDate: null, - achievements: [] + try { + await record('save separates business id from per-call operation id', async () => { + const { recorder, state } = createHarness(); + const saved = await recorder.savePracticeRecord(makeRecord()); + assert.strictEqual(saved.id, 'record-v2'); + assert.strictEqual(state.records.length, 1); + assert.strictEqual(state.commands.length, 1); + assert.notStrictEqual(state.commands[0].operationId, 'record-v2'); + assert(String(state.commands[0].operationId).startsWith('practice-complete_')); + assert.deepStrictEqual(clone(state.commands[0].record.answers), { q1: 'A', q2: 'B' }); + assert(Array.isArray(state.commands[0].record.answerList)); + assert.deepStrictEqual(clone(state.commands[0].record.answerList.map((item) => item.questionId)), ['q1', 'q2']); + assert.deepStrictEqual(clone(state.commands[0].record.questionTypeMap), { q1: 'true-false-not-given' }); + assert.deepStrictEqual(clone(state.commands[0].record.interactions), [{ type: 'answer', questionId: 'q1' }]); + assert.deepStrictEqual(clone(saved.correctAnswerMap), { q1: 'A', q2: 'C' }); + await recorder.savePracticeRecord({ ...makeRecord(), title: 'Updated title' }); + assert.notStrictEqual( + state.commands[1].operationId, + state.commands[0].operationId, + 'a second logical save of the same record must receive a new operation id' + ); + }); + + await record('internal retries reuse one operation id', async () => { + const { recorder, state } = createHarness(); + state.failCompleteAttempts = 1; + const saved = await recorder.savePracticeRecord(makeRecord('record-retry')); + assert.strictEqual(saved.id, 'record-retry'); + assert.strictEqual(state.commands.length, 2); + assert.strictEqual(state.commands[0].operationId, state.commands[1].operationId); + }); + + await record('global message listener never persists PRACTICE_COMPLETE alongside the host', async () => { + const { recorder } = createHarness(); + let completionCalls = 0; + recorder.handleSessionCompleted = async () => { completionCalls += 1; }; + recorder.handleExamMessage({ + data: { + type: 'PRACTICE_COMPLETE', + data: { examId: 'reading-p1', results: { scoreInfo: { correct: 1, total: 1 } } } + } + }); + assert.strictEqual(completionCalls, 0, 'host-owned completion must not be saved through the recorder global listener'); + }); + + await record('restore and autosave isolate ordinary sessions from suite recovery snapshots', async () => { + const { recorder, windowStub } = createHarness(); + const savedSessions = []; + const restoreLogs = []; + windowStub.console.log = (...args) => restoreLogs.push(args.join(' ')); + windowStub.AppData.recovery.listActiveSessions = async () => clone([ + null, + ['not-an-active-session'], + { id: 'missing-exam', sessionId: 'missing-exam' }, + { examId: 'reading-missing-identity' }, + { + id: 'active-session:mismatched-owner-a', + sessionId: 'mismatched-owner-b', + examId: 'reading-mismatched-identity' + }, + { + schema: 'suite-session-v2', + version: 2, + id: 'suite-owner-restore', + sessionId: 'suite-owner-restore', + examId: 'reading-suite-host', + revision: 4, + status: 'active', + sequence: [{ examId: 'reading-suite-p1' }], + _recoveryExclusiveGroup: 'suite-practice', + updatedAt: '2026-08-09T00:02:00.000Z' + }, + { + schema: 'multi-suite-sessions-v2', + version: 2, + id: 'multi-owner-restore', + sessionId: 'multi-owner-restore', + examId: 'listening-suite-host', + revision: 2, + sessions: [{ id: 'multi-owner-restore', baseExamId: 'listening-base' }], + updatedAt: '2026-08-09T00:03:00.000Z' + }, + { + id: 'active-session:ordinary-session', + sessionId: 'ordinary-session', + examId: ' reading-p1 ', + status: 'active', + updatedAt: '2026-08-09T00:01:00.000Z' + }, + { + // Legacy ordinary recovery entries may have a sessionId but no + // AppData-prefixed entity id yet. + sessionId: 'legacy-ordinary-session', + examId: ' listening-p1 ', + status: 'paused', + lastActivity: '2026-08-09T00:00:00.000Z' + } + ]); + windowStub.AppData.recovery.saveActiveSession = async (value) => { + savedSessions.push(clone(value)); + return { committed: true }; + }; + recorder.activeSessions = new Map(); + + await recorder.restoreActiveSessions(); + + assert.deepStrictEqual( + Array.from(recorder.activeSessions.keys()).sort(), + ['listening-p1', 'reading-p1'], + 'only normalized ordinary exam ids should enter the recorder map' + ); + assert.strictEqual( + recorder.activeSessions.get('reading-p1').id, + 'active-session:ordinary-session' + ); + assert.strictEqual( + recorder.activeSessions.get('listening-p1').id, + 'active-session:legacy-ordinary-session', + 'legacy ordinary sessions must retain compatibility via sessionId' + ); + assert( + restoreLogs.includes('Restored 2 active sessions'), + 'restore diagnostics must count only accepted ordinary sessions' + ); + + await recorder.saveActiveSessions(); + + assert.deepStrictEqual( + savedSessions.map((session) => session.id).sort(), + ['active-session:legacy-ordinary-session', 'active-session:ordinary-session'] + ); + assert(savedSessions.every((session) => ( + session.schema !== 'suite-session-v2' + && session.schema !== 'multi-suite-sessions-v2' + )), 'autosave must never rewrite suite recovery snapshots as recorder sessions'); + assert.strictEqual( + savedSessions.some((session) => ( + session.id === 'active-session:suite-owner-restore' + || session.id === 'active-session:multi-owner-restore' + )), + false, + 'autosave must never generate active-session: clones' + ); + }); + + await record('completion keeps its exact session across same-exam ABA replacement', async () => { + const { recorder, state, windowStub } = createHarness(); + let releaseExamIndex; + let markExamIndexEntered; + const examIndexEntered = new Promise((resolve) => { markExamIndexEntered = resolve; }); + const examIndexGate = new Promise((resolve) => { releaseExamIndex = resolve; }); + windowStub.resolveActiveLibraryIndex = async () => { + markExamIndexEntered(); + await examIndexGate; + return [{ id: 'reading-p1', title: 'Passage 1', type: 'reading', category: 'P1', frequency: 'high' }]; }; - } - }; - windowStub.DataBackupManager = class FakeDataBackupManager { - importPracticeData(data, options = {}) { - calls.push({ method: 'importPracticeData', data, options }); - return { imported: true, options }; - } - - createBackup(backupName, type) { - calls.push({ method: 'createBackup', backupName, type }); - return 'backup-created'; - } - - restoreBackup(backupId) { - calls.push({ method: 'restoreBackup', backupId }); - return { restored: backupId }; - } - }; - - const loadedStats = await recorder.getUserStats(); - assert.deepStrictEqual(loadedStats, stats, 'getUserStats 应通过 PracticeRecordAPI.readStats'); - - const exported = JSON.parse(await recorder.exportData('json')); - assert.strictEqual(exported.version, '0.6.2-fix', 'JSON 导出应使用稳定导出版本'); - assert.deepStrictEqual(exported.practiceRecords, records, 'JSON 导出应通过 PracticeRecordAPI.list'); - assert.deepStrictEqual(exported.userStats, stats, 'JSON 导出应通过 PracticeRecordAPI.readStats'); - - const csv = await recorder.exportData('csv'); - assert(csv.includes('record-export-a'), 'CSV 导出应序列化 PracticeRecordAPI 记录'); - - const importResult = await recorder.importData({ practice_records: records }, { merge: false }); - assert.strictEqual(importResult.imported, true, 'importData 应委托 DataBackupManager'); - assert.strictEqual(importResult.options.mergeMode, 'replace', 'merge=false 应转换为 replace mergeMode'); - - const backupId = recorder.createBackup('legacy-backup'); - assert.strictEqual(backupId, 'backup-created', 'createBackup 应委托 DataBackupManager'); - const restored = recorder.restoreBackup('backup-created'); - assert.deepStrictEqual(restored, { restored: 'backup-created' }, 'restoreBackup 应委托 DataBackupManager'); - assert(calls.some((call) => call.method === 'createBackup' && call.type === 'practice_recorder'), 'createBackup 应标记 practice_recorder 来源'); - recordResult('公开数据方法走统一适配器', { calls: calls.map((call) => call.method) }); -} - -async function main() { - const quietConsole = { - log() {}, - warn() {}, - error() {}, - info() {}, - debug() {} - }; - const windowStub = { - console: quietConsole, - dataRepositories: {} - }; - const sandbox = { - window: windowStub, - console: quietConsole, - setTimeout, - clearTimeout, - setInterval, - clearInterval, - Date, - Math, - JSON, - ScoreStorage: function ScoreStorage() {} - }; - sandbox.globalThis = sandbox.window; - const context = vm.createContext(sandbox); - loadScript('js/core/practiceCore.js', context); - loadScript('js/core/practiceRecorder.js', context); - const PracticeRecorder = sandbox.window.PracticeRecorder; + const sessionA = { + id: 'active-session:session-a', + sessionId: 'session-a', + examId: 'reading-p1', + status: 'active', + startTime: '2026-08-09T00:00:00.000Z', + lastActivity: '2026-08-09T00:01:00.000Z', + progress: { totalQuestions: 1 }, + answers: { q1: 'A' }, + metadata: { examTitle: 'Passage 1', type: 'reading' } + }; + recorder.activeSessions = new Map([['reading-p1', sessionA]]); + recorder.sessionListeners = new Map(); + recorder.practiceTypeCache = new Map(); + recorder.dispatchSessionEvent = () => {}; + + const completion = recorder.handleSessionCompleted({ + examId: 'reading-p1', + sessionId: 'session-a', + results: { + duration: 60, + endTime: '2026-08-09T00:01:00.000Z', + answers: { q1: 'A' }, + correctAnswerMap: { q1: 'A' }, + scoreInfo: { correct: 1, total: 1, accuracy: 1 } + } + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('completion did not reach the gated library-index await')), + 1000 + ); + examIndexEntered.then(() => { + clearTimeout(timeout); + resolve(); + }, reject); + }); + Object.assign(sessionA, { + id: 'active-session:session-b', + sessionId: 'session-b', + startTime: '2026-08-09T00:02:00.000Z', + lastActivity: '2026-08-09T00:03:00.000Z', + answers: { q1: 'B' }, + metadata: { examTitle: 'Passage 1 replacement', type: 'reading' } + }); + releaseExamIndex(); + + const saved = await completion; + assert.strictEqual(saved.sessionId, 'session-a', 'completion must save the session captured before the await'); + assert.strictEqual(state.commands.length, 1); + assert.strictEqual(state.commands[0].record.sessionId, 'session-a'); + assert.strictEqual(recorder.activeSessions.get('reading-p1'), sessionA, 'completion must retain the in-place replacement session'); + assert.strictEqual(sessionA.sessionId, 'session-b', 'completion payload must not rewrite the replacement identity'); + assert.deepStrictEqual(state.discardedActiveSessions, [], 'completion must not discard either entity after losing exact map ownership'); + }); + + await record('explicit ordinary ownership beats a same-exam active suite', async () => { + const completeWithOwnership = async (ownership = {}) => { + const { recorder, state, windowStub } = createHarness(); + const sessionId = `ordinary-${Object.keys(ownership).join('-') || 'legacy'}`; + recorder.activeSessions = new Map([['reading-p1', { + id: `active-session:${sessionId}`, + sessionId, + examId: 'reading-p1', + status: 'active', + startTime: '2026-08-09T01:00:00.000Z', + lastActivity: '2026-08-09T01:01:00.000Z', + progress: { totalQuestions: 1 }, + answers: { q1: 'A' }, + metadata: { examTitle: 'Passage 1', type: 'reading' } + }]]); + recorder.sessionListeners = new Map(); + recorder.practiceTypeCache = new Map(); + recorder.dispatchSessionEvent = () => {}; + windowStub.app = { + suiteExamMap: new Map([['reading-p1', 'active-suite']]), + currentSuiteSession: { + id: 'active-suite', + status: 'active', + sequence: [{ examId: 'reading-p1' }] + } + }; + let fallbackCalls = 0; + const resolveSuiteSessionFromApp = recorder.resolveSuiteSessionFromApp.bind(recorder); + recorder.resolveSuiteSessionFromApp = (...args) => { + fallbackCalls += 1; + return resolveSuiteSessionFromApp(...args); + }; + + const payload = Object.assign({ + examId: 'reading-p1', + sessionId, + results: { + duration: 60, + endTime: '2026-08-09T01:01:00.000Z', + answers: { q1: 'A' }, + correctAnswerMap: { q1: 'A' }, + scoreInfo: { correct: 1, total: 1, accuracy: 1 } + } + }, ownership); + const saved = await recorder.handleSessionCompleted(payload); + return { fallbackCalls, saved, state }; + }; - try { - await testCanonicalCorrectAnswerMapWins(PracticeRecorder); - await testCompletionPayloadCanonicalCorrectAnswerMapWins(PracticeRecorder); - await testPrimarySaveUsesPracticeRecordApi(PracticeRecorder, windowStub); - await testRetrySaveUsesPracticeRecordApi(PracticeRecorder, windowStub); - await testLegacyDataMethodsUseUnifiedAdapters(PracticeRecorder, windowStub); - console.log(JSON.stringify({ - status: 'pass', - detail: `${results.length}/${results.length} 测试通过`, - passed: results.length, - total: results.length - }, null, 2)); + const managedOrdinary = await completeWithOwnership({ suiteSessionId: null }); + assert.strictEqual(managedOrdinary.fallbackCalls, 0, 'managed ordinary ownership must not consult global suite state'); + assert.strictEqual(managedOrdinary.state.commands.length, 1, 'managed ordinary completion must save a single record'); + assert.strictEqual(managedOrdinary.saved.suiteSessionId, null); + + const explicitSingle = await completeWithOwnership({ practiceMode: 'single' }); + assert.strictEqual(explicitSingle.fallbackCalls, 0, 'explicit single mode must not consult global suite state'); + assert.strictEqual(explicitSingle.state.commands.length, 1, 'explicit single completion must save a single record'); + assert.strictEqual(explicitSingle.saved.suiteSessionId, null); + + const legacy = await completeWithOwnership(); + assert.strictEqual(legacy.fallbackCalls, 1, 'ownership-free legacy payloads must retain global suite inference'); + assert.strictEqual(legacy.state.commands.length, 0, 'legacy inferred suite entries must still skip standalone persistence'); + assert.strictEqual(legacy.saved.suiteSessionId, 'active-suite'); + }); + + await record('temporary recovery draft does not overwrite reading drafts', async () => { + const { recorder, state } = createHarness(); + await recorder.saveToTemporaryStorage(makeRecord('record-recovery')); + assert(state.drafts.some((draft) => draft.id === 'reading-draft:reading-p2')); + assert(state.drafts.some((draft) => draft.id === 'practice-record:record-recovery' && draft.kind === 'practice_record_recovery')); + + const recovered = []; + recorder.savePracticeRecord = async (value) => { + recovered.push(clone(value)); + return value; + }; + await recorder.recoverTemporaryRecords(); + assert.strictEqual(recovered.length, 1); + assert.strictEqual(recovered[0].id, 'record-recovery'); + assert.deepStrictEqual(state.drafts.map((draft) => draft.id), ['reading-draft:reading-p2']); + }); + + await record('JSON export is a catalog-governed v2 practice snapshot', async () => { + const { recorder, state } = createHarness(); + state.records.push(makeRecord('record-export')); + const exported = JSON.parse(await recorder.exportData('json')); + assert.strictEqual(exported.format, 'ielts-atlas-data-v2'); + assert.strictEqual(exported.schemaVersion, 2); + assert(Array.isArray(exported.entities.practiceSummaries)); + assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'practiceRecords'), false); + assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'userStats'), false); + assert.deepStrictEqual(state.backupCalls[0], { method: 'export', options: { domains: ['practice'] } }); + }); + + await record('import preview and commit stay inside AppData.backups', async () => { + const { recorder, state } = createHarness(); + const result = await recorder.importData({ practice_records: [makeRecord('legacy-import')] }, { + merge: false, + operationId: 'import-practice-v2' + }); + assert.strictEqual(result.committed, true); + assert.strictEqual(result.backupId, 'backup-before-import'); + const previewCall = state.backupCalls.find((call) => call.method === 'previewImport'); + assert(previewCall); + assert.strictEqual(previewCall.options.practiceMode, 'replace'); + assert.strictEqual(previewCall.payload.practice_records[0].id, 'legacy-import'); + const commitCall = state.backupCalls.find((call) => call.method === 'commitImport' && call.id === 'import-plan-1'); + assert(commitCall); + assert.strictEqual(commitCall.options.confirmDestructive, true); + assert(state.backupCalls.some((call) => call.method === 'recordImport')); + }); + + await record('backup create and restore delegate to the backups domain', async () => { + const { recorder, state } = createHarness(); + const backup = await recorder.createBackup('practice-backup'); + const restored = await recorder.restoreBackup(backup.id); + assert.strictEqual(restored.committed, true); + assert(state.backupCalls.some((call) => call.method === 'create' && call.options.type === 'practice-recorder')); + assert(state.backupCalls.some((call) => call.method === 'restore' && call.id === 'practice-backup')); + }); + + console.log(JSON.stringify({ status: 'pass', detail: `${results.length}/${results.length} tests passed`, results }, null, 2)); } catch (error) { - console.log(JSON.stringify({ - status: 'fail', - detail: error.message, - results - }, null, 2)); + results.push({ name: 'test execution', status: 'fail', error: error.stack || error.message }); + console.log(JSON.stringify({ status: 'fail', results }, null, 2)); process.exit(1); } } diff --git a/developer/tests/js/practiceTimerPreferences.test.js b/developer/tests/js/practiceTimerPreferences.test.js index a2cde853..49e45206 100644 --- a/developer/tests/js/practiceTimerPreferences.test.js +++ b/developer/tests/js/practiceTimerPreferences.test.js @@ -11,14 +11,22 @@ const repoRoot = path.resolve(__dirname, '..', '..', '..'); const source = fs.readFileSync(path.join(repoRoot, 'js/utils/practiceTimerPreferences.js'), 'utf8'); function loadPreferences() { - const store = new Map(); + const persisted = { reading: null, listening: null }; + const calls = []; + let writeFailure = null; const window = { - localStorage: { - getItem(key) { - return store.has(key) ? store.get(key) : null; - }, - setItem(key, value) { - store.set(key, String(value)); + AppData: { + ready: Promise.resolve(true), + preferences: { + async getTimer() { + return JSON.parse(JSON.stringify(persisted)); + }, + async setTimer(scope, value) { + if (writeFailure) throw writeFailure; + const normalized = JSON.parse(JSON.stringify(value)); + calls.push({ scope, value: normalized }); + persisted[scope] = normalized; + } } } }; @@ -30,18 +38,54 @@ function loadPreferences() { Math, JSON, String, - Boolean + Boolean, + console }; vm.runInNewContext(source, context, { filename: 'practiceTimerPreferences.js' }); - return { manager: window.PracticeTimerPreferences, store }; + return { + manager: window.PracticeTimerPreferences, + calls, + persisted, + failWrites(error) { writeFailure = error; } + }; } -const { manager, store } = loadPreferences(); +async function testHydrationRetriesAfterAppDataInstall() { + const window = {}; + const context = { + window, + globalThis: window, + Object, + Number, + Math, + JSON, + String, + Boolean, + console + }; + vm.runInNewContext(source, context, { filename: 'practiceTimerPreferences-before-appdata.js' }); + assert.equal(await window.PracticeTimerPreferences.ready, false); + window.AppData = { + ready: Promise.resolve(true), + preferences: { + async getTimer() { + return { reading: { mode: 'countdown', countdownMinutes: 12 }, listening: { expiryAction: 'lock' } }; + } + } + }; + assert.equal(await window.PracticeTimerPreferences.ready, true); + assert.equal(window.PracticeTimerPreferences.read('reading').countdownMinutes, 12); + assert.equal(window.PracticeTimerPreferences.read('listening').expiryAction, 'lock'); +} + +await testHydrationRetriesAfterAppDataInstall(); -assert.equal(manager.READING_KEY, 'ielts_reading_timer_preferences_v2'); -assert.equal(manager.LISTENING_KEY, 'ielts_listening_timer_preferences_v1'); +const { manager, calls, persisted, failWrites } = loadPreferences(); +const plain = (value) => JSON.parse(JSON.stringify(value)); -assert.deepEqual(manager.read('reading'), { +assert.equal(await manager.ready, true); + +assert.deepEqual(plain(manager.read('reading')), { version: 1, mode: 'elapsed', countdownMinutes: 60, @@ -50,7 +94,7 @@ assert.deepEqual(manager.read('reading'), { expiryAction: 'warn' }); -const reading = manager.save('reading', { +const reading = await manager.save('reading', { mode: 'countdown', countdownMinutes: 999, limitEnabled: true, @@ -63,7 +107,7 @@ assert.equal(reading.limitEnabled, true); assert.equal(reading.limitMinutes, 1); assert.equal(reading.expiryAction, 'auto-submit'); -const listening = manager.save('listening', { +const listening = await manager.save('listening', { mode: 'invalid', countdownMinutes: '30', limitEnabled: false, @@ -76,13 +120,20 @@ assert.equal(listening.limitEnabled, false); assert.equal(listening.limitMinutes, 60); assert.equal(listening.expiryAction, 'lock'); -assert.notEqual(manager.keyFor('reading'), manager.keyFor('listening')); -assert(store.has(manager.READING_KEY), 'reading preferences should be persisted'); -assert(store.has(manager.LISTENING_KEY), 'listening preferences should be persisted'); +assert.deepEqual(calls.map((entry) => entry.scope), ['reading', 'listening']); +assert.deepEqual(persisted.reading, plain(reading)); +assert.deepEqual(persisted.listening, plain(listening)); assert.equal(manager.read('reading').expiryAction, 'auto-submit'); assert.equal(manager.read('listening').expiryAction, 'lock'); +failWrites(new Error('timer persistence unavailable')); +await assert.rejects( + manager.save('reading', { mode: 'elapsed', expiryAction: 'warn' }), + /timer persistence unavailable/ +); +assert.equal(manager.read('reading').expiryAction, 'auto-submit', 'failed writes must not change the cached preference'); + process.stdout.write(JSON.stringify({ status: 'pass', - detail: 'practice timer preferences sanitize and persist independently' + detail: 'practice timer preferences sanitize and persist through AppData independently' })); diff --git a/developer/tests/js/readingAnnotationHostProtocol.test.js b/developer/tests/js/readingAnnotationHostProtocol.test.js new file mode 100644 index 00000000..22124e03 --- /dev/null +++ b/developer/tests/js/readingAnnotationHostProtocol.test.js @@ -0,0 +1,813 @@ +#!/usr/bin/env node +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +function loadScript(relativePath, context) { + vm.runInContext(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'), context, { filename: relativePath }); +} + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function createHarness(initialRecord, options = {}) { + const records = new Map([[String(initialRecord.id), clone(initialRecord)]]); + const saveCalls = []; + const conflicts = []; + let revision = 1; + const listeners = new Map(); + let drafts = []; + const documentStub = { + addEventListener() {}, + removeEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; } + }; + const windowStub = { + document: documentStub, + location: { origin: 'http://localhost', href: 'http://localhost/' }, + resolveActiveLibraryIndex: async () => [], + addEventListener(type, handler) { listeners.set(type, handler); }, + removeEventListener(type, handler) { + if (listeners.get(type) === handler) listeners.delete(type); + }, + AppData: { + ready: Promise.resolve(), + practice: { + async get(id) { return clone(records.get(String(id)) || null); }, + async list() { return clone(Array.from(records.values())); }, + async updateAnnotations(command) { + const expectedRevision = revision; + const current = clone(records.get(String(command.recordId)) || null); + if (!current) throw new Error(`Unknown record: ${command.recordId}`); + const patch = clone(command.patch); + current.annotations = { ...(current.annotations || {}), [command.examId]: patch }; + if (Array.isArray(current.suiteEntries) && current.suiteEntries.length) { + current.suiteEntries = current.suiteEntries.map((entry) => ( + String(entry.examId) === String(command.examId) + ? { ...entry, ...patch, realData: { ...(entry.realData || {}), ...patch } } + : entry + )); + } else { + Object.assign(current, patch); + current.realData = { ...(current.realData || {}), ...patch }; + } + saveCalls.push({ command: clone(command), record: clone(current) }); + if (typeof options?.beforeSave === 'function') { + await options.beforeSave(current, saveCalls.length); + } + if (revision !== expectedRevision) { + const error = new Error('annotation revision conflict'); + error.code = 'CONFLICT'; + conflicts.push(clone(command)); + throw error; + } + records.set(String(current.id), clone(current)); + revision += 1; + return { committed: true }; + } + }, + recovery: { + async listActiveSessions() { return []; }, + async discardActiveSession() { return { committed: true }; }, + async listDrafts() { return clone(drafts); }, + async saveDraft(value, saveOptions = {}) { + if (typeof options.beforeDraftCommit === 'function') { + await options.beforeDraftCommit(value, saveOptions); + } + if (typeof saveOptions.commitGuard === 'function') { + let allowed = false; + try { allowed = saveOptions.commitGuard() === true; } catch (_) {} + if (!allowed) { + return { committed: false, stale: true, code: 'STALE_RECOVERY_WRITE' }; + } + } + const item = { ...clone(value), updatedAt: new Date().toISOString() }; + const index = drafts.findIndex((draft) => draft.id === item.id); + if (index >= 0) drafts[index] = item; + else drafts.push(item); + return { committed: true, item: clone(item) }; + }, + async discardDraft(id) { + drafts = drafts.filter((draft) => draft.id !== id); + return { committed: true }; + } + } + } + }; + const sandbox = { + window: windowStub, + document: documentStub, + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + Date, + Math, + JSON, + Map, + Set, + URL, + URLSearchParams + }; + sandbox.globalThis = windowStub; + const context = vm.createContext(sandbox); + loadScript('js/app/examSessionMixin.js', context); + loadScript('js/app/suitePracticeMixin.js', context); + const app = { components: {}, setState() {}, getState() { return null; } }; + Object.assign(app, windowStub.ExamSystemAppMixins.examSession, windowStub.ExamSystemAppMixins.suitePractice); + return { app, windowStub, records, saveCalls, conflicts, listeners, getDrafts: () => clone(drafts) }; +} + +function bindReviewProtocol(harness, record, examId) { + const examWindow = { + name: `review-${examId}`, + closed: false, + location: { href: `http://localhost/${examId}.html` }, + _messages: [], + postMessage(message) { this._messages.push(clone(message)); } + }; + const review = harness.app._buildReviewSession(record); + assert(review, 'review session should be created'); + harness.app._ensureReviewReplayStore().set(review.sessionId, review); + const info = { + window: examWindow, + expectedSessionId: `session-${examId}`, + windowSessionToken: `token-${examId}`, + windowSessionTokenSessionId: `session-${examId}`, + expectedUrl: `http://localhost/${examId}.html`, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + reviewMode: true, + reviewSessionId: review.sessionId, + reviewEntryIndex: review.entries.findIndex(entry => entry.examId === examId), + readOnly: true + }; + harness.app.examWindows = new Map([[examId, info]]); + harness.app.setupExamWindowCommunication(examWindow, examId); + const handler = harness.app.messageHandlers.get(examId); + assert.strictEqual(typeof handler, 'function', 'message handler should be registered'); + return { examWindow, review, info, handler }; +} + +async function send(handler, examWindow, data, type = 'READING_ANNOTATION_SYNC', overrides = {}) { + await handler({ + origin: overrides.origin || 'http://localhost', + source: overrides.source || examWindow, + data: { type, source: overrides.envelopeSource || 'practice_page', data } + }); +} + +function bindLiveProtocol(harness, examId, libraryConfigurationId = null) { + const examWindow = { + name: `practice-${examId}`, + closed: false, + location: { href: `http://localhost/${examId}.html` }, + postMessage() {} + }; + const info = { + window: examWindow, + expectedSessionId: `session-${examId}`, + windowSessionToken: `token-${examId}`, + windowSessionTokenSessionId: `session-${examId}`, + expectedUrl: `http://localhost/${examId}.html`, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + reviewMode: false, + practiceMode: 'single', + readOnly: false, + ...(libraryConfigurationId ? { libraryConfigurationId } : {}) + }; + harness.app.examWindows = new Map([[examId, info]]); + harness.app.setupExamWindowCommunication(examWindow, examId); + const handler = harness.app.messageHandlers.get(examId); + assert.strictEqual(typeof handler, 'function', 'live message handler should be registered'); + return { examWindow, info, handler }; +} + +// 单篇阅读 final-submit 落库后,结果页的 windowInfo 不在 review 回放态, +// 而是持有宿主回传的 submittedRecordId。该 helper 模拟该场景。 +function bindSubmittedProtocol(harness, examId, recordId) { + const examWindow = { + name: `submitted-${examId}`, + closed: false, + location: { href: `http://localhost/${examId}.html` }, + _messages: [], + postMessage(message) { this._messages.push(clone(message)); } + }; + const info = { + window: examWindow, + expectedSessionId: `session-${examId}`, + windowSessionToken: `token-${examId}`, + windowSessionTokenSessionId: `session-${examId}`, + expectedUrl: `http://localhost/${examId}.html`, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + reviewMode: false, + reviewSessionId: null, + practiceMode: 'single', + readOnly: false, + submittedRecordId: String(recordId) + }; + harness.app.examWindows = new Map([[examId, info]]); + harness.app.setupExamWindowCommunication(examWindow, examId); + const handler = harness.app.messageHandlers.get(examId); + assert.strictEqual(typeof handler, 'function', 'submitted message handler should be registered'); + return { examWindow, info, handler }; +} + +async function testSingleRecordTokenGateAndMerge() { + const original = { + id: 'record-single', + examId: 'reading-p1', + status: 'completed', + scoreInfo: { correct: 8, total: 10, percentage: 80 }, + duration: 612, + date: '2026-07-01T00:00:00.000Z', + realData: { source: 'unified-reading', noteText: 'old' } + }; + const harness = createHarness(original); + const { examWindow, review, info, handler } = bindReviewProtocol(harness, original, 'reading-p1'); + const base = { + examId: 'reading-p1', + recordId: original.id, + reviewSessionId: review.sessionId, + sessionId: info.expectedSessionId, + annotations: { + noteText: 'updated', + notes: [{ id: 'n1', body: 'body', outlineId: 'o1' }], + noteOutlines: [{ id: 'o1', title: 'Outline' }], + highlights: [{ id: 'h1', text: 'quote', noteId: 'n1' }], + markedQuestions: ['q2'], + scrollY: 245 + } + }; + + await send(handler, examWindow, { ...base, windowSessionToken: 'forged-token' }); + assert.strictEqual(harness.saveCalls.length, 0, 'forged window token must be rejected'); + await send(handler, examWindow, { ...base, sessionId: 'stale-session', windowSessionToken: info.windowSessionToken }); + assert.strictEqual(harness.saveCalls.length, 0, 'stale session id must be rejected'); + await send(handler, examWindow, { ...base, reviewSessionId: 'review-forged', windowSessionToken: info.windowSessionToken }); + assert.strictEqual(harness.saveCalls.length, 0, 'foreign review session must be rejected'); + const { recordId: _omittedRecordId, ...withoutRecordId } = base; + await send(handler, examWindow, { ...withoutRecordId, windowSessionToken: info.windowSessionToken }); + assert.strictEqual(harness.saveCalls.length, 0, 'missing record binding must be rejected'); + await send( + handler, + examWindow, + { ...base, windowSessionToken: info.windowSessionToken }, + 'READING_ANNOTATION_SYNC', + { origin: 'https://attacker.invalid' } + ); + assert.strictEqual(harness.saveCalls.length, 0, 'wrong origin must be rejected'); + await send( + handler, + examWindow, + { ...base, windowSessionToken: info.windowSessionToken }, + 'READING_ANNOTATION_SYNC', + { source: { name: examWindow.name, location: examWindow.location } } + ); + assert.strictEqual(harness.saveCalls.length, 0, 'lookalike WindowProxy must be rejected'); + await send( + handler, + examWindow, + { ...base, windowSessionToken: info.windowSessionToken }, + 'READING_ANNOTATION_SYNC', + { envelopeSource: 'exam_host' } + ); + assert.strictEqual(harness.saveCalls.length, 0, 'wrong source tag must be rejected'); + + await send(handler, examWindow, { ...base, windowSessionToken: info.windowSessionToken }); + assert.strictEqual(harness.saveCalls.length, 1, 'valid annotation sync should save once'); + const call = harness.saveCalls[0]; + assert.strictEqual(Object.prototype.hasOwnProperty.call(call.command, 'updateStats'), false, 'annotation command must not expose a stats toggle'); + assert.strictEqual(call.command.recordId, original.id); + assert.strictEqual(call.command.examId, 'reading-p1'); + assert.deepStrictEqual(call.record.scoreInfo, original.scoreInfo, 'score must be preserved'); + assert.strictEqual(call.record.duration, original.duration, 'duration must be preserved'); + assert.strictEqual(call.record.status, original.status, 'completion status must be preserved'); + assert.strictEqual(call.record.date, original.date, 'completion date must be preserved'); + assert.strictEqual(call.record.highlights[0].noteId, 'n1', 'highlight noteId link must survive host merge'); + assert.strictEqual(call.record.realData.notes[0].id, 'n1', 'canonical replay data should receive notes'); +} + +async function testSuiteEntryScopedMerge() { + const original = { + id: 'record-suite', + examId: 'suite-record-suite', + status: 'completed', + scoreInfo: { correct: 20, total: 30 }, + duration: 3200, + suiteEntries: [ + { examId: 'reading-p1', scoreInfo: { correct: 7, total: 10 }, notes: [{ id: 'old-p1' }] }, + { examId: 'reading-p2', scoreInfo: { correct: 6, total: 10 }, notes: [{ id: 'keep-p2' }] } + ] + }; + const harness = createHarness(original); + const { examWindow, review, info, handler } = bindReviewProtocol(harness, original, 'reading-p1'); + await send(handler, examWindow, { + examId: 'reading-p1', + recordId: original.id, + reviewSessionId: review.sessionId, + sessionId: info.expectedSessionId, + windowSessionToken: info.windowSessionToken, + notes: [{ id: 'new-p1' }], + noteOutlines: [] + }); + assert.strictEqual(harness.saveCalls.length, 1, 'suite annotation should save aggregate record once'); + const saved = harness.saveCalls[0].record; + assert.strictEqual(saved.suiteEntries[0].notes[0].id, 'new-p1', 'matching suite entry should be updated'); + assert.strictEqual(saved.suiteEntries[1].notes[0].id, 'keep-p2', 'other suite entry must remain unchanged'); + assert.deepStrictEqual(saved.scoreInfo, original.scoreInfo, 'aggregate score must remain unchanged'); +} + +async function testAnnotationWritesAreSerialized() { + const original = { + id: 'record-race', + examId: 'reading-p1', + scoreInfo: { correct: 8, total: 10 }, + notes: [{ id: 'old' }] + }; + let releaseFirstSave; + let markFirstSaveStarted; + const firstSaveStarted = new Promise((resolve) => { markFirstSaveStarted = resolve; }); + const harness = createHarness(original, { + async beforeSave(_record, saveNumber) { + if (saveNumber !== 1) return; + markFirstSaveStarted(); + await new Promise((resolve) => { releaseFirstSave = resolve; }); + } + }); + const { examWindow, review, info, handler } = bindReviewProtocol(harness, original, 'reading-p1'); + const base = { + examId: 'reading-p1', + recordId: original.id, + reviewSessionId: review.sessionId, + sessionId: info.expectedSessionId, + windowSessionToken: info.windowSessionToken + }; + + const first = send(handler, examWindow, { ...base, notes: [{ id: 'first' }] }); + await firstSaveStarted; + const second = send(handler, examWindow, { ...base, notes: [{ id: 'second' }] }); + releaseFirstSave(); + const settled = await Promise.allSettled([first, second]); + + assert.strictEqual(harness.saveCalls.length, 2, 'both valid annotation snapshots should reach the domain boundary'); + assert.strictEqual(harness.conflicts.length, 1, 'a stale concurrent annotation must produce a deterministic conflict'); + assert.strictEqual(settled.filter((result) => result.status === 'rejected').length, 1); + assert.strictEqual( + harness.records.get(original.id).notes[0].id, + 'second', + 'later annotation snapshot must win even when the prior write is slow' + ); +} + +function testSuiteDraftCarriesStructuredNotes() { + const harness = createHarness({ id: 'unused', examId: 'unused' }); + const draft = harness.app._buildSuiteDraftSnapshot({ + draft: { + answers: { q1: 'A' }, + notes: [{ id: 'n1', body: 'draft' }], + noteOutlines: [{ id: 'o1', title: 'Draft outline' }], + highlights: [{ id: 'h1', noteId: 'n1' }] + }, + draftUpdatedAt: 1234 + }); + assert.strictEqual(draft.notes[0].id, 'n1', 'suite draft should retain structured notes'); + assert.strictEqual(draft.noteOutlines[0].id, 'o1', 'suite draft should retain outlines'); + assert.strictEqual(draft.highlights[0].noteId, 'n1', 'suite draft should retain highlight noteId'); + assert.strictEqual(draft.updatedAt, 1234, 'suite draft ordering timestamp should be retained'); +} + +async function testLiveDraftUsesIsolatedTokenGatedStore() { + const harness = createHarness({ id: 'unused-live', examId: 'reading-live' }); + const { examWindow, info, handler } = bindLiveProtocol(harness, 'reading-live'); + const base = { + examId: 'reading-live', + sessionId: info.expectedSessionId, + windowSessionToken: info.windowSessionToken, + draftUpdatedAt: 2000, + draft: { + answers: { q1: 'A' }, + highlights: [{ id: 'h-live', noteId: 'n-live' }], + notes: [{ id: 'n-live', body: 'draft note' }], + noteOutlines: [{ id: 'o-live', title: 'Draft' }], + markedQuestions: ['q1'], + noteText: 'draft note', + scrollY: 88, + updatedAt: 2000 + } + }; + + await send(handler, examWindow, { ...base, windowSessionToken: 'forged' }, 'READING_DRAFT_SYNC'); + assert.strictEqual(harness.getDrafts().length, 0, 'forged draft token must not write recovery data'); + + await send(handler, examWindow, base, 'READING_DRAFT_SYNC'); + assert.strictEqual(harness.saveCalls.length, 0, 'in-progress drafts must not enter practice records'); + const stored = harness.getDrafts().find((draft) => draft.id === 'reading-draft:reading-live'); + assert.strictEqual(stored.notes[0].id, 'n-live', 'structured notes should persist in recovery drafts'); + assert.strictEqual(stored.highlights[0].noteId, 'n-live', 'draft highlight noteId should persist'); + + await send(handler, examWindow, { + ...base, + draftUpdatedAt: 1000, + draft: { ...base.draft, notes: [{ id: 'stale' }], updatedAt: 1000 } + }, 'READING_DRAFT_SYNC'); + assert.strictEqual( + harness.getDrafts().find((draft) => draft.id === 'reading-draft:reading-live').notes[0].id, + 'n-live', + 'older draft snapshots must not replace newer state' + ); + assert.strictEqual( + await harness.app.clearReadingDraftForExam('reading-live', { sessionId: info.expectedSessionId }), + true, + 'matching submitted draft should be discarded by entity id' + ); + assert.strictEqual(harness.getDrafts().length, 0); + + const isolatedHarness = createHarness({ id: 'unused-library-live', examId: 'reading-library-live' }); + const isolated = bindLiveProtocol(isolatedHarness, 'reading-library-live', 'library-a'); + const isolatedBase = { + examId: 'reading-library-live', + sessionId: isolated.info.expectedSessionId, + windowSessionToken: isolated.info.windowSessionToken, + draftUpdatedAt: 3000, + draft: { notes: [{ id: 'note-a' }], updatedAt: 3000 } + }; + await send(isolated.handler, isolated.examWindow, isolatedBase, 'READING_DRAFT_SYNC'); + isolated.info.libraryConfigurationId = 'library-b'; + await send(isolated.handler, isolated.examWindow, { + ...isolatedBase, + draftUpdatedAt: 4000, + draft: { notes: [{ id: 'note-b' }], updatedAt: 4000 } + }, 'READING_DRAFT_SYNC'); + assert.deepStrictEqual( + isolatedHarness.getDrafts().map((draft) => draft.id).sort(), + ['reading-draft:reading-library-live:library-a', 'reading-draft:reading-library-live:library-b'] + ); + assert.strictEqual( + (await isolatedHarness.app.getReadingDraftForExam('reading-library-live', { libraryConfigurationId: 'library-a' })).notes[0].id, + 'note-a' + ); + assert.strictEqual( + (await isolatedHarness.app.getReadingDraftForExam('reading-library-live', { libraryConfigurationId: 'library-b' })).notes[0].id, + 'note-b' + ); + await isolatedHarness.app.clearReadingDraftForExam('reading-library-live', { libraryConfigurationId: 'library-a' }); + assert.deepStrictEqual(isolatedHarness.getDrafts().map((draft) => draft.id), ['reading-draft:reading-library-live:library-b']); +} + +async function testInFlightDraftCommitUsesRegistrationGuard() { + let markCommitEntered; + let releaseCommit; + const commitEntered = new Promise((resolve) => { markCommitEntered = resolve; }); + const commitGate = new Promise((resolve) => { releaseCommit = resolve; }); + const harness = createHarness({ id: 'unused-inflight', examId: 'reading-inflight' }, { + async beforeDraftCommit() { + markCommitEntered(); + await commitGate; + } + }); + const { info } = bindLiveProtocol(harness, 'reading-inflight'); + info.sessionGeneration = 1; + const pending = harness.app.handleReadingDraftSync('reading-inflight', { + examId: 'reading-inflight', + sessionId: info.expectedSessionId, + windowSessionGeneration: info.sessionGeneration, + draftUpdatedAt: 5000, + draft: { answers: { q1: 'stale-inflight' }, updatedAt: 5000 } + }, info); + + await commitEntered; + const replacementWindow = { + name: 'practice-reading-inflight-replacement', + closed: false, + location: { href: 'http://localhost/reading-inflight.html' }, + postMessage() {} + }; + const replacementInfo = { + ...info, + window: replacementWindow, + expectedSessionId: 'session-reading-inflight-replacement', + windowSessionToken: 'token-reading-inflight-replacement', + windowSessionTokenSessionId: 'session-reading-inflight-replacement', + sessionGeneration: 2 + }; + harness.app.examWindows.set('reading-inflight', replacementInfo); + releaseCommit(); + + assert.strictEqual(await pending, false, 'a replaced registration must reject its in-flight draft commit'); + assert.deepStrictEqual(harness.getDrafts(), [], 'the rejected generation must not reach recovery storage'); + assert.strictEqual(harness.app.examWindows.get('reading-inflight'), replacementInfo); +} + +async function testInFlightDraftRejectsInPlaceRegistrationMutation() { + let markCommitEntered; + let releaseCommit; + const commitEntered = new Promise((resolve) => { markCommitEntered = resolve; }); + const commitGate = new Promise((resolve) => { releaseCommit = resolve; }); + const harness = createHarness({ id: 'unused-inplace', examId: 'reading-inplace' }, { + async beforeDraftCommit() { + markCommitEntered(); + await commitGate; + } + }); + const { info } = bindLiveProtocol(harness, 'reading-inplace'); + const pending = harness.app.handleReadingDraftSync('reading-inplace', { + examId: 'reading-inplace', + sessionId: info.expectedSessionId, + draftUpdatedAt: 5100, + draft: { answers: { q1: 'stale-inplace' }, updatedAt: 5100 } + }, info); + + await commitEntered; + // Reset/rebind paths can mutate the existing object rather than replacing the Map value. + info.expectedSessionId = 'session-reading-inplace-rebound'; + info.windowSessionToken = 'token-reading-inplace-rebound'; + info.windowSessionTokenSessionId = info.expectedSessionId; + info.sessionGeneration = 2; + releaseCommit(); + + assert.strictEqual(await pending, false, 'an in-place registration mutation must reject the old draft'); + assert.deepStrictEqual(harness.getDrafts(), [], 'legacy payloads without generation must still be ownership-gated'); +} + +async function testInFlightFinalDraftSurvivesWindowClose() { + let markCommitEntered; + let releaseCommit; + const commitEntered = new Promise((resolve) => { markCommitEntered = resolve; }); + const commitGate = new Promise((resolve) => { releaseCommit = resolve; }); + const harness = createHarness({ id: 'unused-pagehide', examId: 'reading-pagehide' }, { + async beforeDraftCommit() { + markCommitEntered(); + await commitGate; + } + }); + const { examWindow, info } = bindLiveProtocol(harness, 'reading-pagehide'); + info.sessionGeneration = 1; + const pending = harness.app._queueReadingDraftSync('reading-pagehide', { + examId: 'reading-pagehide', + sessionId: info.expectedSessionId, + windowSessionGeneration: info.sessionGeneration, + draftUpdatedAt: 5200, + draft: { answers: { q1: 'final-pagehide' }, updatedAt: 5200 } + }, info); + + await commitEntered; + // pagehide is accepted while live; the WindowProxy may close before the IDB put. + examWindow.closed = true; + harness.app.updateExamStatus = () => {}; + let closeSettled = false; + const closePromise = harness.app.handleExamWindowClosed('reading-pagehide', examWindow) + .then((value) => { closeSettled = true; return value; }); + await Promise.resolve(); + assert.strictEqual(closeSettled, false, 'close cleanup must wait for the accepted draft queue'); + releaseCommit(); + + assert.strictEqual(await pending, true, 'closing an otherwise exact registration must not lose its final draft'); + assert.strictEqual(await closePromise, true); + assert.strictEqual(harness.getDrafts().length, 1); + assert.strictEqual(harness.getDrafts()[0].answers.q1, 'final-pagehide'); + assert.strictEqual(harness.app.examWindows.has('reading-pagehide'), false, 'cleanup may remove the old registration after commit'); +} + +async function testWindowCloseCleanupIsRegistrationScoped() { + const harness = createHarness({ id: 'unused-close-scope', examId: 'reading-close-scope' }); + + // A late close callback with no registration must not wait on an unrelated + // draft tail and later broad-delete a registration created during that wait. + let releaseUnrelatedQueue; + harness.app.examWindows = new Map(); + harness.app._readingDraftStoreQueue = new Promise((resolve) => { releaseUnrelatedQueue = resolve; }); + const missingClose = harness.app.handleExamWindowClosed('reading-close-missing'); + const replacementWithoutPriorOwner = { window: { closed: false }, expectedSessionId: 'new-missing-owner' }; + harness.app.examWindows.set('reading-close-missing', replacementWithoutPriorOwner); + assert.strictEqual(await missingClose, false); + assert.strictEqual(harness.app.examWindows.get('reading-close-missing'), replacementWithoutPriorOwner); + releaseUnrelatedQueue(); + + const { examWindow, info } = bindLiveProtocol(harness, 'reading-close-replaced'); + info.sessionGeneration = 1; + let releaseAcceptedQueue; + harness.app._readingDraftStoreQueue = new Promise((resolve) => { releaseAcceptedQueue = resolve; }); + const closing = harness.app.handleExamWindowClosed('reading-close-replaced', examWindow); + await Promise.resolve(); + + const replacementWindow = { closed: false }; + const replacementInfo = { + window: replacementWindow, + expectedSessionId: 'session-reading-close-replaced-new', + windowSessionToken: 'token-reading-close-replaced-new', + windowSessionTokenSessionId: 'session-reading-close-replaced-new', + sessionGeneration: 2, + suiteSessionId: null + }; + const replacementHandler = () => {}; + harness.app.examWindows.set('reading-close-replaced', replacementInfo); + harness.app.messageHandlers.set('reading-close-replaced', replacementHandler); + releaseAcceptedQueue(); + + assert.strictEqual(await closing, false, 'a close callback must stop owning the map after replacement'); + assert.strictEqual(harness.app.examWindows.get('reading-close-replaced'), replacementInfo); + assert.strictEqual(harness.app.messageHandlers.get('reading-close-replaced'), replacementHandler); +} + +async function testCompletionPersistenceVerification() { + const persisted = { + id: 'record-persisted', + examId: 'reading-persisted', + sessionId: 'session-persisted', + endTime: '2026-07-22T10:00:00.000Z' + }; + const harness = createHarness(persisted); + assert.strictEqual( + await harness.app._isPracticeCompletionPersisted(persisted), + true, + 'an exact canonical record round-trip should authorize draft cleanup' + ); + assert.strictEqual( + await harness.app._isPracticeCompletionPersisted({ ...persisted, endTime: '2026-07-22T10:01:00.000Z' }), + false, + 'an older record with the same id must not authorize cleanup for a newer completion' + ); + assert.strictEqual( + await harness.app._isPracticeCompletionPersisted({ ...persisted, id: 'record-missing' }), + false, + 'a completion absent from the canonical store must retain its resumable draft' + ); + for (const field of ['examId', 'sessionId', 'endTime']) { + const incomplete = { ...persisted }; + delete incomplete[field]; + assert.strictEqual( + await harness.app._isPracticeCompletionPersisted(incomplete), + false, + `a completion without ${field} must not authorize cleanup` + ); + } +} + +async function testSubmittedReadingAnnotationSync() { + // 单篇阅读 final-submit 后,结果页通过宿主回传的 submittedRecordId 发送标注 + // 同步;路由守卫与 handleReadingAnnotationSync 必须按该 id 直连已存档记录, + // 不再要求 review 回放态。同时校验仍拒绝伪造的 token/session/recordId。 + const original = { + id: 'record-submitted', + examId: 'reading-submitted', + status: 'completed', + scoreInfo: { correct: 8, total: 10, percentage: 80 }, + duration: 612, + date: '2026-07-01T00:00:00.000Z', + realData: { source: 'unified-reading', noteText: 'old' } + }; + const harness = createHarness(original); + const { examWindow, info, handler } = bindSubmittedProtocol(harness, 'reading-submitted', original.id); + const base = { + examId: 'reading-submitted', + recordId: original.id, + reviewSessionId: null, + sessionId: info.expectedSessionId, + annotations: { + noteText: 'updated-after-submit', + notes: [{ id: 'n1', body: 'post-submit note', outlineId: 'o1' }], + noteOutlines: [{ id: 'o1', title: 'Outline' }], + highlights: [{ id: 'h1', text: 'quote', noteId: 'n1' }], + markedQuestions: ['q5'], + scrollY: 305 + } + }; + + await send(handler, examWindow, { ...base, windowSessionToken: 'forged-token' }); + assert.strictEqual(harness.saveCalls.length, 0, 'submitted sync: forged window token must be rejected'); + await send(handler, examWindow, { ...base, sessionId: 'stale-session', windowSessionToken: info.windowSessionToken }); + assert.strictEqual(harness.saveCalls.length, 0, 'submitted sync: stale session id must be rejected'); + await send(handler, examWindow, { ...base, recordId: 'record-forged', windowSessionToken: info.windowSessionToken }); + assert.strictEqual(harness.saveCalls.length, 0, 'submitted sync: foreign recordId must be rejected'); + + await send(handler, examWindow, { ...base, windowSessionToken: info.windowSessionToken }); + assert.strictEqual(harness.saveCalls.length, 1, 'submitted sync: valid payload should save once'); + const call = harness.saveCalls[0]; + assert.strictEqual(Object.prototype.hasOwnProperty.call(call.command, 'updateStats'), false, 'submitted annotation command must not expose a stats toggle'); + assert.deepStrictEqual(call.record.scoreInfo, original.scoreInfo, 'submitted sync: score must be preserved'); + assert.strictEqual(call.record.status, original.status, 'submitted sync: completion status must be preserved'); + assert.strictEqual(call.record.realData.noteText, 'updated-after-submit', 'submitted sync: noteText must land on realData'); + assert.strictEqual(call.record.realData.notes[0].id, 'n1', 'submitted sync: notes must land on realData'); + assert.strictEqual(call.record.realData.markedQuestions[0], 'q5', 'submitted sync: markedQuestions must land on realData'); + assert.strictEqual(call.record.highlights[0].noteId, 'n1', 'submitted sync: highlight link must survive host merge'); +} + +async function testSubmittedRecordAnnouncementRequiresCanonicalPersistence() { + const candidate = { + id: 'record-completion', + examId: 'reading-completion', + sessionId: 'session-completion', + endTime: '2026-07-22T11:00:00.000Z' + }; + const harness = createHarness(candidate); + harness.records.delete(candidate.id); + const announcements = []; + const clearedDrafts = []; + let cleanupCalls = 0; + harness.app.components.practiceRecorder = { + async handleSessionCompleted() { + return clone(candidate); + } + }; + harness.app._normalizeListeningSpellingErrors = () => {}; + harness.app._announceSubmittedReadingRecord = (...args) => announcements.push(args); + harness.app.clearReadingDraftForExam = async (...args) => clearedDrafts.push(args); + harness.app.updateExamStatus = () => {}; + harness.app.showRealCompletionNotification = async () => {}; + harness.app.cleanupExamSession = () => { cleanupCalls += 1; }; + harness.app._isResetCapableUnifiedReadingCompletion = () => false; + const submitWindow = { + closed: false, + location: { href: `http://localhost/${candidate.examId}.html` }, + _messages: [], + postMessage(message) { this._messages.push(clone(message)); } + }; + harness.app.examWindows = new Map([[candidate.examId, { + window: submitWindow, + expectedSessionId: candidate.sessionId, + sessionId: candidate.sessionId, + windowSessionToken: 'token-completion', + windowSessionTokenSessionId: candidate.sessionId, + expectedUrl: submitWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); + const submissionId = 'submission-completion'; + + await harness.app.handlePracticeComplete(candidate.examId, { + examId: candidate.examId, + sessionId: candidate.sessionId, + submissionId, + endTime: candidate.endTime + }, submitWindow); + assert.strictEqual(announcements.length, 0, 'temporary-only completion must not announce an unsaved record id'); + assert.strictEqual(clearedDrafts.length, 0, 'temporary-only completion must retain its resumable draft'); + assert.strictEqual(cleanupCalls, 0, 'unverified completion must retain the live session for retry'); + assert.strictEqual(submitWindow._messages.at(-1).type, 'PRACTICE_SUBMIT_FAILED', 'failed persistence must NACK the child'); + assert.deepStrictEqual( + clone({ + submissionId: submitWindow._messages.at(-1).data.submissionId, + sessionId: submitWindow._messages.at(-1).data.sessionId, + examId: submitWindow._messages.at(-1).data.examId, + suiteSessionId: submitWindow._messages.at(-1).data.suiteSessionId + }), + { submissionId, sessionId: candidate.sessionId, examId: candidate.examId, suiteSessionId: null }, + 'NACK must carry every submission correlation field' + ); + + harness.records.set(candidate.id, clone(candidate)); + await harness.app.handlePracticeComplete(candidate.examId, { + examId: candidate.examId, + sessionId: candidate.sessionId, + submissionId, + endTime: candidate.endTime + }, submitWindow); + assert.strictEqual(announcements.length, 1, 'canonical completion should announce its saved record id once'); + assert.strictEqual(clearedDrafts.length, 1, 'canonical completion should clear its resumable draft'); + assert.strictEqual(cleanupCalls, 1, 'verified canonical completion may clean up its live session'); + assert.strictEqual(submitWindow._messages.at(-1).type, 'PRACTICE_SUBMIT_ACK', 'verified persistence must ACK the child'); + assert.deepStrictEqual( + clone({ + submissionId: submitWindow._messages.at(-1).data.submissionId, + sessionId: submitWindow._messages.at(-1).data.sessionId, + examId: submitWindow._messages.at(-1).data.examId, + suiteSessionId: submitWindow._messages.at(-1).data.suiteSessionId + }), + { submissionId, sessionId: candidate.sessionId, examId: candidate.examId, suiteSessionId: null }, + 'ACK must carry every submission correlation field' + ); +} + +async function main() { + await testSingleRecordTokenGateAndMerge(); + await testSuiteEntryScopedMerge(); + await testAnnotationWritesAreSerialized(); + await testSubmittedReadingAnnotationSync(); + await testLiveDraftUsesIsolatedTokenGatedStore(); + await testInFlightDraftCommitUsesRegistrationGuard(); + await testInFlightDraftRejectsInPlaceRegistrationMutation(); + await testInFlightFinalDraftSurvivesWindowClose(); + await testWindowCloseCleanupIsRegistrationScoped(); + await testCompletionPersistenceVerification(); + await testSubmittedRecordAnnouncementRequiresCanonicalPersistence(); + testSuiteDraftCarriesStructuredNotes(); + process.stdout.write(JSON.stringify({ + status: 'pass', + detail: 'reading draft/annotation protocols reject forged tokens, isolate recovery entities, surface CAS conflicts, persist post-submit notes and preserve stats' + })); +} + +main().catch((error) => { + process.stdout.write(JSON.stringify({ status: 'fail', detail: error && error.stack ? error.stack : String(error) })); + process.exit(1); +}); diff --git a/developer/tests/js/resourceCore.test.js b/developer/tests/js/resourceCore.test.js index e354ca95..db4cb3a9 100644 --- a/developer/tests/js/resourceCore.test.js +++ b/developer/tests/js/resourceCore.test.js @@ -22,37 +22,20 @@ function recordResult(name, passed, detail) { } function createResourceCoreHarness() { - const storageState = new Map(); - const localStorageState = new Map(); - - const localStorage = { - getItem(key) { - return localStorageState.has(key) ? localStorageState.get(key) : null; - }, - setItem(key, value) { - localStorageState.set(key, String(value)); - }, - removeItem(key) { - localStorageState.delete(key); - } - }; + const libraryIndexes = new Map(); const windowStub = { console, - localStorage, - storage: { - async get(key, fallback = null) { - return storageState.has(key) ? storageState.get(key) : fallback; - }, - async set(key, value) { - storageState.set(key, JSON.parse(JSON.stringify(value))); - return true; - }, - async remove(key) { - storageState.delete(key); - return true; + AppData: { + ready: Promise.resolve(), + library: { + async getIndex(configurationId) { + return JSON.parse(JSON.stringify(libraryIndexes.get(String(configurationId)) || [])); + }, + async getActive() { return null; } } }, + __libraryIndexes: libraryIndexes, location: { href: 'file:///Users/test/index.html' }, @@ -62,7 +45,6 @@ function createResourceCoreHarness() { const sandbox = { window: windowStub, console, - localStorage, location: windowStub.location, fetch: async () => ({ ok: true, status: 200 }), setTimeout, @@ -242,19 +224,20 @@ function testRuntimeResourceTakesPrecedence(context, ResourceCore) { }); } -async function testDeletePathMapForConfiguration(context, ResourceCore) { - const key = ResourceCore.getPathMapStorageKey('custom_config'); - await context.window.storage.set(key, { - reading: { root: 'ReadingCustom/', exceptions: {} }, - listening: { root: 'ListeningCustom/', exceptions: {} } - }); - - const deleted = await ResourceCore.deletePathMapForConfiguration('custom_config'); +async function testPathMapDerivedFromLibraryIndex(context, ResourceCore) { + const index = [ + { id: 'custom-reading', type: 'reading', path: 'ReadingCustom/set-a/', filename: 'index.html' }, + { id: 'custom-listening', type: 'listening', path: 'ListeningCustom/set-b/', filename: 'index.html' } + ]; + context.window.__libraryIndexes.set('custom_config', index); - assert.strictEqual(deleted, true, '删除 path map 应返回 true'); - assert.strictEqual(await context.window.storage.get(key, null), null, '删除配置时 path map 存储键必须被移除'); + const pathMap = await ResourceCore.loadPathMapForConfiguration('custom_config'); + assert.strictEqual(pathMap.reading.root, 'ReadingCustom/set-a/'); + assert.strictEqual(pathMap.listening.root, 'ListeningCustom/set-b/'); + assert.strictEqual(await ResourceCore.deletePathMapForConfiguration('custom_config'), true, '删除派生缓存应是无状态操作'); + assert.deepStrictEqual(await context.window.AppData.library.getIndex('custom_config'), index, '派生 path map 操作不得修改题库权威 index'); - recordResult('ResourceCore 删除指定配置 path map', true, { key }); + recordResult('ResourceCore 从 AppData.library index 派生 path map', true, { pathMap }); } async function main() { @@ -268,7 +251,7 @@ async function main() { testDefaultRootStillWorks(ResourceCore); testExplicitEmptyRootDoesNotFallback(ResourceCore); testRuntimeResourceTakesPrecedence(context, ResourceCore); - await testDeletePathMapForConfiguration(context, ResourceCore); + await testPathMapDerivedFromLibraryIndex(context, ResourceCore); console.log(JSON.stringify({ status: 'pass', diff --git a/developer/tests/js/reviewHighlightDictionaryProtocol.test.js b/developer/tests/js/reviewHighlightDictionaryProtocol.test.js new file mode 100644 index 00000000..d0d679a2 --- /dev/null +++ b/developer/tests/js/reviewHighlightDictionaryProtocol.test.js @@ -0,0 +1,143 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../..'); +const sourcePath = path.join(repoRoot, 'js/runtime/reviewHighlightDictionary.js'); +const source = fs.readFileSync(sourcePath, 'utf8'); +const instrumentedSource = source.replace( + ' const api = {', + ` global.__reviewHighlightDictionaryTestHooks = { + configure(options) { + currentOptions = { ...currentOptions, ...(options || {}) }; + }, + setActiveLookup(lookup, selectedText) { + activeLookup = lookup || null; + activeHighlight = { textContent: selectedText || '' }; + }, + saveActiveLookup + }; + + const api = {` +); +assert.notStrictEqual(instrumentedSource, source, 'test instrumentation marker must match production source'); + +class HTMLElement {} +class HTMLButtonElement extends HTMLElement { + constructor() { + super(); + this.textContent = '加入生词'; + this.disabled = false; + } +} + +function createHarness() { + let uuidSequence = 0; + const sandbox = { + window: null, + document: {}, + HTMLElement, + HTMLButtonElement, + Node: class Node {}, + crypto: { + randomUUID() { + uuidSequence += 1; + return `request-${uuidSequence}`; + } + }, + console, + setTimeout, + clearTimeout + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + vm.runInContext(instrumentedSource, vm.createContext(sandbox), { + filename: 'js/runtime/reviewHighlightDictionary.js' + }); + return sandbox; +} + +async function run() { + const harness = createHarness(); + const hooks = harness.__reviewHighlightDictionaryTestHooks; + const dictionary = harness.ReviewHighlightDictionary; + const posted = []; + + hooks.configure({ + postMessage(type, payload) { + posted.push({ type, payload }); + return true; + } + }); + hooks.setActiveLookup({ + term: 'resilient', + zh: '有韧性的', + en: 'able to recover quickly', + source: 'local' + }, 'resilient'); + + const failedButton = new HTMLButtonElement(); + const failedSave = hooks.saveActiveLookup(failedButton); + assert.strictEqual(posted.length, 1); + assert.strictEqual(posted[0].type, 'VOCAB_HIGHLIGHT_SAVE'); + assert.strictEqual(posted[0].payload.requestId, 'vocab-highlight-request-1'); + assert.strictEqual(failedButton.textContent, '加入生词', 'bare postMessage delivery must not show success'); + assert.strictEqual( + dictionary.handleSaveOutcome({ requestId: 'unknown-request' }, true), + false, + 'unknown ACK must not settle another request' + ); + assert.strictEqual(failedButton.textContent, '加入生词'); + assert.strictEqual( + dictionary.handleSaveOutcome({ requestId: posted[0].payload.requestId }, false), + true, + 'matching FAILED must settle the pending request' + ); + await failedSave; + assert.strictEqual(failedButton.textContent, '保存失败'); + assert.strictEqual(failedButton.disabled, false); + + const ackButton = new HTMLButtonElement(); + const ackSave = hooks.saveActiveLookup(ackButton); + assert.strictEqual(posted[1].payload.requestId, 'vocab-highlight-request-2'); + dictionary.handleSaveOutcome({ requestId: posted[1].payload.requestId }, true); + await ackSave; + assert.strictEqual(ackButton.textContent, '已加入'); + assert.strictEqual(ackButton.disabled, true); + + const directWrites = []; + harness.AppData = { + ready: Promise.resolve(), + vocab: { + async upsertCollectionWord(collectionId, word) { + directWrites.push({ collectionId, word }); + } + } + }; + hooks.configure({ + postMessage() { + return false; + } + }); + hooks.setActiveLookup({ term: 'durable', zh: '持久的' }, 'durable'); + const directButton = new HTMLButtonElement(); + await hooks.saveActiveLookup(directButton); + assert.strictEqual(directWrites.length, 1, 'unavailable host route must commit through direct AppData'); + assert.strictEqual(directWrites[0].collectionId, 'reading-highlights'); + assert.strictEqual(directButton.textContent, '已加入'); + assert.strictEqual(directButton.disabled, true); + + console.log(JSON.stringify({ + status: 'pass', + detail: 'vocab requestId ACK/FAILED and direct-commit UI checks passed' + })); +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/developer/tests/js/scoreStorageCorrectAnswers.test.js b/developer/tests/js/scoreStorageCorrectAnswers.test.js deleted file mode 100644 index 3f6d7b18..00000000 --- a/developer/tests/js/scoreStorageCorrectAnswers.test.js +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -import assert from 'assert'; -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '../../..'); -const results = []; - -function loadScoreStorageHarness() { - const windowStub = { - AnswerMatchCore: { - compareAnswers(userAnswer, correctAnswer) { - return String(userAnswer || '').trim().toLowerCase() === String(correctAnswer || '').trim().toLowerCase(); - } - } - }; - const sandbox = { - window: windowStub, - console: { - log() {}, - warn() {}, - error() {}, - info() {}, - debug() {} - }, - Date, - Math, - JSON, - Object, - Array, - String, - Number - }; - sandbox.globalThis = sandbox.window; - vm.createContext(sandbox); - const source = fs.readFileSync(path.join(repoRoot, 'js/core/scoreStorage.js'), 'utf8'); - vm.runInContext(source, sandbox, { filename: 'js/core/scoreStorage.js' }); - - const scoreStorage = Object.create(windowStub.ScoreStorage.prototype); - scoreStorage.currentVersion = 'test-version'; - scoreStorage.maxRecords = 1000; - scoreStorage.windowStub = windowStub; - return scoreStorage; -} - -function recordResult(name, passed, detail) { - results.push({ name, passed, detail, timestamp: new Date().toISOString() }); -} - -function createConflictRecord() { - return { - id: 'score-storage-correct-map', - examId: 'reading-score-storage', - type: 'reading', - answers: { q1: 'A', q2: 'D', q3: 'E' }, - correctAnswerMap: { q1: 'A' }, - correctAnswers: { q1: 'B', q2: 'C', q3: 'F' }, - totalQuestions: 3, - realData: { - correctAnswerMap: { q2: 'D' }, - correctAnswers: { q3: 'E' } - }, - startTime: '2026-05-24T10:00:00.000Z', - endTime: '2026-05-24T10:05:00.000Z' - }; -} - -async function testFallbackStandardizeRecordCanonicalCorrectMapWins() { - const scoreStorage = loadScoreStorageHarness(); - const standardized = scoreStorage.standardizeRecord(createConflictRecord()); - - assert.strictEqual(standardized.correctAnswers, 2, '对象型 correctAnswers 不能污染 standardizeRecord 数字答对数'); - assert.strictEqual(standardized.score, 2, 'score 应从 canonical 正确答案表和用户答案推导'); - assert.strictEqual(standardized.correctAnswerMap.q1, 'A', 'standardizeRecord 应优先使用 canonical correctAnswerMap'); - assert.strictEqual(standardized.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应先于 legacy correctAnswers 补缺'); - assert.strictEqual(standardized.correctAnswerMap.q3, 'F', 'legacy correctAnswers 只能作为缺失题目的补缺来源'); - assert.strictEqual(standardized.realData.correctAnswers.q1, 'A', 'realData.correctAnswers 应镜像 canonical map'); - assert.strictEqual(standardized.realData.correctAnswerMap.q2, 'D', 'realData.correctAnswerMap 应镜像 canonical map'); - - recordResult('ScoreStorage fallback standardizeRecord canonical correctAnswerMap wins', true, { - correctAnswerMap: standardized.correctAnswerMap, - correctAnswers: standardized.correctAnswers - }); -} - -async function testNormalizeLegacyRecordCanonicalCorrectMapWins() { - const scoreStorage = loadScoreStorageHarness(); - const normalized = scoreStorage.normalizeLegacyRecord(createConflictRecord()); - - assert.strictEqual(normalized.correctAnswers, 2, 'legacy normalize 不能保留对象型 correctAnswers 到数字字段'); - assert.strictEqual(normalized.correctAnswerMap.q1, 'A', 'legacy normalize 应优先使用 canonical correctAnswerMap'); - assert.strictEqual(normalized.correctAnswerMap.q2, 'D', 'legacy normalize 应使用 realData.correctAnswerMap 补缺'); - assert.strictEqual(normalized.realData.correctAnswers.q1, 'A', 'legacy realData.correctAnswers 应镜像 canonical map'); - - recordResult('ScoreStorage normalizeLegacyRecord canonical correctAnswerMap wins', true, { - correctAnswerMap: normalized.correctAnswerMap, - correctAnswers: normalized.correctAnswers - }); -} - -async function testNormalizeRecordFieldsCanonicalCorrectMapWins() { - const scoreStorage = loadScoreStorageHarness(); - const normalized = scoreStorage.normalizeRecordFields(createConflictRecord()); - - assert.strictEqual(normalized.correctAnswers, 2, 'normalizeRecordFields 不能保留对象型 correctAnswers 到数字字段'); - assert.strictEqual(normalized.correctAnswerMap.q1, 'A', 'normalizeRecordFields 应优先使用 canonical correctAnswerMap'); - assert.strictEqual(normalized.correctAnswerMap.q2, 'D', 'normalizeRecordFields 应使用 realData.correctAnswerMap 补缺'); - assert.strictEqual(normalized.realData.correctAnswers.q1, 'A', 'normalizeRecordFields realData.correctAnswers 应镜像 canonical map'); - assert.strictEqual(normalized.realData.correctAnswerMap.q2, 'D', 'normalizeRecordFields realData.correctAnswerMap 应镜像 canonical map'); - - recordResult('ScoreStorage normalizeRecordFields canonical correctAnswerMap wins', true, { - correctAnswerMap: normalized.correctAnswerMap, - correctAnswers: normalized.correctAnswers - }); -} - -async function testStorageAdapterRecordsAndStatsReadsUsePracticeRecordApiAndRejectWrites() { - const scoreStorage = loadScoreStorageHarness(); - const calls = []; - const records = [{ id: 'api-record-a', examId: 'reading-api-a' }]; - const stats = { totalPractices: 1 }; - - scoreStorage.repositories = { - practice: { - async list() { - throw new Error('ScoreStorage storage adapter must not raw-list practice records'); - }, - async overwrite() { - throw new Error('ScoreStorage storage adapter must not raw-overwrite practice records'); - }, - async clear() { - throw new Error('ScoreStorage storage adapter must not raw-clear practice records'); - } - }, - meta: { - async get(key, fallback = null) { - if (key === 'user_stats') { - throw new Error('ScoreStorage storage adapter must not raw-read user_stats'); - } - calls.push({ type: 'meta.get', key }); - return fallback; - }, - async set(key, value) { - if (key === 'user_stats') { - throw new Error('ScoreStorage storage adapter must not raw-write user_stats'); - } - calls.push({ type: 'meta.set', key, value }); - return true; - }, - async remove(key) { - if (key === 'user_stats') { - throw new Error('ScoreStorage storage adapter must not raw-remove user_stats'); - } - calls.push({ type: 'meta.remove', key }); - return true; - } - }, - backups: { - async list() { - calls.push({ type: 'backups.list' }); - return []; - }, - async saveAll(value) { - calls.push({ type: 'backups.saveAll', value }); - return true; - }, - async clear() { - calls.push({ type: 'backups.clear' }); - return true; - } - } - }; - - scoreStorage.storageKeys = { - practiceRecords: 'practice_records', - userStats: 'user_stats', - storageVersion: 'storage_version', - backupData: 'manual_backups' - }; - - const apiRecords = records.map(record => Object.assign({}, record)); - scoreStorage.windowStub.PracticeRecordAPI = { - async list() { - calls.push({ type: 'api.list' }); - return apiRecords.map(record => Object.assign({}, record)); - }, - async replace(nextRecords, options = {}) { - calls.push({ type: 'api.replace', records: nextRecords.map(record => Object.assign({}, record)), options }); - throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.replace'); - }, - async clear(options = {}) { - calls.push({ type: 'api.clear', options }); - throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.clear'); - }, - async readStats(options = {}) { - calls.push({ type: 'api.readStats', fallback: options.fallback }); - return Object.assign({}, stats); - }, - async writeStats(nextStats) { - calls.push({ type: 'api.writeStats', stats: Object.assign({}, nextStats) }); - throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.writeStats'); - }, - async resetStats(nextStats = null) { - calls.push({ type: 'api.resetStats', stats: nextStats }); - throw new Error('ScoreStorage adapter must not call PracticeRecordAPI.resetStats'); - } - }; - - const adapter = scoreStorage.createStorageAdapter(); - const listed = await adapter.get('practice_records', []); - assert.strictEqual(listed.length, 1, 'adapter.get(practice_records) 应委托 PracticeRecordAPI.list'); - - await assert.rejects( - () => adapter.set('practice_records', [{ id: 'api-record-b', examId: 'reading-api-b' }]), - /ScoreStorage\.storage\.set\(practice_records\) is disabled/, - 'adapter.set(practice_records) 必须禁用' - ); - assert.strictEqual(apiRecords[0].id, 'api-record-a', 'adapter.set(practice_records) 不能改 canonical records'); - - await assert.rejects( - () => adapter.remove('practice_records'), - /ScoreStorage\.storage\.remove\(practice_records\) is disabled/, - 'adapter.remove(practice_records) 必须禁用' - ); - assert.strictEqual(apiRecords.length, 1, 'adapter.remove(practice_records) 不能清空 canonical records'); - - const readStats = await adapter.get('user_stats', { totalPractices: 0 }); - assert.strictEqual(readStats.totalPractices, 1, 'adapter.get(user_stats) 应委托 PracticeRecordAPI.readStats'); - - await assert.rejects( - () => adapter.set('user_stats', { totalPractices: 3 }), - /ScoreStorage\.storage\.set\(user_stats\) is disabled/, - 'adapter.set(user_stats) 必须禁用' - ); - assert.strictEqual(stats.totalPractices, 1, 'adapter.set(user_stats) 不能改 canonical stats'); - - await assert.rejects( - () => adapter.remove('user_stats'), - /ScoreStorage\.storage\.remove\(user_stats\) is disabled/, - 'adapter.remove(user_stats) 必须禁用' - ); - assert.strictEqual(stats.totalPractices, 1, 'adapter.remove(user_stats) 不能重置 canonical stats'); - - assert(!calls.some(call => call.type === 'api.replace'), 'adapter.set(practice_records) 不应调用 PracticeRecordAPI.replace'); - assert(!calls.some(call => call.type === 'api.clear'), 'adapter.remove(practice_records) 不应调用 PracticeRecordAPI.clear'); - assert(!calls.some(call => call.type === 'api.writeStats'), 'adapter.set(user_stats) 不应调用 PracticeRecordAPI.writeStats'); - assert(!calls.some(call => call.type === 'api.resetStats'), 'adapter.remove(user_stats) 不应调用 PracticeRecordAPI.resetStats'); - assert(!calls.some(call => call.type.startsWith('meta.') && call.key === 'user_stats'), 'adapter 不应直接读写 raw user_stats'); - recordResult('ScoreStorage storage adapter records/stats reads use PracticeRecordAPI and writes fail-fast', true, { - calls: calls.map(call => call.type) - }); -} - -async function runAllTests() { - const tests = [ - testFallbackStandardizeRecordCanonicalCorrectMapWins, - testNormalizeLegacyRecordCanonicalCorrectMapWins, - testNormalizeRecordFieldsCanonicalCorrectMapWins, - testStorageAdapterRecordsAndStatsReadsUsePracticeRecordApiAndRejectWrites - ]; - for (const testFn of tests) { - try { - await testFn(); - } catch (error) { - recordResult(testFn.name, false, { error: error.message, stack: error.stack }); - } - } -} - -function printJsonReport() { - const totalTests = results.length; - const passedTests = results.filter(result => result.passed).length; - const failedTests = totalTests - passedTests; - const report = { - status: failedTests === 0 ? 'pass' : 'fail', - detail: `${passedTests}/${totalTests} 测试通过`, - summary: { totalTests, passedTests, failedTests }, - failedTests: results.filter(result => !result.passed) - }; - console.log(JSON.stringify(report, null, 2)); - return report; -} - -(async function main() { - await runAllTests(); - const report = printJsonReport(); - process.exit(report.status === 'pass' ? 0 : 1); -})(); diff --git a/developer/tests/js/serviceFacade.test.js b/developer/tests/js/serviceFacade.test.js deleted file mode 100644 index 15a5741e..00000000 --- a/developer/tests/js/serviceFacade.test.js +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function loadScript(relativePath, context) { - const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function stripComments(source) { - return String(source || '') - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\/\/.*$/gm, ''); -} - -function isForwardOnlyFeatureSource(source) { - const code = stripComments(source); - const normalized = code.replace(/\s+/g, ' ').trim(); - if (!normalized) { - return true; - } - - const reExportOnly = /^export\s+(\*\s+from|\{[^}]+\}\s+from)\s+['"][^'"]+['"]\s*;?\s*$/; - if (reExportOnly.test(normalized)) { - return true; - } - - const commonJsProxy = /^module\.exports\s*=\s*require\(['"][^'"]+['"]\)\s*;?\s*$/; - if (commonJsProxy.test(normalized)) { - return true; - } - - const functionCount = (code.match(/\bfunction\b/g) || []).length; - const directAliasPattern = /(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*=\s*(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*;?/; - const objectAssignAliasPattern = /(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*=\s*Object\.assign\(\{\}\s*,\s*(window|globalThis|global)\.[A-Za-z_$][\w$]*\s*\|\|\s*\{\}\s*\)\s*;?/; - - return functionCount <= 1 && (directAliasPattern.test(code) || objectAssignAliasPattern.test(code)); -} - -async function testPracticeStore() { - const records = []; - const windowStub = { - PracticeRecordAPI: { - async list() { return records.slice(); }, - async replace(next) { - records.splice(0, records.length, ...next); - return true; - }, - async saveRecord(record) { - records.unshift(record); - return record; - } - } - }; - const context = vm.createContext({ window: windowStub, globalThis: windowStub, console }); - loadScript('js/core/practiceStore.js', context); - - assert.strictEqual(typeof windowStub.PracticeStore.list, 'function'); - await windowStub.PracticeStore.save({ id: 'r1' }); - assert.strictEqual((await windowStub.PracticeStore.list()).length, 1); - await windowStub.PracticeStore.clear(); - assert.strictEqual((await windowStub.PracticeStore.list()).length, 0); -} - -function testFeaturesNoForwardOnlyFiles() { - const featureRoot = path.join(repoRoot, 'js', 'features'); - if (!fs.existsSync(featureRoot)) { - return; - } - const queue = [featureRoot]; - const files = []; - - while (queue.length > 0) { - const current = queue.shift(); - const entries = fs.readdirSync(current, { withFileTypes: true }); - entries.forEach((entry) => { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - queue.push(fullPath); - return; - } - if (entry.isFile() && fullPath.endsWith('.js')) { - files.push(fullPath); - } - }); - } - - const forwardOnlyFiles = files - .filter((fullPath) => isForwardOnlyFeatureSource(fs.readFileSync(fullPath, 'utf8'))) - .map((fullPath) => path.relative(repoRoot, fullPath).replace(/\\/g, '/')) - .sort(); - - assert.strictEqual( - forwardOnlyFiles.length, - 0, - `js/features 禁止新增转发-only 文件: ${forwardOnlyFiles.join(', ')}` - ); -} - -function testIndexCssConvergence() { - const source = fs.readFileSync(path.join(repoRoot, 'index.html'), 'utf8'); - const cssHrefs = [...source.matchAll(/]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi)] - .map((match) => match[1].trim()); - - const allowedCss = new Set([ - 'css/main.css', - 'css/heroui-bridge.css', - 'css/onboarding.css' - ]); - const unexpectedCss = cssHrefs.filter((href) => !allowedCss.has(href)); - - assert( - cssHrefs.includes('css/main.css'), - 'index.html 必须保留 css/main.css 作为主样式入口' - ); - assert.strictEqual( - unexpectedCss.length, - 0, - `index.html 样式链接出现拆分迹象(新增小 CSS): ${unexpectedCss.join(', ')}` - ); -} - -function testBuildBundlesNoDeletedScriptRefs() { - const removedScripts = [ - 'js/features/session/examSessionService.js', - 'js/features/session/sessionFeature.js', - 'js/features/app/app-init.js', - 'js/features/practice/practice-sync.js', - 'js/features/overview/overview-runtime.js', - 'js/runtime/mainRuntime.js', - 'js/runtime/legacyPublicAPI.js' - ]; - const buildSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'build-bundles.mjs'), 'utf8'); - const staleRefs = removedScripts.filter((relativePath) => buildSource.includes(relativePath)); - - assert.strictEqual( - staleRefs.length, - 0, - `build-bundles.mjs 仍引用已删除脚本: ${staleRefs.join(', ')}` - ); -} - -function testCompatPatchRegistry() { - const windowStub = {}; - const context = vm.createContext({ window: windowStub, globalThis: windowStub, console }); - loadScript('js/patches/runtime-fixes.js', context); - - assert.strictEqual(typeof windowStub.CompatPatch.register, 'function'); - windowStub.CompatPatch.register('patch-a', { - owner: 'runtime', - reason: 'test patch', - removeAfter: 'test' - }); - const patches = windowStub.CompatPatch.list(); - assert.strictEqual(Array.isArray(patches), true); - assert.strictEqual(patches.some((item) => item && item.name === 'patch-a'), true); -} - -function testReadingLaunchHost() { - const windowStub = { - __READING_EXAM_MANIFEST__: { - 'p1-reading': { dataKey: 'p1-reading-data', script: 'dummy.js' } - }, - buildResourcePath(exam, kind) { - return `${kind}/${exam.pdfFilename || ''}`; - } - }; - const context = vm.createContext({ window: windowStub, globalThis: windowStub, console, URLSearchParams }); - loadScript('js/app/examSessionMixin.js', context); - - const mixin = windowStub.ExamSystemAppMixins && windowStub.ExamSystemAppMixins.examSession; - assert(mixin && typeof mixin.resolveReadingLaunchDescriptor === 'function', 'examSessionMixin 应暴露 resolveReadingLaunchDescriptor'); - const host = { - _ensureAbsoluteUrl(url) { - return url; - }, - _isReadingLibraryExam: mixin._isReadingLibraryExam, - _getUnifiedReadingManifestEntry: mixin._getUnifiedReadingManifestEntry, - _isUnifiedReadingExam: mixin._isUnifiedReadingExam, - _buildUnifiedReadingUrl: mixin._buildUnifiedReadingUrl, - _buildReadingPdfUrl: mixin._buildReadingPdfUrl, - resolveReadingLaunchDescriptor: mixin.resolveReadingLaunchDescriptor - }; - - const unified = host.resolveReadingLaunchDescriptor({ - id: 'p1-reading', - type: 'reading', - pdfFilename: 'reading.pdf' - }); - assert.strictEqual(unified.mode, 'unified_html'); - assert.strictEqual(unified.dataKey, 'p1-reading-data'); - assert(unified.url.includes('reading-practice-unified.html?')); - - const pdf = host.resolveReadingLaunchDescriptor({ - id: 'p2-reading', - type: 'reading', - pdfFilename: 'fallback.pdf' - }); - assert.strictEqual(pdf.mode, 'pdf_manual'); - assert.strictEqual(pdf.pdfUrl, 'pdf/fallback.pdf'); - - const listening = host.resolveReadingLaunchDescriptor({ - id: 'listening-p1', - type: 'listening', - pdfFilename: 'listening.pdf' - }); - assert.strictEqual(listening, null); -} - -function testMainOpenExamDoesNotFallbackToRawHtml() { - const source = fs.readFileSync(path.join(repoRoot, 'js/main.js'), 'utf8').replace(/\r\n/g, '\n'); - const match = source.match(/function openExam\s*\([^)]*\)\s*\{([\s\S]*?)\n\}\n\nfunction viewPDF/); - assert(match, 'main.js 应保留 openExam 函数,并位于 viewPDF 之前'); - - const openExamBody = match[1]; - assert(openExamBody.includes('window.app.openExam'), 'openExam 必须只委托统一 App 练习入口'); - assert(openExamBody.includes('统一练习入口未就绪'), 'openExam 在统一入口不可用时必须提示明确错误点'); - assert(!openExamBody.includes("buildResourcePath(exam, 'html')"), 'openExam 禁止拼接原始 HTML 题源路径'); - assert(!openExamBody.includes('window.open('), 'openExam 禁止直接打开原始题源窗口'); - assert(!openExamBody.includes('startHandshakeFallback'), 'openExam 禁止启动旧 HTML 握手兜底'); - assert(!source.includes('function startHandshakeFallback('), 'main.js 禁止保留旧 HTML 握手兜底函数'); -} - -async function main() { - await testPracticeStore(); - testFeaturesNoForwardOnlyFiles(); - testIndexCssConvergence(); - testBuildBundlesNoDeletedScriptRefs(); - testReadingLaunchHost(); - testMainOpenExamDoesNotFallbackToRawHtml(); - testCompatPatchRegistry(); - console.log(JSON.stringify({ - status: 'pass', - detail: 'convergence facade guard tests passed' - }, null, 2)); -} - -main().catch((error) => { - console.log(JSON.stringify({ - status: 'fail', - detail: error.message - }, null, 2)); - process.exit(1); -}); diff --git a/developer/tests/js/simpleStorageWrapperPracticeData.test.js b/developer/tests/js/simpleStorageWrapperPracticeData.test.js deleted file mode 100644 index 41464d4b..00000000 --- a/developer/tests/js/simpleStorageWrapperPracticeData.test.js +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env node -import assert from 'assert'; -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function createHarness() { - const calls = []; - const records = [{ id: 'record-1', examId: 'reading-1', type: 'reading', score: 1 }]; - const stats = { totalPractices: 1 }; - const meta = new Map(); - - const windowStub = { - PracticeRecordAPI: { - async list() { - calls.push({ type: 'api.list' }); - return records.map(record => Object.assign({}, record)); - }, - async getById(id) { - calls.push({ type: 'api.getById', id }); - return records.find(record => record.id === id) || null; - }, - async readStats(options = {}) { - calls.push({ type: 'api.readStats', fallback: options.fallback }); - return Object.assign({}, options.fallback || {}, stats); - }, - async replace() { - calls.push({ type: 'api.replace' }); - throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.replace'); - }, - async saveRecord() { - calls.push({ type: 'api.saveRecord' }); - throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.saveRecord'); - }, - async deleteById() { - calls.push({ type: 'api.deleteById' }); - throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.deleteById'); - }, - async deleteMany() { - calls.push({ type: 'api.deleteMany' }); - throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.deleteMany'); - }, - async clear() { - calls.push({ type: 'api.clear' }); - throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.clear'); - }, - async writeStats() { - calls.push({ type: 'api.writeStats' }); - throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.writeStats'); - }, - async resetStats() { - calls.push({ type: 'api.resetStats' }); - throw new Error('simpleStorageWrapper must not call PracticeRecordAPI.resetStats'); - } - }, - dataRepositories: { - settings: { - async getAll() { return {}; }, - async saveAll() { return true; }, - async get() { return null; }, - async set() { return true; } - }, - backups: { - async list() { return []; }, - async saveAll() { return true; }, - async add() { return true; }, - async delete() { return true; }, - async clear() { return true; } - }, - meta: { - async get(key, fallback = null) { - calls.push({ type: 'meta.get', key }); - return meta.has(key) ? meta.get(key) : fallback; - }, - async set(key, value) { - calls.push({ type: 'meta.set', key, value }); - meta.set(key, value); - return true; - }, - async remove(key) { - calls.push({ type: 'meta.remove', key }); - meta.delete(key); - return true; - } - } - } - }; - - const sandbox = { - window: windowStub, - globalThis: windowStub, - console: { - log() {}, - warn() {}, - error() {} - } - }; - vm.createContext(sandbox); - const source = fs.readFileSync(path.join(repoRoot, 'js/utils/simpleStorageWrapper.js'), 'utf8'); - vm.runInContext(source, sandbox, { filename: 'js/utils/simpleStorageWrapper.js' }); - - return { - calls, - records, - stats, - meta, - wrapper: windowStub.simpleStorageWrapper - }; -} - -async function assertRejectsWrite(label, operation, expectedPattern) { - await assert.rejects(operation, expectedPattern, `${label} 必须 fail-fast`); -} - -async function main() { - const harness = createHarness(); - const { wrapper, calls, records, stats, meta } = harness; - - assert(wrapper, 'simpleStorageWrapper 应自动连接 dataRepositories'); - - const listed = await wrapper.getPracticeRecords(); - assert.strictEqual(listed.length, 1, 'getPracticeRecords 应保留 PracticeRecordAPI.list 只读兼容'); - assert.strictEqual((await wrapper.get('practice_records', [])).length, 1, 'get(practice_records) 应保留只读兼容'); - assert.strictEqual((await wrapper.getById('record-1')).id, 'record-1', 'getById 应保留只读兼容'); - assert.strictEqual((await wrapper.get('user_stats', { totalPractices: 0 })).totalPractices, 1, 'get(user_stats) 应保留只读兼容'); - - await assertRejectsWrite( - 'savePracticeRecords', - () => wrapper.savePracticeRecords([{ id: 'next' }]), - /SimpleStorageWrapper\.savePracticeRecords is disabled/ - ); - await assertRejectsWrite( - 'addPracticeRecord', - () => wrapper.addPracticeRecord({ id: 'next' }), - /SimpleStorageWrapper\.addPracticeRecord is disabled/ - ); - await assertRejectsWrite( - 'update', - () => wrapper.update('record-1', { score: 2 }), - /SimpleStorageWrapper\.update is disabled/ - ); - await assertRejectsWrite( - 'delete', - () => wrapper.delete('record-1'), - /SimpleStorageWrapper\.delete is disabled/ - ); - await assertRejectsWrite( - 'deletePracticeRecord', - () => wrapper.deletePracticeRecord('record-1'), - /SimpleStorageWrapper\.deletePracticeRecord is disabled/ - ); - await assertRejectsWrite( - 'deletePracticeRecords', - () => wrapper.deletePracticeRecords(['record-1']), - /SimpleStorageWrapper\.deletePracticeRecords is disabled/ - ); - await assertRejectsWrite( - 'set(practice_records)', - () => wrapper.set('practice_records', []), - /SimpleStorageWrapper\.set\(practice_records\) is disabled/ - ); - await assertRejectsWrite( - 'set(user_stats)', - () => wrapper.set('user_stats', { totalPractices: 0 }), - /SimpleStorageWrapper\.set\(user_stats\) is disabled/ - ); - await assertRejectsWrite( - 'remove(practice_records)', - () => wrapper.remove('practice_records'), - /SimpleStorageWrapper\.remove\(practice_records\) is disabled/ - ); - await assertRejectsWrite( - 'remove(user_stats)', - () => wrapper.remove('user_stats'), - /SimpleStorageWrapper\.remove\(user_stats\) is disabled/ - ); - - await wrapper.set('unrelated_meta', { ok: true }); - assert.deepStrictEqual(meta.get('unrelated_meta'), { ok: true }, '非练习数据仍可走 meta repo 写入'); - await wrapper.remove('unrelated_meta'); - assert.strictEqual(meta.has('unrelated_meta'), false, '非练习数据仍可走 meta repo 删除'); - - assert.strictEqual(records.length, 1, 'wrapper 练习数据写入口不能改变 records'); - assert.strictEqual(stats.totalPractices, 1, 'wrapper stats 写入口不能改变 stats'); - const writeCalls = calls.filter(call => /^api\.(replace|saveRecord|deleteById|deleteMany|clear|writeStats|resetStats)$/.test(call.type)); - assert.strictEqual(writeCalls.length, 0, 'wrapper 写入口不能调用 PracticeRecordAPI 写方法'); - - process.stdout.write(JSON.stringify({ - status: 'pass', - detail: 'SimpleStorageWrapper 练习数据只读兼容,公开写入口 fail-fast' - })); -} - -main().catch((error) => { - process.stdout.write(JSON.stringify({ - status: 'fail', - detail: error && error.message ? error.message : String(error) - })); - process.exit(1); -}); diff --git a/developer/tests/js/siteDataReset.test.js b/developer/tests/js/siteDataReset.test.js new file mode 100644 index 00000000..0c028d44 --- /dev/null +++ b/developer/tests/js/siteDataReset.test.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const source = fs.readFileSync(path.join(root, 'js/core/siteDataReset.js'), 'utf8'); + +function createStorage(seed = {}) { + const values = new Map(Object.entries(seed)); + return { + values, + clearCalls: 0, + clear() { + this.clearCalls += 1; + values.clear(); + } + }; +} + +function createHarness(options = {}) { + const events = []; + const messages = []; + const requests = []; + const deleteModes = Object.assign({}, options.deleteModes); + const localStorage = createStorage({ consent: 'yes' }); + const sessionStorage = createStorage({ recovery: 'active' }); + const indexedDB = { + deleteDatabase(name) { + events.push(`delete:${name}`); + const request = { name, completed: false }; + requests.push(request); + request.complete = () => { + if (request.completed) return; + request.completed = true; + events.push(`deleted:${name}`); + request.onsuccess?.({ target: request }); + }; + queueMicrotask(() => { + const mode = deleteModes[name] || 'success'; + if (mode === 'error') { + request.error = new Error(`delete failed: ${name}`); + request.onerror?.({ target: request }); + return; + } + if (mode === 'blocked' || mode === 'blocked-success') { + request.onblocked?.({ target: request }); + if (mode === 'blocked') return; + } + queueMicrotask(request.complete); + }); + return request; + } + }; + const externalBackup = { + calls: 0, + async prepareForFullReset() { + this.calls += 1; + events.push('external:prepare'); + if (options.externalError) throw new Error('external backup busy'); + } + }; + const windowStub = { + indexedDB, + localStorage, + sessionStorage, + ExternalBackupService: externalBackup, + confirm: () => options.confirmed !== false, + showMessage(message, type) { messages.push({ message, type }); }, + console: Object.assign({}, console, { error() {} }), + location: { + reloadCalls: 0, + reload() { this.reloadCalls += 1; events.push('reload'); } + } + }; + const context = vm.createContext({ + window: windowStub, + globalThis: windowStub, + console: windowStub.console, + Promise, + Object, + Error + }); + vm.runInContext(source, context, { filename: 'siteDataReset.js' }); + return { + windowStub, + events, + messages, + requests, + deleteModes, + localStorage, + sessionStorage, + externalBackup, + complete(name) { + const request = requests.find((item) => item.name === name && !item.completed); + assert.ok(request, `missing pending request for ${name}`); + request.complete(); + } + }; +} + +async function flush() { + for (let index = 0; index < 6; index += 1) await Promise.resolve(); +} + +async function testCancelledReset() { + const harness = createHarness({ confirmed: false }); + const result = await harness.windowStub.clearCache(); + assert.equal(result.reason, 'cancelled'); + assert.deepEqual(harness.events, []); + assert.equal(harness.localStorage.clearCalls, 0); +} + +async function testSuccessfulReset() { + const harness = createHarness(); + const result = await harness.windowStub.clearCache(); + assert.equal(result.success, true); + assert.deepEqual(JSON.parse(JSON.stringify(result.databases)), [ + 'IELTSAtlasDataV2', + 'ExamSystemDB', + 'IELTSAtlasExternalBackupV2' + ]); + assert.equal(result.databases.includes('ExamSystemExternalBackup'), false, + 'legacy external handle database stays untouched for this release'); + assert.equal(harness.events[0], 'external:prepare'); + assert.equal(harness.localStorage.values.size, 0); + assert.equal(harness.sessionStorage.values.size, 0); + assert.equal(harness.windowStub.location.reloadCalls, 1); + assert.equal(result.externalBackupFilesPreserved, true); +} + +async function testBlockedDeletionKeepsWaiting() { + const harness = createHarness({ deleteModes: { IELTSAtlasDataV2: 'blocked' } }); + const pending = harness.windowStub.SiteDataReset.perform({ reload: false }); + let settled = false; + pending.finally(() => { settled = true; }); + await flush(); + assert.equal(settled, false); + assert.equal(harness.localStorage.clearCalls, 0, 'storage clears only after every database is deleted'); + assert.ok(harness.messages.some((entry) => entry.type === 'warning' && /关闭其他标签页/.test(entry.message))); + harness.complete('IELTSAtlasDataV2'); + const result = await pending; + assert.equal(result.success, true); + assert.equal(harness.localStorage.values.size, 0); +} + +async function testDeletionFailureIsVisible() { + const harness = createHarness({ deleteModes: { ExamSystemDB: 'error' } }); + const result = await harness.windowStub.clearCache(); + assert.equal(result.success, false); + assert.equal(result.reason, 'partial_reset'); + assert.equal(result.terminal, false); + assert.equal(harness.windowStub.location.reloadCalls, 0); + assert.equal(harness.localStorage.clearCalls, 1); + assert.ok(harness.messages.some((entry) => entry.type === 'error')); +} + +async function testExternalFailureStopsBeforeDeletion() { + const harness = createHarness({ externalError: true }); + const result = await harness.windowStub.clearCache(); + assert.equal(result.reason, 'external_backup_busy'); + assert.equal(harness.events.some((entry) => entry.startsWith('delete:')), false); + assert.equal(harness.localStorage.clearCalls, 0); + assert.equal(harness.windowStub.location.reloadCalls, 0); +} + +async function testConcurrentCallsShareOneRun() { + const harness = createHarness({ deleteModes: { IELTSAtlasDataV2: 'blocked' } }); + const first = harness.windowStub.SiteDataReset.perform({ reload: false }); + const second = harness.windowStub.SiteDataReset.perform({ reload: false }); + await flush(); + assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 3); + assert.equal(harness.externalBackup.calls, 1); + harness.complete('IELTSAtlasDataV2'); + const [left, right] = await Promise.all([first, second]); + assert.equal(left, right); +} + +async function testFinishedNonTerminalRunCanRepeat() { + const harness = createHarness(); + assert.equal((await harness.windowStub.SiteDataReset.perform({ reload: false })).success, true); + assert.equal((await harness.windowStub.SiteDataReset.perform({ reload: false })).success, true); + assert.equal(harness.events.filter((entry) => entry.startsWith('delete:')).length, 6); + assert.equal(harness.localStorage.clearCalls, 2); +} + +await testCancelledReset(); +await testSuccessfulReset(); +await testBlockedDeletionKeepsWaiting(); +await testDeletionFailureIsVisible(); +await testExternalFailureStopsBeforeDeletion(); +await testConcurrentCallsShareOneRun(); +await testFinishedNonTerminalRunCanRepeat(); +console.log('SiteDataReset tests passed'); diff --git a/developer/tests/js/spellingErrorCollector.test.js b/developer/tests/js/spellingErrorCollector.test.js index 32abadbd..cf600617 100644 --- a/developer/tests/js/spellingErrorCollector.test.js +++ b/developer/tests/js/spellingErrorCollector.test.js @@ -22,7 +22,6 @@ const __dirname = path.dirname(__filename); // ============================================================================ global.window = { - storage: null, spellingErrorCollector: null }; @@ -36,26 +35,32 @@ global.console = { info: () => {} }; -// 模拟存储系统 -class MockStorage { - constructor() { - this.data = new Map(); - this.ready = Promise.resolve(); - } - - async get(key) { - return this.data.get(key) || null; - } - - async set(key, value) { - this.data.set(key, value); - return true; - } - - setNamespace() {} +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } -global.window.storage = new MockStorage(); +const vocabCollections = {}; +function resetCollections() { + Object.keys(vocabCollections).forEach((id) => delete vocabCollections[id]); +} +global.window.AppData = { + ready: Promise.resolve(), + vocab: { + async listCollections() { + return clone(vocabCollections); + }, + async saveCollection(id, value) { + vocabCollections[id] = clone(value); + return { committed: true }; + }, + async saveCollections(values) { + Object.entries(values).forEach(([id, value]) => { + vocabCollections[id] = clone(value); + }); + return { committed: true }; + } + } +}; // ============================================================================ // 加载 SpellingErrorCollector @@ -414,6 +419,7 @@ function testSpellingFalsePositiveFilters() { */ async function testVocabListSaveAndLoad() { console.log('测试: 词表保存和加载'); + resetCollections(); const collector = new window.SpellingErrorCollector(); await collector.ensureInitialized(); @@ -452,6 +458,7 @@ async function testVocabListSaveAndLoad() { assert.strictEqual(loadedList.id, 'p1', '词表ID应该正确'); assert.strictEqual(loadedList.words.length, 1, '应该有1个单词'); assert.strictEqual(loadedList.words[0].word, 'accommodation', '单词应该正确'); + assert.ok(vocabCollections['spelling-errors-p1'], '必须写入 canonical collection'); console.log(' ✓ 词表保存和加载正确'); } @@ -529,6 +536,7 @@ async function testMergeErrorsToList() { */ async function testSaveErrors() { console.log('测试: 保存错误到词表'); + resetCollections(); const collector = new window.SpellingErrorCollector(); await collector.ensureInitialized(); @@ -579,6 +587,7 @@ async function testSaveErrors() { */ async function testRemoveWord() { console.log('测试: 移除单词'); + resetCollections(); const collector = new window.SpellingErrorCollector(); await collector.ensureInitialized(); @@ -619,6 +628,7 @@ async function testRemoveWord() { */ async function testClearList() { console.log('测试: 清空词表'); + resetCollections(); const collector = new window.SpellingErrorCollector(); await collector.ensureInitialized(); diff --git a/developer/tests/js/stateSerializerTest.js b/developer/tests/js/stateSerializerTest.js deleted file mode 100644 index 6e396879..00000000 --- a/developer/tests/js/stateSerializerTest.js +++ /dev/null @@ -1,483 +0,0 @@ -/** - * 状态序列化器测试套件 - * 验证Set/Map对象的序列化/反序列化一致性 - */ - -class StateSerializerTest { - constructor() { - this.testResults = []; - this.testData = { - setExample: new Set(['item1', 'item2', 'item3']), - mapExample: new Map([ - ['key1', 'value1'], - ['key2', 'value2'], - ['key3', { complex: 'object' }] - ]), - nestedObject: { - level1: { - level2: { - setInside: new Set(['nested', 'set']), - mapInside: new Map([['nested', 'map']]) - } - }, - array: [ - new Set(['set in array']), - new Map([['map', 'in array']]) - ] - }, - mixedState: { - exam: { - filteredExams: ['exam1', 'exam2'], - configurations: new Map([['config1', { enabled: true }]]) - }, - practice: { - selectedRecords: new Set(['record1', 'record2', 'record3']), - bulkDeleteMode: false - }, - system: { - processedSessions: new Set(['session1', 'session2']), - fallbackExamSessions: new Map([['fallback1', { data: 'test' }]]) - } - } - }; - } - - // 运行所有测试 - async runAllTests() { - console.log('🧪 开始状态序列化测试...'); - - this.testResults = []; - - // 基础类型测试 - this.testSetSerialization(); - this.testMapSerialization(); - this.testDateSerialization(); - this.testNestedObjectSerialization(); - - // 实际应用场景测试 - this.testActualAppState(); - this.testLocalStorageRoundTrip(); - this.testDataConsistency(); - - this.printResults(); - return this.testResults; - } - - // 测试Set序列化 - testSetSerialization() { - const testName = 'Set序列化/反序列化'; - const original = this.testData.setExample; - - try { - const serialized = StateSerializer.serialize(original); - const deserialized = StateSerializer.deserialize(serialized); - - const isValid = this.validateSetEquality(original, deserialized); - this.recordTest(testName, isValid, { - original: Array.from(original), - serialized, - deserialized: Array.from(deserialized) - }); - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试Map序列化 - testMapSerialization() { - const testName = 'Map序列化/反序列化'; - const original = this.testData.mapExample; - - try { - const serialized = StateSerializer.serialize(original); - const deserialized = StateSerializer.deserialize(serialized); - - const isValid = this.validateMapEquality(original, deserialized); - this.recordTest(testName, isValid, { - original: Array.from(original.entries()), - serialized, - deserialized: Array.from(deserialized.entries()) - }); - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试Date序列化 - testDateSerialization() { - const testName = 'Date序列化/反序列化'; - const original = new Date('2024-01-01T00:00:00.000Z'); - - try { - const serialized = StateSerializer.serialize(original); - const deserialized = StateSerializer.deserialize(serialized); - - const isValid = deserialized instanceof Date && deserialized.getTime() === original.getTime(); - this.recordTest(testName, isValid, { - original: original.toISOString(), - serialized, - deserialized: deserialized.toISOString() - }); - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试嵌套对象序列化 - testNestedObjectSerialization() { - const testName = '嵌套对象序列化/反序列化'; - const original = this.testData.nestedObject; - - try { - const serialized = StateSerializer.serialize(original); - const deserialized = StateSerializer.deserialize(serialized); - - const isValid = this.validateNestedObjectEquality(original, deserialized); - this.recordTest(testName, isValid, { - hasSetInside: deserialized.level1.level2.setInside instanceof Set, - hasMapInside: deserialized.level1.level2.mapInside instanceof Map, - setInsideSize: deserialized.level1.level2.setInside.size, - mapInsideSize: deserialized.level1.level2.mapInside.size - }); - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试实际应用状态 - testActualAppState() { - const testName = '实际应用状态序列化'; - const original = this.testData.mixedState; - - try { - const serialized = StateSerializer.serialize(original); - const deserialized = StateSerializer.deserialize(serialized); - - const isValid = this.validateAppStateEquality(original, deserialized); - this.recordTest(testName, isValid, { - selectedRecordsCount: deserialized.practice.selectedRecords.size, - processedSessionsCount: deserialized.system.processedSessions.size, - fallbackSessionsCount: deserialized.system.fallbackExamSessions.size - }); - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试LocalStorage往返 - async testLocalStorageRoundTrip() { - const testName = 'LocalStorage往返测试'; - const original = this.testData.mixedState; - - try { - // 序列化并存储 - const serialized = StateSerializer.serialize(original); - const testKey = 'test_state_roundtrip'; - await storage.set(testKey, serialized); - - // 从存储读取并反序列化 - const stored = await storage.get(testKey); - const deserialized = StateSerializer.deserialize(stored); - - // 清理测试数据 - await storage.remove(testKey); - - const isValid = this.validateAppStateEquality(original, deserialized); - this.recordTest(testName, isValid, { - storageSuccess: true, - dataIntegrity: isValid - }); - } catch (error) { - this.recordTest(testName, false, { error: error.message }); - } - } - - // 测试数据一致性 - testDataConsistency() { - const testName = '数据一致性验证'; - const testCases = [ - new Set([]), - new Set(['single']), - new Set(['a', 'b', 'c', 'd', 'e']), - new Map([]), - new Map([['key', 'value']]), - new Map([['a', 1], ['b', 2], ['c', 3]]) - ]; - - let allPassed = true; - const results = []; - - testCases.forEach((testCase, index) => { - const isValid = StateSerializer.validate(testCase); - results.push({ index, type: testCase.constructor.name, isValid }); - if (!isValid) allPassed = false; - }); - - this.recordTest(testName, allPassed, { results }); - } - - // 验证Set相等性 - validateSetEquality(set1, set2) { - if (!(set2 instanceof Set)) return false; - if (set1.size !== set2.size) return false; - - const arr1 = Array.from(set1).sort(); - const arr2 = Array.from(set2).sort(); - - return JSON.stringify(arr1) === JSON.stringify(arr2); - } - - // 验证Map相等性 - validateMapEquality(map1, map2) { - if (!(map2 instanceof Map)) return false; - if (map1.size !== map2.size) return false; - - const arr1 = Array.from(map1.entries()).sort(); - const arr2 = Array.from(map2.entries()).sort(); - - return JSON.stringify(arr1) === JSON.stringify(arr2); - } - - // 验证嵌套对象相等性 - validateNestedObjectEquality(obj1, obj2) { - // 检查Set - if (!this.validateSetEquality(obj1.level1.level2.setInside, obj2.level1.level2.setInside)) { - return false; - } - - // 检查Map - if (!this.validateMapEquality(obj1.level1.level2.mapInside, obj2.level1.level2.mapInside)) { - return false; - } - - // 检查数组中的Set和Map - if (!this.validateSetEquality(obj1.array[0], obj2.array[0])) { - return false; - } - - if (!this.validateMapEquality(obj1.array[1], obj2.array[1])) { - return false; - } - - return true; - } - - // 验证应用状态相等性 - validateAppStateEquality(state1, state2) { - // 检查practice.selectedRecords - if (!this.validateSetEquality(state1.practice.selectedRecords, state2.practice.selectedRecords)) { - return false; - } - - // 检查system.processedSessions - if (!this.validateSetEquality(state1.system.processedSessions, state2.system.processedSessions)) { - return false; - } - - // 检查system.fallbackExamSessions - if (!this.validateMapEquality(state1.system.fallbackExamSessions, state2.system.fallbackExamSessions)) { - return false; - } - - return true; - } - - // 记录测试结果 - recordTest(testName, passed, details) { - this.testResults.push({ - name: testName, - passed, - details, - timestamp: new Date().toISOString() - }); - - const status = passed ? '✅' : '❌'; - console.log(`${status} ${testName}`); - if (!passed) { - console.error(' 详情:', details); - } - } - - // 打印测试结果 - printResults() { - const totalTests = this.testResults.length; - const passedTests = this.testResults.filter(r => r.passed).length; - const failedTests = totalTests - passedTests; - - console.log('\n📊 测试结果汇总:'); - console.log(`总测试数: ${totalTests}`); - console.log(`通过: ${passedTests} ✅`); - console.log(`失败: ${failedTests} ❌`); - console.log(`成功率: ${((passedTests / totalTests) * 100).toFixed(1)}%`); - - if (failedTests > 0) { - console.log('\n❌ 失败的测试:'); - this.testResults - .filter(r => !r.passed) - .forEach(r => console.log(` - ${r.name}: ${r.details.error || '数据不匹配'}`)); - } - } - - // 创建刷新测试页面 - static createRefreshTestPage() { - const testPage = ` - - - - - - 状态序列化刷新测试 - - - -

🔄 状态序列化刷新测试

- -
-

测试步骤:

-
    -
  1. 点击"创建测试数据"创建Set/Map状态
  2. -
  3. 点击"保存状态到存储"
  4. -
  5. 刷新页面 (F5)
  6. -
  7. 点击"验证恢复的状态"
  8. -
- - - - - - -
- -
-

测试结果:

-
-
- -
-

当前状态:

-
无状态数据
-
- - - - - - -`; - - return testPage; - } -} - -// 导出供使用 -if (typeof module !== 'undefined' && module.exports) { - module.exports = StateSerializerTest; -} \ No newline at end of file diff --git a/developer/tests/js/storageManagerRecords.test.js b/developer/tests/js/storageManagerRecords.test.js deleted file mode 100644 index e2caaad4..00000000 --- a/developer/tests/js/storageManagerRecords.test.js +++ /dev/null @@ -1,629 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import vm from 'vm'; -import assert from 'assert'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function createMemoryStorage() { - const data = new Map(); - return { - get length() { - return data.size; - }, - key(index) { - return Array.from(data.keys())[index] || null; - }, - getItem(key) { - return data.has(String(key)) ? data.get(String(key)) : null; - }, - setItem(key, value) { - data.set(String(key), String(value)); - }, - removeItem(key) { - data.delete(String(key)); - }, - clear() { - data.clear(); - }, - _dump() { - return Object.fromEntries(data.entries()); - } - }; -} - -function createFetch(responseData = null) { - return async () => ({ - ok: true, - async json() { - return responseData || { practice_records: [{ id: 'backup-record', examId: 'reading-backup' }] }; - } - }); -} - -async function createHarness(options = {}) { - const localStorage = createMemoryStorage(); - const sessionStorage = createMemoryStorage(); - const intervals = []; - const timeouts = []; - const listeners = []; - const calls = []; - const quietConsole = { - log() {}, - info() {}, - warn() {}, - error() {} - }; - - const windowStub = { - location: { protocol: options.protocol || 'http:' }, - localStorage, - sessionStorage, - indexedDB: null, - dispatchEvent() {}, - addEventListener(type, handler) { - listeners.push({ type, handler }); - }, - showMessage() {}, - fetch: createFetch(options.backupData), - setTimeout(callback, delay) { - const id = { callback, delay }; - timeouts.push(id); - return id; - }, - clearTimeout(id) { - const index = timeouts.indexOf(id); - if (index >= 0) { - timeouts.splice(index, 1); - } - } - }; - if (options.practiceRecordAPI) { - windowStub.PracticeRecordAPI = options.practiceRecordAPI(calls); - } - - const sandbox = { - window: windowStub, - globalThis: windowStub, - localStorage, - sessionStorage, - document: { - dispatchEvent() {} - }, - CustomEvent: class CustomEvent { - constructor(type, init = {}) { - this.type = type; - this.detail = init.detail || null; - } - }, - console: quietConsole, - JSON, - Date, - Math, - setInterval(callback, delay) { - const id = { callback, delay }; - intervals.push(id); - return id; - }, - clearInterval(id) { - const index = intervals.indexOf(id); - if (index >= 0) { - intervals.splice(index, 1); - } - }, - setTimeout(callback, delay) { - return windowStub.setTimeout(callback, delay); - }, - clearTimeout(id) { - return windowStub.clearTimeout(id); - }, - fetch: createFetch(options.backupData) - }; - const context = vm.createContext(sandbox); - const source = fs.readFileSync(path.join(repoRoot, 'js/utils/storage.js'), 'utf8'); - vm.runInContext(source, context, { filename: 'js/utils/storage.js' }); - await windowStub.persistentStore.ready; - return { - window: windowStub, - persistentStore: windowStub.persistentStore, - localStorage, - sessionStorage, - calls, - intervals, - timeouts, - context, - listeners - }; -} - -function loadScript(relativePath, context) { - const source = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); - vm.runInContext(source, context, { filename: relativePath }); -} - -function flushWindowTimeouts(harness) { - while (harness.timeouts.length > 0) { - const pending = harness.timeouts.splice(0, harness.timeouts.length); - pending.forEach((entry) => { - if (typeof entry.callback === 'function') { - entry.callback(); - } - }); - } -} - -function readEnvelope(storage, key) { - const raw = storage.getItem(key); - return raw ? JSON.parse(raw).data : null; -} - -function readRawPracticeRecords(harness) { - const records = readEnvelope(harness.localStorage, 'exam_system_practice_records'); - return Array.isArray(records) ? records : []; -} - -async function writeRawPracticeRecords(harness, records) { - harness.localStorage.setItem('exam_system_practice_records', JSON.stringify({ - data: records, - timestamp: Date.now(), - version: '0.6.2-fix', - compressed: false - })); -} - -async function testStorageDataSourceReadBypassesPublicPracticeRecordRedirect() { - const calls = []; - const windowStub = { ExamData: {} }; - const sandbox = { - window: windowStub, - console: { - log() {}, - info() {}, - warn() {}, - error() {} - } - }; - const context = vm.createContext(sandbox); - const source = fs.readFileSync(path.join(repoRoot, 'js/data/dataSources/storageDataSource.js'), 'utf8'); - vm.runInContext(source, context, { filename: 'js/data/dataSources/storageDataSource.js' }); - - const storageManager = { - async get(key, defaultValue, options = {}) { - calls.push({ type: 'get', key, options }); - if (key === 'practice_records' && !options.skipPracticeCoreRedirect) { - throw new Error('public practice record redirect would recurse'); - } - return key === 'practice_records' - ? [{ id: `raw-${calls.length}`, examId: 'reading-raw' }] - : defaultValue; - }, - async set() { - return true; - }, - async remove() { - return true; - } - }; - - const externalDataSource = new windowStub.ExamData.StorageDataSource(storageManager); - await assert.rejects( - () => externalDataSource.read('practice_records', []), - /protected key practice_records/, - '外部 new StorageDataSource 不能读取 protected key' - ); - - const dataSource = new windowStub.ExamData.StorageDataSource(storageManager, { - createInternalOptions() { - return { skipPracticeCoreRedirect: true, internalAccessToken: Symbol('test-internal') }; - } - }); - const records = await dataSource.read('practice_records', []); - assert.strictEqual(records[0].id, 'raw-1', 'StorageDataSource.read 应直接读底层 raw store'); - - const txRecords = await dataSource.runTransaction(async (transaction) => { - return transaction.get('practice_records', []); - }); - assert.strictEqual(txRecords[0].id, 'raw-2', 'StorageTransactionContext.get 应直接读底层 raw store'); - - assert.strictEqual(calls.length, 2, 'read 和 transaction.get 应各触发一次底层读取'); - assert(calls.every((call) => call.options && call.options.skipPracticeCoreRedirect === true), - 'StorageDataSource 底层读取必须跳过 PracticeRecordAPI/public storage redirect'); -} - -async function testRuntimeImportFailsWithoutPracticeRecordAPI() { - const harness = await createHarness(); - await writeRawPracticeRecords(harness, [{ id: 'existing-record', examId: 'reading-existing' }]); - - const result = await harness.persistentStore.importData({ - data: { - practice_records: [{ id: 'import-record', examId: 'reading-import' }] - } - }); - - assert.strictEqual(result.success, false, '运行期导入缺少 PracticeRecordAPI 时必须失败'); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'import-record'), - false, - '运行期导入失败不能把导入记录写入 raw practice_records' - ); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'existing-record'), - true, - '统一 API 缺失时导入必须 fail-fast,不能先清空已有练习记录' - ); -} - -async function testInternalStorageAccessIsNotWindowPublic() { - const harness = await createHarness(); - flushWindowTimeouts(harness); - - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window, 'createStorageInternalAccessOptions'), - false, - 'internal storage access 生成器不能挂到 window' - ); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window, 'hasStorageInternalAccess'), - false, - 'internal storage access 校验器不能挂到 window' - ); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window, '__installStorageInternalAccess'), - true, - 'storage internal access installer 在被消费前应保持可用' - ); -} - -async function testFullDataBootstrapHidesInternalPracticeRepositories() { - const harness = await createHarness(); - const scripts = [ - 'js/core/storageProviderRegistry.js', - 'js/data/dataSources/storageDataSource.js', - 'js/data/repositories/baseRepository.js', - 'js/data/repositories/dataRepositoryRegistry.js', - 'js/data/repositories/practiceRepository.js', - 'js/data/repositories/settingsRepository.js', - 'js/data/repositories/backupRepository.js', - 'js/data/repositories/metaRepository.js', - 'js/core/practiceCore.js', - 'js/data/index.js', - 'js/core/practiceRecordAPI.js' - ]; - scripts.forEach((script) => loadScript(script, harness.context)); - flushWindowTimeouts(harness); - - assert(harness.window.PracticeRecordAPI, 'PracticeRecordAPI 应完成初始化'); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(harness.window.ExamData, 'internalRepositories'), - false, - 'ExamData.internalRepositories 不能暴露底层 practiceRepo' - ); - assert.strictEqual( - Boolean(harness.window.dataRepositories && harness.window.dataRepositories.practice), - false, - 'public dataRepositories 不能暴露 practice 仓库' - ); - assert.strictEqual( - typeof harness.window.PracticeCore.__installInternalRepositories, - 'undefined', - 'PracticeCore 内部仓库 installer 必须在数据层注入后删除' - ); - assert.strictEqual( - typeof harness.window.PracticeCore.__installRecordAPI, - 'undefined', - 'PracticeCore RecordAPI installer 必须在 PracticeRecordAPI 初始化后删除' - ); - assert.strictEqual( - typeof harness.window.__installStorageInternalAccess, - 'undefined', - 'storage internal access installer 必须在数据层注入后删除' - ); - - await harness.window.PracticeRecordAPI.saveRecord({ - id: 'bootstrap-record', - examId: 'reading-bootstrap', - type: 'reading', - date: '2026-05-25T00:00:00.000Z', - score: 1, - totalQuestions: 1, - correctAnswers: 1, - accuracy: 1 - }); - const records = await harness.window.PracticeRecordAPI.list(); - assert.strictEqual(records.length, 1, '隐藏内部仓库后 PracticeRecordAPI 仍应能落库'); - assert.strictEqual(records[0].id, 'bootstrap-record'); -} - -async function testRuntimeImportUsesPracticeRecordAPI() { - const savedRecords = []; - const harness = await createHarness({ - practiceRecordAPI: (calls) => ({ - async replace(records) { - calls.push({ type: 'api.replace', records }); - savedRecords.splice(0, savedRecords.length, ...(Array.isArray(records) ? records : [])); - return savedRecords.slice(); - }, - async list() { - calls.push({ type: 'api.list' }); - return savedRecords.slice(); - } - }) - }); - - const result = await harness.persistentStore.importData({ - data: { - practice_records: [{ id: 'import-record', examId: 'reading-import' }] - } - }); - - assert.strictEqual(result.success, true, 'PracticeRecordAPI 可用时运行期导入应成功'); - assert(harness.calls.some((call) => call.type === 'api.replace'), '运行期导入必须调用 PracticeRecordAPI.replace'); - assert.strictEqual(savedRecords.length, 1, 'PracticeRecordAPI 应收到导入记录'); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'import-record'), - false, - '运行期导入成功也不能把导入记录落到 raw practice_records 影子键' - ); -} - -async function testPublicStorageFacadeReadsWithPracticeRecordAPIAndRejectsWrites() { - const savedRecords = [{ id: 'api-existing', examId: 'reading-existing' }]; - let stats = { totalPractices: 1 }; - const harness = await createHarness({ - practiceRecordAPI: (calls) => ({ - async list() { - calls.push({ type: 'api.list' }); - return savedRecords.slice(); - }, - async replace(records, options) { - calls.push({ type: 'api.replace', records, options }); - throw new Error('public storage facade must not call PracticeRecordAPI.replace'); - }, - async clear(options) { - calls.push({ type: 'api.clear', options }); - throw new Error('public storage facade must not call PracticeRecordAPI.clear'); - }, - async readStats(options = {}) { - calls.push({ type: 'api.readStats', options }); - return Object.assign({}, options.fallback || {}, stats); - }, - async writeStats(nextStats) { - calls.push({ type: 'api.writeStats', stats: nextStats }); - throw new Error('public storage facade must not call PracticeRecordAPI.writeStats'); - }, - async resetStats() { - calls.push({ type: 'api.resetStats' }); - throw new Error('public storage facade must not call PracticeRecordAPI.resetStats'); - } - }) - }); - - const listed = await harness.persistentStore.get('practice_records', []); - assert.strictEqual(listed.length, 1, 'public storage.get(practice_records) 应委托 PracticeRecordAPI.list'); - - await assert.rejects( - () => harness.persistentStore.set('practice_records', [{ id: 'api-next', examId: 'reading-next' }]), - /Storage\.set\(practice_records\) is disabled/, - 'public storage.set(practice_records) 必须禁用' - ); - assert.strictEqual(savedRecords[0].id, 'api-existing', 'public storage.set(practice_records) 不能改 canonical records'); - assert.strictEqual(readRawPracticeRecords(harness).some((record) => record && record.id === 'api-next'), false, 'public storage.set(practice_records) 不能写 raw shadow key'); - - await assert.rejects( - () => harness.persistentStore.remove('practice_records'), - /Storage\.remove\(practice_records\) is disabled/, - 'public storage.remove(practice_records) 必须禁用' - ); - assert.strictEqual(savedRecords.length, 1, 'public storage.remove(practice_records) 不能清空 canonical records'); - - const readStats = await harness.persistentStore.get('user_stats', { totalPractices: 0 }); - assert.strictEqual(readStats.totalPractices, 1, 'public storage.get(user_stats) 应委托 PracticeRecordAPI.readStats'); - - await assert.rejects( - () => harness.persistentStore.set('user_stats', { totalPractices: 3 }), - /Storage\.set\(user_stats\) is disabled/, - 'public storage.set(user_stats) 必须禁用' - ); - assert.strictEqual(stats.totalPractices, 1, 'public storage.set(user_stats) 不能改 canonical stats'); - - await assert.rejects( - () => harness.persistentStore.remove('user_stats'), - /Storage\.remove\(user_stats\) is disabled/, - 'public storage.remove(user_stats) 必须禁用' - ); - assert.strictEqual(stats.totalPractices, 1, 'public storage.remove(user_stats) 不能重置 canonical stats'); - - const callTypes = harness.calls.map((call) => call.type); - assert(callTypes.includes('api.list'), 'PracticeRecordAPI.list must be called'); - assert(callTypes.includes('api.readStats'), 'PracticeRecordAPI.readStats must be called'); - assert(!callTypes.includes('api.replace'), 'public storage.set(practice_records) must not call PracticeRecordAPI.replace'); - assert(!callTypes.includes('api.clear'), 'public storage.remove(practice_records) must not call PracticeRecordAPI.clear'); - assert(!callTypes.includes('api.writeStats'), 'public storage.set(user_stats) must not call PracticeRecordAPI.writeStats'); - assert(!callTypes.includes('api.resetStats'), 'public storage.remove(user_stats) must not call PracticeRecordAPI.resetStats'); -} - -async function testPublicBypassOptionsCannotWritePracticeData() { - const savedRecords = [{ id: 'api-existing', examId: 'reading-existing' }]; - let stats = { totalPractices: 1 }; - const harness = await createHarness({ - practiceRecordAPI: (calls) => ({ - async list() { - calls.push({ type: 'api.list' }); - return savedRecords.slice(); - }, - async replace(records, options) { - calls.push({ type: 'api.replace', records, options }); - throw new Error('public bypass options must not call PracticeRecordAPI.replace'); - }, - async clear(options) { - calls.push({ type: 'api.clear', options }); - throw new Error('public bypass options must not call PracticeRecordAPI.clear'); - }, - async readStats(options = {}) { - calls.push({ type: 'api.readStats', options }); - return Object.assign({}, options.fallback || {}, stats); - }, - async writeStats(nextStats) { - calls.push({ type: 'api.writeStats', stats: nextStats }); - throw new Error('public bypass options must not call PracticeRecordAPI.writeStats'); - }, - async resetStats() { - calls.push({ type: 'api.resetStats' }); - throw new Error('public bypass options must not call PracticeRecordAPI.resetStats'); - } - }) - }); - await writeRawPracticeRecords(harness, [{ id: 'raw-shadow', examId: 'reading-shadow' }]); - - const listed = await harness.persistentStore.get('practice_records', [], { skipPracticeCoreRedirect: true }); - assert.strictEqual(listed[0].id, 'api-existing', 'skipPracticeCoreRedirect 不能让 public get 读取 raw practice_records'); - - await assert.rejects( - () => harness.persistentStore.set('practice_records', [{ id: 'api-skip', examId: 'reading-skip' }], { skipPracticeCoreRedirect: true }), - /Storage\.set\(practice_records\) is disabled/, - 'skipPracticeCoreRedirect 不能让 public set 写 practice_records' - ); - assert.strictEqual(savedRecords[0].id, 'api-existing', 'skipPracticeCoreRedirect public set 不能改 canonical records'); - assert.strictEqual(readRawPracticeRecords(harness).some((record) => record && record.id === 'api-skip'), false, - 'skipPracticeCoreRedirect public set 不能写 raw practice_records'); - - await assert.rejects( - () => harness.persistentStore.set('user_stats', { totalPractices: 5 }, { skipReady: true }), - /Storage\.set\(user_stats\) is disabled/, - 'skipReady 不能让 public set(user_stats) 写 stats' - ); - assert.strictEqual(stats.totalPractices, 1, 'skipReady public set(user_stats) 不能改 canonical stats'); - - await assert.rejects( - () => harness.persistentStore.append('practice_records', { id: 'api-append', examId: 'reading-append' }), - /Storage\.append\(practice_records\) is disabled/, - 'public append(practice_records) 必须禁用' - ); - - await assert.rejects( - () => harness.persistentStore.remove('practice_records', { skipReady: true }), - /Storage\.remove\(practice_records\) is disabled/, - 'skipReady 不能让 public remove(practice_records) 清空 records' - ); - assert.strictEqual(savedRecords.length, 1, 'skipReady public remove(practice_records) 不能清空 canonical records'); - - const callTypes = harness.calls.map((call) => call.type); - assert(!callTypes.includes('api.replace'), 'public bypass options must not call PracticeRecordAPI.replace'); - assert(!callTypes.includes('api.clear'), 'public bypass options must not call PracticeRecordAPI.clear'); - assert(!callTypes.includes('api.writeStats'), 'public bypass options must not call PracticeRecordAPI.writeStats'); - assert(!callTypes.includes('api.resetStats'), 'public bypass options must not call PracticeRecordAPI.resetStats'); -} - -async function testCompressedRealDataKeepsOnlyCanonicalCorrectAnswerMap() { - const harness = await createHarness(); - const compressed = harness.persistentStore.compressRealData({ - score: 1, - totalQuestions: 2, - accuracy: 0.5, - percentage: 50, - duration: 30, - answers: { q1: 'A' }, - correctAnswerMap: { q1: 'A' }, - correctAnswers: { q1: 'B' }, - answerComparison: { - q1: { userAnswer: 'A', correctAnswer: 'B', isCorrect: false } - }, - isRealData: true, - source: 'test' - }); - - assert.deepStrictEqual(compressed.correctAnswerMap, { q1: 'A' }, '压缩 realData 只能保留 canonical correctAnswerMap'); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(compressed, 'correctAnswers'), - false, - '压缩 realData 不能保留 legacy correctAnswers 对象' - ); - assert.strictEqual( - Object.prototype.hasOwnProperty.call(compressed.answerComparison.q1, 'correctAnswer'), - false, - '压缩 answerComparison 不能保留 correctAnswer 作为第二事实源' - ); - assert.strictEqual(compressed.answerComparison.q1.isCorrect, false, '压缩 comparison 应保留已有正误显示结果'); -} - -async function testPublicStorageFacadeDoesNotFallbackToPracticeCore() { - const harness = await createHarness(); - harness.window.PracticeCore = { - store: { - handlesStorageKey(key) { - return key === 'practice_records' || key === 'user_stats'; - }, - async routeStorageSet() { - throw new Error('public storage facade must not fallback to PracticeCore.store routeStorageSet'); - }, - async routeStorageRemove() { - throw new Error('public storage facade must not fallback to PracticeCore.store routeStorageRemove'); - } - } - }; - - await assert.rejects( - () => harness.persistentStore.get('practice_records', []), - /Storage\.get\(practice_records\): PracticeRecordAPI\.list not ready/, - 'API 缺失时 public storage.get(practice_records) 必须失败,不能返回默认值或读 raw store' - ); - - await assert.rejects( - () => harness.persistentStore.set('practice_records', [{ id: 'must-not-write' }]), - /Storage\.set\(practice_records\) is disabled/, - 'API 缺失时 public storage.set(practice_records) 必须失败' - ); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'must-not-write'), - false, - 'API 缺失时 public storage.set(practice_records) 不能写 raw store' - ); - - await assert.rejects( - () => harness.persistentStore.remove('practice_records'), - /Storage\.remove\(practice_records\) is disabled/, - 'API 缺失时 public storage.remove(practice_records) 必须失败' - ); -} - -async function testRestoreFromBackupFailsWithoutPracticeRecordAPI() { - const harness = await createHarness(); - const result = await harness.persistentStore.restoreFromBackup(); - - assert.strictEqual(result, false, '内置备份恢复缺少 PracticeRecordAPI 时应跳过失败'); - assert.strictEqual( - readRawPracticeRecords(harness).some((record) => record && record.id === 'backup-record'), - false, - '内置备份恢复失败不能把备份记录写入 raw practice_records' - ); -} - -async function main() { - await testStorageDataSourceReadBypassesPublicPracticeRecordRedirect(); - await testRuntimeImportFailsWithoutPracticeRecordAPI(); - await testInternalStorageAccessIsNotWindowPublic(); - await testFullDataBootstrapHidesInternalPracticeRepositories(); - await testRuntimeImportUsesPracticeRecordAPI(); - await testPublicStorageFacadeReadsWithPracticeRecordAPIAndRejectsWrites(); - await testPublicBypassOptionsCannotWritePracticeData(); - await testCompressedRealDataKeepsOnlyCanonicalCorrectAnswerMap(); - await testPublicStorageFacadeDoesNotFallbackToPracticeCore(); - await testRestoreFromBackupFailsWithoutPracticeRecordAPI(); - - process.stdout.write(JSON.stringify({ - status: 'pass', - detail: 'StorageManager 运行期 records/stats 公开读只通过 PracticeRecordAPI,公开写入口 fail-fast' - })); -} - -main().catch((error) => { - process.stdout.write(JSON.stringify({ - status: 'fail', - detail: error && error.message ? error.message : String(error) - })); - process.exit(1); -}); diff --git a/developer/tests/js/suiteInlineFallback.test.js b/developer/tests/js/suiteInlineFallback.test.js index e2fdf51b..7bde504b 100644 --- a/developer/tests/js/suiteInlineFallback.test.js +++ b/developer/tests/js/suiteInlineFallback.test.js @@ -4,6 +4,7 @@ import fs from 'fs'; import vm from 'vm'; import assert from 'assert'; import { fileURLToPath } from 'url'; +import { webcrypto } from 'node:crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -41,6 +42,7 @@ function createExamWindow(parentWindow) { Date, Array, JSON, + URL, }); scriptContext.globalThis = examWindow; examWindow.window = examWindow; @@ -78,6 +80,7 @@ function createExamWindow(parentWindow) { querySelectorAll() { return [buttonStub]; }, + referrer: 'http://localhost/index.html', readyState: 'complete', defaultView: null }; @@ -87,7 +90,7 @@ function createExamWindow(parentWindow) { document: doc, opener: parentWindow, parent: parentWindow, - location: { href: 'http://localhost/p1.html' }, + location: { href: 'http://localhost/p1.html', protocol: 'http:' }, closed: false, _messageListeners: messageListeners, _messages: [], @@ -101,7 +104,7 @@ function createExamWindow(parentWindow) { postMessage(message) { this._messages.push(message); messageListeners.slice().forEach(listener => { - listener({ data: message, source: parentWindow }); + listener({ data: message, source: parentWindow, origin: 'http://localhost' }); }); }, focus() {}, @@ -135,6 +138,7 @@ async function main() { location: { origin: 'http://localhost', href: 'http://localhost/index.html' }, screen: { availWidth: 1920, availHeight: 1080 }, document: { title: 'IELTS Practice' }, + crypto: webcrypto, postMessage(message) { this._messages.push(message); } @@ -151,6 +155,8 @@ async function main() { Date, JSON, Array, + URL, + Uint8Array, }; sandbox.globalThis = sandbox.window; @@ -191,6 +197,13 @@ async function main() { app.suiteExamMap = new Map([[examId, suiteSessionId]]); const examWindow = createExamWindow(parentWindow); + app.examWindows = new Map([[examId, { + window: examWindow, + expectedSessionId: `session-${examId}`, + expectedUrl: examWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); app.injectInlineScript(examWindow, examId); const initMessage = examWindow._messages.find(msg => msg && msg.type === 'INIT_SESSION'); @@ -209,12 +222,32 @@ async function main() { assert.strictEqual(examWindow._nativeCloseCalled, false, '窗口不应真正关闭'); assert.strictEqual(examWindow.closed, false, '窗口状态应保持开启'); - const navigateMessage = { type: 'SUITE_NAVIGATE', data: { url: 'http://localhost/p2.html', examId: 'reading-inline-2' } }; - examWindow._messageListeners.forEach(listener => listener({ data: navigateMessage })); + const navigateMessage = { + type: 'SUITE_NAVIGATE', + source: 'exam_host', + data: { + url: 'http://localhost/p2.html', + examId: 'reading-inline-2', + windowSessionToken: initMessage.data.windowSessionToken + } + }; + examWindow._messageListeners.forEach(listener => listener({ + data: navigateMessage, + source: parentWindow, + origin: 'http://localhost' + })); assert.strictEqual(examWindow.location.href, 'http://localhost/p2.html', '应在标签页内导航至下一篇'); - const forceCloseMessage = { type: 'SUITE_FORCE_CLOSE', data: { suiteSessionId } }; - examWindow._messageListeners.forEach(listener => listener({ data: forceCloseMessage })); + const forceCloseMessage = { + type: 'SUITE_FORCE_CLOSE', + source: 'exam_host', + data: { suiteSessionId, windowSessionToken: initMessage.data.windowSessionToken } + }; + examWindow._messageListeners.forEach(listener => listener({ + data: forceCloseMessage, + source: parentWindow, + origin: 'http://localhost' + })); assert.strictEqual(examWindow._nativeCloseCalled, true, '强制关闭应调用原生 close'); assert.strictEqual(examWindow.closed, true, '强制关闭后窗口应标记为关闭'); diff --git a/developer/tests/js/suiteModeFlow.test.js b/developer/tests/js/suiteModeFlow.test.js index 3a9b70c1..6606fd4c 100755 --- a/developer/tests/js/suiteModeFlow.test.js +++ b/developer/tests/js/suiteModeFlow.test.js @@ -4,6 +4,7 @@ import fs from 'fs'; import vm from 'vm'; import assert from 'assert'; import { fileURLToPath } from 'url'; +import { webcrypto } from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -23,7 +24,7 @@ function createStubWindow(name) { const stub = { name, closed: false, - location: { href: 'about:blank' }, + location: { href: 'http://localhost/exam.html' }, document: { title: '', addEventListener() {}, removeEventListener() {} }, focus() { this._focused = true; }, close() { this.closed = true; }, @@ -51,19 +52,8 @@ function createStubWindow(name) { } async function main() { - const storageState = new Map(); const practiceRecords = []; - const storage = { - async get(key, fallback = undefined) { - if (storageState.has(key)) { - return deepClone(storageState.get(key)); - } - return deepClone(fallback); - }, - async set(key, value) { - storageState.set(key, deepClone(value)); - } - }; + const windowSessions = new Map(); const documentStub = { title: '', @@ -74,6 +64,15 @@ async function main() { dispatchEvent() { return true; }, createElement() { return { className: '', style: {} }; } }; + const navigatorStub = { + locks: { + request(name, options = {}, callback) { + assert.strictEqual(options.mode, 'exclusive'); + assert.strictEqual(options.ifAvailable, true); + return Promise.resolve(callback({ name: String(name || ''), mode: 'exclusive' })); + } + } + }; const windowStub = { _messages: [], @@ -82,7 +81,9 @@ async function main() { }, addEventListener() {}, removeEventListener() {}, - location: { href: 'http://localhost/' }, + location: { href: 'http://localhost/', origin: 'http://localhost' }, + navigator: navigatorStub, + crypto: webcrypto, screen: { availWidth: 1920, availHeight: 1080 }, document: documentStub, practicePageManager: { @@ -97,59 +98,62 @@ async function main() { } }; - windowStub.storage = storage; - windowStub.PracticeRecordAPI = { - async list() { - return deepClone(practiceRecords); - }, - async saveRecord(record) { - practiceRecords.unshift(deepClone(record)); - return deepClone(record); - }, - async deleteMany(ids) { - const targets = new Set((Array.isArray(ids) ? ids : []).map((id) => String(id))); - let deleted = 0; - for (let index = practiceRecords.length - 1; index >= 0; index -= 1) { - const record = practiceRecords[index]; - if (record && targets.has(String(record.id || record.sessionId || ''))) { - practiceRecords.splice(index, 1); - deleted += 1; + windowStub.resolveActiveLibraryIndex = async () => deepClone(examIndex); + windowStub.AppData = { + ready: Promise.resolve(), + practice: { + async list() { return deepClone(practiceRecords); }, + async getStats() { return { totalPractices: practiceRecords.length }; }, + async finalizeSuite({ record, childSessionIds = [] }) { + const targets = new Set(childSessionIds.map(String)); + for (let index = practiceRecords.length - 1; index >= 0; index -= 1) { + const current = practiceRecords[index]; + if (targets.has(String(current && (current.id || current.sessionId) || ''))) { + practiceRecords.splice(index, 1); + } } + const identity = String(record && (record.id || record.sessionId) || ''); + const existing = practiceRecords.findIndex((item) => String(item && (item.id || item.sessionId) || '') === identity); + if (existing >= 0) practiceRecords[existing] = deepClone(record); + else practiceRecords.unshift(deepClone(record)); + return { committed: true, operationId: `suite-${identity}`, record: deepClone(record), derived: { status: 'ready', pending: [] }, warnings: [] }; } - return { deleted }; }, - async recalculateStats() { - return { totalPractices: practiceRecords.length }; + recovery: { + windowSession: { + save(name, value) { windowSessions.set(String(name), deepClone(value)); return true; }, + get(name) { return deepClone(windowSessions.get(String(name)) || null); }, + discard(name) { windowSessions.delete(String(name)); return true; } + }, + async listDrafts() { return []; }, + async listActiveSessions() { return []; }, + async saveActiveSession() { return { committed: true }; }, + async discardActiveSession() { return { committed: true }; }, + async cleanupForRetry() { return { committed: true, removedCount: 0, removedByKind: {} }; } } }; windowStub.CustomEvent = function CustomEvent(type, init = {}) { return { type, detail: init.detail || null }; }; - const sessionStorageStub = new Map(); - const sessionStorageObj = { - getItem(key) { return sessionStorageStub.get(key) || null; }, - setItem(key, value) { sessionStorageStub.set(key, String(value)); }, - removeItem(key) { sessionStorageStub.delete(key); }, - clear() { sessionStorageStub.clear(); } - }; - const sandbox = { window: windowStub, - storage, console, setTimeout, clearTimeout, setInterval, clearInterval, Math, + crypto: webcrypto, + URL, document: documentStub, CustomEvent: windowStub.CustomEvent }; + sandbox.navigator = navigatorStub; sandbox.globalThis = sandbox.window; - sandbox.window.sessionStorage = sessionStorageObj; const context = vm.createContext(sandbox); + const createVmMap = vm.runInContext('() => new Map()', context); loadScript('js/app/examSessionMixin.js', context); loadScript('js/app/suitePracticeMixin.js', context); @@ -184,10 +188,6 @@ async function main() { } ]; - await storage.set('exam_index', examIndex); - await storage.set('active_exam_index_key', 'exam_index'); - await storage.set('active_sessions', []); - const mixins = windowStub.ExamSystemAppMixins; if (!mixins || !mixins.examSession || !mixins.suitePractice) { throw new Error('未能加载所需的 mixin'); @@ -205,6 +205,7 @@ async function main() { }; Object.assign(app, mixins.examSession, mixins.suitePractice); + app.examWindows = createVmMap(); app.ensureExamWindowSession = function ensureExamWindowSession(examId, win) { if (!this.examWindows) { @@ -223,9 +224,42 @@ async function main() { }; app.setupExamWindowManagement = function setupExamWindowManagement() {}; + let registrationGeneration = 0; + const installManagedTestWindow = (targetApp, examId, targetWindow, options = {}) => { + registrationGeneration += 1; + const expectedSessionId = targetApp.generateSessionId(examId); + const windowInfo = { + examId, + window: targetWindow, + navigationOwnership: options.launchOwnership || null, + suiteSessionId: options.suiteSessionId || targetApp.currentSuiteSession && targetApp.currentSuiteSession.id || null, + expectedSessionId, + windowSessionToken: `flow-token-${registrationGeneration}`, + windowSessionTokenSessionId: expectedSessionId, + sessionGeneration: registrationGeneration, + status: 'active' + }; + targetApp.examWindows.set(examId, windowInfo); + if (options.launchOwnership && typeof targetApp._recordExamLaunchRegistrationReceipt === 'function') { + targetApp._recordExamLaunchRegistrationReceipt( + examId, + options.launchOwnership, + targetApp._captureExamSessionRegistration(examId, windowInfo) + ); + } + if (options.launchOwnership && typeof targetApp._commitExamLaunchOwnership === 'function') { + assert.strictEqual(targetApp._commitExamLaunchOwnership(options.launchOwnership), true); + } + return targetWindow; + }; + const windowsMap = new Map(); const openCalls = []; let openAttempt = 0; + app._postExamMessage = (examId, targetWindow, type, data = {}) => { + targetWindow.postMessage({ type, data: { ...data, examId } }, 'http://localhost'); + return true; + }; app.openExam = async function openExamStub(examId, options = {}) { openAttempt += 1; @@ -242,6 +276,10 @@ async function main() { targetWindow = createStubWindow(name); windowsMap.set(name, targetWindow); } + if (options.launchOwnership + && !this._claimExamLaunchWindowOwnership(options.launchOwnership, targetWindow)) { + return null; + } targetWindow.lastExamId = examId; if (typeof this.startPracticeSession === 'function') { @@ -254,11 +292,15 @@ async function main() { this.setupExamWindowManagement(targetWindow, examId); } - return targetWindow; + return installManagedTestWindow(this, examId, targetWindow, options); } if (openAttempt === 2) { assert(reuseWindow, '第二次调用应提供复用窗口'); + if (options.launchOwnership + && !this._claimExamLaunchWindowOwnership(options.launchOwnership, reuseWindow)) { + return null; + } reuseWindow.lastExamId = examId; if (typeof this.startPracticeSession === 'function') { @@ -271,7 +313,7 @@ async function main() { this.setupExamWindowManagement(reuseWindow, examId); } - return reuseWindow; + return installManagedTestWindow(this, examId, reuseWindow, options); } if (openAttempt === 3) { @@ -283,6 +325,10 @@ async function main() { const newWindow = createStubWindow(name); newWindow.lastExamId = examId; windowsMap.set(name, newWindow); + if (options.launchOwnership + && !this._claimExamLaunchWindowOwnership(options.launchOwnership, newWindow)) { + return null; + } if (typeof this.startPracticeSession === 'function') { await this.startPracticeSession(examId); @@ -294,7 +340,7 @@ async function main() { this.setupExamWindowManagement(newWindow, examId); } - return newWindow; + return installManagedTestWindow(this, examId, newWindow, options); } return null; @@ -346,7 +392,7 @@ async function main() { assert.strictEqual(handledP3, true, 'P3 完成后应顺利收尾'); assert.strictEqual(app.currentSuiteSession, null, '套题会话应在完成后被清理'); - const savedPracticeRecords = await windowStub.PracticeRecordAPI.list(); + const savedPracticeRecords = await windowStub.AppData.practice.list(); assert.strictEqual(savedPracticeRecords.length, 1, '应只生成一条套题练习记录'); assert.strictEqual(savedPracticeRecords[0].suiteEntries.length, 3, '套题记录应包含三篇文章'); @@ -365,6 +411,7 @@ async function main() { _updatePracticeRecordsState: async () => {} }; Object.assign(appSim, mixins.examSession, mixins.suitePractice); + appSim.examWindows = createVmMap(); appSim.ensureExamWindowSession = app.ensureExamWindowSession; appSim.injectDataCollectionScript = app.injectDataCollectionScript; appSim.setupExamWindowManagement = app.setupExamWindowManagement; @@ -373,12 +420,18 @@ async function main() { appSim.openExam = async function(examId, options = {}) { simOpenCalls.push({ examId, options: { ...options } }); const name = options.windowName && options.windowName.trim() ? options.windowName.trim() : '_blank'; - const win = createStubWindow(name); + const win = options.reuseWindow && !options.reuseWindow.closed + ? options.reuseWindow + : createStubWindow(name); + if (options.launchOwnership + && !this._claimExamLaunchWindowOwnership(options.launchOwnership, win)) { + return null; + } win.lastExamId = examId; - return win; + return installManagedTestWindow(this, examId, win, options); }; - sessionStorageStub.clear(); + windowStub.AppData.recovery.windowSession.discard('simulation'); await appSim.startSuitePractice({ flowMode: 'simulation' }); const simSession = appSim.currentSuiteSession; assert(simSession, '模拟会话应被创建'); @@ -393,10 +446,9 @@ async function main() { assert.strictEqual(simSession.currentIndex, 1, '应前进到第二篇'); assert(simSession.draftsByExam[simP1.examId], 'P1 draft 应被保存'); - // 验证 sessionStorage 镜像 - const stored = sessionStorageStub.get('ielts_sim_session'); - assert(stored, 'sessionStorage 应包含会话镜像'); - const snapshot = JSON.parse(stored); + // 验证窗口级 recovery 领域镜像 + const snapshot = windowStub.AppData.recovery.windowSession.get('simulation'); + assert(snapshot, 'recovery.windowSession 应包含会话镜像'); assert.strictEqual(snapshot.id, simSession.id, '镜像 id 应匹配'); assert.strictEqual(snapshot.currentIndex, 1, '镜像 currentIndex 应为 1'); @@ -410,7 +462,7 @@ async function main() { const simNavOob = await appSim._handleSimulationNavigate(simP1.examId, { direction: 'prev' }, simSession.windowRef); assert.strictEqual(simNavOob, false, 'P1 向前导航应失败'); - process.stdout.write(JSON.stringify({ status: 'pass', detail: '模拟模式按顺序串联三篇题目并生成单条记录,导航与 sessionStorage 镜像正常' })); + process.stdout.write(JSON.stringify({ status: 'pass', detail: '模拟模式按顺序串联三篇题目并生成单条记录,导航与 recovery.windowSession 镜像正常' })); } main().catch(error => { diff --git a/developer/tests/js/suiteModeRegression.test.js b/developer/tests/js/suiteModeRegression.test.js index ae532678..c3128f13 100644 --- a/developer/tests/js/suiteModeRegression.test.js +++ b/developer/tests/js/suiteModeRegression.test.js @@ -4,6 +4,7 @@ import fs from 'fs'; import vm from 'vm'; import assert from 'assert'; import { fileURLToPath } from 'url'; +import { webcrypto } from 'node:crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,7 +20,7 @@ function createStubWindow(name) { return { name, closed: false, - location: { href: 'about:blank' }, + location: { href: 'http://localhost/exam.html' }, _messages: [], postMessage(payload) { this._messages.push(payload); @@ -28,24 +29,27 @@ function createStubWindow(name) { }; } -function createSandbox() { - const storageStub = { - _data: new Map(), - async get(key, fallback = null) { - return this._data.has(key) ? this._data.get(key) : fallback; - }, - async set(key, value) { - this._data.set(key, value); - return true; - } +function createSandbox(options = {}) { + const cloneValue = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); + const windowSessionStore = new Map(); + let activeSessions = []; + let practiceRecords = []; + const recoveryControl = { + saveQueue: [], + cleanupQueue: [], + events: [] }; - - const sessionStorageStub = new Map(); - const sessionStorageObj = { - getItem(key) { return sessionStorageStub.get(key) || null; }, - setItem(key, value) { sessionStorageStub.set(key, String(value)); }, - removeItem(key) { sessionStorageStub.delete(key); }, - clear() { sessionStorageStub.clear(); } + const navigatorStub = { + locks: { + async request(name, lockOptions = {}, callback) { + assert.strictEqual(lockOptions.mode, 'exclusive'); + assert.strictEqual(lockOptions.ifAvailable, true); + const normalizedName = String(name || ''); + // Each createApp fixture represents the sole realm under test. The callback's + // returned promise controls how long the exclusive lease remains held. + return await callback({ name: normalizedName, mode: 'exclusive' }); + } + } }; const documentStub = { @@ -82,17 +86,100 @@ function createSandbox() { track(listenerStats.removed, type); }, showMessage() {}, + AppData: { + ready: Promise.resolve(), + recovery: { + async listActiveSessions() { + return cloneValue(activeSessions); + }, + async saveActiveSession(value, options = {}) { + recoveryControl.events.push({ type: 'save', value: cloneValue(value), options: cloneValue(options) }); + const behavior = recoveryControl.saveQueue.length ? recoveryControl.saveQueue.shift() : true; + if (behavior instanceof Error) throw behavior; + if (typeof behavior === 'function') return behavior(value, options); + if (behavior === false) return { committed: false }; + const id = String(value && value.id || ''); + const index = activeSessions.findIndex((item) => String(item && item.id || '') === id); + if (index >= 0) activeSessions[index] = cloneValue(value); + else activeSessions.push(cloneValue(value)); + return { committed: true, item: cloneValue(value) }; + }, + async discardActiveSession(id) { + recoveryControl.events.push({ type: 'discard', id: String(id) }); + activeSessions = activeSessions.filter((item) => String(item && item.id || '') !== String(id)); + return { committed: true }; + }, + async cleanupForRetry(options = {}) { + recoveryControl.events.push({ type: 'cleanup', options: cloneValue(options) }); + const behavior = recoveryControl.cleanupQueue.length ? recoveryControl.cleanupQueue.shift() : true; + if (behavior instanceof Error) throw behavior; + return { committed: behavior !== false, removedCount: behavior === false ? 0 : 1, removedByKind: {} }; + }, + windowSession: { + save(kind, value) { + windowSessionStore.set(String(kind), cloneValue(value)); + return true; + }, + get(kind) { + return cloneValue(windowSessionStore.get(String(kind)) || null); + }, + discard(kind) { + windowSessionStore.delete(String(kind)); + return true; + } + } + }, + practice: { + async list() { + return cloneValue(practiceRecords); + }, + async get(recordId) { + const target = String(recordId || ''); + const record = practiceRecords.find((item) => item && ( + String(item.id || '') === target || String(item.sessionId || '') === target + )); + return cloneValue(record || null); + }, + async getStats() { + return {}; + }, + async completeAttempt(command = {}) { + const record = cloneValue(command.record || {}); + practiceRecords.push(record); + return { committed: true, record }; + }, + async finalizeSuite(command = {}) { + const record = cloneValue(command.record || {}); + practiceRecords = [record]; + return { committed: true, record }; + } + }, + preferences: { + async patchSuite() { + return { committed: true }; + } + } + }, + async resolveExamForPracticeRecord(record) { + const examId = String(record && record.examId || ''); + return examId ? { id: examId, title: record.title || examId, type: 'reading', path: 'Reading/' + examId + '/' } : null; + }, + async resolveActiveLibraryIndex() { + return ['reading-p1', 'reading-p2', 'reading-p3'].map((id) => ({ + id, + title: id, + type: 'reading', + path: 'Reading/' + id + '/' + })); + }, CustomEvent: function CustomEvent(type, init = {}) { return { type, detail: init.detail || null }; }, - location: { origin: 'http://localhost', href: 'http://localhost/' }, - localStorage: { - _data: new Map(), - getItem(key) { return this._data.has(key) ? this._data.get(key) : null; }, - setItem(key, value) { this._data.set(key, String(value)); }, - removeItem(key) { this._data.delete(key); } - }, - sessionStorage: sessionStorageObj, + location: options.protocol === 'file:' + ? { protocol: 'file:', origin: 'null', href: 'file:///index.html' } + : { protocol: 'http:', origin: 'http://localhost', href: 'http://localhost/' }, + crypto: webcrypto, + navigator: navigatorStub, practiceConfig: { suite: {} }, __listenerCount(type) { if (!listenerRegistry.has(type)) return 0; @@ -100,11 +187,14 @@ function createSandbox() { }, __listenerStats: listenerStats }; + windowStub.__dispatchEvent = (type, event) => { + const listeners = listenerRegistry.has(type) ? Array.from(listenerRegistry.get(type)) : []; + listeners.forEach((listener) => listener(event)); + }; const sandbox = { window: windowStub, document: documentStub, - storage: storageStub, console, setTimeout, clearTimeout, @@ -113,15 +203,15 @@ function createSandbox() { Math, CustomEvent: windowStub.CustomEvent, URL, - URLSearchParams + URLSearchParams, + Uint8Array }; - windowStub.storage = storageStub; + sandbox.navigator = navigatorStub; sandbox.globalThis = sandbox.window; - sandbox.window.storage = storageStub; - return { sandbox, windowStub, sessionStorageStub }; + return { sandbox, windowStub, windowSessionStore, recoveryControl }; } -function createApp(windowStub) { +function createApp(windowStub, options = {}) { const app = { components: {}, setState() {}, @@ -133,9 +223,84 @@ function createApp(windowStub) { saveRealPracticeData: async () => {} }; Object.assign(app, windowStub.ExamSystemAppMixins.examSession, windowStub.ExamSystemAppMixins.suitePractice); + app._createSuiteTestMap = typeof windowStub.__createSuiteTestMap === 'function' + ? windowStub.__createSuiteTestMap + : () => new Map(); + if (options.suiteModeReady !== false) { + app._suiteModeReady = true; + app.currentSuiteSession = null; + app.suiteExamMap = app._createSuiteTestMap(); + app.multiSuiteSessionsMap = app._createSuiteTestMap(); + app._multiSuiteCompletionTails = app._createSuiteTestMap(); + app._suiteSessionGeneration = 0; + app._suiteRecoveryReady = Promise.resolve(null); + } return app; } +function installManagedTestWindow(app, examId, targetWindow, options = {}) { + if (!targetWindow || targetWindow.closed) return targetWindow; + const managedMap = typeof app._createSuiteTestMap === 'function' + ? app._createSuiteTestMap() + : new Map(); + if (!app.examWindows || app.examWindows.constructor !== managedMap.constructor) { + if (app.examWindows && typeof app.examWindows.entries === 'function') { + for (const [key, value] of app.examWindows.entries()) managedMap.set(key, value); + } + app.examWindows = managedMap; + } + const previous = app.examWindows.get(examId); + const generation = Math.max(0, Number(previous && previous.sessionGeneration) || 0) + 1; + const expectedSessionId = `test-session:${String(examId)}:${generation}`; + const windowSessionToken = `test-token:${String(examId)}:${generation}`; + const info = { + window: targetWindow, + navigationOwnership: { examId: String(examId), generation }, + suiteSessionId: options.suiteSessionId || (app.currentSuiteSession && app.currentSuiteSession.id) || null, + expectedSessionId, + windowSessionToken, + windowSessionTokenSessionId: expectedSessionId, + expectedUrl: 'http://localhost/exam.html', + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + sessionGeneration: generation, + status: 'active' + }; + app.examWindows.set(examId, info); + if (options.launchOwnership && typeof app._recordExamLaunchRegistrationReceipt === 'function') { + app._recordExamLaunchRegistrationReceipt( + examId, + options.launchOwnership, + app._captureExamSessionRegistration(examId, info) + ); + } + if (options.launchOwnership && typeof app._commitExamLaunchOwnership === 'function') { + assert.strictEqual(app._commitExamLaunchOwnership(options.launchOwnership), true); + } + return targetWindow; +} + +function buildOwnedStartResult(app, examId, value = true, launchOwnership = null) { + const windowInfo = app.examWindows && app.examWindows.get(examId); + return app._buildPracticeSessionOwnedSuccess( + examId, + 'test', + windowInfo && windowInfo.expectedSessionId, + value, + windowInfo, + launchOwnership + ); +} + +async function restoreOwnedMultiSuiteItems(app, windowSession, items) { + assert.strictEqual( + await app._acquireMultiSuiteRecoveryOwnership(windowSession), + true, + 'multi-suite restore fixture must own the base and exact recovery leases' + ); + return await app._restorePersistentMultiSuiteSessions(items, [windowSession]); +} + function plain(value) { return JSON.parse(JSON.stringify(value)); } @@ -165,16 +330,520 @@ function makeSession(sessionId = 'suite_test_1') { } async function run() { - const { sandbox, windowStub, sessionStorageStub } = createSandbox(); + const { sandbox, windowStub, windowSessionStore, recoveryControl } = createSandbox(); const context = vm.createContext(sandbox); loadScript('js/app/examSessionMixin.js', context); loadScript('js/app/suitePracticeMixin.js', context); + windowStub.__createSuiteTestMap = vm.runInContext('() => new Map()', context); if (!windowStub.ExamSystemAppMixins || !windowStub.ExamSystemAppMixins.suitePractice) { throw new Error('mixin 加载失败'); } + // Case 0.0: 套题必须先提交 durable v2 recovery;配额错误只清 recovery 后重试一次。 + { + const app = createApp(windowStub); + const quotaError = new Error('quota'); + quotaError.code = 'QUOTA_EXCEEDED'; + recoveryControl.saveQueue.push(quotaError, true); + recoveryControl.events.length = 0; + let openCount = 0; + app.openExam = async (examId, openOptions = {}) => { + openCount += 1; + assert.deepStrictEqual( + recoveryControl.events.map((event) => event.type), + ['save', 'cleanup', 'save'], + '首题窗口只能在清理并确认 durable recovery 后打开' + ); + const targetWindow = installManagedTestWindow(app, examId, createStubWindow('suite-window'), openOptions); + const targetRegistration = app._captureSuiteNavigationRegistration( + examId, + targetWindow, + openOptions.suiteSessionId, + openOptions.launchOwnership + ); + assert(targetRegistration, 'successful open fixture must install an exact managed registration'); + assert.strictEqual( + app._isSuiteNavigationRegistrationCurrent(examId, targetRegistration, openOptions.suiteSessionId), + true + ); + assert.strictEqual( + app._isSuiteExamLaunchOwnershipCurrent(examId, openOptions.launchOwnership, targetWindow), + false, + 'open commit must release the reservation before returning to the suite caller' + ); + return targetWindow; + }; + assert.strictEqual( + await app._launchSuiteSessionFromSequence(makeSession('suite_start_retry').sequence, { flowMode: 'simulation' }), + true, + '配额清理后的单次重试成功时应启动套题' + ); + assert.strictEqual(openCount, 1, 'durable commit 成功后只能打开一次首题'); + const cleanupEvent = recoveryControl.events.find((event) => event.type === 'cleanup'); + assert.deepStrictEqual( + plain(cleanupEvent.options.preserve.activeSession), + [app.currentSuiteSession.id], + '清理必须保护当前套题 recovery' + ); + } + + // Case 0.0.1: cleanup 后仍失败或存储被拒绝时,套题不得 fail-open。 + for (const failureMode of ['quota', 'backend']) { + const app = createApp(windowStub); + const firstError = new Error(failureMode); + firstError.code = failureMode === 'quota' ? 'QUOTA_EXCEEDED' : 'BACKEND_UNAVAILABLE'; + recoveryControl.events.length = 0; + recoveryControl.saveQueue.push(firstError); + if (failureMode === 'quota') { + const retryError = new Error('quota-retry'); + retryError.code = 'QUOTA_EXCEEDED'; + recoveryControl.saveQueue.push(retryError); + } + let openCount = 0; + app.openExam = async () => { + openCount += 1; + return createStubWindow('must-not-open'); + }; + assert.strictEqual( + await app._launchSuiteSessionFromSequence(makeSession(`suite_start_${failureMode}`).sequence, { flowMode: 'simulation' }), + false, + `${failureMode} 持久化失败必须阻止启动` + ); + assert.strictEqual(openCount, 0, '未确认 recovery 时不得打开首题'); + assert.strictEqual(app.currentSuiteSession, null, '未启动会话不得留在内存中伪装成可恢复状态'); + assert.strictEqual( + recoveryControl.events.filter((event) => event.type === 'cleanup').length, + failureMode === 'quota' ? 1 : 0, + '只有 quota 错误允许触发 recovery cleanup' + ); + } + + // Case 0.0.1a: 刷新后的同名存活题页必须旋转 token 重新绑定,不得导航或重载。 + { + const app = createApp(windowStub); + const session = makeSession('suite_live_rebind'); + session.windowRef = null; + session.windowBinding = { + examId: 'reading-p1', + expectedSessionId: 'reading-p1-session', + windowSessionToken: 'old-window-token', + sessionGeneration: 4, + expectedUrl: 'http://localhost/exam.html?examId=reading-p1', + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }; + session.currentIndex = 1; + session.activeExamId = 'reading-p2'; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + assert.strictEqual( + app._buildSuiteWindowBinding(session).examId, + 'reading-p1', + 'proof 前的 fallback binding 必须保留凭据来源篇章' + ); + const liveChild = createStubWindow('ielts-suite-mode-tab'); + liveChild.location.href = session.windowBinding.expectedUrl; + liveChild.addEventListener = () => {}; + const originalPostMessage = liveChild.postMessage.bind(liveChild); + liveChild.postMessage = (payload, targetOrigin) => { + originalPostMessage(payload, targetOrigin); + if (payload && payload.type === 'SUITE_REBIND_CHALLENGE') { + windowStub.__dispatchEvent('message', { + source: liveChild, + origin: 'http://localhost', + data: { + type: 'SUITE_REBIND_PROOF', + source: 'practice_page', + data: { + challenge: payload.data.challenge, + suiteSessionId: session.id, + examId: 'reading-p2', + sessionId: 'reading-p1-session', + windowSessionToken: 'old-window-token', + windowSessionGeneration: 4 + } + } + }); + } + }; + const originalOpen = windowStub.open; + const observedOpenUrls = []; + windowStub.open = (url, name) => { + observedOpenUrls.push({ url, name }); + return liveChild; + }; + const oldNavigationOwnership = app._recordExamWindowNavigation(liveChild, 'reading-p2'); + const oldSameSuiteInfo = { + examId: 'reading-p2', + window: liveChild, + navigationOwnership: oldNavigationOwnership, + suiteSessionId: session.id, + expectedSessionId: 'reading-p1-session', + windowSessionToken: 'old-window-token', + windowSessionTokenSessionId: 'reading-p1-session', + sessionGeneration: 4, + expectedUrl: session.windowBinding.expectedUrl, + expectedOrigin: session.windowBinding.expectedOrigin, + allowOpaqueOrigin: false, + status: 'active' + }; + app.examWindows = app._createSuiteTestMap(); + app.examWindows.set('reading-p2', oldSameSuiteInfo); + assert.strictEqual( + app._buildSuiteWindowBinding(session).windowSessionToken, + 'old-window-token', + 'the fixture must expose the stale same-suite registration that used to overwrite nextBinding' + ); + let durableBindingBeforeSetup = null; + let durableWindowNameBeforeSetup = null; + const originalSetup = app.setupExamWindowManagement.bind(app); + app.setupExamWindowManagement = (...args) => { + const saves = recoveryControl.events.filter((event) => event.type === 'save' + && event.value && String(event.value.id) === String(session.id)); + const latestSave = saves[saves.length - 1]; + durableBindingBeforeSetup = latestSave && plain(latestSave.value.windowBinding); + durableWindowNameBeforeSetup = latestSave && latestSave.value.windowName; + return originalSetup(...args); + }; + try { + const rebound = await app._tryRebindSuiteWindow(session, session.sequence[1]); + assert.strictEqual(rebound.window, liveChild, '应复用同一 WindowProxy'); + assert.deepStrictEqual(observedOpenUrls, [{ url: '', name: 'ielts-suite-mode-tab' }]); + assert.strictEqual(liveChild.location.href, session.windowBinding.expectedUrl, '重绑定不得改写题页 URL'); + const info = app.examWindows.get('reading-p2'); + assert.strictEqual(info.expectedSessionId, 'reading-p1-session', '重绑定必须保留练习 session 身份'); + assert.strictEqual(info.sessionGeneration, 5, '重绑定 generation 必须严格递增'); + assert.notStrictEqual(info.windowSessionToken, 'old-window-token', '重绑定必须旋转 token'); + assert.strictEqual(session.windowBinding.examId, 'reading-p2', 'proof 成功后才能接管实际活动篇章'); + assert.deepStrictEqual(durableBindingBeforeSetup, plain(session.windowBinding)); + assert.strictEqual(durableBindingBeforeSetup.examId, 'reading-p2'); + assert.strictEqual(durableBindingBeforeSetup.expectedSessionId, 'reading-p1-session'); + assert.strictEqual(durableBindingBeforeSetup.sessionGeneration, 5); + assert.notStrictEqual(durableBindingBeforeSetup.windowSessionToken, 'old-window-token'); + assert.strictEqual(durableWindowNameBeforeSetup, session.windowName); + } finally { + windowStub.open = originalOpen; + const info = app.examWindows && app.examWindows.get('reading-p2'); + if (info && info.closeMonitor) clearInterval(info.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + } + } + + // Case 0.0.1aa: 首个 INIT 之前必须已有包含 window binding 的 durable v2 snapshot。 + { + const app = createApp(windowStub); + const session = makeSession('suite_pre_init_binding'); + session.windowRef = null; + session._suiteGeneration = 1; + session._lastDurableRecoveryRevision = 0; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + assert.strictEqual(await app._commitSuiteRecovery(session, { reason: 'pre-init-base' }), true); + const child = createStubWindow('ielts-suite-mode-tab'); + child.addEventListener = () => {}; + let firstInitDurableSnapshot = null; + const originalPostMessage = child.postMessage.bind(child); + child.postMessage = (payload) => { + if (!firstInitDurableSnapshot && payload && String(payload.type || '').toUpperCase() === 'INIT_SESSION') { + const saves = recoveryControl.events.filter((event) => event.type === 'save'); + firstInitDurableSnapshot = saves.length ? plain(saves[saves.length - 1].value) : null; + } + originalPostMessage(payload); + }; + app.resolveReadingLaunchDescriptor = () => ({ + mode: 'unified_html', + url: 'http://localhost/exam.html?examId=reading-p1' + }); + app.openExamWindow = () => child; + app._guardExamWindowContent = (targetWindow) => targetWindow; + app._captureLaunchLibraryConfigurationId = async () => null; + app.startPracticeSession = async (examId, startOptions = {}) => buildOwnedStartResult( + app, + examId, + 'reading-p1-session', + startOptions.launchOwnership + ); + app.injectDataCollectionScript = () => {}; + const opened = await app.openExam('reading-p1', { + examDefinition: session.sequence[0].exam, + target: 'tab', + windowName: session.windowName, + suiteSessionId: session.id, + suiteFlowMode: 'simulation', + suiteTimerMode: 'countdown', + suiteTimerLimitSeconds: 3600, + sequenceIndex: 0, + sequenceTotal: session.sequence.length + }); + assert.strictEqual(opened, child); + assert(firstInitDurableSnapshot && firstInitDurableSnapshot.windowBinding, 'first INIT must observe a durable window binding'); + const firstInit = child._messages.find((message) => message && String(message.type || '').toUpperCase() === 'INIT_SESSION'); + assert(firstInit, 'suite window must receive INIT after the checkpoint'); + assert.strictEqual(firstInitDurableSnapshot.windowBinding.expectedSessionId, firstInit.data.sessionId); + assert.strictEqual(firstInitDurableSnapshot.windowBinding.windowSessionToken, firstInit.data.windowSessionToken); + const info = app.examWindows && app.examWindows.get('reading-p1'); + if (info && info.closeMonitor) clearInterval(info.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + } + + // Case 0.0.1ab: an async sender from a replaced registration must not restore the old map entry. + { + const app = createApp(windowStub); + const oldWindow = createStubWindow('old-registration'); + const newWindow = createStubWindow('new-registration'); + const oldInfo = app.ensureExamWindowSession('reading-p1', oldWindow); + let releaseOldDraft; + app.getReadingDraftForExam = async () => new Promise((resolve) => { releaseOldDraft = resolve; }); + const staleSend = app._sendExamInitEnvelope('reading-p1', oldWindow); + await Promise.resolve(); + const newInfo = { + ...oldInfo, + window: newWindow, + expectedSessionId: 'reading-p1-new-session', + windowSessionToken: 'reading-p1-new-token', + windowSessionTokenSessionId: 'reading-p1-new-session', + sessionGeneration: Number(oldInfo.sessionGeneration) + 1, + initEnvelopeEpoch: 0 + }; + app.examWindows.set('reading-p1', newInfo); + releaseOldDraft({ sessionId: oldInfo.expectedSessionId, updatedAt: 1, answers: { q1: 'OLD' } }); + assert.strictEqual(await staleSend, null); + assert.strictEqual(app.examWindows.get('reading-p1'), newInfo, 'stale sender must not write its old registration back'); + assert.strictEqual(oldWindow._messages.length, 0, 'stale registration must not receive INIT'); + } + + // Case 0.0.1ac: within one registration, only the latest async draft sender may emit INIT. + { + const app = createApp(windowStub); + const child = createStubWindow('same-registration'); + const info = app.ensureExamWindowSession('reading-p1', child); + let callCount = 0; + let releaseSlowDraft; + app.getReadingDraftForExam = async () => { + callCount += 1; + if (callCount === 1) return new Promise((resolve) => { releaseSlowDraft = resolve; }); + return { sessionId: info.expectedSessionId, updatedAt: 20, answers: { q1: 'NEW' } }; + }; + const slowSend = app._sendExamInitEnvelope('reading-p1', child); + await Promise.resolve(); + const fastPayload = await app._sendExamInitEnvelope('reading-p1', child); + assert.strictEqual(fastPayload.draft.answers.q1, 'NEW'); + releaseSlowDraft({ sessionId: info.expectedSessionId, updatedAt: 10, answers: { q1: 'OLD' } }); + assert.strictEqual(await slowSend, null); + assert.strictEqual(child._messages.length, 2, 'only the latest sender should emit the two INIT aliases'); + assert(child._messages.every((message) => message.data.draft.answers.q1 === 'NEW')); + } + + // Case 0.0.1b: file:// 无法读取子窗口 URL 时,完整持久 binding 仍应允许无导航重绑。 + { + const fileHarness = createSandbox({ protocol: 'file:' }); + const fileContext = vm.createContext(fileHarness.sandbox); + loadScript('js/app/examSessionMixin.js', fileContext); + loadScript('js/app/suitePracticeMixin.js', fileContext); + const fileApp = createApp(fileHarness.windowStub); + const session = makeSession('suite_file_live_rebind'); + session.windowRef = null; + session.windowBinding = { + examId: 'reading-p1', + expectedSessionId: 'reading-p1-file-session', + windowSessionToken: 'old-file-token', + sessionGeneration: 7, + expectedUrl: 'file:///reading-practice-unified.html?examId=reading-p1', + expectedOrigin: 'file://', + allowOpaqueOrigin: true + }; + fileApp.currentSuiteSession = session; + fileApp.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + const liveChild = createStubWindow('ielts-suite-mode-tab'); + Object.defineProperty(liveChild, 'location', { + configurable: true, + get() { + const error = new Error('opaque file origin'); + error.name = 'SecurityError'; + throw error; + } + }); + const observedOpenUrls = []; + const originalPostMessage = liveChild.postMessage.bind(liveChild); + liveChild.postMessage = (payload, targetOrigin) => { + originalPostMessage(payload, targetOrigin); + if (payload && payload.type === 'SUITE_REBIND_CHALLENGE') { + fileHarness.windowStub.__dispatchEvent('message', { + source: liveChild, + origin: 'null', + data: { + type: 'SUITE_REBIND_PROOF', + source: 'practice_page', + data: { + challenge: payload.data.challenge, + suiteSessionId: session.id, + examId: 'reading-p1', + sessionId: 'reading-p1-file-session', + windowSessionToken: 'old-file-token', + windowSessionGeneration: 7 + } + } + }); + } + }; + fileHarness.windowStub.open = (url, name) => { + observedOpenUrls.push({ url, name }); + return liveChild; + }; + const rebound = await fileApp._tryRebindSuiteWindow(session, session.sequence[0]); + assert.strictEqual(rebound.window, liveChild, 'file:// must reuse the surviving named WindowProxy'); + assert.deepStrictEqual(observedOpenUrls, [{ url: '', name: 'ielts-suite-mode-tab' }]); + const info = fileApp.examWindows.get('reading-p1'); + assert.strictEqual(info.expectedSessionId, 'reading-p1-file-session'); + assert.strictEqual(info.sessionGeneration, 8); + assert.notStrictEqual(info.windowSessionToken, 'old-file-token'); + if (info && info.closeMonitor) clearInterval(info.closeMonitor); + if (fileApp._handshakeTimers) { + for (const timer of fileApp._handshakeTimers.values()) clearInterval(timer); + fileApp._handshakeTimers.clear(); + } + } + + // Case 0.0.2: 每题 recovery 未提交时必须 NACK,且保持当前篇可重试。 + { + const fileHarness = createSandbox({ protocol: 'file:' }); + const fileContext = vm.createContext(fileHarness.sandbox); + loadScript('js/app/examSessionMixin.js', fileContext); + loadScript('js/app/suitePracticeMixin.js', fileContext); + const fileApp = createApp(fileHarness.windowStub); + const session = makeSession('suite_file_blank_window'); + session.windowRef = null; + session.windowBinding = { + examId: 'reading-p1', + expectedSessionId: 'reading-p1-file-session', + windowSessionToken: 'old-file-token', + sessionGeneration: 2, + expectedUrl: 'file:///reading-practice-unified.html?examId=reading-p1', + expectedOrigin: 'file://', + allowOpaqueOrigin: true + }; + fileApp.currentSuiteSession = session; + const blankChild = createStubWindow('ielts-suite-mode-tab'); + blankChild.location.href = 'about:blank'; + blankChild.close = function close() { this.closed = true; }; + fileHarness.windowStub.open = () => blankChild; + assert.strictEqual(await fileApp._tryRebindSuiteWindow(session, session.sequence[0]), null); + assert.strictEqual(blankChild.closed, true, 'new file:// about:blank window must be closed instead of treated as a rebound child'); + assert.strictEqual(Boolean(fileApp.examWindows && fileApp.examWindows.has('reading-p1')), false); + } + + // Case 0.0.1c: completed recovery 清理失败时不得并发创建新套题。 + { + const app = createApp(windowStub); + const completed = makeSession('suite_completed_cleanup_failure'); + completed.status = 'completed'; + app.currentSuiteSession = completed; + app._teardownSuiteSession = async () => false; + let openCount = 0; + app.openExam = async () => { + openCount += 1; + return createStubWindow('must-not-open'); + }; + assert.strictEqual( + await app._launchSuiteSessionFromSequence(makeSession('replacement').sequence, { flowMode: 'simulation' }), + false + ); + assert.strictEqual(app.currentSuiteSession, completed); + assert.strictEqual(openCount, 0); + } + + // Case 0.0.2: 每题 recovery 未提交时必须 NACK,且保持当前篇可重试。 + { + const app = createApp(windowStub); + const session = makeSession('suite_passage_nack'); + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + let openCount = 0; + app.openExam = async () => { + openCount += 1; + return createStubWindow('suite-window'); + }; + recoveryControl.saveQueue.push(false); + const payload = { + suiteSessionId: session.id, + submissionId: 'submission-p1', + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + draft: { answers: { q1: 'A' }, updatedAt: 100 }, + draftUpdatedAt: 100 + }; + const failed = await app.handleSuitePracticeComplete('reading-p1', payload, session.windowRef); + assert.strictEqual(failed.handled, true); + assert.strictEqual(failed.committed, false, '未持久化 passage 不得 ACK committed'); + assert.strictEqual(failed.errorCode, 'suite_recovery_save_failed'); + assert.strictEqual(session.currentIndex, 0, '失败后必须停留在 P1'); + assert.strictEqual(session.activeExamId, 'reading-p1'); + assert.strictEqual(openCount, 0, '失败后不得清题或切到 P2'); + + const retried = await app.handleSuitePracticeComplete('reading-p1', payload, session.windowRef); + assert.strictEqual(retried.committed, true, '相同 submission 重试提交成功后才 ACK'); + assert.strictEqual(openCount, 1, '成功重试后只前进一次'); + assert.strictEqual(session.currentIndex, 1); + } + // Case 0: 单题历史回顾必须从记录根层或 realData 回灌高亮 + // A passage CAS receipt remains authoritative even if this continuation loses its + // recovery claim before saveActiveSession returns. Keep the receipt-aligned tuple, + // ACK the submission, and suppress every later window side effect. + for (const autoAdvance of [false, true]) { + const app = createApp(windowStub); + const session = makeSession(`suite_passage_post_receipt_${autoAdvance ? 'advance' : 'manual'}`); + if (!autoAdvance) { + session.flowMode = 'stationary'; + session.autoAdvanceAfterSubmit = false; + } + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + let openCount = 0; + app.openExam = async () => { + openCount += 1; + return createStubWindow('must-not-open-after-post-receipt-loss'); + }; + let claimRelease = null; + recoveryControl.saveQueue.push((value) => { + claimRelease = app._releaseSuiteRecoveryClaim('single', session); + return { committed: true, item: plain(value) }; + }); + const outcome = await app.handleSuitePracticeComplete('reading-p1', { + suiteSessionId: session.id, + submissionId: `post-receipt-${autoAdvance ? 'advance' : 'manual'}`, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, session.windowRef); + if (claimRelease) await claimRelease; + assert.strictEqual(outcome.handled, true); + assert.strictEqual(outcome.committed, true, 'a confirmed per-call durable receipt must still ACK'); + assert.strictEqual(outcome.errorCode, 'suite_advance_superseded'); + assert.strictEqual(openCount, 0, 'post-receipt ownership loss must suppress window navigation'); + assert.strictEqual(session.currentIndex, autoAdvance ? 1 : 0); + assert.strictEqual(session.activeExamId, autoAdvance ? 'reading-p2' : 'reading-p1'); + if (autoAdvance) { + assert.strictEqual(session.pendingAdvance, null); + } else { + assert.strictEqual(session.pendingAdvance.completedExamId, 'reading-p1'); + } + const durableEvent = recoveryControl.events.filter((event) => ( + event.type === 'save' && event.value && event.value.id === session.id + )).at(-1); + assert(durableEvent, 'the post-owner-loss outcome must be backed by this invocation durable receipt'); + assert.strictEqual(durableEvent.value.currentIndex, session.currentIndex); + assert.strictEqual(durableEvent.value.activeExamId, session.activeExamId); + } + { const app = createApp(windowStub); const rootHighlights = [{ id: 'hl-root', scope: 'left', text: 'root highlight' }]; @@ -221,11 +890,22 @@ async function run() { app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); const openCalls = []; + recoveryControl.events.length = 0; app.openExam = async (examId, options = {}) => { + const latestSave = recoveryControl.events + .filter((event) => event.type === 'save' && event.value && event.value.id === session.id) + .at(-1); + assert(latestSave, '导航前必须存在当前套题的 durable recovery commit'); + assert.strictEqual(latestSave.value.activeExamId, examId, '导航前 recovery 必须已指向目标篇章'); + assert.strictEqual( + latestSave.value.currentIndex, + session.sequence.findIndex((entry) => entry.examId === examId), + '导航前 recovery 索引必须已切到目标篇章' + ); openCalls.push({ examId, options }); const win = createStubWindow('suite-window'); win.lastExamId = examId; - return win; + return installManagedTestWindow(app, examId, win, options); }; const originalWindow = session.windowRef; @@ -250,7 +930,6 @@ async function run() { // Case 1.1: 手动回看模式下提交后不应自动跳篇 { - windowStub.localStorage.setItem('suite_auto_advance_after_submit', 'false'); const app = createApp(windowStub); const session = makeSession('suite_manual'); session.flowMode = 'classic'; @@ -281,7 +960,6 @@ async function run() { assert.strictEqual(reviewStateCount, 1, '手动模式应下发回看上下文'); assert.strictEqual(session.currentIndex, 0, '手动模式应停留在当前篇'); assert.strictEqual(session.pendingAdvance.completedExamId, 'reading-p1', '应记录待切题状态'); - windowStub.localStorage.removeItem('suite_auto_advance_after_submit'); } // Case 2: SIMULATION_NAVIGATE 前后切换并保存 draft @@ -298,7 +976,7 @@ async function run() { openCalls.push({ examId, options }); const win = createStubWindow('suite-window'); win.lastExamId = examId; - return win; + return installManagedTestWindow(app, examId, win, options); }; // Navigate prev from P2 to P1 @@ -342,10 +1020,10 @@ async function run() { assert.strictEqual(session.currentIndex, 1, '应回到第二篇'); assert.deepStrictEqual(session.draftsByExam['reading-p1'].answers, { q1: 'A' }, 'P1 draft 应被保存'); assert.strictEqual(session.results.length, 2, '应记录两个篇章快照结果'); - const mirroredSession = JSON.parse(sessionStorageStub.get('ielts_sim_session')); + const mirroredSession = windowSessionStore.get('simulation'); const mirroredP2Result = mirroredSession.results.find(entry => entry.examId === 'reading-p2'); - assert.strictEqual(Object.prototype.hasOwnProperty.call(mirroredP2Result, 'highlights'), false, 'sessionStorage results 不应重复写高亮'); - assert.deepStrictEqual(mirroredSession.draftsByExam['reading-p2'].highlights, p2Highlights, 'sessionStorage 应只在 draft 中保存 P2 高亮'); + assert.strictEqual(Object.prototype.hasOwnProperty.call(mirroredP2Result, 'highlights'), false, 'window session results 不应重复写高亮'); + assert.deepStrictEqual(mirroredSession.draftsByExam['reading-p2'].highlights, p2Highlights, 'window session 应只在 draft 中保存 P2 高亮'); const p2Replay = app._buildSuiteReplayEntry(session, 'reading-p2'); assert.deepStrictEqual(p2Replay.highlights, p2Highlights, '套题中途回看必须能恢复 P2 高亮'); @@ -366,15 +1044,26 @@ async function run() { session.flowMode = 'stationary'; session.autoAdvanceAfterSubmit = false; session.results = [ + { + examId: 'reading-p1', title: 'Passage 1', duration: 10, + answers: { q1: 'A' }, answerComparison: {}, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} + }, { examId: 'reading-p2', title: 'Passage 2', + duration: 10, answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, highlights: [], scrollY: 0, rawData: {} + }, + { + examId: 'reading-p3', title: 'Passage 3', duration: 10, + answers: { q1: 'C' }, answerComparison: {}, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} } ]; session.draftsByExam['reading-p2'] = { @@ -412,9 +1101,9 @@ async function run() { const session = makeSession('suite_final_highlight'); const p2Highlights = [{ scope: 'groups', text: 'P2 answer evidence', color: 'green' }]; session.results = [ - { examId: 'reading-p1', title: 'Passage 1', answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }, - { examId: 'reading-p2', title: 'Passage 2', answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, highlights: p2Highlights, scrollY: 240, rawData: { highlights: p2Highlights, scrollY: 240 } }, - { examId: 'reading-p3', title: 'Passage 3', answers: { q1: 'C' }, answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} } + { examId: 'reading-p1', title: 'Passage 1', duration: 10, answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }, + { examId: 'reading-p2', title: 'Passage 2', duration: 10, answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, highlights: p2Highlights, scrollY: 240, rawData: { highlights: p2Highlights, scrollY: 240 } }, + { examId: 'reading-p3', title: 'Passage 3', duration: 10, answers: { q1: 'C' }, answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} } ]; app.currentSuiteSession = session; app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); @@ -477,6 +1166,8 @@ async function run() { }); app.openExam = async (examId, options = {}) => { assert.strictEqual(examId, 'reading-p2', '下一题应打开 P2'); + assert.strictEqual(options.examDefinition.id, 'reading-p2', '跨题回放必须传入按记录来源解析的题目定义'); + assert.strictEqual(options.requireRecordProvenance, true, '跨题回放不得回落到当前活动题库'); app.examWindows.set(examId, { window: secondWindow, reviewMode: Boolean(options.reviewMode), @@ -508,10 +1199,10 @@ async function run() { app.currentSuiteSession = session; app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - app.openExam = async (examId) => { + app.openExam = async (examId, options = {}) => { const win = createStubWindow('suite-window'); win.lastExamId = examId; - return win; + return installManagedTestWindow(app, examId, win, options); }; const hops = [ @@ -550,10 +1241,15 @@ async function run() { let resolveOpen = null; let openCallCount = 0; - app.openExam = async () => { + app.openExam = async (examId, options = {}) => { openCallCount += 1; return await new Promise((resolve) => { - resolveOpen = () => resolve(createStubWindow('suite-window')); + resolveOpen = () => resolve(installManagedTestWindow( + app, + examId, + createStubWindow('suite-window'), + options + )); }); }; @@ -566,8 +1262,8 @@ async function run() { scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } } }, session.windowRef); - const secondNavigate = await app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef); - assert.strictEqual(secondNavigate, false, '并发切题应被锁拒绝,避免重复导航'); + const secondNavigate = app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef); + await new Promise((resolve) => setTimeout(resolve, 0)); assert.strictEqual(openCallCount, 1, '并发切题期间只允许一次窗口切换'); if (typeof resolveOpen === 'function') { @@ -575,12 +1271,55 @@ async function run() { } const firstNavigateOk = await firstNavigate; assert.strictEqual(firstNavigateOk, true, '首个切题请求应成功'); + assert.strictEqual(await secondNavigate, false, '重复的旧篇请求应在串行等待后按 stale 消息忽略'); session.activeExamId = 'reading-p2'; const staleNavigate = await app._handleSimulationNavigate('reading-p1', { direction: 'next' }, session.windowRef); assert.strictEqual(staleNavigate, false, '非活动篇章消息必须忽略'); } + // Case 2.2.1: 新篇提交若撞上上一跳的 ready 等待,必须排队而不能丢失 + { + const app = createApp(windowStub); + const session = makeSession('suite_nav_queue_next'); + session.currentIndex = 0; + session.activeExamId = 'reading-p1'; + app.currentSuiteSession = session; + + let releaseFirstOpen; + const opened = []; + app.openExam = async (examId, options = {}) => { + opened.push(examId); + if (opened.length === 1) { + await new Promise((resolve) => { releaseFirstOpen = resolve; }); + } + return installManagedTestWindow(app, examId, createStubWindow('suite-window'), options); + }; + + const firstNavigate = app._handleSimulationNavigate( + 'reading-p1', + { direction: 'next' }, + session.windowRef + ); + for (let attempt = 0; attempt < 50 && typeof releaseFirstOpen !== 'function'; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.strictEqual(typeof releaseFirstOpen, 'function', '第一跳必须在 claim 与 durable save 后进入 openExam'); + const queuedNavigate = app._handleSimulationNavigate( + 'reading-p2', + { direction: 'next' }, + session.windowRef + ); + assert.deepStrictEqual(opened, ['reading-p2'], '锁内只应启动第一跳'); + + releaseFirstOpen(); + assert.strictEqual(await firstNavigate, true, '第一跳应成功'); + assert.strictEqual(await queuedNavigate, true, '下一篇提交应在第一跳完成后继续处理'); + assert.deepStrictEqual(opened, ['reading-p2', 'reading-p3'], '排队提交应继续切到 P3'); + assert.strictEqual(session.currentIndex, 2, '串行导航后索引应到达 P3'); + assert.strictEqual(session.activeExamId, 'reading-p3', '串行导航后活动篇章应到达 P3'); + } + // Case 2.3: 重复绑定同一 exam 消息通道时必须替换旧监听器 { const app = createApp(windowStub); @@ -626,6 +1365,7 @@ async function run() { const info = app.ensureExamWindowSession('reading-p2', examWindow); info.expectedSessionId = 'expected_session'; + app._refreshExamWindowToken('reading-p2', info); info.suiteSessionId = session.id; app.examWindows.set('reading-p2', info); @@ -649,6 +1389,7 @@ async function run() { examId: 'reading-p2', suiteSessionId: session.id, sessionId: 'stale_session', + windowSessionToken: info.windowSessionToken, direction: 'prev', source: 'practice_page' }, @@ -680,6 +1421,7 @@ async function run() { const info = app.ensureExamWindowSession('reading-p1', examWindow); info.expectedSessionId = 'expected_inline_session'; + app._refreshExamWindowToken('reading-p1', info); info.suiteSessionId = session.id; app.examWindows.set('reading-p1', info); @@ -693,6 +1435,7 @@ async function run() { examId: 'reading-p2', suiteSessionId: session.id, sessionId: 'stale_inline_session', + windowSessionToken: info.windowSessionToken, draft: { answers: { q1: 'P2 answer' }, highlights: [{ scope: 'left', text: 'P2 highlight' }], @@ -712,7 +1455,51 @@ async function run() { assert.strictEqual(session.draftsByExam['reading-p2'].noteText, 'P2 note', 'P2 noteText 应保存'); } - // Case 2.4.2: inline simulation 草稿同步必须按篇拆分 elapsed,并镜像回 sessionStorage + // Case 2.4.1a: a queued classic draft cannot write through a replaced registration. + { + const app = createApp(windowStub); + const oldWindow = createStubWindow('old-reading-window'); + const newWindow = createStubWindow('new-reading-window'); + const oldInfo = { + window: oldWindow, + expectedSessionId: 'reading-session', + sessionGeneration: 1, + practiceMode: 'classic', + suiteSessionId: null, + reviewMode: false + }; + const newInfo = { ...oldInfo, window: newWindow, sessionGeneration: 2 }; + app.examWindows = new Map([['reading-p1', oldInfo]]); + let releaseQueue; + app._readingDraftStoreQueue = new Promise((resolve) => { releaseQueue = resolve; }); + const pending = app._queueReadingDraftSync('reading-p1', { + sessionId: 'reading-session', + draft: { answers: { q1: 'stale' }, updatedAt: 100 }, + draftUpdatedAt: 100 + }, oldInfo); + app.examWindows.set('reading-p1', newInfo); + releaseQueue(); + assert.strictEqual(await pending, false, '窗口重注册后排队中的旧草稿必须被拒绝'); + } + + // Case 2.4.1b: suite handler failure must not fall back to a standalone v2 attempt. + { + const app = createApp(windowStub); + const session = makeSession('suite_no_standalone_fallback'); + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + app.handleSuitePracticeComplete = async () => { throw new Error('suite handler failure'); }; + let standaloneWrites = 0; + app.saveRealPracticeData = async () => { standaloneWrites += 1; return { id: 'unexpected' }; }; + assert.strictEqual(await app.handlePracticeComplete('reading-p1', { + suiteSessionId: session.id, + answers: { q1: 'A' }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, session.windowRef), false); + assert.strictEqual(standaloneWrites, 0, '套题处理失败不得写入单篇 fallback'); + } + + // Case 2.4.2: inline simulation 草稿同步必须按篇拆分 elapsed,并镜像回窗口会话域 { const app = createApp(windowStub); const session = makeSession('suite_inline_elapsed_route'); @@ -732,6 +1519,7 @@ async function run() { const info = app.ensureExamWindowSession('reading-p1', examWindow); info.expectedSessionId = 'expected_inline_elapsed_session'; + app._refreshExamWindowToken('reading-p1', info); info.suiteSessionId = session.id; app.examWindows.set('reading-p1', info); @@ -745,6 +1533,7 @@ async function run() { examId: 'reading-p2', suiteSessionId: session.id, sessionId: 'stale_inline_elapsed_session', + windowSessionToken: info.windowSessionToken, draft: { answers: { q1: 'P2 answer' }, highlights: [], @@ -761,8 +1550,8 @@ async function run() { }); assert.strictEqual(session.elapsedByExam['reading-p2'], 60, 'P2 elapsed 必须按整套累计时间拆分为单篇时长'); - const mirrored = JSON.parse(sessionStorageStub.get('ielts_sim_session')); - assert.strictEqual(mirrored.elapsedByExam['reading-p2'], 60, 'sessionStorage 镜像也必须保存拆分后的 P2 elapsed'); + const mirrored = windowSessionStore.get('simulation'); + assert.strictEqual(mirrored.elapsedByExam['reading-p2'], 60, '窗口会话镜像也必须保存拆分后的 P2 elapsed'); } // Case 2.5: activeExamId 漂移但 currentIndex 正确时,导航应自愈继续 @@ -775,11 +1564,11 @@ async function run() { app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); let openCount = 0; - app.openExam = async (examId) => { + app.openExam = async (examId, options = {}) => { openCount += 1; const win = createStubWindow('suite-window'); win.lastExamId = examId; - return win; + return installManagedTestWindow(app, examId, win, options); }; const healed = await app._handleSimulationNavigate('reading-p1', { @@ -892,772 +1681,6100 @@ async function run() { assert.deepStrictEqual(plain(session.draftsByExam['reading-p2'].highlights), [{ scope: 'groups', text: 'P2 highlight' }], 'P2 高亮应隔离保存'); } - // Case 3.1: 如果最后一篇已有导航快照,最终提交仍应覆盖并 finalize + // Case 3.0.2: inline simulation 提交必须在落库后 ACK,并可按同一 submissionId 重放 { const app = createApp(windowStub); - const session = makeSession('suite_finalize_upsert'); - session.results = [ - { examId: 'reading-p1', title: 'Passage 1', answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }, - { examId: 'reading-p2', title: 'Passage 2', answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }, - { examId: 'reading-p3', title: 'Passage 3', answers: { q1: 'OLD' }, answerComparison: { q1: { userAnswer: 'OLD', correctAnswer: 'C', isCorrect: false } }, scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0 }, rawData: {} } - ]; - session.currentIndex = 2; - session.activeExamId = 'reading-p3'; + const session = makeSession('suite_inline_submit_ack'); + const sourceWindow = session.windowRef; + const examId = 'reading-p1'; + const sessionId = 'session-inline-submit-ack'; + const submissionId = 'submission-inline-submit-ack'; + sourceWindow.location.href = `http://localhost/${examId}.html`; app.currentSuiteSession = session; app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: 'token-inline-submit-ack', + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + suiteSessionId: session.id + }]]); + const payload = { + examId, + sessionId, + submissionId, + suiteSessionId: session.id, + suiteSubmission: true, + duration: 3600, + suiteEntries: session.sequence.map((entry, index) => ({ + examId: entry.examId, + title: entry.exam.title, + category: entry.exam.category, + duration: 1200, + answers: { q1: String.fromCharCode(65 + index) }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + })) + }; - let finalizeCount = 0; - app.finalizeSuiteRecord = async () => { - finalizeCount += 1; + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true); + let ack = sourceWindow._messages.filter(message => message && message.type === 'PRACTICE_SUBMIT_ACK').at(-1); + assert(ack, 'inline simulation persistence must ACK the child'); + assert.deepStrictEqual(plain({ + submissionId: ack.data.submissionId, + sessionId: ack.data.sessionId, + examId: ack.data.examId, + suiteSessionId: ack.data.suiteSessionId + }), { submissionId, sessionId, examId, suiteSessionId: session.id }); + assert.strictEqual((await windowStub.AppData.practice.list()).length, 1, 'first submit must persist one suite record'); + + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true); + ack = sourceWindow._messages.filter(message => message && message.type === 'PRACTICE_SUBMIT_ACK').at(-1); + assert(ack, 'retry must replay the persisted ACK'); + assert.strictEqual((await windowStub.AppData.practice.list()).length, 1, 'retry must not persist a second suite record'); + app._announcePracticeSubmitOutcome(examId, { ...payload, suiteId: 'set-1' }, sourceWindow, true); + assert.strictEqual(app._replayPracticeSubmitReceipt(examId, { ...payload, suiteId: 'set-1' }, sourceWindow), true); + assert.strictEqual(app._replayPracticeSubmitReceipt(examId, { ...payload, suiteId: 'set-2' }, sourceWindow), false, 'ACK receipt 必须包含 suiteId'); + clearTimeout(session.submitReceiptTeardownTimer); + session.submitReceiptTeardownTimer = null; + + let guardedCloseTeardownCount = 0; + app._teardownSuiteSession = async () => { guardedCloseTeardownCount += 1; return true; }; + session.status = 'active'; + app._ensureSuiteWindowGuard(session, sourceWindow); + sourceWindow.close(); + await Promise.resolve(); + assert.strictEqual(guardedCloseTeardownCount, 0, '进行中的套题必须继续拦截 guarded close'); + session.status = 'completed'; + sourceWindow.close(); + await Promise.resolve(); + assert.strictEqual(guardedCloseTeardownCount, 1, '最终 ACK 的 guarded close 必须立即 teardown 已完成套题'); + app._releaseSuiteWindowGuard(sourceWindow); + + let completedCloseTeardownCount = 0; + app._teardownSuiteSession = async (targetSession) => { + assert.strictEqual(targetSession, session); + completedCloseTeardownCount += 1; + return true; + }; + app.setupExamWindowCommunication(sourceWindow, examId); + const closeAttemptHandler = app.messageHandlers.get(examId); + const closeAttemptEvent = { + source: sourceWindow, + origin: 'http://localhost', + data: { + type: 'SUITE_CLOSE_ATTEMPT', + source: 'practice_page', + data: { + examId, + suiteSessionId: session.id, + windowSessionToken: 'token-inline-submit-ack' + } + } }; + await closeAttemptHandler(closeAttemptEvent); + assert.strictEqual(completedCloseTeardownCount, 1, 'final ACK 后子页退出应立即清理已完成套题'); - const handled = await app.handleSuitePracticeComplete('reading-p3', { - suiteSessionId: session.id, - answers: { q1: 'C' }, - answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } }, - scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } - }, session.windowRef); + session.status = 'active'; + await closeAttemptHandler(closeAttemptEvent); + assert.strictEqual(completedCloseTeardownCount, 1, '进行中的套题仍必须拦截子页关闭'); + } - assert.strictEqual(handled, true, '最后一篇覆盖提交应成功'); - assert.strictEqual(finalizeCount, 1, '最后一篇覆盖提交后仍应 finalize'); - const p3 = session.results.find(item => item.examId === 'reading-p3'); - assert.deepStrictEqual(p3.answers, { q1: 'C' }, '最终提交应覆盖旧快照答案'); - } - - // Case 4: 套题意外关闭后,已作答篇应按单篇普通流程保存 + // Case 3.0.3: multi-suite 保存失败必须 NACK,同键重试成功后才 ACK { const app = createApp(windowStub); - const session = makeSession('suite_abort'); - session.results = [ - { - examId: 'reading-p1', - rawData: { - answers: { q1: 'A' }, - scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } - } - }, - { - examId: 'reading-p2', - rawData: { - answers: { q1: 'B' }, - scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } - } - } - ]; - app.currentSuiteSession = session; - app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - - const savedExamIds = []; - app.saveRealPracticeData = async (examId) => { - savedExamIds.push(examId); + const examId = 'listening-multi-suite'; + const sessionId = 'session-multi-submit'; + const submissionId = 'submission-multi-submit'; + const suiteSessionId = 'suite-session-multi-submit'; + const sourceWindow = createStubWindow('multi-suite-submit'); + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: 'token-multi-submit', + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); + let saveAttempts = 0; + app._saveSuitePracticeRecord = async () => { + saveAttempts += 1; + if (saveAttempts === 1) throw new Error('expected multi-suite save failure'); }; - app._teardownSuiteSession = async () => { - app.currentSuiteSession = null; + const payload = { + examId, + sessionId, + submissionId, + suiteSessionId, + suiteId: 'set-1', + totalSuites: 1, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } }; - await app._abortSuiteSession(session, {}); - assert.deepStrictEqual(savedExamIds.sort(), ['reading-p1', 'reading-p2'], '中断后应保存所有已作答篇章'); + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), false); + let outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1); + assert.strictEqual(outcome.type, 'PRACTICE_SUBMIT_FAILED'); + assert.deepStrictEqual(plain({ + submissionId: outcome.data.submissionId, + sessionId: outcome.data.sessionId, + examId: outcome.data.examId, + suiteSessionId: outcome.data.suiteSessionId + }), { submissionId, sessionId, examId, suiteSessionId }); + + const oldV2Session = app.multiSuiteSessionsMap.get(examId); + delete oldV2Session.suiteResults[0].metadata.submissionId; + delete oldV2Session.finalizeRecord.suiteEntries[0].metadata; + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true); + outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1); + assert.strictEqual(outcome.type, 'PRACTICE_SUBMIT_ACK'); + assert.strictEqual(saveAttempts, 2, 'retry must re-attempt the failed aggregate save exactly once'); + assert.strictEqual(app.multiSuiteSessionsMap.get(examId), oldV2Session, '旧 v2 frozen 必须保留 recovery 作为 durable receipt'); } - // Case 5: sessionStorage 镜像在 teardown 后应被清理 + // Case 3.0.3a: legacy payload 的 finalizing 重试只能收敛原 frozen aggregate { const app = createApp(windowStub); - const session = makeSession('suite_storage_cleanup'); - app.currentSuiteSession = session; - app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - app._mirrorSessionToStorage(session); - assert(sessionStorageStub.has('ielts_sim_session'), '镜像应存在'); - app._clearSessionStorage(); - assert(!sessionStorageStub.has('ielts_sim_session'), '清理后镜像应删除'); + let saveAttempts = 0; + app._saveSuitePracticeRecord = async () => { + saveAttempts += 1; + if (saveAttempts === 1) throw new Error('expected legacy multi-suite save failure'); + }; + const payload = { + suiteId: 'set-1', + totalSuites: 1, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; + assert.strictEqual(await app.handleMultiSuitePracticeComplete('listening-multi-legacy-retry', payload), false); + assert.strictEqual(await app.handleMultiSuitePracticeComplete('listening-multi-legacy-retry', payload), true); + assert.strictEqual(saveAttempts, 2, 'legacy retry 只能重放一次原 frozen aggregate,不能创建新 session 再聚合'); } - // Case 6: _sendSimulationContext 应发送正确的上下文 + // Case 3.0.4: canonical receipt 未确认 committed 时必须 NACK { const app = createApp(windowStub); - const session = makeSession('suite_context'); - const p1Highlights = [{ scope: 'left', text: 'P1 context highlight', color: 'yellow' }]; - session.draftsByExam['reading-p1'] = { answers: { q1: 'A' }, highlights: p1Highlights, scrollY: 0 }; - session.elapsedByExam['reading-p1'] = 45; - app.currentSuiteSession = session; - const targetWindow = createStubWindow('ctx-window'); - const sent = app._sendSimulationContext(session, 'reading-p1', targetWindow); - assert.strictEqual(sent, true, '应成功发送上下文'); - const ctxMsg = targetWindow._messages.find(m => m && m.type === 'SIMULATION_CONTEXT'); - assert(ctxMsg, '应收到 SIMULATION_CONTEXT'); - assert.strictEqual(ctxMsg.data.currentIndex, 0, 'currentIndex 应为 0'); - assert.strictEqual(ctxMsg.data.total, 3, 'total 应为 3'); - assert.strictEqual(Array.isArray(ctxMsg.data.suiteSequence), true, 'SIMULATION_CONTEXT 应包含 suiteSequence'); - assert.deepStrictEqual(ctxMsg.data.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'suiteSequence 应包含三篇 examId'); - assert.strictEqual(ctxMsg.data.isLast, false, 'P1 不是最后一篇'); - assert.strictEqual(ctxMsg.data.canPrev, false, 'P1 不能向前'); - assert.strictEqual(ctxMsg.data.canNext, true, 'P1 可以向后'); - assert.deepStrictEqual(ctxMsg.data.draft.answers, { q1: 'A' }, 'draft 应回传'); - assert.deepStrictEqual(ctxMsg.data.draft.highlights, p1Highlights, 'draft highlights 应随上下文回传,避免切题后丢失高亮'); - assert.strictEqual(ctxMsg.data.elapsed, 45, 'elapsed 应回传'); - - const sentP3 = app._sendSimulationContext(session, 'reading-p3', targetWindow); - assert.strictEqual(sentP3, true, 'P3 上下文应成功'); - const ctxP3 = targetWindow._messages.filter(m => m && m.type === 'SIMULATION_CONTEXT')[1]; - assert.strictEqual(ctxP3.data.isLast, true, 'P3 应标记为最后一篇'); - assert.strictEqual(ctxP3.data.canNext, false, 'P3 不能向后导航'); + const examId = 'listening-multi-uncommitted-receipt'; + const sessionId = 'session-multi-uncommitted-receipt'; + const submissionId = 'submission-multi-uncommitted-receipt'; + const sourceWindow = createStubWindow('multi-uncommitted-receipt'); + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: 'token-multi-uncommitted-receipt', + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); + const originalFinalizeSuite = windowStub.AppData.practice.finalizeSuite; + windowStub.AppData.practice.finalizeSuite = async () => ({ committed: false }); + try { + const committed = await app.handlePracticeComplete(examId, { + examId, + sessionId, + submissionId, + suiteSessionId: 'suite-multi-uncommitted-receipt', + suiteId: 'set-1', + totalSuites: 1, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, sourceWindow); + assert.strictEqual(committed, false, 'uncommitted canonical receipt must not be treated as success'); + } finally { + windowStub.AppData.practice.finalizeSuite = originalFinalizeSuite; + } + const outcome = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)).at(-1); + assert(outcome && outcome.type === 'PRACTICE_SUBMIT_FAILED', 'uncommitted canonical receipt must NACK'); } - // Case 6.1: INIT_SESSION payload 应携带三篇 suiteSequence + // Case 3.0.4a: suiteId multi-suite partial results must restore into a new app instance { + const examId = 'listening-100-p1_set1'; const app = createApp(windowStub); - const session = makeSession('suite_init_sequence'); - app.currentSuiteSession = session; - app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - const initWindow = createStubWindow('init-window'); - const windowInfo = app.ensureExamWindowSession('reading-p1', initWindow); - windowInfo.suiteSessionId = session.id; - windowInfo.suiteFlowMode = 'simulation'; - const payload = app._buildExamInitPayload('reading-p1', windowInfo); - assert.strictEqual(Array.isArray(payload.suiteSequence), true, 'INIT_SESSION 应包含 suiteSequence'); - assert.deepStrictEqual(payload.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'INIT suiteSequence 应覆盖三篇'); - assert.deepStrictEqual(payload.suiteSequence.map(item => item.category), ['P1', 'P2', 'P3'], 'INIT suiteSequence 应带 category'); - } + app.initializeSuiteMode(); - // Case 8: handleSessionReady 应触发首篇模拟上下文下发 - { - const app = createApp(windowStub); - const session = makeSession('suite_session_ready'); - app.currentSuiteSession = session; - app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - app.examWindows = new Map(); - const readyWindow = createStubWindow('ready-window'); - app.examWindows.set('reading-p1', { - examId: 'reading-p1', - window: readyWindow, - expectedSessionId: 'session-reading-p1', - suiteSessionId: session.id + const handled = await app.handleSuitePracticeComplete(examId, { + suiteId: 'set-1', + totalSuites: 2, + sessionId: 'multi-suite-child-session-1', + answers: { q1: 'A' }, + correctAnswers: { q1: 'A' }, + answerComparison: { + q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } + }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + duration: 42 }); - app.handleSessionReady('reading-p1', { sessionId: 'session-reading-p1', pageType: 'unified-reading' }); - const msg = readyWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT'); - assert(msg, 'SESSION_READY 后应下发 SIMULATION_CONTEXT'); - assert.strictEqual(msg.data.examId, 'reading-p1', 'SESSION_READY 下发应匹配 examId'); + assert.strictEqual(handled, true, 'multi-suite 部分结果应成功处理'); + const mirrored = windowSessionStore.get('multi-suite-practice'); + assert.strictEqual(mirrored.schema, 'multi-suite-sessions-v2', 'multi-suite 应写入 v2 恢复快照'); + assert.strictEqual(mirrored.version, 2, 'multi-suite 恢复快照版本必须为 v2'); + const mirroredSession = mirrored.sessions.find((session) => session.baseExamId === 'listening-100-p1'); + assert(mirroredSession, '部分结果应保留目标 multi-suite 会话'); + assert.strictEqual(mirroredSession.suiteResults.length, 1, '部分结果应写入 suiteResults'); + assert.strictEqual(mirroredSession.suiteResults[0].suiteId, 'set-1', '恢复快照应保留 suiteId'); + const durableSession = recoveryControl.events + .filter((event) => event.type === 'save' && event.value.schema === 'multi-suite-sessions-v2') + .at(-1)?.value; + assert(durableSession, '部分结果必须写入 AppData v2 activeSession'); + assert.strictEqual(durableSession.sessions[0].suiteResults.length, 1); + + const restoredApp = createApp(windowStub, { suiteModeReady: false }); + restoredApp.initializeSuiteMode(); + await restoredApp._ensureSuiteRecoveryReady(); + const restoredSession = restoredApp.multiSuiteSessionsMap.get('listening-100-p1'); + assert(restoredSession, '匹配当前标签页窗口镜像时应从 v2 恢复 multi-suite 会话'); + assert.strictEqual(restoredSession.status, 'active', '部分恢复会话应保持 active'); + assert.strictEqual(restoredSession.expectedSuiteCount, 2, '恢复会话应保留 expectedSuiteCount'); + assert.strictEqual(restoredSession.suiteResults.length, 1, '恢复会话应保留部分结果'); + assert.deepStrictEqual( + plain(restoredSession.suiteResults[0].answerComparison), + { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + '恢复会话应保留结果比较数据' + ); + + assert.strictEqual( + await restoredApp._releaseSuiteRecoveryClaim('multi', restoredSession), + true + ); + const originalLocks = windowStub.navigator.locks; + const durableTakeoverLocks = { + held: new Map(), + calls: [], + async request(name, lockOptions = {}, callback) { + assert.strictEqual(lockOptions.mode, 'exclusive'); + assert.strictEqual(lockOptions.ifAvailable, true); + const normalizedName = String(name || ''); + this.calls.push(normalizedName); + if (this.held.has(normalizedName)) return callback(null); + const lock = { name: normalizedName, mode: 'exclusive' }; + this.held.set(normalizedName, lock); + try { + return await callback(lock); + } finally { + if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName); + } + } + }; + windowStub.navigator.locks = durableTakeoverLocks; + const durableOwnerApp = createApp(windowStub); + const durableOwnerSession = plain(restoredSession); + assert.strictEqual( + await durableOwnerApp._acquireMultiSuiteRecoveryOwnership(durableOwnerSession), + true, + 'active tab fixture must hold the base and exact durable recovery leases' + ); + windowSessionStore.delete('multi-suite-practice'); + const foreignTabApp = createApp(windowStub, { suiteModeReady: false }); + foreignTabApp.initializeSuiteMode(); + await foreignTabApp._ensureSuiteRecoveryReady(); + assert.strictEqual( + foreignTabApp.multiSuiteSessionsMap.has('listening-100-p1'), + false, + 'fresh HTTP tab must not bypass an active durable owner lease' + ); + assert.strictEqual( + await durableOwnerApp._releaseSuiteRecoveryClaim('multi', durableOwnerSession), + true + ); + const takeoverFallback = foreignTabApp.getOrCreateMultiSuiteSession( + 'listening-100-p1_set1', + { install: false } + ); + const takeover = await foreignTabApp._refreshPersistentMultiSuiteBase( + 'listening-100-p1', + takeoverFallback + ); + assert.strictEqual(takeover.blocked, false); + const takenOverSession = takeover.session; + assert(takenOverSession, 'the same fresh tab must recover durable state after owner release/crash'); + assert.strictEqual(takenOverSession.id, durableSession.id); + assert.strictEqual( + await foreignTabApp._releaseSuiteRecoveryClaim('multi', takenOverSession), + true + ); + for (const recoveredSession of Array.from(foreignTabApp.multiSuiteSessionsMap.values())) { + if (foreignTabApp._ownsMultiSuiteRecoveryOwnership(recoveredSession)) { + assert.strictEqual( + await foreignTabApp._releaseSuiteRecoveryClaim('multi', recoveredSession), + true + ); + } + } + if (foreignTabApp.currentSuiteSession + && foreignTabApp._ownsSuiteRecoveryClaim('single', foreignTabApp.currentSuiteSession)) { + assert.strictEqual( + await foreignTabApp._releaseSuiteRecoveryClaim('single', foreignTabApp.currentSuiteSession), + true + ); + } + assert.strictEqual( + durableTakeoverLocks.held.has(foreignTabApp._suiteRecoveryClaimName(durableSession.id)), + false, + 'the exact durable takeover lease must be released by fixture cleanup' + ); + windowStub.navigator.locks = originalLocks; + + const retryApp = createApp(windowStub); + const retryPayload = { + suiteId: 'set-1', + totalSuites: 2, + sessionId: 'multi-suite-retry-child', + answers: { q1: 'B' }, + answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; + const saveEventStart = recoveryControl.events.length; + recoveryControl.saveQueue.push(false); + assert.strictEqual( + await retryApp.handleMultiSuitePracticeComplete('listening-retry-p1_set1', retryPayload), + false, + 'durable receipt 未确认时部分结果必须 NACK' + ); + const refreshedAfterNack = createApp(windowStub, { suiteModeReady: false }); + refreshedAfterNack.initializeSuiteMode(); + await refreshedAfterNack._ensureSuiteRecoveryReady(); + assert.strictEqual( + refreshedAfterNack.multiSuiteSessionsMap.has('listening-retry-p1'), + false, + 'v2 枚举成功后不得从 window WAL 恢复未提交结果' + ); + assert.strictEqual( + await retryApp.handleMultiSuitePracticeComplete('listening-retry-p1_set1', retryPayload), + true, + '相同结果重试必须再次尝试 durable save' + ); + assert.strictEqual( + recoveryControl.events.slice(saveEventStart).filter((event) => ( + event.type === 'save' + && event.value.schema === 'multi-suite-sessions-v2' + && event.value.sessions[0].baseExamId === 'listening-retry-p1' + )).length, + 2, + '内存中已存在结果不能绕过未完成的 v2 写入' + ); } - // Case 8.1: 迟到 SESSION_READY 若窗口 URL 已切到其他篇,必须忽略 - { + // Case 3.0.5: reading suite 聚合提交后的 UI/清理故障不得触发单篇 fallback 或假 NACK + for (const failingStep of ['sync', 'overview', 'message', 'teardown-schedule']) { const app = createApp(windowStub); - const session = makeSession('suite_stale_ready'); - session.currentIndex = 0; - session.activeExamId = 'reading-p1'; + const session = makeSession(`suite_post_commit_${failingStep}`); + const sourceWindow = session.windowRef; + const examId = 'reading-p1'; + const sessionId = `session-post-commit-${failingStep}`; + const submissionId = `submission-post-commit-${failingStep}`; + sourceWindow.location.href = `http://localhost/${examId}.html`; app.currentSuiteSession = session; app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - app.examWindows = new Map(); + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: `token-post-commit-${failingStep}`, + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + suiteSessionId: session.id + }]]); - const staleWindow = createStubWindow('ready-window'); - staleWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1'; - app.examWindows.set('reading-p2', { - examId: 'reading-p2', - window: staleWindow, - expectedSessionId: 'session-reading-p2', + const aggregateRecords = []; + let partialFallbacks = 0; + let standaloneFallbacks = 0; + app._saveSuitePracticeRecord = async (record) => { + aggregateRecords.push(record); + }; + app._savePartialSuiteAsIndividual = async () => { + partialFallbacks += 1; + }; + app.saveRealPracticeData = async () => { + standaloneFallbacks += 1; + return { id: `unexpected-standalone-${failingStep}` }; + }; + if (failingStep === 'sync') { + app._updatePracticeRecordsState = async () => { throw new Error('expected sync failure'); }; + } else if (failingStep === 'overview') { + app.refreshOverviewData = () => { throw new Error('expected overview failure'); }; + } else if (failingStep === 'teardown-schedule') { + app._scheduleSuiteSubmitTeardown = () => { throw new Error('expected teardown scheduling failure'); }; + } + const originalShowMessage = windowStub.showMessage; + if (failingStep === 'message') { + windowStub.showMessage = () => { throw new Error('expected completion message failure'); }; + } + + const payload = { + examId, + sessionId, + submissionId, suiteSessionId: session.id, - pageType: 'unified-reading' - }); + suiteSubmission: true, + duration: 3600, + suiteEntries: session.sequence.map((entry, index) => ({ + examId: entry.examId, + title: entry.exam.title, + category: entry.exam.category, + duration: 1200, + answers: { q1: String.fromCharCode(65 + index) }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + })) + }; - app.handleSessionReady('reading-p2', { - sessionId: 'session-reading-p2', - pageType: 'unified-reading' - }); + try { + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: committed suite must return success`); + app.examWindows.get(examId).practiceSubmitReceipts = {}; + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: completed-session retry must return success without receipt cache`); + } finally { + windowStub.showMessage = originalShowMessage; + if (session.submitReceiptTeardownTimer) { + clearTimeout(session.submitReceiptTeardownTimer); + session.submitReceiptTeardownTimer = null; + } + } + const outcomes = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)); + assert(outcomes.length >= 2 && outcomes.every(message => message.type === 'PRACTICE_SUBMIT_ACK'), `${failingStep}: commit and completed-session retry must only ACK`); + assert.strictEqual(aggregateRecords.length, 1, `${failingStep}: aggregate record must be written exactly once`); + assert.strictEqual(partialFallbacks, 0, `${failingStep}: individual suite fallback must not run after commit`); + assert.strictEqual(standaloneFallbacks, 0, `${failingStep}: outer standalone fallback must not run after commit`); + assert.strictEqual(session.status, 'completed', `${failingStep}: committed session must stay completed`); + } - assert.strictEqual(session.activeExamId, 'reading-p1', '迟到 SESSION_READY 不得覆写 activeExamId'); - assert.strictEqual(session.currentIndex, 0, '迟到 SESSION_READY 不得覆写 currentIndex'); - const staleCtx = staleWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT'); - assert.strictEqual(staleCtx, undefined, '迟到 SESSION_READY 不应下发模拟上下文'); + // Case 3.0.6: multi-suite 聚合提交后的各后置步骤故障仍须 ACK 且保持单次聚合写入 + for (const failingStep of ['spelling', 'sync', 'overview', 'session-cleanup', 'message']) { + const app = createApp(windowStub); + const examId = `listening-multi-post-commit-${failingStep}`; + const sessionId = `session-multi-post-commit-${failingStep}`; + const submissionId = `submission-multi-post-commit-${failingStep}`; + const suiteSessionId = `suite-multi-post-commit-${failingStep}`; + const sourceWindow = createStubWindow(`multi-post-commit-${failingStep}`); + sourceWindow.location.href = `http://localhost/${examId}.html`; + app.examWindows = new Map([[examId, { + window: sourceWindow, + expectedSessionId: sessionId, + sessionId, + windowSessionToken: `token-multi-post-commit-${failingStep}`, + windowSessionTokenSessionId: sessionId, + expectedUrl: sourceWindow.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }]]); + + const aggregateRecords = []; + let standaloneFallbacks = 0; + app._saveSuitePracticeRecord = async (record) => { + aggregateRecords.push(record); + }; + app.saveRealPracticeData = async () => { + standaloneFallbacks += 1; + return { id: `unexpected-multi-standalone-${failingStep}` }; + }; + if (failingStep === 'sync') { + app._updatePracticeRecordsState = async () => { throw new Error('expected multi sync failure'); }; + } else if (failingStep === 'overview') { + app.refreshOverviewData = () => { throw new Error('expected multi overview failure'); }; + } else if (failingStep === 'session-cleanup') { + app.multiSuiteSessionsMap = new class extends Map { + delete() { throw new Error('expected multi session cleanup failure'); } + }(); + } + const originalShowMessage = windowStub.showMessage; + const originalCollector = windowStub.spellingErrorCollector; + windowStub.spellingErrorCollector = { + async saveErrors() { + if (failingStep === 'spelling') throw new Error('expected spelling sync failure'); + } + }; + if (failingStep === 'message') { + windowStub.showMessage = () => { throw new Error('expected multi completion message failure'); }; + } + const payload = { + examId, + sessionId, + submissionId, + suiteSessionId, + suiteId: 'set-1', + totalSuites: 1, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + spellingErrors: [{ word: 'practice', answer: 'practise' }] + }; + + try { + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: committed multi-suite must return success`); + assert.strictEqual(await app.handlePracticeComplete(examId, payload, sourceWindow), true, `${failingStep}: multi-suite receipt replay must return success`); + } finally { + windowStub.showMessage = originalShowMessage; + windowStub.spellingErrorCollector = originalCollector; + } + const outcomes = sourceWindow._messages.filter(message => message && /^PRACTICE_SUBMIT_/.test(message.type)); + assert(outcomes.length >= 2 && outcomes.every(message => message.type === 'PRACTICE_SUBMIT_ACK'), `${failingStep}: multi-suite commit and replay must only ACK`); + assert.strictEqual(aggregateRecords.length, 1, `${failingStep}: multi-suite aggregate must be written exactly once`); + assert.strictEqual(standaloneFallbacks, 0, `${failingStep}: multi-suite must not enter standalone fallback after commit`); } - // Case 8.2: waitForSuiteWindowExamReady 不得把调用前的旧 ready 当成当前切题成功 + // Case 3.0.6.1: durable recovery 清理后,v2 聚合记录仍是精确提交的幂等收据 { const app = createApp(windowStub); - const session = makeSession('suite_ready_timestamp_guard'); - const targetWindow = createStubWindow('ready-window'); - targetWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2'; - app.examWindows = new Map([ - ['reading-p2', { - examId: 'reading-p2', - window: targetWindow, - suiteSessionId: session.id, - pageType: 'unified-reading', - lastMessageType: 'SESSION_READY', - lastMessageAt: Date.now() - 5000 - }] - ]); + await app._ensureSuiteRecoveryReady(); + const examId = 'listening-multi-canonical-receipt_set1'; + const payload = { + suiteId: 'set-1', + totalSuites: 1, + sessionId: 'multi-canonical-child', + submissionId: 'multi-canonical-submission', + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; - const ready = await app._waitForSuiteWindowExamReady(session, 'reading-p2', targetWindow, 120); - assert.strictEqual(ready, false, '调用前的旧 SESSION_READY 不能被误判为当前窗口已就绪'); + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, payload), true); + assert.strictEqual(app.multiSuiteSessionsMap.has('listening-multi-canonical-receipt'), false, 'cleanup 成功后 runtime recovery 应释放'); + const committed = await windowStub.AppData.practice.list({ projection: 'detail' }); + assert.strictEqual(committed.length, 1); + assert.strictEqual(committed[0].suiteEntries[0].metadata.sessionId, payload.sessionId); + assert.strictEqual(committed[0].suiteEntries[0].metadata.submissionId, payload.submissionId); + + const refreshed = createApp(windowStub, { suiteModeReady: false }); + refreshed.initializeSuiteMode(); + await refreshed._ensureSuiteRecoveryReady(); + assert.strictEqual(await refreshed.handleMultiSuitePracticeComplete(examId, payload), true, '刷新后的精确重放必须由 canonical 记录 ACK'); + assert.strictEqual((await windowStub.AppData.practice.list({ projection: 'detail' })).length, 1, 'canonical 重放不能生成第二条聚合记录'); + + const concurrentApp = createApp(windowStub); + const originalFinalizeSuite = windowStub.AppData.practice.finalizeSuite; + let finalizeCalls = 0; + windowStub.AppData.practice.finalizeSuite = async (...args) => { + finalizeCalls += 1; + return originalFinalizeSuite(...args); + }; + try { + assert.deepStrictEqual( + await Promise.all([ + concurrentApp.handleMultiSuitePracticeComplete('listening-multi-concurrent_set1', payload), + concurrentApp.handleMultiSuitePracticeComplete('listening-multi-concurrent_set1', payload) + ]), + [true, true], + '完整 triple 的并发重放必须得到相同 ACK' + ); + } finally { + windowStub.AppData.practice.finalizeSuite = originalFinalizeSuite; + } + assert.strictEqual(finalizeCalls, 1, '并发精确重放只能生成一次 canonical aggregate'); } - // Case 8.3: 复用窗口切题若未等到 fresh ready,不得提前把目标篇高亮推送到旧页 + // Case 3.0.6.2: completed multi-suite 只拥有原提交重放,不能吞掉同 base 的下一次运行 { const app = createApp(windowStub); - const session = makeSession('suite_reuse_window_highlight_guard'); - session.currentIndex = 0; - session.activeExamId = 'reading-p1'; - app.currentSuiteSession = session; - app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - - const reusedWindow = createStubWindow('suite-window'); - reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1'; - session.windowRef = reusedWindow; - app.openExam = async (_examId, options = {}) => options.reuseWindow || reusedWindow; - app._waitForSuiteWindowExamReady = async () => false; + await app._ensureSuiteRecoveryReady(); + const examId = 'listening-multi-owner_set1'; + const aggregateRecords = new Map(); + const saveAttempts = []; + app._saveSuitePracticeRecord = async (record) => { + saveAttempts.push(plain(record)); + aggregateRecords.set(record.operationId, plain(record)); + }; + const originalDiscard = windowStub.AppData.recovery.discardActiveSession; + const discardCalls = []; + windowStub.AppData.recovery.discardActiveSession = async (id, options) => { + discardCalls.push({ id: String(id), options: plain(options || {}) }); + return { committed: false }; + }; + const firstPayload = { + suiteId: 'set-1', + totalSuites: 1, + sessionId: 'multi-owner-child', + submissionId: 'multi-owner-submission-1', + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + spellingErrors: [{ word: 'practice', userInput: 'practise' }] + }; + const completedSession = app.getOrCreateMultiSuiteSession(examId); - const ok = await app._handleSimulationNavigate('reading-p1', { - direction: 'next', - draft: { - answers: { q1: 'A' }, - highlights: [{ scope: 'left', text: 'P1 highlight before switch' }], - scrollY: 123, - updatedAt: Date.now() - }, - resultSnapshot: { - answers: { q1: 'A' }, - answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, - scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + try { + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true); + assert.strictEqual(aggregateRecords.size, 1, '首次运行只能生成一条聚合记录'); + assert.strictEqual(saveAttempts.length, 1, '首次运行只能提交一次 aggregate'); + assert.strictEqual(discardCalls.length, 1, 'aggregate 成功后必须尝试清理 durable recovery'); + assert.strictEqual(discardCalls[0].id, completedSession.id, 'durable cleanup 必须针对已完成会话'); + assert.strictEqual(app.multiSuiteSessionsMap.get(completedSession.baseExamId), completedSession, 'durable cleanup 失败时应保留完成态作为重放收据'); + + // durable recovery 在 aggregate commit 与 cleanup 之间只可能恢复到 finalizing; + // 必须先用原 operationId 收敛旧记录,再解释当前 submission。 + completedSession.status = 'finalizing'; + app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession); + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true, 'finalizing 原 submission 必须幂等收敛'); + assert.strictEqual(aggregateRecords.size, 1, 'finalizing 重放不能创建第二条聚合记录'); + assert.strictEqual(saveAttempts.length, 2, 'finalizing 重放必须再次提交原 frozen aggregate'); + assert.deepStrictEqual(saveAttempts[1], saveAttempts[0], 'finalizing 重放的 aggregate 必须保持字节语义稳定'); + + const oldV2Snapshot = plain(completedSession); + oldV2Snapshot.status = 'finalizing'; + delete oldV2Snapshot.suiteResults[0].metadata.submissionId; + delete oldV2Snapshot.finalizeRecord.suiteEntries[0].metadata; + oldV2Snapshot.finalizeRecord.spellingErrors[0].timestamp += 1; + assert.strictEqual(app._isValidMultiSuiteRecoverySnapshot({ + schema: 'multi-suite-sessions-v2', version: 2, sessions: [oldV2Snapshot] + }), true, '升级前缺少 entry metadata 的 v2 frozen snapshot 仍应可恢复'); + + // 模拟旧实现中 cleanup 失败后残留的 completed runtime session。 + app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession); + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true, '原 submission 重放必须幂等成功'); + assert.strictEqual(aggregateRecords.size, 1, '原 submission 重放不能重复聚合'); + assert.strictEqual(saveAttempts.length, 2, 'completed 原 submission 重放不能再次调用 aggregate 保存'); + + const nextPayload = { + ...firstPayload, + submissionId: 'multi-owner-submission-2', + answers: { q1: 'B' }, + answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } } + }; + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, nextPayload), true, '新 submission 必须创建新运行'); + assert.strictEqual(aggregateRecords.size, 2, '同 base 的下一次运行必须生成独立聚合记录'); + assert.strictEqual(saveAttempts.length, 3, '下一次运行必须只新增一次 aggregate 保存'); + const records = Array.from(aggregateRecords.values()); + assert.notStrictEqual(records[0].id, records[1].id, '两次运行必须使用不同 multi-suite session id'); + assert.strictEqual(records[1].answers['set-1::q1'], 'B', '新聚合必须包含下一次运行的答案'); + assert.strictEqual(records[1].suiteEntries.length, 1, '新聚合不能混入旧运行结果'); + assert.strictEqual(records[1].scoreInfo.total, 1, '新聚合分数只能来自当前运行'); + assert.strictEqual(records[1].suiteEntries[0].rawData.submissionId, nextPayload.submissionId, '新聚合必须归属当前 submission'); + + const nextChildPayload = { + ...nextPayload, + sessionId: 'multi-owner-child-2', + answers: { q1: 'C' }, + answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } } + }; + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, nextChildPayload), true, '不同 child session 必须创建新运行'); + assert.strictEqual(aggregateRecords.size, 3, '不同 child session 不能被误判为 completed 重放'); + assert.strictEqual(saveAttempts.length, 4, '不同 child session 必须只新增一次 aggregate 保存'); + assert.strictEqual(Array.from(aggregateRecords.values())[2].answers['set-1::q1'], 'C'); + + completedSession.status = 'finalizing'; + completedSession.finalizeOperationId = 'wrong-operation-id'; + app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession); + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), false, '错配 operationId 的 frozen aggregate 必须 fail closed'); + assert.strictEqual(saveAttempts.length, 4, '错配 operationId 不能触发 aggregate 保存'); + + completedSession.finalizeOperationId = completedSession.finalizeRecord.operationId; + completedSession.finalizeRecord.spellingErrors = []; + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), false, '损坏的顶层拼写汇总必须 fail closed'); + assert.strictEqual(saveAttempts.length, 4, '损坏的拼写汇总不能调用保存'); + + completedSession.finalizeRecord = plain(saveAttempts[0]); + completedSession.finalizeOperationId = completedSession.finalizeRecord.operationId; + completedSession.suiteResults[0].answers.q1 = 'tampered'; + app.multiSuiteSessionsMap.set(completedSession.baseExamId, completedSession); + assert.strictEqual( + await app.handleMultiSuitePracticeComplete(examId, firstPayload), + false, + '不一致的 frozen aggregate 必须 fail closed,不能复用 operationId 重建' + ); + assert.strictEqual(aggregateRecords.size, 3, '损坏 frozen aggregate 不能写入新记录'); + assert.strictEqual(saveAttempts.length, 4, '损坏 frozen aggregate 不能调用保存'); + app.multiSuiteSessionsMap.delete(completedSession.baseExamId); + } finally { + windowStub.AppData.recovery.discardActiveSession = originalDiscard; + for (const record of aggregateRecords.values()) { + await originalDiscard(record.id); } - }, reusedWindow); - - assert.strictEqual(ok, true, 'ready 超时时切题流程仍应继续,由后续 SESSION_READY 兜底'); - assert.strictEqual(session.activeExamId, 'reading-p2', 'activeExamId 应先对齐到目标篇'); - assert.strictEqual( - reusedWindow._messages.some(message => message && message.type === 'SIMULATION_CONTEXT'), - false, - '未拿到 fresh ready 前不得向复用窗口提前发送 SIMULATION_CONTEXT,避免旧页误吃目标篇高亮' - ); - assert.deepStrictEqual( - session.draftsByExam['reading-p1'].highlights, - [{ scope: 'left', text: 'P1 highlight before switch' }], - '切题前当前篇高亮仍应保存在 draft 中' - ); + } } - // Case 8.4: 复用窗口若已落到目标篇 URL,即使 fresh ready 缺失也应兜底下发上下文 + // Case 3.0.6.3: finalizing 必须携带 frozen pair;同一 active suite 的不同提交不能误 ACK { const app = createApp(windowStub); - const session = makeSession('suite_reuse_window_target_url_fallback'); - session.currentIndex = 0; - session.activeExamId = 'reading-p1'; - const p2Highlights = [{ scope: 'left', text: 'P2 highlight after switch', color: 'green' }]; - session.draftsByExam['reading-p2'] = { answers: { q5: 'B' }, highlights: p2Highlights, scrollY: 66 }; - app.currentSuiteSession = session; - app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - - const reusedWindow = createStubWindow('suite-window'); - reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2'; - session.windowRef = reusedWindow; - app.examWindows = new Map([ - ['reading-p2', { window: reusedWindow }] - ]); - app.openExam = async (_examId, options = {}) => options.reuseWindow || reusedWindow; - app._waitForSuiteWindowExamReady = async () => false; - - const ok = await app._handleSimulationNavigate('reading-p1', { - direction: 'next', - draft: { - answers: { q1: 'A' }, - highlights: [{ scope: 'left', text: 'P1 highlight before switch' }], - scrollY: 123, - updatedAt: Date.now() - } - }, reusedWindow); - - assert.strictEqual(ok, true, '目标窗口 URL 已切到新篇时,切题流程应允许兜底恢复'); - const ctxMsg = reusedWindow._messages.find(message => message && message.type === 'SIMULATION_CONTEXT'); - assert(ctxMsg, '目标窗口 URL 已切到新篇时,应继续下发 SIMULATION_CONTEXT'); - assert.strictEqual(ctxMsg.data.examId, 'reading-p2', '兜底上下文必须指向目标篇'); - assert.deepStrictEqual(ctxMsg.data.draft.highlights, p2Highlights, '目标篇高亮应随兜底上下文一起恢复'); + await app._ensureSuiteRecoveryReady(); + const examId = 'listening-multi-active-conflict_set1'; + const firstPayload = { + suiteId: 'set-1', + totalSuites: 2, + sessionId: 'multi-active-child', + submissionId: 'multi-active-submission-1', + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, firstPayload), true); + const session = app.multiSuiteSessionsMap.get('listening-multi-active-conflict'); + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, { + ...firstPayload, + submissionId: 'multi-active-submission-2', + answers: { q1: 'B' } + }), false, '同一 active suite 的不同 submission 不能 ACK 未持久化答案'); + assert.strictEqual(session.suiteResults.length, 1); + assert.strictEqual(session.suiteResults[0].answers.q1, 'A'); + + const missingFrozen = plain(session); + missingFrozen.status = 'finalizing'; + missingFrozen.finalizeOperationId = null; + missingFrozen.finalizeRecord = null; + assert.strictEqual(await app.finalizeMultiSuiteRecord(missingFrozen), false, 'finalizing 缺少 frozen pair 必须 fail closed'); + assert.strictEqual(app._isValidMultiSuiteRecoverySnapshot({ + schema: 'multi-suite-sessions-v2', + version: 2, + sessions: [missingFrozen] + }), false, '缺少 frozen pair 的 finalizing recovery 不能进入运行态'); + await windowStub.AppData.recovery.discardActiveSession(session.id); + app.multiSuiteSessionsMap.delete(session.baseExamId); } - // Case 7: 错篇 PRACTICE_COMPLETE 必须被忽略,不能污染结果 + // Case 3.0.6.4: 恢复的 active-complete 会话(finalize 前崩溃窗口)须先幂等收敛, + // 再让同 base 新一轮继续,而不是被已记录的同 suiteId 阻塞 NACK。 { const app = createApp(windowStub); - const session = makeSession('suite_wrong_exam_complete'); - session.activeExamId = 'reading-p2'; - app.currentSuiteSession = session; - app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - - const handled = await app.handleSuitePracticeComplete('reading-p1', { - suiteSessionId: session.id, + await app._ensureSuiteRecoveryReady(); + const examId = 'listening-multi-active-complete_set1'; + + // 直接构造 active-complete 会话(结果已齐、无 frozen record)。 + // 真实崩溃窗口:最后一次 _commitMultiSuiteRecovery 已把 active 状态写入 durable, + // finalize 尚未执行。因此 durable 与 WAL 都存在,durable 为权威。 + const result = { + suiteId: 'set-1', + examId: examId, answers: { q1: 'A' }, + correctAnswers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + spellingErrors: [], + timestamp: Date.now(), + duration: 10, + metadata: { sessionId: 'multi-active-complete-child', submissionId: 'multi-active-complete-sub-1' }, + rawData: null + }; + const activeCompleteSession = { + id: 'multi_listening-multi-active-complete_crash_1', + baseExamId: 'listening-multi-active-complete', + status: 'active', + startTime: Date.now(), + suiteResults: [result], + expectedSuiteCount: 1, + metadata: { source: 'p1', createdAt: new Date().toISOString() }, + lastUpdate: Date.now(), + revision: 1 + }; + const durableSnap = { + schema: 'multi-suite-sessions-v2', + version: 2, + id: activeCompleteSession.id, + revision: 1, + sessions: [activeCompleteSession], + updatedAt: Date.now() + }; + await windowStub.AppData.recovery.saveActiveSession(durableSnap); + windowSessionStore.set('multi-suite-practice', durableSnap); + + const restoredApp = createApp(windowStub, { suiteModeReady: false }); + restoredApp.initializeSuiteMode(); + await restoredApp._ensureSuiteRecoveryReady(); + const restored = restoredApp.multiSuiteSessionsMap.get('listening-multi-active-complete'); + assert(restored, 'active-complete durable 快照应可恢复'); + assert.strictEqual(restored.status, 'active'); + + // 新一轮同 suiteId、新身份:应触发收敛 finalize 旧会话,再接纳新结果。 + const aggregateRecords = []; + restoredApp._saveSuitePracticeRecord = async (record) => { aggregateRecords.push(record); }; + const newPayload = { + suiteId: 'set-1', + totalSuites: 1, + sessionId: 'multi-active-complete-new-child', + submissionId: 'multi-active-complete-sub-2', + answers: { q1: 'B' }, + answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } - }, session.windowRef); - - assert.strictEqual(handled, true, '错篇提交应被处理为忽略'); - assert.strictEqual(session.results.length, 0, '错篇提交不得写入 session.results'); + }; + assert.strictEqual( + await restoredApp.handleMultiSuitePracticeComplete(examId, newPayload), + true, + 'active-complete 恢复后同 suiteId 新提交不得被 NACK' + ); + // 旧 active-complete 会话先收敛为一条聚合记录;随后新一轮完成自己的一条。 + assert.strictEqual(aggregateRecords.length, 2, '旧会话收敛 + 新一轮完成各生成一条记录'); + const firstRecord = aggregateRecords[0]; + assert.strictEqual(firstRecord.suiteEntries.length, 1, '收敛记录应包含旧结果'); + const secondRecord = aggregateRecords[1]; + assert.strictEqual(secondRecord.suiteEntries[0].answers.q1, 'B', '新一轮结果应聚合进第二条记录'); + if (restored && restored.id) { + await windowStub.AppData.recovery.discardActiveSession(restored.id); + } + restoredApp.multiSuiteSessionsMap.delete('listening-multi-active-complete'); } - // Case 9: 听力桥的临时 examId/sessionId 不得阻断父应用当前题源落库 + // Case 3.0.6.5: 损坏 durable + 有效 window-WAL 同时存在时,恢复须保留 WAL 回退; + // 而 durable 完全不存在(save 从未成功)时仍丢弃 WAL(未提交结果不恢复)。 { const app = createApp(windowStub); - const examWindow = createStubWindow('custom-listening-window'); - const examId = 'custom-listening-teacher-pack'; - const expectedSessionId = 'custom-listening-teacher-pack_expected'; - let captured = null; - - app.handlePracticeComplete = async (handledExamId, data) => { - captured = { examId: handledExamId, data }; + await app._ensureSuiteRecoveryReady(); + const examId = 'listening-multi-corrupt-durable_set1'; + const payload = { + suiteId: 'set-1', + totalSuites: 2, + sessionId: 'multi-corrupt-child', + submissionId: 'multi-corrupt-sub-1', + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } }; - - app.setupExamWindowCommunication(examWindow, examId, { - id: examId, - title: 'Teacher Pack Listening', - type: 'listening' + assert.strictEqual(await app.handleMultiSuitePracticeComplete(examId, payload), true); + const session = app.multiSuiteSessionsMap.get('listening-multi-corrupt-durable'); + + // 保留 WAL,同时写入一个损坏的 durable(结构合法但校验失败:finalizeOperationId 错配)。 + const corruptDurable = plain(session); + corruptDurable.finalizeOperationId = 'practice-multisuite:wrong:id:finalize'; + corruptDurable.finalizeRecord = { id: 'stale-record' }; + const corruptDurableRevision = Number(session.revision) + 7; + await windowStub.AppData.recovery.saveActiveSession({ + schema: 'multi-suite-sessions-v2', + version: 2, + id: session.id, + revision: corruptDurableRevision, + sessions: [corruptDurable], + updatedAt: Date.now() }); - const info = app.ensureExamWindowSession(examId, examWindow); - info.expectedSessionId = expectedSessionId; - app.examWindows.set(examId, info); - - const handler = app.messageHandlers.get(examId); - assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler'); + const restoredApp = createApp(windowStub, { suiteModeReady: false }); + restoredApp.initializeSuiteMode(); + await restoredApp._ensureSuiteRecoveryReady(); + assert( + restoredApp.multiSuiteSessionsMap.has('listening-multi-corrupt-durable'), + '损坏 durable 存在时应保留 window-WAL 回退,不能把有效草稿抹掉' + ); + const restoredSession = restoredApp.multiSuiteSessionsMap.get('listening-multi-corrupt-durable'); + assert.strictEqual( + restoredSession._lastDurableRecoveryRevision, + corruptDurableRevision, + '匹配的损坏 durable 必须把实体 revision 交给保留的 window-WAL' + ); + assert.strictEqual( + restoredSession.revision, + corruptDurableRevision, + '保留的 window-WAL 必须追平 durable revision,确保下一次变更可以前进' + ); + restoredSession.revision += 1; + recoveryControl.saveQueue.push(async (value, options) => { + const currentDurable = (await windowStub.AppData.recovery.listActiveSessions()) + .find((item) => String(item && item.id || '') === String(value.id)); + assert.strictEqual(Number(currentDurable && currentDurable.revision), corruptDurableRevision); + assert.strictEqual( + options.expectedEntityRevision, + corruptDurableRevision, + 'WAL 恢复后的下一次保存必须从匹配的 durable revision 做 CAS' + ); + assert.strictEqual(value.revision, corruptDurableRevision + 1); + return { committed: true, item: plain(value) }; + }); + assert.strictEqual( + await restoredApp._commitMultiSuiteRecovery(restoredSession), + true, + '继承 durable revision 后的 window-WAL 必须仍可继续保存' + ); + assert.notStrictEqual(restoredSession._suiteRecoveryWritesBlocked, true); + + for (const [label, invalidRevision] of [['fractional', 1.5], ['infinite', Infinity]]) { + const invalidRevisionWal = { + ...plain(restoredSession), + id: `multi-invalid-revision-${label}`, + baseExamId: `listening-multi-invalid-revision-${label}`, + revision: 2, + _restoredFromWindowSession: true + }; + delete invalidRevisionWal._lastDurableRecoveryRevision; + restoredApp.multiSuiteSessionsMap.set(invalidRevisionWal.baseExamId, invalidRevisionWal); + const invalidRevisionDurable = plain(invalidRevisionWal); + delete invalidRevisionDurable._restoredFromWindowSession; + invalidRevisionDurable.finalizeOperationId = 'practice-multisuite:wrong:id:finalize'; + invalidRevisionDurable.finalizeRecord = { id: 'stale-record' }; + await restoreOwnedMultiSuiteItems(restoredApp, invalidRevisionWal, [{ + schema: 'multi-suite-sessions-v2', + version: 2, + id: invalidRevisionWal.id, + revision: invalidRevision, + sessions: [invalidRevisionDurable] + }]); + const retainedWal = restoredApp.multiSuiteSessionsMap.get(invalidRevisionWal.baseExamId); + assert.strictEqual(retainedWal, invalidRevisionWal); + assert.strictEqual(retainedWal._lastDurableRecoveryRevision, 0); + assert.strictEqual(retainedWal.revision, 2); + retainedWal.revision += 1; + recoveryControl.saveQueue.push((value, options) => { + assert.strictEqual(options.expectedEntityRevision, 0); + assert.strictEqual(value.revision, 3); + assert.strictEqual(value.sessions[0].revision, 3); + return { committed: true, item: plain(value) }; + }); + assert.strictEqual( + await restoredApp._commitMultiSuiteRecovery(retainedWal), + true, + `${label} durable revision 必须按 0 修复且不得锁死有效 WAL` + ); + assert.notStrictEqual(retainedWal._suiteRecoveryWritesBlocked, true); + restoredApp.multiSuiteSessionsMap.delete(invalidRevisionWal.baseExamId); + } - await handler({ - source: examWindow, - origin: 'http://localhost', - data: { - type: 'PRACTICE_COMPLETE', - source: 'listening_record_bridge', - data: { - source: 'listening_record_bridge', - examId: 'listening-unknown', - sessionId: 'listening-unknown_123', - practiceType: 'listening', - pageType: 'listening', - answers: { q1: 'acommodation' }, - correctAnswers: { q1: 'accommodation' }, - answerComparison: { - q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } - }, - scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' } + const duplicateIdWal = { + ...plain(restoredSession), + id: 'multi-duplicate-id', + baseExamId: 'listening-multi-duplicate-id', + revision: 1, + _restoredFromWindowSession: true + }; + delete duplicateIdWal._lastDurableRecoveryRevision; + restoredApp.multiSuiteSessionsMap.set(duplicateIdWal.baseExamId, duplicateIdWal); + await restoreOwnedMultiSuiteItems(restoredApp, duplicateIdWal, [3, 9].map((revision, index) => { + const candidateSession = { + ...plain(duplicateIdWal), + revision, + lastUpdate: 1000 + index + }; + delete candidateSession._restoredFromWindowSession; + return { + schema: 'multi-suite-sessions-v2', + version: 2, + id: duplicateIdWal.id, + revision, + sessions: [candidateSession], + updatedAt: 1000 + index + }; + })); + const restoredDuplicate = restoredApp.multiSuiteSessionsMap.get(duplicateIdWal.baseExamId); + assert.strictEqual( + restoredDuplicate._lastDurableRecoveryRevision, + 3, + '重复 active-session id 必须继承 AppData CAS 实际使用的首项 revision' + ); + assert.strictEqual(restoredDuplicate.revision, 3); + restoredApp.multiSuiteSessionsMap.delete(duplicateIdWal.baseExamId); + + const durableOwnerId = 'multi-valid-durable-owner'; + const conflictingWal = { + ...plain(restoredDuplicate), + id: durableOwnerId, + baseExamId: 'listening-valid-owner-window-wal', + revision: 2, + _restoredFromWindowSession: true + }; + delete conflictingWal._lastDurableRecoveryRevision; + restoredApp.multiSuiteSessionsMap.set(conflictingWal.baseExamId, conflictingWal); + const durableOwnedSession = { + ...plain(restoredDuplicate), + id: durableOwnerId, + baseExamId: 'listening-valid-owner-durable', + revision: 6, + lastUpdate: Date.now() + 100 + }; + delete durableOwnedSession._restoredFromWindowSession; + delete durableOwnedSession._lastDurableRecoveryRevision; + await restoreOwnedMultiSuiteItems(restoredApp, conflictingWal, [{ + schema: 'multi-suite-sessions-v2', + version: 2, + id: durableOwnerId, + revision: 6, + sessions: [durableOwnedSession], + updatedAt: Date.now() + 100 + }]); + assert.strictEqual( + restoredApp.multiSuiteSessionsMap.has(conflictingWal.baseExamId), + false, + 'a valid durable entity must evict a different-base WAL that reuses its exact CAS id' + ); + const restoredDurableOwner = restoredApp.multiSuiteSessionsMap.get(durableOwnedSession.baseExamId); + assert(restoredDurableOwner, 'the valid durable owner must still be restored'); + assert.strictEqual(restoredDurableOwner.id, durableOwnerId); + assert.strictEqual(restoredDurableOwner._lastDurableRecoveryRevision, 6); + assert.strictEqual( + Array.from(restoredApp.multiSuiteSessionsMap.values()) + .filter((item) => item && String(item.id || '') === durableOwnerId) + .length, + 1, + 'one AppData identity must produce only one live multi-suite session' + ); + restoredApp.multiSuiteSessionsMap.delete(durableOwnedSession.baseExamId); + + const canonicalOwnerId = 'multi-valid-durable-canonical-owner'; + const canonicalBaseExamId = 'listening-valid-owner-canonical'; + const rawWalBaseExamId = ` ${canonicalBaseExamId} `; + const nonCanonicalWal = { + ...plain(restoredDurableOwner), + id: canonicalOwnerId, + baseExamId: rawWalBaseExamId, + revision: 2, + _restoredFromWindowSession: true + }; + delete nonCanonicalWal._lastDurableRecoveryRevision; + restoredApp.multiSuiteSessionsMap.set(rawWalBaseExamId, nonCanonicalWal); + const canonicalDurableSession = { + ...plain(restoredDurableOwner), + id: canonicalOwnerId, + baseExamId: rawWalBaseExamId, + revision: 7, + lastUpdate: Date.now() + 200 + }; + delete canonicalDurableSession._restoredFromWindowSession; + delete canonicalDurableSession._lastDurableRecoveryRevision; + await restoreOwnedMultiSuiteItems(restoredApp, nonCanonicalWal, [{ + schema: 'multi-suite-sessions-v2', + version: 2, + id: canonicalOwnerId, + revision: 7, + sessions: [canonicalDurableSession], + updatedAt: Date.now() + 200 + }]); + assert.strictEqual( + restoredApp.multiSuiteSessionsMap.has(rawWalBaseExamId), + false, + 'a non-canonical WAL key must not coexist with the canonical durable base' + ); + assert(restoredApp.multiSuiteSessionsMap.has(canonicalBaseExamId)); + assert.strictEqual( + restoredApp.multiSuiteSessionsMap.get(canonicalBaseExamId).baseExamId, + canonicalBaseExamId, + 'durable restore must canonicalize both the Map key and the session property' + ); + assert.strictEqual( + Array.from(restoredApp.multiSuiteSessionsMap.values()) + .filter((item) => item && String(item.id || '') === canonicalOwnerId) + .length, + 1, + 'canonicalization differences must not duplicate one CAS identity' + ); + restoredApp.multiSuiteSessionsMap.delete(canonicalBaseExamId); + + const competingBaseExamId = 'listening-valid-owner-competing-id'; + const competingWal = { + ...plain(restoredDurableOwner), + id: 'multi-window-owner-competing-id', + baseExamId: ` ${competingBaseExamId} `, + revision: 2, + _restoredFromWindowSession: true + }; + delete competingWal._lastDurableRecoveryRevision; + const competingDurable = { + ...plain(restoredDurableOwner), + id: 'multi-durable-owner-competing-id', + baseExamId: competingBaseExamId, + revision: 8, + lastUpdate: Date.now() + 300 + }; + delete competingDurable._restoredFromWindowSession; + delete competingDurable._lastDurableRecoveryRevision; + const competingDurableItem = { + schema: 'multi-suite-sessions-v2', + version: 2, + id: competingDurable.id, + revision: 8, + sessions: [competingDurable], + updatedAt: Date.now() + 300 + }; + const originalCompetingLocks = windowStub.navigator.locks; + const previousMultiWindowWal = plain(windowSessionStore.get('multi-suite-practice') || null); + const competingLocks = { + held: new Map(), + async request(name, lockOptions = {}, callback) { + assert.strictEqual(lockOptions.mode, 'exclusive'); + assert.strictEqual(lockOptions.ifAvailable, true); + const normalizedName = String(name || ''); + if (this.held.has(normalizedName)) return callback(null); + const lock = { name: normalizedName, mode: 'exclusive' }; + this.held.set(normalizedName, lock); + try { + return await callback(lock); + } finally { + if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName); } } + }; + windowStub.navigator.locks = competingLocks; + const competingOwnerApp = createApp(windowStub); + const liveCompetingDurable = plain(competingDurable); + assert.strictEqual( + await competingOwnerApp._acquireSuiteRecoveryClaim('multi', liveCompetingDurable), + true, + 'foreign authoritative durable fixture must hold its exact lease' + ); + await windowStub.AppData.recovery.saveActiveSession(plain(competingDurableItem)); + windowSessionStore.set('multi-suite-practice', { + schema: 'multi-suite-sessions-v2', + version: 2, + sessions: [plain(competingWal)], + updatedAt: Date.now() }); + const contendedWalApp = createApp(windowStub, { suiteModeReady: false }); + contendedWalApp.initializeSuiteMode(); + await contendedWalApp._ensureSuiteRecoveryReady(); + assert.strictEqual( + contendedWalApp.multiSuiteSessionsMap.has(competingBaseExamId), + false, + 'a same-base WAL must remain quarantined while the authoritative durable lease is active' + ); + assert.strictEqual( + windowSessionStore.get('multi-suite-practice').sessions[0].id, + competingWal.id, + 'contention must preserve the copied WAL bytes for a later crash takeover retry' + ); + assert.strictEqual( + await competingOwnerApp._releaseSuiteRecoveryClaim('multi', liveCompetingDurable), + true + ); - assert(captured, '听力桥 PRACTICE_COMPLETE 不应被临时 examId/sessionId 静默丢弃'); - assert.strictEqual(captured.examId, examId, '父应用应使用当前打开的题源 examId'); - assert.strictEqual(captured.data.examId, examId, 'payload examId 应被纠正为父应用题源'); - assert.strictEqual(captured.data.sessionId, expectedSessionId, 'payload sessionId 应被纠正为父应用会话'); - } - - // Case 10: 任意目录听力完成后必须进入 PracticeRecorder,并保存错词 - { - const app = createApp(windowStub); - const examId = 'custom-listening-arbitrary-folder'; - const savedCompletions = []; - const savedErrors = []; - let status = null; - - app.components.practiceRecorder = { - handleSessionCompleted: async (payload) => { - savedCompletions.push(payload); - return { id: 'record-custom-listening', examId }; + const takeoverApp = createApp(windowStub, { suiteModeReady: false }); + takeoverApp.initializeSuiteMode(); + await takeoverApp._ensureSuiteRecoveryReady(); + const authoritativeTakeover = takeoverApp.multiSuiteSessionsMap.get(competingBaseExamId); + assert(authoritativeTakeover, 'released/crashed durable owner must be recoverable by a fresh app'); + assert.strictEqual( + authoritativeTakeover.id, + competingDurable.id, + 'the authoritative durable identity must replace the stale same-base WAL identity' + ); + assert.strictEqual( + Array.from(takeoverApp.multiSuiteSessionsMap.values()) + .some((item) => item && item.id === competingWal.id), + false + ); + assert.strictEqual(await takeoverApp._releaseSuiteRecoveryClaim('multi', authoritativeTakeover), true); + for (const app of [contendedWalApp, takeoverApp]) { + for (const recoveredSession of Array.from(app.multiSuiteSessionsMap.values())) { + if (app._ownsSuiteRecoveryClaim('multi', recoveredSession)) { + await app._releaseSuiteRecoveryClaim('multi', recoveredSession); + } + } + if (app.currentSuiteSession && app._ownsSuiteRecoveryClaim('single', app.currentSuiteSession)) { + await app._releaseSuiteRecoveryClaim('single', app.currentSuiteSession); } + } + await windowStub.AppData.recovery.discardActiveSession(competingDurable.id); + if (previousMultiWindowWal) windowSessionStore.set('multi-suite-practice', previousMultiWindowWal); + else windowSessionStore.delete('multi-suite-practice'); + windowStub.navigator.locks = originalCompetingLocks; + + const crossSchemaWal = { + id: 'cross-schema-duplicate-id', + baseExamId: 'listening-cross-schema-duplicate', + revision: 1, + _restoredFromWindowSession: true }; - app.updateExamStatus = (handledExamId, nextStatus) => { - status = { examId: handledExamId, status: nextStatus }; + restoredApp.multiSuiteSessionsMap.set(crossSchemaWal.baseExamId, crossSchemaWal); + const laterMultiSuiteItem = { + ...plain(restoredDuplicate), + id: crossSchemaWal.id, + baseExamId: crossSchemaWal.baseExamId, + revision: 9 }; - app.showRealCompletionNotification = () => {}; - app.cleanupExamSession = async () => {}; - app.setState = () => {}; + await restoreOwnedMultiSuiteItems(restoredApp, crossSchemaWal, [{ + schema: 'suite-session-v2', + version: 2, + id: crossSchemaWal.id, + revision: 4 + }, { + schema: 'multi-suite-sessions-v2', + version: 2, + id: crossSchemaWal.id, + revision: 9, + sessions: [laterMultiSuiteItem] + }]); + assert.strictEqual( + restoredApp.multiSuiteSessionsMap.has(crossSchemaWal.baseExamId), + false, + '同 ID 的 AppData 首项属于其他 schema 时,后项 multi-suite 不得声明 CAS 所有权' + ); - const previousCollector = windowStub.spellingErrorCollector; - windowStub.spellingErrorCollector = { - detectSource: () => 'other', - detectErrors: () => [{ - word: 'accommodation', - userInput: 'acommodation', - questionId: 'q1', - suiteId: null, - examId, - timestamp: 1710000000000, - errorCount: 1, - source: 'other' - }], - saveErrors: async (errors) => { - savedErrors.push(...errors); - return true; + const shadowedMarkerId = 'cross-schema-shadowed-marker'; + const shadowedMarkerBase = 'listening-cross-schema-shadowed-marker'; + const safeFallbackWal = { + ...plain(restoredSession), + id: 'safe-fallback-wal-id', + baseExamId: ` ${shadowedMarkerBase} `, + revision: 2, + lastUpdate: Date.now(), + _suiteRecoveryTimestampKnown: true, + _restoredFromWindowSession: true + }; + delete safeFallbackWal._lastDurableRecoveryRevision; + restoredApp.multiSuiteSessionsMap.set(safeFallbackWal.baseExamId, safeFallbackWal); + const corruptShadowedMarker = { + ...plain(restoredSession), + id: shadowedMarkerId, + baseExamId: shadowedMarkerBase, + revision: 9, + finalizeOperationId: 'practice-multisuite:wrong:id:finalize', + finalizeRecord: { id: 'stale-record' } + }; + delete corruptShadowedMarker._restoredFromWindowSession; + delete corruptShadowedMarker._lastDurableRecoveryRevision; + const originalShadowedMarkerLocks = windowStub.navigator.locks; + const originalShadowedMarkerFence = windowStub.AppData.recovery.getActiveSessionFence; + const shadowedMarkerLocks = { + held: new Map(), + calls: [], + async request(name, lockOptions = {}, callback) { + assert.strictEqual(lockOptions.mode, 'exclusive'); + assert.strictEqual(lockOptions.ifAvailable, true); + const normalizedName = String(name || ''); + this.calls.push(normalizedName); + if (this.held.has(normalizedName)) return callback(null); + const lock = { name: normalizedName, mode: 'exclusive' }; + this.held.set(normalizedName, lock); + try { + return await callback(lock); + } finally { + if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName); + } } }; - - await app.handlePracticeComplete(examId, { - examId, - sessionId: `${examId}_session`, - practiceType: 'listening', - pageType: 'listening', - answers: { q1: 'acommodation' }, - correctAnswers: { q1: 'accommodation' }, - answerComparison: { - q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } - }, - scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' } + windowStub.navigator.locks = shadowedMarkerLocks; + windowStub.AppData.recovery.getActiveSessionFence = async (id) => ({ + id: String(id), + exists: false, + tombstoned: false, + revision: 0 }); + const shadowedMarkerSaveStart = recoveryControl.events.length; + try { + await restoreOwnedMultiSuiteItems(restoredApp, safeFallbackWal, [{ + schema: 'suite-session-v2', + version: 2, + id: shadowedMarkerId, + revision: 4 + }, { + schema: 'multi-suite-sessions-v2', + version: 2, + id: shadowedMarkerId, + revision: 9, + sessions: [corruptShadowedMarker] + }]); + assert.strictEqual( + restoredApp.multiSuiteSessionsMap.get(shadowedMarkerBase), + safeFallbackWal, + 'a shadowed corrupt base marker must not delete a different exact-id WAL' + ); + assert.strictEqual(safeFallbackWal.baseExamId, shadowedMarkerBase); + assert.notStrictEqual( + safeFallbackWal._lastDurableRecoveryRevision, + 9, + 'a later shadowed marker must not lend its revision to a different WAL identity' + ); + const safeFallbackSaves = recoveryControl.events + .slice(shadowedMarkerSaveStart) + .filter((event) => event.type === 'save'); + assert.strictEqual(safeFallbackSaves.length, 1); + assert.strictEqual(safeFallbackSaves[0].value.id, safeFallbackWal.id); + assert.strictEqual(safeFallbackSaves[0].options.expectedEntityRevision, 0); + assert.deepStrictEqual( + shadowedMarkerLocks.calls, + [ + restoredApp._multiSuiteBaseClaimName(safeFallbackWal.baseExamId), + restoredApp._suiteRecoveryClaimName(safeFallbackWal.id) + ], + 'only the WAL base and exact identity may be claimed; a shadowed corrupt marker is not authoritative' + ); + assert.strictEqual( + await restoredApp._releaseSuiteRecoveryClaim('multi', safeFallbackWal), + true + ); + assert.strictEqual(shadowedMarkerLocks.held.size, 0); + } finally { + await windowStub.AppData.recovery.discardActiveSession(safeFallbackWal.id); + if (originalShadowedMarkerFence) { + windowStub.AppData.recovery.getActiveSessionFence = originalShadowedMarkerFence; + } else { + delete windowStub.AppData.recovery.getActiveSessionFence; + } + windowStub.navigator.locks = originalShadowedMarkerLocks; + } + restoredApp.multiSuiteSessionsMap.delete(shadowedMarkerBase); + + const exactIdWal = { + id: 'multi-exact-id', + baseExamId: 'listening-multi-exact-id', + revision: 2, + lastUpdate: Date.now(), + _suiteRecoveryTimestampKnown: true, + _restoredFromWindowSession: true + }; + restoredApp.multiSuiteSessionsMap.set(exactIdWal.baseExamId, exactIdWal); + const originalExactIdFence = windowStub.AppData.recovery.getActiveSessionFence; + windowStub.AppData.recovery.getActiveSessionFence = async (id) => ({ + id: String(id), + exists: false, + tombstoned: false, + revision: 0 + }); + const exactIdSaveStart = recoveryControl.events.length; + try { + await restoreOwnedMultiSuiteItems(restoredApp, exactIdWal, [{ + schema: 'multi-suite-sessions-v2', + version: 2, + id: `${exactIdWal.id} `, + revision: 8, + sessions: [{ baseExamId: exactIdWal.baseExamId }] + }]); + assert.notStrictEqual( + exactIdWal._lastDurableRecoveryRevision, + 8, + 'active-session id 必须按 AppData 原始字符串精确匹配,不能 trim 后借用 revision' + ); + assert.strictEqual( + restoredApp.multiSuiteSessionsMap.get(exactIdWal.baseExamId), + exactIdWal, + 'the exact-id WAL may establish its own expected=0 entity without borrowing a whitespace alias' + ); + const exactIdSaves = recoveryControl.events + .slice(exactIdSaveStart) + .filter((event) => event.type === 'save'); + assert.strictEqual(exactIdSaves.length, 1); + assert.strictEqual(exactIdSaves[0].value.id, exactIdWal.id); + assert.strictEqual(exactIdSaves[0].options.expectedEntityRevision, 0); + assert.strictEqual(await restoredApp._releaseSuiteRecoveryClaim('multi', exactIdWal), true); + } finally { + await windowStub.AppData.recovery.discardActiveSession(exactIdWal.id); + if (originalExactIdFence) { + windowStub.AppData.recovery.getActiveSessionFence = originalExactIdFence; + } else { + delete windowStub.AppData.recovery.getActiveSessionFence; + } + } + restoredApp.multiSuiteSessionsMap.delete(exactIdWal.baseExamId); + + const mismatchedOwnerWal = { + ...plain(restoredSession), + id: 'multi-nested-owner', + baseExamId: 'listening-multi-mismatched-owner', + revision: 2, + _restoredFromWindowSession: true + }; + delete mismatchedOwnerWal._lastDurableRecoveryRevision; + restoredApp.multiSuiteSessionsMap.set(mismatchedOwnerWal.baseExamId, mismatchedOwnerWal); + const mismatchedDurableSession = { + ...plain(mismatchedOwnerWal), + id: 'multi-corrupt-nested-owner' + }; + delete mismatchedDurableSession._restoredFromWindowSession; + const laterValidDuplicate = plain(mismatchedOwnerWal); + delete laterValidDuplicate._restoredFromWindowSession; + await restoreOwnedMultiSuiteItems(restoredApp, mismatchedOwnerWal, [{ + schema: 'multi-suite-sessions-v2', + version: 2, + id: mismatchedOwnerWal.id, + revision: 11, + sessions: [mismatchedDurableSession] + }, { + schema: 'multi-suite-sessions-v2', + version: 2, + id: mismatchedOwnerWal.id, + revision: 12, + sessions: [laterValidDuplicate] + }]); + assert.strictEqual( + restoredApp.multiSuiteSessionsMap.get(mismatchedOwnerWal.baseExamId), + mismatchedOwnerWal, + 'a corrupt first exact-id owner must block a later duplicate from replacing the window WAL' + ); + assert.strictEqual(mismatchedOwnerWal._lastDurableRecoveryRevision, 11); + assert.strictEqual(mismatchedOwnerWal.revision, 11, 'the retained WAL must inherit the actual first CAS owner revision'); + restoredApp.multiSuiteSessionsMap.delete(mismatchedOwnerWal.baseExamId); - windowStub.spellingErrorCollector = previousCollector; - - assert.strictEqual(savedCompletions.length, 1, '听力完成应调用 PracticeRecorder 落库'); - assert.strictEqual(savedCompletions[0].examId, examId, '落库 payload 应保留当前听力 examId'); - assert.strictEqual(savedErrors.length, 1, '任意目录听力错词应保存到词表链路'); - assert.strictEqual(savedErrors[0].word, 'accommodation', '错词应来自 answerComparison'); - assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态'); + await windowStub.AppData.recovery.discardActiveSession(session.id); + restoredApp.multiSuiteSessionsMap.delete('listening-multi-corrupt-durable'); } - // Case 10b: 听力桥自带错词也必须归一到父页面当前题源,不能写入临时 listening-unknown + // Case 3.0.6.6: multi-suite recovery 收到 stale receipt 后必须置 write-block, + // 后续提交短路,避免用同一旧 revision 无限重试并反复 NACK 合法提交(F3)。 { const app = createApp(windowStub); - const examId = 'listening-p1-normalized-errors'; - const savedErrors = []; - - app.components.practiceRecorder = { - handleSessionCompleted: async () => ({ id: 'record-normalized-errors', examId }) - }; - app.updateExamStatus = () => {}; - app.showRealCompletionNotification = () => {}; - app.cleanupExamSession = async () => {}; - app.setState = () => {}; - - const previousCollector = windowStub.spellingErrorCollector; - windowStub.spellingErrorCollector = { - detectSource: () => 'p1', - detectErrors: () => [], - saveErrors: async (errors) => { - savedErrors.push(...errors); - return true; - } + await app._ensureSuiteRecoveryReady(); + const session = { + id: 'multi-writeblock-session', + baseExamId: 'listening-multi-writeblock', + status: 'active', + startTime: Date.now(), + suiteResults: [], + expectedSuiteCount: 1, + metadata: { source: 'p1' }, + lastUpdate: Date.now(), + revision: 2, + _lastDurableRecoveryRevision: 1 }; + app.multiSuiteSessionsMap.set(session.baseExamId, session); - await app.handlePracticeComplete(examId, { - examId, - sessionId: `${examId}_session`, - practiceType: 'listening', - pageType: 'listening', - answers: { q1: 'acommodation' }, - correctAnswers: { q1: 'accommodation' }, - answerComparison: { - q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } - }, - scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }, - spellingErrors: [{ - word: 'accommodation', - userInput: 'acommodation', - questionId: 'q1', - suiteId: null, - examId: 'listening-unknown', - timestamp: 1710000000000, - errorCount: 1, - source: 'other' - }] - }); + const saveEventsBefore = recoveryControl.events.filter((event) => event.type === 'save').length; + recoveryControl.saveQueue.push(() => ({ committed: false, code: 'STALE_RECOVERY_WRITE' })); + assert.strictEqual( + await app._commitMultiSuiteRecovery(session), + false, + 'stale receipt 时 multi-suite recovery 提交必须失败' + ); + assert.strictEqual( + session._suiteRecoveryWritesBlocked, + true, + 'stale receipt 后 multi-suite session 必须置 write-block' + ); - windowStub.spellingErrorCollector = previousCollector; + // write-block 后不应再向 AppData 发起任何持久化提交。 + const saveEventsMid = recoveryControl.events.filter((event) => event.type === 'save').length; + assert.strictEqual( + await app._commitMultiSuiteRecovery(session), + false, + 'write-block 后提交必须短路返回 false' + ); + const saveEventsAfter = recoveryControl.events.filter((event) => event.type === 'save').length; + assert.strictEqual( + saveEventsAfter, + saveEventsMid, + 'write-block 后不得再调用 saveActiveSession' + ); + assert(saveEventsAfter > saveEventsBefore, 'stale receipt 必须确实触发过一次持久化提交'); - assert.strictEqual(savedErrors.length, 1, '听力桥自带错词应继续保存'); - assert.strictEqual(savedErrors[0].examId, examId, '错词 examId 必须归一到父页面题源'); - assert.strictEqual(savedErrors[0].source, 'p1', 'P1 听力错词 source 必须归一,避免写到 other 词表'); + app.multiSuiteSessionsMap.delete(session.baseExamId); + await windowStub.AppData.recovery.discardActiveSession(session.id); } - // Case 11: 听力桥 bootstrap ready 不得提前结束父子握手 + // Case 3.0.6.7: 已完成的套题会话关闭末篇子页时,不得把末篇标成 interrupted(H1)。 { const app = createApp(windowStub); - const examWindow = createStubWindow('custom-listening-handshake-window'); - const examId = 'custom-listening-handshake'; - const expectedSessionId = 'custom-listening-handshake_expected'; - - app.setupExamWindowCommunication(examWindow, examId, { - id: examId, - title: 'Handshake Listening', - type: 'listening' - }); - - const info = app.ensureExamWindowSession(examId, examWindow); - info.expectedSessionId = expectedSessionId; - app.examWindows.set(examId, info); - examWindow._messages.length = 0; - - const timer = setInterval(() => {}, 10000); - app._handshakeTimers = new Map([[examId, timer]]); + const suite = makeSession('suite_completed_close'); + suite.status = 'completed'; + const child = suite.windowRef; + app.currentSuiteSession = suite; + app.suiteExamMap = new Map(suite.sequence.map((item) => [item.examId, suite.id])); + app.examWindows = new Map([['reading-p1', { window: child, suiteSessionId: suite.id }]]); + const statusUpdates = []; + app.updateExamStatus = (examId, status) => { statusUpdates.push({ examId, status }); }; + const cleanupCalls = []; + app.cleanupExamSession = async (examId) => { cleanupCalls.push(examId); }; + + assert.strictEqual(await app.handleExamWindowClosed('reading-p1', child), true); + assert.strictEqual( + statusUpdates.some((update) => update.examId === 'reading-p1' && update.status === 'completed'), + true, + 'completed 套题关闭末篇必须标记 completed 而非 interrupted' + ); + assert.strictEqual( + statusUpdates.some((update) => update.status === 'interrupted'), + false, + 'completed 套题关闭末篇不得落为 interrupted' + ); + assert.strictEqual(cleanupCalls.includes('reading-p1'), true, 'completed 套题关闭后必须清理 exam session'); + app.examWindows.delete('reading-p1'); + } - const handler = app.messageHandlers.get(examId); - assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler'); + // Case 3.0.7: stale completed-session teardown must not own a newer session + { + const app = createApp(windowStub); + const staleSession = makeSession('suite_stale_completed'); + staleSession.status = 'completed'; + staleSession._suiteGeneration = 1; + const freshSession = makeSession('suite_fresh_started'); + freshSession.status = 'active'; + freshSession._suiteGeneration = 2; + freshSession.windowRef = createStubWindow('suite-fresh-window'); + + app.currentSuiteSession = freshSession; + app.suiteExamMap = new Map(freshSession.sequence.map(item => [item.examId, freshSession.id])); + assert.strictEqual(await app._ensureSuiteRecoveryClaim('single', freshSession), true); + app._mirrorSessionToStorage(freshSession); + const freshSnapshot = plain(windowSessionStore.get('simulation')); + const freshWindow = freshSession.windowRef; + let scheduledTeardown = null; + let discardCalls = 0; + const originalSetTimeout = sandbox.setTimeout; + const originalClearTimeout = sandbox.clearTimeout; + const originalDiscard = windowStub.AppData.recovery.windowSession.discard; + + sandbox.setTimeout = (callback) => { + scheduledTeardown = callback; + return { unref() {} }; + }; + sandbox.clearTimeout = () => {}; + windowStub.AppData.recovery.windowSession.discard = (...args) => { + discardCalls += 1; + return originalDiscard(...args); + }; try { - await handler({ - source: examWindow, - origin: 'http://localhost', - data: { - type: 'SESSION_READY', - source: 'listening_record_bridge', - data: { - source: 'listening_record_bridge', - examId: 'listening-unknown', - sessionId: 'listening-unknown_123', - pageType: 'listening', - type: 'listening', - initialized: false - } - } - }); - - const preInitInfo = app.examWindows.get(examId); - assert.strictEqual(app._handshakeTimers.has(examId), true, 'pre-init ready 不得停止 INIT 重试'); - assert.strictEqual(preInitInfo.dataCollectorReady, undefined, 'pre-init ready 不得标记 collector ready'); - assert(examWindow._messages.some(message => message && message.type === 'INIT_SESSION'), 'pre-init ready 后应补发 INIT_SESSION'); - - await handler({ - source: examWindow, - origin: 'http://localhost', - data: { - type: 'SESSION_READY', - source: 'listening_record_bridge', - data: { - source: 'listening_record_bridge', - examId, - sessionId: expectedSessionId, - pageType: 'listening', - type: 'listening', - initialized: true - } - } - }); - - assert.strictEqual(app._handshakeTimers.has(examId), false, 'initialized ready 才能停止 INIT 重试'); - assert.strictEqual(app.examWindows.get(examId).dataCollectorReady, true, 'initialized ready 应标记 collector ready'); + assert.strictEqual(app._scheduleSuiteSubmitTeardown(staleSession), true, '旧 session 应成功注册延迟清理'); + assert.strictEqual(typeof scheduledTeardown, 'function', '应捕获延迟清理回调'); + scheduledTeardown(); + await Promise.resolve(); } finally { - clearInterval(timer); + sandbox.setTimeout = originalSetTimeout; + sandbox.clearTimeout = originalClearTimeout; + windowStub.AppData.recovery.windowSession.discard = originalDiscard; } + + assert.strictEqual(app.currentSuiteSession, freshSession, '旧 session teardown 不得替换新 session'); + assert.strictEqual(freshWindow.closed, false, '旧 session teardown 不得关闭新 session 窗口'); + assert.strictEqual(app.suiteExamMap.get('reading-p1'), freshSession.id, '旧 session teardown 不得清理新 suiteExamMap'); + assert.deepStrictEqual(plain(windowSessionStore.get('simulation')), freshSnapshot, '旧 session teardown 不得清理新 snapshot'); + assert.strictEqual(discardCalls, 0, '旧 session teardown 不得 discard 新 session snapshot'); } - // Case 12: 听力完成早于 initialized ready 时,也必须先补建 recorder session 再落库 + // Case 3.0.8: timer 与进行中的 teardown 重叠时,失败后必须保留一次自动重试。 { const app = createApp(windowStub); - const examWindow = createStubWindow('custom-listening-complete-first-window'); - const examId = 'custom-listening-complete-first'; - const expectedSessionId = 'custom-listening-complete-first_expected'; - const calls = []; - let status = null; + const session = makeSession('suite_teardown_timer_overlap'); + session.status = 'completed'; + session.windowRef.close = function close() { this.closed = true; }; + const suiteWindow = session.windowRef; + const overlapExamId = session.activeExamId; + const overlapInfo = { + window: suiteWindow, + suiteSessionId: session.id, + expectedSessionId: 'teardown-overlap-attempt', + windowSessionToken: 'teardown-overlap-token', + windowSessionTokenSessionId: 'teardown-overlap-attempt', + sessionGeneration: 3 + }; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[overlapExamId, overlapInfo]]); + app.messageHandlers = new Map([[overlapExamId, () => {}]]); + session.windowBinding = { + examId: overlapExamId, + expectedSessionId: overlapInfo.expectedSessionId, + windowSessionToken: overlapInfo.windowSessionToken, + sessionGeneration: overlapInfo.sessionGeneration + }; - app.components.practiceRecorder = { - activeSessions: new Map(), - startPracticeSession(handledExamId, examData) { - calls.push({ type: 'startPracticeSession', examId: handledExamId, examData }); - this.activeSessions.set(handledExamId, { - examId: handledExamId, - sessionId: `${handledExamId}_generated`, - metadata: {}, - progress: { totalQuestions: examData.totalQuestions || 0 }, - answers: {} - }); - return this.activeSessions.get(handledExamId); - }, - handleSessionStarted(payload) { - calls.push({ type: 'handleSessionStarted', payload }); - assert(this.activeSessions.has(payload.examId), '补建 session 必须先于 handleSessionStarted'); - const session = this.activeSessions.get(payload.examId); - session.sessionId = payload.sessionId; - session.metadata = { ...session.metadata, ...payload.metadata }; - this.activeSessions.set(payload.examId, session); - }, - async handleSessionCompleted(payload) { - calls.push({ type: 'handleSessionCompleted', payload }); - assert(this.activeSessions.has(payload.examId), '真实 recorder 没有 active session 会拒绝落库'); - const session = this.activeSessions.get(payload.examId); - assert.strictEqual(session.sessionId, payload.sessionId, '完成 payload 必须使用父页面 expectedSessionId'); - return { id: `record_${payload.sessionId}`, examId: payload.examId, sessionId: payload.sessionId }; - } + const scheduledTimers = []; + const originalSetTimeout = sandbox.setTimeout; + const originalClearTimeout = sandbox.clearTimeout; + sandbox.setTimeout = (callback) => { + const timer = { run: callback, unref() {} }; + scheduledTimers.push(timer); + return timer; }; - app.updateExamStatus = (handledExamId, nextStatus) => { - status = { examId: handledExamId, status: nextStatus }; + sandbox.clearTimeout = () => {}; + + let discardAttempts = 0; + let markDiscardStarted; + let releaseDiscard; + const discardStarted = new Promise((resolve) => { markDiscardStarted = resolve; }); + const discardGate = new Promise((resolve) => { releaseDiscard = resolve; }); + app._discardPersistentSuiteRecovery = async () => { + discardAttempts += 1; + if (discardAttempts === 1) { + markDiscardStarted(); + return discardGate; + } + return true; }; - app.showRealCompletionNotification = () => {}; - app.cleanupExamSession = async () => {}; - app.setState = () => {}; - app.setupExamWindowCommunication(examWindow, examId, { - id: examId, - title: 'Complete First Listening', - type: 'listening' - }); + try { + assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true); + session.activeExamId = session.sequence[1].examId; + const firstTeardown = app._teardownSuiteSession(session); + await discardStarted; + const frozenRegistrations = session._suiteTeardownRegistrations; + assert(frozenRegistrations && typeof frozenRegistrations.get === 'function' && frozenRegistrations.size === 1); + const overlappingTimer = scheduledTimers[0].run(); + releaseDiscard(false); + assert.strictEqual(await firstTeardown, false); + await overlappingTimer; + assert.strictEqual( + session._suiteTeardownRegistrations, + frozenRegistrations, + 'completed teardown retry must retain the original exact registration snapshot' + ); + assert.strictEqual(scheduledTimers.length, 2, '失败的重叠 teardown 必须重新挂起 fallback timer'); + assert.strictEqual(session.submitReceiptTeardownTimer, scheduledTimers[1]); + await scheduledTimers[1].run(); + } finally { + sandbox.setTimeout = originalSetTimeout; + sandbox.clearTimeout = originalClearTimeout; + } - const info = app.ensureExamWindowSession(examId, examWindow); - info.expectedSessionId = expectedSessionId; - app.examWindows.set(examId, info); + assert.strictEqual(discardAttempts, 2, 'fallback timer 必须在瞬时失败后重新尝试 discard'); + assert.strictEqual(app.currentSuiteSession, null, '重试成功后必须完成 teardown'); + assert.strictEqual(suiteWindow.closed, true, '重试成功后必须关闭已完成题页'); + assert.strictEqual( + app.examWindows.has(session.sequence[1].examId), + false, + 'late activeExamId mutation must not create a ghost registration during force-close' + ); + assert.strictEqual(session._suiteTeardownRegistrations, undefined); + } - const handler = app.messageHandlers.get(examId); - assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler'); + // Case 3.1: 如果最后一篇已有导航快照,最终提交仍应覆盖并 finalize + { + const app = createApp(windowStub); + const session = makeSession('suite_finalize_upsert'); + session.results = [ + { examId: 'reading-p1', title: 'Passage 1', answers: { q1: 'A' }, answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }, + { examId: 'reading-p2', title: 'Passage 2', answers: { q1: 'B' }, answerComparison: { q1: { userAnswer: 'B', correctAnswer: 'B', isCorrect: true } }, scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, rawData: {} }, + { examId: 'reading-p3', title: 'Passage 3', answers: { q1: 'OLD' }, answerComparison: { q1: { userAnswer: 'OLD', correctAnswer: 'C', isCorrect: false } }, scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0 }, rawData: {} } + ]; + session.currentIndex = 2; + session.activeExamId = 'reading-p3'; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); - await handler({ - source: examWindow, - origin: 'http://localhost', - data: { - type: 'PRACTICE_COMPLETE', - source: 'listening_record_bridge', - data: { - source: 'listening_record_bridge', - examId: 'listening-unknown', - sessionId: 'listening-unknown_early', - practiceType: 'listening', - pageType: 'listening', - title: 'Complete First Listening', - answers: { q1: 'acommodation' }, - correctAnswers: { q1: 'accommodation' }, - answerComparison: { - q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } - }, - scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' } - } - } - }); + let finalizeCount = 0; + app.finalizeSuiteRecord = async () => { + finalizeCount += 1; + }; - assert.deepStrictEqual( - calls.map(call => call.type), - ['startPracticeSession', 'handleSessionStarted', 'handleSessionCompleted'], - 'complete-before-ready 必须先建会话、再同步 sessionId、最后落库' - ); - assert.strictEqual(calls[2].payload.examId, examId, '完成 payload examId 应被纠正为父页面当前题源'); - assert.strictEqual(calls[2].payload.sessionId, expectedSessionId, '完成 payload sessionId 应被纠正为父页面会话'); - assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态'); + const handled = await app.handleSuitePracticeComplete('reading-p3', { + suiteSessionId: session.id, + answers: { q1: 'C' }, + answerComparison: { q1: { userAnswer: 'C', correctAnswer: 'C', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, session.windowRef); + + assert.strictEqual(handled, true, '最后一篇覆盖提交应成功'); + assert.strictEqual(finalizeCount, 1, '最后一篇覆盖提交后仍应 finalize'); + const p3 = session.results.find(item => item.examId === 'reading-p3'); + assert.deepStrictEqual(p3.answers, { q1: 'C' }, '最终提交应覆盖旧快照答案'); } - // Case 13: 统一阅读提交后 reset 必须复用父页通信链路并重建 recorder session + // Case 4: 显式中断只清理 v2 套题会话,不拆分写入 v1 单篇记录 { const app = createApp(windowStub); - const examWindow = createStubWindow('unified-reading-retake-window'); - const examId = 'reading-retake-unified'; - const firstSessionId = 'reading-retake-first-session'; - const resetSessionId = 'reading-retake-reset-session'; - const completions = []; - const recorderStarts = []; - const resetStarts = []; - const statuses = []; + const session = makeSession('suite_abort'); + session.results = [ + { + examId: 'reading-p1', + rawData: { + answers: { q1: 'A' }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + } + }, + { + examId: 'reading-p2', + rawData: { + answers: { q1: 'B' }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + } + } + ]; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + + const savedExamIds = []; + app.saveRealPracticeData = async (examId) => { + savedExamIds.push(examId); + }; + app._teardownSuiteSession = async () => { + app.currentSuiteSession = null; + }; + + await app._abortSuiteSession(session, {}); + assert.deepStrictEqual(savedExamIds, [], '中断后不得通过 v1 单篇路径写入记录'); + } + + // Case 4.2: 下一篇打开失败不得删除已提交结果,且可重试继续。 + { + const app = createApp(windowStub); + const session = makeSession('suite_open_next_retry'); + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + app.openExam = async () => { throw new Error('expected next-window failure'); }; + const discardCount = recoveryControl.events.filter((event) => event.type === 'discard').length; + const outcome = await app.handleSuitePracticeComplete('reading-p1', { + suiteSessionId: session.id, + submissionId: 'submit-open-next-retry', + duration: 30, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, session.windowRef); + assert.strictEqual(outcome.handled, true); + assert.strictEqual(outcome.committed, true, '当前篇 durable 后必须 ACK'); + assert.strictEqual(outcome.errorCode, 'suite_advance_failed'); + assert.strictEqual(session.activeExamId, 'reading-p2', 'recovery 必须指向待打开的下一篇'); + assert.strictEqual(session.results.length, 1, '已提交结果必须留在 suite session'); + assert.strictEqual( + recoveryControl.events.filter((event) => event.type === 'discard').length, + discardCount, + '自动切题失败不得 discard recovery' + ); + const retriedWindow = createStubWindow('open-next-retried'); + app.openExam = async (examId, options = {}) => ( + installManagedTestWindow(app, examId, retriedWindow, options) + ); + assert.strictEqual(await app.continueSuitePractice(), true, '现有继续入口应能重试打开下一篇'); + } + + // Case 4.3: 关闭活动子页必须暂停计时并写入 durable recovery。 + { + const app = createApp(windowStub); + const session = makeSession('suite_close_pauses_timer'); + const child = session.windowRef; + session.suiteTimerRunning = true; + session.suiteTimerPausedAtMs = null; + session.suiteTimerPausedOffsetMs = 0; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + app.examWindows = new Map([['reading-p1', { window: child, suiteSessionId: session.id }]]); + const saveStart = recoveryControl.events.length; + assert.strictEqual(await app.handleExamWindowClosed('reading-p1', child), true); + assert.strictEqual(session.suiteTimerRunning, false); + assert(Number.isFinite(Number(session.suiteTimerPausedAtMs))); + const pausedElapsed = app._computeSuiteElapsedSeconds(session, session.suiteTimerPausedAtMs); + assert.strictEqual( + app._computeSuiteElapsedSeconds(session, session.suiteTimerPausedAtMs + 60000), + pausedElapsed, + '窗口关闭后套题总计时不得继续增长' + ); + const saved = recoveryControl.events.slice(saveStart).filter((event) => event.type === 'save').at(-1)?.value; + assert(saved && saved.suiteTimerRunning === false, '暂停字段必须写入 AppData v2 recovery'); + assert.strictEqual(saved.suiteTimerPausedAtMs, session.suiteTimerPausedAtMs); + } + + // Case 4.4: 同名入口必须按 suite session 隔离窗口所有权。 + { + const originalOpen = windowStub.open; + const windowsByName = new Map(); + windowStub.open = (_url, name) => { + if (!windowsByName.has(name)) { + const child = createStubWindow(name); + child.close = () => { child.closed = true; }; + windowsByName.set(name, child); + } + return windowsByName.get(name); + }; + try { + const firstApp = createApp(windowStub); + const secondApp = createApp(windowStub); + firstApp._generateSuiteSessionId = () => 'suite-window-owner-a'; + secondApp._generateSuiteSessionId = () => 'suite-window-owner-b'; + firstApp.openExam = async (examId, options = {}) => installManagedTestWindow( + firstApp, + examId, + windowStub.open('', options.windowName), + options + ); + secondApp.openExam = async (examId, options = {}) => installManagedTestWindow( + secondApp, + examId, + windowStub.open('', options.windowName), + options + ); + const sequence = makeSession().sequence; + assert.strictEqual(await firstApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + assert.strictEqual(await secondApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + assert.notStrictEqual(firstApp.currentSuiteSession.windowName, secondApp.currentSuiteSession.windowName); + const secondWindow = secondApp.currentSuiteSession.windowRef; + assert.strictEqual(await firstApp._teardownSuiteSession(firstApp.currentSuiteSession), true); + assert.strictEqual(secondWindow.closed, false, '旧主页面 teardown 不得关闭新 session 的子页'); + } finally { + windowStub.open = originalOpen; + } + } + + // Case 5: AppData window-session mirror must be cleared after teardown + { + const app = createApp(windowStub); + const session = makeSession('suite_storage_cleanup'); + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + assert.strictEqual(await app._ensureSuiteRecoveryClaim('single', session), true); + app._mirrorSessionToStorage(session); + assert(windowSessionStore.has('simulation'), '镜像应存在'); + app._clearSessionStorage(); + assert(!windowSessionStore.has('simulation'), '清理后镜像应删除'); + } + + // Case 6: _sendSimulationContext 应发送正确的上下文 + { + const app = createApp(windowStub); + const session = makeSession('suite_context'); + const p1Highlights = [{ scope: 'left', text: 'P1 context highlight', color: 'yellow' }]; + session.draftsByExam['reading-p1'] = { answers: { q1: 'A' }, highlights: p1Highlights, scrollY: 0 }; + session.draftsByExam['reading-p2'] = { answers: { q2: 'B' }, noteText: 'P2 draft' }; + session.elapsedByExam['reading-p1'] = 45; + app.currentSuiteSession = session; + const targetWindow = createStubWindow('ctx-window'); + const sent = app._sendSimulationContext(session, 'reading-p1', targetWindow); + assert.strictEqual(sent, true, '应成功发送上下文'); + const ctxMsg = targetWindow._messages.find(m => m && m.type === 'SIMULATION_CONTEXT'); + assert(ctxMsg, '应收到 SIMULATION_CONTEXT'); + assert.strictEqual(ctxMsg.data.currentIndex, 0, 'currentIndex 应为 0'); + assert.strictEqual(ctxMsg.data.total, 3, 'total 应为 3'); + assert.strictEqual(Array.isArray(ctxMsg.data.suiteSequence), true, 'SIMULATION_CONTEXT 应包含 suiteSequence'); + assert.deepStrictEqual(ctxMsg.data.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'suiteSequence 应包含三篇 examId'); + assert.strictEqual(ctxMsg.data.isLast, false, 'P1 不是最后一篇'); + assert.strictEqual(ctxMsg.data.canPrev, false, 'P1 不能向前'); + assert.strictEqual(ctxMsg.data.canNext, true, 'P1 可以向后'); + assert.deepStrictEqual(ctxMsg.data.draft.answers, { q1: 'A' }, 'draft 应回传'); + assert.deepStrictEqual(ctxMsg.data.draft.highlights, p1Highlights, 'draft highlights 应随上下文回传,避免切题后丢失高亮'); + assert.deepStrictEqual( + plain(ctxMsg.data.draftsByExam), + plain(session.draftsByExam), + 'inline context 必须同时携带所有篇章草稿' + ); + assert.notStrictEqual(ctxMsg.data.draftsByExam, session.draftsByExam, '草稿集合必须以克隆值发送'); + assert.strictEqual(ctxMsg.data.elapsed, 45, 'elapsed 应回传'); + + const sentP3 = app._sendSimulationContext(session, 'reading-p3', targetWindow); + assert.strictEqual(sentP3, true, 'P3 上下文应成功'); + const ctxP3 = targetWindow._messages.filter(m => m && m.type === 'SIMULATION_CONTEXT')[1]; + assert.strictEqual(ctxP3.data.isLast, true, 'P3 应标记为最后一篇'); + assert.strictEqual(ctxP3.data.canNext, false, 'P3 不能向后导航'); + } + + // Case 6.1: INIT_SESSION payload 应携带三篇 suiteSequence + { + const app = createApp(windowStub); + const session = makeSession('suite_init_sequence'); + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + const initWindow = createStubWindow('init-window'); + const windowInfo = app.ensureExamWindowSession('reading-p1', initWindow); + windowInfo.suiteSessionId = session.id; + windowInfo.suiteFlowMode = 'simulation'; + const payload = app._buildExamInitPayload('reading-p1', windowInfo); + assert.strictEqual(Array.isArray(payload.suiteSequence), true, 'INIT_SESSION 应包含 suiteSequence'); + assert.deepStrictEqual(payload.suiteSequence.map(item => item.examId), ['reading-p1', 'reading-p2', 'reading-p3'], 'INIT suiteSequence 应覆盖三篇'); + assert.deepStrictEqual(payload.suiteSequence.map(item => item.category), ['P1', 'P2', 'P3'], 'INIT suiteSequence 应带 category'); + } + + // Case 6.2: 占位页 URL 必须显式传播窄范围 suite 测试标志 + { + const app = createApp(windowStub); + const placeholderUrl = app._buildExamPlaceholderUrl( + { + id: 'reading-p1', + title: 'Passage 1 & 特殊字符', + category: 'P1' + }, + { + suiteSessionId: 'suite placeholder session', + sequenceIndex: 0 + } + ); + const parsed = new URL(placeholderUrl); + assert.strictEqual(parsed.pathname.endsWith('/templates/exam-placeholder.html'), true, '应使用套题占位页'); + assert.strictEqual(parsed.searchParams.get('suite_test'), '1', '占位页必须收到 suite_test=1'); + assert.strictEqual(parsed.searchParams.get('suiteSessionId'), 'suite placeholder session', '套题会话 ID 应 round-trip'); + assert.strictEqual(parsed.searchParams.get('title'), 'Passage 1 & 特殊字符', '标题特殊字符应由 URLSearchParams 安全编码'); + assert.strictEqual(parsed.searchParams.get('index'), '0', '首篇 index=0 不应被省略'); + } + + // Case 8: handleSessionReady 应触发首篇模拟上下文下发 + { + const app = createApp(windowStub); + const session = makeSession('suite_session_ready'); + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map(); + const readyWindow = createStubWindow('ready-window'); + app.examWindows.set('reading-p1', { + examId: 'reading-p1', + window: readyWindow, + expectedSessionId: 'session-reading-p1', + suiteSessionId: session.id + }); + + app.handleSessionReady('reading-p1', { + sessionId: 'session-reading-p1', + suiteSessionId: session.id, + pageType: 'unified-reading' + }); + const msg = readyWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT'); + assert(msg, 'SESSION_READY 后应下发 SIMULATION_CONTEXT'); + assert.strictEqual(msg.data.examId, 'reading-p1', 'SESSION_READY 下发应匹配 examId'); + } + + // Case 8.1: 迟到 SESSION_READY 若窗口 URL 已切到其他篇,必须忽略 + { + const app = createApp(windowStub); + const session = makeSession('suite_stale_ready'); + session.currentIndex = 0; + session.activeExamId = 'reading-p1'; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map(); + + const staleWindow = createStubWindow('ready-window'); + staleWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1'; + app.examWindows.set('reading-p2', { + examId: 'reading-p2', + window: staleWindow, + expectedSessionId: 'session-reading-p2', + suiteSessionId: session.id, + pageType: 'unified-reading' + }); + + app.handleSessionReady('reading-p2', { + sessionId: 'session-reading-p2', + pageType: 'unified-reading' + }); + + assert.strictEqual(session.activeExamId, 'reading-p1', '迟到 SESSION_READY 不得覆写 activeExamId'); + assert.strictEqual(session.currentIndex, 0, '迟到 SESSION_READY 不得覆写 currentIndex'); + const staleCtx = staleWindow._messages.find(item => item && item.type === 'SIMULATION_CONTEXT'); + assert.strictEqual(staleCtx, undefined, '迟到 SESSION_READY 不应下发模拟上下文'); + } + + // Case 8.2: waitForSuiteWindowExamReady 不得把调用前的旧 ready 当成当前切题成功 + { + const app = createApp(windowStub); + const session = makeSession('suite_ready_timestamp_guard'); + const targetWindow = createStubWindow('ready-window'); + targetWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2'; + app.examWindows = new Map([ + ['reading-p2', { + examId: 'reading-p2', + window: targetWindow, + suiteSessionId: session.id, + pageType: 'unified-reading', + lastMessageType: 'SESSION_READY', + lastMessageAt: Date.now() - 5000 + }] + ]); + + const ready = await app._waitForSuiteWindowExamReady(session, 'reading-p2', targetWindow, 120); + assert.strictEqual(ready, false, '调用前的旧 SESSION_READY 不能被误判为当前窗口已就绪'); + } + + // Case 8.3: 复用窗口切题若未等到 fresh ready,不得提前把目标篇高亮推送到旧页 + { + const app = createApp(windowStub); + const session = makeSession('suite_reuse_window_highlight_guard'); + session.currentIndex = 0; + session.activeExamId = 'reading-p1'; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + + const reusedWindow = createStubWindow('suite-window'); + reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1'; + session.windowRef = reusedWindow; + app.openExam = async (examId, options = {}) => installManagedTestWindow( + app, + examId, + options.reuseWindow || reusedWindow, + options + ); + app._waitForSuiteWindowExamReady = async () => false; + + const ok = await app._handleSimulationNavigate('reading-p1', { + direction: 'next', + draft: { + answers: { q1: 'A' }, + highlights: [{ scope: 'left', text: 'P1 highlight before switch' }], + scrollY: 123, + updatedAt: Date.now() + }, + resultSnapshot: { + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + } + }, reusedWindow); + + assert.strictEqual(ok, true, 'ready 超时时切题流程仍应继续,由后续 SESSION_READY 兜底'); + assert.strictEqual(session.activeExamId, 'reading-p2', 'activeExamId 应先对齐到目标篇'); + assert.strictEqual( + reusedWindow._messages.some(message => message && message.type === 'SIMULATION_CONTEXT'), + false, + '未拿到 fresh ready 前不得向复用窗口提前发送 SIMULATION_CONTEXT,避免旧页误吃目标篇高亮' + ); + assert.deepStrictEqual( + session.draftsByExam['reading-p1'].highlights, + [{ scope: 'left', text: 'P1 highlight before switch' }], + '切题前当前篇高亮仍应保存在 draft 中' + ); + } + + // Case 8.4: 复用窗口若已落到目标篇 URL,即使 fresh ready 缺失也应兜底下发上下文 + { + const app = createApp(windowStub); + const session = makeSession('suite_reuse_window_target_url_fallback'); + session.currentIndex = 0; + session.activeExamId = 'reading-p1'; + const p2Highlights = [{ scope: 'left', text: 'P2 highlight after switch', color: 'green' }]; + session.draftsByExam['reading-p2'] = { answers: { q5: 'B' }, highlights: p2Highlights, scrollY: 66 }; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + + const reusedWindow = createStubWindow('suite-window'); + reusedWindow.location.href = 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p2'; + session.windowRef = reusedWindow; + app.examWindows = new Map([ + ['reading-p2', { window: reusedWindow }] + ]); + app.openExam = async (examId, options = {}) => installManagedTestWindow( + app, + examId, + options.reuseWindow || reusedWindow, + options + ); + app._waitForSuiteWindowExamReady = async () => false; + + const ok = await app._handleSimulationNavigate('reading-p1', { + direction: 'next', + draft: { + answers: { q1: 'A' }, + highlights: [{ scope: 'left', text: 'P1 highlight before switch' }], + scrollY: 123, + updatedAt: Date.now() + } + }, reusedWindow); + + assert.strictEqual(ok, true, '目标窗口 URL 已切到新篇时,切题流程应允许兜底恢复'); + const ctxMsg = reusedWindow._messages.find(message => message && message.type === 'SIMULATION_CONTEXT'); + assert(ctxMsg, '目标窗口 URL 已切到新篇时,应继续下发 SIMULATION_CONTEXT'); + assert.strictEqual(ctxMsg.data.examId, 'reading-p2', '兜底上下文必须指向目标篇'); + assert.deepStrictEqual(ctxMsg.data.draft.highlights, p2Highlights, '目标篇高亮应随兜底上下文一起恢复'); + } + + // Case 7: 错篇 PRACTICE_COMPLETE 必须被忽略,不能污染结果 + { + const app = createApp(windowStub); + const session = makeSession('suite_wrong_exam_complete'); + session.activeExamId = 'reading-p2'; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + + const handled = await app.handleSuitePracticeComplete('reading-p1', { + suiteSessionId: session.id, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, session.windowRef); + + assert.strictEqual(handled, true, '错篇提交应被处理为忽略'); + assert.strictEqual(session.results.length, 0, '错篇提交不得写入 session.results'); + } + + // Case 9: 听力桥的临时 examId/sessionId 不得阻断父应用当前题源落库 + { + const app = createApp(windowStub); + const examWindow = createStubWindow('custom-listening-window'); + const examId = 'custom-listening-teacher-pack'; + const expectedSessionId = 'custom-listening-teacher-pack_expected'; + let captured = null; + + app.handlePracticeComplete = async (handledExamId, data) => { + captured = { examId: handledExamId, data }; + }; + + app.setupExamWindowCommunication(examWindow, examId, { + id: examId, + title: 'Teacher Pack Listening', + type: 'listening' + }); + + const info = app.ensureExamWindowSession(examId, examWindow); + info.expectedSessionId = expectedSessionId; + app._refreshExamWindowToken(examId, info); + app.examWindows.set(examId, info); + + const handler = app.messageHandlers.get(examId); + assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler'); + + await handler({ + source: examWindow, + origin: 'http://localhost', + data: { + type: 'PRACTICE_COMPLETE', + source: 'listening_record_bridge', + data: { + source: 'listening_record_bridge', + examId: 'listening-unknown', + sessionId: 'listening-unknown_123', + submissionId: 'listening-submit-teacher-pack', + windowSessionToken: info.windowSessionToken, + practiceType: 'listening', + pageType: 'listening', + answers: { q1: 'acommodation' }, + correctAnswers: { q1: 'accommodation' }, + answerComparison: { + q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } + }, + scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' } + } + } + }); + + assert(captured, '听力桥 PRACTICE_COMPLETE 不应被临时 examId/sessionId 静默丢弃'); + assert.strictEqual(captured.examId, examId, '父应用应使用当前打开的题源 examId'); + assert.strictEqual(captured.data.examId, examId, 'payload examId 应被纠正为父应用题源'); + assert.strictEqual(captured.data.sessionId, expectedSessionId, 'payload sessionId 应被纠正为父应用会话'); + } + + // Case 10: 任意目录听力完成后必须进入 PracticeRecorder,并保存错词 + { + const app = createApp(windowStub); + const examId = 'custom-listening-arbitrary-folder'; + const savedCompletions = []; + const savedErrors = []; + let status = null; + + app.components.practiceRecorder = { + handleSessionCompleted: async (payload) => { + savedCompletions.push(payload); + const record = { + id: 'record-custom-listening', + examId, + sessionId: `${examId}_session`, + endTime: '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; + } + }; + app.updateExamStatus = (handledExamId, nextStatus) => { + status = { examId: handledExamId, status: nextStatus }; + }; + app.showRealCompletionNotification = () => {}; + app.cleanupExamSession = async () => {}; + app.setState = () => {}; + const completionWindow = createStubWindow('custom-listening-completion-window'); + const completionInfo = app.ensureExamWindowSession(examId, completionWindow); + completionInfo.expectedSessionId = `${examId}_session`; + app.examWindows.set(examId, completionInfo); + const completionRegistration = app._captureExamSessionRegistration(examId, completionInfo); + + const previousCollector = windowStub.spellingErrorCollector; + windowStub.spellingErrorCollector = { + detectSource: () => 'other', + detectErrors: () => [{ + word: 'accommodation', + userInput: 'acommodation', + questionId: 'q1', + suiteId: null, + examId, + timestamp: 1710000000000, + errorCount: 1, + source: 'other' + }], + saveErrors: async (errors) => { + savedErrors.push(...errors); + return true; + } + }; + + await app.handlePracticeComplete(examId, { + examId, + sessionId: `${examId}_session`, + practiceType: 'listening', + pageType: 'listening', + answers: { q1: 'acommodation' }, + correctAnswers: { q1: 'accommodation' }, + answerComparison: { + q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } + }, + scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' } + }, completionWindow, { expectedRegistration: completionRegistration }); + + windowStub.spellingErrorCollector = previousCollector; + + assert.strictEqual(savedCompletions.length, 1, '听力完成应调用 PracticeRecorder 落库'); + assert.strictEqual(savedCompletions[0].examId, examId, '落库 payload 应保留当前听力 examId'); + assert.strictEqual(savedErrors.length, 1, '任意目录听力错词应保存到词表链路'); + assert.strictEqual(savedErrors[0].word, 'accommodation', '错词应来自 answerComparison'); + assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态'); + } + + // Case 10b: 听力桥自带错词也必须归一到父页面当前题源,不能写入临时 listening-unknown + { + const app = createApp(windowStub); + const examId = 'listening-p1-normalized-errors'; + const savedErrors = []; + + app.components.practiceRecorder = { + handleSessionCompleted: async () => { + const record = { + id: 'record-normalized-errors', + examId, + sessionId: `${examId}_session`, + endTime: '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; + } + }; + app.updateExamStatus = () => {}; + app.showRealCompletionNotification = () => {}; + app.cleanupExamSession = async () => {}; + app.setState = () => {}; + const completionWindow = createStubWindow('normalized-listening-completion-window'); + const completionInfo = app.ensureExamWindowSession(examId, completionWindow); + completionInfo.expectedSessionId = `${examId}_session`; + app.examWindows.set(examId, completionInfo); + const completionRegistration = app._captureExamSessionRegistration(examId, completionInfo); + + const previousCollector = windowStub.spellingErrorCollector; + windowStub.spellingErrorCollector = { + detectSource: () => 'p1', + detectErrors: () => [], + saveErrors: async (errors) => { + savedErrors.push(...errors); + return true; + } + }; + + await app.handlePracticeComplete(examId, { + examId, + sessionId: `${examId}_session`, + practiceType: 'listening', + pageType: 'listening', + answers: { q1: 'acommodation' }, + correctAnswers: { q1: 'accommodation' }, + answerComparison: { + q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } + }, + scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' }, + spellingErrors: [{ + word: 'accommodation', + userInput: 'acommodation', + questionId: 'q1', + suiteId: null, + examId: 'listening-unknown', + timestamp: 1710000000000, + errorCount: 1, + source: 'other' + }] + }, completionWindow, { expectedRegistration: completionRegistration }); + + windowStub.spellingErrorCollector = previousCollector; + + assert.strictEqual(savedErrors.length, 1, '听力桥自带错词应继续保存'); + assert.strictEqual(savedErrors[0].examId, examId, '错词 examId 必须归一到父页面题源'); + assert.strictEqual(savedErrors[0].source, 'p1', 'P1 听力错词 source 必须归一,避免写到 other 词表'); + } + + // Case 11: 听力桥 bootstrap ready 不得提前结束父子握手 + { + const app = createApp(windowStub); + const examWindow = createStubWindow('custom-listening-handshake-window'); + const examId = 'custom-listening-handshake'; + const expectedSessionId = 'custom-listening-handshake_expected'; + + app.setupExamWindowCommunication(examWindow, examId, { + id: examId, + title: 'Handshake Listening', + type: 'listening' + }); + + const info = app.ensureExamWindowSession(examId, examWindow); + info.expectedSessionId = expectedSessionId; + app._refreshExamWindowToken(examId, info); + app.examWindows.set(examId, info); + examWindow._messages.length = 0; + + const timer = setInterval(() => {}, 10000); + app._handshakeTimers = new Map([[examId, timer]]); + + const handler = app.messageHandlers.get(examId); + assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler'); + + try { + await handler({ + source: examWindow, + origin: 'http://localhost', + data: { + type: 'SESSION_READY', + source: 'listening_record_bridge', + data: { + source: 'listening_record_bridge', + examId: 'listening-unknown', + sessionId: 'listening-unknown_123', + pageType: 'listening', + type: 'listening', + initialized: false + } + } + }); + + const preInitInfo = app.examWindows.get(examId); + assert.strictEqual(app._handshakeTimers.has(examId), true, 'pre-init ready 不得停止 INIT 重试'); + assert.strictEqual(preInitInfo.dataCollectorReady, undefined, 'pre-init ready 不得标记 collector ready'); + assert(examWindow._messages.some(message => message && message.type === 'INIT_SESSION'), 'pre-init ready 后应补发 INIT_SESSION'); + + await handler({ + source: examWindow, + origin: 'http://localhost', + data: { + type: 'SESSION_READY', + source: 'listening_record_bridge', + data: { + source: 'listening_record_bridge', + examId, + sessionId: expectedSessionId, + windowSessionToken: info.windowSessionToken, + pageType: 'listening', + type: 'listening', + initialized: true + } + } + }); + + assert.strictEqual(app._handshakeTimers.has(examId), false, 'initialized ready 才能停止 INIT 重试'); + assert.strictEqual(app.examWindows.get(examId).dataCollectorReady, true, 'initialized ready 应标记 collector ready'); + } finally { + clearInterval(timer); + } + } + + // Case 11.1: 占位页无 token 的 bootstrap ready 也只能触发 INIT,不能结束握手 + { + const app = createApp(windowStub); + const examWindow = createStubWindow('suite-placeholder-handshake-window'); + const examId = 'suite-placeholder-handshake'; + app.setupExamWindowCommunication(examWindow, examId, { id: examId, type: 'reading' }); + const info = app.ensureExamWindowSession(examId, examWindow); + info.expectedOrigin = 'null'; + info.allowOpaqueOrigin = true; + examWindow._messages.length = 0; + + const timer = setInterval(() => {}, 10000); + app._handshakeTimers = new Map([[examId, timer]]); + try { + await app.messageHandlers.get(examId)({ + source: examWindow, + origin: 'file://', + data: { + type: 'SESSION_READY', + source: 'suite_placeholder', + data: { + source: 'suite_placeholder', + examId, + sessionId: null, + windowSessionToken: null, + pageType: 'suite-placeholder' + } + } + }); + + assert.strictEqual(app._handshakeTimers.has(examId), true, '占位页 bootstrap ready 不得停止 INIT 重试'); + assert.strictEqual(app.examWindows.get(examId).dataCollectorReady, undefined, '无 token ready 不得标记 collector ready'); + assert(examWindow._messages.some(message => message && message.type === 'INIT_SESSION'), '无 token ready 后应立即补发 INIT_SESSION'); + } finally { + clearInterval(timer); + } + } + + // Case 12: 听力完成早于 initialized ready 时,也必须先补建 recorder session 再落库 + { + const app = createApp(windowStub); + const examWindow = createStubWindow('custom-listening-complete-first-window'); + const examId = 'custom-listening-complete-first'; + const expectedSessionId = 'custom-listening-complete-first_expected'; + const calls = []; + let status = null; + + app.components.practiceRecorder = { + activeSessions: new Map(), + startPracticeSession(handledExamId, examData) { + calls.push({ type: 'startPracticeSession', examId: handledExamId, examData }); + this.activeSessions.set(handledExamId, { + examId: handledExamId, + sessionId: `${handledExamId}_generated`, + metadata: {}, + progress: { totalQuestions: examData.totalQuestions || 0 }, + answers: {} + }); + return this.activeSessions.get(handledExamId); + }, + handleSessionStarted(payload) { + calls.push({ type: 'handleSessionStarted', payload }); + assert(this.activeSessions.has(payload.examId), '补建 session 必须先于 handleSessionStarted'); + const session = this.activeSessions.get(payload.examId); + session.sessionId = payload.sessionId; + session.metadata = { ...session.metadata, ...payload.metadata }; + this.activeSessions.set(payload.examId, session); + }, + async handleSessionCompleted(payload) { + calls.push({ type: 'handleSessionCompleted', payload }); + assert(this.activeSessions.has(payload.examId), '真实 recorder 没有 active session 会拒绝落库'); + const session = this.activeSessions.get(payload.examId); + assert.strictEqual(session.sessionId, payload.sessionId, '完成 payload 必须使用父页面 expectedSessionId'); + const record = { + id: `record_${payload.sessionId}`, + examId: payload.examId, + sessionId: payload.sessionId, + endTime: payload.endTime || '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; + } + }; + app.updateExamStatus = (handledExamId, nextStatus) => { + status = { examId: handledExamId, status: nextStatus }; + }; + app.showRealCompletionNotification = () => {}; + app.cleanupExamSession = async () => {}; + app.setState = () => {}; + + app.setupExamWindowCommunication(examWindow, examId, { + id: examId, + title: 'Complete First Listening', + type: 'listening' + }); + + const info = app.ensureExamWindowSession(examId, examWindow); + info.expectedSessionId = expectedSessionId; + app._refreshExamWindowToken(examId, info); + app.examWindows.set(examId, info); + + const handler = app.messageHandlers.get(examId); + assert.strictEqual(typeof handler, 'function', '听力题源应注册 message handler'); + + await handler({ + source: examWindow, + origin: 'http://localhost', + data: { + type: 'PRACTICE_COMPLETE', + source: 'listening_record_bridge', + data: { + source: 'listening_record_bridge', + examId: 'listening-unknown', + sessionId: 'listening-unknown_early', + submissionId: 'listening-submit-complete-first', + windowSessionToken: info.windowSessionToken, + practiceType: 'listening', + pageType: 'listening', + title: 'Complete First Listening', + answers: { q1: 'acommodation' }, + correctAnswers: { q1: 'accommodation' }, + answerComparison: { + q1: { userAnswer: 'acommodation', correctAnswer: 'accommodation', isCorrect: false } + }, + scoreInfo: { correct: 0, total: 1, accuracy: 0, percentage: 0, source: 'listening_record_bridge' } + } + } + }); + + assert.deepStrictEqual( + calls.map(call => call.type), + ['startPracticeSession', 'handleSessionStarted', 'handleSessionCompleted'], + 'complete-before-ready 必须先建会话、再同步 sessionId、最后落库' + ); + assert.strictEqual(calls[2].payload.examId, examId, '完成 payload examId 应被纠正为父页面当前题源'); + assert.strictEqual(calls[2].payload.sessionId, expectedSessionId, '完成 payload sessionId 应被纠正为父页面会话'); + assert.deepStrictEqual(status, { examId, status: 'completed' }, '完成后应更新题源状态'); + } + + // Case 13: 统一阅读提交后 reset 必须复用父页通信链路并重建 recorder session + { + const app = createApp(windowStub); + const examWindow = createStubWindow('unified-reading-retake-window'); + const examId = 'reading-retake-unified'; + const firstSessionId = 'reading-retake-first-session'; + const resetSessionId = 'reading-retake-reset-session'; + const completions = []; + const recorderStarts = []; + const resetStarts = []; + const statuses = []; let cleanupCount = 0; let restartCount = 0; - app.generateSessionId = () => resetSessionId; + app.generateSessionId = () => resetSessionId; + app.components.practiceRecorder = { + activeSessions: new Map(), + async handleSessionCompleted(payload) { + completions.push(payload); + this.activeSessions.delete(payload.examId); + const record = { + id: `record_${payload.sessionId}`, + examId: payload.examId, + sessionId: payload.sessionId, + endTime: payload.endTime || '2026-07-26T00:00:00.000Z' + }; + return (await windowStub.AppData.practice.completeAttempt({ record })).record; + }, + handleSessionStarted(payload) { + recorderStarts.push(payload); + const session = this.activeSessions.get(payload.examId) || { + examId: payload.examId, + metadata: {}, + progress: {}, + answers: {} + }; + session.sessionId = payload.sessionId; + session.metadata = { ...session.metadata, ...payload.metadata }; + this.activeSessions.set(payload.examId, session); + } + }; + app.startPracticeSession = async (handledExamId, startOptions = {}) => { + resetStarts.push(handledExamId); + app.components.practiceRecorder.activeSessions.set(handledExamId, { + examId: handledExamId, + sessionId: 'temporary-reset-session', + metadata: {}, + progress: {}, + answers: {} + }); + return buildOwnedStartResult( + app, + handledExamId, + true, + startOptions.launchOwnership + ); + }; + app.cleanupExamSession = async () => { + cleanupCount += 1; + }; + app.restartExamHandshake = (targetWindow, handledExamId) => { + restartCount += 1; + assert.strictEqual(targetWindow, examWindow, 'reset 应复用当前统一阅读窗口'); + assert.strictEqual(handledExamId, examId, 'reset 握手应使用当前题源'); + }; + app.updateExamStatus = (handledExamId, status) => { + statuses.push({ examId: handledExamId, status }); + }; + app.showRealCompletionNotification = () => {}; + app.setState = () => {}; + + app.setupExamWindowCommunication(examWindow, examId, { + id: examId, + title: 'Unified Retake Reading', + type: 'reading' + }); + + const info = app.ensureExamWindowSession(examId, examWindow); + info.expectedSessionId = firstSessionId; + app._refreshExamWindowToken(examId, info); + app.examWindows.set(examId, info); + const initialGeneration = info.sessionGeneration; + + const handler = app.messageHandlers.get(examId); + assert.strictEqual(typeof handler, 'function', '统一阅读题源应注册 message handler'); + + await handler({ + source: examWindow, + origin: 'http://localhost', + data: { + type: 'PRACTICE_COMPLETE', + source: 'practice_page', + data: { + examId, + sessionId: firstSessionId, + submissionId: 'reading-submit-first-session', + windowSessionToken: info.windowSessionToken, + answers: { q1: 'A' }, + answerComparison: { + q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } + }, + scoreInfo: { correct: 1, total: 1, totalQuestions: 1, accuracy: 1, percentage: 100 }, + metadata: { + type: 'reading', + examType: 'reading', + practiceMode: 'single', + renderMode: 'unified-reading' + } + } + } + }); + + assert.strictEqual(cleanupCount, 0, '统一阅读提交后不得清掉父页消息 handler'); + assert.strictEqual(app.messageHandlers.has(examId), true, '统一阅读完成后应保留 message handler 等待 reset'); + assert.strictEqual(app.examWindows.get(examId).status, 'completed', '统一阅读完成后应标记窗口完成态'); + assert.strictEqual(completions.length, 1, '统一阅读完成应正常进入 recorder'); + + examWindow._messages.length = 0; + recorderStarts.length = 0; + await handler({ + source: examWindow, + origin: 'http://localhost', + data: { + type: 'PRACTICE_RESET_REQUEST', + source: 'practice_page', + data: { + examId, + sessionId: firstSessionId, + windowSessionToken: info.windowSessionToken, + reason: 'retake-after-submit', + fromPracticeMode: 'single', + targetPracticeMode: 'single', + normalUrl: 'file:///reading-practice-unified.html?examId=reading-retake-unified' + } + } + }); + + const resetInfo = app.examWindows.get(examId); + assert.strictEqual(resetInfo.expectedSessionId, resetSessionId, 'reset 必须生成新的 expectedSessionId'); + assert(resetInfo.sessionGeneration > initialGeneration, 'reset 旋转 token 时必须推进 generation'); + assert.strictEqual(resetInfo.status, 'active', 'reset 后窗口应回到 active'); + assert.deepStrictEqual(resetStarts, [examId], 'reset 后必须补建练习会话'); + assert.strictEqual(recorderStarts.length, 1, 'reset 后必须同步 recorder sessionId'); + assert.strictEqual(recorderStarts[0].sessionId, resetSessionId, 'recorder sessionId 必须使用 reset 后的新 session'); + assert.strictEqual( + app.components.practiceRecorder.activeSessions.get(examId).sessionId, + resetSessionId, + 'reset 后 active recorder session 必须可被下一次提交使用' + ); + assert( + examWindow._messages.some(message => message && message.type === 'INIT_SESSION' && message.data && message.data.sessionId === resetSessionId), + 'reset 后必须向子页发送新的 INIT_SESSION' + ); + assert.strictEqual(restartCount, 1, 'reset 后必须重启握手'); + assert(statuses.some(item => item.examId === examId && item.status === 'in-progress'), 'reset 后题源状态应回到 in-progress'); + } + + // Finalization must freeze teardown ownership before its first durable await. + { + const app = createApp(windowStub); + const session = makeSession('suite_finalize_freezes_owner'); + const examId = session.activeExamId; + const suiteWindow = session.windowRef; + suiteWindow.close = function close() { this.closed = true; }; + session._suiteGeneration = 1; + session.results = session.sequence.map((entry, index) => ({ + examId: entry.examId, + title: entry.exam.title, + category: entry.exam.category, + duration: 10, + answers: { q1: String.fromCharCode(65 + index) }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + rawData: {} + })); + const suiteInfo = { + window: suiteWindow, + suiteSessionId: session.id, + expectedSessionId: 'suite-finalize-owner-attempt', + windowSessionToken: 'suite-finalize-owner-token', + windowSessionTokenSessionId: 'suite-finalize-owner-attempt', + sessionGeneration: 7 + }; + session.windowBinding = { + examId, + expectedSessionId: suiteInfo.expectedSessionId, + windowSessionToken: suiteInfo.windowSessionToken, + sessionGeneration: suiteInfo.sessionGeneration + }; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, suiteInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + + let markFinalizeWriteEntered; + let releaseFinalizeWrite; + const finalizeWriteEntered = new Promise((resolve) => { markFinalizeWriteEntered = resolve; }); + const finalizeWriteGate = new Promise((resolve) => { releaseFinalizeWrite = resolve; }); + let recoveryWrites = 0; + app._commitSuiteRecovery = async () => { + recoveryWrites += 1; + if (recoveryWrites === 1) { + markFinalizeWriteEntered(); + await finalizeWriteGate; + } + return true; + }; + app._saveSuitePracticeRecord = async () => {}; + app._updatePracticeRecordsState = async () => {}; + app._discardPersistentSuiteRecovery = async () => true; + + const finalizing = app.finalizeSuiteRecord(session, { deferTeardown: true }); + await finalizeWriteEntered; + const frozenRegistrations = session._suiteTeardownRegistrations; + assert(frozenRegistrations && typeof frozenRegistrations.get === 'function'); + assert.strictEqual(frozenRegistrations.get(examId).windowInfo, suiteInfo); + + const normalWindow = createStubWindow('normal-after-finalize-start'); + normalWindow.close = function close() { this.closed = true; }; + const normalInfo = { + window: normalWindow, + suiteSessionId: null, + expectedSessionId: 'normal-after-finalize-attempt', + windowSessionToken: 'normal-after-finalize-token', + windowSessionTokenSessionId: 'normal-after-finalize-attempt', + sessionGeneration: 8 + }; + // Simulate a late binding mutation while the first finalize write is in flight. + session.windowRef = normalWindow; + session.windowBinding = { + examId, + expectedSessionId: normalInfo.expectedSessionId, + windowSessionToken: normalInfo.windowSessionToken, + sessionGeneration: normalInfo.sessionGeneration + }; + app.examWindows.set(examId, normalInfo); + const normalHandler = () => {}; + app.messageHandlers.set(examId, normalHandler); + releaseFinalizeWrite(); + + assert.strictEqual(await finalizing, true); + assert.strictEqual(session._suiteTeardownRegistrations, frozenRegistrations); + let scheduledTeardown = null; + const originalSetTimeout = sandbox.setTimeout; + const originalClearTimeout = sandbox.clearTimeout; + sandbox.setTimeout = (callback) => { + scheduledTeardown = callback; + return { unref() {} }; + }; + sandbox.clearTimeout = () => {}; + try { + assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true); + assert.strictEqual(session._suiteTeardownRegistrations, frozenRegistrations, 'delayed scheduling must reuse the pre-finalize snapshot'); + await scheduledTeardown(); + } finally { + sandbox.setTimeout = originalSetTimeout; + sandbox.clearTimeout = originalClearTimeout; + } + assert.strictEqual(suiteWindow.closed, true, 'teardown must close the originally frozen suite window'); + assert.strictEqual(normalWindow.closed, false, 'teardown must not close a late normal replacement'); + assert.strictEqual(app.examWindows.get(examId), normalInfo); + assert.strictEqual(app.messageHandlers.get(examId), normalHandler); + } + + // Reusing a suite WindowProxy must invalidate its old owner before openExam's first await. + { + const app = createApp(windowStub); + const session = makeSession('suite_reuse_navigation_gap'); + const examId = session.activeExamId; + const sharedWindow = session.windowRef; + sharedWindow.close = function close() { this.closed = true; }; + session.status = 'completed'; + session._suiteGeneration = 1; + const suiteInfo = { + window: sharedWindow, + suiteSessionId: session.id, + expectedSessionId: 'suite-reuse-gap-attempt', + windowSessionToken: 'suite-reuse-gap-token', + windowSessionTokenSessionId: 'suite-reuse-gap-attempt', + expectedUrl: 'http://localhost/suite-old.html', + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + sessionGeneration: 5 + }; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, suiteInfo]]); + app.setupExamWindowCommunication(sharedWindow, examId, { + id: examId, + title: 'Suite reuse gap', + type: 'reading' + }); + const oldMessageHandler = app.messageHandlers.get(examId); + const oldHandshakeTimer = setInterval(() => {}, 60000); + app._handshakeTimers = new Map([[examId, oldHandshakeTimer]]); + session.windowBinding = { + examId, + expectedSessionId: suiteInfo.expectedSessionId, + windowSessionToken: suiteInfo.windowSessionToken, + sessionGeneration: suiteInfo.sessionGeneration + }; + app._ensureSuiteWindowGuard(session, sharedWindow); + await windowStub.AppData.recovery.saveActiveSession({ id: session.id, status: 'completed' }); + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${suiteInfo.expectedSessionId}`, + examId, + sessionId: suiteInfo.expectedSessionId, + status: 'started' + }); + + let scheduledTeardown = null; + let markReuseCleanupEntered; + let releaseReuseCleanup; + const reuseCleanupEntered = new Promise((resolve) => { markReuseCleanupEntered = resolve; }); + const reuseCleanupGate = new Promise((resolve) => { releaseReuseCleanup = resolve; }); + const originalSetTimeout = sandbox.setTimeout; + const originalClearTimeout = sandbox.clearTimeout; + const originalCleanupReused = app._cleanupReusedWindowSessions; + const originalCaptureLibrary = app._captureLaunchLibraryConfigurationId; + const originalStartPractice = app.startPracticeSession; + const originalInject = app.injectDataCollectionScript; + const originalGuard = app._guardExamWindowContent; + const originalResolveReading = app.resolveReadingLaunchDescriptor; + sandbox.setTimeout = (callback) => { + if (!scheduledTeardown) scheduledTeardown = callback; + return { unref() {} }; + }; + sandbox.clearTimeout = () => {}; + app._cleanupReusedWindowSessions = async () => { + markReuseCleanupEntered(); + await reuseCleanupGate; + return []; + }; + app._captureLaunchLibraryConfigurationId = async () => null; + app.startPracticeSession = async (handledExamId, startOptions = {}) => buildOwnedStartResult( + app, + handledExamId, + true, + startOptions.launchOwnership + ); + app.injectDataCollectionScript = () => {}; + app._guardExamWindowContent = (targetWindow) => targetWindow; + app.resolveReadingLaunchDescriptor = () => ({ + mode: 'unified_html', + url: 'http://localhost/normal-reuse-gap.html' + }); + + let normalInfo = null; + try { + assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true); + const opening = app.openExam(examId, { + examDefinition: { id: examId, title: 'Normal reuse gap', type: 'reading', hasHtml: true }, + target: 'tab', + reuseWindow: sharedWindow, + practiceMode: 'single' + }); + await reuseCleanupEntered; + const pendingInfo = app.examWindows.get(examId); + assert.notStrictEqual(pendingInfo, suiteInfo, 'navigation must synchronously replace the old registration'); + assert.strictEqual(pendingInfo.window, sharedWindow); + assert.strictEqual(pendingInfo.suiteSessionId, null); + assert(pendingInfo.sessionGeneration > suiteInfo.sessionGeneration); + assert.strictEqual(pendingInfo.handshakeDeferred, true); + assert.strictEqual(app.messageHandlers.has(examId), false, 'the navigated-away document handler must be detached synchronously'); + assert.strictEqual(app._handshakeTimers.has(examId), false, 'the old handshake retry must be stopped synchronously'); + const messageCountBeforePendingRequest = sharedWindow._messages.length; + await oldMessageHandler({ + source: sharedWindow, + origin: 'http://localhost', + data: { type: 'REQUEST_INIT', source: 'practice_page', data: { examId } } + }); + assert.strictEqual( + sharedWindow._messages.length, + messageCountBeforePendingRequest, + 'a detached handler must fail closed while reassignment is pending' + ); + + await scheduledTeardown(); + assert.strictEqual(sharedWindow.closed, false, 'teardown must not close the already navigated normal page'); + assert.strictEqual( + sharedWindow._messages.some(message => message && message.type === 'SUITE_FORCE_CLOSE'), + false, + 'the pending normal reuse must not receive a suite force-close envelope' + ); + + releaseReuseCleanup(); + assert.strictEqual(await opening, sharedWindow); + normalInfo = app.examWindows.get(examId); + assert.strictEqual(normalInfo.window, sharedWindow); + assert.strictEqual(normalInfo.suiteSessionId, null); + assert.strictEqual(normalInfo.status, 'active'); + } finally { + releaseReuseCleanup && releaseReuseCleanup(); + sandbox.setTimeout = originalSetTimeout; + sandbox.clearTimeout = originalClearTimeout; + app._cleanupReusedWindowSessions = originalCleanupReused; + app._captureLaunchLibraryConfigurationId = originalCaptureLibrary; + app.startPracticeSession = originalStartPractice; + app.injectDataCollectionScript = originalInject; + app._guardExamWindowContent = originalGuard; + app.resolveReadingLaunchDescriptor = originalResolveReading; + } + + const activeAfterTeardown = await windowStub.AppData.recovery.listActiveSessions(); + assert.strictEqual(app.currentSuiteSession, null); + assert.strictEqual( + activeAfterTeardown.some(item => item && item.sessionId === suiteInfo.expectedSessionId), + false, + 'the old suite attempt recovery must still be removed during the navigation gap' + ); + if (normalInfo && normalInfo.closeMonitor) clearInterval(normalInfo.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + app.examWindows.delete(examId); + app.messageHandlers.delete(examId); + } + + // A raw PDF reuse has no later managed handshake; it must fully detach the old owner. + { + const app = createApp(windowStub); + const session = makeSession('suite_pdf_reuse_gap'); + const examId = session.activeExamId; + const sharedWindow = session.windowRef; + sharedWindow.close = function close() { this.closed = true; }; + session.status = 'completed'; + session._suiteGeneration = 1; + const closeMonitorToken = setInterval(() => {}, 60000); + const handshakeTimerToken = setInterval(() => {}, 60000); + const suiteInfo = { + window: sharedWindow, + suiteSessionId: session.id, + expectedSessionId: 'suite-pdf-owner-attempt', + windowSessionToken: 'suite-pdf-owner-token', + windowSessionTokenSessionId: 'suite-pdf-owner-attempt', + sessionGeneration: 3, + closeMonitor: closeMonitorToken + }; + session.windowBinding = { + examId, + expectedSessionId: suiteInfo.expectedSessionId, + windowSessionToken: suiteInfo.windowSessionToken, + sessionGeneration: suiteInfo.sessionGeneration + }; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, suiteInfo]]); + const listenerCountBeforeHandler = windowStub.__listenerCount('message'); + app.setupExamWindowCommunication(sharedWindow, examId, { + id: examId, + title: 'Suite PDF reuse', + type: 'reading' + }); + assert.strictEqual(windowStub.__listenerCount('message'), listenerCountBeforeHandler + 1); + app._handshakeTimers = new Map([[examId, handshakeTimerToken]]); + app._discardPersistentSuiteRecovery = async () => true; + + let scheduledTeardown = null; + const originalSetTimeout = sandbox.setTimeout; + const originalClearTimeout = sandbox.clearTimeout; + const originalClearInterval = sandbox.clearInterval; + const clearedIntervals = new Set(); + sandbox.setTimeout = (callback) => { + scheduledTeardown = callback; + return { unref() {} }; + }; + sandbox.clearTimeout = () => {}; + sandbox.clearInterval = (timer) => { + clearedIntervals.add(timer); + originalClearInterval(timer); + }; + try { + assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true); + assert.strictEqual( + app._openPdfWindow({ id: examId, title: 'Normal PDF' }, 'http://localhost/normal.pdf', { + reuseWindow: sharedWindow, + target: 'tab' + }), + sharedWindow + ); + assert.strictEqual(sharedWindow.location.href, 'http://localhost/normal.pdf'); + assert.strictEqual(app.examWindows.has(examId), false, 'unmanaged PDF reuse must not leave a provisional registration'); + assert.strictEqual(app.messageHandlers.has(examId), false); + assert.strictEqual(app._handshakeTimers.has(examId), false); + assert.strictEqual(windowStub.__listenerCount('message'), listenerCountBeforeHandler, 'PDF reuse must detach the real old message listener'); + assert(clearedIntervals.has(closeMonitorToken), 'PDF reuse must stop the old close monitor'); + assert(clearedIntervals.has(handshakeTimerToken), 'PDF reuse must stop the old handshake timer'); + + // A later managed launch may occupy the same exam id on another window. + // The invalidated PDF WindowProxy must remain remembered independently + // of the current examWindows entry until the frozen teardown completes. + const normalWindow = createStubWindow('normal-after-pdf-reuse'); + normalWindow.close = function close() { this.closed = true; }; + const normalInfo = { + window: normalWindow, + suiteSessionId: null, + expectedSessionId: 'normal-after-pdf-attempt', + windowSessionToken: 'normal-after-pdf-token', + windowSessionTokenSessionId: 'normal-after-pdf-attempt', + sessionGeneration: 4 + }; + const normalHandler = () => {}; + app.examWindows.set(examId, normalInfo); + app.messageHandlers.set(examId, normalHandler); + + await scheduledTeardown(); + assert.strictEqual(sharedWindow.closed, false, 'delayed teardown must not close the already navigated PDF'); + assert.strictEqual(normalWindow.closed, false); + assert.strictEqual(app.examWindows.get(examId), normalInfo); + assert.strictEqual(app.messageHandlers.get(examId), normalHandler); + assert.strictEqual( + sharedWindow._messages.some(message => message && message.type === 'SUITE_FORCE_CLOSE'), + false, + 'the PDF replacement must not receive a suite force-close envelope' + ); + assert.strictEqual(app.currentSuiteSession, null); + } finally { + sandbox.setTimeout = originalSetTimeout; + sandbox.clearTimeout = originalClearTimeout; + sandbox.clearInterval = originalClearInterval; + if (suiteInfo.closeMonitor) clearInterval(suiteInfo.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + } + } + + // Delayed completed-suite teardown only owns its exact registration and recovery. + { + const app = createApp(windowStub); + const session = makeSession('suite_delayed_exact_owner'); + const examId = session.activeExamId; + const sharedWindow = session.windowRef; + sharedWindow.close = function close() { this.closed = true; }; + session.status = 'completed'; + session._suiteGeneration = 1; + const suiteInfo = { + window: sharedWindow, + suiteSessionId: session.id, + expectedSessionId: 'suite-owned-attempt', + windowSessionToken: 'suite-owned-token', + sessionGeneration: 4 + }; + const suiteHandler = () => {}; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, suiteInfo]]); + app.messageHandlers = new Map([[examId, suiteHandler]]); + session.windowBinding = { + examId, + expectedSessionId: suiteInfo.expectedSessionId, + windowSessionToken: suiteInfo.windowSessionToken, + sessionGeneration: suiteInfo.sessionGeneration + }; + app._ensureSuiteWindowGuard(session, sharedWindow); + assert.strictEqual(sharedWindow.__IELTS_SUITE_PARENT_GUARD__.sessionId, session.id); + assert.strictEqual(await app._ensureSuiteRecoveryClaim('single', session), true); + app._mirrorSessionToStorage(session); + await windowStub.AppData.recovery.saveActiveSession({ id: session.id, status: 'completed' }); + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${suiteInfo.expectedSessionId}`, + examId, + sessionId: suiteInfo.expectedSessionId, + status: 'started' + }); + + let scheduledTeardown = null; + const originalSetTimeout = sandbox.setTimeout; + const originalClearTimeout = sandbox.clearTimeout; + sandbox.setTimeout = (callback) => { + scheduledTeardown = callback; + return { unref() {} }; + }; + sandbox.clearTimeout = () => {}; + + const normalInfo = { + window: sharedWindow, + // A completed suite may still cause normal initialization to inherit this tag. + suiteSessionId: session.id, + expectedSessionId: 'normal-reused-attempt', + windowSessionToken: 'normal-reused-token', + sessionGeneration: suiteInfo.sessionGeneration + 1 + }; + const lateWindowRef = createStubWindow('late-normal-window-ref'); + lateWindowRef.close = function close() { this.closed = true; }; + const normalHandler = () => {}; + try { + assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true); + assert.strictEqual(typeof scheduledTeardown, 'function'); + + // The normal practice reuses the same exam id and WindowProxy before the delay expires. + app.examWindows.set(examId, normalInfo); + app.messageHandlers.set(examId, normalHandler); + // Even if a late protocol message mutates the live binding, the scheduled + // teardown must retain the completed suite's pre-delay ownership snapshot. + session.windowBinding = { + examId, + expectedSessionId: normalInfo.expectedSessionId, + windowSessionToken: normalInfo.windowSessionToken, + sessionGeneration: normalInfo.sessionGeneration + }; + session.windowRef = lateWindowRef; + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${normalInfo.expectedSessionId}`, + examId, + sessionId: normalInfo.expectedSessionId, + status: 'started' + }); + + await scheduledTeardown(); + } finally { + sandbox.setTimeout = originalSetTimeout; + sandbox.clearTimeout = originalClearTimeout; + } + + const activeAfterTeardown = await windowStub.AppData.recovery.listActiveSessions(); + assert.strictEqual(app.currentSuiteSession, null, 'completed suite teardown should still finish'); + assert.strictEqual(app.suiteExamMap.has(examId), false, 'completed suite routing should be cleared'); + assert.strictEqual(windowSessionStore.has('simulation'), false, 'completed suite snapshot should be cleared'); + assert.strictEqual(sharedWindow.closed, false, 'reused normal-practice window must stay open'); + assert.strictEqual(lateWindowRef.closed, false, 'live windowRef changes must not redirect frozen teardown at another window'); + assert.strictEqual( + sharedWindow.__IELTS_SUITE_PARENT_GUARD__, + undefined, + 'the completed suite guard should be released from the reused window' + ); + assert.strictEqual( + sharedWindow._messages.some((message) => message && message.type === 'SUITE_FORCE_CLOSE'), + false, + 'reused normal-practice window must not receive SUITE_FORCE_CLOSE' + ); + assert.strictEqual(app.examWindows.get(examId), normalInfo, 'new registration must survive stale teardown'); + assert.strictEqual(app.messageHandlers.get(examId), normalHandler, 'new message handler must survive stale teardown'); + assert.strictEqual( + activeAfterTeardown.some((item) => item && item.sessionId === suiteInfo.expectedSessionId), + false, + 'the completed suite attempt recovery should be removed by exact session id' + ); + assert.strictEqual( + activeAfterTeardown.some((item) => item && item.sessionId === normalInfo.expectedSessionId), + true, + 'the reused normal-practice recovery must survive stale teardown' + ); + + app.examWindows.delete(examId); + app.messageHandlers.delete(examId); + await windowStub.AppData.recovery.discardActiveSession(`active-session:${normalInfo.expectedSessionId}`); + } + + // A different-window normal registration must survive while the frozen suite window closes. + { + const app = createApp(windowStub); + const session = makeSession('suite_delayed_distinct_window'); + const examId = session.activeExamId; + const suiteWindow = session.windowRef; + suiteWindow.close = function close() { this.closed = true; }; + session.status = 'completed'; + session._suiteGeneration = 1; + const suiteInfo = { + window: suiteWindow, + suiteSessionId: session.id, + expectedSessionId: 'suite-distinct-attempt', + windowSessionToken: 'suite-distinct-token', + windowSessionTokenSessionId: 'suite-distinct-attempt', + sessionGeneration: 7 + }; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[examId, suiteInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + session.windowBinding = { + examId, + expectedSessionId: suiteInfo.expectedSessionId, + windowSessionToken: suiteInfo.windowSessionToken, + sessionGeneration: suiteInfo.sessionGeneration + }; + app._ensureSuiteWindowGuard(session, suiteWindow); + await windowStub.AppData.recovery.saveActiveSession({ id: session.id, status: 'completed' }); + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${suiteInfo.expectedSessionId}`, + examId, + sessionId: suiteInfo.expectedSessionId, + status: 'started' + }); + + let scheduledTeardown = null; + const originalSetTimeout = sandbox.setTimeout; + const originalClearTimeout = sandbox.clearTimeout; + sandbox.setTimeout = (callback) => { + scheduledTeardown = callback; + return { unref() {} }; + }; + sandbox.clearTimeout = () => {}; + + const normalWindow = createStubWindow('distinct-normal-window'); + normalWindow.close = function close() { this.closed = true; }; + const normalInfo = { + window: normalWindow, + suiteSessionId: null, + expectedSessionId: 'normal-distinct-attempt', + windowSessionToken: 'normal-distinct-token', + windowSessionTokenSessionId: 'normal-distinct-attempt', + sessionGeneration: suiteInfo.sessionGeneration + 1 + }; + const normalHandler = () => {}; + try { + assert.strictEqual(app._scheduleSuiteSubmitTeardown(session), true); + app.examWindows.set(examId, normalInfo); + app.messageHandlers.set(examId, normalHandler); + session.windowRef = normalWindow; + session.windowBinding = { + examId, + expectedSessionId: normalInfo.expectedSessionId, + windowSessionToken: normalInfo.windowSessionToken, + sessionGeneration: normalInfo.sessionGeneration + }; + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${normalInfo.expectedSessionId}`, + examId, + sessionId: normalInfo.expectedSessionId, + status: 'started' + }); + await scheduledTeardown(); + } finally { + sandbox.setTimeout = originalSetTimeout; + sandbox.clearTimeout = originalClearTimeout; + } + + const activeAfterTeardown = await windowStub.AppData.recovery.listActiveSessions(); + assert.strictEqual(suiteWindow.closed, true, 'the frozen suite window must be closed'); + assert.strictEqual(normalWindow.closed, false, 'the distinct normal window must stay open'); + assert.strictEqual( + suiteWindow._messages.some(message => message && message.type === 'SUITE_FORCE_CLOSE'), + false, + 'teardown must not route a force-close envelope through the replacement registration' + ); + assert.strictEqual(app.examWindows.get(examId), normalInfo, 'the distinct normal registration must survive'); + assert.strictEqual(normalInfo.window, normalWindow, 'teardown must not rebind the normal registration to the old suite window'); + assert.strictEqual(normalInfo.windowSessionToken, 'normal-distinct-token', 'teardown must not rotate the normal token'); + assert.strictEqual(app.messageHandlers.get(examId), normalHandler, 'the distinct normal handler must survive'); + assert.strictEqual( + activeAfterTeardown.some(item => item && item.sessionId === suiteInfo.expectedSessionId), + false, + 'the old suite attempt recovery must be removed' + ); + assert.strictEqual( + activeAfterTeardown.some(item => item && item.sessionId === normalInfo.expectedSessionId), + true, + 'the distinct normal recovery must survive' + ); + + app.examWindows.delete(examId); + app.messageHandlers.delete(examId); + await windowStub.AppData.recovery.discardActiveSession(`active-session:${normalInfo.expectedSessionId}`); + } + + // A failed active-suite abort must recapture ownership after navigation. + { + const app = createApp(windowStub); + const session = makeSession('suite_abort_recaptures_registration'); + const firstExamId = session.sequence[0].examId; + const firstWindow = session.windowRef; + firstWindow.close = function close() { this.closed = true; }; + const firstInfo = { + window: firstWindow, + suiteSessionId: session.id, + expectedSessionId: 'abort-first-attempt', + windowSessionToken: 'abort-first-token', + windowSessionTokenSessionId: 'abort-first-attempt', + sessionGeneration: 1 + }; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + app.examWindows = new Map([[firstExamId, firstInfo]]); + app.messageHandlers = new Map([[firstExamId, () => {}]]); + session.windowBinding = { + examId: firstExamId, + expectedSessionId: firstInfo.expectedSessionId, + windowSessionToken: firstInfo.windowSessionToken, + sessionGeneration: firstInfo.sessionGeneration + }; + + let discardAttempts = 0; + app._discardPersistentSuiteRecovery = async () => { + discardAttempts += 1; + return discardAttempts > 1; + }; + + assert.strictEqual( + await app._abortSuiteSession(session, { reason: 'user_discard' }), + false, + 'the first abort should surface the durable discard failure' + ); + assert.strictEqual(session.status, 'active', 'a failed abort must leave the suite active'); + assert.strictEqual(app.currentSuiteSession, session, 'a failed abort must retain the active owner'); + assert.strictEqual( + session._suiteTeardownRegistrations, + undefined, + 'an active abort failure must discard its frozen teardown registration' + ); + assert.strictEqual(firstWindow.closed, false, 'a failed abort must not close the current window'); + + const secondExamId = session.sequence[1].examId; + const secondWindow = createStubWindow('suite-window-after-abort-retry'); + secondWindow.close = function close() { this.closed = true; }; + const secondInfo = { + window: secondWindow, + suiteSessionId: session.id, + expectedSessionId: 'abort-second-attempt', + windowSessionToken: 'abort-second-token', + windowSessionTokenSessionId: 'abort-second-attempt', + sessionGeneration: 2 + }; + session.currentIndex = 1; + session.activeExamId = secondExamId; + session.windowRef = secondWindow; + session.windowBinding = { + examId: secondExamId, + expectedSessionId: secondInfo.expectedSessionId, + windowSessionToken: secondInfo.windowSessionToken, + sessionGeneration: secondInfo.sessionGeneration + }; + app.examWindows.delete(firstExamId); + app.messageHandlers.delete(firstExamId); + app.examWindows.set(secondExamId, secondInfo); + app.messageHandlers.set(secondExamId, () => {}); + + const recaptured = app._captureSuiteTeardownRegistrations(session); + assert.strictEqual( + recaptured.get(secondExamId).windowInfo, + secondInfo, + 'the retry precondition must expose the newly bound exact registration' + ); + const cleanupResults = []; + const cleanupExamSession = app.cleanupExamSession.bind(app); + app.cleanupExamSession = async (...args) => { + const result = await cleanupExamSession(...args); + cleanupResults.push({ examId: args[0], result }); + return result; + }; + + assert.strictEqual( + await app._abortSuiteSession(session, { reason: 'user_discard' }), + true, + 'the retry should teardown the registration captured after navigation' + ); + assert.strictEqual(discardAttempts, 2, 'the retry must attempt durable discard again'); + assert.strictEqual(secondWindow.closed, true, 'the retry must close the newly bound suite window'); + assert.strictEqual(firstWindow.closed, false, 'the retry must not reuse and close the stale window snapshot'); + assert.deepStrictEqual( + cleanupResults, + [{ examId: secondExamId, result: true }], + 'the retry must run exact cleanup for the newly captured registration' + ); + assert.strictEqual(app.currentSuiteSession, null, 'the successful retry must finish teardown'); + assert.strictEqual(app.examWindows.has(secondExamId), false, 'the newly captured registration must be cleaned'); + } + + // A failed primary recovery save must retry with the frozen registration id. + { + const app = createApp(windowStub); + const examId = 'reading-fallback-session-alignment'; + const ownerSessionId = 'fallback-owner-session'; + const unrelatedSessionId = 'fallback-unrelated-session'; + const examWindow = createStubWindow('fallback-session-alignment-window'); + const windowInfo = { + window: examWindow, + expectedSessionId: ownerSessionId, + windowSessionToken: 'fallback-session-token', + windowSessionTokenSessionId: ownerSessionId, + sessionGeneration: 1, + suiteSessionId: null + }; + const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex; + const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open'); + const originalOpen = windowStub.open; + const fallbackOpenCalls = []; + let generatedSessionIdCount = 0; + + app.examWindows = new Map([[examId, windowInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + app.generateSessionId = () => { + generatedSessionIdCount += 1; + return 'unexpected-generated-fallback-session'; + }; + windowStub.resolveActiveLibraryIndex = async () => [{ + id: examId, + title: 'Fallback Session Alignment', + type: 'reading' + }]; + windowStub.open = (url, name) => { + fallbackOpenCalls.push({ url, name }); + return createStubWindow(name); + }; + + try { + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${unrelatedSessionId}`, + examId, + sessionId: unrelatedSessionId, + status: 'started' + }); + const recoveryEventStart = recoveryControl.events.length; + recoveryControl.saveQueue.push(new Error('transient primary recovery failure'), true); + + await app.startPracticeSession(examId); + + const attemptedSaves = recoveryControl.events + .slice(recoveryEventStart) + .filter((event) => event.type === 'save' && event.value && event.value.examId === examId); + assert.strictEqual(attemptedSaves.length, 2, 'primary failure must make one fallback save attempt'); + attemptedSaves.forEach((event) => { + assert.strictEqual(event.value.sessionId, ownerSessionId); + assert.strictEqual(event.value.id, `active-session:${ownerSessionId}`); + }); + const activeAfterFallback = await windowStub.AppData.recovery.listActiveSessions(); + assert( + activeAfterFallback.some((item) => item && item.id === `active-session:${ownerSessionId}`), + 'fallback recovery must use the exact registration id' + ); + assert.strictEqual(windowInfo.expectedSessionId, ownerSessionId); + assert.strictEqual(generatedSessionIdCount, 0, 'fallback must not mint another session id'); + assert.strictEqual(fallbackOpenCalls.length, 1); + assert.strictEqual(fallbackOpenCalls[0].name, `practice_${ownerSessionId}`); + + const expectedRegistration = app._captureExamSessionRegistration(examId, windowInfo); + assert.strictEqual( + await app.cleanupExamSession(examId, { expectedRegistration }), + true, + 'the exact current registration should be cleaned' + ); + const activeAfterCleanup = await windowStub.AppData.recovery.listActiveSessions(); + assert.strictEqual( + activeAfterCleanup.some((item) => item && item.id === `active-session:${ownerSessionId}`), + false, + 'exact cleanup must delete the fallback recovery it owns' + ); + assert.strictEqual( + activeAfterCleanup.some((item) => item && item.id === `active-session:${unrelatedSessionId}`), + true, + 'exact cleanup must preserve another session for the same exam' + ); + } finally { + await windowStub.AppData.recovery.discardActiveSession(`active-session:${ownerSessionId}`); + await windowStub.AppData.recovery.discardActiveSession(`active-session:${unrelatedSessionId}`); + await windowStub.AppData.recovery.discardActiveSession('active-session:unexpected-generated-fallback-session'); + windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex; + if (hadOpen) { + windowStub.open = originalOpen; + } else { + delete windowStub.open; + } + } + } + + // A fallback save that loses its exact registration immediately after commit must clean its ghost. + { + const app = createApp(windowStub); + const examId = 'reading-fallback-inflight-replacement'; + const ownerSessionId = 'fallback-inflight-owner'; + const replacementSessionId = 'fallback-inflight-replacement'; + const ownerInfo = { + window: createStubWindow('fallback-inflight-owner-window'), + expectedSessionId: ownerSessionId, + windowSessionToken: 'fallback-inflight-owner-token', + windowSessionTokenSessionId: ownerSessionId, + sessionGeneration: 1, + suiteSessionId: null + }; + const replacementInfo = { + window: createStubWindow('fallback-inflight-replacement-window'), + expectedSessionId: replacementSessionId, + windowSessionToken: 'fallback-inflight-replacement-token', + windowSessionTokenSessionId: replacementSessionId, + sessionGeneration: 2, + suiteSessionId: null + }; + const replacementHandler = () => {}; + const recovery = windowStub.AppData.recovery; + const originalSaveActiveSession = recovery.saveActiveSession; + const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex; + const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open'); + const originalOpen = windowStub.open; + let fallbackOpenCount = 0; + let fallbackSaveCount = 0; + + app.examWindows = new Map([[examId, ownerInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + app.components.practiceRecorder = { + startPracticeSession() { + throw new Error('force final fallback'); + } + }; + windowStub.resolveActiveLibraryIndex = async () => [{ + id: examId, + title: 'Fallback In-flight Replacement', + type: 'reading' + }]; + windowStub.open = () => { + fallbackOpenCount += 1; + return createStubWindow('unexpected-fallback-window'); + }; + recovery.saveActiveSession = async (value, options = {}) => { + fallbackSaveCount += 1; + const allowed = typeof options.commitGuard === 'function' && options.commitGuard() === true; + if (!allowed) { + return { committed: false, stale: true, code: 'STALE_RECOVERY_WRITE' }; + } + const receipt = await originalSaveActiveSession(value, options); + app.examWindows.set(examId, replacementInfo); + app.messageHandlers.set(examId, replacementHandler); + return receipt; + }; + + try { + await originalSaveActiveSession({ + id: `active-session:${replacementSessionId}`, + examId, + sessionId: replacementSessionId, + status: 'started' + }); + assert.strictEqual( + await app.startPracticeSessionFallback(examId, {}, { sessionId: 'unowned-fallback-session' }), + null, + 'fallback without an immutable registration tuple must fail closed' + ); + assert.strictEqual(fallbackSaveCount, 0, 'an unowned fallback must not reach recovery storage'); + assert.strictEqual(await app.startPracticeSession(examId), null); + assert.strictEqual(fallbackSaveCount, 1, 'the fallback must make only its owner-bound save attempt'); + assert.strictEqual(fallbackOpenCount, 0, 'a stale fallback must not open another practice window'); + assert.strictEqual(app.examWindows.get(examId), replacementInfo, 'the replacement registration must survive'); + assert.strictEqual(app.messageHandlers.get(examId), replacementHandler, 'the replacement handler must survive'); + const active = await recovery.listActiveSessions(); + assert.strictEqual( + active.some((item) => item && item.id === `active-session:${ownerSessionId}`), + false, + 'post-commit ownership loss must discard the just-written fallback recovery' + ); + assert.strictEqual( + active.some((item) => item && item.id === `active-session:${replacementSessionId}`), + true, + 'post-commit cleanup must preserve the replacement recovery' + ); + } finally { + recovery.saveActiveSession = originalSaveActiveSession; + await recovery.discardActiveSession(`active-session:${ownerSessionId}`); + await recovery.discardActiveSession(`active-session:${replacementSessionId}`); + await recovery.discardActiveSession('active-session:unowned-fallback-session'); + windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex; + if (hadOpen) { + windowStub.open = originalOpen; + } else { + delete windowStub.open; + } + } + } + + // The async practice page manager must receive the host id and may realign only its exact owner. + { + const app = createApp(windowStub); + const examId = 'reading-manager-session-alignment'; + const hostSessionId = 'manager-host-session'; + const managerSessionId = 'manager-actual-session'; + const windowInfo = { + window: createStubWindow('manager-session-alignment-window'), + expectedSessionId: hostSessionId, + windowSessionToken: 'manager-host-token', + windowSessionTokenSessionId: hostSessionId, + sessionGeneration: 1, + suiteSessionId: null + }; + const initialToken = windowInfo.windowSessionToken; + const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex; + const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager'); + const originalManager = windowStub.practicePageManager; + + app.examWindows = new Map([[examId, windowInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + windowStub.resolveActiveLibraryIndex = async () => [{ + id: examId, + title: 'Manager Session Alignment', + type: 'reading' + }]; + windowStub.practicePageManager = { + async startPracticeSession(handledExamId, examData) { + assert.strictEqual(handledExamId, examId); + assert.strictEqual(examData.sessionId, hostSessionId, 'manager must receive the host owner id'); + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${managerSessionId}`, + examId, + sessionId: managerSessionId, + status: 'started' + }); + return managerSessionId; + } + }; + + try { + const startResult = await app.startPracticeSession(examId); + assert.strictEqual(app._isPracticeSessionOwnedSuccess(startResult), true); + assert.strictEqual(startResult.sessionId, managerSessionId); + assert.strictEqual(startResult.value, managerSessionId); + assert.strictEqual(windowInfo.expectedSessionId, managerSessionId); + assert.strictEqual(windowInfo.windowSessionTokenSessionId, managerSessionId); + assert.notStrictEqual(windowInfo.windowSessionToken, initialToken, 'manager id realignment must rotate the token'); + + windowStub.practicePageManager.startPracticeSession = async (handledExamId, examData) => { + assert.strictEqual(handledExamId, examId); + assert.strictEqual(examData.sessionId, managerSessionId); + return true; + }; + const booleanResult = await app.startPracticeSession(examId, { + examDefinition: { id: examId, title: 'Manager Boolean Success', type: 'reading' } + }); + assert.strictEqual(app._isPracticeSessionOwnedSuccess(booleanResult), true); + assert.strictEqual(booleanResult.sessionId, managerSessionId); + assert.notStrictEqual(windowInfo.expectedSessionId, 'true', 'boolean success must not become a session id'); + + const expectedRegistration = app._captureExamSessionRegistration(examId, windowInfo); + assert.strictEqual(await app.cleanupExamSession(examId, { expectedRegistration }), true); + const active = await windowStub.AppData.recovery.listActiveSessions(); + assert.strictEqual( + active.some((item) => item && item.id === `active-session:${managerSessionId}`), + false, + 'cleanup must own the manager-aligned recovery id' + ); + } finally { + await windowStub.AppData.recovery.discardActiveSession(`active-session:${managerSessionId}`); + windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex; + if (hadManager) { + windowStub.practicePageManager = originalManager; + } else { + delete windowStub.practicePageManager; + } + } + } + + // A manager result that arrives after replacement may clean its own ghost, never the new owner. + { + const app = createApp(windowStub); + const examId = 'reading-manager-inflight-replacement'; + const hostSessionId = 'manager-inflight-host'; + const managerSessionId = 'manager-inflight-actual'; + const replacementSessionId = 'manager-inflight-replacement'; + const ownerInfo = { + window: createStubWindow('manager-inflight-owner-window'), + expectedSessionId: hostSessionId, + windowSessionToken: 'manager-inflight-owner-token', + windowSessionTokenSessionId: hostSessionId, + sessionGeneration: 1, + suiteSessionId: null + }; + const replacementInfo = { + window: createStubWindow('manager-inflight-replacement-window'), + expectedSessionId: replacementSessionId, + windowSessionToken: 'manager-inflight-replacement-token', + windowSessionTokenSessionId: replacementSessionId, + sessionGeneration: 2, + suiteSessionId: null + }; + const replacementHandler = () => {}; + const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex; + const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager'); + const originalManager = windowStub.practicePageManager; + + app.examWindows = new Map([[examId, ownerInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + windowStub.resolveActiveLibraryIndex = async () => [{ + id: examId, + title: 'Manager In-flight Replacement', + type: 'reading' + }]; + windowStub.practicePageManager = { + async startPracticeSession(handledExamId, examData) { + assert.strictEqual(handledExamId, examId); + assert.strictEqual(examData.sessionId, hostSessionId); + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${managerSessionId}`, + examId, + sessionId: managerSessionId, + status: 'started' + }); + app.examWindows.set(examId, replacementInfo); + app.messageHandlers.set(examId, replacementHandler); + return managerSessionId; + } + }; + + try { + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${replacementSessionId}`, + examId, + sessionId: replacementSessionId, + status: 'started' + }); + assert.strictEqual(await app.startPracticeSession(examId), null); + assert.strictEqual(app.examWindows.get(examId), replacementInfo); + assert.strictEqual(app.messageHandlers.get(examId), replacementHandler); + assert.strictEqual(replacementInfo.expectedSessionId, replacementSessionId); + assert.strictEqual(replacementInfo.windowSessionToken, 'manager-inflight-replacement-token'); + const active = await windowStub.AppData.recovery.listActiveSessions(); + assert.strictEqual( + active.some((item) => item && item.id === `active-session:${managerSessionId}`), + false, + 'the displaced manager recovery must be discarded by its actual id' + ); + assert.strictEqual( + active.some((item) => item && item.id === `active-session:${replacementSessionId}`), + true, + 'the replacement recovery must survive targeted ghost cleanup' + ); + } finally { + await windowStub.AppData.recovery.discardActiveSession(`active-session:${managerSessionId}`); + await windowStub.AppData.recovery.discardActiveSession(`active-session:${replacementSessionId}`); + windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex; + if (hadManager) { + windowStub.practicePageManager = originalManager; + } else { + delete windowStub.practicePageManager; + } + } + } + + // A registration replaced while findExamDefinition is pending must not be adopted by the old start. + { + const app = createApp(windowStub); + const examId = 'reading-start-definition-owner-gate'; + const ownerWindow = createStubWindow('definition-owner-window'); + const replacementWindow = createStubWindow('definition-replacement-window'); + const ownerInfo = { + window: ownerWindow, + expectedSessionId: 'definition-owner-session', + windowSessionToken: 'definition-owner-token', + windowSessionTokenSessionId: 'definition-owner-session', + sessionGeneration: 1, + suiteSessionId: null + }; + const replacementInfo = { + window: replacementWindow, + expectedSessionId: 'definition-replacement-session', + windowSessionToken: 'definition-replacement-token', + windowSessionTokenSessionId: 'definition-replacement-session', + sessionGeneration: 2, + suiteSessionId: null + }; + const replacementHandler = () => {}; + const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex; + let markLookupEntered; + let releaseLookup; + const lookupEntered = new Promise((resolve) => { markLookupEntered = resolve; }); + const lookupGate = new Promise((resolve) => { releaseLookup = resolve; }); + windowStub.resolveActiveLibraryIndex = async () => { + markLookupEntered(); + await lookupGate; + return [{ id: examId, title: 'Definition owner gate', type: 'reading' }]; + }; + app.examWindows = new Map([[examId, ownerInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + + try { + const starting = app.startPracticeSession(examId); + await lookupEntered; + app.examWindows.set(examId, replacementInfo); + app.messageHandlers.set(examId, replacementHandler); + releaseLookup(); + + assert.strictEqual(await starting, null); + assert.strictEqual(app.examWindows.get(examId), replacementInfo); + assert.strictEqual(app.messageHandlers.get(examId), replacementHandler); + assert.strictEqual(replacementInfo.expectedSessionId, 'definition-replacement-session'); + assert.strictEqual(replacementInfo.windowSessionToken, 'definition-replacement-token'); + } finally { + releaseLookup && releaseLookup(); + windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex; + } + } + + // A stale openExam index lookup must stop before it navigates or overwrites a replacement. + { + const app = createApp(windowStub); + const examId = 'reading-open-index-owner-gate'; + const ownerInfo = { + window: createStubWindow('open-index-owner-window'), + expectedSessionId: 'open-index-owner-session', + windowSessionToken: 'open-index-owner-token', + windowSessionTokenSessionId: 'open-index-owner-session', + sessionGeneration: 1, + suiteSessionId: null + }; + const replacementInfo = { + window: createStubWindow('open-index-replacement-window'), + expectedSessionId: 'open-index-replacement-session', + windowSessionToken: 'open-index-replacement-token', + windowSessionTokenSessionId: 'open-index-replacement-session', + sessionGeneration: 2, + suiteSessionId: null + }; + const replacementHandler = () => {}; + const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex; + const originalOpenExamWindow = app.openExamWindow; + const originalInject = app.injectDataCollectionScript; + let markIndexEntered; + let releaseIndex; + const indexEntered = new Promise((resolve) => { markIndexEntered = resolve; }); + const indexGate = new Promise((resolve) => { releaseIndex = resolve; }); + let openCount = 0; + let injectCount = 0; + windowStub.resolveActiveLibraryIndex = async () => { + markIndexEntered(); + await indexGate; + return [{ id: examId, title: 'Open index owner gate', type: 'reading', hasHtml: true }]; + }; + app.openExamWindow = () => { + openCount += 1; + return ownerInfo.window; + }; + app.injectDataCollectionScript = () => { + injectCount += 1; + }; + app.examWindows = new Map([[examId, ownerInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + + try { + const opening = app.openExam(examId, { target: 'tab' }); + await indexEntered; + app.examWindows.set(examId, replacementInfo); + app.messageHandlers.set(examId, replacementHandler); + releaseIndex(); + + assert.strictEqual(await opening, null); + assert.strictEqual(openCount, 0, 'a stale index lookup must stop before navigation'); + assert.strictEqual(injectCount, 0); + assert.strictEqual(app.examWindows.get(examId), replacementInfo); + assert.strictEqual(app.messageHandlers.get(examId), replacementHandler); + } finally { + releaseIndex && releaseIndex(); + windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex; + app.openExamWindow = originalOpenExamWindow; + app.injectDataCollectionScript = originalInject; + } + } + + // Removing the frozen predecessor during the index await is neutral; only a different tuple supersedes launch. + { + const app = createApp(windowStub); + const examId = 'reading-open-index-predecessor-closed'; + const oldWindow = createStubWindow('open-index-predecessor-window'); + const newWindow = createStubWindow('_blank'); + newWindow.addEventListener = () => {}; + const oldInfo = { + window: oldWindow, + expectedSessionId: 'open-index-predecessor-session', + windowSessionToken: 'open-index-predecessor-token', + windowSessionTokenSessionId: 'open-index-predecessor-session', + sessionGeneration: 1, + suiteSessionId: null + }; + const originalResolveActiveLibraryIndex = windowStub.resolveActiveLibraryIndex; + let markIndexEntered; + let releaseIndex; + const indexEntered = new Promise(resolve => { markIndexEntered = resolve; }); + const indexGate = new Promise(resolve => { releaseIndex = resolve; }); + windowStub.resolveActiveLibraryIndex = async () => { + markIndexEntered(); + await indexGate; + return [{ id: examId, title: 'Closed predecessor launch', type: 'reading', hasHtml: true }]; + }; + app.examWindows = new Map([[examId, oldInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + app.resolveReadingLaunchDescriptor = () => ({ + mode: 'unified_html', + url: `http://localhost/${examId}.html` + }); + app.openExamWindow = () => newWindow; + app._guardExamWindowContent = targetWindow => targetWindow; + app._captureLaunchLibraryConfigurationId = async () => null; + app.startPracticeSession = async (handledExamId, startOptions = {}) => buildOwnedStartResult( + app, + handledExamId, + true, + startOptions.launchOwnership + ); + app.injectDataCollectionScript = () => {}; + try { + const opening = app.openExam(examId, { target: 'tab', windowName: '_blank' }); + await indexEntered; + const predecessorRegistration = app._captureExamSessionRegistration(examId, oldInfo); + assert.strictEqual(await app.cleanupExamSession(examId, { + expectedRegistration: predecessorRegistration, + recoverySessionId: predecessorRegistration.expectedSessionId + }), true); + releaseIndex(); + assert.strictEqual(await opening, newWindow); + assert.strictEqual(app.examWindows.get(examId).window, newWindow); + assert.notStrictEqual(app.examWindows.get(examId), oldInfo); + } finally { + releaseIndex && releaseIndex(); + windowStub.resolveActiveLibraryIndex = originalResolveActiveLibraryIndex; + const current = app.examWindows && app.examWindows.get(examId); + if (current && current.closeMonitor) clearInterval(current.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + } + } + + // An openExam manager await that loses its exact owner must not inject, INIT, checkpoint, or rebind. + { + const app = createApp(windowStub); + const examId = 'reading-open-manager-owner-gate'; + const exam = { id: examId, title: 'Open manager owner gate', type: 'reading', hasHtml: true }; + const oldWindow = createStubWindow('open-manager-old-window'); + const replacementWindow = createStubWindow('open-manager-replacement-window'); + const oldLoadHandlers = []; + const replacementLoadHandlers = []; + oldWindow.addEventListener = (type, handler) => { + if (type === 'load' && typeof handler === 'function') oldLoadHandlers.push(handler); + }; + replacementWindow.addEventListener = (type, handler) => { + if (type === 'load' && typeof handler === 'function') replacementLoadHandlers.push(handler); + }; + const originalManager = windowStub.practicePageManager; + const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager'); + const originalResolveReading = app.resolveReadingLaunchDescriptor; + const originalOpenExamWindow = app.openExamWindow; + const originalGuard = app._guardExamWindowContent; + const originalCaptureLibrary = app._captureLaunchLibraryConfigurationId; + const originalInject = app.injectDataCollectionScript; + let markManagerEntered; + let releaseManager; + const managerEntered = new Promise((resolve) => { markManagerEntered = resolve; }); + const managerGate = new Promise((resolve) => { releaseManager = resolve; }); + let injectCount = 0; + let checkpointCount = 0; + app.resolveReadingLaunchDescriptor = () => ({ + mode: 'unified_html', + url: `http://localhost/${examId}.html` + }); + app.openExamWindow = () => oldWindow; + app._guardExamWindowContent = (targetWindow) => targetWindow; + app._captureLaunchLibraryConfigurationId = async () => null; + app.injectDataCollectionScript = () => { + injectCount += 1; + }; + windowStub.practicePageManager = { + async startPracticeSession(handledExamId, examData) { + assert.strictEqual(handledExamId, examId); + assert.strictEqual(typeof examData.sessionId, 'string'); + assert(examData.sessionId.length > 0); + markManagerEntered(); + await managerGate; + return examData.sessionId; + } + }; + + let replacementRegistration = null; + let replacementHandler = null; + try { + const opening = app.openExam(examId, { + examDefinition: exam, + target: 'tab', + windowName: 'open-manager-owner-gate-tab', + suiteSessionId: 'suite-open-manager-owner-gate', + beforeSuiteHandshake: async () => { + checkpointCount += 1; + return true; + } + }); + await managerEntered; + const oldRegistration = app._captureExamSessionRegistration(examId); + assert(oldRegistration && oldRegistration.window === oldWindow); + + replacementRegistration = app.setupExamWindowManagement( + replacementWindow, + examId, + exam, + { + expectedUrl: `http://localhost/${examId}-replacement.html`, + deferInitialHandshake: true + } + ); + replacementHandler = app.messageHandlers.get(examId); + assert(replacementRegistration && replacementRegistration.window === replacementWindow); + releaseManager(); + + assert.strictEqual(await opening, null); + assert.strictEqual(injectCount, 0, 'a displaced launch must never inject into its old WindowProxy'); + assert.strictEqual(checkpointCount, 0, 'a displaced launch must not mutate the suite binding'); + assert.strictEqual( + oldWindow._messages.some(message => message && String(message.type || '').toUpperCase() === 'INIT_SESSION'), + false, + 'the displaced WindowProxy must not receive INIT' + ); + + await Promise.all(oldLoadHandlers.map(handler => Promise.resolve(handler()))); + assert.strictEqual(app.examWindows.get(examId), replacementRegistration.windowInfo); + assert.strictEqual(app.messageHandlers.get(examId), replacementHandler); + assert.strictEqual(replacementRegistration.windowInfo.window, replacementWindow); + assert.strictEqual( + oldWindow._messages.some(message => message && String(message.type || '').toUpperCase() === 'INIT_SESSION'), + false, + 'stale load callbacks must remain owner-gated' + ); + } finally { + releaseManager && releaseManager(); + app.resolveReadingLaunchDescriptor = originalResolveReading; + app.openExamWindow = originalOpenExamWindow; + app._guardExamWindowContent = originalGuard; + app._captureLaunchLibraryConfigurationId = originalCaptureLibrary; + app.injectDataCollectionScript = originalInject; + if (hadManager) { + windowStub.practicePageManager = originalManager; + } else { + delete windowStub.practicePageManager; + } + const current = app.examWindows && app.examWindows.get(examId); + if (current && current.closeMonitor) clearInterval(current.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + replacementLoadHandlers.length = 0; + } + } + + // Failed deferred starts must tear down only their exact owned popup registration. + for (const failureMode of [ + 'manager-false', + 'recovery-uncommitted', + 'checkpoint-false', + 'config-window-close', + 'manager-window-close' + ]) { + const app = createApp(windowStub); + const examId = `reading-open-start-failure-${failureMode}`; + const exam = { id: examId, title: failureMode, type: 'reading', hasHtml: true }; + const examWindow = createStubWindow(`start-failure-${failureMode}`); + examWindow.addEventListener = () => {}; + examWindow.close = function close() { this.closed = true; }; + const hadManager = Object.prototype.hasOwnProperty.call(windowStub, 'practicePageManager'); + const originalManager = windowStub.practicePageManager; + let injectCount = 0; + app.resolveReadingLaunchDescriptor = () => ({ + mode: 'unified_html', + url: `http://localhost/${examId}.html` + }); + app.openExamWindow = () => examWindow; + app._guardExamWindowContent = targetWindow => targetWindow; + app._captureLaunchLibraryConfigurationId = async () => { + if (failureMode === 'config-window-close') examWindow.close(); + return null; + }; + app.injectDataCollectionScript = () => { injectCount += 1; }; + if (failureMode === 'manager-false' || failureMode === 'manager-window-close') { + windowStub.practicePageManager = { + async startPracticeSession() { + if (failureMode === 'manager-window-close') { + examWindow.close(); + return true; + } + return false; + } + }; + } else { + delete windowStub.practicePageManager; + } + if (failureMode === 'recovery-uncommitted') { + recoveryControl.saveQueue.push(false); + } + if (failureMode === 'checkpoint-false') { + app.startPracticeSession = async (handledExamId, startOptions = {}) => buildOwnedStartResult( + app, + handledExamId, + true, + startOptions.launchOwnership + ); + } + try { + const opened = await app.openExam(examId, { + examDefinition: exam, + target: 'tab', + windowName: examWindow.name, + ...(failureMode === 'checkpoint-false' ? { + suiteSessionId: `suite-${examId}`, + beforeSuiteHandshake: async () => false + } : {}) + }); + assert.strictEqual(opened, null); + assert.strictEqual(Boolean(app.examWindows && app.examWindows.has(examId)), false); + assert.strictEqual(Boolean(app.messageHandlers && app.messageHandlers.has(examId)), false); + assert.strictEqual(examWindow.closed, true); + assert.strictEqual(injectCount, 0); + } finally { + if (hadManager) { + windowStub.practicePageManager = originalManager; + } else { + delete windowStub.practicePageManager; + } + const current = app.examWindows && app.examWindows.get(examId); + if (current && current.closeMonitor) clearInterval(current.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + } + } + + // A completed first navigation is owned by its exact provisional registration. A newer + // same-exam launch that fails before navigation must not strand the first launch mid-config. + { + const app = createApp(windowStub); + const examId = 'reading-post-navigation-registration-owner'; + const exam = { id: examId, title: 'Post-navigation owner', type: 'reading', hasHtml: true }; + const examWindow = createStubWindow('_blank'); + examWindow.addEventListener = () => {}; + examWindow.close = function close() { this.closed = true; }; + const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open'); + const originalOpen = windowStub.open; + let markConfigurationEntered; + let releaseConfiguration; + const configurationEntered = new Promise(resolve => { markConfigurationEntered = resolve; }); + const configurationGate = new Promise(resolve => { releaseConfiguration = resolve; }); + let configurationCommitGuard = null; + let injectCount = 0; + let restartCount = 0; + windowStub.open = () => examWindow; + app.resolveReadingLaunchDescriptor = () => ({ + mode: 'unified_html', + url: `http://localhost/${examId}.html` + }); + app._guardExamWindowContent = targetWindow => targetWindow; + app._captureLaunchLibraryConfigurationId = async (_handledExamId, captureOptions = {}) => { + configurationCommitGuard = captureOptions.commitGuard; + assert.strictEqual(configurationCommitGuard(), true); + markConfigurationEntered(); + await configurationGate; + assert.strictEqual(configurationCommitGuard(), true); + return null; + }; + app.startPracticeSession = async (handledExamId, startOptions = {}) => { + assert.strictEqual( + app._isExamSessionRegistrationCurrent(handledExamId, startOptions.expectedRegistration), + true + ); + return buildOwnedStartResult(app, handledExamId, true); + }; + app.restartExamHandshake = () => { restartCount += 1; }; + app.injectDataCollectionScript = () => { injectCount += 1; }; + let provisionalRegistration = null; + try { + const opening = app.openExam(examId, { + examDefinition: exam, + target: 'tab', + windowName: '_blank' + }); + await configurationEntered; + provisionalRegistration = app._captureExamSessionRegistration(examId); + assert(provisionalRegistration); + assert.strictEqual(provisionalRegistration.window, examWindow); + assert.strictEqual(provisionalRegistration.windowInfo.launchProvisional, true); + assert.strictEqual( + app._isOpenExamRegistrationCurrent(examId, provisionalRegistration, examWindow), + true + ); + + await assert.rejects( + app.openExam(examId, { requireRecordProvenance: true }), + /./ + ); + assert.strictEqual(configurationCommitGuard(), true); + assert.strictEqual( + app._isExamSessionRegistrationCurrent(examId, provisionalRegistration), + true, + 'a failed pre-navigation reservation must not supersede the navigated provisional tuple' + ); + releaseConfiguration(); + + assert.strictEqual(await opening, examWindow); + const finalRegistration = app._captureExamSessionRegistration(examId); + assert(finalRegistration); + assert.notStrictEqual(finalRegistration.windowInfo, provisionalRegistration.windowInfo); + assert.strictEqual(finalRegistration.windowInfo.launchProvisional, undefined); + assert.strictEqual(finalRegistration.windowInfo.handshakeDeferred, false); + assert.strictEqual(app.messageHandlers.has(examId), true); + assert.strictEqual(restartCount, 1); + assert.strictEqual(injectCount, 1); + } finally { + releaseConfiguration && releaseConfiguration(); + if (hadOpen) windowStub.open = originalOpen; + else delete windowStub.open; + const current = app.examWindows && app.examWindows.get(examId); + if (current && current.closeMonitor) clearInterval(current.closeMonitor); + if (app._handshakeTimers) { + for (const timer of app._handshakeTimers.values()) clearInterval(timer); + app._handshakeTimers.clear(); + } + } + } + + // A supplied launch token publishes the exact final registration it created. A same-suite, + // same-WindowProxy successor installed after openExam resolves must not be re-captured as the + // older launch's result by reading the global examWindows map. + { + const app = createApp(windowStub); + const examId = 'reading-launch-registration-receipt'; + const suiteSessionId = 'suite-launch-registration-receipt'; + const exam = { id: examId, title: 'Launch receipt', type: 'reading', hasHtml: true }; + const examWindow = createStubWindow('launch-registration-receipt-window'); + examWindow.addEventListener = () => {}; + app.resolveReadingLaunchDescriptor = () => ({ + mode: 'unified_html', + url: `http://localhost/${examId}.html` + }); + app._guardExamWindowContent = targetWindow => targetWindow; + app._captureLaunchLibraryConfigurationId = async () => null; + app.restartExamHandshake = () => {}; + app.injectDataCollectionScript = () => {}; + app.startPracticeSession = async (handledExamId, startOptions = {}) => { + const expectedRegistration = startOptions.expectedRegistration; + assert.strictEqual( + app._isExamSessionRegistrationCurrent(handledExamId, expectedRegistration), + true + ); + const previousInfo = expectedRegistration.windowInfo; + const realignedInfo = { + ...previousInfo, + expectedSessionId: 'launch-receipt-manager-session', + sessionId: null, + sessionGeneration: Number(previousInfo.sessionGeneration || 0) + 1, + windowSessionToken: 'launch-receipt-manager-token', + windowSessionTokenSessionId: 'launch-receipt-manager-session' + }; + app.examWindows.set(handledExamId, realignedInfo); + const registration = app._captureExamSessionRegistration(handledExamId, realignedInfo); + return Object.freeze({ + owned: true, + status: 'owned-success', + examId: handledExamId, + source: 'manager', + sessionId: realignedInfo.expectedSessionId, + value: null, + registration + }); + }; + + const launchOwnership = app._beginExamLaunchOwnership(examId, { + reuseWindow: examWindow, + windowName: examWindow.name + }); + const opened = await app.openExam(examId, { + examDefinition: exam, + target: 'tab', + reuseWindow: examWindow, + windowName: examWindow.name, + launchOwnership, + suiteSessionId, + beforeSuiteHandshake: async (context = {}) => { + assert.strictEqual(context.commitGuard(), true); + return true; + } + }); + assert.strictEqual(opened, examWindow); + const launchReceipt = app._captureExamLaunchRegistrationReceipt( + examId, + launchOwnership, + examWindow + ); + assert(launchReceipt, 'successful supplied launch must publish an exact receipt'); + assert.strictEqual(launchReceipt.expectedSessionId, 'launch-receipt-manager-session'); + assert.strictEqual(launchReceipt.suiteSessionId, suiteSessionId); + assert.strictEqual( + app._isExamSessionRegistrationCurrent(examId, launchReceipt), + true + ); + + const newerRegistration = app.setupExamWindowManagement( + examWindow, + examId, + exam, + { + expectedRegistration: launchReceipt, + expectedUrl: `http://localhost/${examId}.html`, + suiteSessionId, + skipContentGuard: true, + deferInitialHandshake: true + } + ); + assert(newerRegistration); + assert.strictEqual(newerRegistration.window, examWindow); + assert.strictEqual(newerRegistration.suiteSessionId, suiteSessionId); + assert.notStrictEqual(newerRegistration.windowInfo, launchReceipt.windowInfo); + assert.notStrictEqual(newerRegistration.expectedSessionId, launchReceipt.expectedSessionId); + assert.strictEqual( + app._captureExamLaunchRegistrationReceipt(examId, launchOwnership, examWindow), + null, + 'a newer same-suite tuple must invalidate, not replace, the older launch receipt' + ); + assert.strictEqual(app.examWindows.get(examId), newerRegistration.windowInfo); + + const current = app.examWindows.get(examId); + if (current && current.closeMonitor) clearInterval(current.closeMonitor); + if (app._handshakeTimers && app._handshakeTimers.has(examId)) { + clearInterval(app._handshakeTimers.get(examId)); + app._handshakeTimers.delete(examId); + } + const currentHandler = app.messageHandlers && app.messageHandlers.get(examId); + if (currentHandler) { + windowStub.removeEventListener('message', currentHandler); + app.messageHandlers.delete(examId); + } + } + + // Direct location assignment does not prove that options.windowName resolves to the reused + // WindowProxy. Only window.open(requestedName) may establish that named-context proof. + for (const launchKind of ['html', 'pdf']) { + const app = createApp(windowStub); + const examId = `reading-direct-reuse-name-proof-${launchKind}`; + const exam = { id: examId, title: `Direct reuse ${launchKind}`, type: 'reading' }; + const actualName = `actual-direct-reuse-${launchKind}`; + const unrelatedName = `unrelated-direct-reuse-${launchKind}`; + const examWindow = createStubWindow(actualName); + const ownership = app._beginExamLaunchOwnership(examId, { + reuseWindow: examWindow, + windowName: unrelatedName + }); + const launchOptions = { + examId, + reuseWindow: examWindow, + windowName: unrelatedName, + launchOwnership: ownership + }; + const opened = launchKind === 'html' + ? app.openExamWindow(`http://localhost/${examId}.html`, exam, launchOptions) + : app._openPdfWindow(exam, `http://localhost/${examId}.pdf`, launchOptions); + assert.strictEqual(opened, examWindow); + assert.notStrictEqual( + app._resolveExamLaunchProvenWindow(`window-name:${unrelatedName}`), + examWindow, + `${launchKind} direct reuse must not treat options.windowName as resolved proof` + ); + assert.strictEqual( + app._resolveExamLaunchProvenWindow(`window-name:${actualName}`), + examWindow, + `${launchKind} direct reuse may retain the safely read actual WindowProxy name` + ); + + const namedExamId = `reading-unrelated-named-continuation-${launchKind}`; + const namedContinuation = app._beginExamLaunchOwnership(namedExamId, { + windowName: unrelatedName + }); + Object.defineProperty(examWindow, 'name', { + configurable: true, + get() { throw new Error('cross-origin name'); } + }); + const opaqueOwnership = app._beginExamLaunchOwnership( + `reading-opaque-direct-reuse-${launchKind}`, + { reuseWindow: examWindow } + ); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent(namedExamId, namedContinuation), + true, + `${launchKind} opaque reuse must not steal an unrelated unproven named continuation` + ); + assert.strictEqual( + app._examLaunchOwnershipTargetLeaseKeys.get(opaqueOwnership) + .has(`window-name:${unrelatedName}`), + false + ); + } + + // An abort may await durable recovery cleanup after removing its exact HTML tuple. A raw PDF + // navigation on the same WindowProxy has no managed map entry, but its committed navigation + // token must still prevent the older abort from closing the newly owned document. + { + const app = createApp(windowStub); + const htmlExamId = 'reading-html-abort-before-pdf-reuse'; + const pdfExamId = 'reading-pdf-reuses-aborting-window'; + const examWindow = createStubWindow('html-abort-pdf-reuse-window'); + let closeCount = 0; + examWindow.close = function close() { + closeCount += 1; + this.closed = true; + }; + const htmlOwnership = app._beginExamLaunchOwnership(htmlExamId, { + reuseWindow: examWindow + }); + assert.strictEqual(app.openExamWindow( + `http://localhost/${htmlExamId}.html`, + { id: htmlExamId, title: 'HTML abort owner', type: 'reading' }, + { + examId: htmlExamId, + reuseWindow: examWindow, + launchOwnership: htmlOwnership + } + ), examWindow); + const htmlRegistration = app._captureExamSessionRegistration(htmlExamId); + assert(htmlRegistration && htmlRegistration.navigationOwnership); + + let markDiscardEntered; + let releaseDiscard; + const discardEntered = new Promise(resolve => { markDiscardEntered = resolve; }); + const discardGate = new Promise(resolve => { releaseDiscard = resolve; }); + app._discardActiveSessionsForExam = async () => { + markDiscardEntered(); + await discardGate; + return 0; + }; + const aborting = app._abortOwnedExamLaunch( + htmlExamId, + examWindow, + htmlOwnership, + htmlRegistration + ); + await discardEntered; + assert.strictEqual(app.examWindows.has(htmlExamId), false); + + const pdfOwnership = app._beginExamLaunchOwnership(pdfExamId, { + reuseWindow: examWindow + }); + assert.strictEqual(app._openPdfWindow( + { id: pdfExamId, title: 'PDF replacement', type: 'reading' }, + `http://localhost/${pdfExamId}.pdf`, + { reuseWindow: examWindow, launchOwnership: pdfOwnership } + ), examWindow); + assert.notStrictEqual( + app._examWindowCommittedNavigationOwners.get(examWindow), + htmlRegistration.navigationOwnership + ); + assert.strictEqual(app.examWindows.has(pdfExamId), false, 'raw PDF must remain unmanaged'); + + releaseDiscard(); + assert.strictEqual(await aborting, true); + assert.strictEqual(closeCount, 0, 'the stale HTML abort must not close the newer raw PDF'); + assert.strictEqual(examWindow.closed, false); + assert.strictEqual(examWindow.location.href, `http://localhost/${pdfExamId}.pdf`); + } + + // Popup fallback may register the host window, but startup abort must never close the app itself. + { + const app = createApp(windowStub); + const examId = 'reading-host-window-start-abort'; + const hadClose = Object.prototype.hasOwnProperty.call(windowStub, 'close'); + const originalClose = windowStub.close; + let hostCloseCount = 0; + windowStub.close = () => { hostCloseCount += 1; }; + const info = { + window: windowStub, + expectedSessionId: 'host-window-abort-session', + windowSessionToken: 'host-window-abort-token', + windowSessionTokenSessionId: 'host-window-abort-session', + sessionGeneration: 1, + suiteSessionId: null, + expectedOrigin: 'http://localhost' + }; + app.examWindows = new Map([[examId, info]]); + app.messageHandlers = new Map([[examId, () => {}]]); + const launchOwnership = app._beginExamLaunchOwnership(examId, { reuseWindow: windowStub }); + const registration = app._captureExamSessionRegistration(examId, info); + try { + assert.strictEqual( + await app._abortOwnedExamLaunch(examId, windowStub, launchOwnership, registration), + true + ); + assert.strictEqual(hostCloseCount, 0); + assert.strictEqual(app.examWindows.has(examId), false); + } finally { + if (hadClose) windowStub.close = originalClose; + else delete windowStub.close; + } + } + + // Launch ownership reserves implicit names, cannot be widened by a caller, and gates guard retries. + { + const app = createApp(windowStub); + const implicitExamId = 'reading-implicit-lease'; + const implicitOwnership = app._beginExamLaunchOwnership(implicitExamId, {}); + assert(implicitOwnership.targetLeaseKeys.includes(`window-name:exam_${implicitExamId}`)); + assert(implicitOwnership.targetLeaseKeys.includes(`window-name:pdf_${implicitExamId}`)); + + let staleOpenCount = 0; + const originalOpenExamWindow = app.openExamWindow; + app.openExamWindow = () => { + staleOpenCount += 1; + return createStubWindow('unexpected-expanded-target'); + }; + assert.strictEqual(await app.openExam(implicitExamId, { + examDefinition: { id: implicitExamId, title: 'Implicit lease', type: 'reading', hasHtml: true }, + launchOwnership: implicitOwnership, + windowName: 'caller-added-target' + }), null); + assert.strictEqual(staleOpenCount, 0, 'an adopted launch token must not gain a new named target'); + app.openExamWindow = originalOpenExamWindow; + + const sharedWindow = createStubWindow(`exam_${implicitExamId}`); + app._beginExamLaunchOwnership('reading-window-holder', { reuseWindow: sharedWindow }); + const staleImplicit = app._beginExamLaunchOwnership(implicitExamId, {}); + Object.defineProperty(sharedWindow, 'name', { + configurable: true, + get() { throw new Error('cross-origin name'); } + }); + const opaqueWinner = app._beginExamLaunchOwnership('reading-window-winner', { reuseWindow: sharedWindow }); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent(implicitExamId, staleImplicit), + false, + 'claiming an opaque WindowProxy must transfer its previously proven named lease' + ); + const inheritedTargetKey = `window-name:exam_${implicitExamId}`; + const interveningNamedLaunch = app._beginExamLaunchOwnership('reading-window-name-intervening', { + windowName: `exam_${implicitExamId}` + }); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-window-winner', opaqueWinner), + false, + 'an inherited effective target key must remain part of the opaque owner lease' + ); + const secondOpaqueWinner = app._beginExamLaunchOwnership('reading-window-second-winner', { + reuseWindow: sharedWindow + }); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-window-name-intervening', interveningNamedLaunch), + false, + 'a later opaque reuse must transfer the proven target key across multiple generations' + ); + assert( + app._examLaunchOwnershipTargetLeaseKeys.get(secondOpaqueWinner).has(inheritedTargetKey), + 'the multi-generation owner must retain the inherited effective target key' + ); + const secondNamedWindow = createStubWindow(`exam_${implicitExamId}`); + const secondNamedOwner = app._beginExamLaunchOwnership('reading-window-second-context', { + windowName: `exam_${implicitExamId}` + }); + assert.strictEqual( + app._claimExamLaunchWindowOwnership( + secondNamedOwner, + secondNamedWindow, + `exam_${implicitExamId}` + ), + true + ); + const opaqueFirstContextReuse = app._beginExamLaunchOwnership('reading-window-first-context-reuse', { + reuseWindow: sharedWindow + }); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-window-second-context', secondNamedOwner), + true, + 'an opaque reuse of P1 must not steal a target name now proven to resolve to P2' + ); + assert.strictEqual( + app._examLaunchOwnershipTargetLeaseKeys.get(opaqueFirstContextReuse).has(inheritedTargetKey), + false + ); + + const renamedWindow = createStubWindow('proof-name-foo'); + app._beginExamLaunchOwnership('reading-proof-name-holder', { reuseWindow: renamedWindow }); + assert.strictEqual( + app._resolveExamLaunchProvenWindow('window-name:proof-name-foo'), + renamedWindow + ); + renamedWindow.name = 'proof-name-bar'; + const pendingFooOwner = app._beginExamLaunchOwnership('reading-proof-name-pending-foo', { + windowName: 'proof-name-foo' + }); + app._beginExamLaunchOwnership('reading-proof-name-bar-reuse', { + reuseWindow: renamedWindow + }); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-proof-name-pending-foo', pendingFooOwner), + true, + 'a readable rename must revoke the old proof instead of stealing the pending old name' + ); + assert.strictEqual(app._resolveExamLaunchProvenWindow('window-name:proof-name-foo'), null); + assert.strictEqual( + app._resolveExamLaunchProvenWindow('window-name:proof-name-bar'), + renamedWindow + ); + + const closedProofWindow = createStubWindow('proof-name-closed'); + app._beginExamLaunchOwnership('reading-proof-name-closed-holder', { + reuseWindow: closedProofWindow + }); + const closedProofKey = 'window-name:proof-name-closed'; + const storedClosedProof = app._examLaunchProvenWindowByTargetKey.get(closedProofKey); + if (typeof WeakRef === 'function') { + assert.notStrictEqual(storedClosedProof, closedProofWindow, 'proof map must not strongly retain WindowProxy'); + assert.strictEqual(typeof storedClosedProof.deref, 'function'); + } + closedProofWindow.closed = true; + assert.strictEqual(app._resolveExamLaunchProvenWindow(closedProofKey), null); + assert.strictEqual(app._examLaunchProvenWindowByTargetKey.has(closedProofKey), false); + + const committedReservationApp = createApp(windowStub); + for (let index = 0; index < 4; index += 1) { + const committedExamId = `reading-committed-reservation-${index}`; + const committedWindow = createStubWindow(`committed-actual-name-${index}`); + const customWindowName = `committed-custom-name-${index}`; + const committedOwnership = committedReservationApp._beginExamLaunchOwnership( + committedExamId, + { + reuseWindow: committedWindow, + windowName: customWindowName + } + ); + assert.strictEqual( + committedReservationApp._examLaunchOwnershipExplicitWindows.get(committedOwnership), + committedWindow + ); + const launchOptions = { + examId: committedExamId, + reuseWindow: committedWindow, + windowName: customWindowName, + launchOwnership: committedOwnership + }; + assert.strictEqual( + committedReservationApp.openExamWindow( + `http://localhost/${committedExamId}.html`, + { id: committedExamId, title: 'Committed reservation', type: 'reading' }, + launchOptions + ), + committedWindow + ); + assert( + launchOptions.navigationRegistration + && committedReservationApp._isExamSessionRegistrationCurrent( + committedExamId, + launchOptions.navigationRegistration + ), + 'navigation must install an exact registration before releasing its reservation' + ); + assert.strictEqual( + committedReservationApp._commitExamLaunchOwnership(committedOwnership), + true + ); + assert.strictEqual(committedReservationApp._examLaunchOwnerships.has(committedExamId), false); + assert.strictEqual( + committedReservationApp._examLaunchTargetOwnerships.size, + 0, + 'successful unique named launches must not accumulate target reservations' + ); + assert.strictEqual( + committedReservationApp._examLaunchWindowOwnerships.get(committedWindow), + undefined + ); + assert.strictEqual( + committedReservationApp._examLaunchOwnershipExplicitWindows.has(committedOwnership), + false, + 'commit must release the token side table strong WindowProxy reference' + ); + assert.strictEqual( + committedReservationApp._claimExamLaunchWindowOwnership( + committedOwnership, + committedWindow, + customWindowName + ), + false, + 'a committed continuation must never reacquire its released reservation' + ); + assert.strictEqual( + committedReservationApp._isExamLaunchOwnershipCurrent( + committedExamId, + committedOwnership, + null, + committedWindow + ), + false + ); + if (index === 0) { + const provenName = `committed-actual-name-${index}`; + const pendingNamedOwnership = committedReservationApp._beginExamLaunchOwnership( + 'reading-committed-proof-pending', + { windowName: provenName } + ); + Object.defineProperty(committedWindow, 'name', { + configurable: true, + get() { throw new Error('cross-origin name'); } + }); + const opaqueReuseOwnership = committedReservationApp._beginExamLaunchOwnership( + 'reading-committed-proof-opaque-reuse', + { reuseWindow: committedWindow } + ); + assert.strictEqual( + committedReservationApp._isExamLaunchOwnershipCurrent( + 'reading-committed-proof-pending', + pendingNamedOwnership + ), + false, + 'weak name proof must survive reservation release and protect a later opaque reuse' + ); + assert.strictEqual( + committedReservationApp._rollbackExamLaunchOwnership(opaqueReuseOwnership), + true + ); + assert.strictEqual( + committedReservationApp._rollbackExamLaunchOwnership(pendingNamedOwnership), + true + ); + assert.strictEqual(committedReservationApp._examLaunchTargetOwnerships.size, 0); + } + committedWindow.closed = true; + assert.strictEqual( + committedReservationApp._resolveExamLaunchProvenWindow( + `window-name:committed-actual-name-${index}` + ), + null, + 'closed committed windows must not retain a proven-name resolution' + ); + } + + const rollbackApp = createApp(windowStub); + const rollbackWindow = createStubWindow('rollback-nested-target'); + const rollbackExamId = 'reading-rollback-nested'; + rollbackApp.setupExamWindowCommunication( + rollbackWindow, + rollbackExamId, + { id: rollbackExamId, title: 'Installed launch owner', type: 'reading' }, + { expectedUrl: 'http://localhost/exam.html' } + ); + const installedInfo = rollbackApp.examWindows.get(rollbackExamId); + const installedHandler = rollbackApp.messageHandlers.get(rollbackExamId); + const installedRegistration = rollbackApp._captureExamSessionRegistration( + rollbackExamId, + installedInfo + ); + const predecessor = rollbackApp._beginExamLaunchOwnership(rollbackExamId, { + reuseWindow: rollbackWindow, + windowName: rollbackWindow.name + }); + const staleReservation = rollbackApp._beginExamLaunchOwnership(rollbackExamId, { + reuseWindow: rollbackWindow, + windowName: rollbackWindow.name + }); + const newestReservation = rollbackApp._beginExamLaunchOwnership(rollbackExamId, { + reuseWindow: rollbackWindow, + windowName: rollbackWindow.name + }); + assert.strictEqual(rollbackApp._rollbackExamLaunchOwnership(staleReservation), false); + assert.strictEqual(rollbackApp._rollbackExamLaunchOwnership(newestReservation), true); + assert.strictEqual( + rollbackApp._isExamLaunchOwnershipCurrent( + rollbackExamId, + predecessor, + null, + rollbackWindow + ), + false, + 'a failed nested reservation must not resurrect an older open continuation' + ); + assert.strictEqual( + rollbackApp._isExamSessionRegistrationCurrent( + rollbackExamId, + installedRegistration + ), + true, + 'pre-navigation reservations must not invalidate the installed page registration' + ); + assert.strictEqual( + rollbackApp.messageHandlers.get(rollbackExamId), + installedHandler + ); + const messagesBeforeInstalledRequest = rollbackWindow._messages.length; + await installedHandler({ + source: rollbackWindow, + origin: 'http://localhost', + data: { + type: 'REQUEST_INIT', + source: 'practice_page', + data: { examId: rollbackExamId } + } + }); + assert( + rollbackWindow._messages.length > messagesBeforeInstalledRequest, + 'a failed pre-navigation launch must not disable the installed page protocol' + ); + const nextReservation = rollbackApp._beginExamLaunchOwnership(rollbackExamId, { + reuseWindow: rollbackWindow, + windowName: rollbackWindow.name + }); + assert.strictEqual( + rollbackApp._isExamLaunchOwnershipCurrent( + rollbackExamId, + nextReservation, + null, + rollbackWindow + ), + true, + 'a later launch must replace stale reservations without traversing predecessor chains' + ); + const rollbackInfo = rollbackApp.examWindows.get(rollbackExamId); + if (rollbackInfo && rollbackInfo.closeMonitor) clearInterval(rollbackInfo.closeMonitor); + + for (const takeoverMode of ['pre-navigation-failure', 'committed-navigation']) { + const guardApp = createApp(windowStub); + const guardExamId = `reading-guard-retry-owner-${takeoverMode}`; + const guardWindow = createStubWindow(`guard-retry-window-${takeoverMode}`); + let replaceCount = 0; + guardWindow.location = { + href: 'about:blank', + replace(url) { + replaceCount += 1; + this.href = url; + } + }; + const originalSetTimeout = sandbox.setTimeout; + const originalShouldUsePlaceholder = guardApp._shouldUsePlaceholderPage; + let scheduledRetry = null; + sandbox.setTimeout = (callback) => { + scheduledRetry = callback; + return 1; + }; + guardApp._shouldUsePlaceholderPage = () => true; + try { + const guardOwnership = guardApp._beginExamLaunchOwnership(guardExamId, { + reuseWindow: guardWindow + }); + const navigationOwnership = guardApp._recordExamWindowNavigation( + guardWindow, + guardExamId + ); + const navigationRegistration = guardApp._installExamNavigationProvisionalRegistration( + guardExamId, + guardWindow, + { expectedUrl: 'about:blank' } + ); + guardApp._guardExamWindowContent( + guardWindow, + { id: guardExamId, title: 'Guard retry' }, + { + examId: guardExamId, + launchOwnership: guardOwnership, + navigationOwnership, + navigationRegistration, + guardRetryCount: 3 + } + ); + assert.strictEqual(typeof scheduledRetry, 'function'); + + const replacementOwnership = guardApp._beginExamLaunchOwnership(guardExamId, { + reuseWindow: guardWindow + }); + if (takeoverMode === 'pre-navigation-failure') { + assert.strictEqual(guardApp._rollbackExamLaunchOwnership(replacementOwnership), true); + scheduledRetry(); + assert.strictEqual( + replaceCount, + 1, + 'a failed pre-navigation reservation must not suppress the installed page retry' + ); + assert.notStrictEqual(guardWindow.location.href, 'about:blank'); + } else { + const replacementUrl = `http://localhost/${guardExamId}-replacement.html`; + assert.strictEqual(guardApp.openExamWindow( + replacementUrl, + { id: guardExamId, title: 'Committed replacement', type: 'reading' }, + { + examId: guardExamId, + reuseWindow: guardWindow, + launchOwnership: replacementOwnership + } + ), guardWindow); + scheduledRetry(); + assert.strictEqual( + replaceCount, + 0, + 'a real navigation must invalidate the older installed-page retry' + ); + assert.strictEqual(guardWindow.location.href, replacementUrl); + } + } finally { + sandbox.setTimeout = originalSetTimeout; + guardApp._shouldUsePlaceholderPage = originalShouldUsePlaceholder; + } + } + + for (const successorMode of ['same-navigation', 'new-navigation']) { + const guardApp = createApp(windowStub); + const guardExamId = `reading-guard-managed-successor-${successorMode}`; + const guardWindow = createStubWindow(`guard-managed-successor-${successorMode}`); + guardWindow.addEventListener = () => {}; + let replaceCount = 0; + guardWindow.location = { + href: 'about:blank', + replace(url) { + replaceCount += 1; + this.href = url; + } + }; + const originalSetTimeout = sandbox.setTimeout; + const originalShouldUsePlaceholder = guardApp._shouldUsePlaceholderPage; + let scheduledRetry = null; + sandbox.setTimeout = (callback) => { + scheduledRetry = callback; + return 1; + }; + guardApp._shouldUsePlaceholderPage = () => true; + try { + const navigationOwnership = guardApp._recordExamWindowNavigation( + guardWindow, + guardExamId + ); + const provisionalRegistration = guardApp._installExamNavigationProvisionalRegistration( + guardExamId, + guardWindow, + { expectedUrl: 'about:blank' } + ); + guardApp._guardExamWindowContent( + guardWindow, + { id: guardExamId, title: 'Guard managed successor' }, + { + examId: guardExamId, + navigationOwnership, + navigationRegistration: provisionalRegistration, + guardRetryCount: 3 + } + ); + assert.strictEqual(typeof scheduledRetry, 'function'); + const guardRetry = scheduledRetry; + + const successorNavigationOwnership = successorMode === 'new-navigation' + ? guardApp._recordExamWindowNavigation(guardWindow, guardExamId) + : navigationOwnership; + const managedRegistration = guardApp.setupExamWindowManagement( + guardWindow, + guardExamId, + { id: guardExamId, title: 'Managed successor', type: 'reading' }, + { + expectedRegistration: provisionalRegistration, + expectedUrl: 'about:blank', + skipContentGuard: true, + deferInitialHandshake: true + } + ); + assert(managedRegistration, 'setup must install the managed successor registration'); + assert.notStrictEqual(managedRegistration.windowInfo, provisionalRegistration.windowInfo); + assert.strictEqual( + managedRegistration.navigationOwnership, + successorNavigationOwnership + ); + + guardRetry(); + assert.strictEqual( + replaceCount, + successorMode === 'same-navigation' ? 1 : 0, + successorMode === 'same-navigation' + ? 'a managed successor on the same navigation must inherit the pending retry' + : 'a managed successor after a new navigation must reject the stale retry' + ); + const managedInfo = guardApp.examWindows.get(guardExamId); + if (managedInfo && managedInfo.closeMonitor) clearInterval(managedInfo.closeMonitor); + if (guardApp._handshakeTimers && guardApp._handshakeTimers.has(guardExamId)) { + clearInterval(guardApp._handshakeTimers.get(guardExamId)); + } + const managedHandler = guardApp.messageHandlers + && guardApp.messageHandlers.get(guardExamId); + if (managedHandler) { + windowStub.removeEventListener('message', managedHandler); + guardApp.messageHandlers.delete(guardExamId); + } + } finally { + sandbox.setTimeout = originalSetTimeout; + guardApp._shouldUsePlaceholderPage = originalShouldUsePlaceholder; + } + } + } + + // Managed suiteSessionId:null is an ordinary owner even while the same exam is active in a suite. + { + const app = createApp(windowStub); + const examId = 'reading-managed-ordinary-owner'; + const examWindow = createStubWindow('managed-ordinary-window'); + const windowInfo = { + window: examWindow, + expectedSessionId: 'managed-ordinary-session', + windowSessionToken: 'managed-ordinary-token', + windowSessionTokenSessionId: 'managed-ordinary-session', + sessionGeneration: 1, + suiteSessionId: null, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + status: 'active' + }; + app.examWindows = new Map([[examId, windowInfo]]); + app.currentSuiteSession = { + id: 'suite-managed-ordinary', + status: 'active', + activeExamId: examId, + currentIndex: 0, + sequence: [{ examId }], + results: [] + }; + app.suiteExamMap.set(examId, app.currentSuiteSession.id); + let resolverCalls = 0; + app._resolveSuiteSessionId = () => { + resolverCalls += 1; + return app.currentSuiteSession.id; + }; + const initPayload = app._buildExamInitPayload(examId, windowInfo); + assert.strictEqual(initPayload.suiteSessionId, null); + assert.strictEqual(resolverCalls, 0, 'explicit null must bypass global suite inference'); + + const registration = app._captureExamSessionRegistration(examId, windowInfo); + let suiteReadyCalls = 0; + let suiteReviewNavigateCalls = 0; + let ordinaryReviewNavigateCalls = 0; + let simulationNavigateCalls = 0; + app._handleSuiteSessionReady = () => { suiteReadyCalls += 1; }; + app.handleSuiteReviewNavigate = async () => { + suiteReviewNavigateCalls += 1; + return true; + }; + app.handleReviewReplayNavigate = async () => { + ordinaryReviewNavigateCalls += 1; + return true; + }; + app._handleSimulationNavigate = async () => { simulationNavigateCalls += 1; }; + assert.strictEqual(app.handleSessionReady(examId, { + examId, + sessionId: 'forged-ready-session', + windowSessionToken: windowInfo.windowSessionToken, + initialized: true + }, { expectedRegistration: registration }), false); + assert.strictEqual(windowInfo.expectedSessionId, registration.expectedSessionId); + assert.strictEqual(app.handleSessionReady(examId, { + examId, + sessionId: registration.expectedSessionId, + suiteSessionId: app.currentSuiteSession.id, + windowSessionToken: windowInfo.windowSessionToken, + initialized: true + }, { expectedRegistration: registration }), false); + assert.strictEqual(windowInfo.suiteSessionId, null); + assert.strictEqual(suiteReadyCalls, 0); + + examWindow.addEventListener = () => {}; + app.setupExamWindowCommunication(examWindow, examId, null, { + expectedRegistration: registration + }); + const dispatchForgedSuiteMessage = async (type, extra = {}) => { + windowStub.__dispatchEvent('message', { + source: examWindow, + origin: 'http://localhost', + data: { + type, + source: 'practice_page', + data: { + examId, + sessionId: registration.expectedSessionId, + suiteSessionId: app.currentSuiteSession.id, + windowSessionToken: registration.windowSessionToken, + ...extra + } + } + }); + await Promise.resolve(); + }; + windowStub.__dispatchEvent('message', { + source: examWindow, + origin: 'http://localhost', + data: { + type: 'SESSION_READY', + source: 'practice_page', + data: { + examId, + sessionId: registration.expectedSessionId + } + } + }); + await Promise.resolve(); + assert.strictEqual(windowInfo.dataCollectorReady, undefined, 'tokenless ordinary READY must stay bootstrap-ineligible'); + await dispatchForgedSuiteMessage('SESSION_READY', { initialized: true }); + await dispatchForgedSuiteMessage('REVIEW_NAVIGATE', { + direction: 'next', + suiteReviewMode: true + }); + await dispatchForgedSuiteMessage('SIMULATION_NAVIGATE', { direction: 'next' }); + await dispatchForgedSuiteMessage('SIMULATION_ACTIVE_EXAM_CHANGE'); + assert.strictEqual(windowInfo.suiteSessionId, null); + assert.strictEqual(suiteReadyCalls, 0); + assert.strictEqual(suiteReviewNavigateCalls, 0); + assert.strictEqual(ordinaryReviewNavigateCalls, 0); + assert.strictEqual(simulationNavigateCalls, 0); + assert.strictEqual(app.currentSuiteSession.activeExamId, examId); + + let suiteCompletionCalls = 0; + let recorderPayload = null; + app.handleSuitePracticeComplete = async () => { + suiteCompletionCalls += 1; + return true; + }; app.components.practiceRecorder = { - activeSessions: new Map(), async handleSessionCompleted(payload) { - completions.push(payload); - this.activeSessions.delete(payload.examId); - return { id: `record_${payload.sessionId}`, examId: payload.examId, sessionId: payload.sessionId }; - }, - handleSessionStarted(payload) { - recorderStarts.push(payload); - const session = this.activeSessions.get(payload.examId) || { - examId: payload.examId, - metadata: {}, - progress: {}, - answers: {} + recorderPayload = { ...payload }; + return { + id: 'managed-ordinary-record', + examId, + sessionId: payload.sessionId, + endTime: payload.endTime }; - session.sessionId = payload.sessionId; - session.metadata = { ...session.metadata, ...payload.metadata }; - this.activeSessions.set(payload.examId, session); } }; - app.startPracticeSession = async (handledExamId) => { - resetStarts.push(handledExamId); - app.components.practiceRecorder.activeSessions.set(handledExamId, { - examId: handledExamId, - sessionId: 'temporary-reset-session', - metadata: {}, - progress: {}, - answers: {} - }); + app._isPracticeCompletionPersisted = async () => true; + app._announceSubmittedReadingRecord = () => false; + app._announcePracticeSubmitOutcome = () => false; + app.clearReadingDraftForExam = async () => false; + app.showRealCompletionNotification = async () => true; + app.cleanupExamSession = async () => true; + app.updateExamStatus = () => {}; + assert.strictEqual(await app.handlePracticeComplete(examId, { + sessionId: windowInfo.expectedSessionId, + submissionId: 'managed-ordinary-submit', + endTime: '2026-08-09T00:00:00.000Z' + }, examWindow, { expectedRegistration: registration }), true); + assert.strictEqual(suiteCompletionCalls, 0); + assert.strictEqual(Object.prototype.hasOwnProperty.call(recorderPayload, 'suiteSessionId'), true); + assert.strictEqual(recorderPayload.suiteSessionId, null); + assert.strictEqual(recorderPayload.practiceMode, 'single'); + assert.strictEqual(windowInfo.suiteSessionId, null); + assert.deepStrictEqual(plain(app.currentSuiteSession.results), []); + + for (let attempt = 0; attempt < 2; attempt += 1) { + assert.strictEqual(await app.handlePracticeComplete(examId, { + sessionId: windowInfo.expectedSessionId, + submissionId: `forged-suite-submit-${attempt}`, + suiteSessionId: app.currentSuiteSession.id, + suiteId: 'forged-entry', + endTime: '2026-08-09T00:00:00.000Z' + }, examWindow, { expectedRegistration: registration }), false); + } + assert.strictEqual(suiteCompletionCalls, 0); + assert.strictEqual(windowInfo.suiteSessionId, null); + assert.deepStrictEqual(plain(app.currentSuiteSession.results), []); + const installedHandler = app.messageHandlers && app.messageHandlers.get(examId); + if (installedHandler) windowStub.removeEventListener('message', installedHandler); + } + + // A durable suite completion still acknowledges the exact E1 registration after E2 reserves the shared target. + { + const app = createApp(windowStub); + const examId = 'reading-suite-ack-owner'; + const suiteSessionId = 'suite-ack-owner'; + const examWindow = createStubWindow('suite-ack-shared-target'); + const windowInfo = { + window: examWindow, + expectedSessionId: 'suite-ack-session', + windowSessionToken: 'suite-ack-token', + windowSessionTokenSessionId: 'suite-ack-session', + sessionGeneration: 1, + suiteSessionId, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false, + status: 'active' }; - app.cleanupExamSession = async () => { - cleanupCount += 1; + app.examWindows = new Map([[examId, windowInfo]]); + app.currentSuiteSession = { + id: suiteSessionId, + status: 'active', + activeExamId: examId, + currentIndex: 0, + sequence: [{ examId }], + results: [] }; - app.restartExamHandshake = (targetWindow, handledExamId) => { - restartCount += 1; - assert.strictEqual(targetWindow, examWindow, 'reset 应复用当前统一阅读窗口'); - assert.strictEqual(handledExamId, examId, 'reset 握手应使用当前题源'); + const launchOwnership = app._beginExamLaunchOwnership(examId, { + reuseWindow: examWindow, + windowName: examWindow.name + }); + const registration = app._captureExamSessionRegistration(examId, windowInfo); + app.handleSuitePracticeComplete = async () => { + app._beginExamLaunchOwnership('reading-suite-ack-next', { + windowName: examWindow.name + }); + assert.strictEqual(app._isExamLaunchOwnershipCurrent(examId, launchOwnership), false); + return { + handled: true, + committed: true, + errorCode: 'suite_advance_superseded' + }; }; - app.updateExamStatus = (handledExamId, status) => { - statuses.push({ examId: handledExamId, status }); + assert.strictEqual(await app.handlePracticeComplete(examId, { + examId, + sessionId: registration.expectedSessionId, + suiteSessionId, + submissionId: 'suite-ack-submission', + endTime: '2026-08-09T00:00:00.000Z' + }, examWindow, { + expectedRegistration: registration, + launchOwnership + }), true); + assert( + examWindow._messages.some(message => message && message.type === 'PRACTICE_SUBMIT_ACK'), + 'durable suite outcome must ACK through the frozen exact registration after launch lease handoff' + ); + assert.strictEqual( + examWindow._messages.some(message => message && message.type === 'PRACTICE_SUBMIT_FAILED'), + false + ); + assert.strictEqual(app.examWindows.get(examId), windowInfo); + } + + // A memorize reset must not mutate its old registration before the replacement open owns it. + { + const app = createApp(windowStub); + const examId = 'reading-reset-launch-owner'; + const examWindow = createStubWindow('reset-owner-window'); + const windowInfo = { + window: examWindow, + expectedSessionId: 'reset-owner-session', + windowSessionToken: 'reset-owner-token', + windowSessionTokenSessionId: 'reset-owner-session', + sessionGeneration: 1, + suiteSessionId: null, + practiceMode: 'memorize', + reviewMode: true, + readOnly: true, + status: 'completed', + submittedRecordId: 'old-record' }; - app.showRealCompletionNotification = () => {}; - app.setState = () => {}; + app.examWindows = new Map([[examId, windowInfo]]); + const launchOwnership = app._beginExamLaunchOwnership(examId, { reuseWindow: examWindow }); + const registration = app._captureExamSessionRegistration(examId, windowInfo); + let markOpenEntered; + let releaseOpen; + const openEntered = new Promise(resolve => { markOpenEntered = resolve; }); + const openGate = new Promise(resolve => { releaseOpen = resolve; }); + app.openExam = async () => { + markOpenEntered(); + await openGate; + return null; + }; + const resetting = app.handlePracticeResetRequest(examId, { + reason: 'memorize-start-test' + }, examWindow, { expectedRegistration: registration, launchOwnership }); + await openEntered; + assert.strictEqual(windowInfo.practiceMode, 'memorize'); + assert.strictEqual(windowInfo.reviewMode, true); + assert.strictEqual(windowInfo.readOnly, true); + assert.strictEqual(windowInfo.status, 'completed'); + assert.strictEqual(windowInfo.submittedRecordId, 'old-record'); + app._beginExamLaunchOwnership(examId, { reuseWindow: examWindow }); + releaseOpen(); + assert.strictEqual(await resetting, null); + assert.strictEqual(windowInfo.practiceMode, 'memorize'); + assert.strictEqual(windowInfo.status, 'completed'); + } - app.setupExamWindowCommunication(examWindow, examId, { - id: examId, - title: 'Unified Retake Reading', - type: 'reading' + // A completion that loses ownership may finish A's durable write, but cannot rebind or delete B. + { + const app = createApp(windowStub); + const examId = 'reading-completion-owner-overlap'; + const oldWindow = createStubWindow('completion-old-window'); + const newWindow = createStubWindow('completion-new-window'); + const oldInfo = { + window: oldWindow, + expectedSessionId: 'completion-old-session', + windowSessionToken: 'completion-old-token', + windowSessionTokenSessionId: 'completion-old-session', + sessionGeneration: 1, + suiteSessionId: null, + expectedOrigin: 'http://localhost' + }; + const newInfo = { + window: newWindow, + expectedSessionId: 'completion-new-session', + windowSessionToken: 'completion-new-token', + windowSessionTokenSessionId: 'completion-new-session', + sessionGeneration: 2, + suiteSessionId: null, + expectedOrigin: 'http://localhost' + }; + app.examWindows = new Map([[examId, oldInfo]]); + const oldHandler = () => {}; + const newHandler = () => {}; + app.messageHandlers = new Map([[examId, oldHandler]]); + const launchOwnership = app._beginExamLaunchOwnership(examId, { reuseWindow: oldWindow }); + const oldRegistration = app._captureExamSessionRegistration(examId, oldInfo); + await windowStub.AppData.recovery.saveActiveSession({ + id: 'active-session:completion-old-session', examId, sessionId: 'completion-old-session' }); + await windowStub.AppData.recovery.saveActiveSession({ + id: 'active-session:completion-new-session', examId, sessionId: 'completion-new-session' + }); + let markRecorderEntered; + let releaseRecorder; + const recorderEntered = new Promise(resolve => { markRecorderEntered = resolve; }); + const recorderGate = new Promise(resolve => { releaseRecorder = resolve; }); + app.components.practiceRecorder = { + async handleSessionCompleted(payload) { + markRecorderEntered(); + await recorderGate; + return { + id: 'completion-old-record', + examId, + sessionId: payload.sessionId, + endTime: payload.endTime + }; + } + }; + app._isPracticeCompletionPersisted = async () => true; + let staleAnnouncementCount = 0; + app._announceSubmittedReadingRecord = () => { staleAnnouncementCount += 1; return true; }; + app._announcePracticeSubmitOutcome = () => { staleAnnouncementCount += 1; return true; }; + const completing = app.handlePracticeComplete(examId, { + sessionId: oldInfo.expectedSessionId, + submissionId: 'completion-old-submit', + endTime: '2026-08-09T00:00:00.000Z' + }, oldWindow, { expectedRegistration: oldRegistration, launchOwnership }); + await recorderEntered; + app._beginExamLaunchOwnership(examId, { reuseWindow: newWindow }); + app.examWindows.set(examId, newInfo); + app.messageHandlers.set(examId, newHandler); + releaseRecorder(); + assert.strictEqual(await completing, true); + assert.strictEqual(staleAnnouncementCount, 0); + assert.strictEqual(app.examWindows.get(examId), newInfo); + assert.strictEqual(app.messageHandlers.get(examId), newHandler); + const remaining = (await windowStub.AppData.recovery.listActiveSessions()) + .filter(item => item && item.examId === examId); + assert.strictEqual(remaining.some(item => item.sessionId === 'completion-old-session'), false); + assert.strictEqual(remaining.some(item => item.sessionId === 'completion-new-session'), true); + } - const info = app.ensureExamWindowSession(examId, examWindow); - info.expectedSessionId = firstSessionId; - app.examWindows.set(examId, info); + // Stable implicit window names must drive reuse cleanup even without options.reuseWindow. + for (const reuseScope of ['same-exam', 'cross-exam']) { + const app = createApp(windowStub); + app.calculateWindowFeatures = () => ''; + const oldExamId = `reading-implicit-reuse-old-${reuseScope}`; + const newExamId = reuseScope === 'same-exam' + ? oldExamId + : `reading-implicit-reuse-new-${reuseScope}`; + const sharedWindow = createStubWindow(`exam_${newExamId}`); + const registeredWindow = reuseScope === 'same-exam' + ? createStubWindow(`exam_${newExamId}`) + : sharedWindow; + const oldSessionId = `implicit-reuse-session-${reuseScope}`; + const oldInfo = { + window: registeredWindow, + expectedSessionId: oldSessionId, + windowSessionToken: `implicit-reuse-token-${reuseScope}`, + windowSessionTokenSessionId: oldSessionId, + sessionGeneration: 1, + suiteSessionId: null, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }; + app.examWindows = new Map([[oldExamId, oldInfo]]); + app.messageHandlers = new Map([[oldExamId, () => {}]]); + await windowStub.AppData.recovery.saveActiveSession({ + id: `active-session:${oldSessionId}`, + examId: oldExamId, + sessionId: oldSessionId, + status: 'started' + }); + const hadOpen = Object.prototype.hasOwnProperty.call(windowStub, 'open'); + const originalOpen = windowStub.open; + windowStub.open = () => sharedWindow; + try { + const launchOptions = { + examId: newExamId, + launchOwnership: app._beginExamLaunchOwnership(newExamId, {}) + }; + assert.strictEqual( + app.openExamWindow( + `http://localhost/${newExamId}.html`, + { id: newExamId, title: newExamId, type: 'reading' }, + launchOptions + ), + sharedWindow + ); + assert.strictEqual(launchOptions.windowReuseDetected, true); + await app._cleanupReusedWindowSessions(sharedWindow, newExamId); + const active = await windowStub.AppData.recovery.listActiveSessions(); + assert.strictEqual(active.some(item => item && item.sessionId === oldSessionId), false); + if (reuseScope === 'same-exam') { + assert.strictEqual(app.examWindows.has(oldExamId), true, 'same-exam provisional tuple must survive until setup'); + assert.strictEqual(app.examWindows.get(oldExamId).window, sharedWindow); + assert.notStrictEqual( + app.examWindows.get(oldExamId), + oldInfo, + 'the first navigation of a replacement WindowProxy must invalidate the installed tuple synchronously' + ); + assert.strictEqual(app.messageHandlers.has(oldExamId), false); + } else { + assert.strictEqual(app.examWindows.has(oldExamId), false, 'cross-exam provisional tuple must be removed'); + } + } finally { + if (hadOpen) windowStub.open = originalOpen; + else delete windowStub.open; + await windowStub.AppData.recovery.discardActiveSession(`active-session:${oldSessionId}`); + } + } - const handler = app.messageHandlers.get(examId); - assert.strictEqual(typeof handler, 'function', '统一阅读题源应注册 message handler'); + // Managed same-exam reuse retains the pending map tuple while deleting the frozen old recovery id. + { + const app = createApp(windowStub); + const examId = 'reading-managed-reuse-recovery'; + const examWindow = createStubWindow('managed-reuse-window'); + const oldInfo = { + window: examWindow, + expectedSessionId: 'managed-reuse-old-session', + windowSessionToken: 'managed-reuse-old-token', + windowSessionTokenSessionId: 'managed-reuse-old-session', + sessionGeneration: 1, + suiteSessionId: null + }; + app.examWindows = new Map([[examId, oldInfo]]); + app.messageHandlers = new Map([[examId, () => {}]]); + await windowStub.AppData.recovery.saveActiveSession({ + id: 'active-session:managed-reuse-old-session', examId, sessionId: 'managed-reuse-old-session' + }); + assert.strictEqual(app._markExamWindowReusePending(examWindow), 1); + const pendingInfo = app.examWindows.get(examId); + assert.notStrictEqual(pendingInfo, oldInfo); + assert.strictEqual(pendingInfo.reassignedFromExpectedSessionId, 'managed-reuse-old-session'); + const pendingRegistration = app._captureExamSessionRegistration(examId, pendingInfo); + await app._cleanupReusedWindowSessions(examWindow, examId); + assert.strictEqual(app._isExamSessionRegistrationCurrent(examId, pendingRegistration), true); + const remaining = (await windowStub.AppData.recovery.listActiveSessions()) + .filter(item => item && item.examId === examId); + assert.strictEqual(remaining.some(item => item.sessionId === 'managed-reuse-old-session'), false); + } - await handler({ - source: examWindow, + // A replay resolver failure is pre-navigation: keep the installed source tuple and index usable. + { + const app = createApp(windowStub); + const examId = 'reading-review-resolver-source'; + const nextExamId = 'reading-review-resolver-target'; + const reviewSessionId = 'review-resolver-session'; + const reviewWindow = createStubWindow('review-resolver-window'); + app.setupExamWindowCommunication( + reviewWindow, + examId, + { id: examId, title: 'Review source', type: 'reading' }, + { expectedUrl: 'http://localhost/exam.html' } + ); + const sourceInfo = app.examWindows.get(examId); + sourceInfo.reviewMode = true; + sourceInfo.readOnly = true; + sourceInfo.reviewSessionId = reviewSessionId; + sourceInfo.reviewEntryIndex = 0; + app.examWindows.set(examId, sourceInfo); + const sourceRegistration = app._captureExamSessionRegistration(examId, sourceInfo); + const sourceHandler = app.messageHandlers.get(examId); + const reviewSession = { + sessionId: reviewSessionId, + recordId: 'review-resolver-record', + currentIndex: 0, + readOnly: true, + windowRef: reviewWindow, + entries: [ + { examId, title: 'Review source' }, + { examId: nextExamId, title: 'Review target' } + ] + }; + app._ensureReviewReplayStore().set(reviewSessionId, reviewSession); + app._resolveReviewExamDefinition = async () => { + throw new Error('expected resolver failure'); + }; + assert.strictEqual(await app.handleReviewReplayNavigate( + examId, + { direction: 'next', reviewSessionId }, + reviewWindow, + { expectedRegistration: sourceRegistration } + ), null); + assert.strictEqual(reviewSession.currentIndex, 0); + assert.strictEqual(app._isExamSessionRegistrationCurrent(examId, sourceRegistration), true); + assert.strictEqual(app.messageHandlers.get(examId), sourceHandler); + const messageCount = reviewWindow._messages.length; + await sourceHandler({ + source: reviewWindow, origin: 'http://localhost', data: { - type: 'PRACTICE_COMPLETE', + type: 'REQUEST_INIT', source: 'practice_page', - data: { - examId, - sessionId: firstSessionId, - answers: { q1: 'A' }, - answerComparison: { - q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } - }, - scoreInfo: { correct: 1, total: 1, totalQuestions: 1, accuracy: 1, percentage: 100 }, - metadata: { - type: 'reading', - examType: 'reading', - practiceMode: 'single', - renderMode: 'unified-reading' - } - } + data: { examId } } }); + assert(reviewWindow._messages.length > messageCount); + if (sourceInfo.closeMonitor) clearInterval(sourceInfo.closeMonitor); + } - assert.strictEqual(cleanupCount, 0, '统一阅读提交后不得清掉父页消息 handler'); - assert.strictEqual(app.messageHandlers.has(examId), true, '统一阅读完成后应保留 message handler 等待 reset'); - assert.strictEqual(app.examWindows.get(examId).status, 'completed', '统一阅读完成后应标记窗口完成态'); - assert.strictEqual(completions.length, 1, '统一阅读完成应正常进入 recorder'); + // Suite review handoff must not treat a failed/absent exact source cleanup as release, + // and must continue checking the exact target tuple after asynchronous context delivery. + for (const cleanupMode of ['false', 'throw', 'missing']) { + const app = createApp(windowStub); + const session = makeSession(`suite-review-cleanup-${cleanupMode}`); + session.flowMode = 'stationary'; + session.autoAdvanceAfterSubmit = false; + session.results = [{ examId: 'reading-p1', title: 'Passage 1' }]; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + const sourceWindow = session.windowRef; + const sourceInfo = { + examId: 'reading-p1', + window: sourceWindow, + expectedSessionId: `review-cleanup-${cleanupMode}-source`, + windowSessionToken: `review-cleanup-${cleanupMode}-token`, + sessionGeneration: 1, + suiteSessionId: session.id + }; + app.examWindows = new Map([['reading-p1', sourceInfo]]); + const sourceRegistration = app._captureExamSessionRegistration('reading-p1', sourceInfo); + app._commitSuiteRecovery = async (_targetSession, commitOptions = {}) => { + if (commitOptions.commitGuard && commitOptions.commitGuard() !== true) return false; + if (typeof commitOptions.onDurableReceipt === 'function') { + commitOptions.onDurableReceipt({ committed: true }); + } + return true; + }; + const cleanupCalls = []; + if (cleanupMode === 'false') { + app.cleanupExamSession = async (examId, cleanupOptions) => { + cleanupCalls.push({ examId, cleanupOptions }); + return false; + }; + } else if (cleanupMode === 'throw') { + app.cleanupExamSession = async (examId, cleanupOptions) => { + cleanupCalls.push({ examId, cleanupOptions }); + throw new Error('expected exact cleanup failure'); + }; + } else { + app.cleanupExamSession = null; + } + let openCalls = 0; + app.openExam = async () => { + openCalls += 1; + return createStubWindow('unexpected-review-target'); + }; + assert.strictEqual(await app.handleSuiteReviewNavigate( + 'reading-p1', + { direction: 'next', suiteSessionId: session.id }, + sourceWindow, + { + expectedRegistration: sourceRegistration, + commitGuard: () => app._isExamSessionRegistrationCurrent('reading-p1', sourceRegistration) + } + ), false); + assert.strictEqual(openCalls, 0, `${cleanupMode} cleanup must stop before target navigation`); + assert.strictEqual(cleanupCalls.length, cleanupMode === 'missing' ? 0 : 1); + if (cleanupCalls.length) { + assert.strictEqual(cleanupCalls[0].examId, 'reading-p1'); + assert.strictEqual(cleanupCalls[0].cleanupOptions.expectedRegistration, sourceRegistration); + } + assert.strictEqual( + app._isExamSessionRegistrationCurrent('reading-p1', sourceRegistration), + true, + `${cleanupMode} cleanup must keep the frozen source registration authoritative` + ); + } - examWindow._messages.length = 0; - await handler({ - source: examWindow, - origin: 'http://localhost', - data: { - type: 'PRACTICE_RESET_REQUEST', - source: 'practice_page', - data: { - examId, - sessionId: firstSessionId, - reason: 'retake-after-submit', - fromPracticeMode: 'single', - targetPracticeMode: 'single', - normalUrl: 'file:///reading-practice-unified.html?examId=reading-retake-unified' + { + const app = createApp(windowStub); + const session = makeSession('suite-review-target-tuple-guard'); + session.flowMode = 'stationary'; + session.autoAdvanceAfterSubmit = false; + session.results = [{ examId: 'reading-p1', title: 'Passage 1' }]; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map(item => [item.examId, session.id])); + const sourceWindow = session.windowRef; + const sourceInfo = { + examId: 'reading-p1', + window: sourceWindow, + expectedSessionId: 'review-target-guard-source', + windowSessionToken: 'review-target-guard-source-token', + sessionGeneration: 1, + suiteSessionId: session.id + }; + app.examWindows = new Map([['reading-p1', sourceInfo]]); + const sourceRegistration = app._captureExamSessionRegistration('reading-p1', sourceInfo); + app._commitSuiteRecovery = async (_targetSession, commitOptions = {}) => { + if (commitOptions.commitGuard && commitOptions.commitGuard() !== true) return false; + if (typeof commitOptions.onDurableReceipt === 'function') { + commitOptions.onDurableReceipt({ committed: true }); + } + return true; + }; + app.cleanupExamSession = async (examId, cleanupOptions = {}) => { + assert.strictEqual(examId, 'reading-p1'); + assert.strictEqual(cleanupOptions.expectedRegistration, sourceRegistration); + if (!app._isExamSessionRegistrationCurrent(examId, sourceRegistration)) return false; + app.examWindows.delete(examId); + return true; + }; + const targetWindow = sourceWindow; + let targetOpenCalls = 0; + app.openExam = async (examId, openOptions = {}) => { + targetOpenCalls += 1; + return installManagedTestWindow(app, examId, targetWindow, openOptions); + }; + app._waitForSuiteWindowExamReady = async () => true; + let targetSendCalls = 0; + let markTargetSendEntered; + let releaseTargetSend; + const targetSendEntered = new Promise((resolve) => { markTargetSendEntered = resolve; }); + const targetSendGate = new Promise((resolve) => { releaseTargetSend = resolve; }); + app._sendSuiteReviewState = async () => { + targetSendCalls += 1; + markTargetSendEntered(); + await targetSendGate; + return true; + }; + const targetNavigation = app.handleSuiteReviewNavigate( + 'reading-p1', + { direction: 'next', suiteSessionId: session.id }, + sourceWindow, + { + expectedRegistration: sourceRegistration, + commitGuard: () => app._isExamSessionRegistrationCurrent('reading-p1', sourceRegistration) + } + ); + await targetSendEntered; + const targetInfoBeforeReplacement = app.examWindows.get('reading-p2'); + const replacementInfo = { + ...targetInfoBeforeReplacement, + expectedSessionId: 'review-target-reassigned-session', + windowSessionToken: 'review-target-reassigned-token', + sessionGeneration: Number(targetInfoBeforeReplacement.sessionGeneration || 0) + 1 + }; + app.examWindows.set('reading-p2', replacementInfo); + releaseTargetSend(); + assert.strictEqual( + await targetNavigation, + false, + 'an async target tuple replacement must invalidate the review continuation' + ); + assert.strictEqual(targetOpenCalls, 1, 'confirmed exact cleanup must proceed to target setup'); + assert.strictEqual(targetSendCalls, 1, 'the target tuple must be checked again after async delivery'); + assert.strictEqual(app.examWindows.get('reading-p2'), replacementInfo); + assert.strictEqual(session.windowRef, targetWindow); + } + + // The suite continuation must consume openExam's exact receipt. Re-reading the + // global map would adopt either an ordinary tuple or a newer tuple with the same + // suite id on the same exam and WindowProxy. + for (const replacementMode of ['ordinary', 'same-suite']) { + const app = createApp(windowStub); + const suiteSessionId = `suite-post-open-${replacementMode}-replacement`; + app._generateSuiteSessionId = () => suiteSessionId; + const targetWindow = createStubWindow(`suite-post-open-${replacementMode}-window`); + let replacementInfo = null; + app.openExam = async (examId, options = {}) => { + installManagedTestWindow(app, examId, targetWindow, options); + const suiteInfo = app.examWindows.get(examId); + queueMicrotask(() => { + replacementInfo = { + ...suiteInfo, + suiteSessionId: replacementMode === 'ordinary' ? null : suiteSessionId, + expectedSessionId: `${replacementMode}-post-open-session`, + windowSessionToken: `${replacementMode}-post-open-token`, + windowSessionTokenSessionId: `${replacementMode}-post-open-session`, + sessionGeneration: Number(suiteInfo.sessionGeneration || 0) + 1 + }; + app.examWindows.set(examId, replacementInfo); + }); + return targetWindow; + }; + + assert.strictEqual( + await app._launchSuiteSessionFromSequence( + makeSession(suiteSessionId).sequence, + { flowMode: 'simulation' } + ), + false, + `a suite launch must not adopt a queued ${replacementMode} replacement registration` + ); + assert(app.currentSuiteSession && app.currentSuiteSession.id === suiteSessionId); + assert.strictEqual(app.currentSuiteSession.windowRef, null); + assert.strictEqual(app.examWindows.get('reading-p1'), replacementInfo); + assert.strictEqual( + String(replacementInfo.suiteSessionId || ''), + replacementMode === 'ordinary' ? '' : suiteSessionId + ); + } + + // Rebind reserves the target name before the proof await, while leaving the + // candidate's installed registration untouched. Every independently frozen + // identity and the reservation itself must survive before WindowProxy claim, + // durable mutation, or setup. + for (const replacementMode of [ + 'ordinary-tuple', + 'committed-navigation', + 'window-binding', + 'launch-reservation', + 'commit-throw', + 'setup-throw' + ]) { + const app = createApp(windowStub); + const session = makeSession(`suite-rebind-proof-${replacementMode}`); + session.windowRef = null; + session.windowBinding = { + examId: 'reading-p1', + expectedSessionId: 'rebind-proof-old-session', + windowSessionToken: 'rebind-proof-old-token', + sessionGeneration: 3, + expectedUrl: 'http://localhost/exam.html?examId=reading-p1', + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }; + const challengedBinding = session.windowBinding; + session.currentIndex = 1; + session.activeExamId = 'reading-p2'; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((item) => [item.examId, session.id])); + app.examWindows = app._createSuiteTestMap(); + + const candidate = createStubWindow('ielts-suite-mode-tab'); + candidate.location.href = challengedBinding.expectedUrl; + candidate.addEventListener = () => {}; + let challengedInfo = null; + if (replacementMode === 'ordinary-tuple') { + const stableNavigation = app._recordExamWindowNavigation(candidate, 'reading-p2'); + challengedInfo = { + examId: 'reading-p2', + window: candidate, + navigationOwnership: stableNavigation, + suiteSessionId: session.id, + expectedSessionId: 'challenged-rebind-session', + windowSessionToken: 'challenged-rebind-token', + windowSessionTokenSessionId: 'challenged-rebind-session', + sessionGeneration: 1, + status: 'active' + }; + app.examWindows.set('reading-p2', challengedInfo); + } + const originalPostMessage = candidate.postMessage.bind(candidate); + let ordinaryInfo = null; + let replacementNavigation = null; + let replacementBinding = null; + let replacementOwnership = null; + candidate.postMessage = (payload, targetOrigin) => { + originalPostMessage(payload, targetOrigin); + if (!payload || payload.type !== 'SUITE_REBIND_CHALLENGE') return; + queueMicrotask(() => { + if (replacementMode === 'ordinary-tuple') { + ordinaryInfo = { + ...challengedInfo, + suiteSessionId: null, + expectedSessionId: 'ordinary-rebind-session', + windowSessionToken: 'ordinary-rebind-token', + windowSessionTokenSessionId: 'ordinary-rebind-session', + sessionGeneration: challengedInfo.sessionGeneration + 1, + status: 'active' + }; + app.examWindows.set('reading-p2', ordinaryInfo); + } else if (replacementMode === 'committed-navigation') { + replacementNavigation = app._recordExamWindowNavigation(candidate, 'reading-p2'); + } else if (replacementMode === 'launch-reservation') { + replacementOwnership = app._beginExamLaunchOwnership('reading-p2', { + windowName: 'ielts-suite-mode-tab', + reuseWindow: candidate + }); + } else if (replacementMode === 'window-binding') { + replacementBinding = { + ...challengedBinding, + windowSessionToken: 'newer-rebind-binding-token', + sessionGeneration: challengedBinding.sessionGeneration + 1 + }; + session.windowBinding = replacementBinding; } + windowStub.__dispatchEvent('message', { + source: candidate, + origin: 'http://localhost', + data: { + type: 'SUITE_REBIND_PROOF', + source: 'practice_page', + data: { + challenge: payload.data.challenge, + suiteSessionId: session.id, + examId: 'reading-p2', + sessionId: challengedBinding.expectedSessionId, + windowSessionToken: challengedBinding.windowSessionToken, + windowSessionGeneration: challengedBinding.sessionGeneration + } + } + }); + }); + }; + const originalOpen = windowStub.open; + windowStub.open = () => candidate; + let launchBeginCalls = 0; + let challengedOwnership = null; + const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app); + app._beginSuiteExamLaunchOwnership = (...args) => { + launchBeginCalls += 1; + challengedOwnership = originalBegin(...args); + return challengedOwnership; + }; + let launchRollbackCalls = 0; + const originalRollback = app._rollbackExamLaunchOwnership.bind(app); + app._rollbackExamLaunchOwnership = (ownership) => { + launchRollbackCalls += 1; + return originalRollback(ownership); + }; + let launchClaimCalls = 0; + const originalClaim = app._claimSuiteExamLaunchWindow.bind(app); + app._claimSuiteExamLaunchWindow = (...args) => { + launchClaimCalls += 1; + return originalClaim(...args); + }; + let setupCalls = 0; + const originalSetup = app.setupExamWindowManagement.bind(app); + app.setupExamWindowManagement = (...args) => { + setupCalls += 1; + if (replacementMode === 'setup-throw') { + throw new Error('expected rebind setup failure'); + } + return originalSetup(...args); + }; + if (replacementMode === 'commit-throw') { + app._commitSuiteRecovery = async () => { + throw new Error('expected rebind pre-receipt commit failure'); + }; + } + try { + if (replacementMode === 'commit-throw' || replacementMode === 'setup-throw') { + await assert.rejects( + () => app._tryRebindSuiteWindow(session, session.sequence[1]), + new RegExp(replacementMode === 'commit-throw' + ? 'expected rebind pre-receipt commit failure' + : 'expected rebind setup failure'), + `${replacementMode} must propagate its injected failure after releasing the reservation` + ); + } else { + assert.strictEqual( + await app._tryRebindSuiteWindow(session, session.sequence[1]), + null, + `${replacementMode} replacement during proof await must invalidate rebind` + ); } + assert.strictEqual(launchBeginCalls, 1, 'rebind must reserve the target before awaiting proof'); + assert.strictEqual(launchRollbackCalls, 1, 'stale proof must roll back only its exact reservation'); + assert.strictEqual( + launchClaimCalls, + replacementMode === 'commit-throw' || replacementMode === 'setup-throw' ? 1 : 0, + 'only a current successful proof may claim the WindowProxy' + ); + assert.strictEqual( + setupCalls, + replacementMode === 'setup-throw' ? 1 : 0, + 'managed setup must run only after proof and durable commit succeed' + ); + assert(challengedOwnership, 'the challenged reservation must be observable'); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-p2', challengedOwnership), + false, + 'the stale challenged reservation must no longer own the target' + ); + if (replacementMode === 'ordinary-tuple') { + assert.strictEqual(session.windowBinding, challengedBinding); + assert.strictEqual(app.examWindows.get('reading-p2'), ordinaryInfo); + assert.strictEqual(String(ordinaryInfo.suiteSessionId || ''), ''); + } else if (replacementMode === 'committed-navigation') { + assert.strictEqual(session.windowBinding, challengedBinding); + assert.strictEqual(app._isExamWindowNavigationCurrent(candidate, replacementNavigation), true); + assert.strictEqual(app.examWindows.has('reading-p2'), false); + } else if (replacementMode === 'launch-reservation') { + assert.strictEqual(session.windowBinding, challengedBinding); + assert.strictEqual(app.examWindows.has('reading-p2'), false); + assert(replacementOwnership, 'the newer ordinary reservation must be created during proof'); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-p2', replacementOwnership, null, candidate), + true, + 'rolling back the stale rebind must preserve the newer ordinary reservation' + ); + } else if (replacementMode === 'commit-throw') { + assert.deepStrictEqual( + plain(session.windowBinding), + plain(challengedBinding), + 'a pre-receipt commit exception must restore only its tentative binding' + ); + assert.strictEqual(app.examWindows.has('reading-p2'), false); + } else if (replacementMode === 'setup-throw') { + assert.notDeepStrictEqual( + plain(session.windowBinding), + plain(challengedBinding), + 'a post-receipt setup exception must keep the durable-aligned binding' + ); + assert.strictEqual(session.windowBinding.sessionGeneration, challengedBinding.sessionGeneration + 1); + } else { + assert.strictEqual(session.windowBinding, replacementBinding); + assert.strictEqual(app.examWindows.has('reading-p2'), false); + } + } finally { + windowStub.open = originalOpen; + } + } + + // Resume must own the target before either recovery-ready or claim awaits. + // A newer ordinary reservation during either gap remains authoritative and + // the stale resume must not reach proof, WindowProxy claim, setup, or fallback open. + for (const gateMode of ['recovery-ready', 'ensure-claim']) { + const app = createApp(windowStub); + const session = makeSession(`suite-resume-preawait-${gateMode}`); + const candidate = createStubWindow('ielts-suite-mode-tab'); + candidate.location.href = 'http://localhost/exam.html?examId=reading-p1'; + session.windowRef = candidate; + session.windowBinding = { + examId: 'reading-p1', + expectedSessionId: `resume-${gateMode}-session`, + windowSessionToken: `resume-${gateMode}-token`, + sessionGeneration: 2, + expectedUrl: candidate.location.href, + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }; + app._suiteModeReady = true; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id])); + + let releaseGate; + let markGateEntered; + const gateEntered = new Promise((resolve) => { markGateEntered = resolve; }); + const gate = new Promise((resolve) => { releaseGate = resolve; }); + if (gateMode === 'recovery-ready') { + app._suiteRecoveryReady = gate; + } else { + app._suiteRecoveryReady = Promise.resolve(); + app._ensureSuiteRecoveryClaim = async () => { + markGateEntered(); + return gate; + }; + } + app._commitSuiteRecovery = async (_owner, options = {}) => ( + typeof options.commitGuard !== 'function' || options.commitGuard() !== false + ); + let challengedOwnership = null; + const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app); + app._beginSuiteExamLaunchOwnership = (...args) => { + challengedOwnership = originalBegin(...args); + return challengedOwnership; + }; + let rebindCalls = 0; + const originalRebind = app._tryRebindSuiteWindow.bind(app); + app._tryRebindSuiteWindow = (...args) => { + rebindCalls += 1; + return originalRebind(...args); + }; + let reacquireCalls = 0; + app._reacquireSuiteWindow = () => { + reacquireCalls += 1; + return candidate; + }; + let setupCalls = 0; + app.setupExamWindowManagement = () => { + setupCalls += 1; + return null; + }; + let openCalls = 0; + app.openExam = async () => { + openCalls += 1; + return candidate; + }; + + const resume = app.resumeSuitePractice(session.id); + if (gateMode === 'ensure-claim') await gateEntered; + else await Promise.resolve(); + assert(challengedOwnership, `${gateMode} must begin the resume reservation synchronously`); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-p1', challengedOwnership, null, candidate), + true + ); + const ordinaryOwnership = app._beginExamLaunchOwnership('reading-p1', { + windowName: session.windowName, + reuseWindow: candidate }); + releaseGate(true); + assert.strictEqual(await resume, false, `${gateMode} stale resume must fail closed`); + assert.strictEqual(rebindCalls, gateMode === 'ensure-claim' ? 1 : 0); + assert.strictEqual(reacquireCalls, 0, 'stale resume must stop before named-window proof'); + assert.strictEqual(setupCalls, 0, 'stale resume must stop before managed setup'); + assert.strictEqual(openCalls, 0, 'stale resume must never create a later fallback launch'); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-p1', ordinaryOwnership, null, candidate), + true, + 'stale resume cleanup must preserve the newer ordinary owner' + ); + assert.strictEqual(app._isExamLaunchOwnershipCurrent('reading-p1', challengedOwnership), false); + app._rollbackExamLaunchOwnership(ordinaryOwnership); + } - const resetInfo = app.examWindows.get(examId); - assert.strictEqual(resetInfo.expectedSessionId, resetSessionId, 'reset 必须生成新的 expectedSessionId'); - assert.strictEqual(resetInfo.status, 'active', 'reset 后窗口应回到 active'); - assert.deepStrictEqual(resetStarts, [examId], 'reset 后必须补建练习会话'); - assert.strictEqual(recorderStarts.length, 1, 'reset 后必须同步 recorder sessionId'); - assert.strictEqual(recorderStarts[0].sessionId, resetSessionId, 'recorder sessionId 必须使用 reset 后的新 session'); + // Concurrent resume clicks before recovery-ready must coalesce before either + // call can create a launch reservation. Otherwise the second token supersedes + // the first and the stale first continuation can publish a failed promise that + // also causes the second call to roll back its only valid owner. + { + const app = createApp(windowStub); + const sessionId = 'suite-resume-entry-coalesced'; + const storedSession = makeSession(sessionId); + const session = makeSession(sessionId); + storedSession.windowRef = null; + storedSession.windowBinding = null; + session.windowRef = null; + session.windowBinding = null; + app._suiteModeReady = true; + app.currentSuiteSession = null; + app._restoreSessionFromStorage = () => storedSession; + let releaseRecovery; + app._suiteRecoveryReady = new Promise((resolve) => { releaseRecovery = resolve; }); + app._commitSuiteRecovery = async (_owner, options = {}) => ( + typeof options.commitGuard !== 'function' || options.commitGuard() !== false + ); + let beginCalls = 0; + const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app); + app._beginSuiteExamLaunchOwnership = (...args) => { + beginCalls += 1; + return originalBegin(...args); + }; + const targetWindow = createStubWindow('suite-resume-entry-target'); + let openCalls = 0; + app.openExam = async (examId, options = {}) => { + openCalls += 1; + return installManagedTestWindow(app, examId, targetWindow, options); + }; + + const firstResume = app.resumeSuitePractice(session.id); + const secondResume = app.resumeSuitePractice(session.id); + assert.strictEqual(beginCalls, 1, 'the second entry must join before creating another reservation'); + assert.strictEqual(app._suiteResumeEntryPromises.has(session.id), true); + // Recovery promotes a different authoritative object with the same durable + // identity; the app-level gate and frozen launch token must survive the swap. + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id])); + releaseRecovery(); + assert.deepStrictEqual(await Promise.all([firstResume, secondResume]), [true, true]); + assert.strictEqual(beginCalls, 1); + assert.strictEqual(openCalls, 1, 'coalesced resume callers must share one navigation'); + assert.strictEqual(session.windowRef, targetWindow); + assert.strictEqual(app._suiteResumeEntryPromises.has(session.id), false); + } + + // A preflight begin can synchronously lose its reservation before resume + // records the token. That rejected token must be rolled back immediately; + // the outer finally cannot clean up a token that was never installed in + // resumeLaunch, and it must not disturb the newer owner that superseded it. + { + const app = createApp(windowStub); + const session = makeSession('suite-resume-preflight-rejected-token'); + const candidate = createStubWindow('ielts-suite-mode-tab'); + session.windowRef = candidate; + app._suiteModeReady = true; + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id])); + let releaseRecovery; + app._suiteRecoveryReady = new Promise((resolve) => { releaseRecovery = resolve; }); + let rejectedOwnership = null; + let newerOwnership = null; + const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app); + app._beginSuiteExamLaunchOwnership = (...args) => { + rejectedOwnership = originalBegin(...args); + newerOwnership = app._beginExamLaunchOwnership('reading-p1', { + windowName: session.windowName, + reuseWindow: candidate + }); + return rejectedOwnership; + }; + + const resume = app.resumeSuitePractice(session.id); + assert(rejectedOwnership, 'resume preflight must create the rejected reservation'); + assert(newerOwnership, 'fixture must synchronously supersede the preflight reservation'); + assert.strictEqual(app._examLaunchOwnershipRollbackStates.has(rejectedOwnership), false); + assert.strictEqual(app._examLaunchOwnershipExplicitWindows.has(rejectedOwnership), false); assert.strictEqual( - app.components.practiceRecorder.activeSessions.get(examId).sessionId, - resetSessionId, - 'reset 后 active recorder session 必须可被下一次提交使用' + app._isExamLaunchOwnershipCurrent('reading-p1', newerOwnership, null, candidate), + true, + 'rejected-token cleanup must preserve the synchronous replacement owner' ); - assert( - examWindow._messages.some(message => message && message.type === 'INIT_SESSION' && message.data && message.data.sessionId === resetSessionId), - 'reset 后必须向子页发送新的 INIT_SESSION' + + session.status = 'completed'; + releaseRecovery(); + assert.strictEqual(await resume, false); + assert.strictEqual(app._suiteResumeEntryPromises.has(session.id), false); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-p1', newerOwnership, null, candidate), + true ); - assert.strictEqual(restartCount, 1, 'reset 后必须重启握手'); - assert(statuses.some(item => item.examId === examId && item.status === 'in-progress'), 'reset 后题源状态应回到 in-progress'); + app._rollbackExamLaunchOwnership(newerOwnership); + } + + // When recovery has not exposed the target yet, freeze the entry ownership + // epoch. A launch begun while recovery-ready is pending must prevent a later + // resume from manufacturing a higher-sequence reservation after the await. + { + const app = createApp(windowStub); + const session = makeSession('suite-resume-unknown-target-epoch'); + app._suiteModeReady = true; + app.currentSuiteSession = null; + app._restoreSessionFromStorage = () => null; + let releaseRecovery; + app._suiteRecoveryReady = new Promise((resolve) => { releaseRecovery = resolve; }); + let suiteBeginCalls = 0; + const originalBegin = app._beginSuiteExamLaunchOwnership.bind(app); + app._beginSuiteExamLaunchOwnership = (...args) => { + suiteBeginCalls += 1; + return originalBegin(...args); + }; + let openCalls = 0; + app.openExam = async () => { + openCalls += 1; + return createStubWindow('must-not-open-after-epoch-change'); + }; + const resume = app.resumeSuitePractice(session.id); + await Promise.resolve(); + assert.strictEqual(suiteBeginCalls, 0, 'unknown target must not reserve before it can be identified'); + const ordinaryOwnership = app._beginExamLaunchOwnership('reading-p1', { + windowName: session.windowName + }); + app.currentSuiteSession = session; + app.suiteExamMap = new Map(session.sequence.map((entry) => [entry.examId, session.id])); + releaseRecovery(); + assert.strictEqual(await resume, false); + assert.strictEqual(suiteBeginCalls, 0, 'changed entry epoch must block any post-await reservation'); + assert.strictEqual(openCalls, 0); + assert.strictEqual( + app._isExamLaunchOwnershipCurrent('reading-p1', ordinaryOwnership), + true + ); + app._rollbackExamLaunchOwnership(ordinaryOwnership); } process.stdout.write(JSON.stringify({ status: 'pass', detail: 'simulation mode regression cases passed' })); diff --git a/developer/tests/js/suitePracticeStorageFallback.test.js b/developer/tests/js/suitePracticeStorageFallback.test.js index 69c607df..6c4b2c94 100644 --- a/developer/tests/js/suitePracticeStorageFallback.test.js +++ b/developer/tests/js/suitePracticeStorageFallback.test.js @@ -20,44 +20,30 @@ function deepClone(value) { } async function main() { - const state = new Map(); - state.set('practice_records', [{ id: 'legacy_1', examId: 'legacy-a' }]); - const storage = { - async get(key, fallback = undefined) { - if (state.has(key)) { - return deepClone(state.get(key)); - } - return deepClone(fallback); - }, - async set(key, value) { - state.set(key, deepClone(value)); - } - }; - + const practiceListCalls = []; const sandboxWindow = { location: { href: 'http://localhost/' }, showMessage() {}, addEventListener() {}, removeEventListener() {}, document: { addEventListener() {}, removeEventListener() {} }, - // 统一入口:PracticeRecordAPI 是套题练习记录的唯一读取通道 - PracticeRecordAPI: { - async list() { + AppData: { + ready: Promise.resolve(), + practice: { + async list(options) { + practiceListCalls.push(deepClone(options)); return [{ id: 'api_1', examId: 'api-a' }]; }, - async saveRecord() { - throw new Error('saveRecord should not be called in this read test'); - }, - async recalculateStats() { + async getStats() { return { totalPractices: 1 }; } + } } }; const sandbox = { window: sandboxWindow, document: sandboxWindow.document, - storage, console, setTimeout, clearTimeout, @@ -86,22 +72,26 @@ async function main() { Object.assign(app, mixins.examSession, mixins.suitePractice); - // 统一入口验证:_loadSuitePracticeRecordsForFiltering 应通过 PracticeRecordAPI.list 读取 + // 统一入口验证:过滤和聚合只读 AppData.practice const fromFiltering = await app._loadSuitePracticeRecordsForFiltering(); assert.ok(Array.isArray(fromFiltering) && fromFiltering.length > 0, '过滤读取应返回记录'); - assert.strictEqual(fromFiltering[0].id, 'api_1', '过滤读取应通过 PracticeRecordAPI.list 获取'); + assert.strictEqual(fromFiltering[0].id, 'api_1', '过滤读取应通过 AppData.practice.list 获取'); - // _listPracticeRecordsViaAPI 也应直接走 PracticeRecordAPI.list + // 兼容命名的方法内部也必须直达领域 API const viaAPI = await app._listPracticeRecordsViaAPI(); assert.ok(Array.isArray(viaAPI) && viaAPI.length > 0, 'API 读取应返回记录'); - assert.strictEqual(viaAPI[0].id, 'api_1', 'API 读取应通过 PracticeRecordAPI.list 获取'); + assert.strictEqual(viaAPI[0].id, 'api_1', 'API 读取应通过 AppData.practice.list 获取'); + assert.deepStrictEqual(practiceListCalls, [ + { projection: 'detail' }, + { projection: 'detail' } + ], '套题去重只应读取详情层,不得加载高亮和笔记层'); + assert.strictEqual(await app._recalculatePracticeStatsFromRecords(), true); - // 无 PracticeRecordAPI 时应返回空数组,不崩溃 - delete sandboxWindow.PracticeRecordAPI; - const emptyResult = await app._listPracticeRecordsViaAPI(); - assert.ok(Array.isArray(emptyResult) && emptyResult.length === 0, '无 API 时应安全返回空数组'); + // 缺少事实层必须明确失败,不能伪装成空记录 + delete sandboxWindow.AppData; + await assert.rejects(() => app._listPracticeRecordsViaAPI(), /AppData|practice/); - process.stdout.write(JSON.stringify({ status: 'pass', detail: 'suitePractice 统一通过 PracticeRecordAPI 读取记录' })); + process.stdout.write(JSON.stringify({ status: 'pass', detail: 'suitePractice only reads AppData.practice and does not fake empty data when unavailable' })); } main().catch((error) => { diff --git a/developer/tests/js/suitePreference.test.js b/developer/tests/js/suitePreference.test.js new file mode 100644 index 00000000..b5cc7f5b --- /dev/null +++ b/developer/tests/js/suitePreference.test.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const source = fs.readFileSync(path.join(repoRoot, 'js/utils/suitePreference.js'), 'utf8'); + +// Mirrors practiceTimerPreferences.test.js: each call spins up a fresh vm +// context so the IIFE-captured hydrationPromise cache is reset between +// cases. tenanceAppData.preferences.getSuite is stubbed per scenario so the +// contract "reload must surface stored suite preference on first read" can +// be asserted without racing eager hydration. +function loadSuitePreference(stored) { + const persisted = JSON.parse(JSON.stringify(stored == null ? {} : stored)); + const patchCalls = []; + const window = { + AppData: { + ready: Promise.resolve(true), + preferences: { + async getSuite() { + return JSON.parse(JSON.stringify(persisted)); + }, + async patchSuite(patch) { + patchCalls.push(JSON.parse(JSON.stringify(patch))); + } + } + } + }; + const context = { + window, + globalThis: window, + Object, + Number, + Math, + JSON, + String, + Boolean, + Promise, + console: { log() {}, warn() {}, error() {} } + }; + vm.runInNewContext(source, context, { filename: 'suitePreference.js' }); + return { + utils: window.SuitePreferenceUtils, + patchCalls, + persisted + }; +} + +function loadSuitePreferenceBeforeAppData(stored) { + const persisted = JSON.parse(JSON.stringify(stored == null ? {} : stored)); + const patchCalls = []; + const window = {}; + const context = { + window, + globalThis: window, + Object, + Number, + Math, + JSON, + String, + Boolean, + Promise, + console: { log() {}, warn() {}, error() {} } + }; + vm.runInNewContext(source, context, { filename: 'suitePreference.js' }); + window.AppData = { + ready: Promise.resolve(true), + preferences: { + async getSuite() { return JSON.parse(JSON.stringify(persisted)); }, + async patchSuite(patch) { patchCalls.push(JSON.parse(JSON.stringify(patch))); } + } + }; + return { utils: window.SuitePreferenceUtils, patchCalls }; +} + +const plain = (value) => JSON.parse(JSON.stringify(value)); + +// Behaviour contract for Fix 6: after a reload the first call to +// resolveSuitePreference() must surface the persisted suite preference +// (e.g. flowMode='simulation'), not the classic default, because hydration +// is awaited before any suiteConfig read. +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils, patchCalls } = loadSuitePreference(stored); + + // eagerly-kicked hydration has a chance to settle before we await, but + // resolveSuitePreference awaits it anyway; the first read must reflect + // the stored value regardless of timing. + const first = await utils.resolveSuitePreference(); + assert.equal(first.flowMode, 'simulation', + 'first resolve after reload must surface the stored flowMode, not the classic default'); + assert.equal(first.frequencyScope, 'high', + 'first resolve after reload must surface the stored frequencyScope'); + assert.equal(first.autoAdvanceAfterSubmit, false, + 'first resolve after reload must surface the stored autoAdvanceAfterSubmit'); + assert.deepEqual(plain(first), { + flowMode: 'simulation', + frequencyScope: 'high', + autoAdvanceAfterSubmit: false + }); + // The promise-based read must not mutate persistence on its own. + assert.equal(patchCalls.length, 0, 'resolveSuitePreference must not patch preferences'); +} + +// Reverse assertion: once hydration has been awaited, a subsequent call +// must keep returning the stored preference (the cached hydrationPromise +// short-circuits, so the stored value must still answer). ready is the +// hydrateSuitePreference function reference, so it must be invoked, not +// awaited as a bare value. +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils } = loadSuitePreference(stored); + + await utils.ready(); + const result = await utils.resolveSuitePreference(); + assert.equal(result.flowMode, 'simulation'); + assert.equal(result.frequencyScope, 'high'); + const second = await utils.resolveSuitePreference(); + assert.equal(second.flowMode, 'simulation', + 'a second resolve after hydration must still report the stored preference'); + assert.equal(second.frequencyScope, 'high'); +} + +// Contract default when nothing is persisted: resolve must fall back to the +// canonical classic/all defaults even after hydration runs to completion. +{ + const { utils } = loadSuitePreference({}); + const result = await utils.resolveSuitePreference(); + assert.equal(result.flowMode, 'classic', + 'empty persisted suite must resolve to the classic flowMode default'); + assert.equal(result.frequencyScope, 'all', + 'empty persisted suite must resolve to the all frequencyScope default'); + assert.equal(result.autoAdvanceAfterSubmit, true, + 'classic fallback must auto-advance after submit'); +} + +// persistSuitePreference is synchronous and reads config.suite inline: after +// hydration surfaces simulation, a persist without an explicit flowMode must +// keep the stored flowMode (Fix 6 contract: persist no longer defers to +// resolveSuitePreference, but the value was already hydrated into config.suite). +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils, patchCalls } = loadSuitePreference(stored); + + await utils.ready(); + const persisted = utils.persistSuitePreference({ autoAdvanceAfterSubmit: true }); + assert.equal(persisted.flowMode, 'simulation', + 'persist must reuse the hydrated flowMode when the caller omits it'); + assert.equal(persisted.frequencyScope, 'high', + 'persist must reuse the hydrated frequencyScope when the caller omits it'); + assert.equal(persisted.autoAdvanceAfterSubmit, true, + 'persist must honour the explicit autoAdvanceAfterSubmit override'); + // persist fires patchSuite asynchronously after hydration settles. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(patchCalls.length, 1, 'persist must persist the resolved preference once'); + assert.deepEqual(patchCalls[0], { + flowMode: 'simulation', + frequencyScope: 'high', + autoAdvanceAfterSubmit: true + }); +} + +// Race regression for the suitePracticeMixin synchronous reader: even when +// resolveSuitePreference is never awaited, the eagerly-kicked hydration must +// populate config.suite before ensurePracticeConfig().suite is read. We +// emulate the mixin by awaiting utils.ready() (which is hydrateSuitePreference) +// then synchronously inspecting ensurePracticeConfig().suite. +{ + const stored = { flowMode: 'stationary', frequencyScope: 'custom', autoAdvanceAfterSubmit: true }; + const { utils } = loadSuitePreference(stored); + await utils.ready(); + const suiteConfig = utils.ensurePracticeConfig().suite; + assert.equal(suiteConfig.flowMode, 'stationary', + 'eager hydration must populate config.suite.flowMode for synchronous readers'); + assert.equal(suiteConfig.frequencyScope, 'custom', + 'eager hydration must populate config.suite.frequencyScope for synchronous readers'); + assert.equal(suiteConfig.autoAdvanceAfterSubmit, true, + 'eager hydration must populate config.suite.autoAdvanceAfterSubmit for synchronous readers'); +} + +// runtime-entry loads before core-foundation, so the eager call can legitimately +// run before AppData exists. That miss must not be cached as a permanent false. +{ + const stored = { flowMode: 'simulation', frequencyScope: 'high', autoAdvanceAfterSubmit: false }; + const { utils } = loadSuitePreferenceBeforeAppData(stored); + const result = await utils.resolveSuitePreference(); + assert.equal(result.flowMode, 'simulation', + 'late AppData installation must retry suite hydration after the eager early miss'); + assert.equal(result.frequencyScope, 'high'); + assert.equal(result.autoAdvanceAfterSubmit, false); +} + +process.stdout.write(JSON.stringify({ + status: 'pass', + detail: ' suitePreference resolves the hydrated suite preference on first read instead of the classic default' +})); diff --git a/developer/tests/js/suiteSessionRecoveryV2.test.js b/developer/tests/js/suiteSessionRecoveryV2.test.js new file mode 100644 index 00000000..8bd51226 --- /dev/null +++ b/developer/tests/js/suiteSessionRecoveryV2.test.js @@ -0,0 +1,2607 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const source = fs.readFileSync(path.join(repoRoot, 'js/app/suitePracticeMixin.js'), 'utf8'); + +function createSessionStore() { + const values = new Map(); + const calls = { save: 0, get: 0, discard: 0 }; + return { + save(name, value) { calls.save += 1; values.set(String(name), structuredClone(value)); return true; }, + get(name) { calls.get += 1; return values.has(String(name)) ? structuredClone(values.get(String(name))) : null; }, + discard(name) { calls.discard += 1; values.delete(String(name)); return true; }, + peek(name) { return values.get(String(name)) || null; }, + calls + }; +} + +function createPassthroughLockManager() { + return { + async request(name, options, callback) { + return callback({ name, mode: options && options.mode || 'exclusive' }); + } + }; +} + +function createExclusiveLockManager() { + const held = new Map(); + const calls = []; + let nextError = null; + return { + held, + calls, + failNext(error) { nextError = error; }, + async request(name, options, callback) { + calls.push({ name: String(name), options: structuredClone(options || {}) }); + if (nextError) { + const error = nextError; + nextError = null; + throw error; + } + assert.equal(options && options.mode, 'exclusive'); + assert.equal(options && options.ifAvailable, true, 'claim contention must never queue a stale WAL'); + if (held.has(String(name))) return callback(null); + const lock = { name: String(name), mode: 'exclusive' }; + held.set(String(name), lock); + try { + return await callback(lock); + } finally { + if (held.get(String(name)) === lock) held.delete(String(name)); + } + } + }; +} + +function createHarness(options = {}) { + const sessionStore = options.sessionStore || createSessionStore(); + const activeSessionStore = options.activeSessionStore || new Map(); + const recoveryFenceStore = options.recoveryFenceStore || new Map(); + const realisticRecoveryStore = options.realisticRecoveryStore === true; + const recoveryRevision = (item) => { + const revision = Number(item && item.revision); + return Number.isSafeInteger(revision) && revision >= 0 ? revision : 0; + }; + const recoveryGroup = (item) => { + const explicit = String(item && item._recoveryExclusiveGroup || '').trim(); + if (explicit) return explicit; + return item && item.schema === 'suite-session-v2' && Number(item.version) === 2 + ? 'suite-practice' + : ''; + }; + const cloneRecoveryOptions = (value = {}) => Object.fromEntries(Object.entries(value) + .filter(([, entry]) => typeof entry !== 'function') + .map(([key, entry]) => [key, structuredClone(entry)])); + const recoveryCalls = { + save: 0, + discard: 0, + cleanup: 0, + saveQueue: [], + discardQueue: [], + discardOptions: [], + listedItems: null, + listQueue: [], + list: 0 + }; + const messages = []; + const practiceFinalizes = []; + const rawStorageTrap = new Proxy({}, { + get() { + throw new Error('suite recovery must use AppData v2, not raw Web Storage'); + }, + set() { + throw new Error('suite recovery must use AppData v2, not raw Web Storage'); + } + }); + const protocol = options.protocol === 'file:' ? 'file:' : 'http:'; + const windowStub = { + location: { + protocol, + href: protocol === 'file:' ? 'file:///index.html' : 'http://localhost/' + }, + localStorage: rawStorageTrap, + sessionStorage: rawStorageTrap, + showMessage(text, type) { messages.push({ text, type }); }, + addEventListener() {}, + removeEventListener() {} + }; + windowStub.navigator = options.locks === null + ? {} + : { locks: options.locks || createPassthroughLockManager() }; + const sandbox = { + window: windowStub, + console, + Date, + Math, + JSON, + Array, + Object, + Map, + Set, + URL, + structuredClone, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + AppData: { + ready: Promise.resolve(), + recovery: { + windowSession: sessionStore, + async listActiveSessions() { + recoveryCalls.list += 1; + if (options.listActiveSessionsError) throw options.listActiveSessionsError; + const items = recoveryCalls.listQueue.length + ? recoveryCalls.listQueue.shift() + : Array.isArray(recoveryCalls.listedItems) + ? recoveryCalls.listedItems + : Array.from(activeSessionStore.values()); + return items + .filter((value) => !realisticRecoveryStore || value?._recoveryTombstone !== true) + .map((value) => structuredClone(value)); + }, + async getActiveSessionFence(id) { + const normalizedId = String(id); + if (recoveryFenceStore.has(normalizedId)) { + return structuredClone(recoveryFenceStore.get(normalizedId)); + } + const active = activeSessionStore.get(normalizedId); + return active + ? { + id: normalizedId, + exists: true, + tombstoned: active._recoveryTombstone === true, + revision: recoveryRevision(active) + } + : { id: normalizedId, exists: false, tombstoned: false, revision: 0 }; + }, + async saveActiveSession(value, options = {}) { + recoveryCalls.save += 1; + const behavior = recoveryCalls.saveQueue.length ? recoveryCalls.saveQueue.shift() : true; + const outcome = typeof behavior === 'function' + ? await behavior(structuredClone(value), cloneRecoveryOptions(options)) + : behavior; + if (outcome instanceof Error) throw outcome; + if (outcome === false || (outcome && outcome.committed === false)) { + return outcome === false ? { committed: false } : structuredClone(outcome); + } + if (typeof options.commitGuard === 'function' && options.commitGuard() === false) { + return { committed: false, stale: true, reason: 'COMMIT_GUARD_REJECTED' }; + } + const normalizedId = String(value.id); + if (realisticRecoveryStore + && Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) { + const expectedRevision = Number(options.expectedEntityRevision); + const active = activeSessionStore.get(normalizedId); + const actualRevision = active ? recoveryRevision(active) : 0; + if (actualRevision !== expectedRevision) { + return { + committed: false, + stale: true, + code: 'STALE_RECOVERY_WRITE', + expectedEntityRevision: expectedRevision, + actualEntityRevision: actualRevision + }; + } + } + const exclusiveGroup = String(options.exclusiveGroup || '').trim(); + if (realisticRecoveryStore && exclusiveGroup) { + const conflicting = Array.from(activeSessionStore.entries()).find(([id, item]) => ( + String(id) !== normalizedId + && item?._recoveryTombstone !== true + && recoveryGroup(item) === exclusiveGroup + )); + if (conflicting) { + return { + committed: false, + stale: true, + code: 'RECOVERY_GROUP_CONFLICT', + conflictingEntityId: String(conflicting[0]) + }; + } + } + const storedValue = structuredClone(value); + if (exclusiveGroup) storedValue._recoveryExclusiveGroup = exclusiveGroup; + activeSessionStore.set(normalizedId, storedValue); + recoveryFenceStore.set(String(value.id), { + id: String(value.id), + exists: true, + tombstoned: false, + revision: recoveryRevision(value) + }); + return outcome && typeof outcome === 'object' + ? { ...structuredClone(outcome), committed: true, item: structuredClone(value) } + : { committed: true, item: structuredClone(value) }; + }, + async discardActiveSession(id, options = {}) { + recoveryCalls.discard += 1; + recoveryCalls.discardOptions.push(cloneRecoveryOptions(options)); + const behavior = recoveryCalls.discardQueue.length ? recoveryCalls.discardQueue.shift() : true; + const outcome = typeof behavior === 'function' + ? await behavior(String(id), cloneRecoveryOptions(options)) + : behavior; + if (outcome instanceof Error) throw outcome; + if (outcome === false || (outcome && outcome.committed === false)) { + return outcome === false ? { committed: false } : structuredClone(outcome); + } + if (typeof options.commitGuard === 'function' && options.commitGuard() === false) { + return { committed: false, stale: true, reason: 'COMMIT_GUARD_REJECTED' }; + } + const normalizedId = String(id); + if (realisticRecoveryStore + && Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) { + const expectedRevision = Number(options.expectedEntityRevision); + const active = activeSessionStore.get(normalizedId); + const actualRevision = active ? recoveryRevision(active) : 0; + if (actualRevision !== expectedRevision) { + return { + committed: false, + stale: true, + code: 'STALE_RECOVERY_WRITE', + expectedEntityRevision: expectedRevision, + actualEntityRevision: actualRevision + }; + } + activeSessionStore.set(normalizedId, { + id: normalizedId, + revision: Math.min(Number.MAX_SAFE_INTEGER, actualRevision + 1), + _recoveryTombstone: true, + discardedAt: Date.now() + }); + } else { + activeSessionStore.delete(normalizedId); + } + if (Object.prototype.hasOwnProperty.call(options, 'expectedEntityRevision')) { + const expectedRevision = Number(options.expectedEntityRevision); + recoveryFenceStore.set(normalizedId, { + id: normalizedId, + exists: true, + tombstoned: true, + revision: Number.isSafeInteger(expectedRevision) && expectedRevision >= 0 + ? Math.min(Number.MAX_SAFE_INTEGER, expectedRevision + 1) + : 1 + }); + } else { + recoveryFenceStore.delete(String(id)); + } + return outcome && typeof outcome === 'object' + ? { ...structuredClone(outcome), committed: true } + : { committed: true }; + }, + async cleanupForRetry() { + recoveryCalls.cleanup += 1; + return { committed: true, removedCount: 0, removedByKind: {} }; + } + }, + practice: { + async finalizeSuite(command = {}) { + practiceFinalizes.push(structuredClone(command)); + return { committed: true, record: structuredClone(command.record) }; + } + } + } + }; + if (options.listActiveSessionsUnavailable === true) { + delete sandbox.AppData.recovery.listActiveSessions; + } + windowStub.AppData = sandbox.AppData; + windowStub.ExamSystemAppMixins = {}; + sandbox.globalThis = windowStub; + const vmContext = vm.createContext(sandbox); + vm.runInContext(source, vmContext, { filename: 'js/app/suitePracticeMixin.js' }); + const createVmMap = vm.runInContext('() => new Map()', vmContext); + const mixin = windowStub.ExamSystemAppMixins.suitePractice; + const sequence = ['p1', 'p2', 'p3'].map((examId, index) => ({ + examId, + exam: { id: examId, title: `Passage ${index + 1}`, category: `P${index + 1}` }, + category: `P${index + 1}` + })); + const makeApp = () => { + const app = { + components: {}, + currentSuiteSession: null, + suiteExamMap: new Map(), + examWindows: createVmMap(), + messages + }; + Object.assign(app, mixin); + let registrationGeneration = 0; + app._installManagedTestWindow = (examId, targetWindow) => { + registrationGeneration += 1; + app.examWindows.set(examId, { + window: targetWindow, + suiteSessionId: app.currentSuiteSession && app.currentSuiteSession.id || null, + registrationToken: `test-registration-${registrationGeneration}`, + sessionGeneration: registrationGeneration + }); + return targetWindow; + }; + app._captureExamSessionRegistration = (examId, windowInfo) => Object.freeze({ + examId, + window: windowInfo.window, + suiteSessionId: String(windowInfo.suiteSessionId || ''), + registrationToken: windowInfo.registrationToken, + sessionGeneration: windowInfo.sessionGeneration + }); + app._isExamSessionRegistrationCurrent = (examId, registration) => { + const current = app.examWindows.get(examId); + return !!current + && current.window === registration.window + && String(current.suiteSessionId || '') === String(registration.suiteSessionId || '') + && current.registrationToken === registration.registrationToken + && current.sessionGeneration === registration.sessionGeneration; + }; + let launchSequence = 0; + const launchReceipts = new WeakMap(); + app._beginExamLaunchOwnership = (examId) => Object.freeze({ + examId: String(examId || ''), + sequence: ++launchSequence, + initialState: null, + targetLeaseKeys: Object.freeze([]) + }); + app._isExamLaunchOwnershipCurrent = (examId, ownership) => Boolean( + ownership && String(ownership.examId || '') === String(examId || '') + ); + app._claimExamLaunchWindowOwnership = () => true; + app._commitExamLaunchOwnership = () => true; + app._rollbackExamLaunchOwnership = () => true; + app._recordExamLaunchRegistrationReceipt = (examId, ownership, registration) => { + if (!ownership || !registration) return false; + launchReceipts.set(ownership, { examId: String(examId || ''), registration }); + return true; + }; + app._captureExamLaunchRegistrationReceipt = (examId, ownership, targetWindow = null) => { + const receipt = ownership && launchReceipts.get(ownership); + if (!receipt + || receipt.examId !== String(examId || '') + || (targetWindow && receipt.registration.window !== targetWindow) + || !app._isExamSessionRegistrationCurrent(examId, receipt.registration)) return null; + return receipt.registration; + }; + let openExamImplementation = null; + Object.defineProperty(app, 'openExam', { + configurable: true, + get() { + return openExamImplementation; + }, + set(implementation) { + openExamImplementation = async (...args) => { + const targetWindow = await implementation.apply(app, args); + if (!targetWindow || targetWindow.closed) return targetWindow; + app._installManagedTestWindow(args[0], targetWindow); + const launchOwnership = args[1] && args[1].launchOwnership; + if (launchOwnership) { + app._recordExamLaunchRegistrationReceipt( + args[0], + launchOwnership, + app._captureExamSessionRegistration(args[0], app.examWindows.get(args[0])) + ); + } + return targetWindow; + }; + } + }); + app._clearSuiteHandshakes = () => {}; + app._ensureSuiteWindowGuard = () => {}; + app._releaseSuiteWindowGuard = () => {}; + app._focusSuiteWindow = () => {}; + app._sendSimulationContext = () => true; + app.updateExamStatus = () => {}; + app.cleanupExamSession = async () => {}; + return app; + }; + sandbox.localStorage = rawStorageTrap; + sandbox.sessionStorage = rawStorageTrap; + return { + sessionStore, + activeSessionStore, + recoveryFenceStore, + recoveryCalls, + messages, + practiceFinalizes, + makeApp, + sequence + }; +} + +async function main() { + const fixtureTimeBase = Date.now() - 60_000; + const { sessionStore, activeSessionStore, messages, practiceFinalizes, makeApp, sequence } = createHarness(); + const firstApp = makeApp(); + let firstWindow; + firstApp.openExam = async () => { + const snapshot = sessionStore.peek('simulation'); + assert.equal(snapshot.status, 'active'); + assert.equal(snapshot.currentIndex, 0); + assert.equal(snapshot.sequence.length, 3); + firstWindow = { closed: false, name: 'suite-window' }; + return firstWindow; + }; + assert.equal(await firstApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + assert.equal(sessionStore.peek('simulation').status, 'active'); + + const tabOwnedWal = structuredClone(sessionStore.peek('simulation')); + const tabOwnedDurable = structuredClone(activeSessionStore.get(tabOwnedWal.id)); + assert(tabOwnedDurable, 'fixture must persist the tab-owned suite recovery'); + + const durableOnlySingleLocks = createExclusiveLockManager(); + const olderDurableSingle = { + ...structuredClone(tabOwnedDurable), + id: 'suite-durable-only-older', + lastUpdate: fixtureTimeBase + 100 + }; + const newestDurableSingle = { + ...structuredClone(tabOwnedDurable), + id: 'suite-durable-only-newest', + lastUpdate: fixtureTimeBase + 200 + }; + const durableOnlySingleStore = new Map([ + [olderDurableSingle.id, olderDurableSingle], + [newestDurableSingle.id, newestDurableSingle] + ]); + const durableSingleOwnerHarness = createHarness({ + locks: durableOnlySingleLocks, + activeSessionStore: durableOnlySingleStore, + realisticRecoveryStore: true + }); + const durableSingleOwnerApp = durableSingleOwnerHarness.makeApp(); + const heldNewestSingle = durableSingleOwnerApp._restoreSessionFromStorage(newestDurableSingle); + assert.equal(await durableSingleOwnerApp._acquireSuiteRecoveryClaim('single', heldNewestSingle), true); + + const emptyHttpTabHarness = createHarness({ + locks: durableOnlySingleLocks, + activeSessionStore: durableOnlySingleStore, + realisticRecoveryStore: true + }); + const emptyHttpTabApp = emptyHttpTabHarness.makeApp(); + emptyHttpTabApp.initializeSuiteMode(); + await emptyHttpTabApp._ensureSuiteRecoveryReady(); + assert.equal(emptyHttpTabApp.currentSuiteSession, null, 'a fresh HTTP tab must not bypass the newest durable owner lease'); + assert.equal(emptyHttpTabHarness.recoveryCalls.discard, 0, 'lease contention must not clean durable recovery'); + assert.equal( + durableOnlySingleLocks.calls.filter((call) => call.name === emptyHttpTabApp._suiteRecoveryClaimName(olderDurableSingle.id)).length, + 0, + 'newest single contention must not fall back to an older durable id' + ); + assert.equal(await durableSingleOwnerApp._releaseSuiteRecoveryClaim('single', heldNewestSingle), true); + + const heldOlderSingle = durableSingleOwnerApp._restoreSessionFromStorage(olderDurableSingle); + assert.equal(await durableSingleOwnerApp._acquireSuiteRecoveryClaim('single', heldOlderSingle), true); + assert.equal(await emptyHttpTabApp._refreshSuiteRecoveryCandidates(), null); + assert.equal(emptyHttpTabApp.currentSuiteSession, null, 'an active older singleton must block coordinated takeover of the whole group'); + assert.equal(emptyHttpTabHarness.recoveryCalls.discard, 0, 'older-owner contention must not partially clean the singleton group'); + assert.equal(durableOnlySingleStore.get(olderDurableSingle.id)._recoveryTombstone, undefined); + assert.equal(durableOnlySingleStore.get(newestDurableSingle.id)._recoveryTombstone, undefined); + assert.equal( + durableOnlySingleLocks.held.has(emptyHttpTabApp._suiteRecoveryClaimName(newestDurableSingle.id)), + false, + 'a failed group claim must release the provisional authoritative lock' + ); + assert.equal(await durableSingleOwnerApp._releaseSuiteRecoveryClaim('single', heldOlderSingle), true); + + assert.equal(await emptyHttpTabApp._refreshSuiteRecoveryCandidates(), emptyHttpTabApp.currentSuiteSession); + assert.equal(emptyHttpTabApp.currentSuiteSession.id, newestDurableSingle.id, 'the same tab must take over the newest durable after owner release'); + assert.equal(emptyHttpTabHarness.recoveryCalls.discard, 1, 'takeover must tombstone the older singleton before installing the newest'); + assert.equal(emptyHttpTabHarness.recoveryCalls.discardOptions[0].expectedEntityRevision, Number(olderDurableSingle.revision) || 0); + assert.equal(durableOnlySingleStore.get(olderDurableSingle.id)._recoveryTombstone, true); + assert.equal( + durableOnlySingleStore.get(olderDurableSingle.id).revision, + (Number(olderDurableSingle.revision) || 0) + 1 + ); + assert.equal(durableOnlySingleLocks.held.size, 1); + emptyHttpTabApp.currentSuiteSession.revision += 1; + emptyHttpTabApp.currentSuiteSession.lastUpdate = Date.now(); + assert.equal( + await emptyHttpTabApp._commitSuiteRecovery(emptyHttpTabApp.currentSuiteSession, { notify: false }), + true, + 'the newest singleton must commit after coordinated cleanup removes the legacy group conflict' + ); + assert.equal(durableOnlySingleStore.get(newestDurableSingle.id)._recoveryExclusiveGroup, 'suite-practice'); + assert.equal(await emptyHttpTabApp._releaseSuiteRecoveryClaim('single', emptyHttpTabApp.currentSuiteSession), true); + assert.equal(durableOnlySingleLocks.held.size, 0); + + const matchingGroupHttpLocks = createExclusiveLockManager(); + const matchingGroupHttpStore = new Map([ + [olderDurableSingle.id, structuredClone(olderDurableSingle)], + [newestDurableSingle.id, structuredClone(newestDurableSingle)] + ]); + const matchingGroupOlderOwnerHarness = createHarness({ + locks: matchingGroupHttpLocks, + activeSessionStore: matchingGroupHttpStore, + realisticRecoveryStore: true + }); + const matchingGroupOlderOwnerApp = matchingGroupOlderOwnerHarness.makeApp(); + const matchingGroupHeldOlder = matchingGroupOlderOwnerApp._restoreSessionFromStorage(olderDurableSingle); + assert.equal(await matchingGroupOlderOwnerApp._acquireSuiteRecoveryClaim('single', matchingGroupHeldOlder), true); + const matchingGroupHttpHarness = createHarness({ + locks: matchingGroupHttpLocks, + activeSessionStore: matchingGroupHttpStore, + realisticRecoveryStore: true + }); + matchingGroupHttpHarness.sessionStore.save('simulation', structuredClone(newestDurableSingle)); + const matchingGroupHttpApp = matchingGroupHttpHarness.makeApp(); + matchingGroupHttpApp.initializeSuiteMode(); + await matchingGroupHttpApp._ensureSuiteRecoveryReady(); + assert.equal(matchingGroupHttpApp.currentSuiteSession, null, 'a matching WAL must remain quarantined while another singleton id is live'); + assert.equal(matchingGroupHttpHarness.recoveryCalls.discard, 0); + assert.equal(matchingGroupHttpHarness.sessionStore.peek('simulation').id, newestDurableSingle.id); + assert.equal(await matchingGroupOlderOwnerApp._releaseSuiteRecoveryClaim('single', matchingGroupHeldOlder), true); + assert.equal(await matchingGroupHttpApp._refreshSuiteRecoveryCandidates(), matchingGroupHttpApp.currentSuiteSession); + assert.equal(matchingGroupHttpApp.currentSuiteSession.id, newestDurableSingle.id); + assert.equal(matchingGroupHttpStore.get(olderDurableSingle.id)._recoveryTombstone, true); + matchingGroupHttpApp.currentSuiteSession.revision += 1; + matchingGroupHttpApp.currentSuiteSession.lastUpdate = Date.now(); + assert.equal(await matchingGroupHttpApp._commitSuiteRecovery( + matchingGroupHttpApp.currentSuiteSession, + { notify: false } + ), true, 'a matching HTTP WAL must commit after coordinated legacy-id cleanup'); + assert.equal(await matchingGroupHttpApp._releaseSuiteRecoveryClaim('single', matchingGroupHttpApp.currentSuiteSession), true); + + const splitWalGroupLocks = createExclusiveLockManager(); + const splitWalGroupStore = new Map([ + [olderDurableSingle.id, structuredClone(olderDurableSingle)], + [newestDurableSingle.id, structuredClone(newestDurableSingle)] + ]); + const splitWalGroupTabA = createHarness({ + locks: splitWalGroupLocks, + activeSessionStore: splitWalGroupStore, + realisticRecoveryStore: true + }); + const splitWalGroupTabB = createHarness({ + locks: splitWalGroupLocks, + activeSessionStore: splitWalGroupStore, + realisticRecoveryStore: true + }); + splitWalGroupTabA.sessionStore.save('simulation', structuredClone(olderDurableSingle)); + splitWalGroupTabB.sessionStore.save('simulation', structuredClone(newestDurableSingle)); + const splitWalGroupAppA = splitWalGroupTabA.makeApp(); + const splitWalGroupAppB = splitWalGroupTabB.makeApp(); + splitWalGroupAppA.initializeSuiteMode(); + splitWalGroupAppB.initializeSuiteMode(); + await Promise.all([ + splitWalGroupAppA._ensureSuiteRecoveryReady(), + splitWalGroupAppB._ensureSuiteRecoveryReady() + ]); + const splitWalOwners = [splitWalGroupAppA, splitWalGroupAppB] + .filter((app) => app.currentSuiteSession); + assert.equal(splitWalOwners.length, 1, 'different-id WAL tabs must elect one singleton owner'); + assert.equal(splitWalOwners[0].currentSuiteSession.id, newestDurableSingle.id); + assert.equal( + splitWalGroupTabA.recoveryCalls.discard + splitWalGroupTabB.recoveryCalls.discard, + 1, + 'the group winner must tombstone exactly the older durable identity' + ); + assert.equal(splitWalGroupStore.get(olderDurableSingle.id)._recoveryTombstone, true); + assert.equal(splitWalGroupStore.get(newestDurableSingle.id)._recoveryTombstone, undefined); + const splitWalGroupName = splitWalGroupAppA._singleSuiteRecoveryGroupClaimName(); + assert.equal( + splitWalGroupLocks.calls.filter((call) => call.name === splitWalGroupName).length, + 2, + 'both tabs must contend on the origin-wide singleton group lock first' + ); + assert.deepEqual( + splitWalGroupLocks.calls + .map((call) => call.name) + .filter((name) => name.startsWith('ielts-atlas:suite-recovery:')) + .sort(), + [ + splitWalGroupAppA._suiteRecoveryClaimName(olderDurableSingle.id), + splitWalGroupAppA._suiteRecoveryClaimName(newestDurableSingle.id) + ].sort(), + 'only the group winner may request the two coordinated exact identities' + ); + assert.equal(splitWalGroupLocks.held.has(splitWalGroupName), false, 'the short-lived group lock must be released after install'); + assert.deepEqual( + Array.from(splitWalGroupLocks.held.keys()), + [splitWalGroupAppA._suiteRecoveryClaimName(newestDurableSingle.id)], + 'only the authoritative exact lease may survive recovery' + ); + const splitWalLoserHarness = splitWalOwners[0] === splitWalGroupAppA + ? splitWalGroupTabB + : splitWalGroupTabA; + assert.equal( + splitWalLoserHarness.sessionStore.peek('simulation').recoveryLeaseContended, + true, + 'group contention must preserve and mark the losing tab WAL for retry' + ); + assert.equal(await splitWalOwners[0]._releaseSuiteRecoveryClaim( + 'single', + splitWalOwners[0].currentSuiteSession + ), true); + assert.equal(splitWalGroupLocks.held.size, 0); + const splitWalLoserApp = splitWalOwners[0] === splitWalGroupAppA + ? splitWalGroupAppB + : splitWalGroupAppA; + assert.equal( + await splitWalLoserApp._refreshSuiteRecoveryCandidates(), + splitWalLoserApp.currentSuiteSession, + 'the marked group-contention WAL must remain retryable after owner release' + ); + assert.equal(splitWalLoserApp.currentSuiteSession.id, newestDurableSingle.id); + assert.equal(await splitWalLoserApp._releaseSuiteRecoveryClaim( + 'single', + splitWalLoserApp.currentSuiteSession + ), true); + assert.equal(splitWalGroupLocks.held.size, 0); + + const matchingGroupFileStore = new Map([ + [olderDurableSingle.id, structuredClone(olderDurableSingle)], + [newestDurableSingle.id, structuredClone(newestDurableSingle)] + ]); + const matchingGroupFileHarness = createHarness({ + protocol: 'file:', + activeSessionStore: matchingGroupFileStore, + realisticRecoveryStore: true + }); + matchingGroupFileHarness.sessionStore.save('simulation', structuredClone(newestDurableSingle)); + const matchingGroupFileApp = matchingGroupFileHarness.makeApp(); + matchingGroupFileApp.initializeSuiteMode(); + await matchingGroupFileApp._ensureSuiteRecoveryReady(); + assert.equal(matchingGroupFileApp.currentSuiteSession.id, newestDurableSingle.id); + assert.equal(matchingGroupFileStore.get(olderDurableSingle.id)._recoveryTombstone, true); + matchingGroupFileApp.currentSuiteSession.revision += 1; + matchingGroupFileApp.currentSuiteSession.lastUpdate = Date.now(); + assert.equal(await matchingGroupFileApp._commitSuiteRecovery( + matchingGroupFileApp.currentSuiteSession, + { notify: false } + ), true, 'a matching file WAL must commit after coordinated legacy-id cleanup'); + assert.equal(await matchingGroupFileApp._releaseSuiteRecoveryClaim('single', matchingGroupFileApp.currentSuiteSession), true); + + const staleOlderWalLocks = createExclusiveLockManager(); + const staleOlderDurable = { + ...structuredClone(olderDurableSingle), + elapsedByExam: { p1: 111 } + }; + const authoritativeNewerDurable = { + ...structuredClone(newestDurableSingle), + elapsedByExam: { p1: 999 } + }; + const staleOlderWalStore = new Map([ + [staleOlderDurable.id, structuredClone(staleOlderDurable)], + [authoritativeNewerDurable.id, structuredClone(authoritativeNewerDurable)] + ]); + const staleOlderWalHarness = createHarness({ + locks: staleOlderWalLocks, + activeSessionStore: staleOlderWalStore, + realisticRecoveryStore: true + }); + staleOlderWalHarness.sessionStore.save('simulation', structuredClone(staleOlderDurable)); + const staleOlderWalApp = staleOlderWalHarness.makeApp(); + staleOlderWalApp.initializeSuiteMode(); + await staleOlderWalApp._ensureSuiteRecoveryReady(); + assert.equal(staleOlderWalApp.currentSuiteSession.id, authoritativeNewerDurable.id, 'a matching stale WAL must not override the newer singleton owner'); + assert.equal(staleOlderWalApp.currentSuiteSession.elapsedByExam.p1, 999, 'newer durable progress must survive group reconciliation'); + assert.equal(staleOlderWalStore.get(staleOlderDurable.id)._recoveryTombstone, true); + staleOlderWalApp.currentSuiteSession.revision += 1; + assert.equal(await staleOlderWalApp._commitSuiteRecovery( + staleOlderWalApp.currentSuiteSession, + { notify: false } + ), true); + assert.equal(await staleOlderWalApp._releaseSuiteRecoveryClaim('single', staleOlderWalApp.currentSuiteSession), true); + + const durableOnlyFileStore = new Map([ + [olderDurableSingle.id, structuredClone(olderDurableSingle)], + [newestDurableSingle.id, structuredClone(newestDurableSingle)] + ]); + const durableOnlyFileHarness = createHarness({ + protocol: 'file:', + activeSessionStore: durableOnlyFileStore, + realisticRecoveryStore: true + }); + const durableOnlyFileApp = durableOnlyFileHarness.makeApp(); + durableOnlyFileApp.initializeSuiteMode(); + await durableOnlyFileApp._ensureSuiteRecoveryReady(); + assert.equal(durableOnlyFileApp.currentSuiteSession.id, newestDurableSingle.id, 'file durable-only recovery must select the newest singleton'); + assert.equal(durableOnlyFileStore.get(olderDurableSingle.id)._recoveryTombstone, true, 'file durable-only recovery must clean the older group member first'); + durableOnlyFileApp.currentSuiteSession.revision += 1; + assert.equal(await durableOnlyFileApp._commitSuiteRecovery( + durableOnlyFileApp.currentSuiteSession, + { notify: false } + ), true, 'file durable-only recovery must commit after coordinated group cleanup'); + assert.equal(await durableOnlyFileApp._releaseSuiteRecoveryClaim('single', durableOnlyFileApp.currentSuiteSession), true); + + const vanishedSingleLocks = createExclusiveLockManager(); + const vanishedSingleHarness = createHarness({ locks: vanishedSingleLocks }); + vanishedSingleHarness.recoveryCalls.listQueue.push( + [structuredClone(newestDurableSingle)], + [] + ); + const vanishedSingleApp = vanishedSingleHarness.makeApp(); + vanishedSingleApp.initializeSuiteMode(); + await vanishedSingleApp._ensureSuiteRecoveryReady(); + assert.equal(vanishedSingleApp.currentSuiteSession, null, 'a durable clone that vanishes under its lease must not be exposed'); + assert.equal(vanishedSingleHarness.recoveryCalls.save, 0, 'a vanished durable clone must never expected=0 resurrect itself'); + assert.equal(vanishedSingleHarness.recoveryCalls.list, 2); + assert.equal(vanishedSingleLocks.held.size, 0); + + for (const enumerationFailure of ['missing', 'throw']) { + const unavailableSingleLocks = createExclusiveLockManager(); + const unavailableSingleHarness = createHarness({ + locks: unavailableSingleLocks, + ...(enumerationFailure === 'missing' + ? { listActiveSessionsUnavailable: true } + : { listActiveSessionsError: new Error('active recovery enumeration failed') }) + }); + unavailableSingleHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal)); + unavailableSingleHarness.recoveryFenceStore.set(String(tabOwnedWal.id), { + id: String(tabOwnedWal.id), + exists: true, + tombstoned: true, + revision: (Number(tabOwnedWal.revision) || 0) + 1 + }); + const unavailableSingleApp = unavailableSingleHarness.makeApp(); + unavailableSingleApp.initializeSuiteMode(); + await unavailableSingleApp._ensureSuiteRecoveryReady(); + assert.equal( + unavailableSingleApp.currentSuiteSession, + null, + `HTTP single WAL must remain quarantined when durable enumeration is ${enumerationFailure}` + ); + assert.equal(unavailableSingleHarness.recoveryCalls.save, 0); + assert.equal(unavailableSingleHarness.sessionStore.peek('simulation').id, tabOwnedWal.id); + assert.equal(unavailableSingleLocks.held.size, 0); + assert.equal( + unavailableSingleLocks.calls.filter((call) => ( + call.name === unavailableSingleApp._singleSuiteRecoveryGroupClaimName() + )).length, + 1, + `HTTP ${enumerationFailure} enumeration must release its acquired group coordination lock` + ); + } + + const mismatchedWalLocks = createExclusiveLockManager(); + const mismatchedWalStore = new Map([ + [tabOwnedDurable.id, structuredClone(tabOwnedDurable)] + ]); + const mismatchedWalOwnerHarness = createHarness({ + locks: mismatchedWalLocks, + activeSessionStore: mismatchedWalStore, + realisticRecoveryStore: true + }); + const mismatchedWalOwnerApp = mismatchedWalOwnerHarness.makeApp(); + const mismatchedForeignOwner = mismatchedWalOwnerApp._restoreSessionFromStorage(tabOwnedDurable); + assert.equal(await mismatchedWalOwnerApp._acquireSuiteRecoveryClaim('single', mismatchedForeignOwner), true); + const mismatchedWalHarness = createHarness({ + locks: mismatchedWalLocks, + activeSessionStore: mismatchedWalStore, + realisticRecoveryStore: true + }); + const localWalId = 'suite-local-tab-owner'; + mismatchedWalHarness.sessionStore.save('simulation', { + ...structuredClone(tabOwnedWal), + id: localWalId, + revision: 1, + lastUpdate: Number(tabOwnedWal.lastUpdate) + 1 + }); + const mismatchedWalApp = mismatchedWalHarness.makeApp(); + mismatchedWalApp.initializeSuiteMode(); + await mismatchedWalApp._ensureSuiteRecoveryReady(); + assert.equal(mismatchedWalApp.currentSuiteSession, null, 'a mismatched pre-first-save WAL must remain quarantined while the durable singleton is live'); + assert.equal(mismatchedWalHarness.sessionStore.peek('simulation').id, localWalId, 'fail-closed coordination must preserve the local WAL bytes'); + assert.equal(mismatchedWalHarness.recoveryCalls.save, 0, 'the mismatched WAL must not attempt expected=0 against a foreign exclusive group'); + assert.equal(mismatchedWalHarness.activeSessionStore.has(tabOwnedDurable.id), true, 'mismatched foreign durable recovery must not be discarded'); + assert.equal(mismatchedWalHarness.recoveryCalls.discard, 0); + assert.equal(await mismatchedWalOwnerApp._releaseSuiteRecoveryClaim('single', mismatchedForeignOwner), true); + assert.equal(await mismatchedWalApp._refreshSuiteRecoveryCandidates(), mismatchedWalApp.currentSuiteSession); + assert.equal(mismatchedWalApp.currentSuiteSession.id, tabOwnedDurable.id, 'after release the authoritative durable singleton must replace the mismatched WAL owner'); + assert.equal(await mismatchedWalApp._releaseSuiteRecoveryClaim('single', mismatchedWalApp.currentSuiteSession), true); + + const matchedWalHarness = createHarness(); + const matchedLocalWal = { + ...structuredClone(tabOwnedWal), + currentIndex: 0, + activeExamId: 'p1', + revision: 10, + lastUpdate: fixtureTimeBase + 1000 + }; + const matchedDurable = { + ...structuredClone(tabOwnedDurable), + currentIndex: 1, + activeExamId: 'p2', + revision: 11, + lastUpdate: fixtureTimeBase + 2000 + }; + matchedWalHarness.sessionStore.save('simulation', matchedLocalWal); + matchedWalHarness.activeSessionStore.set(matchedDurable.id, matchedDurable); + const matchedWalApp = matchedWalHarness.makeApp(); + matchedWalApp.initializeSuiteMode(); + await matchedWalApp._ensureSuiteRecoveryReady(); + assert.equal(matchedWalApp.currentSuiteSession.id, matchedLocalWal.id); + assert.equal(matchedWalApp.currentSuiteSession.currentIndex, 1, 'matching HTTP WAL evidence must allow the newer durable snapshot'); + assert.equal(matchedWalApp.currentSuiteSession.activeExamId, 'p2'); + + const bindingOwnerHarness = createHarness(); + const bindingOwnerApp = bindingOwnerHarness.makeApp(); + const priorSuiteBinding = { + examId: 'p1', + expectedSessionId: 'suite-binding-child', + windowSessionToken: 'suite-binding-token', + sessionGeneration: 7, + expectedUrl: 'http://localhost/exam.html?examId=p1', + expectedOrigin: 'http://localhost', + allowOpaqueOrigin: false + }; + const bindingOwnerSession = bindingOwnerApp._restoreSessionFromStorage({ + ...structuredClone(tabOwnedWal), + id: 'suite-binding-owner', + activeExamId: 'p1', + currentIndex: 0, + revision: 0, + windowBinding: structuredClone(priorSuiteBinding) + }); + assert(bindingOwnerSession); + assert.equal(await bindingOwnerApp._acquireSuiteRecoveryClaim('single', bindingOwnerSession), true); + bindingOwnerApp.currentSuiteSession = bindingOwnerSession; + bindingOwnerApp.suiteExamMap = new Map(bindingOwnerSession.sequence.map((entry) => [entry.examId, bindingOwnerSession.id])); + bindingOwnerApp.examWindows = new Map([['p1', { + window: { closed: false }, + suiteSessionId: null, + expectedSessionId: 'ordinary-child-session', + windowSessionToken: 'ordinary-window-token', + sessionGeneration: 12 + }]]); + assert.deepEqual( + structuredClone(bindingOwnerApp._buildSuiteWindowBinding(bindingOwnerSession)), + priorSuiteBinding, + 'a managed ordinary same-exam registration must not replace the persisted suite binding' + ); + assert.equal(await bindingOwnerApp._commitSuiteRecovery(bindingOwnerSession, { notify: false }), true); + assert.deepEqual( + bindingOwnerHarness.activeSessionStore.get(bindingOwnerSession.id).windowBinding, + priorSuiteBinding, + 'the durable suite commit must retain the exact prior suite-owned binding' + ); + assert.equal(await bindingOwnerApp._releaseSuiteRecoveryClaim('single', bindingOwnerSession), true); + + const sharedSingleLocks = createExclusiveLockManager(); + const sharedSingleDurable = new Map([[matchedDurable.id, structuredClone(matchedDurable)]]); + const sharedSingleFences = new Map(); + const copiedSingleTabA = createHarness({ + locks: sharedSingleLocks, + activeSessionStore: sharedSingleDurable, + recoveryFenceStore: sharedSingleFences + }); + const copiedSingleTabB = createHarness({ + locks: sharedSingleLocks, + activeSessionStore: sharedSingleDurable, + recoveryFenceStore: sharedSingleFences + }); + copiedSingleTabA.sessionStore.save('simulation', structuredClone(matchedLocalWal)); + copiedSingleTabB.sessionStore.save('simulation', structuredClone(matchedLocalWal)); + const copiedSingleAppA = copiedSingleTabA.makeApp(); + const copiedSingleAppB = copiedSingleTabB.makeApp(); + const staleSingleA = copiedSingleAppA._restoreSessionFromStorage(); + const staleSingleB = copiedSingleAppB._restoreSessionFromStorage(); + copiedSingleAppA.initializeSuiteMode(); + copiedSingleAppB.initializeSuiteMode(); + assert.equal(copiedSingleAppA.currentSuiteSession, null, 'copyable WAL must remain quarantined before lease acquisition'); + assert.equal(copiedSingleAppB.currentSuiteSession, null, 'both copied tabs must quarantine WAL synchronously'); + await Promise.all([ + copiedSingleAppA._ensureSuiteRecoveryReady(), + copiedSingleAppB._ensureSuiteRecoveryReady() + ]); + const singleWinner = copiedSingleAppA.currentSuiteSession ? copiedSingleAppA : copiedSingleAppB; + const singleLoser = singleWinner === copiedSingleAppA ? copiedSingleAppB : copiedSingleAppA; + const singleWinnerHarness = singleWinner === copiedSingleAppA ? copiedSingleTabA : copiedSingleTabB; + const singleLoserHarness = singleWinner === copiedSingleAppA ? copiedSingleTabB : copiedSingleTabA; + const staleSingleLoser = singleWinner === copiedSingleAppA ? staleSingleB : staleSingleA; + assert(singleWinner.currentSuiteSession, 'exactly one copied tab must own the single-suite recovery'); + assert.equal(singleLoser.currentSuiteSession, null); + assert.equal(sharedSingleLocks.held.size, 1, 'the winner must hold its lease for the live recovery lifetime'); + assert.equal(sharedSingleLocks.calls[0].name, sharedSingleLocks.calls[1].name, 'single copied WALs must contend on one exact-id lock'); + assert.equal( + singleLoserHarness.sessionStore.peek('simulation').recoveryLeaseContended, + true, + 'the loser must retain only a non-secret contention marker for safe crash takeover' + ); + const singleSaveCountBefore = copiedSingleTabA.recoveryCalls.save + copiedSingleTabB.recoveryCalls.save; + assert.equal(await singleLoser._commitSuiteRecovery(staleSingleLoser, { reason: 'copied-tab-stale-ref' }), false); + assert.equal(await singleWinner._commitSuiteRecovery(singleWinner.currentSuiteSession, { reason: 'lease-owner' }), true); + assert.equal( + copiedSingleTabA.recoveryCalls.save + copiedSingleTabB.recoveryCalls.save, + singleSaveCountBefore + 1, + 'only the object bound to the held claim may commit' + ); + singleWinnerHarness.recoveryCalls.discardQueue.push(false); + assert.equal(await singleWinner._discardStoredSuiteSession(singleWinner.currentSuiteSession), false); + assert.equal(sharedSingleLocks.held.size, 1, 'failed durable discard must retain the lease for retry'); + assert.equal(await singleWinner._discardStoredSuiteSession(singleWinner.currentSuiteSession), true); + assert.equal(sharedSingleLocks.held.size, 0, 'successful local cleanup must release the lease'); + + const staleSingleRetry = createHarness({ + locks: sharedSingleLocks, + activeSessionStore: sharedSingleDurable, + recoveryFenceStore: sharedSingleFences + }); + staleSingleRetry.sessionStore.save('simulation', structuredClone(singleLoserHarness.sessionStore.peek('simulation'))); + const staleSingleRetryApp = staleSingleRetry.makeApp(); + staleSingleRetryApp.initializeSuiteMode(); + await staleSingleRetryApp._ensureSuiteRecoveryReady(); + assert.equal(staleSingleRetryApp.currentSuiteSession, null, 'a contended WAL must not resurrect after the owner discarded durable state'); + assert.equal(staleSingleRetry.sessionStore.peek('simulation'), null); + assert.equal(staleSingleRetry.recoveryCalls.save, 0); + assert.equal(sharedSingleLocks.held.size, 0); + + const uncontendedPreSaveHarness = createHarness(); + const uncontendedPreSaveWal = { + ...structuredClone(tabOwnedWal), + id: 'suite-uncontended-pre-first-save', + revision: 0, + lastUpdate: fixtureTimeBase + 2500 + }; + uncontendedPreSaveHarness.sessionStore.save('simulation', structuredClone(uncontendedPreSaveWal)); + const uncontendedPreSaveApp = uncontendedPreSaveHarness.makeApp(); + uncontendedPreSaveApp.initializeSuiteMode(); + await uncontendedPreSaveApp._ensureSuiteRecoveryReady(); + assert.equal(uncontendedPreSaveApp.currentSuiteSession.id, uncontendedPreSaveWal.id); + assert.equal(uncontendedPreSaveHarness.recoveryCalls.save, 1, 'an uncontended missing fence must migrate the first WAL'); + assert.equal( + await uncontendedPreSaveApp._releaseSuiteRecoveryClaim('single', uncontendedPreSaveApp.currentSuiteSession), + true + ); + + const preSaveCrashLocks = createExclusiveLockManager(); + const preSaveCrashDurable = new Map(); + const preSaveCrashFences = new Map(); + const preSaveCrashWal = { + ...structuredClone(tabOwnedWal), + id: 'suite-pre-first-save-crash', + revision: 0, + lastUpdate: fixtureTimeBase + 3000 + }; + const preSaveCrashTabA = createHarness({ + locks: preSaveCrashLocks, + activeSessionStore: preSaveCrashDurable, + recoveryFenceStore: preSaveCrashFences + }); + const preSaveCrashTabB = createHarness({ + locks: preSaveCrashLocks, + activeSessionStore: preSaveCrashDurable, + recoveryFenceStore: preSaveCrashFences + }); + preSaveCrashTabA.sessionStore.save('simulation', structuredClone(preSaveCrashWal)); + preSaveCrashTabB.sessionStore.save('simulation', structuredClone(preSaveCrashWal)); + preSaveCrashTabA.recoveryCalls.saveQueue.push(new Error('owner crashed before first durable save')); + preSaveCrashTabB.recoveryCalls.saveQueue.push(new Error('owner crashed before first durable save')); + const preSaveCrashAppA = preSaveCrashTabA.makeApp(); + const preSaveCrashAppB = preSaveCrashTabB.makeApp(); + preSaveCrashAppA.initializeSuiteMode(); + preSaveCrashAppB.initializeSuiteMode(); + await Promise.all([ + preSaveCrashAppA._ensureSuiteRecoveryReady(), + preSaveCrashAppB._ensureSuiteRecoveryReady() + ]); + const preSaveCrashWinner = preSaveCrashAppA.currentSuiteSession ? preSaveCrashAppA : preSaveCrashAppB; + const preSaveCrashLoserHarness = preSaveCrashWinner === preSaveCrashAppA ? preSaveCrashTabB : preSaveCrashTabA; + assert(preSaveCrashWinner.currentSuiteSession, 'the first claimant keeps its WAL after a transient first-save failure'); + assert.equal(preSaveCrashDurable.has(preSaveCrashWal.id), false); + assert.equal(preSaveCrashLoserHarness.sessionStore.peek('simulation').recoveryLeaseContended, true); + assert.equal( + await preSaveCrashWinner._releaseSuiteRecoveryClaim('single', preSaveCrashWinner.currentSuiteSession), + true, + 'simulated owner crash must release the browser-held lease' + ); + assert.equal(preSaveCrashLocks.held.size, 0); + + const preSaveCrashTakeover = createHarness({ + locks: preSaveCrashLocks, + activeSessionStore: preSaveCrashDurable, + recoveryFenceStore: preSaveCrashFences + }); + preSaveCrashTakeover.sessionStore.save( + 'simulation', + structuredClone(preSaveCrashLoserHarness.sessionStore.peek('simulation')) + ); + const preSaveCrashTakeoverApp = preSaveCrashTakeover.makeApp(); + preSaveCrashTakeoverApp.initializeSuiteMode(); + await preSaveCrashTakeoverApp._ensureSuiteRecoveryReady(); + assert(preSaveCrashTakeoverApp.currentSuiteSession, 'a missing fence must preserve and migrate the only crash WAL'); + assert.equal(preSaveCrashTakeoverApp.currentSuiteSession.id, preSaveCrashWal.id); + assert.equal(preSaveCrashTakeover.recoveryCalls.save, 1); + assert.equal(preSaveCrashDurable.has(preSaveCrashWal.id), true); + assert.equal( + await preSaveCrashTakeoverApp._releaseSuiteRecoveryClaim( + 'single', + preSaveCrashTakeoverApp.currentSuiteSession + ), + true + ); + assert.equal(preSaveCrashLocks.held.size, 0); + + const expiredWalHarness = createHarness(); + expiredWalHarness.sessionStore.save('simulation', { + ...structuredClone(tabOwnedWal), + id: 'suite-expired-copied-wal', + revision: 0, + lastUpdate: Date.now() - (31 * 24 * 60 * 60 * 1000) + }); + const expiredWalApp = expiredWalHarness.makeApp(); + expiredWalApp.initializeSuiteMode(); + await expiredWalApp._ensureSuiteRecoveryReady(); + assert.equal(expiredWalApp.currentSuiteSession, null, 'a copied WAL older than the durable recovery TTL must not revive'); + assert.equal(expiredWalHarness.sessionStore.peek('simulation'), null); + assert.equal(expiredWalHarness.recoveryCalls.save, 0); + + const fileFallbackHarness = createHarness({ protocol: 'file:' }); + fileFallbackHarness.activeSessionStore.set(tabOwnedDurable.id, structuredClone(tabOwnedDurable)); + const fileFallbackApp = fileFallbackHarness.makeApp(); + fileFallbackApp.initializeSuiteMode(); + await fileFallbackApp._ensureSuiteRecoveryReady(); + assert.equal(fileFallbackApp.currentSuiteSession.id, tabOwnedDurable.id, 'file: must retain durable recovery fallback without a window WAL'); + + const shadowedSuiteOwnerHarness = createHarness(); + shadowedSuiteOwnerHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal)); + shadowedSuiteOwnerHarness.recoveryCalls.listedItems = [{ + schema: 'foreign-recovery-schema', + version: 1, + id: tabOwnedWal.id, + revision: 0, + lastUpdate: fixtureTimeBase + 3000 + }, { + ...structuredClone(tabOwnedDurable), + revision: 9, + lastUpdate: fixtureTimeBase + 4000 + }]; + const shadowedSuiteOwnerApp = shadowedSuiteOwnerHarness.makeApp(); + shadowedSuiteOwnerApp.initializeSuiteMode(); + await shadowedSuiteOwnerApp._ensureSuiteRecoveryReady(); + assert.equal(shadowedSuiteOwnerApp.currentSuiteSession, null, 'a later suite candidate must not bypass another-schema first ownership of the same AppData id'); + assert.equal(shadowedSuiteOwnerHarness.sessionStore.peek('simulation'), null, 'unsafe same-id WAL must be cleared instead of migrated over the first owner'); + assert.equal(shadowedSuiteOwnerHarness.recoveryCalls.save, 0); + assert.equal(shadowedSuiteOwnerHarness.recoveryCalls.discard, 0); + + const corruptFirstOwnerHarness = createHarness(); + corruptFirstOwnerHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal)); + const corruptFirstOwner = { + ...structuredClone(tabOwnedDurable), + status: 'invalid', + revision: 4, + lastUpdate: fixtureTimeBase + 3000 + }; + const laterValidDuplicate = { + ...structuredClone(tabOwnedDurable), + currentIndex: 1, + activeExamId: 'p2', + revision: 9, + lastUpdate: fixtureTimeBase + 4000 + }; + corruptFirstOwnerHarness.recoveryCalls.listedItems = [corruptFirstOwner, laterValidDuplicate]; + corruptFirstOwnerHarness.recoveryCalls.saveQueue.push((value, options) => { + assert.equal(options.expectedEntityRevision, 4, 'repair must CAS against the actual corrupt first owner'); + assert(value.revision >= 5); + assert.equal(value.activeExamId, tabOwnedWal.activeExamId, 'repair must use this tab WAL, not the later duplicate payload'); + return { committed: true }; + }); + const corruptFirstOwnerApp = corruptFirstOwnerHarness.makeApp(); + corruptFirstOwnerApp.initializeSuiteMode(); + await corruptFirstOwnerApp._ensureSuiteRecoveryReady(); + assert.equal(corruptFirstOwnerApp.currentSuiteSession.id, tabOwnedWal.id); + assert.equal(corruptFirstOwnerApp.currentSuiteSession.activeExamId, tabOwnedWal.activeExamId); + assert.notEqual(corruptFirstOwnerApp.currentSuiteSession._suiteRecoveryWritesBlocked, true); + assert.equal(corruptFirstOwnerHarness.recoveryCalls.discard, 0, 'matching corrupt durable must be repaired without a tombstone'); + + const multiSuiteSession = { + id: 'multi-suite-tab-owner', + baseExamId: 'listening-multi-tab-owner', + status: 'active', + startTime: 1000, + suiteResults: [], + expectedSuiteCount: 2, + metadata: {}, + lastUpdate: fixtureTimeBase + 1000, + revision: 3, + finalizeOperationId: null, + finalizeRecord: null + }; + const multiSuiteWal = { + schema: 'multi-suite-sessions-v2', + version: 2, + sessions: [structuredClone(multiSuiteSession)], + updatedAt: fixtureTimeBase + 1000 + }; + const multiSuiteDurable = { + ...structuredClone(multiSuiteWal), + id: multiSuiteSession.id, + revision: multiSuiteSession.revision + }; + + for (const enumerationFailure of ['missing', 'throw']) { + const unavailableMultiLocks = createExclusiveLockManager(); + const unavailableMultiHarness = createHarness({ + locks: unavailableMultiLocks, + ...(enumerationFailure === 'missing' + ? { listActiveSessionsUnavailable: true } + : { listActiveSessionsError: new Error('multi recovery enumeration failed') }) + }); + unavailableMultiHarness.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal)); + unavailableMultiHarness.recoveryFenceStore.set(multiSuiteSession.id, { + id: multiSuiteSession.id, + exists: true, + tombstoned: true, + revision: multiSuiteSession.revision + 1 + }); + const unavailableMultiApp = unavailableMultiHarness.makeApp(); + unavailableMultiApp.initializeSuiteMode(); + await unavailableMultiApp._ensureSuiteRecoveryReady(); + assert.equal( + unavailableMultiApp.multiSuiteSessionsMap.has(multiSuiteSession.baseExamId), + false, + `HTTP multi WAL must remain quarantined when durable enumeration is ${enumerationFailure}` + ); + assert.equal(unavailableMultiHarness.recoveryCalls.save, 0); + assert.equal( + unavailableMultiHarness.sessionStore.peek('multi-suite-practice').sessions[0].id, + multiSuiteSession.id + ); + assert.equal(unavailableMultiLocks.held.size, 0); + } + + const crossKindLocks = createExclusiveLockManager(); + const crossKindHarness = createHarness({ locks: crossKindLocks }); + const crossKindId = tabOwnedWal.id; + const crossKindMultiSession = { + ...structuredClone(multiSuiteSession), + id: crossKindId, + baseExamId: 'listening-cross-kind-owner' + }; + const crossKindMultiWal = { + schema: 'multi-suite-sessions-v2', + version: 2, + sessions: [structuredClone(crossKindMultiSession)], + updatedAt: fixtureTimeBase + 2000 + }; + const crossKindMultiDurable = { + ...structuredClone(crossKindMultiWal), + id: crossKindId, + revision: crossKindMultiSession.revision + }; + crossKindHarness.sessionStore.save('simulation', structuredClone(tabOwnedWal)); + crossKindHarness.sessionStore.save('multi-suite-practice', structuredClone(crossKindMultiWal)); + crossKindHarness.recoveryCalls.listedItems = [ + structuredClone(crossKindMultiDurable), + structuredClone(tabOwnedDurable) + ]; + const crossKindApp = crossKindHarness.makeApp(); + const crossKindStaleSingle = crossKindApp._restoreSessionFromStorage(); + crossKindApp.initializeSuiteMode(); + await crossKindApp._ensureSuiteRecoveryReady(); + const crossKindRestoredMulti = crossKindApp.multiSuiteSessionsMap.get(crossKindMultiSession.baseExamId); + assert.equal(crossKindApp.currentSuiteSession, null, 'a multi-suite first owner must reject the same-id single WAL'); + assert(crossKindRestoredMulti, 'the authoritative same-id multi WAL must retry its claim after single releases it'); + assert.equal(crossKindRestoredMulti.id, crossKindId); + assert.equal(crossKindApp._ownsMultiSuiteRecoveryOwnership(crossKindRestoredMulti), true); + assert.equal(crossKindLocks.held.size, 2); + assert(crossKindLocks.held.has(crossKindApp._suiteRecoveryClaimName(crossKindId))); + assert(crossKindLocks.held.has(crossKindApp._multiSuiteBaseClaimName(crossKindMultiSession.baseExamId))); + assert.equal(crossKindHarness.recoveryCalls.save, 0); + assert.equal(crossKindHarness.recoveryCalls.discard, 0); + assert.equal( + await crossKindApp._commitSuiteRecovery(crossKindStaleSingle, { notify: false }), + false, + 'the stale single object must not borrow the multi-suite claim' + ); + const crossKindSaveCount = crossKindHarness.recoveryCalls.save; + crossKindRestoredMulti.revision += 1; + assert.equal(await crossKindApp._commitMultiSuiteRecovery(crossKindRestoredMulti), true); + assert.equal(crossKindHarness.recoveryCalls.save, crossKindSaveCount + 1); + assert.equal(crossKindHarness.activeSessionStore.get(crossKindId).schema, 'multi-suite-sessions-v2'); + assert.equal(await crossKindApp._releaseSuiteRecoveryClaim('single', crossKindStaleSingle), false); + assert.equal(crossKindLocks.held.size, 2, 'a stale cross-kind release must not drop either multi ownership lease'); + assert.equal(await crossKindApp._releaseSuiteRecoveryClaim('multi', crossKindRestoredMulti), true); + assert.equal(crossKindLocks.held.size, 0); + + const reverseCrossKindLocks = createExclusiveLockManager(); + const reverseCrossKindHarness = createHarness({ locks: reverseCrossKindLocks }); + const reverseCrossKindId = 'suite-reverse-cross-kind-owner'; + const reverseCrossKindSingle = { + ...structuredClone(tabOwnedDurable), + id: reverseCrossKindId, + lastUpdate: fixtureTimeBase + 3000 + }; + const reverseCrossKindMultiSession = { + ...structuredClone(multiSuiteSession), + id: reverseCrossKindId, + baseExamId: 'listening-reverse-cross-kind-owner' + }; + const reverseCrossKindMultiWal = { + schema: 'multi-suite-sessions-v2', + version: 2, + sessions: [structuredClone(reverseCrossKindMultiSession)], + updatedAt: fixtureTimeBase + 3000 + }; + reverseCrossKindHarness.sessionStore.save('multi-suite-practice', reverseCrossKindMultiWal); + reverseCrossKindHarness.recoveryCalls.listedItems = [structuredClone(reverseCrossKindSingle)]; + const reverseCrossKindApp = reverseCrossKindHarness.makeApp(); + const reverseCrossKindStaleMulti = reverseCrossKindApp + ._restoreMultiSuiteSessionsFromStorage({ install: false })[0]; + reverseCrossKindApp.initializeSuiteMode(); + await reverseCrossKindApp._ensureSuiteRecoveryReady(); + assert(reverseCrossKindApp.currentSuiteSession, 'durable single must retry after the wrong-kind multi WAL releases the shared id'); + assert.equal(reverseCrossKindApp.currentSuiteSession.id, reverseCrossKindId); + assert.equal(reverseCrossKindApp.multiSuiteSessionsMap.has(reverseCrossKindMultiSession.baseExamId), false); + assert.equal(reverseCrossKindApp._ownsSuiteRecoveryClaim('single', reverseCrossKindApp.currentSuiteSession), true); + assert.equal(reverseCrossKindLocks.held.size, 1); + assert.equal( + await reverseCrossKindApp._commitMultiSuiteRecovery(reverseCrossKindStaleMulti), + false, + 'the rejected wrong-kind WAL object must not reacquire the durable single identity' + ); + assert.equal(await reverseCrossKindApp._releaseSuiteRecoveryClaim( + 'single', + reverseCrossKindApp.currentSuiteSession + ), true); + assert.equal(reverseCrossKindLocks.held.size, 0); + + const aliasClaimLocks = createExclusiveLockManager(); + const aliasClaimHarness = createHarness({ locks: aliasClaimLocks }); + const aliasClaimApp = aliasClaimHarness.makeApp(); + aliasClaimApp.multiSuiteSessionsMap = new Map(); + const aliasCanonicalBase = 'listening-alias-claim-owner'; + const aliasLosingSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-alias-losing-object', + baseExamId: ` ${aliasCanonicalBase} `, + _restoredFromWindowSession: true, + _suiteRecoveryTimestampKnown: true + }; + const aliasWinningSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-alias-winning-object', + baseExamId: aliasCanonicalBase, + _restoredFromWindowSession: true, + _suiteRecoveryTimestampKnown: true + }; + assert.equal(await aliasClaimApp._acquireSuiteRecoveryClaim('multi', aliasLosingSession), true); + assert.equal(await aliasClaimApp._acquireSuiteRecoveryClaim('multi', aliasWinningSession), true); + aliasClaimApp.multiSuiteSessionsMap.set(aliasLosingSession.baseExamId, aliasLosingSession); + aliasClaimApp.multiSuiteSessionsMap.set(aliasCanonicalBase, aliasWinningSession); + const aliasDurableMarker = { + schema: 'multi-suite-sessions-v2', + version: 2, + id: 'multi-alias-foreign-marker', + revision: 1, + sessions: [{ baseExamId: aliasCanonicalBase }] + }; + await aliasClaimApp._restorePersistentMultiSuiteSessions([aliasDurableMarker], []); + assert.equal(aliasClaimApp.multiSuiteSessionsMap.get(aliasCanonicalBase), aliasWinningSession); + assert.equal(aliasLosingSession._suiteRecoveryClaimRejected, true); + assert.equal(aliasLosingSession._suiteRecoveryWritesBlocked, true); + const aliasSaveCount = aliasClaimHarness.recoveryCalls.save; + assert.equal(await aliasClaimApp._commitMultiSuiteRecovery(aliasLosingSession), false); + assert.equal(aliasClaimHarness.recoveryCalls.save, aliasSaveCount, 'an evicted alias object must not reacquire after release'); + assert.equal(aliasClaimApp._ownsSuiteRecoveryClaim('multi', aliasWinningSession), true); + assert.equal(await aliasClaimApp._releaseSuiteRecoveryClaim('multi', aliasWinningSession), true); + + const sameObjectAliasSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-alias-same-object', + baseExamId: ` ${aliasCanonicalBase}-same `, + _restoredFromWindowSession: true, + _suiteRecoveryTimestampKnown: true + }; + const sameObjectCanonicalBase = `${aliasCanonicalBase}-same`; + assert.equal(await aliasClaimApp._acquireSuiteRecoveryClaim('multi', sameObjectAliasSession), true); + aliasClaimApp.multiSuiteSessionsMap.set(sameObjectAliasSession.baseExamId, sameObjectAliasSession); + aliasClaimApp.multiSuiteSessionsMap.set(sameObjectCanonicalBase, sameObjectAliasSession); + await aliasClaimApp._restorePersistentMultiSuiteSessions([{ + ...aliasDurableMarker, + id: 'multi-alias-same-object-marker', + sessions: [{ baseExamId: sameObjectCanonicalBase }] + }], []); + assert.equal(aliasClaimApp.multiSuiteSessionsMap.get(sameObjectCanonicalBase), sameObjectAliasSession); + assert.equal(aliasClaimApp._ownsSuiteRecoveryClaim('multi', sameObjectAliasSession), true); + assert.notEqual(sameObjectAliasSession._suiteRecoveryClaimRejected, true); + assert.equal(await aliasClaimApp._releaseSuiteRecoveryClaim('multi', sameObjectAliasSession), true); + assert.equal(aliasClaimLocks.held.size, 0); + + const durableOnlyMultiLocks = createExclusiveLockManager(); + const durableOnlyMultiBase = 'listening-durable-only-owner'; + const olderDurableMultiSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-durable-only-older', + baseExamId: durableOnlyMultiBase, + lastUpdate: fixtureTimeBase + 500 + }; + const newestDurableMultiSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-durable-only-newest', + baseExamId: durableOnlyMultiBase, + lastUpdate: fixtureTimeBase + 600 + }; + const durableMultiWrapper = (session) => ({ + schema: 'multi-suite-sessions-v2', + version: 2, + id: session.id, + revision: session.revision, + sessions: [structuredClone(session)], + updatedAt: session.lastUpdate + }); + const olderDurableMulti = durableMultiWrapper(olderDurableMultiSession); + const newestDurableMulti = durableMultiWrapper(newestDurableMultiSession); + const durableOnlyMultiStore = new Map([ + [olderDurableMulti.id, olderDurableMulti], + [newestDurableMulti.id, newestDurableMulti] + ]); + const durableMultiOwnerHarness = createHarness({ + locks: durableOnlyMultiLocks, + activeSessionStore: durableOnlyMultiStore + }); + const durableMultiOwnerApp = durableMultiOwnerHarness.makeApp(); + const heldNewestMulti = structuredClone(newestDurableMultiSession); + assert.equal(await durableMultiOwnerApp._acquireMultiSuiteRecoveryOwnership(heldNewestMulti), true); + + const emptyMultiHttpHarness = createHarness({ + locks: durableOnlyMultiLocks, + activeSessionStore: durableOnlyMultiStore + }); + const emptyMultiHttpApp = emptyMultiHttpHarness.makeApp(); + emptyMultiHttpApp.initializeSuiteMode(); + await emptyMultiHttpApp._ensureSuiteRecoveryReady(); + assert.equal(emptyMultiHttpApp.multiSuiteSessionsMap.has(durableOnlyMultiBase), false, 'a fresh HTTP tab must not bypass the newest per-base lease'); + assert.equal( + durableOnlyMultiLocks.calls.filter((call) => call.name === emptyMultiHttpApp._suiteRecoveryClaimName(olderDurableMulti.id)).length, + 0, + 'newest per-base contention must not fall back to an older multi id' + ); + const durableOnlyMultiPayload = { + suiteId: 'set-1', + totalSuites: 2, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; + assert.equal( + await emptyMultiHttpApp.handleMultiSuitePracticeComplete( + `${durableOnlyMultiBase}_set1`, + durableOnlyMultiPayload + ), + false, + 'the targeted handler must respect the live authoritative entity lock' + ); + assert.equal(await durableMultiOwnerApp._releaseSuiteRecoveryClaim('multi', heldNewestMulti), true); + assert.equal( + await emptyMultiHttpApp.handleMultiSuitePracticeComplete( + `${durableOnlyMultiBase}_set1`, + durableOnlyMultiPayload + ), + true, + 'the same handler must take over the durable base after the entity owner releases' + ); + const durableOnlyMultiRestored = emptyMultiHttpApp.multiSuiteSessionsMap.get(durableOnlyMultiBase); + assert.equal(durableOnlyMultiRestored.id, newestDurableMulti.id, 'the same tab must take over the newest per-base durable after release'); + assert.equal(durableOnlyMultiLocks.held.size, 2); + assert.equal(await emptyMultiHttpApp._releaseSuiteRecoveryClaim('multi', durableOnlyMultiRestored), true); + assert.equal(durableOnlyMultiLocks.held.size, 0, 'older durable ids must never leave unused claims behind'); + + const handlerTakeoverLocks = createExclusiveLockManager(); + const handlerTakeoverSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-handler-crash-takeover', + baseExamId: 'listening-multi-handler-crash-takeover', + expectedSuiteCount: 2, + suiteResults: [], + lastUpdate: fixtureTimeBase + 700 + }; + const handlerTakeoverDurable = durableMultiWrapper(handlerTakeoverSession); + const handlerTakeoverStore = new Map([ + [handlerTakeoverDurable.id, structuredClone(handlerTakeoverDurable)] + ]); + const handlerOwnerHarness = createHarness({ + locks: handlerTakeoverLocks, + activeSessionStore: handlerTakeoverStore + }); + const handlerOwnerApp = handlerOwnerHarness.makeApp(); + const heldHandlerOwner = structuredClone(handlerTakeoverSession); + assert.equal(await handlerOwnerApp._acquireMultiSuiteRecoveryOwnership(heldHandlerOwner), true); + + const handlerRetryHarness = createHarness({ + locks: handlerTakeoverLocks, + activeSessionStore: handlerTakeoverStore + }); + const handlerRetryApp = handlerRetryHarness.makeApp(); + handlerRetryApp.initializeSuiteMode(); + await handlerRetryApp._ensureSuiteRecoveryReady(); + assert.equal( + handlerRetryApp.multiSuiteSessionsMap.has(handlerTakeoverSession.baseExamId), + false, + 'a live owner must keep the durable base quarantined in the retrying tab' + ); + const handlerPayload = { + suiteId: 'set-1', + totalSuites: 2, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }; + const canonicalReplayLocks = createExclusiveLockManager(); + const canonicalReplayHarness = createHarness({ + locks: canonicalReplayLocks, + activeSessionStore: new Map(), + realisticRecoveryStore: true + }); + const canonicalReplayApp = canonicalReplayHarness.makeApp(); + canonicalReplayApp.initializeSuiteMode(); + await canonicalReplayApp._ensureSuiteRecoveryReady(); + const canonicalReplayBase = 'listening-multi-canonical-replay'; + const canonicalReplayPayload = { + ...handlerPayload, + sessionId: 'canonical-child-session', + submissionId: 'canonical-child-submission' + }; + canonicalReplayApp._generateMultiSuiteSessionId = () => 'multi-canonical-replay-empty-owner'; + canonicalReplayApp._listPracticeRecordsViaAPI = async () => [{ + multiSuite: true, + examId: canonicalReplayBase, + suiteEntries: [{ + suiteId: canonicalReplayPayload.suiteId, + metadata: { + sessionId: canonicalReplayPayload.sessionId, + submissionId: canonicalReplayPayload.submissionId + } + }] + }]; + assert.equal( + await canonicalReplayApp.handleMultiSuitePracticeComplete( + `${canonicalReplayBase}_set1`, + canonicalReplayPayload + ), + true, + 'a canonical durable receipt must ACK a stale child without starting a new base owner' + ); + assert.equal(canonicalReplayApp.multiSuiteSessionsMap.has(canonicalReplayBase), false); + assert.equal(canonicalReplayHarness.recoveryCalls.save, 0); + assert.equal(canonicalReplayHarness.sessionStore.peek('multi-suite-practice'), null); + assert.equal(canonicalReplayLocks.held.size, 0, 'canonical replay must release the provisional base and exact leases'); + + assert.equal( + await handlerRetryApp.handleMultiSuitePracticeComplete( + `${handlerTakeoverSession.baseExamId}_set1`, + handlerPayload + ), + false, + 'the production completion path must not create a new id while the durable owner is alive' + ); + assert.equal(handlerRetryHarness.recoveryCalls.save, 0); + assert.equal(handlerRetryApp.multiSuiteSessionsMap.has(handlerTakeoverSession.baseExamId), false); + + assert.equal(await handlerOwnerApp._releaseSuiteRecoveryClaim('multi', heldHandlerOwner), true); + const idleOtherBaseSession = { + ...structuredClone(handlerTakeoverSession), + id: 'multi-idle-other-base', + baseExamId: 'listening-multi-idle-other-base', + lastUpdate: fixtureTimeBase + 800 + }; + const idleOtherBaseDurable = durableMultiWrapper(idleOtherBaseSession); + handlerTakeoverStore.set(idleOtherBaseDurable.id, structuredClone(idleOtherBaseDurable)); + const callsBeforeIdleInitialize = handlerTakeoverLocks.calls.length; + const idleMultiHarness = createHarness({ + locks: handlerTakeoverLocks, + activeSessionStore: handlerTakeoverStore + }); + const idleMultiApp = idleMultiHarness.makeApp(); + idleMultiApp.initializeSuiteMode(); + await idleMultiApp._ensureSuiteRecoveryReady(); + assert.equal(idleMultiApp.multiSuiteSessionsMap.size, 0, 'an idle HTTP tab must not install durable-only multi bases'); + assert.equal(handlerTakeoverLocks.calls.length, callsBeforeIdleInitialize, 'idle startup must not claim any durable-only base or entity'); + assert.equal(handlerTakeoverLocks.held.size, 0); + assert.equal( + await handlerRetryApp.handleMultiSuitePracticeComplete( + `${handlerTakeoverSession.baseExamId}_set1`, + handlerPayload + ), + true, + 'the same app instance must refresh and merge into the released durable owner' + ); + const handlerTakeoverRestored = handlerRetryApp.multiSuiteSessionsMap.get(handlerTakeoverSession.baseExamId); + assert(handlerTakeoverRestored); + assert.equal(handlerTakeoverRestored.id, handlerTakeoverSession.id, 'retry must preserve the durable entity identity'); + assert.equal(handlerTakeoverRestored.suiteResults.length, 1); + assert.equal(handlerTakeoverRestored.suiteResults[0].suiteId, handlerPayload.suiteId); + assert.equal(handlerRetryHarness.recoveryCalls.save, 1); + assert.equal(handlerTakeoverStore.get(handlerTakeoverSession.id).id, handlerTakeoverSession.id); + assert.equal(await handlerRetryApp._releaseSuiteRecoveryClaim('multi', handlerTakeoverRestored), true); + assert.equal(handlerTakeoverLocks.held.size, 0); + + const emptySameBaseLocks = createExclusiveLockManager(); + const emptySameBaseStore = new Map(); + const emptySameBaseHarnessA = createHarness({ + locks: emptySameBaseLocks, + activeSessionStore: emptySameBaseStore, + realisticRecoveryStore: true + }); + const emptySameBaseHarnessB = createHarness({ + locks: emptySameBaseLocks, + activeSessionStore: emptySameBaseStore, + realisticRecoveryStore: true + }); + const emptySameBaseAppA = emptySameBaseHarnessA.makeApp(); + const emptySameBaseAppB = emptySameBaseHarnessB.makeApp(); + emptySameBaseAppA.initializeSuiteMode(); + emptySameBaseAppB.initializeSuiteMode(); + await Promise.all([ + emptySameBaseAppA._ensureSuiteRecoveryReady(), + emptySameBaseAppB._ensureSuiteRecoveryReady() + ]); + emptySameBaseHarnessA.recoveryCalls.listQueue.push([]); + emptySameBaseHarnessB.recoveryCalls.listQueue.push([]); + emptySameBaseAppA._generateMultiSuiteSessionId = () => 'multi-empty-same-base-a'; + emptySameBaseAppB._generateMultiSuiteSessionId = () => 'multi-empty-same-base-b'; + const emptySameBase = 'listening-multi-empty-base-race'; + const emptySameBaseOutcomes = await Promise.all([ + emptySameBaseAppA.handleMultiSuitePracticeComplete(`${emptySameBase}_set1`, handlerPayload), + emptySameBaseAppB.handleMultiSuitePracticeComplete(`${emptySameBase}_set1`, handlerPayload) + ]); + assert.deepEqual([...emptySameBaseOutcomes].sort(), [false, true], 'only one empty tab may create the first entity for a canonical base'); + assert.equal( + emptySameBaseAppA.multiSuiteSessionsMap.size + emptySameBaseAppB.multiSuiteSessionsMap.size, + 1 + ); + assert.equal(emptySameBaseStore.size, 1, 'same-base first submit must persist exactly one entity id'); + assert.equal( + emptySameBaseHarnessA.recoveryCalls.save + emptySameBaseHarnessB.recoveryCalls.save, + 1 + ); + const emptySameBaseWinner = emptySameBaseAppA.multiSuiteSessionsMap.has(emptySameBase) + ? emptySameBaseAppA + : emptySameBaseAppB; + assert.equal( + await emptySameBaseWinner._releaseSuiteRecoveryClaim( + 'multi', + emptySameBaseWinner.multiSuiteSessionsMap.get(emptySameBase) + ), + true + ); + assert.equal(emptySameBaseLocks.held.size, 0); + + const parallelBaseLocks = createExclusiveLockManager(); + const parallelBaseStore = new Map(); + const parallelBaseHarnessA = createHarness({ + locks: parallelBaseLocks, + activeSessionStore: parallelBaseStore, + realisticRecoveryStore: true + }); + const parallelBaseHarnessB = createHarness({ + locks: parallelBaseLocks, + activeSessionStore: parallelBaseStore, + realisticRecoveryStore: true + }); + const parallelBaseAppA = parallelBaseHarnessA.makeApp(); + const parallelBaseAppB = parallelBaseHarnessB.makeApp(); + parallelBaseAppA.initializeSuiteMode(); + parallelBaseAppB.initializeSuiteMode(); + await Promise.all([ + parallelBaseAppA._ensureSuiteRecoveryReady(), + parallelBaseAppB._ensureSuiteRecoveryReady() + ]); + parallelBaseHarnessA.recoveryCalls.listQueue.push([]); + parallelBaseHarnessB.recoveryCalls.listQueue.push([]); + parallelBaseAppA._generateMultiSuiteSessionId = () => 'multi-parallel-base-a'; + parallelBaseAppB._generateMultiSuiteSessionId = () => 'multi-parallel-base-b'; + const parallelBaseA = 'listening-multi-parallel-base-a'; + const parallelBaseB = 'listening-multi-parallel-base-b'; + assert.deepEqual(await Promise.all([ + parallelBaseAppA.handleMultiSuitePracticeComplete(`${parallelBaseA}_set1`, handlerPayload), + parallelBaseAppB.handleMultiSuitePracticeComplete(`${parallelBaseB}_set1`, handlerPayload) + ]), [true, true], 'different canonical bases must remain independently writable'); + assert.equal(parallelBaseStore.size, 2); + assert.equal(parallelBaseLocks.held.size, 4, 'each active base must retain one base lease and one exact entity lease'); + assert(parallelBaseLocks.held.has(parallelBaseAppA._multiSuiteBaseClaimName(parallelBaseA))); + assert(parallelBaseLocks.held.has(parallelBaseAppB._multiSuiteBaseClaimName(parallelBaseB))); + assert.equal(await parallelBaseAppA._releaseSuiteRecoveryClaim( + 'multi', + parallelBaseAppA.multiSuiteSessionsMap.get(parallelBaseA) + ), true); + assert.equal(await parallelBaseAppB._releaseSuiteRecoveryClaim( + 'multi', + parallelBaseAppB.multiSuiteSessionsMap.get(parallelBaseB) + ), true); + assert.equal(parallelBaseLocks.held.size, 0); + + const vanishedMultiLocks = createExclusiveLockManager(); + const vanishedMultiHarness = createHarness({ locks: vanishedMultiLocks }); + const vanishedMultiApp = vanishedMultiHarness.makeApp(); + vanishedMultiApp.initializeSuiteMode(); + await vanishedMultiApp._ensureSuiteRecoveryReady(); + vanishedMultiHarness.recoveryCalls.listQueue.push( + [structuredClone(newestDurableMulti)], + [] + ); + assert.equal( + await vanishedMultiApp.handleMultiSuitePracticeComplete( + `${durableOnlyMultiBase}_set1`, + durableOnlyMultiPayload + ), + false, + 'a targeted durable clone that vanishes under its leases must not be exposed' + ); + assert.equal(vanishedMultiApp.multiSuiteSessionsMap.has(durableOnlyMultiBase), false); + assert.equal(vanishedMultiHarness.recoveryCalls.save, 0, 'a vanished durable multi clone must not expected=0 resurrect itself'); + assert.equal(vanishedMultiHarness.recoveryCalls.list, 3); + assert.equal(vanishedMultiLocks.held.size, 0); + + const matchedMultiHttpHarness = createHarness(); + matchedMultiHttpHarness.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal)); + matchedMultiHttpHarness.activeSessionStore.set(multiSuiteDurable.id, structuredClone(multiSuiteDurable)); + const matchedMultiHttpApp = matchedMultiHttpHarness.makeApp(); + matchedMultiHttpApp.initializeSuiteMode(); + await matchedMultiHttpApp._ensureSuiteRecoveryReady(); + const matchedMultiSession = matchedMultiHttpApp.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId); + assert(matchedMultiSession, 'matching HTTP multi-suite WAL identity must allow durable recovery'); + assert.equal(matchedMultiSession.id, multiSuiteSession.id); + assert.equal(matchedMultiSession._lastDurableRecoveryRevision, multiSuiteSession.revision); + + const replacedWalLocks = createExclusiveLockManager(); + const replacedWalSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-suite-replaced-window-wal', + lastUpdate: fixtureTimeBase + }; + const replacedWalHarness = createHarness({ + locks: replacedWalLocks, + activeSessionStore: new Map([[multiSuiteDurable.id, structuredClone(multiSuiteDurable)]]) + }); + replacedWalHarness.sessionStore.save('multi-suite-practice', { + ...structuredClone(multiSuiteWal), + sessions: [replacedWalSession] + }); + const replacedWalApp = replacedWalHarness.makeApp(); + replacedWalApp.initializeSuiteMode(); + await replacedWalApp._ensureSuiteRecoveryReady(); + const replacedWalOwner = replacedWalApp.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId); + assert.equal(replacedWalOwner.id, multiSuiteSession.id, 'the newest durable identity must replace a stale same-base WAL id'); + assert.equal(replacedWalLocks.held.size, 2, 'only the installed base and exact durable leases may remain held'); + assert(replacedWalLocks.held.has(replacedWalApp._multiSuiteBaseClaimName(multiSuiteSession.baseExamId))); + assert(replacedWalLocks.held.has(replacedWalApp._suiteRecoveryClaimName(multiSuiteSession.id))); + assert.equal( + replacedWalLocks.held.has(replacedWalApp._suiteRecoveryClaimName(replacedWalSession.id)), + false, + 'the displaced WAL exact-id lease must be released after the base transfers' + ); + assert.equal(await replacedWalApp._releaseSuiteRecoveryClaim('multi', replacedWalOwner), true); + assert.equal(replacedWalLocks.held.size, 0); + + let signalExactRequest; + const exactRequestStarted = new Promise((resolve) => { signalExactRequest = resolve; }); + const halfLockManager = { + held: new Map(), + async request(name, lockOptions, callback) { + assert.equal(lockOptions.mode, 'exclusive'); + assert.equal(lockOptions.ifAvailable, true); + const normalizedName = String(name); + const lock = { name: normalizedName, mode: 'exclusive' }; + if (normalizedName.includes(':multi-suite-base:')) { + this.held.set(normalizedName, lock); + void callback(lock); + await exactRequestStarted; + this.held.delete(normalizedName); + throw new Error('simulated base lock loss while exact acquisition is pending'); + } + signalExactRequest(); + await new Promise((resolve) => setTimeout(resolve, 0)); + this.held.set(normalizedName, lock); + try { + return await callback(lock); + } finally { + if (this.held.get(normalizedName) === lock) this.held.delete(normalizedName); + } + } + }; + const halfLockHarness = createHarness({ locks: halfLockManager }); + const halfLockApp = halfLockHarness.makeApp(); + const halfLockSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-half-lock-race', + baseExamId: 'listening-multi-half-lock-race' + }; + assert.equal( + await halfLockApp._acquireMultiSuiteRecoveryOwnership(halfLockSession), + false, + 'an exact lease acquired after unexpected base loss must not become an owner' + ); + assert.equal(halfLockManager.held.size, 0, 'late exact acquisition must be released instead of leaking a half-lock'); + assert.equal(halfLockApp._ownsSuiteRecoveryClaim('multi', halfLockSession), false); + + const sharedMultiLocks = createExclusiveLockManager(); + const sharedMultiDurable = new Map([[multiSuiteDurable.id, structuredClone(multiSuiteDurable)]]); + const copiedMultiTabA = createHarness({ locks: sharedMultiLocks, activeSessionStore: sharedMultiDurable }); + const copiedMultiTabB = createHarness({ locks: sharedMultiLocks, activeSessionStore: sharedMultiDurable }); + copiedMultiTabA.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal)); + copiedMultiTabB.sessionStore.save('multi-suite-practice', structuredClone(multiSuiteWal)); + const copiedMultiAppA = copiedMultiTabA.makeApp(); + const copiedMultiAppB = copiedMultiTabB.makeApp(); + const staleMultiA = copiedMultiAppA._restoreMultiSuiteSessionsFromStorage({ install: false })[0]; + const staleMultiB = copiedMultiAppB._restoreMultiSuiteSessionsFromStorage({ install: false })[0]; + copiedMultiAppA.initializeSuiteMode(); + copiedMultiAppB.initializeSuiteMode(); + assert.equal(copiedMultiAppA.multiSuiteSessionsMap.size, 0, 'multi WAL must remain quarantined before claim acquisition'); + assert.equal(copiedMultiAppB.multiSuiteSessionsMap.size, 0); + await Promise.all([ + copiedMultiAppA._ensureSuiteRecoveryReady(), + copiedMultiAppB._ensureSuiteRecoveryReady() + ]); + const multiWinner = copiedMultiAppA.multiSuiteSessionsMap.has(multiSuiteSession.baseExamId) + ? copiedMultiAppA + : copiedMultiAppB; + const multiLoser = multiWinner === copiedMultiAppA ? copiedMultiAppB : copiedMultiAppA; + const multiWinnerHarness = multiWinner === copiedMultiAppA ? copiedMultiTabA : copiedMultiTabB; + const multiLoserHarness = multiWinner === copiedMultiAppA ? copiedMultiTabB : copiedMultiTabA; + const staleMultiLoser = multiWinner === copiedMultiAppA ? staleMultiB : staleMultiA; + const multiWinnerSession = multiWinner.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId); + assert(multiWinnerSession); + assert.equal(multiLoser.multiSuiteSessionsMap.size, 0, 'the copied multi-suite loser must not become a runtime owner'); + assert.equal(sharedMultiLocks.held.size, 2); + assert.equal(sharedMultiLocks.calls[0].name, sharedMultiLocks.calls[1].name); + assert.equal( + multiLoserHarness.sessionStore.peek('multi-suite-practice').sessions[0].recoveryLeaseContended, + true + ); + multiWinnerSession.revision += 1; + staleMultiLoser.revision += 1; + const multiSaveCountBefore = copiedMultiTabA.recoveryCalls.save + copiedMultiTabB.recoveryCalls.save; + const [multiWinnerCommit, multiLoserCommit] = await Promise.all([ + multiWinner._commitMultiSuiteRecovery(multiWinnerSession), + multiLoser._commitMultiSuiteRecovery(staleMultiLoser) + ]); + assert.deepEqual([multiWinnerCommit, multiLoserCommit], [true, false]); + assert.equal( + copiedMultiTabA.recoveryCalls.save + copiedMultiTabB.recoveryCalls.save, + multiSaveCountBefore + 1, + 'only the multi-suite object bound to the held claim may commit' + ); + assert.equal(await multiWinner._releaseSuiteRecoveryClaim('multi', multiWinnerSession), true); + assert.equal(sharedMultiLocks.held.size, 0); + + const uncontendedMultiHarness = createHarness(); + const uncontendedMultiSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-uncontended-pre-first-save', + baseExamId: 'listening-multi-uncontended-pre-first-save', + revision: 0 + }; + uncontendedMultiHarness.sessionStore.save('multi-suite-practice', { + ...structuredClone(multiSuiteWal), + sessions: [structuredClone(uncontendedMultiSession)] + }); + const uncontendedMultiApp = uncontendedMultiHarness.makeApp(); + uncontendedMultiApp.initializeSuiteMode(); + await uncontendedMultiApp._ensureSuiteRecoveryReady(); + const uncontendedMultiRestored = uncontendedMultiApp.multiSuiteSessionsMap.get(uncontendedMultiSession.baseExamId); + assert(uncontendedMultiRestored, 'an uncontended multi WAL with no fence must be migrated, not discarded'); + assert.equal(uncontendedMultiHarness.recoveryCalls.save, 1); + assert.equal(await uncontendedMultiApp._releaseSuiteRecoveryClaim('multi', uncontendedMultiRestored), true); + + const preSaveMultiLocks = createExclusiveLockManager(); + const preSaveMultiDurable = new Map(); + const preSaveMultiFences = new Map(); + const preSaveMultiOwnerHarness = createHarness({ + locks: preSaveMultiLocks, + activeSessionStore: preSaveMultiDurable, + recoveryFenceStore: preSaveMultiFences + }); + const preSaveMultiOwner = preSaveMultiOwnerHarness.makeApp(); + preSaveMultiOwner.multiSuiteSessionsMap = new Map(); + const preSaveMultiSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-pre-first-save-crash', + baseExamId: 'listening-multi-pre-first-save-crash', + revision: 0 + }; + assert.equal(await preSaveMultiOwner._acquireMultiSuiteRecoveryOwnership(preSaveMultiSession), true); + preSaveMultiOwner.multiSuiteSessionsMap.set(preSaveMultiSession.baseExamId, preSaveMultiSession); + assert.equal(preSaveMultiOwner._mirrorMultiSuiteSessionsToStorage(), true); + const preSaveMultiWal = structuredClone( + preSaveMultiOwnerHarness.sessionStore.peek('multi-suite-practice') + ); + + const preSaveMultiLoserHarness = createHarness({ + locks: preSaveMultiLocks, + activeSessionStore: preSaveMultiDurable, + recoveryFenceStore: preSaveMultiFences + }); + preSaveMultiLoserHarness.sessionStore.save('multi-suite-practice', structuredClone(preSaveMultiWal)); + const preSaveMultiLoser = preSaveMultiLoserHarness.makeApp(); + preSaveMultiLoser.initializeSuiteMode(); + await preSaveMultiLoser._ensureSuiteRecoveryReady(); + assert.equal(preSaveMultiLoser.multiSuiteSessionsMap.size, 0); + assert.equal( + preSaveMultiLoserHarness.sessionStore.peek('multi-suite-practice').sessions[0].recoveryLeaseContended, + true + ); + assert.equal(await preSaveMultiOwner._releaseSuiteRecoveryClaim('multi', preSaveMultiSession), true); + assert.equal(preSaveMultiLocks.held.size, 0); + + const preSaveMultiTakeoverHarness = createHarness({ + locks: preSaveMultiLocks, + activeSessionStore: preSaveMultiDurable, + recoveryFenceStore: preSaveMultiFences + }); + preSaveMultiTakeoverHarness.sessionStore.save( + 'multi-suite-practice', + structuredClone(preSaveMultiLoserHarness.sessionStore.peek('multi-suite-practice')) + ); + const preSaveMultiTakeover = preSaveMultiTakeoverHarness.makeApp(); + preSaveMultiTakeover.initializeSuiteMode(); + await preSaveMultiTakeover._ensureSuiteRecoveryReady(); + const migratedPreSaveMulti = preSaveMultiTakeover.multiSuiteSessionsMap.get(preSaveMultiSession.baseExamId); + assert(migratedPreSaveMulti, 'a missing fence must migrate the contended multi WAL after owner crash'); + assert.equal(preSaveMultiTakeoverHarness.recoveryCalls.save, 1); + assert.equal(preSaveMultiDurable.get(preSaveMultiSession.id).schema, 'multi-suite-sessions-v2'); + assert.equal(await preSaveMultiTakeover._releaseSuiteRecoveryClaim('multi', migratedPreSaveMulti), true); + assert.equal(preSaveMultiLocks.held.size, 0); + + const expiredMultiHarness = createHarness(); + const expiredMultiSession = { + ...structuredClone(multiSuiteSession), + id: 'multi-expired-copied-wal', + baseExamId: 'listening-multi-expired-copied-wal', + revision: 0, + lastUpdate: Date.now() - (31 * 24 * 60 * 60 * 1000) + }; + expiredMultiHarness.sessionStore.save('multi-suite-practice', { + ...structuredClone(multiSuiteWal), + sessions: [expiredMultiSession], + updatedAt: Date.now() + }); + const expiredMultiApp = expiredMultiHarness.makeApp(); + expiredMultiApp.initializeSuiteMode(); + await expiredMultiApp._ensureSuiteRecoveryReady(); + assert.equal(expiredMultiApp.multiSuiteSessionsMap.size, 0); + assert.equal(expiredMultiHarness.sessionStore.peek('multi-suite-practice'), null); + assert.equal(expiredMultiHarness.recoveryCalls.save, 0); + + const throwingFileLocks = { + calls: 0, + request() { this.calls += 1; throw new Error('file: must bypass Web Locks'); } + }; + const multiFileFallbackHarness = createHarness({ protocol: 'file:', locks: throwingFileLocks }); + multiFileFallbackHarness.activeSessionStore.set(multiSuiteDurable.id, structuredClone(multiSuiteDurable)); + const multiFileFallbackApp = multiFileFallbackHarness.makeApp(); + multiFileFallbackApp.initializeSuiteMode(); + await multiFileFallbackApp._ensureSuiteRecoveryReady(); + assert.equal( + multiFileFallbackApp.multiSuiteSessionsMap.get(multiSuiteSession.baseExamId).id, + multiSuiteSession.id, + 'file: must retain durable-only multi-suite recovery fallback' + ); + assert.equal(throwingFileLocks.calls, 0); + + const fileWindowOnlyHarness = createHarness({ protocol: 'file:', locks: throwingFileLocks }); + const fileWindowOnlySession = { + ...structuredClone(multiSuiteSession), + id: 'multi-file-window-only', + baseExamId: 'listening-multi-file-window-only', + revision: 0 + }; + fileWindowOnlyHarness.sessionStore.save('multi-suite-practice', { + ...structuredClone(multiSuiteWal), + sessions: [fileWindowOnlySession] + }); + const fileWindowOnlyApp = fileWindowOnlyHarness.makeApp(); + fileWindowOnlyApp.initializeSuiteMode(); + await fileWindowOnlyApp._ensureSuiteRecoveryReady(); + assert(fileWindowOnlyApp.multiSuiteSessionsMap.has(fileWindowOnlySession.baseExamId)); + assert.equal(fileWindowOnlyHarness.recoveryCalls.save, 1, 'file: window-only multi WAL must establish expected=0 durable CAS'); + assert.equal(throwingFileLocks.calls, 0); + + const noLocksHarness = createHarness({ locks: null }); + noLocksHarness.sessionStore.save('simulation', structuredClone(matchedLocalWal)); + noLocksHarness.activeSessionStore.set(matchedDurable.id, structuredClone(matchedDurable)); + const noLocksApp = noLocksHarness.makeApp(); + noLocksApp.initializeSuiteMode(); + await noLocksApp._ensureSuiteRecoveryReady(); + assert.equal(noLocksApp.currentSuiteSession, null, 'HTTP recovery must fail closed without navigator.locks'); + assert.equal(noLocksHarness.recoveryCalls.save, 0); + assert.equal(noLocksHarness.recoveryCalls.discard, 0); + assert.equal( + noLocksHarness.sessionStore.peek('simulation').recoveryLeaseContended, + true, + 'an unavailable group lock must preserve and mark the WAL for a later retry' + ); + assert.equal(noLocksHarness.sessionStore.peek('simulation').id, matchedLocalWal.id); + assert.equal(noLocksHarness.sessionStore.peek('simulation').revision, matchedLocalWal.revision); + + const throwingLocks = createExclusiveLockManager(); + throwingLocks.failNext(new Error('locks backend unavailable')); + const throwingLocksHarness = createHarness({ locks: throwingLocks }); + throwingLocksHarness.sessionStore.save('simulation', structuredClone(matchedLocalWal)); + throwingLocksHarness.activeSessionStore.set(matchedDurable.id, structuredClone(matchedDurable)); + const throwingLocksApp = throwingLocksHarness.makeApp(); + throwingLocksApp.initializeSuiteMode(); + await throwingLocksApp._ensureSuiteRecoveryReady(); + assert.equal(throwingLocksApp.currentSuiteSession, null, 'request failure must fail closed without hanging'); + assert.equal(throwingLocksHarness.sessionStore.peek('simulation').recoveryLeaseContended, true); + assert.equal(throwingLocksHarness.sessionStore.peek('simulation').id, matchedLocalWal.id); + assert.equal(throwingLocksHarness.sessionStore.peek('simulation').revision, matchedLocalWal.revision); + assert.equal(throwingLocksHarness.recoveryCalls.save, 0); + assert.equal(throwingLocksHarness.recoveryCalls.discard, 0); + assert.equal(throwingLocks.held.size, 0, 'a failed group request must not leak a hold'); + assert.deepEqual( + throwingLocks.calls.map((call) => call.name), + [throwingLocksApp._singleSuiteRecoveryGroupClaimName()], + 'a failed group request must stop before the WAL exact-id request' + ); + assert.equal( + await throwingLocksApp._refreshSuiteRecoveryCandidates(), + throwingLocksApp.currentSuiteSession, + 'group request failure must not terminalize the serialized WAL retry' + ); + assert.equal(throwingLocksApp.currentSuiteSession.id, matchedDurable.id); + assert.equal(await throwingLocksApp._releaseSuiteRecoveryClaim( + 'single', + throwingLocksApp.currentSuiteSession + ), true); + assert.equal(throwingLocks.held.size, 0); + + // Drafts may arrive after the child Window exists but before openExam() resolves. + // The initializing snapshot must bind that exact source and persist the draft. + const earlyHarness = createHarness(); + const initializingApp = earlyHarness.makeApp(); + let initializingWindow; + initializingApp.openExam = async () => { + initializingWindow = { closed: false, name: 'initializing-window' }; + initializingApp._installManagedTestWindow('p1', initializingWindow); + const initializingSession = initializingApp.currentSuiteSession; + assert.equal(initializingSession.status, 'active'); + const accepted = await initializingApp._handleSuiteDraftSync('p1', { + suiteSessionId: initializingSession.id, + draft: { answers: { q1: 'early' }, updatedAt: 120 }, + draftUpdatedAt: 120, + elapsed: 4 + }, initializingApp.examWindows.get('p1'), initializingWindow); + assert.equal(accepted, true); + assert.equal(initializingSession.windowRef, initializingWindow); + return initializingWindow; + }; + assert.equal(await initializingApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + + const choiceHarness = createHarness(); + const choiceSourceApp = choiceHarness.makeApp(); + choiceSourceApp.openExam = async () => ({ closed: false, name: 'choice-window', close() { this.closed = true; } }); + assert.equal(await choiceSourceApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + const choiceApp = choiceHarness.makeApp(); + choiceApp.initializeSuiteMode(); + await choiceApp._ensureSuiteRecoveryReady(); + const choiceCandidate = await choiceApp.getSuiteRecoveryCandidate(); + assert.equal(choiceCandidate.id, choiceSourceApp.currentSuiteSession.id); + let implicitResumeCount = 0; + choiceApp.resumeSuitePractice = async () => { implicitResumeCount += 1; return true; }; + assert.equal(await choiceApp.startSuitePractice(), false, '未明确选择时不得自动继续恢复套题'); + assert.equal(implicitResumeCount, 0, '再次点击套题入口不能隐式等同于继续'); + assert.equal(await choiceApp.abandonSuiteRecovery(), false, '放弃 recovery 必须携带用户看到的 session id'); + assert.equal(await choiceApp.startSuitePractice({ recoveryAction: 'continue' }), false, '继续 recovery 必须携带用户看到的 session id'); + assert.equal(await choiceApp.abandonSuiteRecovery('another-suite'), false, '放弃操作必须绑定用户确认的 recovery identity'); + assert.equal(choiceApp.currentSuiteSession.id, choiceCandidate.id); + assert.equal(await choiceApp.abandonSuiteRecovery(choiceCandidate.id), true, '用户明确放弃后应完整 teardown'); + assert.equal(choiceApp.currentSuiteSession, null); + assert.equal(choiceHarness.activeSessionStore.size, 0, '放弃必须清除 durable active-session recovery'); + assert.equal(choiceHarness.practiceFinalizes.length, 0, '放弃未完成套题不得生成单篇或聚合记录'); + + const discardFailureHarness = createHarness(); + const discardFailureApp = discardFailureHarness.makeApp(); + const discardFailureWindow = { closed: false, name: 'discard-failure', close() { this.closed = true; } }; + discardFailureApp.openExam = async () => discardFailureWindow; + discardFailureApp.initializeSuiteMode(); + await discardFailureApp._ensureSuiteRecoveryReady(); + assert.equal(await discardFailureApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + const discardFailureSession = discardFailureApp.currentSuiteSession; + const discardFallbackTimer = setTimeout(() => {}, 60000); + discardFallbackTimer.unref?.(); + discardFailureSession.submitReceiptTeardownTimer = discardFallbackTimer; + discardFailureHarness.recoveryCalls.discardQueue.push(false); + assert.equal(await discardFailureApp.abandonSuiteRecovery(discardFailureSession.id), false); + assert.equal(discardFailureApp.currentSuiteSession, discardFailureSession, 'discard failure must retain the in-memory suite'); + assert.equal(discardFailureSession.status, 'active', 'discard failure must not mark the suite aborted'); + assert.equal(discardFailureWindow.closed, false, 'discard failure must not close the active question window'); + assert.equal(discardFailureSession.submitReceiptTeardownTimer, discardFallbackTimer, 'discard failure must preserve the fallback teardown timer'); + assert(discardFailureHarness.activeSessionStore.has(discardFailureSession.id), 'discard failure must retain durable recovery'); + assert.equal(await discardFailureApp.abandonSuiteRecovery(discardFailureSession.id), true, 'discard should remain retryable'); + + const queuedWriteHarness = createHarness(); + const queuedWriteApp = queuedWriteHarness.makeApp(); + const queuedWriteWindow = { closed: false, name: 'queued-write', close() { this.closed = true; } }; + queuedWriteApp.openExam = async () => queuedWriteWindow; + queuedWriteApp.initializeSuiteMode(); + await queuedWriteApp._ensureSuiteRecoveryReady(); + assert.equal(await queuedWriteApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + const queuedWriteSession = queuedWriteApp.currentSuiteSession; + let releaseQueuedSave; + let markQueuedSaveStarted; + const queuedSaveStarted = new Promise((resolve) => { markQueuedSaveStarted = resolve; }); + queuedWriteHarness.recoveryCalls.saveQueue.push(async () => { + markQueuedSaveStarted(); + return new Promise((resolve) => { releaseQueuedSave = resolve; }); + }); + const pendingRecoveryWrite = queuedWriteApp._commitSuiteRecovery(queuedWriteSession, { reason: 'queued-before-discard' }); + await queuedSaveStarted; + const queuedAbandon = queuedWriteApp.abandonSuiteRecovery(queuedWriteSession.id); + await Promise.resolve(); + assert.equal(queuedWriteWindow.closed, false, 'teardown must not close the window before the queued write settles'); + releaseQueuedSave({ committed: true }); + assert.equal(await pendingRecoveryWrite, true); + assert.equal(await queuedAbandon, true); + assert.equal(queuedWriteHarness.activeSessionStore.has(queuedWriteSession.id), false, 'discard must run after queued save and prevent resurrection'); + + const submitAbandonHarness = createHarness(); + const submitAbandonApp = submitAbandonHarness.makeApp(); + let submitAbandonOpenCount = 0; + const submitAbandonWindow = { closed: false, name: 'submit-abandon', close() { this.closed = true; } }; + submitAbandonApp.openExam = async () => { + submitAbandonOpenCount += 1; + return submitAbandonWindow; + }; + submitAbandonApp.initializeSuiteMode(); + await submitAbandonApp._ensureSuiteRecoveryReady(); + assert.equal(await submitAbandonApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + const submitAbandonSession = submitAbandonApp.currentSuiteSession; + let releaseSubmitSave; + let markSubmitSaveStarted; + const submitSaveStarted = new Promise((resolve) => { markSubmitSaveStarted = resolve; }); + submitAbandonHarness.recoveryCalls.saveQueue.push(async () => { + markSubmitSaveStarted(); + return new Promise((resolve) => { releaseSubmitSave = resolve; }); + }); + const submitOutcomePromise = submitAbandonApp.handleSuitePracticeComplete('p1', { + suiteSessionId: submitAbandonSession.id, + submissionId: 'submit-abandon-p1', + duration: 10, + answers: { q1: 'A' }, + answerComparison: { q1: { userAnswer: 'A', correctAnswer: 'A', isCorrect: true } }, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 } + }, submitAbandonWindow); + await submitSaveStarted; + const submitAbandonPromise = submitAbandonApp.abandonSuiteRecovery(submitAbandonSession.id); + await Promise.resolve(); + releaseSubmitSave({ committed: true }); + const submitOutcome = await submitOutcomePromise; + assert.equal(submitOutcome.handled, true); + assert.equal(submitOutcome.committed, false, 'abandon must invalidate the in-flight submit continuation before ACK'); + assert.equal(submitOutcome.errorCode, 'suite_teardown_in_progress'); + assert.equal(await submitAbandonPromise, true); + assert.equal(submitAbandonOpenCount, 1, 'an abandoned submit must not open the next passage'); + + const walPreservationHarness = createHarness(); + const walSourceApp = walPreservationHarness.makeApp(); + walSourceApp.openExam = async () => ({ closed: false, name: 'wal-source' }); + walSourceApp.initializeSuiteMode(); + await walSourceApp._ensureSuiteRecoveryReady(); + assert.equal(await walSourceApp._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + const validWal = walPreservationHarness.sessionStore.peek('simulation'); + walPreservationHarness.activeSessionStore.clear(); + walPreservationHarness.activeSessionStore.set(validWal.id, { + schema: 'suite-session-v2', + version: 2, + id: validWal.id, + status: 'invalid', + revision: 4, + lastUpdate: Number(validWal.lastUpdate) + 1000 + }); + walPreservationHarness.recoveryCalls.saveQueue.push((value, options) => { + assert.equal(options.expectedEntityRevision, 4, 'invalid durable repair must CAS against the listed entity revision'); + assert(value.revision > 4, 'the repaired snapshot must advance beyond the invalid durable revision'); + return { committed: true }; + }); + const walRestoredApp = walPreservationHarness.makeApp(); + walRestoredApp.initializeSuiteMode(); + await walRestoredApp._ensureSuiteRecoveryReady(); + assert.equal(walRestoredApp.currentSuiteSession.id, validWal.id, 'invalid durable candidates must not erase a valid window WAL'); + assert.equal(walPreservationHarness.sessionStore.peek('simulation').id, validWal.id); + assert.equal(walPreservationHarness.recoveryCalls.discard, 0, 'matching invalid durable state must be repaired without writing a tombstone'); + assert.equal(walRestoredApp.currentSuiteSession._suiteRecoveryWritesBlocked, undefined); + + const unsafeRevisionHarness = createHarness(); + const unsafeRevisionSource = unsafeRevisionHarness.makeApp(); + unsafeRevisionSource.openExam = async () => ({ closed: false, name: 'unsafe-revision-source' }); + unsafeRevisionSource.initializeSuiteMode(); + await unsafeRevisionSource._ensureSuiteRecoveryReady(); + assert.equal(await unsafeRevisionSource._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + const unsafeRevisionWal = unsafeRevisionHarness.sessionStore.peek('simulation'); + unsafeRevisionHarness.activeSessionStore.clear(); + unsafeRevisionHarness.activeSessionStore.set(unsafeRevisionWal.id, { + schema: 'suite-session-v2', + version: 2, + id: unsafeRevisionWal.id, + status: 'invalid', + revision: 1.5, + lastUpdate: Number(unsafeRevisionWal.lastUpdate) + 1000 + }); + unsafeRevisionHarness.recoveryCalls.saveQueue.push((value, options) => { + assert.equal(options.expectedEntityRevision, 0, 'unsafe durable revisions must normalize to the initial CAS revision'); + assert(value.revision > 0); + return { committed: true }; + }); + const unsafeRevisionRestoredApp = unsafeRevisionHarness.makeApp(); + unsafeRevisionRestoredApp.initializeSuiteMode(); + await unsafeRevisionRestoredApp._ensureSuiteRecoveryReady(); + assert.equal(unsafeRevisionRestoredApp.currentSuiteSession.id, unsafeRevisionWal.id); + assert.equal(unsafeRevisionHarness.recoveryCalls.discard, 0); + assert.notEqual(unsafeRevisionRestoredApp.currentSuiteSession._suiteRecoveryWritesBlocked, true); + + // A structurally valid single-suite entity with an unsafe imported revision must + // be offered as revision 0, then remain both writable and abandonable under the + // same safe-integer CAS contract used by AppData. + for (const [label, unsafeRevision] of [ + ['fractional', 1.5], + ['infinite', Number.POSITIVE_INFINITY], + ['unsafe-integer', Number.MAX_SAFE_INTEGER + 1] + ]) { + const validUnsafeHarness = createHarness(); + const validUnsafeId = `suite-valid-unsafe-${label}`; + const validUnsafeSnapshot = { + ...structuredClone(tabOwnedDurable), + id: validUnsafeId, + revision: unsafeRevision, + lastUpdate: fixtureTimeBase + 7000 + }; + validUnsafeHarness.sessionStore.save('simulation', { + ...structuredClone(validUnsafeSnapshot), + lastUpdate: fixtureTimeBase + 6000 + }); + validUnsafeHarness.activeSessionStore.set(validUnsafeId, structuredClone(validUnsafeSnapshot)); + const validUnsafeApp = validUnsafeHarness.makeApp(); + validUnsafeApp.initializeSuiteMode(); + await validUnsafeApp._ensureSuiteRecoveryReady(); + const restoredUnsafeSession = validUnsafeApp.currentSuiteSession; + assert.equal(restoredUnsafeSession.id, validUnsafeId); + assert.equal(restoredUnsafeSession.revision, 0, `${label} runtime revision must normalize to zero`); + assert.equal(restoredUnsafeSession._lastDurableRecoveryRevision, 0, `${label} durable CAS base must normalize to zero`); + + const observedWrites = []; + validUnsafeHarness.recoveryCalls.saveQueue.push((value, options) => { + observedWrites.push({ value, options }); + return { committed: true }; + }); + validUnsafeHarness.recoveryCalls.saveQueue.push((value, options) => { + observedWrites.push({ value, options }); + return { committed: true }; + }); + assert.equal(await validUnsafeApp._commitSuiteRecovery(restoredUnsafeSession, { notify: false }), true); + assert.equal(await validUnsafeApp._commitSuiteRecovery(restoredUnsafeSession, { notify: false }), true); + assert.equal(observedWrites[0].options.expectedEntityRevision, 0); + assert.equal(observedWrites[0].value.revision, 1); + assert.equal(observedWrites[1].options.expectedEntityRevision, 1); + assert.equal(observedWrites[1].value.revision, 2); + assert.equal(restoredUnsafeSession._lastDurableRecoveryRevision, 2); + + const abandonHarness = createHarness(); + abandonHarness.sessionStore.save('simulation', structuredClone(validUnsafeSnapshot)); + abandonHarness.activeSessionStore.set(validUnsafeId, structuredClone(validUnsafeSnapshot)); + const abandonApp = abandonHarness.makeApp(); + abandonApp.initializeSuiteMode(); + await abandonApp._ensureSuiteRecoveryReady(); + assert.equal(await abandonApp.abandonSuiteRecovery(validUnsafeId), true, `${label} recovery must remain abandonable`); + assert.equal(abandonHarness.recoveryCalls.discardOptions.at(-1).expectedEntityRevision, 0); + assert.equal(abandonApp.currentSuiteSession, null); + } + + const invalidUnsafeFileHarness = createHarness({ protocol: 'file:' }); + invalidUnsafeFileHarness.activeSessionStore.set('suite-invalid-unsafe-file', { + schema: 'suite-session-v2', + version: 2, + id: 'suite-invalid-unsafe-file', + status: 'invalid', + revision: Number.POSITIVE_INFINITY, + lastUpdate: fixtureTimeBase + 8000 + }); + const invalidUnsafeFileApp = invalidUnsafeFileHarness.makeApp(); + invalidUnsafeFileApp.initializeSuiteMode(); + await invalidUnsafeFileApp._ensureSuiteRecoveryReady(); + assert.equal(invalidUnsafeFileApp.currentSuiteSession, null); + assert.equal(invalidUnsafeFileHarness.recoveryCalls.discardOptions[0].expectedEntityRevision, 0); + + const walOrderingHarness = createHarness(); + const walOrderingSource = walOrderingHarness.makeApp(); + walOrderingSource.openExam = async () => ({ closed: false, name: 'wal-ordering-source' }); + walOrderingSource.initializeSuiteMode(); + await walOrderingSource._ensureSuiteRecoveryReady(); + assert.equal(await walOrderingSource._launchSuiteSessionFromSequence(sequence, { flowMode: 'simulation' }), true); + const orderingBase = walOrderingHarness.sessionStore.peek('simulation'); + const laterTimestampSameRevision = { + ...structuredClone(orderingBase), + revision: 10, + lastUpdate: fixtureTimeBase + 9000, + currentIndex: 0, + activeExamId: 'p1' + }; + const higherDurableRevision = { + ...structuredClone(orderingBase), + revision: 10, + lastUpdate: fixtureTimeBase + 1000, + currentIndex: 1, + activeExamId: 'p2' + }; + walOrderingHarness.sessionStore.save('simulation', laterTimestampSameRevision); + walOrderingHarness.activeSessionStore.set(orderingBase.id, higherDurableRevision); + const savesBeforeEqualRevisionRestore = walOrderingHarness.recoveryCalls.save; + const walOrderingApp = walOrderingHarness.makeApp(); + walOrderingApp.initializeSuiteMode(); + await walOrderingApp._ensureSuiteRecoveryReady(); + assert.equal(walOrderingApp.currentSuiteSession.currentIndex, 1, 'durable recovery must beat a later same-revision WAL branch'); + assert.equal(walOrderingApp.currentSuiteSession.activeExamId, 'p2'); + assert.equal(walOrderingHarness.recoveryCalls.save, savesBeforeEqualRevisionRestore, 'same-revision WAL must not be promoted'); + + walOrderingHarness.sessionStore.save('simulation', { + ...laterTimestampSameRevision, + revision: 11 + }); + walOrderingHarness.activeSessionStore.set(orderingBase.id, higherDurableRevision); + walOrderingHarness.recoveryCalls.saveQueue.push((_value, options) => { + assert.equal(options.expectedEntityRevision, 10); + walOrderingHarness.activeSessionStore.set(orderingBase.id, { + ...higherDurableRevision, + revision: 15 + }); + return { committed: false, code: 'STALE_RECOVERY_WRITE', actualEntityRevision: 15 }; + }); + const failedPromotionApp = walOrderingHarness.makeApp(); + failedPromotionApp.initializeSuiteMode(); + assert.equal(failedPromotionApp.currentSuiteSession, null, 'WAL promotion must remain quarantined until its claim and durable merge settle'); + await failedPromotionApp._ensureSuiteRecoveryReady(); + assert.equal(failedPromotionApp.currentSuiteSession.currentIndex, 1, 'failed promotion must fall back to durable progress'); + assert.equal(failedPromotionApp.currentSuiteSession.activeExamId, 'p2'); + assert.equal(failedPromotionApp.currentSuiteSession._lastDurableRecoveryRevision, 10, 'stale receipt must not adopt another tab revision'); + assert.equal(walOrderingHarness.sessionStore.peek('simulation').revision, 10, 'losing WAL must be replaced by the durable snapshot'); + const savesBeforeDurableReload = walOrderingHarness.recoveryCalls.save; + const durableReloadApp = walOrderingHarness.makeApp(); + durableReloadApp.initializeSuiteMode(); + await durableReloadApp._ensureSuiteRecoveryReady(); + assert.equal(durableReloadApp.currentSuiteSession.activeExamId, 'p2'); + assert.equal(walOrderingHarness.recoveryCalls.save, savesBeforeDurableReload, 'reload must not retry the losing WAL'); + + const resumedApp = makeApp(); + resumedApp.initializeSuiteMode(); + await resumedApp._ensureSuiteRecoveryReady(); + assert.equal(resumedApp.currentSuiteSession.id, firstApp.currentSuiteSession.id); + assert.equal(resumedApp.currentSuiteSession.activeExamId, 'p1'); + assert.equal(resumedApp.currentSuiteSession.globalTimerAnchorMs, firstApp.currentSuiteSession.globalTimerAnchorMs); + + const suiteWindow = { closed: false, name: 'suite-window' }; + const session = resumedApp.currentSuiteSession; + session.status = 'active'; + session.windowRef = suiteWindow; + session.activeExamId = 'p2'; + session.currentIndex = 1; + resumedApp._installManagedTestWindow('p2', suiteWindow); + const windowInfo = resumedApp.examWindows.get('p2'); + assert.equal(await resumedApp._handleSuiteDraftSync('p2', { + suiteSessionId: session.id, + draft: { answers: { q2: 'new' }, updatedAt: 100 }, + draftUpdatedAt: 100, + elapsed: 12 + }, windowInfo, suiteWindow), true); + assert.deepEqual(session.draftsByExam.p2.answers, { q2: 'new' }); + assert.equal(await resumedApp._handleSuiteDraftSync('p2', { + suiteSessionId: session.id, + draft: { answers: { q2: 'equal-must-reject' }, updatedAt: 100 }, + draftUpdatedAt: 100 + }, windowInfo, suiteWindow), false); + assert.deepEqual(session.draftsByExam.p2.answers, { q2: 'new' }); + assert.equal(await resumedApp._handleSuiteDraftSync('p2', { + suiteSessionId: session.id, + draft: { answers: { q2: 'missing-time-must-reject' } } + }, windowInfo, suiteWindow), false); + assert.equal(await resumedApp._handleSuiteDraftSync('p1', { + suiteSessionId: session.id, + draft: { answers: { q1: 'late-p1' }, updatedAt: 200 }, + draftUpdatedAt: 200 + }, windowInfo, suiteWindow), true); + assert.equal(session.activeExamId, 'p2', '迟到的旧篇草稿不得回滚活动篇章'); + assert.equal(session.currentIndex, 1); + + // A paused suite must retain its pause state when a draft omits timer fields. + const pausedSession = { + ...session, + suiteTimerRunning: false, + suiteTimerPausedAtMs: 5000, + suiteTimerPausedOffsetMs: 3000 + }; + resumedApp._syncSuiteTimerFromPayload(pausedSession, { + draft: { answers: { q1: 'paused' }, updatedAt: 300 } + }); + assert.equal(pausedSession.suiteTimerRunning, false, '普通草稿同步不得恢复暂停套题计时'); + assert.equal(pausedSession.suiteTimerPausedAtMs, 5000, '普通草稿同步不得清除暂停时间'); + + let openedOnResume = false; + resumedApp.openExam = async () => { + openedOnResume = true; + return { closed: false, name: 'replacement' }; + }; + session.windowRef = null; + session._restoredFromStorage = true; + resumedApp._fetchSuiteExamIndex = async () => sequence.map((entry) => entry.exam); + assert.equal(await resumedApp.resumeSuitePractice(session.id), true); + assert.equal(openedOnResume, true); + assert.equal(sessionStore.peek('simulation').draftsByExam.p2.answers.q2, 'new'); + + const missingApp = makeApp(); + missingApp.initializeSuiteMode(); + await missingApp._ensureSuiteRecoveryReady(); + missingApp._fetchSuiteExamIndex = async () => [sequence[0].exam]; + missingApp.openExam = async () => { throw new Error('must not open missing exam'); }; + const missingRecoveryId = missingApp.currentSuiteSession.id; + const discardCallsBeforeMismatch = sessionStore.calls.discard; + assert.equal(await missingApp.resumeSuitePractice(missingApp.currentSuiteSession.id), false); + assert.equal(sessionStore.peek('simulation').id, missingRecoveryId, '题库不一致不得擅自放弃 recovery'); + assert.equal(sessionStore.calls.discard, discardCallsBeforeMismatch, '题库不一致不得清除窗口恢复镜像'); + assert.equal( + messages.some((entry) => entry.type === 'warning' && entry.text.includes('恢复数据仍会保留')), + true, + '题库不一致必须明确告知用户 recovery 已保留' + ); + + const invalidTerminalSnapshot = { + schema: 'suite-session-v2', + version: 2, + id: 'suite_invalid_terminal', + status: 'finalizing', + sequence, + suiteSequence: sequence, + currentIndex: sequence.length, + activeExamId: null, + results: [{ + examId: 'p1', + title: 'Passage 1', + category: 'P1', + scoreInfo: { correct: 1, total: 1 } + }], + draftsByExam: {}, + elapsedByExam: {}, + suiteTimerMode: 'elapsed', + suiteTimerLimitSeconds: 3600, + startTime: 1000, + globalTimerAnchorMs: 1000, + suiteTimerAnchorMs: 1000, + lastUpdate: fixtureTimeBase + 1000 + }; + sessionStore.save('simulation', invalidTerminalSnapshot); + const corruptApp = makeApp(); + corruptApp.initializeSuiteMode(); + assert.equal(corruptApp.currentSuiteSession, null, '不完整终态快照必须被丢弃'); + assert.equal(sessionStore.peek('simulation'), null, '不完整终态快照不得永久卡在 finalizing'); + + const validTerminalResults = sequence.map((entry) => ({ + examId: entry.examId, + title: entry.exam.title, + category: entry.category, + duration: 10, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + answers: { [`q-${entry.examId}`]: 'A' }, + answerComparison: {} + })); + const terminalActiveId = 'suite_terminal_active_id'; + const terminalActiveHarness = createHarness(); + terminalActiveHarness.sessionStore.save('simulation', { + ...invalidTerminalSnapshot, + id: terminalActiveId, + status: 'active', + activeExamId: 'p3', + results: validTerminalResults, + finalizeOperationId: null, + finalizeRecord: null + }); + const terminalActiveApp = terminalActiveHarness.makeApp(); + terminalActiveApp.initializeSuiteMode(); + await terminalActiveApp._ensureSuiteRecoveryReady(); + assert.equal(terminalActiveApp.currentSuiteSession.status, 'finalizing', '已完成索引的活动篇章快照必须转入终态恢复'); + assert.equal(terminalActiveApp.currentSuiteSession.activeExamId, null, '终态恢复不得重新打开最后一篇'); + + const malformedRecordId = 'suite_malformed_record'; + sessionStore.save('simulation', { + ...invalidTerminalSnapshot, + id: malformedRecordId, + results: validTerminalResults, + finalizeOperationId: `practice-suite:${malformedRecordId}:finalize`, + finalizeRecord: { + id: malformedRecordId, + sessionId: malformedRecordId, + operationId: `practice-suite:${malformedRecordId}:finalize`, + suiteEntries: sequence.map((entry) => ({ examId: entry.examId })) + } + }); + const malformedRecordApp = makeApp(); + malformedRecordApp.initializeSuiteMode(); + assert.equal(malformedRecordApp.currentSuiteSession, null, '缺少聚合字段的终态记录不得重放'); + assert.equal(sessionStore.peek('simulation'), null); + + const incompleteFinalizeApp = makeApp(); + const incompleteFinalizeSession = { + id: 'suite_incomplete_finalize', + status: 'active', + startTime: 1000, + sequence, + currentIndex: 2, + activeExamId: 'p3', + results: validTerminalResults.filter((entry) => entry.examId !== 'p2'), + draftsByExam: {}, + elapsedByExam: {}, + flowMode: 'stationary', + windowRef: null + }; + incompleteFinalizeApp.currentSuiteSession = incompleteFinalizeSession; + incompleteFinalizeApp._saveSuitePracticeRecord = async () => { + throw new Error('incomplete suite must not be persisted'); + }; + assert.equal(await incompleteFinalizeApp._finalizeSuiteRecordWithGate(incompleteFinalizeSession), false); + assert.equal(incompleteFinalizeSession.status, 'active'); + assert.equal(incompleteFinalizeSession.currentIndex, 1); + assert.equal(incompleteFinalizeSession.activeExamId, 'p2'); + + const finalApp = makeApp(); + let committedRecord = null; + const finalSession = { + id: 'suite_terminal', + status: 'active', + startTime: 1000, + globalTimerAnchorMs: 1000, + suiteTimerAnchorMs: 1000, + sequence, + currentIndex: sequence.length, + activeExamId: null, + results: sequence.map((entry) => ({ + examId: entry.examId, + title: entry.exam.title, + category: entry.category, + duration: 10, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + answers: { [`q-${entry.examId}`]: 'A' }, + answerComparison: {} + })), + draftsByExam: {}, + elapsedByExam: {}, + windowRef: null, + windowBinding: { + examId: 'p3', + expectedSessionId: 'terminal-window-session', + windowSessionToken: 'terminal-window-token', + sessionGeneration: 2 + } + }; + const finalChild = { + closed: false, + postMessage() {}, + close() { this.closed = true; } + }; + finalApp.currentSuiteSession = finalSession; + finalApp._resolveSuiteSequenceNumber = async () => 1; + finalApp._formatSuiteDateLabel = () => '2026-08-07'; + finalApp._updatePracticeRecordsState = async () => {}; + finalApp._postExamMessage = () => true; + finalApp.refreshOverviewData = () => {}; + let reboundTerminalExamId = ''; + finalApp._tryRebindSuiteWindow = async (_session, entry) => { + reboundTerminalExamId = entry.examId; + finalApp.examWindows = new Map([[entry.examId, { + window: finalChild, + suiteSessionId: finalSession.id, + expectedSessionId: finalSession.windowBinding.expectedSessionId, + windowSessionToken: finalSession.windowBinding.windowSessionToken, + windowSessionTokenSessionId: finalSession.windowBinding.expectedSessionId, + sessionGeneration: finalSession.windowBinding.sessionGeneration + }]]); + return { window: finalChild }; + }; + assert.equal(await finalApp.resumeSuitePractice(finalSession.id), true); + committedRecord = practiceFinalizes[0] && practiceFinalizes[0].record; + assert.equal(practiceFinalizes.length, 1, '终态恢复必须调用 v2 AppData.practice.finalizeSuite'); + assert.equal(practiceFinalizes[0].operationId, 'practice-suite:suite_terminal:finalize'); + assert.equal(practiceFinalizes[0].record.operationId, practiceFinalizes[0].operationId); + assert.equal(sessionStore.peek('simulation'), null); + assert.equal(finalSession.status, 'completed'); + assert.equal(reboundTerminalExamId, 'p3', '终态恢复必须按持久 binding 重新绑定存活题页'); + assert.equal(finalChild.closed, true, '终态恢复完成后必须关闭重新绑定的题页'); + assert.equal(messages.some((entry) => entry.type === 'error'), false); + + const divergentId = 'suite_divergent_record'; + sessionStore.save('simulation', { + ...invalidTerminalSnapshot, + id: divergentId, + results: validTerminalResults, + finalizeOperationId: `practice-suite:${divergentId}:finalize`, + finalizeRecord: { + ...committedRecord, + id: divergentId, + sessionId: divergentId, + operationId: `practice-suite:${divergentId}:finalize`, + correctAnswers: 999, + scoreInfo: { ...committedRecord.scoreInfo, correct: 999 } + } + }); + const divergentApp = makeApp(); + divergentApp.initializeSuiteMode(); + assert.equal(divergentApp.currentSuiteSession, null, '分数与结果不一致的终态聚合记录必须被丢弃'); + assert.equal(sessionStore.peek('simulation'), null); + + const concurrentApp = makeApp(); + const concurrentWindow = { closed: false, name: 'concurrent-window' }; + const concurrentSession = { + ...finalSession, + id: 'suite_concurrent_finalize', + status: 'active', + currentIndex: 0, + activeExamId: 'p1', + results: [], + draftsByExam: {}, + elapsedByExam: {}, + flowMode: 'simulation', + windowRef: concurrentWindow, + finalizeRecord: null, + finalizeOperationId: null, + _lastDurableRecoveryRevision: 0, + _suiteRecoveryWritesBlocked: false, + _suiteTeardownInProgress: false, + _teardownPromise: null + }; + concurrentApp.currentSuiteSession = concurrentSession; + concurrentApp._resolveSuiteSequenceNumber = async () => 1; + concurrentApp._formatSuiteDateLabel = () => '2026-08-07'; + concurrentApp._updatePracticeRecordsState = async () => {}; + concurrentApp.refreshOverviewData = () => {}; + concurrentApp._suiteModeReady = true; + let finalizeCalls = 0; + let releaseFinalizeSave; + let markFinalizeSaveStarted; + const finalizeSaveStarted = new Promise((resolve) => { markFinalizeSaveStarted = resolve; }); + const finalizeSaveGate = new Promise((resolve) => { releaseFinalizeSave = resolve; }); + concurrentApp._saveSuitePracticeRecord = async (record) => { + finalizeCalls += 1; + markFinalizeSaveStarted(); + await finalizeSaveGate; + return record; + }; + const concurrentPayload = { + suiteSessionId: concurrentSession.id, + suiteSubmission: true, + submissionId: 'submission-concurrent', + suiteEntries: sequence.map((entry) => ({ + examId: entry.examId, + duration: 10, + scoreInfo: { correct: 1, total: 1, accuracy: 1, percentage: 100 }, + answers: { [`q-${entry.examId}`]: 'A' }, + answerComparison: {} + })) + }; + const concurrentSubmits = [ + concurrentApp._handleInlineSimulationSuiteSubmit('p1', concurrentPayload, concurrentWindow), + concurrentApp._handleInlineSimulationSuiteSubmit('p1', concurrentPayload, concurrentWindow) + ]; + await finalizeSaveStarted; + assert.equal(await concurrentApp.abandonSuiteRecovery(concurrentSession.id), false, 'live finalize 期间不得承诺放弃成功'); + assert.equal(concurrentApp.currentSuiteSession, concurrentSession, '拒绝放弃时必须保留当前 finalizing session'); + releaseFinalizeSave(); + const concurrentOutcomes = await Promise.all(concurrentSubmits); + assert.equal(finalizeCalls, 1, '并发 inline submit 必须复用同一个 finalize promise'); + assert.equal(concurrentOutcomes.every((outcome) => outcome && outcome.committed === true), true); + + const mirrorHarness = createHarness(); + const mirrorApp = mirrorHarness.makeApp(); + const mirrorOwner = { id: 'suite-mirror-denied' }; + assert.equal(await mirrorApp._acquireSuiteRecoveryClaim('single', mirrorOwner), true); + mirrorHarness.sessionStore.save = () => { + const error = new Error('session storage denied'); + error.name = 'SecurityError'; + throw error; + }; + assert.equal(mirrorApp._mirrorSuiteRecoverySnapshot({ id: 'suite-mirror-denied' }, mirrorOwner), false); + assert.equal( + mirrorHarness.messages.filter((entry) => entry.type === 'warning' && entry.text.includes('临时恢复存储')).length, + 1, + 'sessionStorage 拒绝必须产生可见降级提示' + ); + assert.equal(mirrorApp._mirrorSuiteRecoverySnapshot({ id: 'suite-mirror-denied' }, mirrorOwner), false); + assert.equal( + mirrorHarness.messages.filter((entry) => entry.type === 'warning' && entry.text.includes('临时恢复存储')).length, + 1, + '连续镜像失败提示必须节流' + ); + + process.stdout.write(JSON.stringify({ status: 'pass', detail: 'v2 suite recovery state machine passed' })); +} + +main().catch((error) => { + process.stdout.write(JSON.stringify({ status: 'fail', detail: error.stack || String(error) })); + process.exit(1); +}); diff --git a/developer/tests/js/unifiedReadingCoreRegression.test.js b/developer/tests/js/unifiedReadingCoreRegression.test.js index 25a1bb82..c94da775 100644 --- a/developer/tests/js/unifiedReadingCoreRegression.test.js +++ b/developer/tests/js/unifiedReadingCoreRegression.test.js @@ -15,22 +15,6 @@ function loadScript(relativePath, context) { vm.runInContext(code, context, { filename: relativePath }); } -function createSessionStorageStub() { - const store = new Map(); - return { - store, - getItem(key) { - return store.has(key) ? store.get(key) : null; - }, - setItem(key, value) { - store.set(key, String(value)); - }, - removeItem(key) { - store.delete(key); - } - }; -} - function createClassList() { return { add() {}, @@ -51,7 +35,6 @@ function createContext() { HTMLSelectElement.prototype = Object.create(HTMLElement.prototype); HTMLSelectElement.prototype.constructor = HTMLSelectElement; - const sessionStorage = createSessionStorageStub(); const timer = { textContent: '', style: {}, @@ -125,7 +108,6 @@ function createContext() { }, history: { replaceState() {} }, document, - sessionStorage, opener: null, parent: null, addEventListener() {}, @@ -195,7 +177,6 @@ function createContext() { HTMLSelectElement, CustomEvent: window.CustomEvent, CSS: window.CSS, - sessionStorage, location: window.location }; sandbox.globalThis = window; @@ -254,19 +235,76 @@ async function testSubmitPostsBeforeExplanationRenderFinishes() { releaseExplanation = resolve; })); - const submitPromise = hooks.handleSubmit(); + let submitError = null; + const submitPromise = hooks.handleSubmit().catch((error) => { + submitError = error; + }); await Promise.resolve(); + assert.ifError(submitError); assert.strictEqual(messages.length, 1, 'submit should notify host before explanation rendering completes'); assert.strictEqual(messages[0].type, 'PRACTICE_COMPLETE', 'submit should post a practice completion message'); assert.strictEqual(messages[0].data?.answers?.q1, 'A', 'posted submission should include the current answer'); releaseExplanation(); await submitPromise; + assert.ifError(submitError); hooks.setTestOverride('renderExplanations', null); assert.strictEqual(window.__UNIFIED_READING_SIMULATION_MODE__, false, 'submit regression harness should remain in non-simulation mode'); } +function testDraftBearingInitIsNotSuppressed() { + const { hooks } = loadHooks(); + const baseData = { + examId: 'reading-p1', + sessionId: 'session-init', + windowSessionToken: 'token-init', + messageIssuedAtMs: 1000 + }; + const noDraftSignature = hooks.buildInitSignature(baseData); + const draftData = { + ...baseData, + draft: { answers: { q1: 'A' }, updatedAt: 2000 } + }; + const draftSignature = hooks.buildInitSignature(draftData); + assert.notStrictEqual( + draftSignature, + noDraftSignature, + 'a later draft-bearing INIT must not be suppressed by an earlier no-draft INIT' + ); + assert.strictEqual( + hooks.buildInitSignature(draftData), + draftSignature, + 'repeated INITs carrying the same draft should still be deduplicated' + ); +} + +function testSuiteReviewAnnotationsUseDraftChannel() { + const { hooks } = loadHooks(); + const messages = []; + const hostWindow = { + postMessage(payload) { + messages.push(payload); + } + }; + hooks.setTestState({ + examId: 'reading-suite-review', + sessionId: 'session-suite-review', + suiteSessionId: 'suite-review', + simulationMode: true, + suiteReviewMode: true, + reviewMode: true, + readOnly: true, + parentWindow: hostWindow + }); + + hooks.syncReadingAnnotation('note-edit'); + + assert.strictEqual(messages.length, 1, 'suite review annotation should emit one persistence message'); + assert.strictEqual(messages[0].type, 'SIMULATION_DRAFT_SYNC', 'suite review annotation must use the suite draft channel'); + assert.strictEqual(messages[0].data?.examId, 'reading-suite-review', 'suite draft sync must retain the active exam id'); +} + function testGroupedCheckboxSplitKeysScorePartially() { const { hooks } = loadHooks(); const results = hooks.buildResultsFromAnswers({ @@ -358,6 +396,8 @@ function testSuiteTimerIgnoresEmptyLimitValues() { async function main() { await testSubmitPostsBeforeExplanationRenderFinishes(); + testDraftBearingInitIsNotSuppressed(); + testSuiteReviewAnnotationsUseDraftChannel(); testGroupedCheckboxSplitKeysScorePartially(); testGroupedCheckboxSingleKeyArrayScoresPartially(); testAcceptedAnswerArraysStaySinglePoint(); diff --git a/developer/tests/js/unifiedReadingLockRegression.test.js b/developer/tests/js/unifiedReadingLockRegression.test.js index 51853362..8d5b3117 100644 --- a/developer/tests/js/unifiedReadingLockRegression.test.js +++ b/developer/tests/js/unifiedReadingLockRegression.test.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from 'fs'; import path from 'path'; +import vm from 'vm'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -15,7 +16,101 @@ function ok(cond, label, failed) { if (!cond) failed.push(label); } -function run() { +async function testEndlessLifecycle(failed) { + const appActionsSource = read('js/presentation/app-actions.js'); + const intervalCallbacks = new Map(); + let nextIntervalId = 0; + let messageHandler = null; + const practiceWindow = { + closed: false, + focus() {}, + postMessage() {}, + location: { href: 'about:blank' } + }; + const openCalls = []; + const examWindows = new Map(); + const exams = [{ + id: 'reading-endless', + type: 'reading', + hasHtml: true, + title: 'Endless Reading' + }]; + const windowStub = { + location: { href: 'https://example.test/index.html' }, + document: { + readyState: 'loading', + addEventListener() {}, + querySelector() { return null; } + }, + resolveActiveLibraryIndex: async () => exams, + showMessage() {}, + addEventListener(type, listener) { + if (type === 'message') messageHandler = listener; + }, + removeEventListener(type, listener) { + if (type === 'message' && messageHandler === listener) messageHandler = null; + }, + app: { + examWindows, + async openExam(examId, options) { + openCalls.push({ examId, options }); + examWindows.set(examId, { + expectedOrigin: 'https://example.test', + allowOpaqueOrigin: false, + windowSessionToken: 'endless-token' + }); + return practiceWindow; + }, + _postExamMessage() { return true; } + } + }; + const context = vm.createContext({ + window: windowStub, + document: windowStub.document, + console, + URL, + Promise, + Math, + Date, + setInterval(callback) { + const id = ++nextIntervalId; + intervalCallbacks.set(id, callback); + return id; + }, + clearInterval(id) { + intervalCallbacks.delete(id); + } + }); + vm.runInContext(appActionsSource, context, { filename: 'app-actions.js' }); + + await windowStub.AppActions.startEndlessPractice(); + ok(openCalls.length === 1, 'endless_first_open_not_called', failed); + ok(openCalls[0]?.options?.endlessMode === true, 'endless_first_open_missing_mode', failed); + ok(openCalls[0]?.options?.windowName === 'ielts-endless-mode-tab', 'endless_first_open_missing_stable_window_name', failed); + ok(typeof messageHandler === 'function', 'endless_message_handler_not_installed', failed); + + messageHandler?.({ + source: practiceWindow, + origin: 'https://example.test', + data: { + type: 'PRACTICE_COMPLETE', + source: 'practice_page', + data: { windowSessionToken: 'endless-token' } + } + }); + const countdownId = Math.max(...intervalCallbacks.keys()); + for (let tick = 0; tick < 5; tick += 1) { + intervalCallbacks.get(countdownId)?.(); + } + await Promise.resolve(); + await Promise.resolve(); + ok(openCalls.length === 2, 'endless_next_exam_did_not_use_openExam', failed); + ok(openCalls[1]?.options?.reuseWindow === practiceWindow, 'endless_next_exam_did_not_reuse_window', failed); + ok(openCalls[1]?.options?.endlessMode === true, 'endless_next_exam_missing_mode', failed); + windowStub.AppActions.stopEndlessPractice({ silent: true }); +} + +async function run() { const failed = []; const unifiedHtml = read('assets/generated/reading-exams/reading-practice-unified.html'); const unifiedPage = read('js/runtime/unifiedReadingPage.js'); @@ -24,6 +119,11 @@ function run() { ok(!/practice-page-ui\.js/.test(unifiedHtml), 'unified_html_loads_practice_page_ui', failed); ok(!/leftHtmlWithHighlights/.test(unifiedPage), 'unified_page_contains_leftHtmlWithHighlights', failed); ok(/function enterSubmittedReadOnlyState\s*\(/.test(unifiedPage), 'missing_enterSubmittedReadOnlyState', failed); + ok(/function setTimerLockMode\s*\([\s\S]*data-note-outline-add[\s\S]*disabled/.test(unifiedPage), 'timer_lock_does_not_disable_note_controls', failed); + ok(/function canEditReadingNotes\s*\(\)\s*\{\s*if \(state\.timerLocked\) return false;/.test(unifiedPage), 'can_edit_notes_allows_timer_lock', failed); + ok(/function upsertNote\s*\([\s\S]*if \(!canEditReadingNotes\(\)\) return null;/.test(unifiedPage), 'note_upsert_not_guarded_by_timer_lock', failed); + ok(/function syncReadingAnnotation\s*\([\s\S]*if \(!canEditReadingNotes\(\)\) return;/.test(unifiedPage), 'annotation_sync_not_guarded_by_timer_lock', failed); + ok(/function canSyncReadingDraft\s*\([\s\S]*!state\.timerLocked/.test(unifiedPage), 'draft_sync_not_guarded_by_timer_lock', failed); ok(/dom\.exitBtn\?\.addEventListener\('click',\s*handleExitClick\)/.test(unifiedPage), 'missing_exit_btn_binding', failed); ok(/ENDLESS_USER_EXIT/.test(unifiedPage), 'missing_endless_exit_message', failed); ok(/stopEndlessPractice/.test(unifiedPage), 'missing_endless_stop_function', failed); @@ -33,6 +133,7 @@ function run() { ok(/displayAnswerValue\(entry\.userAnswer\)/.test(unifiedPage), 'review_results_user_answer_not_normalized', failed); ok(/displayAnswerValue\(entry\.correctAnswer,\s*''\)/.test(unifiedPage), 'review_results_correct_answer_not_normalized', failed); ok(/setDropzoneAnswer\(dropzone,\s*value,\s*label\)/.test(unifiedPage), 'dropzone_replay_label_not_preserved', failed); + ok(/value:\s*item\.dataset\.heading\s*\|\|\s*item\.dataset\.option\s*\|\|\s*item\.dataset\.key/.test(unifiedPage), 'drag_payload_ignores_data_key', failed); ok(/const valueList = splitAnswerTokens\(rawValue\);/.test(unifiedPage), 'replay_field_value_list_not_normalized', failed); ok(!/String\(rawValue == null \? '' : rawValue\)\.split/.test(unifiedPage), 'replay_raw_object_string_split_regressed', failed); ok(/--reading-left-pane-width/.test(unifiedHtml), 'missing_resizable_reading_pane_width_var', failed); @@ -52,6 +153,7 @@ function run() { ok(/#right \.tfng-item > p\s*\{[\s\S]*margin:\s*0 0 6px/.test(unifiedHtml), 'tfng_stem_option_spacing_not_scoped', failed); ok(/\.tfng-options\s*\{[\s\S]*gap:\s*4px 12px/.test(unifiedHtml), 'tfng_option_row_spacing_missing', failed); ok(/function restoreHighlights\s*\([\s\S]*?return restoredCount;/.test(highlightShared), 'restoreHighlights_no_restore_count', failed); + await testEndlessLifecycle(failed); if (failed.length) { process.stdout.write(JSON.stringify({ @@ -68,4 +170,4 @@ function run() { })); } -run(); +await run(); diff --git a/developer/tests/js/unifiedReadingNotesMigration.test.js b/developer/tests/js/unifiedReadingNotesMigration.test.js new file mode 100644 index 00000000..dbe38998 --- /dev/null +++ b/developer/tests/js/unifiedReadingNotesMigration.test.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..', '..', '..'); +const page = fs.readFileSync(path.join(root, 'js/runtime/unifiedReadingPage.js'), 'utf8'); +const highlights = fs.readFileSync(path.join(root, 'js/runtime/readingHighlightShared.js'), 'utf8'); + +assert.match(highlights, /noteId:\s*node\.dataset/); +assert.match(highlights, /offsetSpan\.dataset\.noteId\s*=\s*String\(record\.noteId\)/); +assert.match(highlights, /span\.dataset\.noteId\s*=\s*String\(record\.noteId\)/); + +for (const [field, collector] of [['notes', 'collectNotes'], ['noteOutlines', 'collectNoteOutlines'], ['markedQuestions', 'getCurrentMarkedQuestions']]) { + assert.match(page, new RegExp(`${field}: ${collector}`, 'm'), `${field} must be collected into drafts/submissions`); + assert.match(page, new RegExp(`${field}: normalize`, 'm'), `${field} must be normalized at payload boundaries`); +} + +assert.match(page, /if \(state\.reviewMode\) \{[\s\S]*postMessage\('READING_ANNOTATION_SYNC'/); +assert.match(page, /postMessage\('READING_DRAFT_SYNC'/); +assert.match(page, /function canSyncReadingDraft\(\)/); +assert.match(page, /attachReadingDraftLifecycleHooks/); +assert.match(page, /recordId:\s*state\.reviewRecordId/); +assert.match(page, /annotations:\s*\{[\s\S]*highlights:[\s\S]*noteText:[\s\S]*notes:[\s\S]*noteOutlines:[\s\S]*markedQuestions:[\s\S]*scrollY:/); +assert.match(page, /state\.reviewRecordId\s*=\s*String\(data\.recordId \|\| entry\.id/); +assert.match(page, /function canEditReadingNotes\(\)[\s\S]*!state\.readOnly[\s\S]*!state\.timerLocked[\s\S]*!state\.memorizeMode[\s\S]*!state\.submitted/); +assert.match(page, /const canEditNotes = canEditReadingNotes\(\)/); +assert.match(page, /control\.closest\('#reading-note-editor, #reading-note-drawer'\)/); +assert.match(page, /syncReadingAnnotation\('highlight'\)/); +assert.match(page, /function clearStructuredNotesForReset\(\)[\s\S]*\.hl\[data-note-id\], \.hl\[data-hl-type="note"\]/); +assert.match(page, /#reading-note-drawer\{[^}]*z-index:3600/); +assert.match(page, /#reading-note-editor\{[^}]*z-index:3700/); +assert.match(page, /data-result-question-id/); +assert.match(page, /displayUserAnswer:\s*selectedTokens\.length/); + +assert.doesNotMatch(page, /saveLocalReadingRecord|ExamSystemDB|exam_system_practice_records|indexedDB/i); + +console.log(JSON.stringify({ + status: 'pass', + detail: 'structured notes, note anchors, review sync, display controls and safe storage boundaries covered' +})); diff --git a/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js b/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js index 68c0a87a..862652e6 100644 --- a/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js +++ b/developer/tests/js/unifiedReadingPageInlineSuiteRegression.test.js @@ -15,18 +15,20 @@ function loadScript(relativePath, context) { vm.runInContext(code, context, { filename: relativePath }); } -function createSessionStorageStub() { +function createWindowSessionStub() { const store = new Map(); return { - store, - getItem(key) { - return store.has(key) ? store.get(key) : null; + save(name, value) { + store.set(String(name), JSON.parse(JSON.stringify(value))); + return true; }, - setItem(key, value) { - store.set(key, String(value)); + get(name) { + const value = store.get(String(name)); + return value == null ? null : JSON.parse(JSON.stringify(value)); }, - removeItem(key) { - store.delete(key); + discard(name) { + store.delete(String(name)); + return true; } }; } @@ -34,7 +36,25 @@ function createSessionStorageStub() { function createDocumentStub() { const radio = { checked: true, value: 'A' }; const notes = { value: 'fresh note' }; + const timerClasses = new Set(); + const pendingScripts = []; + const timer = { + textContent: '', + dataset: {}, + style: {}, + classList: { + add(...names) { names.forEach((name) => timerClasses.add(name)); }, + remove(...names) { names.forEach((name) => timerClasses.delete(name)); }, + toggle(name, enabled) { + if (enabled) timerClasses.add(name); + else timerClasses.delete(name); + }, + contains(name) { return timerClasses.has(name); } + } + }; return { + __timer: timer, + __pendingScripts: pendingScripts, body: { dataset: {}, classList: { @@ -56,32 +76,73 @@ function createDocumentStub() { } return []; }, - getElementById() { - return null; + getElementById(id) { + return id === 'timer' ? timer : null; + }, + createElement(tagName) { + const normalizedTag = String(tagName || '').toLowerCase(); + if (normalizedTag === 'script') { + return { src: '', defer: false, onload: null, onerror: null }; + } + if (normalizedTag === 'template') { + return { + innerHTML: '', + content: { querySelectorAll() { return []; } } + }; + } + return { + dataset: {}, + style: {}, + classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + setAttribute() {}, + addEventListener() {}, + appendChild() {} + }; + }, + head: { + appendChild(script) { + pendingScripts.push(script); + return script; + } }, addEventListener() {}, removeEventListener() {} }; } +function hostEvent(sourceWindow, type, data, overrides = {}) { + return { + source: overrides.source || sourceWindow, + origin: overrides.origin || 'http://localhost', + data: { type, source: overrides.envelopeSource || 'exam_host', data } + }; +} + function createContext() { - const sessionStorage = createSessionStorageStub(); + const windowSession = createWindowSessionStub(); const document = createDocumentStub(); + const messages = []; + let closeCount = 0; const window = { location: { href: 'http://localhost/assets/generated/reading-exams/reading-practice-unified.html?examId=reading-p1', - search: '?examId=reading-p1' + search: '?examId=reading-p1', + protocol: 'http:' }, history: { replaceState() {} }, document, - sessionStorage, + AppData: { + ready: Promise.resolve(true), + recovery: { windowSession } + }, opener: null, parent: null, addEventListener() {}, removeEventListener() {}, scrollTo() {}, scrollY: 0, - close() {}, + close() { closeCount += 1; }, + showMessage(text, type) { messages.push({ text, type }); }, console, setTimeout, clearTimeout, @@ -132,22 +193,21 @@ function createContext() { HTMLInputElement: window.HTMLInputElement, HTMLTextAreaElement: window.HTMLTextAreaElement, HTMLSelectElement: window.HTMLSelectElement, - sessionStorage, location: window.location }; sandbox.globalThis = window; - return { context: vm.createContext(sandbox), window, document, sessionStorage }; + return { context: vm.createContext(sandbox), window, document, windowSession, messages, getCloseCount: () => closeCount }; } function loadHooks() { - const { context, window, sessionStorage } = createContext(); + const { context, window, document, windowSession, messages, getCloseCount } = createContext(); window.__IELTS_READING_PAGE_TEST_HOOKS__ = true; window.__READING_EXAM_MANIFEST__ = {}; window.__READING_EXAM_DATA__ = new Map(); loadScript('js/runtime/unifiedReadingPage.js', context); const hooks = window.__IELTS_UNIFIED_READING_PAGE_TEST__; assert(hooks, 'should expose unified reading page test hooks'); - return { hooks, window, sessionStorage }; + return { hooks, window, document, windowSession, messages, getCloseCount }; } function plain(value) { @@ -178,6 +238,31 @@ async function testDraftArbitration() { assert.strictEqual(fresh.updatedAt, 3000, 'newer draft must win updatedAt'); } +async function testSuiteTimerModePrecedence() { + const { hooks, document } = loadHooks(); + const pausedAtMs = Date.now(); + hooks.setTestState({ + suiteSessionId: 'suite-timer', + suiteTimerMode: 'elapsed', + suiteTimerLimitSeconds: 60, + suiteTimerAnchorMs: pausedAtMs - 120000, + pagePausedAtMs: pausedAtMs, + pagePausedOffsetMs: 0 + }); + hooks.renderTimer(); + assert.strictEqual(document.__timer.textContent, '02:00', 'elapsed 套题必须显示正计时'); + assert.strictEqual(document.__timer.dataset.timerMode, 'elapsed'); + assert.strictEqual(document.__timer.classList.contains('timer-expired'), false, 'elapsed 套题不得按 limit 触发倒计时过期'); + + hooks.setTestState({ + suiteTimerMode: 'countdown', + suiteTimerLimitSeconds: 3600 + }); + hooks.renderTimer(); + assert.strictEqual(document.__timer.textContent, '58 minutes remaining'); + assert.strictEqual(document.__timer.dataset.timerMode, 'countdown'); +} + async function testInlineEnvelopeGuard() { const { hooks } = loadHooks(); @@ -231,7 +316,7 @@ async function testInlineEnvelopeGuard() { } async function testInlineReinitSnapshot() { - const { hooks, sessionStorage, window } = loadHooks(); + const { hooks, windowSession, window } = loadHooks(); hooks.setTestState({ examId: 'reading-p1', @@ -284,9 +369,8 @@ async function testInlineReinitSnapshot() { assert.deepStrictEqual(plain(slotEntry[1].draft.answers), { q1: 'A' }, 'slot draft must be updated before reinit'); assert.strictEqual(slotEntry[1].draft.noteText, 'fresh note', 'slot draft noteText must be updated before reinit'); - const storageKey = 'ielts_sim_draft::suite-1::reading-p1'; - assert(sessionStorage.store.has(storageKey), 'reinit snapshot must persist the local mirror'); - const stored = JSON.parse(sessionStorage.store.get(storageKey)); + const stored = windowSession.get('simulation-draft:suite-1:reading-p1'); + assert(stored, 'reinit snapshot must persist the window-session draft'); assert.deepStrictEqual(plain(stored.draft.answers), { q1: 'A' }, 'persisted mirror must use the captured draft'); } @@ -299,8 +383,12 @@ async function testWindowSessionMessageGuard() { sessionId: 'session-new', suiteSessionId: 'suite-new', parentWindow: sourceWindow, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, windowSessionToken: 'token-new', windowSessionIssuedAtMs: 5000, + windowSessionGeneration: 2, lastInitSignature: '', simulationCtx: { examId: 'reading-p2', flowMode: 'simulation', currentIndex: 1 }, suite: { @@ -316,29 +404,21 @@ async function testWindowSessionMessageGuard() { } }); - await hooks.handleIncoming({ - source: sourceWindow, - data: { - type: 'INIT_SESSION', - data: { + await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', { examId: 'reading-p2', sessionId: 'session-new', suiteSessionId: 'suite-new', windowSessionToken: 'token-old', - messageIssuedAtMs: 4000 - } - } - }); + windowSessionGeneration: 1, + messageIssuedAtMs: 4000, + parentOrigin: 'http://localhost' + })); let state = hooks.getTestState(); assert.strictEqual(state.lastInitSignature, '', 'stale INIT_SESSION must not overwrite current inline session'); assert.strictEqual(state.windowSessionToken, 'token-new', 'stale INIT_SESSION must not replace window token'); - await hooks.handleIncoming({ - source: sourceWindow, - data: { - type: 'SIMULATION_CONTEXT', - data: { + await hooks.handleIncoming(hostEvent(sourceWindow, 'SIMULATION_CONTEXT', { examId: 'reading-p2', sessionId: 'session-new', suiteSessionId: 'suite-new', @@ -346,47 +426,580 @@ async function testWindowSessionMessageGuard() { currentIndex: 0, total: 3, windowSessionToken: 'token-old', + windowSessionGeneration: 1, messageIssuedAtMs: 4000, suiteSequence: [ { examId: 'reading-p1' }, { examId: 'reading-p2' }, { examId: 'reading-p3' } ] - } - } - }); + })); state = hooks.getTestState(); assert.strictEqual(state.simulationCtx.currentIndex, 1, 'stale SIMULATION_CONTEXT must not replace active simulation context'); - await hooks.handleIncoming({ - source: sourceWindow, - data: { - type: 'INIT_SESSION', - data: { + await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', { examId: 'reading-p2', sessionId: 'session-newer', suiteSessionId: 'suite-new', windowSessionToken: 'token-newer', - messageIssuedAtMs: 6000 - } - } - }); + windowSessionGeneration: 3, + messageIssuedAtMs: 6000, + parentOrigin: 'http://localhost' + })); state = hooks.getTestState(); assert.strictEqual(state.sessionId, 'session-newer', 'newer INIT_SESSION must still be accepted'); assert.strictEqual(state.windowSessionToken, 'token-newer', 'newer INIT_SESSION must adopt the latest window token'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', { + examId: 'reading-p2', + sessionId: 'session-newer', + suiteSessionId: 'suite-new', + windowSessionToken: 'token-rebound', + windowSessionGeneration: 4, + messageIssuedAtMs: 7000, + parentOrigin: 'http://localhost' + })); + + state = hooks.getTestState(); + assert.strictEqual( + state.windowSessionToken, + 'token-rebound', + 'same-session rebind INIT must not be suppressed when token/generation rotates' + ); + + assert.strictEqual( + hooks.shouldAcceptWindowSessionMessage({ + windowSessionToken: 'token-same-ms-old', + windowSessionGeneration: 4, + messageIssuedAtMs: 7000 + }, sourceWindow), + false, + '同一注册代际的旧 token 即使时间戳相同也必须拒绝' + ); + assert.strictEqual( + hooks.shouldAcceptWindowSessionMessage({ + windowSessionToken: 'token-next-generation', + windowSessionGeneration: 5, + messageIssuedAtMs: 7000 + }, sourceWindow), + true, + '更高注册代际必须覆盖旧 token' + ); + hooks.setTestState({ + windowSessionToken: 'token-current-no-generation', + windowSessionIssuedAtMs: 6000, + windowSessionGeneration: 0 + }); + assert.strictEqual( + hooks.shouldAcceptWindowSessionMessage({ + windowSessionToken: 'token-equal-ms-no-generation', + messageIssuedAtMs: 6000 + }, sourceWindow), + false, + '缺少注册代际且时间戳相等的不同 token 必须拒绝' + ); + hooks.stopReadingDraftSync(); + hooks.stopSimulationDraftSync(); +} + +async function testReferrerlessInitBindsOnlyTrustedHost() { + const { hooks } = loadHooks(); + const parentWindow = { postMessage() {} }; + hooks.setTestState({ + examId: 'reading-p1', + sessionId: null, + suiteSessionId: null, + parentWindow, + expectedParentOrigin: '', + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', + lastInitSignature: '' + }); + const initData = { + examId: 'reading-p1', + sessionId: 'referrerless-session', + parentOrigin: 'https://host.example', + windowSessionToken: 'referrerless-token' + }; + await hooks.handleIncoming(hostEvent({ postMessage() {} }, 'INIT_SESSION', initData, { origin: 'https://host.example' })); + assert.strictEqual(hooks.getTestState().parentOrigin, '', 'a forged source must not bind a referrerless child'); + await hooks.handleIncoming(hostEvent(parentWindow, 'INIT_SESSION', initData, { origin: 'https://attacker.invalid' })); + assert.strictEqual(hooks.getTestState().parentOrigin, '', 'a mismatched origin must not bind a referrerless child'); + await hooks.handleIncoming(hostEvent(parentWindow, 'INIT_SESSION', initData, { origin: 'https://host.example' })); + assert.strictEqual(hooks.getTestState().parentOrigin, 'https://host.example', 'trusted non-opaque INIT must bind the missing referrer origin'); + assert.strictEqual(hooks.getTestState().windowSessionToken, 'referrerless-token'); + + const fileHarness = loadHooks(); + const fileParent = { postMessage() {} }; + fileHarness.window.location.protocol = 'file:'; + fileHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: null, + suiteSessionId: null, + parentWindow: fileParent, + expectedParentOrigin: '', + parentOrigin: '', + parentOriginIsOpaque: false, + windowSessionToken: '', + lastInitSignature: '' + }); + await fileHarness.hooks.handleIncoming(hostEvent(fileParent, 'INIT_SESSION', { + examId: 'reading-p1', + sessionId: 'file-session', + parentOrigin: 'file://', + windowSessionToken: 'file-token' + }, { origin: 'file://' })); + assert.strictEqual(fileHarness.hooks.getTestState().parentOrigin, 'null', + 'file:// opener INIT must bind the opaque origin'); + assert.strictEqual(fileHarness.hooks.getTestState().parentOriginIsOpaque, true); + assert.strictEqual(fileHarness.hooks.getTestState().windowSessionToken, 'file-token'); +} + +async function testSavedRecordAcknowledgementSessionGate() { + const { hooks } = loadHooks(); + const sourceWindow = { name: 'saved-record-host' }; + hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-current', + submittedRecordId: 'record-existing', + parentWindow: sourceWindow, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-current', + suite: { + inline: false, + slotsByExamId: new Map() + } + }); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + examId: 'reading-p1', + sessionId: 'session-stale', + recordId: 'record-stale', + windowSessionToken: 'token-current' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-existing', + 'a late acknowledgement from an older session must be ignored' + ); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + examId: 'reading-p1', + recordId: 'record-without-session', + windowSessionToken: 'token-current' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-existing', + 'an acknowledgement without a session binding must be ignored' + ); + + const validAcknowledgement = { + examId: 'reading-p1', + sessionId: 'session-current', + recordId: 'record-current', + windowSessionToken: 'token-current' + }; + await hooks.handleIncoming(hostEvent( + sourceWindow, + 'PRACTICE_RECORD_SAVED', + validAcknowledgement, + { origin: 'https://attacker.invalid' } + )); + await hooks.handleIncoming(hostEvent( + sourceWindow, + 'PRACTICE_RECORD_SAVED', + validAcknowledgement, + { source: { name: sourceWindow.name } } + )); + await hooks.handleIncoming(hostEvent( + sourceWindow, + 'PRACTICE_RECORD_SAVED', + validAcknowledgement, + { envelopeSource: 'practice_page' } + )); + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + ...validAcknowledgement, + windowSessionToken: 'token-forged' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-existing', + 'wrong origin/window/source/token must not overwrite the saved record binding' + ); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + ...validAcknowledgement + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-current', + 'the current session acknowledgement should bind its saved record id' + ); + + hooks.setTestState({ sessionId: null }); + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_RECORD_SAVED', { + examId: 'reading-p1', + sessionId: 'session-current', + recordId: 'record-during-restart', + windowSessionToken: 'token-current' + })); + assert.strictEqual( + hooks.getTestState().submittedRecordId, + 'record-current', + 'an acknowledgement received during session restart must be ignored' + ); +} + +async function testSubmitAcknowledgementStateMachine() { + const falseHarness = loadHooks(); + falseHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-false', + parentWindow: { postMessage() { return false; } }, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-false', + submissionStatus: 'draft', + submissionId: '' + }); + assert.strictEqual(falseHarness.hooks.beginSubmission('PRACTICE_COMPLETE', {}), false); + assert.strictEqual(falseHarness.hooks.getTestState().submissionStatus, 'draft'); + assert.strictEqual(falseHarness.hooks.getTestState().readOnly, false); + + const failedHarness = loadHooks(); + const throwingParent = { + postMessage() { + throw new Error('delivery failed'); + } + }; + failedHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-failed', + parentWindow: throwingParent, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-failed', + submissionStatus: 'draft', + submissionId: '' + }); + assert.strictEqual( + failedHarness.hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), + false, + 'a synchronous postMessage failure must reject the submission attempt' + ); + assert.strictEqual(failedHarness.hooks.getTestState().submissionStatus, 'draft'); + assert.strictEqual(failedHarness.hooks.getTestState().readOnly, false); + + const delivered = []; + const sourceWindow = { + postMessage(message) { + delivered.push(message); + } + }; + const { hooks, messages, getCloseCount } = loadHooks(); + hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-current', + suiteSessionId: null, + parentWindow: sourceWindow, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-current', + submissionStatus: 'draft', + submissionId: '' + }); + + assert.strictEqual(hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), true); + let state = hooks.getTestState(); + const submissionId = state.submissionId; + assert.strictEqual(state.submissionStatus, 'submitting'); + assert.strictEqual(state.submitted, false, 'delivery alone must not mark the page submitted'); + assert.strictEqual(state.readOnly, false, 'delivery alone must not lock the page'); + assert.strictEqual(delivered.length, 1); + assert.strictEqual(delivered[0].data.submissionId, submissionId); + assert.strictEqual( + hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), + false, + 'a duplicate click while submitting must be ignored' + ); + assert.strictEqual(delivered.length, 1, 'duplicate clicks must not emit a second message'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', { + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current' + })); + assert.strictEqual(hooks.getTestState().submissionStatus, 'submitting', 'ACK without examId must be ignored'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_FAILED', { + examId: 'reading-p1', + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current', + errorCode: 'suite_recovery_save_failed' + })); + state = hooks.getTestState(); + assert.strictEqual(state.submissionStatus, 'draft'); + assert.strictEqual(state.readOnly, false); + assert.strictEqual(messages.length, 1, 'a persistence NACK must be visible in the active question page'); + assert.strictEqual(messages[0].type, 'error'); + assert.match(messages[0].text, /未能安全保存/); + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', { + examId: 'reading-p1', + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current' + })); + assert.strictEqual(hooks.getTestState().submissionStatus, 'draft', 'late ACK after NACK must not submit the page'); + assert.strictEqual(hooks.getTestState().readOnly, false); + + assert.strictEqual(hooks.beginSubmission('PRACTICE_COMPLETE', { answers: { q1: 'A' } }), true); + assert.strictEqual(delivered.length, 2); + assert.strictEqual(delivered[1].data.submissionId, submissionId, 'retry must reuse the idempotency key'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'PRACTICE_SUBMIT_ACK', { + examId: 'reading-p1', + sessionId: 'session-submit-current', + submissionId, + windowSessionToken: 'token-submit-current' + })); + state = hooks.getTestState(); + assert.strictEqual(state.submissionStatus, 'submitted'); + assert.strictEqual(state.submitted, true); + assert.strictEqual(state.readOnly, true, 'only a valid ACK may lock the page'); + assert.strictEqual(getCloseCount(), 0, 'single-passage ACK must keep its result page open'); + + const suiteHarness = loadHooks(); + const suiteParent = { postMessage() {} }; + suiteHarness.hooks.setTestState({ + examId: 'reading-p3', + sessionId: 'session-suite-final', + suiteSessionId: 'suite-final', + parentWindow: suiteParent, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-suite-final', + simulationMode: true, + simulationCtx: { isLast: true }, + submissionStatus: 'draft', + submissionId: '' + }); + assert.strictEqual(suiteHarness.hooks.beginSubmission('SIMULATION_SUBMIT', { + suiteSessionId: 'suite-final' + }), true); + const suiteSubmissionId = suiteHarness.hooks.getTestState().submissionId; + await suiteHarness.hooks.handleIncoming(hostEvent(suiteParent, 'PRACTICE_SUBMIT_ACK', { + examId: 'reading-p3', + sessionId: 'session-suite-final', + suiteSessionId: 'suite-final', + submissionId: suiteSubmissionId, + windowSessionToken: 'token-suite-final' + })); + assert.strictEqual(suiteHarness.getCloseCount(), 1, 'final simulation-suite ACK must close the child page'); + + const timeoutHarness = loadHooks(); + const timeoutParent = { postMessage() {} }; + timeoutHarness.hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-submit-timeout', + parentWindow: timeoutParent, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: 'token-submit-timeout', + submissionStatus: 'draft', + submissionId: '' + }); + assert.strictEqual(timeoutHarness.hooks.beginSubmission('PRACTICE_COMPLETE', {}), true); + const timeoutSubmissionId = timeoutHarness.hooks.getTestState().submissionId; + assert.strictEqual(timeoutHarness.hooks.expirePendingSubmission(timeoutSubmissionId), true); + assert.strictEqual(timeoutHarness.hooks.getTestState().submissionStatus, 'draft'); + assert.strictEqual(timeoutHarness.hooks.getTestState().readOnly, false); + await timeoutHarness.hooks.handleIncoming(hostEvent(timeoutParent, 'PRACTICE_SUBMIT_ACK', { + examId: 'reading-p1', + sessionId: 'session-submit-timeout', + submissionId: timeoutSubmissionId, + windowSessionToken: 'token-submit-timeout' + })); + assert.strictEqual(timeoutHarness.hooks.getTestState().submissionStatus, 'draft', 'late ACK after timeout must be ignored'); + assert.strictEqual(timeoutHarness.hooks.getTestState().readOnly, false); +} + +async function testInteractionDraftWritesParentWalImmediately() { + const { hooks, window } = loadHooks(); + const received = []; + const parentWindow = { + closed: false, + app: { + receiveSuiteDraftSnapshotFromChild(examId, payload, sourceWindow) { + received.push({ examId, payload: plain(payload), sourceWindow }); + return Promise.resolve(true); + } + }, + postMessage() {} + }; + hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'session-wal', + suiteSessionId: 'suite-wal', + parentWindow, + simulationMode: true, + readOnly: false, + timerLocked: false, + windowSessionToken: 'token-wal', + windowSessionGeneration: 7 + }); + hooks.scheduleInteractionDraftSync('input'); + await Promise.resolve(); + assert.strictEqual(received.length, 1, 'input microtask must write parent WAL without waiting for periodic timer'); + assert.strictEqual(received[0].examId, 'reading-p1'); + assert.strictEqual(received[0].payload.suiteSessionId, 'suite-wal'); + assert.strictEqual(received[0].payload.windowSessionToken, 'token-wal'); + assert.strictEqual(received[0].payload.windowSessionGeneration, 7); + assert.strictEqual(received[0].sourceWindow, window, 'direct WAL must identify the exact child WindowProxy'); +} + +async function testStaleInitCannotInstallDatasetsAfterNewerGeneration() { + const { hooks, window, document } = loadHooks(); + const sourceWindow = { postMessage() {} }; + window.__READING_EXAM_MANIFEST__ = { + 'reading-slow': { + dataKey: 'reading-slow', + script: 'slow-reading-dataset.js', + title: 'Slow dataset' + } + }; + window.__READING_EXAM_DATA__ = new Map(); + hooks.setTestState({ + examId: 'reading-slow', + sessionId: null, + suiteSessionId: null, + sessionReadySent: false, + parentWindow: sourceWindow, + expectedParentOrigin: 'http://localhost', + parentOrigin: 'http://localhost', + parentOriginIsOpaque: false, + windowSessionToken: '', + windowSessionGeneration: 0, + hostInitEpoch: 0, + lastInitSignature: '', + suite: { + inline: false, + activeExamId: null, + currentIndex: 0, + sequence: [], + slotsByExamId: new Map() + } + }); + + const staleInit = hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', { + examId: 'reading-slow', + sessionId: 'session-stale', + suiteSessionId: 'suite-race', + suiteFlowMode: 'simulation', + suiteSequenceIndex: 0, + suiteSequenceTotal: 1, + suiteSequence: [{ examId: 'reading-slow', dataKey: 'reading-slow' }], + windowSessionToken: 'token-stale', + windowSessionGeneration: 1, + messageIssuedAtMs: 1000, + parentOrigin: 'http://localhost' + })); + await Promise.resolve(); + await Promise.resolve(); + assert.strictEqual(document.__pendingScripts.length, 1, 'stale INIT should be waiting on its dataset script'); + + await hooks.handleIncoming(hostEvent(sourceWindow, 'INIT_SESSION', { + examId: 'reading-slow', + sessionId: 'session-current', + suiteSessionId: 'suite-race', + windowSessionToken: 'token-current', + windowSessionGeneration: 2, + messageIssuedAtMs: 2000, + parentOrigin: 'http://localhost' + })); + const currentSignature = hooks.getTestState().lastInitSignature; + assert(currentSignature, 'newer INIT should complete before the stale dataset load'); + + window.__READING_EXAM_DATA__.set('reading-slow', { + meta: { title: 'Slow dataset' }, + passage: { blocks: [] }, + questionGroups: [], + questionOrder: [] + }); + document.__pendingScripts[0].onload(); + await staleInit; + + const state = hooks.getTestState(); + assert.strictEqual(state.windowSessionToken, 'token-current', 'stale INIT must not replace the newer token'); + assert.strictEqual(state.lastInitSignature, currentSignature, 'stale INIT must not replace the newer INIT signature'); + assert.strictEqual(state.suiteInline, false, 'stale INIT must not install inline suite state after a newer generation'); + assert.deepStrictEqual(plain(state.suiteSequence), [], 'stale INIT must not overwrite the newer suite sequence'); +} + +async function testFileRebindChallengeProvesExistingWindowIdentity() { + const { hooks, window } = loadHooks(); + const proofs = []; + const parentWindow = { postMessage(message, origin) { proofs.push({ message: plain(message), origin }); } }; + window.location.protocol = 'file:'; + hooks.setTestState({ + examId: 'reading-p1', + sessionId: 'reading-p1-session', + suiteSessionId: 'suite-file-proof', + parentWindow, + parentOrigin: 'null', + parentOriginIsOpaque: true, + windowSessionToken: 'file-proof-token', + windowSessionGeneration: 9 + }); + await hooks.handleIncoming(hostEvent(parentWindow, 'SUITE_REBIND_CHALLENGE', { + challenge: 'challenge-1', + suiteSessionId: 'suite-file-proof', + examId: 'reading-p1', + windowSessionToken: 'file-proof-token' + }, { origin: 'null' })); + assert.strictEqual(proofs.length, 1, 'the surviving file child must answer its original parent'); + assert.strictEqual(proofs[0].origin, '*'); + assert.strictEqual(proofs[0].message.type, 'SUITE_REBIND_PROOF'); + assert.strictEqual(proofs[0].message.data.windowSessionToken, 'file-proof-token'); + assert.strictEqual(proofs[0].message.data.windowSessionGeneration, 9); + + await hooks.handleIncoming(hostEvent({ postMessage() {} }, 'SUITE_REBIND_CHALLENGE', { + challenge: 'challenge-forged', + suiteSessionId: 'suite-file-proof', + examId: 'reading-p1', + windowSessionToken: 'file-proof-token' + }, { origin: 'null' })); + assert.strictEqual(proofs.length, 1, 'a different WindowProxy must not obtain the proof token'); } async function main() { await testDraftArbitration(); + await testSuiteTimerModePrecedence(); await testInlineEnvelopeGuard(); await testInlineReinitSnapshot(); await testWindowSessionMessageGuard(); + await testReferrerlessInitBindsOnlyTrustedHost(); + await testSavedRecordAcknowledgementSessionGate(); + await testSubmitAcknowledgementStateMachine(); + await testInteractionDraftWritesParentWalImmediately(); + await testStaleInitCannotInstallDatasetsAfterNewerGeneration(); + await testFileRebindChallengeProvesExistingWindowIdentity(); process.stdout.write(JSON.stringify({ status: 'pass', detail: 'unified reading inline suite regressions covered' })); + process.exit(0); } main().catch((error) => { diff --git a/developer/tests/js/vocabDataIO.test.js b/developer/tests/js/vocabDataIO.test.js new file mode 100644 index 00000000..888fedd9 --- /dev/null +++ b/developer/tests/js/vocabDataIO.test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +global.window = global; +global.AppData = { + ready: Promise.resolve(), + vocab: { + async getConfig() { + return { activeListId: 'spelling-errors-p1', dailyNew: 8 }; + }, + async readList() { + throw new Error('进度导出不应读取原始 collection 词条'); + } + } +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../..'); +eval(fs.readFileSync(path.join(repoRoot, 'js/utils/vocabDataIO.js'), 'utf8')); + +function makeJsonFile(payload, name = 'data.json') { + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + Object.defineProperty(blob, 'name', { value: name }); + return blob; +} + +const vendorWords = await window.VocabDataIO.importWordList(makeJsonFile({ + version: 'vendor-1', + words: [{ id: 'vendor-1', word: 'alpha', meaning: 'A', correctCount: 2 }] +}, 'vendor.json')); +assert.strictEqual(vendorWords.type, 'wordlist'); +assert.strictEqual(vendorWords.entries.length, 1); + +const importBlob = new Blob([JSON.stringify({ + version: '2.0', + listId: 'spelling-errors-p1', + config: { activeListId: 'spelling-errors-p1', dailyNew: 8 }, + words: [{ id: 'word-1', word: 'garden', meaning: '花园', nextReview: '2026-07-25T00:00:00.000Z' }] +})], { type: 'application/json' }); +Object.defineProperty(importBlob, 'name', { value: 'progress.json' }); + +const imported = await window.VocabDataIO.importWordList(importBlob); +assert.strictEqual(imported.type, 'progress'); +assert.strictEqual(imported.meta.listId, 'spelling-errors-p1'); +assert.strictEqual(imported.entries[0].nextReview, '2026-07-25T00:00:00.000Z'); + +await assert.rejects( + window.VocabDataIO.importWordList(makeJsonFile({ + type: 'progress', + version: '2.0', + listId: 'spelling-errors-p1', + config: { activeListId: 'spelling-errors-p1' }, + words: [{ word: 123, meaning: { value: 'bad' } }] + }, 'bad-progress.json')), + /invalid|无效/i +); + +await assert.rejects( + window.VocabDataIO.importWordList(makeJsonFile({ + type: 'progress', + words: [{ word: 'alpha', meaning: 'A' }] + }, 'incomplete-progress.json')), + /v2|配置|词表/i +); + +await assert.rejects( + window.VocabDataIO.importWordList(makeJsonFile({ + version: '0.6.2-fix', + config: { activeListId: 'default' }, + words: [{ word: 'alpha', meaning: 'A', correctCount: 2 }], + reviewQueue: ['alpha'] + }, 'v1-progress.json')), + /不支持 v1 进度备份/ +); + +const exportBlob = await window.VocabDataIO.exportProgress([ + { id: 'word-1', word: 'garden', meaning: '花园', userInput: 'gardon' } +]); +const exported = JSON.parse(await exportBlob.text()); +assert.strictEqual(exported.listId, 'spelling-errors-p1'); +assert.strictEqual(exported.type, 'progress'); +assert.strictEqual(exported.words[0].word, 'garden'); +assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'reviewQueue'), false); +const roundTrip = await window.VocabDataIO.importWordList(makeJsonFile(exported, 'round-trip.json')); +assert.strictEqual(roundTrip.type, 'progress'); +assert.strictEqual(roundTrip.entries[0].meaning, '花园'); + +console.log(JSON.stringify({ + status: 'pass', + detail: 'vocab progress import/export preserves canonical list identity and excludes derived queue' +}, null, 2)); diff --git a/developer/tests/js/vocabStore.test.js b/developer/tests/js/vocabStore.test.js index cb80d4fa..1a82c308 100644 --- a/developer/tests/js/vocabStore.test.js +++ b/developer/tests/js/vocabStore.test.js @@ -9,42 +9,100 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..', '..', '..'); -function createLocalStorage(seed = {}) { - const store = new Map(Object.entries(seed)); - return { - getItem(key) { - return store.has(key) ? store.get(key) : null; +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function createVocabFacade(seed = {}) { + const state = { + words: clone(seed.words || []), + collections: clone(seed.collections || {}), + config: { activeListId: 'default', ...(clone(seed.config) || {}) } + }; + const vocab = { + async getConfig() { + return clone(state.config); + }, + async listWords() { + return clone(state.words); + }, + async listCollections() { + return clone(state.collections); }, - setItem(key, value) { - store.set(key, String(value)); + async replaceListWords({ listId = 'default', words = [] }) { + if (seed.failReplace) throw new Error('backend write failed'); + if (listId === 'default') { + state.words = clone(words); + } else { + state.collections[listId] = { + ...(state.collections[listId] || {}), + id: listId, + words: clone(words) + }; + } + return { committed: true }; }, - removeItem(key) { - store.delete(key); + async mergeListWords({ listId = 'default', words = [] }) { + const target = listId === 'default' + ? state.words + : (state.collections[listId]?.words || []); + const merged = clone(target); + let addedCount = 0; + let updatedCount = 0; + for (const incoming of words) { + const identity = String(incoming.word || incoming.id || '').trim().toLowerCase(); + const index = merged.findIndex((word) => String(word.word || word.id || '').trim().toLowerCase() === identity); + if (index >= 0) { + merged[index] = { ...merged[index], ...clone(incoming) }; + updatedCount += 1; + } else { + merged.push(clone(incoming)); + addedCount += 1; + } + } + await this.replaceListWords({ listId, words: merged }); + return { committed: true, words: clone(merged), addedCount, updatedCount }; }, - clear() { - store.clear(); + async patchConfig(patch = {}) { + state.config = { ...state.config, ...clone(patch) }; + return { committed: true }; + }, + async activateList(listId) { + state.config = { ...state.config, activeListId: listId }; + return { committed: true }; + }, + async patchWord({ listId = 'default', wordId, patch = {} }) { + const source = listId === 'default' + ? state.words + : (state.collections[listId]?.words || []); + const index = source.findIndex((word) => (word.id || word.word) === wordId); + if (index < 0) throw new Error(`Unknown word: ${wordId}`); + source[index] = { ...source[index], ...clone(patch) }; + return { committed: true, word: clone(source[index]) }; } }; + return { state, vocab }; } -function loadVocabStore({ embeddedWords, storageSeed }) { +function loadVocabStore({ embeddedWords, dataSeed }) { const quietConsole = { log() {}, warn() {}, error() {}, info() {} }; + const { state: appDataState, vocab } = createVocabFacade(dataSeed); const windowStub = { console: quietConsole, __EMBEDDED_WORDLISTS__: { ielts_core: embeddedWords || [] }, - location: { protocol: 'file:' } + location: { protocol: 'file:' }, + AppData: { ready: Promise.resolve(), vocab } }; const sandbox = { window: windowStub, console: quietConsole, - localStorage: createLocalStorage(storageSeed), Date, Math, JSON, @@ -52,7 +110,6 @@ function loadVocabStore({ embeddedWords, storageSeed }) { clearTimeout }; sandbox.globalThis = sandbox.window; - sandbox.window.localStorage = sandbox.localStorage; sandbox.window.Date = Date; sandbox.window.Math = Math; sandbox.window.JSON = JSON; @@ -62,6 +119,7 @@ function loadVocabStore({ embeddedWords, storageSeed }) { const context = vm.createContext(sandbox); const source = fs.readFileSync(path.join(repoRoot, 'js/core/vocabStore.js'), 'utf8'); vm.runInContext(source, context, { filename: 'js/core/vocabStore.js' }); + sandbox.window.VocabStore.__appDataState = appDataState; return sandbox.window.VocabStore; } @@ -72,9 +130,11 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() { meaning: 'n. 住宿', example: 'The hotel provides comfortable accommodation.' }], - storageSeed: { - vocab_list_p1_errors: JSON.stringify({ - id: 'p1', + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-p1': { + id: 'spelling-errors-p1', words: [{ word: 'accommodation', userInput: 'accomodation', @@ -84,7 +144,8 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() { errorCount: 2, source: 'p1' }] - }) + } + } } }); @@ -102,9 +163,11 @@ async function testSpellingErrorUsesEmbeddedLexiconMeaning() { async function testSpellingErrorFallsBackWhenLexiconMissing() { const vocabStore = loadVocabStore({ embeddedWords: [], - storageSeed: { - vocab_list_p4_errors: JSON.stringify({ - id: 'p4', + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-p4': { + id: 'spelling-errors-p4', words: [{ word: 'specialised', userInput: 'specializedd', @@ -114,7 +177,8 @@ async function testSpellingErrorFallsBackWhenLexiconMissing() { errorCount: 1, source: 'p4' }] - }) + } + } } }); @@ -130,8 +194,12 @@ async function testSpellingErrorFallsBackWhenLexiconMissing() { async function testSpellingErrorPreservesStoredMeaningAndMetadata() { const vocabStore = loadVocabStore({ embeddedWords: [], - storageSeed: { - vocab_list_master_errors: JSON.stringify([{ + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-master': { + id: 'spelling-errors-master', + words: [{ id: 'spelling-all-garden', word: 'garden', meaning: 'n. 花园;庭院', @@ -145,7 +213,9 @@ async function testSpellingErrorPreservesStoredMeaningAndMetadata() { acceptedAnswers: ['green garden', 'green gardens'], canonicalAnswer: 'green garden', reasonCode: 'edit' - }]) + }] + } + } } }); @@ -168,8 +238,12 @@ async function testSpellingErrorPreservesStoredMeaningAndMetadata() { async function testSpellingErrorMetadataSurvivesStudyUpdates() { const vocabStore = loadVocabStore({ embeddedWords: [], - storageSeed: { - vocab_list_master_errors: JSON.stringify([{ + dataSeed: { + words: [{ id: 'default-seed', word: 'unrelated', meaning: 'seed' }], + collections: { + 'spelling-errors-master': { + id: 'spelling-errors-master', + words: [{ id: 'spelling-all-garden', word: 'garden', meaning: 'n. 花园;庭院', @@ -181,7 +255,9 @@ async function testSpellingErrorMetadataSurvivesStudyUpdates() { source: 'p1', acceptedAnswers: ['green garden'], canonicalAnswer: 'green garden' - }]) + }] + } + } } }); @@ -199,6 +275,83 @@ async function testSpellingErrorMetadataSurvivesStudyUpdates() { assert.strictEqual(updated.errorCount, 3, '背诵更新不应该洗掉错误次数'); assert.deepStrictEqual(updated.acceptedAnswers, ['green garden']); assert.strictEqual(updated.canonicalAnswer, 'green garden'); + assert.strictEqual(vocabStore.__appDataState.config.activeListId, 'spelling-errors-master'); + assert.strictEqual( + vocabStore.__appDataState.collections['spelling-errors-master'].words[0].note, + 'new memory note', + '学习更新必须通过 AppData.vocab.patchWord 提交' + ); +} + +async function testDefaultLexiconWriteFailureRejectsInitialization() { + const vocabStore = loadVocabStore({ + embeddedWords: [{ word: 'alpha', meaning: 'A' }], + dataSeed: { failReplace: true } + }); + + await assert.rejects(vocabStore.init(), /backend write failed/); + assert.strictEqual(vocabStore.state.ready, false, '持久化失败时不得把词汇域标记为 ready'); +} + +async function testConfigUsesCentralBoundsAndTypes() { + const vocabStore = loadVocabStore({ + embeddedWords: [], + dataSeed: { + words: [{ id: 'word-1', word: 'alpha', meaning: 'A' }] + } + }); + await vocabStore.init(); + + await vocabStore.setConfig({ + dailyNew: -10, + reviewLimit: 999, + masteryCount: 2.9, + notify: 'yes', + theme: 'neon' + }); + let config = vocabStore.getConfig(); + assert.strictEqual(config.dailyNew, 0); + assert.strictEqual(config.reviewLimit, 300); + assert.strictEqual(config.masteryCount, 2); + assert.strictEqual(config.notify, true); + assert.strictEqual(config.theme, 'auto'); + + await vocabStore.setConfig({ + dailyNew: '10', + reviewLimit: Number.NaN, + masteryCount: Number.POSITIVE_INFINITY, + notify: false, + theme: 'dark' + }); + config = vocabStore.getConfig(); + assert.strictEqual(config.dailyNew, 20); + assert.strictEqual(config.reviewLimit, 100); + assert.strictEqual(config.masteryCount, 4); + assert.strictEqual(config.notify, false); + assert.strictEqual(config.theme, 'dark'); +} + +async function testProgressRestoreRequiresCompleteV2Identity() { + const vocabStore = loadVocabStore({ + embeddedWords: [], + dataSeed: { + words: [{ id: 'word-1', word: 'alpha', meaning: 'A' }] + } + }); + await vocabStore.init(); + + await assert.rejects( + vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], { dailyNew: 10 }, null), + /未知词表/ + ); + await assert.rejects( + vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], null, 'custom'), + /有效配置/ + ); + await assert.rejects( + vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], { dailyNew: 10 }, 'other-list'), + /未知词表/ + ); } async function main() { @@ -212,6 +365,12 @@ async function main() { results.push({ name: '错词保留已补全释义和元数据', status: 'pass' }); await testSpellingErrorMetadataSurvivesStudyUpdates(); results.push({ name: '背诵更新保留错词业务元数据', status: 'pass' }); + await testDefaultLexiconWriteFailureRejectsInitialization(); + results.push({ name: '默认词库持久化失败会阻断 ready', status: 'pass' }); + await testConfigUsesCentralBoundsAndTypes(); + results.push({ name: '配置写入遵守统一范围和类型', status: 'pass' }); + await testProgressRestoreRequiresCompleteV2Identity(); + results.push({ name: '进度恢复要求完整 v2 词表身份', status: 'pass' }); console.log(JSON.stringify({ status: 'pass', detail: `${results.length}/${results.length} 测试通过`, diff --git a/developer/tests/performance-test.html b/developer/tests/performance-test.html deleted file mode 100644 index 22706abe..00000000 --- a/developer/tests/performance-test.html +++ /dev/null @@ -1,725 +0,0 @@ - - - - - - 性能基线测量工具 - - - -
-

📊 性能基线测量工具

-

测量系统性能指标,建立性能基线,识别优化机会

- -
-

🎯 测试控制面板

- -
- - - - - - -
- -
-
-
-
准备就绪
- -
- 实时指标: - FPS: -- - 内存: -- - 延迟: -- -
-
- -
-

📈 性能指标

- -
- -
-
- -
-

🎯 性能建议

-
-

运行测试后将显示性能优化建议...

-
-
- -
-

📊 详细报告

-
运行测试后将显示详细的性能报告...
-
- -
-

🔧 高级测试

- -
- - - - -
- -
-
-
- - - - - - - - - - - - - - - - diff --git a/developer/tests/refresh-test.html b/developer/tests/refresh-test.html deleted file mode 100644 index f42fb82f..00000000 --- a/developer/tests/refresh-test.html +++ /dev/null @@ -1,408 +0,0 @@ - - - - - - 状态序列化刷新测试 - - - -
-

🔄 状态序列化刷新测试

-

测试Set/Map对象在页面刷新后的持久化能力

- -
-

📋 测试步骤:

-
    -
  1. 点击"创建测试数据" - 创建包含Set/Map的测试状态
  2. -
  3. 点击"保存状态到存储" - 序列化并保存到localStorage
  4. -
  5. 刷新页面 (F5) - 模拟用户刷新行为
  6. -
  7. 点击"验证恢复的状态" - 检查数据完整性
  8. -
- -
- - - - - - - -
-
- -
-

📊 测试结果:

-
-
点击上方按钮开始测试
-
-
- -
-

📋 当前状态:

-
无状态数据
-
- -
-

🔍 存储检查:

-
- -

-            
-
-
- - - - - - - - - - - - - - - - - - diff --git a/developer/tests/regression-test.html b/developer/tests/regression-test.html deleted file mode 100644 index b30c4b80..00000000 --- a/developer/tests/regression-test.html +++ /dev/null @@ -1,625 +0,0 @@ - - - - - - 本地回归测试套件 - - - -
-

🧪 本地回归测试套件

-

自动化测试核心功能,确保系统稳定性和数据完整性

- -
-

🎯 测试控制面板

- -
- - - - - - -
- -
-
-
-
准备就绪
-
- -
-

📊 测试结果汇总

- -
-
-
0
-
总测试数
-
-
-
0
-
通过测试
-
-
-
0
-
失败测试
-
-
-
0%
-
成功率
-
-
- -
-
- -
-

📋 详细测试报告

-
点击"运行所有测试"开始测试...
-
- -
-
-

Exam加载测试

-

验证考试数据加载、分类、搜索和UI渲染功能

- -
-
- -
-

Practice记录测试

-

验证练习记录的创建、读取、更新、删除和批量操作

- -
-
- -
-

备份恢复测试

-

验证数据备份创建、恢复、删除和完整性验证

- -
-
- -
-

状态序列化测试

-

验证Set/Map对象的序列化/反序列化和数据完整性

- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/developer/tests/run-integration-tests.bat b/developer/tests/run-integration-tests.bat index 053d624b..764bfaef 100644 --- a/developer/tests/run-integration-tests.bat +++ b/developer/tests/run-integration-tests.bat @@ -2,15 +2,7 @@ echo Running Integration Tests... echo. -echo Test 1: Multi-Suite Submission Flow -node developer\tests\js\integration\multiSuiteSubmission.test.js -if %ERRORLEVEL% NEQ 0 ( - echo FAILED: Multi-Suite Submission Test - exit /b 1 -) -echo. - -echo Test 2: Spelling Error Collection Flow +echo Test 1: Spelling Error Collection Flow node developer\tests\js\integration\spellingErrorCollection.test.js if %ERRORLEVEL% NEQ 0 ( echo FAILED: Spelling Error Collection Test @@ -18,7 +10,7 @@ if %ERRORLEVEL% NEQ 0 ( ) echo. -echo Test 3: Vocab List Switching Flow +echo Test 2: Vocab List Switching Flow node developer\tests\js\integration\vocabListSwitching.test.js if %ERRORLEVEL% NEQ 0 ( echo FAILED: Vocab List Switching Test @@ -26,7 +18,7 @@ if %ERRORLEVEL% NEQ 0 ( ) echo. -echo Test 4: Vocab Session View Flow +echo Test 3: Vocab Session View Flow node developer\tests\js\integration\vocabSessionView.test.js if %ERRORLEVEL% NEQ 0 ( echo FAILED: Vocab Session View Test @@ -35,16 +27,4 @@ if %ERRORLEVEL% NEQ 0 ( echo. echo All integration tests passed! -echo. - -echo Running Performance Benchmarks... -echo. -node developer\tests\js\integration\performance.benchmark.js -if %ERRORLEVEL% NEQ 0 ( - echo WARNING: Performance benchmark failed - echo Continuing anyway... -) -echo. - -echo All tests completed! exit /b 0 diff --git a/developer/tests/run_all_tests.py b/developer/tests/run_all_tests.py index dc72d71d..004cd5c7 100644 --- a/developer/tests/run_all_tests.py +++ b/developer/tests/run_all_tests.py @@ -163,62 +163,72 @@ def run_ci_tests(self) -> bool: return False def run_e2e_tests(self) -> bool: - """运行 E2E 测试""" + """运行统一 E2E 套件(file:// 兼容,含提交/结算与导出导入)""" self.log("=" * 80) - self.log("运行 E2E 套题练习流程测试") + self.log("运行统一 E2E 套件 (e2e_runner.py)") self.log("=" * 80) - - test_script = REPO_ROOT / "developer" / "tests" / "e2e" / "suite_practice_flow.py" - + + test_script = REPO_ROOT / "developer" / "tests" / "e2e" / "e2e_runner.py" + if not test_script.exists(): self.log(f"E2E 测试脚本不存在: {test_script}", "ERROR") return False - + try: result = subprocess.run( [sys.executable, str(test_script)], capture_output=True, text=True, - timeout=180 + timeout=900 ) - + print(result.stdout) if result.stderr: print(result.stderr) - + passed = result.returncode == 0 - - # 尝试解析 JSON 报告 - report_path = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" / "suite-practice-flow-report.json" + + report_path = REPO_ROOT / "developer" / "tests" / "e2e" / "reports" / "e2e-unified-report.json" if report_path.exists(): try: report = json.loads(report_path.read_text(encoding="utf-8")) self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": report.get("status", "unknown"), - "duration": report.get("duration"), - "consoleLogs": len(report.get("consoleLogs", [])) + "duration": report.get("durationSeconds"), + "cases": [ + { + "name": item.get("name"), + "status": item.get("status"), + "exitCode": item.get("exitCode"), + } + for item in report.get("cases", []) + ], }) except Exception: - pass + self.results.append({ + "name": "E2E 统一套件", + "status": "pass" if passed else "fail", + "returnCode": result.returncode + }) else: self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": "pass" if passed else "fail", "returnCode": result.returncode }) - + if passed: self.log("E2E 测试通过", "SUCCESS") else: self.log(f"E2E 测试失败 (返回码: {result.returncode})", "ERROR") - + return passed - + except subprocess.TimeoutExpired: - self.log("E2E 测试超时 (180秒)", "ERROR") + self.log("E2E 测试超时 (900秒)", "ERROR") self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": "fail", "error": "超时" }) @@ -226,7 +236,7 @@ def run_e2e_tests(self) -> bool: except Exception as e: self.log(f"运行 E2E 测试时出错: {e}", "ERROR") self.results.append({ - "name": "E2E 套题练习流程", + "name": "E2E 统一套件", "status": "fail", "error": str(e) }) diff --git a/developer/tests/storage-vocab-test.html b/developer/tests/storage-vocab-test.html deleted file mode 100644 index 0e5cae55..00000000 --- a/developer/tests/storage-vocab-test.html +++ /dev/null @@ -1,386 +0,0 @@ - - - - - - Storage Vocabulary Test - - - -

Storage Vocabulary Test

-

测试词表存储、同步和导出功能

- -
-

1. 词表存储测试

- - - - -
-
- -
-

2. 数据同步测试

- - - -
-
- -
-

3. 降级存储测试

- - -
-
- -
-

4. 数据导出测试

- - - - -
-
- - - - - diff --git a/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js b/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js index 50c04e1f..8d281211 100644 --- a/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js +++ b/developer/tests/tools/reading-json/export_generated_reading_dataset.node.js @@ -21,13 +21,17 @@ function readText(filePath) { } function parseArgs(argv) { - const args = { examId: '', list: false }; + const args = { examId: '', list: false, all: false }; for (let i = 2; i < argv.length; i += 1) { const token = argv[i]; if (token === '--list') { args.list = true; continue; } + if (token === '--all') { + args.all = true; + continue; + } if (token === '--exam-id') { args.examId = (argv[i + 1] || '').trim(); i += 1; @@ -120,6 +124,23 @@ function pickManifestEntry(manifest, examId) { return null; } +function buildPayload(dataset, entry, fallbackExamId = '') { + return { + examId: dataset.examId || entry.examId || fallbackExamId, + questionOrder: Array.isArray(dataset.questionOrder) ? dataset.questionOrder : [], + answerKey: dataset.answerKey && typeof dataset.answerKey === 'object' ? dataset.answerKey : {}, + questionGroups: Array.isArray(dataset.questionGroups) ? dataset.questionGroups : [], + questionDisplayMap: dataset.questionDisplayMap && typeof dataset.questionDisplayMap === 'object' + ? dataset.questionDisplayMap + : {}, + meta: dataset.meta && typeof dataset.meta === 'object' ? dataset.meta : {}, + metaQuestionIntroHtml: dataset.meta && typeof dataset.meta.questionIntroHtml === 'string' + ? dataset.meta.questionIntroHtml + : '', + script: entry.script + }; +} + function main() { if (!fs.existsSync(MANIFEST_PATH)) { fail('reading_manifest_not_found'); @@ -134,6 +155,17 @@ function main() { return; } + if (args.all) { + const entries = buildEntryList(manifest); + const datasets = Object.fromEntries(entries.map((entry) => { + const dataset = loadDataset(context, registry, entry); + const payload = buildPayload(dataset, entry, entry.examId); + return [payload.examId, payload]; + })); + process.stdout.write(`${JSON.stringify({ entries, datasets })}\n`); + return; + } + if (!args.examId) { fail('missing_required_arg:--exam-id'); } @@ -144,20 +176,7 @@ function main() { } const dataset = loadDataset(context, registry, entry); - const payload = { - examId: dataset.examId || entry.examId || args.examId, - questionOrder: Array.isArray(dataset.questionOrder) ? dataset.questionOrder : [], - answerKey: dataset.answerKey && typeof dataset.answerKey === 'object' ? dataset.answerKey : {}, - questionGroups: Array.isArray(dataset.questionGroups) ? dataset.questionGroups : [], - questionDisplayMap: dataset.questionDisplayMap && typeof dataset.questionDisplayMap === 'object' - ? dataset.questionDisplayMap - : {}, - meta: dataset.meta && typeof dataset.meta === 'object' ? dataset.meta : {}, - metaQuestionIntroHtml: dataset.meta && typeof dataset.meta.questionIntroHtml === 'string' - ? dataset.meta.questionIntroHtml - : '', - script: entry.script - }; + const payload = buildPayload(dataset, entry, args.examId); process.stdout.write(`${JSON.stringify(payload)}\n`); } diff --git a/developer/tests/vocabListSwitcher.test.html b/developer/tests/vocabListSwitcher.test.html deleted file mode 100644 index b2bcc900..00000000 --- a/developer/tests/vocabListSwitcher.test.html +++ /dev/null @@ -1,614 +0,0 @@ - - - - - - VocabListSwitcher 单元测试 - - - -

🧪 VocabListSwitcher 单元测试

-

测试词表切换器组件的核心功能

- -
-

测试摘要

-
总测试数: 0
-
通过: 0
-
失败: 0
-
成功率: 0%
-
- -
-

控制面板

- - -
- -
-

1. 组件渲染测试

- -
-
-
- -
-

2. 词表切换测试

- -
-
-
- -
-

3. 词表计数更新测试

- -
-
- -
-

4. 用户偏好保存测试

- -
-
- -
-

5. 错误处理测试

- -
-
- -
-

6. 下拉菜单交互测试

- - -
- -
-

测试日志

-
-
- - - - - - - - - diff --git a/findings.md b/findings.md new file mode 100644 index 00000000..20b88cf0 --- /dev/null +++ b/findings.md @@ -0,0 +1,224 @@ +# Findings + +## Baseline + +- Branch: `codex/audit-tmp-migration` +- HEAD: `2a1801cd6f8ddc721a6971e9e07c39623083cc2d` +- Divergence from `origin/opensource`: 7 upstream-only / 25 branch-only commits. +- Existing dirty files are user-owned accuracy UI and generated bundle changes; preserve them. + +## Verified defects from the audit + +- Full restore writes only present document envelopes and listed entity stores. +- Partial practice replace can leave detail/annotation orphans. +- Operation journal survives restore and business IDs are reused as operation IDs. +- v1 nested annotations and `scoreInfo.correct/total` are lost from light/full projection. +- Concurrent vocab writes conflict and one update is lost. +- `file://` fallback uses `"file://"` as target origin; Chromium silently drops the message. +- Listening marks completion before INIT/ACK. +- `examSessionMixin.createFallbackRecorder` overwrites the safer app-level fallback. +- DataKernel listeners are realm-local; file-page BroadcastChannel was proven available. +- Row checksum errors currently latch the entire backend. + +## Prior live probes + +- Full restore left a post-backup vocab word in place. +- Nested v1 import produced `correctAnswers=0`, `totalQuestions=0`, and null annotations. +- Two concurrent vocab upserts produced one fulfillment, one `CONFLICT`, and one saved word. +- Reusing a record ID as operation ID rejected the changed second save. +- A commit in a second file-page realm was readable by the first realm but absent from its committed listener. + +## Implementation decisions + +- Full-user-data mirror semantics will be prepared in `createImportPlan`; DataKernel stays a validated atomic installer and receives explicit cleared envelopes/all entity layers. +- `resetJournal` is applied inside the same install transaction after checking replay against the old journal; the new journal contains only the restore receipt. +- Partial practice imports may update a subset of stores only when the resulting three recordId sets remain identical; incomplete replace is rejected. +- Cross-realm commit broadcast must not depend on local listeners being registered, otherwise a writer-only child realm would never notify the parent. +- User-owned accuracy changes are source-backed and will be preserved by rebuilding bundles from the modified source files. + +## Implemented worktree checkpoint + +- `appData.js` now normalizes accuracy values above 1 to ratios, derives `correctAnswers/totalQuestions` from both `scoreInfo.correctAnswers/totalQuestions` and legacy `scoreInfo.correct/total`, and emits percentage separately. +- `splitPracticeRecord` now fills top-level annotation fields from `realData`, then `rawData`, without persisting either legacy mirror. +- Practice light projection now emits sanitized `suiteEntrySummaries`; Browse consumption and regression coverage remain pending. +- `dataKernel.js` has a partial implementation for `CORRUPT_RECORD`, cross-realm commit broadcast, and restore journal reset; exact transaction/error boundaries still need focused source review. + +## Remaining data-layer gaps after source review + +- `createImportPlan` still has the old contract: full replace only iterates present envelopes, and practice replace only clears/rebuilds stores present in the payload. It must synthesize cleared envelopes for every importable/exportable user-data catalog entry and require all three practice stores for replace. +- Practice merge currently snapshots only source stores and can still produce unequal recordId sets. The resulting three-store sets need validation before `installSnapshot`. +- `backups.restore` does not pass `resetJournal: true`; DataKernel supports it but the public restore path currently preserves the old journal. +- `vocab.saveCollection`, `saveCollections`, `upsertCollectionWord`, `mergeListWords`, and related collection read-modify-write paths are still unqueued bare CAS operations. +- `operationId()` already generates a fresh ID by default; the remaining idempotency bug is at callers that explicitly pass `record.id/sessionId`. Same-request retries must preserve an explicit request/submission operation ID. +- Full-mirror document scope is exactly catalog entries with `export:true` and an import policy other than `ignore`; this includes `backups.settings` but excludes `backups.entries`, backup history, `recovery.windowSession`, and the journal. +- `DataKernel.listEntities` must skip corrupt summary rows so stats/list rendering continue; direct `readEntity` remains the diagnostic path that returns `CORRUPT_RECORD`. +- Vocab RMW coverage is broader than `upsertCollectionWord`: collection/config/word merge, patch, and progress methods all read revisions before CAS. A single realm-local queue plus bounded re-read/retry is the simplest consistent contract. + +## Messaging review checkpoint + +- Fallback `main.js` still computes `targetOrigin` by accepting any truthy origin before testing `protocol === 'file:'`; therefore a Chromium value of `"file://"` still reaches `postMessage`. The file check must take precedence and the declared parent origin must be normalized to `"null"`. +- Listening still sets `state.completed = true` before INIT/ACK and only retries while `!state.completed`; it has no persisted submission receipt handler. +- Reading highlight UI still treats successful `postMessage` invocation as persistence success. It needs a generated `requestId`, pending request map, ACK/FAILED listener, and direct-AppData fallback only when no host route is available. +- `app.js` defines the richer fallback recorder, while `examSessionMixin.js` still defines another `createFallbackRecorder`; the later mixin assignment can overwrite the richer implementation. +- The main exam mixin already has the correct opaque-origin endpoint (`expectedOrigin:"null"`, wildcard send) and a persisted `PRACTICE_SUBMIT_ACK/FAILED` receipt cache. Listening can join that contract without inventing a second host protocol. +- Listening bootstrap creates a provisional sessionId before INIT; therefore a pre-INIT completion must cache extracted details/submissionId, then build the final payload only after host INIT replaces the provisional sessionId. +- Completion retry timers must resend the cached payload directly. Calling `onComplete()` and rescheduling all timers from inside each timer would create an endless timer-reset loop. +- Business-id reuse existed in `PracticeRecorder.savePracticeRecord`, suite finalize, and the host completion fallback. Recorder now owns one new op per call and reuses it only for its internal retries; submission-correlated host/suite saves derive the op from submissionId. +- `handlePracticeComplete` called the general recorder-session rebind and then called the listening wrapper that delegates to the same general rebind, producing duplicate `handleSessionStarted`; the second call is redundant. +- The remaining suite reset failure was fixture accounting, not a second reset call: the first reading completion correctly invokes the general recorder rebind, leaving one entry in `recorderStarts`; reset invokes `_syncRecorderSessionStarted` once. Reset the probe before the reset request so the assertion measures only reset behavior. +- The Listening bridge already has the intended pending state machine in source: `onComplete` creates one submission, `sendPendingCompletion` refuses to emit before trusted INIT, and retry timers resend the cached payload. The existing parser test is not visibly read by ordinary `Get-Content`, so inspect its encoding before extending it. +- `listeningRecordBridgeParser.test.js` is normal UTF-8 and only covers safe literal parsing; protocol coverage belongs in a separate VM harness. The bridge deliberately exposes `__listeningBridgeComplete` and `__listeningBridgeGetState`, which makes pre-INIT/retry/ACK assertions possible without production-only test hooks. +- The static runner has an explicit `security_regression_tests` list, so the new Listening protocol VM regression must be registered there rather than merely added to the filesystem. +- The vocab dictionary's public API exposes ACK settlement but not the save initiator. A VM-only source injection can expose closure hooks without changing production code, allowing exact assertions that `postMessage` delivery leaves the button pending, FAILED shows `保存失败` when direct AppData is unavailable, ACK shows `已加入`, and direct AppData commit is also accepted. +- Browse completion is not owned by `browseController.js`; that module only controls modes and filters. Existing Browse record tests exercise `BrowsePreferencesUtils`, so the child completion consumer must be located in the legacy view/presentation layer before choosing the regression harness. +- The actual Browse completion index is `rebuildBrowseCompletionIndex` in `legacyViewBundle.js`; both its indexed path and path/file fallback enumerate only `record.suiteEntries`. The existing `legacyViewReadStatus.test.js` is the exact regression surface and currently labels a full `suiteEntries` object as “lightweight”. +- The cross-realm chain is correctly connected: DataKernel remote BroadcastChannel events are dispatched with `remote:true`; `AppData.backups.onDataCommitted` directly subscribes to that kernel listener; ExternalBackupService subscribes once and calls `markDirty` without filtering remote events. The backup regression should emit an explicit remote event so this contract cannot regress silently. +- `practiceRecordPersistence.test.js` already drives the app-level fallback handshake/submit path, but its harness hardcodes an HTTP origin and discards `postMessage` targetOrigin. Extending this existing test to run the fallback completion under `location.protocol='file:'`, `origin='file://'`, event origin `null`, and asserting every reply target is `*` is the smallest realistic origin regression. +- The production protocol scan found completion senders in the inline suite placeholder, Practice Enhancer, unified reading, Listening bridge, and shipped templates, plus an E2E inline fixture. The host now rejects missing correlation metadata, so each sender path must be checked for enrichment rather than assuming literal payloads contain the fields. + +## Current Production Reports + +- Built-in Reading exams must come strictly from `assets/generated/reading-exams/manifest.js`; imported-library state or AppData readiness must not replace an available built-in index with an empty list. +- `assets/generated/listening-exams/manifest.js` may be absent in distributed packages. Its loader is optional and must not gate Reading browse startup or practice submission. +- The reported save failure reaches `AppData.practice.completeAttempt` through `ExamSystemApp.saveRealPracticeData`; `canonicalizeRecord` rejects `correctAnswers` because the upstream completion normalization produced a negative or non-finite number. The upstream computation must be fixed rather than weakening the non-negative persistence invariant. + +## Confirmed Root Causes + +- `js/data/v2/dataKernel.js:143-160` reads legacy IndexedDB rows as the value itself. The v1 store actually persisted `{ key, value, timestamp }`, so `practice_records`, library configuration, and active-key values are currently parsed one level too high. The practice migration therefore sees no array and imports zero records. +- v1 used exact `exam_index` as the built-in/default-library sentinel. `migrateLegacyData` copies that value to `library.activeConfigurationId`, while `importedLibraryId` rejects every `exam_index`/`exam_index_*` ID. `LibraryManager.loadActiveLibrary` then treats the invalid sentinel as a non-default library, receives an empty index, dispatches `examIndexLoaded` with `[]`, and never reads the generated Reading manifest. +- Existing poisoned v2 envelopes survive a source fix because document migration skips any existing envelope and practice migration returns as soon as one summary exists. The repair must be idempotent and must merge missing legacy records instead of using collection non-emptiness as completion. +- Reading, Listening, and the generic practice enhancer use object-valued `correctAnswers` for the answer-key map and place the numeric score in `scoreInfo.correct`. Suite aggregates use numeric top-level `correctAnswers`. `canonicalizeRecord` currently validates the overloaded object field before adapting it, so a valid completion fails persistence. The canonical non-negative invariant is correct; the compatibility boundary must select the first valid scalar score candidate and preserve the map separately. +- `LibraryManager.loadActiveLibrary` already treats the Listening manifest as optional for a default library, but an invalid/non-default active ID returns an empty custom index before reaching that code. Empty or invalid active custom state must reset to the default and continue through the manifest path. + +## Chosen Hotfix Contract + +- Unwrap legacy IDB rows strictly through `.value`; no historical production writer supports raw business values in that object store. +- Translate the v1 exact default sentinel to v2 `null`; never persist the generated Reading `exam_index` cache as user library data. +- Remap valid v1 custom `exam_index_*` libraries to accepted deterministic IDs, but only when their index is a non-empty array. +- Repair poisoned active-library state on startup and make browse startup fall back to the generated Reading manifest when a selected custom library is missing or empty. +- Merge missing legacy records by stable ID, skip already-migrated IDs, and use a versioned repair operation ID. +- Normalize overloaded completion score fields before canonical validation; preserve `correctAnswerMap`, keep legal zeroes, and never accept a negative/non-finite candidate when a later valid candidate exists. + +## Supplied Backup: Confirmed Semantics + +- `ielts-atlas-backup-2026-07-28T15-14-09-096Z.json` is checksum-valid (`fnv1a-88bc05b5`) but semantically poisoned: all three practice entity stores are empty, `library.activeConfigurationId` is `"[object Object]"`, `library.importedIndexes` is missing, and settings/vocab/achievements contain old `{key,value,timestamp}` rows rather than business values. +- The exporter faithfully captured an already-corrupted v2 database. The product defect is that it labeled a sparse physical snapshot as `scope:"full"`, generated a valid checksum, and provided no semantic validation or completeness manifest. +- Old opensource main UI exports practice records and stats and defaults to merge; it does not import library configuration/active state, so its normal merge/replace path cannot clear the built-in library. The dangerous old `StorageManager.importData` full clear path existed but was not the normal DataBackupManager UI. +- The v2 migration row-wrapper bug is a new regression. Old `StorageManager.getFromIndexedDB` correctly read `request.result.value`; v2 migration read the entire row. +- The supplied file cannot reconstruct missing practice records or lost library configurations by itself. It can safely recover inner settings/vocab/achievement values. Practice/library recovery additionally needs the original `ExamSystemDB` or another older backup. +- The built-in Reading manifest is code, not user data. Only user custom-library configurations/indexes/active selection belong to snapshot state; `null` active always means load the generated manifest. +- A checksum proves byte-level integrity, not business correctness. Import preview must distinguish `trusted-full`, `degraded-partial`, and `invalid` inputs. +- New exports now materialize every exportable catalog key as an explicit `present` or `cleared` envelope. Missing keys in older sparse snapshots are preserved rather than inferred as deletion requests. +- v2 import canonicalization repairs only exact legacy row aliases and rejects cross-domain wrappers. Full snapshots require a coherent library bundle; valid partial library updates remain importable. +- Destructive import preview reports existing/incoming/final/removed practice counts. Commit requires explicit `confirmDestructive:true` after user confirmation. +- Startup uses one versioned `v1ToV2` state. It migrates only an empty v2 database or repairs an exact known poison fingerprint; marker absence alone never replays a frozen v1 database. +- The supplied JSON itself still cannot yield missing practice records: its three entity arrays are genuinely empty. Recovery succeeds only if the user's old IndexedDB or another older backup still contains those records. + +## Raw-Data-First Re-audit (Final) + +- Historical production has one IndexedDB row contract: `{key,value,timestamp}`. Its `value` is the serialized storage envelope `{data,timestamp,version[,compressed]}`. The old reader returned `request.result.value`; the bad v2 migration uniquely passed the whole row to the legacy parser. +- The whole corruption chain comes from that one wrong boundary. Object documents retained the wrapper, array documents normalized to `[]`, nullable strings became `"[object Object]"`, and practice extraction found no record array. Checksums later certified those already-wrong bytes. +- Raw unprefixed compatibility exists only in Web Storage for `practice_records`, `vocab_user_config`, and `user_achievements`. The reader now accepts exactly those evidenced variants and no speculative raw-IDB shape. +- Initial migration runs only when v2 has no user envelopes and no practice summaries. Exact wrapper/library poison may trigger a narrow repair. A healthy existing v2 database is marked `existing-v2` without reading or replaying frozen v1 data. +- Wrapper repair requires the expected legacy alias and an object payload. It preserves fields added to the outer v2 document after the bad migration. +- Library poison repair restores only exact wrapped legacy index IDs. A poisoned active ID consults the old active ID only when v2 has no usable current index; otherwise it becomes the built-in/default selection and existing v2 custom libraries remain untouched. +- Practice recovery from old storage is limited to initial migration or an exact poisoned state with an empty summary store. This is the only unavoidable ambiguity: after a bad migration, an intentionally cleared empty practice store is indistinguishable from the original collapse while the poison fingerprint remains. +- The built-in Reading index is never user data. `null`/invalid/empty custom selection displays `assets/generated/reading-exams/manifest.js`; this fallback does not mutate the persisted selection. A healthy custom library remains active even during forced reload. +- Sparse or poisoned old backups are degraded to partial imports. Missing keys do not imply deletion, and the supplied file's merge path preserves current practice/library data. Explicit destructive replace requires `confirmDestructive:true`. +- The supplied backup cannot recover practice records or custom indexes because those arrays/envelopes are already empty or absent. Recovery requires the user's surviving old `ExamSystemDB` or an older intact backup; no code fallback can reconstruct data that is absent from both. + +## Persistent v1 Reconciliation Decision + +- The user explicitly prefers recovery completeness over preventing old v1 records from reappearing after a later v2 deletion. +- Required startup behavior: if canonical v1 data is readable, merge every valid v1 practice record and user library into v2 on every startup; retain valid v2-only additions; replace known wrapper/`"[object Object]"` migration poison with decoded v1 values. +- Repeated startup must be idempotent by stable record/library IDs and checksum/revision comparisons, not by skipping legacy reads through a completion marker. +- The completion marker is diagnostic only. It must never suppress a legacy read or reconciliation, and it must not be rewritten when no business data changed. +- Practice reconciliation is record-based: a complete healthy v2 three-layer record wins on an ID collision; a missing v1 ID is added; a partially present v2 record is replaced atomically from v1 to avoid mixed summary/detail/annotation provenance. +- Library reconciliation is a deterministic union by remapped legacy ID. Healthy v2-only libraries and healthy active selections survive; missing v1 libraries are added; poisoned or dangling active selection is repaired from the v1 active key. +- A failed or incomplete legacy read produces no v2 writes and no marker update; the application continues on existing v2 and retries next startup. +- Exact current gates to remove are `migrateLegacyData`'s completed-marker return and healthy-v2 `existing-v2` return. Library, document, and practice reconciliation must no longer depend on `freshMigration`, `poisonDetected`, or an empty summary collection. +- Existing `practiceLayers(..., true)` exposes all three revisions and `practiceUpserts` already emits a single atomic three-store mutation, so a partial record can be replaced coherently without adding a new kernel repair API. +- `migrateLegacyLibraryData` already compares configuration/index checksums before writing; changing it to an unconditional deterministic union keeps repeated startup diff-only. +- The VM regression now proves that an old completion marker cannot suppress reconciliation, v1-only and v2-only records coexist, complete healthy same-ID v2 records win, partial three-layer records are atomically rebuilt from v1, and a second boot rereads v1 without incrementing any business revision. +- The real IndexedDB regression now updates `ExamSystemDB` after a completed v4 reconciliation, reloads into a new realm, verifies the newly appended v1 record is migrated beside a v2-only record, then proves a third unchanged boot has identical document/entity revisions and checksums. +- Exact wrappers are not the only historical bad output: array/object legacy documents written by the faulty migration can be recognized by their `legacy-documents-*` operation ID. Those documents should be refreshed from the live v1 alias when values differ; later normal v2 writes have a different operation ID and remain authoritative. +- `AppData.practice.delete` removes all three layers without a tombstone. Under the user-selected persistent-union policy, deleting a record that still exists in v1 must therefore be temporary: the next startup restores it. +- Catalog policies provide the general document merge contract needed for “all v1 data”: `patch` objects should include v1-only keys while healthy v2 values win conflicts; `merge-by-id` arrays should include v1-only items while healthy v2 items win the same identity. Exact bad-migration operation IDs remain a full v1 replacement. +- The existing `mergeImportValue`/`mergeCollection` helpers already implement those policies. Calling them as `(legacyValue, currentV2Value)` produces the desired union with healthy v2 winning collisions and avoids a second merge implementation. +- Persistent document reconciliation now applies those catalog policies on every startup: the test proves legacy-only settings and vocabulary are added, current v2 values win shared keys/IDs, and exact bad-migration writes are still replaced rather than merged. +# 2026-07-30 Review Fixes, v2 Insights, And Endless Mode + +## Confirmed review regressions + +- `js/data/v2/appData.js`: persistent legacy reconciliation currently re-merges `active_sessions`, `temp_practice_records`, `interrupted_records`, and `rejected_completion_payloads`, resurrecting v2-deleted recovery rows. +- `js/utils/BrowsePreferencesUtils.js`: first synchronous preference read returns/caches defaults while async AppData hydration finishes without reapplying the initial filter/scroll state. +- `developer/tests/ci/run_static_suite.py`: the exam app method-contract collector scans only `js/app/*Mixin.js`, while `createFallbackRecorder` now exists only in `js/app.js`. +- `js/data/v2/appData.js`: achievement projection supports an existing unlocked state, but `getAll()` always passes `{}` and no durable v2 progress document currently owns new unlocks. +- `developer/tests/e2e/suite_practice_flow.py`: the E2E predicate reads properties from the Promise returned by async `resolveSuitePreference()`. +- `js/boot-fallbacks.js`: pre-import backup creation happens before semantic preview and user confirmation. + +## Lightweight practice insight gap + +- `js/main.js` loads `AppData.practice.list({ projection: 'light' })`, but `PracticePriorityRenderer.calculateReadingRadarData()` reads `questionTypePerformance`, `answerDetails`, and `scoreInfo.details`, all of which live only in v2 detail records. +- The production radar therefore receives records with no classifiable wrong-answer data and reports zero errors. +- Suite child records are deleted after finalization; `suiteEntrySummaries` currently preserve score metadata but no compact question-type error counts. +- The appropriate contract is a compact derived `questionTypeErrorCounts` field on summaries and suite-entry summaries, not a fallback to loading every detail record. +- `filterByExamType()` also ignores existing `suiteEntrySummaries`; it can consume those without a new API. + +## Endless mode + +- `js/presentation/app-actions.js` initializes `endlessState` as `null`, writes `endlessState.examIndex` before constructing the object, and deterministically throws on the first start. +- The generated runtime-entry bundle contains the same defect and is what `index.html` executes. +- The unified reading page only emits `ENDLESS_USER_EXIT` when an endless marker is present, but current first/subsequent exam opens do not add that marker. +- Subsequent endless exams manually navigate/register/start a session instead of using the normal `app.openExam()` lifecycle, risking stale window/session state. +- Focused regressions must execute the lifecycle; the existing endless test only scans source strings. + +## Source-contract decisions after main-agent read + +- The root README confirms bundles are the only production runtime and must be rebuilt from source; `file://` remains a required execution mode. +- Recovery documents are cataloged as authoritative/exportable `merge-by-id` data. Their backup/import semantics should remain intact; only startup legacy reconciliation needs a one-shot policy. +- `lightFromCanonical()` is the canonical summary constructor and `lightSuiteEntry()` is the canonical compact suite-entry constructor, so derived error counts belong in those two functions and will naturally persist in `practiceSummaries`. +- `filterByExamType()` currently consults the entire exam index before record metadata. It should first honor `suiteEntrySummaries`, then the summary's own type, and only use the exam index as a legacy fallback. +- The current import fallback has a clean sequencing boundary: preview and optional confirmation end immediately before `commitImport()`, making backup creation safe to move to that point without changing payload validation. +- `openExam()` already owns reused-window cleanup, launch-library provenance, session registration, recorder start, and injection. Endless follow-up navigation should call this path instead of duplicating those responsibilities. +- Achievement tests currently assert `getAll()` performs one `practiceSummaries` list and no document reads; adding durable progress intentionally changes that contract to one `achievements.progress` read and requires updating the focused harness/catalog expectations. +- Existing Browse preference coverage is concentrated in `developer/tests/js/browsePreferencesRecords.test.js`; it already models AppData preference failures and is the right place for delayed-hydration ordering coverage. +- The static method contract has a single collector in `run_static_suite.py`; scanning `app.js` alongside mixins fixes the source-of-truth mismatch without duplicating a method. +- The recovery facade exposes discard/complete methods that write a cleared/current v2 envelope, so the transient reconciliation regression can model the real user path and reboot the shared kernel. +- `unifiedReadingLockRegression.test.js` is already registered by the static suite and can host an executable VM lifecycle check for first open and countdown-driven window reuse without adding another runner block. + +## Final implementation and residual gates + +- New practice writes persist answer-free `questionTypeErrorCounts` in summaries and suite-entry summaries. `practice.listInsights({limit:10})` supplies the same contract for historical rows by reading only the bounded missing details; annotations and all-history detail scans remain excluded. +- Browse activation now awaits preference hydration before reading the persisted filter, and initial preference UI/scroll restoration use the same readiness promise. +- Achievement unlock facts are stored in exportable/importable `achievements.progress`; deleting source practice rows no longer relocks them. +- Endless mode now constructs state atomically, marks the unified URL, opens first and later exams through `app.openExam()`, reuses the stable tab, and cleans up/report failures. +- The final full static report passed every gate changed by this work. Its remaining failures are outside this scope: the pre-existing v2 migration allowlist mismatch, noisy suite-regression JSON parsing, four NB replay content fixtures, and the 480-second Reading quick audit timeout. +- The suite E2E reached lazy loading, persisted preference setup, and window launch after its two runner API fixes, then stopped at the existing first-passage readiness timeout caused by unavailable local exercise resources. + +## Residual gate triage + +- The v2 unique-entry failure is a guard allowlist drift: `run_static_suite.py` still anchors the allowed AppData legacy-import region at the removed `findDeclaredValue` symbol. The legacy reads are confined to the intended v1 compatibility/migration boundaries; repair the semantic allowlist and keep a negative guard case. +- `suiteModeRegression.test.js` exits successfully and prints pass JSON on its final stdout line. The static runner incorrectly parses the entire noisy stdout as one JSON document even though it already has a last-line JSON helper. +- All four NB replay failures are stale tests. The runtime now requires a trusted `INIT_SESSION`, `source: exam_host`, and a matching window token; with that protocol the four resources restore answers, answered state, highlights, text, and mirror data correctly. `p2-high-201` also needs its test selector scoped to the clone group. +- Reading quick is not merely a slow-machine timeout. It spends about 218 seconds launching Node/VM once per 232 static datasets, then twelve UI cases each exhaust a 30-second wait for an obsolete or premature `#results` contract. Fix the page-ready/result contract and batch the static exporter before revisiting the 480-second outer bound. +- Suite E2E does not fail because the optional listening manifest is absent. `_buildExamPlaceholderUrl()` drops the parent's test mode, so `exam-placeholder.html` identifies itself as non-test, sets `examState=blocked`, and never enables completion. Propagate the narrow `suite_test=1` flag and rerun the full suite chain. +- Release recommendation: repair the three test-infrastructure failures promptly to restore a trustworthy green gate; keep Reading UI and suite end-to-end paths release-blocking until their real chains run successfully. + +## Residual gate repair design + +- Reading quick currently opens the unified page as a top-level `file://` document. Even after the click handler binds, submission intentionally cannot post to itself; results render only after a correlated `PRACTICE_SUBMIT_ACK`. The audit must host the page in an iframe, perform the existing `REQUEST_INIT`/`INIT_SESSION` handshake, wait for `SESSION_READY`, and ACK `PRACTICE_COMPLETE`. +- No new Reading runtime-ready sentinel is needed: `SESSION_READY` is emitted only after action and message listeners have been attached. +- The Reading exporter can add an `--all` mode that loads all 232 registered datasets into one VM/context. Python should consume that bundle once instead of launching Node once per exam. +- Suite placeholder propagation should use the already-supported narrow `suite_test=1` query flag. The template and environment detector need no behavior change. +- The NB replay fixture must retain the production trusted-message gate and instead send a valid INIT plus matching token/source. Its selectors must be scoped to the same clone-enabled group so `p2-high-201` covers the intended case. + +## Final verification (2026-07-31) + +- Placeholder reuse can render before the next `INIT_SESSION`; URL-level `suiteFlowMode` recovery now makes simulation/stationary behavior deterministic, while late contexts preserve submitted-final navigation. +- The unified static suite passes all checks. The only fixture-level correction was adding `practice.listInsights()` to the practice-persistence AppData stub. +- Bundle drift is green for all 14 outputs; eight historical symbol-collision warnings remain explicitly non-blocking. +- The legacy migration fixture should derive its fresh timestamp from `Date.now()` and include a 31-day stale row to keep TTL cleanup explicitly covered. +- Main-agent marker verification confirmed bounded semantic regions: DataKernel legacy constants end before `function clone`; AppData import recognition ends before `entityRowFromLayer`; AppData document migration ends before kernel initialization; migration fixtures are bounded by their harness/main functions rather than whole-file exemptions. +- The existing reliable-submit E2E already contains a compact file-compatible iframe host and correlation helpers. Reading audit can embed a smaller auto-ACK variant, operate on the named frame, and retain page-level screenshots/console collection. +- `suiteModeRegression.test.js` already exposes native `URL`/`URLSearchParams` in its VM sandbox, so the placeholder URL query and special-character round-trip can be covered without new harness dependencies. diff --git a/index.html b/index.html index b8d457f4..7a775908 100644 --- a/index.html +++ b/index.html @@ -784,10 +784,11 @@

🔧 系统管理

系统工具和设置选项

+ @@ -2628,9 +2619,31 @@
+