mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
Compare commits
12 Commits
4d95de51a0
...
efcacded66
| Author | SHA1 | Date | |
|---|---|---|---|
|
efcacded66
|
|||
|
615625d215
|
|||
|
90a9147363
|
|||
|
8f6877db12
|
|||
|
8e5cb5f885
|
|||
|
1408ad652a
|
|||
|
d5b746bd1d
|
|||
|
6139d14cd1
|
|||
|
9caf25bedb
|
|||
|
23b2360ac4
|
|||
|
742a0dabe5
|
|||
|
4c03e34caf
|
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -61,6 +61,16 @@ jobs:
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
- name: Coverage suite (maintained source lane)
|
||||
run: bun run test:coverage:src
|
||||
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-test-src
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -49,6 +49,16 @@ jobs:
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
- name: Coverage suite (maintained source lane)
|
||||
run: bun run test:coverage:src
|
||||
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-test-src
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
67
Backlog.md
67
Backlog.md
@@ -18,7 +18,9 @@ Priority keys:
|
||||
|
||||
## Active
|
||||
|
||||
None.
|
||||
| ID | Pri | Status | Area | Title |
|
||||
| ------ | --- | ------ | -------------- | --------------------------------------------------- |
|
||||
| SM-013 | P1 | doing | review-followup | Address PR #36 CodeRabbit action items |
|
||||
|
||||
## Ready
|
||||
|
||||
@@ -34,6 +36,8 @@ None.
|
||||
| SM-008 | P3 | todo | subtitles | Add core subtitle-position persistence/path tests |
|
||||
| SM-009 | P3 | todo | tokenizer | Add tests for JLPT token filter |
|
||||
| SM-010 | P1 | todo | immersion-tracker | Refactor storage + immersion-tracker service into focused modules |
|
||||
| SM-011 | P1 | done | tests | Add coverage reporting for maintained test lanes |
|
||||
| SM-012 | P2 | done | config/runtime | Replace JSON serialize-clone helpers with structured cloning |
|
||||
|
||||
## Icebox
|
||||
|
||||
@@ -45,7 +49,7 @@ None.
|
||||
|
||||
Title: Add tests for CLI parser and args normalizer
|
||||
Priority: P1
|
||||
Status: todo
|
||||
Status: done
|
||||
Scope:
|
||||
|
||||
- `launcher/config/cli-parser-builder.ts`
|
||||
@@ -192,3 +196,62 @@ Acceptance:
|
||||
- YouTube code split into pure utilities, a stateful manager (`YouTubeManager`), and a dedicated write queue (`WriteQueue`)
|
||||
- removed `storage.ts` is replaced with focused modules and updated imports
|
||||
- no API or migration regressions; existing tests for trackers/storage coverage remain green or receive focused updates
|
||||
|
||||
### SM-011
|
||||
|
||||
Title: Add coverage reporting for maintained test lanes
|
||||
Priority: P1
|
||||
Status: done
|
||||
Scope:
|
||||
|
||||
- `package.json`
|
||||
- CI workflow files under `.github/`
|
||||
- `docs/workflow/verification.md`
|
||||
Acceptance:
|
||||
- at least one maintained test lane emits machine-readable coverage output
|
||||
- CI surfaces coverage as an artifact, summary, or check output
|
||||
- local contributor path for coverage is documented
|
||||
- chosen coverage path works with Bun/TypeScript lanes already maintained by the repo
|
||||
Implementation note:
|
||||
- Added `bun run test:coverage:src` for the maintained source lane via a sharded coverage runner, with merged LCOV output at `coverage/test-src/lcov.info` and CI/release artifact upload as `coverage-test-src`.
|
||||
|
||||
### SM-012
|
||||
|
||||
Title: Replace JSON serialize-clone helpers with structured cloning
|
||||
Priority: P2
|
||||
Status: todo
|
||||
Scope:
|
||||
|
||||
- `src/runtime-options.ts`
|
||||
- `src/config/definitions.ts`
|
||||
- `src/config/service.ts`
|
||||
- `src/main/controller-config-update.ts`
|
||||
Acceptance:
|
||||
- runtime/config clone helpers stop using `JSON.parse(JSON.stringify(...))`
|
||||
- replacement preserves current behavior for plain config/runtime objects
|
||||
- focused tests cover clone/merge behavior that could regress during the swap
|
||||
- no new clone helper is introduced in these paths without a documented reason
|
||||
|
||||
Done:
|
||||
|
||||
- replaced JSON serialize-clone call sites in runtime/config/controller update paths with `structuredClone`
|
||||
- updated focused tests and fixtures to cover detached clone behavior and guard against regressions
|
||||
|
||||
### SM-013
|
||||
|
||||
Title: Address PR #36 CodeRabbit action items
|
||||
Priority: P1
|
||||
Status: doing
|
||||
Scope:
|
||||
|
||||
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
|
||||
- `scripts/subminer-change-verification.test.ts`
|
||||
- `src/core/services/immersion-tracker/query-sessions.ts`
|
||||
- `src/core/services/immersion-tracker/query-trends.ts`
|
||||
- `src/core/services/immersion-tracker/maintenance.ts`
|
||||
- `src/main/boot/services.ts`
|
||||
- `src/main/character-dictionary-runtime/zip.test.ts`
|
||||
Acceptance:
|
||||
- fix valid open CodeRabbit findings on PR #36
|
||||
- add focused regression coverage for behavior changes where practical
|
||||
- verify touched tests plus typecheck stay green
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
id: TASK-238.6
|
||||
title: Extract remaining inline runtime logic and composer gaps from src/main.ts
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-27 00:00'
|
||||
updated_date: '2026-03-27 22:13'
|
||||
labels:
|
||||
- tech-debt
|
||||
- runtime
|
||||
@@ -34,11 +35,11 @@ priority: high
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 `runYoutubePlaybackFlow`, `maybeSignalPluginAutoplayReady`, `refreshSubtitlePrefetchFromActiveTrack`, `publishDiscordPresence`, and `handleModalInputStateChange` no longer live as substantial inline logic in `src/main.ts`.
|
||||
- [ ] #2 The large subtitle/prefetch, stats startup, and overlay visibility dependency groupings are wrapped behind named composer helpers instead of remaining inline in `src/main.ts`.
|
||||
- [ ] #3 `src/main.ts` reads primarily as a boot and lifecycle coordinator, with domain behavior concentrated in named runtime modules.
|
||||
- [ ] #4 Focused tests cover the extracted behavior or the new composer surfaces.
|
||||
- [ ] #5 The task records whether the remaining size still justifies a boot-phase split or whether that follow-up can wait.
|
||||
- [x] #1 `runYoutubePlaybackFlow`, `maybeSignalPluginAutoplayReady`, `refreshSubtitlePrefetchFromActiveTrack`, `publishDiscordPresence`, and `handleModalInputStateChange` no longer live as substantial inline logic in `src/main.ts`.
|
||||
- [x] #2 The large subtitle/prefetch, stats startup, and overlay visibility dependency groupings are wrapped behind named composer helpers instead of remaining inline in `src/main.ts`.
|
||||
- [x] #3 `src/main.ts` reads primarily as a boot and lifecycle coordinator, with domain behavior concentrated in named runtime modules.
|
||||
- [x] #4 Focused tests cover the extracted behavior or the new composer surfaces.
|
||||
- [x] #5 The task records whether the remaining size still justifies a boot-phase split or whether that follow-up can wait.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -58,3 +59,26 @@ Guardrails:
|
||||
- Prefer moving logic to existing runtime surfaces over creating new giant helper files.
|
||||
- Do not expand into unrelated `src/main.ts` cleanup that is already tracked by other TASK-238 slices.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Extracted the remaining inline runtime seams from `src/main.ts` into focused runtime modules:
|
||||
`src/main/runtime/youtube-playback-runtime.ts`,
|
||||
`src/main/runtime/autoplay-ready-gate.ts`,
|
||||
`src/main/runtime/subtitle-prefetch-runtime.ts`,
|
||||
`src/main/runtime/discord-presence-runtime.ts`,
|
||||
and `src/main/runtime/overlay-modal-input-state.ts`.
|
||||
|
||||
Added named composer wrappers for the grouped subtitle/prefetch, stats startup, and overlay visibility wiring in `src/main/runtime/composers/`.
|
||||
|
||||
Re-scan result for the boot-phase split follow-up: the entrypoint is materially closer to a boot/lifecycle coordinator now, so TASK-238.7 remains a valid future cleanup but no longer feels urgent or blocking for maintainability.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
TASK-238.6 is complete. Verification passed with `bun run typecheck`, focused runtime/composer tests, `bun run test:fast`, `bun run test:env`, and `bun run build`. The remaining `src/main.ts` work is now better isolated behind runtime modules and composer helpers, and the boot-phase split can wait for a later cleanup pass instead of being treated as immediate follow-on work.
|
||||
|
||||
Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.6-main-runtime-refactor.md` under runtime internals.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
id: TASK-238.7
|
||||
title: Split src/main.ts into boot-phase services, runtimes, and handlers
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-27 00:00'
|
||||
updated_date: '2026-03-27 22:45'
|
||||
labels:
|
||||
- tech-debt
|
||||
- runtime
|
||||
@@ -31,11 +32,11 @@ After the remaining inline runtime logic and composer gaps are extracted, `src/m
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`.
|
||||
- [ ] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch.
|
||||
- [ ] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection.
|
||||
- [ ] #4 Existing startup behavior remains unchanged across desktop and headless flows.
|
||||
- [ ] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes.
|
||||
- [x] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`.
|
||||
- [x] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch.
|
||||
- [x] #3 Handler/composer invocation lives in a dedicated boot module, with `src/main.ts` reduced to app lifecycle and startup-path selection.
|
||||
- [x] #4 Existing startup behavior remains unchanged across desktop and headless flows.
|
||||
- [x] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
@@ -56,3 +57,29 @@ Guardrails:
|
||||
- Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer.
|
||||
- Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added boot-phase modules under `src/main/boot/`:
|
||||
`services.ts` for config/user-data/runtime-registry/overlay bootstrap service construction,
|
||||
`runtimes.ts` for named runtime/composer entrypoints and grouped boot-phase seams,
|
||||
and `handlers.ts` for handler/composer boot entrypoints.
|
||||
|
||||
Rewired `src/main.ts` to source boot-phase service construction from `createMainBootServices(...)` and to route runtime/handler composition through boot-level exports instead of keeping the entrypoint as the direct owner of every composition import.
|
||||
|
||||
Added focused tests for the new boot seams in
|
||||
`src/main/boot/services.test.ts`,
|
||||
`src/main/boot/runtimes.test.ts`,
|
||||
and `src/main/boot/handlers.test.ts`.
|
||||
|
||||
Updated internal architecture docs to note that `src/main/boot/` now owns boot-phase assembly seams so `src/main.ts` can stay centered on lifecycle coordination and startup-path selection.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
TASK-238.7 is complete. Verification passed with focused boot tests, `bun run typecheck`, `bun run test:fast`, and `bun run build`. `src/main.ts` still acts as the composition root, but the boot-phase split now moves service instantiation, runtime composition seams, and handler composition seams into dedicated `src/main/boot/*` modules so the entrypoint reads more like a lifecycle coordinator than a single monolithic bootstrap file.
|
||||
|
||||
Backlog completion now includes changelog artifact `changes/2026-03-27-task-238.7-main-boot-split.md` for the internal runtime architecture pass.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
5
changes/2026-03-27-sm-011-coverage-reporting.md
Normal file
5
changes/2026-03-27-sm-011-coverage-reporting.md
Normal file
@@ -0,0 +1,5 @@
|
||||
type: internal
|
||||
area: release
|
||||
|
||||
- Added a maintained source coverage lane that shards Bun coverage one test file at a time and merges LCOV output into `coverage/test-src/lcov.info`.
|
||||
- CI and release quality-gate now upload the merged source-lane LCOV artifact for inspection.
|
||||
6
changes/2026-03-27-task-238.6-main-runtime-refactor.md
Normal file
6
changes/2026-03-27-task-238.6-main-runtime-refactor.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: internal
|
||||
area: runtime
|
||||
|
||||
- Extracted remaining inline runtime logic from `src/main.ts` into dedicated runtime modules and composer helpers.
|
||||
- Added focused regression tests for the extracted runtime/composer boundaries.
|
||||
- Updated task tracking notes to mark TASK-238.6 complete and confirm follow-on boot-phase split can be deferred.
|
||||
6
changes/2026-03-27-task-238.7-main-boot-split.md
Normal file
6
changes/2026-03-27-task-238.7-main-boot-split.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: internal
|
||||
area: runtime
|
||||
|
||||
- Split `src/main.ts` boot wiring into dedicated `src/main/boot/services.ts`, `src/main/boot/runtimes.ts`, and `src/main/boot/handlers.ts` modules.
|
||||
- Added focused tests for the new boot-phase seams and kept the startup/typecheck/build verification lanes green.
|
||||
- Updated internal architecture/task docs to record the boot-phase split and new ownership boundary.
|
||||
@@ -10,13 +10,18 @@ let mermaidLoader: Promise<any> | null = null;
|
||||
let plausibleTrackerInitialized = false;
|
||||
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
||||
const PLAUSIBLE_DOMAIN = 'subminer.moe';
|
||||
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/event';
|
||||
const PLAUSIBLE_ENABLED_HOSTNAMES = new Set(['docs.subminer.moe']);
|
||||
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture';
|
||||
|
||||
async function initPlausibleTracker() {
|
||||
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { init } = await import('@plausible-analytics/tracker');
|
||||
init({
|
||||
domain: PLAUSIBLE_DOMAIN,
|
||||
|
||||
@@ -6,14 +6,17 @@ const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||
|
||||
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe', () => {
|
||||
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe capture endpoint', () => {
|
||||
expect(docsConfigContents).toContain("hostname: 'https://docs.subminer.moe'");
|
||||
expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = 'subminer.moe'");
|
||||
expect(docsThemeContents).toContain('const PLAUSIBLE_ENABLED_HOSTNAMES = new Set([');
|
||||
expect(docsThemeContents).toContain("'docs.subminer.moe'");
|
||||
expect(docsThemeContents).toContain(
|
||||
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/event'",
|
||||
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture'",
|
||||
);
|
||||
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
|
||||
expect(docsThemeContents).toContain('const { init } = await import');
|
||||
expect(docsThemeContents).toContain('!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)');
|
||||
expect(docsThemeContents).toContain('domain: PLAUSIBLE_DOMAIN');
|
||||
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
|
||||
expect(docsThemeContents).toContain('outboundLinks: true');
|
||||
|
||||
@@ -21,6 +21,7 @@ Read when: you need internal architecture, workflow, verification, or release gu
|
||||
|
||||
- New feature or refactor: [Workflow](./workflow/README.md), then [Architecture](./architecture/README.md)
|
||||
- Test/build/release work: [Verification](./workflow/verification.md), then [Release Guide](./RELEASING.md)
|
||||
- Coverage lane selection or LCOV artifact path: [Verification](./workflow/verification.md)
|
||||
- “What owns this behavior?”: [Domains](./architecture/domains.md)
|
||||
- “Can these modules depend on each other?”: [Layering](./architecture/layering.md)
|
||||
- “What doc should exist for this?”: [Catalog](./knowledge-base/catalog.md)
|
||||
|
||||
@@ -24,6 +24,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
||||
## Current Shape
|
||||
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
|
||||
@@ -31,8 +31,15 @@ bun run docs:build
|
||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
## Coverage Reporting
|
||||
|
||||
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
|
||||
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||
- CI and release quality-gate runs upload that LCOV file as the `coverage-test-src` artifact.
|
||||
|
||||
## Rules
|
||||
|
||||
- Capture exact failing command and error when verification breaks.
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||
"test:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist",
|
||||
"test:src": "bun scripts/run-test-lane.mjs bun-src-full",
|
||||
"test:coverage:src": "bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src",
|
||||
"test:coverage:subtitle:src": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage/test-subtitle src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
|
||||
"test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
|
||||
@@ -63,7 +65,7 @@
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
|
||||
@@ -113,15 +113,17 @@ run_step() {
|
||||
local name=$2
|
||||
local command=$3
|
||||
local note=${4:-}
|
||||
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||
local stdout_rel="steps/${slug}.stdout.log"
|
||||
local stderr_rel="steps/${slug}.stderr.log"
|
||||
local step_slug="${lane_slug}--${slug}"
|
||||
local stdout_rel="steps/${step_slug}.stdout.log"
|
||||
local stderr_rel="steps/${step_slug}.stderr.log"
|
||||
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
||||
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
||||
local status exit_code
|
||||
|
||||
COMMANDS_RUN+=("$command")
|
||||
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt"
|
||||
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${step_slug}.command.txt"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
||||
@@ -129,7 +131,11 @@ run_step() {
|
||||
status="dry-run"
|
||||
exit_code=0
|
||||
else
|
||||
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
||||
if HOME="$SESSION_HOME" \
|
||||
XDG_CONFIG_HOME="$SESSION_XDG_CONFIG_HOME" \
|
||||
SUBMINER_SESSION_LOGS_DIR="$SESSION_LOGS_DIR" \
|
||||
SUBMINER_SESSION_MPV_LOG="$SESSION_MPV_LOG" \
|
||||
bash -c "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
||||
status="passed"
|
||||
exit_code=0
|
||||
EXECUTED_REAL_STEPS=1
|
||||
@@ -157,9 +163,11 @@ record_nonpassing_step() {
|
||||
local name=$2
|
||||
local status=$3
|
||||
local note=$4
|
||||
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||
local stdout_rel="steps/${slug}.stdout.log"
|
||||
local stderr_rel="steps/${slug}.stderr.log"
|
||||
local step_slug="${lane_slug}--${slug}"
|
||||
local stdout_rel="steps/${step_slug}.stdout.log"
|
||||
local stderr_rel="steps/${step_slug}.stderr.log"
|
||||
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
||||
: >"$ARTIFACT_DIR/$stderr_rel"
|
||||
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
||||
@@ -179,8 +187,10 @@ record_failed_step() {
|
||||
FAILED=1
|
||||
FAILURE_STEP=$2
|
||||
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
||||
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
|
||||
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
|
||||
local lane_slug=${1//[^a-zA-Z0-9_-]/-}
|
||||
local step_slug=${2//[^a-zA-Z0-9_-]/-}
|
||||
FAILURE_STDOUT="steps/${lane_slug}--${step_slug}.stdout.log"
|
||||
FAILURE_STDERR="steps/${lane_slug}--${step_slug}.stderr.log"
|
||||
add_blocker "$3"
|
||||
record_nonpassing_step "$1" "$2" "failed" "$3"
|
||||
}
|
||||
@@ -212,7 +222,7 @@ acquire_real_runtime_lease() {
|
||||
if [[ -f "$lease_dir/session_id" ]]; then
|
||||
owner=$(cat "$lease_dir/session_id")
|
||||
fi
|
||||
add_blocker "real-runtime lease already held${owner:+ by $owner}"
|
||||
REAL_RUNTIME_LEASE_ERROR="real-runtime lease already held${owner:+ by $owner}"
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -377,8 +387,11 @@ FAILURE_COMMAND=""
|
||||
FAILURE_STDOUT=""
|
||||
FAILURE_STDERR=""
|
||||
REAL_RUNTIME_LEASE_DIR=""
|
||||
REAL_RUNTIME_LEASE_ERROR=""
|
||||
PATH_SELECTION_MODE="auto"
|
||||
|
||||
trap 'release_real_runtime_lease' EXIT
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--lane)
|
||||
@@ -486,7 +499,7 @@ for lane in "${SELECTED_LANES[@]}"; do
|
||||
continue
|
||||
fi
|
||||
if ! acquire_real_runtime_lease; then
|
||||
record_blocked_step "$lane" "real-runtime-lease" "${BLOCKERS[-1]}"
|
||||
record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR"
|
||||
continue
|
||||
fi
|
||||
helper=$(find_real_runtime_helper || true)
|
||||
|
||||
61
scripts/run-coverage-lane.test.ts
Normal file
61
scripts/run-coverage-lane.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { mergeLcovReports } from './run-coverage-lane';
|
||||
|
||||
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
|
||||
const merged = mergeLcovReports([
|
||||
[
|
||||
'SF:src/example.ts',
|
||||
'FN:10,alpha',
|
||||
'FNDA:1,alpha',
|
||||
'DA:10,1',
|
||||
'DA:11,0',
|
||||
'BRDA:10,0,0,1',
|
||||
'BRDA:10,0,1,-',
|
||||
'end_of_record',
|
||||
'',
|
||||
].join('\n'),
|
||||
[
|
||||
'SF:src/example.ts',
|
||||
'FN:10,alpha',
|
||||
'FN:20,beta',
|
||||
'FNDA:2,alpha',
|
||||
'FNDA:1,beta',
|
||||
'DA:10,2',
|
||||
'DA:11,1',
|
||||
'DA:20,1',
|
||||
'BRDA:10,0,0,0',
|
||||
'BRDA:10,0,1,1',
|
||||
'end_of_record',
|
||||
'',
|
||||
].join('\n'),
|
||||
]);
|
||||
|
||||
assert.match(merged, /SF:src\/example\.ts/);
|
||||
assert.match(merged, /FN:10,alpha/);
|
||||
assert.match(merged, /FN:20,beta/);
|
||||
assert.match(merged, /FNDA:3,alpha/);
|
||||
assert.match(merged, /FNDA:1,beta/);
|
||||
assert.match(merged, /FNF:2/);
|
||||
assert.match(merged, /FNH:2/);
|
||||
assert.match(merged, /DA:10,3/);
|
||||
assert.match(merged, /DA:11,1/);
|
||||
assert.match(merged, /DA:20,1/);
|
||||
assert.match(merged, /LF:3/);
|
||||
assert.match(merged, /LH:3/);
|
||||
assert.match(merged, /BRDA:10,0,0,1/);
|
||||
assert.match(merged, /BRDA:10,0,1,1/);
|
||||
assert.match(merged, /BRF:2/);
|
||||
assert.match(merged, /BRH:2/);
|
||||
});
|
||||
|
||||
test('mergeLcovReports keeps distinct source files as separate records', () => {
|
||||
const merged = mergeLcovReports([
|
||||
['SF:src/a.ts', 'DA:1,1', 'end_of_record', ''].join('\n'),
|
||||
['SF:src/b.ts', 'DA:2,1', 'end_of_record', ''].join('\n'),
|
||||
]);
|
||||
|
||||
assert.match(merged, /SF:src\/a\.ts[\s\S]*end_of_record/);
|
||||
assert.match(merged, /SF:src\/b\.ts[\s\S]*end_of_record/);
|
||||
});
|
||||
298
scripts/run-coverage-lane.ts
Normal file
298
scripts/run-coverage-lane.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
type LaneConfig = {
|
||||
roots: string[];
|
||||
include: string[];
|
||||
exclude: Set<string>;
|
||||
};
|
||||
|
||||
type LcovRecord = {
|
||||
sourceFile: string;
|
||||
functions: Map<string, number>;
|
||||
functionHits: Map<string, number>;
|
||||
lines: Map<number, number>;
|
||||
branches: Map<string, { line: number; block: string; branch: string; hits: number | null }>;
|
||||
};
|
||||
|
||||
const repoRoot = resolve(__dirname, '..');
|
||||
|
||||
const lanes: Record<string, LaneConfig> = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
exclude: new Set([
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
]),
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
||||
},
|
||||
};
|
||||
|
||||
function collectFiles(rootDir: string, includeSuffixes: string[], excludeSet: Set<string>): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (currentDir: string) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
function getLaneFiles(laneName: string): string[] {
|
||||
const lane = lanes[laneName];
|
||||
if (!lane) {
|
||||
throw new Error(`Unknown coverage lane: ${laneName}`);
|
||||
}
|
||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No test files found for coverage lane: ${laneName}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseCoverageDirArg(argv: string[]): string {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--coverage-dir') {
|
||||
const next = argv[index + 1];
|
||||
if (typeof next !== 'string') {
|
||||
throw new Error('Missing value for --coverage-dir');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return 'coverage';
|
||||
}
|
||||
|
||||
function parseLcovReport(report: string): LcovRecord[] {
|
||||
const records: LcovRecord[] = [];
|
||||
let current: LcovRecord | null = null;
|
||||
|
||||
const ensureCurrent = (): LcovRecord => {
|
||||
if (!current) {
|
||||
throw new Error('Malformed lcov report: record data before SF');
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
for (const rawLine of report.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('TN:')) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('SF:')) {
|
||||
current = {
|
||||
sourceFile: line.slice(3),
|
||||
functions: new Map(),
|
||||
functionHits: new Map(),
|
||||
lines: new Map(),
|
||||
branches: new Map(),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (line === 'end_of_record') {
|
||||
if (current) {
|
||||
records.push(current);
|
||||
current = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('FN:')) {
|
||||
const [lineNumber, ...nameParts] = line.slice(3).split(',');
|
||||
ensureCurrent().functions.set(nameParts.join(','), Number(lineNumber));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('FNDA:')) {
|
||||
const [hits, ...nameParts] = line.slice(5).split(',');
|
||||
ensureCurrent().functionHits.set(nameParts.join(','), Number(hits));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('DA:')) {
|
||||
const [lineNumber, hits] = line.slice(3).split(',');
|
||||
ensureCurrent().lines.set(Number(lineNumber), Number(hits));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('BRDA:')) {
|
||||
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
|
||||
if (lineNumber === undefined || block === undefined || branch === undefined || hits === undefined) {
|
||||
continue;
|
||||
}
|
||||
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
|
||||
line: Number(lineNumber),
|
||||
block,
|
||||
branch,
|
||||
hits: hits === '-' ? null : Number(hits),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
records.push(current);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
export function mergeLcovReports(reports: string[]): string {
|
||||
const merged = new Map<string, LcovRecord>();
|
||||
|
||||
for (const report of reports) {
|
||||
for (const record of parseLcovReport(report)) {
|
||||
let target = merged.get(record.sourceFile);
|
||||
if (!target) {
|
||||
target = {
|
||||
sourceFile: record.sourceFile,
|
||||
functions: new Map(),
|
||||
functionHits: new Map(),
|
||||
lines: new Map(),
|
||||
branches: new Map(),
|
||||
};
|
||||
merged.set(record.sourceFile, target);
|
||||
}
|
||||
|
||||
for (const [name, line] of record.functions) {
|
||||
if (!target.functions.has(name)) {
|
||||
target.functions.set(name, line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, hits] of record.functionHits) {
|
||||
target.functionHits.set(name, (target.functionHits.get(name) ?? 0) + hits);
|
||||
}
|
||||
|
||||
for (const [lineNumber, hits] of record.lines) {
|
||||
target.lines.set(lineNumber, (target.lines.get(lineNumber) ?? 0) + hits);
|
||||
}
|
||||
|
||||
for (const [branchKey, branchRecord] of record.branches) {
|
||||
const existing = target.branches.get(branchKey);
|
||||
if (!existing) {
|
||||
target.branches.set(branchKey, { ...branchRecord });
|
||||
continue;
|
||||
}
|
||||
if (branchRecord.hits === null) {
|
||||
continue;
|
||||
}
|
||||
existing.hits = (existing.hits ?? 0) + branchRecord.hits;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const sourceFile of [...merged.keys()].sort()) {
|
||||
const record = merged.get(sourceFile)!;
|
||||
chunks.push(`SF:${record.sourceFile}`);
|
||||
|
||||
const functions = [...record.functions.entries()].sort((a, b) =>
|
||||
a[1] === b[1] ? a[0].localeCompare(b[0]) : a[1] - b[1],
|
||||
);
|
||||
for (const [name, line] of functions) {
|
||||
chunks.push(`FN:${line},${name}`);
|
||||
}
|
||||
for (const [name] of functions) {
|
||||
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
|
||||
}
|
||||
chunks.push(`FNF:${functions.length}`);
|
||||
chunks.push(`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`);
|
||||
|
||||
const branches = [...record.branches.values()].sort((a, b) =>
|
||||
a.line === b.line
|
||||
? a.block === b.block
|
||||
? a.branch.localeCompare(b.branch)
|
||||
: a.block.localeCompare(b.block)
|
||||
: a.line - b.line,
|
||||
);
|
||||
for (const branch of branches) {
|
||||
chunks.push(
|
||||
`BRDA:${branch.line},${branch.block},${branch.branch},${branch.hits === null ? '-' : branch.hits}`,
|
||||
);
|
||||
}
|
||||
chunks.push(`BRF:${branches.length}`);
|
||||
chunks.push(`BRH:${branches.filter((branch) => (branch.hits ?? 0) > 0).length}`);
|
||||
|
||||
const lines = [...record.lines.entries()].sort((a, b) => a[0] - b[0]);
|
||||
for (const [lineNumber, hits] of lines) {
|
||||
chunks.push(`DA:${lineNumber},${hits}`);
|
||||
}
|
||||
chunks.push(`LF:${lines.length}`);
|
||||
chunks.push(`LH:${lines.filter(([, hits]) => hits > 0).length}`);
|
||||
chunks.push('end_of_record');
|
||||
}
|
||||
|
||||
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
||||
}
|
||||
|
||||
function runCoverageLane(): number {
|
||||
const laneName = process.argv[2];
|
||||
if (laneName === undefined) {
|
||||
process.stderr.write('Missing coverage lane name\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
const coverageDir = resolve(repoRoot, parseCoverageDirArg(process.argv.slice(3)));
|
||||
const shardRoot = join(coverageDir, '.shards');
|
||||
mkdirSync(coverageDir, { recursive: true });
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
mkdirSync(shardRoot, { recursive: true });
|
||||
|
||||
const files = getLaneFiles(laneName);
|
||||
const reports: string[] = [];
|
||||
|
||||
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: repoRoot,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
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'));
|
||||
}
|
||||
|
||||
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(runCoverageLane());
|
||||
}
|
||||
@@ -33,7 +33,7 @@ function runBash(args: string[]) {
|
||||
}
|
||||
|
||||
function parseArtifactDir(stdout: string): string {
|
||||
const match = stdout.match(/^artifact_dir=(.+)$/m);
|
||||
const match = stdout.match(/^artifacts: (.+)$/m);
|
||||
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
||||
return match[1] ?? '';
|
||||
}
|
||||
@@ -42,10 +42,17 @@ function readSummaryJson(artifactDir: string) {
|
||||
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
selectedLanes: string[];
|
||||
lanes: string[];
|
||||
blockers?: string[];
|
||||
artifactDir: string;
|
||||
pathSelectionMode?: string;
|
||||
steps: Array<{
|
||||
lane: string;
|
||||
name: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
note: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,15 +78,14 @@ test('verifier blocks requested real-runtime lane when runtime execution is not
|
||||
'launcher/mpv.ts',
|
||||
]);
|
||||
|
||||
assert.notEqual(result.status, 0, result.stdout);
|
||||
assert.match(result.stdout, /^result=blocked$/m);
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.equal(summary.status, 'blocked');
|
||||
assert.deepEqual(summary.selectedLanes, ['real-runtime']);
|
||||
assert.deepEqual(summary.lanes, ['real-runtime']);
|
||||
assert.ok(summary.sessionId.length > 0);
|
||||
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true);
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, 'summary.json')), true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,16 +102,81 @@ test('verifier fails closed for unknown lanes', () => {
|
||||
'src/main.ts',
|
||||
]);
|
||||
|
||||
assert.notEqual(result.status, 0, result.stdout);
|
||||
assert.match(result.stdout, /^result=failed$/m);
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.equal(summary.status, 'failed');
|
||||
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
|
||||
assert.equal(summary.status, 'blocked');
|
||||
assert.deepEqual(summary.lanes, ['not-a-lane']);
|
||||
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier keeps non-passing step artifacts distinct across lanes', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--lane',
|
||||
'docs',
|
||||
'--lane',
|
||||
'not-a-lane',
|
||||
'src/main.ts',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
const docsStep = summary.steps.find((step) => step.lane === 'docs' && step.name === 'docs-kb');
|
||||
const unknownStep = summary.steps.find(
|
||||
(step) => step.lane === 'not-a-lane' && step.name === 'unknown-lane',
|
||||
);
|
||||
|
||||
assert.ok(docsStep);
|
||||
assert.ok(unknownStep);
|
||||
assert.notEqual(docsStep?.stdout, unknownStep?.stdout);
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, docsStep!.stdout)), true);
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, unknownStep!.stdout)), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier records the real-runtime lease blocker once', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const leaseDir = path.join(
|
||||
repoRoot,
|
||||
'.tmp',
|
||||
'skill-verification',
|
||||
'locks',
|
||||
'exclusive-real-runtime',
|
||||
);
|
||||
fs.mkdirSync(leaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(leaseDir, 'session_id'), 'other-session');
|
||||
|
||||
try {
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--allow-real-runtime',
|
||||
'--lane',
|
||||
'real-runtime',
|
||||
'launcher/mpv.ts',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.deepEqual(summary.blockers, ['real-runtime lease already held by other-session']);
|
||||
} finally {
|
||||
fs.rmSync(leaseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier allocates unique session ids and artifact roots by default', () => {
|
||||
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||
@@ -121,9 +192,9 @@ test('verifier allocates unique session ids and artifact roots by default', () =
|
||||
const secondSummary = readSummaryJson(secondArtifactDir);
|
||||
|
||||
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
||||
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
|
||||
assert.equal(firstSummary.pathSelectionMode, 'explicit');
|
||||
assert.equal(secondSummary.pathSelectionMode, 'explicit');
|
||||
assert.notEqual(firstArtifactDir, secondArtifactDir);
|
||||
assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes');
|
||||
assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes');
|
||||
} finally {
|
||||
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
||||
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
|
||||
|
||||
@@ -85,13 +85,15 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now(),
|
||||
refreshedAtMs: 120_000,
|
||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
@@ -102,12 +104,20 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
);
|
||||
|
||||
manager.startLifecycle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
getMsUntilNextRefresh: () => number;
|
||||
}
|
||||
).getMsUntilNextRefresh() > 0,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
@@ -124,13 +134,15 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now() - 61_000,
|
||||
refreshedAtMs: 59_000,
|
||||
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
@@ -156,6 +168,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
assert.equal(manager.isKnownWord('犬'), true);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import test from 'node:test';
|
||||
import { PollingRunner } from './polling';
|
||||
|
||||
test('polling runner records newly added cards after initialization', async () => {
|
||||
const originalDateNow = Date.now;
|
||||
const recordedCards: number[] = [];
|
||||
let tracked = new Set<number>();
|
||||
const responses = [
|
||||
[10, 11],
|
||||
[10, 11, 12, 13],
|
||||
];
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
@@ -35,4 +38,7 @@ test('polling runner records newly added cards after initialization', async () =
|
||||
await runner.pollOnce();
|
||||
|
||||
assert.deepEqual(recordedCards, [2]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import { resolve } from 'node:path';
|
||||
|
||||
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
||||
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
test('ci workflow lints changelog fragments', () => {
|
||||
assert.match(ciWorkflow, /bun run changelog:lint/);
|
||||
@@ -18,3 +22,17 @@ test('ci workflow checks pull requests for required changelog fragments', () =>
|
||||
test('ci workflow verifies generated config examples stay in sync', () => {
|
||||
assert.match(ciWorkflow, /bun run verify:config-example/);
|
||||
});
|
||||
|
||||
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('ci workflow runs the maintained source coverage lane and uploads lcov output', () => {
|
||||
assert.match(ciWorkflow, /name: Coverage suite \(maintained source lane\)/);
|
||||
assert.match(ciWorkflow, /run: bun run test:coverage:src/);
|
||||
assert.match(ciWorkflow, /name: Upload coverage artifact/);
|
||||
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||
});
|
||||
|
||||
@@ -4,7 +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, RUNTIME_OPTION_REGISTRY } from './definitions';
|
||||
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY, deepMergeRawConfig } from './definitions';
|
||||
import { generateConfigTemplate } from './template';
|
||||
|
||||
function makeTempDir(): string {
|
||||
@@ -1032,6 +1032,61 @@ 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(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"tags": ["SubMiner"]
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const raw = service.getRawConfig();
|
||||
raw.ankiConnect!.tags!.push('mutated');
|
||||
|
||||
assert.deepEqual(service.getRawConfig().ankiConnect?.tags, ['SubMiner']);
|
||||
});
|
||||
|
||||
test('deepMergeRawConfig returns a detached merged clone', () => {
|
||||
const base = {
|
||||
ankiConnect: {
|
||||
tags: ['SubMiner'],
|
||||
behavior: {
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const merged = deepMergeRawConfig(base, {
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
autoUpdateNewCards: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
merged.ankiConnect!.tags!.push('mutated');
|
||||
merged.ankiConnect!.behavior!.autoUpdateNewCards = true;
|
||||
|
||||
assert.deepEqual(base.ankiConnect?.tags, ['SubMiner']);
|
||||
assert.equal(base.ankiConnect?.behavior?.autoUpdateNewCards, true);
|
||||
});
|
||||
|
||||
test('warning emission order is deterministic across reloads', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
|
||||
@@ -84,11 +84,11 @@ export const CONFIG_OPTION_REGISTRY = [
|
||||
export { CONFIG_TEMPLATE_SECTIONS };
|
||||
|
||||
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as ResolvedConfig;
|
||||
return structuredClone(config);
|
||||
}
|
||||
|
||||
export function deepMergeRawConfig(base: RawConfig, patch: RawConfig): RawConfig {
|
||||
const clone = JSON.parse(JSON.stringify(base)) as Record<string, unknown>;
|
||||
const clone = structuredClone(base) as Record<string, unknown>;
|
||||
const patchObject = patch as Record<string, unknown>;
|
||||
|
||||
const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): void => {
|
||||
|
||||
@@ -61,7 +61,7 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
getRawConfig(): RawConfig {
|
||||
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
|
||||
return structuredClone(this.rawConfig);
|
||||
}
|
||||
|
||||
getWarnings(): ConfigValidationWarning[] {
|
||||
|
||||
@@ -81,6 +81,34 @@ function cleanupDbPath(dbPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T {
|
||||
const realDate = Date;
|
||||
const fixedDateMs = fixedDate.getTime();
|
||||
|
||||
type MockDateArgs = [any, any, any, any, any, any, any];
|
||||
|
||||
class MockDate extends Date {
|
||||
constructor(...args: MockDateArgs) {
|
||||
if (args.length === 0) {
|
||||
super(fixedDateMs);
|
||||
} else {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
static override now(): number {
|
||||
return fixedDateMs;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.Date = MockDate as DateConstructor;
|
||||
try {
|
||||
return run(realDate);
|
||||
} finally {
|
||||
globalThis.Date = realDate;
|
||||
}
|
||||
}
|
||||
|
||||
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -787,6 +815,196 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', {
|
||||
canonicalTitle: 'Monthly Trends',
|
||||
sourcePath: '/tmp/feb-trends.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const marVideoId = getOrCreateVideoRecord(db, 'local:/tmp/mar-trends.mkv', {
|
||||
canonicalTitle: 'Monthly Trends',
|
||||
sourcePath: '/tmp/mar-trends.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Monthly Trends',
|
||||
canonicalTitle: 'Monthly Trends',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, febVideoId, {
|
||||
animeId,
|
||||
parsedBasename: 'feb-trends.mkv',
|
||||
parsedTitle: 'Monthly Trends',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, marVideoId, {
|
||||
animeId,
|
||||
parsedBasename: 'mar-trends.mkv',
|
||||
parsedTitle: 'Monthly Trends',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 2,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime();
|
||||
const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime();
|
||||
const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId;
|
||||
const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId;
|
||||
|
||||
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
|
||||
[febSessionId, febStartedAtMs, 100, 2, 3],
|
||||
[marSessionId, marStartedAtMs, 120, 4, 5],
|
||||
] as const) {
|
||||
stmts.telemetryInsertStmt.run(
|
||||
sessionId,
|
||||
startedAtMs + 60_000,
|
||||
30 * 60_000,
|
||||
30 * 60_000,
|
||||
4,
|
||||
tokensSeen,
|
||||
cardsMined,
|
||||
yomitanLookupCount,
|
||||
yomitanLookupCount,
|
||||
yomitanLookupCount,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
startedAtMs + 60_000,
|
||||
startedAtMs + 60_000,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
status = 2,
|
||||
total_watched_ms = ?,
|
||||
active_watched_ms = ?,
|
||||
lines_seen = ?,
|
||||
tokens_seen = ?,
|
||||
cards_mined = ?,
|
||||
lookup_count = ?,
|
||||
lookup_hits = ?,
|
||||
yomitan_lookup_count = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(
|
||||
startedAtMs + 60_000,
|
||||
30 * 60_000,
|
||||
30 * 60_000,
|
||||
4,
|
||||
tokensSeen,
|
||||
cardsMined,
|
||||
yomitanLookupCount,
|
||||
yomitanLookupCount,
|
||||
yomitanLookupCount,
|
||||
startedAtMs + 60_000,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const insertMonthlyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const febEpochDay = Math.floor(febStartedAtMs / 86_400_000);
|
||||
const marEpochDay = Math.floor(marStartedAtMs / 86_400_000);
|
||||
insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'二月',
|
||||
'二月',
|
||||
'にがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
Math.floor(febStartedAtMs / 1000),
|
||||
Math.floor(febStartedAtMs / 1000),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
'三月',
|
||||
'三月',
|
||||
'さんがつ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
Math.floor(marStartedAtMs / 1000),
|
||||
Math.floor(marStartedAtMs / 1000),
|
||||
1,
|
||||
);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.newWords.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.episodes.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.lookups.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -857,6 +1075,61 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const insertWord = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const justBeforeWeekBoundary = Math.floor(
|
||||
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000,
|
||||
);
|
||||
const justAfterWeekBoundary = Math.floor(
|
||||
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
|
||||
);
|
||||
insertWord.run(
|
||||
'境界前',
|
||||
'境界前',
|
||||
'きょうかいまえ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
justBeforeWeekBoundary,
|
||||
justBeforeWeekBoundary,
|
||||
1,
|
||||
);
|
||||
insertWord.run(
|
||||
'境界後',
|
||||
'境界後',
|
||||
'きょうかいご',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
justAfterWeekBoundary,
|
||||
justAfterWeekBoundary,
|
||||
1,
|
||||
);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.newWordsThisWeek, 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -1244,11 +1517,12 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
|
||||
|
||||
const rows = getMonthlyRollups(db, 1);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[1]?.cardsPerHour, 30);
|
||||
assert.equal(rows[1]?.tokensPerMin, 3);
|
||||
assert.equal(rows[1]?.lookupHitRate ?? null, null);
|
||||
assert.equal(rows[0]?.cardsPerHour ?? null, null);
|
||||
assert.equal(rows[0]?.tokensPerMin ?? null, null);
|
||||
const rowsByVideoId = new Map(rows.map((row) => [row.videoId, row]));
|
||||
assert.equal(rowsByVideoId.get(2)?.cardsPerHour, 30);
|
||||
assert.equal(rowsByVideoId.get(2)?.tokensPerMin, 3);
|
||||
assert.equal(rowsByVideoId.get(2)?.lookupHitRate ?? null, null);
|
||||
assert.equal(rowsByVideoId.get(1)?.cardsPerHour ?? null, null);
|
||||
assert.equal(rowsByVideoId.get(1)?.tokensPerMin ?? null, null);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
|
||||
@@ -31,9 +31,9 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const nowMs = 90 * 86_400_000;
|
||||
const staleEndedAtMs = nowMs - 40 * 86_400_000;
|
||||
const keptEndedAtMs = nowMs - 5 * 86_400_000;
|
||||
const nowMs = 1_000_000_000;
|
||||
const staleEndedAtMs = nowMs - 400_000_000;
|
||||
const keptEndedAtMs = nowMs - 50_000_000;
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO imm_videos (
|
||||
@@ -49,14 +49,14 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, ${nowMs - 2 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}),
|
||||
(2, ${nowMs - 12 * 60 * 60 * 1000}, 0, 0, ${nowMs}, ${nowMs});
|
||||
(1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}),
|
||||
(2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs});
|
||||
`);
|
||||
|
||||
const result = pruneRawRetention(db, nowMs, {
|
||||
eventsRetentionMs: 7 * 86_400_000,
|
||||
telemetryRetentionMs: 1 * 86_400_000,
|
||||
sessionsRetentionMs: 30 * 86_400_000,
|
||||
eventsRetentionMs: 120_000_000,
|
||||
telemetryRetentionMs: 80_000_000,
|
||||
sessionsRetentionMs: 300_000_000,
|
||||
});
|
||||
|
||||
const remainingSessions = db
|
||||
@@ -82,15 +82,21 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
||||
}
|
||||
});
|
||||
|
||||
test('toMonthKey floors negative timestamps into the prior UTC month', () => {
|
||||
assert.equal(toMonthKey(-1), 196912);
|
||||
assert.equal(toMonthKey(-86_400_000), 196912);
|
||||
assert.equal(toMonthKey(0), 197001);
|
||||
});
|
||||
|
||||
test('raw retention keeps rollups and rollup retention prunes them separately', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const nowMs = Date.UTC(2026, 2, 16, 12, 0, 0, 0);
|
||||
const oldDay = Math.floor((nowMs - 90 * 86_400_000) / 86_400_000);
|
||||
const oldMonth = toMonthKey(nowMs - 400 * 86_400_000);
|
||||
const nowMs = 1_000_000_000;
|
||||
const oldDay = Math.floor((nowMs - 200_000_000) / 86_400_000);
|
||||
const oldMonth = 196912;
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO imm_videos (
|
||||
@@ -101,12 +107,12 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
INSERT INTO imm_sessions (
|
||||
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'session-1', 1, ${nowMs - 90 * 86_400_000}, ${nowMs - 90 * 86_400_000 + 1_000}, 2, ${nowMs}, ${nowMs}
|
||||
1, 'session-1', 1, ${nowMs - 200_000_000}, ${nowMs - 199_999_000}, 2, ${nowMs}, ${nowMs}
|
||||
);
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, ${nowMs - 90 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}
|
||||
1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}
|
||||
);
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
@@ -123,9 +129,9 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
`);
|
||||
|
||||
pruneRawRetention(db, nowMs, {
|
||||
eventsRetentionMs: 7 * 86_400_000,
|
||||
telemetryRetentionMs: 30 * 86_400_000,
|
||||
sessionsRetentionMs: 30 * 86_400_000,
|
||||
eventsRetentionMs: 120_000_000,
|
||||
telemetryRetentionMs: 120_000_000,
|
||||
sessionsRetentionMs: 120_000_000,
|
||||
});
|
||||
|
||||
const rollupsAfterRawPrune = db
|
||||
@@ -139,8 +145,8 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
assert.equal(monthlyAfterRawPrune?.total, 1);
|
||||
|
||||
const rollupPrune = pruneRollupRetention(db, nowMs, {
|
||||
dailyRollupRetentionMs: 30 * 86_400_000,
|
||||
monthlyRollupRetentionMs: 365 * 86_400_000,
|
||||
dailyRollupRetentionMs: 120_000_000,
|
||||
monthlyRollupRetentionMs: 1,
|
||||
});
|
||||
|
||||
const rollupsAfterRollupPrune = db
|
||||
|
||||
@@ -30,7 +30,7 @@ interface RawRetentionResult {
|
||||
}
|
||||
|
||||
export function toMonthKey(timestampMs: number): number {
|
||||
const epochDay = Number(BigInt(Math.trunc(timestampMs)) / BigInt(DAILY_MS));
|
||||
const epochDay = Math.floor(timestampMs / DAILY_MS);
|
||||
const z = epochDay + 719468;
|
||||
const era = Math.floor(z / 146097);
|
||||
const doe = z - era * 146097;
|
||||
@@ -61,19 +61,19 @@ export function pruneRawRetention(
|
||||
const sessionsCutoff = nowMs - policy.sessionsRetentionMs;
|
||||
|
||||
const deletedSessionEvents = (
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff) as {
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(toDbMs(eventCutoff)) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes;
|
||||
const deletedTelemetryRows = (
|
||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff) as {
|
||||
changes: number;
|
||||
}
|
||||
db
|
||||
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||
.run(toDbMs(telemetryCutoff)) as { changes: number }
|
||||
).changes;
|
||||
const deletedEndedSessions = (
|
||||
db
|
||||
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
|
||||
.run(sessionsCutoff) as { changes: number }
|
||||
.run(toDbMs(sessionsCutoff)) as { changes: number }
|
||||
).changes;
|
||||
|
||||
return {
|
||||
|
||||
@@ -131,7 +131,8 @@ export function getSessionWordsByLine(
|
||||
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
|
||||
const now = new Date();
|
||||
const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
||||
const weekAgoSec = todayStartSec - 7 * 86_400;
|
||||
const weekAgoSec =
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000;
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
|
||||
@@ -83,7 +83,13 @@ function getTrendMonthlyLimit(range: TrendRange): number {
|
||||
if (range === 'all') {
|
||||
return 120;
|
||||
}
|
||||
return Math.max(1, Math.ceil(TREND_DAY_LIMITS[range] / 30));
|
||||
const now = new Date();
|
||||
const cutoff = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() - (TREND_DAY_LIMITS[range] - 1),
|
||||
);
|
||||
return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1);
|
||||
}
|
||||
|
||||
function getTrendCutoffMs(range: TrendRange): number | null {
|
||||
@@ -122,6 +128,11 @@ function getLocalDateForEpochDay(epochDay: number): Date {
|
||||
return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000);
|
||||
}
|
||||
|
||||
function getLocalMonthKey(timestampMs: number): number {
|
||||
const date = new Date(timestampMs);
|
||||
return date.getFullYear() * 100 + date.getMonth() + 1;
|
||||
}
|
||||
|
||||
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
|
||||
return session.tokensSeen;
|
||||
}
|
||||
@@ -218,6 +229,20 @@ function buildSessionSeriesByDay(
|
||||
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
|
||||
}
|
||||
|
||||
function buildSessionSeriesByMonth(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
getValue: (session: TrendSessionMetricRow) => number,
|
||||
): TrendChartPoint[] {
|
||||
const byMonth = new Map<number, number>();
|
||||
for (const session of sessions) {
|
||||
const monthKey = getLocalMonthKey(session.startedAtMs);
|
||||
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
|
||||
}
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
|
||||
}
|
||||
|
||||
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||
const lookupsByDay = new Map<number, number>();
|
||||
const wordsByDay = new Map<number, number>();
|
||||
@@ -441,6 +466,26 @@ function buildEpisodesPerDayFromDailyRollups(
|
||||
}));
|
||||
}
|
||||
|
||||
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
|
||||
const byMonth = new Map<number, Set<number>>();
|
||||
|
||||
for (const rollup of rollups) {
|
||||
if (rollup.videoId === null) {
|
||||
continue;
|
||||
}
|
||||
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
videoIds.add(rollup.videoId);
|
||||
byMonth.set(rollup.rollupDayOrMonth, videoIds);
|
||||
}
|
||||
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([monthKey, videoIds]) => ({
|
||||
label: makeTrendLabel(monthKey),
|
||||
value: videoIds.size,
|
||||
}));
|
||||
}
|
||||
|
||||
function getTrendSessionMetrics(
|
||||
db: DatabaseSync,
|
||||
cutoffMs: number | null,
|
||||
@@ -494,6 +539,32 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh
|
||||
}));
|
||||
}
|
||||
|
||||
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey,
|
||||
COUNT(*) AS wordCount
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
${whereClause}
|
||||
GROUP BY monthKey
|
||||
ORDER BY monthKey ASC
|
||||
`);
|
||||
|
||||
const rows = (
|
||||
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
|
||||
) as Array<{
|
||||
monthKey: number;
|
||||
wordCount: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: makeTrendLabel(row.monthKey),
|
||||
value: row.wordCount,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getTrendsDashboard(
|
||||
db: DatabaseSync,
|
||||
range: TrendRange = '30d',
|
||||
@@ -502,10 +573,11 @@ export function getTrendsDashboard(
|
||||
const dayLimit = getTrendDayLimit(range);
|
||||
const monthlyLimit = getTrendMonthlyLimit(range);
|
||||
const cutoffMs = getTrendCutoffMs(range);
|
||||
|
||||
const chartRollups =
|
||||
groupBy === 'month' ? getMonthlyRollups(db, monthlyLimit) : getDailyRollups(db, dayLimit);
|
||||
const useMonthlyBuckets = groupBy === 'month';
|
||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
||||
|
||||
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
||||
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
||||
const titlesByVideoId = getVideoAnimeTitleMap(
|
||||
db,
|
||||
@@ -545,11 +617,19 @@ export function getTrendsDashboard(
|
||||
watchTime: accumulatePoints(activity.watchTime),
|
||||
sessions: accumulatePoints(activity.sessions),
|
||||
words: accumulatePoints(activity.words),
|
||||
newWords: accumulatePoints(buildNewWordsPerDay(db, cutoffMs)),
|
||||
newWords: accumulatePoints(
|
||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
||||
),
|
||||
cards: accumulatePoints(activity.cards),
|
||||
episodes: accumulatePoints(buildEpisodesPerDayFromDailyRollups(dailyRollups)),
|
||||
episodes: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
||||
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
||||
),
|
||||
lookups: accumulatePoints(
|
||||
buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||
useMonthlyBuckets
|
||||
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
||||
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||
),
|
||||
},
|
||||
ratios: {
|
||||
|
||||
@@ -263,7 +263,9 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
const expectedPostedPayload = JSON.parse(JSON.stringify(expectedPayload));
|
||||
const expectedPostedPayload = Object.fromEntries(
|
||||
Object.entries(structuredClone(expectedPayload)).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
const ok = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
|
||||
@@ -1255,7 +1255,7 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return JSON.parse(JSON.stringify(optionsFull));
|
||||
return structuredClone(optionsFull);
|
||||
}
|
||||
if (script.includes('setAllSettings')) {
|
||||
return true;
|
||||
|
||||
918
src/main.ts
918
src/main.ts
File diff suppressed because it is too large
Load Diff
94
src/main/boot/handlers.test.ts
Normal file
94
src/main/boot/handlers.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMainBootHandlers } from './handlers';
|
||||
|
||||
test('createMainBootHandlers returns grouped handler bundles', () => {
|
||||
const handlers = createMainBootHandlers<any, any, any, any>({
|
||||
startupLifecycleDeps: {
|
||||
registerProtocolUrlHandlersMainDeps: {} as never,
|
||||
onWillQuitCleanupMainDeps: {} as never,
|
||||
shouldRestoreWindowsOnActivateMainDeps: {} as never,
|
||||
restoreWindowsOnActivateMainDeps: {} as never,
|
||||
},
|
||||
ipcRuntimeDeps: {
|
||||
mpvCommandMainDeps: {} as never,
|
||||
handleMpvCommandFromIpcRuntime: () => ({ ok: true }) as never,
|
||||
runSubsyncManualFromIpc: () => Promise.resolve({ ok: true }) as never,
|
||||
registration: {
|
||||
runtimeOptions: {} as never,
|
||||
mainDeps: {} as never,
|
||||
ankiJimakuDeps: {} as never,
|
||||
registerIpcRuntimeServices: () => {},
|
||||
},
|
||||
},
|
||||
cliStartupDeps: {
|
||||
cliCommandContextMainDeps: {} as never,
|
||||
cliCommandRuntimeHandlerMainDeps: {} as never,
|
||||
initialArgsRuntimeHandlerMainDeps: {} as never,
|
||||
},
|
||||
headlessStartupDeps: {
|
||||
startupRuntimeHandlersDeps: {
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: () => () => {},
|
||||
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: () => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: (args) => startAppLifecycle(args as never),
|
||||
}),
|
||||
createStartupBootstrapRuntimeDeps: (deps) => ({
|
||||
startAppLifecycle: deps.startAppLifecycle,
|
||||
}),
|
||||
runStartupBootstrapRuntime: () => ({ mode: 'started' } as never),
|
||||
applyStartupState: () => {},
|
||||
},
|
||||
},
|
||||
overlayWindowDeps: {
|
||||
createOverlayWindowDeps: {
|
||||
createOverlayWindowCore: (kind) => ({ kind }),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => false,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
getYomitanSession: () => null,
|
||||
},
|
||||
setMainWindow: () => {},
|
||||
setModalWindow: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof handlers.startupLifecycle.registerProtocolUrlHandlers, 'function');
|
||||
assert.equal(typeof handlers.ipcRuntime.registerIpcRuntimeHandlers, 'function');
|
||||
assert.equal(typeof handlers.cliStartup.handleCliCommand, 'function');
|
||||
assert.equal(typeof handlers.headlessStartup.runAndApplyStartupState, 'function');
|
||||
assert.equal(typeof handlers.overlayWindow.createMainWindow, 'function');
|
||||
});
|
||||
40
src/main/boot/handlers.ts
Normal file
40
src/main/boot/handlers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { composeOverlayWindowHandlers } from '../runtime/composers/overlay-window-composer';
|
||||
import {
|
||||
composeCliStartupHandlers,
|
||||
composeHeadlessStartupHandlers,
|
||||
composeIpcRuntimeHandlers,
|
||||
composeStartupLifecycleHandlers,
|
||||
} from '../runtime/composers';
|
||||
|
||||
export interface MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps> {
|
||||
startupLifecycleDeps: Parameters<typeof composeStartupLifecycleHandlers>[0];
|
||||
ipcRuntimeDeps: Parameters<typeof composeIpcRuntimeHandlers>[0];
|
||||
cliStartupDeps: Parameters<typeof composeCliStartupHandlers>[0];
|
||||
headlessStartupDeps: Parameters<
|
||||
typeof composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>
|
||||
>[0];
|
||||
overlayWindowDeps: Parameters<typeof composeOverlayWindowHandlers<TBrowserWindow>>[0];
|
||||
}
|
||||
|
||||
export function createMainBootHandlers<
|
||||
TBrowserWindow,
|
||||
TCliArgs,
|
||||
TStartupState,
|
||||
TBootstrapDeps,
|
||||
>(params: MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps>) {
|
||||
return {
|
||||
startupLifecycle: composeStartupLifecycleHandlers(params.startupLifecycleDeps),
|
||||
ipcRuntime: composeIpcRuntimeHandlers(params.ipcRuntimeDeps),
|
||||
cliStartup: composeCliStartupHandlers(params.cliStartupDeps),
|
||||
headlessStartup: composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>(
|
||||
params.headlessStartupDeps,
|
||||
),
|
||||
overlayWindow: composeOverlayWindowHandlers<TBrowserWindow>(params.overlayWindowDeps),
|
||||
};
|
||||
}
|
||||
|
||||
export const composeBootStartupLifecycleHandlers = composeStartupLifecycleHandlers;
|
||||
export const composeBootIpcRuntimeHandlers = composeIpcRuntimeHandlers;
|
||||
export const composeBootCliStartupHandlers = composeCliStartupHandlers;
|
||||
export const composeBootHeadlessStartupHandlers = composeHeadlessStartupHandlers;
|
||||
export const composeBootOverlayWindowHandlers = composeOverlayWindowHandlers;
|
||||
339
src/main/boot/runtimes.test.ts
Normal file
339
src/main/boot/runtimes.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMainBootRuntimes } from './runtimes';
|
||||
|
||||
test('createMainBootRuntimes returns grouped runtime bundles', () => {
|
||||
const runtimes = createMainBootRuntimes<any, any, any, any>({
|
||||
overlayVisibilityRuntimeDeps: {
|
||||
overlayVisibilityRuntime: {} as never,
|
||||
restorePreviousSecondarySubVisibilityMainDeps: {} as never,
|
||||
broadcastRuntimeOptionsChangedMainDeps: {} as never,
|
||||
sendToActiveOverlayWindowMainDeps: {} as never,
|
||||
setOverlayDebugVisualizationEnabledMainDeps: {} as never,
|
||||
openRuntimeOptionsPaletteMainDeps: {} as never,
|
||||
},
|
||||
jellyfinRuntimeHandlerDeps: {
|
||||
getResolvedJellyfinConfigMainDeps: {} as never,
|
||||
getJellyfinClientInfoMainDeps: {} as never,
|
||||
waitForMpvConnectedMainDeps: {} as never,
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps: {} as never,
|
||||
ensureMpvConnectedForJellyfinPlaybackMainDeps: {} as never,
|
||||
preloadJellyfinExternalSubtitlesMainDeps: {} as never,
|
||||
playJellyfinItemInMpvMainDeps: {} as never,
|
||||
remoteComposerOptions: {} as never,
|
||||
handleJellyfinAuthCommandsMainDeps: {} as never,
|
||||
handleJellyfinListCommandsMainDeps: {} as never,
|
||||
handleJellyfinPlayCommandMainDeps: {} as never,
|
||||
handleJellyfinRemoteAnnounceCommandMainDeps: {} as never,
|
||||
startJellyfinRemoteSessionMainDeps: {} as never,
|
||||
stopJellyfinRemoteSessionMainDeps: {} as never,
|
||||
runJellyfinCommandMainDeps: {} as never,
|
||||
maybeFocusExistingJellyfinSetupWindowMainDeps: {} as never,
|
||||
openJellyfinSetupWindowMainDeps: {} as never,
|
||||
},
|
||||
anilistSetupDeps: {
|
||||
notifyDeps: {} as never,
|
||||
consumeTokenDeps: {} as never,
|
||||
handleProtocolDeps: {} as never,
|
||||
registerProtocolClientDeps: {} as never,
|
||||
},
|
||||
buildOpenAnilistSetupWindowMainDeps: {
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () => null as never,
|
||||
buildAuthorizeUrl: () => 'https://example.test',
|
||||
consumeCallbackUrl: () => false,
|
||||
openSetupInBrowser: () => {},
|
||||
loadManualTokenEntry: () => Promise.resolve(),
|
||||
redirectUri: 'https://example.test/callback',
|
||||
developerSettingsUrl: 'https://example.test/dev',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
setSetupWindow: () => {},
|
||||
openExternal: () => {},
|
||||
},
|
||||
anilistTrackingDeps: {
|
||||
refreshClientSecretMainDeps: {} as never,
|
||||
getCurrentMediaKeyMainDeps: {} as never,
|
||||
resetMediaTrackingMainDeps: {} as never,
|
||||
getMediaGuessRuntimeStateMainDeps: {} as never,
|
||||
setMediaGuessRuntimeStateMainDeps: {} as never,
|
||||
resetMediaGuessStateMainDeps: {} as never,
|
||||
maybeProbeDurationMainDeps: {} as never,
|
||||
ensureMediaGuessMainDeps: {} as never,
|
||||
processNextRetryUpdateMainDeps: {} as never,
|
||||
maybeRunPostWatchUpdateMainDeps: {} as never,
|
||||
},
|
||||
statsStartupRuntimeDeps: {
|
||||
ensureStatsServerStarted: () => '',
|
||||
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
|
||||
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
|
||||
ensureImmersionTrackerStarted: () => {},
|
||||
},
|
||||
runStatsCliCommandDeps: {
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
ensureImmersionTrackerStarted: () => {},
|
||||
ensureVocabularyCleanupTokenizerReady: async () => {},
|
||||
getImmersionTracker: () => null,
|
||||
ensureStatsServerStarted: () => '',
|
||||
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
|
||||
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
|
||||
openExternal: () => Promise.resolve(),
|
||||
writeResponse: () => {},
|
||||
exitAppWithCode: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
appReadyRuntimeDeps: {
|
||||
reloadConfigMainDeps: {
|
||||
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => {},
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfigErrorMainDeps: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
appReadyRuntimeMainDeps: {
|
||||
ensureDefaultConfigBootstrap: () => {},
|
||||
loadSubtitlePosition: () => {},
|
||||
resolveKeybindings: () => {},
|
||||
createMpvClient: () => {},
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
initRuntimeOptionsManager: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
createSubtitleTimingTracker: () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
prewarmSubtitleDictionaries: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {},
|
||||
handleInitialArgs: () => {},
|
||||
logDebug: () => {},
|
||||
now: () => Date.now(),
|
||||
},
|
||||
immersionTrackerStartupMainDeps: {
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () =>
|
||||
({
|
||||
startSession: () => {},
|
||||
}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
},
|
||||
mpvRuntimeDeps: {
|
||||
bindMpvMainEventHandlersMainDeps: {
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: null,
|
||||
immersionTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: null,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
},
|
||||
mpvClientRuntimeServiceFactoryMainDeps: {
|
||||
createClient: class FakeMpvClient {
|
||||
connected = true;
|
||||
on(): void {}
|
||||
connect(): void {}
|
||||
} as never,
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getResolvedConfig: () => ({ auto_start_overlay: false }),
|
||||
isAutoStartOverlayEnabled: () => true,
|
||||
setOverlayVisible: () => {},
|
||||
isVisibleOverlayVisible: () => false,
|
||||
getReconnectTimer: () => null,
|
||||
setReconnectTimer: () => {},
|
||||
},
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
getCurrentMetrics: () => ({
|
||||
subPos: 100,
|
||||
subFontSize: 36,
|
||||
subScale: 1,
|
||||
subMarginY: 0,
|
||||
subMarginX: 0,
|
||||
subFont: '',
|
||||
subSpacing: 0,
|
||||
subBold: false,
|
||||
subItalic: false,
|
||||
subBorderSize: 0,
|
||||
subShadowOffset: 0,
|
||||
subAssOverride: 'yes',
|
||||
subScaleByWindow: true,
|
||||
subUseMargins: true,
|
||||
osdHeight: 0,
|
||||
osdDimensions: null,
|
||||
}),
|
||||
setCurrentMetrics: () => {},
|
||||
applyPatch: (current: any) => ({ next: current, changed: false }),
|
||||
broadcastMetrics: () => {},
|
||||
},
|
||||
tokenizer: {
|
||||
buildTokenizerDepsMainDeps: {
|
||||
getYomitanExt: () => null,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
getYomitanParserReadyPromise: () => null,
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => {},
|
||||
isKnownWord: () => false,
|
||||
recordLookup: () => {},
|
||||
getKnownWordMatchMode: () => 'headword',
|
||||
getMinSentenceWordsForNPlusOne: () => 3,
|
||||
getJlptLevel: () => null,
|
||||
getJlptEnabled: () => false,
|
||||
getFrequencyDictionaryEnabled: () => false,
|
||||
getFrequencyDictionaryMatchMode: () => 'headword',
|
||||
getFrequencyRank: () => null,
|
||||
getYomitanGroupDebugEnabled: () => false,
|
||||
getMecabTokenizer: () => null,
|
||||
},
|
||||
createTokenizerRuntimeDeps: () => ({}),
|
||||
tokenizeSubtitle: async () => ({ text: '' }),
|
||||
createMecabTokenizerAndCheckMainDeps: {
|
||||
getMecabTokenizer: () => null,
|
||||
setMecabTokenizer: () => {},
|
||||
createMecabTokenizer: () => ({}) as never,
|
||||
checkAvailability: async () => {},
|
||||
},
|
||||
prewarmSubtitleDictionariesMainDeps: {
|
||||
ensureJlptDictionaryLookup: async () => {},
|
||||
ensureFrequencyDictionaryLookup: async () => {},
|
||||
},
|
||||
},
|
||||
warmups: {
|
||||
launchBackgroundWarmupTaskMainDeps: {
|
||||
now: () => 0,
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
startBackgroundWarmupsMainDeps: {
|
||||
getStarted: () => false,
|
||||
setStarted: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
ensureYomitanExtensionLoaded: async () => {},
|
||||
shouldWarmupMecab: () => false,
|
||||
shouldWarmupYomitanExtension: () => false,
|
||||
shouldWarmupSubtitleDictionaries: () => false,
|
||||
shouldWarmupJellyfinRemoteSession: () => false,
|
||||
shouldAutoConnectJellyfinRemote: () => false,
|
||||
startJellyfinRemoteSession: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
trayRuntimeDeps: {
|
||||
resolveTrayIconPathDeps: {} as never,
|
||||
buildTrayMenuTemplateDeps: {} as never,
|
||||
ensureTrayDeps: {} as never,
|
||||
destroyTrayDeps: {} as never,
|
||||
buildMenuFromTemplate: () => ({}) as never,
|
||||
},
|
||||
yomitanProfilePolicyDeps: {
|
||||
externalProfilePath: '',
|
||||
logInfo: () => {},
|
||||
},
|
||||
yomitanExtensionRuntimeDeps: {
|
||||
loadYomitanExtensionCore: async () => null,
|
||||
userDataPath: '/tmp',
|
||||
externalProfilePath: '',
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: () => {},
|
||||
setYomitanSession: () => {},
|
||||
getYomitanExtension: () => null,
|
||||
getLoadInFlight: () => null,
|
||||
setLoadInFlight: () => {},
|
||||
},
|
||||
yomitanSettingsRuntimeDeps: {
|
||||
ensureYomitanExtensionLoaded: async () => {},
|
||||
getYomitanSession: () => null,
|
||||
openYomitanSettingsWindow: () => {},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
createOverlayRuntimeBootstrapHandlers: () => ({
|
||||
initializeOverlayRuntime: () => {},
|
||||
}),
|
||||
initializeOverlayRuntimeMainDeps: {},
|
||||
initializeOverlayRuntimeBootstrapDeps: {},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtimes.overlayVisibilityComposer.sendToActiveOverlayWindow, 'function');
|
||||
assert.equal(typeof runtimes.jellyfinRuntimeHandlers.runJellyfinCommand, 'function');
|
||||
assert.equal(typeof runtimes.anilistSetupHandlers.notifyAnilistSetup, 'function');
|
||||
assert.equal(typeof runtimes.openAnilistSetupWindow, 'function');
|
||||
assert.equal(typeof runtimes.anilistTrackingHandlers.maybeRunAnilistPostWatchUpdate, 'function');
|
||||
assert.equal(typeof runtimes.runStatsCliCommand, 'function');
|
||||
assert.equal(typeof runtimes.appReadyRuntime.appReadyRuntimeRunner, 'function');
|
||||
assert.equal(typeof runtimes.initializeOverlayRuntime, 'function');
|
||||
});
|
||||
127
src/main/boot/runtimes.ts
Normal file
127
src/main/boot/runtimes.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createOpenFirstRunSetupWindowHandler } from '../runtime/first-run-setup-window';
|
||||
import { createRunStatsCliCommandHandler } from '../runtime/stats-cli-command';
|
||||
import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
|
||||
import {
|
||||
createBuildOpenAnilistSetupWindowMainDepsHandler,
|
||||
createMaybeFocusExistingAnilistSetupWindowHandler,
|
||||
createOpenAnilistSetupWindowHandler,
|
||||
} from '../runtime/domains/anilist';
|
||||
import {
|
||||
createTrayRuntimeHandlers,
|
||||
createYomitanExtensionRuntime,
|
||||
createYomitanSettingsRuntime,
|
||||
} from '../runtime/domains/overlay';
|
||||
import {
|
||||
composeAnilistSetupHandlers,
|
||||
composeAnilistTrackingHandlers,
|
||||
composeAppReadyRuntime,
|
||||
composeJellyfinRuntimeHandlers,
|
||||
composeMpvRuntimeHandlers,
|
||||
composeOverlayVisibilityRuntime,
|
||||
composeStatsStartupRuntime,
|
||||
} from '../runtime/composers';
|
||||
|
||||
export interface MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData> {
|
||||
overlayVisibilityRuntimeDeps: Parameters<typeof composeOverlayVisibilityRuntime>[0];
|
||||
jellyfinRuntimeHandlerDeps: Parameters<typeof composeJellyfinRuntimeHandlers>[0];
|
||||
anilistSetupDeps: Parameters<typeof composeAnilistSetupHandlers>[0];
|
||||
buildOpenAnilistSetupWindowMainDeps: Parameters<
|
||||
typeof createBuildOpenAnilistSetupWindowMainDepsHandler
|
||||
>[0];
|
||||
anilistTrackingDeps: Parameters<typeof composeAnilistTrackingHandlers>[0];
|
||||
statsStartupRuntimeDeps: Parameters<typeof composeStatsStartupRuntime>[0];
|
||||
runStatsCliCommandDeps: Parameters<typeof createRunStatsCliCommandHandler>[0];
|
||||
appReadyRuntimeDeps: Parameters<typeof composeAppReadyRuntime>[0];
|
||||
mpvRuntimeDeps: any;
|
||||
trayRuntimeDeps: Parameters<typeof createTrayRuntimeHandlers>[0];
|
||||
yomitanProfilePolicyDeps: Parameters<typeof createYomitanProfilePolicy>[0];
|
||||
yomitanExtensionRuntimeDeps: Parameters<typeof createYomitanExtensionRuntime>[0];
|
||||
yomitanSettingsRuntimeDeps: Parameters<typeof createYomitanSettingsRuntime>[0];
|
||||
createOverlayRuntimeBootstrapHandlers: (params: {
|
||||
initializeOverlayRuntimeMainDeps: unknown;
|
||||
initializeOverlayRuntimeBootstrapDeps: unknown;
|
||||
}) => {
|
||||
initializeOverlayRuntime: () => void;
|
||||
};
|
||||
initializeOverlayRuntimeMainDeps: unknown;
|
||||
initializeOverlayRuntimeBootstrapDeps: unknown;
|
||||
}
|
||||
|
||||
export function createMainBootRuntimes<
|
||||
TBrowserWindow,
|
||||
TMpvClient,
|
||||
TTokenizerDeps,
|
||||
TSubtitleData,
|
||||
>(
|
||||
params: MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData>,
|
||||
) {
|
||||
const overlayVisibilityComposer = composeOverlayVisibilityRuntime(
|
||||
params.overlayVisibilityRuntimeDeps,
|
||||
);
|
||||
const jellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers(
|
||||
params.jellyfinRuntimeHandlerDeps,
|
||||
);
|
||||
const anilistSetupHandlers = composeAnilistSetupHandlers(params.anilistSetupDeps);
|
||||
const buildOpenAnilistSetupWindowMainDepsHandler =
|
||||
createBuildOpenAnilistSetupWindowMainDepsHandler(params.buildOpenAnilistSetupWindowMainDeps);
|
||||
const maybeFocusExistingAnilistSetupWindow =
|
||||
params.buildOpenAnilistSetupWindowMainDeps.maybeFocusExistingSetupWindow;
|
||||
const anilistTrackingHandlers = composeAnilistTrackingHandlers(params.anilistTrackingDeps);
|
||||
const statsStartupRuntime = composeStatsStartupRuntime(params.statsStartupRuntimeDeps);
|
||||
const runStatsCliCommand = createRunStatsCliCommandHandler(params.runStatsCliCommandDeps);
|
||||
const appReadyRuntime = composeAppReadyRuntime(params.appReadyRuntimeDeps);
|
||||
const mpvRuntimeHandlers = composeMpvRuntimeHandlers<any, any, any>(
|
||||
params.mpvRuntimeDeps as any,
|
||||
);
|
||||
const trayRuntimeHandlers = createTrayRuntimeHandlers(params.trayRuntimeDeps);
|
||||
const yomitanProfilePolicy = createYomitanProfilePolicy(params.yomitanProfilePolicyDeps);
|
||||
const yomitanExtensionRuntime = createYomitanExtensionRuntime(
|
||||
params.yomitanExtensionRuntimeDeps,
|
||||
);
|
||||
const yomitanSettingsRuntime = createYomitanSettingsRuntime(
|
||||
params.yomitanSettingsRuntimeDeps,
|
||||
);
|
||||
const overlayRuntimeBootstrapHandlers = params.createOverlayRuntimeBootstrapHandlers({
|
||||
initializeOverlayRuntimeMainDeps: params.initializeOverlayRuntimeMainDeps,
|
||||
initializeOverlayRuntimeBootstrapDeps: params.initializeOverlayRuntimeBootstrapDeps,
|
||||
});
|
||||
|
||||
return {
|
||||
overlayVisibilityComposer,
|
||||
jellyfinRuntimeHandlers,
|
||||
anilistSetupHandlers,
|
||||
maybeFocusExistingAnilistSetupWindow,
|
||||
buildOpenAnilistSetupWindowMainDepsHandler,
|
||||
openAnilistSetupWindow: () =>
|
||||
createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(),
|
||||
anilistTrackingHandlers,
|
||||
statsStartupRuntime,
|
||||
runStatsCliCommand,
|
||||
appReadyRuntime,
|
||||
mpvRuntimeHandlers,
|
||||
trayRuntimeHandlers,
|
||||
yomitanProfilePolicy,
|
||||
yomitanExtensionRuntime,
|
||||
yomitanSettingsRuntime,
|
||||
initializeOverlayRuntime: overlayRuntimeBootstrapHandlers.initializeOverlayRuntime,
|
||||
openFirstRunSetupWindowHandler: createOpenFirstRunSetupWindowHandler,
|
||||
};
|
||||
}
|
||||
|
||||
export const composeBootOverlayVisibilityRuntime = composeOverlayVisibilityRuntime;
|
||||
export const composeBootJellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers;
|
||||
export const composeBootAnilistSetupHandlers = composeAnilistSetupHandlers;
|
||||
export const composeBootAnilistTrackingHandlers = composeAnilistTrackingHandlers;
|
||||
export const composeBootStatsStartupRuntime = composeStatsStartupRuntime;
|
||||
export const createBootRunStatsCliCommandHandler = createRunStatsCliCommandHandler;
|
||||
export const composeBootAppReadyRuntime = composeAppReadyRuntime;
|
||||
export const composeBootMpvRuntimeHandlers = composeMpvRuntimeHandlers;
|
||||
export const createBootTrayRuntimeHandlers = createTrayRuntimeHandlers;
|
||||
export const createBootYomitanProfilePolicy = createYomitanProfilePolicy;
|
||||
export const createBootYomitanExtensionRuntime = createYomitanExtensionRuntime;
|
||||
export const createBootYomitanSettingsRuntime = createYomitanSettingsRuntime;
|
||||
export const createBootMaybeFocusExistingAnilistSetupWindowHandler =
|
||||
createMaybeFocusExistingAnilistSetupWindowHandler;
|
||||
export const createBootBuildOpenAnilistSetupWindowMainDepsHandler =
|
||||
createBuildOpenAnilistSetupWindowMainDepsHandler;
|
||||
export const createBootOpenAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler;
|
||||
115
src/main/boot/services.test.ts
Normal file
115
src/main/boot/services.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMainBootServices } from './services';
|
||||
|
||||
test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
type MockAppLifecycleApp = {
|
||||
requestSingleInstanceLock: () => boolean;
|
||||
quit: () => void;
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
|
||||
const calls: string[] = [];
|
||||
let setPathValue: string | null = null;
|
||||
const appOnCalls: string[] = [];
|
||||
let secondInstanceHandlerRegistered = false;
|
||||
|
||||
const services = createMainBootServices<
|
||||
{ configDir: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ kind: string },
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getModalWindow: () => null },
|
||||
{ inputState: boolean },
|
||||
{ measurementStore: boolean },
|
||||
{ modalRuntime: boolean },
|
||||
{ mpvSocketPath: string; texthookerPort: number },
|
||||
MockAppLifecycleApp
|
||||
>({
|
||||
platform: 'linux',
|
||||
argv: ['node', 'main.ts'],
|
||||
appDataDir: undefined,
|
||||
xdgConfigHome: undefined,
|
||||
homeDir: '/home/tester',
|
||||
defaultMpvLogFile: '/tmp/default.log',
|
||||
envMpvLog: ' /tmp/custom.log ',
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||
resolveConfigDir: () => '/tmp/subminer-config',
|
||||
existsSync: () => false,
|
||||
mkdirSync: (targetPath) => {
|
||||
calls.push(`mkdir:${targetPath}`);
|
||||
},
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
app: {
|
||||
setPath: (_name, value) => {
|
||||
setPathValue = value;
|
||||
},
|
||||
quit: () => {},
|
||||
on: (event) => {
|
||||
appOnCalls.push(event);
|
||||
return {};
|
||||
},
|
||||
whenReady: async () => {},
|
||||
},
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => {
|
||||
secondInstanceHandlerRegistered = true;
|
||||
},
|
||||
onConfigStartupParseError: () => {
|
||||
throw new Error('unexpected parse failure');
|
||||
},
|
||||
createConfigService: (configDir) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: () => ({ kind: 'ws' }),
|
||||
createLogger: (scope) =>
|
||||
({
|
||||
scope,
|
||||
warn: () => {},
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
}) as const,
|
||||
createMainRuntimeRegistry: () => ({ registry: true }),
|
||||
createOverlayManager: () => ({
|
||||
getModalWindow: () => null,
|
||||
}),
|
||||
createOverlayModalInputState: () => ({ inputState: true }),
|
||||
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
createOverlayModalRuntime: () => ({ modalRuntime: true }),
|
||||
createAppState: (input) => ({ ...input }),
|
||||
});
|
||||
|
||||
assert.equal(services.configDir, '/tmp/subminer-config');
|
||||
assert.equal(services.userDataPath, '/tmp/subminer-config');
|
||||
assert.equal(services.defaultMpvLogPath, '/tmp/custom.log');
|
||||
assert.equal(services.defaultImmersionDbPath, '/tmp/subminer-config/immersion.sqlite');
|
||||
assert.deepEqual(services.configService, { configDir: '/tmp/subminer-config' });
|
||||
assert.deepEqual(services.anilistTokenStore, {
|
||||
targetPath: '/tmp/subminer-config/anilist-token-store.json',
|
||||
});
|
||||
assert.deepEqual(services.jellyfinTokenStore, {
|
||||
targetPath: '/tmp/subminer-config/jellyfin-token-store.json',
|
||||
});
|
||||
assert.deepEqual(services.anilistUpdateQueue, {
|
||||
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
|
||||
});
|
||||
assert.deepEqual(services.appState, {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
texthookerPort: 5174,
|
||||
});
|
||||
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
|
||||
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
|
||||
assert.deepEqual(appOnCalls, ['ready']);
|
||||
assert.equal(secondInstanceHandlerRegistered, true);
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
|
||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||
});
|
||||
262
src/main/boot/services.ts
Normal file
262
src/main/boot/services.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { ConfigStartupParseError } from '../../config';
|
||||
|
||||
export interface MainBootServicesParams<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp,
|
||||
> {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
defaultMpvLogFile: string;
|
||||
envMpvLog: string | undefined;
|
||||
defaultTexthookerPort: number;
|
||||
getDefaultSocketPath: () => string;
|
||||
resolveConfigDir: (input: {
|
||||
platform: NodeJS.Platform;
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
existsSync: (targetPath: string) => boolean;
|
||||
}) => string;
|
||||
existsSync: (targetPath: string) => boolean;
|
||||
mkdirSync: (targetPath: string, options: { recursive: true }) => void;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
app: {
|
||||
setPath: (name: string, value: string) => void;
|
||||
quit: () => void;
|
||||
on: (...args: any[]) => unknown;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
shouldBypassSingleInstanceLock: () => boolean;
|
||||
requestSingleInstanceLockEarly: () => boolean;
|
||||
registerSecondInstanceHandlerEarly: (
|
||||
listener: (_event: unknown, argv: string[]) => void,
|
||||
) => void;
|
||||
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
||||
createConfigService: (configDir: string) => TConfigService;
|
||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
||||
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
||||
createSubtitleWebSocket: () => TSubtitleWebSocket;
|
||||
createLogger: (scope: string) => TLogger & {
|
||||
warn: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
};
|
||||
createMainRuntimeRegistry: () => TRuntimeRegistry;
|
||||
createOverlayManager: () => TOverlayManager;
|
||||
createOverlayModalInputState: (params: any) => TOverlayModalInputState;
|
||||
createOverlayContentMeasurementStore: (params: {
|
||||
logger: TLogger;
|
||||
}) => TOverlayContentMeasurementStore;
|
||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
|
||||
getSyncOverlayVisibilityForModal: () => () => void;
|
||||
createOverlayModalRuntime: (params: {
|
||||
overlayManager: TOverlayManager;
|
||||
overlayModalInputState: TOverlayModalInputState;
|
||||
onModalStateChange: (isActive: boolean) => void;
|
||||
}) => TOverlayModalRuntime;
|
||||
createAppState: (input: {
|
||||
mpvSocketPath: string;
|
||||
texthookerPort: number;
|
||||
}) => TAppState;
|
||||
}
|
||||
|
||||
export interface MainBootServicesResult<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp,
|
||||
> {
|
||||
configDir: string;
|
||||
userDataPath: string;
|
||||
defaultMpvLogPath: string;
|
||||
defaultImmersionDbPath: string;
|
||||
configService: TConfigService;
|
||||
anilistTokenStore: TAnilistTokenStore;
|
||||
jellyfinTokenStore: TJellyfinTokenStore;
|
||||
anilistUpdateQueue: TAnilistUpdateQueue;
|
||||
subtitleWsService: TSubtitleWebSocket;
|
||||
annotationSubtitleWsService: TSubtitleWebSocket;
|
||||
logger: TLogger;
|
||||
runtimeRegistry: TRuntimeRegistry;
|
||||
overlayManager: TOverlayManager;
|
||||
overlayModalInputState: TOverlayModalInputState;
|
||||
overlayContentMeasurementStore: TOverlayContentMeasurementStore;
|
||||
overlayModalRuntime: TOverlayModalRuntime;
|
||||
appState: TAppState;
|
||||
appLifecycleApp: TAppLifecycleApp;
|
||||
}
|
||||
|
||||
export function createMainBootServices<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager extends { getModalWindow: () => unknown },
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp,
|
||||
>(
|
||||
params: MainBootServicesParams<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
>,
|
||||
): MainBootServicesResult<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
> {
|
||||
const configDir = params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const userDataPath = configDir;
|
||||
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
|
||||
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
|
||||
|
||||
const configService = (() => {
|
||||
try {
|
||||
return params.createConfigService(configDir);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigStartupParseError) {
|
||||
params.onConfigStartupParseError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
const anilistTokenStore = params.createAnilistTokenStore(
|
||||
params.joinPath(userDataPath, 'anilist-token-store.json'),
|
||||
);
|
||||
const jellyfinTokenStore = params.createJellyfinTokenStore(
|
||||
params.joinPath(userDataPath, 'jellyfin-token-store.json'),
|
||||
);
|
||||
const anilistUpdateQueue = params.createAnilistUpdateQueue(
|
||||
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
|
||||
);
|
||||
const subtitleWsService = params.createSubtitleWebSocket();
|
||||
const annotationSubtitleWsService = params.createSubtitleWebSocket();
|
||||
const logger = params.createLogger('main');
|
||||
const runtimeRegistry = params.createMainRuntimeRegistry();
|
||||
const overlayManager = params.createOverlayManager();
|
||||
const overlayModalInputState = params.createOverlayModalInputState({
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => {
|
||||
params.getSyncOverlayShortcutsForModal()(isActive);
|
||||
},
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
params.getSyncOverlayVisibilityForModal()();
|
||||
},
|
||||
});
|
||||
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
|
||||
logger,
|
||||
});
|
||||
const overlayModalRuntime = params.createOverlayModalRuntime({
|
||||
overlayManager,
|
||||
overlayModalInputState,
|
||||
onModalStateChange: (isActive: boolean) =>
|
||||
(overlayModalInputState as { handleModalInputStateChange?: (isActive: boolean) => void })
|
||||
.handleModalInputStateChange?.(isActive),
|
||||
});
|
||||
const appState = params.createAppState({
|
||||
mpvSocketPath: params.getDefaultSocketPath(),
|
||||
texthookerPort: params.defaultTexthookerPort,
|
||||
});
|
||||
|
||||
if (!params.existsSync(userDataPath)) {
|
||||
params.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
params.app.setPath('userData', userDataPath);
|
||||
|
||||
const appLifecycleApp = {
|
||||
requestSingleInstanceLock: () =>
|
||||
params.shouldBypassSingleInstanceLock()
|
||||
? true
|
||||
: params.requestSingleInstanceLockEarly(),
|
||||
quit: () => params.app.quit(),
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === 'second-instance') {
|
||||
params.registerSecondInstanceHandlerEarly(
|
||||
listener as (_event: unknown, argv: string[]) => void,
|
||||
);
|
||||
return appLifecycleApp;
|
||||
}
|
||||
params.app.on(event, listener);
|
||||
return appLifecycleApp;
|
||||
},
|
||||
whenReady: () => params.app.whenReady(),
|
||||
} as TAppLifecycleApp;
|
||||
|
||||
return {
|
||||
configDir,
|
||||
userDataPath,
|
||||
defaultMpvLogPath,
|
||||
defaultImmersionDbPath,
|
||||
configService,
|
||||
anilistTokenStore,
|
||||
jellyfinTokenStore,
|
||||
anilistUpdateQueue,
|
||||
subtitleWsService,
|
||||
annotationSubtitleWsService,
|
||||
logger,
|
||||
runtimeRegistry,
|
||||
overlayManager,
|
||||
overlayModalInputState,
|
||||
overlayContentMeasurementStore,
|
||||
overlayModalRuntime,
|
||||
appState,
|
||||
appLifecycleApp,
|
||||
};
|
||||
}
|
||||
@@ -48,9 +48,14 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
||||
const termEntries: CharacterDictionaryTermEntry[] = [
|
||||
['アルファ', 'あるふぁ', '', '', 0, ['Alpha entry'], 0, 'name'],
|
||||
];
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
const originalBufferConcat = Buffer.concat;
|
||||
|
||||
try {
|
||||
fs.writeFileSync = ((..._args: unknown[]) => {
|
||||
throw new Error('buildDictionaryZip should not call fs.writeFileSync');
|
||||
}) as typeof fs.writeFileSync;
|
||||
|
||||
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
|
||||
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
|
||||
}) as typeof Buffer.concat;
|
||||
@@ -92,6 +97,7 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
||||
assert.equal(termBank[0]?.[0], 'アルファ');
|
||||
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
||||
} finally {
|
||||
fs.writeFileSync = originalWriteFileSync;
|
||||
Buffer.concat = originalBufferConcat;
|
||||
cleanupDir(tempDir);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
|
||||
test('SM-012 controller config update path does not use JSON serialize-clone helpers', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/main/controller-config-update.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
{
|
||||
@@ -52,3 +62,16 @@ test('applyControllerConfigUpdate merges buttonIndices while replacing only upda
|
||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 });
|
||||
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate detaches updated binding values from the patch object', () => {
|
||||
const update = {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button' as const, buttonIndex: 7 },
|
||||
},
|
||||
};
|
||||
|
||||
const next = applyControllerConfigUpdate(undefined, update);
|
||||
update.bindings.toggleLookup.buttonIndex = 99;
|
||||
|
||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export function applyControllerConfigUpdate(
|
||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
||||
>) {
|
||||
if (value === undefined) continue;
|
||||
(nextBindings as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(value));
|
||||
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
|
||||
}
|
||||
|
||||
nextController.bindings = nextBindings;
|
||||
|
||||
@@ -21,7 +21,7 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
||||
now: () => 7,
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', episode: 1 });
|
||||
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', season: null, episode: 1 });
|
||||
deps.refreshRetryQueueState();
|
||||
deps.setLastAttemptAt(1);
|
||||
deps.setLastError('x');
|
||||
|
||||
@@ -84,7 +84,10 @@ test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
|
||||
const originalDateNow = Date.now;
|
||||
const events: string[] = [];
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
@@ -105,10 +108,16 @@ test('consumeAnilistSetupCallbackUrl persists token and closes window for callba
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
|
||||
const originalDateNow = Date.now;
|
||||
const events: string[] = [];
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
@@ -129,6 +138,9 @@ test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
|
||||
|
||||
45
src/main/runtime/autoplay-ready-gate.test.ts
Normal file
45
src/main/runtime/autoplay-ready-gate.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAutoplayReadyGate } from './autoplay-ready-gate';
|
||||
|
||||
test('autoplay ready gate suppresses duplicate media signals unless forced while paused', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands.slice(0, 3), [
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
['script-message', 'subminer-autoplay-ready'],
|
||||
]);
|
||||
assert.ok(commands.some((command) => command[0] === 'set_property' && command[1] === 'pause'));
|
||||
assert.equal(scheduled.length > 0, true);
|
||||
});
|
||||
129
src/main/runtime/autoplay-ready-gate.ts
Normal file
129
src/main/runtime/autoplay-ready-gate.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected?: boolean;
|
||||
requestProperty: (property: string) => Promise<unknown>;
|
||||
send: (payload: { command: Array<string | boolean> }) => void;
|
||||
};
|
||||
|
||||
export type AutoplayReadyGateDeps = {
|
||||
isAppOwnedFlowInFlight: () => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentVideoPath: () => string | null;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
signalPluginAutoplayReady: () => void;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logDebug: (message: string) => void;
|
||||
};
|
||||
|
||||
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
let autoPlayReadySignalMediaPath: string | null = null;
|
||||
let autoPlayReadySignalGeneration = 0;
|
||||
|
||||
const invalidatePendingAutoplayReadyFallbacks = (): void => {
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
): void => {
|
||||
if (deps.isAppOwnedFlowInFlight()) {
|
||||
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
||||
return;
|
||||
}
|
||||
if (!payload.text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() ||
|
||||
deps.getCurrentVideoPath()?.trim() ||
|
||||
'__unknown__';
|
||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||
const allowDuplicateWhilePaused =
|
||||
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
|
||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateMediaSignal && allowDuplicateWhilePaused) {
|
||||
deps.signalPluginAutoplayReady();
|
||||
return;
|
||||
}
|
||||
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
|
||||
const releaseRetryDelayMs = 200;
|
||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
retryDelayMs: releaseRetryDelayMs,
|
||||
});
|
||||
|
||||
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
|
||||
try {
|
||||
const pauseProperty = await client.requestProperty('pause');
|
||||
if (typeof pauseProperty === 'boolean') {
|
||||
return pauseProperty;
|
||||
}
|
||||
if (typeof pauseProperty === 'string') {
|
||||
return pauseProperty.toLowerCase() !== 'no' && pauseProperty !== '0';
|
||||
}
|
||||
if (typeof pauseProperty === 'number') {
|
||||
return pauseProperty !== 0;
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] failed to read pause property for media ${mediaPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const attemptRelease = (attempt: number): void => {
|
||||
void (async () => {
|
||||
if (
|
||||
autoPlayReadySignalMediaPath !== mediaPath ||
|
||||
playbackGeneration !== autoPlayReadySignalGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!mpvClient?.connected) {
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||
if (!shouldUnpause) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.signalPluginAutoplayReady();
|
||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
attemptRelease(0);
|
||||
};
|
||||
|
||||
return {
|
||||
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
||||
invalidatePendingAutoplayReadyFallbacks,
|
||||
maybeSignalPluginAutoplayReady,
|
||||
};
|
||||
}
|
||||
@@ -9,5 +9,8 @@ export * from './jellyfin-remote-composer';
|
||||
export * from './jellyfin-runtime-composer';
|
||||
export * from './mpv-runtime-composer';
|
||||
export * from './overlay-window-composer';
|
||||
export * from './overlay-visibility-runtime-composer';
|
||||
export * from './shortcuts-runtime-composer';
|
||||
export * from './stats-startup-composer';
|
||||
export * from './subtitle-prefetch-runtime-composer';
|
||||
export * from './startup-lifecycle-composer';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer';
|
||||
|
||||
test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => {
|
||||
const composed = composeOverlayVisibilityRuntime({
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
restorePreviousSecondarySubVisibilityMainDeps: {
|
||||
getMpvClient: () => null,
|
||||
},
|
||||
broadcastRuntimeOptionsChangedMainDeps: {
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
getRuntimeOptionsState: () => [],
|
||||
broadcastToOverlayWindows: () => {},
|
||||
},
|
||||
sendToActiveOverlayWindowMainDeps: {
|
||||
sendToActiveOverlayWindowRuntime: () => true,
|
||||
},
|
||||
setOverlayDebugVisualizationEnabledMainDeps: {
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
getCurrentEnabled: () => false,
|
||||
setCurrentEnabled: () => {},
|
||||
},
|
||||
openRuntimeOptionsPaletteMainDeps: {
|
||||
openRuntimeOptionsPaletteRuntime: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.updateVisibleOverlayVisibility, 'function');
|
||||
assert.equal(typeof composed.restorePreviousSecondarySubVisibility, 'function');
|
||||
assert.equal(typeof composed.broadcastRuntimeOptionsChanged, 'function');
|
||||
assert.equal(typeof composed.sendToActiveOverlayWindow, 'function');
|
||||
assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function');
|
||||
assert.equal(typeof composed.openRuntimeOptionsPalette, 'function');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
createBroadcastRuntimeOptionsChangedHandler,
|
||||
createOpenRuntimeOptionsPaletteHandler,
|
||||
createRestorePreviousSecondarySubVisibilityHandler,
|
||||
createSendToActiveOverlayWindowHandler,
|
||||
createSetOverlayDebugVisualizationEnabledHandler,
|
||||
} from '../overlay-runtime-main-actions';
|
||||
import {
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler,
|
||||
createBuildOpenRuntimeOptionsPaletteMainDepsHandler,
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler,
|
||||
createBuildSendToActiveOverlayWindowMainDepsHandler,
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
|
||||
} from '../overlay-runtime-main-actions-main-deps';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type RestorePreviousSecondarySubVisibilityMainDeps = Parameters<
|
||||
typeof createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler
|
||||
>[0];
|
||||
type BroadcastRuntimeOptionsChangedMainDeps = Parameters<
|
||||
typeof createBuildBroadcastRuntimeOptionsChangedMainDepsHandler
|
||||
>[0];
|
||||
type SendToActiveOverlayWindowMainDeps = Parameters<
|
||||
typeof createBuildSendToActiveOverlayWindowMainDepsHandler
|
||||
>[0];
|
||||
type SetOverlayDebugVisualizationEnabledMainDeps = Parameters<
|
||||
typeof createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler
|
||||
>[0];
|
||||
type OpenRuntimeOptionsPaletteMainDeps = Parameters<
|
||||
typeof createBuildOpenRuntimeOptionsPaletteMainDepsHandler
|
||||
>[0];
|
||||
|
||||
export type OverlayVisibilityRuntimeComposerOptions = ComposerInputs<{
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
restorePreviousSecondarySubVisibilityMainDeps: RestorePreviousSecondarySubVisibilityMainDeps;
|
||||
broadcastRuntimeOptionsChangedMainDeps: BroadcastRuntimeOptionsChangedMainDeps;
|
||||
sendToActiveOverlayWindowMainDeps: SendToActiveOverlayWindowMainDeps;
|
||||
setOverlayDebugVisualizationEnabledMainDeps: SetOverlayDebugVisualizationEnabledMainDeps;
|
||||
openRuntimeOptionsPaletteMainDeps: OpenRuntimeOptionsPaletteMainDeps;
|
||||
}>;
|
||||
|
||||
export type OverlayVisibilityRuntimeComposerResult = ComposerOutputs<{
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
restorePreviousSecondarySubVisibility: ReturnType<
|
||||
typeof createRestorePreviousSecondarySubVisibilityHandler
|
||||
>;
|
||||
broadcastRuntimeOptionsChanged: ReturnType<typeof createBroadcastRuntimeOptionsChangedHandler>;
|
||||
sendToActiveOverlayWindow: ReturnType<typeof createSendToActiveOverlayWindowHandler>;
|
||||
setOverlayDebugVisualizationEnabled: ReturnType<
|
||||
typeof createSetOverlayDebugVisualizationEnabledHandler
|
||||
>;
|
||||
openRuntimeOptionsPalette: ReturnType<typeof createOpenRuntimeOptionsPaletteHandler>;
|
||||
}>;
|
||||
|
||||
export function composeOverlayVisibilityRuntime(
|
||||
options: OverlayVisibilityRuntimeComposerOptions,
|
||||
): OverlayVisibilityRuntimeComposerResult {
|
||||
return {
|
||||
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
|
||||
options.restorePreviousSecondarySubVisibilityMainDeps,
|
||||
)(),
|
||||
),
|
||||
broadcastRuntimeOptionsChanged: createBroadcastRuntimeOptionsChangedHandler(
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler(
|
||||
options.broadcastRuntimeOptionsChangedMainDeps,
|
||||
)(),
|
||||
),
|
||||
sendToActiveOverlayWindow: createSendToActiveOverlayWindowHandler(
|
||||
createBuildSendToActiveOverlayWindowMainDepsHandler(
|
||||
options.sendToActiveOverlayWindowMainDeps,
|
||||
)(),
|
||||
),
|
||||
setOverlayDebugVisualizationEnabled: createSetOverlayDebugVisualizationEnabledHandler(
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler(
|
||||
options.setOverlayDebugVisualizationEnabledMainDeps,
|
||||
)(),
|
||||
),
|
||||
openRuntimeOptionsPalette: createOpenRuntimeOptionsPaletteHandler(
|
||||
createBuildOpenRuntimeOptionsPaletteMainDepsHandler(
|
||||
options.openRuntimeOptionsPaletteMainDeps,
|
||||
)(),
|
||||
),
|
||||
};
|
||||
}
|
||||
23
src/main/runtime/composers/stats-startup-composer.test.ts
Normal file
23
src/main/runtime/composers/stats-startup-composer.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeStatsStartupRuntime } from './stats-startup-composer';
|
||||
|
||||
test('composeStatsStartupRuntime returns stats startup handlers', async () => {
|
||||
const composed = composeStatsStartupRuntime({
|
||||
ensureStatsServerStarted: () => 'http://127.0.0.1:8766',
|
||||
ensureBackgroundStatsServerStarted: () => ({
|
||||
url: 'http://127.0.0.1:8766',
|
||||
runningInCurrentProcess: true,
|
||||
}),
|
||||
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
|
||||
ensureImmersionTrackerStarted: () => {},
|
||||
});
|
||||
|
||||
assert.equal(composed.ensureStatsServerStarted(), 'http://127.0.0.1:8766');
|
||||
assert.deepEqual(composed.ensureBackgroundStatsServerStarted(), {
|
||||
url: 'http://127.0.0.1:8766',
|
||||
runningInCurrentProcess: true,
|
||||
});
|
||||
assert.deepEqual(await composed.stopBackgroundStatsServer(), { ok: true, stale: false });
|
||||
assert.equal(typeof composed.ensureImmersionTrackerStarted, 'function');
|
||||
});
|
||||
26
src/main/runtime/composers/stats-startup-composer.ts
Normal file
26
src/main/runtime/composers/stats-startup-composer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
type BackgroundStatsStartResult = {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
|
||||
type BackgroundStatsStopResult = {
|
||||
ok: boolean;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
export type StatsStartupComposerOptions = ComposerInputs<{
|
||||
ensureStatsServerStarted: () => string;
|
||||
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
|
||||
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
}>;
|
||||
|
||||
export type StatsStartupComposerResult = ComposerOutputs<StatsStartupComposerOptions>;
|
||||
|
||||
export function composeStatsStartupRuntime(
|
||||
options: StatsStartupComposerOptions,
|
||||
): StatsStartupComposerResult {
|
||||
return options;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeSubtitlePrefetchRuntime } from './subtitle-prefetch-runtime-composer';
|
||||
|
||||
test('composeSubtitlePrefetchRuntime returns subtitle prefetch runtime helpers', () => {
|
||||
const composed = composeSubtitlePrefetchRuntime({
|
||||
subtitlePrefetchInitController: {
|
||||
cancelPendingInit: () => {},
|
||||
initSubtitlePrefetch: async () => {},
|
||||
},
|
||||
refreshSubtitleSidebarFromSource: async () => {},
|
||||
refreshSubtitlePrefetchFromActiveTrack: async () => {},
|
||||
scheduleSubtitlePrefetchRefresh: () => {},
|
||||
clearScheduledSubtitlePrefetchRefresh: () => {},
|
||||
});
|
||||
|
||||
assert.equal(typeof composed.cancelPendingInit, 'function');
|
||||
assert.equal(typeof composed.initSubtitlePrefetch, 'function');
|
||||
assert.equal(typeof composed.refreshSubtitleSidebarFromSource, 'function');
|
||||
assert.equal(typeof composed.refreshSubtitlePrefetchFromActiveTrack, 'function');
|
||||
assert.equal(typeof composed.scheduleSubtitlePrefetchRefresh, 'function');
|
||||
assert.equal(typeof composed.clearScheduledSubtitlePrefetchRefresh, 'function');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SubtitlePrefetchInitController } from '../subtitle-prefetch-init';
|
||||
import type { ComposerInputs, ComposerOutputs } from './contracts';
|
||||
|
||||
export type SubtitlePrefetchRuntimeComposerOptions = ComposerInputs<{
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
|
||||
clearScheduledSubtitlePrefetchRefresh: () => void;
|
||||
}>;
|
||||
|
||||
export type SubtitlePrefetchRuntimeComposerResult = ComposerOutputs<{
|
||||
cancelPendingInit: () => void;
|
||||
initSubtitlePrefetch: SubtitlePrefetchInitController['initSubtitlePrefetch'];
|
||||
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
|
||||
clearScheduledSubtitlePrefetchRefresh: () => void;
|
||||
}>;
|
||||
|
||||
export function composeSubtitlePrefetchRuntime(
|
||||
options: SubtitlePrefetchRuntimeComposerOptions,
|
||||
): SubtitlePrefetchRuntimeComposerResult {
|
||||
return {
|
||||
cancelPendingInit: () => options.subtitlePrefetchInitController.cancelPendingInit(),
|
||||
initSubtitlePrefetch: options.subtitlePrefetchInitController.initSubtitlePrefetch,
|
||||
refreshSubtitleSidebarFromSource: options.refreshSubtitleSidebarFromSource,
|
||||
refreshSubtitlePrefetchFromActiveTrack: options.refreshSubtitlePrefetchFromActiveTrack,
|
||||
scheduleSubtitlePrefetchRefresh: options.scheduleSubtitlePrefetchRefresh,
|
||||
clearScheduledSubtitlePrefetchRefresh: options.clearScheduledSubtitlePrefetchRefresh,
|
||||
};
|
||||
}
|
||||
76
src/main/runtime/discord-presence-runtime.test.ts
Normal file
76
src/main/runtime/discord-presence-runtime.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createDiscordPresenceRuntime } from './discord-presence-runtime';
|
||||
|
||||
test('discord presence runtime refreshes duration and publishes the current snapshot', async () => {
|
||||
const snapshots: Array<Record<string, unknown>> = [];
|
||||
let mediaDurationSec: number | null = null;
|
||||
|
||||
const runtime = createDiscordPresenceRuntime({
|
||||
getDiscordPresenceService: () => ({
|
||||
publish: (snapshot: Record<string, unknown>) => {
|
||||
snapshots.push(snapshot);
|
||||
},
|
||||
}),
|
||||
isDiscordPresenceEnabled: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
currentTimePos: 12,
|
||||
requestProperty: async (name: string) => {
|
||||
assert.equal(name, 'duration');
|
||||
return 42;
|
||||
},
|
||||
}) as never,
|
||||
getCurrentMediaTitle: () => 'Episode 1',
|
||||
getCurrentMediaPath: () => '/media/episode-1.mkv',
|
||||
getCurrentSubtitleText: () => '字幕',
|
||||
getPlaybackPaused: () => false,
|
||||
getFallbackMediaDurationSec: () => 90,
|
||||
getSessionStartedAtMs: () => 1_000,
|
||||
getMediaDurationSec: () => mediaDurationSec,
|
||||
setMediaDurationSec: (next) => {
|
||||
mediaDurationSec = next;
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.refreshDiscordPresenceMediaDuration();
|
||||
runtime.publishDiscordPresence();
|
||||
|
||||
assert.equal(mediaDurationSec, 42);
|
||||
assert.deepEqual(snapshots, [
|
||||
{
|
||||
mediaTitle: 'Episode 1',
|
||||
mediaPath: '/media/episode-1.mkv',
|
||||
subtitleText: '字幕',
|
||||
currentTimeSec: 12,
|
||||
mediaDurationSec: 42,
|
||||
paused: false,
|
||||
connected: true,
|
||||
sessionStartedAtMs: 1_000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('discord presence runtime skips publish when disabled or service missing', () => {
|
||||
let published = false;
|
||||
const runtime = createDiscordPresenceRuntime({
|
||||
getDiscordPresenceService: () => null,
|
||||
isDiscordPresenceEnabled: () => false,
|
||||
getMpvClient: () => null,
|
||||
getCurrentMediaTitle: () => null,
|
||||
getCurrentMediaPath: () => null,
|
||||
getCurrentSubtitleText: () => '',
|
||||
getPlaybackPaused: () => null,
|
||||
getFallbackMediaDurationSec: () => null,
|
||||
getSessionStartedAtMs: () => 0,
|
||||
getMediaDurationSec: () => null,
|
||||
setMediaDurationSec: () => {
|
||||
published = true;
|
||||
},
|
||||
});
|
||||
|
||||
runtime.publishDiscordPresence();
|
||||
|
||||
assert.equal(published, false);
|
||||
});
|
||||
74
src/main/runtime/discord-presence-runtime.ts
Normal file
74
src/main/runtime/discord-presence-runtime.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
type DiscordPresenceServiceLike = {
|
||||
publish: (snapshot: {
|
||||
mediaTitle: string | null;
|
||||
mediaPath: string | null;
|
||||
subtitleText: string;
|
||||
currentTimeSec: number | null;
|
||||
mediaDurationSec: number | null;
|
||||
paused: boolean | null;
|
||||
connected: boolean;
|
||||
sessionStartedAtMs: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
connected?: boolean;
|
||||
currentTimePos?: number | null;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type DiscordPresenceRuntimeDeps = {
|
||||
getDiscordPresenceService: () => DiscordPresenceServiceLike | null;
|
||||
isDiscordPresenceEnabled: () => boolean;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentSubtitleText: () => string;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getFallbackMediaDurationSec: () => number | null;
|
||||
getSessionStartedAtMs: () => number;
|
||||
getMediaDurationSec: () => number | null;
|
||||
setMediaDurationSec: (durationSec: number | null) => void;
|
||||
};
|
||||
|
||||
export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) {
|
||||
const refreshDiscordPresenceMediaDuration = async (): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await client.requestProperty('duration');
|
||||
const numeric = Number(value);
|
||||
deps.setMediaDurationSec(Number.isFinite(numeric) && numeric > 0 ? numeric : null);
|
||||
} catch {
|
||||
deps.setMediaDurationSec(null);
|
||||
}
|
||||
};
|
||||
|
||||
const publishDiscordPresence = (): void => {
|
||||
const discordPresenceService = deps.getDiscordPresenceService();
|
||||
if (!discordPresenceService || deps.isDiscordPresenceEnabled() !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshDiscordPresenceMediaDuration();
|
||||
const client = deps.getMpvClient();
|
||||
discordPresenceService.publish({
|
||||
mediaTitle: deps.getCurrentMediaTitle(),
|
||||
mediaPath: deps.getCurrentMediaPath(),
|
||||
subtitleText: deps.getCurrentSubtitleText(),
|
||||
currentTimeSec: client?.currentTimePos ?? null,
|
||||
mediaDurationSec: deps.getMediaDurationSec() ?? deps.getFallbackMediaDurationSec(),
|
||||
paused: deps.getPlaybackPaused(),
|
||||
connected: Boolean(client?.connected),
|
||||
sessionStartedAtMs: deps.getSessionStartedAtMs(),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
refreshDiscordPresenceMediaDuration,
|
||||
publishDiscordPresence,
|
||||
};
|
||||
}
|
||||
87
src/main/runtime/overlay-modal-input-state.test.ts
Normal file
87
src/main/runtime/overlay-modal-input-state.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createOverlayModalInputState } from './overlay-modal-input-state';
|
||||
|
||||
function createModalWindow() {
|
||||
const calls: string[] = [];
|
||||
let destroyed = false;
|
||||
let focused = false;
|
||||
let webContentsFocused = false;
|
||||
|
||||
return {
|
||||
calls,
|
||||
setDestroyed(next: boolean) {
|
||||
destroyed = next;
|
||||
},
|
||||
setFocused(next: boolean) {
|
||||
focused = next;
|
||||
},
|
||||
setWebContentsFocused(next: boolean) {
|
||||
webContentsFocused = next;
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||
calls.push(`ignore:${ignore}`);
|
||||
},
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
},
|
||||
focus: () => {
|
||||
focused = true;
|
||||
calls.push('focus');
|
||||
},
|
||||
isFocused: () => focused,
|
||||
webContents: {
|
||||
isFocused: () => webContentsFocused,
|
||||
focus: () => {
|
||||
webContentsFocused = true;
|
||||
calls.push('web-focus');
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('overlay modal input state activates modal window interactivity and syncs dependents', () => {
|
||||
const modalWindow = createModalWindow();
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => modalWindow as never,
|
||||
syncOverlayShortcutsForModal: (isActive) => {
|
||||
calls.push(`shortcuts:${isActive}`);
|
||||
},
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(true);
|
||||
|
||||
assert.equal(state.getModalInputExclusive(), true);
|
||||
assert.deepEqual(modalWindow.calls, [
|
||||
'ignore:false',
|
||||
'top:true:screen-saver:1',
|
||||
'focus',
|
||||
'web-focus',
|
||||
]);
|
||||
assert.deepEqual(calls, ['shortcuts:true', 'visibility']);
|
||||
});
|
||||
|
||||
test('overlay modal input state is idempotent for unchanged state', () => {
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => null,
|
||||
syncOverlayShortcutsForModal: (isActive) => {
|
||||
calls.push(`shortcuts:${isActive}`);
|
||||
},
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(false);
|
||||
state.handleModalInputStateChange(true);
|
||||
state.handleModalInputStateChange(true);
|
||||
|
||||
assert.equal(state.getModalInputExclusive(), true);
|
||||
assert.deepEqual(calls, ['shortcuts:true', 'visibility']);
|
||||
});
|
||||
38
src/main/runtime/overlay-modal-input-state.ts
Normal file
38
src/main/runtime/overlay-modal-input-state.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
export type OverlayModalInputStateDeps = {
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
};
|
||||
|
||||
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
let modalInputExclusive = false;
|
||||
|
||||
const handleModalInputStateChange = (isActive: boolean): void => {
|
||||
if (modalInputExclusive === isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalInputExclusive = isActive;
|
||||
if (isActive) {
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
modalWindow.setIgnoreMouseEvents(false);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
modalWindow.focus();
|
||||
if (!modalWindow.webContents.isFocused()) {
|
||||
modalWindow.webContents.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deps.syncOverlayShortcutsForModal(isActive);
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
};
|
||||
|
||||
return {
|
||||
getModalInputExclusive: (): boolean => modalInputExclusive,
|
||||
handleModalInputStateChange,
|
||||
};
|
||||
}
|
||||
59
src/main/runtime/subtitle-prefetch-runtime.test.ts
Normal file
59
src/main/runtime/subtitle-prefetch-runtime.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createResolveActiveSubtitleSidebarSourceHandler } from './subtitle-prefetch-runtime';
|
||||
|
||||
test('subtitle prefetch runtime resolves direct external subtitle sources first', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg',
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
throw new Error('should not extract external tracks');
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: ' /tmp/current.ass ',
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: null,
|
||||
sidRaw: null,
|
||||
videoPath: '/media/video.mkv',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
path: '/tmp/current.ass',
|
||||
sourceKey: '/tmp/current.ass',
|
||||
});
|
||||
});
|
||||
|
||||
test('subtitle prefetch runtime extracts internal subtitle tracks into a stable source key', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg-custom',
|
||||
extractInternalSubtitleTrack: async (ffmpegPath, videoPath, track) => {
|
||||
assert.equal(ffmpegPath, 'ffmpeg-custom');
|
||||
assert.equal(videoPath, '/media/video.mkv');
|
||||
assert.equal((track as Record<string, unknown>)['ff-index'], 7);
|
||||
return {
|
||||
path: '/tmp/subminer-sidebar-123/track_7.ass',
|
||||
cleanup: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: {
|
||||
type: 'sub',
|
||||
id: 3,
|
||||
'ff-index': 7,
|
||||
codec: 'ass',
|
||||
},
|
||||
trackListRaw: [],
|
||||
sidRaw: 3,
|
||||
videoPath: '/media/video.mkv',
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
path: '/tmp/subminer-sidebar-123/track_7.ass',
|
||||
sourceKey: 'internal:/media/video.mkv:track:3:ff:7',
|
||||
cleanup: resolved?.cleanup,
|
||||
});
|
||||
});
|
||||
180
src/main/runtime/subtitle-prefetch-runtime.ts
Normal file
180
src/main/runtime/subtitle-prefetch-runtime.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { SubtitlePrefetchInitController } from './subtitle-prefetch-init';
|
||||
import { buildSubtitleSidebarSourceKey } from './subtitle-prefetch-source';
|
||||
|
||||
type MpvSubtitleTrackLike = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
selected?: unknown;
|
||||
external?: unknown;
|
||||
codec?: unknown;
|
||||
'ff-index'?: unknown;
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
type ActiveSubtitleSidebarSource = {
|
||||
path: string;
|
||||
sourceKey: string;
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActiveSubtitleTrack(
|
||||
currentTrackRaw: unknown,
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
): MpvSubtitleTrackLike | null {
|
||||
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
|
||||
const track = currentTrackRaw as MpvSubtitleTrackLike;
|
||||
if (track.type === undefined || track.type === 'sub') {
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
const sid = parseTrackId(sidRaw);
|
||||
if (!Array.isArray(trackListRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bySid =
|
||||
sid === null
|
||||
? null
|
||||
: ((trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const track = entry as MpvSubtitleTrackLike;
|
||||
return track.type === 'sub' && parseTrackId(track.id) === sid;
|
||||
}) as MpvSubtitleTrackLike | undefined) ?? null);
|
||||
if (bySid) {
|
||||
return bySid;
|
||||
}
|
||||
|
||||
return (
|
||||
(trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const track = entry as MpvSubtitleTrackLike;
|
||||
return track.type === 'sub' && track.selected === true;
|
||||
}) as MpvSubtitleTrackLike | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
getFfmpegPath: () => string;
|
||||
extractInternalSubtitleTrack: (
|
||||
ffmpegPath: string,
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
|
||||
}) {
|
||||
return async (input: {
|
||||
currentExternalFilenameRaw: unknown;
|
||||
currentTrackRaw: unknown;
|
||||
trackListRaw: unknown;
|
||||
sidRaw: unknown;
|
||||
videoPath: string;
|
||||
}): Promise<ActiveSubtitleSidebarSource | null> => {
|
||||
const currentExternalFilename =
|
||||
typeof input.currentExternalFilenameRaw === 'string'
|
||||
? input.currentExternalFilenameRaw.trim()
|
||||
: '';
|
||||
if (currentExternalFilename) {
|
||||
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
|
||||
}
|
||||
|
||||
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
|
||||
if (!track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const externalFilename =
|
||||
typeof track['external-filename'] === 'string' ? track['external-filename'].trim() : '';
|
||||
if (externalFilename) {
|
||||
return { path: externalFilename, sourceKey: externalFilename };
|
||||
}
|
||||
|
||||
const extracted = await deps.extractInternalSubtitleTrack(
|
||||
deps.getFfmpegPath(),
|
||||
input.videoPath,
|
||||
track,
|
||||
);
|
||||
if (!extracted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...extracted,
|
||||
sourceKey: buildSubtitleSidebarSourceKey(input.videoPath, track, extracted.path),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
||||
getMpvClient: () => {
|
||||
connected?: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
subtitlePrefetchInitController: SubtitlePrefetchInitController;
|
||||
resolveActiveSubtitleSidebarSource: (
|
||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||
) => Promise<ActiveSubtitleSidebarSource | null>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
|
||||
await Promise.all([
|
||||
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
|
||||
client.requestProperty('current-tracks/sub').catch(() => null),
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('path'),
|
||||
]);
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||
if (!videoPath) {
|
||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedSource = await deps.resolveActiveSubtitleSidebarSource({
|
||||
currentExternalFilenameRaw,
|
||||
currentTrackRaw,
|
||||
trackListRaw,
|
||||
sidRaw,
|
||||
videoPath,
|
||||
});
|
||||
if (!resolvedSource) {
|
||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||
resolvedSource.path,
|
||||
deps.getLastObservedTimePos(),
|
||||
resolvedSource.sourceKey,
|
||||
);
|
||||
} finally {
|
||||
await resolvedSource.cleanup?.();
|
||||
}
|
||||
} catch {
|
||||
// Skip refresh when the track query fails.
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export interface WindowsMpvShortcutInstallResult {
|
||||
}
|
||||
|
||||
export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string {
|
||||
return path.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
|
||||
return path.win32.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvShortcutPaths(options: {
|
||||
@@ -32,11 +32,11 @@ export function resolveWindowsMpvShortcutPaths(options: {
|
||||
desktopDir: string;
|
||||
}): WindowsMpvShortcutPaths {
|
||||
return {
|
||||
startMenuPath: path.join(
|
||||
startMenuPath: path.win32.join(
|
||||
resolveWindowsStartMenuProgramsDir(options.appDataDir),
|
||||
WINDOWS_MPV_SHORTCUT_NAME,
|
||||
),
|
||||
desktopPath: path.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
|
||||
desktopPath: path.win32.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcut
|
||||
return {
|
||||
target: exePath,
|
||||
args: '--launch-mpv',
|
||||
cwd: path.dirname(exePath),
|
||||
cwd: path.win32.dirname(exePath),
|
||||
description: 'Launch mpv with the SubMiner profile',
|
||||
icon: exePath,
|
||||
iconIndex: 0,
|
||||
@@ -79,7 +79,7 @@ export function applyWindowsMpvShortcuts(options: {
|
||||
const failures: string[] = [];
|
||||
|
||||
const ensureShortcut = (shortcutPath: string): void => {
|
||||
mkdirSync(path.dirname(shortcutPath), { recursive: true });
|
||||
mkdirSync(path.win32.dirname(shortcutPath), { recursive: true });
|
||||
const ok = options.writeShortcutLink(shortcutPath, 'replace', details);
|
||||
if (!ok) {
|
||||
failures.push(shortcutPath);
|
||||
|
||||
80
src/main/runtime/youtube-playback-runtime.test.ts
Normal file
80
src/main/runtime/youtube-playback-runtime.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createYoutubePlaybackRuntime } from './youtube-playback-runtime';
|
||||
|
||||
test('youtube playback runtime resets flow ownership after a successful run', async () => {
|
||||
const calls: string[] = [];
|
||||
let appOwnedFlowInFlight = false;
|
||||
let timeoutCallback: (() => void) | null = null;
|
||||
|
||||
const runtime = createYoutubePlaybackRuntime({
|
||||
platform: 'linux',
|
||||
directPlaybackFormat: 'best',
|
||||
mpvYtdlFormat: 'bestvideo+bestaudio',
|
||||
autoLaunchTimeoutMs: 2_000,
|
||||
connectTimeoutMs: 1_000,
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
getMpvConnected: () => true,
|
||||
invalidatePendingAutoplayReadyFallbacks: () => {
|
||||
calls.push('invalidate-autoplay');
|
||||
},
|
||||
setAppOwnedFlowInFlight: (next) => {
|
||||
appOwnedFlowInFlight = next;
|
||||
calls.push(`app-owned:${next}`);
|
||||
},
|
||||
ensureYoutubePlaybackRuntimeReady: async () => {
|
||||
calls.push('ensure-runtime-ready');
|
||||
},
|
||||
resolveYoutubePlaybackUrl: async () => {
|
||||
throw new Error('linux path should not resolve direct playback url');
|
||||
},
|
||||
launchWindowsMpv: () => ({ ok: false }),
|
||||
waitForYoutubeMpvConnected: async (timeoutMs) => {
|
||||
calls.push(`wait-connected:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
prepareYoutubePlaybackInMpv: async ({ url }) => {
|
||||
calls.push(`prepare:${url}`);
|
||||
return true;
|
||||
},
|
||||
runYoutubePlaybackFlow: async ({ url, mode }) => {
|
||||
calls.push(`run-flow:${url}:${mode}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
timeoutCallback = callback;
|
||||
calls.push('schedule-arm');
|
||||
return 1 as never;
|
||||
},
|
||||
clearScheduled: () => {
|
||||
calls.push('clear-scheduled');
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runYoutubePlaybackFlow({
|
||||
url: 'https://youtu.be/demo',
|
||||
mode: 'download',
|
||||
source: 'initial',
|
||||
});
|
||||
|
||||
assert.equal(appOwnedFlowInFlight, false);
|
||||
assert.equal(runtime.getQuitOnDisconnectArmed(), false);
|
||||
assert.deepEqual(calls.slice(0, 6), [
|
||||
'invalidate-autoplay',
|
||||
'app-owned:true',
|
||||
'ensure-runtime-ready',
|
||||
'wait-connected:1000',
|
||||
'schedule-arm',
|
||||
'prepare:https://youtu.be/demo',
|
||||
]);
|
||||
|
||||
assert.ok(timeoutCallback);
|
||||
const scheduledCallback = timeoutCallback as () => void;
|
||||
scheduledCallback();
|
||||
assert.equal(runtime.getQuitOnDisconnectArmed(), true);
|
||||
});
|
||||
149
src/main/runtime/youtube-playback-runtime.ts
Normal file
149
src/main/runtime/youtube-playback-runtime.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
||||
|
||||
type LaunchResult = {
|
||||
ok: boolean;
|
||||
mpvPath?: string;
|
||||
};
|
||||
|
||||
export type YoutubePlaybackRuntimeDeps = {
|
||||
platform: NodeJS.Platform;
|
||||
directPlaybackFormat: string;
|
||||
mpvYtdlFormat: string;
|
||||
autoLaunchTimeoutMs: number;
|
||||
connectTimeoutMs: number;
|
||||
socketPath: string;
|
||||
getMpvConnected: () => boolean;
|
||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
||||
setAppOwnedFlowInFlight: (next: boolean) => void;
|
||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
||||
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
|
||||
launchWindowsMpv: (playbackUrl: string, args: string[]) => LaunchResult;
|
||||
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
|
||||
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
}) => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
};
|
||||
|
||||
export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
|
||||
let quitOnDisconnectArmed = false;
|
||||
let quitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let playbackFlowGeneration = 0;
|
||||
|
||||
const clearYoutubePlayQuitOnDisconnectArmTimer = (): void => {
|
||||
if (quitOnDisconnectArmTimer) {
|
||||
deps.clearScheduled(quitOnDisconnectArmTimer);
|
||||
quitOnDisconnectArmTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const runYoutubePlaybackFlow = async (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}): Promise<void> => {
|
||||
const flowGeneration = ++playbackFlowGeneration;
|
||||
deps.invalidatePendingAutoplayReadyFallbacks();
|
||||
deps.setAppOwnedFlowInFlight(true);
|
||||
let flowCompleted = false;
|
||||
|
||||
try {
|
||||
clearYoutubePlayQuitOnDisconnectArmTimer();
|
||||
quitOnDisconnectArmed = false;
|
||||
await deps.ensureYoutubePlaybackRuntimeReady();
|
||||
|
||||
let playbackUrl = request.url;
|
||||
let launchedWindowsMpv = false;
|
||||
if (deps.platform === 'win32') {
|
||||
try {
|
||||
playbackUrl = await deps.resolveYoutubePlaybackUrl(
|
||||
request.url,
|
||||
deps.directPlaybackFormat,
|
||||
);
|
||||
deps.logInfo('Resolved direct YouTube playback URL for Windows MPV startup.');
|
||||
} catch (error) {
|
||||
deps.logWarn(
|
||||
`Failed to resolve direct YouTube playback URL; falling back to page URL: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.platform === 'win32' && !deps.getMpvConnected()) {
|
||||
const launchResult = deps.launchWindowsMpv(playbackUrl, [
|
||||
'--pause=yes',
|
||||
'--ytdl=yes',
|
||||
`--ytdl-format=${deps.mpvYtdlFormat}`,
|
||||
'--sub-auto=no',
|
||||
'--sub-file-paths=.;subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--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',
|
||||
`--input-ipc-server=${deps.socketPath}`,
|
||||
]);
|
||||
launchedWindowsMpv = launchResult.ok;
|
||||
if (launchResult.ok && launchResult.mpvPath) {
|
||||
deps.logInfo(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
|
||||
}
|
||||
if (!launchResult.ok) {
|
||||
deps.logWarn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||
}
|
||||
}
|
||||
|
||||
const connected = await deps.waitForYoutubeMpvConnected(
|
||||
launchedWindowsMpv ? deps.autoLaunchTimeoutMs : deps.connectTimeoutMs,
|
||||
);
|
||||
if (!connected) {
|
||||
throw new Error(
|
||||
launchedWindowsMpv
|
||||
? 'MPV not connected after auto-launch. Ensure mpv is installed and can open the requested YouTube URL.'
|
||||
: 'MPV not connected. Start mpv with the SubMiner profile or retry after mpv finishes starting.',
|
||||
);
|
||||
}
|
||||
|
||||
if (request.source === 'initial') {
|
||||
quitOnDisconnectArmTimer = deps.schedule(() => {
|
||||
if (playbackFlowGeneration !== flowGeneration) {
|
||||
return;
|
||||
}
|
||||
quitOnDisconnectArmed = true;
|
||||
quitOnDisconnectArmTimer = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl });
|
||||
if (!mediaReady) {
|
||||
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
|
||||
}
|
||||
|
||||
await deps.runYoutubePlaybackFlow({
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
});
|
||||
flowCompleted = true;
|
||||
deps.logInfo(`YouTube playback flow completed from ${request.source}.`);
|
||||
} finally {
|
||||
if (playbackFlowGeneration === flowGeneration) {
|
||||
if (!flowCompleted) {
|
||||
clearYoutubePlayQuitOnDisconnectArmTimer();
|
||||
quitOnDisconnectArmed = false;
|
||||
}
|
||||
deps.setAppOwnedFlowInFlight(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
clearYoutubePlayQuitOnDisconnectArmTimer,
|
||||
getQuitOnDisconnectArmed: (): boolean => quitOnDisconnectArmed,
|
||||
runYoutubePlaybackFlow,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,13 @@ test('release workflow verifies generated config examples before packaging artif
|
||||
assert.match(releaseWorkflow, /bun run verify:config-example/);
|
||||
});
|
||||
|
||||
test('release quality gate runs the maintained source coverage lane and uploads lcov output', () => {
|
||||
assert.match(releaseWorkflow, /name: Coverage suite \(maintained source lane\)/);
|
||||
assert.match(releaseWorkflow, /run: bun run test:coverage:src/);
|
||||
assert.match(releaseWorkflow, /name: Upload coverage artifact/);
|
||||
assert.match(releaseWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||
});
|
||||
|
||||
test('release build jobs install and cache stats dependencies before packaging', () => {
|
||||
assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/);
|
||||
assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/);
|
||||
|
||||
64
src/runtime-options.test.ts
Normal file
64
src/runtime-options.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
|
||||
test('SM-012 runtime options path does not use JSON serialize-clone helpers', () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), 'src/runtime-options.ts'), 'utf-8');
|
||||
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
|
||||
});
|
||||
|
||||
test('RuntimeOptionsManager returns detached effective Anki config copies', () => {
|
||||
const baseConfig = {
|
||||
deck: 'Mining',
|
||||
note: 'Sentence',
|
||||
tags: ['SubMiner'],
|
||||
behavior: {
|
||||
autoUpdateNewCards: true,
|
||||
updateIntervalMs: 5000,
|
||||
},
|
||||
fieldMapping: {
|
||||
sentence: 'Sentence',
|
||||
meaning: 'Meaning',
|
||||
audio: 'Audio',
|
||||
image: 'Image',
|
||||
context: 'Context',
|
||||
source: 'Source',
|
||||
definition: 'Definition',
|
||||
sequence: 'Sequence',
|
||||
contextSecondary: 'ContextSecondary',
|
||||
contextTertiary: 'ContextTertiary',
|
||||
primarySpelling: 'PrimarySpelling',
|
||||
primaryReading: 'PrimaryReading',
|
||||
wordSpelling: 'WordSpelling',
|
||||
wordReading: 'WordReading',
|
||||
},
|
||||
duplicates: {
|
||||
mode: 'note' as const,
|
||||
scope: 'deck' as const,
|
||||
allowedFields: [],
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
model: '',
|
||||
systemPrompt: '',
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new RuntimeOptionsManager(
|
||||
() => structuredClone(baseConfig),
|
||||
{
|
||||
applyAnkiPatch: () => undefined,
|
||||
onOptionsChanged: () => undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const effective = manager.getEffectiveAnkiConnectConfig();
|
||||
effective.tags!.push('mutated');
|
||||
effective.behavior!.autoUpdateNewCards = false;
|
||||
|
||||
assert.deepEqual(baseConfig.tags, ['SubMiner']);
|
||||
assert.equal(baseConfig.behavior.autoUpdateNewCards, true);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from './config';
|
||||
type RuntimeOverrides = Record<string, unknown>;
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function getPathValue(source: Record<string, unknown>, path: string): unknown {
|
||||
|
||||
Reference in New Issue
Block a user