test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-05 00:07:44 -07:00
1 parent 4b6842d034
commit b48083ddd8
368 files changed
+18045 -60331

No files matched your search

@@ -1,52 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import { AnilistSelector } from './AnilistSelector';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousISReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', {
value: previousWindow,
configurable: true,
});
Object.defineProperty(globalThis, 'document', {
value: previousDocument,
configurable: true,
});
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
};
}
import { installDom } from '../../test-utils/dom';
function renderSelector(root: Root, props: { animeId: number; initialQuery: string }): void {
root.render(
@@ -1,29 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeCard } from './AnimeCard';
test('AnimeCard includes linked AniList id in cover URLs to avoid stale library covers', () => {
const markup = renderToStaticMarkup(
<AnimeCard
anime={{
mediaKind: 'anime',
animeId: 42,
canonicalTitle: 'Test Anime',
anilistId: 21699,
tmdbId: null,
tmdbType: null,
totalSessions: 1,
totalActiveMs: 600_000,
totalCards: 0,
totalTokensSeen: 100,
episodeCount: 1,
episodesTotal: 10,
lastWatchedMs: 1_000,
}}
onClick={() => {}}
/>,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=21699/);
});
@@ -1,17 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeCoverImage } from './AnimeCoverImage';
import { AnimeHeader } from './AnimeHeader';
test('AnimeCoverImage includes manual relink cover retry tokens', () => {
const markup = renderToStaticMarkup(
<AnimeCoverImage animeId={42} title="Test Anime" coverRetryToken={7} />,
);
assert.match(markup, /\/api\/stats\/anime\/42\/cover\?coverRetry=7/);
});
test('AnimeHeader uses the linked AniList id to avoid stale cached cover art', () => {
const markup = renderToStaticMarkup(
<AnimeHeader
@@ -1,48 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeLibraryItem } from '../../types/stats';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { LibraryEntryPicker } from './LibraryEntryPicker';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousIsReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
};
}
import { installDom } from '../../test-utils/dom';
function libraryItem(animeId: number, title: string): AnimeLibraryItem {
return {
@@ -1,48 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeLibraryItem, StatsMergeAnimeResponse } from '../../types/stats';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { AnimeTab } from './AnimeTab';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousIsReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
};
}
import { installDom } from '../../test-utils/dom';
function libraryItem(animeId: number, title: string, episodeCount: number): AnimeLibraryItem {
return {
+1 -37
View File
@@ -1,47 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeDetailData, AnimeLibraryItem } from '../../types/stats';
import { AnimeTab } from './AnimeTab';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousIsReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
};
}
import { installDom } from '../../test-utils/dom';
function libraryItem(anilistId: number | null): AnimeLibraryItem {
return {
@@ -1,47 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeEpisode, AnimeLibraryItem, StatsMoveVideoResponse } from '../../types/stats';
import { EpisodeList } from './EpisodeList';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousIsReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
};
}
import { installDom } from '../../test-utils/dom';
function episode(videoId: number, title: string): AnimeEpisode {
return {
@@ -1,50 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { StatsTmdbSearchResult } from '../../types/stats';
import { TmdbSelector } from './TmdbSelector';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousISReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', {
value: previousDocument,
configurable: true,
});
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
};
}
import { installDom } from '../../test-utils/dom';
const HANZAWA: StatsTmdbSearchResult = {
tmdbId: 61222,
@@ -1,22 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
test('delete confirmation dialog swallows Escape before closing', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'stats/src/components/layout/DeleteConfirmDialog.tsx'),
'utf8',
);
const handlerBlock = source.match(
/const onKeyDown = \(event: KeyboardEvent\) => \{(?<body>[\s\S]*?)\n \};/,
)?.groups?.body;
assert.ok(handlerBlock);
assert.match(handlerBlock, /event\.preventDefault\(\);/);
assert.match(handlerBlock, /event\.stopPropagation\(\);/);
assert.match(handlerBlock, /event\.stopImmediatePropagation\(\);/);
assert.ok(
handlerBlock.indexOf('event.stopPropagation();') < handlerBlock.indexOf('finish(false);'),
);
});
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
@@ -8,30 +7,7 @@ import { setDeleteConfirmPresenter } from '../../lib/delete-confirm';
import { localDayFromMs } from '../../lib/formatters';
import type { OverviewData, SessionSummary } from '../../types/stats';
import { OverviewTab } from './OverviewTab';
function installDom() {
const window = new Window();
const properties = {
window,
document: window.document,
HTMLElement: window.HTMLElement,
ResizeObserver: window.ResizeObserver,
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = Object.keys(properties).map(
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
);
for (const [key, value] of Object.entries(properties)) {
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
}
return () => {
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
window.happyDOM.abort();
};
}
import { installDom } from '../../test-utils/dom';
function session(sessionId: number): SessionSummary {
return {
@@ -1,47 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { TrackingSnapshot } from './TrackingSnapshot';
import type { OverviewSummary } from '../../lib/dashboard-data';
const summary: OverviewSummary = {
todayActiveMs: 0,
todayCards: 0,
streakDays: 0,
allTimeMinutes: 120,
totalTrackedCards: 9,
episodesToday: 0,
activeAnimeCount: 0,
totalEpisodesWatched: 5,
totalAnimeCompleted: 1,
averageSessionMinutes: 40,
activeDays: 12,
totalSessions: 15,
lookupRate: {
shortValue: '2.3 / 100 words',
longValue: '2.3 lookups per 100 words',
},
todayTokens: 0,
newWordsToday: 0,
newWordsThisWeek: 0,
recentWatchTime: [],
};
test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', () => {
const markup = renderToStaticMarkup(
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
);
assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.3 \/ 100 words/);
assert.match(markup, /Lifetime Yomitan lookups normalized by total words seen/);
});
test('TrackingSnapshot labels new words as unique headwords', () => {
const markup = renderToStaticMarkup(
<TrackingSnapshot summary={summary} knownWordsSummary={null} />,
);
assert.match(markup, /Unique headwords seen for the first time today/);
assert.match(markup, /Unique headwords seen for the first time this week/);
});
@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { formatSentenceSearchMatchCountLabel } from './SearchTab';
const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx');
test('formatSentenceSearchMatchCountLabel uses singular label for one result', () => {
assert.equal(formatSentenceSearchMatchCountLabel(1), 'Match');
assert.equal(formatSentenceSearchMatchCountLabel(0), 'Matches');
assert.equal(formatSentenceSearchMatchCountLabel(2), 'Matches');
});
test('SearchTab forwards stored secondary subtitle text when mining from search results', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /buildStatsMineCardParams\(result,\s*searchedWord,\s*mode\)/);
});
test('SearchTab enables headword sentence search by default and forwards the toggle', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /const \[searchByHeadword,\s*setSearchByHeadword\] = useState\(true\);/);
assert.match(
source,
/apiClient\s*\.\s*searchSentences\(trimmed,\s*SEARCH_LIMIT,\s*searchByHeadword\)/,
);
assert.match(source, /checked=\{searchByHeadword\}/);
assert.match(source, /setSearchByHeadword\(event\.target\.checked\)/);
});
@@ -93,29 +93,3 @@ test('SessionEventPopover hides preview-unavailable fallback while note info is
assert.match(markup, /Loading Anki note info/);
assert.doesNotMatch(markup, /Preview unavailable/);
});
test('SessionEventPopover keeps the loading state clean until note preview data arrives', () => {
const marker: SessionChartMarker = {
key: 'card-9001',
kind: 'card',
anchorTsMs: 9_001,
eventTsMs: 9_001,
noteIds: [1773808840964],
cardsDelta: 1,
};
const markup = renderToStaticMarkup(
<SessionEventPopover
marker={marker}
noteInfos={new Map()}
loading={true}
pinned={true}
onTogglePinned={() => {}}
onClose={() => {}}
onOpenNote={() => {}}
/>,
);
assert.match(markup, /Loading Anki note info/);
assert.doesNotMatch(markup, /Preview unavailable/);
});
@@ -132,29 +132,9 @@ test('deleting session counts keep rows disabled during overlapping delete flows
assert.equal(deleting.size, 0);
});
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
let startCalled = false;
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
const handler = buildBucketDeleteHandler({
bucket,
apiClient: { deleteSessions: async () => {} },
confirm: () => false,
onStart: () => {
startCalled = true;
},
onSuccess: () => {},
onError: () => {},
});
await handler();
assert.equal(startCalled, false);
});
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
let deleteCalled = false;
let startCalled = false;
let successCalled = false;
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
@@ -167,6 +147,9 @@ test('buildBucketDeleteHandler is a no-op when confirm returns false', async ()
},
},
confirm: () => false,
onStart: () => {
startCalled = true;
},
onSuccess: () => {
successCalled = true;
},
@@ -176,6 +159,7 @@ test('buildBucketDeleteHandler is a no-op when confirm returns false', async ()
await handler();
assert.equal(deleteCalled, false);
assert.equal(startCalled, false);
assert.equal(successCalled, false);
});
@@ -1,28 +1,8 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeVisibilityFilter } from './TrendsTab';
test('AnimeVisibilityFilter uses title visibility wording', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={null}
maxTitlesMode="total"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /Title Visibility/);
assert.doesNotMatch(markup, /Anime Visibility/);
});
test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
@@ -43,25 +23,6 @@ test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
assert.match(markup, /<option value="7" selected="">/);
});
test('AnimeVisibilityFilter offers top vs most-recent ranking modes', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
animeTitles={['KonoSuba']}
hiddenAnime={new Set()}
maxTitles={7}
maxTitlesMode="recent"
onShowAll={() => {}}
onHideAll={() => {}}
onToggleAnime={() => {}}
onMaxTitlesChange={() => {}}
onMaxTitlesModeChange={() => {}}
/>,
);
assert.match(markup, /<option value="total">top<\/option>/);
assert.match(markup, /<option value="recent" selected="">most recent<\/option>/);
});
test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing all titles', () => {
const markup = renderToStaticMarkup(
<AnimeVisibilityFilter
@@ -80,10 +41,3 @@ test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing
assert.match(markup, /aria-label="Title ranking mode"/);
assert.doesNotMatch(markup, /aria-label="Title ranking mode"[^>]*disabled/);
});
test('TrendsTab source labels words per minute without reading speed wording', async () => {
const source = await readFile(new URL('./TrendsTab.tsx', import.meta.url), 'utf8');
assert.match(source, /title="Words \/ Min"/);
assert.doesNotMatch(source, /Reading Speed/);
});
@@ -3,6 +3,7 @@ import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { buildCrossAnimeWordRows, CrossAnimeWordsTable } from './CrossAnimeWordsTable';
import type { VocabularyEntry } from '../../types/stats';
import { withLocalStorage } from '../../test-utils/dom';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
return {
@@ -20,46 +21,6 @@ function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
} as VocabularyEntry;
}
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('cross-title rows can hide kana-only headwords', () => {
const rows = buildCrossAnimeWordRows(
[
@@ -77,17 +38,6 @@ test('cross-title rows can hide kana-only headwords', () => {
);
});
test('cross-title table renders a Hide Kana filter button', () => {
const markup = renderToStaticMarkup(
<CrossAnimeWordsTable
words={[makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' })]}
knownWords={new Set()}
/>,
);
assert.match(markup, /Hide Kana/);
});
test('cross-title table uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.crossAnimeWords.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
@@ -1,47 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { StatsDuplicateLineCleanupResult } from '../../types/stats';
import { DuplicateLineCleanup } from './DuplicateLineCleanup';
interface TestWindow extends Window {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
function installDom(): () => void {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
const previousISReactActEnvironment = (
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT;
const window = new Window() as TestWindow;
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
return () => {
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: previousHTMLElement,
configurable: true,
});
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment;
};
}
import { installDom } from '../../test-utils/dom';
function findButton(container: Element, label: string): HTMLButtonElement {
const match = [...container.querySelectorAll('button')].find(
@@ -7,6 +7,7 @@ import {
isKanaOnlyTokenText,
} from './FrequencyRankTable';
import type { VocabularyEntry } from '../../types/stats';
import { withLocalStorage } from '../../test-utils/dom';
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
return {
@@ -24,46 +25,6 @@ function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
} as VocabularyEntry;
}
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
const entry = makeEntry({});
const markup = renderToStaticMarkup(
@@ -117,14 +78,6 @@ test('frequency rows can hide kana-only headwords', () => {
);
});
test('renders a Hide Kana filter button', () => {
const entry = makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' });
const markup = renderToStaticMarkup(
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
);
assert.match(markup, /Hide Kana/);
});
test('uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
@@ -1,21 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { KanjiDetailPanel } from './KanjiDetailPanel';
test('KanjiDetailPanel uses the centered detail modal layout', () => {
const markup = renderToStaticMarkup(
createElement(KanjiDetailPanel, { kanjiId: 1, onClose: () => {} }),
);
assert.match(
markup,
/class="[^"]*fixed[^"]*inset-0[^"]*z-40[^"]*flex[^"]*items-center[^"]*justify-center[^"]*p-4/,
);
assert.match(
markup,
/class="[^"]*relative[^"]*flex[^"]*max-h-\[85vh\][^"]*w-full[^"]*max-w-2xl[^"]*flex-col/,
);
assert.doesNotMatch(markup, /class="[^"]*absolute[^"]*right-0[^"]*top-0[^"]*h-full/);
});
@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const WORD_DETAIL_PANEL_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'WordDetailPanel.tsx',
);
test('WordDetailPanel uses the shared stats mining payload builder', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /buildStatsMineCardParams/);
assert.match(source, /getStatsMineCardUnavailableReason/);
assert.match(source, /buildStatsMineCardParams\(\s*occ,\s*data!\.detail\.headword,\s*mode\s*\)/);
});
test('WordDetailPanel shows partial media mining errors instead of silent success', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /getStatsMineCardError/);
assert.match(source, /const responseError = getStatsMineCardError\(result\);/);
});
test('WordDetailPanel uses the wider centered detail modal layout', () => {
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
assert.match(source, /fixed inset-0 z-40 flex items-center justify-center p-4/);
assert.match(source, /relative flex max-h-\[85vh\] w-full max-w-2xl flex-col/);
});
-20
View File
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { toErrorMessage } from './useSessions';
const USE_SESSIONS_PATH = fileURLToPath(new URL('./useSessions.ts', import.meta.url));
test('toErrorMessage normalizes Error and non-Error rejections', () => {
assert.equal(toErrorMessage(new Error('network down')), 'network down');
assert.equal(toErrorMessage('bad gateway'), 'bad gateway');
assert.equal(toErrorMessage(503), '503');
});
test('useSessions and useSessionDetail route catch handlers through toErrorMessage', () => {
const source = fs.readFileSync(USE_SESSIONS_PATH, 'utf8');
const matches = source.match(/setError\(toErrorMessage\(err\)\)/g);
assert.equal(matches?.length, 2);
});
+1 -63
View File
@@ -1,77 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { apiClient } from '../lib/api-client';
import { resetExcludedWordsStoreForTests, setExcludedWords } from './useExcludedWords';
import { useVocabulary } from './useVocabulary';
import type { StatsVocabularyCharts, StatsVocabularySummary } from '../types/stats';
import { installDom, installLocalStorage } from '../test-utils/dom';
type VocabularyState = ReturnType<typeof useVocabulary>;
function installDom(): () => void {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
const previousIsReactActEnvironment = Object.getOwnPropertyDescriptor(
globalThis,
'IS_REACT_ACT_ENVIRONMENT',
);
const window = new Window();
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
Object.defineProperty(globalThis, 'HTMLElement', {
value: window.HTMLElement,
configurable: true,
});
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
value: true,
configurable: true,
writable: true,
});
return () => {
const restoreProperty = (name: string, descriptor: PropertyDescriptor | undefined) => {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
};
restoreProperty('window', previousWindow);
restoreProperty('document', previousDocument);
restoreProperty('HTMLElement', previousHTMLElement);
restoreProperty('IS_REACT_ACT_ENVIRONMENT', previousIsReactActEnvironment);
};
}
test('DOM harness restores the original global property descriptors', () => {
const propertyNames = ['window', 'document', 'HTMLElement', 'IS_REACT_ACT_ENVIRONMENT'] as const;
const before = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
const restore = installDom();
restore();
const after = propertyNames.map((name) => Object.getOwnPropertyDescriptor(globalThis, name));
assert.deepEqual(after, before);
});
function installLocalStorage(): () => void {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
return () => {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else delete (globalThis as { localStorage?: unknown }).localStorage;
};
}
interface FakeClock {
tick: (ms: number) => void;
restore: () => void;
+93 -274
View File
@@ -2,6 +2,41 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { apiClient, BASE_URL } from './api-client';
interface SeenRequest {
url: string;
method: string;
body: unknown;
}
/** Stubs `fetch` for one test body; every call is recorded in `requests`. */
async function withFetchStub(
respond: () => Response,
run: (requests: SeenRequest[]) => Promise<void>,
): Promise<void> {
const originalFetch = globalThis.fetch;
const requests: SeenRequest[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({
url: String(input),
method: init?.method ?? 'GET',
body: init?.body ? JSON.parse(String(init.body)) : undefined,
});
return respond();
}) as typeof globalThis.fetch;
try {
await run(requests);
} finally {
globalThis.fetch = originalFetch;
}
}
const jsonResponse = (payload: unknown) =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
animeId: number,
@@ -11,184 +46,53 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
assert.equal(getAnimeCoverUrl(42, 3), `${BASE_URL}/api/stats/anime/42/cover?coverRetry=3`);
});
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(
JSON.stringify({ recommendations: [{ recommendationId: 4, animeIds: [7, 8] }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof globalThis.fetch;
try {
const result = await apiClient.getAnimeMergeRecommendations();
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations`);
assert.deepEqual(result.recommendations, [{ recommendationId: 4, animeIds: [7, 8] }]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('dismissAnimeMergeRecommendation sends DELETE for the selected suggestion', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.dismissAnimeMergeRecommendation(4);
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations/4`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
});
test('getCoverImages batches anime and media cover requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
let seenBody = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
seenBody = String(init?.body ?? '');
return new Response(JSON.stringify({ anime: {}, media: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
assert.equal(seenUrl, `${BASE_URL}/api/stats/covers`);
assert.equal(seenMethod, 'POST');
assert.deepEqual(JSON.parse(seenBody), { animeIds: [1, 2], videoIds: [7, 8] });
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession sends a DELETE request to the session endpoint', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(null, { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.deleteSession(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
test('getCoverImages dedupes anime and media ids before batching the request', async () => {
await withFetchStub(
() => jsonResponse({ anime: {}, media: {} }),
async (requests) => {
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
assert.deepEqual(requests, [
{
url: `${BASE_URL}/api/stats/covers`,
method: 'POST',
body: { animeIds: [1, 2], videoIds: [7, 8] },
},
]);
},
);
});
test('searchSentences encodes realtime sentence search requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.searchSentences('猫 食べる', 25);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=true`,
);
await apiClient.searchSentences('猫 食べる', 25, false);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=false`,
);
} finally {
globalThis.fetch = originalFetch;
}
await withFetchStub(
() => jsonResponse([]),
async (requests) => {
await apiClient.searchSentences('猫 食べる', 25);
await apiClient.searchSentences('猫 食べる', 25, false);
const query = 'q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25';
assert.deepEqual(
requests.map((request) => request.url),
[
`${BASE_URL}/api/stats/sentences/search?${query}&headword=true`,
`${BASE_URL}/api/stats/sentences/search?${query}&headword=false`,
],
);
},
);
});
test('deleteSession throws when the stats API delete request fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response('boom', {
status: 500,
statusText: 'Internal Server Error',
})) as typeof globalThis.fetch;
try {
await assert.rejects(() => apiClient.deleteSession(7), /Stats API error: 500 boom/);
} finally {
globalThis.fetch = originalFetch;
}
});
test('getTrendsDashboard requests the chart-ready trends endpoint with range and grouping', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(
JSON.stringify({
activity: { watchTime: [], cards: [], words: [], sessions: [] },
progress: {
watchTime: [],
sessions: [],
words: [],
newWords: [],
cards: [],
episodes: [],
lookups: [],
},
ratios: { lookupsPerHundred: [] },
librarySummary: [],
animeCumulative: {
watchTime: [],
episodes: [],
cards: [],
words: [],
},
patterns: {
watchTimeByDayOfWeek: [],
watchTimeByHour: [],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof globalThis.fetch;
try {
await apiClient.getTrendsDashboard('90d', 'month');
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month&fillEmpty=true`,
);
} finally {
globalThis.fetch = originalFetch;
}
await withFetchStub(
() => new Response('boom', { status: 500, statusText: 'Internal Server Error' }),
async () => {
await assert.rejects(() => apiClient.deleteSession(7), /Stats API error: 500 boom/);
},
);
});
test('getTrendsDashboard accepts 365d range and builds correct URL', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(
JSON.stringify({
await withFetchStub(
() =>
jsonResponse({
activity: { watchTime: [], cards: [], words: [], sessions: [] },
progress: {
watchTime: [],
@@ -201,113 +105,28 @@ test('getTrendsDashboard accepts 365d range and builds correct URL', async () =>
},
ratios: { lookupsPerHundred: [] },
librarySummary: [],
animeCumulative: {
watchTime: [],
episodes: [],
cards: [],
words: [],
},
patterns: {
watchTimeByDayOfWeek: [],
watchTimeByHour: [],
},
animeCumulative: { watchTime: [], episodes: [], cards: [], words: [] },
patterns: { watchTimeByDayOfWeek: [], watchTimeByHour: [] },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof globalThis.fetch;
try {
await apiClient.getTrendsDashboard('365d', 'day', false);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day&fillEmpty=false`,
);
} finally {
globalThis.fetch = originalFetch;
}
async (requests) => {
await apiClient.getTrendsDashboard('365d', 'day', false);
assert.equal(
requests[0]?.url,
`${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day&fillEmpty=false`,
);
},
);
});
test('getSessionEvents can request only specific event types', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getSessionEvents(42, 120, [4, 5, 6, 7, 8, 9]);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sessions/42/events?limit=120&types=4%2C5%2C6%2C7%2C8%2C9`,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('getExcludedWords requests database-backed exclusions', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([{ headword: '猫', word: '猫', reading: 'ねこ' }]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
const words = await apiClient.getExcludedWords();
assert.equal(seenUrl, `${BASE_URL}/api/stats/excluded-words`);
assert.deepEqual(words, [{ headword: '猫', word: '猫', reading: 'ねこ' }]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('setExcludedWords replaces database-backed exclusions', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
let seenBody = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
seenBody = String(init?.body ?? '');
return new Response(null, { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.setExcludedWords([{ headword: '猫', word: '猫', reading: 'ねこ' }]);
assert.equal(seenUrl, `${BASE_URL}/api/stats/excluded-words`);
assert.equal(seenMethod, 'PUT');
assert.deepEqual(JSON.parse(seenBody), {
words: [{ headword: '猫', word: '猫', reading: 'ねこ' }],
});
} finally {
globalThis.fetch = originalFetch;
}
});
test('getSessionTimeline requests full session history when limit is omitted', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as typeof globalThis.fetch;
try {
await apiClient.getSessionTimeline(42);
assert.equal(seenUrl, `${BASE_URL}/api/stats/sessions/42/timeline`);
} finally {
globalThis.fetch = originalFetch;
}
await withFetchStub(
() => jsonResponse([]),
async (requests) => {
await apiClient.getSessionEvents(42, 120, [4, 5, 6, 7, 8, 9]);
assert.equal(
requests[0]?.url,
`${BASE_URL}/api/stats/sessions/42/events?limit=120&types=4%2C5%2C6%2C7%2C8%2C9`,
);
},
);
});
-40
View File
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const APP_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../App.tsx');
test('App lazy-loads non-overview tabs and detail surfaces behind Suspense boundaries', () => {
const source = fs.readFileSync(APP_PATH, 'utf8');
assert.match(source, /\bSuspense\b/, 'expected Suspense boundary in App');
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/search\/SearchTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
assert.doesNotMatch(source, /import \{ AnimeTab \} from '\.\/components\/anime\/AnimeTab';/);
assert.doesNotMatch(source, /import \{ TrendsTab \} from '\.\/components\/trends\/TrendsTab';/);
assert.doesNotMatch(
source,
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
);
assert.doesNotMatch(source, /import \{ SearchTab \} from '\.\/components\/search\/SearchTab';/);
assert.doesNotMatch(
source,
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
);
assert.doesNotMatch(
source,
/import \{ MediaDetailView \} from '\.\/components\/library\/MediaDetailView';/,
);
assert.doesNotMatch(
source,
/import \{ WordDetailPanel \} from '\.\/components\/vocabulary\/WordDetailPanel';/,
);
});
-16
View File
@@ -1,16 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CHART_DEFAULTS, CHART_THEME, TOOLTIP_CONTENT_STYLE } from './chart-theme';
test('CHART_THEME exposes a grid color', () => {
assert.equal(CHART_THEME.grid, '#494d64');
});
test('CHART_DEFAULTS uses 11px ticks for legibility', () => {
assert.equal(CHART_DEFAULTS.tickFontSize, 11);
});
test('TOOLTIP_CONTENT_STYLE mirrors the shared tooltip colors', () => {
assert.equal(TOOLTIP_CONTENT_STYLE.background, CHART_THEME.tooltipBg);
assert.ok(String(TOOLTIP_CONTENT_STYLE.border).includes(CHART_THEME.tooltipBorder));
});
+1 -67
View File
@@ -8,12 +8,7 @@ import type {
StreakCalendarDay,
VocabularyEntry,
} from '../types/stats';
import {
buildOverviewSummary,
buildStreakCalendar,
buildTrendDashboard,
buildVocabularySummary,
} from './dashboard-data';
import { buildOverviewSummary, buildStreakCalendar } from './dashboard-data';
test('buildOverviewSummary aggregates tracked totals and recent windows', () => {
const now = Date.UTC(2026, 2, 13, 12);
@@ -155,67 +150,6 @@ test('buildOverviewSummary prefers lifetime totals from hints when provided', ()
assert.equal(summary.lookupRate, null);
});
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
const now = Date.UTC(2026, 2, 13, 12);
const nowSec = now / 1000;
const words: VocabularyEntry[] = [
{
wordId: 1,
headword: '猫',
word: '猫',
reading: 'ねこ',
partOfSpeech: null,
pos1: null,
pos2: null,
pos3: null,
frequency: 4,
frequencyRank: null,
animeCount: 1,
firstSeen: nowSec - 2 * 86_400,
lastSeen: nowSec - 1,
},
];
const summary = buildVocabularySummary(words, [], now);
assert.equal(summary.newThisWeek, 1);
});
test('buildTrendDashboard derives dense chart series', () => {
const now = Date.UTC(2026, 2, 13, 12);
const today = Math.floor(now / 86_400_000);
const rollups: DailyRollup[] = [
{
rollupDayOrMonth: today - 1,
videoId: 1,
totalSessions: 2,
totalActiveMin: 60,
totalLinesSeen: 30,
totalTokensSeen: 100,
totalCards: 3,
cardsPerHour: 3,
tokensPerMin: 2,
lookupHitRate: 0.5,
},
{
rollupDayOrMonth: today,
videoId: 1,
totalSessions: 1,
totalActiveMin: 30,
totalLinesSeen: 10,
totalTokensSeen: 30,
totalCards: 1,
cardsPerHour: 2,
tokensPerMin: 1.33,
lookupHitRate: 0.75,
},
];
const dashboard = buildTrendDashboard(rollups);
assert.equal(dashboard.watchTime.length, 2);
assert.equal(dashboard.words[1]?.value, 30);
assert.equal(dashboard.sessions[0]?.value, 2);
});
test('buildStreakCalendar converts epoch days to YYYY-MM-DD dates', () => {
const days: StreakCalendarDay[] = [
{ epochDay: 20525, totalActiveMin: 45 },
+127 -245
View File
@@ -8,271 +8,153 @@ import {
setDeleteConfirmPresenter,
} from './delete-confirm';
test('confirmSessionDelete uses the shared session delete warning copy', async () => {
interface NativeStatsApi {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
}
type ElectronGlobal = typeof globalThis & { electronAPI?: { stats?: NativeStatsApi } };
/**
* Runs `run` with a fake browser `confirm` (answering `confirmAnswer`) and, when
* `electronStats` is given, a fake Electron stats bridge built from the shared `calls`
* log. Calls into either end up in `calls`, in order.
*/
async function withConfirmGlobals(
options: {
confirmAnswer?: boolean;
electronStats?: (calls: string[]) => NativeStatsApi;
},
run: (calls: string[]) => Promise<void>,
): Promise<void> {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
const globals = globalThis as ElectronGlobal;
const originalConfirm = globals.confirm;
const originalElectronAPI = globals.electronAPI;
globals.confirm = ((message?: string) => {
calls.push(`browser-confirm:${message ?? ''}`);
return options.confirmAnswer ?? true;
}) as typeof globalThis.confirm;
globals.electronAPI = options.electronStats ? { stats: options.electronStats(calls) } : undefined;
try {
assert.equal(await confirmSessionDelete(), true);
assert.deepEqual(calls, ['Delete this session and all associated data?']);
await run(calls);
} finally {
globalThis.confirm = originalConfirm;
globals.confirm = originalConfirm;
globals.electronAPI = originalElectronAPI;
}
}
const SESSION_WARNING = 'Delete this session and all associated data?';
test('confirmSessionDelete falls back to the browser confirm with the shared warning copy', async () => {
await withConfirmGlobals({}, async (calls) => {
assert.equal(await confirmSessionDelete(), true);
assert.deepEqual(calls, [`browser-confirm:${SESSION_WARNING}`]);
});
});
test('confirmSessionDelete suspends stats overlay layering around native confirm', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
const originalElectronAPI = (
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = {
stats: {
beginNativeDialog: () => calls.push('begin-native-dialog'),
endNativeDialog: () => calls.push('end-native-dialog'),
await withConfirmGlobals(
{
electronStats: (calls) => ({
beginNativeDialog: () => calls.push('begin-native-dialog'),
endNativeDialog: () => calls.push('end-native-dialog'),
}),
},
};
globalThis.confirm = ((message?: string) => {
calls.push(`confirm:${message ?? ''}`);
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmSessionDelete(), true);
assert.deepEqual(calls, [
'begin-native-dialog',
'confirm:Delete this session and all associated data?',
'end-native-dialog',
]);
} finally {
globalThis.confirm = originalConfirm;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = originalElectronAPI;
}
async (calls) => {
assert.equal(await confirmSessionDelete(), true);
assert.deepEqual(calls, [
'begin-native-dialog',
`browser-confirm:${SESSION_WARNING}`,
'end-native-dialog',
]);
},
);
});
test('confirmSessionDelete uses parented Electron confirm when available', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
const originalElectronAPI = (
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = {
stats: {
confirmNativeDialog: (message) => {
calls.push(`native-confirm:${message}`);
return false;
},
beginNativeDialog: () => calls.push('begin-native-dialog'),
endNativeDialog: () => calls.push('end-native-dialog'),
await withConfirmGlobals(
{
electronStats: (calls) => ({
confirmNativeDialog: (message) => {
calls.push(`native-confirm:${message}`);
return false;
},
beginNativeDialog: () => calls.push('begin-native-dialog'),
endNativeDialog: () => calls.push('end-native-dialog'),
}),
},
};
globalThis.confirm = ((message?: string) => {
calls.push(`browser-confirm:${message ?? ''}`);
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmSessionDelete(), false);
assert.deepEqual(calls, ['native-confirm:Delete this session and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
beginNativeDialog?: () => void;
endNativeDialog?: () => void;
};
};
}
).electronAPI = originalElectronAPI;
}
async (calls) => {
assert.equal(await confirmSessionDelete(), false);
assert.deepEqual(calls, [`native-confirm:${SESSION_WARNING}`]);
},
);
});
test('confirmSessionDelete uses the registered stats presenter before native or browser confirm', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
const originalElectronAPI = (
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
};
};
}
).electronAPI;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
};
};
}
).electronAPI = {
stats: {
confirmNativeDialog: (message) => {
calls.push(`native-confirm:${message}`);
return true;
},
await withConfirmGlobals(
{
electronStats: (calls) => ({
confirmNativeDialog: (message) => {
calls.push(`native-confirm:${message}`);
return true;
},
}),
},
};
globalThis.confirm = ((message?: string) => {
calls.push(`browser-confirm:${message ?? ''}`);
return true;
}) as typeof globalThis.confirm;
async (calls) => {
const unregister = setDeleteConfirmPresenter(async (message) => {
calls.push(`presenter:${message}`);
return false;
});
const unregister = setDeleteConfirmPresenter(async (message) => {
calls.push(`presenter:${message}`);
return false;
});
try {
assert.equal(await confirmSessionDelete(), false);
assert.deepEqual(calls, ['presenter:Delete this session and all associated data?']);
} finally {
unregister();
globalThis.confirm = originalConfirm;
(
globalThis as typeof globalThis & {
electronAPI?: {
stats?: {
confirmNativeDialog?: (message: string) => boolean;
};
};
try {
assert.equal(await confirmSessionDelete(), false);
assert.deepEqual(calls, [`presenter:${SESSION_WARNING}`]);
} finally {
unregister();
}
).electronAPI = originalElectronAPI;
}
},
);
});
test('confirmDayGroupDelete includes the day label and count in the warning copy', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
const copyCases = [
{
name: 'confirmDayGroupDelete includes the day label and count',
ask: () => confirmDayGroupDelete('Today', 3),
message: 'Delete all 3 sessions from Today and all associated data?',
},
{
name: 'confirmDayGroupDelete uses singular for one session',
ask: () => confirmDayGroupDelete('Yesterday', 1),
message: 'Delete this session from Yesterday and all associated data?',
},
{
name: 'confirmBucketDelete names the episode and count for multiple sessions',
ask: () => confirmBucketDelete('My Episode', 3),
message: 'Delete all 3 sessions of "My Episode" from this day and all associated data?',
},
{
name: 'confirmBucketDelete uses a clean singular form for one session',
ask: () => confirmBucketDelete('Solo Episode', 1),
message: 'Delete this session of "Solo Episode" from this day and all associated data?',
},
{
name: 'confirmEpisodeDelete includes the episode title',
ask: () => confirmEpisodeDelete('Episode 4'),
message: 'Delete "Episode 4" and all its sessions?',
},
];
try {
assert.equal(await confirmDayGroupDelete('Today', 3), true);
assert.deepEqual(calls, ['Delete all 3 sessions from Today and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmDayGroupDelete uses singular for one session', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmDayGroupDelete('Yesterday', 1), true);
assert.deepEqual(calls, ['Delete this session from Yesterday and all associated data?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmBucketDelete asks about merging multiple sessions of the same episode', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return true;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmBucketDelete('My Episode', 3), true);
assert.deepEqual(calls, [
'Delete all 3 sessions of "My Episode" from this day and all associated data?',
]);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmBucketDelete uses a clean singular form for one session', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return false;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmBucketDelete('Solo Episode', 1), false);
assert.deepEqual(calls, [
'Delete this session of "Solo Episode" from this day and all associated data?',
]);
} finally {
globalThis.confirm = originalConfirm;
}
});
test('confirmEpisodeDelete includes the episode title in the shared warning copy', async () => {
const calls: string[] = [];
const originalConfirm = globalThis.confirm;
globalThis.confirm = ((message?: string) => {
calls.push(message ?? '');
return false;
}) as typeof globalThis.confirm;
try {
assert.equal(await confirmEpisodeDelete('Episode 4'), false);
assert.deepEqual(calls, ['Delete "Episode 4" and all its sessions?']);
} finally {
globalThis.confirm = originalConfirm;
}
});
for (const { name, ask, message } of copyCases) {
test(`${name} and returns the user's answer`, async () => {
for (const answer of [true, false]) {
await withConfirmGlobals({ confirmAnswer: answer }, async (calls) => {
assert.equal(await ask(), answer);
assert.deepEqual(calls, [`browser-confirm:${message}`]);
});
}
});
}
-22
View File
@@ -1,8 +1,5 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { renderToStaticMarkup } from 'react-dom/server';
import { DeleteProgressToast } from '../components/common/DeleteProgressToast';
import { apiClient } from './api-client';
@@ -84,25 +81,6 @@ test('DeleteProgressToast stays hidden while idle and reports active deletes', (
}
});
test('the delete indicator is mounted once at the app root, not inside tab panels', () => {
const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relativePath: string): string =>
fs.readFileSync(path.join(srcDir, relativePath), 'utf8');
const app = read('App.tsx');
assert.match(app, /<DeleteProgressToast \/>/);
// Sibling of the confirm dialog: outside every `hidden` tab panel, so the
// indicator survives tab switches and detail-view navigation.
assert.match(app, /<DeleteConfirmDialog \/>\s*<DeleteProgressToast \/>/);
for (const tab of [
'components/overview/OverviewTab.tsx',
'components/sessions/SessionsTab.tsx',
]) {
assert.doesNotMatch(read(tab), /DeleteProgressToast/, `${tab} must not mount its own toast`);
}
});
test('every api client delete registers with the global progress indicator', async () => {
const originalFetch = globalThis.fetch;
const seenCounts: number[] = [];
@@ -8,7 +8,6 @@ import {
resolveMediaCoverApiUrl,
summarizeMediaLibraryGroups,
} from './media-library-grouping';
import { CoverImage } from '../components/library/CoverImage';
import { MediaCard } from '../components/library/MediaCard';
const youtubeEpisodeA: MediaLibraryItem = {
@@ -153,19 +152,6 @@ test('groupMediaLibraryItems backfills missing group artwork from later items',
assert.equal(groups[0]?.imageUrl, second.videoThumbnailUrl);
});
test('CoverImage renders explicit remote artwork when src is provided', () => {
const markup = renderToStaticMarkup(
<CoverImage
videoId={youtubeEpisodeA.videoId}
title={youtubeEpisodeA.canonicalTitle}
src={youtubeEpisodeA.videoThumbnailUrl}
className="w-8 h-8"
/>,
);
assert.match(markup, /src="https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg"/);
});
test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => {
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
-40
View File
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { MediaSessionList } from '../components/library/MediaSessionList';
test('MediaSessionList renders expandable session rows with delete affordance', () => {
const markup = renderToStaticMarkup(
<MediaSessionList
sessions={[
{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 9,
animeId: 3,
animeTitle: 'Anime',
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 1_000,
activeWatchedMs: 900,
linesSeen: 12,
tokensSeen: 24,
cardsMined: 2,
lookupCount: 3,
lookupHits: 2,
yomitanLookupCount: 1,
knownWordsSeen: 6,
knownWordRate: 25,
},
]}
onDeleteSession={() => {}}
initialExpandedSessionId={7}
/>,
);
assert.match(markup, /Session History/);
assert.match(markup, /aria-expanded="true"/);
assert.match(markup, /Delete session Episode 7/);
assert.match(markup, /words/);
assert.match(markup, /No word data for this session/);
});
-31
View File
@@ -1,43 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import {
SessionDetail,
buildKnownWordsRatioChartData,
getKnownPctAxisMax,
} from '../components/sessions/SessionDetail';
import { buildSessionChartEvents } from './session-events';
import { EventType } from '../types/stats';
test('SessionDetail omits the misleading new words metric', () => {
const markup = renderToStaticMarkup(
<SessionDetail
session={{
sessionId: 7,
canonicalTitle: 'Episode 7',
videoId: 7,
animeId: null,
animeTitle: null,
startedAtMs: 0,
endedAtMs: null,
totalWatchedMs: 0,
activeWatchedMs: 0,
linesSeen: 12,
tokensSeen: 24,
cardsMined: 0,
lookupCount: 0,
lookupHits: 0,
yomitanLookupCount: 0,
knownWordsSeen: 0,
knownWordRate: 0,
}}
/>,
);
assert.match(markup, /No word data/);
assert.doesNotMatch(markup, /New words/);
});
test('buildSessionChartEvents keeps only chart-relevant events and pairs pause ranges', () => {
const chartEvents = buildSessionChartEvents([
{ eventType: EventType.SUBTITLE_LINE, tsMs: 1_000, payload: '{"line":"ignored"}' },
@@ -1,42 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { TabBar } from '../components/layout/TabBar';
import { EpisodeList } from '../components/anime/EpisodeList';
test('TabBar renders Library instead of Anime for the media library tab', () => {
const markup = renderToStaticMarkup(<TabBar activeTab="overview" onTabChange={() => {}} />);
assert.doesNotMatch(markup, />Anime</);
assert.match(markup, />Overview</);
assert.match(markup, />Library</);
assert.match(markup, />Search</);
});
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
const markup = renderToStaticMarkup(
<EpisodeList
episodes={[
{
videoId: 9,
episode: 9,
season: 1,
durationMs: 1,
endedMediaMs: null,
watched: 0,
canonicalTitle: 'Episode 9',
totalSessions: 1,
totalActiveMs: 1,
totalCards: 1,
totalTokensSeen: 350,
totalYomitanLookupCount: 7,
lastWatchedMs: 0,
},
]}
onOpenDetail={() => {}}
/>,
);
assert.match(markup, />Details</);
assert.match(markup, /Episode 9/);
});
-52
View File
@@ -1,52 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const VOCABULARY_TAB_PATH = fileURLToPath(
new URL('../components/vocabulary/VocabularyTab.tsx', import.meta.url),
);
const VOCABULARY_HOOK_PATH = fileURLToPath(new URL('../hooks/useVocabulary.ts', import.meta.url));
test('VocabularyTab declares all hooks before loading and error early returns', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
const loadingGuardIndex = source.indexOf('if (loading) {');
assert.notEqual(loadingGuardIndex, -1, 'expected loading early return');
const hooksAfterLoadingGuard = source
.slice(loadingGuardIndex)
.match(/\buse(?:State|Effect|Memo|Callback|Ref|Reducer)\s*\(/g);
assert.deepEqual(hooksAfterLoadingGuard ?? [], []);
});
test('VocabularyTab uses uncapped server-side data for its charts and card totals', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(source, /\} = useVocabulary\(\);/);
assert.match(source, /charts\?\.topWordsWithoutNames/);
assert.match(source, /charts\?\.newWordsTimelineWithoutNames/);
assert.doesNotMatch(source, /buildVocabularySummary\(/);
assert.match(source, /uniqueWords: summary\?\.uniqueWordsWithoutNames \?\? 0/);
assert.match(source, /uniqueWords: summary\?\.uniqueWords \?\? 0/);
assert.match(source, /value=\{summary \? formatNumber\(summary\.uniqueKanji\) : '…'\}/);
});
test('VocabularyTab surfaces aggregate failures with a retry control', () => {
const source = fs.readFileSync(VOCABULARY_TAB_PATH, 'utf8');
assert.match(source, /aggregatesError/);
assert.match(source, /onClick=\{refreshAggregates\}/);
});
test('useVocabulary loads exact card totals without holding up the vocabulary tables', () => {
const source = fs.readFileSync(VOCABULARY_HOOK_PATH, 'utf8');
assert.match(
source,
/Promise\.allSettled\(\[\s*client\.getVocabulary\(500\),\s*client\.getKanji\(200\),\s*client\.getKnownWords\(\),?\s*\]\)/,
);
assert.match(source, /client\s*\.getVocabularySummary\(\)\s*\.then\(/);
assert.match(source, /client\s*\.getVocabularyCharts\(\)/);
});
-60
View File
@@ -3,7 +3,6 @@ import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { MediaHeader } from '../components/library/MediaHeader';
import { EpisodeList } from '../components/anime/EpisodeList';
import { AnimeOverviewStats } from '../components/anime/AnimeOverviewStats';
import { SessionRow } from '../components/sessions/SessionRow';
import { EventType, type SessionEvent } from '../types/stats';
import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup';
@@ -29,30 +28,6 @@ test('getYomitanLookupEvents keeps only Yomitan lookup events', () => {
);
});
test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
const markup = renderToStaticMarkup(
<MediaHeader
detail={{
videoId: 7,
canonicalTitle: 'Episode 7',
animeId: null,
totalSessions: 4,
totalActiveMs: 90_000,
totalCards: 12,
totalTokensSeen: 1000,
totalLinesSeen: 120,
totalLookupCount: 30,
totalLookupHits: 21,
totalYomitanLookupCount: 23,
}}
/>,
);
assert.match(markup, /23/);
assert.match(markup, /2\.3 \/ 100 words/);
assert.match(markup, /2\.3 lookups per 100 words/);
});
test('MediaHeader distinguishes word occurrences from known unique words', () => {
const markup = renderToStaticMarkup(
<MediaHeader
@@ -110,41 +85,6 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
assert.doesNotMatch(markup, /90%/);
});
test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
const markup = renderToStaticMarkup(
<AnimeOverviewStats
detail={{
animeId: 1,
canonicalTitle: 'Anime',
mediaKind: 'anime',
anilistId: null,
tmdbId: null,
tmdbType: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
description: null,
totalSessions: 5,
totalActiveMs: 100_000,
totalCards: 8,
totalTokensSeen: 800,
totalLinesSeen: 100,
totalLookupCount: 50,
totalLookupHits: 30,
totalYomitanLookupCount: 16,
episodeCount: 3,
lastWatchedMs: 0,
}}
knownWordsSummary={null}
/>,
);
assert.match(markup, /Lookups/);
assert.match(markup, /16/);
assert.match(markup, /2\.0 \/ 100 words/);
assert.match(markup, /Yomitan lookups per 100 words seen/);
});
test('SessionRow prefers word-based count when available', () => {
const markup = renderToStaticMarkup(
<SessionRow
-38
View File
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
test('stats overlay mode paints an opaque full-viewport background', () => {
assert.match(css, /html,\s*body,\s*#root\s*\{[^}]*height:\s*100%;/s);
assert.match(css, /body\.overlay-mode\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s);
assert.doesNotMatch(css, /body\.overlay-mode\s*\{[^}]*rgba\(/s);
assert.match(
css,
/body\.overlay-mode #root\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s,
);
});
test('reduced motion stops the looping delete indicator rather than speeding it up', () => {
const reducedMotion = /@media \(prefers-reduced-motion: reduce\)\s*\{(?<body>.*)\n\}/s.exec(css)
?.groups?.body;
assert.ok(reducedMotion, 'expected a prefers-reduced-motion block');
// Shortening an infinite animation makes it run faster, so the looping rules
// have to switch it off outright and hold a static state instead.
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation:\s*none;/s);
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*width:\s*100%;/s);
assert.match(reducedMotion, /\.animate-spin\s*\{[^}]*animation:\s*none;/s);
assert.doesNotMatch(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation-duration:/s);
assert.doesNotMatch(reducedMotion, /\.animate-spin\s*\{[^}]*animation-duration:/s);
});
test('the looping delete indicator keeps its animation for everyone else', () => {
const beforeMediaQuery = css.slice(0, css.indexOf('@media (prefers-reduced-motion'));
assert.match(
beforeMediaQuery,
/\.animate-indeterminate\s*\{\s*animation:\s*indeterminate-sweep/s,
);
});
+62
View File
@@ -0,0 +1,62 @@
import { Window } from 'happy-dom';
/**
* Installs a happy-dom window as the global DOM for React tests and returns a
* function that restores the previous globals.
*/
export function installDom(): () => void {
const window = new Window();
const properties = {
window,
document: window.document,
HTMLElement: window.HTMLElement,
ResizeObserver: window.ResizeObserver,
IS_REACT_ACT_ENVIRONMENT: true,
};
const previous = Object.keys(properties).map(
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
);
for (const [key, value] of Object.entries(properties)) {
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
}
return () => {
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
window.happyDOM.abort();
};
}
/** Installs an in-memory `localStorage` seeded with `initial`; returns the restore function. */
export function installLocalStorage(initial: Record<string, string> = {}): () => void {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
key: (index: number) => Array.from(values.keys())[index] ?? null,
removeItem: (key: string) => values.delete(key),
setItem: (key: string, value: string) => values.set(key, value),
},
});
return () => {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else Reflect.deleteProperty(globalThis, 'localStorage');
};
}
/** Runs `run` with an in-memory `localStorage` seeded with `initial`. */
export function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const restore = installLocalStorage(initial);
try {
return run();
} finally {
restore();
}
}