diff --git a/src/index.html b/src/index.html
index 3a11862..e4aa46b 100644
--- a/src/index.html
+++ b/src/index.html
@@ -3,6 +3,7 @@
+
diff --git a/src/js/pages/extract-batch-files.js b/src/js/pages/extract-batch-files.js
index a904878..7f0eb01 100644
--- a/src/js/pages/extract-batch-files.js
+++ b/src/js/pages/extract-batch-files.js
@@ -12,9 +12,19 @@ function extractHtmlText(html) {
return documentFragment.body?.textContent || '';
}
+export function resetBatchUploads() {
+ batchFileReadGeneration++;
+ batchUploadedTexts = [];
+ const input = document.getElementById('batch-file-input');
+ if (input) input.value = '';
+ renderBatchFileList();
+}
+
export function handleBatchFiles(input) {
const generation = ++batchFileReadGeneration;
const files = Array.from(input.files);
+ // File 对象已复制到数组,随即清空输入框的值,否则再次选择同一批文件不会触发 change 事件。
+ input.value = '';
if (!files.length) {
batchUploadedTexts = [];
renderBatchFileList();
@@ -44,15 +54,24 @@ export function handleBatchFiles(input) {
const name = file.name.toLowerCase();
reader.onload = event => {
if (generation !== batchFileReadGeneration) return;
- let text = event.target.result;
- if (name.endsWith('.eml')) {
- text = extractEmlBody(text);
- if (!text.trim()) showToast(`邮件「${file.name}」未解析到可读正文`, 'warning');
- } else if (name.endsWith('.html') || name.endsWith('.htm')) {
- text = extractHtmlText(text);
+ // 解析异常必须就地兜住:否则 finishFile() 不会执行,计数永远到不了文件总数,
+ // 整个列表会一直停在「读取文件中...」,同批次其他文件也跟着卡死。
+ try {
+ let text = event.target.result;
+ if (name.endsWith('.eml')) {
+ text = extractEmlBody(text);
+ } else if (name.endsWith('.html') || name.endsWith('.htm')) {
+ text = extractHtmlText(text);
+ }
+ // 空正文的文件一律提示后跳过:静默丢弃会让用户面对一个空列表,无从判断是哪一步出了问题。
+ if (String(text || '').trim()) loadedTexts[index] = { name: file.name, content: text };
+ else showToast(`文件「${file.name}」未解析到可读正文,已跳过`, 'warning');
+ } catch (error) {
+ console.warn(`Failed to parse uploaded file "${file.name}"`, error);
+ showToast(`文件「${file.name}」解析失败,已跳过`, 'warning');
+ } finally {
+ finishFile();
}
- loadedTexts[index] = { name: file.name, content: text };
- finishFile();
};
reader.onerror = () => {
if (generation !== batchFileReadGeneration) return;
@@ -67,6 +86,9 @@ export function handleBatchFiles(input) {
export function renderBatchFileList() {
const listElement = document.getElementById('batch-file-list');
if (!listElement) return;
+ // 来源信息必须与列表同步刷新,否则移除最后一个文件后仍显示「1 个上传文件」,
+ // 而批量提取此时已经没有上传来源了。
+ updateBatchSourceInfo();
if (batchUploadedTexts.length === 0) {
listElement.innerHTML = '';
return;
@@ -82,8 +104,6 @@ export function renderBatchFileList() {
`).join('')}
`;
-
- updateBatchSourceInfo();
}
export function removeBatchFile(index) {
diff --git a/src/js/pages/extract.js b/src/js/pages/extract.js
index 2150e58..fa38d0f 100644
--- a/src/js/pages/extract.js
+++ b/src/js/pages/extract.js
@@ -9,17 +9,37 @@ import { getToday, initWordSchedule } from '../services/ebbinghaus.js';
import { autoResizeTextarea, escapeHtml } from '../ui/dom.js';
import { detectProperNouns, extractEnglishWords, extractWithFrequency, findWordContext } from '../services/extractor.js';
import { getNextId } from './words.js';
-import { batchUploadedTexts } from './extract-batch-files.js';
+import { batchUploadedTexts, renderBatchFileList, resetBatchUploads } from './extract-batch-files.js';
export { batchUploadedTexts, handleBatchFiles, removeBatchFile, renderBatchFileList, updateBatchSourceInfo } from './extract-batch-files.js';
export let batchMode = false;
export let batchResults = [];
let extractGeneration = 0;
+// 每类操作各自的归属令牌:只有某个按钮最近一次操作才能恢复该按钮,
+// 避免旧的异步流程在新流程运行期间把按钮改回可用状态。
+let extractBusyToken = 0;
+let batchBusyToken = 0;
+let addSelectedBusyToken = 0;
+
+// 按钮文案在模板和恢复函数里各出现一次,抽成常量以免改了一处漏了另一处。
+const EXTRACT_BTN_LABEL = ' 提取单词';
+const BATCH_EXTRACT_BTN_LABEL = ' 批量提取';
+const EXTRACTING_BTN_LABEL = ' 提取中...';
function beginExtractGeneration() {
return ++extractGeneration;
}
+function resetExtractButton() {
+ const btn = document.querySelector('[data-action="extract.doExtractFromEmail"]');
+ if (btn?.isConnected) { btn.disabled = false; btn.innerHTML = EXTRACT_BTN_LABEL; }
+}
+
+function resetBatchExtractButton() {
+ const btn = document.getElementById('batch-extract-btn');
+ if (btn?.isConnected) { btn.disabled = false; btn.innerHTML = BATCH_EXTRACT_BTN_LABEL; }
+}
+
function isCurrentExtractGeneration(generation, ...elements) {
return generation === extractGeneration
&& state.currentPage === 'extract'
@@ -40,7 +60,7 @@ export function renderExtract(el) {
-
+
已保存 ${state.mailEmails.length} 封邮件
@@ -60,7 +80,7 @@ export function renderExtract(el) {
粘贴邮件内容
-
+
@@ -95,6 +115,10 @@ export function renderExtract(el) {
const ta = document.getElementById('email-input');
ta.addEventListener('input', () => autoResizeTextarea(ta));
ta.addEventListener('paste', () => setTimeout(() => autoResizeTextarea(ta), 0));
+
+ // 已上传的文件保存在模块状态里,会跨页面切换存活。重建 DOM 后必须重新渲染,
+ // 否则界面看起来没有任何来源,批量提取却仍在使用这些看不见的文件。
+ if (state.batchExtractEnabled) renderBatchFileList();
}
export async function doExtractFromEmail() {
@@ -106,47 +130,51 @@ export async function doExtractFromEmail() {
if (!text) { showToast('请粘贴邮件内容', 'error'); return; }
batchMode = false;
- if (extractBtn) { extractBtn.disabled = true; extractBtn.innerHTML = ' 提取中...'; }
+ const busyToken = ++extractBusyToken;
+ if (extractBtn) { extractBtn.disabled = true; extractBtn.innerHTML = EXTRACTING_BTN_LABEL; }
- const extracted = extractEnglishWords(text);
- if (!extracted.length) {
- if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = ' 提取单词'; }
- showToast('未提取到有效英文单词', 'info');
- return;
- }
+ // 无论正常结束、提前退出还是抛错,按钮都要恢复,否则会一直停在禁用+转圈状态。
+ try {
+ const extracted = extractEnglishWords(text);
+ if (!extracted.length) {
+ showToast('未提取到有效英文单词', 'info');
+ return;
+ }
- const properNouns = detectProperNouns(text);
- let filtered = extracted.filter(w => !COMMON_NAMES.has(w) && !properNouns.has(w));
- let nameCount = extracted.length - filtered.length;
+ const properNouns = detectProperNouns(text);
+ let filtered = extracted.filter(w => !COMMON_NAMES.has(w) && !properNouns.has(w));
+ let nameCount = extracted.length - filtered.length;
- const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
- if (hasAI && filtered.length > 0) {
- showToast('正在使用 AI 识别人名...', 'info');
- const aiNames = await filterNamesWithAI(filtered);
- if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
- if (aiNames.length > 0) {
- const aiNameSet = new Set(aiNames);
- filtered = filtered.filter(w => !aiNameSet.has(w));
- nameCount += aiNames.length;
+ const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
+ if (hasAI && filtered.length > 0) {
+ showToast('正在使用 AI 识别人名...', 'info');
+ const aiNames = await filterNamesWithAI(filtered);
+ if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
+ if (aiNames.length > 0) {
+ const aiNameSet = new Set(aiNames);
+ filtered = filtered.filter(w => !aiNameSet.has(w));
+ nameCount += aiNames.length;
+ }
+ }
+
+ let dedupCount = 0;
+ if (filtered.length > 0) {
+ if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
+ const dedupMapping = hasAI ? await deduplicateWithAI(filtered) : deduplicateBasic(filtered);
+ if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
+ dedupCount = Object.keys(dedupMapping).length;
+ if (dedupCount > 0) filtered = filtered.filter(w => !dedupMapping[w]);
}
- }
- let dedupCount = 0;
- if (filtered.length > 0) {
- if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
- const dedupMapping = hasAI ? await deduplicateWithAI(filtered) : deduplicateBasic(filtered);
if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
- dedupCount = Object.keys(dedupMapping).length;
- if (dedupCount > 0) filtered = filtered.filter(w => !dedupMapping[w]);
+
+ renderWordChips(filtered);
+ const nameMsg = nameCount > 0 ? `,过滤了 ${nameCount} 个人名/专有名词` : '';
+ const dedupMsg = dedupCount > 0 ? `,合并了 ${dedupCount} 个词形变化` : '';
+ showToast(`提取到 ${filtered.length} 个单词${nameMsg}${dedupMsg}`, 'success');
+ } finally {
+ if (busyToken === extractBusyToken) resetExtractButton();
}
-
- if (!isCurrentExtractGeneration(generation, inputEl, chipsContainer)) return;
- if (extractBtn?.isConnected) { extractBtn.disabled = false; extractBtn.innerHTML = ' 提取单词'; }
-
- renderWordChips(filtered);
- const nameMsg = nameCount > 0 ? `,过滤了 ${nameCount} 个人名/专有名词` : '';
- const dedupMsg = dedupCount > 0 ? `,合并了 ${dedupCount} 个词形变化` : '';
- showToast(`提取到 ${filtered.length} 个单词${nameMsg}${dedupMsg}`, 'success');
}
export function renderWordChips(wordList) {
@@ -198,6 +226,7 @@ export async function addSelectedWords() {
const progress = document.getElementById('extract-progress');
progress.style.display = 'block';
+ const busyToken = ++addSelectedBusyToken;
document.getElementById('add-selected-btn').disabled = true;
let success = 0, fail = 0, skipped = 0;
const addedWordIds = [];
@@ -218,83 +247,97 @@ export async function addSelectedWords() {
const handled = new Set();
let nextId = getNextId(); // 循环外取一次基准 id,循环内自增,避免逐词全量重扫
- for (let i = 0; i < selected.length; i += WORD_TRANSLATE_BATCH_SIZE) {
- const batch = selected.slice(i, i + WORD_TRANSLATE_BATCH_SIZE);
- const end = Math.min(i + batch.length, selected.length);
- progress.textContent = `翻译中 ${i + 1}-${end}/${selected.length},一次请求 ${batch.length} 个单词...`;
- try {
- const results = await translateWords(batch.map(item => item.word));
- if (!isCurrentOperation()) { discardAddedWords(); return; }
- // 翻译等待期间用户可能在别处手动加词,刷新基准防止 id 冲突
- nextId = Math.max(nextId, getNextId());
- const resultMap = new Map(results.map(item => [String(item.english || '').trim().toLowerCase(), item]));
+ // 中途放弃(切换词库、页面重渲染等)也必须解禁按钮,否则「翻译并添加选中」会一直不可点。
+ try {
+ for (let i = 0; i < selected.length; i += WORD_TRANSLATE_BATCH_SIZE) {
+ const batch = selected.slice(i, i + WORD_TRANSLATE_BATCH_SIZE);
+ const end = Math.min(i + batch.length, selected.length);
+ progress.textContent = `翻译中 ${i + 1}-${end}/${selected.length},一次请求 ${batch.length} 个单词...`;
+ try {
+ const results = await translateWords(batch.map(item => item.word));
+ if (!isCurrentOperation()) { discardAddedWords(); return; }
+ // 翻译等待期间用户可能在别处手动加词,刷新基准防止 id 冲突
+ nextId = Math.max(nextId, getNextId());
+ const resultMap = new Map(results.map(item => [String(item.english || '').trim().toLowerCase(), item]));
- batch.forEach(item => {
- const key = item.word.trim().toLowerCase();
- if (!key || handled.has(key)) return;
- handled.add(key);
+ batch.forEach(item => {
+ const key = item.word.trim().toLowerCase();
+ if (!key || handled.has(key)) return;
+ handled.add(key);
- if (existing.has(key)) {
- skipped++;
- return;
- }
+ if (existing.has(key)) {
+ skipped++;
+ return;
+ }
- const base = resultMap.get(key);
- if (!base || !String(base.chinese || '').trim()) {
- // 缺少中文释义的脏数据不入库,否则本地出题会因缺字段崩溃
- fail++;
- return;
- }
+ const base = resultMap.get(key);
+ if (!base || !String(base.chinese || '').trim()) {
+ // 缺少中文释义的脏数据不入库,否则本地出题会因缺字段崩溃
+ fail++;
+ return;
+ }
- const contextSentence = findWordContext(item.word, emailText);
- const frequency = item.freq > 0 ? Math.max(base.frequency || 0, item.freq) : base.frequency;
- state.words.push({
- id: nextId++,
- ...base,
- frequency,
- sourceContext: contextSentence ? { emailTitle, sentence: contextSentence } : null
+ const contextSentence = findWordContext(item.word, emailText);
+ const frequency = item.freq > 0 ? Math.max(base.frequency || 0, item.freq) : base.frequency;
+ state.words.push({
+ id: nextId++,
+ ...base,
+ frequency,
+ sourceContext: contextSentence ? { emailTitle, sentence: contextSentence } : null
+ });
+ const addedWordId = state.words[state.words.length - 1].id;
+ initWordSchedule(addedWordId, false);
+ addedWordIds.push(addedWordId);
+ existing.add(key);
+ success++;
});
- const addedWordId = state.words[state.words.length - 1].id;
- initWordSchedule(addedWordId, false);
- addedWordIds.push(addedWordId);
- existing.add(key);
- success++;
- });
- } catch (err) {
- if (!isCurrentOperation()) { discardAddedWords(); return; }
- batch.forEach(item => {
- const key = item.word.trim().toLowerCase();
- if (!key || handled.has(key)) return;
- handled.add(key);
- if (existing.has(key)) { skipped++; return; }
- fail++;
- });
+ } catch (err) {
+ if (!isCurrentOperation()) { discardAddedWords(); return; }
+ batch.forEach(item => {
+ const key = item.word.trim().toLowerCase();
+ if (!key || handled.has(key)) return;
+ handled.add(key);
+ if (existing.has(key)) { skipped++; return; }
+ fail++;
+ });
+ }
}
- }
- if (success > 0 && !(saveWords() && saveSchedule())) {
- discardAddedWords();
- fail += success;
- success = 0;
- }
+ if (success > 0 && !(saveWords() && saveSchedule())) {
+ discardAddedWords();
+ fail += success;
+ success = 0;
+ }
- document.getElementById('add-selected-btn').disabled = false;
- const skippedText = skipped > 0 ? `,跳过 ${skipped} 个(已在词库)` : '';
- progress.textContent = `完成!成功 ${success} 个,失败 ${fail} 个${skippedText}`;
- showToast(`批量添加完成:${success} 成功,${fail} 失败${skippedText}`, success > 0 ? 'success' : 'error');
+ const skippedText = skipped > 0 ? `,跳过 ${skipped} 个(已在词库)` : '';
+ progress.textContent = `完成!成功 ${success} 个,失败 ${fail} 个${skippedText}`;
+ showToast(`批量添加完成:${success} 成功,${fail} 失败${skippedText}`, success > 0 ? 'success' : 'error');
- if (success > 0) {
- if (isBatch) {
- doBatchExtract();
- } else {
- const proper = detectProperNouns(emailText);
- const refreshed = extractEnglishWords(emailText).filter(w => !COMMON_NAMES.has(w) && !proper.has(w));
- renderWordChips(refreshed);
+ if (success > 0) {
+ if (isBatch) {
+ doBatchExtract();
+ } else {
+ const proper = detectProperNouns(emailText);
+ const refreshed = extractEnglishWords(emailText).filter(w => !COMMON_NAMES.has(w) && !proper.has(w));
+ renderWordChips(refreshed);
+ }
+ }
+ } finally {
+ if (busyToken === addSelectedBusyToken) {
+ const addBtn = document.getElementById('add-selected-btn');
+ if (addBtn?.isConnected) addBtn.disabled = false;
}
}
}
export function clearEmailExtract() {
+ // 上传的文件同样属于「本次提词的输入」,否则清空后批量提取仍会用到它们;
+ // 但按钮标签只说「清空」,丢弃文件又不可撤销,交给用户单独确认。
+ // 确认结果只决定是否连带移除文件:拒绝也要照常清空输入框,否则点了「清空」什么都没发生。
+ const uploadedCount = batchUploadedTexts.length;
+ const alsoResetUploads = uploadedCount === 0
+ || confirm(`同时移除已上传的 ${uploadedCount} 个文件?`);
+
beginExtractGeneration();
batchMode = false;
batchResults = [];
@@ -307,6 +350,7 @@ export function clearEmailExtract() {
document.getElementById('extract-progress').style.display = 'none';
const statusEl = document.getElementById('batch-result');
if (statusEl) statusEl.innerHTML = '';
+ if (alsoResetUploads) resetBatchUploads();
}
export function saveEmailForLearn() {
@@ -332,66 +376,68 @@ export async function doBatchExtract() {
const batchBtn = document.getElementById('batch-extract-btn');
const chipsContainer = document.getElementById('word-chips');
- if (batchBtn) { batchBtn.disabled = true; batchBtn.innerHTML = ' 提取中...'; }
+ const busyToken = ++batchBusyToken;
+ if (batchBtn) { batchBtn.disabled = true; batchBtn.innerHTML = EXTRACTING_BTN_LABEL; }
- let results = extractWithFrequency(allEmails);
- if (results.length === 0) {
- if (batchBtn) { batchBtn.disabled = false; batchBtn.innerHTML = ' 批量提取'; }
- showToast('未提取到有效单词', 'info');
- return;
- }
-
- const words = results.map(r => r.word);
- const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
- let dedupCount = 0;
- if (words.length > 0) {
- if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
- const dedupMapping = hasAI ? await deduplicateWithAI(words) : deduplicateBasic(words);
- if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
- dedupCount = Object.keys(dedupMapping).length;
- if (dedupCount > 0) {
- const resultMap = {};
- for (const r of results) resultMap[r.word] = r;
- for (const [variant, base] of Object.entries(dedupMapping)) {
- if (resultMap[variant] && resultMap[base]) {
- resultMap[base].emailCount = Math.max(resultMap[base].emailCount, resultMap[variant].emailCount);
- resultMap[base].totalCount += resultMap[variant].totalCount;
- }
- }
- results = results.filter(r => !dedupMapping[r.word]);
+ // 与单封提词同理:按钮状态放在 finally 里恢复,任何提前退出都不会留下卡住的转圈按钮。
+ try {
+ let results = extractWithFrequency(allEmails);
+ if (results.length === 0) {
+ showToast('未提取到有效单词', 'info');
+ return;
}
- }
- if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
- if (batchBtn?.isConnected) { batchBtn.disabled = false; batchBtn.innerHTML = ' 批量提取'; }
+ const words = results.map(r => r.word);
+ const hasAI = state.settings.apiKey && state.settings.apiUrl && state.settings.model;
+ let dedupCount = 0;
+ if (words.length > 0) {
+ if (hasAI) showToast('正在使用 AI 去重词形变化...', 'info');
+ const dedupMapping = hasAI ? await deduplicateWithAI(words) : deduplicateBasic(words);
+ if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
+ dedupCount = Object.keys(dedupMapping).length;
+ if (dedupCount > 0) {
+ const resultMap = {};
+ for (const r of results) resultMap[r.word] = r;
+ for (const [variant, base] of Object.entries(dedupMapping)) {
+ if (resultMap[variant] && resultMap[base]) {
+ resultMap[base].emailCount = Math.max(resultMap[base].emailCount, resultMap[variant].emailCount);
+ resultMap[base].totalCount += resultMap[variant].totalCount;
+ }
+ }
+ results = results.filter(r => !dedupMapping[r.word]);
+ }
+ }
- batchResults = results;
- batchMode = true;
+ if (!isCurrentExtractGeneration(generation, chipsContainer)) return;
- const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
- const infoEl = document.getElementById('extract-info');
- const actionsEl = document.getElementById('extract-actions');
+ batchResults = results;
+ batchMode = true;
- if (!chipsContainer) return;
+ const existingWords = new Set(state.words.map(w => w.english.toLowerCase()));
+ const infoEl = document.getElementById('extract-info');
+ const actionsEl = document.getElementById('extract-actions');
- chipsContainer.innerHTML = results.map(r => {
- const isExist = existingWords.has(r.word);
- return ` {
+ const isExist = existingWords.has(r.word);
+ return `${escapeHtml(r.word)} ${r.emailCount}${isExist ? ' \u2713' : ''}`;
- }).join('');
+ }).join('');
- const selectable = results.filter(r => !existingWords.has(r.word)).length;
- const existing = results.length - selectable;
- const dedupMsg = dedupCount > 0 ? `,合并 ${dedupCount} 个词形变化` : '';
- if (infoEl) infoEl.textContent = `从 ${allEmails.length} 个来源中提取到 ${results.length} 个单词,${existing} 个已存在,${selectable} 个可添加${dedupMsg}(频次排序)`;
- if (actionsEl) actionsEl.style.display = selectable > 0 ? 'flex' : 'none';
+ const selectable = results.filter(r => !existingWords.has(r.word)).length;
+ const existing = results.length - selectable;
+ const dedupMsg = dedupCount > 0 ? `,合并 ${dedupCount} 个词形变化` : '';
+ if (infoEl) infoEl.textContent = `从 ${allEmails.length} 个来源中提取到 ${results.length} 个单词,${existing} 个已存在,${selectable} 个可添加${dedupMsg}(频次排序)`;
+ if (actionsEl) actionsEl.style.display = selectable > 0 ? 'flex' : 'none';
- const statusEl = document.getElementById('batch-result');
- if (statusEl) statusEl.innerHTML = ` 已提取到右侧面板`;
+ const statusEl = document.getElementById('batch-result');
+ if (statusEl) statusEl.innerHTML = ` 已提取到右侧面板`;
- updateSelectedCount();
- showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success');
+ updateSelectedCount();
+ showToast(`批量提取完成:${results.length} 个单词${dedupMsg}`, 'success');
+ } finally {
+ if (busyToken === batchBusyToken) resetBatchExtractButton();
+ }
}
export function toggleBatchExtract(enabled) {
diff --git a/src/js/pages/learn.js b/src/js/pages/learn.js
index a56b5c2..6f4ee02 100644
--- a/src/js/pages/learn.js
+++ b/src/js/pages/learn.js
@@ -189,14 +189,26 @@ export function renderLearnSession(el) {
}
export function jumpToReviewCard(idx) {
- if (!state.learnSession) return;
- state.learnSession.index = idx;
- state.learnSession.flipped = false;
- rerenderLearnSession();
const s = state.learnSession;
- if (s.cards[idx]) {
- speak(s.cards[idx].english);
+ if (!s || !Array.isArray(s.cards)) return;
+ // 越界的下标会让 index 直接跨过卡片总数,从而误触发「复习完成」。
+ if (!Number.isInteger(idx) || idx < 0 || idx >= s.cards.length) return;
+ s.index = idx;
+ s.flipped = false;
+ rerenderLearnSession();
+ speak(s.cards[idx].english);
+}
+
+// 复习完成的判断依据是「所有卡片都已评分」,而不是下标越过末尾:用户可以在侧栏
+// 自由跳转,若只看下标,跳到最后一张评分后就会带着一堆未评分的卡片直接结束。
+function nextUnratedIndex(s, from) {
+ for (let i = from; i < s.cards.length; i++) {
+ if (!s.rated[i]) return i;
}
+ for (let i = 0; i < from; i++) {
+ if (!s.rated[i]) return i;
+ }
+ return s.cards.length;
}
export function toggleLearnFavorite(wordId) {
@@ -238,7 +250,7 @@ export function rateLearnCard(rating) {
s.results[rating]++;
s.rated[s.index] = rating;
- s.index++;
+ s.index = nextUnratedIndex(s, s.index + 1);
s.flipped = false;
rerenderLearnSession();
if (s.index < s.cards.length) {
@@ -299,7 +311,9 @@ export function prevLearnCard() {
export function nextLearnCardDirect() {
const s = state.learnSession;
if (!s || !s.rated?.[s.index]) return;
- s.index++;
+ const next = s.index + 1;
+ // 仍有未评分卡片时不允许越过末尾,否则手动翻到最后一张就会提前进入完成页。
+ s.index = next >= s.cards.length ? nextUnratedIndex(s, 0) : next;
s.flipped = false;
rerenderLearnSession();
const w = s.cards[s.index];
diff --git a/src/js/pages/quiz-session.js b/src/js/pages/quiz-session.js
index 88fddd1..de1d9ab 100644
--- a/src/js/pages/quiz-session.js
+++ b/src/js/pages/quiz-session.js
@@ -150,6 +150,7 @@ export function answerQuiz(optionIndex) {
export function nextQuizQuestion() {
const s = state.quizSession;
+ if (!s) return;
s.index++;
if (s.index >= s.questions.length) {
s.finished = true;
diff --git a/src/js/pages/reader.js b/src/js/pages/reader.js
index 7f8d662..32fba5e 100644
--- a/src/js/pages/reader.js
+++ b/src/js/pages/reader.js
@@ -332,6 +332,9 @@ export async function addWordFromReader(word, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.textContent = '添加中...'; }
try {
const result = await translateWord(word);
+ // 兜底:缺中文释义的单词不入库,否则单词页搜索会在 w.chinese.includes() 上抛错。
+ // translateWords 已在上游过滤,这里只防它日后放宽校验。
+ if (!String(result?.chinese || '').trim()) throw new Error('AI 未返回中文释义');
if (!isCurrentOperation()) return false;
if (wordExists()) {
showToast(`「${word}」已在词库中`, 'info');
diff --git a/src/js/pages/settings-data.js b/src/js/pages/settings-data.js
index 7ad2797..95ed0e9 100644
--- a/src/js/pages/settings-data.js
+++ b/src/js/pages/settings-data.js
@@ -79,6 +79,8 @@ export function handleImportBackup(input) {
if (importButton) importButton.disabled = true;
const file = input.files[0];
+ // 立即清空输入框的值,否则导入失败后再次选择同一个备份文件不会触发 change 事件。
+ input.value = '';
if (!file) return;
backupReadPending = true;
diff --git a/src/js/pages/settings.js b/src/js/pages/settings.js
index e086548..b589d9f 100644
--- a/src/js/pages/settings.js
+++ b/src/js/pages/settings.js
@@ -304,11 +304,29 @@ export function setAutoPlayScrollMode(mode) {
return true;
}
+// 0 表示留空(沿用内置默认值);其余取值夹到输入框声明的范围内,
+// 因为 min/max 属性本身不阻止用户手动键入越界数字,存进去会得到快到无法听清的播放节奏。
+function clampAutoPlayMs(rawValue, min, max) {
+ const parsed = parseInt(rawValue, 10);
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
+ return Math.min(max, Math.max(min, parsed));
+}
+
+// 夹取结果写回输入框:否则表单继续显示被拒绝的数字,用户会以为存进去的是自己填的值。
+// 输入框缺失时沿用当前值,不能当成留空 —— 否则会把已保存的设置静默重置为默认。
+function readClampedAutoPlayMs(id, min, max, currentValue) {
+ const input = document.getElementById(id);
+ if (!input) return currentValue;
+ const value = clampAutoPlayMs(input.value, min, max);
+ input.value = value > 0 ? String(value) : '';
+ return value;
+}
+
export function saveAutoPlaySettings() {
- const intervalVal = parseInt(document.getElementById('set-word-interval').value) || 0;
- const repeatDelayVal = parseInt(document.getElementById('set-repeat-delay').value) || 0;
const previousInterval = state.autoPlayInterval;
const previousRepeatDelay = state.autoPlayRepeatDelay;
+ const intervalVal = readClampedAutoPlayMs('set-word-interval', 500, 10000, previousInterval);
+ const repeatDelayVal = readClampedAutoPlayMs('set-repeat-delay', 300, 5000, previousRepeatDelay);
state.autoPlayInterval = intervalVal;
state.autoPlayRepeatDelay = repeatDelayVal;
const saved = saveJsonSetting(STORAGE_KEYS.autoPlayInterval, intervalVal)
diff --git a/src/js/pages/stats.js b/src/js/pages/stats.js
index 194ee86..da71dd3 100644
--- a/src/js/pages/stats.js
+++ b/src/js/pages/stats.js
@@ -4,6 +4,12 @@ import { getErrorTopWords, getLast30DaysData, getStats, getStreak } from '../ser
import { escapeHtml } from '../ui/dom.js';
let studyChart = null;
+
+function rateOf(records) {
+ if (records.length === 0) return 0;
+ return Math.round(records.filter(r => r.isCorrect).length / records.length * 100);
+}
+
// ==================== Page: Stats ====================
export function renderStats(el) {
destroyStudyChart();
@@ -25,14 +31,16 @@ export function renderStats(el) {
const s = getStats();
const streak = getStreak();
- const todayRecords = state.records.filter(r => r.date === getToday());
- const todayCorrect = todayRecords.filter(r => r.isCorrect).length;
- const todayTotal = todayRecords.length;
- const todayRate = todayTotal > 0 ? Math.round(todayCorrect / todayTotal * 100) : 0;
+ const today = getToday();
+ // 练习次数统计所有记录(复习也算练习量)。
+ const todayTotal = state.records.filter(r => r.date === today).length;
- const allCorrect = state.records.filter(r => r.isCorrect).length;
- const allTotal = state.records.length;
- const allRate = allTotal > 0 ? Math.round(allCorrect / allTotal * 100) : 0;
+ // 正确率只按测验记录计算,与「最容易出错的单词」口径保持一致:
+ // 复习自评(认识/模糊/不认识)不是答题对错,混进来会让百分比失真。
+ const quizRecords = state.records.filter(r => r.type === 'quiz');
+ const todayQuizRecords = quizRecords.filter(r => r.date === today);
+ const todayRate = rateOf(todayQuizRecords);
+ const allRate = rateOf(quizRecords);
const dailyData = getLast30DaysData();
const errorTop = getErrorTopWords(10);
diff --git a/src/js/pages/words-autoplay.js b/src/js/pages/words-autoplay.js
index 6ce0c24..95bffc1 100644
--- a/src/js/pages/words-autoplay.js
+++ b/src/js/pages/words-autoplay.js
@@ -4,12 +4,17 @@ import { logTts, prefetchAudio, speak, stopAllAudio } from '../services/tts.js';
let autoPlayActive = false;
let autoPlayTimer = null;
+export function isAutoPlayActive() {
+ return autoPlayActive;
+}
+
export function toggleAutoPlay() {
if (autoPlayActive) stopAutoPlay();
else startAutoPlay();
}
export function startAutoPlay() {
+ if (autoPlayActive) return;
const rows = document.querySelectorAll('.word-table tbody tr[data-word-id]');
const wordIds = Array.from(rows).map(row => Number.parseInt(row.dataset.wordId, 10));
if (wordIds.length === 0) return;
@@ -62,6 +67,7 @@ export function playWordSequence(wordIds, index) {
}
}
+ // 0 表示「未设置」,回落到默认值:设置页把留空的输入框保存为 0,并明示「留空使用默认」。
const repeatCount = Math.max(1, state.autoPlayRepeat || 1);
const repeatDelay = state.autoPlayRepeatDelay || 1000;
const wordInterval = state.autoPlayInterval || 2500;
diff --git a/src/js/pages/words.js b/src/js/pages/words.js
index 8a82a8b..ccca5d6 100644
--- a/src/js/pages/words.js
+++ b/src/js/pages/words.js
@@ -9,6 +9,7 @@ import { speak } from '../services/tts.js';
import { getToday, initWordSchedule } from '../services/ebbinghaus.js';
import { getMastery, getQuizErrorWords } from '../services/stats.js';
import { debounce, escapeHtml, formatFormsHtml } from '../ui/dom.js';
+import { isAutoPlayActive, stopAutoPlay } from './words-autoplay.js';
export { playWordSequence, prefetchWordListAudio, startAutoPlay, stopAutoPlay, toggleAutoPlay, updateAutoPlayBtn } from './words-autoplay.js';
@@ -84,6 +85,12 @@ export function renderWordList() {
const container = document.getElementById('word-list-container');
if (!container) return;
+ // 自动播放按渲染时的行序列推进。列表内容变化(搜索、筛选、翻页、删除)后原序列
+ // 已经对不上界面,继续播下去会朗读屏幕上不存在的单词,因此先停掉。
+ // 必须先判断是否在播:stopAutoPlay() 会连带 stopAllAudio(),无条件调用会掐断
+ // 用户手动点「朗读」触发的单次发音。
+ if (isAutoPlayActive()) stopAutoPlay();
+
let filtered = state.words;
if (state.wordSearch) {
const q = state.wordSearch.toLowerCase();
diff --git a/src/js/services/ai.js b/src/js/services/ai.js
index 4eb36b9..746415b 100644
--- a/src/js/services/ai.js
+++ b/src/js/services/ai.js
@@ -164,16 +164,23 @@ export async function translateWords(words) {
]);
const parsed = extractJsonValue(content, null);
- const list = Array.isArray(parsed) ? parsed : (isPlainObject(parsed) && Array.isArray(parsed.words) ? parsed.words : []);
+ // 单词查询常只返回一个裸对象(而非单元素数组),按单元素列表处理,
+ // 否则会被当成格式错误而丢弃一条本可用的释义。
+ const list = Array.isArray(parsed)
+ ? parsed
+ : (isPlainObject(parsed) ? (Array.isArray(parsed.words) ? parsed.words : [parsed]) : []);
+ // 缺中文释义的条目不算有效返回:入库后单词页搜索会在 w.chinese.includes() 上抛错,
+ // 而这份脏数据已经落盘,清缓存也救不回来。
+ const isUsableEntry = item => isPlainObject(item) && !!String(item.chinese || '').trim();
const byWord = new Map();
list.forEach(item => {
- if (!isPlainObject(item)) return;
+ if (!isUsableEntry(item)) return;
const english = String(item.english || '').trim().toLowerCase();
if (english) byWord.set(english, { ...item, english });
});
const results = uniqueWords.map(word => byWord.get(word)).filter(Boolean);
- if (!results.length && uniqueWords.length === 1 && list.length === 1 && isPlainObject(list[0])) {
+ if (!results.length && uniqueWords.length === 1 && list.length === 1 && isUsableEntry(list[0])) {
results.push({ ...list[0], english: String(list[0].english || uniqueWords[0]).trim() });
}
if (!results.length) throw new Error('AI 返回的单词数据格式无效');
diff --git a/src/js/services/quiz-generator.js b/src/js/services/quiz-generator.js
index 164335e..94379e4 100644
--- a/src/js/services/quiz-generator.js
+++ b/src/js/services/quiz-generator.js
@@ -84,15 +84,16 @@ export function extractJsonValue(content, fallback = null) {
try {
return JSON.parse(cleaned);
} catch (_) {
- const firstObject = cleaned.indexOf('{');
- const lastObject = cleaned.lastIndexOf('}');
- const firstArray = cleaned.indexOf('[');
- const lastArray = cleaned.lastIndexOf(']');
- const candidates = [];
- if (firstObject >= 0 && lastObject > firstObject) candidates.push(cleaned.slice(firstObject, lastObject + 1));
- if (firstArray >= 0 && lastArray > firstArray) candidates.push(cleaned.slice(firstArray, lastArray + 1));
- for (const candidate of candidates) {
- try { return JSON.parse(candidate); } catch (_) { /* try next */ }
+ // 模型常在 JSON 前后附带说明文字。最外层结构以先出现的括号为准,
+ // 否则「这是结果:[{...}]」会被截成内层的第一个对象,整个数组元素全部丢失。
+ const candidates = [
+ { start: cleaned.indexOf('{'), end: cleaned.lastIndexOf('}') },
+ { start: cleaned.indexOf('['), end: cleaned.lastIndexOf(']') }
+ ]
+ .filter(({ start, end }) => start >= 0 && end > start)
+ .sort((a, b) => a.start - b.start);
+ for (const { start, end } of candidates) {
+ try { return JSON.parse(cleaned.slice(start, end + 1)); } catch (_) { /* try next */ }
}
return fallback;
}
diff --git a/src/js/services/stats.js b/src/js/services/stats.js
index 7453887..74272d4 100644
--- a/src/js/services/stats.js
+++ b/src/js/services/stats.js
@@ -93,7 +93,9 @@ export function getLast30DaysData() {
export function getErrorTopWords(n) {
const wordStats = {};
- state.records.forEach(r => {
+ // 与 getQuizErrorWords 保持一致,只统计测验记录:复习时的自评(认识/模糊/不认识)
+ // 与答题正确率不是一回事,混在一起会让「最易错单词」的正确率和排名失真。
+ state.records.filter(r => r.type === 'quiz').forEach(r => {
if (!wordStats[r.wordId]) wordStats[r.wordId] = { correct: 0, total: 0 };
wordStats[r.wordId].total++;
if (r.isCorrect) wordStats[r.wordId].correct++;
diff --git a/src/js/services/tts.js b/src/js/services/tts.js
index 9ba5008..0969143 100644
--- a/src/js/services/tts.js
+++ b/src/js/services/tts.js
@@ -272,22 +272,28 @@ async function speakCloudTts(text, rate = 1, requestSeq = ttsRequestSeq) {
}
};
try {
- await new Promise((resolve, reject) => {
+ // 返回值表示「云端 TTS 是否已接管本次播放」,而非是否播完:只要声音已经开始,
+ // 被暂停(新的朗读请求、stopAllAudio、系统层面打断)也算接管,否则调用方会
+ // 继续降级到有道并把同一个词重播一遍。本次请求是否已被取代由调用方比对
+ // requestSeq 判断。
+ return await new Promise((resolve, reject) => {
let settled = false;
+ let playbackStarted = false;
const settle = (fn, value) => {
if (settled) return;
settled = true;
fn(value);
};
+ audio.addEventListener('playing', () => { playbackStarted = true; }, { once: true });
audio.addEventListener('ended', () => settle(resolve, true), { once: true });
- audio.addEventListener('pause', () => settle(resolve, false), { once: true });
+ // 个别浏览器不派发 playing,用播放位置兜底判断声音是否已经出来过。
+ audio.addEventListener('pause', () => settle(resolve, playbackStarted || audio.currentTime > 0), { once: true });
audio.addEventListener('error', () => settle(reject, new Error('Azure TTS 音频播放失败')), { once: true });
const playPromise = audio.play();
if (playPromise) playPromise.catch(err => settle(reject, err));
});
- return true;
} finally {
cleanupAudio();
}
diff --git a/src/sw.js b/src/sw.js
index 9874a9a..71c5f04 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -83,7 +83,17 @@ async function handleSameOrigin(event, request, url) {
request,
url.searchParams.has('v') ? undefined : { cache: 'no-cache' }
);
- if (!response.ok) return response;
+ if (!response.ok) {
+ // 只在两种情况下回退到缓存副本:瞬时故障(服务重启、网关抖动、限流)以及导航
+ // 请求(拿不到 HTML 入口等于白屏,而 HTML 每次都网络优先重取,不存在卡版本)。
+ // 其余永久性错误(404/410:资源已删除或改名)原样返回,但保留缓存条目:
+ // 这条分支在查缓存之前就返回了,旧副本不可能顶掉新版本,而留着它能让后续
+ // 离线访问(catch 分支)仍有兜底 —— 部署间隙的一次 404 不该毁掉离线副本。
+ const isTransient = response.status >= 500 || response.status === 408 || response.status === 429;
+ if (!isTransient && request.mode !== 'navigate') return response;
+ const cached = await matchSameOriginCache(request);
+ return cached || response;
+ }
// 只缓存成功的基础响应(避免缓存重定向/错误页/opaque 响应)。
if (response.type === 'basic') {
diff --git a/src/test/ai.test.js b/src/test/ai.test.js
new file mode 100644
index 0000000..44cd66e
--- /dev/null
+++ b/src/test/ai.test.js
@@ -0,0 +1,101 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { state } from '../js/core/state.js';
+import { translateWords } from '../js/services/ai.js';
+
+function stubAiReply(content) {
+ const calls = [];
+ globalThis.fetch = async (url, options) => {
+ calls.push({ url, options });
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ choices: [{ message: { content } }] })
+ };
+ };
+ return calls;
+}
+
+function withAiSettings() {
+ state.settings = { apiKey: 'k', apiUrl: 'https://example.test/v1', model: 'test-model' };
+}
+
+test('translateWords accepts a plain JSON array', async () => {
+ withAiSettings();
+ stubAiReply('[{"english":"hello","chinese":"你好"}]');
+ const results = await translateWords(['hello']);
+ assert.equal(results.length, 1);
+ assert.equal(results[0].english, 'hello');
+ assert.equal(results[0].chinese, '你好');
+});
+
+test('translateWords accepts an array wrapped in explanatory prose', async () => {
+ withAiSettings();
+ stubAiReply('这是结果:[{"english":"hello","chinese":"你好"},{"english":"world","chinese":"世界"}]');
+ const results = await translateWords(['hello', 'world']);
+ assert.deepEqual(results.map(item => item.english), ['hello', 'world']);
+ assert.deepEqual(results.map(item => item.chinese), ['你好', '世界']);
+});
+
+test('translateWords accepts a single bare object instead of a one-element array', async () => {
+ withAiSettings();
+ stubAiReply('{"english":"hello","chinese":"你好","phonetic":"/həˈloʊ/"}');
+ const results = await translateWords(['hello']);
+ assert.equal(results.length, 1);
+ assert.equal(results[0].english, 'hello');
+ assert.equal(results[0].chinese, '你好');
+});
+
+test('translateWords accepts a bare object missing the english field for a single word', async () => {
+ withAiSettings();
+ stubAiReply('{"chinese":"你好"}');
+ const results = await translateWords(['hello']);
+ assert.equal(results.length, 1);
+ assert.equal(results[0].english, 'hello');
+ assert.equal(results[0].chinese, '你好');
+});
+
+test('translateWords rejects a bare object without a Chinese meaning', async () => {
+ withAiSettings();
+ stubAiReply('{"error":"quota exceeded"}');
+ await assert.rejects(() => translateWords(['hello']), /单词数据格式无效/);
+});
+
+test('translateWords drops list entries that carry no Chinese meaning', async () => {
+ withAiSettings();
+ stubAiReply('[{"english":"hello","chinese":"你好"},{"english":"world"}]');
+ const results = await translateWords(['hello', 'world']);
+ assert.deepEqual(results.map(item => item.english), ['hello']);
+});
+
+test('translateWords accepts a words-keyed wrapper object', async () => {
+ withAiSettings();
+ stubAiReply('{"words":[{"english":"hello","chinese":"你好"}]}');
+ const results = await translateWords(['hello']);
+ assert.equal(results.length, 1);
+ assert.equal(results[0].chinese, '你好');
+});
+
+test('translateWords deduplicates and lowercases requested words', async () => {
+ withAiSettings();
+ const calls = stubAiReply('[{"english":"hello","chinese":"你好"}]');
+ const results = await translateWords(['Hello', 'HELLO', ' hello ']);
+ assert.equal(results.length, 1);
+ const body = JSON.parse(calls[0].options.body);
+ assert.equal(body.messages.at(-1).content.includes('"hello"'), true);
+});
+
+test('translateWords rejects unusable AI payloads', async () => {
+ withAiSettings();
+ stubAiReply('完全不是 JSON');
+ await assert.rejects(() => translateWords(['hello']), /单词数据格式无效/);
+});
+
+test('translateWords returns an empty list without calling the API for blank input', async () => {
+ withAiSettings();
+ let called = false;
+ globalThis.fetch = async () => { called = true; throw new Error('should not be called'); };
+ assert.deepEqual(await translateWords(['', ' ']), []);
+ assert.equal(called, false);
+});
diff --git a/src/test/quiz-generator.test.js b/src/test/quiz-generator.test.js
index ccfc0bd..a7a128d 100644
--- a/src/test/quiz-generator.test.js
+++ b/src/test/quiz-generator.test.js
@@ -68,6 +68,23 @@ test('extractJsonValue handles fences, surrounding prose, and fallback values',
assert.equal(extractJsonValue('not json', 'fallback'), 'fallback');
});
+test('extractJsonValue keeps the outermost structure when prose wraps an array of objects', () => {
+ // 取先出现的括号,否则数组会被截成里面的第一个对象,其余元素全部丢失。
+ assert.deepEqual(
+ extractJsonValue('Here you go: [{"english":"hello"},{"english":"world"}]'),
+ [{ english: 'hello' }, { english: 'world' }]
+ );
+ assert.deepEqual(
+ extractJsonValue('```json\n好的:[{"english":"hi"}]\n```'),
+ [{ english: 'hi' }]
+ );
+ // 对象在前时仍应取对象包装体,而不是里面嵌套的数组。
+ assert.deepEqual(
+ extractJsonValue('Sure! {"questions":[{"question":"q"}]}'),
+ { questions: [{ question: 'q' }] }
+ );
+});
+
test('isPlainObject accepts ordinary and null-prototype objects only', () => {
assert.equal(isPlainObject({}), true);
assert.equal(isPlainObject(Object.create(null)), true);
diff --git a/src/test/stats.test.js b/src/test/stats.test.js
new file mode 100644
index 0000000..3265c5d
--- /dev/null
+++ b/src/test/stats.test.js
@@ -0,0 +1,58 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { state } from '../js/core/state.js';
+import { getErrorTopWords, invalidateErrorWordsCache } from '../js/services/stats.js';
+
+const WORDS = [
+ { id: 1, english: 'alpha', chinese: '甲' },
+ { id: 2, english: 'beta', chinese: '乙' }
+];
+
+function setRecords(records) {
+ state.words = WORDS;
+ state.records = records;
+ invalidateErrorWordsCache();
+}
+
+test('getErrorTopWords counts only quiz records', () => {
+ setRecords([
+ { wordId: 1, type: 'quiz', isCorrect: false },
+ { wordId: 1, type: 'quiz', isCorrect: true },
+ // 复习自评不应计入答题正确率,否则会把 alpha 的正确率抬到 75%
+ { wordId: 1, type: 'review', isCorrect: true },
+ { wordId: 1, type: 'review', isCorrect: true }
+ ]);
+
+ const [alpha] = getErrorTopWords(10);
+ assert.equal(alpha.id, 1);
+ assert.equal(alpha.total, 2);
+ assert.equal(alpha.rate, 50);
+});
+
+test('getErrorTopWords omits words with only review records', () => {
+ setRecords([
+ { wordId: 1, type: 'quiz', isCorrect: false },
+ { wordId: 2, type: 'review', isCorrect: false }
+ ]);
+
+ assert.deepEqual(getErrorTopWords(10).map(word => word.id), [1]);
+});
+
+test('getErrorTopWords sorts by ascending accuracy and respects the limit', () => {
+ setRecords([
+ { wordId: 1, type: 'quiz', isCorrect: true },
+ { wordId: 1, type: 'quiz', isCorrect: true },
+ { wordId: 1, type: 'quiz', isCorrect: false },
+ { wordId: 2, type: 'quiz', isCorrect: false },
+ { wordId: 2, type: 'quiz', isCorrect: false }
+ ]);
+
+ assert.deepEqual(getErrorTopWords(10).map(word => word.id), [2, 1]);
+ assert.equal(getErrorTopWords(1).length, 1);
+});
+
+test('getErrorTopWords skips records whose word no longer exists', () => {
+ setRecords([{ wordId: 999, type: 'quiz', isCorrect: false }]);
+ assert.deepEqual(getErrorTopWords(10), []);
+});