Compare commits

...

2 Commits

Author SHA1 Message Date
sudacode 95e0abc7b7 test(immersion): apply runtime SQLite pragmas in query tests
The query test suite opened each temp database with SQLite's defaults
(rollback journal, synchronous=FULL) while the runtime opens them via
applyPragmas (WAL, synchronous=NORMAL). Every statement therefore ran as
its own fsync-ing transaction.

That is unnoticeable on a fast local disk but dominates on CI: the file
took 40.6s there versus 0.5s locally, and the 106-insert case
'getVocabularyStats pages past hidden rows' crossed the 5s per-test
timeout and failed the quality gate.

Open test databases through a helper that applies the same pragmas as
production, so the tests exercise the runtime's actual SQLite
configuration instead of a slower one.
2026-07-28 02:23:16 -07:00
sudacode d9155ceacb docs: update requirements table and Windows install instructions
- Add TsukiHime and AniSkip to feature table; note xz dependency
- Clarify Anki+AnkiConnect is required to mine, not to run
- Rework Windows setup with winget/scoop commands and PATH steps
- Note ffmpeg has no path-override setting, unlike mpv
- Fix screenshot alt text and installation.md anchor link
2026-07-28 02:10:50 -07:00
3 changed files with 119 additions and 82 deletions
+35 -7
View File
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
<div align="center">
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
</div>
<br>
@@ -90,6 +90,14 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
<td><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td>
</tr>
<tr>
<td><b>TsukiHime</b></td>
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
</tr>
<tr>
<td><b>AniSkip</b></td>
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
</tr>
<tr>
<td><b>alass / ffsubsync</b></td>
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
@@ -110,18 +118,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
| Dependency | Status | What it does |
| -------------------- | ----------- | ---------------------------------------- |
| -------------------- | ---------------- | -------------------------------------------------------- |
| mpv | Required | The video player SubMiner overlays on |
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| yt-dlp | Optional | YouTube playback |
| fzf / rofi | Optional | Video picker in the launcher |
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
| alass / ffsubsync | Optional | Subtitle sync |
| guessit | Optional | Better anime title and episode detection |
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
<details>
<summary><b>Platform-specific install commands</b></summary>
@@ -138,9 +147,23 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
brew install mpv ffmpeg mecab mecab-ipadic
```
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
**Windows:**
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
```powershell
winget install shinchiro.mpv
winget install Gyan.FFmpeg
```
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
```powershell
scoop bucket add extras
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
```
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
</details>
@@ -166,6 +189,11 @@ paru -S subminer-bin
mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.local/bin/SubMiner.AppImage
```
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
+6 -8
View File
@@ -183,12 +183,8 @@ Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -ve
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
3. Press `Win`, type **Edit the system environment variables**, and open it.
4. Click **Environment Variables…**
5. Under **User variables**, select **Path** and click **Edit…**
6. Click **New** and paste the folder containing `mpv.exe`. Click **New** again and paste the folder containing `ffmpeg.exe`.
7. Click **OK** on all three dialogs.
8. Close and reopen your terminal (PATH changes only apply to newly opened windows), then check:
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
```powershell
mpv --version
@@ -197,8 +193,10 @@ ffmpeg -version
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
::: tip Don't want to touch PATH at all?
mpv is the only hard requirement, and you can point SubMiner straight at it instead: leave `PATH` alone and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup. ffmpeg still needs to be on `PATH` for card audio and screenshots.
::: tip mpv can skip PATH, ffmpeg cannot
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
:::
</details>
@@ -5,6 +5,7 @@ import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import {
applyPragmas,
createTrackerPreparedStatements,
ensureSchema,
getOrCreateAnimeRecord,
@@ -57,6 +58,16 @@ function makeDbPath(): string {
return path.join(dir, 'immersion.sqlite');
}
// Match the runtime's SQLite tuning (WAL + synchronous=NORMAL). Without it these
// tests run at SQLite's defaults, where every statement is its own fsync-ing
// transaction; on slow CI disks that is enough to push insert-heavy cases past
// the 5s per-test timeout.
function openTestDb(dbPath: string) {
const db = new Database(dbPath);
applyPragmas(db);
return db;
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
@@ -172,7 +183,7 @@ function withMockNowMs<T>(fixedDateMs: string | number, run: () => T): T {
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -231,7 +242,7 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -299,7 +310,7 @@ test('getAnimeEpisodes prefers the latest session media position when the latest
test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -397,7 +408,7 @@ test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -469,7 +480,7 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -541,7 +552,7 @@ test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to
test('getSessionTimeline returns the full session when no limit is provided', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -592,7 +603,7 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
test('getDailyRollups limits by distinct days (not rows)', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -625,7 +636,7 @@ test('getDailyRollups limits by distinct days (not rows)', () => {
test('getTrendsDashboard returns chart-ready aggregated series', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -779,7 +790,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -869,7 +880,7 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1005,7 +1016,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1069,7 +1080,7 @@ test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1123,7 +1134,7 @@ test(
{ timeout: 20_000 },
() => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1183,7 +1194,7 @@ test(
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
@@ -1406,7 +1417,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
test('getQueryHints reads all-time totals from lifetime summary', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1476,7 +1487,7 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
withMockNowMs('1773601200000', () => {
try {
@@ -1528,7 +1539,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
test('word-count read models use filtered persisted occurrences with raw fallback', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1611,7 +1622,7 @@ test('word-count read models use filtered persisted occurrences with raw fallbac
test('rollups keep persisted totals when retained-session word counts are partial', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1672,7 +1683,7 @@ test('rollups keep persisted totals when retained-session word counts are partia
test('getQueryHints counts new words by distinct headword first-seen time', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1737,7 +1748,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
test('getSessionSummaries with no telemetry returns zero aggregates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1771,7 +1782,7 @@ test('getSessionSummaries with no telemetry returns zero aggregates', () => {
test('getSessionSummaries uses denormalized session metrics for ended sessions without telemetry', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1823,7 +1834,7 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
test('getVocabularyStats returns rows ordered by frequency descending', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1866,7 +1877,7 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1921,7 +1932,7 @@ test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', ()
test('getVocabularyStats pages past hidden rows until enough visible rows are collected', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1956,7 +1967,7 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
test('getVocabularyStats returns empty array when no words exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -1970,7 +1981,7 @@ test('getVocabularyStats returns empty array when no words exist', () => {
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2100,7 +2111,7 @@ test('cleanupVocabularyStats repairs stored POS metadata and removes excluded im
test('getDailyRollups returns all rows for the most recent rollup days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2136,7 +2147,7 @@ test('getDailyRollups returns all rows for the most recent rollup days', () => {
test('getMonthlyRollups returns all rows for the most recent rollup months', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2167,7 +2178,7 @@ test('getMonthlyRollups returns all rows for the most recent rollup months', ()
test('getMonthlyRollups derives rate metrics from stored monthly totals', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2198,7 +2209,7 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
test('getAnimeDailyRollups returns all rows for the most recent rollup days', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const insertRollup = db.prepare(
@@ -2254,7 +2265,7 @@ test('getAnimeDailyRollups returns all rows for the most recent rollup days', ()
test('cleanupVocabularyStats merges repaired duplicates instead of violating the imm_words unique key', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2367,7 +2378,7 @@ test('cleanupVocabularyStats merges repaired duplicates instead of violating the
test('getKanjiStats returns rows ordered by frequency descending', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2405,7 +2416,7 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
test('getKanjiStats returns empty array when no kanji exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2419,7 +2430,7 @@ test('getKanjiStats returns empty array when no kanji exist', () => {
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2480,7 +2491,7 @@ test('getSessionEvents returns events ordered by ts_ms ascending', () => {
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2524,7 +2535,7 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
test('getSessionEvents returns empty array for session with no events', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2547,7 +2558,7 @@ test('getSessionEvents returns empty array for session with no events', () => {
test('getSessionEvents filters events to the requested session id', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2613,7 +2624,7 @@ test('getSessionEvents filters events to the requested session id', () => {
test('getSessionEvents respects limit parameter', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2656,7 +2667,7 @@ test('getSessionEvents respects limit parameter', () => {
test('getSessionEvents filters by event type before applying limit', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2728,7 +2739,7 @@ test('getSessionEvents filters by event type before applying limit', () => {
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -2803,7 +2814,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
test('anime-level queries group by anime_id and preserve episode-level rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3083,7 +3094,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
test('anime library and detail still return lifetime rows without retained sessions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3180,7 +3191,7 @@ test('anime library and detail still return lifetime rows without retained sessi
test('anime and media detail prefer lifetime totals over partial retained sessions', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3270,7 +3281,7 @@ test('anime and media detail prefer lifetime totals over partial retained sessio
test('media detail resolves retained sessions before lifetime summary exists', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3323,7 +3334,7 @@ test('media detail resolves retained sessions before lifetime summary exists', (
test('media library and detail queries read lifetime totals', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3415,7 +3426,7 @@ test('media library and detail queries read lifetime totals', () => {
test('media library and detail queries include joined youtube metadata when present', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3512,7 +3523,7 @@ test('media library and detail queries include joined youtube metadata when pres
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3639,7 +3650,7 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3685,7 +3696,7 @@ test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', (
test('anime/media detail and episode queries use ended-session metrics when telemetry rows are absent', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3827,7 +3838,7 @@ test('anime/media detail and episode queries use ended-session metrics when tele
test('getWordOccurrences maps a normalized word back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3902,7 +3913,7 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -3999,7 +4010,7 @@ test('searchSubtitleSentences searches known subtitle lines and returns media co
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4083,7 +4094,7 @@ test('searchSubtitleSentences searches subtitle lines by resolved headword candi
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4158,7 +4169,7 @@ test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line c
test('deleteSession removes the session and all associated session-scoped rows', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4306,7 +4317,7 @@ test('deleteSession removes the session and all associated session-scoped rows',
test('deleteSession rebuilds word and kanji aggregates from retained subtitle lines', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4462,7 +4473,7 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
test('deleteSession removes zero-session media from library and trends', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4667,7 +4678,7 @@ test('deleteSession removes zero-session media from library and trends', () => {
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4774,7 +4785,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4856,7 +4867,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
test('getTrendsDashboard rollup word metrics keep persisted totals over partial session counts', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);
@@ -4971,7 +4982,7 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
const db = openTestDb(dbPath);
try {
ensureSchema(db);