diff --git a/AGENTS.md b/AGENTS.md
index ea2191a8..516d7a8f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,7 @@ Start here, then leave this file.
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
+- Testing guidelines: [`docs/workflow/testing.md`](./docs/workflow/testing.md)
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
@@ -47,7 +48,7 @@ Start here, then leave this file.
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
-- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
+- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build`
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
## Docs Upkeep
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f064625f..da526ce2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# Changelog
+## v0.20.1 (2026-10-03)
+
+### Added
+- **YouTube Browser Window**:
+ - Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
+ - Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
+ - Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
+ - Closing the window quits a SubMiner started by `subminer youtube`.
+- **Whisper Subtitles for YouTube**:
+ - Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
+ - The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
+ - The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
+- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
+- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
+
+### Changed
+- **Release Assets**: Releases no longer include package-size reports.
+
+### Fixed
+- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
+- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
+- **Jellyfin**:
+ - The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
+ - Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
+ - Timing review previews and mined card audio use the audio track you are playing.
+- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
+- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
+- **Stats**:
+ - Mined cards are counted once in session and lifetime stats.
+ - Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
+ - Overview totals, charts, and the activity calendar refresh after deleting sessions.
+- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
+- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
+- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
+
+### Docs
+- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
+- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
+
+
+Internal changes
+
+### Internal
+- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
+- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
+- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
+- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
+
+
+
## v0.20.0 (2026-09-23)
### Added
diff --git a/bun.lock b/bun.lock
index 828ba640..b9768c5a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -467,7 +467,7 @@
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
- "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
+ "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
diff --git a/changes/README.md b/changes/README.md
index a3eb3989..3a04f5f5 100644
--- a/changes/README.md
+++ b/changes/README.md
@@ -42,7 +42,7 @@ How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
-- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
+- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
diff --git a/changes/appimage-sandboxed-launch.md b/changes/appimage-sandboxed-launch.md
deleted file mode 100644
index 2868cea6..00000000
--- a/changes/appimage-sandboxed-launch.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: linux
-
-- Fixed the AppImage exiting without opening a window when launched inside a sandbox that mounts the image itself, such as `firejail --appimage` (used by the AppImage catalog test). The detached background app now runs straight from the sandbox's squashfs mount instead of trying to re-mount the AppImage over FUSE.
diff --git a/changes/closed-pipe-crash.md b/changes/closed-pipe-crash.md
deleted file mode 100644
index 06bad486..00000000
--- a/changes/closed-pipe-crash.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: app
-
-- SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
diff --git a/changes/docs-site-simplify.md b/changes/docs-site-simplify.md
deleted file mode 100644
index 5ee27e0c..00000000
--- a/changes/docs-site-simplify.md
+++ /dev/null
@@ -1,7 +0,0 @@
-type: docs
-area: docs
-
-- Rewrote the docs site to be shorter and easier to scan: pages lead with setup and use, reference material lives in compact tables, and internal detail was cut from user pages.
-- The configuration reference now has a short explanation and a key/default table for each config block.
-- Fixed docs that no longer matched current behavior.
-- Fixed the Arch Linux install command for MeCab, which is only in the AUR (`mecab-git`), and brought the README requirements and quick start in line with the installation guide.
diff --git a/changes/electron43-profile-storage-safety.md b/changes/electron43-profile-storage-safety.md
deleted file mode 100644
index b81570ae..00000000
--- a/changes/electron43-profile-storage-safety.md
+++ /dev/null
@@ -1,6 +0,0 @@
-type: fixed
-area: dictionary
-
-- Upgraded the desktop runtime to Electron 43.7.7 and added profile guards that block unsupported runtimes and Electron major or minor downgrades before Yomitan storage is loaded. Patch downgrades within the same major and minor version are allowed.
-- Development launches now use a separate `SubMiner-dev` profile unless production-profile access is explicitly requested.
-- Automatic character-dictionary changes now stop when a previously non-empty Yomitan profile suddenly reports zero dictionaries.
diff --git a/changes/hyprland-fullscreen-refresh.md b/changes/hyprland-fullscreen-refresh.md
deleted file mode 100644
index d6d7e97c..00000000
--- a/changes/hyprland-fullscreen-refresh.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Keep the subtitle overlay mapped during fullscreen transitions on Hyprland, avoiding focus changes from repeated hide/show calls that can cancel mpv fullscreen entry. Preserve the hide/show refresh on Sway.
diff --git a/changes/hyprland-x11-fullscreen-overlay-input.md b/changes/hyprland-x11-fullscreen-overlay-input.md
deleted file mode 100644
index c0860632..00000000
--- a/changes/hyprland-x11-fullscreen-overlay-input.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Fixed the overlay (subtitles and sidebar) becoming unclickable after mpv goes fullscreen on Hyprland with `mpv.backend: x11`. The launcher no longer strips `HYPRLAND_INSTANCE_SIGNATURE` from the X11 environment, so the overlay can lift Hyprland's fullscreen input block again.
diff --git a/changes/jellyfin-fast-subtitle-selection.md b/changes/jellyfin-fast-subtitle-selection.md
deleted file mode 100644
index 0bd9bec5..00000000
--- a/changes/jellyfin-fast-subtitle-selection.md
+++ /dev/null
@@ -1,5 +0,0 @@
-type: fixed
-area: jellyfin
-
-- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
-- Jellyfin episodes with image-based embedded subtitles (PGS, DVD, DVB) now auto-select the Japanese and English tracks. SubMiner no longer requests those tracks as text, and a subtitle track that fails to download no longer cancels selection of the others.
diff --git a/changes/jellyfin-review-audio-track.md b/changes/jellyfin-review-audio-track.md
deleted file mode 100644
index f4eeffa4..00000000
--- a/changes/jellyfin-review-audio-track.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: jellyfin
-
-- Timing review previews and mined card audio now use the audio track you are playing on Jellyfin direct-play streams, instead of the track Jellyfin marks as default.
diff --git a/changes/mpv-key-forwarding-late-connect.md b/changes/mpv-key-forwarding-late-connect.md
deleted file mode 100644
index 2139bc82..00000000
--- a/changes/mpv-key-forwarding-late-connect.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- Fixed mpv key bindings (input.conf and mpv defaults, e.g. `9`/`0` volume) doing nothing while the overlay had focus when the overlay loaded before SubMiner connected to mpv, common in `mpv.backend: x11` mode. The overlay now reloads mpv's bindings once mpv connects.
diff --git a/changes/overlay-scroll-wheel-bindings.md b/changes/overlay-scroll-wheel-bindings.md
deleted file mode 100644
index ab5e4d4d..00000000
--- a/changes/overlay-scroll-wheel-bindings.md
+++ /dev/null
@@ -1,5 +0,0 @@
-type: added
-area: overlay
-
-- Added scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`, with modifiers) to `keybindings`, including capture from the settings key editor.
-- Mouse button and scroll wheel bindings from mpv (`input.conf` and mpv defaults, e.g. double-click fullscreen, wheel volume, back/forward for playlist) now work while the cursor is over the overlay. On Hyprland, where the overlay always receives input, they previously did nothing. SubMiner bindings and the right-click pause still take priority.
diff --git a/changes/patched-electron-undici.md b/changes/patched-electron-undici.md
deleted file mode 100644
index 65cca7fd..00000000
--- a/changes/patched-electron-undici.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: dependencies
-
-- Updated Electron to 42.10.0 and undici to 7.29.1 to resolve the security advisories blocking CI.
diff --git a/changes/remove-package-size-reports.md b/changes/remove-package-size-reports.md
deleted file mode 100644
index e21b3c44..00000000
--- a/changes/remove-package-size-reports.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: changed
-area: release
-
-- Removed package-size JSON reports from future releases and their CI size comparisons. Package-content validation remains enabled.
diff --git a/changes/session-help-run-commands.md b/changes/session-help-run-commands.md
deleted file mode 100644
index 7b7d8351..00000000
--- a/changes/session-help-run-commands.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: added
-area: overlay
-
-- Session help can run the highlighted command during playback: press `Enter` or double-click a row. Hovering a row with the mouse makes it the `Enter` target. Help opened from the tray with no video loaded stays read-only.
diff --git a/changes/setup-launcher-badge-label.md b/changes/setup-launcher-badge-label.md
new file mode 100644
index 00000000..0bed41f1
--- /dev/null
+++ b/changes/setup-launcher-badge-label.md
@@ -0,0 +1,4 @@
+type: fixed
+area: setup
+
+- First-run setup now labels a missing command-line launcher "Not set up" instead of "Not installed", so the AppImage catalog test no longer reads the setup window as an error screen.
diff --git a/changes/timing-review-macos-cursor.md b/changes/timing-review-macos-cursor.md
deleted file mode 100644
index f7a7d370..00000000
--- a/changes/timing-review-macos-cursor.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: overlay
-
-- On macOS with Bluetooth headphones such as AirPods, the timing review preview cursor now moves smoothly across the selection instead of stuttering and jumping to the end partway through. This affected Opus audio, including Jellyfin streams.
diff --git a/changes/timing-review-thin-handles.md b/changes/timing-review-thin-handles.md
deleted file mode 100644
index c5b08e0e..00000000
--- a/changes/timing-review-thin-handles.md
+++ /dev/null
@@ -1,5 +0,0 @@
-type: fixed
-area: overlay
-
-- Media timing review trim handles are now thin lines centered on the clip edge, so it's clear where the clip starts and ends and less of the waveform is covered.
-- Original subtitle boundaries use orange bars without text tags, leaving the waveform unobstructed.
diff --git a/changes/youtube-browser-window.md b/changes/youtube-browser-window.md
deleted file mode 100644
index 3b33ac24..00000000
--- a/changes/youtube-browser-window.md
+++ /dev/null
@@ -1,7 +0,0 @@
-type: added
-area: youtube
-
-- Added a YouTube browser window (`subminer youtube`/`subminer yt`, or tray **Browse YouTube**) that keeps your YouTube login across restarts.
-- Clicking a video plays it in mpv instead of the page; middle-click, Shift/Ctrl-click, or the right-click menu queues it in mpv's playlist.
-- Queued YouTube videos get the same Japanese subtitle setup when mpv reaches them.
-- Closing the YouTube window quits a SubMiner started by `subminer youtube` (after mpv closes, if a video is still playing).
diff --git a/changes/youtube-translated-caption-429.md b/changes/youtube-translated-caption-429.md
deleted file mode 100644
index aa6acfd1..00000000
--- a/changes/youtube-translated-caption-429.md
+++ /dev/null
@@ -1,4 +0,0 @@
-type: fixed
-area: youtube
-
-- YouTube Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track; SubMiner now picks the real track and falls back to yt-dlp if a direct subtitle download is refused.
diff --git a/changes/youtube-whisper-subtitles.md b/changes/youtube-whisper-subtitles.md
deleted file mode 100644
index ae3cc246..00000000
--- a/changes/youtube-whisper-subtitles.md
+++ /dev/null
@@ -1,7 +0,0 @@
-type: added
-area: youtube
-
-- Added `youtube.subtitleSource`: set it to `whisper` to transcribe YouTube videos with Whisper instead of downloading YouTube's captions. The default stays `youtube`.
-- Whisper mode keeps the video paused while it transcribes, with progress in the subtitle generation modal; close the modal to keep watching, or cancel to continue without subtitles.
-- The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos, whatever `youtube.subtitleSource` is set to.
-- Whisper mode downloads a small audio-only stream and deletes it once generation ends, or when you switch videos, close mpv, or quit.
diff --git a/docs-site/changelog.md b/docs-site/changelog.md
index 4717bb91..0eac81a4 100644
--- a/docs-site/changelog.md
+++ b/docs-site/changelog.md
@@ -1,5 +1,55 @@
# Changelog
+## v0.20.1 (2026-10-03)
+
+**Added**
+- **YouTube Browser Window**:
+ - Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
+ - Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
+ - Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
+ - Closing the window quits a SubMiner started by `subminer youtube`.
+- **Whisper Subtitles for YouTube**:
+ - Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
+ - The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
+ - The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
+- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
+- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
+
+**Changed**
+- **Release Assets**: Releases no longer include package-size reports.
+
+**Fixed**
+- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
+- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
+- **Jellyfin**:
+ - The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
+ - Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
+ - Timing review previews and mined card audio use the audio track you are playing.
+- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
+- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
+- **Stats**:
+ - Mined cards are counted once in session and lifetime stats.
+ - Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
+ - Overview totals, charts, and the activity calendar refresh after deleting sessions.
+- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
+- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
+- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
+
+**Docs**
+- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
+- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
+
+
+Internal changes
+
+**Internal**
+- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
+- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
+- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
+- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
+
+
+
## v0.20.0 (2026-09-23)
**Added**
diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md
index dce70d03..ce281114 100644
--- a/docs-site/immersion-tracking.md
+++ b/docs-site/immersion-tracking.md
@@ -42,6 +42,8 @@ To share stats and watch history between machines, use [`subminer sync `](
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
+Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
+

### Library
@@ -124,4 +126,6 @@ Leave out `--lookback-days` to scan all history. Word and kanji counts are corre
By default SubMiner keeps everything. To limit history, set `immersionTracking.retentionPreset` to `minimal`, `balanced`, or `deep-history`, or set the `immersionTracking.retention.*Days` values yourself (`0` keeps all). Lifetime totals and vocabulary counts are stored separately and stay exact when old sessions are pruned.
+Deleting retained sessions preserves earlier word and kanji discovery dates from pruned history. New occurrences record observation time so deletion can update their dates exactly. Older history used write time, so an earlier discovery date is preserved when its origin cannot be determined.
+
See [Immersion tracking](/configuration#immersion-tracking) and [Stats dashboard](/configuration#stats-dashboard) in the config reference for every option and default.
diff --git a/docs/knowledge-base/catalog.md b/docs/knowledge-base/catalog.md
index d0120cad..bf605db7 100644
--- a/docs/knowledge-base/catalog.md
+++ b/docs/knowledge-base/catalog.md
@@ -21,6 +21,7 @@ Read when: finding internal docs or checking verification status
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership |
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
+| Testing guidelines | `docs/workflow/testing.md` | active | 2026-10-04 | test style and anti-patterns |
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
## Update Rules
diff --git a/docs/workflow/README.md b/docs/workflow/README.md
index b89a6d9b..7dbf4143 100644
--- a/docs/workflow/README.md
+++ b/docs/workflow/README.md
@@ -13,6 +13,7 @@ This section is the internal workflow map for contributors and agents.
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
+- [Testing](./testing.md) - what a good test looks like and what not to write
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
- [Release Guide](../RELEASING.md) - tagged release workflow
diff --git a/docs/workflow/testing.md b/docs/workflow/testing.md
new file mode 100644
index 00000000..ac36d2ed
--- /dev/null
+++ b/docs/workflow/testing.md
@@ -0,0 +1,49 @@
+
+
+# Testing Guidelines
+
+Status: active
+Last verified: 2026-10-04
+Owner: Kyle Yasuda
+Read when: adding or changing tests
+
+Which lanes to run lives in [Verification](./verification.md). This page covers what a
+good test looks like here.
+
+## Test Behavior, Not Wiring
+
+- Exercise a module through its public interface and assert outcomes: return values,
+ resulting state, files written, rows in sqlite, messages sent to a fake process.
+- Prefer real collaborators that are cheap: tmp dirs, in-memory or tmp sqlite, fake
+ executables, pure parsers. Fake only the edges (Electron, mpv socket, network).
+- Assert call order only when order is the behavior (startup sequencing, ABA races),
+ and then assert relative order, not the whole call log.
+
+## Do Not Write
+
+- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`,
+ `*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
+- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
+ and asserting with regexes. If the invariant matters, extract the logic and run it
+ (see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
+ exception and belong in one parsed-YAML check.
+- "Stays deleted" tests for removed modules, flags, or selectors. `tsc` and review
+ catch reintroduction.
+- Tests that restate constants, defaults, labels, or window options.
+- The same rule at several layers. Test a rule once where it lives; keep one
+ end-to-end case (for the tokenizer, the golden corpus) for integration.
+
+## Keep Tests Small
+
+- Shared setup goes in a `makeDeps(overrides)` helper or a `*-test-harness.ts` file, not
+ pasted into every test.
+- Near-identical cases become a table: `for (const c of cases) test(c.name, ...)`.
+- Inject timers, clocks, and loggers instead of patching globals or private members.
+- A bug fix gets a regression test for the behavior that broke, named after the
+ behavior, not the ticket.
+
+## Known Debt
+
+- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
+ regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
+ behavioral one as the logic moves out of `main.ts` closures.
diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md
index fbdceedd..1cef340f 100644
--- a/docs/workflow/verification.md
+++ b/docs/workflow/verification.md
@@ -14,8 +14,9 @@ Read when: selecting the right verification lane for a change
files in `package.json`.
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
(per-file isolation with a wall timeout) so a hanging test or leaked global in
- one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
- `--single-process` restores the shared-process mode for debugging.
+ one file cannot cascade into the rest of the lane. Files run in parallel, one
+ worker per CPU by default; `--jobs 1` runs serially and `--single-process`
+ restores the shared-process mode for debugging.
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
unit, and `scripts/**`.
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
@@ -71,7 +72,7 @@ bun run docs:build
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
`test:src` through a sharded coverage runner: one Bun coverage process per test
- file, then merged LCOV output.
+ file (parallel, one per CPU unless `--jobs N`), then merged LCOV output.
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
source test gate, not a report-only step.
- Machine-readable output lands at `coverage/test-src/lcov.info`.
diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts
index bc56cf8b..2b8b96fc 100644
--- a/launcher/commands/command-modules.test.ts
+++ b/launcher/commands/command-modules.test.ts
@@ -150,74 +150,7 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
);
});
-test('doctor command forwards refresh-known-words to app binary', () => {
- const context = createContext();
- context.args.doctor = true;
- context.args.doctorRefreshKnownWords = true;
- const forwarded: string[][] = [];
-
- const handled = runDoctorCommand(context, {
- commandExists: () => false,
- configExists: () => true,
- resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
- runAppCommandWithInherit: (_appPath, appArgs) => {
- forwarded.push(appArgs);
- },
- });
-
- assert.equal(handled, true);
- assert.deepEqual(forwarded, [['--refresh-known-words']]);
-});
-
-test('logs command exports logs and writes archive path', () => {
- const writes: string[] = [];
- const context = createContext();
- context.args.logsExport = true;
- context.processAdapter = {
- ...context.processAdapter,
- writeStdout: (text) => writes.push(text),
- };
-
- const handled = runLogsCommand(context, {
- exportLogsArchive: () => ({
- zipPath: '/tmp/subminer-logs.zip',
- exportedFiles: ['/tmp/app.log'],
- mode: 'current-day',
- }),
- });
-
- assert.equal(handled, true);
- assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
-});
-
-test('logs command ignores unrelated launcher commands', () => {
- const context = createContext();
-
- assert.equal(runLogsCommand(context), false);
-});
-
-test('app command starts default macOS background app detached from launcher', () => {
- const context = createContext();
- context.args.appPassthrough = true;
- context.args.appArgs = [];
- const calls: string[] = [];
-
- const handled = runAppPassthroughCommand(context, {
- runAppCommandWithInherit: () => {
- calls.push('attached');
- },
- launchSyncUiDetached: () => calls.push('sync-ui'),
- launchYoutubeBrowserDetached: () => calls.push('youtube'),
- launchAppBackgroundDetached: (appPath, logLevel) => {
- calls.push(`detached:${appPath}:${logLevel}`);
- },
- });
-
- assert.equal(handled, true);
- assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
-});
-
-test('app command starts default Linux background app detached from launcher', () => {
+test('app command starts default background app detached from launcher app detached from launcher', () => {
const context = createContext();
context.args.appPassthrough = true;
context.args.appArgs = [];
@@ -389,49 +322,6 @@ test('dictionary command forwards candidate and selection modes to app binary',
]);
});
-test('dictionary command returns after app handoff starts', () => {
- const context = createContext();
- context.args.dictionary = true;
-
- const handled = runDictionaryCommand(context, {
- runAppCommandWithInherit: () => undefined,
- });
-
- assert.equal(handled, true);
-});
-
-test('update command runs direct Linux release update without launching Electron', async () => {
- const context = createContext();
- context.args.update = true;
- const calls: string[] = [];
-
- const handled = await runUpdateCommand(context, {
- runAppCommandCaptureOutput: () => {
- throw new Error('unexpected Electron launch');
- },
- runDirectReleaseUpdate: async (request) => {
- calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
- return {
- appImage: { status: 'not-found' },
- launcher: { status: 'updated' },
- supportAssets: [{ status: 'skipped' }],
- };
- },
- readMainConfig: () => null,
- log: (level, _configured, message) => {
- calls.push(`${level}:${message}`);
- },
- });
-
- assert.equal(handled, true);
- assert.deepEqual(calls, [
- 'direct:/tmp/subminer.app:/tmp/subminer:stable',
- 'info:AppImage update: not-found',
- 'info:Launcher update: updated',
- 'info:Support assets update: skipped',
- ]);
-});
-
test('stats command launches attached app command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
@@ -449,16 +339,41 @@ test('stats command launches attached app command with response path', async ()
assert.equal(harness.removedPaths.length, 1);
});
-test('stats background command launches attached daemon control command with response path', async () => {
- const harness = createStatsTestHarness({ stats: true, statsBackground: true });
- const handled = await runStatsCommand(harness.context, harness.commandDeps);
+const STATS_RESPONSE_ARGS = ['--stats-response-path', '/tmp/subminer-stats-test/response.json'];
- assert.equal(handled, true);
- assert.deepEqual(harness.forwarded, [
- ['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
- ]);
- assert.equal(harness.removedPaths.length, 1);
-});
+const statsForwardingCases = [
+ {
+ name: 'stats background command launches the daemon start command',
+ overrides: { statsBackground: true },
+ expected: ['--stats-daemon-start', ...STATS_RESPONSE_ARGS],
+ },
+ {
+ name: 'stats stop command forwards the daemon stop flag',
+ overrides: { statsStop: true },
+ expected: ['--stats-daemon-stop', ...STATS_RESPONSE_ARGS],
+ },
+ {
+ name: 'stats cleanup command forwards cleanup vocab flags',
+ overrides: { statsCleanup: true, statsCleanupVocab: true },
+ expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-vocab'],
+ },
+ {
+ name: 'stats cleanup command forwards the lifetime rebuild flag',
+ overrides: { statsCleanup: true, statsCleanupLifetime: true },
+ expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-lifetime'],
+ },
+];
+
+for (const c of statsForwardingCases) {
+ test(`${c.name} to the app`, async () => {
+ const harness = createStatsTestHarness({ stats: true, ...c.overrides });
+ const handled = await runStatsCommand(harness.context, harness.commandDeps);
+
+ assert.equal(handled, true);
+ assert.deepEqual(harness.forwarded, [c.expected]);
+ assert.equal(harness.removedPaths.length, 1);
+ });
+}
test('stats command waits for attached app exit after startup response', async () => {
const harness = createStatsTestHarness({ stats: true });
@@ -503,45 +418,6 @@ test('stats command throws when attached app exits non-zero after startup respon
assert.equal(harness.removedPaths.length, 1);
});
-test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
- const harness = createStatsTestHarness({
- stats: true,
- statsCleanup: true,
- statsCleanupVocab: true,
- });
- const handled = await runStatsCommand(harness.context, {
- ...harness.commandDeps,
- waitForStatsResponse: async () => ({ ok: true }),
- });
-
- assert.equal(handled, true);
- assert.deepEqual(harness.forwarded, [
- [
- '--stats',
- '--stats-response-path',
- '/tmp/subminer-stats-test/response.json',
- '--stats-cleanup',
- '--stats-cleanup-vocab',
- ],
- ]);
- assert.equal(harness.removedPaths.length, 1);
-});
-
-test('stats stop command forwards stop flag to the app', async () => {
- const harness = createStatsTestHarness({ stats: true, statsStop: true });
-
- const handled = await runStatsCommand(harness.context, {
- ...harness.commandDeps,
- waitForStatsResponse: async () => ({ ok: true }),
- });
-
- assert.equal(handled, true);
- assert.deepEqual(harness.forwarded, [
- ['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
- ]);
- assert.equal(harness.removedPaths.length, 1);
-});
-
test('stats stop command exits on process exit without waiting for startup response', async () => {
const harness = createStatsTestHarness({ stats: true, statsStop: true });
let waitedForResponse = false;
@@ -563,30 +439,6 @@ test('stats stop command exits on process exit without waiting for startup respo
assert.equal(harness.removedPaths.length, 1);
});
-test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
- const harness = createStatsTestHarness({
- stats: true,
- statsCleanup: true,
- statsCleanupLifetime: true,
- });
- const handled = await runStatsCommand(harness.context, {
- ...harness.commandDeps,
- waitForStatsResponse: async () => ({ ok: true }),
- });
-
- assert.equal(handled, true);
- assert.deepEqual(harness.forwarded, [
- [
- '--stats',
- '--stats-response-path',
- '/tmp/subminer-stats-test/response.json',
- '--stats-cleanup',
- '--stats-cleanup-lifetime',
- ],
- ]);
- assert.equal(harness.removedPaths.length, 1);
-});
-
test('stats command throws when stats response reports an error', async () => {
const harness = createStatsTestHarness({ stats: true });
@@ -692,36 +544,3 @@ test('stats command aborts pending response wait when attached app fails to spaw
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
});
-
-test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
- const harness = createStatsTestHarness({
- stats: true,
- statsCleanup: true,
- statsCleanupVocab: true,
- });
- let aborted = false;
-
- await assert.rejects(async () => {
- await runStatsCommand(harness.context, {
- ...harness.commandDeps,
- runAppCommandAttached: async (...args) => {
- await harness.runAppCommandAttachedStub(...args);
- return 2;
- },
- waitForStatsResponse: async (_responsePath, signal) =>
- await new Promise((resolve) => {
- signal?.addEventListener(
- 'abort',
- () => {
- aborted = true;
- resolve({ ok: false, error: 'aborted' });
- },
- { once: true },
- );
- }),
- });
- }, /Stats app exited before startup response \(status 2\)\./);
-
- assert.equal(aborted, true);
- assert.equal(harness.removedPaths.length, 1);
-});
diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts
index f50ee97e..125bd54b 100644
--- a/launcher/commands/playback-command.test.ts
+++ b/launcher/commands/playback-command.test.ts
@@ -4,81 +4,53 @@ import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
+import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
-import { state } from '../mpv.js';
+import { state, type startMpv } from '../mpv.js';
+import { makeLauncherArgs } from '../test-support/args.js';
+import { withEnv } from '../test-support/env.js';
-function createContext(): LauncherCommandContext {
+type PlaybackDeps = Parameters[1];
+type StartMpvOptions = NonNullable[6]>;
+
+const YOUTUBE_URL = 'https://www.youtube.com/watch?v=65Ovd7t8sNw';
+
+function makePluginConfig(
+ overrides: Partial = {},
+): LauncherCommandContext['pluginRuntimeConfig'] {
return {
- args: {
- backend: 'auto',
- directory: '.',
- recursive: false,
- profile: '',
- startOverlay: false,
- youtubePrimarySubLangs: [],
- youtubeSecondarySubLangs: [],
- youtubeAudioLangs: [],
- useTexthooker: false,
- autoStartOverlay: false,
- texthookerOnly: false,
- texthookerOpenBrowser: false,
- useRofi: false,
- history: false,
- sync: false,
- syncCliTokens: [],
- syncUi: false,
- logLevel: 'info',
- logRotation: 7,
- passwordStore: '',
- target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
+ socketPath: '/tmp/subminer.sock',
+ binaryPath: '',
+ backend: 'auto',
+ autoStart: true,
+ autoStartVisibleOverlay: true,
+ autoStartPauseUntilReady: true,
+ osdMessages: false,
+ texthookerEnabled: false,
+ ...overrides,
+ };
+}
+
+/** A context for a YouTube URL target with logging at info and CLI texthooker off. */
+function createContext(
+ options: {
+ args?: Partial;
+ plugin?: Partial;
+ } = {},
+): LauncherCommandContext {
+ return {
+ args: makeLauncherArgs({
+ target: YOUTUBE_URL,
targetKind: 'url',
- jimakuApiKey: '',
- jimakuApiKeyCommand: '',
- jimakuApiBaseUrl: '',
- jimakuLanguagePreference: 'ja',
- jimakuMaxEntryResults: 20,
- jellyfin: false,
- jellyfinLogin: false,
- jellyfinLogout: false,
- jellyfinPlay: false,
- jellyfinDiscovery: false,
- dictionary: false,
- dictionaryCandidates: false,
- dictionarySelect: false,
- stats: false,
- doctor: false,
- doctorRefreshKnownWords: false,
- logsExport: false,
- version: false,
- settings: false,
- youtubeBrowser: false,
- configPath: false,
- configShow: false,
- mpvIdle: false,
- mpvSocket: false,
- mpvStatus: false,
- mpvArgs: '',
- appPassthrough: false,
- appArgs: [],
- jellyfinServer: '',
- jellyfinUsername: '',
- jellyfinPassword: '',
- launchMode: 'normal',
- },
+ logLevel: 'info',
+ useTexthooker: false,
+ ...options.args,
+ }),
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
- pluginRuntimeConfig: {
- socketPath: '/tmp/subminer.sock',
- binaryPath: '',
- backend: 'auto',
- autoStart: true,
- autoStartVisibleOverlay: true,
- autoStartPauseUntilReady: true,
- osdMessages: false,
- texthookerEnabled: false,
- },
+ pluginRuntimeConfig: makePluginConfig(options.plugin),
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
processAdapter: {
@@ -93,8 +65,70 @@ function createContext(): LauncherCommandContext {
};
}
+const FILE_TARGET = { target: '/tmp/movie.mkv', targetKind: 'file' } as const;
+
+/**
+ * Playback harness. Deps are inert by default and record what the tests assert on: the
+ * options mpv was started with, the overlay launches, and an `events` log for the few cases
+ * where relative order is the behavior.
+ */
+function makeHarness(
+ options: {
+ context?: LauncherCommandContext;
+ deps?: Partial;
+ events?: string[];
+ } = {},
+) {
+ const context = options.context ?? createContext();
+ const events = options.events ?? [];
+ const startMpvOptions: StartMpvOptions[] = [];
+ const overlayCalls: Array<{ extraAppArgs: string[]; configDir?: string }> = [];
+ const deps: PlaybackDeps = {
+ ensurePlaybackSetupReady: async () => {},
+ ensureRuntimePluginReady: async () => {
+ events.push('plugin');
+ },
+ chooseTarget: async () => ({
+ target: context.args.target,
+ kind: context.args.targetKind === 'file' ? 'file' : 'url',
+ }),
+ checkDependencies: () => {},
+ registerCleanup: () => {},
+ startMpv: async (_target, _kind, _args, _socket, _appPath, _subtitles, startOptions) => {
+ events.push('startMpv');
+ if (startOptions) startMpvOptions.push(startOptions);
+ },
+ waitForUnixSocketReady: async () => true,
+ startOverlay: async (_appPath, _args, _socket, extraAppArgs = [], configDir) => {
+ events.push('startOverlay');
+ overlayCalls.push({ extraAppArgs, configDir });
+ },
+ launchAppCommandDetached: () => {},
+ log: () => {},
+ cleanupPlaybackSession: async () => {},
+ getMpvProc: () => null,
+ ...options.deps,
+ };
+ return {
+ context,
+ events,
+ startMpvOptions,
+ overlayCalls,
+ run: () => runPlaybackCommandWithDeps(context, deps),
+ };
+}
+
+function assertRanInOrder(events: string[], ...expected: string[]): void {
+ const positions = expected.map((event) => events.indexOf(event));
+ assert.ok(
+ positions.every(
+ (position, index) => position >= 0 && (index === 0 || position > positions[index - 1]!),
+ ),
+ `expected ${expected.join(' -> ')} in order, got ${events.join(', ')}`,
+ );
+}
+
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
- assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
@@ -108,121 +142,41 @@ test('playback cleanup signal handlers are registered once across repeated sessi
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
- const calls: string[] = [];
- const context = createContext();
- context.pluginRuntimeConfig = {
- ...context.pluginRuntimeConfig,
- autoStart: false,
- autoStartVisibleOverlay: false,
- autoStartPauseUntilReady: false,
- };
- const receivedStartMpvOptions: Record[] = [];
-
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {},
- chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
- checkDependencies: () => {},
- registerCleanup: () => {},
- startMpv: async (
- _target,
- _targetKind,
- _args,
- _socketPath,
- _appPath,
- _preloadedSubtitles,
- options,
- ) => {
- if (options) {
- receivedStartMpvOptions.push(options as Record);
- }
- calls.push('startMpv');
- },
- waitForUnixSocketReady: async () => true,
- startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
- calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
- },
- launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
- calls.push(`launch:${appArgs.join(' ')}`);
- },
- log: () => {},
- cleanupPlaybackSession: async () => {},
- getMpvProc: () => null,
+ const harness = makeHarness({
+ context: createContext({
+ plugin: {
+ autoStart: false,
+ autoStartVisibleOverlay: false,
+ autoStartPauseUntilReady: false,
+ },
+ }),
});
- assert.deepEqual(calls, [
- 'startMpv',
- 'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw',
- ]);
- assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
- assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
+ await harness.run();
+
+ assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
+ assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--youtube-play', YOUTUBE_URL]);
+ assert.equal(harness.startMpvOptions[0]?.startPaused, true);
+ assert.equal(harness.startMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
});
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
- const context = createContext();
- context.pluginRuntimeConfig = {
- ...context.pluginRuntimeConfig,
- autoStart: true,
- autoStartVisibleOverlay: true,
- autoStartPauseUntilReady: true,
- };
- const receivedStartMpvOptions: Record[] = [];
+ const harness = makeHarness();
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {},
- chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
- checkDependencies: () => {},
- registerCleanup: () => {},
- startMpv: async (
- _target,
- _targetKind,
- _args,
- _socketPath,
- _appPath,
- _preloadedSubtitles,
- options,
- ) => {
- if (options) {
- receivedStartMpvOptions.push(options as Record);
- }
- },
- waitForUnixSocketReady: async () => true,
- startOverlay: async () => {},
- launchAppCommandDetached: () => {},
- log: () => {},
- cleanupPlaybackSession: async () => {},
- getMpvProc: () => null,
- });
+ await harness.run();
- const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
- | { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
- | undefined;
+ const runtimeConfig = harness.startMpvOptions[0]?.runtimePluginConfig;
assert.equal(runtimeConfig?.autoStart, false);
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
});
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
- const context = createContext();
- context.args = {
- ...context.args,
- target: '/tmp/movie.mkv',
- targetKind: 'file',
- useTexthooker: true,
- };
- context.pluginRuntimeConfig = {
- socketPath: '/tmp/subminer.sock',
- binaryPath: '',
- backend: 'auto',
- autoStart: true,
- autoStartVisibleOverlay: false,
- autoStartPauseUntilReady: false,
- osdMessages: false,
- texthookerEnabled: false,
- };
- const appPath = context.appPath ?? '';
- state.appPath = appPath;
+ const context = createContext({
+ args: { ...FILE_TARGET, useTexthooker: true },
+ plugin: { autoStartVisibleOverlay: false, autoStartPauseUntilReady: false },
+ });
+ state.appPath = context.appPath ?? '';
state.overlayManagedByLauncher = false;
const mpvProc = new EventEmitter() as EventEmitter & {
exitCode: number | null;
@@ -233,31 +187,27 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
mpvProc.killed = false;
mpvProc.kill = () => true;
let cleanupSawManagedOverlay = true;
-
- try {
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {},
- chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
- checkDependencies: () => {},
- registerCleanup: () => {},
+ const harness = makeHarness({
+ context,
+ deps: {
startMpv: async () => {
setTimeout(() => {
mpvProc.exitCode = 0;
mpvProc.emit('exit', 0);
}, 5);
},
- waitForUnixSocketReady: async () => true,
startOverlay: async () => {
throw new Error('startOverlay should not run when plugin auto-start is used');
},
- launchAppCommandDetached: () => {},
- log: () => {},
cleanupPlaybackSession: async () => {
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
},
getMpvProc: () => mpvProc as NonNullable,
- });
+ },
+ });
+
+ try {
+ await harness.run();
assert.equal(cleanupSawManagedOverlay, false);
} finally {
@@ -267,258 +217,89 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
});
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
- const context = createContext();
- context.args = {
- ...context.args,
- target: '/tmp/movie.mkv',
- targetKind: 'file',
- useTexthooker: true,
- };
- context.pluginRuntimeConfig = {
- socketPath: '/tmp/subminer.sock',
- binaryPath: '',
- backend: 'auto',
- autoStart: true,
- autoStartVisibleOverlay: true,
- autoStartPauseUntilReady: true,
- osdMessages: false,
- texthookerEnabled: true,
- };
- const calls: string[] = [];
- const receivedStartMpvOptions: Record[] = [];
-
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {},
- chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
- checkDependencies: () => {},
- registerCleanup: () => {},
- startMpv: async (
- _target,
- _targetKind,
- _args,
- _socketPath,
- _appPath,
- _preloadedSubtitles,
- options,
- ) => {
- calls.push('startMpv');
- if (options) {
- receivedStartMpvOptions.push(options as Record);
- }
- },
- waitForUnixSocketReady: async () => true,
- startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
- calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
- },
- launchAppCommandDetached: () => {},
- log: () => {},
- cleanupPlaybackSession: async () => {},
- getMpvProc: () => null,
- isAppControlServerAvailable: async () => true,
- } as Parameters[1] & {
- isAppControlServerAvailable: () => Promise;
+ const harness = makeHarness({
+ context: createContext({
+ args: { ...FILE_TARGET, useTexthooker: true },
+ plugin: { texthookerEnabled: true },
+ }),
+ deps: { isAppControlServerAvailable: async () => true },
});
- assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
- assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
- assert.equal(
- (receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
- ?.autoStart,
- false,
- );
+ await harness.run();
+
+ assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
+ assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, [
+ '--show-visible-overlay',
+ '--texthooker',
+ ]);
+ assert.equal(harness.startMpvOptions[0]?.startPaused, true);
+ assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.autoStart, false);
});
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
- const context = createContext();
- const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
- const originalAppData = process.env.APPDATA;
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
fs.mkdirSync(expectedConfigDir, { recursive: true });
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
- context.args = {
- ...context.args,
- target: '/tmp/movie.mkv',
- targetKind: 'file',
- useTexthooker: true,
- };
- context.pluginRuntimeConfig = {
- socketPath: '/tmp/subminer.sock',
- binaryPath: '',
- backend: 'auto',
- autoStart: true,
- autoStartVisibleOverlay: true,
- autoStartPauseUntilReady: true,
- osdMessages: false,
- texthookerEnabled: true,
- };
let availabilityConfigDir: string | undefined;
- let overlayConfigDir: string | undefined;
- let overlayLoadingOsd: boolean | undefined;
-
- try {
- process.env.XDG_CONFIG_HOME = xdgConfigHome;
- process.env.APPDATA = xdgConfigHome;
-
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {},
- chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
- checkDependencies: () => {},
- registerCleanup: () => {},
- startMpv: async (
- _target,
- _targetKind,
- _args,
- _socketPath,
- _appPath,
- _preloadedSubtitles,
- options,
- ) => {
- overlayLoadingOsd = (
- options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
- )?.overlayLoadingOsd;
- },
- waitForUnixSocketReady: async () => true,
- startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
- overlayConfigDir = configDir;
- },
- launchAppCommandDetached: () => {},
- log: () => {},
- cleanupPlaybackSession: async () => {},
- getMpvProc: () => null,
+ const harness = makeHarness({
+ context: createContext({
+ args: { ...FILE_TARGET, useTexthooker: true },
+ plugin: { texthookerEnabled: true },
+ }),
+ deps: {
isAppControlServerAvailable: async (_logLevel, configDir) => {
availabilityConfigDir = configDir;
return true;
},
- });
+ },
+ });
+
+ try {
+ await withEnv({ XDG_CONFIG_HOME: xdgConfigHome, APPDATA: xdgConfigHome }, () => harness.run());
assert.equal(availabilityConfigDir, expectedConfigDir);
- assert.equal(overlayConfigDir, expectedConfigDir);
- assert.equal(overlayLoadingOsd, true);
+ assert.equal(harness.overlayCalls[0]?.configDir, expectedConfigDir);
+ assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true);
} finally {
- if (originalXdgConfigHome === undefined) {
- delete process.env.XDG_CONFIG_HOME;
- } else {
- process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
- }
- if (originalAppData === undefined) {
- delete process.env.APPDATA;
- } else {
- process.env.APPDATA = originalAppData;
- }
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
}
});
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
- const context = createContext();
- context.args = {
- ...context.args,
- target: '/tmp/movie.mkv',
- targetKind: 'file',
- };
- context.pluginRuntimeConfig = {
- socketPath: '/tmp/subminer.sock',
- binaryPath: '',
- backend: 'auto',
- autoStart: true,
- autoStartVisibleOverlay: true,
- autoStartPauseUntilReady: true,
- osdMessages: false,
- texthookerEnabled: true,
- };
- const calls: string[] = [];
-
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {},
- chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
- checkDependencies: () => {},
- registerCleanup: () => {},
- startMpv: async () => {
- calls.push('startMpv');
- },
- waitForUnixSocketReady: async () => true,
- startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
- calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
- },
- launchAppCommandDetached: () => {},
- log: () => {},
- cleanupPlaybackSession: async () => {},
- getMpvProc: () => null,
- isAppControlServerAvailable: async () => true,
- } as Parameters[1] & {
- isAppControlServerAvailable: () => Promise;
+ const harness = makeHarness({
+ context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }),
+ deps: { isAppControlServerAvailable: async () => true },
});
- assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
+ await harness.run();
+
+ assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']);
});
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
- const context = createContext();
- context.args = {
- ...context.args,
- target: '/tmp/movie.mkv',
- targetKind: 'file',
- };
- const calls: string[] = [];
+ const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) });
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {
- calls.push('plugin');
- },
- chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
- checkDependencies: () => {},
- registerCleanup: () => {},
- startMpv: async () => {
- calls.push('startMpv');
- },
- waitForUnixSocketReady: async () => true,
- startOverlay: async () => {},
- launchAppCommandDetached: () => {},
- log: () => {},
- cleanupPlaybackSession: async () => {},
- getMpvProc: () => null,
- });
+ await harness.run();
- assert.deepEqual(calls, ['plugin', 'startMpv']);
+ assertRanInOrder(harness.events, 'plugin', 'startMpv');
});
test('rofi playback repairs support assets before opening the picker', async () => {
- const context = createContext();
- context.args = {
- ...context.args,
- target: '',
- targetKind: '',
- useRofi: true,
- };
- const calls: string[] = [];
-
- await runPlaybackCommandWithDeps(context, {
- ensurePlaybackSetupReady: async () => {},
- ensureRuntimePluginReady: async () => {
- calls.push('assets');
+ const events: string[] = [];
+ const harness = makeHarness({
+ events,
+ context: createContext({ args: { target: '', targetKind: '', useRofi: true } }),
+ deps: {
+ chooseTarget: async () => {
+ events.push('picker');
+ return { target: '/tmp/movie.mkv', kind: 'file' };
+ },
+ checkPickerDependencies: () => {},
},
- chooseTarget: async () => {
- calls.push('picker');
- return { target: '/tmp/movie.mkv', kind: 'file' };
- },
- checkPickerDependencies: () => {},
- checkDependencies: () => {},
- registerCleanup: () => {},
- startMpv: async () => {
- calls.push('startMpv');
- },
- waitForUnixSocketReady: async () => true,
- startOverlay: async () => {},
- launchAppCommandDetached: () => {},
- log: () => {},
- cleanupPlaybackSession: async () => {},
- getMpvProc: () => null,
});
- assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
+ await harness.run();
+
+ assertRanInOrder(events, 'plugin', 'picker', 'startMpv');
});
diff --git a/launcher/commands/sync-command.test.ts b/launcher/commands/sync-command.test.ts
index 66cfc8f6..2728d44f 100644
--- a/launcher/commands/sync-command.test.ts
+++ b/launcher/commands/sync-command.test.ts
@@ -48,49 +48,6 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
assert.equal(spawned.length, 1);
});
-test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
- const spawned: string[][] = [];
- const deps: Partial = {
- runAppCommand: (_appPath, appArgs) => {
- spawned.push(appArgs);
- },
- };
-
- await runSyncCommand(
- makeContext({
- syncCliTokens: [
- 'media-box',
- '--pull',
- '--remote-cmd',
- '/opt/SubMiner.AppImage',
- '--db',
- '/tmp/db.sqlite',
- '--force',
- '--json',
- ],
- logLevel: 'debug',
- }),
- deps,
- );
-
- assert.deepEqual(spawned, [
- [
- '--sync-cli',
- 'sync',
- 'media-box',
- '--pull',
- '--remote-cmd',
- '/opt/SubMiner.AppImage',
- '--db',
- '/tmp/db.sqlite',
- '--force',
- '--json',
- '--log-level',
- 'debug',
- ],
- ]);
-});
-
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
const deps: Partial = {
runAppCommand: () => {
diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts
index 4f2ff1f0..1d85686e 100644
--- a/launcher/config-domain-parsers.test.ts
+++ b/launcher/config-domain-parsers.test.ts
@@ -8,7 +8,6 @@ import {
buildPluginRuntimeScriptOptParts,
parsePluginRuntimeConfigFromMainConfig,
} from './config/plugin-runtime-config.js';
-import { getDefaultSocketPath } from './types.js';
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
const parsed = parseLauncherYoutubeSubgenConfig({
@@ -207,10 +206,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
);
});
-test('getDefaultSocketPath returns Windows named pipe default', () => {
- assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
-});
-
test('parseLauncherMpvConfig reads configured mpv profile', () => {
assert.deepEqual(
parseLauncherMpvConfig({
diff --git a/launcher/config.test.ts b/launcher/config.test.ts
deleted file mode 100644
index 72f5ee2d..00000000
--- a/launcher/config.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import { execFileSync } from 'node:child_process';
-import path from 'node:path';
-
-test('launcher root help lists subcommands', () => {
- const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
- encoding: 'utf8',
- });
-
- assert.match(output, /Commands:/);
- assert.match(output, /jellyfin\|jf/);
- assert.match(output, /doctor/);
- assert.match(output, /config/);
- assert.match(output, /mpv/);
- assert.match(output, /logs/);
- assert.match(output, /dictionary\|dict/);
- assert.match(output, /texthooker/);
- assert.match(output, /app\|bin/);
-});
diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts
index 9bbb4c96..ea178c29 100644
--- a/launcher/config/args-normalizer.test.ts
+++ b/launcher/config/args-normalizer.test.ts
@@ -9,6 +9,7 @@ import {
applyRootOptionsToArgs,
createDefaultArgs,
} from './args-normalizer.js';
+import type { CliInvocations } from './cli-parser-builder.js';
function withTempDir(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
@@ -19,6 +20,48 @@ function withTempDir(fn: (dir: string) => T): T {
}
}
+/** An invocations bag with nothing triggered; tests pass only the fields under test. */
+function emptyInvocations(overrides: Partial = {}): CliInvocations {
+ return {
+ jellyfinInvocation: null,
+ configInvocation: null,
+ settingsInvocation: null,
+ youtubeBrowserInvocation: null,
+ mpvInvocation: null,
+ appInvocation: null,
+ dictionaryTriggered: false,
+ dictionaryTarget: null,
+ dictionaryLogLevel: null,
+ dictionaryCandidates: false,
+ dictionarySelect: false,
+ dictionaryAnilistId: null,
+ statsTriggered: false,
+ statsBackground: false,
+ statsStop: false,
+ statsCleanup: false,
+ statsCleanupVocab: false,
+ statsCleanupLifetime: false,
+ statsCleanupDuplicateLines: false,
+ statsCleanupDryRun: false,
+ statsCleanupLookbackDays: null,
+ statsLogLevel: null,
+ syncTriggered: false,
+ syncCliTokens: [],
+ syncLogLevel: null,
+ syncUiTriggered: false,
+ syncUiLogLevel: null,
+ doctorTriggered: false,
+ doctorLogLevel: null,
+ doctorRefreshKnownWords: false,
+ logsTriggered: false,
+ logsExport: false,
+ texthookerTriggered: false,
+ texthookerLogLevel: null,
+ texthookerOpenBrowser: false,
+ ...overrides,
+ };
+}
+
test('createDefaultArgs normalizes configured language codes', () => {
const parsed = createDefaultArgs({
primarySubLanguages: [' JA ', 'jpn', 'ja'],
@@ -92,53 +135,23 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
const parsed = createDefaultArgs({});
- applyInvocationsToArgs(parsed, {
- jellyfinInvocation: {
- action: 'play',
- play: true,
- server: 'https://jf.example',
- username: 'alice',
- password: 'secret',
- logLevel: 'debug',
- },
- configInvocation: {
- action: 'show',
- logLevel: 'warn',
- },
- settingsInvocation: null,
- youtubeBrowserInvocation: null,
- mpvInvocation: null,
- appInvocation: null,
- dictionaryTriggered: false,
- dictionaryTarget: null,
- dictionaryLogLevel: null,
- dictionaryCandidates: false,
- dictionarySelect: false,
- dictionaryAnilistId: null,
- statsTriggered: false,
- statsBackground: false,
- statsStop: false,
- statsCleanup: false,
- statsCleanupVocab: false,
- statsCleanupLifetime: false,
- statsCleanupDuplicateLines: false,
- statsCleanupDryRun: false,
- statsCleanupLookbackDays: null,
- statsLogLevel: null,
- syncTriggered: false,
- syncCliTokens: [],
- syncLogLevel: null,
- syncUiTriggered: false,
- syncUiLogLevel: null,
- doctorTriggered: false,
- doctorLogLevel: null,
- doctorRefreshKnownWords: false,
- logsTriggered: false,
- logsExport: false,
- texthookerTriggered: false,
- texthookerLogLevel: null,
- texthookerOpenBrowser: false,
- });
+ applyInvocationsToArgs(
+ parsed,
+ emptyInvocations({
+ jellyfinInvocation: {
+ action: 'play',
+ play: true,
+ server: 'https://jf.example',
+ username: 'alice',
+ password: 'secret',
+ logLevel: 'debug',
+ },
+ configInvocation: {
+ action: 'show',
+ logLevel: 'warn',
+ },
+ }),
+ );
assert.equal(parsed.jellyfin, false);
assert.equal(parsed.jellyfinPlay, true);
@@ -155,45 +168,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
const parsed = createDefaultArgs({});
- applyInvocationsToArgs(parsed, {
- jellyfinInvocation: null,
- configInvocation: null,
- settingsInvocation: {
- logLevel: undefined,
- },
- youtubeBrowserInvocation: null,
- mpvInvocation: null,
- appInvocation: null,
- dictionaryTriggered: false,
- dictionaryTarget: null,
- dictionaryLogLevel: null,
- dictionaryCandidates: false,
- dictionarySelect: false,
- dictionaryAnilistId: null,
- statsTriggered: false,
- statsBackground: false,
- statsStop: false,
- statsCleanup: false,
- statsCleanupVocab: false,
- statsCleanupLifetime: false,
- statsCleanupDuplicateLines: false,
- statsCleanupDryRun: false,
- statsCleanupLookbackDays: null,
- statsLogLevel: null,
- syncTriggered: false,
- syncCliTokens: [],
- syncLogLevel: null,
- syncUiTriggered: false,
- syncUiLogLevel: null,
- doctorTriggered: false,
- doctorLogLevel: null,
- doctorRefreshKnownWords: false,
- logsTriggered: false,
- logsExport: false,
- texthookerTriggered: false,
- texthookerLogLevel: null,
- texthookerOpenBrowser: false,
- });
+ applyInvocationsToArgs(
+ parsed,
+ emptyInvocations({
+ settingsInvocation: {
+ logLevel: undefined,
+ },
+ }),
+ );
assert.equal(parsed.settings, true);
assert.equal(parsed.configPath, false);
@@ -203,45 +185,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
const parsed = createDefaultArgs({});
const error = withProcessExitIntercept(() => {
- applyInvocationsToArgs(parsed, {
- jellyfinInvocation: null,
- configInvocation: {
- action: undefined,
- },
- settingsInvocation: null,
- youtubeBrowserInvocation: null,
- mpvInvocation: null,
- appInvocation: null,
- dictionaryTriggered: false,
- dictionaryTarget: null,
- dictionaryLogLevel: null,
- dictionaryCandidates: false,
- dictionarySelect: false,
- dictionaryAnilistId: null,
- statsTriggered: false,
- statsBackground: false,
- statsStop: false,
- statsCleanup: false,
- statsCleanupVocab: false,
- statsCleanupLifetime: false,
- statsCleanupDuplicateLines: false,
- statsCleanupDryRun: false,
- statsCleanupLookbackDays: null,
- statsLogLevel: null,
- syncTriggered: false,
- syncCliTokens: [],
- syncLogLevel: null,
- syncUiTriggered: false,
- syncUiLogLevel: null,
- doctorTriggered: false,
- doctorLogLevel: null,
- doctorRefreshKnownWords: false,
- logsTriggered: false,
- logsExport: false,
- texthookerTriggered: false,
- texthookerLogLevel: null,
- texthookerOpenBrowser: false,
- });
+ applyInvocationsToArgs(
+ parsed,
+ emptyInvocations({
+ configInvocation: {
+ action: undefined,
+ },
+ }),
+ );
});
assert.equal(error.code, 1);
@@ -251,43 +202,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
- applyInvocationsToArgs(parsed, {
- jellyfinInvocation: null,
- configInvocation: null,
- settingsInvocation: null,
- youtubeBrowserInvocation: null,
- mpvInvocation: null,
- appInvocation: null,
- dictionaryTriggered: false,
- dictionaryTarget: null,
- dictionaryLogLevel: null,
- dictionaryCandidates: false,
- dictionarySelect: false,
- dictionaryAnilistId: null,
- statsTriggered: false,
- statsBackground: false,
- statsStop: false,
- statsCleanup: false,
- statsCleanupVocab: false,
- statsCleanupLifetime: false,
- statsCleanupDuplicateLines: false,
- statsCleanupDryRun: false,
- statsCleanupLookbackDays: null,
- statsLogLevel: null,
- syncTriggered: false,
- syncCliTokens: [],
- syncLogLevel: null,
- syncUiTriggered: false,
- syncUiLogLevel: null,
- doctorTriggered: false,
- doctorLogLevel: null,
- doctorRefreshKnownWords: false,
- logsTriggered: false,
- logsExport: false,
- texthookerTriggered: true,
- texthookerLogLevel: null,
- texthookerOpenBrowser: true,
- });
+ applyInvocationsToArgs(
+ parsed,
+ emptyInvocations({
+ texthookerTriggered: true,
+ texthookerOpenBrowser: true,
+ }),
+ );
assert.equal(parsed.texthookerOnly, true);
assert.equal(parsed.texthookerOpenBrowser, true);
diff --git a/launcher/jellyfin.test.ts b/launcher/jellyfin.test.ts
index 329570e4..12582d46 100644
--- a/launcher/jellyfin.test.ts
+++ b/launcher/jellyfin.test.ts
@@ -1,128 +1,305 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import type { Args } from './types.js';
-import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {
+ buildForwardedJellyfinAppArgs,
+ buildRootSearchGroups,
+ classifyJellyfinChildSelection,
+ deriveJellyfinTokenStorePath,
+ hasStoredJellyfinSession,
+ parseEpisodePathFromDisplay,
+ parseJellyfinErrorFromAppOutput,
+ parseJellyfinItemsFromAppOutput,
+ parseJellyfinLibrariesFromAppOutput,
+ parseJellyfinPreviewAuthResponse,
+ readUtf8FileAppendedSince,
+ runJellyfinPlayMenuWithDeps,
+ shouldRetryWithStartForNoRunningInstance,
+} from './jellyfin.js';
+import { makeLauncherArgs } from './test-support/args.js';
+import { withEnv } from './test-support/env.js';
-function createArgs(): Args {
- return {
- backend: 'auto',
- directory: '.',
- recursive: false,
- profile: '',
- startOverlay: false,
- youtubePrimarySubLangs: [],
- youtubeSecondarySubLangs: [],
- youtubeAudioLangs: [],
- useTexthooker: false,
- autoStartOverlay: false,
- texthookerOnly: false,
- texthookerOpenBrowser: false,
- useRofi: false,
- history: false,
- sync: false,
- syncCliTokens: [],
- syncUi: false,
- logLevel: 'info',
- logRotation: 7,
- passwordStore: '',
- target: '',
- targetKind: '',
- jimakuApiKey: '',
- jimakuApiKeyCommand: '',
- jimakuApiBaseUrl: '',
- jimakuLanguagePreference: 'ja',
- jimakuMaxEntryResults: 20,
- jellyfin: false,
- jellyfinLogin: false,
- jellyfinLogout: false,
- jellyfinPlay: false,
- jellyfinDiscovery: false,
- dictionary: false,
- dictionaryCandidates: false,
- dictionarySelect: false,
- stats: false,
- doctor: false,
- doctorRefreshKnownWords: false,
- logsExport: false,
- version: false,
- settings: false,
- youtubeBrowser: false,
- configPath: false,
- configShow: false,
- mpvIdle: false,
- mpvSocket: false,
- mpvStatus: false,
- mpvArgs: '',
- appPassthrough: false,
- appArgs: [],
- jellyfinServer: 'https://jellyfin.example.test',
- jellyfinUsername: '',
- jellyfinPassword: '',
- launchMode: 'normal',
- };
+function withTempDir(fn: (dir: string) => T): T {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-'));
+ try {
+ return fn(dir);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
}
-test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
- const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
- const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
- process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
- process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
- const calls: string[] = [];
+class HandoffComplete extends Error {}
- try {
- await assert.rejects(
- () =>
- runJellyfinPlayMenuWithDeps(
- '/tmp/SubMiner.AppImage',
- createArgs(),
- '/tmp/subminer',
- '/tmp/subminer.sock',
- {
- loadLauncherJellyfinConfig: () => ({}),
- findRofiTheme: () => null,
- resolveJellyfinSelection: async () => 'item-123',
- resolveJellyfinSelectionViaApp: async () => {
- throw new Error('unexpected app-based selection');
- },
- hasStoredJellyfinSession: () => true,
- requestJellyfinPreviewAuthFromApp: async () => null,
- resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
- pathExists: () => false,
- ensureRuntimePluginReady: async () => {
- calls.push('plugin');
- },
- waitForUnixSocketReady: async () => {
- calls.push('wait');
- return true;
- },
- launchMpvIdleDetached: async () => {
- calls.push('launch');
- },
- resolveLauncherRuntimePluginPath: () => {
- calls.push('resolve');
- return '/tmp/plugin/main.lua';
- },
- runAppCommandWithInheritLogged: () => {
- calls.push('handoff');
- throw new Error('stop after handoff');
- },
- log: () => {},
- },
- ),
- /stop after handoff/,
- );
+type PlayMenuDeps = NonNullable[4]>;
- assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
- } finally {
- if (originalAccessToken === undefined) {
- delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
- } else {
- process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
- }
- if (originalUserId === undefined) {
- delete process.env.SUBMINER_JELLYFIN_USER_ID;
- } else {
- process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
- }
- }
+/**
+ * Runs the play menu with fake deps and a direct env session. The app handoff never returns in
+ * production, so the fake throws `HandoffComplete` after recording its args into `handoff`.
+ */
+async function runPlayMenu(options: {
+ socketReady?: boolean;
+ overrides?: Partial;
+ args?: Parameters[0];
+}) {
+ const launches: string[] = [];
+ const handoff: { appArgs: string[] | null } = { appArgs: null };
+ const deps: PlayMenuDeps = {
+ loadLauncherJellyfinConfig: () => ({}),
+ findRofiTheme: () => null,
+ resolveJellyfinSelection: async () => 'item-123',
+ resolveJellyfinSelectionViaApp: async () => {
+ throw new Error('unexpected app-based selection');
+ },
+ hasStoredJellyfinSession: () => true,
+ requestJellyfinPreviewAuthFromApp: async () => null,
+ resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
+ pathExists: () => options.socketReady ?? false,
+ ensureRuntimePluginReady: async () => {
+ launches.push('plugin');
+ },
+ waitForUnixSocketReady: async () => true,
+ launchMpvIdleDetached: async () => {
+ launches.push('launch');
+ },
+ resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua',
+ runAppCommandWithInheritLogged: (_appPath, appArgs) => {
+ handoff.appArgs = appArgs;
+ throw new HandoffComplete();
+ },
+ log: () => {},
+ ...options.overrides,
+ };
+
+ await withEnv(
+ { SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' },
+ () =>
+ assert.rejects(
+ () =>
+ runJellyfinPlayMenuWithDeps(
+ '/tmp/SubMiner.AppImage',
+ makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }),
+ '/tmp/subminer',
+ '/tmp/subminer.sock',
+ deps,
+ ),
+ HandoffComplete,
+ ),
+ );
+
+ return { launches, appArgs: handoff.appArgs };
+}
+
+test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => {
+ const { launches, appArgs } = await runPlayMenu({});
+
+ assert.deepEqual(launches, ['plugin', 'launch']);
+ assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
+});
+
+test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => {
+ const { launches, appArgs } = await runPlayMenu({ socketReady: true });
+
+ assert.deepEqual(launches, []);
+ assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
+});
+
+test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
+ const parsed = parseJellyfinLibrariesFromAppOutput(`
+[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
+[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
+`);
+
+ assert.deepEqual(parsed, [
+ { id: 'lib1', name: 'Anime', kind: 'tvshows' },
+ { id: 'lib2', name: 'Movies', kind: 'movies' },
+ ]);
+});
+
+test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
+ const parsed = parseJellyfinItemsFromAppOutput(`
+[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
+[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
+`);
+
+ assert.deepEqual(parsed, [
+ {
+ id: 'item-10',
+ name: 'Solo Leveling S01E10',
+ type: 'Episode',
+ display: 'Solo Leveling S01E10',
+ },
+ {
+ id: 'movie-1',
+ name: 'Movie [Alt]',
+ type: 'Movie',
+ display: 'Movie [Alt]',
+ },
+ ]);
+});
+
+test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
+ const forwarded = buildForwardedJellyfinAppArgs(
+ {
+ jellyfinServer: 'https://jf.example.test/',
+ passwordStore: 'gnome-libsecret',
+ logLevel: 'info',
+ } as never,
+ ['--jellyfin-libraries'],
+ );
+
+ assert.deepEqual(forwarded, [
+ '--jellyfin-libraries',
+ '--jellyfin-server',
+ 'https://jf.example.test',
+ '--password-store',
+ 'gnome-libsecret',
+ '--log-level',
+ 'info',
+ ]);
+});
+
+test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
+ const parsed = parseJellyfinErrorFromAppOutput(`
+[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
+[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
+`);
+
+ assert.equal(
+ parsed,
+ 'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
+ );
+});
+
+test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
+ const parsed = parseJellyfinErrorFromAppOutput(`
+[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
+`);
+
+ assert.equal(
+ parsed,
+ '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
+ );
+});
+
+test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
+ const parsed = parseJellyfinPreviewAuthResponse(
+ JSON.stringify({
+ serverUrl: 'http://pve-main:8096/',
+ accessToken: 'token-123',
+ userId: 'user-1',
+ }),
+ );
+
+ assert.deepEqual(parsed, {
+ serverUrl: 'http://pve-main:8096',
+ accessToken: 'token-123',
+ userId: 'user-1',
+ });
+});
+
+test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
+ assert.equal(parseJellyfinPreviewAuthResponse(''), null);
+ assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
+ assert.equal(
+ parseJellyfinPreviewAuthResponse(
+ JSON.stringify({
+ serverUrl: 'http://pve-main:8096',
+ accessToken: '',
+ userId: 'user-1',
+ }),
+ ),
+ null,
+ );
+});
+
+test('hasStoredJellyfinSession checks token-store existence', () => {
+ const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
+ const tokenPath = deriveJellyfinTokenStorePath(configPath);
+ const exists = (candidate: string): boolean => candidate === tokenPath;
+ assert.equal(hasStoredJellyfinSession(configPath, exists), true);
+ assert.equal(
+ hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists),
+ false,
+ );
+});
+
+test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
+ assert.equal(
+ shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
+ true,
+ );
+ assert.equal(
+ shouldRetryWithStartForNoRunningInstance(
+ 'Missing Jellyfin session. Run --jellyfin-login first.',
+ ),
+ false,
+ );
+});
+
+test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
+ withTempDir((root) => {
+ const logPath = path.join(root, 'SubMiner.log');
+ const prefix = '[subminer] こんにちは\n';
+ const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
+ fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
+
+ const byteOffset = Buffer.byteLength(prefix, 'utf8');
+ const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
+ assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
+
+ const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
+ assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
+ });
+});
+
+test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
+ assert.deepEqual(
+ parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
+ {
+ seriesName: 'KONOSUBA',
+ seasonNumber: 1,
+ },
+ );
+ assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
+ seriesName: 'Frieren',
+ seasonNumber: 2,
+ });
+});
+
+test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
+ assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
+ assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
+});
+
+test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
+ const groups = buildRootSearchGroups([
+ { id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
+ { id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
+ { id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
+ ]);
+
+ assert.deepEqual(groups, [
+ {
+ id: 'series-1',
+ name: 'The Eminence in Shadow',
+ type: 'Series',
+ display: 'The Eminence in Shadow (Series)',
+ },
+ {
+ id: 'movie-1',
+ name: 'Spirited Away',
+ type: 'Movie',
+ display: 'Spirited Away (Movie)',
+ },
+ ]);
+});
+
+test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
+ const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
+ assert.deepEqual(next, {
+ kind: 'container',
+ id: 'season-2',
+ });
});
diff --git a/launcher/main.test.ts b/launcher/main.test.ts
index 775ae848..5d214190 100644
--- a/launcher/main.test.ts
+++ b/launcher/main.test.ts
@@ -4,21 +4,6 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
-import { resolveConfigFilePath } from '../src/config/path-resolution.js';
-import {
- parseJellyfinLibrariesFromAppOutput,
- parseJellyfinItemsFromAppOutput,
- parseJellyfinErrorFromAppOutput,
- parseJellyfinPreviewAuthResponse,
- deriveJellyfinTokenStorePath,
- hasStoredJellyfinSession,
- shouldRetryWithStartForNoRunningInstance,
- readUtf8FileAppendedSince,
- parseEpisodePathFromDisplay,
- buildRootSearchGroups,
- classifyJellyfinChildSelection,
- buildForwardedJellyfinAppArgs,
-} from './jellyfin.js';
type RunResult = {
status: number | null;
@@ -96,74 +81,205 @@ const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-
fi
`;
+interface Sandbox {
+ root: string;
+ homeDir: string;
+ xdgConfigHome: string;
+ /** `/SubMiner`, created on demand by `writeConfigFile`. */
+ configDir: string;
+ /** Base launcher env (isolated HOME/XDG); add `SUBMINER_APPIMAGE_PATH` etc. per test. */
+ env: NodeJS.ProcessEnv;
+ writeConfigFile(name: string, content: string | object): void;
+}
+
+/** Runs `fn` inside a temp root with an isolated home and XDG config dir. */
+function withSandbox(fn: (sandbox: Sandbox) => T): T {
+ return withTempDir((root) => {
+ const homeDir = path.join(root, 'home');
+ const xdgConfigHome = path.join(root, 'xdg');
+ const configDir = path.join(xdgConfigHome, 'SubMiner');
+ return fn({
+ root,
+ homeDir,
+ xdgConfigHome,
+ configDir,
+ env: makeTestEnv(homeDir, xdgConfigHome),
+ writeConfigFile: (name, content) => {
+ fs.mkdirSync(configDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(configDir, name),
+ typeof content === 'string' ? content : JSON.stringify(content),
+ );
+ },
+ });
+ });
+}
+
+function writeExecutable(filePath: string, content: string): void {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, content, 'utf8');
+ fs.chmodSync(filePath, 0o755);
+}
+
+/**
+ * Writes a fake SubMiner app script. `body` runs after the runtime-plugin preflight prelude
+ * (omit the prelude with `preflight: false` for apps that never reach playback). The default
+ * body is a no-op; the launcher itself captures forwarded app args when
+ * `SUBMINER_TEST_CAPTURE` is set.
+ */
+function makeFakeApp(
+ root: string,
+ options: { name?: string; body?: string; preflight?: boolean } = {},
+): string {
+ const appPath = path.join(root, options.name ?? 'fake-subminer.sh');
+ const prelude = options.preflight === false ? '' : RUNTIME_PLUGIN_PREFLIGHT_SH;
+ writeExecutable(appPath, `#!/bin/sh\n${prelude}${options.body ?? 'exit 0\n'}`);
+ return appPath;
+}
+
+/**
+ * Fake mpv/yt-dlp/ffmpeg in `/bin`. The mpv stub records its argv into
+ * `SUBMINER_TEST_MPV_ARGS` and briefly serves the requested IPC socket so startup succeeds.
+ */
+function makeFakeMpv(root: string): {
+ binDir: string;
+ mpvArgsPath: string;
+ env: NodeJS.ProcessEnv;
+} {
+ const binDir = path.join(root, 'bin');
+ const mpvArgsPath = path.join(root, 'mpv-args.txt');
+ const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
+ writeExecutable(
+ path.join(binDir, 'mpv'),
+ `#!/bin/sh
+set -eu
+printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
+socket_path=""
+for arg in "$@"; do
+ case "$arg" in
+ --input-ipc-server=*)
+ socket_path="\${arg#--input-ipc-server=}"
+ ;;
+ esac
+done
+${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
+`,
+ );
+ writeExecutable(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n');
+ writeExecutable(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n');
+ const pathValue = `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`;
+ return {
+ binDir,
+ mpvArgsPath,
+ env: { PATH: pathValue, Path: pathValue, SUBMINER_TEST_MPV_ARGS: mpvArgsPath },
+ };
+}
+
+/**
+ * Sets up a completed-setup sandbox that reaches real playback: fake app + fake mpv, a config
+ * pointing mpv at a temp socket. Note: no `SUBMINER_TEST_CAPTURE` here. When set, the launcher
+ * intercepts every app command, including the Linux runtime-plugin preflight install, and
+ * returns without running the fake app, so the preflight would poll 30s for a response.
+ */
+function setupPlayback(sandbox: Sandbox, options: { autoStart: boolean; extraConfig?: object }) {
+ const socketPath = path.join(sandbox.root, 'mpv.sock');
+ sandbox.writeConfigFile('setup-state.json', {
+ version: 1,
+ status: 'completed',
+ completedAt: '2026-03-08T00:00:00.000Z',
+ completionSource: 'user',
+ lastSeenYomitanDictionaryCount: 0,
+ pluginInstallStatus: 'installed',
+ pluginInstallPathSummary: null,
+ });
+ sandbox.writeConfigFile('config.jsonc', {
+ auto_start_overlay: options.autoStart,
+ mpv: {
+ socketPath,
+ autoStartSubMiner: options.autoStart,
+ pauseUntilOverlayReady: options.autoStart,
+ },
+ ...options.extraConfig,
+ });
+ const appPath = makeFakeApp(sandbox.root);
+ const mpv = makeFakeMpv(sandbox.root);
+ const videoPath = path.join(sandbox.root, 'movie.mkv');
+ fs.writeFileSync(videoPath, 'fake video content');
+
+ return {
+ videoPath,
+ env: { ...sandbox.env, ...mpv.env, SUBMINER_APPIMAGE_PATH: appPath },
+ readMpvArgs: () => fs.readFileSync(mpv.mpvArgsPath, 'utf8'),
+ };
+}
+
+function assertSucceeded(result: RunResult): void {
+ assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
+}
+
+/** Script for a fake app that answers `--stats-response-path` after `sleepSeconds`. */
+function statsResponderScript(
+ options: { sleepSeconds?: number; captureEnv?: string } = {},
+): string {
+ return `set -eu
+response_path=""
+prev=""
+for arg in "$@"; do
+ if [ "$prev" = "--stats-response-path" ]; then
+ response_path="$arg"
+ prev=""
+ continue
+ fi
+ case "$arg" in
+ --stats-response-path=*)
+ response_path="\${arg#--stats-response-path=}"
+ ;;
+ --stats-response-path)
+ prev="--stats-response-path"
+ ;;
+ esac
+done
+${options.captureEnv ? `if [ -n "$${options.captureEnv}" ]; then\n printf '%s\\n' "$@" > "$${options.captureEnv}"\nfi\n` : ''}${options.sleepSeconds ? `sleep ${options.sleepSeconds}\n` : ''}mkdir -p "$(dirname "$response_path")"
+printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
+exit 0
+`;
+}
+
test('config path uses XDG_CONFIG_HOME override', () => {
- withTempDir((root) => {
- const xdgConfigHome = path.join(root, 'xdg');
- const homeDir = path.join(root, 'home');
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"source":"xdg"}');
+ withSandbox((sandbox) => {
+ sandbox.writeConfigFile('config.json', '{"source":"xdg"}');
- const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome));
+ const result = runLauncher(['config', 'path'], sandbox.env);
assert.equal(result.status, 0);
- assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.json'));
+ assert.equal(result.stdout.trim(), path.join(sandbox.configDir, 'config.json'));
});
});
-test('config discovery ignores lowercase subminer candidate', () => {
- const homeDir = '/home/tester';
- const xdgConfigHome = '/tmp/xdg-config';
- const expected = path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc');
- const foundPaths = new Set([path.posix.join(xdgConfigHome, 'subminer', 'config.json')]);
+for (const flag of ['--version', '-v']) {
+ test(`${flag} prints installed app version without requiring app binary`, () => {
+ withSandbox((sandbox) => {
+ const result = runLauncher([flag], sandbox.env);
- const resolved = resolveConfigFilePath({
- xdgConfigHome,
- homeDir,
- platform: 'linux',
- existsSync: (candidate) => foundPaths.has(path.posix.normalize(candidate)),
+ assert.equal(result.status, 0);
+ assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
+ assert.equal(result.stderr, '');
+ });
});
-
- assert.equal(resolved, expected);
-});
-
-test('version flag prints installed app version without requiring app binary', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const result = runLauncher(['--version'], makeTestEnv(homeDir, xdgConfigHome));
-
- assert.equal(result.status, 0);
- assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
- assert.equal(result.stderr, '');
- });
-});
-
-test('short version flag prints installed app version without requiring app binary', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const result = runLauncher(['-v'], makeTestEnv(homeDir, xdgConfigHome));
-
- assert.equal(result.status, 0);
- assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
- assert.equal(result.stderr, '');
- });
-});
+}
test('logs export writes sanitized archive without requiring app binary', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
+ withSandbox((sandbox) => {
const logsDir =
process.platform === 'win32'
- ? path.join(xdgConfigHome, 'SubMiner', 'logs')
- : path.join(homeDir, '.config', 'SubMiner', 'logs');
+ ? path.join(sandbox.xdgConfigHome, 'SubMiner', 'logs')
+ : path.join(sandbox.homeDir, '.config', 'SubMiner', 'logs');
fs.mkdirSync(logsDir, { recursive: true });
fs.writeFileSync(path.join(logsDir, 'app-2026-W21.log'), `/home/kyle/video.mkv\n`, 'utf8');
- const result = runLauncher(['logs', '-e'], makeTestEnv(homeDir, xdgConfigHome));
+ const result = runLauncher(['logs', '-e'], sandbox.env);
- assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
+ assertSucceeded(result);
const zipPath = result.stdout.trim();
assert.match(zipPath, /subminer-logs-.+\.zip$/);
assert.equal(fs.existsSync(zipPath), true);
@@ -174,28 +290,22 @@ test('logs export writes sanitized archive without requiring app binary', () =>
});
test('config path prefers jsonc over json for same directory', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"format":"json"}');
- fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"format":"jsonc"}');
+ withSandbox((sandbox) => {
+ sandbox.writeConfigFile('config.json', '{"format":"json"}');
+ sandbox.writeConfigFile('config.jsonc', '{"format":"jsonc"}');
- const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome));
+ const result = runLauncher(['config', 'path'], sandbox.env);
assert.equal(result.status, 0);
- assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
+ assert.equal(result.stdout.trim(), path.join(sandbox.configDir, 'config.jsonc'));
});
});
test('config show prints config body and appends trailing newline', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"logLevel":"debug"}');
+ withSandbox((sandbox) => {
+ sandbox.writeConfigFile('config.jsonc', '{"logLevel":"debug"}');
- const result = runLauncher(['config', 'show'], makeTestEnv(homeDir, xdgConfigHome));
+ const result = runLauncher(['config', 'show'], sandbox.env);
assert.equal(result.status, 0);
assert.equal(result.stdout, '{"logLevel":"debug"}\n');
@@ -203,17 +313,11 @@ test('config show prints config body and appends trailing newline', () => {
});
test('mpv socket command returns socket path from plugin runtime config', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const expectedSocket = path.join(root, 'custom', 'subminer.sock');
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
- JSON.stringify({ mpv: { socketPath: expectedSocket } }),
- );
+ withSandbox((sandbox) => {
+ const expectedSocket = path.join(sandbox.root, 'custom', 'subminer.sock');
+ sandbox.writeConfigFile('config.jsonc', { mpv: { socketPath: expectedSocket } });
- const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
+ const result = runLauncher(['mpv', 'socket'], sandbox.env);
assert.equal(result.status, 0);
assert.equal(result.stdout.trim(), expectedSocket);
@@ -221,16 +325,12 @@ test('mpv socket command returns socket path from plugin runtime config', () =>
});
test('mpv status exits non-zero when socket is not ready', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const socketPath = path.join(root, 'missing.sock');
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
- JSON.stringify({ mpv: { socketPath } }),
- );
- const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
+ withSandbox((sandbox) => {
+ sandbox.writeConfigFile('config.jsonc', {
+ mpv: { socketPath: path.join(sandbox.root, 'missing.sock') },
+ });
+
+ const result = runLauncher(['mpv', 'status'], sandbox.env);
assert.equal(result.status, 1);
assert.match(result.stdout, /socket not ready/i);
@@ -238,15 +338,8 @@ test('mpv status exits non-zero when socket is not ready', () => {
});
test('doctor reports checks and exits non-zero without hard dependencies', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- PATH: '',
- Path: '',
- };
- const result = runLauncher(['doctor'], env);
+ withSandbox((sandbox) => {
+ const result = runLauncher(['doctor'], { ...sandbox.env, PATH: '', Path: '' });
assert.equal(result.status, 1);
assert.match(result.stdout, /\[doctor\] app binary:/);
@@ -256,24 +349,16 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
});
test('doctor refresh-known-words forwards app refresh command without requiring mpv', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
+ withSandbox((sandbox) => {
+ const capturePath = path.join(sandbox.root, 'captured-args.txt');
const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
+ ...sandbox.env,
PATH: '',
Path: '',
- SUBMINER_APPIMAGE_PATH: appPath,
+ SUBMINER_APPIMAGE_PATH: makeFakeApp(sandbox.root),
SUBMINER_TEST_CAPTURE: capturePath,
};
+
const result = runLauncher(['doctor', '--refresh-known-words'], env);
assert.equal(result.status, 0);
@@ -282,99 +367,97 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
-for (const flag of ['--settings', '--yomitan', '--hachidori']) {
- test(`launcher ${flag} forwards the matching app settings command`, () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
+const forwardedAppArgsCases: Array<{
+ name: string;
+ /** Receives the sandbox root so cases can create targets (dictionary folders). */
+ argv: (root: string) => string[];
+ expected: (root: string) => string;
+}> = [
+ {
+ name: 'launcher settings command forwards app settings window command',
+ argv: () => ['settings'],
+ expected: () => '--settings\n',
+ },
+ ...['--settings', '--yomitan', '--hachidori'].map((flag) => ({
+ name: `launcher ${flag} forwards the matching app settings command`,
+ argv: () => [flag],
+ expected: () => `${flag}\n`,
+ })),
+ {
+ name: 'launcher youtube command forwards the YouTube browser flag and log level',
+ argv: () => ['yt', '--log-level', 'debug'],
+ expected: () => '--youtube-browser\n--log-level\ndebug\n',
+ },
+ {
+ name: 'dictionary command forwards --dictionary and --dictionary-target to app command path',
+ argv: (root) => ['dictionary', path.join(root, 'anime-folder')],
+ expected: (root) =>
+ `--start\n--dictionary\n--dictionary-target\n${path.join(root, 'anime-folder')}\n`,
+ },
+ {
+ name: 'jellyfin discovery routes to app --background and remote announce with log-level forwarding',
+ argv: () => ['jellyfin', 'discovery', '--log-level', 'debug'],
+ expected: () => '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
+ },
+ {
+ name: 'jellyfin discovery via jf alias forwards remote announce for cast visibility',
+ argv: () => ['-R', 'jf', '--discovery', '--log-level', 'debug'],
+ expected: () => '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
+ },
+ {
+ name: 'jellyfin login routes credentials to app command',
+ argv: () => [
+ 'jellyfin',
+ 'login',
+ '--server',
+ 'https://jf.example.test',
+ '--username',
+ 'alice',
+ '--password',
+ 'secret',
+ ],
+ expected: () =>
+ '--jellyfin-login\n--jellyfin-server\nhttps://jf.example.test\n--jellyfin-username\nalice\n--jellyfin-password\nsecret\n',
+ },
+ {
+ name: 'jellyfin setup forwards password-store to app command',
+ argv: () => ['jf', 'setup', '--password-store', 'gnome-libsecret'],
+ expected: () => '--jellyfin\n--password-store\ngnome-libsecret\n',
+ },
+];
+for (const c of forwardedAppArgsCases) {
+ test(c.name, () => {
+ withSandbox((sandbox) => {
+ fs.mkdirSync(path.join(sandbox.root, 'anime-folder'), { recursive: true });
+ const capturePath = path.join(sandbox.root, 'captured-args.txt');
const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
+ ...sandbox.env,
+ SUBMINER_APPIMAGE_PATH: makeFakeApp(sandbox.root),
SUBMINER_TEST_CAPTURE: capturePath,
};
- const result = runLauncher([flag], env);
+
+ const result = runLauncher(c.argv(sandbox.root), env);
assert.equal(result.status, 0);
- assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`);
+ assert.equal(fs.readFileSync(capturePath, 'utf8'), c.expected(sandbox.root));
});
});
}
-test('launcher settings command forwards app settings window command', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const result = runLauncher(['settings'], env);
-
- assert.equal(result.status, 0);
- assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
- });
-});
-
-test('launcher youtube command forwards the YouTube browser flag and log level', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const result = runLauncher(['yt', '--log-level', 'debug'], env);
-
- assert.equal(result.status, 0);
- assert.equal(fs.readFileSync(capturePath, 'utf8'), '--youtube-browser\n--log-level\ndebug\n');
- });
-});
-
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
+ withSandbox((sandbox) => {
+ const appPath = makeFakeApp(sandbox.root, {
+ preflight: false,
+ body: [
'printf "%s\\n" "2026-05-17 02:59:52.141 SubMiner[29060:305323] representedObject is not a WeakPtrToElectronMenuModelAsNSObject" >&2',
'printf "%s\\n" "real stderr line" >&2',
'exit 0',
'',
].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
+ });
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- };
- const result = runLauncher(['settings'], env);
+ const result = runLauncher(['settings'], { ...sandbox.env, SUBMINER_APPIMAGE_PATH: appPath });
assert.equal(result.status, 0);
assert.equal(result.stderr, 'real stderr line\n');
@@ -382,326 +465,60 @@ test('launcher settings command suppresses known Electron macOS menu diagnostics
});
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const binDir = path.join(root, 'bin');
- const appPath = path.join(root, 'fake-subminer.sh');
- const videoPath = path.join(root, 'movie.mkv');
- const mpvArgsPath = path.join(root, 'mpv-args.txt');
- const socketPath = path.join(root, 'mpv.sock');
- const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
+ withSandbox((sandbox) => {
+ const playback = setupPlayback(sandbox, { autoStart: false });
- fs.mkdirSync(binDir, { recursive: true });
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(videoPath, 'fake video content');
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
- JSON.stringify({
- version: 1,
- status: 'completed',
- completedAt: '2026-03-08T00:00:00.000Z',
- completionSource: 'user',
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'installed',
- pluginInstallPathSummary: null,
- }),
+ const result = runLauncher(
+ ['--args', '--pause=yes --title="movie night"', playback.videoPath],
+ playback.env,
);
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
- JSON.stringify({
- auto_start_overlay: false,
- mpv: {
- socketPath,
- autoStartSubMiner: false,
- pauseUntilOverlayReady: false,
- },
- }),
- );
- fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
- fs.chmodSync(appPath, 0o755);
- fs.writeFileSync(
- path.join(binDir, 'mpv'),
- `#!/bin/sh
-set -eu
-printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
-socket_path=""
-for arg in "$@"; do
- case "$arg" in
- --input-ipc-server=*)
- socket_path="\${arg#--input-ipc-server=}"
- ;;
- esac
-done
-${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
-`,
- 'utf8',
- );
- fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
- fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
- fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
- fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
- fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
- Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
- };
- const result = runLauncher(['--args', '--pause=yes --title="movie night"', videoPath], env);
-
- assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
- const argsFile = fs.readFileSync(mpvArgsPath, 'utf8');
- const forwardedArgs = argsFile
+ assertSucceeded(result);
+ const forwardedArgs = playback
+ .readMpvArgs()
.trim()
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
-
assert.equal(forwardedArgs.includes('--pause=yes'), true);
assert.equal(forwardedArgs.includes('--title=movie night'), true);
- assert.equal(forwardedArgs.includes(videoPath), true);
+ assert.equal(forwardedArgs.includes(playback.videoPath), true);
});
});
test('launcher forwards non-info log level into mpv logging args', { timeout: 15000 }, () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const binDir = path.join(root, 'bin');
- const appPath = path.join(root, 'fake-subminer.sh');
- const videoPath = path.join(root, 'movie.mkv');
- const mpvArgsPath = path.join(root, 'mpv-args.txt');
- const socketPath = path.join(root, 'mpv.sock');
- const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
+ withSandbox((sandbox) => {
+ const playback = setupPlayback(sandbox, {
+ autoStart: true,
+ extraConfig: { logging: { files: { mpv: true } } },
+ });
- fs.mkdirSync(binDir, { recursive: true });
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(videoPath, 'fake video content');
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
- JSON.stringify({
- version: 1,
- status: 'completed',
- completedAt: '2026-03-08T00:00:00.000Z',
- completionSource: 'user',
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'installed',
- pluginInstallPathSummary: null,
- }),
- );
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
- JSON.stringify({
- auto_start_overlay: true,
- mpv: {
- socketPath,
- autoStartSubMiner: true,
- pauseUntilOverlayReady: true,
- },
- logging: {
- files: {
- mpv: true,
- },
- },
- }),
- );
- fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
- fs.chmodSync(appPath, 0o755);
+ const result = runLauncher(['--log-level', 'debug', playback.videoPath], playback.env);
- fs.writeFileSync(
- path.join(binDir, 'mpv'),
- `#!/bin/sh
-set -eu
-printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
-socket_path=""
-for arg in "$@"; do
- case "$arg" in
- --input-ipc-server=*)
- socket_path="\${arg#--input-ipc-server=}"
- ;;
- esac
-done
-${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
-`,
- 'utf8',
- );
- fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
- fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
- fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
- fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
- fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
- Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
- };
- const result = runLauncher(['--log-level', 'debug', videoPath], env);
-
- assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
- const mpvArgs = fs.readFileSync(mpvArgsPath, 'utf8');
+ assertSucceeded(result);
+ const mpvArgs = playback.readMpvArgs();
assert.match(mpvArgs, /--msg-level=all=warn,subminer=debug/);
assert.doesNotMatch(mpvArgs, /--script-opts=.*subminer-log_level=debug/);
});
});
test('launcher routes youtube urls through regular playback startup', { timeout: 15000 }, () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const binDir = path.join(root, 'bin');
- const appPath = path.join(root, 'fake-subminer.sh');
- const mpvArgsPath = path.join(root, 'mpv-args.txt');
- const socketPath = path.join(root, 'mpv.sock');
- const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
+ withSandbox((sandbox) => {
+ const playback = setupPlayback(sandbox, { autoStart: true });
+ const url = 'https://www.youtube.com/watch?v=abc123';
- fs.mkdirSync(binDir, { recursive: true });
- fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
- JSON.stringify({
- version: 1,
- status: 'completed',
- completedAt: '2026-03-08T00:00:00.000Z',
- completionSource: 'user',
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'installed',
- pluginInstallPathSummary: null,
- }),
- );
- fs.writeFileSync(
- path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
- JSON.stringify({
- auto_start_overlay: true,
- mpv: {
- socketPath,
- autoStartSubMiner: true,
- pauseUntilOverlayReady: true,
- },
- }),
- );
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- fs.writeFileSync(
- path.join(binDir, 'mpv'),
- `#!/bin/sh
-set -eu
-printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
-socket_path=""
-for arg in "$@"; do
- case "$arg" in
- --input-ipc-server=*)
- socket_path="\${arg#--input-ipc-server=}"
- ;;
- esac
-done
-${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
-`,
- 'utf8',
- );
- fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
- fs.writeFileSync(path.join(binDir, 'yt-dlp'), '#!/bin/sh\nexit 0\n', 'utf8');
- fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8');
- fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
- fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
-
- // Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts
- // *every* app command — including the Linux runtime-plugin preflight's
- // `--ensure-linux-runtime-plugin-assets` install — and returns without
- // running the fake app, so the preflight would poll 30s for a response that
- // never arrives and time out. This test asserts on the mpv args, not on
- // captured app args, so capture isn't needed.
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
- Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
- };
// Pass an explicit backend so overlay startup doesn't probe for a display
// (headless CI has none), matching launcher/smoke.e2e.test.ts.
- const result = runLauncher(['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'], env);
+ const result = runLauncher(['--backend', 'x11', url], playback.env);
- assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
- const forwardedArgs = fs
- .readFileSync(mpvArgsPath, 'utf8')
+ assertSucceeded(result);
+ const forwardedArgs = playback
+ .readMpvArgs()
.trim()
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
- assert.equal(forwardedArgs.includes('https://www.youtube.com/watch?v=abc123'), true);
- });
-});
-
-test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const targetPath = path.join(root, 'anime-folder');
- fs.mkdirSync(targetPath, { recursive: true });
- const result = runLauncher(['dictionary', targetPath], env);
-
- assert.equal(result.status, 0);
- assert.equal(
- fs.readFileSync(capturePath, 'utf8'),
- `--start\n--dictionary\n--dictionary-target\n${targetPath}\n`,
- );
- });
-});
-
-test('dictionary command forwards manual AniList selection modes to app command path', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const targetPath = path.join(root, 'anime.mkv');
- fs.writeFileSync(targetPath, '');
-
- assert.equal(runLauncher(['dictionary', '--candidates', targetPath], env).status, 0);
- assert.equal(
- fs.readFileSync(capturePath, 'utf8'),
- `--start\n--dictionary-candidates\n--dictionary-target\n${targetPath}\n`,
- );
- assert.equal(runLauncher(['dictionary', '--select', '21355', targetPath], env).status, 0);
- assert.equal(
- fs.readFileSync(capturePath, 'utf8'),
- `--start\n--dictionary-select\n--dictionary-anilist-id\n21355\n--dictionary-target\n${targetPath}\n`,
- );
+ assert.equal(forwardedArgs.includes(url), true);
});
});
@@ -709,50 +526,21 @@ test(
'stats command launches attached app flow and waits for response file',
{ timeout: 15000 },
() => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh
-set -eu
-response_path=""
-prev=""
-for arg in "$@"; do
- if [ "$prev" = "--stats-response-path" ]; then
- response_path="$arg"
- prev=""
- continue
- fi
- case "$arg" in
- --stats-response-path=*)
- response_path="\${arg#--stats-response-path=}"
- ;;
- --stats-response-path)
- prev="--stats-response-path"
- ;;
- esac
-done
-if [ -n "$SUBMINER_TEST_STATS_CAPTURE" ]; then
- printf '%s\\n' "$@" > "$SUBMINER_TEST_STATS_CAPTURE"
-fi
-mkdir -p "$(dirname "$response_path")"
-printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
-exit 0
-`,
- );
- fs.chmodSync(appPath, 0o755);
-
+ withSandbox((sandbox) => {
+ const capturePath = path.join(sandbox.root, 'captured-args.txt');
+ const appPath = makeFakeApp(sandbox.root, {
+ preflight: false,
+ body: statsResponderScript({ captureEnv: 'SUBMINER_TEST_STATS_CAPTURE' }),
+ });
const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
+ ...sandbox.env,
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_STATS_CAPTURE: capturePath,
};
+
const result = runLauncher(['stats', '--log-level', 'debug'], env);
- assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
+ assertSucceeded(result);
assert.match(
fs.readFileSync(capturePath, 'utf8'),
/^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/,
@@ -761,396 +549,31 @@ exit 0
},
);
+// The startup response timeout (12s) is not overridable, so this really waits 9s.
test(
'stats command tolerates slower dashboard startup before timing out',
{ timeout: 20000 },
() => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer-slow.sh');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh
-set -eu
-response_path=""
-prev=""
-for arg in "$@"; do
- if [ "$prev" = "--stats-response-path" ]; then
- response_path="$arg"
- prev=""
- continue
- fi
- case "$arg" in
- --stats-response-path=*)
- response_path="\${arg#--stats-response-path=}"
- ;;
- --stats-response-path)
- prev="--stats-response-path"
- ;;
- esac
-done
-sleep 9
-mkdir -p "$(dirname "$response_path")"
-printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
-exit 0
-`,
- );
- fs.chmodSync(appPath, 0o755);
+ withSandbox((sandbox) => {
+ const appPath = makeFakeApp(sandbox.root, {
+ name: 'fake-subminer-slow.sh',
+ preflight: false,
+ body: statsResponderScript({ sleepSeconds: 9 }),
+ });
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- };
- const result = runLauncher(['stats'], env);
+ const result = runLauncher(['stats'], { ...sandbox.env, SUBMINER_APPIMAGE_PATH: appPath });
- assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
+ assertSucceeded(result);
});
},
);
-test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
-
- assert.equal(result.status, 0);
- assert.equal(
- fs.readFileSync(capturePath, 'utf8'),
- '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
- );
- });
-});
-
-test('jellyfin discovery via jf alias forwards remote announce for cast visibility', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const result = runLauncher(['-R', 'jf', '--discovery', '--log-level', 'debug'], env);
-
- assert.equal(result.status, 0);
- assert.equal(
- fs.readFileSync(capturePath, 'utf8'),
- '--background\n--jellyfin-remote-announce\n--log-level\ndebug\n',
- );
- });
-});
-
-test('jellyfin login routes credentials to app command', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const result = runLauncher(
- [
- 'jellyfin',
- 'login',
- '--server',
- 'https://jf.example.test',
- '--username',
- 'alice',
- '--password',
- 'secret',
- ],
- env,
- );
-
- assert.equal(result.status, 0);
- assert.equal(
- fs.readFileSync(capturePath, 'utf8'),
- '--jellyfin-login\n--jellyfin-server\nhttps://jf.example.test\n--jellyfin-username\nalice\n--jellyfin-password\nsecret\n',
- );
- });
-});
-
-test('jellyfin setup forwards password-store to app command', () => {
- withTempDir((root) => {
- const homeDir = path.join(root, 'home');
- const xdgConfigHome = path.join(root, 'xdg');
- const appPath = path.join(root, 'fake-subminer.sh');
- const capturePath = path.join(root, 'captured-args.txt');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
-
- const env = {
- ...makeTestEnv(homeDir, xdgConfigHome),
- SUBMINER_APPIMAGE_PATH: appPath,
- SUBMINER_TEST_CAPTURE: capturePath,
- };
- const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env);
-
- assert.equal(result.status, 0);
- assert.equal(
- fs.readFileSync(capturePath, 'utf8'),
- '--jellyfin\n--password-store\ngnome-libsecret\n',
- );
- });
-});
-
-test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
- const parsed = parseJellyfinLibrariesFromAppOutput(`
-[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
-[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
-`);
-
- assert.deepEqual(parsed, [
- { id: 'lib1', name: 'Anime', kind: 'tvshows' },
- { id: 'lib2', name: 'Movies', kind: 'movies' },
- ]);
-});
-
-test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
- const parsed = parseJellyfinItemsFromAppOutput(`
-[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
-[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
-`);
-
- assert.deepEqual(parsed, [
- {
- id: 'item-10',
- name: 'Solo Leveling S01E10',
- type: 'Episode',
- display: 'Solo Leveling S01E10',
- },
- {
- id: 'movie-1',
- name: 'Movie [Alt]',
- type: 'Movie',
- display: 'Movie [Alt]',
- },
- ]);
-});
-
-test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
- const forwarded = buildForwardedJellyfinAppArgs(
- {
- jellyfinServer: 'https://jf.example.test/',
- passwordStore: 'gnome-libsecret',
- logLevel: 'info',
- } as never,
- ['--jellyfin-libraries'],
- );
-
- assert.deepEqual(forwarded, [
- '--jellyfin-libraries',
- '--jellyfin-server',
- 'https://jf.example.test',
- '--password-store',
- 'gnome-libsecret',
- '--log-level',
- 'info',
- ]);
-});
-
-test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
- const parsed = parseJellyfinErrorFromAppOutput(`
-[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
-[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
-`);
-
- assert.equal(
- parsed,
- 'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
- );
-});
-
-test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
- const parsed = parseJellyfinErrorFromAppOutput(`
-[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
-`);
-
- assert.equal(
- parsed,
- '[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
- );
-});
-
-test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
- const parsed = parseJellyfinPreviewAuthResponse(
- JSON.stringify({
- serverUrl: 'http://pve-main:8096/',
- accessToken: 'token-123',
- userId: 'user-1',
- }),
- );
-
- assert.deepEqual(parsed, {
- serverUrl: 'http://pve-main:8096',
- accessToken: 'token-123',
- userId: 'user-1',
- });
-});
-
-test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
- assert.equal(parseJellyfinPreviewAuthResponse(''), null);
- assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
- assert.equal(
- parseJellyfinPreviewAuthResponse(
- JSON.stringify({
- serverUrl: 'http://pve-main:8096',
- accessToken: '',
- userId: 'user-1',
- }),
- ),
- null,
- );
-});
-
-test('deriveJellyfinTokenStorePath resolves alongside config path', () => {
- const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
- const tokenPath = deriveJellyfinTokenStorePath(configPath);
- assert.equal(tokenPath, path.join(path.dirname(configPath), 'jellyfin-token-store.json'));
-});
-
-test('hasStoredJellyfinSession checks token-store existence', () => {
- const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
- const tokenPath = deriveJellyfinTokenStorePath(configPath);
- const exists = (candidate: string): boolean => candidate === tokenPath;
- assert.equal(hasStoredJellyfinSession(configPath, exists), true);
- assert.equal(
- hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists),
- false,
- );
-});
-
-test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
- assert.equal(
- shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
- true,
- );
- assert.equal(
- shouldRetryWithStartForNoRunningInstance(
- 'Missing Jellyfin session. Run --jellyfin-login first.',
- ),
- false,
- );
-});
-
-test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
- withTempDir((root) => {
- const logPath = path.join(root, 'SubMiner.log');
- const prefix = '[subminer] こんにちは\n';
- const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
- fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
-
- const byteOffset = Buffer.byteLength(prefix, 'utf8');
- const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
- assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
-
- const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
- assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
- });
-});
-
-test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
- assert.deepEqual(
- parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
- {
- seriesName: 'KONOSUBA',
- seasonNumber: 1,
- },
- );
- assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
- seriesName: 'Frieren',
- seasonNumber: 2,
- });
-});
-
-test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
- assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
- assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
-});
-
-test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
- const groups = buildRootSearchGroups([
- { id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
- { id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
- { id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
- ]);
-
- assert.deepEqual(groups, [
- {
- id: 'series-1',
- name: 'The Eminence in Shadow',
- type: 'Series',
- display: 'The Eminence in Shadow (Series)',
- },
- {
- id: 'movie-1',
- name: 'Spirited Away',
- type: 'Movie',
- display: 'Spirited Away (Movie)',
- },
- ]);
-});
-
-test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
- const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
- assert.deepEqual(next, {
- kind: 'container',
- id: 'season-2',
- });
-});
-
test('external Yomitan profile remains available while a running app awaits a backend switch', () => {
- withTempDir((dir) => {
- const env = makeTestEnv(dir, path.join(dir, 'config'));
- const configPath = resolveConfigFilePath({
- appDataDir: env.APPDATA,
- xdgConfigHome: env.XDG_CONFIG_HOME,
- homeDir: dir,
- existsSync: () => false,
+ withSandbox(({ env, writeConfigFile }) => {
+ writeConfigFile('config.jsonc', {
+ dictionaryBackend: 'hachidori',
+ yomitan: { externalProfilePath: '/external/yomitan-profile' },
});
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
- fs.writeFileSync(
- configPath,
- JSON.stringify({
- dictionaryBackend: 'hachidori',
- yomitan: { externalProfilePath: '/external/yomitan-profile' },
- }),
- );
const result = spawnSync(
process.execPath,
[
diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts
index 637dc3ea..ff0fab42 100644
--- a/launcher/mpv.test.ts
+++ b/launcher/mpv.test.ts
@@ -7,6 +7,8 @@ import net from 'node:net';
import { EventEmitter } from 'node:events';
import type { Args } from './types';
import { getAppControlSocketPath } from '../src/shared/app-control';
+import { makeLauncherArgs } from './test-support/args.js';
+import { withEnv } from './test-support/env.js';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
import {
buildConfiguredMpvDefaultArgs,
@@ -28,13 +30,28 @@ import {
state,
waitForUnixSocketReady,
} from './mpv';
-import * as mpvModule from './mpv';
-function createTempSocketPath(): { dir: string; socketPath: string } {
+function makeArgs(overrides: Partial = {}): Args {
+ return makeLauncherArgs({ backend: 'x11', logLevel: 'error', ...overrides });
+}
+
+// ── shared helpers ───────────────────────────────────────────────────────────
+
+interface TempCase {
+ dir: string;
+ socketPath: string;
+}
+
+/** Runs `run` in a fresh temp dir (with an mpv socket path inside it) and removes it afterwards. */
+async function withTempCase(run: (tempCase: TempCase) => T | Promise): Promise {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
fs.mkdirSync(baseDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(baseDir, 'case-'));
- return { dir, socketPath: path.join(dir, 'mpv.sock') };
+ try {
+ return await run({ dir, socketPath: path.join(dir, 'mpv.sock') });
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
}
function withPlatform(platform: NodeJS.Platform, callback: () => T): T {
@@ -53,9 +70,83 @@ function withPlatform(platform: NodeJS.Platform, callback: () => T): T {
}
}
-test('mpv module exposes only canonical socket readiness helper', () => {
- assert.equal('waitForSocket' in mpvModule, false);
-});
+/** Writes an executable shell script `name` into `dir` and returns its path. */
+function writeFakeApp(dir: string, lines: string[], name = 'fake-subminer.sh'): string {
+ const appPath = path.join(dir, name);
+ fs.writeFileSync(appPath, ['#!/bin/sh', ...lines, ''].join('\n'));
+ fs.chmodSync(appPath, 0o755);
+ return appPath;
+}
+
+/** Fake app that logs every invocation's argv and answers `--app-ping` with `pingExitCode`. */
+function writeInvocationRecorder(dir: string, pingExitCode: number) {
+ const invocationsPath = path.join(dir, 'app-invocations.log');
+ const appPath = writeFakeApp(dir, [
+ `printf '%s\\n' "$@" >> ${JSON.stringify(invocationsPath)}`,
+ `if [ "$1" = "--app-ping" ]; then exit ${pingExitCode}; fi`,
+ 'exit 0',
+ ]);
+ return {
+ appPath,
+ readInvocations: () =>
+ fs.existsSync(invocationsPath) ? fs.readFileSync(invocationsPath, 'utf8') : '',
+ };
+}
+
+/** Makes every `net.createConnection` look like an mpv socket that connects after `delayMs`. */
+async function withConnectableSockets(delayMs: number, run: () => Promise): Promise {
+ const originalCreateConnection = net.createConnection;
+ net.createConnection = (() => {
+ const socket = new EventEmitter() as net.Socket;
+ socket.destroy = (() => socket) as net.Socket['destroy'];
+ socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
+ setTimeout(() => socket.emit('connect'), delayMs);
+ return socket;
+ }) as typeof net.createConnection;
+ try {
+ return await run();
+ } finally {
+ net.createConnection = originalCreateConnection;
+ }
+}
+
+function resetLauncherState(): void {
+ state.overlayProc = null;
+ state.overlayManagedByLauncher = false;
+ state.appPath = '';
+}
+
+const listen = (server: net.Server, socketPath: string) =>
+ new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(socketPath, resolve);
+ });
+
+const closeServer = (server: net.Server) =>
+ new Promise((resolve) => server.close(() => resolve()));
+
+/** Fake app control server: records each request's argv and answers with `reply`. */
+function createControlServer(reply: { ok: boolean; error?: string } = { ok: true }) {
+ const receivedArgv: string[][] = [];
+ const server = net.createServer((socket) => {
+ let buffer = '';
+ socket.on('data', (chunk) => {
+ buffer += chunk.toString('utf8');
+ const newlineIndex = buffer.indexOf('\n');
+ if (newlineIndex < 0) return;
+ const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown };
+ if (Array.isArray(payload.argv)) {
+ receivedArgv.push(
+ payload.argv.filter((value): value is string => typeof value === 'string'),
+ );
+ }
+ socket.end(JSON.stringify(reply) + '\n');
+ });
+ });
+ return { server, receivedArgv };
+}
+
+// ── app command execution ────────────────────────────────────────────────────
test('runAppCommandCaptureOutput captures status and stdio', () => {
const result = runAppCommandCaptureOutput(process.execPath, [
@@ -69,10 +160,8 @@ test('runAppCommandCaptureOutput captures status and stdio', () => {
assert.equal(result.error, undefined);
});
-test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', () => {
- const original = process.env.ELECTRON_RUN_AS_NODE;
- try {
- process.env.ELECTRON_RUN_AS_NODE = '1';
+test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', async () => {
+ await withEnv({ ELECTRON_RUN_AS_NODE: '1' }, () => {
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write(String(process.env.ELECTRON_RUN_AS_NODE ?? ""));',
@@ -80,62 +169,49 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
assert.equal(result.status, 0);
assert.equal(result.stdout, '');
- } finally {
- if (original === undefined) {
- delete process.env.ELECTRON_RUN_AS_NODE;
- } else {
- process.env.ELECTRON_RUN_AS_NODE = original;
- }
- }
+ });
});
-test('runAppCommandCaptureOutput transports Linux AppImage args through environment', () => {
- if (process.platform !== 'linux') return;
- const { dir } = createTempSocketPath();
- const appPath = path.join(dir, 'SubMiner.AppImage');
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- 'printf "args:%s\\n" "$*"',
- 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
- 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
- 'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
+test(
+ 'runAppCommandCaptureOutput transports Linux AppImage args through environment',
+ { skip: process.platform !== 'linux' },
+ async () => {
+ await withTempCase(({ dir }) => {
+ const appPath = writeFakeApp(
+ dir,
+ [
+ 'printf "args:%s\\n" "$*"',
+ 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
+ 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
+ 'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
+ ],
+ 'SubMiner.AppImage',
+ );
- try {
- const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
+ const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
- assert.equal(result.status, 0);
- assert.match(result.stdout, /^args:\n/m);
- assert.match(result.stdout, /^argc:2\n/m);
- assert.match(result.stdout, /^arg0:--app-ping\n/m);
- assert.match(result.stdout, /^arg1:--socket\n/m);
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
-});
+ assert.equal(result.status, 0);
+ assert.match(result.stdout, /^args:\n/m);
+ assert.match(result.stdout, /^argc:2\n/m);
+ assert.match(result.stdout, /^arg0:--app-ping\n/m);
+ assert.match(result.stdout, /^arg1:--socket\n/m);
+ });
+ },
+);
-test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () => {
- const { dir } = createTempSocketPath();
- const appPath = path.join(dir, 'SubMiner.AppImage');
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- 'printf "args:%s\\n" "$*"',
- 'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"',
- 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
- 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
+test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', async () => {
+ await withTempCase(({ dir }) => {
+ const appPath = writeFakeApp(
+ dir,
+ [
+ 'printf "args:%s\\n" "$*"',
+ 'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"',
+ 'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
+ 'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
+ ],
+ 'SubMiner.AppImage',
+ );
- try {
const result = withPlatform('linux', () =>
runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']),
);
@@ -145,11 +221,11 @@ test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', ()
assert.match(result.stdout, /^electron-node:1$/m);
assert.match(result.stdout, /^argc:4$/m);
assert.match(result.stdout, /^arg0:--sync-cli$/m);
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ });
});
+// ── args, env, and plugin resolution ─────────────────────────────────────────
+
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
@@ -276,27 +352,12 @@ test('buildMpvBackendArgs keeps supported Hyprland and Sway auto backends unchan
test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured defaults', () => {
withPlatform('linux', () => {
- assert.deepEqual(
- buildConfiguredMpvDefaultArgs(makeArgs({ launchMode: 'maximized' }), {
- DISPLAY: ':1',
- WAYLAND_DISPLAY: 'wayland-0',
- XDG_SESSION_TYPE: 'wayland',
- XDG_CURRENT_DESKTOP: 'KDE',
- XDG_SESSION_DESKTOP: 'plasma',
- }),
- [
- '--sub-auto=fuzzy',
- '--sub-file-paths=.;subs;subtitles',
- '--sid=auto',
- '--secondary-sid=auto',
- '--sub-visibility=no',
- '--secondary-sub-visibility=no',
- '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
- '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
- '--gpu-context=x11vk,x11egl,x11',
- '--window-maximized=yes',
- ],
- );
+ const args = buildConfiguredMpvDefaultArgs(makeArgs({ launchMode: 'maximized' }), {
+ DISPLAY: ':1',
+ XDG_SESSION_TYPE: 'x11',
+ });
+
+ assert.equal(args.at(-1), '--window-maximized=yes');
});
});
@@ -432,27 +493,25 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
assert.match(error.stderr, /Failed to launch texthooker mode/);
});
-test('launchTexthookerOnly forwards browser-open request to app command', () => {
- const { dir } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- const argsPath = path.join(dir, 'args.txt');
- const openedUrls: string[] = [];
- fs.writeFileSync(appPath, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsPath}"\nexit 0\n`);
- fs.chmodSync(appPath, 0o755);
+test('launchTexthookerOnly forwards browser-open request to app command', async () => {
+ await withTempCase(({ dir }) => {
+ const argsPath = path.join(dir, 'args.txt');
+ const openedUrls: string[] = [];
+ const appPath = writeFakeApp(dir, [`printf '%s\\n' "$@" > "${argsPath}"`, 'exit 0']);
- const error = withProcessExitIntercept(() => {
- launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), {
- openBrowser: (url) => openedUrls.push(url),
+ const error = withProcessExitIntercept(() => {
+ launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), {
+ openBrowser: (url) => openedUrls.push(url),
+ });
});
- });
- assert.equal(error.code, 0);
- assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [
- '--texthooker',
- '--open-browser',
- ]);
- assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']);
- fs.rmSync(dir, { recursive: true, force: true });
+ assert.equal(error.code, 0);
+ assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [
+ '--texthooker',
+ '--open-browser',
+ ]);
+ assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']);
+ });
});
test('launchAppCommandDetached handles child process spawn errors', async () => {
@@ -476,31 +535,24 @@ test('launchAppCommandDetached handles child process spawn errors', async () =>
});
test('launchAppBackgroundDetached starts background child directly', async () => {
- const { dir } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- const argsPath = path.join(dir, 'args.txt');
- const envPath = path.join(dir, 'env.txt');
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
+ await withTempCase(async ({ dir }) => {
+ const argsPath = path.join(dir, 'args.txt');
+ const envPath = path.join(dir, 'env.txt');
+ const appPath = writeFakeApp(dir, [
`printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`,
`printf '%s\\n' "$SUBMINER_BACKGROUND_CHILD" > ${JSON.stringify(envPath)}`,
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
+ ]);
- launchAppBackgroundDetached(appPath, 'info');
+ launchAppBackgroundDetached(appPath, 'info');
- const deadline = Date.now() + 1000;
- while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
- await new Promise((resolve) => setTimeout(resolve, 20));
- }
+ const deadline = Date.now() + 1000;
+ while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ }
- assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background');
- assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1');
- fs.rmSync(dir, { recursive: true, force: true });
+ assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background');
+ assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1');
+ });
});
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
@@ -527,578 +579,302 @@ test('stopOverlay logs a warning when stop command cannot be spawned', () => {
} finally {
process.stdout.write = originalWrite;
state.stopRequested = false;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
- state.overlayProc = null;
+ resetLauncherState();
}
});
+// ── socket readiness ─────────────────────────────────────────────────────────
+
test('waitForUnixSocketReady returns false when socket never appears', async () => {
- const { dir, socketPath } = createTempSocketPath();
- try {
- const ready = await waitForUnixSocketReady(socketPath, 120);
- assert.equal(ready, false);
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ await withTempCase(async ({ socketPath }) => {
+ assert.equal(await waitForUnixSocketReady(socketPath, 120), false);
+ });
});
test('waitForUnixSocketReady returns false when path exists but is not socket', async () => {
- const { dir, socketPath } = createTempSocketPath();
- try {
+ await withTempCase(async ({ socketPath }) => {
fs.writeFileSync(socketPath, 'not-a-socket');
- const ready = await waitForUnixSocketReady(socketPath, 200);
- assert.equal(ready, false);
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ assert.equal(await waitForUnixSocketReady(socketPath, 200), false);
+ });
});
test('waitForUnixSocketReady returns true when socket becomes connectable before timeout', async () => {
- const { dir, socketPath } = createTempSocketPath();
- fs.writeFileSync(socketPath, '');
- const originalCreateConnection = net.createConnection;
- try {
- net.createConnection = (() => {
- const socket = new EventEmitter() as net.Socket;
- socket.destroy = (() => socket) as net.Socket['destroy'];
- socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
- setTimeout(() => socket.emit('connect'), 25);
- return socket;
- }) as typeof net.createConnection;
-
- const ready = await waitForUnixSocketReady(socketPath, 400);
+ await withTempCase(async ({ socketPath }) => {
+ fs.writeFileSync(socketPath, '');
+ const ready = await withConnectableSockets(25, () => waitForUnixSocketReady(socketPath, 400));
assert.equal(ready, true);
- } finally {
- net.createConnection = originalCreateConnection;
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ });
});
-function makeArgs(overrides: Partial = {}): Args {
- return {
- backend: 'x11',
- directory: '.',
- recursive: false,
- profile: '',
- startOverlay: false,
- youtubePrimarySubLangs: [],
- youtubeSecondarySubLangs: [],
- youtubeAudioLangs: [],
- useTexthooker: false,
- autoStartOverlay: false,
- texthookerOnly: false,
- texthookerOpenBrowser: false,
- useRofi: false,
- history: false,
- sync: false,
- syncCliTokens: [],
- syncUi: false,
- logLevel: 'error',
- logRotation: 7,
- passwordStore: '',
- target: '',
- targetKind: '',
- jimakuApiKey: '',
- jimakuApiKeyCommand: '',
- jimakuApiBaseUrl: '',
- jimakuLanguagePreference: 'none',
- jimakuMaxEntryResults: 10,
- jellyfin: false,
- jellyfinLogin: false,
- jellyfinLogout: false,
- jellyfinPlay: false,
- jellyfinDiscovery: false,
- dictionary: false,
- dictionaryCandidates: false,
- dictionarySelect: false,
- stats: false,
- doctor: false,
- doctorRefreshKnownWords: false,
- logsExport: false,
- version: false,
- settings: false,
- youtubeBrowser: false,
- configPath: false,
- configShow: false,
- mpvIdle: false,
- mpvSocket: false,
- mpvStatus: false,
- mpvArgs: '',
- appPassthrough: false,
- appArgs: [],
- jellyfinServer: '',
- jellyfinUsername: '',
- jellyfinPassword: '',
- launchMode: 'normal',
- ...overrides,
- };
+// ── startOverlay ownership and attach ────────────────────────────────────────
+
+/**
+ * Runs `startOverlay` against a recording fake app and a connectable mpv socket, and returns
+ * what the app saw plus the launcher ownership state, captured before state is reset.
+ */
+async function runStartOverlay(options: { pingExitCode: number; alreadyManaged?: boolean }) {
+ return withTempCase(async ({ dir, socketPath }) => {
+ const app = writeInvocationRecorder(dir, options.pingExitCode);
+ fs.writeFileSync(socketPath, '');
+ if (options.alreadyManaged) {
+ state.appPath = app.appPath;
+ state.overlayManagedByLauncher = true;
+ }
+ try {
+ await withConnectableSockets(10, () => startOverlay(app.appPath, makeArgs(), socketPath));
+ return {
+ invocations: app.readInvocations(),
+ managedByLauncher: state.overlayManagedByLauncher,
+ ownedAppPath: state.appPath,
+ appPath: app.appPath,
+ };
+ } finally {
+ resetLauncherState();
+ }
+ });
}
-test('startOverlay resolves without fixed 2s sleep when readiness signals arrive quickly', async () => {
- const { dir, socketPath } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
- fs.chmodSync(appPath, 0o755);
- fs.writeFileSync(socketPath, '');
- const originalCreateConnection = net.createConnection;
- try {
- net.createConnection = (() => {
- const socket = new EventEmitter() as net.Socket;
- socket.destroy = (() => socket) as net.Socket['destroy'];
- socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
- setTimeout(() => socket.emit('connect'), 10);
- return socket;
- }) as typeof net.createConnection;
-
- const startedAt = Date.now();
- await startOverlay(appPath, makeArgs(), socketPath);
- const elapsedMs = Date.now() - startedAt;
-
- assert.ok(elapsedMs < 1200, `expected startOverlay <1200ms, got ${elapsedMs}ms`);
- } finally {
- net.createConnection = originalCreateConnection;
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- fs.rmSync(dir, { recursive: true, force: true });
- }
-});
-
test('startOverlay captures app stdout and stderr into app log', async () => {
- const { dir, socketPath } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appLogPath = path.join(dir, 'app.log');
- const originalAppLog = process.env.SUBMINER_APP_LOG;
- fs.writeFileSync(
- appPath,
- '#!/bin/sh\nprintf "hello from stdout\\n"\nprintf "hello from stderr\\n" >&2\nexit 0\n',
- );
- fs.chmodSync(appPath, 0o755);
- fs.writeFileSync(socketPath, '');
- const originalCreateConnection = net.createConnection;
- try {
- process.env.SUBMINER_APP_LOG = appLogPath;
- net.createConnection = (() => {
- const socket = new EventEmitter() as net.Socket;
- socket.destroy = (() => socket) as net.Socket['destroy'];
- socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
- setTimeout(() => socket.emit('connect'), 10);
- return socket;
- }) as typeof net.createConnection;
+ await withTempCase(async ({ dir, socketPath }) => {
+ const appLogPath = path.join(dir, 'app.log');
+ const appPath = writeFakeApp(dir, [
+ 'printf "hello from stdout\\n"',
+ 'printf "hello from stderr\\n" >&2',
+ 'exit 0',
+ ]);
+ fs.writeFileSync(socketPath, '');
- await startOverlay(appPath, makeArgs(), socketPath);
+ try {
+ await withEnv({ SUBMINER_APP_LOG: appLogPath }, () =>
+ withConnectableSockets(10, () => startOverlay(appPath, makeArgs(), socketPath)),
+ );
- const logText = fs.readFileSync(appLogPath, 'utf8');
- assert.match(logText, /\[STDOUT\] hello from stdout/);
- assert.match(logText, /\[STDERR\] hello from stderr/);
- } finally {
- net.createConnection = originalCreateConnection;
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- if (originalAppLog === undefined) {
- delete process.env.SUBMINER_APP_LOG;
- } else {
- process.env.SUBMINER_APP_LOG = originalAppLog;
+ const logText = fs.readFileSync(appLogPath, 'utf8');
+ assert.match(logText, /\[STDOUT\] hello from stdout/);
+ assert.match(logText, /\[STDERR\] hello from stderr/);
+ } finally {
+ resetLauncherState();
}
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ });
});
test('startOverlay starts launcher-owned playback in background managed mode', async () => {
- const { dir, socketPath } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appInvocationsPath = path.join(dir, 'app-invocations.log');
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
- 'if [ "$1" = "--app-ping" ]; then exit 1; fi',
- 'exit 0',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
- fs.writeFileSync(socketPath, '');
- const originalCreateConnection = net.createConnection;
- try {
- net.createConnection = (() => {
- const socket = new EventEmitter() as net.Socket;
- socket.destroy = (() => socket) as net.Socket['destroy'];
- socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
- setTimeout(() => socket.emit('connect'), 10);
- return socket;
- }) as typeof net.createConnection;
+ const result = await runStartOverlay({ pingExitCode: 1 });
- await startOverlay(appPath, makeArgs(), socketPath);
-
- const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
- assert.match(invocationText, /--background/);
- assert.match(invocationText, /--managed-playback/);
- assert.equal(state.overlayManagedByLauncher, true);
- assert.equal(state.appPath, appPath);
- } finally {
- net.createConnection = originalCreateConnection;
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ assert.match(result.invocations, /--background/);
+ assert.match(result.invocations, /--managed-playback/);
+ assert.equal(result.managedByLauncher, true);
+ assert.equal(result.ownedAppPath, result.appPath);
});
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
- const { dir, socketPath } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appInvocationsPath = path.join(dir, 'app-invocations.log');
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
- 'if [ "$1" = "--app-ping" ]; then exit 0; fi',
- 'exit 0',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
- fs.writeFileSync(socketPath, '');
- const originalCreateConnection = net.createConnection;
- try {
- net.createConnection = (() => {
- const socket = new EventEmitter() as net.Socket;
- socket.destroy = (() => socket) as net.Socket['destroy'];
- socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
- setTimeout(() => socket.emit('connect'), 10);
- return socket;
- }) as typeof net.createConnection;
+ const result = await runStartOverlay({ pingExitCode: 0 });
- await startOverlay(appPath, makeArgs(), socketPath);
-
- const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
- assert.match(invocationText, /--app-ping/);
- assert.match(invocationText, /--start/);
- assert.doesNotMatch(invocationText, /--background/);
- assert.equal(state.overlayManagedByLauncher, false);
- assert.equal(state.appPath, '');
- } finally {
- net.createConnection = originalCreateConnection;
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
- fs.rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
- if (process.platform === 'win32') return;
-
- const { dir, socketPath } = createTempSocketPath();
- const controlSocketPath = path.join(dir, 'control.sock');
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appInvocationsPath = path.join(dir, 'app-invocations.log');
- const receivedControlArgv: string[][] = [];
- const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
-
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
- 'if [ "$1" = "--app-ping" ]; then exit 0; fi',
- 'exit 0',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
-
- const mpvServer = net.createServer((socket) => socket.end());
- const controlServer = net.createServer((socket) => {
- let buffer = '';
- socket.on('data', (chunk) => {
- buffer += chunk.toString('utf8');
- const newlineMatch = buffer.match(/\r?\n/);
- if (!newlineMatch || newlineMatch.index === undefined) return;
- const line = buffer.slice(0, newlineMatch.index).trim();
- buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length);
- if (!line) return;
- const payload = JSON.parse(line) as { argv?: unknown };
- if (Array.isArray(payload.argv)) {
- receivedControlArgv.push(
- payload.argv.filter((value): value is string => typeof value === 'string'),
- );
- }
- socket.end(JSON.stringify({ ok: true }) + '\n');
- });
- });
-
- try {
- process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
- await new Promise((resolve, reject) => {
- mpvServer.once('error', reject);
- mpvServer.listen(socketPath, resolve);
- });
- await new Promise((resolve, reject) => {
- controlServer.once('error', reject);
- controlServer.listen(controlSocketPath, resolve);
- });
-
- await startOverlay(appPath, makeArgs(), socketPath);
-
- const invocationText = fs.existsSync(appInvocationsPath)
- ? fs.readFileSync(appInvocationsPath, 'utf8')
- : '';
- assert.equal(invocationText, '');
- assert.equal(receivedControlArgv.length, 1);
- assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
- '--start',
- '--managed-playback',
- '--backend',
- 'x11',
- '--socket',
- socketPath,
- '--log-level',
- ]);
- assert.equal(state.overlayManagedByLauncher, false);
- assert.equal(state.appPath, '');
- } finally {
- if (originalControlSocket === undefined) {
- delete process.env.SUBMINER_APP_CONTROL_SOCKET;
- } else {
- process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
- }
- await new Promise((resolve) => mpvServer.close(() => resolve()));
- await new Promise((resolve) => controlServer.close(() => resolve()));
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
- fs.rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test('startOverlay uses caller config dir for app control socket discovery', async () => {
- if (process.platform === 'win32') return;
-
- const { dir, socketPath } = createTempSocketPath();
- const configDir = path.join(dir, 'launcher-config');
- const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
- fs.mkdirSync(configDir, { recursive: true });
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appInvocationsPath = path.join(dir, 'app-invocations.log');
- const receivedControlArgv: string[][] = [];
- const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
-
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
- 'if [ "$1" = "--app-ping" ]; then exit 0; fi',
- 'exit 0',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
-
- const mpvServer = net.createServer((socket) => socket.end());
- const controlServer = net.createServer((socket) => {
- let buffer = '';
- socket.on('data', (chunk) => {
- buffer += chunk.toString('utf8');
- const newlineIndex = buffer.indexOf('\n');
- if (newlineIndex < 0) return;
- const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown };
- if (Array.isArray(payload.argv)) {
- receivedControlArgv.push(
- payload.argv.filter((value): value is string => typeof value === 'string'),
- );
- }
- socket.end(JSON.stringify({ ok: true }) + '\n');
- });
- });
-
- try {
- delete process.env.SUBMINER_APP_CONTROL_SOCKET;
- await new Promise((resolve, reject) => {
- mpvServer.once('error', reject);
- mpvServer.listen(socketPath, resolve);
- });
- await new Promise((resolve, reject) => {
- controlServer.once('error', reject);
- controlServer.listen(controlSocketPath, resolve);
- });
-
- await startOverlay(appPath, makeArgs(), socketPath, [], configDir);
-
- const invocationText = fs.existsSync(appInvocationsPath)
- ? fs.readFileSync(appInvocationsPath, 'utf8')
- : '';
- assert.equal(invocationText, '');
- assert.equal(receivedControlArgv.length, 1);
- assert.deepEqual(receivedControlArgv[0]?.slice(0, 6), [
- '--start',
- '--managed-playback',
- '--backend',
- 'x11',
- '--socket',
- socketPath,
- ]);
- } finally {
- if (originalControlSocket === undefined) {
- delete process.env.SUBMINER_APP_CONTROL_SOCKET;
- } else {
- process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
- }
- await new Promise((resolve) => mpvServer.close(() => resolve()));
- await new Promise((resolve) => controlServer.close(() => resolve()));
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
- fs.rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test('startOverlay falls back to legacy app startup when control command fails', async () => {
- if (process.platform === 'win32') return;
-
- const { dir, socketPath } = createTempSocketPath();
- const controlSocketPath = path.join(dir, 'control.sock');
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appInvocationsPath = path.join(dir, 'app-invocations.log');
- const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
-
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
- 'if [ "$1" = "--app-ping" ]; then exit 0; fi',
- 'exit 0',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
-
- const controlServer = net.createServer((socket) => {
- socket.on('data', () => {
- socket.end(JSON.stringify({ ok: false, error: 'boom' }) + '\n');
- });
- });
-
- try {
- process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
- await new Promise((resolve, reject) => {
- controlServer.once('error', reject);
- controlServer.listen(controlSocketPath, resolve);
- });
-
- await startOverlay(appPath, makeArgs(), socketPath);
-
- const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
- assert.match(invocationText, /--app-ping/);
- assert.match(invocationText, /--start/);
- } finally {
- if (originalControlSocket === undefined) {
- delete process.env.SUBMINER_APP_CONTROL_SOCKET;
- } else {
- process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
- }
- await new Promise((resolve) => controlServer.close(() => resolve()));
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ assert.match(result.invocations, /--app-ping/);
+ assert.match(result.invocations, /--start/);
+ assert.doesNotMatch(result.invocations, /--background/);
+ assert.equal(result.managedByLauncher, false);
+ assert.equal(result.ownedAppPath, '');
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
- const { dir, socketPath } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appInvocationsPath = path.join(dir, 'app-invocations.log');
- fs.writeFileSync(
- appPath,
- [
- '#!/bin/sh',
- `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
- 'if [ "$1" = "--app-ping" ]; then exit 0; fi',
- 'exit 0',
- '',
- ].join('\n'),
- );
- fs.chmodSync(appPath, 0o755);
- fs.writeFileSync(socketPath, '');
- const originalCreateConnection = net.createConnection;
- try {
- state.appPath = appPath;
- state.overlayManagedByLauncher = true;
- net.createConnection = (() => {
- const socket = new EventEmitter() as net.Socket;
- socket.destroy = (() => socket) as net.Socket['destroy'];
- socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
- setTimeout(() => socket.emit('connect'), 10);
- return socket;
- }) as typeof net.createConnection;
+ const result = await runStartOverlay({ pingExitCode: 0, alreadyManaged: true });
- await startOverlay(appPath, makeArgs(), socketPath);
-
- assert.equal(state.overlayManagedByLauncher, true);
- assert.equal(state.appPath, appPath);
- } finally {
- net.createConnection = originalCreateConnection;
- state.overlayProc = null;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ assert.equal(result.managedByLauncher, true);
+ assert.equal(result.ownedAppPath, result.appPath);
});
+const controlSocketCases = [
+ {
+ name: 'startOverlay attaches through the running app control socket without spawning another app command',
+ // The control socket is found through SUBMINER_APP_CONTROL_SOCKET.
+ useConfigDir: false,
+ },
+ {
+ name: 'startOverlay uses caller config dir for app control socket discovery',
+ useConfigDir: true,
+ },
+];
+
+for (const c of controlSocketCases) {
+ test(c.name, { skip: process.platform === 'win32' }, async () => {
+ await withTempCase(async ({ dir, socketPath }) => {
+ const configDir = path.join(dir, 'launcher-config');
+ fs.mkdirSync(configDir, { recursive: true });
+ const controlSocketPath = c.useConfigDir
+ ? getAppControlSocketPath({ configDir, platform: 'linux' })
+ : path.join(dir, 'control.sock');
+ const app = writeInvocationRecorder(dir, 0);
+ const mpvServer = net.createServer((socket) => socket.end());
+ const control = createControlServer();
+
+ try {
+ await listen(mpvServer, socketPath);
+ await listen(control.server, controlSocketPath);
+
+ await withEnv(
+ { SUBMINER_APP_CONTROL_SOCKET: c.useConfigDir ? undefined : controlSocketPath },
+ () =>
+ c.useConfigDir
+ ? startOverlay(app.appPath, makeArgs(), socketPath, [], configDir)
+ : startOverlay(app.appPath, makeArgs(), socketPath),
+ );
+
+ assert.equal(app.readInvocations(), '');
+ assert.equal(control.receivedArgv.length, 1);
+ assert.deepEqual(control.receivedArgv[0]?.slice(0, 6), [
+ '--start',
+ '--managed-playback',
+ '--backend',
+ 'x11',
+ '--socket',
+ socketPath,
+ ]);
+ assert.equal(state.overlayManagedByLauncher, false);
+ assert.equal(state.appPath, '');
+ } finally {
+ await closeServer(mpvServer);
+ await closeServer(control.server);
+ resetLauncherState();
+ }
+ });
+ });
+}
+
+test(
+ 'startOverlay falls back to legacy app startup when control command fails',
+ { skip: process.platform === 'win32' },
+ async () => {
+ await withTempCase(async ({ dir, socketPath }) => {
+ const controlSocketPath = path.join(dir, 'control.sock');
+ const app = writeInvocationRecorder(dir, 0);
+ const control = createControlServer({ ok: false, error: 'boom' });
+
+ try {
+ await listen(control.server, controlSocketPath);
+
+ await withEnv({ SUBMINER_APP_CONTROL_SOCKET: controlSocketPath }, () =>
+ startOverlay(app.appPath, makeArgs(), socketPath),
+ );
+
+ const invocations = app.readInvocations();
+ assert.match(invocations, /--app-ping/);
+ assert.match(invocations, /--start/);
+ } finally {
+ await closeServer(control.server);
+ resetLauncherState();
+ }
+ });
+ },
+);
+
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
- const { dir } = createTempSocketPath();
- const appPath = path.join(dir, 'fake-subminer.sh');
- const appInvocationsPath = path.join(dir, 'app-invocations.log');
- fs.writeFileSync(
- appPath,
- `#!/bin/sh\necho \"$@\" >> ${JSON.stringify(appInvocationsPath)}\nexit 0\n`,
- );
- fs.chmodSync(appPath, 0o755);
+ await withTempCase(async ({ dir }) => {
+ const app = writeInvocationRecorder(dir, 0);
+ const calls: string[] = [];
+ const overlayProc = {
+ killed: false,
+ kill: () => {
+ calls.push('overlay-kill');
+ return true;
+ },
+ } as unknown as NonNullable;
+ const mpvProc = {
+ killed: false,
+ kill: () => {
+ calls.push('mpv-kill');
+ return true;
+ },
+ } as unknown as NonNullable;
- const calls: string[] = [];
- const overlayProc = {
- killed: false,
- kill: () => {
- calls.push('overlay-kill');
- return true;
- },
- } as unknown as NonNullable;
- const mpvProc = {
- killed: false,
- kill: () => {
- calls.push('mpv-kill');
- return true;
- },
- } as unknown as NonNullable;
-
- state.stopRequested = false;
- state.appPath = appPath;
- state.overlayManagedByLauncher = true;
- state.overlayProc = overlayProc;
- state.mpvProc = mpvProc;
-
- try {
- await cleanupPlaybackSession(makeArgs());
-
- assert.deepEqual(calls, ['overlay-kill', 'mpv-kill']);
- assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/);
- } finally {
- state.overlayProc = null;
- state.mpvProc = null;
- state.overlayManagedByLauncher = false;
- state.appPath = '';
state.stopRequested = false;
- fs.rmSync(dir, { recursive: true, force: true });
- }
+ state.appPath = app.appPath;
+ state.overlayManagedByLauncher = true;
+ state.overlayProc = overlayProc;
+ state.mpvProc = mpvProc;
+
+ try {
+ await cleanupPlaybackSession(makeArgs());
+
+ assert.deepEqual(calls.sort(), ['mpv-kill', 'overlay-kill']);
+ assert.match(app.readInvocations(), /--stop/);
+ } finally {
+ state.mpvProc = null;
+ state.stopRequested = false;
+ resetLauncherState();
+ }
+ });
});
-// ── findAppBinary: Linux packaged path discovery ──────────────────────────────
+// ── findAppBinary ────────────────────────────────────────────────────────────
+
+interface FindAppBinaryScenario {
+ platform: NodeJS.Platform;
+ home: string;
+ /** Extra env; SUBMINER_APPIMAGE_PATH / SUBMINER_BINARY_PATH are always cleared first. */
+ env?: Record;
+ /** When set, `fs.accessSync` accepts only these paths. */
+ executables?: string[];
+ /** When set, `fs.existsSync` / `fs.statSync` see only these paths. */
+ existing?: string[];
+ directories?: string[];
+ /** When set, replaces `fs.realpathSync`. */
+ realpath?: (filePath: string) => string;
+}
+
+/**
+ * Runs `run` with platform, home dir, env, and (optionally) fs probes faked for
+ * `findAppBinary`, which reads them from process globals. Everything is restored afterwards.
+ */
+async function withFindAppBinaryScenario(
+ scenario: FindAppBinaryScenario,
+ run: (pathModule: typeof path) => void,
+): Promise {
+ const restores: Array<() => void> = [];
+ const stub = (target: object, key: string, value: unknown): void => {
+ const original = Object.getOwnPropertyDescriptor(target, key);
+ Object.defineProperty(target, key, { value, configurable: true, writable: true });
+ restores.push(() => {
+ if (original) Object.defineProperty(target, key, original);
+ else Reflect.deleteProperty(target, key);
+ });
+ };
+ const missing = (code: string, filePath: string) =>
+ Object.assign(new Error(`${code}: ${filePath}`), { code });
+
+ try {
+ stub(process, 'platform', scenario.platform);
+ stub(os, 'homedir', () => scenario.home);
+ if (scenario.executables) {
+ const executables = new Set(scenario.executables);
+ stub(fs, 'accessSync', (filePath: string): void => {
+ if (!executables.has(filePath)) throw missing('EACCES', filePath);
+ });
+ }
+ if (scenario.existing || scenario.directories) {
+ const directories = new Set(scenario.directories ?? []);
+ const files = new Set(scenario.existing ?? []);
+ stub(
+ fs,
+ 'existsSync',
+ (filePath: string) => files.has(filePath) || directories.has(filePath),
+ );
+ stub(fs, 'statSync', (filePath: string) => {
+ if (directories.has(filePath)) return { isDirectory: () => true };
+ if (files.has(filePath)) return { isDirectory: () => false };
+ throw missing('ENOENT', filePath);
+ });
+ }
+ if (scenario.realpath) stub(fs, 'realpathSync', scenario.realpath);
+
+ await withEnv(
+ { SUBMINER_APPIMAGE_PATH: undefined, SUBMINER_BINARY_PATH: undefined, ...scenario.env },
+ () => run(scenario.platform === 'win32' ? (path.win32 as typeof path) : path),
+ );
+ } finally {
+ for (const restore of restores.reverse()) restore();
+ }
+}
function makeExecutable(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -1106,194 +882,19 @@ function makeExecutable(filePath: string): void {
fs.chmodSync(filePath, 0o755);
}
-function withFindAppBinaryEnvSandbox(run: () => void): void {
- const originalAppImagePath = process.env.SUBMINER_APPIMAGE_PATH;
- const originalBinaryPath = process.env.SUBMINER_BINARY_PATH;
- try {
- delete process.env.SUBMINER_APPIMAGE_PATH;
- delete process.env.SUBMINER_BINARY_PATH;
- run();
- } finally {
- if (originalAppImagePath === undefined) {
- delete process.env.SUBMINER_APPIMAGE_PATH;
- } else {
- process.env.SUBMINER_APPIMAGE_PATH = originalAppImagePath;
- }
- if (originalBinaryPath === undefined) {
- delete process.env.SUBMINER_BINARY_PATH;
- } else {
- process.env.SUBMINER_BINARY_PATH = originalBinaryPath;
- }
- }
-}
-
-function withFindAppBinaryPlatformSandbox(
- platform: NodeJS.Platform,
- run: (pathModule: typeof path) => void,
-): void {
- const originalPlatform = process.platform;
- try {
- Object.defineProperty(process, 'platform', { value: platform, configurable: true });
- withFindAppBinaryEnvSandbox(() =>
- run(platform === 'win32' ? (path.win32 as typeof path) : path),
- );
- } finally {
- Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
- }
-}
-
-function withAccessSyncStub(
- isExecutablePath: (filePath: string) => boolean,
- run: () => void,
-): void {
- const originalAccessSync = fs.accessSync;
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).accessSync = (filePath: string): void => {
- if (isExecutablePath(filePath)) {
- return;
- }
- throw Object.assign(new Error(`EACCES: ${filePath}`), { code: 'EACCES' });
- };
- run();
- } finally {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).accessSync = originalAccessSync;
- }
-}
-
-function withExistsAndStatSyncStubs(
- options: {
- existingPaths?: string[];
- directoryPaths?: string[];
- },
- run: () => void,
-): void {
- const existingPaths = new Set(options.existingPaths ?? []);
- const directoryPaths = new Set(options.directoryPaths ?? []);
- const originalExistsSync = fs.existsSync;
- const originalStatSync = fs.statSync;
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).existsSync = (filePath: string): boolean =>
- existingPaths.has(filePath) || directoryPaths.has(filePath);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).statSync = (filePath: string) => {
- if (directoryPaths.has(filePath)) {
- return { isDirectory: () => true };
- }
- if (existingPaths.has(filePath)) {
- return { isDirectory: () => false };
- }
- throw Object.assign(new Error(`ENOENT: ${filePath}`), { code: 'ENOENT' });
- };
- run();
- } finally {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).existsSync = originalExistsSync;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).statSync = originalStatSync;
- }
-}
-
-function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: () => void): void {
- const originalRealpathSync = fs.realpathSync;
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).realpathSync = (filePath: string): string => resolvePath(filePath);
- run();
- } finally {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (fs as any).realpathSync = originalRealpathSync;
- }
-}
-
-function listRepoRootWindowsTempArtifacts(): string[] {
- return fs
- .readdirSync(process.cwd())
- .filter((entry) => /^\\tmp\\subminer-test-win-/.test(entry))
- .sort();
-}
-
-function runFindAppBinaryWindowsPathCase(): void {
- const baseDir = 'C:\\Users\\tester\\subminer-test-win-path';
- const originalHomedir = os.homedir;
- const originalPath = process.env.PATH;
- try {
- os.homedir = () => baseDir;
- const binDir = path.win32.join(baseDir, 'bin');
- const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
- process.env.PATH = `${binDir}${path.win32.delimiter}${originalPath ?? ''}`;
-
- withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
- withAccessSyncStub(
- (filePath) => filePath === wrapperPath,
- () => {
- const result = findAppBinary(
- pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
- pathModule,
- );
- assert.equal(result, wrapperPath);
- },
- );
- });
- } finally {
- os.homedir = originalHomedir;
- process.env.PATH = originalPath;
- }
-}
-
-function runFindAppBinaryWindowsInstallDirCase(): void {
- const baseDir = 'C:\\Users\\tester\\subminer-test-win-dir';
- const originalHomedir = os.homedir;
- const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
- try {
- os.homedir = () => baseDir;
- const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
- const appExe = path.win32.join(installDir, 'SubMiner.exe');
- process.env.SUBMINER_BINARY_PATH = installDir;
-
- withPlatform('win32', () => {
- withExistsAndStatSyncStubs({ existingPaths: [appExe], directoryPaths: [installDir] }, () => {
- withAccessSyncStub(
- (filePath) => filePath === appExe,
- () => {
- const result = findAppBinary(
- path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
- path.win32,
- );
- assert.equal(result, appExe);
- },
- );
- });
- });
- } finally {
- os.homedir = originalHomedir;
- if (originalSubminerBinaryPath === undefined) {
- delete process.env.SUBMINER_BINARY_PATH;
- } else {
- process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
- }
- }
-}
-
test(
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
{ concurrency: false },
- () => {
+ async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
- const originalHomedir = os.homedir;
try {
- os.homedir = () => baseDir;
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
makeExecutable(appImage);
- withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
- const result = findAppBinary('/some/other/path/subminer', pathModule);
- assert.equal(result, appImage);
+ await withFindAppBinaryScenario({ platform: 'linux', home: baseDir }, (pathModule) => {
+ assert.equal(findAppBinary('/some/other/path/subminer', pathModule), appImage);
});
} finally {
- os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true });
}
},
@@ -1302,166 +903,124 @@ test(
test(
'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist',
{ concurrency: false },
- () => {
- const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
- const originalHomedir = os.homedir;
- try {
- os.homedir = () => baseDir;
- withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
- withAccessSyncStub(
- (filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
- () => {
- const result = findAppBinary('/some/other/path/subminer', pathModule);
- assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
- },
+ async () => {
+ await withFindAppBinaryScenario(
+ {
+ platform: 'linux',
+ home: '/home/tester',
+ executables: ['/opt/SubMiner/SubMiner.AppImage'],
+ },
+ (pathModule) => {
+ assert.equal(
+ findAppBinary('/some/other/path/subminer', pathModule),
+ '/opt/SubMiner/SubMiner.AppImage',
);
- });
- } finally {
- os.homedir = originalHomedir;
- fs.rmSync(baseDir, { recursive: true, force: true });
- }
+ },
+ );
},
);
test(
'findAppBinary finds subminer on PATH when AppImage candidates do not exist',
{ concurrency: false },
- () => {
- const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
- const originalHomedir = os.homedir;
- const originalPath = process.env.PATH;
- try {
- os.homedir = () => baseDir;
- // No AppImage candidates in empty home dir; place subminer wrapper on PATH
- const binDir = path.join(baseDir, 'bin');
- const wrapperPath = path.join(binDir, 'subminer');
- makeExecutable(wrapperPath);
- process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
+ async () => {
+ const binDir = '/home/tester/bin';
+ const wrapperPath = path.join(binDir, 'subminer');
- withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
- withAccessSyncStub(
- (filePath) => filePath === wrapperPath,
- () => {
- // selfPath must differ from wrapperPath so the self-check does not exclude it
- const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
- assert.equal(result, wrapperPath);
- },
- );
- });
- } finally {
- os.homedir = originalHomedir;
- process.env.PATH = originalPath;
- fs.rmSync(baseDir, { recursive: true, force: true });
- }
+ await withFindAppBinaryScenario(
+ {
+ platform: 'linux',
+ home: '/home/tester',
+ env: { PATH: binDir },
+ executables: [wrapperPath],
+ },
+ (pathModule) => {
+ // selfPath must differ from wrapperPath so the self-check does not exclude it
+ assert.equal(findAppBinary('/home/tester/launcher/subminer', pathModule), wrapperPath);
+ },
+ );
},
);
test(
'findAppBinary excludes PATH matches that canonicalize to the launcher path',
{ concurrency: false },
- () => {
- const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
- const originalHomedir = os.homedir;
- const originalPath = process.env.PATH;
- try {
- os.homedir = () => baseDir;
- const binDir = path.join(baseDir, 'bin');
- const wrapperPath = path.join(binDir, 'subminer');
- const canonicalPath = path.join(baseDir, 'launch', 'subminer');
- makeExecutable(wrapperPath);
- process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
+ async () => {
+ const binDir = '/home/tester/bin';
+ const wrapperPath = path.join(binDir, 'subminer');
+ const canonicalPath = '/home/tester/launch/subminer';
- withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
- withAccessSyncStub(
- (filePath) => filePath === wrapperPath,
- () => {
- withRealpathSyncStub(
- (filePath) => {
- if (filePath === canonicalPath || filePath === wrapperPath) {
- return canonicalPath;
- }
- return filePath;
- },
- () => {
- const result = findAppBinary(canonicalPath, pathModule);
- assert.equal(result, null);
- },
- );
- },
- );
- });
- } finally {
- os.homedir = originalHomedir;
- process.env.PATH = originalPath;
- fs.rmSync(baseDir, { recursive: true, force: true });
- }
+ await withFindAppBinaryScenario(
+ {
+ platform: 'linux',
+ home: '/home/tester',
+ env: { PATH: binDir },
+ executables: [wrapperPath],
+ realpath: (filePath) =>
+ filePath === canonicalPath || filePath === wrapperPath ? canonicalPath : filePath,
+ },
+ (pathModule) => {
+ assert.equal(findAppBinary(canonicalPath, pathModule), null);
+ },
+ );
},
);
-test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
- const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
- const originalHomedir = os.homedir;
- const originalLocalAppData = process.env.LOCALAPPDATA;
- try {
- os.homedir = () => baseDir;
- process.env.LOCALAPPDATA = path.win32.join(baseDir, 'AppData', 'Local');
- const appExe = path.win32.join(
- baseDir,
- 'AppData',
- 'Local',
- 'Programs',
- 'SubMiner',
- 'SubMiner.exe',
- );
-
- withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
- withAccessSyncStub(
- (filePath) => filePath === appExe,
- () => {
- const result = findAppBinary(
- pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
- pathModule,
- );
- assert.equal(result, appExe);
- },
- );
- });
- } finally {
- os.homedir = originalHomedir;
- if (originalLocalAppData === undefined) {
- delete process.env.LOCALAPPDATA;
- } else {
- process.env.LOCALAPPDATA = originalLocalAppData;
- }
- fs.rmSync(baseDir, { recursive: true, force: true });
- }
-});
+const windowsHome = 'C:\\Users\\tester';
+const windowsLauncherPath = path.win32.join(windowsHome, 'launcher', 'SubMiner.exe');
test(
- 'findAppBinary Windows cases do not leak backslash temp artifacts on POSIX',
+ 'findAppBinary resolves Windows install paths when present',
{ concurrency: false },
- () => {
- if (path.sep === '\\') {
- return;
- }
+ async () => {
+ const localAppData = path.win32.join(windowsHome, 'AppData', 'Local');
+ const appExe = path.win32.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe');
- const before = listRepoRootWindowsTempArtifacts();
- runFindAppBinaryWindowsPathCase();
- runFindAppBinaryWindowsInstallDirCase();
- const after = listRepoRootWindowsTempArtifacts();
-
- assert.deepEqual(after, before);
+ await withFindAppBinaryScenario(
+ {
+ platform: 'win32',
+ home: windowsHome,
+ env: { LOCALAPPDATA: localAppData },
+ executables: [appExe],
+ },
+ (pathModule) => {
+ assert.equal(findAppBinary(windowsLauncherPath, pathModule), appExe);
+ },
+ );
},
);
-test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, () => {
- runFindAppBinaryWindowsPathCase();
+test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, async () => {
+ const binDir = path.win32.join(windowsHome, 'bin');
+ const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
+
+ await withFindAppBinaryScenario(
+ { platform: 'win32', home: windowsHome, env: { PATH: binDir }, executables: [wrapperPath] },
+ (pathModule) => {
+ assert.equal(findAppBinary(windowsLauncherPath, pathModule), wrapperPath);
+ },
+ );
});
test(
'findAppBinary resolves a Windows install directory to SubMiner.exe',
{ concurrency: false },
- () => {
- runFindAppBinaryWindowsInstallDirCase();
+ async () => {
+ const installDir = path.win32.join(windowsHome, 'Programs', 'SubMiner');
+ const appExe = path.win32.join(installDir, 'SubMiner.exe');
+
+ await withFindAppBinaryScenario(
+ {
+ platform: 'win32',
+ home: windowsHome,
+ env: { SUBMINER_BINARY_PATH: installDir },
+ existing: [appExe],
+ directories: [installDir],
+ executables: [appExe],
+ },
+ (pathModule) => {
+ assert.equal(findAppBinary(windowsLauncherPath, pathModule), appExe);
+ },
+ );
},
);
diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts
index 60cca3f4..8d9fe82d 100644
--- a/launcher/parse-args.test.ts
+++ b/launcher/parse-args.test.ts
@@ -30,12 +30,6 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
-test('parseArgs appends CLI mpv profile to configured mpv profile', () => {
- const parsed = parseArgs(['--profile', 'hdr'], 'subminer', {}, { profile: 'anime' });
-
- assert.equal(parsed.profile, 'anime,hdr');
-});
-
test('parseArgs maps root settings window option', () => {
const parsed = parseArgs(['--settings'], 'subminer', {});
@@ -224,14 +218,6 @@ test('parseArgs maps stats cleanup to vocab mode by default', () => {
assert.equal(parsed.statsCleanupVocab, true);
});
-test('parseArgs maps explicit stats cleanup vocab flag', () => {
- const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
-
- assert.equal(parsed.stats, true);
- assert.equal(parsed.statsCleanup, true);
- assert.equal(parsed.statsCleanupVocab, true);
-});
-
test('parseArgs maps lifetime stats cleanup flag', () => {
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
diff --git a/launcher/runtime-plugin-preflight.test.ts b/launcher/runtime-plugin-preflight.test.ts
index 7d9be846..cd13beb0 100644
--- a/launcher/runtime-plugin-preflight.test.ts
+++ b/launcher/runtime-plugin-preflight.test.ts
@@ -8,282 +8,168 @@ import {
installManagedPluginAssetsViaApp,
} from './runtime-plugin-preflight';
-test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
- const calls: string[] = [];
+type EnsureOptions = Parameters[0];
- await ensureLinuxRuntimePluginAvailable({
- platform: 'darwin',
- detectInstalledPlugin: () => {
- calls.push('detect');
- return false;
- },
- resolveRuntimePluginPath: () => {
- calls.push('resolve');
- return null;
- },
- installManagedPluginAssets: async () => {
- calls.push('install');
- return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
- },
- log: () => {
- calls.push('log');
- },
- });
-
- assert.deepEqual(calls, []);
-});
-
-test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
- const calls: string[] = [];
-
- await ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- detectInstalledPlugin: () => {
- calls.push('detect');
- return true;
- },
- resolveRuntimePluginPath: () => {
- calls.push('resolve');
- return null;
- },
- installManagedPluginAssets: async () => {
- calls.push('install');
- return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
- },
- isManagedThemeAvailable: () => {
- calls.push('theme');
- return true;
- },
- isManagedThumbnailerAvailable: () => {
- calls.push('thumbnailer');
- return true;
- },
- log: () => {},
- });
-
- assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
-});
-
-test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
- const calls: string[] = [];
-
- await ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- xdgDataHome: '/tmp/xdg-data',
- detectInstalledPlugin: () => {
- calls.push('detect');
- return false;
- },
- resolveRuntimePluginPath: () => {
- calls.push('resolve');
- return '/tmp/plugin/main.lua';
- },
- installManagedPluginAssets: async () => {
- calls.push('install');
- return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
- },
- isManagedThemeAvailable: () => {
- calls.push('theme');
- return true;
- },
- isManagedThumbnailerAvailable: () => {
- calls.push('thumbnailer');
- return true;
- },
- log: () => {},
- });
-
- assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
-});
-
-test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
- const calls: string[] = [];
- let themeAvailable = false;
-
- await ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- xdgDataHome: '/tmp/xdg-data',
- detectInstalledPlugin: () => {
- calls.push('detect');
- return false;
- },
- resolveRuntimePluginPath: () => {
- calls.push('resolve');
- return '/tmp/plugin/main.lua';
- },
- isManagedThemeAvailable: () => {
- calls.push('theme');
- return themeAvailable;
- },
- isManagedThumbnailerAvailable: () => {
- calls.push('thumbnailer');
- return true;
- },
- installManagedPluginAssets: async () => {
- calls.push('install');
- themeAvailable = true;
- return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
- },
- log: (level, _configured, message) => {
- calls.push(`${level}:${message}`);
- },
- });
-
- assert.deepEqual(calls, [
- 'detect',
- 'resolve',
- 'theme',
- 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
- 'install',
- 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
- 'resolve',
- 'theme',
- 'thumbnailer',
- ]);
-});
-
-test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => {
- const calls: string[] = [];
- let thumbnailerAvailable = false;
-
- await ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- xdgDataHome: '/tmp/xdg-data',
- detectInstalledPlugin: () => true,
- resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
- isManagedThemeAvailable: () => true,
- isManagedThumbnailerAvailable: () => thumbnailerAvailable,
- installManagedPluginAssets: async () => {
- calls.push('install');
- thumbnailerAvailable = true;
- return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
- },
- log: (_level, _configured, message) => {
- calls.push(message);
- },
- });
-
- assert.deepEqual(calls, [
- 'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
- 'install',
- 'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
- ]);
-});
-
-test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
- const calls: string[] = [];
- let thumbnailerAvailable = false;
-
- await ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- xdgDataHome: '/tmp/xdg-data',
- detectInstalledPlugin: () => true,
- resolveRuntimePluginPath: () => {
- calls.push('resolve');
- return null;
- },
- isManagedThemeAvailable: () => true,
- isManagedThumbnailerAvailable: () => thumbnailerAvailable,
- installManagedPluginAssets: async () => {
- calls.push('install');
- thumbnailerAvailable = true;
- return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
- },
- log: () => {},
- });
-
- assert.deepEqual(calls, ['install']);
-});
-
-test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
- const calls: string[] = [];
- let resolveCount = 0;
-
- await ensureLinuxRuntimePluginAvailable({
+/**
+ * Linux preflight harness: by default every managed asset resolves, so tests override only
+ * the probes that should report something missing. Counts installs and collects log messages.
+ */
+function makeEnsureHarness(overrides: Partial = {}) {
+ const state = { installs: 0, logs: [] as string[] };
+ const options: EnsureOptions = {
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => false,
- resolveRuntimePluginPath: () => {
- resolveCount += 1;
- calls.push(`resolve:${resolveCount}`);
- return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
- },
+ resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => true,
installManagedPluginAssets: async () => {
- calls.push('install');
+ state.installs += 1;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
- log: (level, _configured, message) => {
- calls.push(`${level}:${message}`);
+ log: (_level, _configured, message) => {
+ state.logs.push(message);
+ },
+ ...overrides,
+ };
+ return { state, run: () => ensureLinuxRuntimePluginAvailable(options) };
+}
+
+test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
+ const unexpected = () => {
+ throw new Error('probed on a non-Linux platform');
+ };
+ const harness = makeEnsureHarness({
+ platform: 'darwin',
+ detectInstalledPlugin: unexpected,
+ resolveRuntimePluginPath: unexpected,
+ isManagedThemeAvailable: unexpected,
+ isManagedThumbnailerAvailable: unexpected,
+ });
+
+ await harness.run();
+
+ assert.equal(harness.state.installs, 0);
+ assert.deepEqual(harness.state.logs, []);
+});
+
+const skipInstallCases: Array<{ name: string; overrides: Partial }> = [
+ {
+ name: 'plugin, theme, and thumbnailer exist',
+ overrides: { detectInstalledPlugin: () => true, resolveRuntimePluginPath: () => null },
+ },
+ { name: 'all managed assets resolve', overrides: {} },
+];
+
+for (const c of skipInstallCases) {
+ test(`ensureLinuxRuntimePluginAvailable skips install when ${c.name}`, async () => {
+ const harness = makeEnsureHarness(c.overrides);
+
+ await harness.run();
+
+ assert.equal(harness.state.installs, 0);
+ assert.deepEqual(harness.state.logs, []);
+ });
+}
+
+const installWhenMissingCases: Array<{ name: string; missing: 'theme' | 'thumbnailer' }> = [
+ { name: 'rofi theme is missing', missing: 'theme' },
+ { name: 'thumbnailer is missing', missing: 'thumbnailer' },
+];
+
+for (const c of installWhenMissingCases) {
+ test(`ensureLinuxRuntimePluginAvailable installs managed assets when ${c.name}`, async () => {
+ let available = false;
+ const probe = {
+ [c.missing === 'theme' ? 'isManagedThemeAvailable' : 'isManagedThumbnailerAvailable']: () =>
+ available,
+ };
+ const harness = makeEnsureHarness({
+ ...probe,
+ installManagedPluginAssets: async () => {
+ harness.state.installs += 1;
+ available = true;
+ return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
+ },
+ });
+
+ await harness.run();
+
+ assert.equal(harness.state.installs, 1);
+ assert.equal(harness.state.logs.length, 2);
+ assert.match(harness.state.logs[0]!, /support assets missing; installing/);
+ assert.match(
+ harness.state.logs[1]!,
+ /installed: plugin=\/tmp\/plugin\/main\.lua theme=.*subminer\.rasi thumbnailer=.*\.thumbnailer/,
+ );
+ });
+}
+
+test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
+ let thumbnailerAvailable = false;
+ const harness = makeEnsureHarness({
+ detectInstalledPlugin: () => true,
+ // An installed plugin means the bundled path is never needed, even after the install.
+ resolveRuntimePluginPath: () => null,
+ isManagedThumbnailerAvailable: () => thumbnailerAvailable,
+ installManagedPluginAssets: async () => {
+ harness.state.installs += 1;
+ thumbnailerAvailable = true;
+ return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
});
- assert.deepEqual(calls, [
- 'resolve:1',
- 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
- 'install',
- 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
- 'resolve:2',
- ]);
+ await harness.run();
+
+ assert.equal(harness.state.installs, 1);
});
-test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
- await assert.rejects(
- () =>
- ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- detectInstalledPlugin: () => false,
- resolveRuntimePluginPath: () => null,
- installManagedPluginAssets: async () => ({
- ok: false,
- status: 'failed',
- error: 'copy failed',
- }),
- log: () => {},
- }),
- /copy failed/,
- );
+test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
+ let installed = false;
+ const harness = makeEnsureHarness({
+ resolveRuntimePluginPath: () => (installed ? '/tmp/plugin/main.lua' : null),
+ installManagedPluginAssets: async () => {
+ harness.state.installs += 1;
+ installed = true;
+ return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
+ },
+ });
+
+ await harness.run();
+
+ assert.equal(harness.state.installs, 1);
});
-test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
- await assert.rejects(
- () =>
- ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- detectInstalledPlugin: () => false,
- resolveRuntimePluginPath: () => null,
- installManagedPluginAssets: async () => ({
- ok: true,
- status: 'installed',
- path: '/tmp/plugin/main.lua',
- }),
- log: () => {},
+const failureCases: Array<{ name: string; overrides: Partial; error: RegExp }> = [
+ {
+ name: 'install result is not ok',
+ overrides: {
+ resolveRuntimePluginPath: () => null,
+ installManagedPluginAssets: async () => ({
+ ok: false,
+ status: 'failed',
+ error: 'copy failed',
}),
- /managed runtime plugin assets could not be installed/i,
- );
-});
+ },
+ error: /copy failed/,
+ },
+ {
+ name: 'runtime path remains unresolved after install',
+ overrides: { resolveRuntimePluginPath: () => null },
+ error: /managed runtime plugin assets could not be installed/i,
+ },
+ {
+ name: 'thumbnailer remains missing after install',
+ overrides: { detectInstalledPlugin: () => true, isManagedThumbnailerAvailable: () => false },
+ error: /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
+ },
+];
-test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => {
- await assert.rejects(
- () =>
- ensureLinuxRuntimePluginAvailable({
- platform: 'linux',
- xdgDataHome: '/tmp/xdg-data',
- detectInstalledPlugin: () => true,
- resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
- isManagedThemeAvailable: () => true,
- isManagedThumbnailerAvailable: () => false,
- installManagedPluginAssets: async () => ({
- ok: true,
- status: 'installed',
- path: '/tmp/plugin/main.lua',
- }),
- log: () => {},
- }),
- /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
- );
-});
+for (const c of failureCases) {
+ test(`ensureLinuxRuntimePluginAvailable fails when ${c.name}`, async () => {
+ await assert.rejects(() => makeEnsureHarness(c.overrides).run(), c.error);
+ });
+}
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts
index d8ccdf84..13de6560 100644
--- a/launcher/setup-gate.test.ts
+++ b/launcher/setup-gate.test.ts
@@ -7,104 +7,90 @@ import {
} from './setup-gate';
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
-const commandLineSetupDefaults = {
- bunInstallStatus: 'unknown',
- launcherInstallStatus: 'unknown',
- launcherInstallPath: null,
-} satisfies Pick;
+/** An in-progress setup state; tests override only the fields that matter. */
+function makeState(overrides: Partial = {}): SetupState {
+ return {
+ version: 4,
+ status: 'in_progress',
+ completedAt: null,
+ completionSource: null,
+ yomitanSetupMode: null,
+ lastSeenYomitanDictionaryCount: 0,
+ pluginInstallStatus: 'unknown',
+ pluginInstallPathSummary: null,
+ windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
+ windowsMpvShortcutLastStatus: 'unknown',
+ bunInstallStatus: 'unknown',
+ launcherInstallStatus: 'unknown',
+ launcherInstallPath: null,
+ ...overrides,
+ };
+}
-test('waitForSetupCompletion resolves completed and cancelled states', async () => {
- const sequence: Array = [
- null,
- {
- version: 4,
- status: 'in_progress',
- completedAt: null,
- completionSource: null,
- yomitanSetupMode: null,
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'unknown',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- },
- {
- version: 4,
- status: 'completed',
- completedAt: '2026-03-07T00:00:00.000Z',
- completionSource: 'user',
- yomitanSetupMode: 'internal',
- lastSeenYomitanDictionaryCount: 1,
- pluginInstallStatus: 'skipped',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'skipped',
- ...commandLineSetupDefaults,
- },
- ];
+function completedState(overrides: Partial = {}): SetupState {
+ return makeState({
+ status: 'completed',
+ completedAt: '2026-03-07T00:00:00.000Z',
+ completionSource: 'user',
+ yomitanSetupMode: 'internal',
+ lastSeenYomitanDictionaryCount: 1,
+ ...overrides,
+ });
+}
- const result = await waitForSetupCompletion({
- readSetupState: () => sequence.shift() ?? null,
+const cancelledState = () => makeState({ status: 'cancelled' });
+
+/** Deterministic clock: every `now()` advances 100ms and `sleep` returns immediately. */
+function fakeClock() {
+ let value = 0;
+ return {
sleep: async () => undefined,
- now: (() => {
- let value = 0;
- return () => (value += 100);
- })(),
+ now: () => (value += 100),
timeoutMs: 5_000,
pollIntervalMs: 100,
- });
+ };
+}
- assert.equal(result, 'completed');
-});
+/** Reader that serves `states` in order, then repeats the last one; counts its reads. */
+function sequenceReader(states: Array) {
+ const reader = {
+ reads: 0,
+ read: (): SetupState | null => states[Math.min(reader.reads++, states.length - 1)] ?? null,
+ };
+ return reader;
+}
+
+for (const c of [
+ { name: 'completed', last: completedState(), expected: 'completed' },
+ { name: 'cancelled', last: cancelledState(), expected: 'cancelled' },
+] as const) {
+ test(`waitForSetupCompletion resolves ${c.name} state after polling through earlier states`, async () => {
+ const reader = sequenceReader([null, makeState(), c.last]);
+
+ const result = await waitForSetupCompletion({
+ readSetupState: reader.read,
+ ...fakeClock(),
+ });
+
+ assert.equal(result, c.expected);
+ assert.equal(reader.reads, 3);
+ });
+}
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
const calls: string[] = [];
- let reads = 0;
+ const reader = sequenceReader([
+ null,
+ makeState(),
+ completedState({ pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv' }),
+ ]);
const ready = await ensureLauncherSetupReady({
- readSetupState: () => {
- reads += 1;
- if (reads === 1) return null;
- if (reads === 2) {
- return {
- version: 4,
- status: 'in_progress',
- completedAt: null,
- completionSource: null,
- yomitanSetupMode: null,
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'unknown',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- };
- }
- return {
- version: 4,
- status: 'completed',
- completedAt: '2026-03-07T00:00:00.000Z',
- completionSource: 'user',
- yomitanSetupMode: 'internal',
- lastSeenYomitanDictionaryCount: 1,
- pluginInstallStatus: 'installed',
- pluginInstallPathSummary: '/tmp/mpv',
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'installed',
- ...commandLineSetupDefaults,
- };
- },
+ readSetupState: reader.read,
launchSetupApp: () => {
calls.push('launch');
},
- sleep: async () => undefined,
- now: (() => {
- let value = 0;
- return () => (value += 100);
- })(),
- timeoutMs: 5_000,
- pollIntervalMs: 100,
+ ...fakeClock(),
});
assert.equal(ready, true);
@@ -120,10 +106,7 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
launchSetupApp: () => {
calls.push('launch');
},
- sleep: async () => undefined,
- now: () => 0,
- timeoutMs: 5_000,
- pollIntervalMs: 100,
+ ...fakeClock(),
});
assert.equal(ready, true);
@@ -133,76 +116,47 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
const calls: string[] = [];
let legacyPluginInstalled = true;
- let reads = 0;
+ // The completion timestamp moves forward once the setup app has finished.
+ const reader = sequenceReader([
+ completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
+ completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
+ completedState({
+ completedAt: '2026-05-12T14:40:00.000Z',
+ yomitanSetupMode: null,
+ lastSeenYomitanDictionaryCount: 0,
+ }),
+ ]);
const ready = await ensureLauncherSetupReady({
- readSetupState: () => {
- reads += 1;
- return {
- version: 4,
- status: 'completed',
- completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
- completionSource: 'user',
- yomitanSetupMode: null,
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'unknown',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- };
- },
+ readSetupState: reader.read,
hasLegacyMpvPlugin: () => legacyPluginInstalled,
launchSetupApp: () => {
calls.push('launch');
legacyPluginInstalled = false;
},
- sleep: async () => undefined,
- now: (() => {
- let value = 0;
- return () => (value += 100);
- })(),
- timeoutMs: 5_000,
- pollIntervalMs: 100,
+ ...fakeClock(),
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
- assert.equal(reads >= 3, true);
+ assert.equal(reader.reads >= 3, true);
});
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
const calls: string[] = [];
- let reads = 0;
+ const reader = sequenceReader([
+ completedState({ lastSeenYomitanDictionaryCount: 2 }),
+ completedState({ lastSeenYomitanDictionaryCount: 2 }),
+ completedState({ completedAt: '2026-05-12T14:30:00.000Z', lastSeenYomitanDictionaryCount: 2 }),
+ ]);
const ready = await ensureLauncherSetupReady({
- readSetupState: () => {
- reads += 1;
- return {
- version: 4,
- status: 'completed',
- completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
- completionSource: 'user',
- yomitanSetupMode: 'internal',
- lastSeenYomitanDictionaryCount: 2,
- pluginInstallStatus: 'unknown',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- };
- },
+ readSetupState: reader.read,
hasLegacyMpvPlugin: () => true,
launchSetupApp: () => {
calls.push('launch');
},
- sleep: async () => undefined,
- now: (() => {
- let value = 0;
- return () => (value += 100);
- })(),
- timeoutMs: 5_000,
- pollIntervalMs: 100,
+ ...fakeClock(),
});
assert.equal(ready, true);
@@ -211,90 +165,30 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
- readSetupState: () => ({
- version: 4,
- status: 'cancelled',
- completedAt: null,
- completionSource: null,
- yomitanSetupMode: null,
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'unknown',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- }),
+ readSetupState: cancelledState,
launchSetupApp: () => undefined,
- sleep: async () => undefined,
- now: (() => {
- let value = 0;
- return () => (value += 100);
- })(),
- timeoutMs: 5_000,
- pollIntervalMs: 100,
+ ...fakeClock(),
});
assert.equal(result, false);
});
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
- let reads = 0;
+ const reader = sequenceReader([
+ cancelledState(),
+ cancelledState(),
+ makeState(),
+ completedState({
+ completionSource: 'legacy_auto_detected',
+ pluginInstallStatus: 'installed',
+ pluginInstallPathSummary: '/tmp/mpv',
+ }),
+ ]);
const result = await ensureLauncherSetupReady({
- readSetupState: () => {
- reads += 1;
- if (reads <= 2) {
- return {
- version: 4,
- status: 'cancelled',
- completedAt: null,
- completionSource: null,
- yomitanSetupMode: null,
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'unknown',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- };
- }
- if (reads === 3) {
- return {
- version: 4,
- status: 'in_progress',
- completedAt: null,
- completionSource: null,
- yomitanSetupMode: null,
- lastSeenYomitanDictionaryCount: 0,
- pluginInstallStatus: 'unknown',
- pluginInstallPathSummary: null,
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- };
- }
- return {
- version: 4,
- status: 'completed',
- completedAt: '2026-03-07T00:00:00.000Z',
- completionSource: 'legacy_auto_detected',
- yomitanSetupMode: 'internal',
- lastSeenYomitanDictionaryCount: 1,
- pluginInstallStatus: 'installed',
- pluginInstallPathSummary: '/tmp/mpv',
- windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
- windowsMpvShortcutLastStatus: 'unknown',
- ...commandLineSetupDefaults,
- };
- },
+ readSetupState: reader.read,
launchSetupApp: () => undefined,
- sleep: async () => undefined,
- now: (() => {
- let value = 0;
- return () => (value += 100);
- })(),
- timeoutMs: 5_000,
- pollIntervalMs: 100,
+ ...fakeClock(),
});
assert.equal(result, true);
diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts
index 718622f8..b695e176 100644
--- a/launcher/smoke.e2e.test.ts
+++ b/launcher/smoke.e2e.test.ts
@@ -9,7 +9,6 @@ import {
createDefaultSetupState,
getDefaultConfigDir,
getSetupStatePath,
- readSetupState,
writeSetupState,
} from '../src/shared/setup-state.js';
@@ -407,65 +406,65 @@ setInterval(() => {}, 1000);
};
}
-test('launcher smoke fixture seeds completed setup state', () => {
- const smokeCase = createSmokeCase('setup-state');
+/**
+ * Some sandboxes forbid binding unix sockets (EPERM). The fake mpv needs one, so these tests
+ * cannot pass there; probe once and skip them instead of weakening every assertion.
+ */
+function unixSocketsDenied(): boolean {
+ if (process.platform === 'win32') return false;
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sock-probe-'));
try {
- const configDir = getDefaultConfigDir({
- xdgConfigHome: smokeCase.xdgConfigHome,
- homeDir: smokeCase.homeDir,
- });
- const statePath = getSetupStatePath(configDir);
-
- assert.equal(readSetupState(statePath)?.status, 'completed');
+ const probe = spawnSync(
+ process.execPath,
+ [
+ '-e',
+ `require('node:net').createServer().on('error', (e) => { process.stderr.write(String(e)); process.exit(1); }).listen(${JSON.stringify(path.join(dir, 's.sock'))}, function () { this.close(() => process.exit(0)); });`,
+ ],
+ { encoding: 'utf8', timeout: 5000 },
+ );
+ return probe.status !== 0 && /eperm|operation not permitted/i.test(probe.stderr);
} finally {
- fs.rmSync(smokeCase.root, { recursive: true, force: true });
- fs.rmSync(smokeCase.socketDir, { recursive: true, force: true });
+ fs.rmSync(dir, { recursive: true, force: true });
}
-});
+}
-test('launcher mpv status returns ready when socket is connectable', async () => {
- await withSmokeCase('mpv-status', async (smokeCase) => {
- const env = makeTestEnv(smokeCase);
- const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
- env,
- stdio: 'ignore',
- });
+const skipWithoutUnixSockets = unixSocketsDenied() ? 'sandbox denies unix socket binding' : false;
- try {
- await waitForSocketReady(smokeCase.socketPath);
- const result = runLauncher(
- smokeCase,
- ['mpv', 'status', '--log-level', 'debug'],
+test(
+ 'launcher mpv status returns ready when socket is connectable',
+ { skip: skipWithoutUnixSockets },
+ async () => {
+ await withSmokeCase('mpv-status', async (smokeCase) => {
+ const env = makeTestEnv(smokeCase);
+ const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
env,
- 'mpv-status',
- );
- const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
- const fakeMpvError = fakeMpvEntries.find(
- (entry): entry is { error: string } => typeof entry.error === 'string',
- )?.error;
- const unixSocketDenied =
- typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
+ stdio: 'ignore',
+ });
- if (unixSocketDenied) {
- assert.equal(result.status, 1);
- assert.match(result.stdout, /socket not ready/i);
- } else {
+ try {
+ await waitForSocketReady(smokeCase.socketPath);
+ const result = runLauncher(
+ smokeCase,
+ ['mpv', 'status', '--log-level', 'debug'],
+ env,
+ 'mpv-status',
+ );
assert.equal(result.status, 0);
assert.match(result.stdout, /socket ready/i);
+ } finally {
+ if (fakeMpv.exitCode === null) {
+ await new Promise((resolve) => {
+ fakeMpv.once('close', () => resolve());
+ });
+ }
}
- } finally {
- if (fakeMpv.exitCode === null) {
- await new Promise((resolve) => {
- fakeMpv.once('close', () => resolve());
- });
- }
- }
- });
-});
+ });
+ },
+);
test(
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
- { timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
+ { timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
const env = makeTestEnv(smokeCase);
@@ -484,13 +483,8 @@ test(
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
- const mpvError = mpvEntries.find(
- (entry): entry is { error: string } => typeof entry.error === 'string',
- )?.error;
- const unixSocketDenied =
- typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
- assert.equal(result.status, unixSocketDenied ? 3 : 0);
+ assert.equal(result.status, 0);
assert.match(result.stdout, /Starting SubMiner overlay/i);
assert.equal(appStartEntries.length, 1);
@@ -523,7 +517,7 @@ test(
test(
'launcher start-overlay attaches to a running background app without spawning another app start command',
- { timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
+ { timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const controlServer = await startFakeControlServer(smokeCase);
@@ -550,13 +544,8 @@ test(
const appStopEntries = readJsonLines(appStopPath);
const controlEntries = readJsonLines(controlServer.logPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
- const mpvError = mpvEntries.find(
- (entry): entry is { error: string } => typeof entry.error === 'string',
- )?.error;
- const unixSocketDenied =
- typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
- assert.equal(result.status, unixSocketDenied ? 3 : 0);
+ assert.equal(result.status, 0);
if (process.platform === 'linux') {
assert.equal(appEntries.length > 0, true);
assert.equal(
@@ -583,7 +572,7 @@ test(
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
- { timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
+ { timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
async () => {
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
fs.writeFileSync(
@@ -607,14 +596,9 @@ test(
);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
- const mpvError = mpvEntries.find(
- (entry): entry is { error: string } => typeof entry.error === 'string',
- )?.error;
- const unixSocketDenied =
- typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
const mpvFirstArgs = mpvEntries[0]?.argv;
- assert.equal(result.status, unixSocketDenied ? 3 : 0);
+ assert.equal(result.status, 0);
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(
diff --git a/launcher/test-support/args.ts b/launcher/test-support/args.ts
new file mode 100644
index 00000000..f1d5a8a2
--- /dev/null
+++ b/launcher/test-support/args.ts
@@ -0,0 +1,7 @@
+import { parseArgs } from '../config.js';
+import type { Args } from '../types.js';
+
+/** Default launcher `Args` (as if run with no CLI input), with `overrides` applied. */
+export function makeLauncherArgs(overrides: Partial = {}): Args {
+ return { ...parseArgs([], 'subminer', {}), ...overrides };
+}
diff --git a/launcher/test-support/env.ts b/launcher/test-support/env.ts
new file mode 100644
index 00000000..c86fdd77
--- /dev/null
+++ b/launcher/test-support/env.ts
@@ -0,0 +1,25 @@
+/**
+ * Runs `run` with the given process env values (`undefined` deletes a key) and restores the
+ * originals afterwards, even if `run` throws or rejects.
+ */
+export async function withEnv(
+ overrides: Record,
+ run: () => T | Promise,
+): Promise {
+ const originals = Object.fromEntries(
+ Object.keys(overrides).map((key) => [key, process.env[key]]),
+ );
+ const apply = (values: Record): void => {
+ for (const [key, value] of Object.entries(values)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ };
+
+ apply(overrides);
+ try {
+ return await run();
+ } finally {
+ apply(originals);
+ }
+}
diff --git a/launcher/test-support/immersion-db-schema.test.ts b/launcher/test-support/immersion-db-schema.test.ts
index 22d71d2e..eb59a25a 100644
--- a/launcher/test-support/immersion-db-schema.test.ts
+++ b/launcher/test-support/immersion-db-schema.test.ts
@@ -5,7 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { Database as BunDatabase } from 'bun:sqlite';
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
-import { createImmersionDbFixture, insertFixtureSession } from './immersion-db-fixture.js';
+import { createImmersionDbFixture } from './immersion-db-fixture.js';
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
@@ -108,65 +108,3 @@ test('fixture schema stays aligned with production sync-touched tables and index
fs.rmSync(dir, { recursive: true, force: true });
}
});
-
-test('fixture leaves lexical rollups pending when their table is absent', () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-rollup-state-'));
- const fixturePath = path.join(dir, 'fixture.sqlite');
- try {
- createImmersionDbFixture(fixturePath);
- const db = new BunDatabase(fixturePath, { readonly: true });
- try {
- const state = db
- .query<{ state_value: string }>(
- `SELECT state_value FROM imm_rollup_state
- WHERE state_key = 'lexical_daily_rollups_version'`,
- )
- .get();
- const rollupTable = db
- .query<{ name: string }>(
- `SELECT name FROM sqlite_schema
- WHERE type = 'table' AND name = 'imm_lexical_daily_rollups'`,
- )
- .get();
-
- assert.equal(state?.state_value, '0');
- assert.equal(rollupTable, null);
- } finally {
- db.close();
- }
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test('fixture session inserts enforce foreign keys', () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-'));
- const fixturePath = path.join(dir, 'fixture.sqlite');
- try {
- createImmersionDbFixture(fixturePath);
- const db = new BunDatabase(fixturePath, { readwrite: true });
- try {
- db.run(`
- CREATE TRIGGER remove_fixture_video
- BEFORE INSERT ON imm_sessions
- BEGIN
- DELETE FROM imm_videos WHERE video_id = NEW.video_id;
- END
- `);
- } finally {
- db.close();
- }
-
- assert.throws(
- () =>
- insertFixtureSession(fixturePath, {
- uuid: 'foreign-key-check',
- videoKey: 'foreign-key-check',
- startedAtMs: Date.UTC(2026, 6, 9),
- }),
- /FOREIGN KEY constraint failed/,
- );
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
-});
diff --git a/package.json b/package.json
index 8e6c6a71..307e7d06 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
- "version": "0.20.1-beta.1",
+ "version": "0.20.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
diff --git a/release/prerelease-notes.md b/release/prerelease-notes.md
index 9555498f..596d3245 100644
--- a/release/prerelease-notes.md
+++ b/release/prerelease-notes.md
@@ -1,73 +1,76 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
-
+
+
+## Changes since v0.20.1-beta.1
+
+- Upgraded the desktop runtime to Electron 43.7.7.
+- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
+- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
+- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
+- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
+- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
+- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
+- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
+- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
+- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
+- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
+- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
## Highlights
-
### Added
- **YouTube Browser Window**:
- - Open a YouTube browser with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in to YouTube across restarts.
- - Clicking a video plays it in mpv instead of on the page.
- - Middle-click, Shift-click, Ctrl-click, or the right-click menu adds a video to mpv's playlist.
- - When a queued video starts, it gets the same Japanese subtitle setup.
- - If you started SubMiner with `subminer youtube`, closing the YouTube window quits SubMiner. If a video is still playing, SubMiner quits after mpv closes.
+ - Open it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
+ - Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
+ - Queued videos get the same Japanese subtitle setup when they start.
+ - Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
- **Whisper Subtitles for YouTube**:
- - Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions. The default is still `youtube`.
- - The video stays paused while Whisper works, and progress shows in the subtitle generation modal. Close the modal to keep watching, or cancel to carry on without subtitles.
- - The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos with either `youtube.subtitleSource` setting.
- - Whisper downloads only a small audio stream and deletes it when generation ends, or when you switch videos, close mpv, or quit.
+ - Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
+ - The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
+ - The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
- **Mouse and Scroll Wheel Bindings**:
- - `keybindings` now accepts scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`), with or without modifiers. You can also record them in the settings key editor.
- - Mouse button and scroll wheel bindings from mpv now work while the cursor is over the overlay. This covers your `input.conf` and mpv's defaults, such as double-click for fullscreen, the wheel for volume, and back/forward for the playlist.
- - Hyprland users benefit most: the overlay always takes input there, so these bindings did nothing before.
- - SubMiner's own bindings and right-click pause still win over mpv's.
-- **Session Help Commands**:
- - During playback, press `Enter` or double-click a row in session help to run that command.
- - Hovering over a row makes it the target for `Enter`.
- - Session help opened from the tray with no video loaded is still read-only.
+ - You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
+ - mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
+ - When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
+- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
### Changed
-- **Release Assets**: Releases no longer include package-size JSON reports. Package contents are still validated before release.
+- **Release Assets**: Releases no longer include package-size JSON reports.
### Fixed
- **Jellyfin Subtitles**:
- - The Japanese subtitle track is selected, and annotations start, as soon as that track downloads. SubMiner no longer waits for every other track.
- - Subtitle tracks now download in parallel, so a slow embedded track no longer holds up the main subtitles.
- - Episodes with image-based subtitles (PGS, DVD, DVB) now select the Japanese and English tracks automatically.
- - If one subtitle track fails to download, the other tracks are still selected.
-- **Jellyfin Audio Track**: On direct-play Jellyfin streams, timing review previews and mined card audio now use the audio track you're playing, not the one Jellyfin marks as default.
+ - The Japanese track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
+ - Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
+ - If one track fails to download, the other tracks are still selected.
+- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
- **Media Timing Review**:
- - Trim handles are now thin lines centered on the clip edges. The start and end of the clip are easier to see, and less of the waveform is hidden.
- - The original subtitle boundaries are now plain orange bars without text labels, so the waveform stays visible.
- - On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly. Before, it stuttered and jumped to the end partway through. This affected Opus audio, including Jellyfin streams.
-- **Hyprland Fullscreen**:
- - The subtitle overlay now stays visible when mpv enters or leaves fullscreen, so focus changes no longer cancel fullscreen. Sway still refreshes the overlay as before.
- - With `mpv.backend: x11`, subtitles and the sidebar no longer stop responding to clicks after mpv goes fullscreen.
-- **mpv Key Bindings in the Overlay**:
- - mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus.
- - They used to do nothing if the overlay loaded before SubMiner connected to mpv, which was common with `mpv.backend: x11`. The overlay now reloads mpv's bindings once mpv connects.
-- **YouTube Japanese Subtitles**:
- - Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429", because SubMiner now picks the real Japanese track.
- - If YouTube refuses a direct subtitle download, SubMiner retries with yt-dlp.
-- **AppImage in Sandboxes**: The AppImage now opens its window when started in a sandbox that mounts the image itself, such as `firejail --appimage`. Before, it exited without opening a window.
-- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes SubMiner with a "write EPIPE" error.
-- **Security Updates**: Electron is updated to 42.10.0 and undici to 7.29.1 to fix known security advisories.
+ - Trim handles are now thin lines centered on the clip edges.
+ - Original subtitle boundaries are now plain orange bars with no labels.
+ - The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
+- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
+- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
+- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
+- **Stats Accuracy**:
+ - Sentence mining no longer counts each card twice in session and lifetime stats.
+ - Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
+ - Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
+- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
+- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
+- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
+- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
### Docs
-- **Docs Site Rewrite**:
- - Pages are shorter and easier to scan. Each one starts with setup and usage, and reference details are in compact tables.
- - The configuration reference now gives each config block a short explanation and a table of keys and defaults.
- - Pages that no longer matched how SubMiner works have been corrected.
-- **Arch Linux Install**:
- - The MeCab install command is fixed. MeCab is only in the AUR, as `mecab-git`.
- - The README's requirements and quick start now match the installation guide.
+- **Docs Site Rewrite**: Pages are shorter and easier to scan. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
+- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
+- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
## What's Changed
+- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
@@ -77,14 +80,17 @@
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
+- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
+- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
+- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
## Installation
-See the [installation guide](https://docs.subminer.moe/installation) for full setup steps.
+See the README and docs/installation guide for full setup steps.
## Assets
-- Linux: `SubMiner-*.AppImage`
+- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
diff --git a/release/release-notes.md b/release/release-notes.md
new file mode 100644
index 00000000..8ce3ea4e
--- /dev/null
+++ b/release/release-notes.md
@@ -0,0 +1,66 @@
+## Highlights
+### Added
+- **YouTube Browser Window**:
+ - Browse YouTube in a window that keeps your login across restarts. Open it with `subminer youtube` (or `subminer yt`), or choose **Browse YouTube** from the tray.
+ - Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
+ - Queued videos get the same Japanese subtitle setup when they start playing.
+- **Whisper Subtitles for YouTube**: Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions. The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos.
+- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay.
+- **Run Commands From Session Help**: Press `Enter` or double-click a row in session help to run that command during playback.
+
+### Fixed
+- **YouTube**:
+ - Japanese subtitles no longer fail with "HTTP 429" on videos that also have a machine-translated Japanese track.
+ - On Windows, the subtitle generation modal now works during YouTube playback.
+ - On Windows, playing a video queued from the YouTube browser window no longer clears the rest of mpv's queue.
+- **Jellyfin**:
+ - The Japanese subtitle track is selected as soon as it downloads, instead of waiting for every other track.
+ - Japanese and English image-based subtitles (PGS, DVD, DVB) are now selected automatically.
+ - Timing review previews and mined card audio use the audio track you are playing.
+- **Hyprland Overlay**: The overlay no longer interrupts mpv when it goes fullscreen. With `mpv.backend: x11`, the overlay stays clickable after mpv goes fullscreen.
+- **mpv Key Bindings**: Keys from mpv's defaults and your `input.conf` (such as `9` and `0` for volume) now work while the overlay has focus, even when the overlay loads before SubMiner connects to mpv.
+- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and the original subtitle boundaries are plain orange bars. On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly.
+- **Stats**:
+ - Each mined card is counted once instead of twice.
+ - Overview totals, charts, the calendar, and the vocabulary summary update right after you delete sessions.
+ - Word and kanji dates stay correct after deletes and pruning, and the new-words charts now agree with the vocabulary summary. Existing data is repaired in the background on the next launch.
+- **Yomitan Profile Safety**: Electron is updated to 43.7.7, and SubMiner now refuses to load your Yomitan data with an older or unsupported runtime. Automatic character-dictionary updates stop if a profile that had dictionaries suddenly reports none.
+- **Security Updates**: Updated Electron and undici to resolve security advisories.
+- **AppImage in Sandboxes**: The AppImage now opens a window when launched inside a sandbox such as `firejail --appimage`.
+- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when you stop the terminal command that started it with Ctrl+C.
+
+### Docs
+- **Docs Site**: The docs site is shorter and easier to scan, and the configuration reference now has a table of keys and defaults for each section. Outdated pages were corrected, and the Arch Linux MeCab install now uses the AUR package `mecab-git`.
+- **Stats**: Added docs on how the Overview refreshes after you delete sessions and how discovery dates are kept after pruning.
+
+## What's Changed
+
+- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
+- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
+- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
+- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
+- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
+- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
+- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
+- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
+- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
+- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
+- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
+- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
+- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
+
+## Installation
+
+See the README and docs/installation guide for full setup steps.
+
+## Assets
+
+- Linux: `SubMiner.AppImage`
+- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
+- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
+- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
+- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
+
+Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
+
+The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts
index cfb2ef06..77661893 100644
--- a/scripts/build-changelog.test.ts
+++ b/scripts/build-changelog.test.ts
@@ -14,6 +14,63 @@ function createWorkspace(name: string): string {
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
}
+interface Project {
+ /** Workspace parent dir; also handy as an empty directory. */
+ workspace: string;
+ projectRoot: string;
+ read(relativePath: string): string;
+ exists(relativePath: string): boolean;
+ write(relativePath: string, content: string): void;
+ remove(relativePath: string): void;
+ /** Absolute path of `relativePath` inside the project. */
+ at(relativePath: string): string;
+}
+
+const EMPTY_CHANGELOG = '# Changelog\n';
+
+function packageJson(version: string): string {
+ return JSON.stringify({ name: 'subminer', version }, null, 2);
+}
+
+/** Builds a fragment file body; `extraMeta` lines (e.g. `breaking: true`) follow `area`. */
+function fragment(type: string, area: string, body: string, extraMeta: string[] = []): string {
+ return [`type: ${type}`, `area: ${area}`, ...extraMeta, '', body].join('\n');
+}
+
+/**
+ * Creates a temp project containing `files` (relative path -> content), always with an
+ * empty `changes/` dir, runs `run`, then removes the workspace.
+ */
+async function withProject(
+ name: string,
+ files: Record,
+ run: (project: Project) => T | Promise,
+): Promise {
+ const workspace = createWorkspace(name);
+ const projectRoot = path.join(workspace, 'SubMiner');
+ const at = (relativePath: string) => path.join(projectRoot, relativePath);
+ const write = (relativePath: string, content: string) => {
+ fs.mkdirSync(path.dirname(at(relativePath)), { recursive: true });
+ fs.writeFileSync(at(relativePath), content, 'utf8');
+ };
+ fs.mkdirSync(at('changes'), { recursive: true });
+ for (const [relativePath, content] of Object.entries(files)) write(relativePath, content);
+
+ try {
+ return await run({
+ workspace,
+ projectRoot,
+ at,
+ write,
+ read: (relativePath) => fs.readFileSync(at(relativePath), 'utf8'),
+ exists: (relativePath) => fs.existsSync(at(relativePath)),
+ remove: (relativePath) => fs.rmSync(at(relativePath)),
+ });
+ } finally {
+ fs.rmSync(workspace, { recursive: true, force: true });
+ }
+}
+
type RunClaudeArgs = { input: string; args: string[] };
function recordingRunClaude(responder: (input: string) => string): {
@@ -44,22 +101,6 @@ function fragmentTypesInPrompt(input: string): string[] {
.map((line) => line.slice('type: '.length).trim());
}
-function assertPromptRequestsNestedBullets(input: string): void {
- assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
- assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
- assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
- assert.match(input, /Avoid paragraph-style release-note bullets/);
-}
-
-function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
- assertPromptRequestsNestedBullets(input);
- assert.match(
- input,
- /In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
- );
- assert.match(input, /Do not require the exact nested labels/);
-}
-
function defaultPolishedBody(input: string): string {
const mode = modeFromPrompt(input);
const types = fragmentTypesInPrompt(input);
@@ -99,27 +140,23 @@ function defaultStubClaude() {
return recordingRunClaude(defaultPolishedBody);
}
-test('resolveChangelogOutputPaths stays repo-local and never writes docs paths', async () => {
- const { resolveChangelogOutputPaths } = await loadModule();
- const workspace = createWorkspace('with-docs-repo');
- const projectRoot = path.join(workspace, 'SubMiner');
+const PRERELEASE_BANNER =
+ '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.';
- fs.mkdirSync(projectRoot, { recursive: true });
+const INSTALLATION_SECTION = [
+ '## Installation',
+ '',
+ 'See the README and docs/installation guide for full setup steps.',
+ '',
+];
- try {
- const outputPaths = resolveChangelogOutputPaths({ cwd: projectRoot });
-
- assert.deepEqual(outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
- assert.equal(outputPaths.includes(path.join(projectRoot, 'docs', 'changelog.md')), false);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
-});
+/** Fixture of a previous prerelease-notes.md: banner, optional marker, then `bodyLines`. */
+function existingPrereleaseNotes(markerLine: string | null, bodyLines: string[]): string {
+ return [PRERELEASE_BANNER, '', ...(markerLine ? [markerLine, ''] : []), ...bodyLines].join('\n');
+}
test('writeChangelogArtifacts ignores README, groups fragments by type, writes release notes, and deletes only fragment files', async () => {
const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('write-artifacts');
- const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
@@ -128,76 +165,55 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
'',
].join('\n');
- fs.mkdirSync(projectRoot, { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', 'README.md'),
- '# Changelog Fragments\n\nIgnored helper text.\n',
- 'utf8',
+ await withProject(
+ 'write-artifacts',
+ {
+ 'CHANGELOG.md': existingChangelog,
+ 'changes/README.md': '# Changelog Fragments\n\nIgnored helper text.\n',
+ 'changes/001.md': fragment('added', 'overlay', '- Added release fragments.'),
+ 'changes/002.md': fragment('fixed', 'release', 'Fixed release notes generation.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ const result = writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.4.1',
+ date: '2026-03-07',
+ deps: { runClaude: stub.runClaude },
+ });
+
+ assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
+ assert.deepEqual(result.deletedFragmentPaths, [
+ project.at('changes/001.md'),
+ project.at('changes/002.md'),
+ ]);
+ assert.equal(project.exists('changes/001.md'), false);
+ assert.equal(project.exists('changes/002.md'), false);
+ assert.equal(project.exists('changes/README.md'), true);
+
+ assert.deepEqual(
+ stub.calls.map((call) => modeFromPrompt(call.input)),
+ ['changelog', 'release-notes'],
+ 'expected one Claude call per output (changelog + release notes)',
+ );
+
+ const changelog = project.read('CHANGELOG.md');
+ assert.match(changelog, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n/);
+ assert.match(changelog, /### Added\n- Polished: added entry\./);
+ assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
+ assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
+
+ const releaseNotes = project.read('release/release-notes.md');
+ assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
+ assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
+ assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
+ assert.match(releaseNotes, /- Windows: `SubMiner-\*\.exe` and `SubMiner-\*-win\.zip`/);
+ },
);
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Added release fragments.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- ['type: fixed', 'area: release', '', 'Fixed release notes generation.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- const result = writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.4.1',
- date: '2026-03-07',
- deps: { runClaude: stub.runClaude },
- });
-
- assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
- assert.deepEqual(result.deletedFragmentPaths, [
- path.join(projectRoot, 'changes', '001.md'),
- path.join(projectRoot, 'changes', '002.md'),
- ]);
- assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false);
- assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false);
- assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true);
-
- assert.equal(
- stub.calls.length,
- 2,
- 'expected one Claude call per output (changelog + release notes)',
- );
- assert.deepEqual(
- stub.calls.map((call) => modeFromPrompt(call.input)),
- ['changelog', 'release-notes'],
- );
-
- const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
- assert.match(changelog, /^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n/);
- assert.match(changelog, /### Added\n- Polished: added entry\./);
- assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
- assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
-
- const releaseNotes = fs.readFileSync(
- path.join(projectRoot, 'release', 'release-notes.md'),
- 'utf8',
- );
- assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
- assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
- assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
- assert.match(releaseNotes, /- Windows: `SubMiner-\*\.exe` and `SubMiner-\*-win\.zip`/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writeChangelogArtifacts skips changelog prepend when release section already exists', async () => {
const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('write-artifacts-existing-version');
- const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
@@ -207,286 +223,160 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
'',
].join('\n');
- fs.mkdirSync(projectRoot, { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'),
- 'utf8',
+ await withProject(
+ 'write-artifacts-existing-version',
+ {
+ 'CHANGELOG.md': existingChangelog,
+ 'changes/001.md': fragment('added', 'overlay', '- Stale release fragment.'),
+ },
+ (project) => {
+ const result = writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.4.1',
+ date: '2026-03-08',
+ });
+
+ assert.deepEqual(result.deletedFragmentPaths, [project.at('changes/001.md')]);
+ assert.equal(project.exists('changes/001.md'), false);
+ assert.equal(project.read('CHANGELOG.md'), existingChangelog);
+ assert.match(
+ project.read('release/release-notes.md'),
+ /## Highlights\n### Added\n- Existing release bullet\./,
+ );
+ },
);
-
- try {
- const result = writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.4.1',
- date: '2026-03-08',
- });
-
- assert.deepEqual(result.deletedFragmentPaths, [path.join(projectRoot, 'changes', '001.md')]);
- assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false);
-
- const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
- assert.equal(changelog, existingChangelog);
- const releaseNotes = fs.readFileSync(
- path.join(projectRoot, 'release', 'release-notes.md'),
- 'utf8',
- );
- assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writeStableReleaseArtifacts reuses the requested version and date for changelog, release notes, and docs-site output', async () => {
const { writeStableReleaseArtifacts } = await loadModule();
- const workspace = createWorkspace('write-stable-release-artifacts');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.4.1' }, null, 2),
- 'utf8',
+ await withProject(
+ 'write-stable-release-artifacts',
+ {
+ 'docs-site/.keep': '',
+ 'package.json': packageJson('0.4.1'),
+ 'CHANGELOG.md': EMPTY_CHANGELOG,
+ 'changes/001.md': fragment('fixed', 'release', '- Reused explicit stable release date.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ const result = writeStableReleaseArtifacts({
+ cwd: project.projectRoot,
+ version: '0.4.1',
+ date: '2026-03-07',
+ deps: { runClaude: stub.runClaude },
+ });
+
+ assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
+ assert.equal(result.releaseNotesPath, project.at('release/release-notes.md'));
+ assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
+
+ assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/);
+ assert.match(project.read('docs-site/changelog.md'), /## v0\.4\.1 \(2026-03-07\)/);
+ },
);
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: fixed', 'area: release', '', '- Reused explicit stable release date.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- const result = writeStableReleaseArtifacts({
- cwd: projectRoot,
- version: '0.4.1',
- date: '2026-03-07',
- deps: { runClaude: stub.runClaude },
- });
-
- assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
- assert.equal(result.releaseNotesPath, path.join(projectRoot, 'release', 'release-notes.md'));
- assert.equal(result.docsChangelogPath, path.join(projectRoot, 'docs-site', 'changelog.md'));
-
- const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
- const docsChangelog = fs.readFileSync(
- path.join(projectRoot, 'docs-site', 'changelog.md'),
- 'utf8',
- );
-
- assert.match(changelog, /## v0\.4\.1 \(2026-03-07\)/);
- assert.match(docsChangelog, /## v0\.4\.1 \(2026-03-07\)/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => {
const { verifyChangelogReadyForRelease } = await loadModule();
- const workspace = createWorkspace('verify-release');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', 'README.md'),
- '# Changelog Fragments\n',
- 'utf8',
+ await withProject(
+ 'verify-release',
+ {
+ 'CHANGELOG.md': EMPTY_CHANGELOG,
+ 'changes/README.md': '# Changelog Fragments\n',
+ 'changes/001.md': '- Pending fragment.\n',
+ },
+ (project) => {
+ assert.throws(
+ () => verifyChangelogReadyForRelease({ cwd: project.projectRoot, version: '0.4.1' }),
+ /Pending changelog fragments/,
+ );
+
+ project.remove('changes/001.md');
+
+ assert.throws(
+ () => verifyChangelogReadyForRelease({ cwd: project.projectRoot, version: '0.4.1' }),
+ /Missing CHANGELOG section for v0\.4\.1/,
+ );
+ },
);
- fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), '- Pending fragment.\n', 'utf8');
-
- try {
- assert.throws(
- () => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }),
- /Pending changelog fragments/,
- );
-
- fs.rmSync(path.join(projectRoot, 'changes', '001.md'));
-
- assert.throws(
- () => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }),
- /Missing CHANGELOG section for v0\.4\.1/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('verifyChangelogReadyForRelease rejects explicit release versions that do not match package.json', async () => {
const { verifyChangelogReadyForRelease } = await loadModule();
- const workspace = createWorkspace('verify-release-version-match');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.4.0' }, null, 2),
- 'utf8',
+ await withProject(
+ 'verify-release-version-match',
+ {
+ 'package.json': packageJson('0.4.0'),
+ 'CHANGELOG.md': '# Changelog\n\n## v0.4.1 (2026-03-09)\n- Ready.\n',
+ },
+ (project) => {
+ assert.throws(
+ () => verifyChangelogReadyForRelease({ cwd: project.projectRoot, version: '0.4.1' }),
+ /package\.json version \(0\.4\.0\) does not match requested release version \(0\.4\.1\)/,
+ );
+ },
);
- fs.writeFileSync(
- path.join(projectRoot, 'CHANGELOG.md'),
- '# Changelog\n\n## v0.4.1 (2026-03-09)\n- Ready.\n',
- 'utf8',
- );
-
- try {
- assert.throws(
- () => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }),
- /package\.json version \(0\.4\.0\) does not match requested release version \(0\.4\.1\)/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writeChangelogArtifacts renders breaking changes section above type sections', async () => {
const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('breaking-changes');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(projectRoot, { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: changed', 'area: config', 'breaking: true', '', '- Renamed `foo` to `bar`.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- ['type: fixed', 'area: overlay', '', '- Fixed subtitle rendering.'].join('\n'),
- 'utf8',
- );
+ await withProject(
+ 'breaking-changes',
+ {
+ 'CHANGELOG.md': EMPTY_CHANGELOG,
+ 'changes/001.md': fragment('changed', 'config', '- Renamed `foo` to `bar`.', [
+ 'breaking: true',
+ ]),
+ 'changes/002.md': fragment('fixed', 'overlay', '- Fixed subtitle rendering.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.5.0',
+ date: '2026-04-06',
+ deps: { runClaude: stub.runClaude },
+ });
- try {
- const stub = defaultStubClaude();
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.5.0',
- date: '2026-04-06',
- deps: { runClaude: stub.runClaude },
- });
+ const changelog = project.read('CHANGELOG.md');
+ const breakingIndex = changelog.indexOf('### Breaking Changes');
+ const changedIndex = changelog.indexOf('### Changed');
+ const fixedIndex = changelog.indexOf('### Fixed');
- const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
- const breakingIndex = changelog.indexOf('### Breaking Changes');
- const changedIndex = changelog.indexOf('### Changed');
- const fixedIndex = changelog.indexOf('### Fixed');
+ assert.notEqual(breakingIndex, -1, 'Breaking Changes section should exist');
+ assert.notEqual(changedIndex, -1, 'Changed section should exist');
+ assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
+ assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
+ assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
- assert.notEqual(breakingIndex, -1, 'Breaking Changes section should exist');
- assert.notEqual(changedIndex, -1, 'Changed section should exist');
- assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
- assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
- assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
-
- const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
- assert.ok(changelogCall, 'expected at least one changelog-mode Claude invocation');
- assert.match(
- changelogCall.input,
- /breaking: true/,
- 'breaking metadata should reach the prompt verbatim',
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
-});
-
-test('writeChangelogArtifacts prompts Claude to summarize the final stable outcome instead of prerelease churn', async () => {
- const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('stable-outcome-prompt');
- const projectRoot = path.join(workspace, 'SubMiner');
-
- fs.mkdirSync(projectRoot, { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- [
- 'type: added',
- 'area: config',
- '',
- '- Added a dedicated Config window with launcher entry points.',
- ].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- [
- 'type: changed',
- 'area: config',
- 'breaking: true',
- '',
- '- Renamed the Config window to Settings window and changed the launcher entry point to `subminer settings`.',
- ].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '003.md'),
- [
- 'type: fixed',
- 'area: config',
- '',
- '- Fixed Settings window search and live subtitle CSS saves.',
- ].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.12.0',
- date: '2026-05-24',
- deps: { runClaude: stub.runClaude },
- });
-
- const prompts = stub.calls.map((call) => call.input);
- assert.equal(prompts.length, 2, 'expected changelog and release-notes prompts');
- for (const prompt of prompts) {
- assert.match(prompt, /Treat the fragment list as one cumulative release outcome/);
+ const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
+ assert.ok(changelogCall, 'expected at least one changelog-mode Claude invocation');
assert.match(
- prompt,
- /only if the final release requires action from users upgrading from the previous stable release/,
+ changelogCall.input,
+ /breaking: true/,
+ 'breaking metadata should reach the prompt verbatim',
);
- assert.match(prompt, /Config window.*Settings window/s);
- assert.match(
- prompt,
- /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
- );
- assertPromptRequestsNestedBullets(prompt);
- }
-
- const releaseNotesPrompt = stub.calls.find(
- (call) => modeFromPrompt(call.input) === 'release-notes',
- );
- assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
- assertReleaseNotesPromptRequestsNestedBullets(releaseNotesPrompt.input);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
+ },
+ );
});
test('verifyChangelogFragments rejects invalid metadata', async () => {
const { verifyChangelogFragments } = await loadModule();
- const workspace = createWorkspace('lint-invalid');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: nope', 'area: overlay', '', '- Invalid type.'].join('\n'),
- 'utf8',
+ await withProject(
+ 'lint-invalid',
+ { 'changes/001.md': fragment('nope', 'overlay', '- Invalid type.') },
+ (project) => {
+ assert.throws(
+ () => verifyChangelogFragments({ cwd: project.projectRoot }),
+ /must declare type as one of/,
+ );
+ },
);
-
- try {
- assert.throws(
- () => verifyChangelogFragments({ cwd: projectRoot }),
- /must declare type as one of/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('verifyPullRequestChangelog requires fragments for user-facing changes and skips docs-only changes', async () => {
@@ -561,401 +451,253 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-beta-notes');
- const projectRoot = path.join(workspace, 'SubMiner');
- const changelogPath = path.join(projectRoot, 'CHANGELOG.md');
- const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md');
const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n';
const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n';
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
- 'utf8',
+ await withProject(
+ 'prerelease-beta-notes',
+ {
+ 'package.json': packageJson('0.11.3-beta.1'),
+ 'CHANGELOG.md': existingChangelog,
+ 'docs-site/changelog.md': existingDocsChangelog,
+ 'changes/001.md': fragment('added', 'overlay', '- Added prerelease coverage.'),
+ 'changes/002.md': fragment('fixed', 'launcher', '- Fixed prerelease packaging checks.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ const outputPath = writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.11.3-beta.1',
+ deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
+ });
+
+ assert.equal(outputPath, project.at('release/prerelease-notes.md'));
+ assert.equal(
+ project.read('CHANGELOG.md'),
+ existingChangelog,
+ 'stable CHANGELOG.md should remain unchanged',
+ );
+ assert.equal(
+ project.read('docs-site/changelog.md'),
+ existingDocsChangelog,
+ 'docs-site changelog should remain unchanged',
+ );
+ assert.equal(project.exists('changes/001.md'), true);
+ assert.equal(project.exists('changes/002.md'), true);
+
+ assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
+ assert.equal(modeFromPrompt(stub.calls[0]!.input), 'release-notes');
+
+ const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
+ assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
+ assert.match(prereleaseNotes, //);
+ assert.doesNotMatch(prereleaseNotes, /## Changes since /);
+ assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
+ assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
+ assert.match(
+ prereleaseNotes,
+ /## Installation\n\nSee the README and docs\/installation guide/,
+ );
+ assert.match(prereleaseNotes, /Windows `subminer\.cmd` launcher/);
+ assert.match(
+ prereleaseNotes,
+ /Both launcher downloads use Bun included with the SubMiner app/,
+ );
+ assert.match(prereleaseNotes, /Bun corresponding source: `bun-v1\.3\.5-source\.tar\.gz`/);
+ assert.match(prereleaseNotes, /statically links JavaScriptCore \(LGPL 2\.0\)/);
+ },
);
- fs.writeFileSync(changelogPath, existingChangelog, 'utf8');
- fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- ['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- const outputPath = writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.11.3-beta.1',
- deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
- });
-
- assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
- assert.equal(
- fs.readFileSync(changelogPath, 'utf8'),
- existingChangelog,
- 'stable CHANGELOG.md should remain unchanged',
- );
- assert.equal(
- fs.readFileSync(docsChangelogPath, 'utf8'),
- existingDocsChangelog,
- 'docs-site changelog should remain unchanged',
- );
- assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true);
- assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true);
-
- assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
- assert.equal(modeFromPrompt(stub.calls[0]!.input), 'release-notes');
-
- const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
- assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
- assert.match(prereleaseNotes, //);
- assert.doesNotMatch(prereleaseNotes, /## Changes since /);
- assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
- assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
- assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
- assert.match(prereleaseNotes, /Windows `subminer\.cmd` launcher/);
- assert.match(prereleaseNotes, /Both launcher downloads use Bun included with the SubMiner app/);
- assert.match(prereleaseNotes, /Bun corresponding source: `bun-v1\.3\.5-source\.tar\.gz`/);
- assert.match(prereleaseNotes, /statically links JavaScriptCore \(LGPL 2\.0\)/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writePrereleaseNotesForVersion reuses existing prerelease notes when adding new fragments', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-reuse-existing-notes');
- const projectRoot = path.join(workspace, 'SubMiner');
- const existingNotes = [
- '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
- '',
- '',
- '',
+ const existingNotes = existingPrereleaseNotes('', [
'## Highlights',
'### Added',
'- Overlay: Previous beta entry.',
'',
- '## Installation',
- '',
- 'See the README and docs/installation guide for full setup steps.',
- '',
+ ...INSTALLATION_SECTION,
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
'',
- ].join('\n');
+ ]);
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
- 'utf8',
+ await withProject(
+ 'prerelease-reuse-existing-notes',
+ {
+ 'package.json': packageJson('0.11.3-beta.2'),
+ 'release/prerelease-notes.md': existingNotes,
+ 'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
+ },
+ (project) => {
+ const stub = recordingRunClaude((input) => {
+ if (!input.includes('Overlay: Previous beta entry.')) {
+ return '### Fixed\n- Launcher: Added only the latest fix.';
+ }
+ return [
+ '### Added',
+ '- Overlay: Previous beta entry.',
+ '',
+ '### Fixed',
+ '- Launcher: Added only the latest fix.',
+ ].join('\n');
+ });
+
+ const outputPath = writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.11.3-beta.2',
+ deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
+ });
+
+ assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
+ assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
+
+ const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
+ assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./);
+ assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./);
+ },
);
- fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: fixed', 'area: launcher', '', '- Fixed launcher prerelease packaging.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = recordingRunClaude((input) => {
- if (!input.includes('Overlay: Previous beta entry.')) {
- return '### Fixed\n- Launcher: Added only the latest fix.';
- }
- return [
- '### Added',
- '- Overlay: Previous beta entry.',
- '',
- '### Fixed',
- '- Launcher: Added only the latest fix.',
- ].join('\n');
- });
-
- const outputPath = writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.11.3-beta.2',
- deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
- });
-
- assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
- assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
-
- const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
- assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./);
- assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an older release line', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-ignore-unmarked-old-notes');
- const projectRoot = path.join(workspace, 'SubMiner');
- const existingNotes = [
- '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
- '',
+ const existingNotes = existingPrereleaseNotes(null, [
'## Highlights',
'### Added',
'- Settings Window: Previous release line entry.',
'',
- '## Installation',
- '',
- 'See the README and docs/installation guide for full setup steps.',
- '',
- ].join('\n');
+ ...INSTALLATION_SECTION,
+ ]);
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.17.0-beta.1' }, null, 2),
- 'utf8',
+ await withProject(
+ 'prerelease-ignore-unmarked-old-notes',
+ {
+ 'package.json': packageJson('0.17.0-beta.1'),
+ 'release/prerelease-notes.md': existingNotes,
+ 'changes/001.md': fragment(
+ 'changed',
+ 'overlay',
+ '- Replaced subtitle delay actions with native mpv keybindings.',
+ ),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ const outputPath = writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.17.0-beta.1',
+ deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
+ });
+
+ assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
+ assert.doesNotMatch(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
+ assert.doesNotMatch(stub.calls[0]!.input, /Settings Window: Previous release line entry/);
+
+ const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
+ assert.match(prereleaseNotes, /### Changed\n- Polished: changed entry\./);
+ },
);
- fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- [
- 'type: changed',
- 'area: overlay',
- '',
- '- Replaced subtitle delay actions with native mpv keybindings.',
- ].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- const outputPath = writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.17.0-beta.1',
- deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
- });
-
- assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
- assert.doesNotMatch(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
- assert.doesNotMatch(stub.calls[0]!.input, /Settings Window: Previous release line entry/);
-
- const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
- assert.match(prereleaseNotes, /### Changed\n- Polished: changed entry\./);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
-});
-
-test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
- const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-net-outcome-prompt');
- const projectRoot = path.join(workspace, 'SubMiner');
- const existingNotes = [
- '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
- '',
- '',
- '',
- '## Highlights',
- '### Added',
- '- Config Window: Previous beta entry.',
- '',
- '## Installation',
- '',
- 'See the README and docs/installation guide for full setup steps.',
- '',
- '## Assets',
- '',
- '- Linux: `SubMiner.AppImage`',
- '',
- ].join('\n');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
- 'utf8',
- );
- fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- [
- 'type: changed',
- 'area: config',
- 'breaking: true',
- '',
- '- Renamed the Config window to Settings window.',
- ].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- ['type: fixed', 'area: config', '', '- Fixed Settings window search.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = recordingRunClaude(() => '### Added\n- Settings Window: Current beta state.');
- writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.12.0-beta.2',
- deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
- });
-
- assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
- const prompt = stub.calls[0]!.input;
- assert.match(prompt, /EXISTING PRERELEASE NOTES/);
- assert.match(prompt, /Existing prerelease notes are a baseline, not an immutable changelog/);
- assert.match(prompt, /replace stale beta or RC wording/);
- assert.match(
- prompt,
- /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
- );
- assertReleaseNotesPromptRequestsNestedBullets(prompt);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-rc-notes');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2),
- 'utf8',
+ await withProject(
+ 'prerelease-rc-notes',
+ {
+ 'package.json': packageJson('0.11.3-rc.1'),
+ 'changes/001.md': fragment('changed', 'release', '- Prepared release candidate notes.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ const outputPath = writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.11.3-rc.1',
+ deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
+ });
+
+ assert.match(
+ fs.readFileSync(outputPath, 'utf8'),
+ /## Highlights\n### Changed\n- Polished: changed entry\./,
+ );
+ },
);
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- const outputPath = writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.11.3-rc.1',
- deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
- });
-
- const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
- assert.match(prereleaseNotes, /## Highlights\n### Changed\n- Polished: changed entry\./);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
-test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => {
- const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-alpha-reject');
- const projectRoot = path.join(workspace, 'SubMiner');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'),
- 'utf8',
- );
-
- try {
- assert.throws(
- () =>
- writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.11.3-alpha.1',
- }),
- /Unsupported prerelease version \(0\.11\.3-alpha\.1\)/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
-});
-
-test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => {
- const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-version-mismatch');
- const projectRoot = path.join(workspace, 'SubMiner');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'),
- 'utf8',
- );
-
- try {
- assert.throws(
- () =>
- writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.11.3-beta.2',
- }),
+const prereleaseRejectionCases = [
+ {
+ name: 'rejects unsupported prerelease identifiers',
+ packageVersion: '0.11.3-alpha.1',
+ requestedVersion: '0.11.3-alpha.1',
+ fragments: {
+ 'changes/001.md': fragment('added', 'overlay', '- Unsupported alpha prerelease.'),
+ },
+ error: /Unsupported prerelease version \(0\.11\.3-alpha\.1\)/,
+ },
+ {
+ name: 'rejects mismatched package versions',
+ packageVersion: '0.11.3-beta.1',
+ requestedVersion: '0.11.3-beta.2',
+ fragments: { 'changes/001.md': fragment('added', 'overlay', '- Mismatched prerelease.') },
+ error:
/package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/,
+ },
+ {
+ name: 'rejects empty prerelease note generation when no fragments exist',
+ packageVersion: '0.11.3-beta.1',
+ requestedVersion: '0.11.3-beta.1',
+ fragments: {},
+ error: /No changelog fragments found in changes\//,
+ },
+];
+
+for (const c of prereleaseRejectionCases) {
+ test(`writePrereleaseNotesForVersion ${c.name}`, async () => {
+ const { writePrereleaseNotesForVersion } = await loadModule();
+
+ await withProject(
+ 'prerelease-reject',
+ { 'package.json': packageJson(c.packageVersion), ...c.fragments },
+ (project) => {
+ assert.throws(
+ () =>
+ writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: c.requestedVersion,
+ }),
+ c.error,
+ );
+ },
);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
-});
+ });
+}
-test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => {
- const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-no-fragments');
- const projectRoot = path.join(workspace, 'SubMiner');
+/** Project with one valid fragment and a fixed stable-release call, for Claude failure modes. */
+async function expectChangelogFailure(
+ name: string,
+ files: Record,
+ runClaude: (input: string, args: string[]) => string,
+ error: RegExp,
+): Promise {
+ const { writeChangelogArtifacts } = await loadModule();
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
- 'utf8',
- );
-
- try {
+ await withProject(name, { 'CHANGELOG.md': EMPTY_CHANGELOG, ...files }, (project) => {
assert.throws(
() =>
- writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.11.3-beta.1',
+ writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.5.0',
+ date: '2026-04-06',
+ deps: { runClaude },
}),
- /No changelog fragments found in changes\//,
+ error,
);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
-});
+ });
+}
+
+const aChange = { 'changes/001.md': fragment('added', 'overlay', '- A change.') };
test('writeChangelogArtifacts surfaces a clear error when claude is missing from PATH', async () => {
- const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('claude-missing');
- const projectRoot = path.join(workspace, 'SubMiner');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- A change.'].join('\n'),
- 'utf8',
- );
-
// The production defaultRunClaude wrapper translates ENOENT into this friendly
// message; we simulate that contract here so the test exercises the propagation
// path through polishFragmentsWithClaude rather than re-implementing the
@@ -966,98 +708,28 @@ test('writeChangelogArtifacts surfaces a clear error when claude is missing from
);
};
- try {
- assert.throws(
- () =>
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.5.0',
- date: '2026-04-06',
- deps: { runClaude: enoent },
- }),
- /claude CLI not found on PATH/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
+ await expectChangelogFailure('claude-missing', aChange, enoent, /claude CLI not found on PATH/);
});
test('writeChangelogArtifacts rejects empty claude output', async () => {
- const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('claude-empty');
- const projectRoot = path.join(workspace, 'SubMiner');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- A change.'].join('\n'),
- 'utf8',
+ await expectChangelogFailure(
+ 'claude-empty',
+ aChange,
+ () => ' \n ',
+ /claude returned empty output/,
);
-
- try {
- assert.throws(
- () =>
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.5.0',
- date: '2026-04-06',
- deps: { runClaude: () => ' \n ' },
- }),
- /claude returned empty output/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writeChangelogArtifacts rejects claude output missing required section headers', async () => {
- const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('claude-no-headers');
- const projectRoot = path.join(workspace, 'SubMiner');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- A change.'].join('\n'),
- 'utf8',
+ await expectChangelogFailure(
+ 'claude-no-headers',
+ aChange,
+ () => 'Sure, here is your changelog: it is great.',
+ /missing the expected section heading/,
);
-
- try {
- assert.throws(
- () =>
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.5.0',
- date: '2026-04-06',
- deps: { runClaude: () => 'Sure, here is your changelog: it is great.' },
- }),
- /missing the expected section heading/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writeChangelogArtifacts rejects changelog-mode output that omits the Internal wrapper when internal fragments are present', async () => {
- const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('claude-no-details');
- const projectRoot = path.join(workspace, 'SubMiner');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- ['type: internal', 'area: release', '', '- An internal note.'].join('\n'),
- 'utf8',
- );
-
const noDetailsResponder = (input: string): string => {
if (modeFromPrompt(input) === 'changelog') {
return '### Added\n- Polished: added.\n\n### Internal\n- Polished: internal (no details wrapper).';
@@ -1065,234 +737,184 @@ test('writeChangelogArtifacts rejects changelog-mode output that omits the Inter
return defaultPolishedBody(input);
};
- try {
- assert.throws(
- () =>
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.5.0',
- date: '2026-04-06',
- deps: { runClaude: noDetailsResponder },
- }),
- /Internal changes<\/summary> wrapper/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
+ await expectChangelogFailure(
+ 'claude-no-details',
+ {
+ 'changes/001.md': fragment('added', 'overlay', '- A user-facing change.'),
+ 'changes/002.md': fragment('internal', 'release', '- An internal note.'),
+ },
+ noDetailsResponder,
+ /Internal changes<\/summary> wrapper/,
+ );
});
test('writeChangelogArtifacts filters internal fragments from the release-notes Claude prompt', async () => {
const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('release-notes-internal-filter');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- A user-facing change.'].join('\n'),
- 'utf8',
+ await withProject(
+ 'release-notes-internal-filter',
+ {
+ 'CHANGELOG.md': EMPTY_CHANGELOG,
+ 'changes/001.md': fragment('added', 'overlay', '- A user-facing change.'),
+ 'changes/002.md': fragment('internal', 'release', '- An internal CI tweak.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.5.0',
+ date: '2026-04-06',
+ deps: { runClaude: stub.runClaude },
+ });
+
+ const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
+ const releaseNotesCall = stub.calls.find(
+ (call) => modeFromPrompt(call.input) === 'release-notes',
+ );
+ assert.ok(changelogCall, 'expected a changelog-mode invocation');
+ assert.ok(releaseNotesCall, 'expected a release-notes-mode invocation');
+
+ assert.deepEqual(
+ fragmentTypesInPrompt(changelogCall.input).sort(),
+ ['added', 'internal'],
+ 'changelog mode keeps internal fragments',
+ );
+ assert.deepEqual(
+ fragmentTypesInPrompt(releaseNotesCall.input),
+ ['added'],
+ 'release-notes mode drops internal fragments',
+ );
+
+ const releaseNotes = project.read('release/release-notes.md');
+ assert.doesNotMatch(releaseNotes, //);
+ assert.doesNotMatch(releaseNotes, /### Internal/);
+ assert.match(
+ project.read('CHANGELOG.md'),
+ /[\s\S]*Internal changes<\/summary>/,
+ );
+ },
);
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- ['type: internal', 'area: release', '', '- An internal CI tweak.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.5.0',
- date: '2026-04-06',
- deps: { runClaude: stub.runClaude },
- });
-
- const changelogCall = stub.calls.find((call) => modeFromPrompt(call.input) === 'changelog');
- const releaseNotesCall = stub.calls.find(
- (call) => modeFromPrompt(call.input) === 'release-notes',
- );
- assert.ok(changelogCall, 'expected a changelog-mode invocation');
- assert.ok(releaseNotesCall, 'expected a release-notes-mode invocation');
-
- assert.deepEqual(
- fragmentTypesInPrompt(changelogCall.input).sort(),
- ['added', 'internal'],
- 'changelog mode keeps internal fragments',
- );
- assert.deepEqual(
- fragmentTypesInPrompt(releaseNotesCall.input),
- ['added'],
- 'release-notes mode drops internal fragments',
- );
-
- const releaseNotes = fs.readFileSync(
- path.join(projectRoot, 'release', 'release-notes.md'),
- 'utf8',
- );
- assert.doesNotMatch(releaseNotes, //);
- assert.doesNotMatch(releaseNotes, /### Internal/);
-
- const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
- assert.match(changelog, /[\s\S]*Internal changes<\/summary>/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => {
const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('release-notes-contributors');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '002.md'),
- ['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- const resolveContributionsCalls: string[][] = [];
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.6.0',
- date: '2026-05-06',
- deps: {
- runClaude: stub.runClaude,
- resolveContributions: (fragmentPaths) => {
- resolveContributionsCalls.push(fragmentPaths);
- return [
- {
- prNumber: 110,
- login: 'ksyasuda',
- title: 'feat(overlay): add a feature',
- isFirstContribution: false,
- },
- {
- prNumber: 112,
- login: 'bee-san',
- title: 'fix(jellyfin): restart remote session',
- isFirstContribution: true,
- },
- ];
+ await withProject(
+ 'release-notes-contributors',
+ {
+ 'CHANGELOG.md': EMPTY_CHANGELOG,
+ 'changes/001.md': fragment('added', 'overlay', '- Added a feature.'),
+ 'changes/002.md': fragment('fixed', 'jellyfin', '- Fixed a bug.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ const resolveContributionsCalls: string[][] = [];
+ writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.6.0',
+ date: '2026-05-06',
+ deps: {
+ runClaude: stub.runClaude,
+ resolveContributions: (fragmentPaths) => {
+ resolveContributionsCalls.push(fragmentPaths);
+ return [
+ {
+ prNumber: 110,
+ login: 'ksyasuda',
+ title: 'feat(overlay): add a feature',
+ isFirstContribution: false,
+ },
+ {
+ prNumber: 112,
+ login: 'bee-san',
+ title: 'fix(jellyfin): restart remote session',
+ isFirstContribution: true,
+ },
+ ];
+ },
},
- },
- });
+ });
- assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release');
- assert.deepEqual(resolveContributionsCalls[0], [
- path.join(projectRoot, 'changes', '001.md'),
- path.join(projectRoot, 'changes', '002.md'),
- ]);
+ assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release');
+ assert.deepEqual(resolveContributionsCalls[0], [
+ project.at('changes/001.md'),
+ project.at('changes/002.md'),
+ ]);
- const releaseNotes = fs.readFileSync(
- path.join(projectRoot, 'release', 'release-notes.md'),
- 'utf8',
- );
- assert.match(releaseNotes, /## What's Changed\n\n/);
- assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
- assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
- assert.match(
- releaseNotes,
- /## New Contributors\n\n- @bee-san made their first contribution in #112/,
- );
- assert.ok(
- releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
- "What's Changed should follow Highlights",
- );
- assert.ok(
- releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
- 'contributor attribution should appear before Installation',
- );
- assert.doesNotMatch(releaseNotes, /## What’s Changed/);
- assert.doesNotMatch(
- releaseNotes,
- /ksyasuda made their first contribution/,
- 'returning contributors are not listed under New Contributors',
- );
+ const releaseNotes = project.read('release/release-notes.md');
+ assert.match(releaseNotes, /## What's Changed\n\n/);
+ assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
+ assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
+ assert.match(
+ releaseNotes,
+ /## New Contributors\n\n- @bee-san made their first contribution in #112/,
+ );
+ assert.ok(
+ releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
+ "What's Changed should follow Highlights",
+ );
+ assert.ok(
+ releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
+ 'contributor attribution should appear before Installation',
+ );
+ assert.doesNotMatch(releaseNotes, /## What’s Changed/);
+ assert.doesNotMatch(
+ releaseNotes,
+ /ksyasuda made their first contribution/,
+ 'returning contributors are not listed under New Contributors',
+ );
- // Attribution is a release-notes concern only; the CHANGELOG stays clean.
- const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
- assert.doesNotMatch(changelog, /What's Changed|What’s Changed/);
- assert.doesNotMatch(changelog, /New Contributors/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
+ // Attribution is a release-notes concern only; the CHANGELOG stays clean.
+ const changelog = project.read('CHANGELOG.md');
+ assert.doesNotMatch(changelog, /What's Changed|What’s Changed/);
+ assert.doesNotMatch(changelog, /New Contributors/);
+ },
+ );
});
test('writeChangelogArtifacts skips contributor attribution in GitHub Actions without a token', async () => {
const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('release-notes-actions-no-token');
- const projectRoot = path.join(workspace, 'SubMiner');
- const originalActions = process.env.GITHUB_ACTIONS;
- const originalGhToken = process.env.GH_TOKEN;
- const originalGithubToken = process.env.GITHUB_TOKEN;
- const originalPath = process.env.PATH;
+ const envKeys = ['GITHUB_ACTIONS', 'GH_TOKEN', 'GITHUB_TOKEN', 'PATH'] as const;
+ const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
const originalWarn = console.warn;
const warnings: string[] = [];
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: release', '', '- Added a feature.'].join('\n'),
- 'utf8',
+ await withProject(
+ 'release-notes-actions-no-token',
+ {
+ 'CHANGELOG.md': EMPTY_CHANGELOG,
+ 'changes/001.md': fragment('added', 'release', '- Added a feature.'),
+ },
+ (project) => {
+ try {
+ process.env.GITHUB_ACTIONS = 'true';
+ delete process.env.GH_TOKEN;
+ delete process.env.GITHUB_TOKEN;
+ // An empty PATH dir means the default lookup could not find `gh` even if it tried.
+ process.env.PATH = project.workspace;
+ console.warn = (message?: unknown) => {
+ warnings.push(String(message));
+ };
+
+ writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.6.0',
+ date: '2026-05-06',
+ deps: { runClaude: defaultStubClaude().runClaude },
+ });
+
+ assert.deepEqual(warnings, []);
+ assert.doesNotMatch(project.read('release/release-notes.md'), /## What's Changed/);
+ } finally {
+ console.warn = originalWarn;
+ for (const key of envKeys) {
+ const original = originalEnv[key];
+ if (original === undefined) delete process.env[key];
+ else process.env[key] = original;
+ }
+ }
+ },
);
-
- try {
- process.env.GITHUB_ACTIONS = 'true';
- delete process.env.GH_TOKEN;
- delete process.env.GITHUB_TOKEN;
- process.env.PATH = workspace;
- console.warn = (message?: unknown) => {
- warnings.push(String(message));
- };
-
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.6.0',
- date: '2026-05-06',
- deps: { runClaude: defaultStubClaude().runClaude },
- });
-
- assert.deepEqual(warnings, []);
- const releaseNotes = fs.readFileSync(
- path.join(projectRoot, 'release', 'release-notes.md'),
- 'utf8',
- );
- assert.doesNotMatch(releaseNotes, /## What's Changed/);
- } finally {
- console.warn = originalWarn;
- if (originalActions === undefined) {
- delete process.env.GITHUB_ACTIONS;
- } else {
- process.env.GITHUB_ACTIONS = originalActions;
- }
- if (originalGhToken === undefined) {
- delete process.env.GH_TOKEN;
- } else {
- process.env.GH_TOKEN = originalGhToken;
- }
- if (originalGithubToken === undefined) {
- delete process.env.GITHUB_TOKEN;
- } else {
- process.env.GITHUB_TOKEN = originalGithubToken;
- }
- if (originalPath === undefined) {
- delete process.env.PATH;
- } else {
- process.env.PATH = originalPath;
- }
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('shouldSkipDefaultContributionLookup skips GitHub Actions without a gh token', async () => {
@@ -1324,10 +946,19 @@ test('shouldSkipDefaultContributionLookup skips GitHub Actions without a gh toke
);
});
+const INTERNAL_DETAILS_BLOCK = [
+ '',
+ 'Internal changes
',
+ '',
+ '### Internal',
+ '- Polished: internal release note.',
+ '',
+ ' ',
+ '',
+];
+
test('writeReleaseNotesForVersion preserves committed contributor attribution before installation', async () => {
const { writeReleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('release-notes-preserve-attribution');
- const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
@@ -1335,24 +966,14 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be
'### Added',
'- Polished: released feature.',
'',
- '',
- 'Internal changes
',
- '',
- '### Internal',
- '- Polished: internal release note.',
- '',
- ' ',
- '',
+ ...INTERNAL_DETAILS_BLOCK,
].join('\n');
const committedReleaseNotes = [
'## Highlights',
'### Added',
'- Old generated body.',
'',
- '## Installation',
- '',
- 'See the README and docs/installation guide for full setup steps.',
- '',
+ ...INSTALLATION_SECTION,
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
@@ -1367,50 +988,42 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be
'',
].join('\n');
- fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'release', 'release-notes.md'),
- committedReleaseNotes,
- 'utf8',
+ await withProject(
+ 'release-notes-preserve-attribution',
+ { 'CHANGELOG.md': existingChangelog, 'release/release-notes.md': committedReleaseNotes },
+ (project) => {
+ const outputPath = writeReleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.8.0',
+ });
+ const releaseNotes = fs.readFileSync(outputPath, 'utf8');
+
+ assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./);
+ assert.doesNotMatch(releaseNotes, //);
+ assert.doesNotMatch(releaseNotes, /### Internal/);
+ assert.match(
+ releaseNotes,
+ /## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/,
+ );
+ assert.match(
+ releaseNotes,
+ /## New Contributors\n\n- @bee-san made their first contribution in #112/,
+ );
+ assert.ok(
+ releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
+ "What's Changed should follow Highlights",
+ );
+ assert.ok(
+ releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
+ 'New Contributors should appear before Installation',
+ );
+ assert.doesNotMatch(releaseNotes, /## What’s Changed/);
+ },
);
-
- try {
- const outputPath = writeReleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.8.0',
- });
- const releaseNotes = fs.readFileSync(outputPath, 'utf8');
-
- assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: released feature\./);
- assert.doesNotMatch(releaseNotes, //);
- assert.doesNotMatch(releaseNotes, /### Internal/);
- assert.match(
- releaseNotes,
- /## What's Changed\n\n- feat\(release\): add contributor attribution by @ksyasuda in #114/,
- );
- assert.match(
- releaseNotes,
- /## New Contributors\n\n- @bee-san made their first contribution in #112/,
- );
- assert.ok(
- releaseNotes.indexOf("## What's Changed") > releaseNotes.indexOf('## Highlights'),
- "What's Changed should follow Highlights",
- );
- assert.ok(
- releaseNotes.indexOf('## New Contributors') < releaseNotes.indexOf('## Installation'),
- 'New Contributors should appear before Installation',
- );
- assert.doesNotMatch(releaseNotes, /## What’s Changed/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writeChangelogArtifacts strips blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule();
- const workspace = createWorkspace('reuse-existing-section');
- const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = [
'# Changelog',
'',
@@ -1418,49 +1031,36 @@ test('writeChangelogArtifacts strips blocks from release notes when re
'### Added',
'- Polished: previously committed.',
'',
- '',
- 'Internal changes
',
- '',
- '### Internal',
- '- Polished: internal note.',
- '',
- ' ',
- '',
+ ...INTERNAL_DETAILS_BLOCK,
].join('\n');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Stale fragment.'].join('\n'),
- 'utf8',
+ await withProject(
+ 'reuse-existing-section',
+ {
+ 'CHANGELOG.md': existingChangelog,
+ 'changes/001.md': fragment('added', 'overlay', '- Stale fragment.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ writeChangelogArtifacts({
+ cwd: project.projectRoot,
+ version: '0.4.1',
+ date: '2026-03-08',
+ deps: { runClaude: stub.runClaude },
+ });
+
+ assert.equal(
+ stub.calls.length,
+ 0,
+ 'no Claude calls should fire when the section already exists',
+ );
+
+ const releaseNotes = project.read('release/release-notes.md');
+ assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
+ assert.doesNotMatch(releaseNotes, //);
+ assert.doesNotMatch(releaseNotes, /### Internal/);
+ },
);
-
- try {
- const stub = defaultStubClaude();
- writeChangelogArtifacts({
- cwd: projectRoot,
- version: '0.4.1',
- date: '2026-03-08',
- deps: { runClaude: stub.runClaude },
- });
-
- assert.equal(
- stub.calls.length,
- 0,
- 'no Claude calls should fire when the section already exists',
- );
-
- const releaseNotes = fs.readFileSync(
- path.join(projectRoot, 'release', 'release-notes.md'),
- 'utf8',
- );
- assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
- assert.doesNotMatch(releaseNotes, //);
- assert.doesNotMatch(releaseNotes, /### Internal/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('selectPreviousPrereleaseTag orders betas before rcs and filters other base versions', async () => {
@@ -1487,348 +1087,259 @@ test('selectPreviousPrereleaseTag orders betas before rcs and filters other base
test('writePrereleaseNotesForVersion adds a delta section generated from fragment diffs', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-delta-section');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: fixed', 'area: overlay', '', '- Fixed overlay focus and macOS helper.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = recordingRunClaude((input) =>
- input.includes('MODIFIED FRAGMENT')
- ? '- Fixed the macOS helper deployment target for older systems.'
- : '### Fixed\n- Overlay: cumulative fixed entry.',
- );
- const outputPath = writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.12.0-beta.2',
- deps: {
- runClaude: stub.runClaude,
- listPrereleaseTags: () => ['v0.12.0-beta.1'],
- resolveFragmentDelta: (_cwd, previousTag) => {
- assert.equal(previousTag, 'v0.12.0-beta.1');
- return [
- {
- path: 'changes/002.md',
- status: 'added',
- after: 'type: fixed\narea: macos\n\n- Fixed helper deployment target.',
- },
- {
- path: 'changes/001.md',
- status: 'modified',
- before: '- Fixed overlay focus.',
- after: '- Fixed overlay focus and macOS helper.',
- },
- {
- path: 'changes/003.md',
- status: 'deleted',
- before: 'type: added\narea: stats\n\n- Reverted experimental stats view.',
- },
- ];
+ await withProject(
+ 'prerelease-delta-section',
+ {
+ 'package.json': packageJson('0.12.0-beta.2'),
+ 'changes/001.md': fragment('fixed', 'overlay', '- Fixed overlay focus and macOS helper.'),
+ },
+ (project) => {
+ const stub = recordingRunClaude((input) =>
+ input.includes('MODIFIED FRAGMENT')
+ ? '- Fixed the macOS helper deployment target for older systems.'
+ : '### Fixed\n- Overlay: cumulative fixed entry.',
+ );
+ const outputPath = writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.12.0-beta.2',
+ deps: {
+ runClaude: stub.runClaude,
+ listPrereleaseTags: () => ['v0.12.0-beta.1'],
+ resolveFragmentDelta: (_cwd, previousTag) => {
+ assert.equal(previousTag, 'v0.12.0-beta.1');
+ return [
+ {
+ path: 'changes/002.md',
+ status: 'added',
+ after: 'type: fixed\narea: macos\n\n- Fixed helper deployment target.',
+ },
+ {
+ path: 'changes/001.md',
+ status: 'modified',
+ before: '- Fixed overlay focus.',
+ after: '- Fixed overlay focus and macOS helper.',
+ },
+ {
+ path: 'changes/003.md',
+ status: 'deleted',
+ before: 'type: added\narea: stats\n\n- Reverted experimental stats view.',
+ },
+ ];
+ },
},
- },
- });
+ });
- assert.equal(stub.calls.length, 2, 'delta and cumulative polish are separate Claude calls');
- const deltaPrompt = stub.calls[0]!.input;
- assert.match(deltaPrompt, /ADDED FRAGMENT changes\/002\.md/);
- assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/001\.md/);
- assert.match(deltaPrompt, /BEFORE:\n- Fixed overlay focus\./);
- assert.match(deltaPrompt, /AFTER:\n- Fixed overlay focus and macOS helper\./);
- assert.match(deltaPrompt, /DELETED FRAGMENT changes\/003\.md/);
- assert.match(deltaPrompt, /If the edit is editorial/);
- assert.match(deltaPrompt, /removed or reverted/);
- assert.match(deltaPrompt, /No user-facing changes since v0\.12\.0-beta\.1\./);
- assert.equal(modeFromPrompt(stub.calls[1]!.input), 'release-notes');
+ assert.equal(stub.calls.length, 2, 'delta and cumulative polish are separate Claude calls');
+ const deltaPrompt = stub.calls[0]!.input;
+ assert.match(deltaPrompt, /ADDED FRAGMENT changes\/002\.md/);
+ assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/001\.md/);
+ assert.match(deltaPrompt, /BEFORE:\n- Fixed overlay focus\./);
+ assert.match(deltaPrompt, /AFTER:\n- Fixed overlay focus and macOS helper\./);
+ assert.match(deltaPrompt, /DELETED FRAGMENT changes\/003\.md/);
+ assert.equal(modeFromPrompt(stub.calls[1]!.input), 'release-notes');
- const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
- assert.match(
- prereleaseNotes,
- //,
- );
- const deltaIndex = prereleaseNotes.indexOf('## Changes since v0.12.0-beta.1');
- const highlightsIndex = prereleaseNotes.indexOf('## Highlights');
- assert.ok(deltaIndex !== -1, 'delta section heading should be present');
- assert.ok(deltaIndex < highlightsIndex, 'delta section should precede Highlights');
- assert.match(prereleaseNotes, /- Fixed the macOS helper deployment target for older systems\./);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
+ const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
+ assert.match(
+ prereleaseNotes,
+ //,
+ );
+ const deltaIndex = prereleaseNotes.indexOf('## Changes since v0.12.0-beta.1');
+ const highlightsIndex = prereleaseNotes.indexOf('## Highlights');
+ assert.ok(deltaIndex !== -1, 'delta section heading should be present');
+ assert.ok(deltaIndex < highlightsIndex, 'delta section should precede Highlights');
+ assert.match(
+ prereleaseNotes,
+ /- Fixed the macOS helper deployment target for older systems\./,
+ );
+ },
+ );
});
test('writePrereleaseNotesForVersion renders a fallback delta line when no fragments changed', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-empty-delta');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2),
- 'utf8',
+ await withProject(
+ 'prerelease-empty-delta',
+ {
+ 'package.json': packageJson('0.12.0-beta.3'),
+ 'changes/001.md': fragment('fixed', 'overlay', '- Fixed overlay focus.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ const outputPath = writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.12.0-beta.3',
+ deps: {
+ runClaude: stub.runClaude,
+ listPrereleaseTags: () => ['v0.12.0-beta.1', 'v0.12.0-beta.2'],
+ resolveFragmentDelta: () => [],
+ },
+ });
+
+ assert.equal(stub.calls.length, 1, 'empty delta must not spend a Claude call');
+ assert.match(
+ fs.readFileSync(outputPath, 'utf8'),
+ /## Changes since v0\.12\.0-beta\.2\n\n- No changelog fragment changes since v0\.12\.0-beta\.2; this build contains packaging or internal-only updates\./,
+ );
+ },
);
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = defaultStubClaude();
- const outputPath = writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.12.0-beta.3',
- deps: {
- runClaude: stub.runClaude,
- listPrereleaseTags: () => ['v0.12.0-beta.1', 'v0.12.0-beta.2'],
- resolveFragmentDelta: () => [],
- },
- });
-
- assert.equal(stub.calls.length, 1, 'empty delta must not spend a Claude call');
- const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
- assert.match(
- prereleaseNotes,
- /## Changes since v0\.12\.0-beta\.2\n\n- No changelog fragment changes since v0\.12\.0-beta\.2; this build contains packaging or internal-only updates\./,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writePrereleaseNotesForVersion rejects non-bullet delta output from Claude', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-delta-invalid-output');
- const projectRoot = path.join(workspace, 'SubMiner');
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
- 'utf8',
+ await withProject(
+ 'prerelease-delta-invalid-output',
+ {
+ 'package.json': packageJson('0.12.0-beta.2'),
+ 'changes/001.md': fragment('fixed', 'overlay', '- Fixed overlay focus.'),
+ },
+ (project) => {
+ const stub = recordingRunClaude(() => 'Here are the changes:\n- One change.');
+ assert.throws(
+ () =>
+ writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.12.0-beta.2',
+ deps: {
+ runClaude: stub.runClaude,
+ listPrereleaseTags: () => ['v0.12.0-beta.1'],
+ resolveFragmentDelta: () => [
+ { path: 'changes/001.md', status: 'added', after: '- Fixed overlay focus.' },
+ ],
+ },
+ }),
+ /delta output must contain only Markdown bullets/,
+ );
+ },
);
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'),
- 'utf8',
- );
-
- try {
- const stub = recordingRunClaude(() => 'Here are the changes:\n- One change.');
- assert.throws(
- () =>
- writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.12.0-beta.2',
- deps: {
- runClaude: stub.runClaude,
- listPrereleaseTags: () => ['v0.12.0-beta.1'],
- resolveFragmentDelta: () => [
- { path: 'changes/001.md', status: 'added', after: '- Fixed overlay focus.' },
- ],
- },
- }),
- /delta output must contain only Markdown bullets/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('writePrereleaseNotesForVersion strips the stale delta section from the reused baseline', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-reuse-strips-delta');
- const projectRoot = path.join(workspace, 'SubMiner');
- const existingNotes = [
- '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
- '',
+ const existingNotes = existingPrereleaseNotes(
'',
- '',
- '## Changes since v0.12.0-beta.1',
- '',
- '- Stale beta-to-beta delta bullet.',
- '',
- '## Highlights',
- '### Added',
- '- Overlay: Previous beta entry.',
- '',
- '## Installation',
- '',
- 'See the README and docs/installation guide for full setup steps.',
- '',
- ].join('\n');
-
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2),
- 'utf8',
- );
- fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
- fs.writeFileSync(
- path.join(projectRoot, 'changes', '001.md'),
- ['type: added', 'area: overlay', '', '- Added overlay coverage.'].join('\n'),
- 'utf8',
+ [
+ '## Changes since v0.12.0-beta.1',
+ '',
+ '- Stale beta-to-beta delta bullet.',
+ '',
+ '## Highlights',
+ '### Added',
+ '- Overlay: Previous beta entry.',
+ '',
+ ...INSTALLATION_SECTION,
+ ],
);
- try {
- const stub = defaultStubClaude();
- writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.12.0-beta.3',
- deps: {
- runClaude: stub.runClaude,
- listPrereleaseTags: () => [],
- resolveFragmentDelta: () => [],
- },
- });
+ await withProject(
+ 'prerelease-reuse-strips-delta',
+ {
+ 'package.json': packageJson('0.12.0-beta.3'),
+ 'release/prerelease-notes.md': existingNotes,
+ 'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
+ },
+ (project) => {
+ const stub = defaultStubClaude();
+ writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.12.0-beta.3',
+ deps: {
+ runClaude: stub.runClaude,
+ listPrereleaseTags: () => [],
+ resolveFragmentDelta: () => [],
+ },
+ });
- assert.equal(stub.calls.length, 1);
- const prompt = stub.calls[0]!.input;
- assert.match(prompt, /EXISTING PRERELEASE NOTES/);
- assert.match(prompt, /Overlay: Previous beta entry\./);
- assert.doesNotMatch(prompt, /Stale beta-to-beta delta bullet\./);
- assert.doesNotMatch(prompt, /## Changes since /);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
+ assert.equal(stub.calls.length, 1);
+ const prompt = stub.calls[0]!.input;
+ assert.match(prompt, /EXISTING PRERELEASE NOTES/);
+ assert.match(prompt, /Overlay: Previous beta entry\./);
+ assert.doesNotMatch(prompt, /Stale beta-to-beta delta bullet\./);
+ assert.doesNotMatch(prompt, /## Changes since /);
+ },
+ );
});
test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale or legacy markers', async () => {
const { verifyPrereleaseNotesMatchVersion } = await loadModule();
- const workspace = createWorkspace('verify-prerelease-notes');
- const projectRoot = path.join(workspace, 'SubMiner');
- const notesPath = path.join(projectRoot, 'release', 'prerelease-notes.md');
- fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
- 'utf8',
+ await withProject(
+ 'verify-prerelease-notes',
+ { 'package.json': packageJson('0.12.0-beta.2') },
+ (project) => {
+ const verify = (version = '0.12.0-beta.2') =>
+ verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version });
+ const notesPath = 'release/prerelease-notes.md';
+
+ assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
+
+ project.write(
+ notesPath,
+ '\n\n## Highlights\n',
+ );
+ verify();
+ verify('v0.12.0-beta.2');
+
+ project.write(notesPath, '\n\n## Highlights\n');
+ assert.throws(
+ () => verify(),
+ /generated for 0\.12\.0-beta\.1 but this release is 0\.12\.0-beta\.2/,
+ );
+
+ project.write(notesPath, '\n\n## Highlights\n');
+ assert.throws(() => verify(), /missing or legacy prerelease-version marker/);
+ },
);
-
- try {
- assert.throws(
- () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
- /Missing .*prerelease-notes\.md/,
- );
-
- fs.writeFileSync(
- notesPath,
- '\n\n## Highlights\n',
- 'utf8',
- );
- verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' });
- verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: 'v0.12.0-beta.2' });
-
- fs.writeFileSync(
- notesPath,
- '\n\n## Highlights\n',
- 'utf8',
- );
- assert.throws(
- () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
- /generated for 0\.12\.0-beta\.1 but this release is 0\.12\.0-beta\.2/,
- );
-
- fs.writeFileSync(
- notesPath,
- '\n\n## Highlights\n',
- 'utf8',
- );
- assert.throws(
- () => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
- /missing or legacy prerelease-version marker/,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
test('default git tag listing and fragment delta resolution work against a real repository', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
- const workspace = createWorkspace('prerelease-git-defaults');
- const projectRoot = path.join(workspace, 'SubMiner');
- const git = (...args: string[]): void => {
- execFileSync('git', args, { cwd: projectRoot, stdio: 'ignore' });
- };
- fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
- 'utf8',
+ await withProject(
+ 'prerelease-git-defaults',
+ {
+ 'package.json': packageJson('0.11.3-beta.1'),
+ 'changes/kept.md': fragment('added', 'overlay', '- Kept change.'),
+ 'changes/edited.md': fragment('fixed', 'launcher', '- Original launcher fix.'),
+ 'changes/removed.md': fragment('added', 'stats', '- Reverted stats change.'),
+ },
+ (project) => {
+ const git = (...args: string[]): void => {
+ execFileSync('git', args, { cwd: project.projectRoot, stdio: 'ignore' });
+ };
+
+ git('init', '--quiet');
+ git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '.');
+ git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'beta.1');
+ git('tag', 'v0.11.3-beta.1');
+
+ project.write('changes/edited.md', fragment('fixed', 'launcher', '- Broader launcher fix.'));
+ project.remove('changes/removed.md');
+ project.write('changes/new.md', fragment('added', 'anki', '- New anki change.'));
+ project.write('package.json', packageJson('0.11.3-beta.2'));
+
+ const stub = recordingRunClaude((input) =>
+ input.includes('PREVIOUS_TAG:') ? '- Delta bullet.' : defaultPolishedBody(input),
+ );
+ writePrereleaseNotesForVersion({
+ cwd: project.projectRoot,
+ version: '0.11.3-beta.2',
+ deps: { runClaude: stub.runClaude },
+ });
+
+ assert.equal(stub.calls.length, 2);
+ const deltaPrompt = stub.calls[0]!.input;
+ assert.match(deltaPrompt, /PREVIOUS_TAG: v0\.11\.3-beta\.1/);
+ assert.match(deltaPrompt, /ADDED FRAGMENT changes\/new\.md/);
+ assert.match(deltaPrompt, /- New anki change\./);
+ assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/edited\.md/);
+ assert.match(deltaPrompt, /- Original launcher fix\./);
+ assert.match(deltaPrompt, /- Broader launcher fix\./);
+ assert.match(deltaPrompt, /DELETED FRAGMENT changes\/removed\.md/);
+ assert.match(deltaPrompt, /- Reverted stats change\./);
+ assert.doesNotMatch(deltaPrompt, /kept\.md/);
+ },
);
- fs.writeFileSync(
- path.join(projectRoot, 'changes', 'kept.md'),
- ['type: added', 'area: overlay', '', '- Kept change.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', 'edited.md'),
- ['type: fixed', 'area: launcher', '', '- Original launcher fix.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'changes', 'removed.md'),
- ['type: added', 'area: stats', '', '- Reverted stats change.'].join('\n'),
- 'utf8',
- );
-
- try {
- git('init', '--quiet');
- git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '.');
- git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'beta.1');
- git('tag', 'v0.11.3-beta.1');
-
- fs.writeFileSync(
- path.join(projectRoot, 'changes', 'edited.md'),
- ['type: fixed', 'area: launcher', '', '- Broader launcher fix.'].join('\n'),
- 'utf8',
- );
- fs.rmSync(path.join(projectRoot, 'changes', 'removed.md'));
- fs.writeFileSync(
- path.join(projectRoot, 'changes', 'new.md'),
- ['type: added', 'area: anki', '', '- New anki change.'].join('\n'),
- 'utf8',
- );
- fs.writeFileSync(
- path.join(projectRoot, 'package.json'),
- JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
- 'utf8',
- );
-
- const stub = recordingRunClaude((input) =>
- input.includes('PREVIOUS_TAG:') ? '- Delta bullet.' : defaultPolishedBody(input),
- );
- writePrereleaseNotesForVersion({
- cwd: projectRoot,
- version: '0.11.3-beta.2',
- deps: { runClaude: stub.runClaude },
- });
-
- assert.equal(stub.calls.length, 2);
- const deltaPrompt = stub.calls[0]!.input;
- assert.match(deltaPrompt, /PREVIOUS_TAG: v0\.11\.3-beta\.1/);
- assert.match(deltaPrompt, /ADDED FRAGMENT changes\/new\.md/);
- assert.match(deltaPrompt, /- New anki change\./);
- assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/edited\.md/);
- assert.match(deltaPrompt, /- Original launcher fix\./);
- assert.match(deltaPrompt, /- Broader launcher fix\./);
- assert.match(deltaPrompt, /DELETED FRAGMENT changes\/removed\.md/);
- assert.match(deltaPrompt, /- Reverted stats change\./);
- assert.doesNotMatch(deltaPrompt, /kept\.md/);
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
});
diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts
index 2eba9bf9..b15615e6 100644
--- a/scripts/build-changelog.ts
+++ b/scripts/build-changelog.ts
@@ -476,21 +476,29 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
-4. Each top-level change item should:
- - Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- - Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- - Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
+4. Start every item with a short bold feature/area name in title case and a colon. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
+ - Default to inline: put one or two short sentences right after the colon. If the important details fit in two short sentences, stay inline.
+ - Use nested bullets only when an item has several distinct changes a user would care about separately and inline text would turn into a long run-on sentence. Then leave the top-level bullet as just the name and indent two to four nested bullets by two spaces.
+ - Each nested bullet is a single short sentence, roughly 20 words or fewer. Never write paragraph-length bullets. Cut minor details rather than adding sentences.
+5. Keep items brief. Release notes summarize; the docs hold the details.
+ - Say what changed for the user and how to reach it (shortcut, command, setting, or menu). Stop there.
+ - Leave out how it works internally, edge cases, precedence rules, defaults, fallbacks, tool discovery, migration mechanics, and step-by-step usage.
+ - Name a config key only when users must set it to use the change or to keep their current behavior.
+ - Write in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
+ - Merge aggressively: one item per feature or area. If five fragments touch Jellyfin subtitles, audio, and auth, write one Jellyfin item, not five. Group small unrelated fixes for the same area into one item.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- - Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
-5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
- - **Playlist Browser**:
- - Saved shows now open without rescanning the library.
- - The picker remembers the last folder you browsed between launches.
- Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
- Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
- Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
- Bullets inside the Internal section may stay single-level.
-6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
+ - Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules, including any action users must take. Do not soften or omit them.
+6. Match the length and density of this example:
+ ### Added
+ - **Subtitle Selection Modal**: Optional modal for picking primary and secondary subtitle tracks. Enable it in Settings under Behavior, then press g followed by s.
+ ### Changed
+ - **Faster Sync**: Sync between macOS and Linux machines now uses compressed, incremental rsync transfers. Windows and machines without rsync fall back to compressed scp.
+ ### Fixed
+ - **Subtitle Sidebar**: Space keeps its playback action after clicking a cue, and the sidebar no longer jumps away from the current position during subtitle gaps.
+ - **Jellyfin 12**:
+ - Playback, subtitles, artwork, and "Play on SubMiner" now work on Jellyfin 12.
+ - Casting no longer drops after about a minute.
+ - Mined cards get the episode title again.
7. Do not invent features. Every bullet must be grounded in the input fragments.
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
@@ -791,7 +799,7 @@ function polishFragmentsWithClaude(
? [
'## Existing Prerelease Notes',
'',
- 'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
+ 'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. The output rules above win over the baseline format: shorten long baseline bullets and collapse nested lists that do not need to be nested. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'',
].join('\n')
: '';
@@ -814,7 +822,7 @@ Rules:
3. ADDED fragments describe changes that are new in this build; summarize them.
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
-6. Keep bullets short and concrete. Use nested bullets sparingly.
+6. Keep each bullet to one short, concrete sentence. Use nested bullets sparingly.
7. Do not invent changes. Every bullet must be grounded in the diffs.
8. If no bullet survives rules 2-5, output exactly this single line:
- No user-facing changes since PREVIOUS_TAG.
diff --git a/scripts/compare-yomitan-api.test.ts b/scripts/compare-yomitan-api.test.ts
deleted file mode 100644
index c6ffe913..00000000
--- a/scripts/compare-yomitan-api.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import test from 'node:test';
-
-const source = readFileSync('scripts/compare-yomitan-api.ts', 'utf8');
-
-test('creates the configured Yomitan user-data directory before Electron uses it', () => {
- const setPathIndex = source.indexOf(
- "electronModule.app.setPath('userData', options.yomitanUserDataPath)",
- );
- assert.notEqual(setPathIndex, -1);
-
- const mkdirIndex = source.lastIndexOf(
- 'fs.mkdirSync(options.yomitanUserDataPath, { recursive: true })',
- setPathIndex,
- );
- assert.ok(mkdirIndex > -1, 'must create the user-data directory');
- assert.ok(mkdirIndex < setPathIndex, 'must create the directory before app.setPath');
-});
-
-test('lets output drain by using exitCode in the top-level completion handlers', () => {
- const completionHandlers = source.slice(source.lastIndexOf('\nmain()'));
- assert.match(completionHandlers, /process\.exitCode = process\.exitCode \?\? 0/);
- assert.match(completionHandlers, /process\.exitCode = 1/);
- assert.doesNotMatch(completionHandlers, /process\.exit\(/);
-});
diff --git a/scripts/docs-knowledge-base.test.ts b/scripts/docs-knowledge-base.test.ts
index d08052df..93abafa5 100644
--- a/scripts/docs-knowledge-base.test.ts
+++ b/scripts/docs-knowledge-base.test.ts
@@ -21,6 +21,7 @@ const requiredDocs = [
'docs/workflow/README.md',
'docs/workflow/agent-skills.md',
'docs/workflow/planning.md',
+ 'docs/workflow/testing.md',
'docs/workflow/verification.md',
] as const;
diff --git a/scripts/get-mpv-window-macos.test.ts b/scripts/get-mpv-window-macos.test.ts
deleted file mode 100644
index 59baab6f..00000000
--- a/scripts/get-mpv-window-macos.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import test from 'node:test';
-
-const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
-
-test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
- const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
- assert.notEqual(minimizedAssignmentIndex, -1);
-
- const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
- assert.notEqual(loopStartIndex, -1);
-
- const pidExtractionIndex = source.indexOf(
- 'AXUIElementGetPid(window, &windowPid)',
- loopStartIndex,
- );
- const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
- const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
-
- assert.ok(
- pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
- 'window PID must be extracted before accepting a minimized window',
- );
- assert.ok(
- appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
- 'window PID must match the owning app before accepting a minimized window',
- );
- assert.ok(
- socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
- 'target socket must be validated before accepting a minimized window',
- );
-});
-
-test('focused mpv window follows the frontmost mpv app signal', () => {
- const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
- assert.notEqual(focusHelperIndex, -1);
-
- const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
- const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
-
- assert.ok(
- focusHelperBody.includes('frontmost.pid == ownerPid'),
- 'matching frontmost PID should mark the mpv window focused',
- );
- assert.ok(
- focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
- 'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
- );
- assert.ok(
- source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
- 'Accessibility path should use the shared focused mpv helper',
- );
- assert.ok(
- source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
- 'CoreGraphics path should use the shared focused mpv helper',
- );
-});
-
-test('frontmost mpv app emits active state when geometry lookup misses', () => {
- assert.ok(
- /case\s+\.active:/.test(source),
- 'helper should expose an active state without window geometry',
- );
- assert.ok(
- source.includes('if windowHasTargetSocket(frontmost.pid)'),
- 'active state should still accept a matching target socket when available',
- );
- assert.ok(
- source.includes('return targetMpvSocketPath != nil'),
- 'active state should preserve frontmost mpv even if command-line socket detection fails',
- );
- assert.ok(
- source.includes('return .active'),
- 'lookup should preserve active mpv state after geometry lookup misses',
- );
- assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
-});
-
-test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
- assert.ok(
- /case\s+\.inactive:/.test(source),
- 'helper should expose an inactive state without window geometry',
- );
- assert.ok(
- source.includes('if frontmost != nil'),
- 'helper should distinguish a known non-mpv frontmost app from an unknown miss',
- );
- assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
- assert.ok(
- source.includes('print("inactive")'),
- 'inactive state should be printed for the tracker',
- );
-});
diff --git a/scripts/get_frequency.test.ts b/scripts/get_frequency.test.ts
deleted file mode 100644
index 5911864d..00000000
--- a/scripts/get_frequency.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import path from 'node:path';
-import test from 'node:test';
-import { spawnSync } from 'node:child_process';
-
-function createWorkspace(name: string): string {
- const baseDir = path.join(process.cwd(), '.tmp', 'get-frequency-typecheck-test');
- fs.mkdirSync(baseDir, { recursive: true });
- return fs.mkdtempSync(path.join(baseDir, `${name}-`));
-}
-
-test('scripts/get_frequency.ts typechecks in isolation', () => {
- const workspace = createWorkspace('isolated-script');
- const tsconfigPath = path.join(workspace, 'tsconfig.json');
-
- fs.writeFileSync(
- tsconfigPath,
- JSON.stringify(
- {
- extends: '../../../tsconfig.typecheck.json',
- include: ['../../../scripts/get_frequency.ts'],
- exclude: [],
- },
- null,
- 2,
- ),
- 'utf8',
- );
-
- try {
- const result = spawnSync('bunx', ['tsc', '--noEmit', '-p', tsconfigPath], {
- cwd: process.cwd(),
- encoding: 'utf8',
- });
-
- assert.equal(
- result.status,
- 0,
- `expected scripts/get_frequency.ts to typecheck\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
- );
- } finally {
- fs.rmSync(workspace, { recursive: true, force: true });
- }
-});
diff --git a/scripts/package-bun-source.test.ts b/scripts/package-bun-source.test.ts
index d361523a..0bd341c2 100644
--- a/scripts/package-bun-source.test.ts
+++ b/scripts/package-bun-source.test.ts
@@ -39,24 +39,6 @@ describe('Bun corresponding-source manifest', () => {
}
});
- test('pins the source to the revision reported by the distributed binary', async () => {
- const manifest = parseSourceManifest(
- JSON.parse(
- await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
- ),
- );
-
- expect(manifest.version).toBe('1.3.5');
- expect(manifest.bunRevision).toBe('1e86cebd74a5723e818b5c0555276b646bcf0e4c');
- expect(manifest.releaseTagCommit).toBe('fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab');
- expect(manifest.sources.find((source) => source.name === 'WebKit')?.revision).toBe(
- '6d0f3aac0b817cc01a846b3754b21271adedac12',
- );
- expect(manifest.sources.find((source) => source.name === 'tinycc')?.revision).toBe(
- '29985a3b59898861442fa3b43f663fc1af2591d7',
- );
- });
-
test('parses commit and tag registrations from Bun CMake', () => {
const registrations = parseRegisteredRepositories(`
register_repository(
diff --git a/scripts/preload-bundles.test.ts b/scripts/preload-bundles.test.ts
deleted file mode 100644
index 95fd30a5..00000000
--- a/scripts/preload-bundles.test.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import path from 'node:path';
-
-test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
- const packageJson = JSON.parse(
- fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
- ) as { scripts: Record };
- const command = packageJson.scripts['build:syncui'] ?? '';
-
- assert.match(command, /src\/preload-syncui\.ts/);
- assert.match(command, /--bundle/);
- assert.match(command, /--external:electron/);
- assert.match(command, /--outfile=dist\/preload-syncui\.js/);
-});
diff --git a/scripts/prepare-build-assets.test.ts b/scripts/prepare-build-assets.test.ts
deleted file mode 100644
index d710f8b9..00000000
--- a/scripts/prepare-build-assets.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import test from 'node:test';
-
-const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
-
-test('macOS helper build creates dist scripts directory before swiftc output', () => {
- const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
- assert.notEqual(buildFunctionIndex, -1);
-
- const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex);
- assert.notEqual(swiftcIndex, -1);
-
- const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
-
- assert.ok(
- ensureDirIndex > buildFunctionIndex,
- 'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
- );
-});
-
-// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
-// version as the helper's minimum, so released builds refuse to load on older macOS.
-test('macOS helper is compiled with an explicit deployment target', () => {
- assert.match(source, /-target/);
- assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/);
-});
diff --git a/scripts/run-coverage-lane.test.ts b/scripts/run-coverage-lane.test.ts
index 2a5fa2e7..a49ed4f6 100644
--- a/scripts/run-coverage-lane.test.ts
+++ b/scripts/run-coverage-lane.test.ts
@@ -75,7 +75,7 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
});
-test('runCoverageLane returns a failure when a discovered test fails', () => {
+test('runCoverageLane returns a failure when a discovered test fails', async () => {
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
try {
mkdirSync(join(repoRoot, 'src'));
@@ -93,10 +93,10 @@ test('runCoverageLane returns a failure when a discovered test fails', () => {
);
assert.notEqual(
- runCoverageLane({
+ await runCoverageLane({
repoRootDir: repoRoot,
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
- stdio: 'pipe',
+ quiet: true,
}),
0,
);
diff --git a/scripts/run-coverage-lane.ts b/scripts/run-coverage-lane.ts
index 5d567b40..8687e437 100644
--- a/scripts/run-coverage-lane.ts
+++ b/scripts/run-coverage-lane.ts
@@ -1,5 +1,6 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
-import { spawnSync } from 'node:child_process';
+import { spawn } from 'node:child_process';
+import { availableParallelism } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { collectLaneFiles } from './test-lanes';
@@ -201,9 +202,40 @@ export function mergeLcovReports(reports: string[]): string {
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
}
-export function runCoverageLane(
- options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
-): number {
+type ShardResult = { status: number; output: string; lcov: string | null };
+
+// Runs one test file under coverage into its own shard directory, buffering
+// output so parallel shards do not interleave on the terminal.
+function runShard(repoRootDir: string, file: string, shardDir: string): Promise {
+ return new Promise((resolveShard) => {
+ const child = spawn(
+ 'bun',
+ ['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
+ { cwd: repoRootDir },
+ );
+ let output = '';
+ child.stdout.on('data', (chunk) => (output += chunk));
+ child.stderr.on('data', (chunk) => (output += chunk));
+ child.on('error', (error) => resolveShard({ status: 1, output: String(error), lcov: null }));
+ child.on('close', (code) => {
+ const lcovPath = join(shardDir, 'lcov.info');
+ const lcov = existsSync(lcovPath) ? readFileSync(lcovPath, 'utf8') : null;
+ resolveShard({ status: code ?? 1, output, lcov });
+ });
+ });
+}
+
+function parseJobsArg(argv: string[]): number {
+ const index = argv.indexOf('--jobs');
+ if (index === -1) return availableParallelism();
+ return Math.max(1, Number(argv[index + 1]) || 1);
+}
+
+// Shards run in parallel (one per CPU unless --jobs N). The first failing shard
+// stops scheduling new shards and its exit status becomes the lane result.
+export async function runCoverageLane(
+ options: { repoRootDir?: string; argv?: string[]; quiet?: boolean } = {},
+): Promise {
const repoRootDir = options.repoRootDir ?? repoRoot;
const argv = options.argv ?? process.argv.slice(2);
const laneName = argv[0];
@@ -225,40 +257,44 @@ export function runCoverageLane(
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
return 1;
}
- const reports: string[] = [];
+
+ const reports: Array = new Array(files.length).fill(null);
+ const failures: Array<{ file: string; result: ShardResult }> = [];
+ let nextIndex = 0;
+
+ const worker = async (): Promise => {
+ while (failures.length === 0 && nextIndex < files.length) {
+ const index = nextIndex++;
+ const file = files[index]!;
+ const shardDir = join(shardRoot, String(index + 1).padStart(3, '0'));
+ const result = await runShard(repoRootDir, file, shardDir);
+ if (result.status !== 0) {
+ failures.push({ file, result });
+ return;
+ }
+ if (result.lcov === null && !options.quiet) {
+ process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
+ }
+ reports[index] = result.lcov;
+ }
+ };
try {
- for (const [index, file] of files.entries()) {
- const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
- const result = spawnSync(
- 'bun',
- ['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
- {
- cwd: repoRootDir,
- stdio: options.stdio ?? 'inherit',
- },
- );
+ const jobs = Math.min(parseJobsArg(argv), files.length);
+ await Promise.all(Array.from({ length: jobs }, worker));
- if (result.error) {
- throw result.error;
+ const [failure] = failures;
+ if (failure !== undefined) {
+ const { file, result } = failure;
+ if (!options.quiet) {
+ process.stderr.write(`${result.output}\nCoverage shard failed: ${file}\n`);
}
- if ((result.status ?? 1) !== 0) {
- return result.status ?? 1;
- }
-
- const lcovPath = join(shardDir, 'lcov.info');
- if (!existsSync(lcovPath)) {
- process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
- continue;
- }
-
- reports.push(readFileSync(lcovPath, 'utf8'));
+ return result.status;
}
- writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
- process.stdout.write(
- `Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
- );
+ const lcovPath = join(coverageDir, 'lcov.info');
+ writeFileSync(lcovPath, mergeLcovReports(reports.filter((r) => r !== null)), 'utf8');
+ process.stdout.write(`Merged LCOV written to ${relative(repoRootDir, lcovPath)}\n`);
return 0;
} finally {
rmSync(shardRoot, { recursive: true, force: true });
@@ -267,5 +303,5 @@ export function runCoverageLane(
// @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
if (import.meta.main) {
- process.exit(runCoverageLane());
+ runCoverageLane().then((status) => process.exit(status));
}
diff --git a/scripts/run-test-lane.mjs b/scripts/run-test-lane.mjs
index 82106baa..4b617b96 100644
--- a/scripts/run-test-lane.mjs
+++ b/scripts/run-test-lane.mjs
@@ -1,5 +1,6 @@
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
+import { availableParallelism } from 'node:os';
import { spawn, spawnSync } from 'node:child_process';
import { collectLaneFiles } from './test-lanes.ts';
@@ -7,6 +8,8 @@ import { collectLaneFiles } from './test-lanes.ts';
// test file so a hanging test or leaked global in one file cannot poison the
// rest of the lane. Use --single-process for the old all-in-one-process mode.
//
+// Files run in parallel, one worker per CPU by default; --jobs 1 runs serially.
+//
// Usage: bun scripts/run-test-lane.mjs [--jobs N] [--timeout-secs N] [--single-process]
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
@@ -34,7 +37,12 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
}
function parseArgs(argv) {
- const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
+ const options = {
+ lane: undefined,
+ jobs: availableParallelism(),
+ timeoutSecs: 300,
+ singleProcess: false,
+ };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--jobs') {
diff --git a/scripts/stage-bun-runtime.test.ts b/scripts/stage-bun-runtime.test.ts
index 56f1c255..1d166d2d 100644
--- a/scripts/stage-bun-runtime.test.ts
+++ b/scripts/stage-bun-runtime.test.ts
@@ -61,15 +61,6 @@ test('normalizeTarget maps each supported electron-builder target without using
assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/);
});
-test('resolveArtifact chooses baseline x64 builds and standard arm64 builds', () => {
- const config = runtimeConfig();
- assert.equal(resolveArtifact(config, 'linux', 'x64').file, 'bun-linux-x64-baseline.zip');
- assert.equal(resolveArtifact(config, 'darwin', 'x64').file, 'bun-darwin-x64-baseline.zip');
- assert.equal(resolveArtifact(config, 'win32', 'x64').file, 'bun-windows-x64-baseline.zip');
- assert.equal(resolveArtifact(config, 'linux', 'arm64').file, 'bun-linux-aarch64.zip');
- assert.equal(resolveArtifact(config, 'darwin', 'arm64').file, 'bun-darwin-aarch64.zip');
-});
-
test('tracked runtime manifest covers every supported target at the packageManager version', async () => {
const config = await loadRuntimeConfig();
assert.equal(config.version, '1.3.5');
diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts
index c876745d..1b959017 100644
--- a/src/anki-connect.test.ts
+++ b/src/anki-connect.test.ts
@@ -3,20 +3,6 @@ import assert from 'node:assert/strict';
import { AnkiConnectClient } from './anki-connect';
import { setLogLevel } from './logger';
-test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
- const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
- client: {
- defaults: {
- httpAgent?: { options?: { keepAlive?: boolean } };
- httpsAgent?: { options?: { keepAlive?: boolean } };
- };
- };
- };
-
- assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
- assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
-});
-
test('AnkiConnectClient includes action name in retry logs', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: unknown, options: unknown) => Promise };
diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts
index 4370446d..11ac03c6 100644
--- a/src/anki-integration.test.ts
+++ b/src/anki-integration.test.ts
@@ -6,7 +6,6 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { AnkiIntegration } from './anki-integration';
-import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
import type { MediaInput } from './media-input';
import { AnkiConnectConfig } from './types';
@@ -20,13 +19,108 @@ type TestOverlayNotificationPayload = {
actions?: Array<{ id: string; label: string; noteId?: number }>;
};
-interface IntegrationTestContext {
- integration: AnkiIntegration;
- calls: {
- findNotes: number;
- notesInfo: number;
+type GenerateAudioFn = (
+ path: MediaInput,
+ startTime: number,
+ endTime: number,
+ audioPadding?: number,
+ audioStreamIndex?: number,
+ normalizeAudio?: boolean,
+ volumeScale?: number,
+) => Promise;
+
+type PendingYoutubeJob = {
+ sourceUrl: string;
+ noteId: number;
+ startTime: number;
+ endTime: number;
+ label: string | number;
+ audioStreamIndex?: number;
+ audioFieldName?: string;
+ imageFieldName?: string;
+ generateAudio: boolean;
+ generateImage: boolean;
+ volumeScale?: number;
+};
+
+/**
+ * The private members these tests swap or call. AnkiIntegration has no constructor seam for its
+ * AnkiConnect client or media generator, so tests replace them after construction.
+ */
+type IntegrationInternals = {
+ client: {
+ findNotes?: () => Promise;
+ notesInfo?: (noteIds: number[]) => Promise;
+ updateNoteFields?: (noteId: number, fields: Record) => Promise;
+ storeMediaFile?: (filename: string, data: Buffer) => Promise;
};
- stateDir: string;
+ mediaGenerator: {
+ generateAudio?: GenerateAudioFn;
+ generateScreenshot?: (path: MediaInput) => Promise;
+ generateNotificationIcon?: (videoPath: string, timestamp: number) => Promise;
+ writeNotificationIconToFile?: (iconBuffer: Buffer, noteId: number) => string;
+ scheduleNotificationIconCleanup?: (filePath: string) => void;
+ };
+ config: AnkiConnectConfig;
+ cardCreationService: { createSentenceCard: () => Promise };
+ fieldGroupingService: { triggerFieldGroupingForLastAddedCard: () => Promise };
+ showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise;
+ beginUpdateProgress: (message: string) => void;
+ endUpdateProgress: () => void;
+ processSentence: (sentence: string, fields: Record) => string;
+ processSentenceFurigana: (sentence: string, fields: Record) => string;
+ queuePendingYoutubeMediaUpdate: (job: PendingYoutubeJob) => void;
+ queuePendingYoutubeMediaUpdateForNote: (job: {
+ noteId: number;
+ noteInfo: { noteId: number; fields: Record };
+ label: string | number;
+ }) => Promise;
+ generateAudio: () => Promise;
+ formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
+ appendKnownWordsFromNoteInfo: (noteInfo: {
+ noteId: number;
+ fields: Record;
+ }) => void;
+ rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
+};
+
+/** Named stand-ins for AnkiIntegration's long positional constructor arguments. */
+type IntegrationHooks = {
+ mpvClient?: Record;
+ osd?: (text: string) => void;
+ desktop?: (title: string, options: { body?: string; icon?: string }) => void;
+ overlay?: (payload: TestOverlayNotificationPayload) => void;
+ dismissOverlay?: (id: string) => void;
+ knownWordCacheStatePath?: string;
+ getCachedMediaPath?: () => Promise;
+ shouldRequireRemoteMediaCache?: () => boolean;
+ getYoutubeMediaSourceUrl?: () => string | null;
+};
+
+function createIntegration(
+ config: Partial,
+ hooks: IntegrationHooks = {},
+): AnkiIntegration {
+ return new AnkiIntegration(
+ config as AnkiConnectConfig,
+ {} as never,
+ (hooks.mpvClient ?? {}) as never,
+ hooks.osd,
+ hooks.desktop as never,
+ undefined,
+ hooks.knownWordCacheStatePath,
+ {},
+ undefined,
+ hooks.overlay as never,
+ hooks.getCachedMediaPath,
+ hooks.shouldRequireRemoteMediaCache,
+ hooks.getYoutubeMediaSourceUrl,
+ hooks.dismissOverlay,
+ );
+}
+
+function internalsOf(integration: AnkiIntegration): IntegrationInternals {
+ return integration as unknown as IntegrationInternals;
}
function describeMediaInputForTest(input: MediaInput): string {
@@ -36,412 +130,199 @@ function describeMediaInputForTest(input: MediaInput): string {
return `${input.path}:${input.source ?? 'raw'}`;
}
-function createIntegrationTestContext(
- options: {
- highlightEnabled?: boolean;
- nPlusOneEnabled?: boolean;
- onFindNotes?: () => Promise;
- onNotesInfo?: () => Promise;
- stateDirPrefix?: string;
- } = {},
-): IntegrationTestContext {
- const calls = {
- findNotes: 0,
- notesInfo: 0,
- };
+// --- Known-word cache -------------------------------------------------------
- const stateDir = fs.mkdtempSync(
- path.join(os.tmpdir(), options.stateDirPrefix ?? 'subminer-anki-integration-'),
- );
- const knownWordCacheStatePath = path.join(stateDir, 'known-words-cache.json');
+type KnownWordsOptions = {
+ highlightEnabled?: boolean;
+ nPlusOneEnabled?: boolean;
+ onFindNotes?: () => Promise;
+};
- const client = {
- findNotes: async () => {
- calls.findNotes += 1;
- if (options.onFindNotes) {
- return options.onFindNotes();
- }
- return [] as number[];
- },
- notesInfo: async () => {
- calls.notesInfo += 1;
- if (options.onNotesInfo) {
- return options.onNotesInfo();
- }
- return [] as unknown[];
- },
- } as {
- findNotes: () => Promise;
- notesInfo: () => Promise;
- };
+type KnownWordsContext = {
+ integration: AnkiIntegration;
+ calls: { findNotes: number; notesInfo: number };
+};
- const integration = new AnkiIntegration(
- {
- knownWords: {
- highlightEnabled: options.highlightEnabled ?? true,
+/** Runs `run` against an integration whose AnkiConnect client is a counting fake; cleans up its cache dir. */
+async function withKnownWordsIntegration(
+ options: KnownWordsOptions,
+ run: (ctx: KnownWordsContext) => Promise | void,
+): Promise {
+ const calls = { findNotes: 0, notesInfo: 0 };
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-integration-'));
+
+ try {
+ const integration = createIntegration(
+ {
+ knownWords: { highlightEnabled: options.highlightEnabled ?? true },
+ nPlusOne:
+ options.nPlusOneEnabled === undefined ? undefined : { enabled: options.nPlusOneEnabled },
},
- nPlusOne:
- options.nPlusOneEnabled === undefined
- ? undefined
- : {
- enabled: options.nPlusOneEnabled,
- },
- },
- {} as never,
- {} as never,
- undefined,
- undefined,
- undefined,
- knownWordCacheStatePath,
- );
-
- const integrationWithClient = integration as unknown as {
- client: {
- findNotes: () => Promise;
- notesInfo: () => Promise;
- };
- };
- integrationWithClient.client = client;
-
- const privateState = integration as unknown as {
- knownWordsScope: string;
- knownWordsLastRefreshedAtMs: number;
- };
- privateState.knownWordsScope = 'all';
- privateState.knownWordsLastRefreshedAtMs = Date.now();
-
- return {
- integration,
- calls,
- stateDir,
- };
-}
-
-function cleanupIntegrationTestContext(ctx: IntegrationTestContext): void {
- fs.rmSync(ctx.stateDir, { recursive: true, force: true });
-}
-
-function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
- const exact = availableFieldNames.find((name) => name === preferredName);
- if (exact) return exact;
-
- const lower = preferredName.toLowerCase();
- return availableFieldNames.find((name) => name.toLowerCase() === lower) ?? null;
-}
-
-function createFieldGroupingMergeCollaborator(options?: {
- config?: Partial;
- currentSubtitleText?: string;
- generatedMedia?: {
- audioField?: string;
- audioValue?: string;
- imageField?: string;
- imageValue?: string;
- miscInfoValue?: string;
- };
-}): FieldGroupingMergeCollaborator {
- const config = {
- fields: {
- sentence: 'Sentence',
- audio: 'ExpressionAudio',
- image: 'Picture',
- ...(options?.config?.fields ?? {}),
- },
- ...(options?.config ?? {}),
- } as AnkiConnectConfig;
-
- return new FieldGroupingMergeCollaborator({
- getConfig: () => config,
- getEffectiveSentenceCardConfig: () => ({
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- fieldGroupingProvider: 'kiku' as const,
- }),
- getCurrentSubtitleText: () => options?.currentSubtitleText,
- resolveFieldName,
- resolveNoteFieldName: (noteInfo, preferredName) => {
- if (!preferredName) return null;
- return resolveFieldName(Object.keys(noteInfo.fields), preferredName);
- },
- extractFields: (fields) => {
- const result: Record = {};
- for (const [key, value] of Object.entries(fields)) {
- result[key.toLowerCase()] = value.value || '';
- }
- return result;
- },
- processSentence: (mpvSentence) => `${mpvSentence}::processed`,
- generateMediaForMerge: async () => options?.generatedMedia ?? {},
- warnFieldParseOnce: () => undefined,
- });
-}
-
-test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () => {
- const ctx = createIntegrationTestContext();
-
- try {
- await ctx.integration.refreshKnownWordCache();
-
- assert.equal(ctx.calls.findNotes, 1);
- assert.equal(ctx.calls.notesInfo, 0);
- } finally {
- cleanupIntegrationTestContext(ctx);
- }
-});
-
-test('AnkiIntegration.refreshKnownWordCache notifies annotation cache listeners', async () => {
- const ctx = createIntegrationTestContext({
- stateDirPrefix: 'subminer-anki-integration-refresh-notify-',
- });
- let notifications = 0;
-
- try {
- ctx.integration.setKnownWordCacheUpdatedCallback(() => {
- notifications += 1;
- });
-
- await ctx.integration.refreshKnownWordCache();
-
- assert.equal(notifications, 1);
- } finally {
- cleanupIntegrationTestContext(ctx);
- }
-});
-
-test('AnkiIntegration.refreshKnownWordCache notifies when n+1 is enabled without highlights', async () => {
- const ctx = createIntegrationTestContext({
- highlightEnabled: false,
- nPlusOneEnabled: true,
- stateDirPrefix: 'subminer-anki-integration-nplusone-notify-',
- });
- let notifications = 0;
-
- try {
- ctx.integration.setKnownWordCacheUpdatedCallback(() => {
- notifications += 1;
- });
-
- await ctx.integration.refreshKnownWordCache();
-
- assert.equal(ctx.calls.findNotes, 1);
- assert.equal(notifications, 1);
- } finally {
- cleanupIntegrationTestContext(ctx);
- }
-});
-
-test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => {
- const ctx = createIntegrationTestContext({
- highlightEnabled: false,
- stateDirPrefix: 'subminer-anki-integration-disabled-',
- });
-
- try {
- await ctx.integration.refreshKnownWordCache();
-
- assert.equal(ctx.calls.findNotes, 0);
- assert.equal(ctx.calls.notesInfo, 0);
- } finally {
- cleanupIntegrationTestContext(ctx);
- }
-});
-
-test('AnkiIntegration notifies when mined note info updates known words', () => {
- const ctx = createIntegrationTestContext({
- stateDirPrefix: 'subminer-anki-integration-known-update-',
- });
- let notifications = 0;
-
- try {
- const integrationState = ctx.integration as unknown as {
- config: AnkiConnectConfig;
- appendKnownWordsFromNoteInfo: (noteInfo: {
- noteId: number;
- fields: Record;
- }) => void;
- };
- integrationState.config.deck = 'Mining';
- integrationState.config.knownWords = {
- ...integrationState.config.knownWords,
- decks: {
- Mining: ['Word'],
+ { knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json') },
+ );
+ internalsOf(integration).client = {
+ findNotes: async () => {
+ calls.findNotes += 1;
+ return options.onFindNotes ? options.onFindNotes() : [];
+ },
+ notesInfo: async () => {
+ calls.notesInfo += 1;
+ return [];
},
};
- ctx.integration.setKnownWordCacheUpdatedCallback(() => {
- notifications += 1;
- });
- integrationState.appendKnownWordsFromNoteInfo({
- noteId: 42,
- fields: {
- Word: { value: '食べる' },
- },
- });
-
- assert.equal(ctx.integration.isKnownWord('食べる'), true);
- assert.equal(notifications, 1);
+ await run({ integration, calls });
} finally {
- cleanupIntegrationTestContext(ctx);
+ fs.rmSync(stateDir, { recursive: true, force: true });
}
-});
+}
+
+const KNOWN_WORD_REFRESH_CASES: Array<{
+ name: string;
+ options: KnownWordsOptions;
+ expected: { findNotes: number; notifications: number };
+}> = [
+ {
+ name: 'refreshes once, skipping stale checks, and notifies annotation cache listeners',
+ options: {},
+ expected: { findNotes: 1, notifications: 1 },
+ },
+ {
+ name: 'notifies when n+1 is enabled without highlights',
+ options: { highlightEnabled: false, nPlusOneEnabled: true },
+ expected: { findNotes: 1, notifications: 1 },
+ },
+ {
+ name: 'skips work when highlight mode is disabled',
+ options: { highlightEnabled: false },
+ expected: { findNotes: 0, notifications: 0 },
+ },
+];
+
+for (const c of KNOWN_WORD_REFRESH_CASES) {
+ test(`AnkiIntegration.refreshKnownWordCache ${c.name}`, async () => {
+ await withKnownWordsIntegration(c.options, async ({ integration, calls }) => {
+ let notifications = 0;
+ integration.setKnownWordCacheUpdatedCallback(() => {
+ notifications += 1;
+ });
+
+ await integration.refreshKnownWordCache();
+
+ assert.equal(calls.findNotes, c.expected.findNotes);
+ assert.equal(calls.notesInfo, 0);
+ assert.equal(notifications, c.expected.notifications);
+ });
+ });
+}
test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => {
- let releaseFindNotes: (() => void) | undefined;
- const findNotesPromise = new Promise((resolve) => {
+ let releaseFindNotes: () => void = () => {};
+ const findNotesGate = new Promise((resolve) => {
releaseFindNotes = resolve;
});
- const ctx = createIntegrationTestContext({
- onFindNotes: async () => {
- await findNotesPromise;
- return [] as number[];
+ await withKnownWordsIntegration(
+ {
+ onFindNotes: async () => {
+ await findNotesGate;
+ return [];
+ },
},
- stateDirPrefix: 'subminer-anki-integration-concurrent-',
+ async ({ integration, calls }) => {
+ const first = integration.refreshKnownWordCache();
+ await Promise.resolve();
+ const second = integration.refreshKnownWordCache();
+ releaseFindNotes();
+ await Promise.all([first, second]);
+
+ assert.equal(calls.findNotes, 1);
+ assert.equal(calls.notesInfo, 0);
+ },
+ );
+});
+
+test('AnkiIntegration notifies when mined note info updates known words', async () => {
+ await withKnownWordsIntegration({}, ({ integration }) => {
+ let notifications = 0;
+ const internals = internalsOf(integration);
+ internals.config.deck = 'Mining';
+ internals.config.knownWords = {
+ ...internals.config.knownWords,
+ decks: { Mining: ['Word'] },
+ };
+ integration.setKnownWordCacheUpdatedCallback(() => {
+ notifications += 1;
+ });
+
+ internals.appendKnownWordsFromNoteInfo({ noteId: 42, fields: { Word: { value: '食べる' } } });
+
+ assert.equal(integration.isKnownWord('食べる'), true);
+ assert.equal(notifications, 1);
});
-
- const first = ctx.integration.refreshKnownWordCache();
- await Promise.resolve();
- const second = ctx.integration.refreshKnownWordCache();
-
- if (releaseFindNotes !== undefined) {
- releaseFindNotes();
- }
-
- await Promise.all([first, second]);
-
- try {
- assert.equal(ctx.calls.findNotes, 1);
- assert.equal(ctx.calls.notesInfo, 0);
- } finally {
- cleanupIntegrationTestContext(ctx);
- }
});
test('AnkiIntegration resolves merged-away note ids to the kept note id', () => {
- const ctx = createIntegrationTestContext({
- stateDirPrefix: 'subminer-anki-integration-note-redirect-',
+ const integration = createIntegration({});
+ const internals = internalsOf(integration);
+ internals.rememberMergedNoteIds(111, 222);
+ internals.rememberMergedNoteIds(222, 333);
+
+ assert.equal(integration.resolveCurrentNoteId(111), 333);
+ assert.equal(integration.resolveCurrentNoteId(222), 333);
+ assert.equal(integration.resolveCurrentNoteId(333), 333);
+ assert.equal(integration.resolveCurrentNoteId(444), 444);
+});
+
+// --- Sentence highlighting --------------------------------------------------
+
+const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―';
+
+const HIGHLIGHT_CASES = [
+ {
+ name: 'highlights mined word from expression field when sentence has no bold marker',
+ highlightWord: true,
+ noteSentence: MPV_SENTENCE,
+ expected: '先日 貴様らが潜入した キールダンジョンから―',
+ },
+ {
+ name: 'keeps existing Yomitan bold target when present',
+ highlightWord: true,
+ noteSentence: '潜入した',
+ expected: '先日 貴様らが潜入した キールダンジョンから―',
+ },
+ {
+ name: 'leaves sentence plain when word highlighting is disabled',
+ highlightWord: false,
+ noteSentence: '潜入',
+ expected: MPV_SENTENCE,
+ },
+];
+
+for (const c of HIGHLIGHT_CASES) {
+ test(`AnkiIntegration ${c.name}`, () => {
+ const integration = createIntegration({
+ fields: { word: 'Expression', sentence: 'Sentence' },
+ behavior: { highlightWord: c.highlightWord },
+ });
+
+ const processed = internalsOf(integration).processSentence(MPV_SENTENCE, {
+ expression: '潜入',
+ sentence: c.noteSentence,
+ });
+
+ assert.equal(processed, c.expected);
});
-
- try {
- const integrationWithInternals = ctx.integration as unknown as {
- rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
- };
- integrationWithInternals.rememberMergedNoteIds(111, 222);
- integrationWithInternals.rememberMergedNoteIds(222, 333);
-
- assert.equal(ctx.integration.resolveCurrentNoteId(111), 333);
- assert.equal(ctx.integration.resolveCurrentNoteId(222), 333);
- assert.equal(ctx.integration.resolveCurrentNoteId(333), 333);
- assert.equal(ctx.integration.resolveCurrentNoteId(444), 444);
- } finally {
- cleanupIntegrationTestContext(ctx);
- }
-});
-
-function processSentenceWithConfig(
- config: Partial,
- mpvSentence: string,
- noteFields: Record,
-): string {
- const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
- return (
- integration as unknown as {
- processSentence: (sentence: string, fields: Record) => string;
- }
- ).processSentence(mpvSentence, noteFields);
}
-function processSentenceFuriganaWithConfig(
- config: Partial,
- sentenceFurigana: string,
- noteFields: Record,
-): string {
- const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
- return (
- integration as unknown as {
- processSentenceFurigana: (sentence: string, fields: Record) => string;
- }
- ).processSentenceFurigana(sentenceFurigana, noteFields);
-}
-
-test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => {
- const processed = processSentenceWithConfig(
- {
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- },
- behavior: {
- highlightWord: true,
- },
- },
- '先日 貴様らが潜入した キールダンジョンから―',
- {
- expression: '潜入',
- sentence: '先日 貴様らが潜入した キールダンジョンから―',
- },
- );
-
- assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
-});
-
-test('AnkiIntegration keeps existing Yomitan bold target when present', () => {
- const processed = processSentenceWithConfig(
- {
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- },
- behavior: {
- highlightWord: true,
- },
- },
- '先日 貴様らが潜入した キールダンジョンから―',
- {
- expression: '潜入',
- sentence: '潜入した',
- },
- );
-
- assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
-});
-
-test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => {
- const processed = processSentenceWithConfig(
- {
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- },
- behavior: {
- highlightWord: false,
- },
- },
- '先日 貴様らが潜入した キールダンジョンから―',
- {
- expression: '潜入',
- sentence: '潜入',
- },
- );
-
- assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
-});
-
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
- const processed = processSentenceFuriganaWithConfig(
- {
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- },
- behavior: {
- highlightWord: true,
- },
- },
+ const integration = createIntegration({
+ fields: { word: 'Expression', sentence: 'Sentence' },
+ behavior: { highlightWord: true },
+ });
+
+ const processed = internalsOf(integration).processSentenceFurigana(
'不思議な特技を',
- {
- expression: '特技',
- sentence: '不思議な特技を',
- },
+ { expression: '特技', sentence: '不思議な特技を' },
);
assert.equal(
@@ -450,25 +331,7 @@ test('AnkiIntegration highlights mined word in sentence furigana field', () => {
);
});
-test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
- const integration = new AnkiIntegration(
- {
- enabled: true,
- proxy: {
- enabled: false,
- },
- } as never,
- {} as never,
- {} as never,
- );
-
- const privateState = integration as unknown as {
- runtime: {
- proxyServer: unknown | null;
- };
- };
- assert.equal(privateState.runtime.proxyServer, null);
-});
+// --- Proxy, field grouping, notifications -----------------------------------
test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => {
const occupiedServer = http.createServer();
@@ -478,7 +341,7 @@ test('AnkiIntegration reports an occupied proxy address through its notification
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-'));
const overlayNotifications: TestOverlayNotificationPayload[] = [];
- const integration = new AnkiIntegration(
+ const integration = createIntegration(
{
enabled: true,
url: 'http://127.0.0.1:8765',
@@ -488,26 +351,15 @@ test('AnkiIntegration reports an occupied proxy address through its notification
port: occupiedAddress.port,
upstreamUrl: 'http://127.0.0.1:8765',
},
- behavior: {
- notificationType: 'overlay',
+ behavior: { notificationType: 'overlay' },
+ knownWords: { highlightEnabled: false },
+ nPlusOne: { enabled: false },
+ },
+ {
+ knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json'),
+ overlay: (payload) => {
+ overlayNotifications.push(payload);
},
- knownWords: {
- highlightEnabled: false,
- },
- nPlusOne: {
- enabled: false,
- },
- } as never,
- {} as never,
- {} as never,
- undefined,
- undefined,
- undefined,
- path.join(stateDir, 'known-words-cache.json'),
- {},
- undefined,
- (payload) => {
- overlayNotifications.push(payload as TestOverlayNotificationPayload);
},
);
@@ -531,31 +383,11 @@ test('AnkiIntegration reports an occupied proxy address through its notification
});
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
- const integration = new AnkiIntegration(
- {
- isKiku: {
- enabled: true,
- fieldGrouping: 'manual',
- },
- } as never,
- {} as never,
- {} as never,
- );
-
+ const integration = createIntegration({
+ isKiku: { enabled: true, fieldGrouping: 'manual' },
+ });
let groupingTriggered = 0;
- const internals = integration as unknown as {
- cardCreationService: {
- createSentenceCard: (
- sentence: string,
- startTime: number,
- endTime: number,
- secondarySubText?: string,
- ) => Promise;
- };
- fieldGroupingService: {
- triggerFieldGroupingForLastAddedCard: () => Promise;
- };
- };
+ const internals = internalsOf(integration);
internals.cardCreationService = {
createSentenceCard: async () => {
integration.trackDuplicateNoteIdsForNote(42, [7]);
@@ -574,98 +406,58 @@ test('AnkiIntegration triggers field grouping after a local duplicate sentence c
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
const osdMessages: string[] = [];
- const integration = new AnkiIntegration(
- {
- behavior: {
- notificationType: 'osd',
- },
- },
- {} as never,
- {} as never,
- (text) => {
- osdMessages.push(text);
- },
+ const integration = createIntegration(
+ { behavior: { notificationType: 'osd' } },
+ { osd: (text) => osdMessages.push(text) },
);
- await (
- integration as unknown as {
- showNotification: (
- noteId: number,
- label: string | number,
- errorSuffix?: string,
- ) => Promise;
- }
- ).showNotification(42, 'taberu', 'image failed');
+ await internalsOf(integration).showNotification(42, 'taberu', 'image failed');
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
});
-test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => {
+test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
const osdMessages: string[] = [];
- const updatedNotes: Array<{ noteId: number; fields: Record }> = [];
- const storedMedia: string[] = [];
- const mediaInputs: string[] = [];
-
- const integration = new AnkiIntegration(
+ const desktopMessages: string[] = [];
+ const overlayMessages: string[] = [];
+ const integration = createIntegration(
+ { behavior: { notificationType: 'both' } },
{
- fields: {
- audio: 'ExpressionAudio',
- image: 'Picture',
- },
- media: {
- imageFormat: 'jpg',
- },
- behavior: {
- notificationType: 'osd',
- },
- },
- {} as never,
- {} as never,
- (text) => {
- osdMessages.push(text);
+ osd: (text) => osdMessages.push(text),
+ desktop: (title, options) => desktopMessages.push(`${title}:${options.body ?? ''}`),
+ overlay: (payload) =>
+ overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`),
},
);
- const internals = integration as unknown as {
- client: {
- notesInfo: (noteIds: number[]) => Promise;
- updateNoteFields: (noteId: number, fields: Record) => Promise;
- storeMediaFile: (filename: string, data: Buffer) => Promise;
- };
- mediaGenerator: {
- generateAudio: (
- path: MediaInput,
- startTime: number,
- endTime: number,
- audioPadding?: number,
- audioStreamIndex?: number,
- normalizeAudio?: boolean,
- volumeScale?: number,
- ) => Promise;
- generateScreenshot: (path: MediaInput) => Promise;
- };
- queuePendingYoutubeMediaUpdate: (job: {
- sourceUrl: string;
- noteId: number;
- startTime: number;
- endTime: number;
- label: string | number;
- audioStreamIndex?: number;
- audioFieldName?: string;
- imageFieldName?: string;
- generateAudio: boolean;
- generateImage: boolean;
- volumeScale?: number;
- }) => void;
- };
+ assert.equal(await integration.createSentenceCard('食べる', 0, 1), false);
+
+ assert.deepEqual(osdMessages, []);
+ assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']);
+ assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']);
+});
+
+// --- YouTube media cache ----------------------------------------------------
+
+/**
+ * Installs a fake AnkiConnect client whose notes carry empty values for `noteFieldNames`, plus a
+ * media generator that succeeds unless overridden. Records every note update and stored file.
+ */
+function installFakeAnki(
+ integration: AnkiIntegration,
+ options: {
+ noteFieldNames: string[];
+ mediaGenerator?: IntegrationInternals['mediaGenerator'];
+ },
+) {
+ const updatedNotes: Array<{ noteId: number; fields: Record }> = [];
+ const storedMedia: string[] = [];
+ const internals = internalsOf(integration);
internals.client = {
notesInfo: async (noteIds) =>
noteIds.map((noteId) => ({
noteId,
- fields: {
- ExpressionAudio: { value: '' },
- Picture: { value: '' },
- },
+ fields: Object.fromEntries(options.noteFieldNames.map((name) => [name, { value: '' }])),
})),
updateNoteFields: async (noteId, fields) => {
updatedNotes.push({ noteId, fields });
@@ -675,51 +467,68 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
},
};
internals.mediaGenerator = {
- generateAudio: async (
- mediaPath,
- _startTime,
- _endTime,
- _audioPadding,
- audioStreamIndex,
- _normalizeAudio,
- volumeScale,
- ) => {
- mediaInputs.push(
- `audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${volumeScale}`,
- );
- return Buffer.from('audio');
- },
- generateScreenshot: async (mediaPath) => {
- mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`);
- return Buffer.from('image');
- },
+ generateAudio: async () => Buffer.from('audio'),
+ generateScreenshot: async () => Buffer.from('image'),
+ ...options.mediaGenerator,
};
- internals.queuePendingYoutubeMediaUpdate({
+ return { updatedNotes, storedMedia };
+}
+
+function youtubeJob(overrides: Partial & { noteId: number }): PendingYoutubeJob {
+ return {
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
- noteId: 101,
startTime: 10,
endTime: 12,
- label: 'first',
- audioStreamIndex: 22,
+ label: String(overrides.noteId),
audioFieldName: 'SentenceAudio',
imageFieldName: 'Picture',
generateAudio: true,
generateImage: true,
- volumeScale: 0.25,
- });
- internals.queuePendingYoutubeMediaUpdate({
- sourceUrl: 'https://youtu.be/abc123',
- noteId: 202,
- startTime: 20,
- endTime: 22,
- label: 'second',
- audioStreamIndex: 23,
- audioFieldName: 'SentenceAudio',
- imageFieldName: 'Picture',
- generateAudio: true,
- generateImage: true,
- volumeScale: 0.8,
+ ...overrides,
+ };
+}
+
+test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => {
+ const osdMessages: string[] = [];
+ const mediaInputs: string[] = [];
+ const integration = createIntegration(
+ {
+ fields: { audio: 'ExpressionAudio', image: 'Picture' },
+ media: { imageFormat: 'jpg' },
+ behavior: { notificationType: 'osd' },
+ },
+ { osd: (text) => osdMessages.push(text) },
+ );
+ const { updatedNotes, storedMedia } = installFakeAnki(integration, {
+ noteFieldNames: ['ExpressionAudio', 'Picture'],
+ mediaGenerator: {
+ generateAudio: async (mediaPath, _start, _end, _padding, audioStreamIndex, _norm, scale) => {
+ mediaInputs.push(
+ `audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${scale}`,
+ );
+ return Buffer.from('audio');
+ },
+ generateScreenshot: async (mediaPath) => {
+ mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`);
+ return Buffer.from('image');
+ },
+ },
});
+ const internals = internalsOf(integration);
+ internals.queuePendingYoutubeMediaUpdate(
+ youtubeJob({ noteId: 101, label: 'first', audioStreamIndex: 22, volumeScale: 0.25 }),
+ );
+ internals.queuePendingYoutubeMediaUpdate(
+ youtubeJob({
+ noteId: 202,
+ sourceUrl: 'https://youtu.be/abc123',
+ startTime: 20,
+ endTime: 22,
+ label: 'second',
+ audioStreamIndex: 23,
+ volumeScale: 0.8,
+ }),
+ );
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
@@ -733,105 +542,48 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
updatedNotes.map((update) => update.noteId),
[101, 202],
);
- const firstUpdate = updatedNotes[0];
- const secondUpdate = updatedNotes[1];
- assert.ok(firstUpdate);
- assert.ok(secondUpdate);
- assert.match(firstUpdate.fields.SentenceAudio ?? '', /^\[sound:audio_/);
- assert.match(firstUpdate.fields.Picture ?? '', /^
message.includes('Queued YouTube media finished with 0 updated, 1 partial, and 0 failed.'),
),
- true,
);
});
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
- const updatedNotes: Array<{ noteId: number; fields: Record }> = [];
- const storedMedia: string[] = [];
const audioVolumeScales: Array = [];
let mpvVolume = 30;
-
- const integration = new AnkiIntegration(
+ const integration = createIntegration(
+ { fields: { image: 'Picture' }, media: { imageFormat: 'jpg' } },
{
- fields: {
- image: 'Picture',
- },
- media: {
- imageFormat: 'jpg',
- },
- },
- {} as never,
- {
- currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
- currentSubStart: 10,
- currentSubEnd: 12,
- currentTimePos: 11,
- requestProperty: async (name: string) => {
- assert.equal(name, 'volume');
- return mpvVolume;
- },
- } as never,
- () => undefined,
- undefined,
- undefined,
- undefined,
- {},
- undefined,
- undefined,
- async () => null,
- () => true,
- () => 'https://www.youtube.com/watch?v=abc123',
- );
-
- const internals = integration as unknown as {
- client: {
- notesInfo: (noteIds: number[]) => Promise;
- updateNoteFields: (noteId: number, fields: Record) => Promise;
- storeMediaFile: (filename: string) => Promise;
- };
- mediaGenerator: {
- generateAudio: (
- path: MediaInput,
- startTime: number,
- endTime: number,
- audioPadding?: number,
- audioStreamIndex?: number,
- normalizeAudio?: boolean,
- volumeScale?: number,
- ) => Promise;
- generateScreenshot: () => Promise;
- };
- queuePendingYoutubeMediaUpdateForNote: (job: {
- noteId: number;
- noteInfo: { noteId: number; fields: Record };
- label: string | number;
- }) => Promise;
- showNotification: () => Promise;
- };
- internals.client = {
- notesInfo: async (noteIds) =>
- noteIds.map((noteId) => ({
- noteId,
- fields: {
- SentenceAudio: { value: '' },
- Picture: { value: '' },
+ mpvClient: {
+ currentVideoPath:
+ 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
+ currentSubStart: 10,
+ currentSubEnd: 12,
+ currentTimePos: 11,
+ requestProperty: async (name: string) => {
+ assert.equal(name, 'volume');
+ return mpvVolume;
},
- })),
- updateNoteFields: async (noteId, fields) => {
- updatedNotes.push({ noteId, fields });
+ },
+ osd: () => undefined,
+ getCachedMediaPath: async () => null,
+ shouldRequireRemoteMediaCache: () => true,
+ getYoutubeMediaSourceUrl: () => 'https://www.youtube.com/watch?v=abc123',
},
- storeMediaFile: async (filename) => {
- storedMedia.push(filename);
+ );
+ const { updatedNotes, storedMedia } = installFakeAnki(integration, {
+ noteFieldNames: ['SentenceAudio', 'Picture'],
+ mediaGenerator: {
+ generateAudio: async (_path, _start, _end, _padding, _stream, _norm, volumeScale) => {
+ audioVolumeScales.push(volumeScale);
+ return Buffer.from('audio');
+ },
},
- };
- internals.mediaGenerator = {
- generateAudio: async (
- _path,
- _startTime,
- _endTime,
- _audioPadding,
- _audioStreamIndex,
- _normalizeAudio,
- volumeScale,
- ) => {
- audioVolumeScales.push(volumeScale);
- return Buffer.from('audio');
- },
- generateScreenshot: async () => Buffer.from('image'),
- };
+ });
+ const internals = internalsOf(integration);
internals.showNotification = async () => undefined;
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
noteId: 404,
noteInfo: {
noteId: 404,
- fields: {
- ExpressionAudio: { value: '' },
- Picture: { value: '' },
- },
+ fields: { ExpressionAudio: { value: '' }, Picture: { value: '' } },
},
label: 'resolved source',
});
+ // The volume is captured when the job is queued, not when the cache becomes ready.
mpvVolume = 90;
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
@@ -975,67 +663,46 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
volumeScale?: number;
}> = [];
const requestedProperties: string[] = [];
-
- const integration = new AnkiIntegration(
+ const integration = createIntegration(
+ { media: { audioPadding: 0, normalizeAudio: false } },
{
- media: {
- audioPadding: 0,
- normalizeAudio: false,
+ mpvClient: {
+ currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
+ currentAudioStreamIndex: 0,
+ currentSubStart: 10,
+ currentSubEnd: 12,
+ currentTimePos: 11,
+ requestProperty: async (name: string) => {
+ requestedProperties.push(name);
+ return 55;
+ },
},
+ osd: () => undefined,
+ getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
+ shouldRequireRemoteMediaCache: () => true,
},
- {} as never,
- {
- currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
- currentAudioStreamIndex: 0,
- currentSubStart: 10,
- currentSubEnd: 12,
- currentTimePos: 11,
- requestProperty: async (name: string) => {
- requestedProperties.push(name);
- return 55;
- },
- } as never,
- () => undefined,
- undefined,
- undefined,
- undefined,
- {},
- undefined,
- undefined,
- async () => '/tmp/subminer-youtube-media-cache/media.mkv',
- () => true,
);
-
- const internals = integration as unknown as {
- mediaGenerator: {
- generateAudio: (
- path: { path: string },
- startTime: number,
- endTime: number,
- audioPadding?: number,
- audioStreamIndex?: number,
- normalizeAudio?: boolean,
- volumeScale?: number,
- ) => Promise;
- };
- generateAudio: () => Promise;
- };
- internals.mediaGenerator = {
+ internalsOf(integration).mediaGenerator = {
generateAudio: async (
- path,
- _startTime,
- _endTime,
- _audioPadding,
+ mediaPath,
+ _start,
+ _end,
+ _padding,
audioStreamIndex,
normalizeAudio,
volumeScale,
) => {
- audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio, volumeScale });
+ audioCalls.push({
+ path: typeof mediaPath === 'string' ? mediaPath : mediaPath.path,
+ audioStreamIndex,
+ normalizeAudio,
+ volumeScale,
+ });
return Buffer.from('audio');
},
};
- await internals.generateAudio();
+ await internalsOf(integration).generateAudio();
assert.deepEqual(requestedProperties, ['volume']);
assert.deepEqual(audioCalls, [
@@ -1048,172 +715,129 @@ test('AnkiIntegration passes audio normalization config for ready cached YouTube
]);
});
-test('AnkiIntegration announces ready YouTube cache when no queued notes exist', async () => {
- const overlayNotifications: TestOverlayNotificationPayload[] = [];
+const NO_QUEUED_CACHE_READY_CASES: Array<{
+ name: string;
+ options?: { notifyNoQueued: boolean };
+ expectedBodies: string[];
+}> = [
+ {
+ name: 'announces ready YouTube cache when no queued notes exist',
+ expectedBodies: ['YouTube media cache ready.'],
+ },
+ {
+ name: 'can let caller own no-queued YouTube cache ready notification',
+ options: { notifyNoQueued: false },
+ expectedBodies: [],
+ },
+];
- const integration = new AnkiIntegration(
- {
- behavior: {
- notificationType: 'overlay',
- },
- },
- {} as never,
- {} as never,
- undefined,
- undefined,
- undefined,
- undefined,
- {},
- undefined,
- (payload) => {
- overlayNotifications.push(payload as TestOverlayNotificationPayload);
- },
- );
+for (const c of NO_QUEUED_CACHE_READY_CASES) {
+ test(`AnkiIntegration ${c.name}`, async () => {
+ const overlayNotifications: TestOverlayNotificationPayload[] = [];
+ const integration = createIntegration(
+ { behavior: { notificationType: 'overlay' } },
+ { overlay: (payload) => overlayNotifications.push(payload) },
+ );
- await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
+ await integration.handleYoutubeMediaCacheReady(
+ 'https://youtu.be/abc123',
+ '/tmp/media.mkv',
+ c.options,
+ );
- assert.equal(overlayNotifications.length, 1);
- assert.equal(overlayNotifications[0]?.title, 'SubMiner');
- assert.equal(overlayNotifications[0]?.body, 'YouTube media cache ready.');
-});
-
-test('AnkiIntegration can let caller own no-queued YouTube cache ready notification', async () => {
- const overlayNotifications: TestOverlayNotificationPayload[] = [];
-
- const integration = new AnkiIntegration(
- {
- behavior: {
- notificationType: 'overlay',
- },
- },
- {} as never,
- {} as never,
- undefined,
- undefined,
- undefined,
- undefined,
- {},
- undefined,
- (payload) => {
- overlayNotifications.push(payload as TestOverlayNotificationPayload);
- },
- );
-
- await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv', {
- notifyNoQueued: false,
+ assert.deepEqual(
+ overlayNotifications.map((notification) => [notification.title, notification.body]),
+ c.expectedBodies.map((body) => ['SubMiner', body]),
+ );
});
+}
- assert.deepEqual(overlayNotifications, []);
-});
+// --- Overlay / desktop notification surfaces --------------------------------
-test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
+/**
+ * Integration in 'both' notification mode with a fake media generator whose temp-icon write is
+ * `writeNotificationIconToFile`. Returns the surfaces' captured output.
+ */
+function createNotificationImageFixture(
+ writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string,
+) {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const generatedFrom: Array<{ videoPath: string; timestamp: number }> = [];
const cleanupPaths: string[] = [];
- const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
-
- const integration = new AnkiIntegration(
+ const integration = createIntegration(
+ { behavior: { notificationType: 'both' } },
{
- behavior: {
- notificationType: 'both',
+ mpvClient: { currentVideoPath: '/tmp/show.mkv', currentTimePos: 123.45 },
+ desktop: (title, options) => {
+ desktopNotifications.push({ title, body: options.body, icon: options.icon });
},
- },
- {} as never,
- {
- currentVideoPath: '/tmp/show.mkv',
- currentTimePos: 123.45,
- } as never,
- undefined,
- (title, options) => {
- desktopNotifications.push({ title, body: options.body, icon: options.icon });
- },
- undefined,
- undefined,
- {},
- undefined,
- (payload) => {
- overlayNotifications.push(payload as TestOverlayNotificationPayload);
+ overlay: (payload) => overlayNotifications.push(payload),
},
);
-
- (
- integration as unknown as {
- mediaGenerator: {
- generateNotificationIcon: (videoPath: string, timestamp: number) => Promise;
- writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string;
- scheduleNotificationIconCleanup: (filePath: string) => void;
- };
- }
- ).mediaGenerator = {
+ internalsOf(integration).mediaGenerator = {
generateNotificationIcon: async (videoPath, timestamp) => {
generatedFrom.push({ videoPath, timestamp });
return Buffer.from('png');
},
- writeNotificationIconToFile: (iconBuffer, noteId) => {
- assert.equal(iconBuffer.toString(), 'png');
- assert.equal(noteId, 42);
- return notificationIconPath;
- },
+ writeNotificationIconToFile,
scheduleNotificationIconCleanup: (filePath) => {
cleanupPaths.push(filePath);
},
};
+ return { integration, desktopNotifications, overlayNotifications, generatedFrom, cleanupPaths };
+}
- await (
- integration as unknown as {
- showNotification: (noteId: number, label: string | number) => Promise;
- }
- ).showNotification(42, '食べる');
+const PNG_DATA_URL = `data:image/png;base64,${Buffer.from('png').toString('base64')}`;
- assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
- assert.equal(overlayNotifications.length, 1);
- assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated');
- assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる');
- assert.equal(
- overlayNotifications[0]?.image,
- `data:image/png;base64,${Buffer.from('png').toString('base64')}`,
- );
- assert.deepEqual(overlayNotifications[0]?.actions, [
+test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
+ const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
+ const fixture = createNotificationImageFixture((iconBuffer, noteId) => {
+ assert.equal(iconBuffer.toString(), 'png');
+ assert.equal(noteId, 42);
+ return notificationIconPath;
+ });
+
+ await internalsOf(fixture.integration).showNotification(42, '食べる');
+
+ assert.deepEqual(fixture.generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
+ assert.equal(fixture.overlayNotifications.length, 1);
+ assert.equal(fixture.overlayNotifications[0]?.title, 'Anki Card Updated');
+ assert.equal(fixture.overlayNotifications[0]?.body, 'Updated card: 食べる');
+ assert.equal(fixture.overlayNotifications[0]?.image, PNG_DATA_URL);
+ assert.deepEqual(fixture.overlayNotifications[0]?.actions, [
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
]);
- assert.deepEqual(desktopNotifications, [
- {
- title: 'Anki Card Updated',
- body: 'Updated card: 食べる',
- icon: notificationIconPath,
- },
+ assert.deepEqual(fixture.desktopNotifications, [
+ { title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: notificationIconPath },
]);
- assert.deepEqual(cleanupPaths, [notificationIconPath]);
+ assert.deepEqual(fixture.cleanupPaths, [notificationIconPath]);
+});
+
+test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
+ const fixture = createNotificationImageFixture(() => {
+ throw new Error('disk full');
+ });
+
+ await internalsOf(fixture.integration).showNotification(42, '食べる');
+
+ assert.equal(fixture.overlayNotifications[0]?.image, PNG_DATA_URL);
+ assert.deepEqual(fixture.desktopNotifications, [
+ { title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: undefined },
+ ]);
+ assert.deepEqual(fixture.cleanupPaths, []);
});
test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
- const integration = new AnkiIntegration(
- {
- behavior: {
- notificationType: 'overlay',
- },
- },
- {} as never,
- {} as never,
- undefined,
- undefined,
- undefined,
- undefined,
- {},
- undefined,
- (payload) => {
- overlayNotifications.push(payload);
- },
+ const integration = createIntegration(
+ { behavior: { notificationType: 'overlay' } },
+ { overlay: (payload) => overlayNotifications.push(payload) },
);
- const updateNotifications = integration as unknown as {
- beginUpdateProgress: (message: string) => void;
- showNotification: (noteId: number, label: string | number) => Promise;
- };
+ const internals = internalsOf(integration);
- updateNotifications.beginUpdateProgress('Updating card');
- await updateNotifications.showNotification(42, '食べる');
+ internals.beginUpdateProgress('Updating card');
+ await internals.showNotification(42, '食べる');
assert.deepEqual(
overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })),
@@ -1227,343 +851,96 @@ test('AnkiIntegration keeps overlay card-update progress visible until the termi
test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const dismissedIds: string[] = [];
- const integration = new AnkiIntegration(
+ const integration = createIntegration(
+ { behavior: { notificationType: 'overlay' } },
{
- behavior: {
- notificationType: 'overlay',
- },
- },
- {} as never,
- {} as never,
- undefined,
- undefined,
- undefined,
- undefined,
- {},
- undefined,
- (payload) => {
- overlayNotifications.push(payload);
- },
- undefined,
- undefined,
- undefined,
- (id) => {
- dismissedIds.push(id);
+ overlay: (payload) => overlayNotifications.push(payload),
+ dismissOverlay: (id) => dismissedIds.push(id),
},
);
- const updateNotifications = integration as unknown as {
- beginUpdateProgress: (message: string) => void;
- endUpdateProgress: () => void;
- };
+ const internals = internalsOf(integration);
- updateNotifications.beginUpdateProgress('Updating card');
- updateNotifications.endUpdateProgress();
+ internals.beginUpdateProgress('Updating card');
+ internals.endUpdateProgress();
assert.equal(overlayNotifications[0]?.persistent, true);
assert.deepEqual(dismissedIds, ['anki-update-progress']);
});
test('AnkiIntegration dismisses overlay update progress after notifications switch to OSD', () => {
- const behavior: NonNullable = {
- notificationType: 'overlay',
- };
+ const behavior: NonNullable = { notificationType: 'overlay' };
const dismissedIds: string[] = [];
- const integration = new AnkiIntegration(
+ const integration = createIntegration(
{ behavior },
- {} as never,
- {} as never,
- undefined,
- undefined,
- undefined,
- undefined,
- {},
- undefined,
- () => {},
- undefined,
- undefined,
- undefined,
- (id) => {
- dismissedIds.push(id);
- },
+ { overlay: () => {}, dismissOverlay: (id) => dismissedIds.push(id) },
);
- const updateNotifications = integration as unknown as {
- beginUpdateProgress: (message: string) => void;
- endUpdateProgress: () => void;
- };
+ const internals = internalsOf(integration);
- updateNotifications.beginUpdateProgress('Updating card');
+ internals.beginUpdateProgress('Updating card');
behavior.notificationType = 'osd';
- updateNotifications.endUpdateProgress();
+ internals.endUpdateProgress();
assert.deepEqual(dismissedIds, ['anki-update-progress']);
});
-test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
- const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
- const overlayNotifications: TestOverlayNotificationPayload[] = [];
- const cleanupPaths: string[] = [];
+// --- Misc-info metadata -----------------------------------------------------
- const integration = new AnkiIntegration(
- {
- behavior: {
- notificationType: 'both',
- },
- },
- {} as never,
- {
- currentVideoPath: '/tmp/show.mkv',
- currentTimePos: 123.45,
- } as never,
- undefined,
- (title, options) => {
- desktopNotifications.push({ title, body: options.body, icon: options.icon });
- },
- undefined,
- undefined,
- {},
- undefined,
- (payload) => {
- overlayNotifications.push(payload as TestOverlayNotificationPayload);
- },
- );
-
- (
- integration as unknown as {
- mediaGenerator: {
- generateNotificationIcon: () => Promise;
- writeNotificationIconToFile: () => string;
- scheduleNotificationIconCleanup: (filePath: string) => void;
- };
- }
- ).mediaGenerator = {
- generateNotificationIcon: async () => Buffer.from('png'),
- writeNotificationIconToFile: () => {
- throw new Error('disk full');
- },
- scheduleNotificationIconCleanup: (filePath) => {
- cleanupPaths.push(filePath);
- },
- };
-
- await (
- integration as unknown as {
- showNotification: (noteId: number, label: string | number) => Promise;
- }
- ).showNotification(42, '食べる');
-
- assert.equal(
- overlayNotifications[0]?.image,
- `data:image/png;base64,${Buffer.from('png').toString('base64')}`,
- );
- assert.deepEqual(desktopNotifications, [
- {
- title: 'Anki Card Updated',
- body: 'Updated card: 食べる',
- icon: undefined,
- },
- ]);
- assert.deepEqual(cleanupPaths, []);
-});
-
-test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
- const osdMessages: string[] = [];
- const desktopMessages: string[] = [];
- const overlayMessages: string[] = [];
- const integration = new AnkiIntegration(
- {
- behavior: {
- notificationType: 'both',
- },
- },
- {} as never,
- {} as never,
- (text) => {
- osdMessages.push(text);
- },
- (title, options) => {
- desktopMessages.push(`${title}:${options.body ?? ''}`);
- },
- undefined,
- undefined,
- {},
- undefined,
- (payload) => {
- overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`);
- },
- );
-
- assert.equal(await integration.createSentenceCard('食べる', 0, 1), false);
-
- assert.deepEqual(osdMessages, []);
- assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']);
- assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']);
-});
-
-test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
- const collaborator = createFieldGroupingMergeCollaborator();
-
- const merged = await collaborator.computeFieldGroupingMergedFields(
- 101,
- 202,
- {
- noteId: 101,
- fields: {
- SentenceAudio: { value: '[sound:keep.mp3]' },
- ExpressionAudio: { value: '[sound:stale.mp3]' },
- },
- },
- {
- noteId: 202,
- fields: {
- SentenceAudio: { value: '[sound:new.mp3]' },
- },
- },
- false,
- );
-
- assert.equal(
- merged.SentenceAudio,
- '[sound:new.mp3][sound:keep.mp3]',
- );
- assert.equal('ExpressionAudio' in merged, false);
-});
-
-test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => {
- const collaborator = createFieldGroupingMergeCollaborator({
- generatedMedia: {
- audioField: 'SentenceAudio',
- audioValue: '[sound:generated.mp3]',
- },
- });
-
- const merged = await collaborator.computeFieldGroupingMergedFields(
- 11,
- 22,
- {
- noteId: 11,
- fields: {
- SentenceAudio: { value: '' },
- },
- },
- {
- noteId: 22,
- fields: {
- SentenceAudio: { value: '' },
- },
- },
- true,
- );
-
- assert.equal(merged.SentenceAudio, '[sound:generated.mp3]');
-});
-
-test('FieldGroupingMergeCollaborator keeps independent groups for identical sentence, audio, and image values', async () => {
- const collaborator = createFieldGroupingMergeCollaborator();
-
- const merged = await collaborator.computeFieldGroupingMergedFields(
- 202,
- 101,
- {
- noteId: 202,
- fields: {
- Sentence: { value: 'same sentence' },
- SentenceAudio: { value: '[sound:same.mp3]' },
- Picture: { value: '
' },
- ExpressionAudio: { value: '[sound:same.mp3]' },
- },
- },
- {
- noteId: 101,
- fields: {
- Sentence: { value: 'same sentence' },
- SentenceAudio: { value: '[sound:same.mp3]' },
- Picture: { value: '
' },
- },
- },
- false,
- );
-
- assert.equal(
- merged.Sentence,
- 'same sentencesame sentence',
- );
- assert.equal(
- merged.SentenceAudio,
- '[sound:same.mp3][sound:same.mp3]',
- );
- assert.equal(
- merged.Picture,
- '
',
- );
- assert.equal('ExpressionAudio' in merged, false);
-});
-
-test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => {
- const integration = new AnkiIntegration(
- {
- metadata: {
- pattern: '[SubMiner] %f (%t)',
- },
- } as never,
- {} as never,
- {
- currentSubText: '',
+const MISC_INFO_CASES = [
+ {
+ name: 'avoids leaking Jellyfin api_key query params',
+ pattern: '[SubMiner] %f (%t)',
+ mpv: {
currentVideoPath:
'stream?static=true&api_key=secret-token&MediaSourceId=a762ab23d26d4347e3cacdb83aaae405&AudioStreamIndex=3',
- currentTimePos: 426,
- currentSubStart: 426,
- currentSubEnd: 428,
- currentAudioStreamIndex: 3,
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
- send: () => true,
- } as unknown as never,
- );
-
- const privateApi = integration as unknown as {
- formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
- };
- const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
-
- assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
- assert.equal(result.includes('api_key='), false);
-});
-
-test('Anki metadata rejects a credential-bearing media title before metadata arrives', () => {
- const integration = new AnkiIntegration(
- { metadata: { pattern: '[SubMiner] %f | %F (%t)' } } as never,
- {} as never,
- {
+ },
+ fallbackFilename: 'audio_123.mp3',
+ expected: '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)',
+ leaked: 'api_key=',
+ },
+ {
+ name: 'treats ApiKey stream paths like legacy api_key ones',
+ pattern: '[SubMiner] %f (%t)',
+ mpv: {
+ currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
+ currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
+ },
+ fallbackFilename: 'audio_123.mp3',
+ expected: '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)',
+ leaked: 'ApiKey=',
+ },
+ {
+ name: 'rejects a credential-bearing media title before metadata arrives',
+ pattern: '[SubMiner] %f | %F (%t)',
+ mpv: {
currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
currentMediaTitle: 'stream?static=true&api_key=test-secret',
- currentTimePos: 426,
- send: () => true,
- } as never,
- );
- const privateApi = integration as unknown as {
- formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
- };
- const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
- assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
-});
+ },
+ fallbackFilename: 'stream?api_key=test-secret',
+ expected: '[SubMiner] Unknown media | Unknown media (00:07:06)',
+ leaked: 'test-secret',
+ },
+];
-test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => {
- const integration = new AnkiIntegration(
- { metadata: { pattern: '[SubMiner] %f (%t)' } } as never,
- {} as never,
- {
- currentSubText: '',
- currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
- currentTimePos: 426,
- currentSubStart: 426,
- currentSubEnd: 428,
- currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
- send: () => true,
- } as unknown as never,
- );
- const privateApi = integration as unknown as {
- formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
- };
- const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
- assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
- assert.equal(result.includes('ApiKey='), false);
-});
+for (const c of MISC_INFO_CASES) {
+ test(`AnkiIntegration.formatMiscInfoPattern ${c.name}`, () => {
+ const integration = createIntegration(
+ { metadata: { pattern: c.pattern } },
+ {
+ mpvClient: {
+ currentSubText: '',
+ currentTimePos: 426,
+ currentSubStart: 426,
+ currentSubEnd: 428,
+ send: () => true,
+ ...c.mpv,
+ },
+ },
+ );
+
+ const result = internalsOf(integration).formatMiscInfoPattern(c.fallbackFilename, 426);
+
+ assert.equal(result, c.expected);
+ assert.equal(result.includes(c.leaked), false);
+ });
+}
diff --git a/src/anki-integration/card-creation-sentence-media.test.ts b/src/anki-integration/card-creation-sentence-media.test.ts
index baadfd92..503449df 100644
--- a/src/anki-integration/card-creation-sentence-media.test.ts
+++ b/src/anki-integration/card-creation-sentence-media.test.ts
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
-import type { AnkiConnectConfig } from '../types/anki';
-
-type CardCreationDeps = ConstructorParameters[0];
+import {
+ type CardCreationDeps,
+ createAnkiConfig,
+ createCardCreationDeps,
+ createFakeClient,
+ createFakeMediaGenerator,
+} from './card-creation-test-utils';
test('sentence card writes generated audio only to sentence audio field', async () => {
const addedFields: Record[] = [];
@@ -13,128 +17,88 @@ test('sentence card writes generated audio only to sentence audio field', async
const requestedProperties: string[] = [];
const audioVolumeScales: Array = [];
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
-
- const deps: CardCreationDeps = {
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- audio: 'ExpressionAudio',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: true,
- generateImage: false,
- mirrorMpvVolume: true,
- maxMediaDuration: 30,
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: '/video.mp4',
- currentSubText: '字幕',
- currentSubStart: 12,
- currentSubEnd: 14,
- currentTimePos: 13,
- currentAudioStreamIndex: 0,
- requestProperty: async (name: string) => {
- requestedProperties.push(name);
- return 40;
- },
- }) as never,
- client: {
- addNote: async (_deck, _modelName, fields) => {
- addedFields.push(fields);
- return 42;
- },
- addTags: async () => undefined,
- notesInfo: async () => [
- {
- noteId: 42,
- fields: {
- Expression: { value: '字幕' },
- Sentence: { value: '字幕' },
- SelectionText: { value: 'Subtitle' },
- ExpressionAudio: { value: '' },
- SentenceAudio: { value: '' },
- },
- },
- ],
- updateNoteFields: async (_noteId, fields) => {
- updatedFields.push(fields);
- },
- storeMediaFile: async (filename) => {
- storedMedia.push(filename);
- },
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async (
- _path,
- startTime,
- endTime,
- audioPadding,
- _audioStreamIndex,
- _normalizeAudio,
- volumeScale,
- ) => {
- audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
- audioVolumeScales.push(volumeScale);
- return Buffer.from('audio');
- },
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
- for (const preferredName of preferredNames) {
- if (preferredName && preferredName in noteInfo.fields) return preferredName;
- }
- return null;
- },
- resolveNoteFieldName: (noteInfo, preferredName) =>
- preferredName && preferredName in noteInfo.fields ? preferredName : null,
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: () => undefined,
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: true,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
- }),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => undefined,
- reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
+ let review: Awaited>> = {
+ action: 'confirm',
+ startTime: 11.4,
+ endTime: 14.2,
};
- const service = new CardCreationService(deps);
- const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
+ const service = new CardCreationService(
+ createCardCreationDeps({
+ getConfig: () =>
+ createAnkiConfig({
+ fields: {
+ word: 'Expression',
+ audio: 'ExpressionAudio',
+ translation: 'SelectionText',
+ },
+ media: { generateAudio: true, mirrorMpvVolume: true, maxMediaDuration: 30 },
+ }),
+ getMpvClient: () =>
+ ({
+ currentVideoPath: '/video.mp4',
+ currentSubText: '字幕',
+ currentSubStart: 12,
+ currentSubEnd: 14,
+ currentTimePos: 13,
+ currentAudioStreamIndex: 0,
+ requestProperty: async (name: string) => {
+ requestedProperties.push(name);
+ return 40;
+ },
+ }) as never,
+ client: createFakeClient({
+ addNote: async (_deck, _modelName, fields) => {
+ addedFields.push(fields);
+ return 42;
+ },
+ notesInfo: async () => [
+ {
+ noteId: 42,
+ fields: {
+ Expression: { value: '字幕' },
+ Sentence: { value: '字幕' },
+ SelectionText: { value: 'Subtitle' },
+ ExpressionAudio: { value: '' },
+ SentenceAudio: { value: '' },
+ },
+ },
+ ],
+ updateNoteFields: async (_noteId, fields) => {
+ updatedFields.push(fields);
+ },
+ storeMediaFile: async (filename) => {
+ storedMedia.push(filename);
+ },
+ }),
+ mediaGenerator: createFakeMediaGenerator({
+ generateAudio: async (
+ _path,
+ startTime,
+ endTime,
+ audioPadding,
+ _audioStreamIndex,
+ _normalizeAudio,
+ volumeScale,
+ ) => {
+ audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
+ audioVolumeScales.push(volumeScale);
+ return Buffer.from('audio');
+ },
+ }),
+ getEffectiveSentenceCardConfig: () => ({
+ model: 'Sentence',
+ sentenceField: 'Sentence',
+ audioField: 'SentenceAudio',
+ lapisEnabled: true,
+ kikuEnabled: false,
+ fieldGroupingMode: 'disabled',
+ }),
+ reviewMediaTiming: async () => review,
+ }),
+ );
- assert.equal(created, true);
+ assert.equal(await service.createSentenceCard('字幕', 12, 14, 'Subtitle'), true);
assert.deepEqual(addedFields[0], {
Sentence: '字幕',
SelectionText: 'Subtitle',
@@ -149,11 +113,11 @@ test('sentence card writes generated audio only to sentence audio field', async
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
assert.equal('ExpressionAudio' in mediaUpdate!, false);
- deps.reviewMediaTiming = async () => ({ action: 'discard' });
+ review = { action: 'discard' };
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
assert.equal(addedFields.length, 1);
- deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
+ review = { action: 'skip-media' };
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
assert.equal(addedFields.length, 2);
assert.equal(storedMedia.length, 1);
diff --git a/src/anki-integration/card-creation-test-utils.ts b/src/anki-integration/card-creation-test-utils.ts
new file mode 100644
index 00000000..292b65a3
--- /dev/null
+++ b/src/anki-integration/card-creation-test-utils.ts
@@ -0,0 +1,107 @@
+import { CardCreationService } from './card-creation';
+import type { AnkiConnectConfig } from '../types/anki';
+
+export type CardCreationDeps = ConstructorParameters[0];
+
+/** Builds the minimal config the service reads; pass only the sections a test cares about. */
+export function createAnkiConfig(
+ overrides: {
+ fields?: Record;
+ media?: Record;
+ } = {},
+): AnkiConnectConfig {
+ return {
+ deck: 'Mining',
+ fields: { sentence: 'Sentence', audio: 'SentenceAudio', ...overrides.fields },
+ media: { generateAudio: false, generateImage: false, ...overrides.media },
+ behavior: {},
+ ai: false,
+ } as AnkiConnectConfig;
+}
+
+/**
+ * Fake CardCreationService dependencies: a bare mpv session, a client that adds note 42, no media,
+ * and field resolvers that match exact field names. Override only what the test exercises.
+ */
+export function createCardCreationDeps(
+ overrides: Partial = {},
+): CardCreationDeps {
+ return {
+ getConfig: () => createAnkiConfig(),
+ getAiConfig: () => ({}),
+ getTimingTracker: () => ({}) as never,
+ getMpvClient: () =>
+ ({
+ currentVideoPath: '/video.mp4',
+ currentSubText: '字幕',
+ currentSubStart: 1,
+ currentSubEnd: 2,
+ currentTimePos: 1.5,
+ currentAudioStreamIndex: 0,
+ }) as never,
+ client: {
+ addNote: async () => 42,
+ addTags: async () => undefined,
+ notesInfo: async () => [],
+ updateNoteFields: async () => undefined,
+ storeMediaFile: async () => undefined,
+ findNotes: async () => [],
+ retrieveMediaFile: async () => '',
+ deleteNotes: async () => undefined,
+ },
+ mediaGenerator: {
+ generateAudio: async () => null,
+ generateScreenshot: async () => null,
+ generateAnimatedImage: async () => null,
+ },
+ showOsdNotification: () => undefined,
+ showUpdateResult: () => undefined,
+ showStatusNotification: () => undefined,
+ showNotification: async () => undefined,
+ beginUpdateProgress: () => undefined,
+ endUpdateProgress: () => undefined,
+ withUpdateProgress: async (_message, action) => action(),
+ resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
+ for (const preferredName of preferredNames) {
+ if (preferredName && preferredName in noteInfo.fields) return preferredName;
+ }
+ return null;
+ },
+ resolveNoteFieldName: (noteInfo, preferredName) =>
+ preferredName && preferredName in noteInfo.fields ? preferredName : null,
+ getAnimatedImageLeadInSeconds: async () => 0,
+ extractFields: () => ({}),
+ processSentence: (sentence) => sentence,
+ setCardTypeFields: () => undefined,
+ mergeFieldValue: (_existing, newValue) => newValue,
+ formatMiscInfoPattern: () => '',
+ getEffectiveSentenceCardConfig: () => ({
+ model: 'Sentence',
+ sentenceField: 'Sentence',
+ audioField: 'SentenceAudio',
+ lapisEnabled: false,
+ kikuEnabled: false,
+ fieldGroupingMode: 'disabled',
+ }),
+ getFallbackDurationSeconds: () => 10,
+ appendKnownWordsFromNoteInfo: () => undefined,
+ removeKnownWordNote: () => undefined,
+ isUpdateInProgress: () => false,
+ setUpdateInProgress: () => undefined,
+ trackLastAddedNoteId: () => undefined,
+ ...overrides,
+ };
+}
+
+/** Anki client overrides, kept separate so a test can swap one method without redeclaring the rest. */
+export function createFakeClient(
+ overrides: Partial = {},
+): CardCreationDeps['client'] {
+ return { ...createCardCreationDeps().client, ...overrides };
+}
+
+export function createFakeMediaGenerator(
+ overrides: Partial = {},
+): CardCreationDeps['mediaGenerator'] {
+ return { ...createCardCreationDeps().mediaGenerator, ...overrides };
+}
diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts
index 881ebe5f..cf2d4002 100644
--- a/src/anki-integration/card-creation.test.ts
+++ b/src/anki-integration/card-creation.test.ts
@@ -2,292 +2,83 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
+import {
+ type CardCreationDeps,
+ createAnkiConfig,
+ createCardCreationDeps,
+ createFakeClient,
+ createFakeMediaGenerator,
+} from './card-creation-test-utils';
import { toMpvEdlValue } from './mpv-edl-test-utils';
import type { MediaInput } from '../media-generator';
-import type { AnkiConnectConfig } from '../types/anki';
+
+const YOUTUBE_URL = 'https://www.youtube.com/watch?v=abc123';
+
+const mediaInputPath = (mediaInput: MediaInput): string =>
+ typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
test('CardCreationService counts locally created sentence cards', async () => {
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- audio: 'SentenceAudio',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: '/video.mp4',
- currentSubText: '字幕',
- currentSubStart: 1,
- currentSubEnd: 2,
- currentTimePos: 1.5,
- currentAudioStreamIndex: 0,
- }) as never,
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => [],
- updateNoteFields: async () => undefined,
- storeMediaFile: async () => undefined,
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async () => null,
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: () => null,
- resolveNoteFieldName: () => null,
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: () => undefined,
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
+ const service = new CardCreationService(
+ createCardCreationDeps({
+ recordCardsMinedCallback: (count, noteIds) => {
+ minedCards.push({ count, noteIds });
+ },
}),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => undefined,
- recordCardsMinedCallback: (count, noteIds) => {
- minedCards.push({ count, noteIds });
- },
- });
+ );
- const created = await service.createSentenceCard('テスト', 0, 1);
-
- assert.equal(created, true);
+ assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]);
});
-test('CardCreationService keeps updating after trackLastAddedNoteId throws', async () => {
- const calls = {
- notesInfo: 0,
- updateNoteFields: 0,
- };
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- audio: 'SentenceAudio',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: '/video.mp4',
- currentSubText: '字幕',
- currentSubStart: 1,
- currentSubEnd: 2,
- currentTimePos: 1.5,
- currentAudioStreamIndex: 0,
- }) as never,
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => {
- calls.notesInfo += 1;
- return [
- {
- noteId: 42,
- fields: {
- Sentence: { value: 'existing' },
- },
+const THROWING_HOOKS: Array<{
+ name: string;
+ hook: Partial;
+}> = [
+ {
+ name: 'trackLastAddedNoteId',
+ hook: {
+ trackLastAddedNoteId: () => {
+ throw new Error('track failed');
+ },
+ },
+ },
+ {
+ name: 'recordCardsMinedCallback',
+ hook: {
+ recordCardsMinedCallback: () => {
+ throw new Error('record failed');
+ },
+ },
+ },
+];
+
+for (const c of THROWING_HOOKS) {
+ test(`CardCreationService keeps updating after ${c.name} throws`, async () => {
+ const calls = { notesInfo: 0, updateNoteFields: 0 };
+ const service = new CardCreationService(
+ createCardCreationDeps({
+ client: createFakeClient({
+ notesInfo: async () => {
+ calls.notesInfo += 1;
+ return [{ noteId: 42, fields: { Sentence: { value: 'existing' } } }];
},
- ];
- },
- updateNoteFields: async () => {
- calls.updateNoteFields += 1;
- },
- storeMediaFile: async () => undefined,
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async () => null,
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: () => null,
- resolveNoteFieldName: () => null,
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: (updatedFields) => {
- updatedFields.CardType = 'sentence';
- },
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
- }),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => {
- throw new Error('track failed');
- },
- });
-
- const created = await service.createSentenceCard('テスト', 0, 1);
-
- assert.equal(created, true);
- assert.equal(calls.notesInfo, 1);
- assert.equal(calls.updateNoteFields, 1);
-});
-
-test('CardCreationService keeps updating after recordCardsMinedCallback throws', async () => {
- const calls = {
- notesInfo: 0,
- updateNoteFields: 0,
- };
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- audio: 'SentenceAudio',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: '/video.mp4',
- currentSubText: '字幕',
- currentSubStart: 1,
- currentSubEnd: 2,
- currentTimePos: 1.5,
- currentAudioStreamIndex: 0,
- }) as never,
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => {
- calls.notesInfo += 1;
- return [
- {
- noteId: 42,
- fields: {
- Sentence: { value: 'existing' },
- },
+ updateNoteFields: async () => {
+ calls.updateNoteFields += 1;
},
- ];
- },
- updateNoteFields: async () => {
- calls.updateNoteFields += 1;
- },
- storeMediaFile: async () => undefined,
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async () => null,
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: () => null,
- resolveNoteFieldName: () => null,
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: (updatedFields) => {
- updatedFields.CardType = 'sentence';
- },
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
- }),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- recordCardsMinedCallback: () => {
- throw new Error('record failed');
- },
+ }),
+ setCardTypeFields: (updatedFields) => {
+ updatedFields.CardType = 'sentence';
+ },
+ ...c.hook,
+ }),
+ );
+
+ assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
+ assert.equal(calls.notesInfo, 1);
+ assert.equal(calls.updateNoteFields, 1);
});
-
- const created = await service.createSentenceCard('テスト', 0, 1);
-
- assert.equal(created, true);
- assert.equal(calls.notesInfo, 1);
- assert.equal(calls.updateNoteFields, 1);
-});
+}
test('CardCreationService uses stream-open-filename for remote media generation', async () => {
let reviewing = false;
@@ -295,8 +86,6 @@ test('CardCreationService uses stream-open-filename for remote media generation'
const imageTimes: number[] = [];
const audioPaths: string[] = [];
const imagePaths: string[] = [];
- const recordMediaPath = (mediaInput: MediaInput): string =>
- typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
const edlSource = [
@@ -305,120 +94,67 @@ test('CardCreationService uses stream-open-filename for remote media generation'
'!global_tags,title=test',
].join(';');
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- audio: 'SentenceAudio',
- image: 'Picture',
- },
- media: {
- generateAudio: true,
- generateImage: true,
- imageFormat: 'jpg',
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- reviewMediaTiming: async () =>
- reviewing
- ? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 }
- : { action: 'use-original' },
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
- currentSubText: '字幕',
- currentSubStart: 1,
- currentSubEnd: 2,
- currentTimePos: 1.5,
- currentAudioStreamIndex: 0,
- requestProperty: async (name: string) => {
- assert.equal(name, 'stream-open-filename');
- return edlSource;
- },
- }) as never,
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => [
- {
- noteId: 42,
- fields: {
- Sentence: { value: '' },
- SentenceAudio: { value: '' },
- Picture: { value: '' },
+ const service = new CardCreationService(
+ createCardCreationDeps({
+ getConfig: () =>
+ createAnkiConfig({
+ fields: { image: 'Picture' },
+ media: { generateAudio: true, generateImage: true, imageFormat: 'jpg' },
+ }),
+ reviewMediaTiming: async () =>
+ reviewing
+ ? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 }
+ : { action: 'use-original' },
+ getMpvClient: () =>
+ ({
+ currentVideoPath: YOUTUBE_URL,
+ currentSubText: '字幕',
+ currentSubStart: 1,
+ currentSubEnd: 2,
+ currentTimePos: 1.5,
+ currentAudioStreamIndex: 0,
+ requestProperty: async (name: string) => {
+ assert.equal(name, 'stream-open-filename');
+ return edlSource;
},
+ }) as never,
+ client: createFakeClient({
+ notesInfo: async () => [
+ {
+ noteId: 42,
+ fields: {
+ Sentence: { value: '' },
+ SentenceAudio: { value: '' },
+ Picture: { value: '' },
+ },
+ },
+ ],
+ findNotes: async () => [42],
+ }),
+ mediaGenerator: createFakeMediaGenerator({
+ generateAudio: async (path, start, end, padding) => {
+ audioRanges.push([start, end, padding ?? -1]);
+ audioPaths.push(mediaInputPath(path));
+ return Buffer.from('audio');
},
- ],
- updateNoteFields: async () => undefined,
- storeMediaFile: async () => undefined,
- findNotes: async () => [42],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async (path, start, end, padding) => {
- audioRanges.push([start, end, padding ?? -1]);
- audioPaths.push(recordMediaPath(path));
- return Buffer.from('audio');
- },
- generateScreenshot: async (path, timestamp) => {
- imageTimes.push(timestamp);
- imagePaths.push(recordMediaPath(path));
- return Buffer.from('image');
- },
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: (noteInfo, preferredName) => {
- if (!preferredName) return null;
- return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
- },
- resolveNoteFieldName: (noteInfo, preferredName) => {
- if (!preferredName) return null;
- return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
- },
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: () => undefined,
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
+ generateScreenshot: async (path, timestamp) => {
+ imageTimes.push(timestamp);
+ imagePaths.push(mediaInputPath(path));
+ return Buffer.from('image');
+ },
+ }),
}),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => undefined,
- });
+ );
- const created = await service.createSentenceCard('テスト', 0, 1);
-
- assert.equal(created, true);
+ assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]);
+
reviewing = true;
assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
assert.equal(imageTimes.at(-1), 3.125);
+
await service.markLastCardAsAudioCard();
assert.equal(imageTimes.length, 3);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
@@ -428,108 +164,48 @@ test('CardCreationService uses stream-open-filename for remote media generation'
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- audio: 'SentenceAudio',
- image: 'Picture',
- },
- media: {
- generateAudio: true,
- generateImage: false,
- imageFormat: 'jpg',
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
- currentSubText: '字幕',
- currentSubStart: 10,
- currentSubEnd: 12,
- currentTimePos: 11,
- currentAudioStreamIndex: 0,
- }) as never,
- getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
- shouldRequireRemoteMediaCache: () => true,
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => [
- {
- noteId: 42,
- fields: {
- Sentence: { value: '' },
- SentenceAudio: { value: '' },
- Picture: { value: '' },
+ const service = new CardCreationService(
+ createCardCreationDeps({
+ getConfig: () =>
+ createAnkiConfig({
+ fields: { image: 'Picture' },
+ media: { generateAudio: true, imageFormat: 'jpg' },
+ }),
+ getMpvClient: () =>
+ ({
+ currentVideoPath: YOUTUBE_URL,
+ currentSubText: '字幕',
+ currentSubStart: 10,
+ currentSubEnd: 12,
+ currentTimePos: 11,
+ currentAudioStreamIndex: 0,
+ }) as never,
+ getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
+ shouldRequireRemoteMediaCache: () => true,
+ client: createFakeClient({
+ notesInfo: async () => [
+ {
+ noteId: 42,
+ fields: {
+ Sentence: { value: '' },
+ SentenceAudio: { value: '' },
+ Picture: { value: '' },
+ },
},
+ ],
+ }),
+ mediaGenerator: createFakeMediaGenerator({
+ generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
+ audioCalls.push({ path: mediaInputPath(path), audioStreamIndex });
+ return Buffer.from('audio');
},
- ],
- updateNoteFields: async () => undefined,
- storeMediaFile: async () => undefined,
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
- audioCalls.push({ path: typeof path === 'string' ? path : path.path, audioStreamIndex });
- return Buffer.from('audio');
- },
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: (noteInfo, preferredName) => {
- if (!preferredName) return null;
- return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
- },
- resolveNoteFieldName: (noteInfo, preferredName) => {
- if (!preferredName) return null;
- return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
- },
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: () => undefined,
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
+ }),
}),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => undefined,
- });
+ );
- const created = await service.createSentenceCard('テスト', 10, 12);
-
- assert.equal(created, true);
+ assert.equal(await service.createSentenceCard('テスト', 10, 12), true);
assert.deepEqual(audioCalls, [
- {
- path: '/tmp/subminer-youtube-media-cache/media.mkv',
- audioStreamIndex: undefined,
- },
+ { path: '/tmp/subminer-youtube-media-cache/media.mkv', audioStreamIndex: undefined },
]);
});
@@ -551,125 +227,67 @@ test('CardCreationService queues YouTube media when required cache is not ready'
}> = [];
let streamRequests = 0;
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- audio: 'SentenceAudio',
- image: 'Picture',
- miscInfo: 'MiscInfo',
- },
- media: {
- generateAudio: true,
- generateImage: true,
- imageFormat: 'jpg',
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
- currentSubText: '字幕',
- currentSubStart: 10,
- currentSubEnd: 12,
- currentTimePos: 11,
- currentAudioStreamIndex: 2,
- requestProperty: async (name: string) => {
- if (name === 'volume') {
- return 35;
- }
- streamRequests += 1;
- return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
- },
- }) as never,
- getCachedMediaPath: async () => null,
- shouldRequireRemoteMediaCache: () => true,
- queuePendingYoutubeMediaUpdate: (job) => {
- queuedUpdates.push(job);
- },
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => [
- {
- noteId: 42,
- fields: {
- Sentence: { value: '' },
- SentenceAudio: { value: '' },
- Picture: { value: '' },
- MiscInfo: { value: '' },
+ const service = new CardCreationService(
+ createCardCreationDeps({
+ getConfig: () =>
+ createAnkiConfig({
+ fields: { image: 'Picture', miscInfo: 'MiscInfo' },
+ media: { generateAudio: true, generateImage: true, imageFormat: 'jpg' },
+ }),
+ getMpvClient: () =>
+ ({
+ currentVideoPath: YOUTUBE_URL,
+ currentSubText: '字幕',
+ currentSubStart: 10,
+ currentSubEnd: 12,
+ currentTimePos: 11,
+ currentAudioStreamIndex: 2,
+ requestProperty: async (name: string) => {
+ if (name === 'volume') return 35;
+ streamRequests += 1;
+ return 'https://rr1---sn.example.googlevideo.com/videoplayback?id=123';
},
+ }) as never,
+ getCachedMediaPath: async () => null,
+ shouldRequireRemoteMediaCache: () => true,
+ queuePendingYoutubeMediaUpdate: (job) => {
+ queuedUpdates.push(job);
+ },
+ client: createFakeClient({
+ notesInfo: async () => [
+ {
+ noteId: 42,
+ fields: {
+ Sentence: { value: '' },
+ SentenceAudio: { value: '' },
+ Picture: { value: '' },
+ MiscInfo: { value: '' },
+ },
+ },
+ ],
+ updateNoteFields: async (noteId, fields) => {
+ updates.push({ noteId, fields });
},
- ],
- updateNoteFields: async (noteId, fields) => {
- updates.push({ noteId, fields });
- },
- storeMediaFile: async () => undefined,
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async () => {
- mediaCalls.push('audio');
- return Buffer.from('audio');
- },
- generateScreenshot: async () => {
- mediaCalls.push('image');
- return Buffer.from('image');
- },
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: (noteInfo, preferredName) => {
- if (!preferredName) return null;
- return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
- },
- resolveNoteFieldName: (noteInfo, preferredName) => {
- if (!preferredName) return null;
- return Object.keys(noteInfo.fields).find((field) => field === preferredName) ?? null;
- },
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: () => undefined,
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
+ }),
+ mediaGenerator: createFakeMediaGenerator({
+ generateAudio: async () => {
+ mediaCalls.push('audio');
+ return Buffer.from('audio');
+ },
+ generateScreenshot: async () => {
+ mediaCalls.push('image');
+ return Buffer.from('image');
+ },
+ }),
}),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => undefined,
- });
+ );
- const created = await service.createSentenceCard('テスト', 10, 12);
-
- assert.equal(created, true);
+ assert.equal(await service.createSentenceCard('テスト', 10, 12), true);
assert.equal(streamRequests, 0);
assert.deepEqual(mediaCalls, []);
assert.deepEqual(queuedUpdates, [
{
- sourceUrl: 'https://www.youtube.com/watch?v=abc123',
+ sourceUrl: YOUTUBE_URL,
noteId: 42,
startTime: 10,
endTime: 12,
@@ -685,179 +303,48 @@ test('CardCreationService queues YouTube media when required cache is not ready'
assert.deepEqual(updates, []);
});
-test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
- const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
- const duplicateLookupExpressions: string[] = [];
+const DUPLICATE_TRACKING_CASES = [
+ {
+ name: 'tracks sorted, deduplicated pre-add duplicate note ids for kiku sentence cards',
+ sentence: '重複文',
+ lookupResult: [18, 7, 30, 7],
+ expected: [{ noteId: 42, duplicateNoteIds: [7, 18, 30] }],
+ },
+ {
+ name: 'does not track duplicate ids when pre-add lookup returns none',
+ sentence: '重複なし',
+ lookupResult: [],
+ expected: [],
+ },
+];
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- audio: 'SentenceAudio',
+for (const c of DUPLICATE_TRACKING_CASES) {
+ test(`CardCreationService ${c.name}`, async () => {
+ const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
+ const lookedUp: string[] = [];
+ const service = new CardCreationService(
+ createCardCreationDeps({
+ getConfig: () => createAnkiConfig({ fields: { word: 'Expression' } }),
+ getEffectiveSentenceCardConfig: () => ({
+ model: 'Sentence',
+ sentenceField: 'Sentence',
+ audioField: 'SentenceAudio',
+ lapisEnabled: false,
+ kikuEnabled: true,
+ fieldGroupingMode: 'manual',
+ }),
+ findDuplicateNoteIds: async (expression) => {
+ lookedUp.push(expression);
+ return c.lookupResult;
},
- media: {
- generateAudio: false,
- generateImage: false,
+ trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
+ trackedDuplicates.push({ noteId, duplicateNoteIds });
},
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: '/video.mp4',
- currentSubText: '字幕',
- currentSubStart: 1,
- currentSubEnd: 2,
- currentTimePos: 1.5,
- currentAudioStreamIndex: 0,
- }) as never,
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => [],
- updateNoteFields: async () => undefined,
- storeMediaFile: async () => undefined,
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async () => null,
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: () => null,
- resolveNoteFieldName: () => null,
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: () => undefined,
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: true,
- fieldGroupingMode: 'manual',
- }),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => undefined,
- findDuplicateNoteIds: async (expression) => {
- duplicateLookupExpressions.push(expression);
- return [18, 7, 30, 7];
- },
- trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
- trackedDuplicates.push({ noteId, duplicateNoteIds });
- },
+ }),
+ );
+
+ assert.equal(await service.createSentenceCard(c.sentence, 0, 1), true);
+ assert.deepEqual(lookedUp, [c.sentence]);
+ assert.deepEqual(trackedDuplicates, c.expected);
});
-
- const created = await service.createSentenceCard('重複文', 0, 1);
-
- assert.equal(created, true);
- assert.deepEqual(duplicateLookupExpressions, ['重複文']);
- assert.deepEqual(trackedDuplicates, [{ noteId: 42, duplicateNoteIds: [7, 18, 30] }]);
-});
-
-test('CardCreationService does not track duplicate ids when pre-add lookup returns none', async () => {
- const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
-
- const service = new CardCreationService({
- getConfig: () =>
- ({
- deck: 'Mining',
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- audio: 'SentenceAudio',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- behavior: {},
- ai: false,
- }) as AnkiConnectConfig,
- getAiConfig: () => ({}),
- getTimingTracker: () => ({}) as never,
- getMpvClient: () =>
- ({
- currentVideoPath: '/video.mp4',
- currentSubText: '字幕',
- currentSubStart: 1,
- currentSubEnd: 2,
- currentTimePos: 1.5,
- currentAudioStreamIndex: 0,
- }) as never,
- client: {
- addNote: async () => 42,
- addTags: async () => undefined,
- notesInfo: async () => [],
- updateNoteFields: async () => undefined,
- storeMediaFile: async () => undefined,
- findNotes: async () => [],
- retrieveMediaFile: async () => '',
- deleteNotes: async () => undefined,
- },
- mediaGenerator: {
- generateAudio: async () => null,
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- },
- showOsdNotification: () => undefined,
- showUpdateResult: () => undefined,
- showStatusNotification: () => undefined,
- showNotification: async () => undefined,
- beginUpdateProgress: () => undefined,
- endUpdateProgress: () => undefined,
- withUpdateProgress: async (_message, action) => action(),
- resolveConfiguredFieldName: () => null,
- resolveNoteFieldName: () => null,
- getAnimatedImageLeadInSeconds: async () => 0,
- extractFields: () => ({}),
- processSentence: (sentence) => sentence,
- setCardTypeFields: () => undefined,
- mergeFieldValue: (_existing, newValue) => newValue,
- formatMiscInfoPattern: () => '',
- getEffectiveSentenceCardConfig: () => ({
- model: 'Sentence',
- sentenceField: 'Sentence',
- audioField: 'SentenceAudio',
- lapisEnabled: false,
- kikuEnabled: true,
- fieldGroupingMode: 'manual',
- }),
- getFallbackDurationSeconds: () => 10,
- appendKnownWordsFromNoteInfo: () => undefined,
- removeKnownWordNote: () => undefined,
- isUpdateInProgress: () => false,
- setUpdateInProgress: () => undefined,
- trackLastAddedNoteId: () => undefined,
- findDuplicateNoteIds: async () => [],
- trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
- trackedDuplicates.push({ noteId, duplicateNoteIds });
- },
- });
-
- const created = await service.createSentenceCard('重複なし', 0, 1);
-
- assert.equal(created, true);
- assert.deepEqual(trackedDuplicates, []);
-});
+}
diff --git a/src/anki-integration/field-grouping-merge.test.ts b/src/anki-integration/field-grouping-merge.test.ts
index 92c04146..2e4768db 100644
--- a/src/anki-integration/field-grouping-merge.test.ts
+++ b/src/anki-integration/field-grouping-merge.test.ts
@@ -500,3 +500,38 @@ test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty
assert.equal(merged.SentenceFurigana, 'duplicate furigana');
});
+
+test('computeFieldGroupingMergedFields keeps independent groups for identical sentence, audio, and image values', async () => {
+ const { collaborator } = createCollaborator();
+
+ const merged = await collaborator.computeFieldGroupingMergedFields(
+ 202,
+ 101,
+ makeNote(202, {
+ Sentence: 'same sentence',
+ SentenceAudio: '[sound:same.mp3]',
+ Picture: '
',
+ ExpressionAudio: '[sound:same.mp3]',
+ }),
+ makeNote(101, {
+ Sentence: 'same sentence',
+ SentenceAudio: '[sound:same.mp3]',
+ Picture: '
',
+ }),
+ false,
+ );
+
+ assert.equal(
+ merged.Sentence,
+ 'same sentencesame sentence',
+ );
+ assert.equal(
+ merged.SentenceAudio,
+ '[sound:same.mp3][sound:same.mp3]',
+ );
+ assert.equal(
+ merged.Picture,
+ '
',
+ );
+ assert.equal('ExpressionAudio' in merged, false);
+});
diff --git a/src/anki-integration/known-word-cache-maturity.test.ts b/src/anki-integration/known-word-cache-maturity.test.ts
index 37d881b7..99411e79 100644
--- a/src/anki-integration/known-word-cache-maturity.test.ts
+++ b/src/anki-integration/known-word-cache-maturity.test.ts
@@ -127,18 +127,6 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
);
});
-test('a cache built under the old tier rules is invalidated', () => {
- const config: AnkiConnectConfig = {
- knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
- };
- // v1 rules put lapsed cards in young because the interval queries did not
- // exclude is:learn; those persisted tiers must not be served under v2.
- assert.notEqual(
- getKnownWordCacheLifecycleConfig(config),
- '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
- );
-});
-
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts
index 0e078e3a..3af34dd2 100644
--- a/src/anki-integration/note-update-workflow.test.ts
+++ b/src/anki-integration/note-update-workflow.test.ts
@@ -245,127 +245,6 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
});
});
-test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
- const harness = createWorkflowHarness();
- harness.deps.getEffectiveSentenceCardConfig = () => ({
- sentenceField: 'Sentence',
- lapisEnabled: false,
- kikuEnabled: true,
- fieldGroupingMode: 'manual',
- wordCardKind: 'click',
- });
- harness.deps.client.notesInfo = async () =>
- [
- {
- noteId: 42,
- fields: {
- Expression: { value: 'taberu' },
- Sentence: { value: '' },
- IsWordAndSentenceCard: { value: 'x' },
- IsClickCard: { value: '' },
- IsSentenceCard: { value: '' },
- IsAudioCard: { value: '' },
- },
- },
- ] satisfies NoteUpdateWorkflowNoteInfo[];
-
- await harness.workflow.execute(42);
-
- assert.equal(harness.updates.length, 1);
- assert.deepEqual(harness.updates[0]?.fields, {
- Sentence: 'subtitle-text',
- IsClickCard: 'x',
- IsWordAndSentenceCard: '',
- IsSentenceCard: '',
- IsAudioCard: '',
- });
-});
-
-test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
- const harness = createWorkflowHarness();
- harness.deps.getEffectiveSentenceCardConfig = () => ({
- sentenceField: 'Sentence',
- lapisEnabled: false,
- kikuEnabled: true,
- fieldGroupingMode: 'manual',
- wordCardKind: 'none',
- });
- harness.deps.client.notesInfo = async () =>
- [
- {
- noteId: 42,
- fields: {
- Expression: { value: 'taberu' },
- Sentence: { value: '' },
- IsWordAndSentenceCard: { value: '' },
- IsSentenceCard: { value: '' },
- IsAudioCard: { value: '' },
- },
- },
- ] satisfies NoteUpdateWorkflowNoteInfo[];
-
- await harness.workflow.execute(42);
-
- assert.equal(harness.updates.length, 1);
- assert.deepEqual(harness.updates[0]?.fields, {
- Sentence: 'subtitle-text',
- });
-});
-
-test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
- const harness = createWorkflowHarness();
- harness.deps.client.notesInfo = async () =>
- [
- {
- noteId: 42,
- fields: {
- Expression: { value: 'taberu' },
- Sentence: { value: '' },
- IsWordAndSentenceCard: { value: '' },
- IsSentenceCard: { value: '' },
- IsAudioCard: { value: '' },
- },
- },
- ] satisfies NoteUpdateWorkflowNoteInfo[];
-
- await harness.workflow.execute(42);
-
- assert.equal(harness.updates.length, 1);
- assert.deepEqual(harness.updates[0]?.fields, {
- Sentence: 'subtitle-text',
- });
-});
-
-test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
- const harness = createWorkflowHarness();
- harness.deps.getEffectiveSentenceCardConfig = () => ({
- sentenceField: 'Sentence',
- lapisEnabled: true,
- kikuEnabled: false,
- fieldGroupingMode: 'disabled',
- });
- harness.deps.client.notesInfo = async () =>
- [
- {
- noteId: 42,
- fields: {
- Expression: { value: 'sentence expression' },
- Sentence: { value: '' },
- IsWordAndSentenceCard: { value: '' },
- IsSentenceCard: { value: 'x' },
- IsAudioCard: { value: '' },
- },
- },
- ] satisfies NoteUpdateWorkflowNoteInfo[];
-
- await harness.workflow.execute(42);
-
- assert.equal(harness.updates.length, 1);
- assert.deepEqual(harness.updates[0]?.fields, {
- Sentence: 'subtitle-text',
- });
-});
-
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [];
diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts
deleted file mode 100644
index db4895c1..00000000
--- a/src/ci-workflow.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import { resolve } from 'node:path';
-
-const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
-const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
-const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
-const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
-const packageJsonPath = resolve(__dirname, '../package.json');
-const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
- scripts: Record;
-};
-
-test('package scripts expose a sharded maintained source coverage lane with lcov output', () => {
- assert.equal(
- packageJson.scripts['test:coverage:src'],
- 'bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src',
- );
-});
-
-test('source and coverage scripts discover the same maintained source lane', () => {
- const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
- const coverageLane = packageJson.scripts['test:coverage:src']?.match(
- /run-coverage-lane\.ts\s+([^\s]+)/,
- )?.[1];
-
- assert.equal(sourceLane, 'bun-src-full');
- assert.equal(coverageLane, sourceLane);
-});
-
-test('environment suite owns launcher smoke execution', () => {
- assert.match(
- packageJson.scripts['test:env'] ?? '',
- /^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
- );
-});
-
-test('ci delegates its gate instead of duplicating quality steps', () => {
- assert.match(
- ciWorkflow,
- /build-test-audit:\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
- );
- assert.doesNotMatch(ciWorkflow, /oven-sh\/setup-bun/);
- assert.doesNotMatch(ciWorkflow, /bun run test:coverage:src/);
- assert.doesNotMatch(ciWorkflow, /bun run test:env/);
-});
-
-test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
- assert.match(docsPagesWorkflow, /name: Docs Pages/);
- assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/);
- assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/);
- assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
- assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
- assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
- assert.match(docsPagesWorkflow, /pages deploy \.\.\/\.tmp\/docs-versioned-site/);
- assert.match(docsPagesWorkflow, /--branch main/);
-});
-
-test('docs deploy syncs frozen archives to R2 and ships the archive Pages Function', () => {
- assert.doesNotMatch(docsPagesWorkflow, /actions\/cache@/);
- assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_ACCESS_KEY_ID/);
- assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY/);
- assert.match(docsPagesWorkflow, /--require-archives/);
- assert.match(docsPagesWorkflow, /--rebuild-archives=\$\{REBUILD_ARCHIVES\}/);
- assert.match(docsPagesWorkflow, /workingDirectory: docs-site/);
-});
-
-test('docs deploy skips invalid release tags without failing the workflow', () => {
- assert.match(docsPagesWorkflow, /id:\s*tag_guard/);
- assert.match(docsPagesWorkflow, /stable_tag=false/);
- assert.doesNotMatch(docsPagesWorkflow, /exit 78/);
- assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/);
-});
diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts
deleted file mode 100644
index 36b1a9a8..00000000
--- a/src/cli/help.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import { printHelp } from './help';
-
-test('printHelp includes configured texthooker port', () => {
- const original = console.log;
- let output = '';
- console.log = (value?: unknown) => {
- output += String(value);
- };
-
- try {
- printHelp(7777);
- } finally {
- console.log = original;
- }
-
- assert.match(output, /--help\s+Show this help/);
- assert.match(output, /default: 7777/);
- assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
- assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
- assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
- assert.doesNotMatch(output, /--refresh-known-words/);
- assert.match(output, /--setup\s+Open first-run setup window/);
- assert.match(output, /--settings\s+Open SubMiner settings window/);
- assert.match(output, /--yomitan\s+Open Yomitan settings window/);
- assert.match(output, /--hachidori\s+Open Hachidori settings window/);
- assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
- assert.match(output, /--anilist-status/);
- assert.match(output, /--anilist-retry-queue/);
- assert.match(output, /--dictionary/);
- assert.match(output, /--dictionary-target/);
- assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
- assert.match(output, /--jellyfin-login/);
- assert.match(output, /--jellyfin-subtitles/);
- assert.match(output, /--jellyfin-play/);
-});
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
index 115f32fc..1b1c383c 100644
--- a/src/config/config.test.ts
+++ b/src/config/config.test.ts
@@ -4,12 +4,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ConfigService, ConfigStartupParseError } from './service';
-import {
- DEFAULT_CONFIG,
- DEFAULT_KEYBINDINGS,
- RUNTIME_OPTION_REGISTRY,
- deepMergeRawConfig,
-} from './definitions';
+import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, deepMergeRawConfig } from './definitions';
import { parseConfigContent } from './parse';
import { generateConfigTemplate } from './template';
import {
@@ -19,11 +14,6 @@ import {
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
-const DEFAULT_SUBTITLE_FONT_FAMILY =
- 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
-const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
-const DEFAULT_SUBTITLE_TEXT_SHADOW =
- '-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function makeTempDir(): string {
@@ -51,122 +41,10 @@ function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record {
- const dir = makeTempDir();
- const service = new ConfigService(dir);
- const config = service.getConfig();
- assert.equal(config.websocket.enabled, false);
- assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
- assert.equal(config.annotationWebsocket.enabled, false);
- assert.equal(config.annotationWebsocket.port, DEFAULT_CONFIG.annotationWebsocket.port);
- assert.equal(config.texthooker.launchAtStartup, false);
- assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
- assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
- assert.equal(config.ankiConnect.media.audioPadding, 0);
- assert.equal(config.anilist.enabled, false);
- assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false);
- assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
- assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
- assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
- assert.equal(config.anilist.characterDictionary.profileScope, 'all');
- assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
- assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
- assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
- assert.equal(config.yomitan.externalProfilePath, '');
- assert.equal(config.jellyfin.remoteControlEnabled, true);
- assert.equal(config.jellyfin.remoteControlAutoConnect, true);
- assert.equal(config.jellyfin.autoAnnounce, false);
- assert.equal('clientName' in config.jellyfin, false);
- assert.equal('remoteControlDeviceName' in config.jellyfin, false);
- assert.equal('deviceId' in config.jellyfin, false);
- assert.equal('clientVersion' in config.jellyfin, false);
- assert.equal(config.youtube.mediaCache.mode, 'direct');
- assert.equal(config.youtube.mediaCache.maxHeight, 720);
- assert.equal(config.ai.enabled, false);
- assert.equal(config.ai.apiKeyCommand, '');
- assert.equal(config.texthooker.openBrowser, false);
- assert.equal(config.controller.enabled, false);
- assert.equal(config.ankiConnect.enabled, true);
- assert.deepEqual(config.ankiConnect.ai, {
- enabled: false,
- model: '',
- systemPrompt: '',
- });
- assert.equal(config.ankiConnect.media.normalizeAudio, true);
- assert.equal(config.ankiConnect.media.mirrorMpvVolume, true);
- assert.equal(config.startupWarmups.lowPowerMode, false);
- assert.equal(config.startupWarmups.mecab, true);
- assert.equal(config.startupWarmups.yomitanExtension, true);
- assert.equal(config.startupWarmups.subtitleDictionaries, true);
- assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
- assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
- assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
- assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
- assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N');
- assert.equal(config.discordPresence.enabled, true);
- assert.equal(config.discordPresence.updateIntervalMs, 3_000);
- assert.equal(config.subtitleStyle.backgroundColor, 'transparent');
- assert.equal(config.subtitleStyle.primaryDefaultMode, 'visible');
- assert.equal(config.subtitleStyle.preserveLineBreaks, false);
- assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
- assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
- assert.equal(config.subtitleStyle.primaryVisibleOnYomitanPopup, true);
- assert.equal(config.subtitleSidebar.enabled, true);
- assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
- assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
- assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
- assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
- assert.equal(config.subtitleStyle.fontWeight, '600');
- assert.equal(config.subtitleStyle.lineHeight, 1.35);
- assert.equal(config.subtitleStyle.letterSpacing, '-0.01em');
- assert.equal(config.subtitleStyle.wordSpacing, 0);
- assert.equal(config.subtitleStyle.fontKerning, 'normal');
- assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
- assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
- assert.equal(config.subtitleStyle.paintOrder, '');
- assert.equal(config.subtitleStyle.WebkitTextStroke, '');
- assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
- assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
- assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
- assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
- assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
- assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
- assert.equal(config.subtitleStyle.secondary.paintOrder, '');
- assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
- assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
- assert.deepEqual(config.subtitleSidebar.css, {});
- assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
- assert.equal(config.immersionTracking.enabled, true);
- assert.equal(config.immersionTracking.dbPath, '');
- assert.equal(config.immersionTracking.batchSize, 25);
- assert.equal(config.immersionTracking.flushIntervalMs, 500);
- assert.equal(config.immersionTracking.queueCap, 1000);
- assert.equal(config.immersionTracking.payloadCapBytes, 256);
- assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
- assert.equal(config.immersionTracking.retention.eventsDays, 0);
- assert.equal(config.immersionTracking.retention.telemetryDays, 0);
- assert.equal(config.immersionTracking.retention.sessionsDays, 0);
- assert.equal(config.immersionTracking.retention.dailyRollupsDays, 0);
- assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 0);
- assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 0);
- assert.equal(config.immersionTracking.retentionMode, 'preset');
- assert.equal(config.immersionTracking.retentionPreset, 'balanced');
- assert.equal(config.immersionTracking.lifetimeSummaries?.global, true);
- assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
- assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
- assert.equal(config.stats.autoOpenBrowser, false);
- assert.equal(config.updates.enabled, true);
- assert.equal(config.updates.checkIntervalHours, 24);
- assert.equal(config.updates.notificationType, 'overlay');
- assert.equal(config.updates.channel, 'stable');
- assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
- assert.equal(config.mpv.backend, 'auto');
- assert.equal(config.mpv.profile, '');
- assert.equal(config.mpv.autoStartSubMiner, true);
- assert.equal(config.mpv.pauseUntilOverlayReady, true);
- assert.equal(config.mpv.subminerBinaryPath, '');
- assert.equal(config.mpv.aniskipEnabled, true);
- assert.equal(config.mpv.aniskipButtonKey, 'TAB');
+test('loads defaults without warnings when config is missing', () => {
+ const service = new ConfigService(makeTempDir());
+ assert.deepEqual(service.getConfig(), DEFAULT_CONFIG);
+ assert.deepEqual(service.getWarnings(), []);
});
test('rejects invalid mpv volume mirroring values', () => {
@@ -375,44 +253,6 @@ test('resolves legacy subtitle appearance options without rewriting config on lo
});
});
-test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
- const validDir = makeTempDir();
- fs.writeFileSync(
- path.join(validDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "preserveLineBreaks": true
- }
- }`,
- 'utf-8',
- );
-
- const validService = new ConfigService(validDir);
- assert.equal(validService.getConfig().subtitleStyle.preserveLineBreaks, true);
-
- const invalidDir = makeTempDir();
- fs.writeFileSync(
- path.join(invalidDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "preserveLineBreaks": "yes"
- }
- }`,
- 'utf-8',
- );
-
- const invalidService = new ConfigService(invalidDir);
- assert.equal(
- invalidService.getConfig().subtitleStyle.preserveLineBreaks,
- DEFAULT_CONFIG.subtitleStyle.preserveLineBreaks,
- );
- assert.ok(
- invalidService
- .getWarnings()
- .some((warning) => warning.path === 'subtitleStyle.preserveLineBreaks'),
- );
-});
-
test('parses texthooker.launchAtStartup and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -564,120 +404,6 @@ test('parses annotationWebsocket settings and warns on invalid values', () => {
);
});
-test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
- const validDir = makeTempDir();
- fs.writeFileSync(
- path.join(validDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "autoPauseVideoOnHover": true
- }
- }`,
- 'utf-8',
- );
-
- const validService = new ConfigService(validDir);
- assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnHover, true);
-
- const invalidDir = makeTempDir();
- fs.writeFileSync(
- path.join(invalidDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "autoPauseVideoOnHover": "yes"
- }
- }`,
- 'utf-8',
- );
-
- const invalidService = new ConfigService(invalidDir);
- assert.equal(
- invalidService.getConfig().subtitleStyle.autoPauseVideoOnHover,
- DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnHover,
- );
- assert.ok(
- invalidService
- .getWarnings()
- .some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnHover'),
- );
-});
-
-test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid values', () => {
- const validDir = makeTempDir();
- fs.writeFileSync(
- path.join(validDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "autoPauseVideoOnYomitanPopup": true
- }
- }`,
- 'utf-8',
- );
-
- const validService = new ConfigService(validDir);
- assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, true);
-
- const invalidDir = makeTempDir();
- fs.writeFileSync(
- path.join(invalidDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "autoPauseVideoOnYomitanPopup": "yes"
- }
- }`,
- 'utf-8',
- );
-
- const invalidService = new ConfigService(invalidDir);
- assert.equal(
- invalidService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup,
- DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnYomitanPopup,
- );
- assert.ok(
- invalidService
- .getWarnings()
- .some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup'),
- );
-});
-
-test('parses subtitleStyle.primaryVisibleOnYomitanPopup and warns on invalid values', () => {
- const validDir = makeTempDir();
- fs.writeFileSync(
- path.join(validDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "primaryVisibleOnYomitanPopup": false
- }
- }`,
- 'utf-8',
- );
-
- const validService = new ConfigService(validDir);
- assert.equal(validService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, false);
-
- const invalidDir = makeTempDir();
- fs.writeFileSync(
- path.join(invalidDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "primaryVisibleOnYomitanPopup": "yes"
- }
- }`,
- 'utf-8',
- );
-
- const invalidService = new ConfigService(invalidDir);
- assert.equal(
- invalidService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup,
- DEFAULT_CONFIG.subtitleStyle.primaryVisibleOnYomitanPopup,
- );
- assert.ok(
- invalidService
- .getWarnings()
- .some((warning) => warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup'),
- );
-});
-
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -716,47 +442,6 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
);
});
-test('parses subtitleStyle.nameMatchColor and warns on invalid values', () => {
- const validDir = makeTempDir();
- fs.writeFileSync(
- path.join(validDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "nameMatchColor": "#eed49f"
- }
- }`,
- 'utf-8',
- );
-
- const validService = new ConfigService(validDir);
- assert.equal(
- ((validService.getConfig().subtitleStyle as unknown as Record)
- .nameMatchColor ?? null) as string | null,
- '#eed49f',
- );
-
- const invalidDir = makeTempDir();
- fs.writeFileSync(
- path.join(invalidDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "nameMatchColor": "pink"
- }
- }`,
- 'utf-8',
- );
-
- const invalidService = new ConfigService(invalidDir);
- assert.equal(
- ((invalidService.getConfig().subtitleStyle as unknown as Record)
- .nameMatchColor ?? null) as string | null,
- '#f5bde6',
- );
- assert.ok(
- invalidService.getWarnings().some((warning) => warning.path === 'subtitleStyle.nameMatchColor'),
- );
-});
-
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -835,82 +520,6 @@ test('parses subtitleStyle.hoverTokenBackgroundColor null as invalid instead of
);
});
-test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
- const validDir = makeTempDir();
- fs.writeFileSync(
- path.join(validDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "nameMatchEnabled": false
- }
- }`,
- 'utf-8',
- );
-
- const validService = new ConfigService(validDir);
- assert.equal(validService.getConfig().subtitleStyle.nameMatchEnabled, false);
-
- const invalidDir = makeTempDir();
- fs.writeFileSync(
- path.join(invalidDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "nameMatchEnabled": "no"
- }
- }`,
- 'utf-8',
- );
-
- const invalidService = new ConfigService(invalidDir);
- assert.equal(
- invalidService.getConfig().subtitleStyle.nameMatchEnabled,
- DEFAULT_CONFIG.subtitleStyle.nameMatchEnabled,
- );
- assert.ok(
- invalidService
- .getWarnings()
- .some((warning) => warning.path === 'subtitleStyle.nameMatchEnabled'),
- );
-});
-
-test('parses subtitleStyle.nameMatchImagesEnabled and warns on invalid values', () => {
- const validDir = makeTempDir();
- fs.writeFileSync(
- path.join(validDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "nameMatchImagesEnabled": true
- }
- }`,
- 'utf-8',
- );
-
- const validService = new ConfigService(validDir);
- assert.equal(validService.getConfig().subtitleStyle.nameMatchImagesEnabled, true);
-
- const invalidDir = makeTempDir();
- fs.writeFileSync(
- path.join(invalidDir, 'config.jsonc'),
- `{
- "subtitleStyle": {
- "nameMatchImagesEnabled": "yes"
- }
- }`,
- 'utf-8',
- );
-
- const invalidService = new ConfigService(invalidDir);
- assert.equal(
- invalidService.getConfig().subtitleStyle.nameMatchImagesEnabled,
- DEFAULT_CONFIG.subtitleStyle.nameMatchImagesEnabled,
- );
- assert.ok(
- invalidService
- .getWarnings()
- .some((warning) => warning.path === 'subtitleStyle.nameMatchImagesEnabled'),
- );
-});
-
test('parses anilist.enabled and warns for invalid value', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -934,77 +543,6 @@ test('parses anilist.enabled and warns for invalid value', () => {
assert.equal(service.getConfig().anilist.enabled, true);
});
-test('parses anilist.characterDictionary config with clamping and enum validation', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "anilist": {
- "characterDictionary": {
- "enabled": true,
- "refreshTtlHours": 0,
- "maxLoaded": 1000,
- "evictionPolicy": "remove",
- "profileScope": "everywhere"
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
- assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
- assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
- assert.equal(config.anilist.characterDictionary.profileScope, 'all');
- assert.ok(
- warnings.some((warning) => warning.path === 'anilist.characterDictionary.refreshTtlHours'),
- );
- assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.maxLoaded'));
- assert.ok(
- warnings.some((warning) => warning.path === 'anilist.characterDictionary.evictionPolicy'),
- );
- assert.ok(
- warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'),
- );
-});
-
-test('parses anilist.characterDictionary.collapsibleSections booleans and warns on invalid values', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "anilist": {
- "characterDictionary": {
- "collapsibleSections": {
- "description": true,
- "characterInformation": "yes",
- "voicedBy": true
- }
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.equal(config.anilist.characterDictionary.collapsibleSections.description, true);
- assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
- assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, true);
- assert.ok(
- warnings.some(
- (warning) =>
- warning.path === 'anilist.characterDictionary.collapsibleSections.characterInformation',
- ),
- );
-});
-
test('parses jellyfin remote control fields and ignores legacy identity fields', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1155,31 +693,6 @@ test('invalid startup warmup values warn and keep defaults', () => {
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.jellyfinRemoteSession'));
});
-test('parses discordPresence fields and warns for invalid types', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "discordPresence": {
- "enabled": true,
- "updateIntervalMs": 3000,
- "debounceMs": 250
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- assert.equal(config.discordPresence.enabled, true);
- assert.equal(config.discordPresence.updateIntervalMs, 3000);
- assert.equal(config.discordPresence.debounceMs, 250);
-
- service.patchRawConfig({ discordPresence: { enabled: 'yes' as never } });
- assert.equal(service.getConfig().discordPresence.enabled, DEFAULT_CONFIG.discordPresence.enabled);
- assert.ok(service.getWarnings().some((warning) => warning.path === 'discordPresence.enabled'));
-});
-
test('accepts immersion tracking config values', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1477,17 +990,6 @@ test('reloadConfigStrict parse failure does not mutate raw config or warnings',
assert.deepEqual(service.getWarnings(), beforeWarnings);
});
-test('SM-012 config paths do not use JSON serialize-clone helpers', () => {
- const definitionsSource = fs.readFileSync(
- path.join(process.cwd(), 'src/config/definitions.ts'),
- 'utf-8',
- );
- const serviceSource = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
-
- assert.equal(definitionsSource.includes('JSON.parse(JSON.stringify('), false);
- assert.equal(serviceSource.includes('JSON.parse(JSON.stringify('), false);
-});
-
test('getRawConfig returns a detached clone', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1574,149 +1076,65 @@ test('warning emission order is deterministic across reloads', () => {
);
});
-test('accepts valid logging.level', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "logging": {
- "level": "warn"
- }
- }`,
- 'utf-8',
- );
+const loggingCases: Array<{
+ name: string;
+ logging: Record;
+ expected: Partial;
+ warningPath?: string;
+}> = [
+ { name: 'accepts valid logging.level', logging: { level: 'warn' }, expected: { level: 'warn' } },
+ { name: 'accepts valid logging.rotation', logging: { rotation: 14 }, expected: { rotation: 14 } },
+ {
+ name: 'accepts valid logging file toggles',
+ logging: { files: { app: false, launcher: true, mpv: true } },
+ expected: { files: { app: false, launcher: true, mpv: true } },
+ },
+ {
+ name: 'falls back for invalid logging.level and reports warning',
+ logging: { level: 'trace' },
+ expected: { level: DEFAULT_CONFIG.logging.level },
+ warningPath: 'logging.level',
+ },
+ {
+ name: 'falls back for invalid logging.rotation and reports warning',
+ logging: { rotation: 0 },
+ expected: { rotation: DEFAULT_CONFIG.logging.rotation },
+ warningPath: 'logging.rotation',
+ },
+ {
+ name: 'falls back for invalid logging file toggles and reports warning',
+ logging: { files: { mpv: 'yes' } },
+ expected: { files: DEFAULT_CONFIG.logging.files },
+ warningPath: 'logging.files.mpv',
+ },
+ {
+ name: 'falls back for invalid logging files object and reports warning',
+ logging: { files: false },
+ expected: { files: DEFAULT_CONFIG.logging.files },
+ warningPath: 'logging.files',
+ },
+];
- const service = new ConfigService(dir);
- const config = service.getConfig();
+for (const c of loggingCases) {
+ test(c.name, () => {
+ const dir = makeTempDir();
+ fs.writeFileSync(
+ path.join(dir, 'config.jsonc'),
+ JSON.stringify({ logging: c.logging }),
+ 'utf-8',
+ );
+ const service = new ConfigService(dir);
+ const logging = service.getConfig().logging;
- assert.equal(config.logging.level, 'warn');
-});
-
-test('accepts valid logging.rotation', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "logging": {
- "rotation": 14
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
-
- assert.equal(config.logging.rotation, 14);
-});
-
-test('accepts valid logging file toggles', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "logging": {
- "files": {
- "app": false,
- "launcher": true,
- "mpv": true
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
-
- assert.deepEqual(config.logging.files, {
- app: false,
- launcher: true,
- mpv: true,
+ for (const [key, value] of Object.entries(c.expected)) {
+ assert.deepEqual(logging[key as keyof typeof logging], value, key);
+ }
+ assert.deepEqual(
+ service.getWarnings().map((warning) => warning.path),
+ c.warningPath ? [c.warningPath] : [],
+ );
});
-});
-
-test('falls back for invalid logging.level and reports warning', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "logging": {
- "level": "trace"
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level);
- assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
-});
-
-test('falls back for invalid logging.rotation and reports warning', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "logging": {
- "rotation": 0
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.equal(config.logging.rotation, DEFAULT_CONFIG.logging.rotation);
- assert.ok(warnings.some((warning) => warning.path === 'logging.rotation'));
-});
-
-test('falls back for invalid logging file toggles and reports warning', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "logging": {
- "files": {
- "mpv": "yes"
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.equal(config.logging.files.mpv, DEFAULT_CONFIG.logging.files.mpv);
- assert.ok(warnings.some((warning) => warning.path === 'logging.files.mpv'));
-});
-
-test('falls back for invalid logging files object and reports warning', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "logging": {
- "files": false
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.deepEqual(config.logging.files, DEFAULT_CONFIG.logging.files);
- assert.ok(warnings.some((warning) => warning.path === 'logging.files'));
-});
+}
test('warns and ignores unknown top-level config keys', () => {
const dir = makeTempDir();
@@ -2193,91 +1611,20 @@ test('controller button index config rejects fractional values', () => {
);
});
-test('runtime options registry is centralized', () => {
- const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
- assert.deepEqual(ids, [
- 'anki.autoUpdateNewCards',
- 'anki.mediaReviewTiming',
- 'subtitle.annotation.knownWords.highlightEnabled',
- 'subtitle.annotation.knownWords.maturityEnabled',
- 'subtitle.annotation.nPlusOne',
- 'subtitle.annotation.jlpt',
- 'subtitle.annotation.frequency',
- 'anki.nPlusOneMatchMode',
- 'anki.kikuFieldGrouping',
- 'anki.senrenFieldGrouping',
- ]);
-});
-
-test('validates ankiConnect knownWords behavior values', () => {
+test('invalid ankiConnect knownWords, n+1, and tags values warn and fall back', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
+ "tags": ["SubMiner", 123],
"knownWords": {
"highlightEnabled": "yes",
"refreshMinutes": -5,
- "addMinedWordsImmediately": "no"
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.equal(
- config.ankiConnect.knownWords.highlightEnabled,
- DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
- );
- assert.equal(
- config.ankiConnect.knownWords.refreshMinutes,
- DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
- );
- assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
- assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.refreshMinutes'));
- assert.equal(
- config.ankiConnect.knownWords.addMinedWordsImmediately,
- DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately,
- );
- assert.ok(
- warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'),
- );
-});
-
-test('accepts valid ankiConnect knownWords behavior values', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "knownWords": {
- "highlightEnabled": true,
- "refreshMinutes": 120,
- "addMinedWordsImmediately": false
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
-
- assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
- assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120);
- assert.equal(config.ankiConnect.knownWords.addMinedWordsImmediately, false);
-});
-
-test('validates ankiConnect n+1 minimum sentence word count', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
+ "addMinedWordsImmediately": "no",
+ "matchMode": "bad-mode",
+ "decks": "not-an-array"
+ },
"nPlusOne": {
"minSentenceWords": 0
}
@@ -2287,22 +1634,43 @@ test('validates ankiConnect n+1 minimum sentence word count', () => {
);
const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
+ const { knownWords, nPlusOne, tags } = service.getConfig().ankiConnect;
+ const defaults = DEFAULT_CONFIG.ankiConnect;
- assert.equal(
- config.ankiConnect.nPlusOne.minSentenceWords,
- DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
- );
- assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords'));
+ assert.equal(knownWords.highlightEnabled, defaults.knownWords.highlightEnabled);
+ assert.equal(knownWords.refreshMinutes, defaults.knownWords.refreshMinutes);
+ assert.equal(knownWords.addMinedWordsImmediately, defaults.knownWords.addMinedWordsImmediately);
+ assert.equal(knownWords.matchMode, defaults.knownWords.matchMode);
+ assert.deepEqual(knownWords.decks, {});
+ assert.equal(nPlusOne.minSentenceWords, defaults.nPlusOne.minSentenceWords);
+ assert.deepEqual(tags, ['SubMiner']);
+
+ const warnedPaths = service.getWarnings().map((warning) => warning.path);
+ for (const expected of [
+ 'ankiConnect.tags',
+ 'ankiConnect.knownWords.highlightEnabled',
+ 'ankiConnect.knownWords.refreshMinutes',
+ 'ankiConnect.knownWords.addMinedWordsImmediately',
+ 'ankiConnect.knownWords.matchMode',
+ 'ankiConnect.knownWords.decks',
+ 'ankiConnect.nPlusOne.minSentenceWords',
+ ]) {
+ assert.ok(warnedPaths.includes(expected), expected);
+ }
});
-test('accepts valid ankiConnect n+1 minimum sentence word count', () => {
+test('accepts valid ankiConnect knownWords and n+1 values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
+ "knownWords": {
+ "highlightEnabled": true,
+ "refreshMinutes": 120,
+ "addMinedWordsImmediately": false,
+ "matchMode": "surface"
+ },
"nPlusOne": {
"minSentenceWords": 4
}
@@ -2312,54 +1680,14 @@ test('accepts valid ankiConnect n+1 minimum sentence word count', () => {
);
const service = new ConfigService(dir);
- const config = service.getConfig();
+ const { knownWords, nPlusOne } = service.getConfig().ankiConnect;
- assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4);
-});
-
-test('validates ankiConnect knownWords match mode values', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "knownWords": {
- "matchMode": "bad-mode"
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.equal(
- config.ankiConnect.knownWords.matchMode,
- DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
- );
- assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matchMode'));
-});
-
-test('accepts valid ankiConnect knownWords match mode values', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "knownWords": {
- "matchMode": "surface"
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
-
- assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
+ assert.equal(knownWords.highlightEnabled, true);
+ assert.equal(knownWords.refreshMinutes, 120);
+ assert.equal(knownWords.addMinedWordsImmediately, false);
+ assert.equal(knownWords.matchMode, 'surface');
+ assert.equal(nPlusOne.minSentenceWords, 4);
+ assert.deepEqual(service.getWarnings(), []);
});
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
@@ -2826,235 +2154,6 @@ test('forces Senren off when Kiku is also enabled and validates Senren fieldGrou
);
});
-test('warns and falls back when isSenren.enabled is not boolean', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "isSenren": { "enabled": "true" }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
-
- assert.equal(
- service.getConfig().ankiConnect.isSenren.enabled,
- DEFAULT_CONFIG.ankiConnect.isSenren.enabled,
- );
- assert.ok(
- service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'),
- );
-});
-
-test('accepts valid ankiConnect knownWords deck object', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "knownWords": {
- "decks": { "Deck One": ["Word", "Reading"], "Deck Two": ["Expression"] }
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
-
- assert.deepEqual(config.ankiConnect.knownWords.decks, {
- 'Deck One': ['Word', 'Reading'],
- 'Deck Two': ['Expression'],
- });
-});
-
-test('accepts valid ankiConnect tags list', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "tags": ["SubMiner", "Mining"]
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
-
- assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']);
-});
-
-test('falls back to default when ankiConnect tags list is invalid', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "tags": ["SubMiner", 123]
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
- assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags'));
-});
-
-test('falls back to default when ankiConnect knownWords deck list is invalid', () => {
- const dir = makeTempDir();
- fs.writeFileSync(
- path.join(dir, 'config.jsonc'),
- `{
- "ankiConnect": {
- "knownWords": {
- "decks": "not-an-array"
- }
- }
- }`,
- 'utf-8',
- );
-
- const service = new ConfigService(dir);
- const config = service.getConfig();
- const warnings = service.getWarnings();
-
- assert.deepEqual(config.ankiConnect.knownWords.decks, {});
- assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'));
-});
-
-test('template generator includes known keys', () => {
- const output = generateConfigTemplate(DEFAULT_CONFIG);
- assert.match(output, /"ai":/);
- assert.match(output, /"ankiConnect":/);
- assert.match(output, /"controller":/);
- assert.match(output, /"logging":/);
- assert.match(output, /"websocket":/);
- assert.match(output, /"discordPresence":/);
- assert.match(output, /"startupWarmups":/);
- assert.match(output, /"updates":/);
- assert.match(output, /"youtube":/);
- assert.doesNotMatch(output, /"deviceId":/);
- assert.doesNotMatch(output, /"clientVersion":/);
- assert.doesNotMatch(output, /"youtubeSubgen":/);
- assert.match(output, /"characterDictionary":\s*\{/);
- assert.doesNotMatch(output, /"characterDictionary":\s*\{\s*"enabled":/);
- assert.match(output, /"preserveLineBreaks": false/);
- assert.match(output, /"knownWords"\s*:\s*\{/);
- assert.match(output, /"knownWordColor": "#a6da95"/);
- assert.match(output, /"nPlusOneColor": "#c6a0f6"/);
- assert.match(output, /"nPlusOne"\s*:\s*\{/);
- assert.match(output, /"minSentenceWords": 3/);
- assert.match(output, /auto-generated from src\/config\/definitions.ts/);
- assert.match(
- output,
- /"level": "warn",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
- );
- assert.match(
- output,
- /"enabled": false,? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
- );
- assert.match(
- output,
- /"enabled": false,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
- );
- assert.match(
- output,
- /"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
- );
- assert.match(
- output,
- /"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
- );
- assert.match(
- output,
- /"preferredGamepadId": "",? \/\/ Preferred controller id saved from the controller config modal\./,
- );
- assert.match(
- output,
- /"toggleLookup": \{\s*"kind": "button"[\s\S]*\},? \/\/ Controller binding descriptor for toggling lookup\. Use Alt\+C learn mode or set a raw button\/axis descriptor manually\./,
- );
- assert.match(
- output,
- /"kind": "button",? \/\/ Discrete binding input source kind\. When kind is "axis", set both axisIndex and direction\. Values: none \| button \| axis/,
- );
- assert.match(output, /"toggleLookup": \{\s*"kind": "button"/);
- assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/);
- assert.match(
- output,
- /"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
- );
- assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
- assert.match(
- output,
- /"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/,
- );
- assert.match(
- output,
- /"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
- );
- assert.match(
- output,
- /"autoPauseVideoOnYomitanPopup": true,? \/\/ Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes\. Values: true \| false/,
- );
- assert.match(
- output,
- /"enabled": true,? \/\/ Enable the subtitle sidebar feature for parsed subtitle sources\. Values: true \| false/,
- );
- assert.match(
- output,
- /"enabled": true,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
- );
- assert.match(
- output,
- /"enabled": false,? \/\/ Enable AI provider usage for Anki translation\/enrichment flows\. Values: true \| false/,
- );
- assert.match(
- output,
- /"model": "",? \/\/ Optional model override for Anki AI translation\/enrichment flows\./,
- );
- assert.match(
- output,
- /"enabled": false,? \/\/ Enable shared OpenAI-compatible AI provider features\. Values: true \| false/,
- );
- assert.match(
- output,
- /"enabled": true,? \/\/ Enable optional Discord Rich Presence updates\. Values: true \| false/,
- );
- assert.match(
- output,
- /"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/,
- );
- assert.match(
- output,
- /"notificationType": "overlay",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
- );
- assert.match(
- output,
- /"channel": "stable",? \/\/ Release channel used for update checks\. Values: stable \| prerelease/,
- );
- assert.match(
- output,
- /"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
- );
- assert.doesNotMatch(output, /"mode": "automatic"/);
- assert.doesNotMatch(output, /"fixWithAi": false/);
- assert.doesNotMatch(output, /"whisperThreads": 4/);
- assert.match(
- output,
- /"launchAtStartup": false,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
- );
-});
-
test('template generator uses settings CSS declaration paths for appearance fields', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output);
diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts
index 791017bb..f96bff6b 100644
--- a/src/config/definitions/domain-registry.test.ts
+++ b/src/config/definitions/domain-registry.test.ts
@@ -2,17 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { ResolvedConfig } from '../../types/config';
-import {
- CONFIG_OPTION_REGISTRY,
- CONFIG_TEMPLATE_SECTIONS,
- DEFAULT_CONFIG,
- DEFAULT_KEYBINDINGS,
- RUNTIME_OPTION_REGISTRY,
-} from '../definitions';
-import { buildCoreConfigOptionRegistry } from './options-core';
-import { buildImmersionConfigOptionRegistry } from './options-immersion';
-import { buildIntegrationConfigOptionRegistry } from './options-integrations';
-import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
+import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG } from '../definitions';
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
const leaves: string[] = [];
@@ -87,58 +77,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet = new Set([
'subtitleStyle.wordSpacing',
]);
-test('config option registry includes critical paths and has unique entries', () => {
- const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
-
- for (const requiredPath of [
- 'logging.level',
- 'logging.files.mpv',
- 'annotationWebsocket.enabled',
- 'controller.enabled',
- 'controller.scrollPixelsPerSecond',
- 'startupWarmups.lowPowerMode',
- 'updates.channel',
- 'youtube.primarySubLanguages',
- 'subtitleStyle.enableJlpt',
- 'subtitleStyle.autoPauseVideoOnYomitanPopup',
- 'ankiConnect.enabled',
- 'subtitleStyle.nameMatchEnabled',
- 'ankiConnect.media.normalizeAudio',
- 'ankiConnect.media.mirrorMpvVolume',
- 'anilist.characterDictionary.collapsibleSections.description',
- 'mpv.executablePath',
- 'mpv.launchMode',
- 'mpv.profile',
- 'mpv.socketPath',
- 'mpv.backend',
- 'mpv.autoStartSubMiner',
- 'mpv.pauseUntilOverlayReady',
- 'mpv.subminerBinaryPath',
- 'mpv.aniskipEnabled',
- 'mpv.aniskipButtonKey',
- 'yomitan.externalProfilePath',
- 'immersionTracking.enabled',
- ]) {
- assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
- }
-
- assert.equal(new Set(paths).size, paths.length);
-});
-
-test('known-word annotation color has one public config path', () => {
- const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
-
- assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
- assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
-});
-
-test('n+1 annotation color has one public config path', () => {
- const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
-
- assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
- assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
-});
-
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
@@ -168,77 +106,10 @@ test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEA
);
});
-test('config template sections include expected domains and unique keys', () => {
+test('config option paths and template section keys are unique', () => {
+ const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
+ assert.equal(new Set(paths).size, paths.length);
+
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
- const requiredKeys: (typeof keys)[number][] = [
- 'websocket',
- 'annotationWebsocket',
- 'controller',
- 'startupWarmups',
- 'youtube',
- 'subtitleStyle',
- 'ankiConnect',
- 'yomitan',
- 'mpv',
- 'immersionTracking',
- ];
-
- for (const requiredKey of requiredKeys) {
- assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`);
- }
-
assert.equal(new Set(keys).size, keys.length);
});
-
-test('domain registry builders each contribute entries to composed registry', () => {
- const domainEntries = [
- buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
- buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
- buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
- buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
- ];
- const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
-
- for (const entries of domainEntries) {
- assert.ok(entries.length > 0);
- assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
- }
-});
-
-test('default keybindings include primary and secondary subtitle track cycling on J keys', () => {
- const keybindingMap = new Map(
- DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
- );
- assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
- assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
- assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
- assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
-});
-
-test('default keybindings include fullscreen on F', () => {
- const keybindingMap = new Map(
- DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
- );
- assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']);
-});
-
-test('default keybindings include replay and next subtitle controls', () => {
- const keybindingMap = new Map(
- DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
- );
- assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
- assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
-});
-
-test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
- const keybindingMap = new Map(
- DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
- );
- assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
- assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
- assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
- assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
- assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
- assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
- assert.equal(keybindingMap.has('Shift+BracketRight'), false);
-});
diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts
index 4bed800e..dd98d0c4 100644
--- a/src/config/settings/registry.test.ts
+++ b/src/config/settings/registry.test.ts
@@ -11,209 +11,6 @@ function field(path: string) {
return match;
}
-test('settings registry splits viewing into appearance and behavior categories', () => {
- assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
- assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
- assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
- assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior');
- assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior');
- assert.equal(field('secondarySub.defaultMode').category, 'behavior');
- assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
- assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
- assert.equal(field('auto_start_overlay').category, 'behavior');
- assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
- assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
- assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
- assert.equal(field('mpv.launchMode').category, 'behavior');
- assert.equal(field('mpv.launchMode').section, 'mpv Playback');
- assert.equal(field('mpv.profile').category, 'behavior');
- assert.equal(field('mpv.profile').section, 'mpv Playback');
- assert.ok(
- fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
- fields.findIndex(
- (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
- ),
- );
- assert.ok(
- fields.findIndex(
- (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
- ) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
- );
-});
-
-test('settings registry groups playback startup controls under playback behavior', () => {
- for (const path of [
- 'subtitleStyle.autoPauseVideoOnHover',
- 'subtitleStyle.autoPauseVideoOnYomitanPopup',
- 'subtitleSidebar.pauseVideoOnHover',
- 'mpv.autoStartSubMiner',
- 'auto_start_overlay',
- 'mpv.pauseUntilOverlayReady',
- ]) {
- assert.equal(field(path).category, 'behavior', path);
- assert.equal(field(path).section, 'Playback Behavior', path);
- }
-});
-
-test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
- assert.equal(field('mpv.aniskipButtonKey').category, 'input');
- assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
- assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
- assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
- assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
-});
-
-test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
- assert.equal(
- field('shortcuts.openCharacterDictionaryManager').label,
- 'Open Character Dictionary Manager',
- );
- assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels');
-});
-
-test('settings registry hides removed modal-only fields', () => {
- for (const path of [
- 'shortcuts.multiCopyTimeoutMs',
- 'anilist.characterDictionary.profileScope',
- 'jellyfin.directPlayContainers',
- ]) {
- assert.equal(
- fields.some((candidate) => candidate.configPath === path),
- false,
- path,
- );
- }
-});
-
-test('settings registry orders websocket server immediately after annotation websocket', () => {
- const integrationSections = [
- ...new Set(
- fields
- .filter((candidate) => candidate.category === 'integrations')
- .map((candidate) => candidate.section),
- ),
- ];
- const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
- assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
-});
-
-test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
- assert.equal(field('websocket.enabled').defaultValue, false);
- assert.equal(
- field('websocket.enabled').description,
- 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
- );
-});
-
-test('settings registry places immersion tracking after other tracking and app sections', () => {
- const trackingSections = [
- ...new Set(
- fields
- .filter((candidate) => candidate.category === 'tracking-app')
- .map((candidate) => candidate.section),
- ),
- ];
- assert.equal(trackingSections.at(-1), 'Immersion tracking');
-});
-
-test('settings registry groups annotation display fields by config group', () => {
- assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
- assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
- assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
- assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
- assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
- assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
-});
-
-test('settings registry groups maturity settings under Known Words with color controls', () => {
- assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words');
- assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words');
- for (const tier of ['new', 'learning', 'young', 'mature']) {
- const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`);
- assert.equal(tierField.subsection, 'Known Words');
- assert.equal(tierField.control, 'color');
- }
-});
-
-test('settings registry orders maturity colors from least mature to mature', () => {
- const knownWordsPaths = fields
- .filter((candidate) => candidate.subsection === 'Known Words')
- .map((candidate) => candidate.configPath);
- assert.deepEqual(knownWordsPaths, [
- 'ankiConnect.knownWords.highlightEnabled',
- 'ankiConnect.knownWords.maturityEnabled',
- 'subtitleStyle.knownWordColor',
- 'ankiConnect.knownWords.matureThresholdDays',
- 'subtitleStyle.knownWordMaturityColors.new',
- 'subtitleStyle.knownWordMaturityColors.learning',
- 'subtitleStyle.knownWordMaturityColors.young',
- 'subtitleStyle.knownWordMaturityColors.mature',
- ]);
-});
-
-test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
- assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
- assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
- assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
- assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
- assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
- assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
- assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
- assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
- assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
- assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
- assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
- assert.equal(
- field('subtitleStyle.frequencyDictionary.sourcePath').section,
- 'Frequency Highlighting',
- );
- assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
- assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
- assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
-});
-
-test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
- assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
-});
-
-test('settings registry exposes specialized controls for config-assisted inputs', () => {
- assert.equal(field('ankiConnect.deck').control, 'anki-deck');
- assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
- assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
- assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
- assert.equal(field('keybindings').control, 'mpv-keybindings');
- assert.equal(field('subtitleStyle.css').control, 'css-declarations');
- assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
- assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
- assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
- assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
- assert.equal(field('stats.toggleKey').control, 'key-code');
- assert.equal(field('discordPresence.presenceStyle').control, 'select');
-});
-
-test('settings registry exposes mpv volume mirroring as a mining toggle', () => {
- const volumeField = field('ankiConnect.media.mirrorMpvVolume');
-
- assert.equal(volumeField.category, 'mining-anki');
- assert.equal(volumeField.section, 'Media Capture');
- assert.equal(volumeField.control, 'boolean');
- assert.equal(volumeField.defaultValue, true);
-});
-
-test('settings registry exposes YouTube media cache mode as a labeled select', () => {
- const mediaCacheMode = field('youtube.mediaCache.mode');
- const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
-
- assert.equal(mediaCacheMode.control, 'select');
- assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
- assert.deepEqual(mediaCacheMode.enumLabels, {
- direct: 'Direct stream extraction',
- background: 'Background media cache',
- });
- assert.equal(mediaCacheMaxHeight.control, 'number');
- assert.equal(mediaCacheMaxHeight.defaultValue, 720);
-});
-
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
const primaryVisible = fields
.filter(
@@ -268,18 +65,6 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
});
-test('settings registry routes playback-related integrations into integrations', () => {
- assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
- assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
- assert.equal(field('subsync.replace').category, 'integrations');
- assert.equal(field('subsync.replace').section, 'Subtitle Sync');
- assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
- assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
- assert.equal(field('tmdb.apiKey').category, 'integrations');
- assert.equal(field('tmdb.apiKey').section, 'TMDB');
- assert.equal(field('tmdb.apiKey').secret, true);
-});
-
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
@@ -309,89 +94,3 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
);
});
-
-test('settings registry hides app-managed and inactive config surfaces', () => {
- const paths = new Set(fields.map((candidate) => candidate.configPath));
- for (const hiddenPath of [
- 'ai.enabled',
- 'ai.apiKey',
- 'ai.apiKeyCommand',
- 'ai.model',
- 'ai.baseUrl',
- 'ai.systemPrompt',
- 'ai.requestTimeoutMs',
- 'ankiConnect.ai.enabled',
- 'ankiConnect.ai.model',
- 'ankiConnect.ai.systemPrompt',
- 'ankiConnect.fields.translation',
- 'controller.bindings',
- 'controller.preferredGamepadId',
- 'controller.preferredGamepadLabel',
- 'controller.profiles',
- 'jellyfin.defaultLibraryId',
- 'subtitleSidebar.toggleKey',
- 'jellyfin.recentServers',
- ]) {
- assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
- }
- assert.equal(paths.has('anilist.characterDictionary.enabled'), false);
-});
-
-test('settings registry marks safe live config paths as hot-reloadable', () => {
- for (const path of [
- 'mpv.aniskipButtonKey',
- 'stats.toggleKey',
- 'stats.markWatchedKey',
- 'logging.level',
- 'logging.rotation',
- 'logging.files.app',
- 'logging.files.launcher',
- 'logging.files.mpv',
- 'youtube.primarySubLanguages',
- 'jimaku.apiBaseUrl',
- 'jimaku.languagePreference',
- 'jimaku.maxEntryResults',
- 'subsync.replace',
- 'ankiConnect.behavior.autoUpdateNewCards',
- 'ankiConnect.deck',
- 'ankiConnect.media.normalizeAudio',
- 'ankiConnect.media.mirrorMpvVolume',
- 'ankiConnect.media.reviewTiming',
- 'ankiConnect.knownWords.highlightEnabled',
- 'ankiConnect.knownWords.refreshMinutes',
- 'ankiConnect.knownWords.addMinedWordsImmediately',
- 'ankiConnect.knownWords.matchMode',
- 'ankiConnect.knownWords.decks',
- 'ankiConnect.nPlusOne.enabled',
- 'ankiConnect.nPlusOne.minSentenceWords',
- 'ankiConnect.fields.word',
- 'ankiConnect.fields.audio',
- 'ankiConnect.fields.wordAudio',
- 'ankiConnect.fields.image',
- 'ankiConnect.fields.sentence',
- 'ankiConnect.fields.miscInfo',
- 'ankiConnect.isLapis.sentenceCardModel',
- 'ankiConnect.isKiku.fieldGrouping',
- 'ankiConnect.isSenren.fieldGrouping',
- ]) {
- assert.equal(field(path).restartBehavior, 'hot-reload', path);
- }
-});
-
-test('settings registry does not expose removed subsync mode option', () => {
- const paths = new Set(fields.map((candidate) => candidate.configPath));
- assert.equal(paths.has('subsync.defaultMode'), false);
-});
-
-test('settings registry keeps unsafe config siblings restart-required', () => {
- for (const path of [
- 'stats.serverPort',
- 'ankiConnect.url',
- 'ankiConnect.proxy.enabled',
- 'mpv.socketPath',
- 'mpv.profile',
- 'websocket.port',
- ]) {
- assert.equal(field(path).restartBehavior, 'restart', path);
- }
-});
diff --git a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts
index f9449b74..7daa228b 100644
--- a/src/core/services/__tests__/immersion-tracker-write-queue.test.ts
+++ b/src/core/services/__tests__/immersion-tracker-write-queue.test.ts
@@ -5,30 +5,6 @@ import os from 'node:os';
import path from 'node:path';
import type { DatabaseSync } from '../immersion-tracker/sqlite';
-type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
-type ImmersionTrackerServiceCtor =
- typeof import('../immersion-tracker-service').ImmersionTrackerService;
-
-let trackerCtor: ImmersionTrackerServiceCtor | null = null;
-
-async function loadTrackerCtor(): Promise {
- if (trackerCtor) return trackerCtor;
- const mod = await import('../immersion-tracker-service');
- trackerCtor = mod.ImmersionTrackerService;
- return trackerCtor;
-}
-
-function makeDbPath(): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
- return path.join(dir, 'immersion.sqlite');
-}
-
-function cleanupDbPath(dbPath: string): void {
- const dir = path.dirname(dbPath);
- if (!fs.existsSync(dir)) return;
- fs.rmSync(dir, { recursive: true, force: true });
-}
-
interface TrackerInternals {
db: DatabaseSync;
queue: unknown[];
@@ -42,155 +18,38 @@ interface TrackerInternals {
writeLock: { locked: boolean };
}
-test('delete maintenance fails closed when queued writes cannot drain', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
+interface TrackerHarness {
+ tracker: TrackerInternals;
+ deleteRunnerCalls: () => number;
+}
+
+/** Runs `fn` against a fresh tracker (batchSize 2) seeded with two library entries. */
+async function withTracker(fn: (harness: TrackerHarness) => Promise): Promise {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
+ const { ImmersionTrackerService } = await import('../immersion-tracker-service');
let deleteRunnerCalls = 0;
-
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor(
- { dbPath, policy: { batchSize: 2 } },
- {
- runDeleteMaintenanceTask: async () => {
- deleteRunnerCalls += 1;
- },
+ const service = new ImmersionTrackerService(
+ { dbPath: path.join(dir, 'immersion.sqlite'), policy: { batchSize: 2 } },
+ {
+ runDeleteMaintenanceTask: async () => {
+ deleteRunnerCalls += 1;
},
- );
- const internals = tracker as unknown as TrackerInternals;
- seedTwoEntries(internals.db);
- queueSubtitleLines(internals, 1);
- let flushCalls = 0;
- internals.flushNow = () => {
- flushCalls += 1;
- if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
- };
-
- await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
-
- assert.equal(flushCalls, 1);
- assert.equal(deleteRunnerCalls, 0);
- assert.equal(internals.writeLock.locked, false);
- } finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
-
-test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
-
+ },
+ );
try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
- const internals = tracker as unknown as TrackerInternals;
- seedTwoEntries(internals.db);
- internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
- queueSubtitleLines(internals, 1);
- internals.flushNow = () => {};
-
- await assert.rejects(
- internals.reassignAnimeAnilist(1, { anilistId: 123 }),
- /queue did not drain/i,
- );
-
- assert.deepEqual(
- internals.db
- .prepare(
- 'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
- )
- .all(),
- [
- { animeId: 1, anilistId: null },
- { animeId: 2, anilistId: 123 },
- ],
- );
+ const tracker = service as unknown as TrackerInternals;
+ seedTwoEntries(tracker.db);
+ await fn({ tracker, deleteRunnerCalls: () => deleteRunnerCalls });
} finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
+ service.destroy();
+ fs.rmSync(dir, { recursive: true, force: true });
}
-});
-
-test('mergeAnime fails closed when queued writes cannot drain', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
-
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
- const internals = tracker as unknown as TrackerInternals;
- seedTwoEntries(internals.db);
- queueSubtitleLines(internals, 1);
- internals.flushNow = () => {};
-
- await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
-
- assert.deepEqual(
- internals.db
- .prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
- .all()
- .map((row) => (row as { animeId: number }).animeId),
- [1, 2],
- );
- } finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
-
-test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
-
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
- const internals = tracker as unknown as TrackerInternals;
- seedTwoEntries(internals.db);
- queueSubtitleLines(internals, 1);
- internals.flushNow = () => {};
-
- await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
- assert.equal(
- (
- internals.db
- .prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
- .get() as {
- animeId: number;
- }
- ).animeId,
- 2,
- );
- } finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
-
-test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
-
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
- const internals = tracker as unknown as TrackerInternals;
- seedTwoEntries(internals.db);
- queueSubtitleLines(internals, 1);
- internals.flushNow = () => {};
-
- await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
- } finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
+}
function seedTwoEntries(db: DatabaseSync): void {
db.exec(`
- INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
- VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
+ INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
+ VALUES (1, 'show', 'Show', NULL, 1000, 1000), (2, 'show season 1', 'Show Season 1', 123, 1000, 1000);
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
@@ -244,25 +103,53 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
});
}
-/** The queued telemetry sample only exists in the database once the queue drained fully. */
-function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
- const row = db
- .prepare(
- `SELECT lines_seen AS linesSeen
- FROM imm_session_telemetry
- WHERE session_id = ?
- ORDER BY sample_ms DESC, telemetry_id DESC
- LIMIT 1`,
- )
- .get(sessionId) as { linesSeen: number } | undefined;
- return row ? Number(row.linesSeen) : null;
+const SNAPSHOT_TABLES = [
+ 'imm_anime',
+ 'imm_videos',
+ 'imm_sessions',
+ 'imm_subtitle_lines',
+ 'imm_session_telemetry',
+ 'imm_lifetime_global',
+ 'imm_lifetime_anime',
+ 'imm_lifetime_media',
+ 'imm_lifetime_applied_sessions',
+];
+
+function snapshotDb(db: DatabaseSync): Record {
+ return Object.fromEntries(
+ SNAPSHOT_TABLES.map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY 1`).all()]),
+ );
}
-function countLinesForAnime(db: DatabaseSync, animeId: number): number {
- const row = db
- .prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
- .get(animeId) as { total: number };
- return Number(row.total);
+const guardedOperations: Array<{
+ name: string;
+ run: (tracker: TrackerInternals) => Promise;
+}> = [
+ { name: 'deleteSession', run: (tracker) => tracker.deleteSession(1) },
+ { name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
+ { name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
+ { name: 'rebuildLifetimeSummaries', run: (tracker) => tracker.rebuildLifetimeSummaries() },
+ {
+ // Anime 2 already owns AniList id 123, so this would otherwise resolve a conflict.
+ name: 'reassignAnimeAnilist',
+ run: (tracker) => tracker.reassignAnimeAnilist(1, { anilistId: 123 }),
+ },
+];
+
+for (const operation of guardedOperations) {
+ test(`${operation.name} fails closed without touching the database when queued writes cannot drain`, async () => {
+ await withTracker(async ({ tracker, deleteRunnerCalls }) => {
+ queueSubtitleLines(tracker, 1);
+ tracker.flushNow = () => {};
+ const before = snapshotDb(tracker.db);
+
+ await assert.rejects(operation.run(tracker), /queue did not drain/i);
+
+ assert.deepEqual(snapshotDb(tracker.db), before);
+ assert.equal(deleteRunnerCalls(), 0);
+ assert.equal(tracker.writeLock.locked, false);
+ });
+ });
}
/**
@@ -271,52 +158,36 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number {
* queue, so anything past `batchSize` would still be unwritten when the merge
* repoints rows.
*/
-test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
+const repointingOperations: Array<{
+ name: string;
+ run: (tracker: TrackerInternals) => Promise;
+}> = [
+ { name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
+ { name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
+];
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
- const internals = tracker as unknown as TrackerInternals;
+for (const operation of repointingOperations) {
+ test(`${operation.name} drains a queue larger than one batch before repointing rows`, async () => {
+ await withTracker(async ({ tracker }) => {
+ queueSubtitleLines(tracker, 8);
+ queueTelemetry(tracker, 8);
+ assert.ok(tracker.queue.length > 2, 'expected more queued writes than one batch');
- seedTwoEntries(internals.db);
- queueSubtitleLines(internals, 8);
- queueTelemetry(internals, 8);
- assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
+ await operation.run(tracker);
- await internals.mergeAnime(1, [2]);
-
- assert.equal(internals.queue.length, 0);
- // Every queued line landed, attributed to the surviving entry.
- assert.equal(countLinesForAnime(internals.db, 1), 8);
- assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
- } finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
-
-test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
-
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
- const internals = tracker as unknown as TrackerInternals;
-
- seedTwoEntries(internals.db);
- queueSubtitleLines(internals, 8);
- queueTelemetry(internals, 8);
-
- await internals.moveVideoToAnime(2, 1);
-
- assert.equal(internals.queue.length, 0);
- assert.equal(countLinesForAnime(internals.db, 1), 8);
- assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
- } finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
+ assert.equal(tracker.queue.length, 0);
+ // Every queued line and the trailing telemetry sample landed on the surviving entry.
+ const lines = tracker.db
+ .prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1')
+ .get() as { total: number };
+ assert.equal(Number(lines.total), 8);
+ const telemetry = tracker.db
+ .prepare(
+ `SELECT lines_seen AS linesSeen FROM imm_session_telemetry
+ WHERE session_id = 1 ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1`,
+ )
+ .get() as { linesSeen: number } | undefined;
+ assert.equal(Number(telemetry?.linesSeen), 8);
+ });
+ });
+}
diff --git a/src/core/services/__tests__/stats-server-route-groups.test.ts b/src/core/services/__tests__/stats-server-route-groups.test.ts
deleted file mode 100644
index 9980ce7c..00000000
--- a/src/core/services/__tests__/stats-server-route-groups.test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import assert from 'node:assert/strict';
-import { describe, test } from 'node:test';
-import {
- registerStatsAnalyticsRoutes,
- registerStatsIntegrationRoutes,
- registerStatsLibraryRoutes,
- registerStatsMiningRoutes,
- registerStatsStaticRoutes,
-} from '../stats-server/routes.js';
-
-describe('stats server route groups', () => {
- test('exposes focused registrars for createStatsApp composition', () => {
- for (const registrar of [
- registerStatsAnalyticsRoutes,
- registerStatsLibraryRoutes,
- registerStatsIntegrationRoutes,
- registerStatsMiningRoutes,
- registerStatsStaticRoutes,
- ]) {
- assert.equal(typeof registrar, 'function');
- }
- });
-});
diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts
index 768c1239..8fd8f66b 100644
--- a/src/core/services/__tests__/stats-server.test.ts
+++ b/src/core/services/__tests__/stats-server.test.ts
@@ -12,10 +12,7 @@ import {
} from '../stats-server.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import { INCOMPATIBLE_PROVIDER_MERGE_MESSAGE } from '../immersion-tracker/anime-merge.js';
-import {
- clearRetimedSecondarySubtitleCache,
- resolveRetimedSecondarySubtitleTextFromSidecar,
-} from '../secondary-subtitle-sidecar.js';
+import type { StatsServerMediaGenerator } from '../stats-server/mining-support.js';
const SESSION_SUMMARIES = [
{
@@ -262,6 +259,22 @@ const EPISODE_CARD_EVENTS = [
{ eventId: 1, sessionId: 1, tsMs: Date.now(), cardsDelta: 1, noteIds: [12345] },
];
+const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
+const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+function animeCoverArt(coverBlob: Buffer) {
+ return {
+ videoId: 1,
+ anilistId: 21858,
+ coverUrl: 'https://example.com/cover.jpg',
+ coverBlob,
+ titleRomaji: 'Little Witch Academia',
+ titleEnglish: 'Little Witch Academia',
+ episodesTotal: 25,
+ fetchedAtMs: Date.now(),
+ };
+}
+
function createMockTracker(
overrides: Partial = {},
): ImmersionTrackerService {
@@ -326,19 +339,7 @@ function createMockTracker(
{ epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 },
],
ensureAnimeCoverArt: async () => false,
- getAnimeCoverArt: async (animeId: number) =>
- animeId === 1
- ? {
- videoId: 1,
- anilistId: 21858,
- coverUrl: 'https://example.com/cover.jpg',
- coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
- titleRomaji: 'Little Witch Academia',
- titleEnglish: 'Little Witch Academia',
- episodesTotal: 25,
- fetchedAtMs: Date.now(),
- }
- : null,
+ getAnimeCoverArt: async (animeId: number) => (animeId === 1 ? animeCoverArt(JPEG_BYTES) : null),
getWordDetail: async (wordId: number) => (wordId === 1 ? WORD_DETAIL : null),
getWordAnimeAppearances: async () => WORD_ANIME_APPEARANCES,
getSimilarWords: async () => SIMILAR_WORDS,
@@ -383,7 +384,10 @@ type CapturedAnkiRequest = {
async function withFakeAnkiConnect(
fn: (requests: CapturedAnkiRequest[], url: string) => Promise,
- options?: { notesInfoFields?: Record | null },
+ options?: {
+ notesInfoFields?: Record | null;
+ onRequest?: (request: CapturedAnkiRequest) => void;
+ },
): Promise {
const requests: CapturedAnkiRequest[] = [];
const server = http.createServer((req, res) => {
@@ -392,6 +396,7 @@ async function withFakeAnkiConnect(
req.on('end', () => {
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as CapturedAnkiRequest;
requests.push(payload);
+ options?.onRequest?.(payload);
let body: unknown = { result: null, error: null };
if (payload.action === 'addNote') {
@@ -445,6 +450,281 @@ async function withFakeAnkiConnect(
}
}
+type StatsApp = ReturnType;
+type StatsAppOptions = NonNullable[1]>;
+type AnkiConfig = NonNullable;
+type MiningMode = 'word' | 'sentence' | 'audio';
+
+const JSON_HEADERS = { 'Content-Type': 'application/json' };
+
+function deferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+/** Swaps globalThis.fetch for the duration of `fn`. */
+async function withFetch(stub: typeof fetch, fn: () => Promise): Promise {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = stub;
+ try {
+ return await fn();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+}
+
+/** Mock tracker that records the name of every tracker method a route calls. */
+function createRecordingTracker(calls: string[]): ImmersionTrackerService {
+ return new Proxy(createMockTracker(), {
+ get(target, prop, receiver) {
+ const value = Reflect.get(target, prop, receiver);
+ if (typeof value !== 'function') return value;
+ return (...args: unknown[]) => {
+ calls.push(String(prop));
+ return value.apply(target, args);
+ };
+ },
+ });
+}
+
+type MiningEnv = {
+ dir: string;
+ sourcePath: string;
+ requests: CapturedAnkiRequest[];
+ ankiUrl: string;
+};
+
+/**
+ * Runs `fn` with a fake episode file (plus optional sidecar `files`) in a temp dir and a fake
+ * AnkiConnect server recording every request.
+ */
+async function withMiningEnv(
+ cfg: {
+ files?: Record;
+ notesInfoFields?: Record | null;
+ onAnkiRequest?: (request: CapturedAnkiRequest) => void;
+ },
+ fn: (env: MiningEnv) => Promise,
+): Promise {
+ await withTempDir(async (dir) => {
+ const sourcePath = path.join(dir, 'episode.mkv');
+ fs.writeFileSync(sourcePath, 'fake media');
+ for (const [name, content] of Object.entries(cfg.files ?? {})) {
+ fs.writeFileSync(path.join(dir, name), content);
+ }
+ await withFakeAnkiConnect((requests, ankiUrl) => fn({ dir, sourcePath, requests, ankiUrl }), {
+ notesInfoFields: cfg.notesInfoFields,
+ onRequest: cfg.onAnkiRequest,
+ });
+ });
+}
+
+const MINE_CARD_DEFAULTS = {
+ startMs: 1_000,
+ endMs: 2_000,
+ sentence: '猫を見た',
+ word: '猫',
+ videoTitle: 'Episode 1',
+};
+
+type MineCardRequest = typeof MINE_CARD_DEFAULTS & { sourcePath: string; secondaryText?: string };
+type MineCardResponse = { noteId?: number; errors?: string[]; error?: string };
+
+async function mineCard(
+ app: StatsApp,
+ mode: MiningMode,
+ overrides: Partial & { sourcePath: string },
+): Promise<{ status: number; body: MineCardResponse }> {
+ const res = await app.request(`/api/stats/mine-card?mode=${mode}`, {
+ method: 'POST',
+ headers: JSON_HEADERS,
+ body: JSON.stringify({ ...MINE_CARD_DEFAULTS, ...overrides }),
+ });
+ return { status: res.status, body: (await res.json()) as MineCardResponse };
+}
+
+const NO_MEDIA = { generateAudio: false, generateImage: false };
+const LAPIS = { isLapis: { enabled: true, sentenceCardModel: 'Lapis Morph' } };
+const SENTENCE_FIELDS = { word: 'Expression', sentence: 'Sentence', translation: 'SelectionText' };
+const AUDIO = Buffer.from('audio');
+const IMAGE = Buffer.from('image');
+
+function ankiConfig(env: MiningEnv, overrides: Partial = {}): AnkiConfig {
+ return { url: env.ankiUrl, deck: 'Mining', media: NO_MEDIA, ...overrides };
+}
+
+/** The note sent with the first AnkiConnect request of `action`, with fields defaulted. */
+function sentNote(requests: CapturedAnkiRequest[], action: 'addNote' | 'updateNoteFields') {
+ const note = requests.find((request) => request.action === action)?.params?.note;
+ return { ...note, fields: note?.fields ?? {} };
+}
+
+/** Media generator stub returning fixed outputs and recording audio ranges. */
+function fakeMedia(
+ outputs: { audio?: Buffer | null; image?: Buffer | null; animated?: Buffer | null } = {},
+) {
+ const audioCalls: Parameters[] = [];
+ let imageCalls = 0;
+ const create = (): StatsServerMediaGenerator => ({
+ generateAudio: async (...args) => {
+ audioCalls.push(args);
+ return outputs.audio ?? null;
+ },
+ generateScreenshot: async () => {
+ imageCalls += 1;
+ return outputs.image ?? null;
+ },
+ generateAnimatedImage: async () => {
+ imageCalls += 1;
+ return outputs.animated ?? null;
+ },
+ });
+ return { create, audioCalls, callCount: () => audioCalls.length + imageCalls };
+}
+
+const KNOWN_WORD_CACHES = [
+ {
+ name: 'filtered persisted totals',
+ cache: { version: 1, refreshedAtMs: 1, scope: 'deck:test', words: ['する'] },
+ knownWordsSeen: 2,
+ },
+ {
+ name: 'a v3 reading-aware cache',
+ cache: {
+ version: 3,
+ refreshedAtMs: 1,
+ scope: 'deck:test',
+ notes: {
+ '101': [{ word: 'する', reading: 'する' }],
+ '102': [{ word: '猫', reading: null }],
+ },
+ },
+ knownWordsSeen: 2,
+ },
+ {
+ name: 'a v4 maturity cache',
+ cache: {
+ version: 4,
+ refreshedAtMs: 1,
+ scope: 'deck:test',
+ notes: {
+ '101': [{ word: 'する', reading: 'する' }],
+ '102': [{ word: '猫', reading: null }],
+ },
+ tiers: { '101': 'mature', '102': 'young' },
+ },
+ knownWordsSeen: 2,
+ },
+ {
+ name: 'an unrecognized cache format (no known words)',
+ cache: { version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} },
+ knownWordsSeen: 0,
+ },
+];
+
+/** Query limits each route caps (or floors) before asking the tracker. */
+const CLAMPED_QUERIES: Array<{
+ path: string;
+ method: keyof ImmersionTrackerService;
+ expectedArgs: unknown[];
+}> = [
+ {
+ path: '/api/stats/streak-calendar?days=999999',
+ method: 'getStreakCalendar',
+ expectedArgs: [365],
+ },
+ {
+ path: '/api/stats/trends/episodes-per-day?limit=999999',
+ method: 'getEpisodesPerDay',
+ expectedArgs: [365],
+ },
+ {
+ path: '/api/stats/trends/new-anime-per-day?limit=999999',
+ method: 'getNewAnimePerDay',
+ expectedArgs: [365],
+ },
+ {
+ path: '/api/stats/trends/watch-time-per-anime?limit=999999',
+ method: 'getWatchTimePerAnime',
+ expectedArgs: [365],
+ },
+ {
+ path: '/api/stats/anime/1/words?limit=999999',
+ method: 'getAnimeWords',
+ expectedArgs: [1, 200],
+ },
+ {
+ path: '/api/stats/anime/1/rollups?limit=999999',
+ method: 'getAnimeDailyRollups',
+ expectedArgs: [1, 365],
+ },
+ {
+ path: '/api/stats/vocabulary?limit=999999',
+ method: 'getVocabularyStats',
+ expectedArgs: [500, undefined],
+ },
+ {
+ path: '/api/stats/vocabulary?limit=12.9',
+ method: 'getVocabularyStats',
+ expectedArgs: [12, undefined],
+ },
+];
+
+const TRENDS_DASHBOARD_QUERIES = [
+ { query: 'range=90d&groupBy=month', expectedArgs: ['90d', 'month', true] },
+ { query: 'range=weird&groupBy=year', expectedArgs: ['30d', 'day', true] },
+ { query: 'range=30d&groupBy=day&fillEmpty=false', expectedArgs: ['30d', 'day', false] },
+];
+
+const MALFORMED_IDS = [
+ '0',
+ '-1',
+ '1.9',
+ '1.0',
+ '1e2',
+ '9007199254740992',
+ '01',
+ '+1',
+ '0x1',
+ '1%0A',
+ '%201',
+];
+
+/** Every route that takes a positive resource id; `:id` is replaced per request. */
+const ID_ROUTES: Array<{
+ method: 'GET' | 'DELETE' | 'PATCH' | 'POST';
+ path: string;
+ body?: unknown;
+}> = [
+ { method: 'GET', path: '/api/stats/sessions/:id/timeline' },
+ { method: 'GET', path: '/api/stats/sessions/:id/events' },
+ { method: 'GET', path: '/api/stats/sessions/:id/known-words-timeline' },
+ { method: 'GET', path: '/api/stats/vocabulary/:id/detail' },
+ { method: 'GET', path: '/api/stats/kanji/:id/detail' },
+ { method: 'GET', path: '/api/stats/episode/:id/detail' },
+ { method: 'GET', path: '/api/stats/media/:id' },
+ { method: 'GET', path: '/api/stats/media/:id/cover' },
+ { method: 'GET', path: '/api/stats/media/:id/known-words-summary' },
+ { method: 'GET', path: '/api/stats/anime/:id' },
+ { method: 'GET', path: '/api/stats/anime/:id/words' },
+ { method: 'GET', path: '/api/stats/anime/:id/rollups' },
+ { method: 'GET', path: '/api/stats/anime/:id/cover' },
+ { method: 'GET', path: '/api/stats/anime/:id/known-words-summary' },
+ { method: 'DELETE', path: '/api/stats/sessions/:id' },
+ { method: 'DELETE', path: '/api/stats/media/:id' },
+ { method: 'DELETE', path: '/api/stats/anime/:id' },
+ { method: 'DELETE', path: '/api/stats/anime/merge-recommendations/:id' },
+ { method: 'PATCH', path: '/api/stats/media/:id/watched', body: { watched: true } },
+ { method: 'PATCH', path: '/api/stats/media/:id/anime', body: { animeId: 7 } },
+ { method: 'PATCH', path: '/api/stats/anime/:id/anilist', body: { anilistId: 21858 } },
+ { method: 'PATCH', path: '/api/stats/anime/:id/tmdb', body: { tmdbId: 12, tmdbType: 'tv' } },
+ { method: 'POST', path: '/api/stats/anime/:id/merge', body: { sourceAnimeIds: [8] } },
+ { method: 'POST', path: '/api/stats/anki/browse?noteId=:id' },
+];
+
describe('stats server API routes', () => {
it('rejects untrusted mutation requests before merging anime', async () => {
let merges = 0;
@@ -534,14 +814,6 @@ describe('stats server API routes', () => {
assert.equal(body.hints.totalYomitanLookupCount, 5);
});
- it('GET /api/stats/sessions returns session list', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/sessions?limit=5');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- });
-
it('GET /api/stats/sentences/search resolves headword candidates by default', async () => {
const seen: Array<{
query: string;
@@ -579,141 +851,32 @@ describe('stats server API routes', () => {
]);
});
- it('GET /api/stats/sessions enriches known-word metrics using filtered persisted totals', async () => {
- await withTempDir(async (dir) => {
- const cachePath = path.join(dir, 'known-words.json');
- fs.writeFileSync(
- cachePath,
- JSON.stringify({
- version: 1,
- refreshedAtMs: 1,
- scope: 'deck:test',
- words: ['する'],
- }),
- );
+ for (const { name, cache, knownWordsSeen } of KNOWN_WORD_CACHES) {
+ it(`GET /api/stats/sessions enriches known-word metrics from ${name}`, async () => {
+ await withTempDir(async (dir) => {
+ const cachePath = path.join(dir, 'known-words.json');
+ fs.writeFileSync(cachePath, JSON.stringify(cache));
+ const app = createStatsApp(
+ createMockTracker({
+ getSessionWordsByLine: async (sessionId: number) =>
+ sessionId === 1
+ ? [
+ { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
+ { lineIndex: 2, headword: '未知', occurrenceCount: 1 },
+ ]
+ : [],
+ }),
+ { knownWordCachePath: cachePath },
+ );
- const app = createStatsApp(
- createMockTracker({
- getSessionWordsByLine: async (sessionId: number) =>
- sessionId === 1
- ? [
- { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
- { lineIndex: 2, headword: '未知', occurrenceCount: 1 },
- ]
- : [],
- }),
- { knownWordCachePath: cachePath },
- );
-
- const res = await app.request('/api/stats/sessions?limit=5');
- assert.equal(res.status, 200);
- const body = await res.json();
- const first = body[0];
- assert.equal(first.knownWordsSeen, 2);
- assert.equal(first.knownWordRate, 66.7);
+ const res = await app.request('/api/stats/sessions?limit=5');
+ assert.equal(res.status, 200);
+ const [first] = await res.json();
+ assert.equal(first.knownWordsSeen, knownWordsSeen);
+ if (knownWordsSeen > 0) assert.equal(first.knownWordRate, 66.7);
+ });
});
- });
-
- it('GET /api/stats/sessions enriches known-word metrics from a v3 reading-aware cache', async () => {
- await withTempDir(async (dir) => {
- const cachePath = path.join(dir, 'known-words.json');
- fs.writeFileSync(
- cachePath,
- JSON.stringify({
- version: 3,
- refreshedAtMs: 1,
- scope: 'deck:test',
- notes: {
- '101': [{ word: 'する', reading: 'する' }],
- '102': [{ word: '猫', reading: null }],
- },
- }),
- );
-
- const app = createStatsApp(
- createMockTracker({
- getSessionWordsByLine: async (sessionId: number) =>
- sessionId === 1
- ? [
- { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
- { lineIndex: 2, headword: '未知', occurrenceCount: 1 },
- ]
- : [],
- }),
- { knownWordCachePath: cachePath },
- );
-
- const res = await app.request('/api/stats/sessions?limit=5');
- assert.equal(res.status, 200);
- const body = await res.json();
- const first = body[0];
- assert.equal(first.knownWordsSeen, 2);
- assert.equal(first.knownWordRate, 66.7);
- });
- });
-
- it('GET /api/stats/sessions enriches known-word metrics from a v4 maturity cache', async () => {
- await withTempDir(async (dir) => {
- const cachePath = path.join(dir, 'known-words.json');
- fs.writeFileSync(
- cachePath,
- JSON.stringify({
- version: 4,
- refreshedAtMs: 1,
- scope: 'deck:test',
- notes: {
- '101': [{ word: 'する', reading: 'する' }],
- '102': [{ word: '猫', reading: null }],
- },
- tiers: { '101': 'mature', '102': 'young' },
- }),
- );
-
- const app = createStatsApp(
- createMockTracker({
- getSessionWordsByLine: async (sessionId: number) =>
- sessionId === 1
- ? [
- { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
- { lineIndex: 2, headword: '未知', occurrenceCount: 1 },
- ]
- : [],
- }),
- { knownWordCachePath: cachePath },
- );
-
- const res = await app.request('/api/stats/sessions?limit=5');
- assert.equal(res.status, 200);
- const body = await res.json();
- const first = body[0];
- assert.equal(first.knownWordsSeen, 2);
- assert.equal(first.knownWordRate, 66.7);
- });
- });
-
- it('GET /api/stats/sessions reports no known words when the cache format is unrecognized', async () => {
- await withTempDir(async (dir) => {
- const cachePath = path.join(dir, 'known-words.json');
- fs.writeFileSync(
- cachePath,
- JSON.stringify({ version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} }),
- );
-
- const app = createStatsApp(
- createMockTracker({
- getSessionWordsByLine: async () => [
- { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
- ],
- }),
- { knownWordCachePath: cachePath },
- );
-
- const res = await app.request('/api/stats/sessions?limit=5');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.equal(body[0].knownWordsSeen, 0);
- });
- });
+ }
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
let seenSessionId = 0;
@@ -791,228 +954,60 @@ describe('stats server API routes', () => {
});
});
- it('GET /api/stats/vocabulary returns word frequency data', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/vocabulary');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- assert.equal(body[0].headword, 'する');
- });
+ for (const { path: requestPath, method, expectedArgs } of CLAMPED_QUERIES) {
+ it(`GET ${requestPath} passes a bounded integer limit to ${method}`, async () => {
+ let seenArgs: unknown[] = [];
+ const app = createStatsApp(
+ createMockTracker({
+ [method]: async (...args: unknown[]) => {
+ seenArgs = args;
+ return [];
+ },
+ }),
+ );
- it('GET /api/stats/vocabulary/summary returns database-wide card totals', async () => {
- const app = createStatsApp(createMockTracker());
-
- const res = await app.request('/api/stats/vocabulary/summary');
-
- assert.equal(res.status, 200);
- assert.deepEqual(await res.json(), {
- uniqueWords: 501,
- uniqueWordsWithoutNames: 500,
- uniqueKanji: 201,
- newThisWeek: 7,
- newThisWeekWithoutNames: 6,
- knownWordCount: 250,
- knownWordCountWithoutNames: 249,
+ const res = await app.request(requestPath);
+ assert.equal(res.status, 200);
+ assert.deepEqual(seenArgs, expectedArgs);
});
- });
+ }
- it('GET /api/stats/vocabulary/charts returns complete chart datasets', async () => {
- const app = createStatsApp(createMockTracker());
-
- const res = await app.request('/api/stats/vocabulary/charts');
-
- assert.equal(res.status, 200);
- assert.deepEqual(await res.json(), {
- ready: true,
- topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
- topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
- newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
- newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
- });
- });
-
- it('GET /api/stats/kanji returns kanji frequency data', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/kanji');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- assert.equal(body[0].kanji, '日');
- });
-
- it('GET /api/stats/streak-calendar returns streak calendar rows', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/streak-calendar');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- assert.equal(body.length, 2);
- assert.equal(body[0].totalActiveMin, 30);
- assert.equal(body[1].totalActiveMin, 45);
- });
-
- it('GET /api/stats/streak-calendar clamps oversized days', async () => {
- let seenDays = 0;
- const app = createStatsApp(
- createMockTracker({
- getStreakCalendar: async (days?: number) => {
- seenDays = days ?? 0;
- return [];
- },
- }),
- );
-
- const res = await app.request('/api/stats/streak-calendar?days=999999');
- assert.equal(res.status, 200);
- assert.equal(seenDays, 365);
- });
-
- it('GET /api/stats/trends/episodes-per-day returns episode count rows', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/trends/episodes-per-day');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- assert.equal(body.length, 2);
- assert.equal(body[0].episodeCount, 3);
- });
-
- it('GET /api/stats/trends/episodes-per-day clamps oversized limits', async () => {
- let seenLimit = 0;
- const app = createStatsApp(
- createMockTracker({
- getEpisodesPerDay: async (limit?: number) => {
- seenLimit = limit ?? 0;
- return EPISODES_PER_DAY;
- },
- }),
- );
- const res = await app.request('/api/stats/trends/episodes-per-day?limit=999999');
- assert.equal(res.status, 200);
- assert.equal(seenLimit, 365);
- });
-
- it('GET /api/stats/trends/new-anime-per-day returns new anime rows', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/trends/new-anime-per-day');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- assert.equal(body.length, 1);
- assert.equal(body[0].newAnimeCount, 2);
- });
-
- it('GET /api/stats/trends/new-anime-per-day clamps oversized limits', async () => {
- let seenLimit = 0;
- const app = createStatsApp(
- createMockTracker({
- getNewAnimePerDay: async (limit?: number) => {
- seenLimit = limit ?? 0;
- return NEW_ANIME_PER_DAY;
- },
- }),
- );
- const res = await app.request('/api/stats/trends/new-anime-per-day?limit=999999');
- assert.equal(res.status, 200);
- assert.equal(seenLimit, 365);
- });
-
- it('GET /api/stats/trends/watch-time-per-anime returns watch time rows', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/trends/watch-time-per-anime');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- assert.equal(body.length, 1);
- assert.equal(body[0].animeTitle, 'Little Witch Academia');
- assert.equal(body[0].totalActiveMin, 25);
- });
-
- it('GET /api/stats/trends/watch-time-per-anime clamps oversized limits', async () => {
- let seenLimit = 0;
- const app = createStatsApp(
- createMockTracker({
- getWatchTimePerAnime: async (limit?: number) => {
- seenLimit = limit ?? 0;
- return WATCH_TIME_PER_ANIME;
- },
- }),
- );
- const res = await app.request('/api/stats/trends/watch-time-per-anime?limit=999999');
- assert.equal(res.status, 200);
- assert.equal(seenLimit, 365);
- });
-
- it('GET /api/stats/trends/dashboard returns chart-ready trends data', async () => {
+ it('GET /api/stats/vocabulary passes excludePos to tracker', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
createMockTracker({
- getTrendsDashboard: async (...args: unknown[]) => {
+ getVocabularyStats: async (...args: unknown[]) => {
seenArgs = args;
- return TRENDS_DASHBOARD;
- },
- }),
- );
-
- const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.deepEqual(seenArgs, ['90d', 'month', true]);
- assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
- assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
- });
-
- it('GET /api/stats/trends/dashboard accepts 365d range', async () => {
- let seenArgs: unknown[] = [];
- const app = createStatsApp(
- createMockTracker({
- getTrendsDashboard: async (...args: unknown[]) => {
- seenArgs = args;
- return TRENDS_DASHBOARD;
- },
- }),
- );
-
- const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
- assert.equal(res.status, 200);
- assert.deepEqual(seenArgs, ['365d', 'month', true]);
- });
-
- it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
- let seenArgs: unknown[] = [];
- const app = createStatsApp(
- createMockTracker({
- getTrendsDashboard: async (...args: unknown[]) => {
- seenArgs = args;
- return TRENDS_DASHBOARD;
- },
- }),
- );
-
- const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
- assert.equal(res.status, 200);
- assert.deepEqual(seenArgs, ['30d', 'day', true]);
- });
-
- it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
- let seenArgs: unknown[] = [];
- const app = createStatsApp(
- createMockTracker({
- getTrendsDashboard: async (...args: unknown[]) => {
- seenArgs = args;
- return TRENDS_DASHBOARD;
+ return VOCABULARY_STATS;
},
}),
);
const res = await app.request(
- '/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
+ '/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20',
);
assert.equal(res.status, 200);
- assert.deepEqual(seenArgs, ['30d', 'day', false]);
+ assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]);
});
+ for (const { query, expectedArgs } of TRENDS_DASHBOARD_QUERIES) {
+ it(`GET /api/stats/trends/dashboard?${query} requests ${expectedArgs.join('/')}`, async () => {
+ let seenArgs: unknown[] = [];
+ const app = createStatsApp(
+ createMockTracker({
+ getTrendsDashboard: async (...args: unknown[]) => {
+ seenArgs = args;
+ return TRENDS_DASHBOARD;
+ },
+ }),
+ );
+
+ const res = await app.request(`/api/stats/trends/dashboard?${query}`);
+ assert.equal(res.status, 200);
+ assert.deepEqual(seenArgs, expectedArgs);
+ });
+ }
+
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
@@ -1061,86 +1056,6 @@ describe('stats server API routes', () => {
assert.equal(res.status, 400);
});
- it('GET /api/stats/vocabulary clamps oversized limits', async () => {
- let seenLimit = 0;
- const app = createStatsApp(
- createMockTracker({
- getVocabularyStats: async (limit?: number, _excludePos?: string[]) => {
- seenLimit = limit ?? 0;
- return VOCABULARY_STATS;
- },
- }),
- );
-
- const res = await app.request('/api/stats/vocabulary?limit=999999');
- assert.equal(res.status, 200);
- assert.equal(seenLimit, 500);
- });
-
- it('GET /api/stats/vocabulary floors fractional pagination limits', async () => {
- let seenLimit = 0;
- const app = createStatsApp(
- createMockTracker({
- getVocabularyStats: async (limit?: number) => {
- seenLimit = limit ?? 0;
- return VOCABULARY_STATS;
- },
- }),
- );
-
- const res = await app.request('/api/stats/vocabulary?limit=12.9');
-
- assert.equal(res.status, 200);
- assert.equal(seenLimit, 12);
- });
-
- it('GET /api/stats/vocabulary passes excludePos to tracker', async () => {
- let seenArgs: unknown[] = [];
- const app = createStatsApp(
- createMockTracker({
- getVocabularyStats: async (...args: unknown[]) => {
- seenArgs = args;
- return VOCABULARY_STATS;
- },
- }),
- );
-
- const res = await app.request(
- '/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20',
- );
- assert.equal(res.status, 200);
- assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]);
- });
-
- it('GET /api/stats/vocabulary returns POS fields', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/vocabulary');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.equal(body[0].partOfSpeech, 'verb');
- assert.equal(body[0].pos1, '動詞');
- assert.equal(body[0].pos2, '自立');
- assert.equal(body[0].pos3, null);
- });
-
- it('GET /api/stats/excluded-words returns tracker exclusion rows', async () => {
- const app = createStatsApp(
- createMockTracker({
- getStatsExcludedWords: async () => [
- { headword: '猫', word: '猫', reading: 'ねこ' },
- { headword: 'する', word: 'する', reading: 'する' },
- ],
- }),
- );
-
- const res = await app.request('/api/stats/excluded-words');
- assert.equal(res.status, 200);
- assert.deepEqual(await res.json(), [
- { headword: '猫', word: '猫', reading: 'ねこ' },
- { headword: 'する', word: 'する', reading: 'する' },
- ]);
- });
-
it('PUT /api/stats/excluded-words replaces tracker exclusion rows', async () => {
let seenWords: unknown = null;
const app = createStatsApp(
@@ -1153,7 +1068,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/excluded-words', {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({
words: [
{ headword: '猫', word: '猫', reading: 'ねこ' },
@@ -1170,6 +1085,18 @@ describe('stats server API routes', () => {
]);
});
+ it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
+ const app = createStatsApp(createMockTracker());
+
+ const res = await app.request('/api/stats/excluded-words', {
+ method: 'PUT',
+ headers: JSON_HEADERS,
+ body: JSON.stringify({ words: [{ headword: '猫', word: 7, reading: 'ねこ' }] }),
+ });
+
+ assert.equal(res.status, 400);
+ });
+
it('POST /api/stats/maintenance/duplicate-lines forwards the window and dry-run flag', async () => {
let seenOptions: unknown = null;
const summary = {
@@ -1193,7 +1120,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ dryRun: true, lookbackDays: 30 }),
});
@@ -1226,160 +1153,61 @@ describe('stats server API routes', () => {
assert.equal(cleanupCalls, 0);
});
- it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => {
- let cleanupCalls = 0;
- const app = createStatsApp(
- createMockTracker({
- cleanupDuplicateSubtitleLines: async () => {
- cleanupCalls += 1;
- return {
- dryRun: true,
- lookbackDays: null,
- scannedLines: 0,
- burstGroups: 0,
- removedLines: 0,
- removedWordOccurrences: 0,
- removedKanjiOccurrences: 0,
- samples: [],
- };
- },
- }),
- );
-
- const res = await app.request('/api/stats/maintenance/duplicate-lines', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }),
- });
-
- assert.equal(res.status, 400);
- assert.equal(cleanupCalls, 0);
- });
-
- it('POST /api/stats/maintenance/duplicate-lines floors a fractional multi-day window', async () => {
- let seenOptions: unknown = null;
- const app = createStatsApp(
- createMockTracker({
- cleanupDuplicateSubtitleLines: async (options: unknown) => {
- seenOptions = options;
- return {
- dryRun: true,
- lookbackDays: 1,
- scannedLines: 0,
- burstGroups: 0,
- removedLines: 0,
- removedWordOccurrences: 0,
- removedKanjiOccurrences: 0,
- samples: [],
- };
- },
- }),
- );
-
- const res = await app.request('/api/stats/maintenance/duplicate-lines', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ dryRun: true, lookbackDays: 1.5 }),
- });
-
- assert.equal(res.status, 200);
- assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 1 });
- });
-
- it('POST /api/stats/maintenance/duplicate-lines accepts an explicit empty object for all history', async () => {
- let seenOptions: unknown = null;
- const app = createStatsApp(
- createMockTracker({
- cleanupDuplicateSubtitleLines: async (options: unknown) => {
- seenOptions = options;
- return {
- dryRun: false,
- lookbackDays: null,
- scannedLines: 0,
- burstGroups: 0,
- removedLines: 0,
- removedWordOccurrences: 0,
- removedKanjiOccurrences: 0,
- samples: [],
- };
- },
- }),
- );
-
- const res = await app.request('/api/stats/maintenance/duplicate-lines', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ // `expected: null` means the body is rejected with 400 before cleanup runs.
+ for (const { name, body, expected } of [
+ {
+ name: 'floors a fractional multi-day window',
+ body: '{"dryRun":true,"lookbackDays":1.5}',
+ expected: { dryRun: true, lookbackDays: 1 },
+ },
+ {
+ name: 'accepts an explicit empty object for all history',
body: '{}',
- });
-
- assert.equal(res.status, 200);
- assert.deepEqual(seenOptions, { dryRun: false, lookbackDays: null });
- });
-
- for (const malformed of [
- { name: 'a missing body', body: undefined },
- { name: 'malformed JSON', body: '{' },
- { name: 'JSON null', body: 'null' },
- { name: 'a JSON array', body: '[]' },
+ expected: { dryRun: false, lookbackDays: null },
+ },
+ {
+ name: 'rejects a window shorter than a day',
+ body: '{"dryRun":true,"lookbackDays":0.5}',
+ expected: null,
+ },
+ { name: 'rejects a missing body', body: undefined, expected: null },
+ { name: 'rejects malformed JSON', body: '{', expected: null },
+ { name: 'rejects JSON null', body: 'null', expected: null },
+ { name: 'rejects a JSON array', body: '[]', expected: null },
]) {
- it(`POST /api/stats/maintenance/duplicate-lines rejects ${malformed.name}`, async () => {
- let cleanupCalls = 0;
+ it(`POST /api/stats/maintenance/duplicate-lines ${name}`, async () => {
+ const seenOptions: unknown[] = [];
const app = createStatsApp(
createMockTracker({
- cleanupDuplicateSubtitleLines: async () => {
- cleanupCalls += 1;
- throw new Error('cleanup must not run');
+ cleanupDuplicateSubtitleLines: async (options: {
+ dryRun: boolean;
+ lookbackDays: number | null;
+ }) => {
+ seenOptions.push(options);
+ return {
+ ...options,
+ scannedLines: 0,
+ burstGroups: 0,
+ removedLines: 0,
+ removedWordOccurrences: 0,
+ removedKanjiOccurrences: 0,
+ samples: [],
+ };
},
}),
);
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: malformed.body,
+ headers: JSON_HEADERS,
+ body,
});
- assert.equal(res.status, 400);
- assert.equal(cleanupCalls, 0);
+ assert.equal(res.status, expected ? 200 : 400);
+ assert.deepEqual(seenOptions, expected ? [expected] : []);
});
}
- it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
- const app = createStatsApp(createMockTracker());
-
- const res = await app.request('/api/stats/excluded-words', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ words: [{ headword: '猫', word: 7, reading: 'ねこ' }] }),
- });
-
- assert.equal(res.status, 400);
- });
-
- it('GET /api/stats/anime returns anime library', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/anime');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
- });
-
- it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
- const app = createStatsApp(
- createMockTracker({
- getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
- } as Partial),
- );
-
- const res = await app.request('/api/stats/anime/merge-recommendations');
-
- assert.equal(res.status, 200);
- assert.deepEqual(await res.json(), {
- recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
- });
- });
-
it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
let dismissedId: number | null = null;
const app = createStatsApp(
@@ -1388,7 +1216,7 @@ describe('stats server API routes', () => {
dismissedId = recommendationId;
return true;
},
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/anime/merge-recommendations/4', {
@@ -1404,7 +1232,7 @@ describe('stats server API routes', () => {
const app = createStatsApp(
createMockTracker({
dismissAnimeMergeRecommendation: async () => false,
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/anime/merge-recommendations/99', {
@@ -1444,7 +1272,7 @@ describe('stats server API routes', () => {
for (const anilistId of [-1, 0, 1.5, 9_007_199_254_740_992, '12', true, undefined]) {
const res = await app.request('/api/stats/anime/1/anilist', {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ anilistId }),
});
assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`);
@@ -1454,7 +1282,7 @@ describe('stats server API routes', () => {
const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' };
const res = await app.request('/api/stats/anime/1/anilist', {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify(body),
});
assert.equal(res.status, 200);
@@ -1485,26 +1313,15 @@ describe('stats server API routes', () => {
it('GET /api/stats/anime/:animeId/cover resends art when the ETag no longer matches', async () => {
// A relinked AniList entry swaps the bytes behind the same cover URL.
- let coverBlob = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
+ let coverBlob = JPEG_BYTES;
const app = createStatsApp(
- createMockTracker({
- getAnimeCoverArt: async () => ({
- videoId: 1,
- anilistId: 21858,
- coverUrl: 'https://example.com/cover.jpg',
- coverBlob,
- titleRomaji: 'Little Witch Academia',
- titleEnglish: 'Little Witch Academia',
- episodesTotal: 25,
- fetchedAtMs: Date.now(),
- }),
- }),
+ createMockTracker({ getAnimeCoverArt: async () => animeCoverArt(coverBlob) }),
);
const first = await app.request('/api/stats/anime/1/cover');
const staleEtag = first.headers.get('etag');
assert.ok(staleEtag);
- coverBlob = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ coverBlob = PNG_BYTES;
const res = await app.request('/api/stats/anime/1/cover', {
headers: { 'If-None-Match': staleEtag },
});
@@ -1515,18 +1332,7 @@ describe('stats server API routes', () => {
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
const app = createStatsApp(
- createMockTracker({
- getAnimeCoverArt: async () => ({
- videoId: 1,
- anilistId: 21858,
- coverUrl: 'https://example.com/cover.png',
- coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
- titleRomaji: 'Little Witch Academia',
- titleEnglish: 'Little Witch Academia',
- episodesTotal: 25,
- fetchedAtMs: Date.now(),
- }),
- }),
+ createMockTracker({ getAnimeCoverArt: async () => animeCoverArt(PNG_BYTES) }),
);
const res = await app.request('/api/stats/anime/1/cover');
assert.equal(res.status, 200);
@@ -1539,72 +1345,21 @@ describe('stats server API routes', () => {
assert.equal(res.status, 404);
});
- it('resource routes reject fractional ids before calling dependencies', async () => {
- const dependencyCalls: string[] = [];
- const originalFetch = globalThis.fetch;
- globalThis.fetch = async () => {
- dependencyCalls.push('fetch');
- return new Response('{}', { status: 200 });
- };
+ it('GET /api/stats/anime/:animeId/cover fetches missing art before serving', async () => {
+ let fetched = false;
+ const app = createStatsApp(
+ createMockTracker({
+ getAnimeCoverArt: async () => (fetched ? animeCoverArt(JPEG_BYTES) : null),
+ ensureAnimeCoverArt: async () => {
+ fetched = true;
+ return true;
+ },
+ }),
+ );
- try {
- const app = createStatsApp(
- createMockTracker({
- getWordDetail: async () => {
- dependencyCalls.push('getWordDetail');
- return null;
- },
- getSessionEvents: async () => {
- dependencyCalls.push('getSessionEvents');
- return [];
- },
- getEpisodeSessions: async () => {
- dependencyCalls.push('getEpisodeSessions');
- return [];
- },
- getAnimeCoverArt: async () => {
- dependencyCalls.push('getAnimeCoverArt');
- return null;
- },
- ensureAnimeCoverArt: async () => {
- dependencyCalls.push('ensureAnimeCoverArt');
- return false;
- },
- setVideoWatched: async () => {
- dependencyCalls.push('setVideoWatched');
- },
- reassignAnimeAnilist: async () => {
- dependencyCalls.push('reassignAnimeAnilist');
- },
- }),
- );
-
- const responses = await Promise.all([
- app.request('/api/stats/vocabulary/1.9/detail'),
- app.request('/api/stats/sessions/1.9/events'),
- app.request('/api/stats/episode/1.9/detail'),
- app.request('/api/stats/anime/1.9/cover'),
- app.request('/api/stats/media/1.9/watched', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: '{"watched":true}',
- }),
- app.request('/api/stats/anime/1.9/anilist', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: '{"anilistId":21858}',
- }),
- app.request('/api/stats/anki/browse?noteId=1.9', { method: 'POST' }),
- ]);
-
- assert.deepEqual(
- responses.map((response) => response.status),
- [400, 400, 400, 400, 400, 400, 400],
- );
- assert.deepEqual(dependencyCalls, []);
- } finally {
- globalThis.fetch = originalFetch;
- }
+ const res = await app.request('/api/stats/anime/1/cover');
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get('content-type'), 'image/jpeg');
});
it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => {
@@ -1618,7 +1373,7 @@ describe('stats server API routes', () => {
videoId,
anilistId: null,
coverUrl: null,
- coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ coverBlob: PNG_BYTES,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
@@ -1638,7 +1393,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/covers', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ animeIds: [1, 99999], videoIds: [7, 99999] }),
});
@@ -1663,58 +1418,6 @@ describe('stats server API routes', () => {
assert.deepEqual(ensureAnimeCoverArtCalls, [99999]);
});
- it('JSON id lists reject malformed members before side effects', async () => {
- const dependencyCalls: string[] = [];
- const originalFetch = globalThis.fetch;
- globalThis.fetch = async () => {
- dependencyCalls.push('fetch');
- return new Response('{}', { status: 200 });
- };
-
- try {
- const app = createStatsApp(
- createMockTracker({
- deleteSessions: async () => {
- dependencyCalls.push('deleteSessions');
- },
- mergeAnime: async () => {
- dependencyCalls.push('mergeAnime');
- return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
- },
- getAnimeCoverArt: async () => {
- dependencyCalls.push('getAnimeCoverArt');
- return null;
- },
- ensureAnimeCoverArt: async () => {
- dependencyCalls.push('ensureAnimeCoverArt');
- return false;
- },
- }),
- );
- const request = async (path: string, body: string, method = 'POST'): Promise =>
- await app.request(path, {
- method,
- headers: { 'Content-Type': 'application/json' },
- body,
- });
-
- const responses = await Promise.all([
- request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'),
- request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'),
- request('/api/stats/covers', '{"animeIds":[1,1.9]}'),
- request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'),
- ]);
-
- assert.deepEqual(
- responses.map((response) => response.status),
- [400, 400, 400, 400],
- );
- assert.deepEqual(dependencyCalls, []);
- } finally {
- globalThis.fetch = originalFetch;
- }
- });
-
it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => {
let activeBackfills = 0;
let maxActiveBackfills = 0;
@@ -1736,7 +1439,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/covers', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ animeIds: [101, 102, 103, 104, 105] }),
});
@@ -1747,33 +1450,56 @@ describe('stats server API routes', () => {
}
});
- it('GET /api/stats/anime/:animeId/cover fetches missing art before serving', async () => {
- let fetched = false;
- const app = createStatsApp(
- createMockTracker({
- getAnimeCoverArt: async () =>
- fetched
- ? {
- videoId: 1,
- anilistId: 21858,
- coverUrl: 'https://example.com/cover.jpg',
- coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
- titleRomaji: 'Little Witch Academia',
- titleEnglish: 'Little Witch Academia',
- episodesTotal: 25,
- fetchedAtMs: Date.now(),
- }
- : null,
- ensureAnimeCoverArt: async () => {
- fetched = true;
- return true;
+ for (const route of ID_ROUTES) {
+ it(`${route.method} ${route.path} rejects malformed ids before calling dependencies`, async () => {
+ const dependencyCalls: string[] = [];
+ const app = createStatsApp(createRecordingTracker(dependencyCalls));
+ await withFetch(
+ async () => {
+ dependencyCalls.push('fetch');
+ return new Response('{}', { status: 200 });
},
- }),
+ async () => {
+ for (const id of MALFORMED_IDS) {
+ const res = await app.request(route.path.replace(':id', id), {
+ method: route.method,
+ ...(route.body === undefined
+ ? {}
+ : { headers: JSON_HEADERS, body: JSON.stringify(route.body) }),
+ });
+ assert.equal(res.status, 400, `accepted malformed id: ${id}`);
+ }
+ },
+ );
+ assert.deepEqual(dependencyCalls, []);
+ });
+ }
+
+ it('JSON id lists reject malformed members before side effects', async () => {
+ const dependencyCalls: string[] = [];
+ const app = createStatsApp(createRecordingTracker(dependencyCalls));
+ const request = async (path: string, body: string, method = 'POST'): Promise =>
+ await app.request(path, { method, headers: JSON_HEADERS, body });
+
+ const statuses = await withFetch(
+ async () => {
+ dependencyCalls.push('fetch');
+ return new Response('{}', { status: 200 });
+ },
+ async () =>
+ (
+ await Promise.all([
+ request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'),
+ request('/api/stats/sessions', '{"sessionIds":[1.5,1e309,9007199254740992]}', 'DELETE'),
+ request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'),
+ request('/api/stats/covers', '{"animeIds":[1,1.9]}'),
+ request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'),
+ ])
+ ).map((response) => response.status),
);
- const res = await app.request('/api/stats/anime/1/cover');
- assert.equal(res.status, 200);
- assert.equal(res.headers.get('content-type'), 'image/jpeg');
+ assert.deepEqual(statuses, [400, 400, 400, 400, 400]);
+ assert.deepEqual(dependencyCalls, []);
});
it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => {
@@ -1795,28 +1521,6 @@ describe('stats server API routes', () => {
assert.deepEqual(seenArgs, [1, 25]);
});
- it('GET /api/stats/anime/:animeId/words rejects invalid animeId', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/anime/0/words');
- assert.equal(res.status, 400);
- });
-
- it('GET /api/stats/anime/:animeId/words clamps oversized limits', async () => {
- let seenArgs: unknown[] = [];
- const app = createStatsApp(
- createMockTracker({
- getAnimeWords: async (...args: unknown[]) => {
- seenArgs = args;
- return ANIME_WORDS;
- },
- }),
- );
-
- const res = await app.request('/api/stats/anime/1/words?limit=999999');
- assert.equal(res.status, 200);
- assert.deepEqual(seenArgs, [1, 200]);
- });
-
it('GET /api/stats/anime/:animeId/rollups returns daily rollups for an anime', async () => {
let seenArgs: unknown[] = [];
const app = createStatsApp(
@@ -1836,28 +1540,6 @@ describe('stats server API routes', () => {
assert.deepEqual(seenArgs, [1, 30]);
});
- it('GET /api/stats/anime/:animeId/rollups rejects invalid animeId', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/anime/-1/rollups');
- assert.equal(res.status, 400);
- });
-
- it('GET /api/stats/anime/:animeId/rollups clamps oversized limits', async () => {
- let seenArgs: unknown[] = [];
- const app = createStatsApp(
- createMockTracker({
- getAnimeDailyRollups: async (...args: unknown[]) => {
- seenArgs = args;
- return DAILY_ROLLUPS;
- },
- }),
- );
-
- const res = await app.request('/api/stats/anime/1/rollups?limit=999999');
- assert.equal(res.status, 200);
- assert.deepEqual(seenArgs, [1, 365]);
- });
-
it('GET /api/stats/vocabulary/:wordId/detail returns word detail', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/vocabulary/1/detail');
@@ -1878,12 +1560,6 @@ describe('stats server API routes', () => {
assert.equal(res.status, 404);
});
- it('GET /api/stats/vocabulary/:wordId/detail returns 400 for invalid wordId', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/vocabulary/0/detail');
- assert.equal(res.status, 400);
- });
-
it('GET /api/stats/kanji/:kanjiId/detail returns kanji detail', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/kanji/1/detail');
@@ -1904,1591 +1580,613 @@ describe('stats server API routes', () => {
assert.equal(res.status, 404);
});
- it('GET /api/stats/kanji/:kanjiId/detail returns 400 for invalid kanjiId', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/kanji/0/detail');
- assert.equal(res.status, 400);
- });
-
- it('GET /api/stats/vocabulary/occurrences still works with detail routes present', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request(
- '/api/stats/vocabulary/occurrences?headword=%E7%8C%AB&word=%E7%8C%AB&reading=%E3%81%AD%E3%81%93',
- );
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- });
-
- it('GET /api/stats/kanji/occurrences still works with detail routes present', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/kanji/occurrences?kanji=%E6%97%A5');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body));
- });
-
it('POST /api/stats/mine-card rejects non-positive source timing before media generation', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
- let generatedAudio = false;
-
+ await withMiningEnv({}, async ({ sourcePath }) => {
+ const media = fakeMedia({ audio: AUDIO, image: IMAGE });
const app = createStatsApp(createMockTracker(), {
- createMediaGenerator: () => ({
- generateAudio: async () => {
- generatedAudio = true;
- return Buffer.from('audio');
- },
- generateScreenshot: async () => Buffer.from('image'),
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- deck: 'Mining',
- media: {
- generateAudio: true,
- generateImage: true,
- },
- },
+ createMediaGenerator: media.create,
+ ankiConnectConfig: { deck: 'Mining', media: { generateAudio: true, generateImage: true } },
});
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 953_991,
- endMs: 953_891,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
+ const { status, body } = await mineCard(app, 'sentence', {
+ sourcePath,
+ startMs: 953_991,
+ endMs: 953_891,
});
- const body = await res.json();
- assert.equal(res.status, 400, JSON.stringify(body));
+ assert.equal(status, 400, JSON.stringify(body));
assert.deepEqual(body, { error: 'endMs must be greater than startMs' });
- assert.equal(generatedAudio, false);
+ assert.equal(media.callCount(), 0);
});
});
it('POST /api/stats/mine-card treats a zero media duration cap as unlimited', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
- const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
- const scenarios = [
+ await withMiningEnv({}, async ({ sourcePath }) => {
+ for (const { maxMediaDuration, expectedEnd } of [
{ maxMediaDuration: 0, expectedEnd: 12 },
{ maxMediaDuration: 1, expectedEnd: 11 },
- ];
-
- for (const scenario of scenarios) {
+ ]) {
+ const media = fakeMedia({ audio: AUDIO });
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => null,
- createMediaGenerator: () => ({
- generateAudio: async (_path, start, end, padding) => {
- audioRanges.push({ start, end, padding });
- return Buffer.from('audio');
- },
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- }),
+ createMediaGenerator: media.create,
ankiConnectConfig: {
deck: 'Mining',
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
- maxMediaDuration: scenario.maxMediaDuration,
+ maxMediaDuration,
},
},
});
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 10_000,
- endMs: 12_000,
- sentence: '猫を見た',
- word: '猫',
- }),
+ const { status } = await mineCard(app, 'word', {
+ sourcePath,
+ startMs: 10_000,
+ endMs: 12_000,
});
- assert.equal(res.status, 502);
- assert.deepEqual(audioRanges.at(-1), {
- start: 10,
- end: scenario.expectedEnd,
- padding: 0.25,
- });
+ assert.equal(status, 502);
+ const [, start, end, padding] = media.audioCalls.at(-1) ?? [];
+ assert.deepEqual({ start, end, padding }, { start: 10, end: expectedEnd, padding: 0.25 });
}
});
});
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
+ await withMiningEnv({}, async ({ sourcePath }) => {
let yomitanCalls = 0;
- let mediaCalls = 0;
+ const media = fakeMedia({ audio: AUDIO, image: IMAGE });
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => {
yomitanCalls += 1;
return 777;
},
- createMediaGenerator: () => ({
- generateAudio: async () => {
- mediaCalls += 1;
- return Buffer.from('audio');
- },
- generateScreenshot: async () => {
- mediaCalls += 1;
- return Buffer.from('image');
- },
- generateAnimatedImage: async () => null,
- }),
+ createMediaGenerator: media.create,
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
});
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: ' ',
- }),
- });
+ const { status } = await mineCard(app, 'word', { sourcePath, word: ' ' });
- assert.equal(res.status, 400);
+ assert.equal(status, 400);
assert.equal(yomitanCalls, 0);
- assert.equal(mediaCalls, 0);
+ assert.equal(media.callCount(), 0);
});
});
it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
- let mediaCalls = 0;
+ await withMiningEnv({}, async ({ sourcePath }) => {
+ const media = fakeMedia({ audio: AUDIO, image: IMAGE });
const app = createStatsApp(createMockTracker(), {
- createMediaGenerator: () => ({
- generateAudio: async () => {
- mediaCalls += 1;
- return Buffer.from('audio');
- },
- generateScreenshot: async () => {
- mediaCalls += 1;
- return Buffer.from('image');
- },
- generateAnimatedImage: async () => null,
- }),
+ createMediaGenerator: media.create,
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
});
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- }),
- });
+ const { status } = await mineCard(app, 'word', { sourcePath });
- assert.equal(res.status, 500);
- assert.equal(mediaCalls, 0);
+ assert.equal(status, 500);
+ assert.equal(media.callCount(), 0);
});
});
- it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
+ // Direct sentence cards resolve their deck: configured deck, then Yomitan's deck, then Default.
+ for (const { name, options, expectedDeck } of [
+ {
+ name: 'falls back to Default deck for empty deck config',
+ options: (env: MiningEnv): StatsAppOptions => ({
+ ankiConnectConfig: ankiConfig(env, { deck: '', fields: SENTENCE_FIELDS, ...LAPIS }),
+ }),
+ expectedDeck: 'Default',
+ },
+ {
+ name: 'uses Yomitan deck for direct sentence cards when config deck is empty',
+ options: (env: MiningEnv): StatsAppOptions => ({
+ getYomitanAnkiDeckName: async () => 'Minecraft',
+ ankiConnectConfig: ankiConfig(env, { deck: '', fields: SENTENCE_FIELDS, ...LAPIS }),
+ }),
+ expectedDeck: 'Minecraft',
+ },
+ {
+ name: 'resolves Anki config at request time',
+ options: (env: MiningEnv): StatsAppOptions => ({
+ getAnkiConnectConfig: () => ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }),
+ }),
+ expectedDeck: 'Mining',
+ },
+ ]) {
+ it(`POST /api/stats/mine-card ${name}`, async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), options(env));
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- ankiConnectConfig: {
- url,
- deck: '',
- tags: ['SubMiner'],
- fields: {
- word: 'Expression',
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- miscInfo: 'MiscInfo',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- isKiku: {
- enabled: true,
- fieldGrouping: 'manual',
- deleteDuplicateInAuto: true,
- },
- },
+ const { status, body } = await mineCard(app, 'sentence', {
+ sourcePath: env.sourcePath,
+ secondaryText: 'I saw a cat',
});
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
+ assert.equal(status, 200, JSON.stringify(body));
assert.equal(body.noteId, 12345);
-
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(addNoteRequest?.params?.note?.deckName, 'Default');
- assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph');
- assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た');
- assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x');
+ const note = sentNote(env.requests, 'addNote');
+ assert.equal(note.deckName, expectedDeck);
+ assert.equal(note.modelName, 'Lapis Morph');
+ assert.equal(note.fields.SelectionText, 'I saw a cat');
});
});
- });
+ }
- it('POST /api/stats/mine-card uses Yomitan deck for direct sentence cards when config deck is empty', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- getYomitanAnkiDeckName: async () => 'Minecraft',
- ankiConnectConfig: {
- url,
- deck: '',
- tags: ['SubMiner'],
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft');
+ it('POST /api/stats/mine-card uses the full sentence as sentence-card expression', async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }),
});
- });
- });
- it('POST /api/stats/mine-card resolves Anki config at request time', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
+ const { status, body } = await mineCard(app, 'sentence', {
+ sourcePath: env.sourcePath,
+ secondaryText: 'I saw a cat',
+ });
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- getAnkiConnectConfig: () => ({
- url,
- deck: 'Mining',
- tags: ['SubMiner'],
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- }),
- } as Parameters[1]);
-
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(addNoteRequest?.params?.note?.deckName, 'Mining');
- assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph');
- assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.deepEqual(sentNote(env.requests, 'addNote').fields, {
+ Expression: '猫を見た',
+ Sentence: '猫を見た',
+ SelectionText: 'I saw a cat',
+ IsSentenceCard: 'x',
});
});
});
it('POST /api/stats/mine-card prefers request secondary text over retimed fallback', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- let retimedCalls = 0;
- const options = {
- resolveRetimedSecondarySubtitleText: async () => {
- retimedCalls += 1;
- return 'Aligned English subtitle';
- },
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- } as Parameters[1] & {
- resolveRetimedSecondarySubtitleText: () => Promise;
- };
- const app = createStatsApp(createMockTracker(), options);
-
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'Stale stored English subtitle',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(
- addNoteRequest?.params?.note?.fields?.SelectionText,
- 'Stale stored English subtitle',
- );
- assert.equal(retimedCalls, 0);
- });
- });
- });
-
- it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- const japanesePath = path.join(dir, 'episode.ja.srt');
- const englishPath = path.join(dir, 'episode.en.srt');
- const alassPath = path.join(dir, 'alass-cli');
- const originalEnglish = `1
-00:00:09,000 --> 00:00:10,000
-Stale English subtitle
-`;
- fs.writeFileSync(sourcePath, 'fake media');
- fs.writeFileSync(alassPath, 'fake alass');
- fs.writeFileSync(
- japanesePath,
- `1
-00:00:01,000 --> 00:00:02,000
-猫を見た
-`,
- );
- fs.writeFileSync(englishPath, originalEnglish);
-
- let alassRuns = 0;
- try {
- const first = await resolveRetimedSecondarySubtitleTextFromSidecar({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- alassPath,
- runAlass: async (_alassPath, referencePath, inputPath, outputPath) => {
- alassRuns += 1;
- assert.equal(referencePath, japanesePath);
- assert.equal(inputPath, englishPath);
- fs.writeFileSync(
- outputPath,
- `1
-00:00:01,000 --> 00:00:02,000
-Aligned English subtitle
-`,
- );
- return { ok: true, code: 0, stdout: '', stderr: '' };
- },
- });
-
- const second = await resolveRetimedSecondarySubtitleTextFromSidecar({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- alassPath,
- runAlass: async () => {
- alassRuns += 1;
- return { ok: false, code: 1, stdout: '', stderr: 'should use cache' };
- },
- });
-
- assert.equal(first, 'Aligned English subtitle');
- assert.equal(second, 'Aligned English subtitle');
- assert.equal(alassRuns, 1);
- assert.equal(fs.readFileSync(englishPath, 'utf8'), originalEnglish);
- } finally {
- clearRetimedSecondarySubtitleCache();
- }
- });
- });
-
- it('shares in-flight retimed secondary subtitle work for concurrent requests', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- const japanesePath = path.join(dir, 'episode.ja.srt');
- const englishPath = path.join(dir, 'episode.en.srt');
- const alassPath = path.join(dir, 'alass-cli');
- fs.writeFileSync(sourcePath, 'fake media');
- fs.writeFileSync(alassPath, 'fake alass');
- fs.writeFileSync(
- japanesePath,
- `1
-00:00:01,000 --> 00:00:02,000
-猫を見た
-`,
- );
- fs.writeFileSync(
- englishPath,
- `1
-00:00:09,000 --> 00:00:10,000
-Stale English subtitle
-`,
- );
-
- let alassRuns = 0;
- let releaseAlass!: () => void;
- const alassGate = new Promise((resolve) => {
- releaseAlass = resolve;
- });
- const input = {
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- alassPath,
- runAlass: async (
- _alassPath: string,
- _referencePath: string,
- _inputPath: string,
- outputPath: string,
- ) => {
- alassRuns += 1;
- await alassGate;
- fs.writeFileSync(
- outputPath,
- `1
-00:00:01,000 --> 00:00:02,000
-Aligned English subtitle
-`,
- );
- return { ok: true, code: 0, stdout: '', stderr: '' };
+ await withMiningEnv({}, async (env) => {
+ let retimedCalls = 0;
+ const app = createStatsApp(createMockTracker(), {
+ resolveRetimedSecondarySubtitleText: async () => {
+ retimedCalls += 1;
+ return 'Aligned English subtitle';
},
- };
+ ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }),
+ });
- try {
- const first = resolveRetimedSecondarySubtitleTextFromSidecar(input);
- const second = resolveRetimedSecondarySubtitleTextFromSidecar(input);
- releaseAlass();
+ const { status, body } = await mineCard(app, 'sentence', {
+ sourcePath: env.sourcePath,
+ secondaryText: 'Stale stored English subtitle',
+ });
- assert.deepEqual(await Promise.all([first, second]), [
- 'Aligned English subtitle',
- 'Aligned English subtitle',
- ]);
- assert.equal(alassRuns, 1);
- } finally {
- clearRetimedSecondarySubtitleCache();
- }
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.equal(
+ sentNote(env.requests, 'addNote').fields.SelectionText,
+ 'Stale stored English subtitle',
+ );
+ assert.equal(retimedCalls, 0);
});
});
+ for (const { name, cues, sentence, expected } of [
+ {
+ name: 'fills selection text from a matching secondary sidecar subtitle',
+ cues: [
+ ['00:00:00,800 --> 00:00:02,500', 'I saw a cat.'],
+ ['00:00:03,000 --> 00:00:04,000', 'Not this line.'],
+ ],
+ sentence: '猫を見た',
+ expected: 'I saw a cat.',
+ },
+ {
+ name: 'does not append the next sidecar cue near a timing boundary',
+ cues: [
+ ['00:00:00,800 --> 00:00:01,500', "I don't give a damn what family she's from."],
+ ['00:00:01,700 --> 00:00:03,000', 'That snobby attitude just pisses me off!'],
+ ],
+ sentence: '名門か何だか知らねえが',
+ expected: "I don't give a damn what family she's from.",
+ },
+ ]) {
+ it(`POST /api/stats/mine-card ${name}`, async () => {
+ const srt = cues
+ .map(([timing, text], index) => `${index + 1}\n${timing}\n${text}\n`)
+ .join('\n');
+ await withMiningEnv({ files: { 'episode.en.srt': srt } }, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }),
+ secondarySubtitleLanguages: ['en'],
+ });
+
+ const { status, body } = await mineCard(app, 'sentence', {
+ sourcePath: env.sourcePath,
+ sentence,
+ });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.equal(sentNote(env.requests, 'addNote').fields.SelectionText, expected);
+ });
+ });
+ }
+
it('POST /api/stats/mine-card adds direct sentence cards before slow media finishes', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const mediaRelease: {
- audio?: () => void;
- image?: () => void;
- } = {};
+ const addNoteSent = deferred();
+ await withMiningEnv(
+ { onAnkiRequest: (request) => request.action === 'addNote' && addNoteSent.resolve() },
+ async (env) => {
+ // Media only finishes once the note exists, so the request deadlocks (and the
+ // test times out) if mining ever waits for media before adding the note.
const app = createStatsApp(createMockTracker(), {
createMediaGenerator: () => ({
- generateAudio: async () =>
- await new Promise((resolve) => {
- mediaRelease.audio = () => resolve(Buffer.from('audio'));
- }),
- generateScreenshot: async () =>
- await new Promise((resolve) => {
- mediaRelease.image = () => resolve(Buffer.from('image'));
- }),
+ generateAudio: async () => addNoteSent.promise.then(() => AUDIO),
+ generateScreenshot: async () => addNoteSent.promise.then(() => IMAGE),
generateAnimatedImage: async () => null,
}),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- tags: ['SubMiner'],
+ ankiConnectConfig: ankiConfig(env, {
fields: {
- word: 'Expression',
+ ...SENTENCE_FIELDS,
audio: 'ExpressionAudio',
image: 'Picture',
- sentence: 'Sentence',
- translation: 'SelectionText',
},
- media: {
- generateAudio: true,
- generateImage: true,
- imageType: 'static',
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- });
-
- const pendingResponse = app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
+ media: { generateAudio: true, generateImage: true, imageType: 'static' },
+ ...LAPIS,
}),
});
- for (let attempt = 0; attempt < 20; attempt += 1) {
- if (requests.some((request) => request.action === 'addNote')) break;
- await new Promise((resolve) => setTimeout(resolve, 1));
- }
- const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
- const addRequest = requests.find((request) => request.action === 'addNote');
- assert.deepEqual(addRequest?.params?.note?.tags, ['SubMiner', 'SubMiner::Stats']);
- mediaRelease.audio?.();
- mediaRelease.image?.();
-
- const res = await pendingResponse;
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- assert.equal(addedBeforeMediaFinished, true);
- });
- });
- });
-
- it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- miscInfo: 'MiscInfo',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- },
+ const { status, body } = await mineCard(app, 'sentence', {
+ sourcePath: env.sourcePath,
+ secondaryText: 'I saw a cat',
});
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- assert.equal(body.noteId, 777);
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- assert.equal(updateRequest?.params?.note?.id, 777);
- assert.equal(updateRequest?.params?.note?.fields?.Sentence, '猫を見た');
- assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined);
- });
- });
- });
-
- it('POST /api/stats/mine-card moves Yomitan-created word notes to the configured deck', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const findCardsRequest = requests.find((request) => request.action === 'findCards');
- assert.equal(findCardsRequest?.params?.query, 'nid:777');
- const changeDeckRequest = requests.find((request) => request.action === 'changeDeck');
- assert.deepEqual(changeDeckRequest?.params?.cards, [9001]);
- assert.equal(changeDeckRequest?.params?.deck, 'Mining');
- });
- });
- });
-
- it('POST /api/stats/mine-card moves Yomitan-created word notes to Yomitan deck when config deck is empty', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- getYomitanAnkiDeckName: async () => 'Minecraft',
- addYomitanNote: async () => 777,
- ankiConnectConfig: {
- url,
- deck: '',
- fields: {
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const findCardsRequest = requests.find((request) => request.action === 'findCards');
- assert.equal(findCardsRequest?.params?.query, 'nid:777');
- const changeDeckRequest = requests.find((request) => request.action === 'changeDeck');
- assert.deepEqual(changeDeckRequest?.params?.cards, [9001]);
- assert.equal(changeDeckRequest?.params?.deck, 'Minecraft');
- });
- });
- });
-
- it('POST /api/stats/mine-card uses the full sentence as sentence-card expression', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- ankiConnectConfig: {
- url,
- deck: 'Minecraft',
- tags: ['SubMiner'],
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft');
- assert.equal(addNoteRequest?.params?.note?.fields?.Expression, '猫を見た');
- assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た');
- assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
- assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x');
- });
- });
- });
-
- it('POST /api/stats/mine-card fills selection text from a matching secondary sidecar subtitle', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
- fs.writeFileSync(
- path.join(dir, 'episode.en.srt'),
- [
- '1',
- '00:00:00,800 --> 00:00:02,500',
- 'I saw a cat.',
- '',
- '2',
- '00:00:03,000 --> 00:00:04,000',
- 'Not this line.',
- '',
- ].join('\n'),
- );
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- ankiConnectConfig: {
- url,
- deck: 'Minecraft',
- tags: ['SubMiner'],
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- secondarySubtitleLanguages: ['en'],
- } as Parameters[1]);
-
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft');
- assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat.');
- });
- });
- });
-
- it('POST /api/stats/mine-card does not append the next sidecar cue near a timing boundary', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
- fs.writeFileSync(
- path.join(dir, 'episode.en.srt'),
- [
- '1',
- '00:00:00,800 --> 00:00:01,500',
- "I don't give a damn what family she's from.",
- '',
- '2',
- '00:00:01,700 --> 00:00:03,000',
- 'That snobby attitude just pisses me off!',
- '',
- ].join('\n'),
- );
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- ankiConnectConfig: {
- url,
- deck: 'Minecraft',
- tags: ['SubMiner'],
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- secondarySubtitleLanguages: ['en'],
- } as Parameters[1]);
-
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '名門か何だか知らねえが',
- word: '名門',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(
- addNoteRequest?.params?.note?.fields?.SelectionText,
- "I don't give a damn what family she's from.",
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.deepEqual(sentNote(env.requests, 'addNote').tags, ['SubMiner', 'SubMiner::Stats']);
+ assert.match(
+ sentNote(env.requests, 'updateNoteFields').fields.Picture ?? '',
+ /^
$/,
);
- });
- });
- });
-
- it('POST /api/stats/mine-card writes word mining audio to SentenceAudio when present', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- createMediaGenerator: () => ({
- generateAudio: async () => Buffer.from('audio'),
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- miscInfo: 'MiscInfo',
- },
- media: {
- generateAudio: true,
- generateImage: false,
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
- assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
- assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
- });
- });
- });
-
- it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(
- async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- createMediaGenerator: () => ({
- generateAudio: async () => null,
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- image: 'Picture',
- sentence: 'Sentence',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isKiku: {
- enabled: true,
- fieldGrouping: 'disabled',
- deleteDuplicateInAuto: true,
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- const fields = updateRequest?.params?.note?.fields ?? {};
- assert.equal(fields.Sentence, '猫を見た');
- assert.equal(fields.IsWordAndSentenceCard, 'x');
- assert.equal(fields.IsSentenceCard, '');
- assert.equal(fields.IsAudioCard, '');
- },
- {
- notesInfoFields: {
- Expression: { value: '猫' },
- Sentence: { value: '' },
- Picture: { value: '' },
- IsWordAndSentenceCard: { value: '' },
- IsSentenceCard: { value: '' },
- IsAudioCard: { value: '' },
- },
- },
- );
- });
- });
-
- it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(
- async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- createMediaGenerator: () => ({
- generateAudio: async () => null,
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- image: 'Picture',
- sentence: 'Sentence',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isKiku: {
- enabled: true,
- fieldGrouping: 'disabled',
- deleteDuplicateInAuto: true,
- },
- lapisKiku: {
- wordCardKind: 'click',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- const fields = updateRequest?.params?.note?.fields ?? {};
- assert.equal(fields.IsClickCard, 'x');
- assert.equal(fields.IsWordAndSentenceCard, '');
- assert.equal(fields.IsSentenceCard, '');
- assert.equal(fields.IsAudioCard, '');
- },
- {
- notesInfoFields: {
- Expression: { value: '猫' },
- Sentence: { value: '' },
- Picture: { value: '' },
- IsWordAndSentenceCard: { value: '' },
- IsClickCard: { value: '' },
- IsSentenceCard: { value: '' },
- IsAudioCard: { value: '' },
- },
- },
- );
- });
- });
-
- it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- createMediaGenerator: () => ({
- generateAudio: async () => Buffer.from('audio'),
- generateScreenshot: async () => Buffer.from('image'),
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- },
- media: {
- generateAudio: true,
- generateImage: true,
- imageType: 'static',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- assert.equal(body.errors, undefined);
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- const fields = updateRequest?.params?.note?.fields ?? {};
- assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
- assert.match(fields.Picture ?? '', /^
$/);
- assert.equal(fields.ExpressionAudio, undefined);
- assert.equal(fields.SelectionText, undefined);
- });
- });
- });
-
- it('POST /api/stats/mine-card writes word mining sentence audio and animated image together', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- createMediaGenerator: () => ({
- generateAudio: async () => Buffer.from('audio'),
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => Buffer.from('animated'),
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- },
- media: {
- generateAudio: true,
- generateImage: true,
- imageType: 'avif',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- assert.equal(body.errors, undefined);
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- const fields = updateRequest?.params?.note?.fields ?? {};
- assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
- assert.match(fields.Picture ?? '', /^
$/);
- assert.equal(fields.ExpressionAudio, undefined);
- assert.equal(fields.SelectionText, undefined);
- });
- });
- });
-
- it('POST /api/stats/mine-card reports an error when requested word image generation returns no image', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (_requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- createMediaGenerator: () => ({
- generateAudio: async () => Buffer.from('audio'),
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- },
- media: {
- generateAudio: true,
- generateImage: true,
- imageType: 'static',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- assert.deepEqual(body.errors, ['image: no image generated']);
- });
- });
- });
-
- it('POST /api/stats/mine-card reports an error when requested word audio generation returns no audio', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (_requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- addYomitanNote: async () => 777,
- createMediaGenerator: () => ({
- generateAudio: async () => null,
- generateScreenshot: async () => Buffer.from('image'),
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- },
- media: {
- generateAudio: true,
- generateImage: true,
- imageType: 'static',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- assert.deepEqual(body.errors, ['audio: no audio generated']);
- });
- });
- });
-
- it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(
- async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- createMediaGenerator: () => ({
- generateAudio: async () => Buffer.from('audio'),
- generateScreenshot: async () => null,
- generateAnimatedImage: async () => null,
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- audio: 'Voice',
- image: 'Picture',
- sentence: 'Sentence',
- },
- media: {
- generateAudio: true,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=audio', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- assert.match(
- updateRequest?.params?.note?.fields?.Voice ?? '',
- /^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
- );
- },
- { notesInfoFields: null },
- );
- });
+ },
+ );
});
it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- let now = 0;
- const timings: Array<{ mode: string; phase: string; elapsedMs: number; noteId?: number }> =
- [];
- const app = createStatsApp(createMockTracker(), {
- nowMs: () => {
- now += 10;
- return now;
+ await withMiningEnv({}, async (env) => {
+ let now = 0;
+ const timings: Array<{ mode: string; phase: string; elapsedMs: number; noteId?: number }> =
+ [];
+ const app = createStatsApp(createMockTracker(), {
+ nowMs: () => {
+ now += 10;
+ return now;
+ },
+ onMiningTiming: (event) => {
+ timings.push(event);
+ },
+ createMediaGenerator: fakeMedia({ audio: AUDIO, image: IMAGE, animated: IMAGE }).create,
+ ankiConnectConfig: ankiConfig(env, {
+ tags: ['SubMiner'],
+ fields: {
+ ...SENTENCE_FIELDS,
+ audio: 'ExpressionAudio',
+ image: 'Picture',
+ miscInfo: 'MiscInfo',
},
- onMiningTiming: (event) => {
- timings.push(event);
- },
- createMediaGenerator: () => ({
- generateAudio: async () => Buffer.from('audio'),
- generateScreenshot: async () => Buffer.from('image'),
- generateAnimatedImage: async () => Buffer.from('animated'),
- }),
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- tags: ['SubMiner'],
- fields: {
- word: 'Expression',
- audio: 'ExpressionAudio',
- image: 'Picture',
- sentence: 'Sentence',
- miscInfo: 'MiscInfo',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: true,
- generateImage: true,
- imageType: 'static',
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=sentence', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
- assert.deepEqual(
- timings.map((entry) => entry.phase),
- [
- 'generateAudio',
- 'generateScreenshot',
- 'addNote',
- 'findCards',
- 'changeDeck',
- 'uploadAudio',
- 'uploadImage',
- 'updateNoteFields',
- ],
- );
- assert.ok(timings.every((entry) => entry.mode === 'sentence' && entry.elapsedMs >= 0));
- assert.equal(timings.find((entry) => entry.phase === 'addNote')?.noteId, 12345);
-
- const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
- const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
- assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/);
- assert.match(
- updateRequest?.params?.note?.fields?.Picture ?? '',
- /^
$/,
- );
- assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
+ media: { generateAudio: true, generateImage: true, imageType: 'static' },
+ ...LAPIS,
+ }),
});
+
+ const { status, body } = await mineCard(app, 'sentence', {
+ sourcePath: env.sourcePath,
+ secondaryText: 'I saw a cat',
+ });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.deepEqual(
+ timings.map((entry) => entry.phase),
+ [
+ 'generateAudio',
+ 'generateScreenshot',
+ 'addNote',
+ 'findCards',
+ 'changeDeck',
+ 'uploadAudio',
+ 'uploadImage',
+ 'updateNoteFields',
+ ],
+ );
+ assert.ok(timings.every((entry) => entry.mode === 'sentence' && entry.elapsedMs >= 0));
+ assert.equal(timings.find((entry) => entry.phase === 'addNote')?.noteId, 12345);
+
+ const { fields } = sentNote(env.requests, 'updateNoteFields');
+ assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/);
+ assert.match(fields.Picture ?? '', /^
$/);
+ assert.equal(fields.ExpressionAudio, undefined);
});
});
it('POST /api/stats/mine-card only writes selection text for sentence cards', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
-
- await withFakeAnkiConnect(async (requests, url) => {
- const app = createStatsApp(createMockTracker(), {
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- fields: {
- word: 'Expression',
- sentence: 'Sentence',
- translation: 'SelectionText',
- },
- media: {
- generateAudio: false,
- generateImage: false,
- },
- isLapis: {
- enabled: true,
- sentenceCardModel: 'Lapis Morph',
- },
- },
- });
-
- const res = await app.request('/api/stats/mine-card?mode=audio', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1_000,
- endMs: 2_000,
- sentence: '猫を見た',
- word: '猫',
- secondaryText: 'I saw a cat',
- videoTitle: 'Episode 1',
- }),
- });
-
- const body = await res.json();
- assert.equal(res.status, 200, JSON.stringify(body));
-
- const addNoteRequest = requests.find((request) => request.action === 'addNote');
- assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, undefined);
- assert.equal(addNoteRequest?.params?.note?.fields?.IsAudioCard, 'x');
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ ankiConnectConfig: ankiConfig(env, { fields: SENTENCE_FIELDS, ...LAPIS }),
});
+
+ const { status, body } = await mineCard(app, 'audio', {
+ sourcePath: env.sourcePath,
+ secondaryText: 'I saw a cat',
+ });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ const { fields } = sentNote(env.requests, 'addNote');
+ assert.equal(fields.SelectionText, undefined);
+ assert.equal(fields.IsAudioCard, 'x');
});
});
- it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/episode/1/detail');
- assert.equal(res.status, 200);
- const body = await res.json();
- assert.ok(Array.isArray(body.sessions));
- assert.ok(Array.isArray(body.words));
- assert.ok(Array.isArray(body.cardEvents));
- assert.equal(body.cardEvents[0].noteIds[0], 12345);
+ it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => {
+ await withMiningEnv({ notesInfoFields: null }, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ createMediaGenerator: fakeMedia({ audio: AUDIO }).create,
+ ankiConnectConfig: ankiConfig(env, {
+ fields: { audio: 'Voice', image: 'Picture', sentence: 'Sentence' },
+ media: { generateAudio: true, generateImage: false },
+ ...LAPIS,
+ }),
+ });
+
+ const { status, body } = await mineCard(app, 'audio', { sourcePath: env.sourcePath });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.match(
+ sentNote(env.requests, 'updateNoteFields').fields.Voice ?? '',
+ /^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
+ );
+ });
});
- it('GET /api/stats/episode/:videoId/detail returns 400 for invalid videoId', async () => {
- const app = createStatsApp(createMockTracker());
- const res = await app.request('/api/stats/episode/0/detail');
- assert.equal(res.status, 400);
+ it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ addYomitanNote: async () => 777,
+ ankiConnectConfig: ankiConfig(env, {
+ fields: {
+ audio: 'ExpressionAudio',
+ image: 'Picture',
+ sentence: 'Sentence',
+ miscInfo: 'MiscInfo',
+ translation: 'SelectionText',
+ },
+ }),
+ });
+
+ const { status, body } = await mineCard(app, 'word', {
+ sourcePath: env.sourcePath,
+ secondaryText: 'I saw a cat',
+ });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.equal(body.noteId, 777);
+ const note = sentNote(env.requests, 'updateNoteFields');
+ assert.equal(note.id, 777);
+ assert.equal(note.fields.Sentence, '猫を見た');
+ assert.equal(note.fields.SelectionText, undefined);
+ });
+ });
+
+ for (const { name, deck, yomitanDeck, expectedDeck } of [
+ { name: 'the configured deck', deck: 'Mining', yomitanDeck: undefined, expectedDeck: 'Mining' },
+ {
+ name: 'Yomitan deck when config deck is empty',
+ deck: '',
+ yomitanDeck: 'Minecraft',
+ expectedDeck: 'Minecraft',
+ },
+ ]) {
+ it(`POST /api/stats/mine-card moves Yomitan-created word notes to ${name}`, async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ getYomitanAnkiDeckName: yomitanDeck ? async () => yomitanDeck : undefined,
+ addYomitanNote: async () => 777,
+ ankiConnectConfig: ankiConfig(env, {
+ deck,
+ fields: { sentence: 'Sentence', translation: 'SelectionText' },
+ }),
+ });
+
+ const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ const findCards = env.requests.find((request) => request.action === 'findCards');
+ assert.equal(findCards?.params?.query, 'nid:777');
+ const changeDeck = env.requests.find((request) => request.action === 'changeDeck');
+ assert.deepEqual(changeDeck?.params?.cards, [9001]);
+ assert.equal(changeDeck?.params?.deck, expectedDeck);
+ });
+ });
+ }
+
+ it('POST /api/stats/mine-card writes word mining audio to SentenceAudio when present', async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ addYomitanNote: async () => 777,
+ createMediaGenerator: fakeMedia({ audio: AUDIO }).create,
+ ankiConnectConfig: ankiConfig(env, {
+ fields: {
+ audio: 'ExpressionAudio',
+ image: 'Picture',
+ sentence: 'Sentence',
+ miscInfo: 'MiscInfo',
+ },
+ media: { generateAudio: true, generateImage: false },
+ }),
+ });
+
+ const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ const { fields } = sentNote(env.requests, 'updateNoteFields');
+ assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
+ assert.equal(fields.ExpressionAudio, undefined);
+ });
+ });
+
+ for (const { name, lapisKiku, expectedFlags } of [
+ {
+ name: 'marks Kiku word mining notes as word-and-sentence cards when enabled',
+ lapisKiku: undefined,
+ expectedFlags: { IsWordAndSentenceCard: 'x', IsSentenceCard: '', IsAudioCard: '' },
+ },
+ {
+ name: 'marks the configured Kiku word card kind',
+ lapisKiku: { wordCardKind: 'click' as const },
+ expectedFlags: {
+ IsClickCard: 'x',
+ IsWordAndSentenceCard: '',
+ IsSentenceCard: '',
+ IsAudioCard: '',
+ },
+ },
+ ]) {
+ it(`POST /api/stats/mine-card ${name}`, async () => {
+ const notesInfoFields = {
+ Expression: { value: '猫' },
+ Sentence: { value: '' },
+ Picture: { value: '' },
+ IsWordAndSentenceCard: { value: '' },
+ IsClickCard: { value: '' },
+ IsSentenceCard: { value: '' },
+ IsAudioCard: { value: '' },
+ };
+ await withMiningEnv({ notesInfoFields }, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ addYomitanNote: async () => 777,
+ createMediaGenerator: fakeMedia().create,
+ ankiConnectConfig: ankiConfig(env, {
+ fields: { image: 'Picture', sentence: 'Sentence' },
+ isKiku: { enabled: true, fieldGrouping: 'disabled', deleteDuplicateInAuto: true },
+ ...(lapisKiku ? { lapisKiku } : {}),
+ }),
+ });
+
+ const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ const { fields } = sentNote(env.requests, 'updateNoteFields');
+ assert.equal(fields.Sentence, '猫を見た');
+ for (const [field, value] of Object.entries(expectedFlags)) {
+ assert.equal(fields[field], value, field);
+ }
+ });
+ });
+ }
+
+ for (const { imageType, media, extension } of [
+ { imageType: 'static' as const, media: { audio: AUDIO, image: IMAGE }, extension: 'jpg' },
+ { imageType: 'avif' as const, media: { audio: AUDIO, animated: IMAGE }, extension: 'avif' },
+ ]) {
+ it(`POST /api/stats/mine-card writes word mining sentence audio and ${imageType} image together`, async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ addYomitanNote: async () => 777,
+ createMediaGenerator: fakeMedia(media).create,
+ ankiConnectConfig: ankiConfig(env, {
+ fields: { audio: 'ExpressionAudio', image: 'Picture', sentence: 'Sentence' },
+ media: { generateAudio: true, generateImage: true, imageType },
+ }),
+ });
+
+ const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.equal(body.errors, undefined);
+ const { fields } = sentNote(env.requests, 'updateNoteFields');
+ assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
+ assert.match(
+ fields.Picture ?? '',
+ new RegExp(`^
$`),
+ );
+ assert.equal(fields.ExpressionAudio, undefined);
+ assert.equal(fields.SelectionText, undefined);
+ });
+ });
+ }
+
+ for (const { missing, media, expectedError } of [
+ { missing: 'image', media: { audio: AUDIO }, expectedError: 'image: no image generated' },
+ { missing: 'audio', media: { image: IMAGE }, expectedError: 'audio: no audio generated' },
+ ]) {
+ it(`POST /api/stats/mine-card reports an error when requested word ${missing} generation returns nothing`, async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ addYomitanNote: async () => 777,
+ createMediaGenerator: fakeMedia(media).create,
+ ankiConnectConfig: ankiConfig(env, {
+ fields: { audio: 'ExpressionAudio', image: 'Picture', sentence: 'Sentence' },
+ media: { generateAudio: true, generateImage: true, imageType: 'static' },
+ }),
+ });
+
+ const { status, body } = await mineCard(app, 'word', { sourcePath: env.sourcePath });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.deepEqual(body.errors, [expectedError]);
+ });
+ });
+ }
+
+ for (const outcome of ['success', 'unavailable', 'throws', 'no-field'] as const) {
+ it(`POST /api/stats/mine-card updates full sentence furigana for word cards without media (${outcome})`, async () => {
+ const notesInfoFields = {
+ Sentence: { value: '猫' },
+ ...(outcome === 'no-field' ? {} : { sentencefurigana: { value: ' 猫[ねこ]' } }),
+ };
+ await withMiningEnv({ notesInfoFields }, async (env) => {
+ let calls = 0;
+ const app = createStatsApp(createMockTracker(), {
+ ankiConnectConfig: ankiConfig(env),
+ addYomitanNote: async () => 12345,
+ generateSentenceFurigana: async (text, word) => {
+ calls++;
+ assert.equal(text, '猫を見た。');
+ assert.equal(word, '猫');
+ if (outcome === 'throws') throw new Error('parser unavailable');
+ return outcome === 'success' ? ' 猫[ねこ]を 見[み]た。' : null;
+ },
+ });
+
+ const { status } = await mineCard(app, 'word', {
+ sourcePath: env.sourcePath,
+ sentence: '猫を見た。',
+ });
+
+ assert.equal(status, 200);
+ const { fields } = sentNote(env.requests, 'updateNoteFields');
+ assert.equal(fields.Sentence, '猫を見た。');
+ assert.equal(
+ fields.sentencefurigana,
+ outcome === 'no-field'
+ ? undefined
+ : outcome === 'success'
+ ? ' 猫[ねこ]を 見[み]た。'
+ : '',
+ );
+ assert.equal(calls, outcome === 'no-field' ? 0 : 1);
+ });
+ });
+ }
+
+ it('POST /api/stats/mine-card skips furigana highlighting when highlightWord is disabled', async () => {
+ const notesInfoFields = { Sentence: { value: '猫' }, SentenceFurigana: { value: ' 猫[ねこ]' } };
+ await withMiningEnv({ notesInfoFields }, async (env) => {
+ const highlights: Array = [];
+ const app = createStatsApp(createMockTracker(), {
+ ankiConnectConfig: ankiConfig(env, { behavior: { highlightWord: false } }),
+ addYomitanNote: async () => 12345,
+ generateSentenceFurigana: async (_text, highlightedText) => {
+ highlights.push(highlightedText);
+ return ' 猫[ねこ]を 見[み]た。';
+ },
+ });
+
+ const { status } = await mineCard(app, 'word', {
+ sourcePath: env.sourcePath,
+ sentence: '猫を見た。',
+ });
+
+ assert.equal(status, 200);
+ assert.deepEqual(highlights, [undefined]);
+ });
});
it('DELETE /api/stats/sessions/:sessionId deletes a session', async () => {
@@ -3508,46 +2206,6 @@ Aligned English subtitle
assert.deepEqual(await res.json(), { ok: true });
});
- it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => {
- let deleteCalls = 0;
- const app = createStatsApp(
- createMockTracker({
- deleteSessions: async () => {
- deleteCalls += 1;
- },
- }),
- );
-
- const res = await app.request('/api/stats/sessions', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: '{"sessionIds":[1.5,1e309,9007199254740992]}',
- });
-
- assert.equal(res.status, 400);
- assert.equal(deleteCalls, 0);
- });
-
- it('DELETE /api/stats/sessions rejects a partly invalid id list without deleting', async () => {
- let deleteCalls = 0;
- const app = createStatsApp(
- createMockTracker({
- deleteSessions: async () => {
- deleteCalls += 1;
- },
- }),
- );
-
- const res = await app.request('/api/stats/sessions', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: '{"sessionIds":[4,1.9,7]}',
- });
-
- assert.equal(res.status, 400);
- assert.equal(deleteCalls, 0);
- });
-
it('DELETE /api/stats/sessions deduplicates valid ids', async () => {
let deletedSessionIds: number[] = [];
const app = createStatsApp(
@@ -3560,7 +2218,7 @@ Aligned English subtitle
const res = await app.request('/api/stats/sessions', {
method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: '{"sessionIds":[4,4,7]}',
});
@@ -3575,7 +2233,7 @@ Aligned English subtitle
deleteAnime: async (animeId: number) => {
deletedAnimeId = animeId;
},
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/anime/7', { method: 'DELETE' });
@@ -3585,49 +2243,6 @@ Aligned English subtitle
assert.deepEqual(await res.json(), { ok: true });
});
- it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => {
- let deleteCalls = 0;
- const app = createStatsApp(
- createMockTracker({
- deleteAnime: async () => {
- deleteCalls += 1;
- },
- } as Partial),
- );
-
- const res = await app.request('/api/stats/anime/0', { method: 'DELETE' });
-
- assert.equal(res.status, 400);
- assert.equal(deleteCalls, 0);
- });
-
- it('DELETE /api/stats/anime/:animeId rejects malformed anime ids before deleting', async () => {
- let deletedAnimeId: number | null = null;
- const app = createStatsApp(
- createMockTracker({
- deleteAnime: async (animeId: number) => {
- deletedAnimeId = animeId;
- },
- }),
- );
-
- for (const animeId of [
- '1.9',
- '1.0',
- '1e2',
- '9007199254740992',
- '1%0A',
- '%201',
- '01',
- '+1',
- '0x1',
- ]) {
- const res = await app.request(`/api/stats/anime/${animeId}`, { method: 'DELETE' });
- assert.equal(res.status, 400, `accepted malformed anime id: ${animeId}`);
- }
- assert.equal(deletedAnimeId, null);
- });
-
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
const app = createStatsApp(
@@ -3640,12 +2255,12 @@ Aligned English subtitle
movedVideos: 3,
};
},
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
// The target repeated in the sources must not delete the entry we keep.
body: '{"sourceAnimeIds":[8,9,8,7]}',
});
@@ -3668,7 +2283,7 @@ Aligned English subtitle
mergeCalls += 1;
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
},
- } as Partial),
+ }),
);
for (const body of [
@@ -3678,7 +2293,7 @@ Aligned English subtitle
]) {
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body,
});
assert.equal(res.status, 400);
@@ -3694,12 +2309,12 @@ Aligned English subtitle
moved = { videoId, animeId };
return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
},
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: '{"animeId":7}',
});
@@ -3721,12 +2336,12 @@ Aligned English subtitle
mergedAnimeIds: [],
movedVideos: 0,
}),
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: '{"sourceAnimeIds":[8]}',
});
@@ -3739,12 +2354,12 @@ Aligned English subtitle
mergeAnime: async () => {
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
},
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: '{"sourceAnimeIds":[8]}',
});
@@ -3758,12 +2373,12 @@ Aligned English subtitle
moveVideoToAnime: async () => {
throw new Error('Unknown episode or target library entry');
},
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: '{"animeId":99}',
});
@@ -3776,12 +2391,12 @@ Aligned English subtitle
moveVideoToAnime: async () => {
throw new Error('database is locked');
},
- } as Partial),
+ }),
);
const res = await app.request('/api/stats/media/12/anime', {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: '{"animeId":7}',
});
@@ -3874,7 +2489,7 @@ Aligned English subtitle
}),
{
status: 200,
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
},
);
}) as typeof fetch;
@@ -3886,7 +2501,7 @@ Aligned English subtitle
});
const res = await app.request('/api/stats/anki/notesInfo', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ noteIds: [111] }),
});
@@ -3937,7 +2552,7 @@ Aligned English subtitle
}),
{
status: 200,
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
},
)) as typeof fetch;
@@ -3953,7 +2568,7 @@ Aligned English subtitle
});
const res = await app.request('/api/stats/anki/notesInfo', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ noteIds: [333] }),
});
@@ -3995,7 +2610,7 @@ Aligned English subtitle
}),
{
status: 200,
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
},
)) as typeof fetch;
@@ -4012,7 +2627,7 @@ Aligned English subtitle
});
const res = await app.request('/api/stats/anki/notesInfo', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ noteIds: [444] }),
});
@@ -4311,7 +2926,7 @@ it('TMDB reassignment returns 404 for a missing library entry before fetching de
const request = (animeId: number) =>
app.request(`/api/stats/anime/${animeId}/tmdb`, {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: JSON_HEADERS,
body: JSON.stringify({ tmdbId: 12, tmdbType: 'tv' }),
});
assert.equal((await request(99999)).status, 404);
@@ -4323,101 +2938,3 @@ it('TMDB reassignment returns 404 for a missing library entry before fetching de
assert.equal(fetches, 1);
assert.deepEqual(assignments, [1]);
});
-
-for (const outcome of ['success', 'unavailable', 'throws', 'no-field'] as const) {
- it(`stats word mining updates full sentence furigana without media (${outcome})`, async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
- await withFakeAnkiConnect(
- async (requests, url) => {
- let calls = 0;
- const app = createStatsApp(createMockTracker(), {
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- media: { generateAudio: false, generateImage: false },
- },
- addYomitanNote: async () => 12345,
- generateSentenceFurigana: async (text, word) => {
- calls++;
- assert.equal(text, '猫を見た。');
- assert.equal(word, '猫');
- if (outcome === 'throws') throw new Error('parser unavailable');
- return outcome === 'success' ? ' 猫[ねこ]を 見[み]た。' : null;
- },
- });
- const response = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1000,
- endMs: 2000,
- sentence: '猫を見た。',
- word: '猫',
- }),
- });
- assert.equal(response.status, 200);
- const fields = requests.find((request) => request.action === 'updateNoteFields')?.params
- ?.note?.fields;
- assert.equal(fields?.Sentence, '猫を見た。');
- assert.equal(
- fields?.sentencefurigana,
- outcome === 'no-field'
- ? undefined
- : outcome === 'success'
- ? ' 猫[ねこ]を 見[み]た。'
- : '',
- );
- assert.equal(calls, outcome === 'no-field' ? 0 : 1);
- },
- {
- notesInfoFields: {
- Sentence: { value: '猫' },
- ...(outcome === 'no-field' ? {} : { sentencefurigana: { value: ' 猫[ねこ]' } }),
- },
- },
- );
- });
- });
-}
-
-it('stats word mining skips furigana highlighting when highlightWord is disabled', async () => {
- await withTempDir(async (dir) => {
- const sourcePath = path.join(dir, 'episode.mkv');
- fs.writeFileSync(sourcePath, 'fake media');
- await withFakeAnkiConnect(
- async (_requests, url) => {
- const highlights: Array = [];
- const app = createStatsApp(createMockTracker(), {
- ankiConnectConfig: {
- url,
- deck: 'Mining',
- media: { generateAudio: false, generateImage: false },
- behavior: { highlightWord: false },
- },
- addYomitanNote: async () => 12345,
- generateSentenceFurigana: async (_text, highlightedText) => {
- highlights.push(highlightedText);
- return ' 猫[ねこ]を 見[み]た。';
- },
- });
- const response = await app.request('/api/stats/mine-card?mode=word', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sourcePath,
- startMs: 1000,
- endMs: 2000,
- sentence: '猫を見た。',
- word: '猫',
- }),
- });
- assert.equal(response.status, 200);
- assert.deepEqual(highlights, [undefined]);
- },
- { notesInfoFields: { Sentence: { value: '猫' }, SentenceFurigana: { value: ' 猫[ねこ]' } } },
- );
- });
-});
diff --git a/src/core/services/anki-jimaku.test.ts b/src/core/services/anki-jimaku.test.ts
index fe820288..c1bf42e0 100644
--- a/src/core/services/anki-jimaku.test.ts
+++ b/src/core/services/anki-jimaku.test.ts
@@ -158,33 +158,6 @@ function createHarness(): RuntimeHarness {
return { options, registered, state };
}
-test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
- const { registered } = createHarness();
- const expected = [
- 'setAnkiConnectEnabled',
- 'clearAnkiHistory',
- 'refreshKnownWords',
- 'respondFieldGrouping',
- 'buildKikuMergePreview',
- 'getJimakuMediaInfo',
- 'searchJimakuEntries',
- 'listJimakuFiles',
- 'resolveJimakuApiKey',
- 'getCurrentMediaPath',
- 'isRemoteMediaPath',
- 'downloadToFile',
- 'onDownloadedSubtitle',
- 'searchTsukihimeEntries',
- 'listTsukihimeFiles',
- 'downloadTsukihimeSubtitle',
- 'onDownloadedSecondarySubtitle',
- ];
-
- for (const key of expected) {
- assert.equal(typeof registered[key], 'function', `missing handler: ${key}`);
- }
-});
-
test('refreshKnownWords throws when integration is unavailable', async () => {
const { registered } = createHarness();
@@ -196,20 +169,6 @@ test('refreshKnownWords throws when integration is unavailable', async () => {
);
});
-test('refreshKnownWords delegates to integration', async () => {
- const { registered, state } = createHarness();
- let refreshed = 0;
- state.ankiIntegration = {
- refreshKnownWordCache: async () => {
- refreshed += 1;
- },
- };
-
- await registered.refreshKnownWords!();
-
- assert.equal(refreshed, 1);
-});
-
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
const { registered, state } = createHarness();
let destroyed = 0;
@@ -274,30 +233,6 @@ test('buildKikuMergePreview returns guard error when integration is missing', as
});
});
-test('buildKikuMergePreview delegates to integration when available', async () => {
- const { registered, state } = createHarness();
- const calls: unknown[] = [];
- state.ankiIntegration = {
- buildFieldGroupingPreview: async (
- keepNoteId: number,
- deleteNoteId: number,
- deleteDuplicate: boolean,
- ) => {
- calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
- return { ok: true };
- },
- };
-
- const result = await registered.buildKikuMergePreview!({
- keepNoteId: 3,
- deleteNoteId: 4,
- deleteDuplicate: true,
- });
-
- assert.deepEqual(calls, [[3, 4, true]]);
- assert.deepEqual(result, { ok: true });
-});
-
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
const { registered, state } = createHarness();
diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts
index 7e91afb1..a51fb49d 100644
--- a/src/core/services/app-ready.test.ts
+++ b/src/core/services/app-ready.test.ts
@@ -103,17 +103,6 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
);
});
-test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
- const { deps, calls } = makeDeps({
- shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
- });
-
- await runAppReadyRuntime(deps);
-
- assert.equal(calls.includes('createImmersionTracker'), false);
- assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
-});
-
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
@@ -226,22 +215,6 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
);
});
-test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => {
- const { deps, calls } = makeDeps({
- createImmersionTracker: undefined,
- });
- await runAppReadyRuntime(deps);
- assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.'));
-});
-
-test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {
- const { deps, calls } = makeDeps({
- shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
- });
- await runAppReadyRuntime(deps);
- assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'));
-});
-
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts
index c04cfece..ae10984e 100644
--- a/src/core/services/cli-command.test.ts
+++ b/src/core/services/cli-command.test.ts
@@ -1,11 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
-import {
- CliCommandServiceDeps,
- createCliCommandDepsRuntime,
- handleCliCommand,
-} from './cli-command';
+import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial = {}): CliArgs {
return {
@@ -561,136 +557,6 @@ test('handleCliCommand applies socket path and connects on start', () => {
assert.ok(calls.includes('connectMpvClient'));
});
-test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
- const calls: string[] = [];
- const client = {
- setSocketPath: (socketPath: string) => {
- calls.push(`setSocketPath:${socketPath}`);
- },
- connect: () => {
- calls.push('connect');
- },
- reconnect: () => {
- calls.push('reconnect');
- },
- };
- const deps = createCliCommandDepsRuntime({
- mpv: {
- getSocketPath: () => '/tmp/runtime.sock',
- setSocketPath: () => {},
- getClient: () => client,
- showOsd: () => {},
- },
- texthooker: {
- service: { isRunning: () => false, start: () => {} },
- getPort: () => 5174,
- setPort: () => {},
- getWebsocketUrl: () => undefined,
- shouldOpenBrowser: () => false,
- openInBrowser: () => {},
- },
- overlay: {
- isInitialized: () => true,
- initialize: () => {},
- toggleVisible: () => {},
- togglePrimarySubtitleBar: () => {},
- setVisible: () => {},
- },
- mining: {
- copyCurrentSubtitle: () => {},
- startPendingMultiCopy: () => {},
- mineSentenceCard: async () => {},
- startPendingMineSentenceMultiple: () => {},
- updateLastCardFromClipboard: async () => {},
- refreshKnownWords: async () => {},
- triggerFieldGrouping: async () => {},
- triggerSubsyncFromConfig: async () => {},
- markLastCardAsAudioCard: async () => {},
- },
- anilist: {
- getStatus: () => ({
- tokenStatus: 'not_checked',
- tokenSource: 'none',
- tokenMessage: null,
- tokenResolvedAt: null,
- tokenErrorAt: null,
- queuePending: 0,
- queueReady: 0,
- queueDeadLetter: 0,
- queueLastAttemptAt: null,
- queueLastError: null,
- }),
- clearToken: () => {},
- openSetup: () => {},
- getQueueStatus: () => ({
- pending: 0,
- ready: 0,
- deadLetter: 0,
- lastAttemptAt: null,
- lastError: null,
- }),
- retryQueueNow: async () => ({ ok: true, message: 'ok' }),
- },
- dictionary: {
- generate: async () => ({
- zipPath: '/tmp/test.zip',
- fromCache: false,
- mediaId: 1,
- mediaTitle: 'Test',
- entryCount: 0,
- }),
- getSelection: async () => ({
- seriesKey: 'test',
- guessTitle: null,
- current: null,
- override: null,
- candidates: [],
- }),
- setSelection: async () => ({
- ok: true,
- seriesKey: 'test',
- selected: { id: 1, title: 'Test', episodes: null },
- staleMediaIds: [],
- }),
- },
- jellyfin: {
- openSetup: () => {},
- runStatsCommand: async () => {},
- runCommand: async () => {},
- },
- ui: {
- openFirstRunSetup: () => {},
- openHachidoriSettings: () => {},
- openYomitanSettings: () => {},
- openConfigSettingsWindow: () => {},
- openSyncUiWindow: () => {},
- openYoutubeBrowserWindow: () => {},
- cycleSecondarySubMode: () => {},
- openRuntimeOptionsPalette: () => {},
- printHelp: () => {},
- },
- app: {
- stop: () => {},
- hasMainWindow: () => true,
- runUpdateCommand: async () => {},
- runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
- runYoutubePlaybackFlow: async () => {},
- },
- dispatchSessionAction: async () => {},
- getMultiCopyTimeoutMs: () => 2500,
- schedule: () => undefined,
- log: () => {},
- logDebug: () => {},
- warn: () => {},
- error: () => {},
- });
-
- deps.setMpvClientSocketPath('/tmp/runtime.sock');
- deps.connectMpvClient();
-
- assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
-});
-
test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
@@ -887,13 +753,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
});
});
-test('handleCliCommand logs AniList status details', () => {
- const { deps, calls } = createDeps();
- handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
- assert.ok(calls.some((value) => value.startsWith('log:AniList token status:')));
- assert.ok(calls.some((value) => value.startsWith('log:AniList queue:')));
-});
-
test('handleCliCommand runs AniList retry command', async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts
index 57149b46..36092309 100644
--- a/src/core/services/discord-presence.test.ts
+++ b/src/core/services/discord-presence.test.ts
@@ -49,28 +49,6 @@ test('buildDiscordPresenceActivity falls back to idle with default style', () =>
assert.equal(payload.details, 'Sentence Mining');
});
-test('buildDiscordPresenceActivity uses meme style fallback', () => {
- const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
- const payload = buildDiscordPresenceActivity(memeConfig, {
- ...baseSnapshot,
- connected: false,
- mediaPath: null,
- });
- assert.equal(payload.details, 'Mining and crafting (Anki cards)');
- assert.equal(payload.smallImageText, 'Sentence Mining');
-});
-
-test('buildDiscordPresenceActivity uses japanese style', () => {
- const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
- const payload = buildDiscordPresenceActivity(jpConfig, {
- ...baseSnapshot,
- connected: false,
- mediaPath: null,
- });
- assert.equal(payload.details, '文の採掘中');
- assert.equal(payload.smallImageText, 'イマージョン学習');
-});
-
test('buildDiscordPresenceActivity uses minimal style', () => {
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
const payload = buildDiscordPresenceActivity(minConfig, {
diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts
index c485ce05..6a14fad4 100644
--- a/src/core/services/field-grouping-overlay.test.ts
+++ b/src/core/services/field-grouping-overlay.test.ts
@@ -3,78 +3,6 @@ import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice } from '../../types';
import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
-test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => {
- const sent: unknown[][] = [];
- let visible = false;
- const restore = new Set<'runtime-options' | 'subsync'>();
-
- const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
- getMainWindow: () => ({
- isDestroyed: () => false,
- webContents: {
- isLoading: () => false,
- send: (...args: unknown[]) => {
- sent.push(args);
- },
- },
- }),
- getVisibleOverlayVisible: () => visible,
- setVisibleOverlayVisible: (next) => {
- visible = next;
- },
- getResolver: () => null,
- setResolver: () => {},
- getRestoreVisibleOverlayOnModalClose: () => restore,
- });
-
- const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, {
- restoreOnModalClose: 'runtime-options',
- });
-
- assert.equal(ok, true);
- assert.equal(visible, true);
- assert.equal(restore.has('runtime-options'), true);
- assert.deepEqual(sent, [['runtime-options:open']]);
-});
-
-test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => {
- let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
- const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
- getMainWindow: () => null,
- getVisibleOverlayVisible: () => false,
- setVisibleOverlayVisible: () => {},
- getResolver: () => resolver,
- setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
- resolver = next;
- },
- getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
- });
-
- const callback = runtime.createFieldGroupingCallback();
- const result = await callback({
- original: {
- noteId: 1,
- expression: 'a',
- sentencePreview: 'a',
- hasAudio: false,
- hasImage: false,
- isOriginal: true,
- },
- duplicate: {
- noteId: 2,
- expression: 'b',
- sentencePreview: 'b',
- hasAudio: false,
- hasImage: false,
- isOriginal: false,
- },
- });
-
- assert.equal(result.cancelled, true);
- assert.equal(result.keepNoteId, 0);
- assert.equal(result.deleteNoteId, 0);
-});
-
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
let resolver: unknown = null;
let visible = false;
diff --git a/src/core/services/field-grouping.test.ts b/src/core/services/field-grouping.test.ts
index 4feece86..d8210465 100644
--- a/src/core/services/field-grouping.test.ts
+++ b/src/core/services/field-grouping.test.ts
@@ -98,124 +98,59 @@ test('field grouping callback does not reject the next request after a response'
assert.equal(secondChoice.keepNoteId, 2);
});
-test('field grouping callback dismisses the modal UI when the send fails', async () => {
- const store = createWrappedResolverStore();
- let dismissed = 0;
- const callback = createFieldGroupingCallback({
- getVisibleOverlayVisible: () => false,
- setVisibleOverlayVisible: () => {},
- getResolver: store.getResolver,
- setResolver: store.setResolver,
- sendRequestToVisibleOverlay: () => false,
- dismissModalUi: () => {
- dismissed += 1;
- },
- });
+const dismissCases = [
+ { name: 'the send fails', sends: false, responseTimeoutMs: undefined },
+ { name: 'the response times out', sends: true, responseTimeoutMs: 5 },
+];
- const result = await callback(makeRequestData());
- assert.equal(result.cancelled, true);
- assert.equal(dismissed, 1);
- assert.equal(store.getResolver(), null);
-});
-
-test('field grouping callback handles modal dismiss failures on send failure', async () => {
- const store = createWrappedResolverStore();
- const originalConsoleError = console.error;
- console.error = () => {};
- try {
+for (const c of dismissCases) {
+ test(`field grouping callback dismisses the modal UI when ${c.name}`, async () => {
+ const store = createWrappedResolverStore();
+ let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
- sendRequestToVisibleOverlay: () => false,
+ sendRequestToVisibleOverlay: () => c.sends,
dismissModalUi: () => {
- throw new Error('dismiss failed');
+ dismissed += 1;
},
+ responseTimeoutMs: c.responseTimeoutMs,
});
const result = await callback(makeRequestData());
-
assert.equal(result.cancelled, true);
+ assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
- } finally {
- console.error = originalConsoleError;
- }
-});
-
-test('field grouping callback handles modal dismiss failures on timeout', async () => {
- const store = createWrappedResolverStore();
- const originalConsoleError = console.error;
- console.error = () => {};
- try {
- const callback = createFieldGroupingCallback({
- getVisibleOverlayVisible: () => false,
- setVisibleOverlayVisible: () => {},
- getResolver: store.getResolver,
- setResolver: store.setResolver,
- sendRequestToVisibleOverlay: () => true,
- dismissModalUi: () => {
- throw new Error('dismiss failed');
- },
- responseTimeoutMs: 5,
- });
-
- const result = await callback(makeRequestData());
-
- assert.equal(result.cancelled, true);
- assert.equal(store.getResolver(), null);
- } finally {
- console.error = originalConsoleError;
- }
-});
-
-test('field grouping callback reports modal dismiss failures', async () => {
- const store = createWrappedResolverStore();
- const errors: unknown[] = [];
- const originalConsoleError = console.error;
- console.error = (...args: unknown[]) => {
- errors.push(args);
- };
- try {
- const callback = createFieldGroupingCallback({
- getVisibleOverlayVisible: () => false,
- setVisibleOverlayVisible: () => {},
- getResolver: store.getResolver,
- setResolver: store.setResolver,
- sendRequestToVisibleOverlay: () => false,
- dismissModalUi: () => {
- throw new Error('dismiss failed');
- },
- });
-
- await callback(makeRequestData());
-
- assert.equal(errors.length, 1);
- } finally {
- console.error = originalConsoleError;
- }
-});
-
-test('field grouping callback dismisses the modal UI when the response times out', async () => {
- const store = createWrappedResolverStore();
- let dismissed = 0;
- const callback = createFieldGroupingCallback({
- getVisibleOverlayVisible: () => false,
- setVisibleOverlayVisible: () => {},
- getResolver: store.getResolver,
- setResolver: store.setResolver,
- sendRequestToVisibleOverlay: () => true,
- dismissModalUi: () => {
- dismissed += 1;
- },
- responseTimeoutMs: 5,
});
- const result = await callback(makeRequestData());
- assert.equal(result.cancelled, true);
- assert.equal(dismissed, 1);
- assert.equal(store.getResolver(), null);
-});
+ test(`field grouping callback still cancels and clears the resolver when ${c.name} and dismiss throws`, async () => {
+ const store = createWrappedResolverStore();
+ const originalConsoleError = console.error;
+ console.error = () => {};
+ try {
+ const callback = createFieldGroupingCallback({
+ getVisibleOverlayVisible: () => false,
+ setVisibleOverlayVisible: () => {},
+ getResolver: store.getResolver,
+ setResolver: store.setResolver,
+ sendRequestToVisibleOverlay: () => c.sends,
+ dismissModalUi: () => {
+ throw new Error('dismiss failed');
+ },
+ responseTimeoutMs: c.responseTimeoutMs,
+ });
+
+ const result = await callback(makeRequestData());
+
+ assert.equal(result.cancelled, true);
+ assert.equal(store.getResolver(), null);
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+}
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
const store = createWrappedResolverStore();
diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts
index ea6fc36e..4adb7bbd 100644
--- a/src/core/services/hyprland-window-placement.test.ts
+++ b/src/core/services/hyprland-window-placement.test.ts
@@ -499,36 +499,13 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H
});
assert.equal(placed, true);
- assert.deepEqual(
- calls.map(([, args]) => args),
- [
- ['-j', 'clients'],
- ['-j', 'status'],
- ['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'],
- ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
- [
- 'dispatch',
- 'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })',
- ],
- [
- 'dispatch',
- 'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })',
- ],
- [
- 'dispatch',
- 'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })',
- ],
- [
- 'dispatch',
- 'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })',
- ],
- [
- 'dispatch',
- 'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })',
- ],
- ['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'],
- ],
- );
+ const dispatches = calls
+ .map(([, args]) => args as string[])
+ .filter(([verb]) => verb === 'dispatch');
+ assert.ok(dispatches.length > 0);
+ for (const [, dispatcher] of dispatches) {
+ assert.ok(dispatcher!.startsWith('hl.dsp.window.'), dispatcher);
+ }
});
test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => {
diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts
index 0e30740f..bbdbed68 100644
--- a/src/core/services/immersion-tracker-service.test.ts
+++ b/src/core/services/immersion-tracker-service.test.ts
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
-import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue';
import { toDbTimestamp } from './immersion-tracker/query-shared';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
@@ -137,11 +136,6 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () =>
assert.equal((queue[1] as Extract).eventType, 3);
});
-test('seam: toMonthKey uses UTC calendar month', () => {
- assert.equal(toMonthKey(-86_400_000), 196912);
- assert.equal(toMonthKey(0), 197001);
-});
-
test('startSession generates UUID-like session identifiers', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -528,37 +522,6 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
}
});
-test('fresh tracker DB creates lifetime summary tables', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
-
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath });
-
- const db = new Database(dbPath);
- const tableRows = db
- .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
- .all() as Array<{ name: string }>;
- db.close();
-
- const tableNames = new Set(tableRows.map((row) => row.name));
- const expectedTables = [
- 'imm_lifetime_global',
- 'imm_lifetime_anime',
- 'imm_lifetime_media',
- 'imm_lifetime_applied_sessions',
- ];
-
- for (const tableName of expectedTables) {
- assert.ok(tableNames.has(tableName), `Expected ${tableName} to exist`);
- }
- } finally {
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
-
test('fresh tracker DB skips lexical rollup backfill work', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -3773,138 +3736,6 @@ test('monthly rollups are grouped by calendar month', async () => {
}
});
-test('flushSingle reuses cached prepared statements', async () => {
- const dbPath = makeDbPath();
- let tracker: ImmersionTrackerService | null = null;
- let originalPrepare: DatabaseSync['prepare'] | null = null;
-
- try {
- const Ctor = await loadTrackerCtor();
- tracker = new Ctor({ dbPath });
- const privateApi = tracker as unknown as {
- db: DatabaseSync;
- flushSingle: (write: {
- kind: 'telemetry' | 'event';
- sessionId: number;
- sampleMs: number;
- eventType?: number;
- lineIndex?: number | null;
- segmentStartMs?: number | null;
- segmentEndMs?: number | null;
- tokensDelta?: number;
- cardsDelta?: number;
- payloadJson?: string | null;
- totalWatchedMs?: number;
- activeWatchedMs?: number;
- linesSeen?: number;
- tokensSeen?: number;
- cardsMined?: number;
- lookupCount?: number;
- lookupHits?: number;
- yomitanLookupCount?: number;
- pauseCount?: number;
- pauseMs?: number;
- seekForwardCount?: number;
- seekBackwardCount?: number;
- mediaBufferEvents?: number;
- }) => void;
- };
-
- originalPrepare = privateApi.db.prepare;
- let prepareCalls = 0;
- privateApi.db.prepare = (...args: Parameters) => {
- prepareCalls += 1;
- return originalPrepare!.apply(privateApi.db, args);
- };
- const preparedRestore = originalPrepare;
-
- privateApi.db.exec(`
- INSERT INTO imm_videos (
- video_id,
- video_key,
- canonical_title,
- source_type,
- duration_ms,
- CREATED_DATE,
- LAST_UPDATE_DATE
- ) VALUES (
- 1,
- 'local:/tmp/prepared.mkv',
- 'Prepared',
- 1,
- 0,
- 1000,
- 1000
- )
- `);
-
- privateApi.db.exec(`
- INSERT INTO imm_sessions (
- session_id,
- session_uuid,
- video_id,
- started_at_ms,
- status,
- CREATED_DATE,
- LAST_UPDATE_DATE,
- ended_at_ms
- ) VALUES (
- 1,
- '33333333-3333-3333-3333-333333333333',
- 1,
- 1000,
- 2,
- 1000,
- 1000,
- 2000
- )
- `);
-
- privateApi.flushSingle({
- kind: 'telemetry',
- sessionId: 1,
- sampleMs: 1500,
- totalWatchedMs: 1000,
- activeWatchedMs: 1000,
- linesSeen: 1,
- tokensSeen: 2,
- cardsMined: 0,
- lookupCount: 0,
- lookupHits: 0,
- yomitanLookupCount: 0,
- pauseCount: 0,
- pauseMs: 0,
- seekForwardCount: 0,
- seekBackwardCount: 0,
- mediaBufferEvents: 0,
- });
-
- privateApi.flushSingle({
- kind: 'event',
- sessionId: 1,
- sampleMs: 1600,
- eventType: 1,
- lineIndex: 1,
- segmentStartMs: 0,
- segmentEndMs: 1000,
- tokensDelta: 2,
- cardsDelta: 0,
- payloadJson: '{"event":"subtitle-line"}',
- });
-
- privateApi.db.prepare = preparedRestore;
-
- assert.equal(prepareCalls, 0);
- } finally {
- if (tracker && originalPrepare) {
- const privateApi = tracker as unknown as { db: DatabaseSync };
- privateApi.db.prepare = originalPrepare;
- }
- tracker?.destroy();
- cleanupDbPath(dbPath);
- }
-});
-
test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains compatible', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
diff --git a/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts b/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts
index 50e1ec8c..95d40c6a 100644
--- a/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts
+++ b/src/core/services/immersion-tracker/__tests__/lexical-removal.test.ts
@@ -5,8 +5,9 @@ import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
-import { ensureSchema } from '../storage.js';
+import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from '../storage.js';
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
+import { pruneRawRetention } from '../maintenance.js';
const DAY_MS = 86_400_000;
const BASE_MS = 1_700_000_000_000;
@@ -30,7 +31,13 @@ function cleanupDbPath(dbPath: string): void {
*/
function seed(
db: DatabaseSync,
- lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
+ lines: Array<{
+ session: 1 | 2;
+ wordId: number;
+ dayOffset: number;
+ msOffset?: number;
+ count?: number;
+ }>,
options: { legacyRows?: boolean } = {},
): void {
db.exec(`
@@ -65,7 +72,7 @@ function seed(
let lineId = 0;
for (const line of lines) {
lineId += 1;
- const seenMs = BASE_MS + line.dayOffset * DAY_MS;
+ const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
if (options.legacyRows) {
@@ -83,13 +90,13 @@ function seed(
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
),
first_seen = (
- SELECT MIN(sl.CREATED_DATE) / 1000
+ SELECT MIN(sl.CREATED_DATE) / 1000.0
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id
),
last_seen = (
- SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
+ SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id
@@ -200,6 +207,167 @@ test('deleting the latest session moves last_seen back to the surviving line', (
}
});
+for (const legacyRows of [false, true]) {
+ test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
+ // Stored seen values are fractional seconds; whole-second rounding used to miss
+ // the removed extreme (or truncate the refreshed one).
+ const { db, dbPath } = createDb(
+ [
+ { session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
+ { session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
+ ],
+ { legacyRows },
+ );
+
+ try {
+ deleteSession(db, 2);
+
+ const word = readWord(db, 22);
+ assert.equal(word?.frequency, 1);
+ assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
+ assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
+ } finally {
+ db.close();
+ cleanupDbPath(dbPath);
+ }
+ });
+}
+
+test('deleting legacy history preserves first_seen when it predates the flush timestamp', () => {
+ const { db, dbPath } = createDb([
+ { session: 1, wordId: 23, dayOffset: 0, msOffset: 400 },
+ { session: 2, wordId: 23, dayOffset: 5, msOffset: 400 },
+ ]);
+
+ try {
+ // A queue delay and retention-pruned history cannot be distinguished from
+ // the old flush-time timestamp. Preserve the earlier date in either case.
+ db.prepare('UPDATE imm_words SET first_seen = ? WHERE id = 23').run(BASE_MS / 1000);
+
+ deleteSession(db, 1);
+
+ assert.equal(readWord(db, 23)?.firstSeen, BASE_MS / 1000);
+ } finally {
+ db.close();
+ cleanupDbPath(dbPath);
+ }
+});
+
+for (const legacyRows of [false, true]) {
+ for (const deletedSession of [2, 3]) {
+ test(`deleting retained session ${deletedSession} preserves pruned first_seen (legacyRows=${legacyRows})`, () => {
+ const { db, dbPath } = createDb(
+ [
+ { session: 1, wordId: 24, dayOffset: 0 },
+ { session: 2, wordId: 24, dayOffset: 1 },
+ ],
+ { legacyRows },
+ );
+
+ try {
+ const latestMs = BASE_MS + 2 * DAY_MS;
+ db.exec(`
+ INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
+ VALUES (3, 's3', 2, '${latestMs}', '${latestMs + 1000}', 2, ${latestMs}, ${latestMs});
+ INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
+ VALUES (3, 3, 2, 3, '猫', ${latestMs}, ${latestMs});
+ INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
+ VALUES (3, 24, 1, ${legacyRows ? 'NULL' : latestMs});
+ UPDATE imm_words SET frequency = 3, last_seen = ${latestMs / 1000} WHERE id = 24;
+ INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency)
+ SELECT 24, '猫', first_seen, last_seen, frequency FROM imm_words WHERE id = 24;
+ INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
+ SELECT line_id, 24, occurrence_count, seen_ms FROM imm_word_line_occurrences WHERE word_id = 24;
+ `);
+ pruneRawRetention(db, latestMs, {
+ eventsRetentionMs: Infinity,
+ telemetryRetentionMs: Infinity,
+ sessionsRetentionMs: 1.5 * DAY_MS,
+ });
+
+ deleteSession(db, deletedSession);
+
+ for (const table of ['imm_words', 'imm_kanji']) {
+ const row = db
+ .prepare(
+ `SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table} WHERE id = 24`,
+ )
+ .get() as { frequency: number; firstSeen: number; lastSeen: number };
+ assert.equal(row.frequency, 2);
+ assert.equal(row.firstSeen, BASE_MS / 1000, `${table} keeps the pruned discovery date`);
+ assert.equal(row.lastSeen, (BASE_MS + (deletedSession === 2 ? 2 : 1) * DAY_MS) / 1000);
+ }
+ } finally {
+ db.close();
+ cleanupDbPath(dbPath);
+ }
+ });
+ }
+}
+
+test('deleting a queued subtitle uses observation time despite a delayed flush', () => {
+ const { db, dbPath } = createDb([]);
+
+ try {
+ const stmts = createTrackerPreparedStatements(db);
+ for (const sessionId of [1, 2]) {
+ const seenMs = BASE_MS + (sessionId - 1) * DAY_MS + 400;
+ executeQueuedWrite(
+ {
+ kind: 'subtitleLine',
+ sessionId,
+ videoId: sessionId,
+ lineIndex: 1,
+ segmentStartMs: null,
+ segmentEndMs: null,
+ text: '猫',
+ wordOccurrences: [
+ {
+ headword: '猫',
+ word: '猫',
+ reading: 'ねこ',
+ partOfSpeech: 'noun',
+ pos1: '名詞',
+ pos2: '一般',
+ pos3: '',
+ occurrenceCount: 1,
+ frequencyRank: null,
+ },
+ ],
+ kanjiOccurrences: [{ kanji: '猫', occurrenceCount: 1 }],
+ firstSeen: seenMs / 1000,
+ lastSeen: seenMs / 1000,
+ },
+ stmts,
+ );
+ for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
+ const row = db
+ .prepare(`SELECT seen_ms AS seenMs FROM ${table} WHERE line_id = ?`)
+ .get(sessionId) as { seenMs: number };
+ assert.equal(row.seenMs, seenMs);
+ }
+ }
+
+ deleteSession(db, 1);
+
+ for (const table of ['imm_words', 'imm_kanji']) {
+ const row = db
+ .prepare(`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table}`)
+ .get() as {
+ frequency: number;
+ firstSeen: number;
+ lastSeen: number;
+ };
+ assert.equal(row.frequency, 1);
+ assert.equal(row.firstSeen, (BASE_MS + DAY_MS + 400) / 1000);
+ assert.equal(row.lastSeen, (BASE_MS + DAY_MS + 400) / 1000);
+ }
+ } finally {
+ db.close();
+ cleanupDbPath(dbPath);
+ }
+});
+
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
// Session 2 carries the middle occurrence; sessions bracket it in time.
const { db, dbPath } = createDb([
diff --git a/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts b/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts
deleted file mode 100644
index 6890ba7a..00000000
--- a/src/core/services/immersion-tracker/__tests__/maintenance-source.test.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import path from 'node:path';
-import test from 'node:test';
-
-test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
- const source = fs.readFileSync(
- path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
- 'utf8',
- );
- const start = source.indexOf('export function getRollupGroupsForSessions');
- const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
- const functionSource = source.slice(start, end);
-
- assert.match(functionSource, /'unixepoch', 'localtime'/);
- assert.doesNotMatch(functionSource, /UNION/);
- assert.doesNotMatch(functionSource, /86400000/);
-});
diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts
index d7fc64e5..eecaac2d 100644
--- a/src/core/services/immersion-tracker/__tests__/query.test.ts
+++ b/src/core/services/immersion-tracker/__tests__/query.test.ts
@@ -602,39 +602,6 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
}
});
-test('getDailyRollups limits by distinct days (not rows)', () => {
- const dbPath = makeDbPath();
- const db = openTestDb(dbPath);
-
- try {
- ensureSchema(db);
-
- const insert = db.prepare(
- `
- INSERT INTO imm_daily_rollups (
- rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
- total_tokens_seen, total_cards
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
- `,
- );
-
- insert.run(10, 1, 1, 1, 0, 0, 2);
- insert.run(10, 2, 1, 1, 0, 0, 3);
- insert.run(9, 1, 1, 1, 0, 0, 1);
- insert.run(8, 1, 1, 1, 0, 0, 1);
-
- const rows = getDailyRollups(db, 2);
- assert.equal(rows.length, 3);
- assert.ok(rows.every((r) => r.rollupDayOrMonth === 10 || r.rollupDayOrMonth === 9));
- assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 1));
- assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 2));
- assert.ok(rows.some((r) => r.rollupDayOrMonth === 9 && r.videoId === 1));
- } finally {
- db.close();
- cleanupDbPath(dbPath);
- }
-});
-
test('getTrendsDashboard returns chart-ready aggregated series', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -2075,20 +2042,6 @@ 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 = openTestDb(dbPath);
-
- try {
- ensureSchema(db);
- const rows = getVocabularyStats(db, 10);
- assert.deepEqual(rows, []);
- } finally {
- db.close();
- cleanupDbPath(dbPath);
- }
-});
-
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -2524,20 +2477,6 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
}
});
-test('getKanjiStats returns empty array when no kanji exist', () => {
- const dbPath = makeDbPath();
- const db = openTestDb(dbPath);
-
- try {
- ensureSchema(db);
- const rows = getKanjiStats(db, 10);
- assert.deepEqual(rows, []);
- } finally {
- db.close();
- cleanupDbPath(dbPath);
- }
-});
-
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -2643,29 +2582,6 @@ 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 = openTestDb(dbPath);
-
- try {
- ensureSchema(db);
-
- const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', {
- canonicalTitle: 'Events Empty',
- sourcePath: '/tmp/events-empty.mkv',
- sourceUrl: null,
- sourceType: SOURCE_TYPE_LOCAL,
- });
- const { sessionId } = startSessionRecord(db, videoId, 6_000_000);
-
- const events = getSessionEvents(db, sessionId, 50);
- assert.deepEqual(events, []);
- } finally {
- db.close();
- cleanupDbPath(dbPath);
- }
-});
-
test('getSessionEvents filters events to the requested session id', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -4567,13 +4483,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
assert.ok(sharedWordRow);
assert.equal(sharedWordRow.frequency, 1);
- assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000));
- assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000));
+ assert.equal(sharedWordRow.first_seen, keptTs / 1000);
+ assert.equal(sharedWordRow.last_seen, keptTs / 1000);
assert.equal(deletedOnlyWordRow ?? null, null);
assert.ok(sharedKanjiRow);
assert.equal(sharedKanjiRow.frequency, 1);
- assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000));
- assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000));
+ assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
+ assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
assert.equal(deletedOnlyKanjiRow ?? null, null);
} finally {
db.close();
diff --git a/src/core/services/immersion-tracker/lexical-rollups.test.ts b/src/core/services/immersion-tracker/lexical-rollups.test.ts
index 60ce4daf..897b7dfc 100644
--- a/src/core/services/immersion-tracker/lexical-rollups.test.ts
+++ b/src/core/services/immersion-tracker/lexical-rollups.test.ts
@@ -16,7 +16,8 @@ import {
} from './query-lexical';
import { Database } from './sqlite';
import type { DatabaseSync } from './sqlite';
-import { ensureSchema } from './storage';
+import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
+import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
@@ -125,6 +126,121 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
}
});
+test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
+ const db = createDb();
+
+ try {
+ const videoId = seedVideo(db, null, 'episode');
+ const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
+ const firstSeen = BASE_MS / 1000;
+ // Legacy row whose stored merged-token POS fails the persistence filter.
+ db.prepare(
+ `INSERT INTO imm_words(
+ headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
+ ) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
+ ).run(firstSeen, firstSeen);
+ rebuildLexicalDailyRollups(db);
+
+ // The incoming token passes the filter, but the upsert keeps the stored POS.
+ executeQueuedWrite(
+ {
+ kind: 'subtitleLine',
+ sessionId,
+ videoId,
+ lineIndex: 1,
+ segmentStartMs: null,
+ segmentEndMs: null,
+ text: 'にし',
+ wordOccurrences: [
+ {
+ headword: '二死',
+ word: 'にし',
+ reading: 'にし',
+ partOfSpeech: 'verb',
+ pos1: '動詞',
+ pos2: '自立',
+ pos3: '*',
+ frequencyRank: null,
+ occurrenceCount: 1,
+ },
+ ],
+ kanjiOccurrences: [],
+ firstSeen: firstSeen + 60,
+ lastSeen: firstSeen + 60,
+ },
+ createTrackerPreparedStatements(db),
+ );
+
+ const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
+ (sum, day) => sum + day.wordCount,
+ 0,
+ );
+ assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
+ assert.equal(plotted, 0);
+ } finally {
+ db.close();
+ }
+});
+
+test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
+ const db = createDb();
+
+ try {
+ const videoId = seedVideo(db, null, 'episode');
+ const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
+ const lineId = Number(
+ db
+ .prepare(
+ `INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
+ VALUES (?, ?, 1, '猫', ?, ?)`,
+ )
+ .run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
+ );
+ const seen = (BASE_MS + 251) / 1000;
+ // first_seen this early may be real history pruned by retention; never touched.
+ const earlyFirst = seen - 86_400.5;
+ const staleLast = seen + 86_400.631;
+ db.prepare(
+ `INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
+ VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
+ ).run(earlyFirst, staleLast, seen - 0.1, seen - 0.1);
+ db.prepare(
+ `INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
+ ).run(earlyFirst, staleLast);
+ db.prepare(
+ `INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
+ VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
+ ).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
+ db.prepare(
+ `INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
+ VALUES (?, 1, 1, ?)`,
+ ).run(lineId, BASE_MS + 251);
+
+ rebuildLexicalDailyRollups(db);
+
+ const bounds = (table: string): Array<[number, number]> =>
+ (
+ db
+ .prepare(`SELECT first_seen AS first, last_seen AS last FROM ${table} ORDER BY id`)
+ .all() as Array<{
+ first: number;
+ last: number;
+ }>
+ ).map((row) => [row.first, row.last]);
+ assert.deepEqual(
+ bounds('imm_words'),
+ [
+ [earlyFirst, seen],
+ [seen - 0.1, seen - 0.1],
+ ],
+ 'healthy rows are untouched',
+ );
+ assert.deepEqual(bounds('imm_kanji'), [[earlyFirst, seen]]);
+ } finally {
+ db.close();
+ }
+});
+
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -268,7 +384,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
state_value INTEGER NOT NULL
);
INSERT INTO imm_rollup_state(state_key, state_value)
- VALUES ('lexical_daily_rollups_version', 2);
+ VALUES ('lexical_daily_rollups_version', 3);
`);
assert.equal(areLexicalDailyRollupsReady(db), true);
diff --git a/src/core/services/immersion-tracker/lexical-rollups.ts b/src/core/services/immersion-tracker/lexical-rollups.ts
index ea5480a9..f00b926d 100644
--- a/src/core/services/immersion-tracker/lexical-rollups.ts
+++ b/src/core/services/immersion-tracker/lexical-rollups.ts
@@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = `
) - 2440587.5 AS INTEGER)
`;
-const LEXICAL_DAILY_ROLLUP_VERSION = '2';
+// v3 re-derives visibility for rows a re-seen word forced visible and repairs
+// last_seen values left behind by session deletes.
+const LEXICAL_DAILY_ROLLUP_VERSION = '3';
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
@@ -26,6 +28,32 @@ export function localEpochDaySql(value: string): string {
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
}
+// Session deletes once rounded removed timestamps to whole seconds, missing
+// fractional last_seen extremes that pointed at deleted history. New seen_ms
+// values match observation time; legacy values were stamped later at flush.
+// Retention prunes the oldest sessions first, so healthy rows never exceed
+// their newest surviving occurrence. Rows with undated occurrences are skipped.
+//
+// first_seen is deliberately not repaired: a stale bound is indistinguishable
+// from real history whose occurrences retention pruned, and frequency does not
+// reliably tell them apart.
+function repairStaleLastSeen(db: DatabaseSync): void {
+ for (const [table, occurrenceTable, col] of [
+ ['imm_words', 'imm_word_line_occurrences', 'word_id'],
+ ['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
+ ] as const) {
+ const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
+ db.exec(`
+ UPDATE ${table}
+ SET last_seen = ${newestSeen}
+ WHERE last_seen > ${newestSeen}
+ AND NOT EXISTS (
+ SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
+ )
+ `);
+ }
+}
+
function createWordRollupTriggers(db: DatabaseSync): void {
const dayForNew = localEpochDaySql('NEW.first_seen');
const dayForOld = localEpochDaySql('OLD.first_seen');
@@ -186,6 +214,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
}
+ repairStaleLastSeen(db);
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.exec(`
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
diff --git a/src/core/services/immersion-tracker/maintenance.test.ts b/src/core/services/immersion-tracker/maintenance.test.ts
index 5ab59f34..12431ae0 100644
--- a/src/core/services/immersion-tracker/maintenance.test.ts
+++ b/src/core/services/immersion-tracker/maintenance.test.ts
@@ -4,12 +4,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from './sqlite';
-import {
- pruneRawRetention,
- pruneRollupRetention,
- runOptimizeMaintenance,
- toMonthKey,
-} from './maintenance';
+import { pruneRawRetention, pruneRollupRetention, toMonthKey } from './maintenance';
import { ensureSchema } from './storage';
import { toDbTimestamp } from './query-shared';
@@ -225,42 +220,3 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
cleanupDbPath(dbPath);
}
});
-
-test('ensureSchema adds sample_ms index for telemetry rollup scans', () => {
- const dbPath = makeDbPath();
- const db = new Database(dbPath);
-
- try {
- ensureSchema(db);
- const indexes = db.prepare("PRAGMA index_list('imm_session_telemetry')").all() as Array<{
- name: string;
- }>;
- const hasSampleMsIndex = indexes.some((row) => row.name === 'idx_telemetry_sample_ms');
- assert.equal(hasSampleMsIndex, true);
-
- const indexColumns = db.prepare("PRAGMA index_info('idx_telemetry_sample_ms')").all() as Array<{
- name: string;
- }>;
- assert.deepEqual(
- indexColumns.map((column) => column.name),
- ['sample_ms'],
- );
- } finally {
- db.close();
- cleanupDbPath(dbPath);
- }
-});
-
-test('runOptimizeMaintenance executes PRAGMA optimize', () => {
- const executedSql: string[] = [];
- const db = {
- exec(source: string) {
- executedSql.push(source);
- return this;
- },
- } as unknown as Parameters[0];
-
- runOptimizeMaintenance(db);
-
- assert.deepEqual(executedSql, ['PRAGMA optimize']);
-});
diff --git a/src/core/services/immersion-tracker/query-shared.ts b/src/core/services/immersion-tracker/query-shared.ts
index 5d09a19b..a445bab8 100644
--- a/src/core/services/immersion-tracker/query-shared.ts
+++ b/src/core/services/immersion-tracker/query-shared.ts
@@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos(
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
}
+// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
+// them, or comparisons against stored extremes miss and refreshes drift.
function toStoredSeenSeconds(ms: number | null): number | null {
if (ms === null || !Number.isFinite(ms)) return null;
- return Math.floor(ms / 1000);
+ return ms / 1000;
}
/**
@@ -320,6 +322,13 @@ export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan)
applyRemovalsForEntity(db, 'kanji', plan.kanji);
}
+// A timestamp repair must keep subtraction-exact lifetime counts and any
+// first_seen date that the deleted occurrences cannot account for.
+type LexicalAggregateOverride = {
+ frequency: number | null;
+ firstSeen: number | null;
+};
+
function applyRemovalsForEntity(
db: DatabaseSync,
entity: LexicalEntity,
@@ -358,7 +367,7 @@ function applyRemovalsForEntity(
`UPDATE ${entityTable} SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = ?`,
);
- const needsExactRefresh: number[] = [];
+ const needsExactRefresh = new Map();
for (const removal of removals) {
const current = selectStmt.get(removal.id) as {
@@ -369,17 +378,9 @@ function applyRemovalsForEntity(
if (!current) continue;
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
- if (nextFrequency <= 0) {
- // The rows in scope are already gone by now, so anything still pointing at
- // this entity means the stored frequency was stale rather than exhausted.
- if (hasOccurrencesStmt.get(removal.id)) {
- needsExactRefresh.push(removal.id);
- } else {
- deleteStmt.run(removal.id);
- }
- continue;
- }
-
+ // Legacy occurrences use flush time, which can follow the actual observation.
+ // An earlier stored date may also belong to retention-pruned history. Only
+ // move it when the removed timestamps prove that its observation was deleted.
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
const firstSeenMayHaveMoved =
@@ -388,11 +389,26 @@ function applyRemovalsForEntity(
const lastSeenMayHaveMoved =
current.lastSeen === null ||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
+ const exactRefreshOverride = {
+ frequency: nextFrequency > 0 ? nextFrequency : null,
+ firstSeen: firstSeenMayHaveMoved ? null : current.firstSeen,
+ };
+ if (nextFrequency <= 0) {
+ // The rows in scope are already gone by now, so anything still pointing at
+ // this entity means the stored frequency was stale rather than exhausted.
+ if (hasOccurrencesStmt.get(removal.id)) {
+ needsExactRefresh.set(removal.id, exactRefreshOverride);
+ } else {
+ deleteStmt.run(removal.id);
+ }
+ continue;
+ }
+
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
// Undated pre-migration occurrences are invisible to the seeks below;
- // fall back to the full re-aggregate that resolves their dates.
+ // resolve their dates without discarding retention-pruned aggregates.
if (hasUndatedOccurrenceStmt.get(removal.id)) {
- needsExactRefresh.push(removal.id);
+ needsExactRefresh.set(removal.id, exactRefreshOverride);
continue;
}
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
@@ -400,13 +416,13 @@ function applyRemovalsForEntity(
if (minSeenMs === null || maxSeenMs === null) {
// Frequency says occurrences remain but none exist: stale row, let the
// exact refresh reconcile (it deletes rows with nothing left).
- needsExactRefresh.push(removal.id);
+ needsExactRefresh.set(removal.id, exactRefreshOverride);
continue;
}
updateAggregatesStmt.run(
nextFrequency,
- Math.floor(Number(minSeenMs) / 1000),
- Math.floor(Number(maxSeenMs) / 1000),
+ firstSeenMayHaveMoved ? toStoredSeenSeconds(Number(minSeenMs)) : current.firstSeen,
+ toStoredSeenSeconds(Number(maxSeenMs)),
removal.id,
);
continue;
@@ -416,13 +432,17 @@ function applyRemovalsForEntity(
}
if (entity === 'word') {
- refreshWordAggregates(db, needsExactRefresh);
+ refreshWordAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
} else {
- refreshKanjiAggregates(db, needsExactRefresh);
+ refreshKanjiAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
}
}
-function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
+function refreshWordAggregates(
+ db: DatabaseSync,
+ wordIds: number[],
+ overrides?: ReadonlyMap