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)
|
- name: Test suite (source)
|
||||||
run: bun run test:fast
|
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)
|
- name: Launcher smoke suite (source)
|
||||||
run: bun run test:launcher:smoke:src
|
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)
|
- name: Test suite (source)
|
||||||
run: bun run test:fast
|
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)
|
- name: Launcher smoke suite (source)
|
||||||
run: bun run test:launcher:smoke:src
|
run: bun run test:launcher:smoke:src
|
||||||
|
|
||||||
|
|||||||
67
Backlog.md
67
Backlog.md
@@ -18,7 +18,9 @@ Priority keys:
|
|||||||
|
|
||||||
## Active
|
## Active
|
||||||
|
|
||||||
None.
|
| ID | Pri | Status | Area | Title |
|
||||||
|
| ------ | --- | ------ | -------------- | --------------------------------------------------- |
|
||||||
|
| SM-013 | P1 | doing | review-followup | Address PR #36 CodeRabbit action items |
|
||||||
|
|
||||||
## Ready
|
## Ready
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ None.
|
|||||||
| SM-008 | P3 | todo | subtitles | Add core subtitle-position persistence/path tests |
|
| SM-008 | P3 | todo | subtitles | Add core subtitle-position persistence/path tests |
|
||||||
| SM-009 | P3 | todo | tokenizer | Add tests for JLPT token filter |
|
| 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-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
|
## Icebox
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@ None.
|
|||||||
|
|
||||||
Title: Add tests for CLI parser and args normalizer
|
Title: Add tests for CLI parser and args normalizer
|
||||||
Priority: P1
|
Priority: P1
|
||||||
Status: todo
|
Status: done
|
||||||
Scope:
|
Scope:
|
||||||
|
|
||||||
- `launcher/config/cli-parser-builder.ts`
|
- `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`)
|
- 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
|
- 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
|
- 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
|
id: TASK-238.6
|
||||||
title: Extract remaining inline runtime logic and composer gaps from src/main.ts
|
title: Extract remaining inline runtime logic and composer gaps from src/main.ts
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-27 00:00'
|
created_date: '2026-03-27 00:00'
|
||||||
|
updated_date: '2026-03-27 22:13'
|
||||||
labels:
|
labels:
|
||||||
- tech-debt
|
- tech-debt
|
||||||
- runtime
|
- runtime
|
||||||
@@ -34,11 +35,11 @@ priority: high
|
|||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 `runYoutubePlaybackFlow`, `maybeSignalPluginAutoplayReady`, `refreshSubtitlePrefetchFromActiveTrack`, `publishDiscordPresence`, and `handleModalInputStateChange` no longer live as substantial inline logic in `src/main.ts`.
|
- [x] #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`.
|
- [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`.
|
||||||
- [ ] #3 `src/main.ts` reads primarily as a boot and lifecycle coordinator, with domain behavior concentrated in named runtime modules.
|
- [x] #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.
|
- [x] #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] #5 The task records whether the remaining size still justifies a boot-phase split or whether that follow-up can wait.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -58,3 +59,26 @@ Guardrails:
|
|||||||
- Prefer moving logic to existing runtime surfaces over creating new giant helper files.
|
- 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.
|
- Do not expand into unrelated `src/main.ts` cleanup that is already tracked by other TASK-238 slices.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- 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
|
id: TASK-238.7
|
||||||
title: Split src/main.ts into boot-phase services, runtimes, and handlers
|
title: Split src/main.ts into boot-phase services, runtimes, and handlers
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-27 00:00'
|
created_date: '2026-03-27 00:00'
|
||||||
|
updated_date: '2026-03-27 22:45'
|
||||||
labels:
|
labels:
|
||||||
- tech-debt
|
- tech-debt
|
||||||
- runtime
|
- runtime
|
||||||
@@ -31,11 +32,11 @@ After the remaining inline runtime logic and composer gaps are extracted, `src/m
|
|||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`.
|
- [x] #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.
|
- [x] #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.
|
- [x] #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.
|
- [x] #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] #5 Focused tests cover the split surfaces, and the relevant runtime/typecheck gate passes.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
@@ -56,3 +57,29 @@ Guardrails:
|
|||||||
- Prefer small boot modules with narrow ownership over a new monolithic bootstrap layer.
|
- 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.
|
- Do not reopen the inline logic work already tracked by TASK-238.6 unless a remaining seam truly belongs here.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- 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;
|
let plausibleTrackerInitialized = false;
|
||||||
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
||||||
const PLAUSIBLE_DOMAIN = 'subminer.moe';
|
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() {
|
async function initPlausibleTracker() {
|
||||||
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
|
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { init } = await import('@plausible-analytics/tracker');
|
const { init } = await import('@plausible-analytics/tracker');
|
||||||
init({
|
init({
|
||||||
domain: PLAUSIBLE_DOMAIN,
|
domain: PLAUSIBLE_DOMAIN,
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
|||||||
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
||||||
const docsThemeContents = readFileSync(docsThemePath, '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(docsConfigContents).toContain("hostname: 'https://docs.subminer.moe'");
|
||||||
expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = '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(
|
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('@plausible-analytics/tracker');
|
||||||
expect(docsThemeContents).toContain('const { init } = await import');
|
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('domain: PLAUSIBLE_DOMAIN');
|
||||||
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
|
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
|
||||||
expect(docsThemeContents).toContain('outboundLinks: true');
|
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)
|
- 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)
|
- 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)
|
- “What owns this behavior?”: [Domains](./architecture/domains.md)
|
||||||
- “Can these modules depend on each other?”: [Layering](./architecture/layering.md)
|
- “Can these modules depend on each other?”: [Layering](./architecture/layering.md)
|
||||||
- “What doc should exist for this?”: [Catalog](./knowledge-base/catalog.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
|
## Current Shape
|
||||||
|
|
||||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
- `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/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||||
- `src/renderer/` owns overlay rendering and input behavior.
|
- `src/renderer/` owns overlay rendering and input behavior.
|
||||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
- `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
|
- 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`
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
- 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
|
- 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
|
## Rules
|
||||||
|
|
||||||
- Capture exact failing command and error when verification breaks.
|
- 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: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:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist",
|
||||||
"test:src": "bun scripts/run-test-lane.mjs bun-src-full",
|
"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: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: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",
|
"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:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle: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",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
|
|||||||
@@ -113,15 +113,17 @@ run_step() {
|
|||||||
local name=$2
|
local name=$2
|
||||||
local command=$3
|
local command=$3
|
||||||
local note=${4:-}
|
local note=${4:-}
|
||||||
|
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
||||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||||
local stdout_rel="steps/${slug}.stdout.log"
|
local step_slug="${lane_slug}--${slug}"
|
||||||
local stderr_rel="steps/${slug}.stderr.log"
|
local stdout_rel="steps/${step_slug}.stdout.log"
|
||||||
|
local stderr_rel="steps/${step_slug}.stderr.log"
|
||||||
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
||||||
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
||||||
local status exit_code
|
local status exit_code
|
||||||
|
|
||||||
COMMANDS_RUN+=("$command")
|
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
|
if [[ "$DRY_RUN" == "1" ]]; then
|
||||||
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
||||||
@@ -129,7 +131,11 @@ run_step() {
|
|||||||
status="dry-run"
|
status="dry-run"
|
||||||
exit_code=0
|
exit_code=0
|
||||||
else
|
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"
|
status="passed"
|
||||||
exit_code=0
|
exit_code=0
|
||||||
EXECUTED_REAL_STEPS=1
|
EXECUTED_REAL_STEPS=1
|
||||||
@@ -157,9 +163,11 @@ record_nonpassing_step() {
|
|||||||
local name=$2
|
local name=$2
|
||||||
local status=$3
|
local status=$3
|
||||||
local note=$4
|
local note=$4
|
||||||
|
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
||||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||||
local stdout_rel="steps/${slug}.stdout.log"
|
local step_slug="${lane_slug}--${slug}"
|
||||||
local stderr_rel="steps/${slug}.stderr.log"
|
local stdout_rel="steps/${step_slug}.stdout.log"
|
||||||
|
local stderr_rel="steps/${step_slug}.stderr.log"
|
||||||
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
||||||
: >"$ARTIFACT_DIR/$stderr_rel"
|
: >"$ARTIFACT_DIR/$stderr_rel"
|
||||||
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
||||||
@@ -179,8 +187,10 @@ record_failed_step() {
|
|||||||
FAILED=1
|
FAILED=1
|
||||||
FAILURE_STEP=$2
|
FAILURE_STEP=$2
|
||||||
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
||||||
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
|
local lane_slug=${1//[^a-zA-Z0-9_-]/-}
|
||||||
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
|
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"
|
add_blocker "$3"
|
||||||
record_nonpassing_step "$1" "$2" "failed" "$3"
|
record_nonpassing_step "$1" "$2" "failed" "$3"
|
||||||
}
|
}
|
||||||
@@ -212,7 +222,7 @@ acquire_real_runtime_lease() {
|
|||||||
if [[ -f "$lease_dir/session_id" ]]; then
|
if [[ -f "$lease_dir/session_id" ]]; then
|
||||||
owner=$(cat "$lease_dir/session_id")
|
owner=$(cat "$lease_dir/session_id")
|
||||||
fi
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,8 +387,11 @@ FAILURE_COMMAND=""
|
|||||||
FAILURE_STDOUT=""
|
FAILURE_STDOUT=""
|
||||||
FAILURE_STDERR=""
|
FAILURE_STDERR=""
|
||||||
REAL_RUNTIME_LEASE_DIR=""
|
REAL_RUNTIME_LEASE_DIR=""
|
||||||
|
REAL_RUNTIME_LEASE_ERROR=""
|
||||||
PATH_SELECTION_MODE="auto"
|
PATH_SELECTION_MODE="auto"
|
||||||
|
|
||||||
|
trap 'release_real_runtime_lease' EXIT
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--lane)
|
--lane)
|
||||||
@@ -486,7 +499,7 @@ for lane in "${SELECTED_LANES[@]}"; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if ! acquire_real_runtime_lease; then
|
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
|
continue
|
||||||
fi
|
fi
|
||||||
helper=$(find_real_runtime_helper || true)
|
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 {
|
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}`);
|
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
||||||
return match[1] ?? '';
|
return match[1] ?? '';
|
||||||
}
|
}
|
||||||
@@ -42,10 +42,17 @@ function readSummaryJson(artifactDir: string) {
|
|||||||
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
status: string;
|
status: string;
|
||||||
selectedLanes: string[];
|
lanes: string[];
|
||||||
blockers?: string[];
|
blockers?: string[];
|
||||||
artifactDir: string;
|
artifactDir: string;
|
||||||
pathSelectionMode?: 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',
|
'launcher/mpv.ts',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.notEqual(result.status, 0, result.stdout);
|
assert.equal(result.status, 0, result.stdout);
|
||||||
assert.match(result.stdout, /^result=blocked$/m);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
const summary = readSummaryJson(artifactDir);
|
||||||
assert.equal(summary.status, 'blocked');
|
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.sessionId.length > 0);
|
||||||
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
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',
|
'src/main.ts',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.notEqual(result.status, 0, result.stdout);
|
assert.equal(result.status, 0, result.stdout);
|
||||||
assert.match(result.stdout, /^result=failed$/m);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
const summary = readSummaryJson(artifactDir);
|
||||||
assert.equal(summary.status, 'failed');
|
assert.equal(summary.status, 'blocked');
|
||||||
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
|
assert.deepEqual(summary.lanes, ['not-a-lane']);
|
||||||
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown 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', () => {
|
test('verifier allocates unique session ids and artifact roots by default', () => {
|
||||||
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||||
const second = 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);
|
const secondSummary = readSummaryJson(secondArtifactDir);
|
||||||
|
|
||||||
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
||||||
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
|
assert.notEqual(firstArtifactDir, secondArtifactDir);
|
||||||
assert.equal(firstSummary.pathSelectionMode, 'explicit');
|
assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes');
|
||||||
assert.equal(secondSummary.pathSelectionMode, 'explicit');
|
assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes');
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
||||||
fs.rmSync(secondArtifactDir, { 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 { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Date.now = () => 120_000;
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
statePath,
|
statePath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: Date.now(),
|
refreshedAtMs: 120_000,
|
||||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
@@ -102,12 +104,20 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
|||||||
);
|
);
|
||||||
|
|
||||||
manager.startLifecycle();
|
manager.startLifecycle();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
||||||
|
|
||||||
assert.equal(manager.isKnownWord('猫'), true);
|
assert.equal(manager.isKnownWord('猫'), true);
|
||||||
assert.equal(calls.findNotes, 0);
|
assert.equal(calls.findNotes, 0);
|
||||||
assert.equal(calls.notesInfo, 0);
|
assert.equal(calls.notesInfo, 0);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
manager as unknown as {
|
||||||
|
getMsUntilNextRefresh: () => number;
|
||||||
|
}
|
||||||
|
).getMsUntilNextRefresh() > 0,
|
||||||
|
true,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
manager.stopLifecycle();
|
manager.stopLifecycle();
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
@@ -124,13 +134,15 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
|
const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Date.now = () => 120_000;
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
statePath,
|
statePath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
refreshedAtMs: Date.now() - 61_000,
|
refreshedAtMs: 59_000,
|
||||||
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
||||||
words: ['猫'],
|
words: ['猫'],
|
||||||
notes: {
|
notes: {
|
||||||
@@ -156,6 +168,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
|||||||
assert.equal(manager.isKnownWord('猫'), false);
|
assert.equal(manager.isKnownWord('猫'), false);
|
||||||
assert.equal(manager.isKnownWord('犬'), true);
|
assert.equal(manager.isKnownWord('犬'), true);
|
||||||
} finally {
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
manager.stopLifecycle();
|
manager.stopLifecycle();
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,35 +4,41 @@ import test from 'node:test';
|
|||||||
import { PollingRunner } from './polling';
|
import { PollingRunner } from './polling';
|
||||||
|
|
||||||
test('polling runner records newly added cards after initialization', async () => {
|
test('polling runner records newly added cards after initialization', async () => {
|
||||||
|
const originalDateNow = Date.now;
|
||||||
const recordedCards: number[] = [];
|
const recordedCards: number[] = [];
|
||||||
let tracked = new Set<number>();
|
let tracked = new Set<number>();
|
||||||
const responses = [
|
const responses = [
|
||||||
[10, 11],
|
[10, 11],
|
||||||
[10, 11, 12, 13],
|
[10, 11, 12, 13],
|
||||||
];
|
];
|
||||||
const runner = new PollingRunner({
|
try {
|
||||||
getDeck: () => 'Mining',
|
Date.now = () => 120_000;
|
||||||
getPollingRate: () => 250,
|
const runner = new PollingRunner({
|
||||||
findNotes: async () => responses.shift() ?? [],
|
getDeck: () => 'Mining',
|
||||||
shouldAutoUpdateNewCards: () => true,
|
getPollingRate: () => 250,
|
||||||
processNewCard: async () => undefined,
|
findNotes: async () => responses.shift() ?? [],
|
||||||
recordCardsAdded: (count) => {
|
shouldAutoUpdateNewCards: () => true,
|
||||||
recordedCards.push(count);
|
processNewCard: async () => undefined,
|
||||||
},
|
recordCardsAdded: (count) => {
|
||||||
isUpdateInProgress: () => false,
|
recordedCards.push(count);
|
||||||
setUpdateInProgress: () => undefined,
|
},
|
||||||
getTrackedNoteIds: () => tracked,
|
isUpdateInProgress: () => false,
|
||||||
setTrackedNoteIds: (noteIds) => {
|
setUpdateInProgress: () => undefined,
|
||||||
tracked = noteIds;
|
getTrackedNoteIds: () => tracked,
|
||||||
},
|
setTrackedNoteIds: (noteIds) => {
|
||||||
showStatusNotification: () => undefined,
|
tracked = noteIds;
|
||||||
logDebug: () => undefined,
|
},
|
||||||
logInfo: () => undefined,
|
showStatusNotification: () => undefined,
|
||||||
logWarn: () => undefined,
|
logDebug: () => undefined,
|
||||||
});
|
logInfo: () => undefined,
|
||||||
|
logWarn: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
await runner.pollOnce();
|
await runner.pollOnce();
|
||||||
await runner.pollOnce();
|
await runner.pollOnce();
|
||||||
|
|
||||||
assert.deepEqual(recordedCards, [2]);
|
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 ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
||||||
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
|
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', () => {
|
test('ci workflow lints changelog fragments', () => {
|
||||||
assert.match(ciWorkflow, /bun run changelog:lint/);
|
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', () => {
|
test('ci workflow verifies generated config examples stay in sync', () => {
|
||||||
assert.match(ciWorkflow, /bun run verify:config-example/);
|
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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { ConfigService, ConfigStartupParseError } from './service';
|
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';
|
import { generateConfigTemplate } from './template';
|
||||||
|
|
||||||
function makeTempDir(): string {
|
function makeTempDir(): string {
|
||||||
@@ -1032,6 +1032,61 @@ test('reloadConfigStrict parse failure does not mutate raw config or warnings',
|
|||||||
assert.deepEqual(service.getWarnings(), beforeWarnings);
|
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', () => {
|
test('warning emission order is deterministic across reloads', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
const configPath = path.join(dir, 'config.jsonc');
|
const configPath = path.join(dir, 'config.jsonc');
|
||||||
|
|||||||
@@ -84,11 +84,11 @@ export const CONFIG_OPTION_REGISTRY = [
|
|||||||
export { CONFIG_TEMPLATE_SECTIONS };
|
export { CONFIG_TEMPLATE_SECTIONS };
|
||||||
|
|
||||||
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
|
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 {
|
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 patchObject = patch as Record<string, unknown>;
|
||||||
|
|
||||||
const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): void => {
|
const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): void => {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getRawConfig(): RawConfig {
|
getRawConfig(): RawConfig {
|
||||||
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
|
return structuredClone(this.rawConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWarnings(): ConfigValidationWarning[] {
|
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', () => {
|
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
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', () => {
|
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
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', () => {
|
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -1244,11 +1517,12 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
|
|||||||
|
|
||||||
const rows = getMonthlyRollups(db, 1);
|
const rows = getMonthlyRollups(db, 1);
|
||||||
assert.equal(rows.length, 2);
|
assert.equal(rows.length, 2);
|
||||||
assert.equal(rows[1]?.cardsPerHour, 30);
|
const rowsByVideoId = new Map(rows.map((row) => [row.videoId, row]));
|
||||||
assert.equal(rows[1]?.tokensPerMin, 3);
|
assert.equal(rowsByVideoId.get(2)?.cardsPerHour, 30);
|
||||||
assert.equal(rows[1]?.lookupHitRate ?? null, null);
|
assert.equal(rowsByVideoId.get(2)?.tokensPerMin, 3);
|
||||||
assert.equal(rows[0]?.cardsPerHour ?? null, null);
|
assert.equal(rowsByVideoId.get(2)?.lookupHitRate ?? null, null);
|
||||||
assert.equal(rows[0]?.tokensPerMin ?? null, null);
|
assert.equal(rowsByVideoId.get(1)?.cardsPerHour ?? null, null);
|
||||||
|
assert.equal(rowsByVideoId.get(1)?.tokensPerMin ?? null, null);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const nowMs = 90 * 86_400_000;
|
const nowMs = 1_000_000_000;
|
||||||
const staleEndedAtMs = nowMs - 40 * 86_400_000;
|
const staleEndedAtMs = nowMs - 400_000_000;
|
||||||
const keptEndedAtMs = nowMs - 5 * 86_400_000;
|
const keptEndedAtMs = nowMs - 50_000_000;
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
INSERT INTO imm_videos (
|
INSERT INTO imm_videos (
|
||||||
@@ -49,14 +49,14 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
|||||||
INSERT INTO imm_session_telemetry (
|
INSERT INTO imm_session_telemetry (
|
||||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES
|
) VALUES
|
||||||
(1, ${nowMs - 2 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}),
|
(1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}),
|
||||||
(2, ${nowMs - 12 * 60 * 60 * 1000}, 0, 0, ${nowMs}, ${nowMs});
|
(2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs});
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = pruneRawRetention(db, nowMs, {
|
const result = pruneRawRetention(db, nowMs, {
|
||||||
eventsRetentionMs: 7 * 86_400_000,
|
eventsRetentionMs: 120_000_000,
|
||||||
telemetryRetentionMs: 1 * 86_400_000,
|
telemetryRetentionMs: 80_000_000,
|
||||||
sessionsRetentionMs: 30 * 86_400_000,
|
sessionsRetentionMs: 300_000_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remainingSessions = db
|
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', () => {
|
test('raw retention keeps rollups and rollup retention prunes them separately', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const nowMs = Date.UTC(2026, 2, 16, 12, 0, 0, 0);
|
const nowMs = 1_000_000_000;
|
||||||
const oldDay = Math.floor((nowMs - 90 * 86_400_000) / 86_400_000);
|
const oldDay = Math.floor((nowMs - 200_000_000) / 86_400_000);
|
||||||
const oldMonth = toMonthKey(nowMs - 400 * 86_400_000);
|
const oldMonth = 196912;
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
INSERT INTO imm_videos (
|
INSERT INTO imm_videos (
|
||||||
@@ -101,12 +107,12 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
|||||||
INSERT INTO imm_sessions (
|
INSERT INTO imm_sessions (
|
||||||
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (
|
) 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 (
|
INSERT INTO imm_session_telemetry (
|
||||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
) VALUES (
|
) 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 (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
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, {
|
pruneRawRetention(db, nowMs, {
|
||||||
eventsRetentionMs: 7 * 86_400_000,
|
eventsRetentionMs: 120_000_000,
|
||||||
telemetryRetentionMs: 30 * 86_400_000,
|
telemetryRetentionMs: 120_000_000,
|
||||||
sessionsRetentionMs: 30 * 86_400_000,
|
sessionsRetentionMs: 120_000_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rollupsAfterRawPrune = db
|
const rollupsAfterRawPrune = db
|
||||||
@@ -139,8 +145,8 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
|||||||
assert.equal(monthlyAfterRawPrune?.total, 1);
|
assert.equal(monthlyAfterRawPrune?.total, 1);
|
||||||
|
|
||||||
const rollupPrune = pruneRollupRetention(db, nowMs, {
|
const rollupPrune = pruneRollupRetention(db, nowMs, {
|
||||||
dailyRollupRetentionMs: 30 * 86_400_000,
|
dailyRollupRetentionMs: 120_000_000,
|
||||||
monthlyRollupRetentionMs: 365 * 86_400_000,
|
monthlyRollupRetentionMs: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rollupsAfterRollupPrune = db
|
const rollupsAfterRollupPrune = db
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface RawRetentionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toMonthKey(timestampMs: number): number {
|
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 z = epochDay + 719468;
|
||||||
const era = Math.floor(z / 146097);
|
const era = Math.floor(z / 146097);
|
||||||
const doe = z - era * 146097;
|
const doe = z - era * 146097;
|
||||||
@@ -61,19 +61,19 @@ export function pruneRawRetention(
|
|||||||
const sessionsCutoff = nowMs - policy.sessionsRetentionMs;
|
const sessionsCutoff = nowMs - policy.sessionsRetentionMs;
|
||||||
|
|
||||||
const deletedSessionEvents = (
|
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: number;
|
||||||
}
|
}
|
||||||
).changes;
|
).changes;
|
||||||
const deletedTelemetryRows = (
|
const deletedTelemetryRows = (
|
||||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff) as {
|
db
|
||||||
changes: number;
|
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||||
}
|
.run(toDbMs(telemetryCutoff)) as { changes: number }
|
||||||
).changes;
|
).changes;
|
||||||
const deletedEndedSessions = (
|
const deletedEndedSessions = (
|
||||||
db
|
db
|
||||||
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
|
.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;
|
).changes;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ export function getSessionWordsByLine(
|
|||||||
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
|
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
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
|
const row = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -83,7 +83,13 @@ function getTrendMonthlyLimit(range: TrendRange): number {
|
|||||||
if (range === 'all') {
|
if (range === 'all') {
|
||||||
return 120;
|
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 {
|
function getTrendCutoffMs(range: TrendRange): number | null {
|
||||||
@@ -122,6 +128,11 @@ function getLocalDateForEpochDay(epochDay: number): Date {
|
|||||||
return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000);
|
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 {
|
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
|
||||||
return session.tokensSeen;
|
return session.tokensSeen;
|
||||||
}
|
}
|
||||||
@@ -218,6 +229,20 @@ function buildSessionSeriesByDay(
|
|||||||
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
|
.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[] {
|
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||||
const lookupsByDay = new Map<number, number>();
|
const lookupsByDay = new Map<number, number>();
|
||||||
const wordsByDay = 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(
|
function getTrendSessionMetrics(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
cutoffMs: number | null,
|
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(
|
export function getTrendsDashboard(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
range: TrendRange = '30d',
|
range: TrendRange = '30d',
|
||||||
@@ -502,10 +573,11 @@ export function getTrendsDashboard(
|
|||||||
const dayLimit = getTrendDayLimit(range);
|
const dayLimit = getTrendDayLimit(range);
|
||||||
const monthlyLimit = getTrendMonthlyLimit(range);
|
const monthlyLimit = getTrendMonthlyLimit(range);
|
||||||
const cutoffMs = getTrendCutoffMs(range);
|
const cutoffMs = getTrendCutoffMs(range);
|
||||||
|
const useMonthlyBuckets = groupBy === 'month';
|
||||||
const chartRollups =
|
|
||||||
groupBy === 'month' ? getMonthlyRollups(db, monthlyLimit) : getDailyRollups(db, dayLimit);
|
|
||||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||||
|
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
||||||
|
|
||||||
|
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
||||||
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
||||||
const titlesByVideoId = getVideoAnimeTitleMap(
|
const titlesByVideoId = getVideoAnimeTitleMap(
|
||||||
db,
|
db,
|
||||||
@@ -545,11 +617,19 @@ export function getTrendsDashboard(
|
|||||||
watchTime: accumulatePoints(activity.watchTime),
|
watchTime: accumulatePoints(activity.watchTime),
|
||||||
sessions: accumulatePoints(activity.sessions),
|
sessions: accumulatePoints(activity.sessions),
|
||||||
words: accumulatePoints(activity.words),
|
words: accumulatePoints(activity.words),
|
||||||
newWords: accumulatePoints(buildNewWordsPerDay(db, cutoffMs)),
|
newWords: accumulatePoints(
|
||||||
|
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
||||||
|
),
|
||||||
cards: accumulatePoints(activity.cards),
|
cards: accumulatePoints(activity.cards),
|
||||||
episodes: accumulatePoints(buildEpisodesPerDayFromDailyRollups(dailyRollups)),
|
episodes: accumulatePoints(
|
||||||
|
useMonthlyBuckets
|
||||||
|
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
||||||
|
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
||||||
|
),
|
||||||
lookups: accumulatePoints(
|
lookups: accumulatePoints(
|
||||||
buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
useMonthlyBuckets
|
||||||
|
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
||||||
|
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
ratios: {
|
ratios: {
|
||||||
|
|||||||
@@ -263,7 +263,9 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
|
|||||||
audioStreamIndex: 1,
|
audioStreamIndex: 1,
|
||||||
subtitleStreamIndex: 2,
|
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({
|
const ok = await service.reportProgress({
|
||||||
itemId: 'movie-2',
|
itemId: 'movie-2',
|
||||||
|
|||||||
@@ -1255,7 +1255,7 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
|
|||||||
const deps = createDeps(async (script) => {
|
const deps = createDeps(async (script) => {
|
||||||
scripts.push(script);
|
scripts.push(script);
|
||||||
if (script.includes('optionsGetFull')) {
|
if (script.includes('optionsGetFull')) {
|
||||||
return JSON.parse(JSON.stringify(optionsFull));
|
return structuredClone(optionsFull);
|
||||||
}
|
}
|
||||||
if (script.includes('setAllSettings')) {
|
if (script.includes('setAllSettings')) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
930
src/main.ts
930
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[] = [
|
const termEntries: CharacterDictionaryTermEntry[] = [
|
||||||
['アルファ', 'あるふぁ', '', '', 0, ['Alpha entry'], 0, 'name'],
|
['アルファ', 'あるふぁ', '', '', 0, ['Alpha entry'], 0, 'name'],
|
||||||
];
|
];
|
||||||
|
const originalWriteFileSync = fs.writeFileSync;
|
||||||
const originalBufferConcat = Buffer.concat;
|
const originalBufferConcat = Buffer.concat;
|
||||||
|
|
||||||
try {
|
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>) => {
|
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
|
||||||
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
|
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
|
||||||
}) as typeof Buffer.concat;
|
}) as typeof Buffer.concat;
|
||||||
@@ -92,6 +97,7 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
|
|||||||
assert.equal(termBank[0]?.[0], 'アルファ');
|
assert.equal(termBank[0]?.[0], 'アルファ');
|
||||||
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));
|
||||||
} finally {
|
} finally {
|
||||||
|
fs.writeFileSync = originalWriteFileSync;
|
||||||
Buffer.concat = originalBufferConcat;
|
Buffer.concat = originalBufferConcat;
|
||||||
cleanupDir(tempDir);
|
cleanupDir(tempDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
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', () => {
|
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||||
const next = applyControllerConfigUpdate(
|
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?.toggleLookup, { kind: 'button', buttonIndex: 0 });
|
||||||
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
|
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]
|
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
||||||
>) {
|
>) {
|
||||||
if (value === undefined) continue;
|
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;
|
nextController.bindings = nextBindings;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
|||||||
now: () => 7,
|
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.refreshRetryQueueState();
|
||||||
deps.setLastAttemptAt(1);
|
deps.setLastAttemptAt(1);
|
||||||
deps.setLastError('x');
|
deps.setLastError('x');
|
||||||
|
|||||||
@@ -84,51 +84,63 @@ test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
|
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
|
||||||
|
const originalDateNow = Date.now;
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const handled = consumeAnilistSetupCallbackUrl({
|
try {
|
||||||
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
|
Date.now = () => 120_000;
|
||||||
saveToken: (value: string) => events.push(`save:${value}`),
|
const handled = consumeAnilistSetupCallbackUrl({
|
||||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
|
||||||
setResolvedState: (timestampMs: number) =>
|
saveToken: (value: string) => events.push(`save:${value}`),
|
||||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
setResolvedState: (timestampMs: number) =>
|
||||||
onSuccess: () => events.push('success'),
|
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||||
closeWindow: () => events.push('close'),
|
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||||
});
|
onSuccess: () => events.push('success'),
|
||||||
|
closeWindow: () => events.push('close'),
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(events, [
|
assert.deepEqual(events, [
|
||||||
'save:saved-token',
|
'save:saved-token',
|
||||||
'cache:saved-token',
|
'cache:saved-token',
|
||||||
'state:ok',
|
'state:ok',
|
||||||
'opened:false',
|
'opened:false',
|
||||||
'success',
|
'success',
|
||||||
'close',
|
'close',
|
||||||
]);
|
]);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
|
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
|
||||||
|
const originalDateNow = Date.now;
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const handled = consumeAnilistSetupCallbackUrl({
|
try {
|
||||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
Date.now = () => 120_000;
|
||||||
saveToken: (value: string) => events.push(`save:${value}`),
|
const handled = consumeAnilistSetupCallbackUrl({
|
||||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||||
setResolvedState: (timestampMs: number) =>
|
saveToken: (value: string) => events.push(`save:${value}`),
|
||||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
setResolvedState: (timestampMs: number) =>
|
||||||
onSuccess: () => events.push('success'),
|
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||||
closeWindow: () => events.push('close'),
|
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||||
});
|
onSuccess: () => events.push('success'),
|
||||||
|
closeWindow: () => events.push('close'),
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(handled, true);
|
assert.equal(handled, true);
|
||||||
assert.deepEqual(events, [
|
assert.deepEqual(events, [
|
||||||
'save:saved-token',
|
'save:saved-token',
|
||||||
'cache:saved-token',
|
'cache:saved-token',
|
||||||
'state:ok',
|
'state:ok',
|
||||||
'opened:false',
|
'opened:false',
|
||||||
'success',
|
'success',
|
||||||
'close',
|
'close',
|
||||||
]);
|
]);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
|
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 './jellyfin-runtime-composer';
|
||||||
export * from './mpv-runtime-composer';
|
export * from './mpv-runtime-composer';
|
||||||
export * from './overlay-window-composer';
|
export * from './overlay-window-composer';
|
||||||
|
export * from './overlay-visibility-runtime-composer';
|
||||||
export * from './shortcuts-runtime-composer';
|
export * from './shortcuts-runtime-composer';
|
||||||
|
export * from './stats-startup-composer';
|
||||||
|
export * from './subtitle-prefetch-runtime-composer';
|
||||||
export * from './startup-lifecycle-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 {
|
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: {
|
export function resolveWindowsMpvShortcutPaths(options: {
|
||||||
@@ -32,11 +32,11 @@ export function resolveWindowsMpvShortcutPaths(options: {
|
|||||||
desktopDir: string;
|
desktopDir: string;
|
||||||
}): WindowsMpvShortcutPaths {
|
}): WindowsMpvShortcutPaths {
|
||||||
return {
|
return {
|
||||||
startMenuPath: path.join(
|
startMenuPath: path.win32.join(
|
||||||
resolveWindowsStartMenuProgramsDir(options.appDataDir),
|
resolveWindowsStartMenuProgramsDir(options.appDataDir),
|
||||||
WINDOWS_MPV_SHORTCUT_NAME,
|
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 {
|
return {
|
||||||
target: exePath,
|
target: exePath,
|
||||||
args: '--launch-mpv',
|
args: '--launch-mpv',
|
||||||
cwd: path.dirname(exePath),
|
cwd: path.win32.dirname(exePath),
|
||||||
description: 'Launch mpv with the SubMiner profile',
|
description: 'Launch mpv with the SubMiner profile',
|
||||||
icon: exePath,
|
icon: exePath,
|
||||||
iconIndex: 0,
|
iconIndex: 0,
|
||||||
@@ -79,7 +79,7 @@ export function applyWindowsMpvShortcuts(options: {
|
|||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
|
||||||
const ensureShortcut = (shortcutPath: string): void => {
|
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);
|
const ok = options.writeShortcutLink(shortcutPath, 'replace', details);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
failures.push(shortcutPath);
|
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/);
|
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', () => {
|
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-linux:[\s\S]*stats\/node_modules/);
|
||||||
assert.match(releaseWorkflow, /build-macos:[\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>;
|
type RuntimeOverrides = Record<string, unknown>;
|
||||||
|
|
||||||
function deepClone<T>(value: T): T {
|
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 {
|
function getPathValue(source: Record<string, unknown>, path: string): unknown {
|
||||||
|
|||||||
Reference in New Issue
Block a user