test: 添加核心服务回归测试

- 为存储、词库、记忆调度、MIME 解析、单词提取、测验生成及备份处理添加 Node.js 测试
- 添加可复用的浏览器环境与 localStorage 测试辅助工具
- 修复移除 HTML 时单词边界丢失的问题,并拒绝无效的测验答案
- 校验导入备份中的日期,并添加 npm 测试脚本
This commit is contained in:
eddy
2026-07-19 23:57:27 +08:00
parent adf4f9d93a
commit 438b6cb7bb
14 changed files with 950 additions and 9 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g"
crossorigin="anonymous" referrerpolicy="no-referrer"> crossorigin="anonymous" referrerpolicy="no-referrer">
<script> <script>
const ASSET_VERSION = '20260719-2'; const ASSET_VERSION = '20260719-3';
const stylesheets = [ const stylesheets = [
'styles/variables.css', 'styles/variables.css',
'styles/base.css', 'styles/base.css',
+9 -4
View File
@@ -99,6 +99,13 @@ export function handleImportBackup(input) {
reader.readAsText(file); reader.readAsText(file);
} }
function isValidBackupDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split('-').map(Number);
const date = new Date(year, month - 1, day);
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
}
function normalizeBackupCategory(value) { function normalizeBackupCategory(value) {
return typeof value === 'string' ? value.trim().slice(0, 100) : ''; return typeof value === 'string' ? value.trim().slice(0, 100) : '';
} }
@@ -153,9 +160,7 @@ function normalizeBackupSchedule(value) {
const numericCount = Number(count); const numericCount = Number(count);
return Number.isFinite(numericCount) ? Math.max(0, Math.trunc(numericCount)) : 0; return Number.isFinite(numericCount) ? Math.max(0, Math.trunc(numericCount)) : 0;
}; };
const nextReview = typeof raw.nextReview === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.nextReview) const nextReview = isValidBackupDate(raw.nextReview) ? raw.nextReview : getToday();
? raw.nextReview
: getToday();
schedule[numericWordId] = { schedule[numericWordId] = {
stage, stage,
nextReview, nextReview,
@@ -192,7 +197,7 @@ function normalizeBackupRecords(value) {
return { return {
...raw, ...raw,
wordId, wordId,
date: typeof raw.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.date) ? raw.date : getToday(), date: isValidBackupDate(raw.date) ? raw.date : getToday(),
time: Number.isFinite(time) && time >= 0 ? time : Date.now(), time: Number.isFinite(time) && time >= 0 ? time : Date.now(),
isCorrect: raw.isCorrect, isCorrect: raw.isCorrect,
type: raw.type, type: raw.type,
+3
View File
@@ -109,6 +109,9 @@ export function sanitizeAiQuestion(q) {
const question = typeof q.question === 'string' ? q.question.trim() : ''; const question = typeof q.question === 'string' ? q.question.trim() : '';
const options = q.options.map(option => typeof option === 'string' ? option.trim() : ''); const options = q.options.map(option => typeof option === 'string' ? option.trim() : '');
if (!question || options.some(option => !option) || new Set(options).size !== 4) return null; if (!question || options.some(option => !option) || new Set(options).size !== 4) return null;
const hasValidAnswerType = typeof q.answer === 'number'
|| (typeof q.answer === 'string' && q.answer.trim() !== '');
if (!hasValidAnswerType) return null;
const answer = Number(q.answer); const answer = Number(q.answer);
if (!Number.isInteger(answer) || answer < 0 || answer > 3) return null; if (!Number.isInteger(answer) || answer < 0 || answer > 3) return null;
return { return {
+2 -1
View File
@@ -3,6 +3,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"serve": "python -m http.server 8000 --directory .." "serve": "python -m http.server 8000 --directory ..",
"test": "node --test test/*.test.js"
} }
} }
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_WORD_LIBRARY } from '../js/constants.js';
import { state } from '../js/core/state.js';
import { getLibraryStorageKey, STORAGE_KEYS } from '../js/core/storage.js';
import {
addDays,
initWordSchedule,
recordAndUpdateSchedule,
toLocalDateStr,
trimRecords
} from '../js/services/ebbinghaus.js';
import { installMinimalBrowser } from './test-helpers.js';
test.beforeEach(() => {
installMinimalBrowser();
state.activeWordLibrary = DEFAULT_WORD_LIBRARY;
state.words = [{ id: 1, english: 'one', chinese: '一' }];
state.records = [];
state.schedule = {};
});
test('toLocalDateStr formats local dates with zero padding', () => {
assert.equal(toLocalDateStr(new Date(2026, 0, 9)), '2026-01-09');
});
test('addDays crosses month, year, and leap-day boundaries', () => {
assert.equal(addDays('2026-01-31', 1), '2026-02-01');
assert.equal(addDays('2025-12-31', 1), '2026-01-01');
assert.equal(addDays('2024-02-28', 1), '2024-02-29');
assert.equal(addDays('2024-03-01', -1), '2024-02-29');
});
test('trimRecords returns the original array when no trimming is needed', () => {
const records = [{ wordId: 1, type: 'quiz' }];
assert.strictEqual(trimRecords(records, 2, 1), records);
});
test('trimRecords keeps the newest record for as many word/type groups as capacity allows', () => {
const records = [
{ id: '1-old', wordId: 1, type: 'quiz' },
{ id: '2-old', wordId: 2, type: 'quiz' },
{ id: '1-new', wordId: 1, type: 'quiz' },
{ id: 'review', wordId: 1, type: 'review' },
{ id: '2-new', wordId: 2, type: 'quiz' }
];
assert.deepEqual(
trimRecords(records, 3, 1).map(record => record.id),
['1-new', 'review', '2-new']
);
});
test('trimRecords fills spare capacity with the newest remaining records', () => {
const records = Array.from({ length: 6 }, (_, index) => ({
id: index + 1,
wordId: 1,
type: 'quiz'
}));
assert.deepEqual(trimRecords(records, 4, 2).map(record => record.id), [3, 4, 5, 6]);
});
test('initWordSchedule creates a schedule without persisting when requested', () => {
assert.equal(initWordSchedule(1, false), true);
assert.equal(state.schedule[1].stage, 0);
assert.equal(state.schedule[1].correctCount, 0);
assert.match(state.schedule[1].nextReview, /^\d{4}-\d{2}-\d{2}$/);
});
test('recordAndUpdateSchedule advances and lowers stages within bounds', () => {
state.schedule = {
1: { stage: 4, nextReview: '2026-01-01', correctCount: 2, incorrectCount: 1 }
};
assert.equal(recordAndUpdateSchedule(1, true, 'quiz', 'en2zh'), true);
assert.equal(state.schedule[1].stage, 5);
assert.equal(state.schedule[1].correctCount, 3);
assert.equal(recordAndUpdateSchedule(1, true, 'quiz', 'en2zh'), true);
assert.equal(state.schedule[1].stage, 5);
for (let index = 0; index < 10; index++) {
assert.equal(recordAndUpdateSchedule(1, false, 'review', 'en2zh'), true);
}
assert.equal(state.schedule[1].stage, 0);
assert.equal(state.schedule[1].incorrectCount, 11);
});
test('recordAndUpdateSchedule creates missing schedules and removes only matching incorrect quiz records', () => {
state.records = [
{ wordId: 1, type: 'quiz', isCorrect: false },
{ wordId: 1, type: 'review', isCorrect: false },
{ wordId: 2, type: 'quiz', isCorrect: false }
];
assert.equal(recordAndUpdateSchedule(1, true, 'quiz', 'en2zh', { removeIncorrectQuizRecords: true }), true);
assert.equal(state.schedule[1].stage, 1);
assert.equal(state.records.some(record => record.wordId === 1 && record.type === 'quiz' && !record.isCorrect), false);
assert.equal(state.records.some(record => record.wordId === 1 && record.type === 'review' && !record.isCorrect), true);
assert.equal(state.records.some(record => record.wordId === 2 && record.type === 'quiz' && !record.isCorrect), true);
});
test('recordAndUpdateSchedule restores memory and persisted values when schedule saving fails', () => {
const previousRecords = [{ wordId: 1, type: 'quiz', isCorrect: false }];
const previousSchedule = { 1: { stage: 2, nextReview: '2026-01-01', correctCount: 1, incorrectCount: 1 } };
state.records = previousRecords;
state.schedule = previousSchedule;
const recordsKey = getLibraryStorageKey(STORAGE_KEYS.records);
const scheduleKey = getLibraryStorageKey(STORAGE_KEYS.schedule);
localStorage.setItem(recordsKey, JSON.stringify(previousRecords));
localStorage.setItem(scheduleKey, JSON.stringify(previousSchedule));
localStorage.failSetKeys.add(scheduleKey);
assert.equal(recordAndUpdateSchedule(1, true, 'quiz', 'en2zh'), false);
assert.strictEqual(state.records, previousRecords);
assert.strictEqual(state.schedule, previousSchedule);
assert.deepEqual(JSON.parse(localStorage.getItem(recordsKey)), previousRecords);
});
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
detectProperNouns,
extractEnglishWords,
extractWithFrequency,
findWordContext
} from '../js/services/extractor.js';
test('extractEnglishWords removes URLs, email addresses, HTML, stop words, and duplicates', () => {
const result = extractEnglishWords(
'The Zebra and apple APPLE. Contact test@example.com or https://example.com/path. <b>Banana</b>'
);
assert.deepEqual(result, ['apple', 'banana', 'contact', 'zebra']);
});
test('extractEnglishWords preserves word boundaries between adjacent HTML elements', () => {
assert.deepEqual(extractEnglishWords('<p>Alpha</p><p>Beta</p>'), ['alpha', 'beta']);
});
test('extractEnglishWords preserves valid apostrophes and hyphens', () => {
assert.deepEqual(extractEnglishWords("state-of-the-art O'Reilly x"), ["o'reilly", 'state-of-the-art']);
});
test('detectProperNouns ignores sentence-initial capitalization and detects internal names', () => {
const result = detectProperNouns('Alice met Zephyrus today. Zephyrus replied. Ordinary words remain ordinary.');
assert.equal(result.has('alice'), false);
assert.equal(result.has('zephyrus'), true);
assert.equal(result.has('ordinary'), false);
});
test('detectProperNouns keeps words that also occur lowercase', () => {
const result = detectProperNouns('We discussed Nimbus today. The nimbus cloud appeared later.');
assert.equal(result.has('nimbus'), false);
});
test('extractWithFrequency preserves word boundaries between adjacent HTML elements', () => {
assert.deepEqual(extractWithFrequency(['<p>alpha</p><p>beta</p>']), [
{ word: 'alpha', emailCount: 1, totalCount: 1 },
{ word: 'beta', emailCount: 1, totalCount: 1 }
]);
});
test('extractWithFrequency counts per-email and total occurrences and filters proper nouns', () => {
const result = extractWithFrequency([
'Zephyrus reviewed widget widget and orchard.',
'The widget entered another orchard. We met Zephyrus again.'
]);
const byWord = new Map(result.map(item => [item.word, item]));
assert.deepEqual(byWord.get('widget'), { word: 'widget', emailCount: 2, totalCount: 3 });
assert.deepEqual(byWord.get('orchard'), { word: 'orchard', emailCount: 2, totalCount: 2 });
assert.equal(byWord.has('zephyrus'), false);
assert.equal(result[0].word, 'widget');
});
test('findWordContext matches whole words case-insensitively and truncates long context', () => {
assert.equal(findWordContext('cat', 'A concatenate example. The Cat sleeps here! Another sentence.'), 'The Cat sleeps here');
assert.equal(findWordContext('missing', 'No target exists.'), null);
const longSentence = `prefix target ${'x'.repeat(150)}`;
assert.equal(findWordContext('target', longSentence).length, 120);
});
test('findWordContext safely handles regular-expression characters', () => {
assert.equal(findWordContext('c++', 'Languages include C++ and Rust.'), null);
});
+168
View File
@@ -0,0 +1,168 @@
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 {
doLoadWords,
fetchWordLibrary,
getWordLibrary,
switchWordLibrary,
toggleAutoLoad
} from '../js/services/library.js';
import { installMinimalBrowser, json } from './test-helpers.js';
registerPage('settings', () => {});
registerPage('home', () => {});
function resetState() {
state.activeWordLibrary = DEFAULT_WORD_LIBRARY;
state.words = [{ id: 1, english: 'existing', chinese: '已有' }];
state.records = [];
state.schedule = {};
state.favorites = [];
state.autoLoadEnabled = true;
state.currentPage = 'settings';
state.learnSession = { active: true };
state.quizSession = { active: true };
state.wordPage = 3;
state.wordSearch = 'query';
state.wordCategory = 'category';
}
test.beforeEach(() => {
installMinimalBrowser();
resetState();
});
test('getWordLibrary falls back to the default library', () => {
assert.equal(getWordLibrary('unknown').id, DEFAULT_WORD_LIBRARY);
assert.equal(getWordLibrary(WORD_LIBRARIES[1].id).id, WORD_LIBRARIES[1].id);
});
test('fetchWordLibrary validates HTTP responses and filters malformed words', async () => {
globalThis.fetch = async () => ({
ok: true,
async json() {
return [
{ english: 'valid', chinese: '有效' },
null,
{ english: '', chinese: '无效' }
];
}
});
assert.deepEqual(await fetchWordLibrary(DEFAULT_WORD_LIBRARY), [{ english: 'valid', chinese: '有效' }]);
globalThis.fetch = async () => ({ ok: false, status: 503 });
await assert.rejects(fetchWordLibrary(DEFAULT_WORD_LIBRARY), /HTTP 503/);
});
test('switchWordLibrary loads persisted library state without fetching', async () => {
const target = WORD_LIBRARIES[1].id;
localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.words, target), json([{ id: 7, english: 'saved', chinese: '已保存' }]));
localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.records, target), json([]));
localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.schedule, target), json({ 7: { stage: 0 } }));
localStorage.setItem(getLibraryStorageKey(STORAGE_KEYS.favorites, target), json([7]));
const select = { value: target };
const originalGetElementById = document.getElementById;
document.getElementById = id => id === 'word-library-select' ? select : originalGetElementById(id);
globalThis.fetch = async () => { throw new Error('should not fetch'); };
await switchWordLibrary();
assert.equal(state.activeWordLibrary, target);
assert.equal(state.words[0].english, 'saved');
assert.deepEqual(state.favorites, [7]);
assert.equal(localStorage.getItem(STORAGE_KEYS.activeWordLibrary), target);
assert.equal(state.learnSession, null);
assert.equal(state.quizSession, null);
assert.equal(state.wordPage, 1);
});
test('switchWordLibrary fetches, deduplicates, normalizes, and persists an empty library', async () => {
const target = WORD_LIBRARIES[1].id;
const select = { value: target };
const originalGetElementById = document.getElementById;
document.getElementById = id => id === 'word-library-select' ? select : originalGetElementById(id);
globalThis.fetch = async () => ({
ok: true,
async json() {
return [
{ english: ' Alpha ', chinese: '甲' },
{ english: 'alpha', chinese: '重复' },
{ english: 'Beta', chinese: '乙' }
];
}
});
await switchWordLibrary();
assert.deepEqual(state.words.map(word => word.english), ['Alpha', 'Beta']);
assert.deepEqual(state.words.map(word => word.id), [1, 2]);
assert.deepEqual(Object.keys(state.schedule), ['1', '2']);
assert.deepEqual(JSON.parse(localStorage.getItem(getLibraryStorageKey(STORAGE_KEYS.words, target))), state.words);
});
test('switchWordLibrary restores memory and storage when persistence fails', async () => {
const target = WORD_LIBRARIES[1].id;
const previousWords = state.words;
const select = { value: target };
const originalGetElementById = document.getElementById;
document.getElementById = id => id === 'word-library-select' ? select : originalGetElementById(id);
localStorage.failSetKeys.add(STORAGE_KEYS.activeWordLibrary);
globalThis.fetch = async () => ({
ok: true,
async json() { return [{ english: 'new', chinese: '新' }]; }
});
await switchWordLibrary();
assert.equal(state.activeWordLibrary, DEFAULT_WORD_LIBRARY);
assert.strictEqual(state.words, previousWords);
assert.equal(localStorage.getItem(getLibraryStorageKey(STORAGE_KEYS.words, target)), null);
});
test('doLoadWords merges unique words and keeps existing IDs and progress', async () => {
state.schedule = { 1: { stage: 2 } };
globalThis.fetch = async () => ({
ok: true,
async json() {
return [
{ english: 'EXISTING', chinese: '重复' },
{ english: 'new', chinese: '新' }
];
}
});
await doLoadWords(true);
assert.deepEqual(state.words.map(word => word.english), ['existing', 'new']);
assert.deepEqual(state.words.map(word => word.id), [1, 2]);
assert.equal(state.schedule[1].stage, 2);
assert.equal(state.schedule[2].stage, 0);
});
test('doLoadWords ignores stale results after the active library changes', async () => {
let resolveResponse;
globalThis.fetch = () => new Promise(resolve => { resolveResponse = resolve; });
const loading = doLoadWords(true);
state.activeWordLibrary = WORD_LIBRARIES[1].id;
resolveResponse({ ok: true, async json() { return [{ english: 'late', chinese: '迟到' }]; } });
await loading;
assert.deepEqual(state.words.map(word => word.english), ['existing']);
});
test('toggleAutoLoad changes memory only after persistence succeeds', () => {
localStorage.failSetKeys.add(STORAGE_KEYS.autoLoadEnabled);
assert.equal(toggleAutoLoad(false), false);
assert.equal(state.autoLoadEnabled, true);
localStorage.failSetKeys.delete(STORAGE_KEYS.autoLoadEnabled);
assert.equal(toggleAutoLoad(false), true);
assert.equal(state.autoLoadEnabled, false);
});
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createTextDecoder,
decodeBase64,
decodeBasicEntities,
decodeMimePart,
decodeQuotedPrintable,
extractEmlBody,
splitMimeSection
} from '../js/services/mime.js';
test('createTextDecoder falls back to UTF-8 for unsupported charsets', () => {
assert.equal(createTextDecoder('definitely-not-a-charset').decode(new TextEncoder().encode('你好')), '你好');
});
test('decodeQuotedPrintable handles encoded bytes and soft line breaks', () => {
assert.equal(decodeQuotedPrintable('Hello=20world=21=\r\nNext', 'utf-8'), 'Hello world!Next');
assert.equal(decodeQuotedPrintable('=E4=BD=A0=E5=A5=BD', 'utf-8'), '你好');
});
test('decodeBase64 decodes Unicode text and preserves invalid input', () => {
const encoded = Buffer.from('Hello 你好', 'utf8').toString('base64');
assert.equal(decodeBase64(encoded, 'utf-8'), 'Hello 你好');
assert.equal(decodeBase64('%%% invalid %%%', 'utf-8'), '%%% invalid %%%');
});
test('splitMimeSection supports CRLF and missing separators', () => {
assert.deepEqual(splitMimeSection('A: b\r\nC: d\r\n\r\nBody'), {
headers: 'A: b\r\nC: d',
body: 'Body'
});
assert.deepEqual(splitMimeSection('Only headers'), { headers: 'Only headers', body: '' });
});
test('decodeBasicEntities handles named, decimal, and hexadecimal entities', () => {
assert.equal(decodeBasicEntities('&lt;b&gt;A&amp;B&nbsp;&#33;&#x3F;&lt;/b&gt;'), '<b>A&B !?</b>');
assert.equal(decodeBasicEntities('&unknown;'), '&unknown;');
});
test('decodeMimePart strips markup and unsafe HTML element contents', () => {
const body = decodeMimePart({
headers: 'Content-Type: text/html; charset=utf-8',
body: '<style>.hidden{}</style><p>Hello&nbsp;<b>world</b></p><script>alert(1)</script>'
});
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',
'',
'<p>HTML version</p>',
'--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',
'',
'<p>Message body</p>',
'--mixed--',
''
].join('\r\n');
assert.equal(extractEmlBody(eml).replace(/\s+/g, ' ').trim(), 'Message body');
});
+102
View File
@@ -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);
});
+188
View File
@@ -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))), []);
});
+111
View File
@@ -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, []);
});
+74
View File
@@ -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);
}
+1 -1
View File
@@ -171,7 +171,7 @@ src/
## 十一、可选增强(本轮不做,另开任务) ## 十一、可选增强(本轮不做,另开任务)
- [ ] 引入 Vite:解决模块数量多时的请求瀑布与缓存指纹问题,`vite build` 产物仍是纯静态文件。 - [ ] 引入 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` 自动持久化。 - [ ] state 写入收口为 action 函数,配合 `saveXxx` 自动持久化。
- [ ] HTML 模板字符串改为 `<template>` 或轻量渲染函数,减少字符串拼接。 - [ ] HTML 模板字符串改为 `<template>` 或轻量渲染函数,减少字符串拼接。
- [ ] PWAmanifest + Service Worker)替代手动 `?v=` 缓存控制。 - [ ] PWAmanifest + Service Worker)替代手动 `?v=` 缓存控制。