test: 添加核心服务回归测试
- 为存储、词库、记忆调度、MIME 解析、单词提取、测验生成及备份处理添加 Node.js 测试 - 添加可复用的浏览器环境与 localStorage 测试辅助工具 - 修复移除 HTML 时单词边界丢失的问题,并拒绝无效的测验答案 - 校验导入备份中的日期,并添加 npm 测试脚本
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@
|
||||
integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<script>
|
||||
const ASSET_VERSION = '20260719-2';
|
||||
const ASSET_VERSION = '20260719-3';
|
||||
const stylesheets = [
|
||||
'styles/variables.css',
|
||||
'styles/base.css',
|
||||
|
||||
@@ -99,6 +99,13 @@ export function handleImportBackup(input) {
|
||||
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) {
|
||||
return typeof value === 'string' ? value.trim().slice(0, 100) : '';
|
||||
}
|
||||
@@ -153,9 +160,7 @@ function normalizeBackupSchedule(value) {
|
||||
const numericCount = Number(count);
|
||||
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)
|
||||
? raw.nextReview
|
||||
: getToday();
|
||||
const nextReview = isValidBackupDate(raw.nextReview) ? raw.nextReview : getToday();
|
||||
schedule[numericWordId] = {
|
||||
stage,
|
||||
nextReview,
|
||||
@@ -192,7 +197,7 @@ function normalizeBackupRecords(value) {
|
||||
return {
|
||||
...raw,
|
||||
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(),
|
||||
isCorrect: raw.isCorrect,
|
||||
type: raw.type,
|
||||
|
||||
@@ -109,6 +109,9 @@ export function sanitizeAiQuestion(q) {
|
||||
const question = typeof q.question === 'string' ? q.question.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;
|
||||
const hasValidAnswerType = typeof q.answer === 'number'
|
||||
|| (typeof q.answer === 'string' && q.answer.trim() !== '');
|
||||
if (!hasValidAnswerType) return null;
|
||||
const answer = Number(q.answer);
|
||||
if (!Number.isInteger(answer) || answer < 0 || answer > 3) return null;
|
||||
return {
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "python -m http.server 8000 --directory .."
|
||||
"serve": "python -m http.server 8000 --directory ..",
|
||||
"test": "node --test test/*.test.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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('<b>A&B !?</b>'), '<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 <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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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))), []);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 模板字符串改为 `<template>` 或轻量渲染函数,减少字符串拼接。
|
||||
- [ ] PWA(manifest + Service Worker)替代手动 `?v=` 缓存控制。
|
||||
|
||||
Reference in New Issue
Block a user