From 438b6cb7bb21bcbbf24630445785030ac26678fc Mon Sep 17 00:00:00 2001 From: eddy Date: Sun, 19 Jul 2026 23:50:44 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为存储、词库、记忆调度、MIME 解析、单词提取、测验生成及备份处理添加 Node.js 测试 - 添加可复用的浏览器环境与 localStorage 测试辅助工具 - 修复移除 HTML 时单词边界丢失的问题,并拒绝无效的测验答案 - 校验导入备份中的日期,并添加 npm 测试脚本 --- src/index.html | 2 +- src/js/pages/settings-data.js | 13 ++- src/js/services/extractor.js | 4 +- src/js/services/quiz-generator.js | 3 + src/package.json | 3 +- src/test/ebbinghaus.test.js | 120 +++++++++++++++++++ src/test/extractor.test.js | 68 +++++++++++ src/test/library.test.js | 168 ++++++++++++++++++++++++++ src/test/mime.test.js | 101 ++++++++++++++++ src/test/quiz-generator.test.js | 102 ++++++++++++++++ src/test/settings-data.test.js | 188 ++++++++++++++++++++++++++++++ src/test/storage.test.js | 111 ++++++++++++++++++ src/test/test-helpers.js | 74 ++++++++++++ todo.md | 2 +- 14 files changed, 950 insertions(+), 9 deletions(-) create mode 100644 src/test/ebbinghaus.test.js create mode 100644 src/test/extractor.test.js create mode 100644 src/test/library.test.js create mode 100644 src/test/mime.test.js create mode 100644 src/test/quiz-generator.test.js create mode 100644 src/test/settings-data.test.js create mode 100644 src/test/storage.test.js create mode 100644 src/test/test-helpers.js diff --git a/src/index.html b/src/index.html index 786cb70..c80d275 100644 --- a/src/index.html +++ b/src/index.html @@ -15,7 +15,7 @@ integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous" referrerpolicy="no-referrer"> ' + }); + assert.equal(body.replace(/\s+/g, ' ').trim(), 'Hello world'); +}); + +test('extractEmlBody decodes a plain-text EML from bytes', () => { + const eml = [ + 'From: sender@example.com', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: base64', + '', + Buffer.from('Plain 你好', 'utf8').toString('base64') + ].join('\r\n'); + + assert.equal(extractEmlBody(new TextEncoder().encode(eml)), 'Plain 你好'); +}); + +test('extractEmlBody prefers plain text in multipart alternative messages', () => { + const eml = [ + 'MIME-Version: 1.0', + 'Content-Type: multipart/alternative; boundary="choice"', + '', + '--choice', + 'Content-Type: text/html; charset=utf-8', + '', + '

HTML version

', + '--choice', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Plain version', + '--choice--', + '' + ].join('\r\n'); + + assert.equal(extractEmlBody(eml).trim(), 'Plain version'); +}); + +test('extractEmlBody skips attachments in multipart messages', () => { + const eml = [ + 'MIME-Version: 1.0', + 'Content-Type: multipart/mixed; boundary="mixed"', + '', + '--mixed', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Disposition: attachment; filename="notes.txt"', + '', + 'Attachment text', + '--mixed', + 'Content-Type: text/html; charset=utf-8', + '', + '

Message body

', + '--mixed--', + '' + ].join('\r\n'); + + assert.equal(extractEmlBody(eml).replace(/\s+/g, ' ').trim(), 'Message body'); +}); diff --git a/src/test/quiz-generator.test.js b/src/test/quiz-generator.test.js new file mode 100644 index 0000000..ccfc0bd --- /dev/null +++ b/src/test/quiz-generator.test.js @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { state } from '../js/core/state.js'; +import { + extractJsonValue, + generateLocalQuiz, + isPlainObject, + sanitizeAiQuestion, + shuffle +} from '../js/services/quiz-generator.js'; + +const WORDS = [ + { id: 1, english: 'apple', chinese: '苹果', phonetic: '/apple/' }, + { id: 2, english: 'banana', chinese: '香蕉', phonetic: '/banana/' }, + { id: 3, english: 'cherry', chinese: '樱桃', phonetic: '/cherry/' }, + { id: 4, english: 'date', chinese: '枣', phonetic: '/date/' }, + { id: 5, english: 'elderberry', chinese: '接骨木果', phonetic: '/elderberry/' } +]; + +test('shuffle returns a permutation without mutating its input', () => { + const input = [1, 2, 3, 4]; + const result = shuffle(input); + assert.deepEqual(input, [1, 2, 3, 4]); + assert.deepEqual([...result].sort(), input); + assert.notStrictEqual(result, input); +}); + +test('generateLocalQuiz creates ordered English-to-Chinese questions', () => { + state.words = WORDS; + const questions = generateLocalQuiz('en2zh', 2, [WORDS[2], WORDS[0], WORDS[1], WORDS[3]], 'order'); + + assert.equal(questions.length, 2); + assert.deepEqual(questions.map(question => question.wordId), [1, 2]); + for (const question of questions) { + assert.equal(question.options.length, 4); + assert.equal(new Set(question.options).size, 4); + assert.equal(question.options[question.answer], question.word.chinese); + assert.equal(question.phonetic, question.word.phonetic); + } +}); + +test('generateLocalQuiz supports Chinese-to-English mode', () => { + state.words = WORDS; + const [question] = generateLocalQuiz('zh2en', 1, WORDS, 'order'); + assert.equal(question.question, '苹果'); + assert.equal(question.options[question.answer], 'apple'); + assert.equal(question.phonetic, null); +}); + +test('generateLocalQuiz rejects undersized pools and skips invalid distractor sets', () => { + state.words = WORDS.slice(0, 3); + assert.deepEqual(generateLocalQuiz('en2zh', 3, state.words, 'order'), []); + + const duplicateMeanings = [ + { id: 1, english: 'a', chinese: '相同' }, + { id: 2, english: 'b', chinese: '相同' }, + { id: 3, english: 'c', chinese: '相同' }, + { id: 4, english: 'd', chinese: '不同' } + ]; + state.words = duplicateMeanings; + assert.deepEqual(generateLocalQuiz('en2zh', 4, duplicateMeanings, 'order'), []); +}); + +test('extractJsonValue handles fences, surrounding prose, and fallback values', () => { + assert.deepEqual(extractJsonValue('```json\n{"ok":true}\n```'), { ok: true }); + assert.deepEqual(extractJsonValue('Result: [1, 2, 3].'), [1, 2, 3]); + assert.equal(extractJsonValue('not json', 'fallback'), 'fallback'); +}); + +test('isPlainObject accepts ordinary and null-prototype objects only', () => { + assert.equal(isPlainObject({}), true); + assert.equal(isPlainObject(Object.create(null)), true); + assert.equal(isPlainObject([]), false); + assert.equal(isPlainObject(new Date()), false); + assert.equal(isPlainObject(null), false); +}); + +test('sanitizeAiQuestion normalizes valid questions and rejects malformed data', () => { + assert.deepEqual( + sanitizeAiQuestion({ + question: ' Choose one ', + options: [' A ', ' B ', ' C ', ' D '], + answer: '2', + explanation: ' because ', + extra: true + }), + { + question: 'Choose one', + options: ['A', 'B', 'C', 'D'], + answer: 2, + explanation: 'because', + extra: true + } + ); + + assert.equal(sanitizeAiQuestion({ question: 'Q', options: ['A', 'A', 'C', 'D'], answer: 0 }), null); + assert.equal(sanitizeAiQuestion({ question: 'Q', options: ['A', 'B', 'C'], answer: 0 }), null); + assert.equal(sanitizeAiQuestion({ question: 'Q', options: ['A', 'B', 'C', 'D'], answer: 4 }), null); + assert.equal(sanitizeAiQuestion({ question: 'Q', options: ['A', 'B', 'C', 'D'], answer: null }), null); + assert.equal(sanitizeAiQuestion({ question: 'Q', options: ['A', 'B', 'C', 'D'], answer: '' }), null); +}); diff --git a/src/test/settings-data.test.js b/src/test/settings-data.test.js new file mode 100644 index 0000000..049dd46 --- /dev/null +++ b/src/test/settings-data.test.js @@ -0,0 +1,188 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DEFAULT_WORD_LIBRARY, WORD_LIBRARIES } from '../js/constants.js'; +import { state } from '../js/core/state.js'; +import { registerPage } from '../js/core/router.js'; +import { getLibraryStorageKey, STORAGE_KEYS } from '../js/core/storage.js'; +import { + clearAllData, + doImportBackup, + exportAllData, + handleImportBackup, + showImportAllModal +} from '../js/pages/settings-data.js'; +import { installMinimalBrowser, json } from './test-helpers.js'; + +registerPage('settings', () => {}); +registerPage('home', () => {}); + +function resetState() { + state.activeWordLibrary = DEFAULT_WORD_LIBRARY; + state.words = [{ id: 1, english: 'old', chinese: '旧' }]; + state.records = []; + state.schedule = {}; + state.favorites = []; + state.mailEmails = []; + state.settings = { apiKey: 'secret', ttsApiKey: 'tts-secret', model: 'old-model' }; + state.currentPage = 'settings'; + state.learnSession = null; + state.quizSession = null; +} + +function installFileReader() { + globalThis.FileReader = class { + readAsText(file) { + if (file.error) { + this.onerror?.(new Error('read failed')); + } else { + this.onload?.({ target: { result: file.content } }); + } + } + }; +} + +function stageBackup(data) { + showImportAllModal(); + const button = document.createElement('button'); + button.id = 'import-backup-btn'; + document.getElementById = id => { + if (id === 'import-backup-btn') return button; + if (id === 'modal-root' || id === 'page-content' || id === 'toast-container') return globalThis.__testElements.get(id); + return null; + }; + handleImportBackup({ files: [{ content: JSON.stringify(data) }] }); + assert.equal(button.disabled, false); +} + +test.beforeEach(() => { + const browser = installMinimalBrowser(); + globalThis.__testElements = browser.elements; + installFileReader(); + resetState(); +}); + +test.afterEach(() => { + delete globalThis.__testElements; +}); + +test('exportAllData excludes API secrets and includes every configured library', async () => { + let exportedBlob; + let revokedUrl; + URL.createObjectURL = blob => { exportedBlob = blob; return 'blob:test'; }; + URL.revokeObjectURL = url => { revokedUrl = url; }; + + exportAllData(); + const data = JSON.parse(await exportedBlob.text()); + + assert.equal(data.settings.apiKey, undefined); + assert.equal(data.settings.ttsApiKey, undefined); + assert.equal(data.settings.model, 'old-model'); + assert.deepEqual(Object.keys(data.libraries).sort(), WORD_LIBRARIES.map(item => item.id).sort()); + assert.equal(revokedUrl, 'blob:test'); +}); + +test('backup import normalizes data, preserves secrets, and persists the selected library', () => { + const targetLibrary = WORD_LIBRARIES[1].id; + stageBackup({ + activeWordLibrary: targetLibrary, + words: [{ id: '5', english: ' New ', chinese: ' 新 ', frequency: '12' }], + records: [{ wordId: '5', date: 'bad', time: '10', isCorrect: true, type: 'quiz', quizMode: 'en2zh' }], + schedule: { 5: { stage: 99, nextReview: '2026-99-99', correctCount: -2, incorrectCount: '3' } }, + favorites: [5, '5', -1, 'bad'], + emails: [{ title: ' Mail ', content: 'Body' }], + settings: { model: ' new-model ', apiKey: 'replacement-secret' } + }); + + doImportBackup(); + + assert.equal(state.activeWordLibrary, targetLibrary); + assert.deepEqual(state.words, [{ id: 5, english: 'New', chinese: '新', frequency: 12 }]); + assert.deepEqual(state.favorites, [5]); + assert.equal(state.schedule[5].stage, 5); + assert.notEqual(state.schedule[5].nextReview, '2026-99-99'); + assert.match(state.schedule[5].nextReview, /^\d{4}-\d{2}-\d{2}$/); + assert.equal(state.schedule[5].correctCount, 0); + assert.equal(state.schedule[5].incorrectCount, 3); + assert.equal(state.settings.model, 'new-model'); + assert.equal(state.settings.apiKey, 'secret'); + assert.equal(localStorage.getItem(STORAGE_KEYS.activeWordLibrary), targetLibrary); + assert.deepEqual(JSON.parse(localStorage.getItem(getLibraryStorageKey(STORAGE_KEYS.words, targetLibrary))), state.words); +}); + +test('backup import rejects duplicate word IDs without changing memory or storage', () => { + const previousWords = state.words; + localStorage.setItem('sentinel', 'unchanged'); + stageBackup({ + words: [ + { id: 1, english: 'one', chinese: '一' }, + { id: 1, english: 'two', chinese: '二' } + ] + }); + + doImportBackup(); + + assert.strictEqual(state.words, previousWords); + assert.equal(localStorage.getItem('sentinel'), 'unchanged'); + assert.equal(localStorage.getItem(getLibraryStorageKey(STORAGE_KEYS.words)), null); +}); + +test('backup import rolls memory and prior writes back when a later storage write fails', () => { + const previousWords = state.words; + const wordsKey = getLibraryStorageKey(STORAGE_KEYS.words); + localStorage.setItem(wordsKey, json(previousWords)); + localStorage.failSetKeys.add(STORAGE_KEYS.settings); + stageBackup({ + words: [{ id: 2, english: 'new', chinese: '新' }], + settings: { model: 'changed' } + }); + + doImportBackup(); + + assert.strictEqual(state.words, previousWords); + assert.deepEqual(JSON.parse(localStorage.getItem(wordsKey)), previousWords); + assert.equal(state.settings.model, 'old-model'); +}); + +test('clearAllData rolls memory and storage back when clearing a library fails', () => { + const wordsKey = getLibraryStorageKey(STORAGE_KEYS.words); + const recordsKey = getLibraryStorageKey(STORAGE_KEYS.records); + const previousWords = state.words; + const previousRecords = [{ wordId: 1, type: 'quiz', isCorrect: true }]; + state.records = previousRecords; + localStorage.setItem(wordsKey, json(previousWords)); + localStorage.setItem(recordsKey, json(previousRecords)); + localStorage.failSetKeys.add(recordsKey); + + assert.equal(clearAllData(), false); + + assert.strictEqual(state.words, previousWords); + assert.strictEqual(state.records, previousRecords); + assert.deepEqual(JSON.parse(localStorage.getItem(wordsKey)), previousWords); + assert.deepEqual(JSON.parse(localStorage.getItem(recordsKey)), previousRecords); +}); + +test('clearAllData keeps favorites in every library and removes records and emails', () => { + const otherLibrary = WORD_LIBRARIES[1].id; + state.words = [ + { id: 1, english: 'keep', chinese: '保留' }, + { id: 2, english: 'remove', chinese: '删除' } + ]; + state.favorites = [1]; + state.records = [{ wordId: 1, type: 'quiz', isCorrect: true }]; + state.mailEmails = [{ id: 1, title: 'mail' }]; + localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.words, otherLibrary), json([ + { id: 3, english: 'other-keep', chinese: '保留' }, + { id: 4, english: 'other-remove', chinese: '删除' } + ])); + localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.favorites, otherLibrary), json([3])); + + assert.equal(clearAllData(), true); + + assert.deepEqual(state.words.map(word => word.id), [1]); + assert.deepEqual(state.records, []); + assert.deepEqual(state.favorites, [1]); + assert.deepEqual(state.mailEmails, []); + assert.deepEqual(JSON.parse(localStorage.getItem(getLibraryStorageKey(STORAGE_KEYS.words, otherLibrary))).map(word => word.id), [3]); + assert.deepEqual(JSON.parse(localStorage.getItem(getLibraryStorageKey(STORAGE_KEYS.records, otherLibrary))), []); +}); diff --git a/src/test/storage.test.js b/src/test/storage.test.js new file mode 100644 index 0000000..12e8be5 --- /dev/null +++ b/src/test/storage.test.js @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DEFAULT_WORD_LIBRARY, WORD_LIBRARIES } from '../js/constants.js'; +import { state } from '../js/core/state.js'; +import { + getLibraryStorageKey, + getStorageItem, + loadBooleanSetting, + loadJsonSetting, + loadLibraryState, + loadNumberSetting, + loadState, + restoreStorageItems, + saveJsonSetting, + snapshotStorageItems, + STORAGE_KEYS +} from '../js/core/storage.js'; +import { installMinimalBrowser, json } from './test-helpers.js'; + +function resetState() { + state.words = []; + state.records = []; + state.schedule = {}; + state.favorites = []; + state.settings = {}; + state.mailEmails = []; + state.activeWordLibrary = DEFAULT_WORD_LIBRARY; +} + +test.beforeEach(() => { + installMinimalBrowser(); + resetState(); +}); + +test('JSON, boolean, and number loaders handle legacy and malformed values', () => { + localStorage.setItem('broken', '{'); + localStorage.setItem('bool-string', 'true'); + localStorage.setItem('bool-json-string', '"false"'); + localStorage.setItem('number', '12.5'); + localStorage.setItem('bad-number', 'nope'); + + assert.deepEqual(loadJsonSetting('missing', { fallback: true }), { fallback: true }); + assert.deepEqual(loadJsonSetting('broken', []), []); + assert.equal(loadBooleanSetting('bool-string', false), true); + assert.equal(loadBooleanSetting('bool-json-string', true), false); + assert.equal(loadNumberSetting('number', 0), 12.5); + assert.equal(loadNumberSetting('bad-number', 7), 7); +}); + +test('storage helpers tolerate read and write failures', () => { + const { storage } = installMinimalBrowser(); + storage.failGetKeys.add('blocked'); + storage.failSetKeys.add('full'); + + assert.equal(getStorageItem('blocked'), null); + assert.equal(saveJsonSetting('full', { value: 1 }), false); +}); + +test('snapshot and restore round-trip existing and missing values', () => { + localStorage.setItem('a', 'old'); + const snapshot = snapshotStorageItems(['a', 'b']); + localStorage.setItem('a', 'new'); + localStorage.setItem('b', 'created'); + + assert.equal(restoreStorageItems(snapshot), true); + assert.equal(localStorage.getItem('a'), 'old'); + assert.equal(localStorage.getItem('b'), null); +}); + +test('loadLibraryState migrates default-library legacy keys once', () => { + localStorage.setItem(STORAGE_KEYS.words, json([{ id: 1, english: 'old', chinese: '旧' }])); + localStorage.setItem(STORAGE_KEYS.records, json([{ wordId: 1 }])); + localStorage.setItem(STORAGE_KEYS.schedule, json({ 1: { stage: 1 } })); + localStorage.setItem(STORAGE_KEYS.favorites, json([1])); + + loadLibraryState(DEFAULT_WORD_LIBRARY, true); + + assert.equal(state.words[0].english, 'old'); + assert.deepEqual(state.favorites, [1]); + assert.equal(localStorage.getItem(STORAGE_KEYS.words), null); + assert.notEqual(localStorage.getItem(getLibraryStorageKey(STORAGE_KEYS.words, DEFAULT_WORD_LIBRARY)), null); +}); + +test('loadLibraryState never reads legacy data for another library', () => { + const otherLibrary = WORD_LIBRARIES[1].id; + localStorage.setItem(STORAGE_KEYS.words, json([{ id: 1, english: 'legacy', chinese: '旧' }])); + + loadLibraryState(otherLibrary, false); + + assert.deepEqual(state.words, []); + assert.notEqual(localStorage.getItem(STORAGE_KEYS.words), null); +}); + +test('loadState falls back from an unknown active library and normalizes corrupt state shapes', () => { + localStorage.setItem(STORAGE_KEYS.activeWordLibrary, 'unknown-library'); + localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.words, DEFAULT_WORD_LIBRARY), json({ bad: true })); + localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.records, DEFAULT_WORD_LIBRARY), json('bad')); + localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.schedule, DEFAULT_WORD_LIBRARY), json([])); + localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.favorites, DEFAULT_WORD_LIBRARY), json({ bad: true })); + localStorage.setItem(STORAGE_KEYS.mailEmails, json({ bad: true })); + + loadState(); + + assert.equal(state.activeWordLibrary, DEFAULT_WORD_LIBRARY); + assert.deepEqual(state.words, []); + assert.deepEqual(state.records, []); + assert.deepEqual(state.schedule, {}); + assert.deepEqual(state.favorites, []); + assert.deepEqual(state.mailEmails, []); +}); diff --git a/src/test/test-helpers.js b/src/test/test-helpers.js new file mode 100644 index 0000000..0401f1c --- /dev/null +++ b/src/test/test-helpers.js @@ -0,0 +1,74 @@ +export class MemoryStorage { + constructor(initial = {}) { + this.items = new Map(Object.entries(initial).map(([key, value]) => [key, String(value)])); + this.failGetKeys = new Set(); + this.failSetKeys = new Set(); + this.failRemoveKeys = new Set(); + } + + getItem(key) { + if (this.failGetKeys.has(key)) throw new Error(`getItem failed: ${key}`); + return this.items.has(key) ? this.items.get(key) : null; + } + + setItem(key, value) { + if (this.failSetKeys.has(key)) throw new Error(`setItem failed: ${key}`); + this.items.set(key, String(value)); + } + + removeItem(key) { + if (this.failRemoveKeys.has(key)) throw new Error(`removeItem failed: ${key}`); + this.items.delete(key); + } + + clear() { + this.items.clear(); + this.failGetKeys.clear(); + this.failSetKeys.clear(); + this.failRemoveKeys.clear(); + } +} + +export function installMinimalBrowser(initialStorage = {}) { + const storage = new MemoryStorage(initialStorage); + const toasts = []; + const elements = new Map(); + const makeElement = tagName => ({ + tagName: String(tagName).toUpperCase(), + className: '', + innerHTML: '', + value: '', + disabled: false, + files: [], + style: {}, + dataset: {}, + isConnected: true, + children: [], + classList: { add() {}, remove() {}, toggle() {} }, + appendChild(child) { this.children.push(child); if (this.id === 'toast-container') toasts.push(child); }, + remove() {}, + click() {}, + querySelector() { return null; } + }); + for (const id of ['toast-container', 'modal-root', 'page-content']) { + const element = makeElement('div'); + element.id = id; + elements.set(id, element); + } + + globalThis.localStorage = storage; + globalThis.document = { + createElement: makeElement, + getElementById(id) { return elements.get(id) || null; }, + querySelectorAll() { return []; } + }; + globalThis.window = { location: { hash: '', pathname: '/', search: '' } }; + globalThis.history = { replaceState() {} }; + globalThis.confirm = () => true; + + return { storage, toasts, elements, makeElement }; +} + +export function json(value) { + return JSON.stringify(value); +} diff --git a/todo.md b/todo.md index fc5954d..bb216c2 100644 --- a/todo.md +++ b/todo.md @@ -171,7 +171,7 @@ src/ ## 十一、可选增强(本轮不做,另开任务) - [ ] 引入 Vite:解决模块数量多时的请求瀑布与缓存指纹问题,`vite build` 产物仍是纯静态文件。 -- [ ] 为纯函数模块(ebbinghaus / quiz-generator / mime / extractor)加 `node:test` 单元测试 —— 模块化后这些可直接在 Node 里测,只需一个 dev-only 的 package.json。 +- [x] 添加 `node:test` 单元测试(54 项):覆盖 ebbinghaus / quiz-generator / mime / extractor,以及 storage 数据兼容与异常、复习状态持久化回滚、备份导入导出与清空、词库切换/加载/竞态回滚;测试发现并修复相邻 HTML 标签导致单词粘连、空 AI 答案被误判为索引 0、备份接受无效日历日期的问题。 - [ ] state 写入收口为 action 函数,配合 `saveXxx` 自动持久化。 - [ ] HTML 模板字符串改为 `