mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)
This commit is contained in:
1 parent
4b6842d034
commit
b48083ddd8
368 files changed
+18045
-60331
No files matched your search
@@ -8,6 +8,7 @@ Start here, then leave this file.
|
|||||||
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
|
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
|
||||||
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
|
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
|
||||||
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
|
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
|
||||||
|
- Testing guidelines: [`docs/workflow/testing.md`](./docs/workflow/testing.md)
|
||||||
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
|
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
|
||||||
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
|
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ Start here, then leave this file.
|
|||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||||
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
|
- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build`
|
||||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||||
|
|
||||||
## Docs Upkeep
|
## Docs Upkeep
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Read when: finding internal docs or checking verification status
|
|||||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||||
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership |
|
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership |
|
||||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
||||||
|
| Testing guidelines | `docs/workflow/testing.md` | active | 2026-10-04 | test style and anti-patterns |
|
||||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||||
|
|
||||||
## Update Rules
|
## Update Rules
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This section is the internal workflow map for contributors and agents.
|
|||||||
|
|
||||||
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
||||||
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
||||||
|
- [Testing](./testing.md) - what a good test looks like and what not to write
|
||||||
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
|
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
|
||||||
- [Release Guide](../RELEASING.md) - tagged release workflow
|
- [Release Guide](../RELEASING.md) - tagged release workflow
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!-- read_when: writing, reviewing, or deleting tests -->
|
||||||
|
|
||||||
|
# Testing Guidelines
|
||||||
|
|
||||||
|
Status: active
|
||||||
|
Last verified: 2026-10-04
|
||||||
|
Owner: Kyle Yasuda
|
||||||
|
Read when: adding or changing tests
|
||||||
|
|
||||||
|
Which lanes to run lives in [Verification](./verification.md). This page covers what a
|
||||||
|
good test looks like here.
|
||||||
|
|
||||||
|
## Test Behavior, Not Wiring
|
||||||
|
|
||||||
|
- Exercise a module through its public interface and assert outcomes: return values,
|
||||||
|
resulting state, files written, rows in sqlite, messages sent to a fake process.
|
||||||
|
- Prefer real collaborators that are cheap: tmp dirs, in-memory or tmp sqlite, fake
|
||||||
|
executables, pure parsers. Fake only the edges (Electron, mpv socket, network).
|
||||||
|
- Assert call order only when order is the behavior (startup sequencing, ABA races),
|
||||||
|
and then assert relative order, not the whole call log.
|
||||||
|
|
||||||
|
## Do Not Write
|
||||||
|
|
||||||
|
- Pass-through tests: a builder or handler that only forwards deps (`*-main-deps.ts`,
|
||||||
|
`*-runtime-handlers.ts`) needs no test. TypeScript already checks the shape.
|
||||||
|
- Source-text tests: reading `.ts`, `.css`, `.html`, `.lua`, `.swift`, or workflow YAML
|
||||||
|
and asserting with regexes. If the invariant matters, extract the logic and run it
|
||||||
|
(see `src/preload-ipc-listeners.ts`). Policy lints over workflow YAML are the
|
||||||
|
exception and belong in one parsed-YAML check.
|
||||||
|
- "Stays deleted" tests for removed modules, flags, or selectors. `tsc` and review
|
||||||
|
catch reintroduction.
|
||||||
|
- Tests that restate constants, defaults, labels, or window options.
|
||||||
|
- The same rule at several layers. Test a rule once where it lives; keep one
|
||||||
|
end-to-end case (for the tokenizer, the golden corpus) for integration.
|
||||||
|
|
||||||
|
## Keep Tests Small
|
||||||
|
|
||||||
|
- Shared setup goes in a `makeDeps(overrides)` helper or a `*-test-harness.ts` file, not
|
||||||
|
pasted into every test.
|
||||||
|
- Near-identical cases become a table: `for (const c of cases) test(c.name, ...)`.
|
||||||
|
- Inject timers, clocks, and loggers instead of patching globals or private members.
|
||||||
|
- A bug fix gets a regression test for the behavior that broke, named after the
|
||||||
|
behavior, not the ticket.
|
||||||
|
|
||||||
|
## Known Debt
|
||||||
|
|
||||||
|
- `src/main/main-wiring.test.ts` and `src/renderer/renderer-init-order.test.ts` still
|
||||||
|
regex `main.ts` / `renderer.ts` to guard startup ordering. Replace each test with a
|
||||||
|
behavioral one as the logic moves out of `main.ts` closures.
|
||||||
@@ -14,8 +14,9 @@ Read when: selecting the right verification lane for a change
|
|||||||
files in `package.json`.
|
files in `package.json`.
|
||||||
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||||
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||||
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
one file cannot cascade into the rest of the lane. Files run in parallel, one
|
||||||
`--single-process` restores the shared-process mode for debugging.
|
worker per CPU by default; `--jobs 1` runs serially and `--single-process`
|
||||||
|
restores the shared-process mode for debugging.
|
||||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||||
unit, and `scripts/**`.
|
unit, and `scripts/**`.
|
||||||
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
||||||
@@ -70,7 +71,7 @@ bun run docs:build
|
|||||||
|
|
||||||
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
|
- `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
|
||||||
`test:src` through a sharded coverage runner: one Bun coverage process per test
|
`test:src` through a sharded coverage runner: one Bun coverage process per test
|
||||||
file, then merged LCOV output.
|
file (parallel, one per CPU unless `--jobs N`), then merged LCOV output.
|
||||||
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
|
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
|
||||||
source test gate, not a report-only step.
|
source test gate, not a report-only step.
|
||||||
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||||
|
|||||||
@@ -150,74 +150,7 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('doctor command forwards refresh-known-words to app binary', () => {
|
test('app command starts default background app detached from launcher app detached from launcher', () => {
|
||||||
const context = createContext();
|
|
||||||
context.args.doctor = true;
|
|
||||||
context.args.doctorRefreshKnownWords = true;
|
|
||||||
const forwarded: string[][] = [];
|
|
||||||
|
|
||||||
const handled = runDoctorCommand(context, {
|
|
||||||
commandExists: () => false,
|
|
||||||
configExists: () => true,
|
|
||||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
|
||||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
|
||||||
forwarded.push(appArgs);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('logs command exports logs and writes archive path', () => {
|
|
||||||
const writes: string[] = [];
|
|
||||||
const context = createContext();
|
|
||||||
context.args.logsExport = true;
|
|
||||||
context.processAdapter = {
|
|
||||||
...context.processAdapter,
|
|
||||||
writeStdout: (text) => writes.push(text),
|
|
||||||
};
|
|
||||||
|
|
||||||
const handled = runLogsCommand(context, {
|
|
||||||
exportLogsArchive: () => ({
|
|
||||||
zipPath: '/tmp/subminer-logs.zip',
|
|
||||||
exportedFiles: ['/tmp/app.log'],
|
|
||||||
mode: 'current-day',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('logs command ignores unrelated launcher commands', () => {
|
|
||||||
const context = createContext();
|
|
||||||
|
|
||||||
assert.equal(runLogsCommand(context), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('app command starts default macOS background app detached from launcher', () => {
|
|
||||||
const context = createContext();
|
|
||||||
context.args.appPassthrough = true;
|
|
||||||
context.args.appArgs = [];
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const handled = runAppPassthroughCommand(context, {
|
|
||||||
runAppCommandWithInherit: () => {
|
|
||||||
calls.push('attached');
|
|
||||||
},
|
|
||||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
|
||||||
launchYoutubeBrowserDetached: () => calls.push('youtube'),
|
|
||||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
|
||||||
calls.push(`detached:${appPath}:${logLevel}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('app command starts default Linux background app detached from launcher', () => {
|
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.appPassthrough = true;
|
context.args.appPassthrough = true;
|
||||||
context.args.appArgs = [];
|
context.args.appArgs = [];
|
||||||
@@ -389,49 +322,6 @@ test('dictionary command forwards candidate and selection modes to app binary',
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dictionary command returns after app handoff starts', () => {
|
|
||||||
const context = createContext();
|
|
||||||
context.args.dictionary = true;
|
|
||||||
|
|
||||||
const handled = runDictionaryCommand(context, {
|
|
||||||
runAppCommandWithInherit: () => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('update command runs direct Linux release update without launching Electron', async () => {
|
|
||||||
const context = createContext();
|
|
||||||
context.args.update = true;
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const handled = await runUpdateCommand(context, {
|
|
||||||
runAppCommandCaptureOutput: () => {
|
|
||||||
throw new Error('unexpected Electron launch');
|
|
||||||
},
|
|
||||||
runDirectReleaseUpdate: async (request) => {
|
|
||||||
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
|
|
||||||
return {
|
|
||||||
appImage: { status: 'not-found' },
|
|
||||||
launcher: { status: 'updated' },
|
|
||||||
supportAssets: [{ status: 'skipped' }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
readMainConfig: () => null,
|
|
||||||
log: (level, _configured, message) => {
|
|
||||||
calls.push(`${level}:${message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
|
||||||
'info:AppImage update: not-found',
|
|
||||||
'info:Launcher update: updated',
|
|
||||||
'info:Support assets update: skipped',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats command launches attached app command with response path', async () => {
|
test('stats command launches attached app command with response path', async () => {
|
||||||
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
|
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
|
||||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||||
@@ -449,16 +339,41 @@ test('stats command launches attached app command with response path', async ()
|
|||||||
assert.equal(harness.removedPaths.length, 1);
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats background command launches attached daemon control command with response path', async () => {
|
const STATS_RESPONSE_ARGS = ['--stats-response-path', '/tmp/subminer-stats-test/response.json'];
|
||||||
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
|
|
||||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
const statsForwardingCases = [
|
||||||
assert.deepEqual(harness.forwarded, [
|
{
|
||||||
['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
|
name: 'stats background command launches the daemon start command',
|
||||||
]);
|
overrides: { statsBackground: true },
|
||||||
assert.equal(harness.removedPaths.length, 1);
|
expected: ['--stats-daemon-start', ...STATS_RESPONSE_ARGS],
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
name: 'stats stop command forwards the daemon stop flag',
|
||||||
|
overrides: { statsStop: true },
|
||||||
|
expected: ['--stats-daemon-stop', ...STATS_RESPONSE_ARGS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stats cleanup command forwards cleanup vocab flags',
|
||||||
|
overrides: { statsCleanup: true, statsCleanupVocab: true },
|
||||||
|
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-vocab'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stats cleanup command forwards the lifetime rebuild flag',
|
||||||
|
overrides: { statsCleanup: true, statsCleanupLifetime: true },
|
||||||
|
expected: ['--stats', ...STATS_RESPONSE_ARGS, '--stats-cleanup', '--stats-cleanup-lifetime'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of statsForwardingCases) {
|
||||||
|
test(`${c.name} to the app`, async () => {
|
||||||
|
const harness = createStatsTestHarness({ stats: true, ...c.overrides });
|
||||||
|
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(harness.forwarded, [c.expected]);
|
||||||
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('stats command waits for attached app exit after startup response', async () => {
|
test('stats command waits for attached app exit after startup response', async () => {
|
||||||
const harness = createStatsTestHarness({ stats: true });
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
@@ -503,45 +418,6 @@ test('stats command throws when attached app exits non-zero after startup respon
|
|||||||
assert.equal(harness.removedPaths.length, 1);
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
|
||||||
const harness = createStatsTestHarness({
|
|
||||||
stats: true,
|
|
||||||
statsCleanup: true,
|
|
||||||
statsCleanupVocab: true,
|
|
||||||
});
|
|
||||||
const handled = await runStatsCommand(harness.context, {
|
|
||||||
...harness.commandDeps,
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(harness.forwarded, [
|
|
||||||
[
|
|
||||||
'--stats',
|
|
||||||
'--stats-response-path',
|
|
||||||
'/tmp/subminer-stats-test/response.json',
|
|
||||||
'--stats-cleanup',
|
|
||||||
'--stats-cleanup-vocab',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
assert.equal(harness.removedPaths.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats stop command forwards stop flag to the app', async () => {
|
|
||||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
|
||||||
|
|
||||||
const handled = await runStatsCommand(harness.context, {
|
|
||||||
...harness.commandDeps,
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(harness.forwarded, [
|
|
||||||
['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
|
|
||||||
]);
|
|
||||||
assert.equal(harness.removedPaths.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
||||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||||
let waitedForResponse = false;
|
let waitedForResponse = false;
|
||||||
@@ -563,30 +439,6 @@ test('stats stop command exits on process exit without waiting for startup respo
|
|||||||
assert.equal(harness.removedPaths.length, 1);
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
|
||||||
const harness = createStatsTestHarness({
|
|
||||||
stats: true,
|
|
||||||
statsCleanup: true,
|
|
||||||
statsCleanupLifetime: true,
|
|
||||||
});
|
|
||||||
const handled = await runStatsCommand(harness.context, {
|
|
||||||
...harness.commandDeps,
|
|
||||||
waitForStatsResponse: async () => ({ ok: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, true);
|
|
||||||
assert.deepEqual(harness.forwarded, [
|
|
||||||
[
|
|
||||||
'--stats',
|
|
||||||
'--stats-response-path',
|
|
||||||
'/tmp/subminer-stats-test/response.json',
|
|
||||||
'--stats-cleanup',
|
|
||||||
'--stats-cleanup-lifetime',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
assert.equal(harness.removedPaths.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats command throws when stats response reports an error', async () => {
|
test('stats command throws when stats response reports an error', async () => {
|
||||||
const harness = createStatsTestHarness({ stats: true });
|
const harness = createStatsTestHarness({ stats: true });
|
||||||
|
|
||||||
@@ -692,36 +544,3 @@ test('stats command aborts pending response wait when attached app fails to spaw
|
|||||||
assert.equal(aborted, true);
|
assert.equal(aborted, true);
|
||||||
assert.equal(harness.removedPaths.length, 1);
|
assert.equal(harness.removedPaths.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
|
|
||||||
const harness = createStatsTestHarness({
|
|
||||||
stats: true,
|
|
||||||
statsCleanup: true,
|
|
||||||
statsCleanupVocab: true,
|
|
||||||
});
|
|
||||||
let aborted = false;
|
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await runStatsCommand(harness.context, {
|
|
||||||
...harness.commandDeps,
|
|
||||||
runAppCommandAttached: async (...args) => {
|
|
||||||
await harness.runAppCommandAttachedStub(...args);
|
|
||||||
return 2;
|
|
||||||
},
|
|
||||||
waitForStatsResponse: async (_responsePath, signal) =>
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
signal?.addEventListener(
|
|
||||||
'abort',
|
|
||||||
() => {
|
|
||||||
aborted = true;
|
|
||||||
resolve({ ok: false, error: 'aborted' });
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}, /Stats app exited before startup response \(status 2\)\./);
|
|
||||||
|
|
||||||
assert.equal(aborted, true);
|
|
||||||
assert.equal(harness.removedPaths.length, 1);
|
|
||||||
});
|
|
||||||
@@ -4,81 +4,53 @@ import { EventEmitter } from 'node:events';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import type { Args } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
|
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
import { state } from '../mpv.js';
|
import { state, type startMpv } from '../mpv.js';
|
||||||
|
import { makeLauncherArgs } from '../test-support/args.js';
|
||||||
|
import { withEnv } from '../test-support/env.js';
|
||||||
|
|
||||||
function createContext(): LauncherCommandContext {
|
type PlaybackDeps = Parameters<typeof runPlaybackCommandWithDeps>[1];
|
||||||
|
type StartMpvOptions = NonNullable<Parameters<typeof startMpv>[6]>;
|
||||||
|
|
||||||
|
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=65Ovd7t8sNw';
|
||||||
|
|
||||||
|
function makePluginConfig(
|
||||||
|
overrides: Partial<LauncherCommandContext['pluginRuntimeConfig']> = {},
|
||||||
|
): LauncherCommandContext['pluginRuntimeConfig'] {
|
||||||
return {
|
return {
|
||||||
args: {
|
socketPath: '/tmp/subminer.sock',
|
||||||
backend: 'auto',
|
binaryPath: '',
|
||||||
directory: '.',
|
backend: 'auto',
|
||||||
recursive: false,
|
autoStart: true,
|
||||||
profile: '',
|
autoStartVisibleOverlay: true,
|
||||||
startOverlay: false,
|
autoStartPauseUntilReady: true,
|
||||||
youtubePrimarySubLangs: [],
|
osdMessages: false,
|
||||||
youtubeSecondarySubLangs: [],
|
texthookerEnabled: false,
|
||||||
youtubeAudioLangs: [],
|
...overrides,
|
||||||
useTexthooker: false,
|
};
|
||||||
autoStartOverlay: false,
|
}
|
||||||
texthookerOnly: false,
|
|
||||||
texthookerOpenBrowser: false,
|
/** A context for a YouTube URL target with logging at info and CLI texthooker off. */
|
||||||
useRofi: false,
|
function createContext(
|
||||||
history: false,
|
options: {
|
||||||
sync: false,
|
args?: Partial<Args>;
|
||||||
syncCliTokens: [],
|
plugin?: Partial<LauncherCommandContext['pluginRuntimeConfig']>;
|
||||||
syncUi: false,
|
} = {},
|
||||||
logLevel: 'info',
|
): LauncherCommandContext {
|
||||||
logRotation: 7,
|
return {
|
||||||
passwordStore: '',
|
args: makeLauncherArgs({
|
||||||
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
target: YOUTUBE_URL,
|
||||||
targetKind: 'url',
|
targetKind: 'url',
|
||||||
jimakuApiKey: '',
|
logLevel: 'info',
|
||||||
jimakuApiKeyCommand: '',
|
useTexthooker: false,
|
||||||
jimakuApiBaseUrl: '',
|
...options.args,
|
||||||
jimakuLanguagePreference: 'ja',
|
}),
|
||||||
jimakuMaxEntryResults: 20,
|
|
||||||
jellyfin: false,
|
|
||||||
jellyfinLogin: false,
|
|
||||||
jellyfinLogout: false,
|
|
||||||
jellyfinPlay: false,
|
|
||||||
jellyfinDiscovery: false,
|
|
||||||
dictionary: false,
|
|
||||||
dictionaryCandidates: false,
|
|
||||||
dictionarySelect: false,
|
|
||||||
stats: false,
|
|
||||||
doctor: false,
|
|
||||||
doctorRefreshKnownWords: false,
|
|
||||||
logsExport: false,
|
|
||||||
version: false,
|
|
||||||
settings: false,
|
|
||||||
youtubeBrowser: false,
|
|
||||||
configPath: false,
|
|
||||||
configShow: false,
|
|
||||||
mpvIdle: false,
|
|
||||||
mpvSocket: false,
|
|
||||||
mpvStatus: false,
|
|
||||||
mpvArgs: '',
|
|
||||||
appPassthrough: false,
|
|
||||||
appArgs: [],
|
|
||||||
jellyfinServer: '',
|
|
||||||
jellyfinUsername: '',
|
|
||||||
jellyfinPassword: '',
|
|
||||||
launchMode: 'normal',
|
|
||||||
},
|
|
||||||
scriptPath: '/tmp/subminer',
|
scriptPath: '/tmp/subminer',
|
||||||
scriptName: 'subminer',
|
scriptName: 'subminer',
|
||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
pluginRuntimeConfig: {
|
pluginRuntimeConfig: makePluginConfig(options.plugin),
|
||||||
socketPath: '/tmp/subminer.sock',
|
|
||||||
binaryPath: '',
|
|
||||||
backend: 'auto',
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
osdMessages: false,
|
|
||||||
texthookerEnabled: false,
|
|
||||||
},
|
|
||||||
appPath: '/tmp/SubMiner.AppImage',
|
appPath: '/tmp/SubMiner.AppImage',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
processAdapter: {
|
processAdapter: {
|
||||||
@@ -93,8 +65,70 @@ function createContext(): LauncherCommandContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILE_TARGET = { target: '/tmp/movie.mkv', targetKind: 'file' } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback harness. Deps are inert by default and record what the tests assert on: the
|
||||||
|
* options mpv was started with, the overlay launches, and an `events` log for the few cases
|
||||||
|
* where relative order is the behavior.
|
||||||
|
*/
|
||||||
|
function makeHarness(
|
||||||
|
options: {
|
||||||
|
context?: LauncherCommandContext;
|
||||||
|
deps?: Partial<PlaybackDeps>;
|
||||||
|
events?: string[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const context = options.context ?? createContext();
|
||||||
|
const events = options.events ?? [];
|
||||||
|
const startMpvOptions: StartMpvOptions[] = [];
|
||||||
|
const overlayCalls: Array<{ extraAppArgs: string[]; configDir?: string }> = [];
|
||||||
|
const deps: PlaybackDeps = {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
events.push('plugin');
|
||||||
|
},
|
||||||
|
chooseTarget: async () => ({
|
||||||
|
target: context.args.target,
|
||||||
|
kind: context.args.targetKind === 'file' ? 'file' : 'url',
|
||||||
|
}),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async (_target, _kind, _args, _socket, _appPath, _subtitles, startOptions) => {
|
||||||
|
events.push('startMpv');
|
||||||
|
if (startOptions) startMpvOptions.push(startOptions);
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socket, extraAppArgs = [], configDir) => {
|
||||||
|
events.push('startOverlay');
|
||||||
|
overlayCalls.push({ extraAppArgs, configDir });
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
...options.deps,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
events,
|
||||||
|
startMpvOptions,
|
||||||
|
overlayCalls,
|
||||||
|
run: () => runPlaybackCommandWithDeps(context, deps),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRanInOrder(events: string[], ...expected: string[]): void {
|
||||||
|
const positions = expected.map((event) => events.indexOf(event));
|
||||||
|
assert.ok(
|
||||||
|
positions.every(
|
||||||
|
(position, index) => position >= 0 && (index === 0 || position > positions[index - 1]!),
|
||||||
|
),
|
||||||
|
`expected ${expected.join(' -> ')} in order, got ${events.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
|
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
|
||||||
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
|
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
const registeredSignals: NodeJS.Signals[] = [];
|
const registeredSignals: NodeJS.Signals[] = [];
|
||||||
context.processAdapter.onSignal = (signal) => {
|
context.processAdapter.onSignal = (signal) => {
|
||||||
@@ -108,121 +142,41 @@ test('playback cleanup signal handlers are registered once across repeated sessi
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
|
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
|
||||||
const calls: string[] = [];
|
const harness = makeHarness({
|
||||||
const context = createContext();
|
context: createContext({
|
||||||
context.pluginRuntimeConfig = {
|
plugin: {
|
||||||
...context.pluginRuntimeConfig,
|
autoStart: false,
|
||||||
autoStart: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartVisibleOverlay: false,
|
autoStartPauseUntilReady: false,
|
||||||
autoStartPauseUntilReady: false,
|
},
|
||||||
};
|
}),
|
||||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {},
|
|
||||||
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async (
|
|
||||||
_target,
|
|
||||||
_targetKind,
|
|
||||||
_args,
|
|
||||||
_socketPath,
|
|
||||||
_appPath,
|
|
||||||
_preloadedSubtitles,
|
|
||||||
options,
|
|
||||||
) => {
|
|
||||||
if (options) {
|
|
||||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
calls.push('startMpv');
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
|
||||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
|
||||||
},
|
|
||||||
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
|
|
||||||
calls.push(`launch:${appArgs.join(' ')}`);
|
|
||||||
},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {},
|
|
||||||
getMpvProc: () => null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
await harness.run();
|
||||||
'startMpv',
|
|
||||||
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
|
||||||
]);
|
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--youtube-play', YOUTUBE_URL]);
|
||||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
|
||||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
assert.equal(harness.startMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
||||||
const context = createContext();
|
const harness = makeHarness();
|
||||||
context.pluginRuntimeConfig = {
|
|
||||||
...context.pluginRuntimeConfig,
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
};
|
|
||||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await harness.run();
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {},
|
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async (
|
|
||||||
_target,
|
|
||||||
_targetKind,
|
|
||||||
_args,
|
|
||||||
_socketPath,
|
|
||||||
_appPath,
|
|
||||||
_preloadedSubtitles,
|
|
||||||
options,
|
|
||||||
) => {
|
|
||||||
if (options) {
|
|
||||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async () => {},
|
|
||||||
launchAppCommandDetached: () => {},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {},
|
|
||||||
getMpvProc: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtimeConfig = receivedStartMpvOptions[0]?.runtimePluginConfig as
|
const runtimeConfig = harness.startMpvOptions[0]?.runtimePluginConfig;
|
||||||
| { autoStart?: boolean; autoStartVisibleOverlay?: boolean; autoStartPauseUntilReady?: boolean }
|
|
||||||
| undefined;
|
|
||||||
assert.equal(runtimeConfig?.autoStart, false);
|
assert.equal(runtimeConfig?.autoStart, false);
|
||||||
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
|
assert.equal(runtimeConfig?.autoStartVisibleOverlay, false);
|
||||||
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
|
assert.equal(runtimeConfig?.autoStartPauseUntilReady, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||||
const context = createContext();
|
const context = createContext({
|
||||||
context.args = {
|
args: { ...FILE_TARGET, useTexthooker: true },
|
||||||
...context.args,
|
plugin: { autoStartVisibleOverlay: false, autoStartPauseUntilReady: false },
|
||||||
target: '/tmp/movie.mkv',
|
});
|
||||||
targetKind: 'file',
|
state.appPath = context.appPath ?? '';
|
||||||
useTexthooker: true,
|
|
||||||
};
|
|
||||||
context.pluginRuntimeConfig = {
|
|
||||||
socketPath: '/tmp/subminer.sock',
|
|
||||||
binaryPath: '',
|
|
||||||
backend: 'auto',
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: false,
|
|
||||||
autoStartPauseUntilReady: false,
|
|
||||||
osdMessages: false,
|
|
||||||
texthookerEnabled: false,
|
|
||||||
};
|
|
||||||
const appPath = context.appPath ?? '';
|
|
||||||
state.appPath = appPath;
|
|
||||||
state.overlayManagedByLauncher = false;
|
state.overlayManagedByLauncher = false;
|
||||||
const mpvProc = new EventEmitter() as EventEmitter & {
|
const mpvProc = new EventEmitter() as EventEmitter & {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
@@ -233,31 +187,27 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
|||||||
mpvProc.killed = false;
|
mpvProc.killed = false;
|
||||||
mpvProc.kill = () => true;
|
mpvProc.kill = () => true;
|
||||||
let cleanupSawManagedOverlay = true;
|
let cleanupSawManagedOverlay = true;
|
||||||
|
const harness = makeHarness({
|
||||||
try {
|
context,
|
||||||
await runPlaybackCommandWithDeps(context, {
|
deps: {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {},
|
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async () => {
|
startMpv: async () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mpvProc.exitCode = 0;
|
mpvProc.exitCode = 0;
|
||||||
mpvProc.emit('exit', 0);
|
mpvProc.emit('exit', 0);
|
||||||
}, 5);
|
}, 5);
|
||||||
},
|
},
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async () => {
|
startOverlay: async () => {
|
||||||
throw new Error('startOverlay should not run when plugin auto-start is used');
|
throw new Error('startOverlay should not run when plugin auto-start is used');
|
||||||
},
|
},
|
||||||
launchAppCommandDetached: () => {},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {
|
cleanupPlaybackSession: async () => {
|
||||||
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
|
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
|
||||||
},
|
},
|
||||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await harness.run();
|
||||||
|
|
||||||
assert.equal(cleanupSawManagedOverlay, false);
|
assert.equal(cleanupSawManagedOverlay, false);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -267,258 +217,89 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
||||||
const context = createContext();
|
const harness = makeHarness({
|
||||||
context.args = {
|
context: createContext({
|
||||||
...context.args,
|
args: { ...FILE_TARGET, useTexthooker: true },
|
||||||
target: '/tmp/movie.mkv',
|
plugin: { texthookerEnabled: true },
|
||||||
targetKind: 'file',
|
}),
|
||||||
useTexthooker: true,
|
deps: { isAppControlServerAvailable: async () => true },
|
||||||
};
|
|
||||||
context.pluginRuntimeConfig = {
|
|
||||||
socketPath: '/tmp/subminer.sock',
|
|
||||||
binaryPath: '',
|
|
||||||
backend: 'auto',
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
osdMessages: false,
|
|
||||||
texthookerEnabled: true,
|
|
||||||
};
|
|
||||||
const calls: string[] = [];
|
|
||||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {},
|
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async (
|
|
||||||
_target,
|
|
||||||
_targetKind,
|
|
||||||
_args,
|
|
||||||
_socketPath,
|
|
||||||
_appPath,
|
|
||||||
_preloadedSubtitles,
|
|
||||||
options,
|
|
||||||
) => {
|
|
||||||
calls.push('startMpv');
|
|
||||||
if (options) {
|
|
||||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
|
||||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
|
||||||
},
|
|
||||||
launchAppCommandDetached: () => {},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {},
|
|
||||||
getMpvProc: () => null,
|
|
||||||
isAppControlServerAvailable: async () => true,
|
|
||||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
|
||||||
isAppControlServerAvailable: () => Promise<boolean>;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
await harness.run();
|
||||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
|
||||||
assert.equal(
|
assertRanInOrder(harness.events, 'startMpv', 'startOverlay');
|
||||||
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, [
|
||||||
?.autoStart,
|
'--show-visible-overlay',
|
||||||
false,
|
'--texthooker',
|
||||||
);
|
]);
|
||||||
|
assert.equal(harness.startMpvOptions[0]?.startPaused, true);
|
||||||
|
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.autoStart, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||||
const context = createContext();
|
|
||||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
||||||
const originalAppData = process.env.APPDATA;
|
|
||||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||||
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||||
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||||
context.args = {
|
|
||||||
...context.args,
|
|
||||||
target: '/tmp/movie.mkv',
|
|
||||||
targetKind: 'file',
|
|
||||||
useTexthooker: true,
|
|
||||||
};
|
|
||||||
context.pluginRuntimeConfig = {
|
|
||||||
socketPath: '/tmp/subminer.sock',
|
|
||||||
binaryPath: '',
|
|
||||||
backend: 'auto',
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
osdMessages: false,
|
|
||||||
texthookerEnabled: true,
|
|
||||||
};
|
|
||||||
let availabilityConfigDir: string | undefined;
|
let availabilityConfigDir: string | undefined;
|
||||||
let overlayConfigDir: string | undefined;
|
const harness = makeHarness({
|
||||||
let overlayLoadingOsd: boolean | undefined;
|
context: createContext({
|
||||||
|
args: { ...FILE_TARGET, useTexthooker: true },
|
||||||
try {
|
plugin: { texthookerEnabled: true },
|
||||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
}),
|
||||||
process.env.APPDATA = xdgConfigHome;
|
deps: {
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {},
|
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async (
|
|
||||||
_target,
|
|
||||||
_targetKind,
|
|
||||||
_args,
|
|
||||||
_socketPath,
|
|
||||||
_appPath,
|
|
||||||
_preloadedSubtitles,
|
|
||||||
options,
|
|
||||||
) => {
|
|
||||||
overlayLoadingOsd = (
|
|
||||||
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
|
|
||||||
)?.overlayLoadingOsd;
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
|
||||||
overlayConfigDir = configDir;
|
|
||||||
},
|
|
||||||
launchAppCommandDetached: () => {},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {},
|
|
||||||
getMpvProc: () => null,
|
|
||||||
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||||
availabilityConfigDir = configDir;
|
availabilityConfigDir = configDir;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withEnv({ XDG_CONFIG_HOME: xdgConfigHome, APPDATA: xdgConfigHome }, () => harness.run());
|
||||||
|
|
||||||
assert.equal(availabilityConfigDir, expectedConfigDir);
|
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||||
assert.equal(overlayConfigDir, expectedConfigDir);
|
assert.equal(harness.overlayCalls[0]?.configDir, expectedConfigDir);
|
||||||
assert.equal(overlayLoadingOsd, true);
|
assert.equal(harness.startMpvOptions[0]?.runtimePluginConfig?.overlayLoadingOsd, true);
|
||||||
} finally {
|
} finally {
|
||||||
if (originalXdgConfigHome === undefined) {
|
|
||||||
delete process.env.XDG_CONFIG_HOME;
|
|
||||||
} else {
|
|
||||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
|
||||||
}
|
|
||||||
if (originalAppData === undefined) {
|
|
||||||
delete process.env.APPDATA;
|
|
||||||
} else {
|
|
||||||
process.env.APPDATA = originalAppData;
|
|
||||||
}
|
|
||||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||||
const context = createContext();
|
const harness = makeHarness({
|
||||||
context.args = {
|
context: createContext({ args: FILE_TARGET, plugin: { texthookerEnabled: true } }),
|
||||||
...context.args,
|
deps: { isAppControlServerAvailable: async () => true },
|
||||||
target: '/tmp/movie.mkv',
|
|
||||||
targetKind: 'file',
|
|
||||||
};
|
|
||||||
context.pluginRuntimeConfig = {
|
|
||||||
socketPath: '/tmp/subminer.sock',
|
|
||||||
binaryPath: '',
|
|
||||||
backend: 'auto',
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
osdMessages: false,
|
|
||||||
texthookerEnabled: true,
|
|
||||||
};
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {},
|
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async () => {
|
|
||||||
calls.push('startMpv');
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
|
||||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
|
||||||
},
|
|
||||||
launchAppCommandDetached: () => {},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {},
|
|
||||||
getMpvProc: () => null,
|
|
||||||
isAppControlServerAvailable: async () => true,
|
|
||||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
|
||||||
isAppControlServerAvailable: () => Promise<boolean>;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
await harness.run();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.overlayCalls[0]?.extraAppArgs, ['--show-visible-overlay']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
|
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
|
||||||
const context = createContext();
|
const harness = makeHarness({ context: createContext({ args: FILE_TARGET }) });
|
||||||
context.args = {
|
|
||||||
...context.args,
|
|
||||||
target: '/tmp/movie.mkv',
|
|
||||||
targetKind: 'file',
|
|
||||||
};
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await harness.run();
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {
|
|
||||||
calls.push('plugin');
|
|
||||||
},
|
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async () => {
|
|
||||||
calls.push('startMpv');
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async () => {},
|
|
||||||
launchAppCommandDetached: () => {},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {},
|
|
||||||
getMpvProc: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
assertRanInOrder(harness.events, 'plugin', 'startMpv');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rofi playback repairs support assets before opening the picker', async () => {
|
test('rofi playback repairs support assets before opening the picker', async () => {
|
||||||
const context = createContext();
|
const events: string[] = [];
|
||||||
context.args = {
|
const harness = makeHarness({
|
||||||
...context.args,
|
events,
|
||||||
target: '',
|
context: createContext({ args: { target: '', targetKind: '', useRofi: true } }),
|
||||||
targetKind: '',
|
deps: {
|
||||||
useRofi: true,
|
chooseTarget: async () => {
|
||||||
};
|
events.push('picker');
|
||||||
const calls: string[] = [];
|
return { target: '/tmp/movie.mkv', kind: 'file' };
|
||||||
|
},
|
||||||
await runPlaybackCommandWithDeps(context, {
|
checkPickerDependencies: () => {},
|
||||||
ensurePlaybackSetupReady: async () => {},
|
|
||||||
ensureRuntimePluginReady: async () => {
|
|
||||||
calls.push('assets');
|
|
||||||
},
|
},
|
||||||
chooseTarget: async () => {
|
|
||||||
calls.push('picker');
|
|
||||||
return { target: '/tmp/movie.mkv', kind: 'file' };
|
|
||||||
},
|
|
||||||
checkPickerDependencies: () => {},
|
|
||||||
checkDependencies: () => {},
|
|
||||||
registerCleanup: () => {},
|
|
||||||
startMpv: async () => {
|
|
||||||
calls.push('startMpv');
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => true,
|
|
||||||
startOverlay: async () => {},
|
|
||||||
launchAppCommandDetached: () => {},
|
|
||||||
log: () => {},
|
|
||||||
cleanupPlaybackSession: async () => {},
|
|
||||||
getMpvProc: () => null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
|
await harness.run();
|
||||||
|
|
||||||
|
assertRanInOrder(events, 'plugin', 'picker', 'startMpv');
|
||||||
});
|
});
|
||||||
@@ -48,49 +48,6 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
|
|||||||
assert.equal(spawned.length, 1);
|
assert.equal(spawned.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
|
|
||||||
const spawned: string[][] = [];
|
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
|
||||||
runAppCommand: (_appPath, appArgs) => {
|
|
||||||
spawned.push(appArgs);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await runSyncCommand(
|
|
||||||
makeContext({
|
|
||||||
syncCliTokens: [
|
|
||||||
'media-box',
|
|
||||||
'--pull',
|
|
||||||
'--remote-cmd',
|
|
||||||
'/opt/SubMiner.AppImage',
|
|
||||||
'--db',
|
|
||||||
'/tmp/db.sqlite',
|
|
||||||
'--force',
|
|
||||||
'--json',
|
|
||||||
],
|
|
||||||
logLevel: 'debug',
|
|
||||||
}),
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(spawned, [
|
|
||||||
[
|
|
||||||
'--sync-cli',
|
|
||||||
'sync',
|
|
||||||
'media-box',
|
|
||||||
'--pull',
|
|
||||||
'--remote-cmd',
|
|
||||||
'/opt/SubMiner.AppImage',
|
|
||||||
'--db',
|
|
||||||
'/tmp/db.sqlite',
|
|
||||||
'--force',
|
|
||||||
'--json',
|
|
||||||
'--log-level',
|
|
||||||
'debug',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
||||||
const deps: Partial<SyncCommandDeps> = {
|
const deps: Partial<SyncCommandDeps> = {
|
||||||
runAppCommand: () => {
|
runAppCommand: () => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
buildPluginRuntimeScriptOptParts,
|
buildPluginRuntimeScriptOptParts,
|
||||||
parsePluginRuntimeConfigFromMainConfig,
|
parsePluginRuntimeConfigFromMainConfig,
|
||||||
} from './config/plugin-runtime-config.js';
|
} from './config/plugin-runtime-config.js';
|
||||||
import { getDefaultSocketPath } from './types.js';
|
|
||||||
|
|
||||||
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||||
const parsed = parseLauncherYoutubeSubgenConfig({
|
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||||
@@ -207,10 +206,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
|
||||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseLauncherMpvConfig reads configured mpv profile', () => {
|
test('parseLauncherMpvConfig reads configured mpv profile', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parseLauncherMpvConfig({
|
parseLauncherMpvConfig({
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
test('launcher root help lists subcommands', () => {
|
|
||||||
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.match(output, /Commands:/);
|
|
||||||
assert.match(output, /jellyfin\|jf/);
|
|
||||||
assert.match(output, /doctor/);
|
|
||||||
assert.match(output, /config/);
|
|
||||||
assert.match(output, /mpv/);
|
|
||||||
assert.match(output, /logs/);
|
|
||||||
assert.match(output, /dictionary\|dict/);
|
|
||||||
assert.match(output, /texthooker/);
|
|
||||||
assert.match(output, /app\|bin/);
|
|
||||||
});
|
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
applyRootOptionsToArgs,
|
applyRootOptionsToArgs,
|
||||||
createDefaultArgs,
|
createDefaultArgs,
|
||||||
} from './args-normalizer.js';
|
} from './args-normalizer.js';
|
||||||
|
import type { CliInvocations } from './cli-parser-builder.js';
|
||||||
|
|
||||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
|
||||||
@@ -19,6 +20,48 @@ function withTempDir<T>(fn: (dir: string) => T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An invocations bag with nothing triggered; tests pass only the fields under test. */
|
||||||
|
function emptyInvocations(overrides: Partial<CliInvocations> = {}): CliInvocations {
|
||||||
|
return {
|
||||||
|
jellyfinInvocation: null,
|
||||||
|
configInvocation: null,
|
||||||
|
settingsInvocation: null,
|
||||||
|
youtubeBrowserInvocation: null,
|
||||||
|
mpvInvocation: null,
|
||||||
|
appInvocation: null,
|
||||||
|
dictionaryTriggered: false,
|
||||||
|
dictionaryTarget: null,
|
||||||
|
dictionaryLogLevel: null,
|
||||||
|
dictionaryCandidates: false,
|
||||||
|
dictionarySelect: false,
|
||||||
|
dictionaryAnilistId: null,
|
||||||
|
statsTriggered: false,
|
||||||
|
statsBackground: false,
|
||||||
|
statsStop: false,
|
||||||
|
statsCleanup: false,
|
||||||
|
statsCleanupVocab: false,
|
||||||
|
statsCleanupLifetime: false,
|
||||||
|
statsCleanupDuplicateLines: false,
|
||||||
|
statsCleanupDryRun: false,
|
||||||
|
statsCleanupLookbackDays: null,
|
||||||
|
statsLogLevel: null,
|
||||||
|
syncTriggered: false,
|
||||||
|
syncCliTokens: [],
|
||||||
|
syncLogLevel: null,
|
||||||
|
syncUiTriggered: false,
|
||||||
|
syncUiLogLevel: null,
|
||||||
|
doctorTriggered: false,
|
||||||
|
doctorLogLevel: null,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
|
logsTriggered: false,
|
||||||
|
logsExport: false,
|
||||||
|
texthookerTriggered: false,
|
||||||
|
texthookerLogLevel: null,
|
||||||
|
texthookerOpenBrowser: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('createDefaultArgs normalizes configured language codes', () => {
|
test('createDefaultArgs normalizes configured language codes', () => {
|
||||||
const parsed = createDefaultArgs({
|
const parsed = createDefaultArgs({
|
||||||
primarySubLanguages: [' JA ', 'jpn', 'ja'],
|
primarySubLanguages: [' JA ', 'jpn', 'ja'],
|
||||||
@@ -92,53 +135,23 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
|
|||||||
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(
|
||||||
jellyfinInvocation: {
|
parsed,
|
||||||
action: 'play',
|
emptyInvocations({
|
||||||
play: true,
|
jellyfinInvocation: {
|
||||||
server: 'https://jf.example',
|
action: 'play',
|
||||||
username: 'alice',
|
play: true,
|
||||||
password: 'secret',
|
server: 'https://jf.example',
|
||||||
logLevel: 'debug',
|
username: 'alice',
|
||||||
},
|
password: 'secret',
|
||||||
configInvocation: {
|
logLevel: 'debug',
|
||||||
action: 'show',
|
},
|
||||||
logLevel: 'warn',
|
configInvocation: {
|
||||||
},
|
action: 'show',
|
||||||
settingsInvocation: null,
|
logLevel: 'warn',
|
||||||
youtubeBrowserInvocation: null,
|
},
|
||||||
mpvInvocation: null,
|
}),
|
||||||
appInvocation: null,
|
);
|
||||||
dictionaryTriggered: false,
|
|
||||||
dictionaryTarget: null,
|
|
||||||
dictionaryLogLevel: null,
|
|
||||||
dictionaryCandidates: false,
|
|
||||||
dictionarySelect: false,
|
|
||||||
dictionaryAnilistId: null,
|
|
||||||
statsTriggered: false,
|
|
||||||
statsBackground: false,
|
|
||||||
statsStop: false,
|
|
||||||
statsCleanup: false,
|
|
||||||
statsCleanupVocab: false,
|
|
||||||
statsCleanupLifetime: false,
|
|
||||||
statsCleanupDuplicateLines: false,
|
|
||||||
statsCleanupDryRun: false,
|
|
||||||
statsCleanupLookbackDays: null,
|
|
||||||
statsLogLevel: null,
|
|
||||||
syncTriggered: false,
|
|
||||||
syncCliTokens: [],
|
|
||||||
syncLogLevel: null,
|
|
||||||
syncUiTriggered: false,
|
|
||||||
syncUiLogLevel: null,
|
|
||||||
doctorTriggered: false,
|
|
||||||
doctorLogLevel: null,
|
|
||||||
doctorRefreshKnownWords: false,
|
|
||||||
logsTriggered: false,
|
|
||||||
logsExport: false,
|
|
||||||
texthookerTriggered: false,
|
|
||||||
texthookerLogLevel: null,
|
|
||||||
texthookerOpenBrowser: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(parsed.jellyfin, false);
|
assert.equal(parsed.jellyfin, false);
|
||||||
assert.equal(parsed.jellyfinPlay, true);
|
assert.equal(parsed.jellyfinPlay, true);
|
||||||
@@ -155,45 +168,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
|
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
|
||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(
|
||||||
jellyfinInvocation: null,
|
parsed,
|
||||||
configInvocation: null,
|
emptyInvocations({
|
||||||
settingsInvocation: {
|
settingsInvocation: {
|
||||||
logLevel: undefined,
|
logLevel: undefined,
|
||||||
},
|
},
|
||||||
youtubeBrowserInvocation: null,
|
}),
|
||||||
mpvInvocation: null,
|
);
|
||||||
appInvocation: null,
|
|
||||||
dictionaryTriggered: false,
|
|
||||||
dictionaryTarget: null,
|
|
||||||
dictionaryLogLevel: null,
|
|
||||||
dictionaryCandidates: false,
|
|
||||||
dictionarySelect: false,
|
|
||||||
dictionaryAnilistId: null,
|
|
||||||
statsTriggered: false,
|
|
||||||
statsBackground: false,
|
|
||||||
statsStop: false,
|
|
||||||
statsCleanup: false,
|
|
||||||
statsCleanupVocab: false,
|
|
||||||
statsCleanupLifetime: false,
|
|
||||||
statsCleanupDuplicateLines: false,
|
|
||||||
statsCleanupDryRun: false,
|
|
||||||
statsCleanupLookbackDays: null,
|
|
||||||
statsLogLevel: null,
|
|
||||||
syncTriggered: false,
|
|
||||||
syncCliTokens: [],
|
|
||||||
syncLogLevel: null,
|
|
||||||
syncUiTriggered: false,
|
|
||||||
syncUiLogLevel: null,
|
|
||||||
doctorTriggered: false,
|
|
||||||
doctorLogLevel: null,
|
|
||||||
doctorRefreshKnownWords: false,
|
|
||||||
logsTriggered: false,
|
|
||||||
logsExport: false,
|
|
||||||
texthookerTriggered: false,
|
|
||||||
texthookerLogLevel: null,
|
|
||||||
texthookerOpenBrowser: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(parsed.settings, true);
|
assert.equal(parsed.settings, true);
|
||||||
assert.equal(parsed.configPath, false);
|
assert.equal(parsed.configPath, false);
|
||||||
@@ -203,45 +185,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
|||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(
|
||||||
jellyfinInvocation: null,
|
parsed,
|
||||||
configInvocation: {
|
emptyInvocations({
|
||||||
action: undefined,
|
configInvocation: {
|
||||||
},
|
action: undefined,
|
||||||
settingsInvocation: null,
|
},
|
||||||
youtubeBrowserInvocation: null,
|
}),
|
||||||
mpvInvocation: null,
|
);
|
||||||
appInvocation: null,
|
|
||||||
dictionaryTriggered: false,
|
|
||||||
dictionaryTarget: null,
|
|
||||||
dictionaryLogLevel: null,
|
|
||||||
dictionaryCandidates: false,
|
|
||||||
dictionarySelect: false,
|
|
||||||
dictionaryAnilistId: null,
|
|
||||||
statsTriggered: false,
|
|
||||||
statsBackground: false,
|
|
||||||
statsStop: false,
|
|
||||||
statsCleanup: false,
|
|
||||||
statsCleanupVocab: false,
|
|
||||||
statsCleanupLifetime: false,
|
|
||||||
statsCleanupDuplicateLines: false,
|
|
||||||
statsCleanupDryRun: false,
|
|
||||||
statsCleanupLookbackDays: null,
|
|
||||||
statsLogLevel: null,
|
|
||||||
syncTriggered: false,
|
|
||||||
syncCliTokens: [],
|
|
||||||
syncLogLevel: null,
|
|
||||||
syncUiTriggered: false,
|
|
||||||
syncUiLogLevel: null,
|
|
||||||
doctorTriggered: false,
|
|
||||||
doctorLogLevel: null,
|
|
||||||
doctorRefreshKnownWords: false,
|
|
||||||
logsTriggered: false,
|
|
||||||
logsExport: false,
|
|
||||||
texthookerTriggered: false,
|
|
||||||
texthookerLogLevel: null,
|
|
||||||
texthookerOpenBrowser: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(error.code, 1);
|
assert.equal(error.code, 1);
|
||||||
@@ -251,43 +202,13 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
|||||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(
|
||||||
jellyfinInvocation: null,
|
parsed,
|
||||||
configInvocation: null,
|
emptyInvocations({
|
||||||
settingsInvocation: null,
|
texthookerTriggered: true,
|
||||||
youtubeBrowserInvocation: null,
|
texthookerOpenBrowser: true,
|
||||||
mpvInvocation: null,
|
}),
|
||||||
appInvocation: null,
|
);
|
||||||
dictionaryTriggered: false,
|
|
||||||
dictionaryTarget: null,
|
|
||||||
dictionaryLogLevel: null,
|
|
||||||
dictionaryCandidates: false,
|
|
||||||
dictionarySelect: false,
|
|
||||||
dictionaryAnilistId: null,
|
|
||||||
statsTriggered: false,
|
|
||||||
statsBackground: false,
|
|
||||||
statsStop: false,
|
|
||||||
statsCleanup: false,
|
|
||||||
statsCleanupVocab: false,
|
|
||||||
statsCleanupLifetime: false,
|
|
||||||
statsCleanupDuplicateLines: false,
|
|
||||||
statsCleanupDryRun: false,
|
|
||||||
statsCleanupLookbackDays: null,
|
|
||||||
statsLogLevel: null,
|
|
||||||
syncTriggered: false,
|
|
||||||
syncCliTokens: [],
|
|
||||||
syncLogLevel: null,
|
|
||||||
syncUiTriggered: false,
|
|
||||||
syncUiLogLevel: null,
|
|
||||||
doctorTriggered: false,
|
|
||||||
doctorLogLevel: null,
|
|
||||||
doctorRefreshKnownWords: false,
|
|
||||||
logsTriggered: false,
|
|
||||||
logsExport: false,
|
|
||||||
texthookerTriggered: true,
|
|
||||||
texthookerLogLevel: null,
|
|
||||||
texthookerOpenBrowser: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(parsed.texthookerOnly, true);
|
assert.equal(parsed.texthookerOnly, true);
|
||||||
assert.equal(parsed.texthookerOpenBrowser, true);
|
assert.equal(parsed.texthookerOpenBrowser, true);
|
||||||
|
|||||||
+297
-120
@@ -1,128 +1,305 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import type { Args } from './types.js';
|
import fs from 'node:fs';
|
||||||
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
buildForwardedJellyfinAppArgs,
|
||||||
|
buildRootSearchGroups,
|
||||||
|
classifyJellyfinChildSelection,
|
||||||
|
deriveJellyfinTokenStorePath,
|
||||||
|
hasStoredJellyfinSession,
|
||||||
|
parseEpisodePathFromDisplay,
|
||||||
|
parseJellyfinErrorFromAppOutput,
|
||||||
|
parseJellyfinItemsFromAppOutput,
|
||||||
|
parseJellyfinLibrariesFromAppOutput,
|
||||||
|
parseJellyfinPreviewAuthResponse,
|
||||||
|
readUtf8FileAppendedSince,
|
||||||
|
runJellyfinPlayMenuWithDeps,
|
||||||
|
shouldRetryWithStartForNoRunningInstance,
|
||||||
|
} from './jellyfin.js';
|
||||||
|
import { makeLauncherArgs } from './test-support/args.js';
|
||||||
|
import { withEnv } from './test-support/env.js';
|
||||||
|
|
||||||
function createArgs(): Args {
|
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||||
return {
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-'));
|
||||||
backend: 'auto',
|
try {
|
||||||
directory: '.',
|
return fn(dir);
|
||||||
recursive: false,
|
} finally {
|
||||||
profile: '',
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
startOverlay: false,
|
}
|
||||||
youtubePrimarySubLangs: [],
|
|
||||||
youtubeSecondarySubLangs: [],
|
|
||||||
youtubeAudioLangs: [],
|
|
||||||
useTexthooker: false,
|
|
||||||
autoStartOverlay: false,
|
|
||||||
texthookerOnly: false,
|
|
||||||
texthookerOpenBrowser: false,
|
|
||||||
useRofi: false,
|
|
||||||
history: false,
|
|
||||||
sync: false,
|
|
||||||
syncCliTokens: [],
|
|
||||||
syncUi: false,
|
|
||||||
logLevel: 'info',
|
|
||||||
logRotation: 7,
|
|
||||||
passwordStore: '',
|
|
||||||
target: '',
|
|
||||||
targetKind: '',
|
|
||||||
jimakuApiKey: '',
|
|
||||||
jimakuApiKeyCommand: '',
|
|
||||||
jimakuApiBaseUrl: '',
|
|
||||||
jimakuLanguagePreference: 'ja',
|
|
||||||
jimakuMaxEntryResults: 20,
|
|
||||||
jellyfin: false,
|
|
||||||
jellyfinLogin: false,
|
|
||||||
jellyfinLogout: false,
|
|
||||||
jellyfinPlay: false,
|
|
||||||
jellyfinDiscovery: false,
|
|
||||||
dictionary: false,
|
|
||||||
dictionaryCandidates: false,
|
|
||||||
dictionarySelect: false,
|
|
||||||
stats: false,
|
|
||||||
doctor: false,
|
|
||||||
doctorRefreshKnownWords: false,
|
|
||||||
logsExport: false,
|
|
||||||
version: false,
|
|
||||||
settings: false,
|
|
||||||
youtubeBrowser: false,
|
|
||||||
configPath: false,
|
|
||||||
configShow: false,
|
|
||||||
mpvIdle: false,
|
|
||||||
mpvSocket: false,
|
|
||||||
mpvStatus: false,
|
|
||||||
mpvArgs: '',
|
|
||||||
appPassthrough: false,
|
|
||||||
appArgs: [],
|
|
||||||
jellyfinServer: 'https://jellyfin.example.test',
|
|
||||||
jellyfinUsername: '',
|
|
||||||
jellyfinPassword: '',
|
|
||||||
launchMode: 'normal',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
|
class HandoffComplete extends Error {}
|
||||||
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
|
||||||
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
|
|
||||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
|
|
||||||
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
try {
|
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
||||||
await assert.rejects(
|
|
||||||
() =>
|
|
||||||
runJellyfinPlayMenuWithDeps(
|
|
||||||
'/tmp/SubMiner.AppImage',
|
|
||||||
createArgs(),
|
|
||||||
'/tmp/subminer',
|
|
||||||
'/tmp/subminer.sock',
|
|
||||||
{
|
|
||||||
loadLauncherJellyfinConfig: () => ({}),
|
|
||||||
findRofiTheme: () => null,
|
|
||||||
resolveJellyfinSelection: async () => 'item-123',
|
|
||||||
resolveJellyfinSelectionViaApp: async () => {
|
|
||||||
throw new Error('unexpected app-based selection');
|
|
||||||
},
|
|
||||||
hasStoredJellyfinSession: () => true,
|
|
||||||
requestJellyfinPreviewAuthFromApp: async () => null,
|
|
||||||
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
|
||||||
pathExists: () => false,
|
|
||||||
ensureRuntimePluginReady: async () => {
|
|
||||||
calls.push('plugin');
|
|
||||||
},
|
|
||||||
waitForUnixSocketReady: async () => {
|
|
||||||
calls.push('wait');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
launchMpvIdleDetached: async () => {
|
|
||||||
calls.push('launch');
|
|
||||||
},
|
|
||||||
resolveLauncherRuntimePluginPath: () => {
|
|
||||||
calls.push('resolve');
|
|
||||||
return '/tmp/plugin/main.lua';
|
|
||||||
},
|
|
||||||
runAppCommandWithInheritLogged: () => {
|
|
||||||
calls.push('handoff');
|
|
||||||
throw new Error('stop after handoff');
|
|
||||||
},
|
|
||||||
log: () => {},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
/stop after handoff/,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
|
/**
|
||||||
} finally {
|
* Runs the play menu with fake deps and a direct env session. The app handoff never returns in
|
||||||
if (originalAccessToken === undefined) {
|
* production, so the fake throws `HandoffComplete` after recording its args into `handoff`.
|
||||||
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
*/
|
||||||
} else {
|
async function runPlayMenu(options: {
|
||||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
|
socketReady?: boolean;
|
||||||
}
|
overrides?: Partial<PlayMenuDeps>;
|
||||||
if (originalUserId === undefined) {
|
args?: Parameters<typeof makeLauncherArgs>[0];
|
||||||
delete process.env.SUBMINER_JELLYFIN_USER_ID;
|
}) {
|
||||||
} else {
|
const launches: string[] = [];
|
||||||
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
|
const handoff: { appArgs: string[] | null } = { appArgs: null };
|
||||||
}
|
const deps: PlayMenuDeps = {
|
||||||
}
|
loadLauncherJellyfinConfig: () => ({}),
|
||||||
|
findRofiTheme: () => null,
|
||||||
|
resolveJellyfinSelection: async () => 'item-123',
|
||||||
|
resolveJellyfinSelectionViaApp: async () => {
|
||||||
|
throw new Error('unexpected app-based selection');
|
||||||
|
},
|
||||||
|
hasStoredJellyfinSession: () => true,
|
||||||
|
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||||
|
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||||
|
pathExists: () => options.socketReady ?? false,
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
launches.push('plugin');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
launchMpvIdleDetached: async () => {
|
||||||
|
launches.push('launch');
|
||||||
|
},
|
||||||
|
resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||||
|
runAppCommandWithInheritLogged: (_appPath, appArgs) => {
|
||||||
|
handoff.appArgs = appArgs;
|
||||||
|
throw new HandoffComplete();
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
...options.overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
await withEnv(
|
||||||
|
{ SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' },
|
||||||
|
() =>
|
||||||
|
assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJellyfinPlayMenuWithDeps(
|
||||||
|
'/tmp/SubMiner.AppImage',
|
||||||
|
makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }),
|
||||||
|
'/tmp/subminer',
|
||||||
|
'/tmp/subminer.sock',
|
||||||
|
deps,
|
||||||
|
),
|
||||||
|
HandoffComplete,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { launches, appArgs: handoff.appArgs };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => {
|
||||||
|
const { launches, appArgs } = await runPlayMenu({});
|
||||||
|
|
||||||
|
assert.deepEqual(launches, ['plugin', 'launch']);
|
||||||
|
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => {
|
||||||
|
const { launches, appArgs } = await runPlayMenu({ socketReady: true });
|
||||||
|
|
||||||
|
assert.deepEqual(launches, []);
|
||||||
|
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
|
||||||
|
const parsed = parseJellyfinLibrariesFromAppOutput(`
|
||||||
|
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
|
||||||
|
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.deepEqual(parsed, [
|
||||||
|
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||||
|
{ id: 'lib2', name: 'Movies', kind: 'movies' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
|
||||||
|
const parsed = parseJellyfinItemsFromAppOutput(`
|
||||||
|
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
|
||||||
|
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.deepEqual(parsed, [
|
||||||
|
{
|
||||||
|
id: 'item-10',
|
||||||
|
name: 'Solo Leveling S01E10',
|
||||||
|
type: 'Episode',
|
||||||
|
display: 'Solo Leveling S01E10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'movie-1',
|
||||||
|
name: 'Movie [Alt]',
|
||||||
|
type: 'Movie',
|
||||||
|
display: 'Movie [Alt]',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
|
||||||
|
const forwarded = buildForwardedJellyfinAppArgs(
|
||||||
|
{
|
||||||
|
jellyfinServer: 'https://jf.example.test/',
|
||||||
|
passwordStore: 'gnome-libsecret',
|
||||||
|
logLevel: 'info',
|
||||||
|
} as never,
|
||||||
|
['--jellyfin-libraries'],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(forwarded, [
|
||||||
|
'--jellyfin-libraries',
|
||||||
|
'--jellyfin-server',
|
||||||
|
'https://jf.example.test',
|
||||||
|
'--password-store',
|
||||||
|
'gnome-libsecret',
|
||||||
|
'--log-level',
|
||||||
|
'info',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
|
||||||
|
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||||
|
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
||||||
|
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
parsed,
|
||||||
|
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
|
||||||
|
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||||
|
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
parsed,
|
||||||
|
'[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
||||||
|
const parsed = parseJellyfinPreviewAuthResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
serverUrl: 'http://pve-main:8096/',
|
||||||
|
accessToken: 'token-123',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(parsed, {
|
||||||
|
serverUrl: 'http://pve-main:8096',
|
||||||
|
accessToken: 'token-123',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
|
||||||
|
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
|
||||||
|
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
|
||||||
|
assert.equal(
|
||||||
|
parseJellyfinPreviewAuthResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
serverUrl: 'http://pve-main:8096',
|
||||||
|
accessToken: '',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasStoredJellyfinSession checks token-store existence', () => {
|
||||||
|
const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
|
||||||
|
const tokenPath = deriveJellyfinTokenStorePath(configPath);
|
||||||
|
const exists = (candidate: string): boolean => candidate === tokenPath;
|
||||||
|
assert.equal(hasStoredJellyfinSession(configPath, exists), true);
|
||||||
|
assert.equal(
|
||||||
|
hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldRetryWithStartForNoRunningInstance(
|
||||||
|
'Missing Jellyfin session. Run --jellyfin-login first.',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const logPath = path.join(root, 'SubMiner.log');
|
||||||
|
const prefix = '[subminer] こんにちは\n';
|
||||||
|
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
|
||||||
|
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
|
||||||
|
|
||||||
|
const byteOffset = Buffer.byteLength(prefix, 'utf8');
|
||||||
|
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
|
||||||
|
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
||||||
|
|
||||||
|
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
|
||||||
|
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
|
||||||
|
{
|
||||||
|
seriesName: 'KONOSUBA',
|
||||||
|
seasonNumber: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
|
||||||
|
seriesName: 'Frieren',
|
||||||
|
seasonNumber: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
|
||||||
|
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
|
||||||
|
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
|
||||||
|
const groups = buildRootSearchGroups([
|
||||||
|
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
|
||||||
|
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
|
||||||
|
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(groups, [
|
||||||
|
{
|
||||||
|
id: 'series-1',
|
||||||
|
name: 'The Eminence in Shadow',
|
||||||
|
type: 'Series',
|
||||||
|
display: 'The Eminence in Shadow (Series)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'movie-1',
|
||||||
|
name: 'Spirited Away',
|
||||||
|
type: 'Movie',
|
||||||
|
display: 'Spirited Away (Movie)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
|
||||||
|
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
|
||||||
|
assert.deepEqual(next, {
|
||||||
|
kind: 'container',
|
||||||
|
id: 'season-2',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+325
-894
File diff suppressed because it is too large.
Load diff
+523
-964
File diff suppressed because it is too large.
Load diff
@@ -30,12 +30,6 @@ test('parseArgs captures mpv args string', () => {
|
|||||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs appends CLI mpv profile to configured mpv profile', () => {
|
|
||||||
const parsed = parseArgs(['--profile', 'hdr'], 'subminer', {}, { profile: 'anime' });
|
|
||||||
|
|
||||||
assert.equal(parsed.profile, 'anime,hdr');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseArgs maps root settings window option', () => {
|
test('parseArgs maps root settings window option', () => {
|
||||||
const parsed = parseArgs(['--settings'], 'subminer', {});
|
const parsed = parseArgs(['--settings'], 'subminer', {});
|
||||||
|
|
||||||
@@ -224,14 +218,6 @@ test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
|||||||
assert.equal(parsed.statsCleanupVocab, true);
|
assert.equal(parsed.statsCleanupVocab, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs maps explicit stats cleanup vocab flag', () => {
|
|
||||||
const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
|
||||||
|
|
||||||
assert.equal(parsed.stats, true);
|
|
||||||
assert.equal(parsed.statsCleanup, true);
|
|
||||||
assert.equal(parsed.statsCleanupVocab, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseArgs maps lifetime stats cleanup flag', () => {
|
test('parseArgs maps lifetime stats cleanup flag', () => {
|
||||||
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
|
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
@@ -8,282 +8,168 @@ import {
|
|||||||
installManagedPluginAssetsViaApp,
|
installManagedPluginAssetsViaApp,
|
||||||
} from './runtime-plugin-preflight';
|
} from './runtime-plugin-preflight';
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
type EnsureOptions = Parameters<typeof ensureLinuxRuntimePluginAvailable>[0];
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
/**
|
||||||
platform: 'darwin',
|
* Linux preflight harness: by default every managed asset resolves, so tests override only
|
||||||
detectInstalledPlugin: () => {
|
* the probes that should report something missing. Counts installs and collects log messages.
|
||||||
calls.push('detect');
|
*/
|
||||||
return false;
|
function makeEnsureHarness(overrides: Partial<EnsureOptions> = {}) {
|
||||||
},
|
const state = { installs: 0, logs: [] as string[] };
|
||||||
resolveRuntimePluginPath: () => {
|
const options: EnsureOptions = {
|
||||||
calls.push('resolve');
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
installManagedPluginAssets: async () => {
|
|
||||||
calls.push('install');
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
|
||||||
},
|
|
||||||
log: () => {
|
|
||||||
calls.push('log');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
platform: 'linux',
|
|
||||||
detectInstalledPlugin: () => {
|
|
||||||
calls.push('detect');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
resolveRuntimePluginPath: () => {
|
|
||||||
calls.push('resolve');
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
installManagedPluginAssets: async () => {
|
|
||||||
calls.push('install');
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
|
||||||
},
|
|
||||||
isManagedThemeAvailable: () => {
|
|
||||||
calls.push('theme');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
isManagedThumbnailerAvailable: () => {
|
|
||||||
calls.push('thumbnailer');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
log: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
platform: 'linux',
|
|
||||||
xdgDataHome: '/tmp/xdg-data',
|
|
||||||
detectInstalledPlugin: () => {
|
|
||||||
calls.push('detect');
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
resolveRuntimePluginPath: () => {
|
|
||||||
calls.push('resolve');
|
|
||||||
return '/tmp/plugin/main.lua';
|
|
||||||
},
|
|
||||||
installManagedPluginAssets: async () => {
|
|
||||||
calls.push('install');
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
|
||||||
},
|
|
||||||
isManagedThemeAvailable: () => {
|
|
||||||
calls.push('theme');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
isManagedThumbnailerAvailable: () => {
|
|
||||||
calls.push('thumbnailer');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
log: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let themeAvailable = false;
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
platform: 'linux',
|
|
||||||
xdgDataHome: '/tmp/xdg-data',
|
|
||||||
detectInstalledPlugin: () => {
|
|
||||||
calls.push('detect');
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
resolveRuntimePluginPath: () => {
|
|
||||||
calls.push('resolve');
|
|
||||||
return '/tmp/plugin/main.lua';
|
|
||||||
},
|
|
||||||
isManagedThemeAvailable: () => {
|
|
||||||
calls.push('theme');
|
|
||||||
return themeAvailable;
|
|
||||||
},
|
|
||||||
isManagedThumbnailerAvailable: () => {
|
|
||||||
calls.push('thumbnailer');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
installManagedPluginAssets: async () => {
|
|
||||||
calls.push('install');
|
|
||||||
themeAvailable = true;
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
|
||||||
},
|
|
||||||
log: (level, _configured, message) => {
|
|
||||||
calls.push(`${level}:${message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'detect',
|
|
||||||
'resolve',
|
|
||||||
'theme',
|
|
||||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
|
||||||
'install',
|
|
||||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
|
||||||
'resolve',
|
|
||||||
'theme',
|
|
||||||
'thumbnailer',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let thumbnailerAvailable = false;
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
platform: 'linux',
|
|
||||||
xdgDataHome: '/tmp/xdg-data',
|
|
||||||
detectInstalledPlugin: () => true,
|
|
||||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
|
||||||
isManagedThemeAvailable: () => true,
|
|
||||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
|
||||||
installManagedPluginAssets: async () => {
|
|
||||||
calls.push('install');
|
|
||||||
thumbnailerAvailable = true;
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
|
||||||
},
|
|
||||||
log: (_level, _configured, message) => {
|
|
||||||
calls.push(message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
|
||||||
'install',
|
|
||||||
'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let thumbnailerAvailable = false;
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
platform: 'linux',
|
|
||||||
xdgDataHome: '/tmp/xdg-data',
|
|
||||||
detectInstalledPlugin: () => true,
|
|
||||||
resolveRuntimePluginPath: () => {
|
|
||||||
calls.push('resolve');
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
isManagedThemeAvailable: () => true,
|
|
||||||
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
|
||||||
installManagedPluginAssets: async () => {
|
|
||||||
calls.push('install');
|
|
||||||
thumbnailerAvailable = true;
|
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
|
||||||
},
|
|
||||||
log: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['install']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let resolveCount = 0;
|
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
xdgDataHome: '/tmp/xdg-data',
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
detectInstalledPlugin: () => false,
|
detectInstalledPlugin: () => false,
|
||||||
resolveRuntimePluginPath: () => {
|
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||||
resolveCount += 1;
|
|
||||||
calls.push(`resolve:${resolveCount}`);
|
|
||||||
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
|
||||||
},
|
|
||||||
isManagedThemeAvailable: () => true,
|
isManagedThemeAvailable: () => true,
|
||||||
isManagedThumbnailerAvailable: () => true,
|
isManagedThumbnailerAvailable: () => true,
|
||||||
installManagedPluginAssets: async () => {
|
installManagedPluginAssets: async () => {
|
||||||
calls.push('install');
|
state.installs += 1;
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
},
|
},
|
||||||
log: (level, _configured, message) => {
|
log: (_level, _configured, message) => {
|
||||||
calls.push(`${level}:${message}`);
|
state.logs.push(message);
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { state, run: () => ensureLinuxRuntimePluginAvailable(options) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||||
|
const unexpected = () => {
|
||||||
|
throw new Error('probed on a non-Linux platform');
|
||||||
|
};
|
||||||
|
const harness = makeEnsureHarness({
|
||||||
|
platform: 'darwin',
|
||||||
|
detectInstalledPlugin: unexpected,
|
||||||
|
resolveRuntimePluginPath: unexpected,
|
||||||
|
isManagedThemeAvailable: unexpected,
|
||||||
|
isManagedThumbnailerAvailable: unexpected,
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.run();
|
||||||
|
|
||||||
|
assert.equal(harness.state.installs, 0);
|
||||||
|
assert.deepEqual(harness.state.logs, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const skipInstallCases: Array<{ name: string; overrides: Partial<EnsureOptions> }> = [
|
||||||
|
{
|
||||||
|
name: 'plugin, theme, and thumbnailer exist',
|
||||||
|
overrides: { detectInstalledPlugin: () => true, resolveRuntimePluginPath: () => null },
|
||||||
|
},
|
||||||
|
{ name: 'all managed assets resolve', overrides: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of skipInstallCases) {
|
||||||
|
test(`ensureLinuxRuntimePluginAvailable skips install when ${c.name}`, async () => {
|
||||||
|
const harness = makeEnsureHarness(c.overrides);
|
||||||
|
|
||||||
|
await harness.run();
|
||||||
|
|
||||||
|
assert.equal(harness.state.installs, 0);
|
||||||
|
assert.deepEqual(harness.state.logs, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const installWhenMissingCases: Array<{ name: string; missing: 'theme' | 'thumbnailer' }> = [
|
||||||
|
{ name: 'rofi theme is missing', missing: 'theme' },
|
||||||
|
{ name: 'thumbnailer is missing', missing: 'thumbnailer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of installWhenMissingCases) {
|
||||||
|
test(`ensureLinuxRuntimePluginAvailable installs managed assets when ${c.name}`, async () => {
|
||||||
|
let available = false;
|
||||||
|
const probe = {
|
||||||
|
[c.missing === 'theme' ? 'isManagedThemeAvailable' : 'isManagedThumbnailerAvailable']: () =>
|
||||||
|
available,
|
||||||
|
};
|
||||||
|
const harness = makeEnsureHarness({
|
||||||
|
...probe,
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
harness.state.installs += 1;
|
||||||
|
available = true;
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.run();
|
||||||
|
|
||||||
|
assert.equal(harness.state.installs, 1);
|
||||||
|
assert.equal(harness.state.logs.length, 2);
|
||||||
|
assert.match(harness.state.logs[0]!, /support assets missing; installing/);
|
||||||
|
assert.match(
|
||||||
|
harness.state.logs[1]!,
|
||||||
|
/installed: plugin=\/tmp\/plugin\/main\.lua theme=.*subminer\.rasi thumbnailer=.*\.thumbnailer/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
|
||||||
|
let thumbnailerAvailable = false;
|
||||||
|
const harness = makeEnsureHarness({
|
||||||
|
detectInstalledPlugin: () => true,
|
||||||
|
// An installed plugin means the bundled path is never needed, even after the install.
|
||||||
|
resolveRuntimePluginPath: () => null,
|
||||||
|
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
harness.state.installs += 1;
|
||||||
|
thumbnailerAvailable = true;
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
await harness.run();
|
||||||
'resolve:1',
|
|
||||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
assert.equal(harness.state.installs, 1);
|
||||||
'install',
|
|
||||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
|
||||||
'resolve:2',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
|
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||||
await assert.rejects(
|
let installed = false;
|
||||||
() =>
|
const harness = makeEnsureHarness({
|
||||||
ensureLinuxRuntimePluginAvailable({
|
resolveRuntimePluginPath: () => (installed ? '/tmp/plugin/main.lua' : null),
|
||||||
platform: 'linux',
|
installManagedPluginAssets: async () => {
|
||||||
detectInstalledPlugin: () => false,
|
harness.state.installs += 1;
|
||||||
resolveRuntimePluginPath: () => null,
|
installed = true;
|
||||||
installManagedPluginAssets: async () => ({
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
ok: false,
|
},
|
||||||
status: 'failed',
|
});
|
||||||
error: 'copy failed',
|
|
||||||
}),
|
await harness.run();
|
||||||
log: () => {},
|
|
||||||
}),
|
assert.equal(harness.state.installs, 1);
|
||||||
/copy failed/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
|
const failureCases: Array<{ name: string; overrides: Partial<EnsureOptions>; error: RegExp }> = [
|
||||||
await assert.rejects(
|
{
|
||||||
() =>
|
name: 'install result is not ok',
|
||||||
ensureLinuxRuntimePluginAvailable({
|
overrides: {
|
||||||
platform: 'linux',
|
resolveRuntimePluginPath: () => null,
|
||||||
detectInstalledPlugin: () => false,
|
installManagedPluginAssets: async () => ({
|
||||||
resolveRuntimePluginPath: () => null,
|
ok: false,
|
||||||
installManagedPluginAssets: async () => ({
|
status: 'failed',
|
||||||
ok: true,
|
error: 'copy failed',
|
||||||
status: 'installed',
|
|
||||||
path: '/tmp/plugin/main.lua',
|
|
||||||
}),
|
|
||||||
log: () => {},
|
|
||||||
}),
|
}),
|
||||||
/managed runtime plugin assets could not be installed/i,
|
},
|
||||||
);
|
error: /copy failed/,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
name: 'runtime path remains unresolved after install',
|
||||||
|
overrides: { resolveRuntimePluginPath: () => null },
|
||||||
|
error: /managed runtime plugin assets could not be installed/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thumbnailer remains missing after install',
|
||||||
|
overrides: { detectInstalledPlugin: () => true, isManagedThumbnailerAvailable: () => false },
|
||||||
|
error: /thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => {
|
for (const c of failureCases) {
|
||||||
await assert.rejects(
|
test(`ensureLinuxRuntimePluginAvailable fails when ${c.name}`, async () => {
|
||||||
() =>
|
await assert.rejects(() => makeEnsureHarness(c.overrides).run(), c.error);
|
||||||
ensureLinuxRuntimePluginAvailable({
|
});
|
||||||
platform: 'linux',
|
}
|
||||||
xdgDataHome: '/tmp/xdg-data',
|
|
||||||
detectInstalledPlugin: () => true,
|
|
||||||
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
|
||||||
isManagedThemeAvailable: () => true,
|
|
||||||
isManagedThumbnailerAvailable: () => false,
|
|
||||||
installManagedPluginAssets: async () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 'installed',
|
|
||||||
path: '/tmp/plugin/main.lua',
|
|
||||||
}),
|
|
||||||
log: () => {},
|
|
||||||
}),
|
|
||||||
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
|
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
|
||||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
|
||||||
|
|||||||
+105
-211
@@ -3,104 +3,90 @@ import assert from 'node:assert/strict';
|
|||||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||||
import type { SetupState } from '../src/shared/setup-state';
|
import type { SetupState } from '../src/shared/setup-state';
|
||||||
|
|
||||||
const commandLineSetupDefaults = {
|
/** An in-progress setup state; tests override only the fields that matter. */
|
||||||
bunInstallStatus: 'unknown',
|
function makeState(overrides: Partial<SetupState> = {}): SetupState {
|
||||||
launcherInstallStatus: 'unknown',
|
return {
|
||||||
launcherInstallPath: null,
|
version: 4,
|
||||||
} satisfies Pick<SetupState, 'bunInstallStatus' | 'launcherInstallStatus' | 'launcherInstallPath'>;
|
status: 'in_progress',
|
||||||
|
completedAt: null,
|
||||||
|
completionSource: null,
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
pluginInstallStatus: 'unknown',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||||
|
windowsMpvShortcutLastStatus: 'unknown',
|
||||||
|
bunInstallStatus: 'unknown',
|
||||||
|
launcherInstallStatus: 'unknown',
|
||||||
|
launcherInstallPath: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
|
function completedState(overrides: Partial<SetupState> = {}): SetupState {
|
||||||
const sequence: Array<SetupState | null> = [
|
return makeState({
|
||||||
null,
|
status: 'completed',
|
||||||
{
|
completedAt: '2026-03-07T00:00:00.000Z',
|
||||||
version: 4,
|
completionSource: 'user',
|
||||||
status: 'in_progress',
|
yomitanSetupMode: 'internal',
|
||||||
completedAt: null,
|
lastSeenYomitanDictionaryCount: 1,
|
||||||
completionSource: null,
|
...overrides,
|
||||||
yomitanSetupMode: null,
|
});
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
}
|
||||||
pluginInstallStatus: 'unknown',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 4,
|
|
||||||
status: 'completed',
|
|
||||||
completedAt: '2026-03-07T00:00:00.000Z',
|
|
||||||
completionSource: 'user',
|
|
||||||
yomitanSetupMode: 'internal',
|
|
||||||
lastSeenYomitanDictionaryCount: 1,
|
|
||||||
pluginInstallStatus: 'skipped',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'skipped',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await waitForSetupCompletion({
|
const cancelledState = () => makeState({ status: 'cancelled' });
|
||||||
readSetupState: () => sequence.shift() ?? null,
|
|
||||||
|
/** Deterministic clock: every `now()` advances 100ms and `sleep` returns immediately. */
|
||||||
|
function fakeClock() {
|
||||||
|
let value = 0;
|
||||||
|
return {
|
||||||
sleep: async () => undefined,
|
sleep: async () => undefined,
|
||||||
now: (() => {
|
now: () => (value += 100),
|
||||||
let value = 0;
|
|
||||||
return () => (value += 100);
|
|
||||||
})(),
|
|
||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
pollIntervalMs: 100,
|
pollIntervalMs: 100,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
assert.equal(result, 'completed');
|
/** Reader that serves `states` in order, then repeats the last one; counts its reads. */
|
||||||
});
|
function sequenceReader(states: Array<SetupState | null>) {
|
||||||
|
const reader = {
|
||||||
|
reads: 0,
|
||||||
|
read: (): SetupState | null => states[Math.min(reader.reads++, states.length - 1)] ?? null,
|
||||||
|
};
|
||||||
|
return reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of [
|
||||||
|
{ name: 'completed', last: completedState(), expected: 'completed' },
|
||||||
|
{ name: 'cancelled', last: cancelledState(), expected: 'cancelled' },
|
||||||
|
] as const) {
|
||||||
|
test(`waitForSetupCompletion resolves ${c.name} state after polling through earlier states`, async () => {
|
||||||
|
const reader = sequenceReader([null, makeState(), c.last]);
|
||||||
|
|
||||||
|
const result = await waitForSetupCompletion({
|
||||||
|
readSetupState: reader.read,
|
||||||
|
...fakeClock(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, c.expected);
|
||||||
|
assert.equal(reader.reads, 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
|
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let reads = 0;
|
const reader = sequenceReader([
|
||||||
|
null,
|
||||||
|
makeState(),
|
||||||
|
completedState({ pluginInstallStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv' }),
|
||||||
|
]);
|
||||||
|
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => {
|
readSetupState: reader.read,
|
||||||
reads += 1;
|
|
||||||
if (reads === 1) return null;
|
|
||||||
if (reads === 2) {
|
|
||||||
return {
|
|
||||||
version: 4,
|
|
||||||
status: 'in_progress',
|
|
||||||
completedAt: null,
|
|
||||||
completionSource: null,
|
|
||||||
yomitanSetupMode: null,
|
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
|
||||||
pluginInstallStatus: 'unknown',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
version: 4,
|
|
||||||
status: 'completed',
|
|
||||||
completedAt: '2026-03-07T00:00:00.000Z',
|
|
||||||
completionSource: 'user',
|
|
||||||
yomitanSetupMode: 'internal',
|
|
||||||
lastSeenYomitanDictionaryCount: 1,
|
|
||||||
pluginInstallStatus: 'installed',
|
|
||||||
pluginInstallPathSummary: '/tmp/mpv',
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'installed',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
},
|
},
|
||||||
sleep: async () => undefined,
|
...fakeClock(),
|
||||||
now: (() => {
|
|
||||||
let value = 0;
|
|
||||||
return () => (value += 100);
|
|
||||||
})(),
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
pollIntervalMs: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(ready, true);
|
assert.equal(ready, true);
|
||||||
@@ -116,10 +102,7 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
|||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
},
|
},
|
||||||
sleep: async () => undefined,
|
...fakeClock(),
|
||||||
now: () => 0,
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
pollIntervalMs: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(ready, true);
|
assert.equal(ready, true);
|
||||||
@@ -129,76 +112,47 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
|||||||
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
|
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let legacyPluginInstalled = true;
|
let legacyPluginInstalled = true;
|
||||||
let reads = 0;
|
// The completion timestamp moves forward once the setup app has finished.
|
||||||
|
const reader = sequenceReader([
|
||||||
|
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
|
||||||
|
completedState({ yomitanSetupMode: null, lastSeenYomitanDictionaryCount: 0 }),
|
||||||
|
completedState({
|
||||||
|
completedAt: '2026-05-12T14:40:00.000Z',
|
||||||
|
yomitanSetupMode: null,
|
||||||
|
lastSeenYomitanDictionaryCount: 0,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => {
|
readSetupState: reader.read,
|
||||||
reads += 1;
|
|
||||||
return {
|
|
||||||
version: 4,
|
|
||||||
status: 'completed',
|
|
||||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
|
|
||||||
completionSource: 'user',
|
|
||||||
yomitanSetupMode: null,
|
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
|
||||||
pluginInstallStatus: 'unknown',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
hasLegacyMpvPlugin: () => legacyPluginInstalled,
|
hasLegacyMpvPlugin: () => legacyPluginInstalled,
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
legacyPluginInstalled = false;
|
legacyPluginInstalled = false;
|
||||||
},
|
},
|
||||||
sleep: async () => undefined,
|
...fakeClock(),
|
||||||
now: (() => {
|
|
||||||
let value = 0;
|
|
||||||
return () => (value += 100);
|
|
||||||
})(),
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
pollIntervalMs: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(ready, true);
|
assert.equal(ready, true);
|
||||||
assert.deepEqual(calls, ['launch']);
|
assert.deepEqual(calls, ['launch']);
|
||||||
assert.equal(reads >= 3, true);
|
assert.equal(reader.reads >= 3, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
|
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let reads = 0;
|
const reader = sequenceReader([
|
||||||
|
completedState({ lastSeenYomitanDictionaryCount: 2 }),
|
||||||
|
completedState({ lastSeenYomitanDictionaryCount: 2 }),
|
||||||
|
completedState({ completedAt: '2026-05-12T14:30:00.000Z', lastSeenYomitanDictionaryCount: 2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => {
|
readSetupState: reader.read,
|
||||||
reads += 1;
|
|
||||||
return {
|
|
||||||
version: 4,
|
|
||||||
status: 'completed',
|
|
||||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
|
|
||||||
completionSource: 'user',
|
|
||||||
yomitanSetupMode: 'internal',
|
|
||||||
lastSeenYomitanDictionaryCount: 2,
|
|
||||||
pluginInstallStatus: 'unknown',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
hasLegacyMpvPlugin: () => true,
|
hasLegacyMpvPlugin: () => true,
|
||||||
launchSetupApp: () => {
|
launchSetupApp: () => {
|
||||||
calls.push('launch');
|
calls.push('launch');
|
||||||
},
|
},
|
||||||
sleep: async () => undefined,
|
...fakeClock(),
|
||||||
now: (() => {
|
|
||||||
let value = 0;
|
|
||||||
return () => (value += 100);
|
|
||||||
})(),
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
pollIntervalMs: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(ready, true);
|
assert.equal(ready, true);
|
||||||
@@ -207,90 +161,30 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
|
|||||||
|
|
||||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||||
const result = await ensureLauncherSetupReady({
|
const result = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => ({
|
readSetupState: cancelledState,
|
||||||
version: 4,
|
|
||||||
status: 'cancelled',
|
|
||||||
completedAt: null,
|
|
||||||
completionSource: null,
|
|
||||||
yomitanSetupMode: null,
|
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
|
||||||
pluginInstallStatus: 'unknown',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
}),
|
|
||||||
launchSetupApp: () => undefined,
|
launchSetupApp: () => undefined,
|
||||||
sleep: async () => undefined,
|
...fakeClock(),
|
||||||
now: (() => {
|
|
||||||
let value = 0;
|
|
||||||
return () => (value += 100);
|
|
||||||
})(),
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
pollIntervalMs: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result, false);
|
assert.equal(result, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
|
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
|
||||||
let reads = 0;
|
const reader = sequenceReader([
|
||||||
|
cancelledState(),
|
||||||
|
cancelledState(),
|
||||||
|
makeState(),
|
||||||
|
completedState({
|
||||||
|
completionSource: 'legacy_auto_detected',
|
||||||
|
pluginInstallStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: '/tmp/mpv',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await ensureLauncherSetupReady({
|
const result = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => {
|
readSetupState: reader.read,
|
||||||
reads += 1;
|
|
||||||
if (reads <= 2) {
|
|
||||||
return {
|
|
||||||
version: 4,
|
|
||||||
status: 'cancelled',
|
|
||||||
completedAt: null,
|
|
||||||
completionSource: null,
|
|
||||||
yomitanSetupMode: null,
|
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
|
||||||
pluginInstallStatus: 'unknown',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (reads === 3) {
|
|
||||||
return {
|
|
||||||
version: 4,
|
|
||||||
status: 'in_progress',
|
|
||||||
completedAt: null,
|
|
||||||
completionSource: null,
|
|
||||||
yomitanSetupMode: null,
|
|
||||||
lastSeenYomitanDictionaryCount: 0,
|
|
||||||
pluginInstallStatus: 'unknown',
|
|
||||||
pluginInstallPathSummary: null,
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
version: 4,
|
|
||||||
status: 'completed',
|
|
||||||
completedAt: '2026-03-07T00:00:00.000Z',
|
|
||||||
completionSource: 'legacy_auto_detected',
|
|
||||||
yomitanSetupMode: 'internal',
|
|
||||||
lastSeenYomitanDictionaryCount: 1,
|
|
||||||
pluginInstallStatus: 'installed',
|
|
||||||
pluginInstallPathSummary: '/tmp/mpv',
|
|
||||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
|
||||||
windowsMpvShortcutLastStatus: 'unknown',
|
|
||||||
...commandLineSetupDefaults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
launchSetupApp: () => undefined,
|
launchSetupApp: () => undefined,
|
||||||
sleep: async () => undefined,
|
...fakeClock(),
|
||||||
now: (() => {
|
|
||||||
let value = 0;
|
|
||||||
return () => (value += 100);
|
|
||||||
})(),
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
pollIntervalMs: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result, true);
|
assert.equal(result, true);
|
||||||
|
|||||||
+51
-67
@@ -9,7 +9,6 @@ import {
|
|||||||
createDefaultSetupState,
|
createDefaultSetupState,
|
||||||
getDefaultConfigDir,
|
getDefaultConfigDir,
|
||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
readSetupState,
|
|
||||||
writeSetupState,
|
writeSetupState,
|
||||||
} from '../src/shared/setup-state.js';
|
} from '../src/shared/setup-state.js';
|
||||||
|
|
||||||
@@ -407,65 +406,65 @@ setInterval(() => {}, 1000);
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('launcher smoke fixture seeds completed setup state', () => {
|
/**
|
||||||
const smokeCase = createSmokeCase('setup-state');
|
* Some sandboxes forbid binding unix sockets (EPERM). The fake mpv needs one, so these tests
|
||||||
|
* cannot pass there; probe once and skip them instead of weakening every assertion.
|
||||||
|
*/
|
||||||
|
function unixSocketsDenied(): boolean {
|
||||||
|
if (process.platform === 'win32') return false;
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sock-probe-'));
|
||||||
try {
|
try {
|
||||||
const configDir = getDefaultConfigDir({
|
const probe = spawnSync(
|
||||||
xdgConfigHome: smokeCase.xdgConfigHome,
|
process.execPath,
|
||||||
homeDir: smokeCase.homeDir,
|
[
|
||||||
});
|
'-e',
|
||||||
const statePath = getSetupStatePath(configDir);
|
`require('node:net').createServer().on('error', (e) => { process.stderr.write(String(e)); process.exit(1); }).listen(${JSON.stringify(path.join(dir, 's.sock'))}, function () { this.close(() => process.exit(0)); });`,
|
||||||
|
],
|
||||||
assert.equal(readSetupState(statePath)?.status, 'completed');
|
{ encoding: 'utf8', timeout: 5000 },
|
||||||
|
);
|
||||||
|
return probe.status !== 0 && /eperm|operation not permitted/i.test(probe.stderr);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(smokeCase.root, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
fs.rmSync(smokeCase.socketDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
test('launcher mpv status returns ready when socket is connectable', async () => {
|
const skipWithoutUnixSockets = unixSocketsDenied() ? 'sandbox denies unix socket binding' : false;
|
||||||
await withSmokeCase('mpv-status', async (smokeCase) => {
|
|
||||||
const env = makeTestEnv(smokeCase);
|
|
||||||
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
|
|
||||||
env,
|
|
||||||
stdio: 'ignore',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
test(
|
||||||
await waitForSocketReady(smokeCase.socketPath);
|
'launcher mpv status returns ready when socket is connectable',
|
||||||
const result = runLauncher(
|
{ skip: skipWithoutUnixSockets },
|
||||||
smokeCase,
|
async () => {
|
||||||
['mpv', 'status', '--log-level', 'debug'],
|
await withSmokeCase('mpv-status', async (smokeCase) => {
|
||||||
|
const env = makeTestEnv(smokeCase);
|
||||||
|
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
|
||||||
env,
|
env,
|
||||||
'mpv-status',
|
stdio: 'ignore',
|
||||||
);
|
});
|
||||||
const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
|
||||||
const fakeMpvError = fakeMpvEntries.find(
|
|
||||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
|
||||||
)?.error;
|
|
||||||
const unixSocketDenied =
|
|
||||||
typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
|
|
||||||
|
|
||||||
if (unixSocketDenied) {
|
try {
|
||||||
assert.equal(result.status, 1);
|
await waitForSocketReady(smokeCase.socketPath);
|
||||||
assert.match(result.stdout, /socket not ready/i);
|
const result = runLauncher(
|
||||||
} else {
|
smokeCase,
|
||||||
|
['mpv', 'status', '--log-level', 'debug'],
|
||||||
|
env,
|
||||||
|
'mpv-status',
|
||||||
|
);
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.match(result.stdout, /socket ready/i);
|
assert.match(result.stdout, /socket ready/i);
|
||||||
|
} finally {
|
||||||
|
if (fakeMpv.exitCode === null) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
fakeMpv.once('close', () => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
});
|
||||||
if (fakeMpv.exitCode === null) {
|
},
|
||||||
await new Promise<void>((resolve) => {
|
);
|
||||||
fakeMpv.once('close', () => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
|
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
|
||||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||||
const env = makeTestEnv(smokeCase);
|
const env = makeTestEnv(smokeCase);
|
||||||
@@ -484,13 +483,8 @@ test(
|
|||||||
const appStartEntries = readJsonLines(appStartPath);
|
const appStartEntries = readJsonLines(appStartPath);
|
||||||
const appStopEntries = readJsonLines(appStopPath);
|
const appStopEntries = readJsonLines(appStopPath);
|
||||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||||
const mpvError = mpvEntries.find(
|
|
||||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
|
||||||
)?.error;
|
|
||||||
const unixSocketDenied =
|
|
||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
assert.equal(result.status, 0);
|
||||||
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
assert.match(result.stdout, /Starting SubMiner overlay/i);
|
||||||
|
|
||||||
assert.equal(appStartEntries.length, 1);
|
assert.equal(appStartEntries.length, 1);
|
||||||
@@ -523,7 +517,7 @@ test(
|
|||||||
|
|
||||||
test(
|
test(
|
||||||
'launcher start-overlay attaches to a running background app without spawning another app start command',
|
'launcher start-overlay attaches to a running background app without spawning another app start command',
|
||||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||||
const controlServer = await startFakeControlServer(smokeCase);
|
const controlServer = await startFakeControlServer(smokeCase);
|
||||||
@@ -550,13 +544,8 @@ test(
|
|||||||
const appStopEntries = readJsonLines(appStopPath);
|
const appStopEntries = readJsonLines(appStopPath);
|
||||||
const controlEntries = readJsonLines(controlServer.logPath);
|
const controlEntries = readJsonLines(controlServer.logPath);
|
||||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||||
const mpvError = mpvEntries.find(
|
|
||||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
|
||||||
)?.error;
|
|
||||||
const unixSocketDenied =
|
|
||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
assert.equal(result.status, 0);
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
assert.equal(appEntries.length > 0, true);
|
assert.equal(appEntries.length > 0, true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -583,7 +572,7 @@ test(
|
|||||||
|
|
||||||
test(
|
test(
|
||||||
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
||||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS, skip: skipWithoutUnixSockets },
|
||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -607,14 +596,9 @@ test(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||||
const mpvError = mpvEntries.find(
|
|
||||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
|
||||||
)?.error;
|
|
||||||
const unixSocketDenied =
|
|
||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
|
||||||
const mpvFirstArgs = mpvEntries[0]?.argv;
|
const mpvFirstArgs = mpvEntries[0]?.argv;
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(Array.isArray(mpvFirstArgs), true);
|
assert.equal(Array.isArray(mpvFirstArgs), true);
|
||||||
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
|
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { parseArgs } from '../config.js';
|
||||||
|
import type { Args } from '../types.js';
|
||||||
|
|
||||||
|
/** Default launcher `Args` (as if run with no CLI input), with `overrides` applied. */
|
||||||
|
export function makeLauncherArgs(overrides: Partial<Args> = {}): Args {
|
||||||
|
return { ...parseArgs([], 'subminer', {}), ...overrides };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Runs `run` with the given process env values (`undefined` deletes a key) and restores the
|
||||||
|
* originals afterwards, even if `run` throws or rejects.
|
||||||
|
*/
|
||||||
|
export async function withEnv<T>(
|
||||||
|
overrides: Record<string, string | undefined>,
|
||||||
|
run: () => T | Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const originals = Object.fromEntries(
|
||||||
|
Object.keys(overrides).map((key) => [key, process.env[key]]),
|
||||||
|
);
|
||||||
|
const apply = (values: Record<string, string | undefined>): void => {
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apply(overrides);
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
apply(originals);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Database as BunDatabase } from 'bun:sqlite';
|
import { Database as BunDatabase } from 'bun:sqlite';
|
||||||
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
|
import { ensureSchema } from '../../src/core/services/immersion-tracker/storage.js';
|
||||||
import { createImmersionDbFixture, insertFixtureSession } from './immersion-db-fixture.js';
|
import { createImmersionDbFixture } from './immersion-db-fixture.js';
|
||||||
|
|
||||||
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
|
type SchemaRow = { type: string; name: string; tbl_name: string; sql: string | null };
|
||||||
|
|
||||||
@@ -108,65 +108,3 @@ test('fixture schema stays aligned with production sync-touched tables and index
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fixture leaves lexical rollups pending when their table is absent', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-rollup-state-'));
|
|
||||||
const fixturePath = path.join(dir, 'fixture.sqlite');
|
|
||||||
try {
|
|
||||||
createImmersionDbFixture(fixturePath);
|
|
||||||
const db = new BunDatabase(fixturePath, { readonly: true });
|
|
||||||
try {
|
|
||||||
const state = db
|
|
||||||
.query<{ state_value: string }>(
|
|
||||||
`SELECT state_value FROM imm_rollup_state
|
|
||||||
WHERE state_key = 'lexical_daily_rollups_version'`,
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
const rollupTable = db
|
|
||||||
.query<{ name: string }>(
|
|
||||||
`SELECT name FROM sqlite_schema
|
|
||||||
WHERE type = 'table' AND name = 'imm_lexical_daily_rollups'`,
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
assert.equal(state?.state_value, '0');
|
|
||||||
assert.equal(rollupTable, null);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fixture session inserts enforce foreign keys', () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-'));
|
|
||||||
const fixturePath = path.join(dir, 'fixture.sqlite');
|
|
||||||
try {
|
|
||||||
createImmersionDbFixture(fixturePath);
|
|
||||||
const db = new BunDatabase(fixturePath, { readwrite: true });
|
|
||||||
try {
|
|
||||||
db.run(`
|
|
||||||
CREATE TRIGGER remove_fixture_video
|
|
||||||
BEFORE INSERT ON imm_sessions
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM imm_videos WHERE video_id = NEW.video_id;
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
insertFixtureSession(fixturePath, {
|
|
||||||
uuid: 'foreign-key-check',
|
|
||||||
videoKey: 'foreign-key-check',
|
|
||||||
startedAtMs: Date.UTC(2026, 6, 9),
|
|
||||||
}),
|
|
||||||
/FOREIGN KEY constraint failed/,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+889
-1373
File diff suppressed because it is too large.
Load diff
@@ -1,26 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
const source = readFileSync('scripts/compare-yomitan-api.ts', 'utf8');
|
|
||||||
|
|
||||||
test('creates the configured Yomitan user-data directory before Electron uses it', () => {
|
|
||||||
const setPathIndex = source.indexOf(
|
|
||||||
"electronModule.app.setPath('userData', options.yomitanUserDataPath)",
|
|
||||||
);
|
|
||||||
assert.notEqual(setPathIndex, -1);
|
|
||||||
|
|
||||||
const mkdirIndex = source.lastIndexOf(
|
|
||||||
'fs.mkdirSync(options.yomitanUserDataPath, { recursive: true })',
|
|
||||||
setPathIndex,
|
|
||||||
);
|
|
||||||
assert.ok(mkdirIndex > -1, 'must create the user-data directory');
|
|
||||||
assert.ok(mkdirIndex < setPathIndex, 'must create the directory before app.setPath');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lets output drain by using exitCode in the top-level completion handlers', () => {
|
|
||||||
const completionHandlers = source.slice(source.lastIndexOf('\nmain()'));
|
|
||||||
assert.match(completionHandlers, /process\.exitCode = process\.exitCode \?\? 0/);
|
|
||||||
assert.match(completionHandlers, /process\.exitCode = 1/);
|
|
||||||
assert.doesNotMatch(completionHandlers, /process\.exit\(/);
|
|
||||||
});
|
|
||||||
@@ -21,6 +21,7 @@ const requiredDocs = [
|
|||||||
'docs/workflow/README.md',
|
'docs/workflow/README.md',
|
||||||
'docs/workflow/agent-skills.md',
|
'docs/workflow/agent-skills.md',
|
||||||
'docs/workflow/planning.md',
|
'docs/workflow/planning.md',
|
||||||
|
'docs/workflow/testing.md',
|
||||||
'docs/workflow/verification.md',
|
'docs/workflow/verification.md',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
|
|
||||||
|
|
||||||
test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
|
|
||||||
const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
|
|
||||||
assert.notEqual(minimizedAssignmentIndex, -1);
|
|
||||||
|
|
||||||
const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
|
|
||||||
assert.notEqual(loopStartIndex, -1);
|
|
||||||
|
|
||||||
const pidExtractionIndex = source.indexOf(
|
|
||||||
'AXUIElementGetPid(window, &windowPid)',
|
|
||||||
loopStartIndex,
|
|
||||||
);
|
|
||||||
const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
|
|
||||||
const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
|
|
||||||
'window PID must be extracted before accepting a minimized window',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
|
|
||||||
'window PID must match the owning app before accepting a minimized window',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
|
|
||||||
'target socket must be validated before accepting a minimized window',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('focused mpv window follows the frontmost mpv app signal', () => {
|
|
||||||
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
|
|
||||||
assert.notEqual(focusHelperIndex, -1);
|
|
||||||
|
|
||||||
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
|
|
||||||
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
focusHelperBody.includes('frontmost.pid == ownerPid'),
|
|
||||||
'matching frontmost PID should mark the mpv window focused',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
|
|
||||||
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
|
|
||||||
'Accessibility path should use the shared focused mpv helper',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
|
|
||||||
'CoreGraphics path should use the shared focused mpv helper',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('frontmost mpv app emits active state when geometry lookup misses', () => {
|
|
||||||
assert.ok(
|
|
||||||
/case\s+\.active:/.test(source),
|
|
||||||
'helper should expose an active state without window geometry',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('if windowHasTargetSocket(frontmost.pid)'),
|
|
||||||
'active state should still accept a matching target socket when available',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('return targetMpvSocketPath != nil'),
|
|
||||||
'active state should preserve frontmost mpv even if command-line socket detection fails',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('return .active'),
|
|
||||||
'lookup should preserve active mpv state after geometry lookup misses',
|
|
||||||
);
|
|
||||||
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
|
|
||||||
assert.ok(
|
|
||||||
/case\s+\.inactive:/.test(source),
|
|
||||||
'helper should expose an inactive state without window geometry',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('if frontmost != nil'),
|
|
||||||
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
|
|
||||||
);
|
|
||||||
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
|
|
||||||
assert.ok(
|
|
||||||
source.includes('print("inactive")'),
|
|
||||||
'inactive state should be printed for the tracker',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
|
|
||||||
function createWorkspace(name: string): string {
|
|
||||||
const baseDir = path.join(process.cwd(), '.tmp', 'get-frequency-typecheck-test');
|
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
|
||||||
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
|
|
||||||
}
|
|
||||||
|
|
||||||
test('scripts/get_frequency.ts typechecks in isolation', () => {
|
|
||||||
const workspace = createWorkspace('isolated-script');
|
|
||||||
const tsconfigPath = path.join(workspace, 'tsconfig.json');
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
tsconfigPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
extends: '../../../tsconfig.typecheck.json',
|
|
||||||
include: ['../../../scripts/get_frequency.ts'],
|
|
||||||
exclude: [],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync('bunx', ['tsc', '--noEmit', '-p', tsconfigPath], {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
result.status,
|
|
||||||
0,
|
|
||||||
`expected scripts/get_frequency.ts to typecheck\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -39,24 +39,6 @@ describe('Bun corresponding-source manifest', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pins the source to the revision reported by the distributed binary', async () => {
|
|
||||||
const manifest = parseSourceManifest(
|
|
||||||
JSON.parse(
|
|
||||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(manifest.version).toBe('1.3.5');
|
|
||||||
expect(manifest.bunRevision).toBe('1e86cebd74a5723e818b5c0555276b646bcf0e4c');
|
|
||||||
expect(manifest.releaseTagCommit).toBe('fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab');
|
|
||||||
expect(manifest.sources.find((source) => source.name === 'WebKit')?.revision).toBe(
|
|
||||||
'6d0f3aac0b817cc01a846b3754b21271adedac12',
|
|
||||||
);
|
|
||||||
expect(manifest.sources.find((source) => source.name === 'tinycc')?.revision).toBe(
|
|
||||||
'29985a3b59898861442fa3b43f663fc1af2591d7',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parses commit and tag registrations from Bun CMake', () => {
|
test('parses commit and tag registrations from Bun CMake', () => {
|
||||||
const registrations = parseRegisteredRepositories(`
|
const registrations = parseRegisteredRepositories(`
|
||||||
register_repository(
|
register_repository(
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
|
||||||
const packageJson = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
|
|
||||||
) as { scripts: Record<string, string> };
|
|
||||||
const command = packageJson.scripts['build:syncui'] ?? '';
|
|
||||||
|
|
||||||
assert.match(command, /src\/preload-syncui\.ts/);
|
|
||||||
assert.match(command, /--bundle/);
|
|
||||||
assert.match(command, /--external:electron/);
|
|
||||||
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
|
|
||||||
|
|
||||||
test('macOS helper build creates dist scripts directory before swiftc output', () => {
|
|
||||||
const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
|
|
||||||
assert.notEqual(buildFunctionIndex, -1);
|
|
||||||
|
|
||||||
const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex);
|
|
||||||
assert.notEqual(swiftcIndex, -1);
|
|
||||||
|
|
||||||
const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
ensureDirIndex > buildFunctionIndex,
|
|
||||||
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
|
||||||
// version as the helper's minimum, so released builds refuse to load on older macOS.
|
|
||||||
test('macOS helper is compiled with an explicit deployment target', () => {
|
|
||||||
assert.match(source, /-target/);
|
|
||||||
assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/);
|
|
||||||
});
|
|
||||||
@@ -75,7 +75,7 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
|
|||||||
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
|
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runCoverageLane returns a failure when a discovered test fails', () => {
|
test('runCoverageLane returns a failure when a discovered test fails', async () => {
|
||||||
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
|
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
|
||||||
try {
|
try {
|
||||||
mkdirSync(join(repoRoot, 'src'));
|
mkdirSync(join(repoRoot, 'src'));
|
||||||
@@ -93,10 +93,10 @@ test('runCoverageLane returns a failure when a discovered test fails', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.notEqual(
|
assert.notEqual(
|
||||||
runCoverageLane({
|
await runCoverageLane({
|
||||||
repoRootDir: repoRoot,
|
repoRootDir: repoRoot,
|
||||||
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
|
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
|
||||||
stdio: 'pipe',
|
quiet: true,
|
||||||
}),
|
}),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import { availableParallelism } from 'node:os';
|
||||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
import { collectLaneFiles } from './test-lanes';
|
import { collectLaneFiles } from './test-lanes';
|
||||||
|
|
||||||
@@ -201,9 +202,40 @@ export function mergeLcovReports(reports: string[]): string {
|
|||||||
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runCoverageLane(
|
type ShardResult = { status: number; output: string; lcov: string | null };
|
||||||
options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
|
|
||||||
): number {
|
// Runs one test file under coverage into its own shard directory, buffering
|
||||||
|
// output so parallel shards do not interleave on the terminal.
|
||||||
|
function runShard(repoRootDir: string, file: string, shardDir: string): Promise<ShardResult> {
|
||||||
|
return new Promise((resolveShard) => {
|
||||||
|
const child = spawn(
|
||||||
|
'bun',
|
||||||
|
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
|
||||||
|
{ cwd: repoRootDir },
|
||||||
|
);
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', (chunk) => (output += chunk));
|
||||||
|
child.stderr.on('data', (chunk) => (output += chunk));
|
||||||
|
child.on('error', (error) => resolveShard({ status: 1, output: String(error), lcov: null }));
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const lcovPath = join(shardDir, 'lcov.info');
|
||||||
|
const lcov = existsSync(lcovPath) ? readFileSync(lcovPath, 'utf8') : null;
|
||||||
|
resolveShard({ status: code ?? 1, output, lcov });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJobsArg(argv: string[]): number {
|
||||||
|
const index = argv.indexOf('--jobs');
|
||||||
|
if (index === -1) return availableParallelism();
|
||||||
|
return Math.max(1, Number(argv[index + 1]) || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shards run in parallel (one per CPU unless --jobs N). The first failing shard
|
||||||
|
// stops scheduling new shards and its exit status becomes the lane result.
|
||||||
|
export async function runCoverageLane(
|
||||||
|
options: { repoRootDir?: string; argv?: string[]; quiet?: boolean } = {},
|
||||||
|
): Promise<number> {
|
||||||
const repoRootDir = options.repoRootDir ?? repoRoot;
|
const repoRootDir = options.repoRootDir ?? repoRoot;
|
||||||
const argv = options.argv ?? process.argv.slice(2);
|
const argv = options.argv ?? process.argv.slice(2);
|
||||||
const laneName = argv[0];
|
const laneName = argv[0];
|
||||||
@@ -225,40 +257,44 @@ export function runCoverageLane(
|
|||||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const reports: string[] = [];
|
|
||||||
|
const reports: Array<string | null> = new Array(files.length).fill(null);
|
||||||
|
const failures: Array<{ file: string; result: ShardResult }> = [];
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
while (failures.length === 0 && nextIndex < files.length) {
|
||||||
|
const index = nextIndex++;
|
||||||
|
const file = files[index]!;
|
||||||
|
const shardDir = join(shardRoot, String(index + 1).padStart(3, '0'));
|
||||||
|
const result = await runShard(repoRootDir, file, shardDir);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
failures.push({ file, result });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.lcov === null && !options.quiet) {
|
||||||
|
process.stdout.write(`Skipping empty coverage shard for ${file}\n`);
|
||||||
|
}
|
||||||
|
reports[index] = result.lcov;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const [index, file] of files.entries()) {
|
const jobs = Math.min(parseJobsArg(argv), files.length);
|
||||||
const shardDir = join(shardRoot, `${String(index + 1).padStart(3, '0')}`);
|
await Promise.all(Array.from({ length: jobs }, worker));
|
||||||
const result = spawnSync(
|
|
||||||
'bun',
|
|
||||||
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
|
|
||||||
{
|
|
||||||
cwd: repoRootDir,
|
|
||||||
stdio: options.stdio ?? 'inherit',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.error) {
|
const [failure] = failures;
|
||||||
throw result.error;
|
if (failure !== undefined) {
|
||||||
|
const { file, result } = failure;
|
||||||
|
if (!options.quiet) {
|
||||||
|
process.stderr.write(`${result.output}\nCoverage shard failed: ${file}\n`);
|
||||||
}
|
}
|
||||||
if ((result.status ?? 1) !== 0) {
|
return result.status;
|
||||||
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');
|
const lcovPath = join(coverageDir, 'lcov.info');
|
||||||
process.stdout.write(
|
writeFileSync(lcovPath, mergeLcovReports(reports.filter((r) => r !== null)), 'utf8');
|
||||||
`Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
|
process.stdout.write(`Merged LCOV written to ${relative(repoRootDir, lcovPath)}\n`);
|
||||||
);
|
|
||||||
return 0;
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(shardRoot, { recursive: true, force: true });
|
rmSync(shardRoot, { recursive: true, force: true });
|
||||||
@@ -267,5 +303,5 @@ export function runCoverageLane(
|
|||||||
|
|
||||||
// @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
|
// @ts-ignore Bun entrypoint detection; TS config for scripts still targets CommonJS.
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
process.exit(runCoverageLane());
|
runCoverageLane().then((status) => process.exit(status));
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
import { availableParallelism } from 'node:os';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { collectLaneFiles } from './test-lanes.ts';
|
import { collectLaneFiles } from './test-lanes.ts';
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ import { collectLaneFiles } from './test-lanes.ts';
|
|||||||
// test file so a hanging test or leaked global in one file cannot poison the
|
// test file so a hanging test or leaked global in one file cannot poison the
|
||||||
// rest of the lane. Use --single-process for the old all-in-one-process mode.
|
// rest of the lane. Use --single-process for the old all-in-one-process mode.
|
||||||
//
|
//
|
||||||
|
// Files run in parallel, one worker per CPU by default; --jobs 1 runs serially.
|
||||||
|
//
|
||||||
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
|
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
|
||||||
|
|
||||||
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||||
@@ -34,7 +37,12 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
|
const options = {
|
||||||
|
lane: undefined,
|
||||||
|
jobs: availableParallelism(),
|
||||||
|
timeoutSecs: 300,
|
||||||
|
singleProcess: false,
|
||||||
|
};
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (arg === '--jobs') {
|
if (arg === '--jobs') {
|
||||||
|
|||||||
@@ -61,15 +61,6 @@ test('normalizeTarget maps each supported electron-builder target without using
|
|||||||
assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/);
|
assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveArtifact chooses baseline x64 builds and standard arm64 builds', () => {
|
|
||||||
const config = runtimeConfig();
|
|
||||||
assert.equal(resolveArtifact(config, 'linux', 'x64').file, 'bun-linux-x64-baseline.zip');
|
|
||||||
assert.equal(resolveArtifact(config, 'darwin', 'x64').file, 'bun-darwin-x64-baseline.zip');
|
|
||||||
assert.equal(resolveArtifact(config, 'win32', 'x64').file, 'bun-windows-x64-baseline.zip');
|
|
||||||
assert.equal(resolveArtifact(config, 'linux', 'arm64').file, 'bun-linux-aarch64.zip');
|
|
||||||
assert.equal(resolveArtifact(config, 'darwin', 'arm64').file, 'bun-darwin-aarch64.zip');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tracked runtime manifest covers every supported target at the packageManager version', async () => {
|
test('tracked runtime manifest covers every supported target at the packageManager version', async () => {
|
||||||
const config = await loadRuntimeConfig();
|
const config = await loadRuntimeConfig();
|
||||||
assert.equal(config.version, '1.3.5');
|
assert.equal(config.version, '1.3.5');
|
||||||
|
|||||||
@@ -3,20 +3,6 @@ import assert from 'node:assert/strict';
|
|||||||
import { AnkiConnectClient } from './anki-connect';
|
import { AnkiConnectClient } from './anki-connect';
|
||||||
import { setLogLevel } from './logger';
|
import { setLogLevel } from './logger';
|
||||||
|
|
||||||
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
|
|
||||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
|
||||||
client: {
|
|
||||||
defaults: {
|
|
||||||
httpAgent?: { options?: { keepAlive?: boolean } };
|
|
||||||
httpsAgent?: { options?: { keepAlive?: boolean } };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
|
|
||||||
assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('AnkiConnectClient includes action name in retry logs', async () => {
|
test('AnkiConnectClient includes action name in retry logs', async () => {
|
||||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||||
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
|
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
|
||||||
|
|||||||
+612
-1235
File diff suppressed because it is too large.
Load diff
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import {
|
||||||
|
type CardCreationDeps,
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
createAnkiConfig,
|
||||||
|
createCardCreationDeps,
|
||||||
|
createFakeClient,
|
||||||
|
createFakeMediaGenerator,
|
||||||
|
} from './card-creation-test-utils';
|
||||||
|
|
||||||
test('sentence card writes generated audio only to sentence audio field', async () => {
|
test('sentence card writes generated audio only to sentence audio field', async () => {
|
||||||
const addedFields: Record<string, string>[] = [];
|
const addedFields: Record<string, string>[] = [];
|
||||||
@@ -13,128 +17,88 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
const requestedProperties: string[] = [];
|
const requestedProperties: string[] = [];
|
||||||
const audioVolumeScales: Array<number | undefined> = [];
|
const audioVolumeScales: Array<number | undefined> = [];
|
||||||
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
||||||
|
let review: Awaited<ReturnType<NonNullable<CardCreationDeps['reviewMediaTiming']>>> = {
|
||||||
const deps: CardCreationDeps = {
|
action: 'confirm',
|
||||||
getConfig: () =>
|
startTime: 11.4,
|
||||||
({
|
endTime: 14.2,
|
||||||
deck: 'Mining',
|
|
||||||
fields: {
|
|
||||||
word: 'Expression',
|
|
||||||
sentence: 'Sentence',
|
|
||||||
audio: 'ExpressionAudio',
|
|
||||||
translation: 'SelectionText',
|
|
||||||
},
|
|
||||||
media: {
|
|
||||||
generateAudio: true,
|
|
||||||
generateImage: false,
|
|
||||||
mirrorMpvVolume: true,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
},
|
|
||||||
behavior: {},
|
|
||||||
ai: false,
|
|
||||||
}) as AnkiConnectConfig,
|
|
||||||
getAiConfig: () => ({}),
|
|
||||||
getTimingTracker: () => ({}) as never,
|
|
||||||
getMpvClient: () =>
|
|
||||||
({
|
|
||||||
currentVideoPath: '/video.mp4',
|
|
||||||
currentSubText: '字幕',
|
|
||||||
currentSubStart: 12,
|
|
||||||
currentSubEnd: 14,
|
|
||||||
currentTimePos: 13,
|
|
||||||
currentAudioStreamIndex: 0,
|
|
||||||
requestProperty: async (name: string) => {
|
|
||||||
requestedProperties.push(name);
|
|
||||||
return 40;
|
|
||||||
},
|
|
||||||
}) as never,
|
|
||||||
client: {
|
|
||||||
addNote: async (_deck, _modelName, fields) => {
|
|
||||||
addedFields.push(fields);
|
|
||||||
return 42;
|
|
||||||
},
|
|
||||||
addTags: async () => undefined,
|
|
||||||
notesInfo: async () => [
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: {
|
|
||||||
Expression: { value: '字幕' },
|
|
||||||
Sentence: { value: '字幕' },
|
|
||||||
SelectionText: { value: 'Subtitle' },
|
|
||||||
ExpressionAudio: { value: '' },
|
|
||||||
SentenceAudio: { value: '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
updateNoteFields: async (_noteId, fields) => {
|
|
||||||
updatedFields.push(fields);
|
|
||||||
},
|
|
||||||
storeMediaFile: async (filename) => {
|
|
||||||
storedMedia.push(filename);
|
|
||||||
},
|
|
||||||
findNotes: async () => [],
|
|
||||||
retrieveMediaFile: async () => '',
|
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
|
||||||
mediaGenerator: {
|
|
||||||
generateAudio: async (
|
|
||||||
_path,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
audioPadding,
|
|
||||||
_audioStreamIndex,
|
|
||||||
_normalizeAudio,
|
|
||||||
volumeScale,
|
|
||||||
) => {
|
|
||||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
|
||||||
audioVolumeScales.push(volumeScale);
|
|
||||||
return Buffer.from('audio');
|
|
||||||
},
|
|
||||||
generateScreenshot: async () => null,
|
|
||||||
generateAnimatedImage: async () => null,
|
|
||||||
},
|
|
||||||
showOsdNotification: () => undefined,
|
|
||||||
showUpdateResult: () => undefined,
|
|
||||||
showStatusNotification: () => undefined,
|
|
||||||
showNotification: async () => undefined,
|
|
||||||
beginUpdateProgress: () => undefined,
|
|
||||||
endUpdateProgress: () => undefined,
|
|
||||||
withUpdateProgress: async (_message, action) => action(),
|
|
||||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
|
||||||
for (const preferredName of preferredNames) {
|
|
||||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
|
||||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
|
||||||
getAnimatedImageLeadInSeconds: async () => 0,
|
|
||||||
extractFields: () => ({}),
|
|
||||||
processSentence: (sentence) => sentence,
|
|
||||||
setCardTypeFields: () => undefined,
|
|
||||||
mergeFieldValue: (_existing, newValue) => newValue,
|
|
||||||
formatMiscInfoPattern: () => '',
|
|
||||||
getEffectiveSentenceCardConfig: () => ({
|
|
||||||
model: 'Sentence',
|
|
||||||
sentenceField: 'Sentence',
|
|
||||||
audioField: 'SentenceAudio',
|
|
||||||
lapisEnabled: true,
|
|
||||||
kikuEnabled: false,
|
|
||||||
fieldGroupingMode: 'disabled',
|
|
||||||
}),
|
|
||||||
getFallbackDurationSeconds: () => 10,
|
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
|
||||||
setUpdateInProgress: () => undefined,
|
|
||||||
trackLastAddedNoteId: () => undefined,
|
|
||||||
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new CardCreationService(deps);
|
const service = new CardCreationService(
|
||||||
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
createCardCreationDeps({
|
||||||
|
getConfig: () =>
|
||||||
|
createAnkiConfig({
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
audio: 'ExpressionAudio',
|
||||||
|
translation: 'SelectionText',
|
||||||
|
},
|
||||||
|
media: { generateAudio: true, mirrorMpvVolume: true, maxMediaDuration: 30 },
|
||||||
|
}),
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
currentVideoPath: '/video.mp4',
|
||||||
|
currentSubText: '字幕',
|
||||||
|
currentSubStart: 12,
|
||||||
|
currentSubEnd: 14,
|
||||||
|
currentTimePos: 13,
|
||||||
|
currentAudioStreamIndex: 0,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
requestedProperties.push(name);
|
||||||
|
return 40;
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
client: createFakeClient({
|
||||||
|
addNote: async (_deck, _modelName, fields) => {
|
||||||
|
addedFields.push(fields);
|
||||||
|
return 42;
|
||||||
|
},
|
||||||
|
notesInfo: async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '字幕' },
|
||||||
|
Sentence: { value: '字幕' },
|
||||||
|
SelectionText: { value: 'Subtitle' },
|
||||||
|
ExpressionAudio: { value: '' },
|
||||||
|
SentenceAudio: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateNoteFields: async (_noteId, fields) => {
|
||||||
|
updatedFields.push(fields);
|
||||||
|
},
|
||||||
|
storeMediaFile: async (filename) => {
|
||||||
|
storedMedia.push(filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mediaGenerator: createFakeMediaGenerator({
|
||||||
|
generateAudio: async (
|
||||||
|
_path,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
audioPadding,
|
||||||
|
_audioStreamIndex,
|
||||||
|
_normalizeAudio,
|
||||||
|
volumeScale,
|
||||||
|
) => {
|
||||||
|
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||||
|
audioVolumeScales.push(volumeScale);
|
||||||
|
return Buffer.from('audio');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
|
model: 'Sentence',
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
audioField: 'SentenceAudio',
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
fieldGroupingMode: 'disabled',
|
||||||
|
}),
|
||||||
|
reviewMediaTiming: async () => review,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(created, true);
|
assert.equal(await service.createSentenceCard('字幕', 12, 14, 'Subtitle'), true);
|
||||||
assert.deepEqual(addedFields[0], {
|
assert.deepEqual(addedFields[0], {
|
||||||
Sentence: '字幕',
|
Sentence: '字幕',
|
||||||
SelectionText: 'Subtitle',
|
SelectionText: 'Subtitle',
|
||||||
@@ -149,11 +113,11 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||||
|
|
||||||
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
review = { action: 'discard' };
|
||||||
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||||
assert.equal(addedFields.length, 1);
|
assert.equal(addedFields.length, 1);
|
||||||
|
|
||||||
deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
|
review = { action: 'skip-media' };
|
||||||
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
|
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
|
||||||
assert.equal(addedFields.length, 2);
|
assert.equal(addedFields.length, 2);
|
||||||
assert.equal(storedMedia.length, 1);
|
assert.equal(storedMedia.length, 1);
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { CardCreationService } from './card-creation';
|
||||||
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
|
||||||
|
export type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
|
/** Builds the minimal config the service reads; pass only the sections a test cares about. */
|
||||||
|
export function createAnkiConfig(
|
||||||
|
overrides: {
|
||||||
|
fields?: Record<string, string>;
|
||||||
|
media?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
): AnkiConnectConfig {
|
||||||
|
return {
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: { sentence: 'Sentence', audio: 'SentenceAudio', ...overrides.fields },
|
||||||
|
media: { generateAudio: false, generateImage: false, ...overrides.media },
|
||||||
|
behavior: {},
|
||||||
|
ai: false,
|
||||||
|
} as AnkiConnectConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake CardCreationService dependencies: a bare mpv session, a client that adds note 42, no media,
|
||||||
|
* and field resolvers that match exact field names. Override only what the test exercises.
|
||||||
|
*/
|
||||||
|
export function createCardCreationDeps(
|
||||||
|
overrides: Partial<CardCreationDeps> = {},
|
||||||
|
): CardCreationDeps {
|
||||||
|
return {
|
||||||
|
getConfig: () => createAnkiConfig(),
|
||||||
|
getAiConfig: () => ({}),
|
||||||
|
getTimingTracker: () => ({}) as never,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
currentVideoPath: '/video.mp4',
|
||||||
|
currentSubText: '字幕',
|
||||||
|
currentSubStart: 1,
|
||||||
|
currentSubEnd: 2,
|
||||||
|
currentTimePos: 1.5,
|
||||||
|
currentAudioStreamIndex: 0,
|
||||||
|
}) as never,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 42,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [],
|
||||||
|
updateNoteFields: async () => undefined,
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
findNotes: async () => [],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
|
},
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
},
|
||||||
|
showOsdNotification: () => undefined,
|
||||||
|
showUpdateResult: () => undefined,
|
||||||
|
showStatusNotification: () => undefined,
|
||||||
|
showNotification: async () => undefined,
|
||||||
|
beginUpdateProgress: () => undefined,
|
||||||
|
endUpdateProgress: () => undefined,
|
||||||
|
withUpdateProgress: async (_message, action) => action(),
|
||||||
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||||
|
for (const preferredName of preferredNames) {
|
||||||
|
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||||
|
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||||
|
getAnimatedImageLeadInSeconds: async () => 0,
|
||||||
|
extractFields: () => ({}),
|
||||||
|
processSentence: (sentence) => sentence,
|
||||||
|
setCardTypeFields: () => undefined,
|
||||||
|
mergeFieldValue: (_existing, newValue) => newValue,
|
||||||
|
formatMiscInfoPattern: () => '',
|
||||||
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
|
model: 'Sentence',
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
audioField: 'SentenceAudio',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: false,
|
||||||
|
fieldGroupingMode: 'disabled',
|
||||||
|
}),
|
||||||
|
getFallbackDurationSeconds: () => 10,
|
||||||
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
|
isUpdateInProgress: () => false,
|
||||||
|
setUpdateInProgress: () => undefined,
|
||||||
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anki client overrides, kept separate so a test can swap one method without redeclaring the rest. */
|
||||||
|
export function createFakeClient(
|
||||||
|
overrides: Partial<CardCreationDeps['client']> = {},
|
||||||
|
): CardCreationDeps['client'] {
|
||||||
|
return { ...createCardCreationDeps().client, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFakeMediaGenerator(
|
||||||
|
overrides: Partial<CardCreationDeps['mediaGenerator']> = {},
|
||||||
|
): CardCreationDeps['mediaGenerator'] {
|
||||||
|
return { ...createCardCreationDeps().mediaGenerator, ...overrides };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -500,3 +500,38 @@ test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty
|
|||||||
|
|
||||||
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
|
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('computeFieldGroupingMergedFields keeps independent groups for identical sentence, audio, and image values', async () => {
|
||||||
|
const { collaborator } = createCollaborator();
|
||||||
|
|
||||||
|
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||||
|
202,
|
||||||
|
101,
|
||||||
|
makeNote(202, {
|
||||||
|
Sentence: 'same sentence',
|
||||||
|
SentenceAudio: '[sound:same.mp3]',
|
||||||
|
Picture: '<img src="same.png">',
|
||||||
|
ExpressionAudio: '[sound:same.mp3]',
|
||||||
|
}),
|
||||||
|
makeNote(101, {
|
||||||
|
Sentence: 'same sentence',
|
||||||
|
SentenceAudio: '[sound:same.mp3]',
|
||||||
|
Picture: '<img src="same.png">',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
merged.Sentence,
|
||||||
|
'<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
merged.SentenceAudio,
|
||||||
|
'<span data-group-id="202">[sound:same.mp3]</span><span data-group-id="101">[sound:same.mp3]</span>',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
merged.Picture,
|
||||||
|
'<img data-group-id="202" src="same.png"><img data-group-id="101" src="same.png">',
|
||||||
|
);
|
||||||
|
assert.equal('ExpressionAudio' in merged, false);
|
||||||
|
});
|
||||||
@@ -127,18 +127,6 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a cache built under the old tier rules is invalidated', () => {
|
|
||||||
const config: AnkiConnectConfig = {
|
|
||||||
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
|
|
||||||
};
|
|
||||||
// v1 rules put lapsed cards in young because the interval queries did not
|
|
||||||
// exclude is:learn; those persisted tiers must not be served under v2.
|
|
||||||
assert.notEqual(
|
|
||||||
getKnownWordCacheLifecycleConfig(config),
|
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
|
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
|
||||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
|
|||||||
@@ -223,127 +223,6 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
|
||||||
sentenceField: 'Sentence',
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
fieldGroupingMode: 'manual',
|
|
||||||
wordCardKind: 'click',
|
|
||||||
});
|
|
||||||
harness.deps.client.notesInfo = async () =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: {
|
|
||||||
Expression: { value: 'taberu' },
|
|
||||||
Sentence: { value: '' },
|
|
||||||
IsWordAndSentenceCard: { value: 'x' },
|
|
||||||
IsClickCard: { value: '' },
|
|
||||||
IsSentenceCard: { value: '' },
|
|
||||||
IsAudioCard: { value: '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.equal(harness.updates.length, 1);
|
|
||||||
assert.deepEqual(harness.updates[0]?.fields, {
|
|
||||||
Sentence: 'subtitle-text',
|
|
||||||
IsClickCard: 'x',
|
|
||||||
IsWordAndSentenceCard: '',
|
|
||||||
IsSentenceCard: '',
|
|
||||||
IsAudioCard: '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
|
||||||
sentenceField: 'Sentence',
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
fieldGroupingMode: 'manual',
|
|
||||||
wordCardKind: 'none',
|
|
||||||
});
|
|
||||||
harness.deps.client.notesInfo = async () =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: {
|
|
||||||
Expression: { value: 'taberu' },
|
|
||||||
Sentence: { value: '' },
|
|
||||||
IsWordAndSentenceCard: { value: '' },
|
|
||||||
IsSentenceCard: { value: '' },
|
|
||||||
IsAudioCard: { value: '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.equal(harness.updates.length, 1);
|
|
||||||
assert.deepEqual(harness.updates[0]?.fields, {
|
|
||||||
Sentence: 'subtitle-text',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
harness.deps.client.notesInfo = async () =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: {
|
|
||||||
Expression: { value: 'taberu' },
|
|
||||||
Sentence: { value: '' },
|
|
||||||
IsWordAndSentenceCard: { value: '' },
|
|
||||||
IsSentenceCard: { value: '' },
|
|
||||||
IsAudioCard: { value: '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.equal(harness.updates.length, 1);
|
|
||||||
assert.deepEqual(harness.updates[0]?.fields, {
|
|
||||||
Sentence: 'subtitle-text',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
|
||||||
sentenceField: 'Sentence',
|
|
||||||
lapisEnabled: true,
|
|
||||||
kikuEnabled: false,
|
|
||||||
fieldGroupingMode: 'disabled',
|
|
||||||
});
|
|
||||||
harness.deps.client.notesInfo = async () =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: {
|
|
||||||
Expression: { value: 'sentence expression' },
|
|
||||||
Sentence: { value: '' },
|
|
||||||
IsWordAndSentenceCard: { value: '' },
|
|
||||||
IsSentenceCard: { value: 'x' },
|
|
||||||
IsAudioCard: { value: '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.equal(harness.updates.length, 1);
|
|
||||||
assert.deepEqual(harness.updates[0]?.fields, {
|
|
||||||
Sentence: 'subtitle-text',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () => [];
|
harness.deps.client.notesInfo = async () => [];
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
|
||||||
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
|
|
||||||
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
|
|
||||||
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
|
|
||||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
|
||||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
|
||||||
scripts: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
test('package scripts expose a sharded maintained source coverage lane with lcov output', () => {
|
|
||||||
assert.equal(
|
|
||||||
packageJson.scripts['test:coverage:src'],
|
|
||||||
'bun run build:yomitan && bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('source and coverage scripts discover the same maintained source lane', () => {
|
|
||||||
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
|
|
||||||
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
|
|
||||||
/run-coverage-lane\.ts\s+([^\s]+)/,
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
assert.equal(sourceLane, 'bun-src-full');
|
|
||||||
assert.equal(coverageLane, sourceLane);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('environment suite owns launcher smoke execution', () => {
|
|
||||||
assert.match(
|
|
||||||
packageJson.scripts['test:env'] ?? '',
|
|
||||||
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ci delegates its gate instead of duplicating quality steps', () => {
|
|
||||||
assert.match(
|
|
||||||
ciWorkflow,
|
|
||||||
/build-test-audit:\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(ciWorkflow, /oven-sh\/setup-bun/);
|
|
||||||
assert.doesNotMatch(ciWorkflow, /bun run test:coverage:src/);
|
|
||||||
assert.doesNotMatch(ciWorkflow, /bun run test:env/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
|
|
||||||
assert.match(docsPagesWorkflow, /name: Docs Pages/);
|
|
||||||
assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/);
|
|
||||||
assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/);
|
|
||||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
|
|
||||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
|
|
||||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
|
|
||||||
assert.match(docsPagesWorkflow, /pages deploy \.\.\/\.tmp\/docs-versioned-site/);
|
|
||||||
assert.match(docsPagesWorkflow, /--branch main/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs deploy syncs frozen archives to R2 and ships the archive Pages Function', () => {
|
|
||||||
assert.doesNotMatch(docsPagesWorkflow, /actions\/cache@/);
|
|
||||||
assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_ACCESS_KEY_ID/);
|
|
||||||
assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY/);
|
|
||||||
assert.match(docsPagesWorkflow, /--require-archives/);
|
|
||||||
assert.match(docsPagesWorkflow, /--rebuild-archives=\$\{REBUILD_ARCHIVES\}/);
|
|
||||||
assert.match(docsPagesWorkflow, /workingDirectory: docs-site/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs deploy skips invalid release tags without failing the workflow', () => {
|
|
||||||
assert.match(docsPagesWorkflow, /id:\s*tag_guard/);
|
|
||||||
assert.match(docsPagesWorkflow, /stable_tag=false/);
|
|
||||||
assert.doesNotMatch(docsPagesWorkflow, /exit 78/);
|
|
||||||
assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/);
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { printHelp } from './help';
|
|
||||||
|
|
||||||
test('printHelp includes configured texthooker port', () => {
|
|
||||||
const original = console.log;
|
|
||||||
let output = '';
|
|
||||||
console.log = (value?: unknown) => {
|
|
||||||
output += String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
printHelp(7777);
|
|
||||||
} finally {
|
|
||||||
console.log = original;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.match(output, /--help\s+Show this help/);
|
|
||||||
assert.match(output, /default: 7777/);
|
|
||||||
assert.match(output, /--launch-mpv.*Launch mpv with SubMiner defaults and exit/);
|
|
||||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
|
||||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
|
||||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
|
||||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
|
||||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
|
||||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
|
||||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
|
||||||
assert.match(output, /--anilist-status/);
|
|
||||||
assert.match(output, /--anilist-retry-queue/);
|
|
||||||
assert.match(output, /--dictionary/);
|
|
||||||
assert.match(output, /--dictionary-target/);
|
|
||||||
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
|
|
||||||
assert.match(output, /--jellyfin-login/);
|
|
||||||
assert.match(output, /--jellyfin-subtitles/);
|
|
||||||
assert.match(output, /--jellyfin-play/);
|
|
||||||
});
|
|
||||||
+103
-1004
File diff suppressed because it is too large.
Load diff
@@ -2,17 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
import {
|
import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, DEFAULT_CONFIG } from '../definitions';
|
||||||
CONFIG_OPTION_REGISTRY,
|
|
||||||
CONFIG_TEMPLATE_SECTIONS,
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
DEFAULT_KEYBINDINGS,
|
|
||||||
RUNTIME_OPTION_REGISTRY,
|
|
||||||
} from '../definitions';
|
|
||||||
import { buildCoreConfigOptionRegistry } from './options-core';
|
|
||||||
import { buildImmersionConfigOptionRegistry } from './options-immersion';
|
|
||||||
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
|
|
||||||
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
|
|
||||||
|
|
||||||
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||||
const leaves: string[] = [];
|
const leaves: string[] = [];
|
||||||
@@ -87,58 +77,6 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
|||||||
'subtitleStyle.wordSpacing',
|
'subtitleStyle.wordSpacing',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
test('config option registry includes critical paths and has unique entries', () => {
|
|
||||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
|
||||||
|
|
||||||
for (const requiredPath of [
|
|
||||||
'logging.level',
|
|
||||||
'logging.files.mpv',
|
|
||||||
'annotationWebsocket.enabled',
|
|
||||||
'controller.enabled',
|
|
||||||
'controller.scrollPixelsPerSecond',
|
|
||||||
'startupWarmups.lowPowerMode',
|
|
||||||
'updates.channel',
|
|
||||||
'youtube.primarySubLanguages',
|
|
||||||
'subtitleStyle.enableJlpt',
|
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
|
||||||
'ankiConnect.enabled',
|
|
||||||
'subtitleStyle.nameMatchEnabled',
|
|
||||||
'ankiConnect.media.normalizeAudio',
|
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
|
||||||
'mpv.executablePath',
|
|
||||||
'mpv.launchMode',
|
|
||||||
'mpv.profile',
|
|
||||||
'mpv.socketPath',
|
|
||||||
'mpv.backend',
|
|
||||||
'mpv.autoStartSubMiner',
|
|
||||||
'mpv.pauseUntilOverlayReady',
|
|
||||||
'mpv.subminerBinaryPath',
|
|
||||||
'mpv.aniskipEnabled',
|
|
||||||
'mpv.aniskipButtonKey',
|
|
||||||
'yomitan.externalProfilePath',
|
|
||||||
'immersionTracking.enabled',
|
|
||||||
]) {
|
|
||||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(new Set(paths).size, paths.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('known-word annotation color has one public config path', () => {
|
|
||||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
|
||||||
|
|
||||||
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
|
|
||||||
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('n+1 annotation color has one public config path', () => {
|
|
||||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
|
||||||
|
|
||||||
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
|
|
||||||
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||||
@@ -168,77 +106,10 @@ test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEA
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('config template sections include expected domains and unique keys', () => {
|
test('config option paths and template section keys are unique', () => {
|
||||||
|
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||||
|
assert.equal(new Set(paths).size, paths.length);
|
||||||
|
|
||||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||||
const requiredKeys: (typeof keys)[number][] = [
|
|
||||||
'websocket',
|
|
||||||
'annotationWebsocket',
|
|
||||||
'controller',
|
|
||||||
'startupWarmups',
|
|
||||||
'youtube',
|
|
||||||
'subtitleStyle',
|
|
||||||
'ankiConnect',
|
|
||||||
'yomitan',
|
|
||||||
'mpv',
|
|
||||||
'immersionTracking',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const requiredKey of requiredKeys) {
|
|
||||||
assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(new Set(keys).size, keys.length);
|
assert.equal(new Set(keys).size, keys.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('domain registry builders each contribute entries to composed registry', () => {
|
|
||||||
const domainEntries = [
|
|
||||||
buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
|
|
||||||
buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
|
|
||||||
buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
|
|
||||||
buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
|
|
||||||
];
|
|
||||||
const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
|
||||||
|
|
||||||
for (const entries of domainEntries) {
|
|
||||||
assert.ok(entries.length > 0);
|
|
||||||
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default keybindings include primary and secondary subtitle track cycling on J keys', () => {
|
|
||||||
const keybindingMap = new Map(
|
|
||||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
|
||||||
);
|
|
||||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
|
||||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
|
||||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
|
|
||||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default keybindings include fullscreen on F', () => {
|
|
||||||
const keybindingMap = new Map(
|
|
||||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
|
||||||
);
|
|
||||||
assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default keybindings include replay and next subtitle controls', () => {
|
|
||||||
const keybindingMap = new Map(
|
|
||||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
|
||||||
);
|
|
||||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
|
|
||||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('default keybindings mirror mpv subtitle delay and sub-step keys', () => {
|
|
||||||
const keybindingMap = new Map(
|
|
||||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
|
||||||
);
|
|
||||||
assert.deepEqual(keybindingMap.get('KeyZ'), ['add', 'sub-delay', -0.1]);
|
|
||||||
assert.deepEqual(keybindingMap.get('Shift+KeyZ'), ['add', 'sub-delay', 0.1]);
|
|
||||||
assert.deepEqual(keybindingMap.get('KeyX'), ['add', 'sub-delay', 0.1]);
|
|
||||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowLeft'), ['sub-step', -1]);
|
|
||||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+ArrowRight'), ['sub-step', 1]);
|
|
||||||
assert.equal(keybindingMap.has('Shift+BracketLeft'), false);
|
|
||||||
assert.equal(keybindingMap.has('Shift+BracketRight'), false);
|
|
||||||
});
|
|
||||||
@@ -11,209 +11,6 @@ function field(path: string) {
|
|||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('settings registry splits viewing into appearance and behavior categories', () => {
|
|
||||||
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
|
|
||||||
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
|
|
||||||
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
|
|
||||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior');
|
|
||||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior');
|
|
||||||
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
|
|
||||||
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
|
||||||
assert.equal(field('auto_start_overlay').category, 'behavior');
|
|
||||||
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
|
|
||||||
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
|
|
||||||
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
|
||||||
assert.equal(field('mpv.launchMode').category, 'behavior');
|
|
||||||
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
|
|
||||||
assert.equal(field('mpv.profile').category, 'behavior');
|
|
||||||
assert.equal(field('mpv.profile').section, 'mpv Playback');
|
|
||||||
assert.ok(
|
|
||||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
|
||||||
fields.findIndex(
|
|
||||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
fields.findIndex(
|
|
||||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
|
||||||
) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry groups playback startup controls under playback behavior', () => {
|
|
||||||
for (const path of [
|
|
||||||
'subtitleStyle.autoPauseVideoOnHover',
|
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
|
||||||
'subtitleSidebar.pauseVideoOnHover',
|
|
||||||
'mpv.autoStartSubMiner',
|
|
||||||
'auto_start_overlay',
|
|
||||||
'mpv.pauseUntilOverlayReady',
|
|
||||||
]) {
|
|
||||||
assert.equal(field(path).category, 'behavior', path);
|
|
||||||
assert.equal(field(path).section, 'Playback Behavior', path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
|
|
||||||
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
|
|
||||||
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
|
||||||
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
|
|
||||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
|
||||||
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
|
|
||||||
assert.equal(
|
|
||||||
field('shortcuts.openCharacterDictionaryManager').label,
|
|
||||||
'Open Character Dictionary Manager',
|
|
||||||
);
|
|
||||||
assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry hides removed modal-only fields', () => {
|
|
||||||
for (const path of [
|
|
||||||
'shortcuts.multiCopyTimeoutMs',
|
|
||||||
'anilist.characterDictionary.profileScope',
|
|
||||||
'jellyfin.directPlayContainers',
|
|
||||||
]) {
|
|
||||||
assert.equal(
|
|
||||||
fields.some((candidate) => candidate.configPath === path),
|
|
||||||
false,
|
|
||||||
path,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry orders websocket server immediately after annotation websocket', () => {
|
|
||||||
const integrationSections = [
|
|
||||||
...new Set(
|
|
||||||
fields
|
|
||||||
.filter((candidate) => candidate.category === 'integrations')
|
|
||||||
.map((candidate) => candidate.section),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
|
|
||||||
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
|
|
||||||
assert.equal(field('websocket.enabled').defaultValue, false);
|
|
||||||
assert.equal(
|
|
||||||
field('websocket.enabled').description,
|
|
||||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry places immersion tracking after other tracking and app sections', () => {
|
|
||||||
const trackingSections = [
|
|
||||||
...new Set(
|
|
||||||
fields
|
|
||||||
.filter((candidate) => candidate.category === 'tracking-app')
|
|
||||||
.map((candidate) => candidate.section),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
assert.equal(trackingSections.at(-1), 'Immersion tracking');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry groups annotation display fields by config group', () => {
|
|
||||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
|
||||||
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
|
|
||||||
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
|
|
||||||
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
|
|
||||||
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry groups maturity settings under Known Words with color controls', () => {
|
|
||||||
assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words');
|
|
||||||
for (const tier of ['new', 'learning', 'young', 'mature']) {
|
|
||||||
const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`);
|
|
||||||
assert.equal(tierField.subsection, 'Known Words');
|
|
||||||
assert.equal(tierField.control, 'color');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry orders maturity colors from least mature to mature', () => {
|
|
||||||
const knownWordsPaths = fields
|
|
||||||
.filter((candidate) => candidate.subsection === 'Known Words')
|
|
||||||
.map((candidate) => candidate.configPath);
|
|
||||||
assert.deepEqual(knownWordsPaths, [
|
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
|
||||||
'ankiConnect.knownWords.maturityEnabled',
|
|
||||||
'subtitleStyle.knownWordColor',
|
|
||||||
'ankiConnect.knownWords.matureThresholdDays',
|
|
||||||
'subtitleStyle.knownWordMaturityColors.new',
|
|
||||||
'subtitleStyle.knownWordMaturityColors.learning',
|
|
||||||
'subtitleStyle.knownWordMaturityColors.young',
|
|
||||||
'subtitleStyle.knownWordMaturityColors.mature',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
|
|
||||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
|
|
||||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
|
|
||||||
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
|
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
|
|
||||||
assert.equal(
|
|
||||||
field('subtitleStyle.frequencyDictionary.sourcePath').section,
|
|
||||||
'Frequency Highlighting',
|
|
||||||
);
|
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
|
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
|
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
|
|
||||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
|
||||||
assert.equal(field('ankiConnect.deck').control, 'anki-deck');
|
|
||||||
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
|
||||||
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
|
||||||
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
|
||||||
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
|
||||||
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
|
|
||||||
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
|
|
||||||
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
|
|
||||||
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
|
||||||
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
|
|
||||||
assert.equal(field('stats.toggleKey').control, 'key-code');
|
|
||||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry exposes mpv volume mirroring as a mining toggle', () => {
|
|
||||||
const volumeField = field('ankiConnect.media.mirrorMpvVolume');
|
|
||||||
|
|
||||||
assert.equal(volumeField.category, 'mining-anki');
|
|
||||||
assert.equal(volumeField.section, 'Media Capture');
|
|
||||||
assert.equal(volumeField.control, 'boolean');
|
|
||||||
assert.equal(volumeField.defaultValue, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry exposes YouTube media cache mode as a labeled select', () => {
|
|
||||||
const mediaCacheMode = field('youtube.mediaCache.mode');
|
|
||||||
const mediaCacheMaxHeight = field('youtube.mediaCache.maxHeight');
|
|
||||||
|
|
||||||
assert.equal(mediaCacheMode.control, 'select');
|
|
||||||
assert.deepEqual(mediaCacheMode.enumValues, ['direct', 'background']);
|
|
||||||
assert.deepEqual(mediaCacheMode.enumLabels, {
|
|
||||||
direct: 'Direct stream extraction',
|
|
||||||
background: 'Background media cache',
|
|
||||||
});
|
|
||||||
assert.equal(mediaCacheMaxHeight.control, 'number');
|
|
||||||
assert.equal(mediaCacheMaxHeight.defaultValue, 720);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||||
const primaryVisible = fields
|
const primaryVisible = fields
|
||||||
.filter(
|
.filter(
|
||||||
@@ -268,18 +65,6 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe
|
|||||||
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
|
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings registry routes playback-related integrations into integrations', () => {
|
|
||||||
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
|
|
||||||
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
|
||||||
assert.equal(field('subsync.replace').category, 'integrations');
|
|
||||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
|
||||||
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
|
|
||||||
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
|
|
||||||
assert.equal(field('tmdb.apiKey').category, 'integrations');
|
|
||||||
assert.equal(field('tmdb.apiKey').section, 'TMDB');
|
|
||||||
assert.equal(field('tmdb.apiKey').secret, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||||
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||||
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||||
@@ -309,89 +94,3 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
|
|||||||
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
|
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings registry hides app-managed and inactive config surfaces', () => {
|
|
||||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
|
||||||
for (const hiddenPath of [
|
|
||||||
'ai.enabled',
|
|
||||||
'ai.apiKey',
|
|
||||||
'ai.apiKeyCommand',
|
|
||||||
'ai.model',
|
|
||||||
'ai.baseUrl',
|
|
||||||
'ai.systemPrompt',
|
|
||||||
'ai.requestTimeoutMs',
|
|
||||||
'ankiConnect.ai.enabled',
|
|
||||||
'ankiConnect.ai.model',
|
|
||||||
'ankiConnect.ai.systemPrompt',
|
|
||||||
'ankiConnect.fields.translation',
|
|
||||||
'controller.bindings',
|
|
||||||
'controller.preferredGamepadId',
|
|
||||||
'controller.preferredGamepadLabel',
|
|
||||||
'controller.profiles',
|
|
||||||
'jellyfin.defaultLibraryId',
|
|
||||||
'subtitleSidebar.toggleKey',
|
|
||||||
'jellyfin.recentServers',
|
|
||||||
]) {
|
|
||||||
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
|
||||||
}
|
|
||||||
assert.equal(paths.has('anilist.characterDictionary.enabled'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
|
||||||
for (const path of [
|
|
||||||
'mpv.aniskipButtonKey',
|
|
||||||
'stats.toggleKey',
|
|
||||||
'stats.markWatchedKey',
|
|
||||||
'logging.level',
|
|
||||||
'logging.rotation',
|
|
||||||
'logging.files.app',
|
|
||||||
'logging.files.launcher',
|
|
||||||
'logging.files.mpv',
|
|
||||||
'youtube.primarySubLanguages',
|
|
||||||
'jimaku.apiBaseUrl',
|
|
||||||
'jimaku.languagePreference',
|
|
||||||
'jimaku.maxEntryResults',
|
|
||||||
'subsync.replace',
|
|
||||||
'ankiConnect.behavior.autoUpdateNewCards',
|
|
||||||
'ankiConnect.deck',
|
|
||||||
'ankiConnect.media.normalizeAudio',
|
|
||||||
'ankiConnect.media.mirrorMpvVolume',
|
|
||||||
'ankiConnect.media.reviewTiming',
|
|
||||||
'ankiConnect.knownWords.highlightEnabled',
|
|
||||||
'ankiConnect.knownWords.refreshMinutes',
|
|
||||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
|
||||||
'ankiConnect.knownWords.matchMode',
|
|
||||||
'ankiConnect.knownWords.decks',
|
|
||||||
'ankiConnect.nPlusOne.enabled',
|
|
||||||
'ankiConnect.nPlusOne.minSentenceWords',
|
|
||||||
'ankiConnect.fields.word',
|
|
||||||
'ankiConnect.fields.audio',
|
|
||||||
'ankiConnect.fields.wordAudio',
|
|
||||||
'ankiConnect.fields.image',
|
|
||||||
'ankiConnect.fields.sentence',
|
|
||||||
'ankiConnect.fields.miscInfo',
|
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
|
||||||
'ankiConnect.isSenren.fieldGrouping',
|
|
||||||
]) {
|
|
||||||
assert.equal(field(path).restartBehavior, 'hot-reload', path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry does not expose removed subsync mode option', () => {
|
|
||||||
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
|
||||||
assert.equal(paths.has('subsync.defaultMode'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings registry keeps unsafe config siblings restart-required', () => {
|
|
||||||
for (const path of [
|
|
||||||
'stats.serverPort',
|
|
||||||
'ankiConnect.url',
|
|
||||||
'ankiConnect.proxy.enabled',
|
|
||||||
'mpv.socketPath',
|
|
||||||
'mpv.profile',
|
|
||||||
'websocket.port',
|
|
||||||
]) {
|
|
||||||
assert.equal(field(path).restartBehavior, 'restart', path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -5,30 +5,6 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||||
|
|
||||||
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
|
|
||||||
type ImmersionTrackerServiceCtor =
|
|
||||||
typeof import('../immersion-tracker-service').ImmersionTrackerService;
|
|
||||||
|
|
||||||
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
|
|
||||||
|
|
||||||
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
|
|
||||||
if (trackerCtor) return trackerCtor;
|
|
||||||
const mod = await import('../immersion-tracker-service');
|
|
||||||
trackerCtor = mod.ImmersionTrackerService;
|
|
||||||
return trackerCtor;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDbPath(): string {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
|
||||||
return path.join(dir, 'immersion.sqlite');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupDbPath(dbPath: string): void {
|
|
||||||
const dir = path.dirname(dbPath);
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrackerInternals {
|
interface TrackerInternals {
|
||||||
db: DatabaseSync;
|
db: DatabaseSync;
|
||||||
queue: unknown[];
|
queue: unknown[];
|
||||||
@@ -42,155 +18,38 @@ interface TrackerInternals {
|
|||||||
writeLock: { locked: boolean };
|
writeLock: { locked: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('delete maintenance fails closed when queued writes cannot drain', async () => {
|
interface TrackerHarness {
|
||||||
const dbPath = makeDbPath();
|
tracker: TrackerInternals;
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
deleteRunnerCalls: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `fn` against a fresh tracker (batchSize 2) seeded with two library entries. */
|
||||||
|
async function withTracker(fn: (harness: TrackerHarness) => Promise<void>): Promise<void> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||||
|
const { ImmersionTrackerService } = await import('../immersion-tracker-service');
|
||||||
let deleteRunnerCalls = 0;
|
let deleteRunnerCalls = 0;
|
||||||
|
const service = new ImmersionTrackerService(
|
||||||
try {
|
{ dbPath: path.join(dir, 'immersion.sqlite'), policy: { batchSize: 2 } },
|
||||||
const Ctor = await loadTrackerCtor();
|
{
|
||||||
tracker = new Ctor(
|
runDeleteMaintenanceTask: async () => {
|
||||||
{ dbPath, policy: { batchSize: 2 } },
|
deleteRunnerCalls += 1;
|
||||||
{
|
|
||||||
runDeleteMaintenanceTask: async () => {
|
|
||||||
deleteRunnerCalls += 1;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
const internals = tracker as unknown as TrackerInternals;
|
);
|
||||||
seedTwoEntries(internals.db);
|
|
||||||
queueSubtitleLines(internals, 1);
|
|
||||||
let flushCalls = 0;
|
|
||||||
internals.flushNow = () => {
|
|
||||||
flushCalls += 1;
|
|
||||||
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
|
|
||||||
};
|
|
||||||
|
|
||||||
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
|
|
||||||
|
|
||||||
assert.equal(flushCalls, 1);
|
|
||||||
assert.equal(deleteRunnerCalls, 0);
|
|
||||||
assert.equal(internals.writeLock.locked, false);
|
|
||||||
} finally {
|
|
||||||
tracker?.destroy();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const Ctor = await loadTrackerCtor();
|
const tracker = service as unknown as TrackerInternals;
|
||||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
seedTwoEntries(tracker.db);
|
||||||
const internals = tracker as unknown as TrackerInternals;
|
await fn({ tracker, deleteRunnerCalls: () => deleteRunnerCalls });
|
||||||
seedTwoEntries(internals.db);
|
|
||||||
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
|
|
||||||
queueSubtitleLines(internals, 1);
|
|
||||||
internals.flushNow = () => {};
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
|
|
||||||
/queue did not drain/i,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
internals.db
|
|
||||||
.prepare(
|
|
||||||
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
|
||||||
)
|
|
||||||
.all(),
|
|
||||||
[
|
|
||||||
{ animeId: 1, anilistId: null },
|
|
||||||
{ animeId: 2, anilistId: 123 },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
tracker?.destroy();
|
service.destroy();
|
||||||
cleanupDbPath(dbPath);
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const Ctor = await loadTrackerCtor();
|
|
||||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
|
||||||
const internals = tracker as unknown as TrackerInternals;
|
|
||||||
seedTwoEntries(internals.db);
|
|
||||||
queueSubtitleLines(internals, 1);
|
|
||||||
internals.flushNow = () => {};
|
|
||||||
|
|
||||||
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
internals.db
|
|
||||||
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
|
|
||||||
.all()
|
|
||||||
.map((row) => (row as { animeId: number }).animeId),
|
|
||||||
[1, 2],
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
tracker?.destroy();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const Ctor = await loadTrackerCtor();
|
|
||||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
|
||||||
const internals = tracker as unknown as TrackerInternals;
|
|
||||||
seedTwoEntries(internals.db);
|
|
||||||
queueSubtitleLines(internals, 1);
|
|
||||||
internals.flushNow = () => {};
|
|
||||||
|
|
||||||
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
|
|
||||||
assert.equal(
|
|
||||||
(
|
|
||||||
internals.db
|
|
||||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
|
|
||||||
.get() as {
|
|
||||||
animeId: number;
|
|
||||||
}
|
|
||||||
).animeId,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
tracker?.destroy();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const Ctor = await loadTrackerCtor();
|
|
||||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
|
||||||
const internals = tracker as unknown as TrackerInternals;
|
|
||||||
seedTwoEntries(internals.db);
|
|
||||||
queueSubtitleLines(internals, 1);
|
|
||||||
internals.flushNow = () => {};
|
|
||||||
|
|
||||||
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
|
|
||||||
} finally {
|
|
||||||
tracker?.destroy();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function seedTwoEntries(db: DatabaseSync): void {
|
function seedTwoEntries(db: DatabaseSync): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
|
VALUES (1, 'show', 'Show', NULL, 1000, 1000), (2, 'show season 1', 'Show Season 1', 123, 1000, 1000);
|
||||||
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
|
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
|
||||||
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
|
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
|
||||||
@@ -244,25 +103,53 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The queued telemetry sample only exists in the database once the queue drained fully. */
|
const SNAPSHOT_TABLES = [
|
||||||
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
|
'imm_anime',
|
||||||
const row = db
|
'imm_videos',
|
||||||
.prepare(
|
'imm_sessions',
|
||||||
`SELECT lines_seen AS linesSeen
|
'imm_subtitle_lines',
|
||||||
FROM imm_session_telemetry
|
'imm_session_telemetry',
|
||||||
WHERE session_id = ?
|
'imm_lifetime_global',
|
||||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
'imm_lifetime_anime',
|
||||||
LIMIT 1`,
|
'imm_lifetime_media',
|
||||||
)
|
'imm_lifetime_applied_sessions',
|
||||||
.get(sessionId) as { linesSeen: number } | undefined;
|
];
|
||||||
return row ? Number(row.linesSeen) : null;
|
|
||||||
|
function snapshotDb(db: DatabaseSync): Record<string, unknown[]> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
SNAPSHOT_TABLES.map((table) => [table, db.prepare(`SELECT * FROM ${table} ORDER BY 1`).all()]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
const guardedOperations: Array<{
|
||||||
const row = db
|
name: string;
|
||||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
|
run: (tracker: TrackerInternals) => Promise<unknown>;
|
||||||
.get(animeId) as { total: number };
|
}> = [
|
||||||
return Number(row.total);
|
{ name: 'deleteSession', run: (tracker) => tracker.deleteSession(1) },
|
||||||
|
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
|
||||||
|
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
|
||||||
|
{ name: 'rebuildLifetimeSummaries', run: (tracker) => tracker.rebuildLifetimeSummaries() },
|
||||||
|
{
|
||||||
|
// Anime 2 already owns AniList id 123, so this would otherwise resolve a conflict.
|
||||||
|
name: 'reassignAnimeAnilist',
|
||||||
|
run: (tracker) => tracker.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const operation of guardedOperations) {
|
||||||
|
test(`${operation.name} fails closed without touching the database when queued writes cannot drain`, async () => {
|
||||||
|
await withTracker(async ({ tracker, deleteRunnerCalls }) => {
|
||||||
|
queueSubtitleLines(tracker, 1);
|
||||||
|
tracker.flushNow = () => {};
|
||||||
|
const before = snapshotDb(tracker.db);
|
||||||
|
|
||||||
|
await assert.rejects(operation.run(tracker), /queue did not drain/i);
|
||||||
|
|
||||||
|
assert.deepEqual(snapshotDb(tracker.db), before);
|
||||||
|
assert.equal(deleteRunnerCalls(), 0);
|
||||||
|
assert.equal(tracker.writeLock.locked, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,52 +158,36 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
|||||||
* queue, so anything past `batchSize` would still be unwritten when the merge
|
* queue, so anything past `batchSize` would still be unwritten when the merge
|
||||||
* repoints rows.
|
* repoints rows.
|
||||||
*/
|
*/
|
||||||
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
|
const repointingOperations: Array<{
|
||||||
const dbPath = makeDbPath();
|
name: string;
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
run: (tracker: TrackerInternals) => Promise<unknown>;
|
||||||
|
}> = [
|
||||||
|
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
|
||||||
|
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
for (const operation of repointingOperations) {
|
||||||
const Ctor = await loadTrackerCtor();
|
test(`${operation.name} drains a queue larger than one batch before repointing rows`, async () => {
|
||||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
await withTracker(async ({ tracker }) => {
|
||||||
const internals = tracker as unknown as TrackerInternals;
|
queueSubtitleLines(tracker, 8);
|
||||||
|
queueTelemetry(tracker, 8);
|
||||||
|
assert.ok(tracker.queue.length > 2, 'expected more queued writes than one batch');
|
||||||
|
|
||||||
seedTwoEntries(internals.db);
|
await operation.run(tracker);
|
||||||
queueSubtitleLines(internals, 8);
|
|
||||||
queueTelemetry(internals, 8);
|
|
||||||
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
|
|
||||||
|
|
||||||
await internals.mergeAnime(1, [2]);
|
assert.equal(tracker.queue.length, 0);
|
||||||
|
// Every queued line and the trailing telemetry sample landed on the surviving entry.
|
||||||
assert.equal(internals.queue.length, 0);
|
const lines = tracker.db
|
||||||
// Every queued line landed, attributed to the surviving entry.
|
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1')
|
||||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
.get() as { total: number };
|
||||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
assert.equal(Number(lines.total), 8);
|
||||||
} finally {
|
const telemetry = tracker.db
|
||||||
tracker?.destroy();
|
.prepare(
|
||||||
cleanupDbPath(dbPath);
|
`SELECT lines_seen AS linesSeen FROM imm_session_telemetry
|
||||||
}
|
WHERE session_id = 1 ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1`,
|
||||||
});
|
)
|
||||||
|
.get() as { linesSeen: number } | undefined;
|
||||||
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
|
assert.equal(Number(telemetry?.linesSeen), 8);
|
||||||
const dbPath = makeDbPath();
|
});
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
});
|
||||||
|
}
|
||||||
try {
|
|
||||||
const Ctor = await loadTrackerCtor();
|
|
||||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
|
||||||
const internals = tracker as unknown as TrackerInternals;
|
|
||||||
|
|
||||||
seedTwoEntries(internals.db);
|
|
||||||
queueSubtitleLines(internals, 8);
|
|
||||||
queueTelemetry(internals, 8);
|
|
||||||
|
|
||||||
await internals.moveVideoToAnime(2, 1);
|
|
||||||
|
|
||||||
assert.equal(internals.queue.length, 0);
|
|
||||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
|
||||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
|
||||||
} finally {
|
|
||||||
tracker?.destroy();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { describe, test } from 'node:test';
|
|
||||||
import {
|
|
||||||
registerStatsAnalyticsRoutes,
|
|
||||||
registerStatsIntegrationRoutes,
|
|
||||||
registerStatsLibraryRoutes,
|
|
||||||
registerStatsMiningRoutes,
|
|
||||||
registerStatsStaticRoutes,
|
|
||||||
} from '../stats-server/routes.js';
|
|
||||||
|
|
||||||
describe('stats server route groups', () => {
|
|
||||||
test('exposes focused registrars for createStatsApp composition', () => {
|
|
||||||
for (const registrar of [
|
|
||||||
registerStatsAnalyticsRoutes,
|
|
||||||
registerStatsLibraryRoutes,
|
|
||||||
registerStatsIntegrationRoutes,
|
|
||||||
registerStatsMiningRoutes,
|
|
||||||
registerStatsStaticRoutes,
|
|
||||||
]) {
|
|
||||||
assert.equal(typeof registrar, 'function');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -158,33 +158,6 @@ function createHarness(): RuntimeHarness {
|
|||||||
return { options, registered, state };
|
return { options, registered, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
|
|
||||||
const { registered } = createHarness();
|
|
||||||
const expected = [
|
|
||||||
'setAnkiConnectEnabled',
|
|
||||||
'clearAnkiHistory',
|
|
||||||
'refreshKnownWords',
|
|
||||||
'respondFieldGrouping',
|
|
||||||
'buildKikuMergePreview',
|
|
||||||
'getJimakuMediaInfo',
|
|
||||||
'searchJimakuEntries',
|
|
||||||
'listJimakuFiles',
|
|
||||||
'resolveJimakuApiKey',
|
|
||||||
'getCurrentMediaPath',
|
|
||||||
'isRemoteMediaPath',
|
|
||||||
'downloadToFile',
|
|
||||||
'onDownloadedSubtitle',
|
|
||||||
'searchTsukihimeEntries',
|
|
||||||
'listTsukihimeFiles',
|
|
||||||
'downloadTsukihimeSubtitle',
|
|
||||||
'onDownloadedSecondarySubtitle',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of expected) {
|
|
||||||
assert.equal(typeof registered[key], 'function', `missing handler: ${key}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshKnownWords throws when integration is unavailable', async () => {
|
test('refreshKnownWords throws when integration is unavailable', async () => {
|
||||||
const { registered } = createHarness();
|
const { registered } = createHarness();
|
||||||
|
|
||||||
@@ -196,20 +169,6 @@ test('refreshKnownWords throws when integration is unavailable', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refreshKnownWords delegates to integration', async () => {
|
|
||||||
const { registered, state } = createHarness();
|
|
||||||
let refreshed = 0;
|
|
||||||
state.ankiIntegration = {
|
|
||||||
refreshKnownWordCache: async () => {
|
|
||||||
refreshed += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await registered.refreshKnownWords!();
|
|
||||||
|
|
||||||
assert.equal(refreshed, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
|
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
|
||||||
const { registered, state } = createHarness();
|
const { registered, state } = createHarness();
|
||||||
let destroyed = 0;
|
let destroyed = 0;
|
||||||
@@ -274,30 +233,6 @@ test('buildKikuMergePreview returns guard error when integration is missing', as
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildKikuMergePreview delegates to integration when available', async () => {
|
|
||||||
const { registered, state } = createHarness();
|
|
||||||
const calls: unknown[] = [];
|
|
||||||
state.ankiIntegration = {
|
|
||||||
buildFieldGroupingPreview: async (
|
|
||||||
keepNoteId: number,
|
|
||||||
deleteNoteId: number,
|
|
||||||
deleteDuplicate: boolean,
|
|
||||||
) => {
|
|
||||||
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
|
|
||||||
return { ok: true };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await registered.buildKikuMergePreview!({
|
|
||||||
keepNoteId: 3,
|
|
||||||
deleteNoteId: 4,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [[3, 4, true]]);
|
|
||||||
assert.deepEqual(result, { ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
|
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
|
||||||
const { registered, state } = createHarness();
|
const { registered, state } = createHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -103,17 +103,6 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
|
|
||||||
const { deps, calls } = makeDeps({
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runAppReadyRuntime(deps);
|
|
||||||
|
|
||||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
|
||||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
@@ -226,22 +215,6 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => {
|
|
||||||
const { deps, calls } = makeDeps({
|
|
||||||
createImmersionTracker: undefined,
|
|
||||||
});
|
|
||||||
await runAppReadyRuntime(deps);
|
|
||||||
assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {
|
|
||||||
const { deps, calls } = makeDeps({
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
});
|
|
||||||
await runAppReadyRuntime(deps);
|
|
||||||
assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
|
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
|
||||||
const { deps, calls } = makeDeps({
|
const { deps, calls } = makeDeps({
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { CliArgs } from '../../cli/args';
|
import { CliArgs } from '../../cli/args';
|
||||||
import {
|
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
||||||
CliCommandServiceDeps,
|
|
||||||
createCliCommandDepsRuntime,
|
|
||||||
handleCliCommand,
|
|
||||||
} from './cli-command';
|
|
||||||
|
|
||||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
return {
|
return {
|
||||||
@@ -557,135 +553,6 @@ test('handleCliCommand applies socket path and connects on start', () => {
|
|||||||
assert.ok(calls.includes('connectMpvClient'));
|
assert.ok(calls.includes('connectMpvClient'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const client = {
|
|
||||||
setSocketPath: (socketPath: string) => {
|
|
||||||
calls.push(`setSocketPath:${socketPath}`);
|
|
||||||
},
|
|
||||||
connect: () => {
|
|
||||||
calls.push('connect');
|
|
||||||
},
|
|
||||||
reconnect: () => {
|
|
||||||
calls.push('reconnect');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const deps = createCliCommandDepsRuntime({
|
|
||||||
mpv: {
|
|
||||||
getSocketPath: () => '/tmp/runtime.sock',
|
|
||||||
setSocketPath: () => {},
|
|
||||||
getClient: () => client,
|
|
||||||
showOsd: () => {},
|
|
||||||
},
|
|
||||||
texthooker: {
|
|
||||||
service: { isRunning: () => false, start: () => {} },
|
|
||||||
getPort: () => 5174,
|
|
||||||
setPort: () => {},
|
|
||||||
getWebsocketUrl: () => undefined,
|
|
||||||
shouldOpenBrowser: () => false,
|
|
||||||
openInBrowser: () => {},
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
isInitialized: () => true,
|
|
||||||
initialize: () => {},
|
|
||||||
toggleVisible: () => {},
|
|
||||||
togglePrimarySubtitleBar: () => {},
|
|
||||||
setVisible: () => {},
|
|
||||||
},
|
|
||||||
mining: {
|
|
||||||
copyCurrentSubtitle: () => {},
|
|
||||||
startPendingMultiCopy: () => {},
|
|
||||||
mineSentenceCard: async () => {},
|
|
||||||
startPendingMineSentenceMultiple: () => {},
|
|
||||||
updateLastCardFromClipboard: async () => {},
|
|
||||||
refreshKnownWords: async () => {},
|
|
||||||
triggerFieldGrouping: async () => {},
|
|
||||||
triggerSubsyncFromConfig: async () => {},
|
|
||||||
markLastCardAsAudioCard: async () => {},
|
|
||||||
},
|
|
||||||
anilist: {
|
|
||||||
getStatus: () => ({
|
|
||||||
tokenStatus: 'not_checked',
|
|
||||||
tokenSource: 'none',
|
|
||||||
tokenMessage: null,
|
|
||||||
tokenResolvedAt: null,
|
|
||||||
tokenErrorAt: null,
|
|
||||||
queuePending: 0,
|
|
||||||
queueReady: 0,
|
|
||||||
queueDeadLetter: 0,
|
|
||||||
queueLastAttemptAt: null,
|
|
||||||
queueLastError: null,
|
|
||||||
}),
|
|
||||||
clearToken: () => {},
|
|
||||||
openSetup: () => {},
|
|
||||||
getQueueStatus: () => ({
|
|
||||||
pending: 0,
|
|
||||||
ready: 0,
|
|
||||||
deadLetter: 0,
|
|
||||||
lastAttemptAt: null,
|
|
||||||
lastError: null,
|
|
||||||
}),
|
|
||||||
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
},
|
|
||||||
dictionary: {
|
|
||||||
generate: async () => ({
|
|
||||||
zipPath: '/tmp/test.zip',
|
|
||||||
fromCache: false,
|
|
||||||
mediaId: 1,
|
|
||||||
mediaTitle: 'Test',
|
|
||||||
entryCount: 0,
|
|
||||||
}),
|
|
||||||
getSelection: async () => ({
|
|
||||||
seriesKey: 'test',
|
|
||||||
guessTitle: null,
|
|
||||||
current: null,
|
|
||||||
override: null,
|
|
||||||
candidates: [],
|
|
||||||
}),
|
|
||||||
setSelection: async () => ({
|
|
||||||
ok: true,
|
|
||||||
seriesKey: 'test',
|
|
||||||
selected: { id: 1, title: 'Test', episodes: null },
|
|
||||||
staleMediaIds: [],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
jellyfin: {
|
|
||||||
openSetup: () => {},
|
|
||||||
runStatsCommand: async () => {},
|
|
||||||
runCommand: async () => {},
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
openFirstRunSetup: () => {},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
openConfigSettingsWindow: () => {},
|
|
||||||
openSyncUiWindow: () => {},
|
|
||||||
openYoutubeBrowserWindow: () => {},
|
|
||||||
cycleSecondarySubMode: () => {},
|
|
||||||
openRuntimeOptionsPalette: () => {},
|
|
||||||
printHelp: () => {},
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
stop: () => {},
|
|
||||||
hasMainWindow: () => true,
|
|
||||||
runUpdateCommand: async () => {},
|
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
|
||||||
runYoutubePlaybackFlow: async () => {},
|
|
||||||
},
|
|
||||||
dispatchSessionAction: async () => {},
|
|
||||||
getMultiCopyTimeoutMs: () => 2500,
|
|
||||||
schedule: () => undefined,
|
|
||||||
log: () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
warn: () => {},
|
|
||||||
error: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
deps.setMpvClientSocketPath('/tmp/runtime.sock');
|
|
||||||
deps.connectMpvClient();
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleCliCommand warns when texthooker port override used while running', () => {
|
test('handleCliCommand warns when texthooker port override used while running', () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
isTexthookerRunning: () => true,
|
isTexthookerRunning: () => true,
|
||||||
@@ -882,13 +749,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleCliCommand logs AniList status details', () => {
|
|
||||||
const { deps, calls } = createDeps();
|
|
||||||
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
|
||||||
assert.ok(calls.some((value) => value.startsWith('log:AniList token status:')));
|
|
||||||
assert.ok(calls.some((value) => value.startsWith('log:AniList queue:')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleCliCommand runs AniList retry command', async () => {
|
test('handleCliCommand runs AniList retry command', async () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
|
handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
|
||||||
|
|||||||
@@ -49,28 +49,6 @@ test('buildDiscordPresenceActivity falls back to idle with default style', () =>
|
|||||||
assert.equal(payload.details, 'Sentence Mining');
|
assert.equal(payload.details, 'Sentence Mining');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildDiscordPresenceActivity uses meme style fallback', () => {
|
|
||||||
const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
|
|
||||||
const payload = buildDiscordPresenceActivity(memeConfig, {
|
|
||||||
...baseSnapshot,
|
|
||||||
connected: false,
|
|
||||||
mediaPath: null,
|
|
||||||
});
|
|
||||||
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
|
|
||||||
assert.equal(payload.smallImageText, 'Sentence Mining');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildDiscordPresenceActivity uses japanese style', () => {
|
|
||||||
const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
|
|
||||||
const payload = buildDiscordPresenceActivity(jpConfig, {
|
|
||||||
...baseSnapshot,
|
|
||||||
connected: false,
|
|
||||||
mediaPath: null,
|
|
||||||
});
|
|
||||||
assert.equal(payload.details, '文の採掘中');
|
|
||||||
assert.equal(payload.smallImageText, 'イマージョン学習');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildDiscordPresenceActivity uses minimal style', () => {
|
test('buildDiscordPresenceActivity uses minimal style', () => {
|
||||||
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
|
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
|
||||||
const payload = buildDiscordPresenceActivity(minConfig, {
|
const payload = buildDiscordPresenceActivity(minConfig, {
|
||||||
|
|||||||
@@ -3,78 +3,6 @@ import assert from 'node:assert/strict';
|
|||||||
import { KikuFieldGroupingChoice } from '../../types';
|
import { KikuFieldGroupingChoice } from '../../types';
|
||||||
import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
|
import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
|
||||||
|
|
||||||
test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => {
|
|
||||||
const sent: unknown[][] = [];
|
|
||||||
let visible = false;
|
|
||||||
const restore = new Set<'runtime-options' | 'subsync'>();
|
|
||||||
|
|
||||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
|
||||||
getMainWindow: () => ({
|
|
||||||
isDestroyed: () => false,
|
|
||||||
webContents: {
|
|
||||||
isLoading: () => false,
|
|
||||||
send: (...args: unknown[]) => {
|
|
||||||
sent.push(args);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getVisibleOverlayVisible: () => visible,
|
|
||||||
setVisibleOverlayVisible: (next) => {
|
|
||||||
visible = next;
|
|
||||||
},
|
|
||||||
getResolver: () => null,
|
|
||||||
setResolver: () => {},
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => restore,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, {
|
|
||||||
restoreOnModalClose: 'runtime-options',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(ok, true);
|
|
||||||
assert.equal(visible, true);
|
|
||||||
assert.equal(restore.has('runtime-options'), true);
|
|
||||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => {
|
|
||||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
|
||||||
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getResolver: () => resolver,
|
|
||||||
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
|
||||||
resolver = next;
|
|
||||||
},
|
|
||||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const callback = runtime.createFieldGroupingCallback();
|
|
||||||
const result = await callback({
|
|
||||||
original: {
|
|
||||||
noteId: 1,
|
|
||||||
expression: 'a',
|
|
||||||
sentencePreview: 'a',
|
|
||||||
hasAudio: false,
|
|
||||||
hasImage: false,
|
|
||||||
isOriginal: true,
|
|
||||||
},
|
|
||||||
duplicate: {
|
|
||||||
noteId: 2,
|
|
||||||
expression: 'b',
|
|
||||||
sentencePreview: 'b',
|
|
||||||
hasAudio: false,
|
|
||||||
hasImage: false,
|
|
||||||
isOriginal: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.cancelled, true);
|
|
||||||
assert.equal(result.keepNoteId, 0);
|
|
||||||
assert.equal(result.deleteNoteId, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
|
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
|
||||||
let resolver: unknown = null;
|
let resolver: unknown = null;
|
||||||
let visible = false;
|
let visible = false;
|
||||||
|
|||||||
@@ -98,124 +98,59 @@ test('field grouping callback does not reject the next request after a response'
|
|||||||
assert.equal(secondChoice.keepNoteId, 2);
|
assert.equal(secondChoice.keepNoteId, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('field grouping callback dismisses the modal UI when the send fails', async () => {
|
const dismissCases = [
|
||||||
const store = createWrappedResolverStore();
|
{ name: 'the send fails', sends: false, responseTimeoutMs: undefined },
|
||||||
let dismissed = 0;
|
{ name: 'the response times out', sends: true, responseTimeoutMs: 5 },
|
||||||
const callback = createFieldGroupingCallback({
|
];
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getResolver: store.getResolver,
|
|
||||||
setResolver: store.setResolver,
|
|
||||||
sendRequestToVisibleOverlay: () => false,
|
|
||||||
dismissModalUi: () => {
|
|
||||||
dismissed += 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await callback(makeRequestData());
|
for (const c of dismissCases) {
|
||||||
assert.equal(result.cancelled, true);
|
test(`field grouping callback dismisses the modal UI when ${c.name}`, async () => {
|
||||||
assert.equal(dismissed, 1);
|
const store = createWrappedResolverStore();
|
||||||
assert.equal(store.getResolver(), null);
|
let dismissed = 0;
|
||||||
});
|
|
||||||
|
|
||||||
test('field grouping callback handles modal dismiss failures on send failure', async () => {
|
|
||||||
const store = createWrappedResolverStore();
|
|
||||||
const originalConsoleError = console.error;
|
|
||||||
console.error = () => {};
|
|
||||||
try {
|
|
||||||
const callback = createFieldGroupingCallback({
|
const callback = createFieldGroupingCallback({
|
||||||
getVisibleOverlayVisible: () => false,
|
getVisibleOverlayVisible: () => false,
|
||||||
setVisibleOverlayVisible: () => {},
|
setVisibleOverlayVisible: () => {},
|
||||||
getResolver: store.getResolver,
|
getResolver: store.getResolver,
|
||||||
setResolver: store.setResolver,
|
setResolver: store.setResolver,
|
||||||
sendRequestToVisibleOverlay: () => false,
|
sendRequestToVisibleOverlay: () => c.sends,
|
||||||
dismissModalUi: () => {
|
dismissModalUi: () => {
|
||||||
throw new Error('dismiss failed');
|
dismissed += 1;
|
||||||
},
|
},
|
||||||
|
responseTimeoutMs: c.responseTimeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await callback(makeRequestData());
|
const result = await callback(makeRequestData());
|
||||||
|
|
||||||
assert.equal(result.cancelled, true);
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(dismissed, 1);
|
||||||
assert.equal(store.getResolver(), null);
|
assert.equal(store.getResolver(), null);
|
||||||
} finally {
|
|
||||||
console.error = originalConsoleError;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('field grouping callback handles modal dismiss failures on timeout', async () => {
|
|
||||||
const store = createWrappedResolverStore();
|
|
||||||
const originalConsoleError = console.error;
|
|
||||||
console.error = () => {};
|
|
||||||
try {
|
|
||||||
const callback = createFieldGroupingCallback({
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getResolver: store.getResolver,
|
|
||||||
setResolver: store.setResolver,
|
|
||||||
sendRequestToVisibleOverlay: () => true,
|
|
||||||
dismissModalUi: () => {
|
|
||||||
throw new Error('dismiss failed');
|
|
||||||
},
|
|
||||||
responseTimeoutMs: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await callback(makeRequestData());
|
|
||||||
|
|
||||||
assert.equal(result.cancelled, true);
|
|
||||||
assert.equal(store.getResolver(), null);
|
|
||||||
} finally {
|
|
||||||
console.error = originalConsoleError;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('field grouping callback reports modal dismiss failures', async () => {
|
|
||||||
const store = createWrappedResolverStore();
|
|
||||||
const errors: unknown[] = [];
|
|
||||||
const originalConsoleError = console.error;
|
|
||||||
console.error = (...args: unknown[]) => {
|
|
||||||
errors.push(args);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const callback = createFieldGroupingCallback({
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getResolver: store.getResolver,
|
|
||||||
setResolver: store.setResolver,
|
|
||||||
sendRequestToVisibleOverlay: () => false,
|
|
||||||
dismissModalUi: () => {
|
|
||||||
throw new Error('dismiss failed');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await callback(makeRequestData());
|
|
||||||
|
|
||||||
assert.equal(errors.length, 1);
|
|
||||||
} finally {
|
|
||||||
console.error = originalConsoleError;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('field grouping callback dismisses the modal UI when the response times out', async () => {
|
|
||||||
const store = createWrappedResolverStore();
|
|
||||||
let dismissed = 0;
|
|
||||||
const callback = createFieldGroupingCallback({
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getResolver: store.getResolver,
|
|
||||||
setResolver: store.setResolver,
|
|
||||||
sendRequestToVisibleOverlay: () => true,
|
|
||||||
dismissModalUi: () => {
|
|
||||||
dismissed += 1;
|
|
||||||
},
|
|
||||||
responseTimeoutMs: 5,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await callback(makeRequestData());
|
test(`field grouping callback still cancels and clears the resolver when ${c.name} and dismiss throws`, async () => {
|
||||||
assert.equal(result.cancelled, true);
|
const store = createWrappedResolverStore();
|
||||||
assert.equal(dismissed, 1);
|
const originalConsoleError = console.error;
|
||||||
assert.equal(store.getResolver(), null);
|
console.error = () => {};
|
||||||
});
|
try {
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => c.sends,
|
||||||
|
dismissModalUi: () => {
|
||||||
|
throw new Error('dismiss failed');
|
||||||
|
},
|
||||||
|
responseTimeoutMs: c.responseTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callback(makeRequestData());
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(store.getResolver(), null);
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
||||||
const store = createWrappedResolverStore();
|
const store = createWrappedResolverStore();
|
||||||
|
|||||||
@@ -499,36 +499,13 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(placed, true);
|
assert.equal(placed, true);
|
||||||
assert.deepEqual(
|
const dispatches = calls
|
||||||
calls.map(([, args]) => args),
|
.map(([, args]) => args as string[])
|
||||||
[
|
.filter(([verb]) => verb === 'dispatch');
|
||||||
['-j', 'clients'],
|
assert.ok(dispatches.length > 0);
|
||||||
['-j', 'status'],
|
for (const [, dispatcher] of dispatches) {
|
||||||
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'],
|
assert.ok(dispatcher!.startsWith('hl.dsp.window.'), dispatcher);
|
||||||
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
|
}
|
||||||
[
|
|
||||||
'dispatch',
|
|
||||||
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'dispatch',
|
|
||||||
'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'dispatch',
|
|
||||||
'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'dispatch',
|
|
||||||
'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'dispatch',
|
|
||||||
'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })',
|
|
||||||
],
|
|
||||||
['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => {
|
test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { toMonthKey } from './immersion-tracker/maintenance';
|
|
||||||
import { enqueueWrite } from './immersion-tracker/queue';
|
import { enqueueWrite } from './immersion-tracker/queue';
|
||||||
import { toDbTimestamp } from './immersion-tracker/query-shared';
|
import { toDbTimestamp } from './immersion-tracker/query-shared';
|
||||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||||
@@ -137,11 +136,6 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () =>
|
|||||||
assert.equal((queue[1] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 3);
|
assert.equal((queue[1] as Extract<QueuedWrite, { kind: 'event' }>).eventType, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('seam: toMonthKey uses UTC calendar month', () => {
|
|
||||||
assert.equal(toMonthKey(-86_400_000), 196912);
|
|
||||||
assert.equal(toMonthKey(0), 197001);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('startSession generates UUID-like session identifiers', async () => {
|
test('startSession generates UUID-like session identifiers', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
@@ -528,37 +522,6 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fresh tracker DB creates lifetime summary tables', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const Ctor = await loadTrackerCtor();
|
|
||||||
tracker = new Ctor({ dbPath });
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
const tableRows = db
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
||||||
.all() as Array<{ name: string }>;
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
const tableNames = new Set(tableRows.map((row) => row.name));
|
|
||||||
const expectedTables = [
|
|
||||||
'imm_lifetime_global',
|
|
||||||
'imm_lifetime_anime',
|
|
||||||
'imm_lifetime_media',
|
|
||||||
'imm_lifetime_applied_sessions',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const tableName of expectedTables) {
|
|
||||||
assert.ok(tableNames.has(tableName), `Expected ${tableName} to exist`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
tracker?.destroy();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fresh tracker DB skips lexical rollup backfill work', async () => {
|
test('fresh tracker DB skips lexical rollup backfill work', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
@@ -3773,138 +3736,6 @@ test('monthly rollups are grouped by calendar month', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('flushSingle reuses cached prepared statements', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
|
||||||
let originalPrepare: DatabaseSync['prepare'] | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const Ctor = await loadTrackerCtor();
|
|
||||||
tracker = new Ctor({ dbPath });
|
|
||||||
const privateApi = tracker as unknown as {
|
|
||||||
db: DatabaseSync;
|
|
||||||
flushSingle: (write: {
|
|
||||||
kind: 'telemetry' | 'event';
|
|
||||||
sessionId: number;
|
|
||||||
sampleMs: number;
|
|
||||||
eventType?: number;
|
|
||||||
lineIndex?: number | null;
|
|
||||||
segmentStartMs?: number | null;
|
|
||||||
segmentEndMs?: number | null;
|
|
||||||
tokensDelta?: number;
|
|
||||||
cardsDelta?: number;
|
|
||||||
payloadJson?: string | null;
|
|
||||||
totalWatchedMs?: number;
|
|
||||||
activeWatchedMs?: number;
|
|
||||||
linesSeen?: number;
|
|
||||||
tokensSeen?: number;
|
|
||||||
cardsMined?: number;
|
|
||||||
lookupCount?: number;
|
|
||||||
lookupHits?: number;
|
|
||||||
yomitanLookupCount?: number;
|
|
||||||
pauseCount?: number;
|
|
||||||
pauseMs?: number;
|
|
||||||
seekForwardCount?: number;
|
|
||||||
seekBackwardCount?: number;
|
|
||||||
mediaBufferEvents?: number;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
originalPrepare = privateApi.db.prepare;
|
|
||||||
let prepareCalls = 0;
|
|
||||||
privateApi.db.prepare = (...args: Parameters<DatabaseSync['prepare']>) => {
|
|
||||||
prepareCalls += 1;
|
|
||||||
return originalPrepare!.apply(privateApi.db, args);
|
|
||||||
};
|
|
||||||
const preparedRestore = originalPrepare;
|
|
||||||
|
|
||||||
privateApi.db.exec(`
|
|
||||||
INSERT INTO imm_videos (
|
|
||||||
video_id,
|
|
||||||
video_key,
|
|
||||||
canonical_title,
|
|
||||||
source_type,
|
|
||||||
duration_ms,
|
|
||||||
CREATED_DATE,
|
|
||||||
LAST_UPDATE_DATE
|
|
||||||
) VALUES (
|
|
||||||
1,
|
|
||||||
'local:/tmp/prepared.mkv',
|
|
||||||
'Prepared',
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
1000,
|
|
||||||
1000
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
privateApi.db.exec(`
|
|
||||||
INSERT INTO imm_sessions (
|
|
||||||
session_id,
|
|
||||||
session_uuid,
|
|
||||||
video_id,
|
|
||||||
started_at_ms,
|
|
||||||
status,
|
|
||||||
CREATED_DATE,
|
|
||||||
LAST_UPDATE_DATE,
|
|
||||||
ended_at_ms
|
|
||||||
) VALUES (
|
|
||||||
1,
|
|
||||||
'33333333-3333-3333-3333-333333333333',
|
|
||||||
1,
|
|
||||||
1000,
|
|
||||||
2,
|
|
||||||
1000,
|
|
||||||
1000,
|
|
||||||
2000
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
privateApi.flushSingle({
|
|
||||||
kind: 'telemetry',
|
|
||||||
sessionId: 1,
|
|
||||||
sampleMs: 1500,
|
|
||||||
totalWatchedMs: 1000,
|
|
||||||
activeWatchedMs: 1000,
|
|
||||||
linesSeen: 1,
|
|
||||||
tokensSeen: 2,
|
|
||||||
cardsMined: 0,
|
|
||||||
lookupCount: 0,
|
|
||||||
lookupHits: 0,
|
|
||||||
yomitanLookupCount: 0,
|
|
||||||
pauseCount: 0,
|
|
||||||
pauseMs: 0,
|
|
||||||
seekForwardCount: 0,
|
|
||||||
seekBackwardCount: 0,
|
|
||||||
mediaBufferEvents: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
privateApi.flushSingle({
|
|
||||||
kind: 'event',
|
|
||||||
sessionId: 1,
|
|
||||||
sampleMs: 1600,
|
|
||||||
eventType: 1,
|
|
||||||
lineIndex: 1,
|
|
||||||
segmentStartMs: 0,
|
|
||||||
segmentEndMs: 1000,
|
|
||||||
tokensDelta: 2,
|
|
||||||
cardsDelta: 0,
|
|
||||||
payloadJson: '{"event":"subtitle-line"}',
|
|
||||||
});
|
|
||||||
|
|
||||||
privateApi.db.prepare = preparedRestore;
|
|
||||||
|
|
||||||
assert.equal(prepareCalls, 0);
|
|
||||||
} finally {
|
|
||||||
if (tracker && originalPrepare) {
|
|
||||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
|
||||||
privateApi.db.prepare = originalPrepare;
|
|
||||||
}
|
|
||||||
tracker?.destroy();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains compatible', async () => {
|
test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains compatible', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
|
|
||||||
const source = fs.readFileSync(
|
|
||||||
path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
const start = source.indexOf('export function getRollupGroupsForSessions');
|
|
||||||
const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
|
|
||||||
const functionSource = source.slice(start, end);
|
|
||||||
|
|
||||||
assert.match(functionSource, /'unixepoch', 'localtime'/);
|
|
||||||
assert.doesNotMatch(functionSource, /UNION/);
|
|
||||||
assert.doesNotMatch(functionSource, /86400000/);
|
|
||||||
});
|
|
||||||
@@ -602,39 +602,6 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getDailyRollups limits by distinct days (not rows)', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = openTestDb(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
|
|
||||||
const insert = db.prepare(
|
|
||||||
`
|
|
||||||
INSERT INTO imm_daily_rollups (
|
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
|
||||||
total_tokens_seen, total_cards
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
insert.run(10, 1, 1, 1, 0, 0, 2);
|
|
||||||
insert.run(10, 2, 1, 1, 0, 0, 3);
|
|
||||||
insert.run(9, 1, 1, 1, 0, 0, 1);
|
|
||||||
insert.run(8, 1, 1, 1, 0, 0, 1);
|
|
||||||
|
|
||||||
const rows = getDailyRollups(db, 2);
|
|
||||||
assert.equal(rows.length, 3);
|
|
||||||
assert.ok(rows.every((r) => r.rollupDayOrMonth === 10 || r.rollupDayOrMonth === 9));
|
|
||||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 1));
|
|
||||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 10 && r.videoId === 2));
|
|
||||||
assert.ok(rows.some((r) => r.rollupDayOrMonth === 9 && r.videoId === 1));
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
@@ -2075,20 +2042,6 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getVocabularyStats returns empty array when no words exist', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = openTestDb(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
const rows = getVocabularyStats(db, 10);
|
|
||||||
assert.deepEqual(rows, []);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
@@ -2524,20 +2477,6 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getKanjiStats returns empty array when no kanji exist', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = openTestDb(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
const rows = getKanjiStats(db, 10);
|
|
||||||
assert.deepEqual(rows, []);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
@@ -2643,29 +2582,6 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getSessionEvents returns empty array for session with no events', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = openTestDb(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
|
|
||||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', {
|
|
||||||
canonicalTitle: 'Events Empty',
|
|
||||||
sourcePath: '/tmp/events-empty.mkv',
|
|
||||||
sourceUrl: null,
|
|
||||||
sourceType: SOURCE_TYPE_LOCAL,
|
|
||||||
});
|
|
||||||
const { sessionId } = startSessionRecord(db, videoId, 6_000_000);
|
|
||||||
|
|
||||||
const events = getSessionEvents(db, sessionId, 50);
|
|
||||||
assert.deepEqual(events, []);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getSessionEvents filters events to the requested session id', () => {
|
test('getSessionEvents filters events to the requested session id', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = openTestDb(dbPath);
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Database } from './sqlite';
|
import { Database } from './sqlite';
|
||||||
import {
|
import { pruneRawRetention, pruneRollupRetention, toMonthKey } from './maintenance';
|
||||||
pruneRawRetention,
|
|
||||||
pruneRollupRetention,
|
|
||||||
runOptimizeMaintenance,
|
|
||||||
toMonthKey,
|
|
||||||
} from './maintenance';
|
|
||||||
import { ensureSchema } from './storage';
|
import { ensureSchema } from './storage';
|
||||||
import { toDbTimestamp } from './query-shared';
|
import { toDbTimestamp } from './query-shared';
|
||||||
|
|
||||||
@@ -225,42 +220,3 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureSchema adds sample_ms index for telemetry rollup scans', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
const indexes = db.prepare("PRAGMA index_list('imm_session_telemetry')").all() as Array<{
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
const hasSampleMsIndex = indexes.some((row) => row.name === 'idx_telemetry_sample_ms');
|
|
||||||
assert.equal(hasSampleMsIndex, true);
|
|
||||||
|
|
||||||
const indexColumns = db.prepare("PRAGMA index_info('idx_telemetry_sample_ms')").all() as Array<{
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
assert.deepEqual(
|
|
||||||
indexColumns.map((column) => column.name),
|
|
||||||
['sample_ms'],
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runOptimizeMaintenance executes PRAGMA optimize', () => {
|
|
||||||
const executedSql: string[] = [];
|
|
||||||
const db = {
|
|
||||||
exec(source: string) {
|
|
||||||
executedSql.push(source);
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
} as unknown as Parameters<typeof runOptimizeMaintenance>[0];
|
|
||||||
|
|
||||||
runOptimizeMaintenance(db);
|
|
||||||
|
|
||||||
assert.deepEqual(executedSql, ['PRAGMA optimize']);
|
|
||||||
});
|
|
||||||
@@ -59,34 +59,6 @@ function cleanupDbPath(dbPath: string): void {
|
|||||||
// libsql keeps Windows file handles alive after close when prepared statements were used.
|
// libsql keeps Windows file handles alive after close when prepared statements were used.
|
||||||
}
|
}
|
||||||
|
|
||||||
test('applyPragmas sets the SQLite tuning defaults used by immersion tracking', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
applyPragmas(db);
|
|
||||||
|
|
||||||
const journalModeRow = db.prepare('PRAGMA journal_mode').get() as {
|
|
||||||
journal_mode: string;
|
|
||||||
};
|
|
||||||
const synchronousRow = db.prepare('PRAGMA synchronous').get() as { synchronous: number };
|
|
||||||
const foreignKeysRow = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
|
|
||||||
const busyTimeoutRow = db.prepare('PRAGMA busy_timeout').get() as { timeout: number };
|
|
||||||
const journalSizeLimitRow = db.prepare('PRAGMA journal_size_limit').get() as {
|
|
||||||
journal_size_limit: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(journalModeRow.journal_mode, 'wal');
|
|
||||||
assert.equal(synchronousRow.synchronous, 1);
|
|
||||||
assert.equal(foreignKeysRow.foreign_keys, 1);
|
|
||||||
assert.equal(busyTimeoutRow.timeout, 2500);
|
|
||||||
assert.equal(journalSizeLimitRow.journal_size_limit, 67_108_864);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applyPragmas installs the busy timeout before WAL negotiation', () => {
|
test('applyPragmas installs the busy timeout before WAL negotiation', () => {
|
||||||
const statements: string[] = [];
|
const statements: string[] = [];
|
||||||
const db: DatabaseSync = {
|
const db: DatabaseSync = {
|
||||||
@@ -110,76 +82,6 @@ test('applyPragmas installs the busy timeout before WAL negotiation', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureSchema creates immersion core tables', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`,
|
|
||||||
)
|
|
||||||
.all() as Array<{ name: string }>;
|
|
||||||
const tableNames = new Set(rows.map((row) => row.name));
|
|
||||||
|
|
||||||
assert.ok(tableNames.has('imm_videos'));
|
|
||||||
assert.ok(tableNames.has('imm_anime'));
|
|
||||||
assert.ok(tableNames.has('imm_sessions'));
|
|
||||||
assert.ok(tableNames.has('imm_session_telemetry'));
|
|
||||||
assert.ok(tableNames.has('imm_session_events'));
|
|
||||||
assert.ok(tableNames.has('imm_daily_rollups'));
|
|
||||||
assert.ok(tableNames.has('imm_monthly_rollups'));
|
|
||||||
assert.ok(tableNames.has('imm_words'));
|
|
||||||
assert.ok(tableNames.has('imm_kanji'));
|
|
||||||
assert.ok(tableNames.has('imm_subtitle_lines'));
|
|
||||||
assert.ok(tableNames.has('imm_word_line_occurrences'));
|
|
||||||
assert.ok(tableNames.has('imm_kanji_line_occurrences'));
|
|
||||||
assert.ok(tableNames.has('imm_rollup_state'));
|
|
||||||
assert.ok(tableNames.has('imm_cover_art_blobs'));
|
|
||||||
assert.ok(tableNames.has('imm_youtube_videos'));
|
|
||||||
assert.ok(tableNames.has('imm_stats_excluded_words'));
|
|
||||||
|
|
||||||
const videoColumns = new Set(
|
|
||||||
(
|
|
||||||
db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{
|
|
||||||
name: string;
|
|
||||||
}>
|
|
||||||
).map((row) => row.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(videoColumns.has('anime_id'));
|
|
||||||
assert.ok(videoColumns.has('parsed_basename'));
|
|
||||||
assert.ok(videoColumns.has('parsed_title'));
|
|
||||||
assert.ok(videoColumns.has('parsed_season'));
|
|
||||||
assert.ok(videoColumns.has('parsed_episode'));
|
|
||||||
assert.ok(videoColumns.has('parser_source'));
|
|
||||||
assert.ok(videoColumns.has('parser_confidence'));
|
|
||||||
assert.ok(videoColumns.has('parse_metadata_json'));
|
|
||||||
assert.ok(videoColumns.has('anime_assignment_locked'));
|
|
||||||
|
|
||||||
const mediaArtColumns = new Set(
|
|
||||||
(
|
|
||||||
db.prepare('PRAGMA table_info(imm_media_art)').all() as Array<{
|
|
||||||
name: string;
|
|
||||||
}>
|
|
||||||
).map((row) => row.name),
|
|
||||||
);
|
|
||||||
assert.ok(mediaArtColumns.has('cover_blob_hash'));
|
|
||||||
|
|
||||||
const rollupStateRow = db
|
|
||||||
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
|
||||||
.get('last_rollup_sample_ms') as {
|
|
||||||
state_value: string;
|
|
||||||
} | null;
|
|
||||||
assert.ok(rollupStateRow);
|
|
||||||
assert.equal(Number(rollupStateRow?.state_value ?? 0), 0);
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
|
test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -752,37 +654,6 @@ test('ensureSchema migrates session event timestamps to text and repairs libsql-
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureSchema creates large-history performance indexes', () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureSchema(db);
|
|
||||||
const indexNames = new Set(
|
|
||||||
(
|
|
||||||
db
|
|
||||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'`)
|
|
||||||
.all() as Array<{
|
|
||||||
name: string;
|
|
||||||
}>
|
|
||||||
).map((row) => row.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(indexNames.has('idx_telemetry_sample_ms'));
|
|
||||||
assert.ok(indexNames.has('idx_sessions_started_at'));
|
|
||||||
assert.ok(indexNames.has('idx_sessions_ended_at'));
|
|
||||||
assert.ok(indexNames.has('idx_words_frequency'));
|
|
||||||
assert.ok(indexNames.has('idx_kanji_frequency'));
|
|
||||||
assert.ok(indexNames.has('idx_media_art_anilist_id'));
|
|
||||||
assert.ok(indexNames.has('idx_media_art_cover_url'));
|
|
||||||
assert.ok(indexNames.has('idx_youtube_videos_channel_id'));
|
|
||||||
assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id'));
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ensureSchema adds the subtitle event index to current-version databases', () => {
|
test('ensureSchema adds the subtitle event index to current-version databases', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -129,47 +129,32 @@ test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies
|
|||||||
assert.deepEqual(playbackFeedback, []);
|
assert.deepEqual(playbackFeedback, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
|
const specialCommandCases = [
|
||||||
const { options, calls, sentCommands, osd } = createOptions();
|
{ name: 'youtube picker open', command: '__youtube-picker-open', expected: 'youtube-picker' },
|
||||||
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
|
{ name: 'jimaku open', command: '__jimaku-open', expected: 'jimaku' },
|
||||||
assert.deepEqual(calls, []);
|
// Legacy Animetosho command is kept as a TsukiHime alias.
|
||||||
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
|
{
|
||||||
assert.deepEqual(osd, []);
|
name: 'legacy Animetosho open as a TsukiHime alias',
|
||||||
});
|
command: '__animetosho-open',
|
||||||
|
expected: 'tsukihime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'playlist browser open',
|
||||||
|
command: '__playlist-browser-open',
|
||||||
|
expected: 'playlist-browser',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => {
|
for (const c of specialCommandCases) {
|
||||||
const { options, calls, sentCommands, osd } = createOptions();
|
test(`handleMpvCommandFromIpc dispatches special ${c.name} command`, async () => {
|
||||||
handleMpvCommandFromIpc(['__youtube-picker-open'], options);
|
const { options, calls, sentCommands, osd } = createOptions();
|
||||||
assert.deepEqual(calls, ['youtube-picker']);
|
handleMpvCommandFromIpc([c.command], options);
|
||||||
assert.deepEqual(sentCommands, []);
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
assert.deepEqual(osd, []);
|
assert.deepEqual(calls, [c.expected]);
|
||||||
});
|
assert.deepEqual(sentCommands, []);
|
||||||
|
assert.deepEqual(osd, []);
|
||||||
test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
|
});
|
||||||
const { options, calls, sentCommands, osd } = createOptions();
|
}
|
||||||
handleMpvCommandFromIpc(['__jimaku-open'], options);
|
|
||||||
assert.deepEqual(calls, ['jimaku']);
|
|
||||||
assert.deepEqual(sentCommands, []);
|
|
||||||
assert.deepEqual(osd, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => {
|
|
||||||
const { options, calls, sentCommands } = createOptions();
|
|
||||||
|
|
||||||
handleMpvCommandFromIpc(['__animetosho-open'], options);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['tsukihime']);
|
|
||||||
assert.deepEqual(sentCommands, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
|
||||||
const { options, calls, sentCommands, osd } = createOptions();
|
|
||||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
assert.deepEqual(calls, ['playlist-browser']);
|
|
||||||
assert.deepEqual(sentCommands, []);
|
|
||||||
assert.deepEqual(osd, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => {
|
test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => {
|
||||||
const { options, osd } = createOptions({
|
const { options, osd } = createOptions({
|
||||||
|
|||||||
+37
-253
@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
import type {
|
import type { SessionActionDispatchRequest, SubtitleSidebarSnapshot } from '../../types';
|
||||||
PlaylistBrowserSnapshot,
|
|
||||||
SessionActionDispatchRequest,
|
|
||||||
SubtitleSidebarSnapshot,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
interface FakeIpcRegistrar {
|
interface FakeIpcRegistrar {
|
||||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||||
@@ -187,107 +183,6 @@ function createFakeImmersionTracker(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const deps = createIpcDepsRuntime({
|
|
||||||
getMainWindow: () => null,
|
|
||||||
getVisibleOverlayVisibility: () => false,
|
|
||||||
onOverlayModalClosed: () => {},
|
|
||||||
onOverlayMouseInteractionChanged: (active) => {
|
|
||||||
calls.push(`overlay-interaction:${active}`);
|
|
||||||
},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
tokenizeCurrentSubtitle: async () => null,
|
|
||||||
getCurrentSubtitleRaw: () => '',
|
|
||||||
getCurrentSubtitleAss: () => '',
|
|
||||||
getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(),
|
|
||||||
getPlaybackPaused: () => true,
|
|
||||||
getSubtitlePosition: () => null,
|
|
||||||
getSubtitleStyle: () => null,
|
|
||||||
saveSubtitlePosition: () => {},
|
|
||||||
getMecabTokenizer: () => null,
|
|
||||||
handleMpvCommand: () => {},
|
|
||||||
getKeybindings: () => [],
|
|
||||||
getSessionBindings: () => [],
|
|
||||||
getConfiguredShortcuts: () => ({}),
|
|
||||||
dispatchSessionAction: async () => {},
|
|
||||||
getStatsToggleKey: () => 'Backquote',
|
|
||||||
getMarkWatchedKey: () => 'KeyW',
|
|
||||||
getOverlayNotificationPosition: () => 'top-right',
|
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
|
||||||
saveControllerConfig: () => {},
|
|
||||||
saveControllerPreference: () => {},
|
|
||||||
getSecondarySubMode: () => 'hover',
|
|
||||||
getMpvClient: () => null,
|
|
||||||
focusMainWindow: () => {},
|
|
||||||
activatePlaybackWindowForOverlayInteraction: () => false,
|
|
||||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
getAnkiConnectStatus: () => false,
|
|
||||||
getRuntimeOptions: () => ({}),
|
|
||||||
setRuntimeOption: () => ({ ok: true }),
|
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
|
||||||
reportOverlayContentBounds: () => {},
|
|
||||||
getAnilistStatus: () => ({ tokenStatus: 'resolved' }),
|
|
||||||
clearAnilistToken: () => {
|
|
||||||
calls.push('clearAnilistToken');
|
|
||||||
},
|
|
||||||
openAnilistSetup: () => {
|
|
||||||
calls.push('openAnilistSetup');
|
|
||||||
},
|
|
||||||
getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }),
|
|
||||||
retryAnilistQueueNow: async () => {
|
|
||||||
calls.push('retryAnilistQueueNow');
|
|
||||||
return { ok: true, message: 'done' };
|
|
||||||
},
|
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
|
||||||
getPlaylistBrowserSnapshot: async () => ({
|
|
||||||
directoryPath: '/tmp',
|
|
||||||
directoryAvailable: true,
|
|
||||||
directoryStatus: '/tmp',
|
|
||||||
directoryItems: [],
|
|
||||||
playlistItems: [],
|
|
||||||
playingIndex: 0,
|
|
||||||
currentFilePath: '/tmp/current.mkv',
|
|
||||||
}),
|
|
||||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }),
|
|
||||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }),
|
|
||||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }),
|
|
||||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }),
|
|
||||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
|
||||||
deps.clearAnilistToken();
|
|
||||||
deps.openAnilistSetup();
|
|
||||||
deps.onOverlayMouseInteractionChanged?.(true, null);
|
|
||||||
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
|
||||||
pending: 1,
|
|
||||||
ready: 0,
|
|
||||||
deadLetter: 0,
|
|
||||||
});
|
|
||||||
assert.deepEqual(await deps.retryAnilistQueueNow(), {
|
|
||||||
ok: true,
|
|
||||||
message: 'done',
|
|
||||||
});
|
|
||||||
assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true);
|
|
||||||
assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), {
|
|
||||||
ok: true,
|
|
||||||
message: 'append',
|
|
||||||
});
|
|
||||||
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
|
||||||
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
|
||||||
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'clearAnilistToken',
|
|
||||||
'openAnilistSetup',
|
|
||||||
'overlay-interaction:true',
|
|
||||||
'retryAnilistQueueNow',
|
|
||||||
]);
|
|
||||||
assert.equal(deps.getPlaybackPaused(), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
|
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
|
||||||
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
|
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
|
||||||
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
|
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
|
||||||
@@ -332,27 +227,6 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
|
|||||||
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers passes sender window to overlay content bounds reports', () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const senderWindows: unknown[] = [];
|
|
||||||
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => {
|
|
||||||
senderWindows.push(senderWindow);
|
|
||||||
}) as IpcServiceDeps['reportOverlayContentBounds'],
|
|
||||||
}),
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds);
|
|
||||||
assert.equal(typeof handler, 'function');
|
|
||||||
|
|
||||||
handler?.({}, { layer: 'visible' });
|
|
||||||
|
|
||||||
assert.deepEqual(senderWindows, [null]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -433,71 +307,6 @@ test('registerIpcHandlers skips AniList update when manual mark watched has no a
|
|||||||
assert.deepEqual(calls, ['mark']);
|
assert.deepEqual(calls, ['mark']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const calls: Array<[string, unknown[]]> = [];
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
getPlaylistBrowserSnapshot: async () => ({
|
|
||||||
directoryPath: '/tmp/videos',
|
|
||||||
directoryAvailable: true,
|
|
||||||
directoryStatus: '/tmp/videos',
|
|
||||||
directoryItems: [],
|
|
||||||
playlistItems: [],
|
|
||||||
playingIndex: 1,
|
|
||||||
currentFilePath: '/tmp/videos/ep2.mkv',
|
|
||||||
}),
|
|
||||||
appendPlaylistBrowserFile: async (filePath) => {
|
|
||||||
calls.push(['append', [filePath]]);
|
|
||||||
return { ok: true, message: 'append-ok' };
|
|
||||||
},
|
|
||||||
playPlaylistBrowserIndex: async (index) => {
|
|
||||||
calls.push(['play', [index]]);
|
|
||||||
return { ok: true, message: 'play-ok' };
|
|
||||||
},
|
|
||||||
removePlaylistBrowserIndex: async (index) => {
|
|
||||||
calls.push(['remove', [index]]);
|
|
||||||
return { ok: true, message: 'remove-ok' };
|
|
||||||
},
|
|
||||||
movePlaylistBrowserIndex: async (index, direction) => {
|
|
||||||
calls.push(['move', [index, direction]]);
|
|
||||||
return { ok: true, message: 'move-ok' };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.(
|
|
||||||
{},
|
|
||||||
)) as PlaylistBrowserSnapshot | undefined;
|
|
||||||
const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.(
|
|
||||||
{},
|
|
||||||
'/tmp/videos/ep3.mkv',
|
|
||||||
);
|
|
||||||
const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2);
|
|
||||||
const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.(
|
|
||||||
{},
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.(
|
|
||||||
{},
|
|
||||||
2,
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(snapshot?.playingIndex, 1);
|
|
||||||
assert.deepEqual(append, { ok: true, message: 'append-ok' });
|
|
||||||
assert.deepEqual(play, { ok: true, message: 'play-ok' });
|
|
||||||
assert.deepEqual(remove, { ok: true, message: 'remove-ok' });
|
|
||||||
assert.deepEqual(move, { ok: true, message: 'move-ok' });
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
['append', ['/tmp/videos/ep3.mkv']],
|
|
||||||
['play', [2]],
|
|
||||||
['remove', [2]],
|
|
||||||
['move', [2, -1]],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: Array<{ id: string; value: unknown }> = [];
|
const calls: Array<{ id: string; value: unknown }> = [];
|
||||||
@@ -610,45 +419,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const snapshot = createSubtitleSidebarSnapshotFixture();
|
|
||||||
snapshot.cues = [{ startTime: 1, endTime: 2, text: 'line-1' }];
|
|
||||||
snapshot.config.enabled = true;
|
|
||||||
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
getSubtitleSidebarSnapshot: async () => snapshot,
|
|
||||||
}),
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = handlers.handle.get(IPC_CHANNELS.request.getSubtitleSidebarSnapshot);
|
|
||||||
assert.ok(handler);
|
|
||||||
assert.deepEqual(await handler!({}), snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers exposes playback window activation request', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const calls: string[] = [];
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
activatePlaybackWindowForOverlayInteraction: async () => {
|
|
||||||
calls.push('activate');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = handlers.handle.get(
|
|
||||||
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
|
|
||||||
);
|
|
||||||
assert.ok(handler);
|
|
||||||
assert.equal(await handler!({}), true);
|
|
||||||
assert.deepEqual(calls, ['activate']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers accepts the keep-without-media timing decision', async () => {
|
test('registerIpcHandlers accepts the keep-without-media timing decision', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const requests: unknown[] = [];
|
const requests: unknown[] = [];
|
||||||
@@ -757,28 +527,6 @@ test('registerIpcHandlers validates and forwards timing review text and screensh
|
|||||||
assert.equal(requests.length, 1);
|
assert.equal(requests.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const calls: string[] = [];
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
immersionTracker: createFakeImmersionTracker({
|
|
||||||
recordYomitanLookup: () => {
|
|
||||||
calls.push('lookup');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup);
|
|
||||||
assert.equal(typeof handler, 'function');
|
|
||||||
|
|
||||||
handler?.({}, null);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['lookup']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => {
|
test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const contexts: unknown[] = [];
|
const contexts: unknown[] = [];
|
||||||
@@ -1369,3 +1117,39 @@ test('mpv discovery has its own request and does not change session bindings', a
|
|||||||
assert.deepEqual(await discovery({}), snapshot);
|
assert.deepEqual(await discovery({}), snapshot);
|
||||||
assert.deepEqual(await session({}), []);
|
assert.deepEqual(await session({}), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers validates playlist browser mutation payloads before forwarding', async () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const forwarded: unknown[][] = [];
|
||||||
|
const ok = { ok: true, message: 'ok' };
|
||||||
|
registerIpcHandlers(
|
||||||
|
createRegisterIpcDeps({
|
||||||
|
appendPlaylistBrowserFile: async (...args) => (forwarded.push(args), ok),
|
||||||
|
playPlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||||
|
removePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||||
|
movePlaylistBrowserIndex: async (...args) => (forwarded.push(args), ok),
|
||||||
|
}),
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
const { request } = IPC_CHANNELS;
|
||||||
|
const cases: Array<{ channel: string; args: unknown[]; valid: boolean }> = [
|
||||||
|
{ channel: request.appendPlaylistBrowserFile, args: ['/media/ep01.mkv'], valid: true },
|
||||||
|
{ channel: request.appendPlaylistBrowserFile, args: [' '], valid: false },
|
||||||
|
{ channel: request.appendPlaylistBrowserFile, args: [42], valid: false },
|
||||||
|
{ channel: request.playPlaylistBrowserIndex, args: [0], valid: true },
|
||||||
|
{ channel: request.playPlaylistBrowserIndex, args: [-1], valid: false },
|
||||||
|
{ channel: request.removePlaylistBrowserIndex, args: [1.5], valid: false },
|
||||||
|
{ channel: request.removePlaylistBrowserIndex, args: ['2'], valid: false },
|
||||||
|
{ channel: request.movePlaylistBrowserIndex, args: [2, -1], valid: true },
|
||||||
|
{ channel: request.movePlaylistBrowserIndex, args: [2, 2], valid: false },
|
||||||
|
{ channel: request.movePlaylistBrowserIndex, args: [Number.NaN, 1], valid: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { channel, args, valid } of cases) {
|
||||||
|
forwarded.length = 0;
|
||||||
|
const result = (await handlers.handle.get(channel)?.({}, ...args)) as { ok: boolean };
|
||||||
|
const label = `${channel}(${args.map(String).join(', ')})`;
|
||||||
|
assert.equal(result.ok, valid, label);
|
||||||
|
assert.deepEqual(forwarded, valid ? [args] : [], label);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import {
|
|
||||||
getIgnoredPos1Entries,
|
|
||||||
JLPT_EXCLUDED_TERMS,
|
|
||||||
JLPT_IGNORED_MECAB_POS1,
|
|
||||||
JLPT_IGNORED_MECAB_POS1_ENTRIES,
|
|
||||||
JLPT_IGNORED_MECAB_POS1_LIST,
|
|
||||||
shouldIgnoreJlptByTerm,
|
|
||||||
shouldIgnoreJlptForMecabPos1,
|
|
||||||
} from './jlpt-token-filter';
|
|
||||||
|
|
||||||
test('shouldIgnoreJlptByTerm matches the excluded JLPT lexical terms', () => {
|
|
||||||
assert.equal(shouldIgnoreJlptByTerm('この'), true);
|
|
||||||
assert.equal(shouldIgnoreJlptByTerm('そこ'), true);
|
|
||||||
assert.equal(shouldIgnoreJlptByTerm('猫'), false);
|
|
||||||
assert.deepEqual(Array.from(JLPT_EXCLUDED_TERMS), [
|
|
||||||
'この',
|
|
||||||
'その',
|
|
||||||
'あの',
|
|
||||||
'どの',
|
|
||||||
'これ',
|
|
||||||
'それ',
|
|
||||||
'あれ',
|
|
||||||
'どれ',
|
|
||||||
'ここ',
|
|
||||||
'そこ',
|
|
||||||
'あそこ',
|
|
||||||
'どこ',
|
|
||||||
'こと',
|
|
||||||
'ああ',
|
|
||||||
'ええ',
|
|
||||||
'うう',
|
|
||||||
'おお',
|
|
||||||
'はは',
|
|
||||||
'へえ',
|
|
||||||
'ふう',
|
|
||||||
'ほう',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldIgnoreJlptForMecabPos1 matches the exported ignored POS1 list', () => {
|
|
||||||
assert.equal(shouldIgnoreJlptForMecabPos1('助詞'), true);
|
|
||||||
assert.equal(shouldIgnoreJlptForMecabPos1('名詞'), false);
|
|
||||||
assert.deepEqual(JLPT_IGNORED_MECAB_POS1, JLPT_IGNORED_MECAB_POS1_LIST);
|
|
||||||
assert.deepEqual(
|
|
||||||
JLPT_IGNORED_MECAB_POS1_ENTRIES.map((entry) => entry.pos1),
|
|
||||||
JLPT_IGNORED_MECAB_POS1_LIST,
|
|
||||||
);
|
|
||||||
assert.deepEqual(getIgnoredPos1Entries(), JLPT_IGNORED_MECAB_POS1_ENTRIES);
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import { showMpvOsdRuntime } from './mpv';
|
||||||
playNextSubtitleRuntime,
|
|
||||||
replayCurrentSubtitleRuntime,
|
|
||||||
sendMpvCommandRuntime,
|
|
||||||
setMpvSubVisibilityRuntime,
|
|
||||||
showMpvOsdRuntime,
|
|
||||||
} from './mpv';
|
|
||||||
|
|
||||||
test('showMpvOsdRuntime sends show-text when connected', () => {
|
test('showMpvOsdRuntime sends show-text when connected', () => {
|
||||||
const commands: (string | number)[][] = [];
|
const commands: (string | number)[][] = [];
|
||||||
@@ -55,29 +49,3 @@ test('showMpvOsdRuntime logs fallback when disconnected', () => {
|
|||||||
assert.equal(shown, false);
|
assert.equal(shown, false);
|
||||||
assert.deepEqual(logs, ['OSD (MPV not connected): hello']);
|
assert.deepEqual(logs, ['OSD (MPV not connected): hello']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mpv runtime command wrappers call expected client methods', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const client = {
|
|
||||||
connected: true,
|
|
||||||
send: ({ command }: { command: (string | number)[] }) => {
|
|
||||||
calls.push(`send:${command.join(',')}`);
|
|
||||||
},
|
|
||||||
replayCurrentSubtitle: () => {
|
|
||||||
calls.push('replay');
|
|
||||||
},
|
|
||||||
playNextSubtitle: () => {
|
|
||||||
calls.push('next');
|
|
||||||
},
|
|
||||||
setSubVisibility: (visible: boolean) => {
|
|
||||||
calls.push(`subVisible:${visible}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
replayCurrentSubtitleRuntime(client);
|
|
||||||
playNextSubtitleRuntime(client);
|
|
||||||
sendMpvCommandRuntime(client, ['script-message', 'x']);
|
|
||||||
setMpvSubVisibilityRuntime(client, false);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['replay', 'next', 'send:script-message,x', 'subVisible:false']);
|
|
||||||
});
|
|
||||||
@@ -48,19 +48,6 @@ class HangingSocket extends FakeSocket {
|
|||||||
|
|
||||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
|
||||||
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
|
||||||
assert.equal(getMpvReconnectDelay(1, true), 1000);
|
|
||||||
assert.equal(getMpvReconnectDelay(2, true), 2000);
|
|
||||||
assert.equal(getMpvReconnectDelay(4, true), 5000);
|
|
||||||
assert.equal(getMpvReconnectDelay(7, true), 10000);
|
|
||||||
|
|
||||||
assert.equal(getMpvReconnectDelay(0, false), 200);
|
|
||||||
assert.equal(getMpvReconnectDelay(2, false), 500);
|
|
||||||
assert.equal(getMpvReconnectDelay(4, false), 1000);
|
|
||||||
assert.equal(getMpvReconnectDelay(6, false), 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scheduleMpvReconnect clears existing timer and increments attempt', () => {
|
test('scheduleMpvReconnect clears existing timer and increments attempt', () => {
|
||||||
const existing = {} as ReturnType<typeof setTimeout>;
|
const existing = {} as ReturnType<typeof setTimeout>;
|
||||||
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
|
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||||
|
|||||||
@@ -262,50 +262,6 @@ test('MpvIpcClient requestProperty throws on mpv error response', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MpvIpcClient connect does not log connect-request at info level', () => {
|
|
||||||
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
|
|
||||||
const originalInfo = console.info;
|
|
||||||
const infoLines: string[] = [];
|
|
||||||
process.env.SUBMINER_LOG_LEVEL = 'info';
|
|
||||||
console.info = (message?: unknown) => {
|
|
||||||
infoLines.push(String(message ?? ''));
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
|
||||||
(client as any).transport.connect = () => {};
|
|
||||||
client.connect();
|
|
||||||
} finally {
|
|
||||||
process.env.SUBMINER_LOG_LEVEL = originalLevel;
|
|
||||||
console.info = originalInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestLogs = infoLines.filter((line) => line.includes('MPV IPC connect requested.'));
|
|
||||||
assert.equal(requestLogs.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MpvIpcClient connect logs connect-request at debug level', () => {
|
|
||||||
const originalLevel = process.env.SUBMINER_LOG_LEVEL;
|
|
||||||
const originalDebug = console.debug;
|
|
||||||
const debugLines: string[] = [];
|
|
||||||
process.env.SUBMINER_LOG_LEVEL = 'debug';
|
|
||||||
console.debug = (message?: unknown) => {
|
|
||||||
debugLines.push(String(message ?? ''));
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
|
||||||
(client as any).transport.connect = () => {};
|
|
||||||
client.connect();
|
|
||||||
} finally {
|
|
||||||
process.env.SUBMINER_LOG_LEVEL = originalLevel;
|
|
||||||
console.debug = originalDebug;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestLogs = debugLines.filter((line) => line.includes('MPV IPC connect requested.'));
|
|
||||||
assert.equal(requestLogs.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
||||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -356,80 +312,6 @@ test('MpvIpcClient failPendingRequests resolves outstanding requests as disconne
|
|||||||
assert.equal((client as any).pendingRequests.size, 0);
|
assert.equal((client as any).pendingRequests.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MpvIpcClient scheduleReconnect schedules timer and invokes connect', () => {
|
|
||||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
|
||||||
const client = new MpvIpcClient(
|
|
||||||
'/tmp/mpv.sock',
|
|
||||||
makeDeps({
|
|
||||||
getReconnectTimer: () => null,
|
|
||||||
setReconnectTimer: (timer) => {
|
|
||||||
timers.push(timer);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let connectCalled = false;
|
|
||||||
(client as any).connect = () => {
|
|
||||||
connectCalled = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalSetTimeout = globalThis.setTimeout;
|
|
||||||
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
|
||||||
handler();
|
|
||||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
(client as any).scheduleReconnect();
|
|
||||||
} finally {
|
|
||||||
(globalThis as any).setTimeout = originalSetTimeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(timers.length, 1);
|
|
||||||
assert.equal(connectCalled, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MpvIpcClient scheduleReconnect clears existing reconnect timer', () => {
|
|
||||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
|
||||||
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
|
|
||||||
const existingTimer = {} as ReturnType<typeof setTimeout>;
|
|
||||||
const client = new MpvIpcClient(
|
|
||||||
'/tmp/mpv.sock',
|
|
||||||
makeDeps({
|
|
||||||
getReconnectTimer: () => existingTimer,
|
|
||||||
setReconnectTimer: (timer) => {
|
|
||||||
timers.push(timer);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let connectCalled = false;
|
|
||||||
(client as any).connect = () => {
|
|
||||||
connectCalled = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalSetTimeout = globalThis.setTimeout;
|
|
||||||
const originalClearTimeout = globalThis.clearTimeout;
|
|
||||||
(globalThis as any).setTimeout = (handler: () => void, _delay: number) => {
|
|
||||||
handler();
|
|
||||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
||||||
};
|
|
||||||
(globalThis as any).clearTimeout = (timer: ReturnType<typeof setTimeout> | null) => {
|
|
||||||
cleared.push(timer);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
(client as any).scheduleReconnect();
|
|
||||||
} finally {
|
|
||||||
(globalThis as any).setTimeout = originalSetTimeout;
|
|
||||||
(globalThis as any).clearTimeout = originalClearTimeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(cleared.length, 1);
|
|
||||||
assert.equal(cleared[0], existingTimer);
|
|
||||||
assert.equal(timers.length, 1);
|
|
||||||
assert.equal(connectCalled, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => {
|
test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect', () => {
|
||||||
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
|
||||||
const client = new MpvIpcClient(
|
const client = new MpvIpcClient(
|
||||||
|
|||||||
@@ -1,51 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createNumericShortcutRuntime, createNumericShortcutSession } from './numeric-shortcut';
|
import { createNumericShortcutSession } from './numeric-shortcut';
|
||||||
|
|
||||||
test('createNumericShortcutRuntime creates sessions wired to globalShortcut', () => {
|
|
||||||
const registered: string[] = [];
|
|
||||||
const unregistered: string[] = [];
|
|
||||||
const osd: string[] = [];
|
|
||||||
const handlers = new Map<string, () => void>();
|
|
||||||
|
|
||||||
const runtime = createNumericShortcutRuntime({
|
|
||||||
globalShortcut: {
|
|
||||||
register: (accelerator, callback) => {
|
|
||||||
registered.push(accelerator);
|
|
||||||
handlers.set(accelerator, callback);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
unregister: (accelerator) => {
|
|
||||||
unregistered.push(accelerator);
|
|
||||||
handlers.delete(accelerator);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => {
|
|
||||||
osd.push(text);
|
|
||||||
},
|
|
||||||
setTimer: () => setTimeout(() => {}, 1000),
|
|
||||||
clearTimer: (timer) => clearTimeout(timer),
|
|
||||||
});
|
|
||||||
|
|
||||||
const session = runtime.createSession();
|
|
||||||
session.start({
|
|
||||||
timeoutMs: 5000,
|
|
||||||
onDigit: () => {},
|
|
||||||
messages: {
|
|
||||||
prompt: 'Select count',
|
|
||||||
timeout: 'Timed out',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(session.isActive(), true);
|
|
||||||
assert.ok(registered.includes('1'));
|
|
||||||
assert.ok(registered.includes('Escape'));
|
|
||||||
assert.equal(osd[0], 'Select count');
|
|
||||||
|
|
||||||
handlers.get('Escape')?.();
|
|
||||||
assert.equal(session.isActive(), false);
|
|
||||||
assert.ok(unregistered.includes('Escape'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('numeric shortcut session handles digit selection and unregisters shortcuts', () => {
|
test('numeric shortcut session handles digit selection and unregisters shortcuts', () => {
|
||||||
const handlers = new Map<string, () => void>();
|
const handlers = new Map<string, () => void>();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { KikuFieldGroupingChoice } from '../../types';
|
import { sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
|
||||||
|
|
||||||
test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => {
|
test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => {
|
||||||
const sent: unknown[][] = [];
|
const sent: unknown[][] = [];
|
||||||
@@ -72,39 +71,3 @@ test('sendToVisibleOverlayRuntime waits for overlay page before sending open com
|
|||||||
|
|
||||||
assert.deepEqual(sent, [['runtime-options:open']]);
|
assert.deepEqual(sent, [['runtime-options:open']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createFieldGroupingCallbackRuntime cancels when overlay request cannot be sent', async () => {
|
|
||||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
|
||||||
const callback = createFieldGroupingCallbackRuntime<'runtime-options' | 'subsync'>({
|
|
||||||
getVisibleOverlayVisible: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
getResolver: () => resolver,
|
|
||||||
setResolver: (next) => {
|
|
||||||
resolver = next;
|
|
||||||
},
|
|
||||||
sendToVisibleOverlay: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await callback({
|
|
||||||
original: {
|
|
||||||
noteId: 1,
|
|
||||||
expression: 'a',
|
|
||||||
sentencePreview: 'a',
|
|
||||||
hasAudio: false,
|
|
||||||
hasImage: false,
|
|
||||||
isOriginal: true,
|
|
||||||
},
|
|
||||||
duplicate: {
|
|
||||||
noteId: 2,
|
|
||||||
expression: 'b',
|
|
||||||
sentencePreview: 'b',
|
|
||||||
hasAudio: false,
|
|
||||||
hasImage: false,
|
|
||||||
isOriginal: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.cancelled, true);
|
|
||||||
assert.equal(result.keepNoteId, 0);
|
|
||||||
assert.equal(result.deleteNoteId, 0);
|
|
||||||
});
|
|
||||||
@@ -1,37 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
broadcastRuntimeOptionsChangedRuntime,
|
|
||||||
createOverlayManager,
|
createOverlayManager,
|
||||||
setOverlayDebugVisualizationEnabledRuntime,
|
setOverlayDebugVisualizationEnabledRuntime,
|
||||||
} from './overlay-manager';
|
} from './overlay-manager';
|
||||||
|
|
||||||
test('overlay manager initializes with empty windows and hidden overlays', () => {
|
|
||||||
const manager = createOverlayManager();
|
|
||||||
assert.equal(manager.getMainWindow(), null);
|
|
||||||
assert.equal(manager.getModalWindow(), null);
|
|
||||||
assert.equal(manager.getVisibleOverlayVisible(), false);
|
|
||||||
assert.deepEqual(manager.getOverlayWindows(), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overlay manager stores window references and returns stable window order', () => {
|
|
||||||
const manager = createOverlayManager();
|
|
||||||
const visibleWindow = {
|
|
||||||
isDestroyed: () => false,
|
|
||||||
} as unknown as Electron.BrowserWindow;
|
|
||||||
const modalWindow = {
|
|
||||||
isDestroyed: () => false,
|
|
||||||
} as unknown as Electron.BrowserWindow;
|
|
||||||
|
|
||||||
manager.setMainWindow(visibleWindow);
|
|
||||||
manager.setModalWindow(modalWindow);
|
|
||||||
|
|
||||||
assert.equal(manager.getMainWindow(), visibleWindow);
|
|
||||||
assert.equal(manager.getModalWindow(), modalWindow);
|
|
||||||
assert.equal(manager.getOverlayWindow(), visibleWindow);
|
|
||||||
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overlay manager excludes destroyed windows', () => {
|
test('overlay manager excludes destroyed windows', () => {
|
||||||
const manager = createOverlayManager();
|
const manager = createOverlayManager();
|
||||||
manager.setMainWindow({
|
manager.setMainWindow({
|
||||||
@@ -44,13 +17,6 @@ test('overlay manager excludes destroyed windows', () => {
|
|||||||
assert.equal(manager.getOverlayWindows().length, 0);
|
assert.equal(manager.getOverlayWindows().length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overlay manager stores visibility state', () => {
|
|
||||||
const manager = createOverlayManager();
|
|
||||||
|
|
||||||
manager.setVisibleOverlayVisible(true);
|
|
||||||
assert.equal(manager.getVisibleOverlayVisible(), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overlay manager broadcasts to non-destroyed windows', () => {
|
test('overlay manager broadcasts to non-destroyed windows', () => {
|
||||||
const manager = createOverlayManager();
|
const manager = createOverlayManager();
|
||||||
const calls: unknown[][] = [];
|
const calls: unknown[][] = [];
|
||||||
@@ -136,19 +102,13 @@ test('overlay manager can suppress z-order promotion during bounds updates', ()
|
|||||||
assert.deepEqual(calls, ['promote:false']);
|
assert.deepEqual(calls, ['promote:false']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runtime-option broadcast still uses expected channel', () => {
|
test('setOverlayDebugVisualizationEnabledRuntime only updates state when the value changes', () => {
|
||||||
const broadcasts: unknown[][] = [];
|
|
||||||
broadcastRuntimeOptionsChangedRuntime(
|
|
||||||
() => [],
|
|
||||||
(channel, ...args) => {
|
|
||||||
broadcasts.push([channel, ...args]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let state = false;
|
let state = false;
|
||||||
const changed = setOverlayDebugVisualizationEnabledRuntime(state, true, (enabled) => {
|
const setState = (enabled: boolean) => {
|
||||||
state = enabled;
|
state = enabled;
|
||||||
});
|
};
|
||||||
assert.equal(changed, true);
|
|
||||||
|
assert.equal(setOverlayDebugVisualizationEnabledRuntime(state, false, setState), false);
|
||||||
|
assert.equal(setOverlayDebugVisualizationEnabledRuntime(state, true, setState), true);
|
||||||
assert.equal(state, true);
|
assert.equal(state, true);
|
||||||
assert.deepEqual(broadcasts, [['runtime-options:changed', []]]);
|
|
||||||
});
|
});
|
||||||
@@ -1,103 +1,53 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import type { WindowGeometry } from '../../types';
|
||||||
import {
|
import {
|
||||||
initializeOverlayAnkiIntegration,
|
initializeOverlayAnkiIntegration,
|
||||||
initializeOverlayRuntime,
|
initializeOverlayRuntime,
|
||||||
startOverlayWindowTracker,
|
startOverlayWindowTracker,
|
||||||
} from './overlay-runtime-init';
|
} from './overlay-runtime-init';
|
||||||
|
|
||||||
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
|
type AnkiOptions = Parameters<typeof initializeOverlayAnkiIntegration>[0];
|
||||||
const calls: string[] = [];
|
type RuntimeOptions = Parameters<typeof initializeOverlayRuntime>[0];
|
||||||
const tracker = {
|
type CreateAnkiIntegrationArgs = Parameters<NonNullable<AnkiOptions['createAnkiIntegration']>>[0];
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
function createFakeTracker(targetMinimized = false) {
|
||||||
|
return {
|
||||||
|
onGeometryChange: null as ((geometry: WindowGeometry) => void) | null,
|
||||||
|
onWindowFound: null as ((geometry: WindowGeometry) => void) | null,
|
||||||
onWindowLost: null as (() => void) | null,
|
onWindowLost: null as (() => void) | null,
|
||||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
||||||
isTargetWindowMinimized: () => false,
|
isTargetWindowMinimized: () => targetMinimized,
|
||||||
start: () => {
|
started: false,
|
||||||
calls.push('start');
|
start() {
|
||||||
|
this.started = true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = startOverlayWindowTracker({
|
function createAnkiHarness(overrides: Partial<AnkiOptions> = {}) {
|
||||||
backendOverride: 'windows',
|
const state = {
|
||||||
getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket',
|
created: [] as CreateAnkiIntegrationArgs[],
|
||||||
createWindowTracker: (override, socketPath) => {
|
started: 0,
|
||||||
calls.push(`create:${override}:${socketPath}`);
|
stored: [] as unknown[],
|
||||||
return tracker as never;
|
};
|
||||||
},
|
const options: AnkiOptions = {
|
||||||
setWindowTracker: (nextTracker) => {
|
getResolvedConfig: () => ({ ankiConnect: { enabled: true } as never }),
|
||||||
calls.push(nextTracker === tracker ? 'set-tracker' : 'clear-tracker');
|
|
||||||
},
|
|
||||||
updateVisibleOverlayBounds: () => {
|
|
||||||
calls.push('bounds');
|
|
||||||
},
|
|
||||||
isVisibleOverlayVisible: () => true,
|
|
||||||
updateVisibleOverlayVisibility: () => {
|
|
||||||
calls.push('visibility');
|
|
||||||
},
|
|
||||||
refreshCurrentSubtitle: () => {
|
|
||||||
calls.push('refresh-subtitle');
|
|
||||||
},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result, tracker);
|
|
||||||
tracker.onWindowFound?.({ x: 10, y: 20, width: 300, height: 200 });
|
|
||||||
tracker.onWindowFocusChange?.(true);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'create:windows:\\\\.\\pipe\\subminer-socket',
|
|
||||||
'set-tracker',
|
|
||||||
'start',
|
|
||||||
'bounds',
|
|
||||||
'visibility',
|
|
||||||
'refresh-subtitle',
|
|
||||||
'visibility',
|
|
||||||
'sync-shortcuts',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
|
||||||
let createdIntegrations = 0;
|
|
||||||
let startedIntegrations = 0;
|
|
||||||
let setIntegrationCalls = 0;
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
|
||||||
backendOverride: null,
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
isVisibleOverlayVisible: () => false,
|
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
setWindowTracker: () => {},
|
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
createWindowTracker: () => null,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: false } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => ({}),
|
getSubtitleTimingTracker: () => ({}),
|
||||||
getMpvClient: () => ({
|
getMpvClient: () => ({ send: () => {} }),
|
||||||
send: () => {},
|
|
||||||
}),
|
|
||||||
getRuntimeOptionsManager: () => ({
|
getRuntimeOptionsManager: () => ({
|
||||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||||
}),
|
}),
|
||||||
createAnkiIntegration: () => {
|
createAnkiIntegration: (args) => {
|
||||||
createdIntegrations += 1;
|
state.created.push(args);
|
||||||
return {
|
return {
|
||||||
start: () => {
|
start: () => {
|
||||||
startedIntegrations += 1;
|
state.started += 1;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setAnkiIntegration: () => {
|
setAnkiIntegration: (integration) => {
|
||||||
setIntegrationCalls += 1;
|
state.stored.push(integration);
|
||||||
},
|
},
|
||||||
showDesktopNotification: () => {},
|
showDesktopNotification: () => {},
|
||||||
createFieldGroupingCallback: () => async () => ({
|
createFieldGroupingCallback: () => async () => ({
|
||||||
@@ -107,264 +57,149 @@ test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled i
|
|||||||
cancelled: false,
|
cancelled: false,
|
||||||
}),
|
}),
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { options, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs initializeOverlayRuntime with Anki disabled and returns the fake tracker plus a
|
||||||
|
* snapshot of what the tracker callbacks did since initialization finished.
|
||||||
|
*/
|
||||||
|
function createTrackerHarness(config: { overlayVisible: boolean; targetMinimized?: boolean }) {
|
||||||
|
const tracker = createFakeTracker(config.targetMinimized);
|
||||||
|
const fresh = () => ({
|
||||||
|
bounds: [] as WindowGeometry[],
|
||||||
|
visibilityRefreshes: 0,
|
||||||
|
subtitleRefreshes: 0,
|
||||||
|
shortcutSyncs: 0,
|
||||||
|
hiddenWindows: [] as string[],
|
||||||
});
|
});
|
||||||
|
let state = fresh();
|
||||||
|
const overlayWindows = ['visible', 'modal'].map((name) => ({
|
||||||
|
hide: () => {
|
||||||
|
state.hiddenWindows.push(name);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
assert.equal(createdIntegrations, 0);
|
const options: RuntimeOptions = {
|
||||||
assert.equal(startedIntegrations, 0);
|
...createAnkiHarness({
|
||||||
assert.equal(setIntegrationCalls, 0);
|
getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }),
|
||||||
});
|
}).options,
|
||||||
|
|
||||||
test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled is true', () => {
|
|
||||||
let createdIntegrations = 0;
|
|
||||||
let startedIntegrations = 0;
|
|
||||||
let setIntegrationCalls = 0;
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
|
||||||
backendOverride: null,
|
backendOverride: null,
|
||||||
createMainWindow: () => {},
|
createMainWindow: () => {},
|
||||||
registerGlobalShortcuts: () => {},
|
registerGlobalShortcuts: () => {},
|
||||||
updateVisibleOverlayBounds: () => {},
|
updateVisibleOverlayBounds: (geometry) => {
|
||||||
isVisibleOverlayVisible: () => false,
|
state.bounds.push(geometry);
|
||||||
updateVisibleOverlayVisibility: () => {},
|
},
|
||||||
getOverlayWindows: () => [],
|
isVisibleOverlayVisible: () => config.overlayVisible,
|
||||||
syncOverlayShortcuts: () => {},
|
updateVisibleOverlayVisibility: () => {
|
||||||
|
state.visibilityRefreshes += 1;
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
state.subtitleRefreshes += 1;
|
||||||
|
},
|
||||||
|
getOverlayWindows: () => overlayWindows as never,
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
state.shortcutSyncs += 1;
|
||||||
|
},
|
||||||
setWindowTracker: () => {},
|
setWindowTracker: () => {},
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
createWindowTracker: () => null,
|
createWindowTracker: () => tracker as never,
|
||||||
getResolvedConfig: () => ({
|
};
|
||||||
ankiConnect: { enabled: true } as never,
|
|
||||||
}),
|
initializeOverlayRuntime(options);
|
||||||
getSubtitleTimingTracker: () => ({}),
|
state = fresh();
|
||||||
getMpvClient: () => ({
|
|
||||||
send: () => {},
|
return { tracker, snapshot: () => state };
|
||||||
}),
|
}
|
||||||
getRuntimeOptionsManager: () => ({
|
|
||||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
|
||||||
}),
|
const tracker = createFakeTracker();
|
||||||
createAnkiIntegration: (args) => {
|
const created: Array<[string | null | undefined, string | null | undefined]> = [];
|
||||||
createdIntegrations += 1;
|
const stored: unknown[] = [];
|
||||||
assert.equal(args.config.enabled, true);
|
const bounds: WindowGeometry[] = [];
|
||||||
return {
|
let visibilityRefreshes = 0;
|
||||||
start: () => {
|
let handlersInstalledAtStart = false;
|
||||||
startedIntegrations += 1;
|
tracker.start = () => {
|
||||||
},
|
handlersInstalledAtStart = tracker.onWindowFound !== null && tracker.onWindowLost !== null;
|
||||||
};
|
tracker.started = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = startOverlayWindowTracker({
|
||||||
|
backendOverride: 'windows',
|
||||||
|
getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket',
|
||||||
|
createWindowTracker: (override, socketPath) => {
|
||||||
|
created.push([override, socketPath]);
|
||||||
|
return tracker as never;
|
||||||
},
|
},
|
||||||
setAnkiIntegration: () => {
|
setWindowTracker: (nextTracker) => {
|
||||||
setIntegrationCalls += 1;
|
stored.push(nextTracker);
|
||||||
},
|
},
|
||||||
showDesktopNotification: () => {},
|
updateVisibleOverlayBounds: (geometry) => {
|
||||||
createFieldGroupingCallback: () => async () => ({
|
bounds.push(geometry);
|
||||||
keepNoteId: 3,
|
|
||||||
deleteNoteId: 4,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(createdIntegrations, 1);
|
|
||||||
assert.equal(startedIntegrations, 1);
|
|
||||||
assert.equal(setIntegrationCalls, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayAnkiIntegration can initialize Anki transport after overlay runtime already exists', () => {
|
|
||||||
let createdIntegrations = 0;
|
|
||||||
let startedIntegrations = 0;
|
|
||||||
let setIntegrationCalls = 0;
|
|
||||||
|
|
||||||
initializeOverlayAnkiIntegration({
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: true } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => ({}),
|
|
||||||
getMpvClient: () => ({
|
|
||||||
send: () => {},
|
|
||||||
}),
|
|
||||||
getRuntimeOptionsManager: () => ({
|
|
||||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
|
||||||
}),
|
|
||||||
createAnkiIntegration: (args) => {
|
|
||||||
createdIntegrations += 1;
|
|
||||||
assert.equal(args.config.enabled, true);
|
|
||||||
return {
|
|
||||||
start: () => {
|
|
||||||
startedIntegrations += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
setAnkiIntegration: () => {
|
isVisibleOverlayVisible: () => true,
|
||||||
setIntegrationCalls += 1;
|
updateVisibleOverlayVisibility: () => {
|
||||||
|
visibilityRefreshes += 1;
|
||||||
},
|
},
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 11,
|
|
||||||
deleteNoteId: 12,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(createdIntegrations, 1);
|
|
||||||
assert.equal(startedIntegrations, 1);
|
|
||||||
assert.equal(setIntegrationCalls, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayAnkiIntegration returns false when integration already exists', () => {
|
|
||||||
let createdIntegrations = 0;
|
|
||||||
let startedIntegrations = 0;
|
|
||||||
let setIntegrationCalls = 0;
|
|
||||||
|
|
||||||
const result = initializeOverlayAnkiIntegration({
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: true } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => ({}),
|
|
||||||
getMpvClient: () => ({
|
|
||||||
send: () => {},
|
|
||||||
}),
|
|
||||||
getRuntimeOptionsManager: () => ({
|
|
||||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
|
||||||
}),
|
|
||||||
getAnkiIntegration: () => ({}),
|
|
||||||
createAnkiIntegration: () => {
|
|
||||||
createdIntegrations += 1;
|
|
||||||
return {
|
|
||||||
start: () => {
|
|
||||||
startedIntegrations += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setAnkiIntegration: () => {
|
|
||||||
setIntegrationCalls += 1;
|
|
||||||
},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 11,
|
|
||||||
deleteNoteId: 12,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result, false);
|
|
||||||
assert.equal(createdIntegrations, 0);
|
|
||||||
assert.equal(startedIntegrations, 0);
|
|
||||||
assert.equal(setIntegrationCalls, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayAnkiIntegration returns false when ankiConnect is disabled', () => {
|
|
||||||
let createdIntegrations = 0;
|
|
||||||
let startedIntegrations = 0;
|
|
||||||
let setIntegrationCalls = 0;
|
|
||||||
|
|
||||||
const result = initializeOverlayAnkiIntegration({
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: false } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => ({}),
|
|
||||||
getMpvClient: () => ({
|
|
||||||
send: () => {},
|
|
||||||
}),
|
|
||||||
getRuntimeOptionsManager: () => ({
|
|
||||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
|
||||||
}),
|
|
||||||
createAnkiIntegration: () => {
|
|
||||||
createdIntegrations += 1;
|
|
||||||
return {
|
|
||||||
start: () => {
|
|
||||||
startedIntegrations += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setAnkiIntegration: () => {
|
|
||||||
setIntegrationCalls += 1;
|
|
||||||
},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 11,
|
|
||||||
deleteNoteId: 12,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result, false);
|
|
||||||
assert.equal(createdIntegrations, 0);
|
|
||||||
assert.equal(startedIntegrations, 0);
|
|
||||||
assert.equal(setIntegrationCalls, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
|
||||||
let createdIntegrations = 0;
|
|
||||||
let startedIntegrations = 0;
|
|
||||||
let setIntegrationCalls = 0;
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
|
||||||
backendOverride: null,
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
isVisibleOverlayVisible: () => false,
|
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
getOverlayWindows: () => [],
|
||||||
syncOverlayShortcuts: () => {},
|
syncOverlayShortcuts: () => {},
|
||||||
setWindowTracker: () => {},
|
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
createWindowTracker: () => null,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: true } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => ({}),
|
|
||||||
getMpvClient: () => ({
|
|
||||||
send: () => {},
|
|
||||||
}),
|
|
||||||
getRuntimeOptionsManager: () => ({
|
|
||||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
|
||||||
}),
|
|
||||||
createAnkiIntegration: () => {
|
|
||||||
createdIntegrations += 1;
|
|
||||||
return {
|
|
||||||
start: () => {
|
|
||||||
startedIntegrations += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setAnkiIntegration: () => {
|
|
||||||
setIntegrationCalls += 1;
|
|
||||||
},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 7,
|
|
||||||
deleteNoteId: 8,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
shouldStartAnkiIntegration: () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(createdIntegrations, 1);
|
const geometry = { x: 10, y: 20, width: 300, height: 200 };
|
||||||
assert.equal(startedIntegrations, 0);
|
tracker.onWindowFound?.(geometry);
|
||||||
assert.equal(setIntegrationCalls, 1);
|
|
||||||
|
assert.equal(result, tracker);
|
||||||
|
assert.deepEqual(created, [['windows', '\\\\.\\pipe\\subminer-socket']]);
|
||||||
|
assert.deepEqual(stored, [tracker]);
|
||||||
|
assert.equal(tracker.started, true);
|
||||||
|
assert.equal(handlersInstalledAtStart, true);
|
||||||
|
assert.deepEqual(bounds, [geometry]);
|
||||||
|
assert.equal(visibilityRefreshes, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime merges shared ai config with Anki overrides', () => {
|
const ankiNotCreatedCases: Array<{ name: string; overrides: Partial<AnkiOptions> }> = [
|
||||||
initializeOverlayRuntime({
|
{
|
||||||
backendOverride: null,
|
name: 'ankiConnect is disabled',
|
||||||
createMainWindow: () => {},
|
overrides: { getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }) },
|
||||||
registerGlobalShortcuts: () => {},
|
},
|
||||||
updateVisibleOverlayBounds: () => {},
|
{ name: 'an integration already exists', overrides: { getAnkiIntegration: () => ({}) } },
|
||||||
isVisibleOverlayVisible: () => false,
|
];
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
for (const c of ankiNotCreatedCases) {
|
||||||
syncOverlayShortcuts: () => {},
|
test(`initializeOverlayAnkiIntegration returns false when ${c.name}`, () => {
|
||||||
setWindowTracker: () => {},
|
const { options, state } = createAnkiHarness(c.overrides);
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
createWindowTracker: () => null,
|
assert.equal(initializeOverlayAnkiIntegration(options), false);
|
||||||
|
assert.equal(state.created.length, 0);
|
||||||
|
assert.equal(state.started, 0);
|
||||||
|
assert.equal(state.stored.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration creates, starts, and stores the integration when enabled', () => {
|
||||||
|
const { options, state } = createAnkiHarness();
|
||||||
|
|
||||||
|
assert.equal(initializeOverlayAnkiIntegration(options), true);
|
||||||
|
assert.equal(state.created.length, 1);
|
||||||
|
assert.equal(state.created[0]!.config.enabled, true);
|
||||||
|
assert.equal(state.started, 1);
|
||||||
|
assert.equal(state.stored.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration can skip starting the Anki integration transport', () => {
|
||||||
|
const { options, state } = createAnkiHarness({ shouldStartAnkiIntegration: () => false });
|
||||||
|
|
||||||
|
initializeOverlayAnkiIntegration(options);
|
||||||
|
|
||||||
|
assert.equal(state.created.length, 1);
|
||||||
|
assert.equal(state.started, 0);
|
||||||
|
assert.equal(state.stored.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration merges shared ai config with Anki overrides', () => {
|
||||||
|
const { options, state } = createAnkiHarness({
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
ankiConnect: {
|
ankiConnect: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -383,45 +218,23 @@ test('initializeOverlayRuntime merges shared ai config with Anki overrides', ()
|
|||||||
requestTimeoutMs: 15000,
|
requestTimeoutMs: 15000,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
getSubtitleTimingTracker: () => ({}),
|
|
||||||
getMpvClient: () => ({
|
|
||||||
send: () => {},
|
|
||||||
}),
|
|
||||||
getRuntimeOptionsManager: () => ({
|
|
||||||
getEffectiveAnkiConnectConfig: (config) => config as never,
|
|
||||||
}),
|
|
||||||
createAnkiIntegration: (args) => {
|
|
||||||
assert.equal(args.aiConfig.apiKey, 'shared-key');
|
|
||||||
assert.equal(args.aiConfig.baseUrl, 'https://openrouter.ai/api');
|
|
||||||
assert.equal(args.aiConfig.model, 'openrouter/anki-model');
|
|
||||||
assert.equal(args.aiConfig.systemPrompt, 'Translate mined sentence text.');
|
|
||||||
return {
|
|
||||||
start: () => {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setAnkiIntegration: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 5,
|
|
||||||
deleteNoteId: 6,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initializeOverlayAnkiIntegration(options);
|
||||||
|
|
||||||
|
const aiConfig = state.created[0]!.aiConfig;
|
||||||
|
assert.equal(aiConfig.apiKey, 'shared-key');
|
||||||
|
assert.equal(aiConfig.baseUrl, 'https://openrouter.ai/api');
|
||||||
|
assert.equal(aiConfig.model, 'openrouter/anki-model');
|
||||||
|
assert.equal(aiConfig.systemPrompt, 'Translate mined sentence text.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus changes', () => {
|
test('initializeOverlayRuntime initializes the Anki integration after the window tracker starts', () => {
|
||||||
let syncCalls = 0;
|
const { options: ankiOptions, state } = createAnkiHarness();
|
||||||
const tracker = {
|
const tracker = createFakeTracker();
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowLost: null as (() => void) | null,
|
|
||||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
|
||||||
start: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
initializeOverlayRuntime({
|
||||||
|
...ankiOptions,
|
||||||
backendOverride: null,
|
backendOverride: null,
|
||||||
createMainWindow: () => {},
|
createMainWindow: () => {},
|
||||||
registerGlobalShortcuts: () => {},
|
registerGlobalShortcuts: () => {},
|
||||||
@@ -429,287 +242,76 @@ test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus cha
|
|||||||
isVisibleOverlayVisible: () => false,
|
isVisibleOverlayVisible: () => false,
|
||||||
updateVisibleOverlayVisibility: () => {},
|
updateVisibleOverlayVisibility: () => {},
|
||||||
getOverlayWindows: () => [],
|
getOverlayWindows: () => [],
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
syncCalls += 1;
|
|
||||||
},
|
|
||||||
setWindowTracker: () => {},
|
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
createWindowTracker: () => tracker as never,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: false } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
setAnkiIntegration: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 1,
|
|
||||||
deleteNoteId: 2,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(typeof tracker.onWindowFocusChange, 'function');
|
|
||||||
tracker.onWindowFocusChange?.(true);
|
|
||||||
assert.equal(syncCalls, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayRuntime refreshes visible overlay when tracker focus changes while overlay is shown', () => {
|
|
||||||
let visibilityRefreshCalls = 0;
|
|
||||||
const tracker = {
|
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowLost: null as (() => void) | null,
|
|
||||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
|
||||||
start: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
|
||||||
backendOverride: null,
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
isVisibleOverlayVisible: () => true,
|
|
||||||
updateVisibleOverlayVisibility: () => {
|
|
||||||
visibilityRefreshCalls += 1;
|
|
||||||
},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
syncOverlayShortcuts: () => {},
|
syncOverlayShortcuts: () => {},
|
||||||
setWindowTracker: () => {},
|
setWindowTracker: () => {},
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
createWindowTracker: () => tracker as never,
|
createWindowTracker: () => tracker as never,
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: false } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
setAnkiIntegration: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 1,
|
|
||||||
deleteNoteId: 2,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tracker.onWindowFocusChange?.(true);
|
assert.equal(tracker.started, true);
|
||||||
|
assert.equal(state.created.length, 1);
|
||||||
assert.equal(visibilityRefreshCalls, 2);
|
assert.equal(state.started, 1);
|
||||||
|
assert.equal(state.stored.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => {
|
const trackerEventCases: Array<{
|
||||||
let subtitleRefreshCalls = 0;
|
name: string;
|
||||||
const tracker = {
|
overlayVisible: boolean;
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
targetMinimized?: boolean;
|
||||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
trigger: (tracker: ReturnType<typeof createFakeTracker>) => void;
|
||||||
onWindowLost: null as (() => void) | null,
|
expected: Partial<ReturnType<ReturnType<typeof createTrackerHarness>['snapshot']>>;
|
||||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
}> = [
|
||||||
start: () => {},
|
{
|
||||||
};
|
name: 'refreshes the visible overlay and re-syncs shortcuts when focus changes while shown',
|
||||||
|
overlayVisible: true,
|
||||||
initializeOverlayRuntime({
|
trigger: (tracker) => tracker.onWindowFocusChange?.(true),
|
||||||
backendOverride: null,
|
expected: { visibilityRefreshes: 1, shortcutSyncs: 1 },
|
||||||
createMainWindow: () => {},
|
},
|
||||||
registerGlobalShortcuts: () => {},
|
{
|
||||||
updateVisibleOverlayBounds: () => {},
|
name: 'only re-syncs shortcuts when focus changes while the overlay is hidden',
|
||||||
isVisibleOverlayVisible: () => true,
|
overlayVisible: false,
|
||||||
updateVisibleOverlayVisibility: () => {},
|
trigger: (tracker) => tracker.onWindowFocusChange?.(true),
|
||||||
refreshCurrentSubtitle: () => {
|
expected: { shortcutSyncs: 1 },
|
||||||
subtitleRefreshCalls += 1;
|
},
|
||||||
|
{
|
||||||
|
name: 'restores bounds, visibility, and subtitle when the tracker finds the target window again',
|
||||||
|
overlayVisible: true,
|
||||||
|
trigger: (tracker) => tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 }),
|
||||||
|
expected: {
|
||||||
|
bounds: [{ x: 100, y: 200, width: 1280, height: 720 }],
|
||||||
|
visibilityRefreshes: 1,
|
||||||
|
subtitleRefreshes: 1,
|
||||||
},
|
},
|
||||||
getOverlayWindows: () => [],
|
},
|
||||||
syncOverlayShortcuts: () => {},
|
{
|
||||||
setWindowTracker: () => {},
|
name: 'hides overlay windows when the tracker loses a minimized target window',
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
overlayVisible: true,
|
||||||
createWindowTracker: () => tracker as never,
|
targetMinimized: true,
|
||||||
getResolvedConfig: () => ({
|
trigger: (tracker) => tracker.onWindowLost?.(),
|
||||||
ankiConnect: { enabled: false } as never,
|
expected: { hiddenWindows: ['visible', 'modal'], shortcutSyncs: 1 },
|
||||||
}),
|
},
|
||||||
getSubtitleTimingTracker: () => null,
|
{
|
||||||
getMpvClient: () => null,
|
name: 'refreshes visibility instead of hiding when the tracker loses a non-minimized target',
|
||||||
getRuntimeOptionsManager: () => null,
|
overlayVisible: true,
|
||||||
setAnkiIntegration: () => {},
|
targetMinimized: false,
|
||||||
showDesktopNotification: () => {},
|
trigger: (tracker) => tracker.onWindowLost?.(),
|
||||||
createFieldGroupingCallback: () => async () => ({
|
expected: { visibilityRefreshes: 1 },
|
||||||
keepNoteId: 1,
|
},
|
||||||
deleteNoteId: 2,
|
];
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
for (const c of trackerEventCases) {
|
||||||
}),
|
test(`initializeOverlayRuntime ${c.name}`, () => {
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
const { tracker, snapshot } = createTrackerHarness(c);
|
||||||
|
|
||||||
|
c.trigger(tracker);
|
||||||
|
|
||||||
|
assert.deepEqual(snapshot(), {
|
||||||
|
bounds: [],
|
||||||
|
visibilityRefreshes: 0,
|
||||||
|
subtitleRefreshes: 0,
|
||||||
|
shortcutSyncs: 0,
|
||||||
|
hiddenWindows: [],
|
||||||
|
...c.expected,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 });
|
|
||||||
|
|
||||||
assert.equal(subtitleRefreshCalls, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const tracker = {
|
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowLost: null as (() => void) | null,
|
|
||||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
|
||||||
isTargetWindowMinimized: () => true,
|
|
||||||
start: () => {},
|
|
||||||
};
|
|
||||||
const overlayWindows = [
|
|
||||||
{
|
|
||||||
hide: () => calls.push('hide-visible'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hide: () => calls.push('hide-modal'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
|
||||||
backendOverride: null,
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
isVisibleOverlayVisible: () => true,
|
|
||||||
updateVisibleOverlayVisibility: () => {},
|
|
||||||
refreshCurrentSubtitle: () => {},
|
|
||||||
getOverlayWindows: () => overlayWindows as never,
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
setWindowTracker: () => {},
|
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
createWindowTracker: () => tracker as never,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: false } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
setAnkiIntegration: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 1,
|
|
||||||
deleteNoteId: 2,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
tracker.onWindowLost?.();
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const tracker = {
|
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowLost: null as (() => void) | null,
|
|
||||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
|
||||||
isTargetWindowMinimized: () => false,
|
|
||||||
start: () => {},
|
|
||||||
};
|
|
||||||
const overlayWindows = [
|
|
||||||
{
|
|
||||||
hide: () => calls.push('hide-visible'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
|
||||||
backendOverride: null,
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: () => {},
|
|
||||||
isVisibleOverlayVisible: () => true,
|
|
||||||
updateVisibleOverlayVisibility: () => {
|
|
||||||
calls.push('update-visible');
|
|
||||||
},
|
|
||||||
refreshCurrentSubtitle: () => {},
|
|
||||||
getOverlayWindows: () => overlayWindows as never,
|
|
||||||
syncOverlayShortcuts: () => {
|
|
||||||
calls.push('sync-shortcuts');
|
|
||||||
},
|
|
||||||
setWindowTracker: () => {},
|
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
createWindowTracker: () => tracker as never,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: false } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
setAnkiIntegration: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 1,
|
|
||||||
deleteNoteId: 2,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
calls.length = 0;
|
|
||||||
tracker.onWindowLost?.();
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['update-visible']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
|
|
||||||
const bounds: Array<{ x: number; y: number; width: number; height: number }> = [];
|
|
||||||
let visibilityRefreshCalls = 0;
|
|
||||||
const tracker = {
|
|
||||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowFound: null as ((...args: unknown[]) => void) | null,
|
|
||||||
onWindowLost: null as (() => void) | null,
|
|
||||||
onWindowFocusChange: null as ((focused: boolean) => void) | null,
|
|
||||||
start: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeOverlayRuntime({
|
|
||||||
backendOverride: null,
|
|
||||||
createMainWindow: () => {},
|
|
||||||
registerGlobalShortcuts: () => {},
|
|
||||||
updateVisibleOverlayBounds: (geometry) => {
|
|
||||||
bounds.push(geometry);
|
|
||||||
},
|
|
||||||
isVisibleOverlayVisible: () => true,
|
|
||||||
updateVisibleOverlayVisibility: () => {
|
|
||||||
visibilityRefreshCalls += 1;
|
|
||||||
},
|
|
||||||
refreshCurrentSubtitle: () => {},
|
|
||||||
getOverlayWindows: () => [],
|
|
||||||
syncOverlayShortcuts: () => {},
|
|
||||||
setWindowTracker: () => {},
|
|
||||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
|
||||||
createWindowTracker: () => tracker as never,
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
ankiConnect: { enabled: false } as never,
|
|
||||||
}),
|
|
||||||
getSubtitleTimingTracker: () => null,
|
|
||||||
getMpvClient: () => null,
|
|
||||||
getRuntimeOptionsManager: () => null,
|
|
||||||
setAnkiIntegration: () => {},
|
|
||||||
showDesktopNotification: () => {},
|
|
||||||
createFieldGroupingCallback: () => async () => ({
|
|
||||||
keepNoteId: 1,
|
|
||||||
deleteNoteId: 2,
|
|
||||||
deleteDuplicate: false,
|
|
||||||
cancelled: false,
|
|
||||||
}),
|
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 };
|
|
||||||
tracker.onWindowFound?.(restoredGeometry);
|
|
||||||
|
|
||||||
assert.deepEqual(bounds, [restoredGeometry]);
|
|
||||||
assert.equal(visibilityRefreshCalls, 2);
|
|
||||||
});
|
|
||||||
@@ -93,34 +93,6 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
|||||||
return { deps, calls, osd };
|
return { deps, calls, osd };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers', async () => {
|
|
||||||
const { deps, calls } = createDeps();
|
|
||||||
const { overlayHandlers, fallbackHandlers } = createOverlayShortcutRuntimeHandlers(deps);
|
|
||||||
|
|
||||||
overlayHandlers.copySubtitle();
|
|
||||||
overlayHandlers.copySubtitleMultiple(1111);
|
|
||||||
overlayHandlers.toggleSecondarySub();
|
|
||||||
overlayHandlers.openRuntimeOptions();
|
|
||||||
overlayHandlers.openCharacterDictionaryManager();
|
|
||||||
overlayHandlers.openJimaku();
|
|
||||||
overlayHandlers.mineSentenceMultiple(2222);
|
|
||||||
overlayHandlers.updateLastCardFromClipboard();
|
|
||||||
fallbackHandlers.mineSentence();
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'copySubtitle',
|
|
||||||
'copySubtitleMultiple:1111',
|
|
||||||
'toggleSecondarySub',
|
|
||||||
'openRuntimeOptions',
|
|
||||||
'openCharacterDictionaryManager',
|
|
||||||
'openJimaku',
|
|
||||||
'mineSentenceMultiple:2222',
|
|
||||||
'updateLastCardFromClipboard',
|
|
||||||
'mineSentence',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', async () => {
|
test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', async () => {
|
||||||
const logs: unknown[][] = [];
|
const logs: unknown[][] = [];
|
||||||
const originalError = console.error;
|
const originalError = console.error;
|
||||||
@@ -243,79 +215,58 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
|||||||
assert.deepEqual(handled, []);
|
assert.deepEqual(handled, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => {
|
const allowWhenRegisteredCases: Array<{
|
||||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
name: string;
|
||||||
const shortcuts = makeShortcuts({
|
shortcut: Partial<ConfiguredShortcuts>;
|
||||||
toggleSecondarySub: 'Ctrl+2',
|
accelerator: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'secondary-sub toggle',
|
||||||
|
shortcut: { toggleSecondarySub: 'Ctrl+2' },
|
||||||
|
accelerator: 'Ctrl+2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'registered-global jimaku shortcut',
|
||||||
|
shortcut: { openJimaku: 'Ctrl+J' },
|
||||||
|
accelerator: 'Ctrl+J',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of allowWhenRegisteredCases) {
|
||||||
|
test(`runOverlayShortcutLocalFallback passes allowWhenRegistered for ${c.name}`, () => {
|
||||||
|
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
||||||
|
|
||||||
|
const result = runOverlayShortcutLocalFallback(
|
||||||
|
{} as Electron.Input,
|
||||||
|
makeShortcuts(c.shortcut),
|
||||||
|
(_input, accelerator, allowWhenRegistered) => {
|
||||||
|
matched.push({
|
||||||
|
accelerator,
|
||||||
|
allowWhenRegistered: allowWhenRegistered === true,
|
||||||
|
});
|
||||||
|
return accelerator === c.accelerator;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
openRuntimeOptions: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
|
openJimaku: () => {},
|
||||||
|
openTsukihime: () => {},
|
||||||
|
markAudioCard: () => {},
|
||||||
|
copySubtitleMultiple: () => {},
|
||||||
|
copySubtitle: () => {},
|
||||||
|
toggleSecondarySub: () => {},
|
||||||
|
updateLastCardFromClipboard: () => {},
|
||||||
|
triggerFieldGrouping: () => {},
|
||||||
|
triggerSubsync: () => {},
|
||||||
|
mineSentence: () => {},
|
||||||
|
mineSentenceMultiple: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result, true);
|
||||||
|
assert.deepEqual(matched, [{ accelerator: c.accelerator, allowWhenRegistered: true }]);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const result = runOverlayShortcutLocalFallback(
|
|
||||||
{} as Electron.Input,
|
|
||||||
shortcuts,
|
|
||||||
(_input, accelerator, allowWhenRegistered) => {
|
|
||||||
matched.push({
|
|
||||||
accelerator,
|
|
||||||
allowWhenRegistered: allowWhenRegistered === true,
|
|
||||||
});
|
|
||||||
return accelerator === 'Ctrl+2';
|
|
||||||
},
|
|
||||||
{
|
|
||||||
openRuntimeOptions: () => {},
|
|
||||||
openCharacterDictionaryManager: () => {},
|
|
||||||
openJimaku: () => {},
|
|
||||||
openTsukihime: () => {},
|
|
||||||
markAudioCard: () => {},
|
|
||||||
copySubtitleMultiple: () => {},
|
|
||||||
copySubtitle: () => {},
|
|
||||||
toggleSecondarySub: () => {},
|
|
||||||
updateLastCardFromClipboard: () => {},
|
|
||||||
triggerFieldGrouping: () => {},
|
|
||||||
triggerSubsync: () => {},
|
|
||||||
mineSentence: () => {},
|
|
||||||
mineSentenceMultiple: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result, true);
|
|
||||||
assert.deepEqual(matched, [{ accelerator: 'Ctrl+2', allowWhenRegistered: true }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', () => {
|
|
||||||
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
|
|
||||||
const shortcuts = makeShortcuts({
|
|
||||||
openJimaku: 'Ctrl+J',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runOverlayShortcutLocalFallback(
|
|
||||||
{} as Electron.Input,
|
|
||||||
shortcuts,
|
|
||||||
(_input, accelerator, allowWhenRegistered) => {
|
|
||||||
matched.push({
|
|
||||||
accelerator,
|
|
||||||
allowWhenRegistered: allowWhenRegistered === true,
|
|
||||||
});
|
|
||||||
return accelerator === 'Ctrl+J';
|
|
||||||
},
|
|
||||||
{
|
|
||||||
openRuntimeOptions: () => {},
|
|
||||||
openCharacterDictionaryManager: () => {},
|
|
||||||
openJimaku: () => {},
|
|
||||||
openTsukihime: () => {},
|
|
||||||
markAudioCard: () => {},
|
|
||||||
copySubtitleMultiple: () => {},
|
|
||||||
copySubtitle: () => {},
|
|
||||||
toggleSecondarySub: () => {},
|
|
||||||
updateLastCardFromClipboard: () => {},
|
|
||||||
triggerFieldGrouping: () => {},
|
|
||||||
triggerSubsync: () => {},
|
|
||||||
mineSentence: () => {},
|
|
||||||
mineSentenceMultiple: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result, true);
|
|
||||||
assert.deepEqual(matched, [{ accelerator: 'Ctrl+J', allowWhenRegistered: true }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runOverlayShortcutLocalFallback returns false when no action matches', () => {
|
test('runOverlayShortcutLocalFallback returns false when no action matches', () => {
|
||||||
const shortcuts = makeShortcuts({
|
const shortcuts = makeShortcuts({
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||||
import {
|
import { registerOverlayShortcuts } from './overlay-shortcut';
|
||||||
registerOverlayShortcuts,
|
|
||||||
syncOverlayShortcutsRuntime,
|
|
||||||
unregisterOverlayShortcutsRuntime,
|
|
||||||
} from './overlay-shortcut';
|
|
||||||
|
|
||||||
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||||
return {
|
return {
|
||||||
@@ -36,27 +32,6 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => {
|
|
||||||
assert.equal(
|
|
||||||
registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), {
|
|
||||||
copySubtitle: () => {},
|
|
||||||
copySubtitleMultiple: () => {},
|
|
||||||
updateLastCardFromClipboard: () => {},
|
|
||||||
triggerFieldGrouping: () => {},
|
|
||||||
triggerSubsync: () => {},
|
|
||||||
mineSentence: () => {},
|
|
||||||
mineSentenceMultiple: () => {},
|
|
||||||
toggleSecondarySub: () => {},
|
|
||||||
markAudioCard: () => {},
|
|
||||||
openCharacterDictionaryManager: () => {},
|
|
||||||
openRuntimeOptions: () => {},
|
|
||||||
openJimaku: () => {},
|
|
||||||
openTsukihime: () => {},
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => {
|
test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
registerOverlayShortcuts(createShortcuts(), {
|
registerOverlayShortcuts(createShortcuts(), {
|
||||||
@@ -77,34 +52,3 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const result = syncOverlayShortcutsRuntime(false, true, {
|
|
||||||
getConfiguredShortcuts: () => createShortcuts(),
|
|
||||||
getOverlayHandlers: () => ({
|
|
||||||
copySubtitle: () => {},
|
|
||||||
copySubtitleMultiple: () => {},
|
|
||||||
updateLastCardFromClipboard: () => {},
|
|
||||||
triggerFieldGrouping: () => {},
|
|
||||||
triggerSubsync: () => {},
|
|
||||||
mineSentence: () => {},
|
|
||||||
mineSentenceMultiple: () => {},
|
|
||||||
toggleSecondarySub: () => {},
|
|
||||||
markAudioCard: () => {},
|
|
||||||
openCharacterDictionaryManager: () => {},
|
|
||||||
openRuntimeOptions: () => {},
|
|
||||||
openJimaku: () => {},
|
|
||||||
openTsukihime: () => {},
|
|
||||||
}),
|
|
||||||
cancelPendingMultiCopy: () => {
|
|
||||||
calls.push('cancel-multi-copy');
|
|
||||||
},
|
|
||||||
cancelPendingMineSentenceMultiple: () => {
|
|
||||||
calls.push('cancel-mine-sentence-multiple');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result, false);
|
|
||||||
assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,19 +2,6 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||||
|
|
||||||
test('overlay window config explicitly disables renderer sandbox for preload compatibility', () => {
|
|
||||||
const options = buildOverlayWindowOptions('visible', {
|
|
||||||
isDev: false,
|
|
||||||
yomitanSession: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(options.title, 'SubMiner Overlay');
|
|
||||||
assert.equal(options.backgroundColor, '#00000000');
|
|
||||||
assert.equal(options.paintWhenInitiallyHidden, true);
|
|
||||||
assert.equal(options.webPreferences?.sandbox, false);
|
|
||||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => {
|
test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => {
|
||||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||||
isDev: false,
|
isDev: false,
|
||||||
@@ -31,117 +18,50 @@ test('macOS modal overlay uses a fullscreen auxiliary panel without changing the
|
|||||||
assert.equal(modalOptions.type, 'panel');
|
assert.equal(modalOptions.type, 'panel');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-macOS modal overlay remains a regular window', () => {
|
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||||
const options = buildOverlayWindowOptions('modal', {
|
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||||
|
isDev: false,
|
||||||
|
platform: 'linux',
|
||||||
|
yomitanSession: null,
|
||||||
|
});
|
||||||
|
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||||
isDev: false,
|
isDev: false,
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
yomitanSession: null,
|
yomitanSession: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(options.type, undefined);
|
assert.equal(visibleOptions.resizable, true);
|
||||||
assert.equal(options.roundedCorners, false);
|
assert.equal(modalOptions.resizable, false);
|
||||||
});
|
|
||||||
|
|
||||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
|
||||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
||||||
|
|
||||||
Object.defineProperty(process, 'platform', {
|
|
||||||
configurable: true,
|
|
||||||
value: 'linux',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
|
||||||
isDev: false,
|
|
||||||
yomitanSession: null,
|
|
||||||
});
|
|
||||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
|
||||||
isDev: false,
|
|
||||||
yomitanSession: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(visibleOptions.resizable, true);
|
|
||||||
assert.equal(modalOptions.resizable, false);
|
|
||||||
} finally {
|
|
||||||
if (originalPlatformDescriptor) {
|
|
||||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay window stays managed so native apps can cover it', () => {
|
test('Linux visible overlay window stays managed so native apps can cover it', () => {
|
||||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||||
|
isDev: false,
|
||||||
Object.defineProperty(process, 'platform', {
|
platform: 'linux',
|
||||||
configurable: true,
|
yomitanSession: null,
|
||||||
value: 'linux',
|
});
|
||||||
|
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||||
|
isDev: false,
|
||||||
|
platform: 'linux',
|
||||||
|
yomitanSession: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
assert.equal(visibleOptions.alwaysOnTop, false);
|
||||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
assert.equal(visibleOptions.focusable, true);
|
||||||
isDev: false,
|
assert.equal(modalOptions.focusable, true);
|
||||||
yomitanSession: null,
|
|
||||||
});
|
|
||||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
|
||||||
isDev: false,
|
|
||||||
yomitanSession: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(visibleOptions.alwaysOnTop, false);
|
|
||||||
assert.equal(visibleOptions.focusable, true);
|
|
||||||
assert.equal(modalOptions.focusable, true);
|
|
||||||
} finally {
|
|
||||||
if (originalPlatformDescriptor) {
|
|
||||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
|
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
|
||||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||||
|
isDev: false,
|
||||||
Object.defineProperty(process, 'platform', {
|
platform: 'linux',
|
||||||
configurable: true,
|
linuxX11FullscreenOverlay: true,
|
||||||
value: 'linux',
|
yomitanSession: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
assert.equal(visibleOptions.alwaysOnTop, true);
|
||||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
assert.equal(visibleOptions.focusable, false);
|
||||||
isDev: false,
|
assert.equal(visibleOptions.resizable, false);
|
||||||
linuxX11FullscreenOverlay: true,
|
|
||||||
yomitanSession: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(visibleOptions.alwaysOnTop, true);
|
|
||||||
assert.equal(visibleOptions.focusable, false);
|
|
||||||
assert.equal(visibleOptions.resizable, false);
|
|
||||||
} finally {
|
|
||||||
if (originalPlatformDescriptor) {
|
|
||||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Windows visible overlay window config does not start as always-on-top', () => {
|
|
||||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
||||||
|
|
||||||
Object.defineProperty(process, 'platform', {
|
|
||||||
configurable: true,
|
|
||||||
value: 'win32',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const options = buildOverlayWindowOptions('visible', {
|
|
||||||
isDev: false,
|
|
||||||
yomitanSession: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(options.alwaysOnTop, false);
|
|
||||||
} finally {
|
|
||||||
if (originalPlatformDescriptor) {
|
|
||||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overlay window config uses the provided Yomitan session when available', () => {
|
test('overlay window config uses the provided Yomitan session when available', () => {
|
||||||
|
|||||||
@@ -133,26 +133,6 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const handled = handleOverlayWindowBlurred({
|
|
||||||
kind: 'visible',
|
|
||||||
windowVisible: true,
|
|
||||||
isOverlayVisible: () => true,
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
moveWindowTop: () => {
|
|
||||||
calls.push('move-top');
|
|
||||||
},
|
|
||||||
platform: 'win32',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, false);
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => {
|
test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
@@ -176,92 +156,6 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
|
|||||||
assert.deepEqual(calls, ['visible-blur']);
|
assert.deepEqual(calls, ['visible-blur']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const handled = handleOverlayWindowBlurred({
|
|
||||||
kind: 'visible',
|
|
||||||
windowVisible: true,
|
|
||||||
isOverlayVisible: () => true,
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
moveWindowTop: () => {
|
|
||||||
calls.push('move-top');
|
|
||||||
},
|
|
||||||
platform: 'darwin',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, false);
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred skips Linux visible overlay restacking after focus loss', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const handled = handleOverlayWindowBlurred({
|
|
||||||
kind: 'visible',
|
|
||||||
windowVisible: true,
|
|
||||||
isOverlayVisible: () => true,
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
moveWindowTop: () => {
|
|
||||||
calls.push('move-top');
|
|
||||||
},
|
|
||||||
platform: 'linux',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, false);
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred notifies Linux visible overlay blur callback without restacking', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const handled = handleOverlayWindowBlurred({
|
|
||||||
kind: 'visible',
|
|
||||||
windowVisible: true,
|
|
||||||
isOverlayVisible: () => true,
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
moveWindowTop: () => {
|
|
||||||
calls.push('move-top');
|
|
||||||
},
|
|
||||||
onVisibleOverlayBlur: () => {
|
|
||||||
calls.push('visible-blur');
|
|
||||||
},
|
|
||||||
platform: 'linux',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, false);
|
|
||||||
assert.deepEqual(calls, ['visible-blur']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const handled = handleOverlayWindowBlurred({
|
|
||||||
kind: 'visible',
|
|
||||||
windowVisible: true,
|
|
||||||
isOverlayVisible: () => true,
|
|
||||||
ensureOverlayWindowLevel: () => {
|
|
||||||
calls.push('ensure-level');
|
|
||||||
},
|
|
||||||
moveWindowTop: () => {
|
|
||||||
calls.push('move-top');
|
|
||||||
},
|
|
||||||
onVisibleOverlayBlur: () => {
|
|
||||||
calls.push('visible-blur');
|
|
||||||
},
|
|
||||||
platform: 'darwin',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(handled, false);
|
|
||||||
assert.deepEqual(calls, ['visible-blur']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred preserves modal window stacking', () => {
|
test('handleOverlayWindowBlurred preserves modal window stacking', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { afterEach, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
clearRetimedSecondarySubtitleCache,
|
||||||
|
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||||
|
type RetimedSubtitleCommandRunner,
|
||||||
|
} from './secondary-subtitle-sidecar.js';
|
||||||
|
|
||||||
|
const ORIGINAL_ENGLISH = `1
|
||||||
|
00:00:09,000 --> 00:00:10,000
|
||||||
|
Stale English subtitle
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ALIGNED_ENGLISH = `1
|
||||||
|
00:00:01,000 --> 00:00:02,000
|
||||||
|
Aligned English subtitle
|
||||||
|
`;
|
||||||
|
|
||||||
|
type SidecarFixture = {
|
||||||
|
sourcePath: string;
|
||||||
|
japanesePath: string;
|
||||||
|
englishPath: string;
|
||||||
|
alassPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Writes an episode with a correctly timed Japanese sidecar and a mistimed English one. */
|
||||||
|
async function withSidecarFixture(fn: (fixture: SidecarFixture) => Promise<void>): Promise<void> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-secondary-sidecar-test-'));
|
||||||
|
try {
|
||||||
|
const fixture = {
|
||||||
|
sourcePath: path.join(dir, 'episode.mkv'),
|
||||||
|
japanesePath: path.join(dir, 'episode.ja.srt'),
|
||||||
|
englishPath: path.join(dir, 'episode.en.srt'),
|
||||||
|
alassPath: path.join(dir, 'alass-cli'),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(fixture.sourcePath, 'fake media');
|
||||||
|
fs.writeFileSync(fixture.alassPath, 'fake alass');
|
||||||
|
fs.writeFileSync(fixture.japanesePath, '1\n00:00:01,000 --> 00:00:02,000\n猫を見た\n');
|
||||||
|
fs.writeFileSync(fixture.englishPath, ORIGINAL_ENGLISH);
|
||||||
|
await fn(fixture);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearRetimedSecondarySubtitleCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => {
|
||||||
|
await withSidecarFixture(async ({ sourcePath, japanesePath, englishPath, alassPath }) => {
|
||||||
|
let alassRuns = 0;
|
||||||
|
const input = { sourcePath, startMs: 1_000, endMs: 2_000, alassPath };
|
||||||
|
|
||||||
|
const first = await resolveRetimedSecondarySubtitleTextFromSidecar({
|
||||||
|
...input,
|
||||||
|
runAlass: async (_alassPath, referencePath, inputPath, outputPath) => {
|
||||||
|
alassRuns += 1;
|
||||||
|
assert.equal(referencePath, japanesePath);
|
||||||
|
assert.equal(inputPath, englishPath);
|
||||||
|
fs.writeFileSync(outputPath, ALIGNED_ENGLISH);
|
||||||
|
return { ok: true, code: 0, stdout: '', stderr: '' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const second = await resolveRetimedSecondarySubtitleTextFromSidecar({
|
||||||
|
...input,
|
||||||
|
runAlass: async () => {
|
||||||
|
alassRuns += 1;
|
||||||
|
return { ok: false, code: 1, stdout: '', stderr: 'should use cache' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(first, 'Aligned English subtitle');
|
||||||
|
assert.equal(second, 'Aligned English subtitle');
|
||||||
|
assert.equal(alassRuns, 1);
|
||||||
|
assert.equal(fs.readFileSync(englishPath, 'utf8'), ORIGINAL_ENGLISH);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares in-flight retimed secondary subtitle work for concurrent requests', async () => {
|
||||||
|
await withSidecarFixture(async ({ sourcePath, alassPath }) => {
|
||||||
|
let alassRuns = 0;
|
||||||
|
let releaseAlass!: () => void;
|
||||||
|
const alassGate = new Promise<void>((resolve) => {
|
||||||
|
releaseAlass = resolve;
|
||||||
|
});
|
||||||
|
const runAlass: RetimedSubtitleCommandRunner = async (_alass, _reference, _input, output) => {
|
||||||
|
alassRuns += 1;
|
||||||
|
await alassGate;
|
||||||
|
fs.writeFileSync(output, ALIGNED_ENGLISH);
|
||||||
|
return { ok: true, code: 0, stdout: '', stderr: '' };
|
||||||
|
};
|
||||||
|
const input = { sourcePath, startMs: 1_000, endMs: 2_000, alassPath, runAlass };
|
||||||
|
|
||||||
|
const first = resolveRetimedSecondarySubtitleTextFromSidecar(input);
|
||||||
|
const second = resolveRetimedSecondarySubtitleTextFromSidecar(input);
|
||||||
|
releaseAlass();
|
||||||
|
|
||||||
|
assert.deepEqual(await Promise.all([first, second]), [
|
||||||
|
'Aligned English subtitle',
|
||||||
|
'Aligned English subtitle',
|
||||||
|
]);
|
||||||
|
assert.equal(alassRuns, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,17 +81,3 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['mark-watched']);
|
assert.deepEqual(calls, ['mark-watched']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispatchSessionAction opens the character dictionary manager', async () => {
|
|
||||||
const { calls, deps } = createDeps();
|
|
||||||
|
|
||||||
await dispatchSessionAction({ actionId: 'openCharacterDictionaryManager' }, deps);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['character-dictionary-manager']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dispatchSessionAction opens subtitle generation without opening the sidebar', async () => {
|
|
||||||
const { calls, deps } = createDeps();
|
|
||||||
await dispatchSessionAction({ actionId: 'openSubtitleGeneration' }, deps);
|
|
||||||
assert.deepEqual(calls, ['subtitle-generation']);
|
|
||||||
});
|
|
||||||
@@ -275,67 +275,6 @@ test('compileSessionBindings drops conflicting bindings that canonicalize to the
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('compileSessionBindings keeps default replay and next subtitle session actions on Linux', () => {
|
|
||||||
const result = compileSessionBindings({
|
|
||||||
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
|
|
||||||
keybindings: DEFAULT_KEYBINDINGS,
|
|
||||||
statsToggleKey: DEFAULT_CONFIG.stats.toggleKey,
|
|
||||||
platform: 'linux',
|
|
||||||
rawConfig: DEFAULT_CONFIG,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
result.warnings.filter((warning) => warning.kind === 'conflict'),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const bySignature = new Map(
|
|
||||||
result.bindings.map((binding) => [
|
|
||||||
`${binding.key.modifiers.join('+')}+${binding.key.code}`,
|
|
||||||
binding,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const replay = bySignature.get('ctrl+shift+KeyH');
|
|
||||||
assert.equal(replay?.actionType, 'session-action');
|
|
||||||
assert.equal(replay?.actionId, 'replayCurrentSubtitle');
|
|
||||||
|
|
||||||
const next = bySignature.get('ctrl+shift+KeyL');
|
|
||||||
assert.equal(next?.actionType, 'session-action');
|
|
||||||
assert.equal(next?.actionId, 'playNextSubtitle');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('compileSessionBindings keeps only the character dictionary manager bound by default', () => {
|
|
||||||
const result = compileSessionBindings({
|
|
||||||
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
|
|
||||||
keybindings: DEFAULT_KEYBINDINGS,
|
|
||||||
statsToggleKey: DEFAULT_CONFIG.stats.toggleKey,
|
|
||||||
platform: 'linux',
|
|
||||||
rawConfig: DEFAULT_CONFIG,
|
|
||||||
});
|
|
||||||
|
|
||||||
const characterDictionaryBindings = result.bindings.flatMap((binding) => {
|
|
||||||
if (binding.actionType !== 'session-action') return [];
|
|
||||||
if (binding.actionId !== 'openCharacterDictionaryManager') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sourcePath: binding.sourcePath,
|
|
||||||
originalKey: binding.originalKey,
|
|
||||||
actionId: binding.actionId,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(characterDictionaryBindings, [
|
|
||||||
{
|
|
||||||
sourcePath: 'shortcuts.openCharacterDictionaryManager',
|
|
||||||
originalKey: 'CommandOrControl+D',
|
|
||||||
actionId: 'openCharacterDictionaryManager',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
|
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
|
||||||
const expectedSpecialActions: Record<string, string> = {
|
const expectedSpecialActions: Record<string, string> = {
|
||||||
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
|
||||||
|
|||||||
@@ -78,19 +78,14 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime configures startup state and starts lifecycle', () => {
|
function runBootstrap(
|
||||||
|
argsOverrides: Partial<CliArgs> = {},
|
||||||
|
depOverrides: Partial<Parameters<typeof runStartupBootstrapRuntime>[0]> = {},
|
||||||
|
) {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const args = makeArgs({
|
const args = makeArgs(argsOverrides);
|
||||||
logLevel: 'debug',
|
|
||||||
socketPath: '/tmp/custom.sock',
|
|
||||||
texthookerPort: 9001,
|
|
||||||
backend: 'x11',
|
|
||||||
autoStartOverlay: true,
|
|
||||||
texthooker: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runStartupBootstrapRuntime({
|
const result = runStartupBootstrapRuntime({
|
||||||
argv: ['node', 'main.ts', '--log-level', 'debug'],
|
argv: ['node', 'main.ts'],
|
||||||
parseArgs: () => args,
|
parseArgs: () => args,
|
||||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||||
forceX11Backend: () => calls.push('forceX11'),
|
forceX11Backend: () => calls.push('forceX11'),
|
||||||
@@ -99,6 +94,19 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle',
|
|||||||
defaultTexthookerPort: 5174,
|
defaultTexthookerPort: 5174,
|
||||||
runGenerateConfigFlow: () => false,
|
runGenerateConfigFlow: () => false,
|
||||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||||
|
...depOverrides,
|
||||||
|
});
|
||||||
|
return { args, result, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runStartupBootstrapRuntime maps CLI args to startup state', () => {
|
||||||
|
const { args, result } = runBootstrap({
|
||||||
|
socketPath: '/tmp/custom.sock',
|
||||||
|
texthookerPort: 9001,
|
||||||
|
backend: 'x11',
|
||||||
|
autoStartOverlay: true,
|
||||||
|
texthooker: true,
|
||||||
|
background: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.initialArgs, args);
|
assert.equal(result.initialArgs, args);
|
||||||
@@ -107,141 +115,59 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle',
|
|||||||
assert.equal(result.backendOverride, 'x11');
|
assert.equal(result.backendOverride, 'x11');
|
||||||
assert.equal(result.autoStartOverlay, true);
|
assert.equal(result.autoStartOverlay, true);
|
||||||
assert.equal(result.texthookerOnlyMode, true);
|
assert.equal(result.texthookerOnlyMode, true);
|
||||||
assert.equal(result.backgroundMode, false);
|
assert.equal(result.backgroundMode, true);
|
||||||
assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime keeps log-level precedence for repeated calls', () => {
|
test('runStartupBootstrapRuntime falls back to defaults when args are unset', () => {
|
||||||
const calls: string[] = [];
|
const { result } = runBootstrap();
|
||||||
const args = makeArgs({
|
|
||||||
logLevel: 'warn',
|
|
||||||
});
|
|
||||||
|
|
||||||
runStartupBootstrapRuntime({
|
|
||||||
argv: ['node', 'main.ts', '--log-level', 'warn'],
|
|
||||||
parseArgs: () => args,
|
|
||||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
|
||||||
forceX11Backend: () => calls.push('forceX11'),
|
|
||||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
|
||||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
runGenerateConfigFlow: () => false,
|
|
||||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls.slice(0, 3), ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime remains lifecycle-stable with Jellyfin CLI flags', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const args = makeArgs({
|
|
||||||
jellyfin: true,
|
|
||||||
jellyfinLibraries: true,
|
|
||||||
socketPath: '/tmp/stable.sock',
|
|
||||||
texthookerPort: 8888,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runStartupBootstrapRuntime({
|
|
||||||
argv: ['node', 'main.ts', '--jellyfin', '--jellyfin-libraries'],
|
|
||||||
parseArgs: () => args,
|
|
||||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
|
||||||
forceX11Backend: () => calls.push('forceX11'),
|
|
||||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
|
||||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
runGenerateConfigFlow: () => false,
|
|
||||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.mpvSocketPath, '/tmp/stable.sock');
|
|
||||||
assert.equal(result.texthookerPort, 8888);
|
|
||||||
assert.equal(result.backendOverride, null);
|
|
||||||
assert.equal(result.autoStartOverlay, false);
|
|
||||||
assert.equal(result.texthookerOnlyMode, false);
|
|
||||||
assert.equal(result.backgroundMode, false);
|
|
||||||
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime keeps --debug separate from log verbosity', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const args = makeArgs({
|
|
||||||
debug: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
runStartupBootstrapRuntime({
|
|
||||||
argv: ['node', 'main.ts', '--debug'],
|
|
||||||
parseArgs: () => args,
|
|
||||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
|
||||||
forceX11Backend: () => calls.push('forceX11'),
|
|
||||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
|
||||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
runGenerateConfigFlow: () => false,
|
|
||||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const args = makeArgs({ generateConfig: true, logLevel: 'warn' });
|
|
||||||
|
|
||||||
const result = runStartupBootstrapRuntime({
|
|
||||||
argv: ['node', 'main.ts', '--generate-config'],
|
|
||||||
parseArgs: () => args,
|
|
||||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
|
||||||
forceX11Backend: () => calls.push('forceX11'),
|
|
||||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
|
||||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
runGenerateConfigFlow: () => true,
|
|
||||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.mpvSocketPath, '/tmp/default.sock');
|
assert.equal(result.mpvSocketPath, '/tmp/default.sock');
|
||||||
assert.equal(result.texthookerPort, 5174);
|
assert.equal(result.texthookerPort, 5174);
|
||||||
assert.equal(result.backendOverride, null);
|
assert.equal(result.backendOverride, null);
|
||||||
|
assert.equal(result.autoStartOverlay, false);
|
||||||
|
assert.equal(result.texthookerOnlyMode, false);
|
||||||
assert.equal(result.backgroundMode, false);
|
assert.equal(result.backgroundMode, false);
|
||||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime lets config govern background log level by default', () => {
|
const logLevelCases: Array<{ name: string; args: Partial<CliArgs>; expected: string[] }> = [
|
||||||
const calls: string[] = [];
|
{
|
||||||
const args = makeArgs({ background: true });
|
name: '--log-level applies the requested level',
|
||||||
|
args: { logLevel: 'debug' },
|
||||||
|
expected: ['setLog:debug:cli'],
|
||||||
|
},
|
||||||
|
{ name: '--update defaults to warn', args: { update: true }, expected: ['setLog:warn:cli'] },
|
||||||
|
{
|
||||||
|
name: '--log-level wins over --update',
|
||||||
|
args: { update: true, logLevel: 'info' },
|
||||||
|
expected: ['setLog:info:cli'],
|
||||||
|
},
|
||||||
|
{ name: '--debug leaves log verbosity alone', args: { debug: true }, expected: [] },
|
||||||
|
{ name: '--background leaves log level to config', args: { background: true }, expected: [] },
|
||||||
|
];
|
||||||
|
|
||||||
const result = runStartupBootstrapRuntime({
|
for (const c of logLevelCases) {
|
||||||
argv: ['node', 'main.ts', '--background'],
|
test(`runStartupBootstrapRuntime log level: ${c.name}`, () => {
|
||||||
parseArgs: () => args,
|
const { calls } = runBootstrap(c.args);
|
||||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
|
||||||
forceX11Backend: () => calls.push('forceX11'),
|
assert.deepEqual(
|
||||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
calls.filter((call) => call.startsWith('setLog:')),
|
||||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
c.expected,
|
||||||
defaultTexthookerPort: 5174,
|
);
|
||||||
runGenerateConfigFlow: () => false,
|
|
||||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
assert.equal(result.backgroundMode, true);
|
test('runStartupBootstrapRuntime applies log level and platform setup before starting lifecycle', () => {
|
||||||
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
const { calls } = runBootstrap({ logLevel: 'debug' });
|
||||||
|
|
||||||
|
const index = (call: string) => calls.indexOf(call);
|
||||||
|
assert.ok(index('setLog:debug:cli') >= 0);
|
||||||
|
assert.ok(index('setLog:debug:cli') < index('forceX11'));
|
||||||
|
assert.ok(index('forceX11') < index('startLifecycle'));
|
||||||
|
assert.ok(index('enforceWayland') < index('startLifecycle'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => {
|
||||||
const calls: string[] = [];
|
const { calls } = runBootstrap({ generateConfig: true }, { runGenerateConfigFlow: () => true });
|
||||||
const args = makeArgs({ update: true });
|
|
||||||
|
|
||||||
const result = runStartupBootstrapRuntime({
|
assert.equal(calls.includes('startLifecycle'), false);
|
||||||
argv: ['node', 'main.ts', '--update'],
|
|
||||||
parseArgs: () => args,
|
|
||||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
|
||||||
forceX11Backend: () => calls.push('forceX11'),
|
|
||||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
|
||||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
|
||||||
defaultTexthookerPort: 5174,
|
|
||||||
runGenerateConfigFlow: () => false,
|
|
||||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.backgroundMode, false);
|
|
||||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
|
||||||
});
|
});
|
||||||
@@ -1,447 +1,126 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { runAppReadyRuntime } from './startup';
|
import { runAppReadyRuntime, type AppReadyRuntimeDeps } from './startup';
|
||||||
|
|
||||||
test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while still handling CLI args', async () => {
|
// Every dep records its name in `calls`; overrides replace individual deps.
|
||||||
|
function createAppReadyDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const record = (name: string) => () => {
|
||||||
await runAppReadyRuntime({
|
calls.push(name);
|
||||||
ensureDefaultConfigBootstrap: () => {
|
};
|
||||||
calls.push('bootstrap');
|
const recordAsync = (name: string) => async () => {
|
||||||
},
|
calls.push(name);
|
||||||
loadSubtitlePosition: () => {
|
};
|
||||||
calls.push('load-subtitle-position');
|
const deps: AppReadyRuntimeDeps = {
|
||||||
},
|
ensureDefaultConfigBootstrap: record('bootstrap'),
|
||||||
resolveKeybindings: () => {
|
loadSubtitlePosition: record('load-subtitle-position'),
|
||||||
calls.push('resolve-keybindings');
|
resolveKeybindings: record('resolve-keybindings'),
|
||||||
},
|
createMpvClient: record('create-mpv'),
|
||||||
createMpvClient: () => {
|
reloadConfig: record('reload-config'),
|
||||||
calls.push('create-mpv');
|
|
||||||
},
|
|
||||||
reloadConfig: () => {
|
|
||||||
calls.push('reload-config');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => ({}),
|
|
||||||
getConfigWarnings: () => [],
|
|
||||||
logConfigWarning: () => {
|
|
||||||
calls.push('config-warning');
|
|
||||||
},
|
|
||||||
setLogLevel: () => {
|
|
||||||
calls.push('set-log-level');
|
|
||||||
},
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
calls.push('init-runtime-options');
|
|
||||||
},
|
|
||||||
setSecondarySubMode: () => {
|
|
||||||
calls.push('set-secondary-sub-mode');
|
|
||||||
},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: 0,
|
|
||||||
defaultAnnotationWebsocketPort: 0,
|
|
||||||
defaultTexthookerPort: 0,
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
startSubtitleWebsocket: () => {
|
|
||||||
calls.push('subtitle-ws');
|
|
||||||
},
|
|
||||||
startAnnotationWebsocket: () => {
|
|
||||||
calls.push('annotation-ws');
|
|
||||||
},
|
|
||||||
startTexthooker: () => {
|
|
||||||
calls.push('texthooker');
|
|
||||||
},
|
|
||||||
log: () => {
|
|
||||||
calls.push('log');
|
|
||||||
},
|
|
||||||
createMecabTokenizerAndCheck: async () => {
|
|
||||||
calls.push('mecab');
|
|
||||||
},
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
calls.push('subtitle-timing');
|
|
||||||
},
|
|
||||||
createImmersionTracker: () => {
|
|
||||||
calls.push('immersion');
|
|
||||||
},
|
|
||||||
startJellyfinRemoteSession: async () => {
|
|
||||||
calls.push('jellyfin');
|
|
||||||
},
|
|
||||||
loadYomitanExtension: async () => {
|
|
||||||
calls.push('load-yomitan');
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {
|
|
||||||
calls.push('first-run');
|
|
||||||
},
|
|
||||||
prewarmSubtitleDictionaries: async () => {
|
|
||||||
calls.push('prewarm');
|
|
||||||
},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('warmups');
|
|
||||||
},
|
|
||||||
texthookerOnlyMode: false,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {
|
|
||||||
calls.push('visible-overlay');
|
|
||||||
},
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
calls.push('init-overlay');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handle-initial-args');
|
|
||||||
},
|
|
||||||
shouldUseMinimalStartup: () => true,
|
|
||||||
shouldSkipHeavyStartup: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['bootstrap', 'reload-config', 'handle-initial-args']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await runAppReadyRuntime({
|
|
||||||
ensureDefaultConfigBootstrap: () => {
|
|
||||||
calls.push('bootstrap');
|
|
||||||
},
|
|
||||||
loadSubtitlePosition: () => {
|
|
||||||
calls.push('load-subtitle-position');
|
|
||||||
},
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
calls.push('resolve-keybindings');
|
|
||||||
},
|
|
||||||
createMpvClient: () => {
|
|
||||||
calls.push('create-mpv');
|
|
||||||
},
|
|
||||||
reloadConfig: () => {
|
|
||||||
calls.push('reload-config');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => ({}),
|
|
||||||
getConfigWarnings: () => [],
|
|
||||||
logConfigWarning: () => {
|
|
||||||
calls.push('config-warning');
|
|
||||||
},
|
|
||||||
setLogLevel: () => {
|
|
||||||
calls.push('set-log-level');
|
|
||||||
},
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
calls.push('init-runtime-options');
|
|
||||||
},
|
|
||||||
setSecondarySubMode: () => {
|
|
||||||
calls.push('set-secondary-sub-mode');
|
|
||||||
},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: 0,
|
|
||||||
defaultAnnotationWebsocketPort: 0,
|
|
||||||
defaultTexthookerPort: 0,
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
startSubtitleWebsocket: () => {
|
|
||||||
calls.push('subtitle-ws');
|
|
||||||
},
|
|
||||||
startAnnotationWebsocket: () => {
|
|
||||||
calls.push('annotation-ws');
|
|
||||||
},
|
|
||||||
startTexthooker: () => {
|
|
||||||
calls.push('texthooker');
|
|
||||||
},
|
|
||||||
log: () => {
|
|
||||||
calls.push('log');
|
|
||||||
},
|
|
||||||
createMecabTokenizerAndCheck: async () => {
|
|
||||||
calls.push('mecab');
|
|
||||||
},
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
calls.push('subtitle-timing');
|
|
||||||
},
|
|
||||||
createImmersionTracker: () => {
|
|
||||||
calls.push('immersion');
|
|
||||||
},
|
|
||||||
startJellyfinRemoteSession: async () => {
|
|
||||||
calls.push('jellyfin');
|
|
||||||
},
|
|
||||||
loadYomitanExtension: async () => {
|
|
||||||
calls.push('load-yomitan');
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {
|
|
||||||
calls.push('first-run');
|
|
||||||
},
|
|
||||||
prewarmSubtitleDictionaries: async () => {
|
|
||||||
calls.push('prewarm');
|
|
||||||
},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('warmups');
|
|
||||||
},
|
|
||||||
texthookerOnlyMode: false,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {
|
|
||||||
calls.push('visible-overlay');
|
|
||||||
},
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
calls.push('init-overlay');
|
|
||||||
},
|
|
||||||
runHeadlessInitialCommand: async () => {
|
|
||||||
calls.push('run-headless-command');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handle-initial-args');
|
|
||||||
},
|
|
||||||
shouldRunHeadlessInitialCommand: () => true,
|
|
||||||
shouldUseMinimalStartup: () => false,
|
|
||||||
shouldSkipHeavyStartup: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'bootstrap',
|
|
||||||
'reload-config',
|
|
||||||
'init-runtime-options',
|
|
||||||
'run-headless-command',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await runAppReadyRuntime({
|
|
||||||
ensureDefaultConfigBootstrap: () => {
|
|
||||||
calls.push('bootstrap');
|
|
||||||
},
|
|
||||||
loadSubtitlePosition: () => {
|
|
||||||
calls.push('load-subtitle-position');
|
|
||||||
},
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
calls.push('resolve-keybindings');
|
|
||||||
},
|
|
||||||
createMpvClient: () => {
|
|
||||||
calls.push('create-mpv');
|
|
||||||
},
|
|
||||||
reloadConfig: () => {
|
|
||||||
calls.push('reload-config');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => ({}),
|
|
||||||
getConfigWarnings: () => [],
|
|
||||||
logConfigWarning: () => {},
|
|
||||||
setLogLevel: () => {},
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
calls.push('init-runtime-options');
|
|
||||||
},
|
|
||||||
setSecondarySubMode: () => {},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: 0,
|
|
||||||
defaultAnnotationWebsocketPort: 0,
|
|
||||||
defaultTexthookerPort: 0,
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
startSubtitleWebsocket: () => {},
|
|
||||||
startAnnotationWebsocket: () => {},
|
|
||||||
startTexthooker: () => {},
|
|
||||||
log: () => {},
|
|
||||||
createMecabTokenizerAndCheck: async () => {},
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
calls.push('subtitle-timing');
|
|
||||||
},
|
|
||||||
createImmersionTracker: () => {},
|
|
||||||
startJellyfinRemoteSession: async () => {},
|
|
||||||
loadYomitanExtension: async () => {
|
|
||||||
calls.push('load-yomitan');
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {},
|
|
||||||
prewarmSubtitleDictionaries: async () => {},
|
|
||||||
startBackgroundWarmups: () => {},
|
|
||||||
texthookerOnlyMode: false,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {},
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
calls.push('init-overlay');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handle-initial-args');
|
|
||||||
},
|
|
||||||
shouldRunHeadlessInitialCommand: () => true,
|
|
||||||
shouldUseMinimalStartup: () => false,
|
|
||||||
shouldSkipHeavyStartup: () => false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'bootstrap',
|
|
||||||
'reload-config',
|
|
||||||
'init-runtime-options',
|
|
||||||
'create-mpv',
|
|
||||||
'subtitle-timing',
|
|
||||||
'load-yomitan',
|
|
||||||
'init-overlay',
|
|
||||||
'handle-initial-args',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
await runAppReadyRuntime({
|
|
||||||
ensureDefaultConfigBootstrap: () => {
|
|
||||||
calls.push('bootstrap');
|
|
||||||
},
|
|
||||||
loadSubtitlePosition: () => {
|
|
||||||
calls.push('load-subtitle-position');
|
|
||||||
},
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
calls.push('resolve-keybindings');
|
|
||||||
},
|
|
||||||
createMpvClient: () => {
|
|
||||||
calls.push('create-mpv');
|
|
||||||
},
|
|
||||||
reloadConfig: () => {
|
|
||||||
calls.push('reload-config');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
websocket: { enabled: false },
|
websocket: { enabled: false },
|
||||||
annotationWebsocket: { enabled: false },
|
annotationWebsocket: { enabled: false },
|
||||||
texthooker: { launchAtStartup: false },
|
texthooker: { launchAtStartup: false },
|
||||||
}),
|
}),
|
||||||
getConfigWarnings: () => [],
|
getConfigWarnings: () => [],
|
||||||
logConfigWarning: () => {},
|
logConfigWarning: record('config-warning'),
|
||||||
setLogLevel: () => {
|
setLogLevel: record('set-log-level'),
|
||||||
calls.push('set-log-level');
|
initRuntimeOptionsManager: record('init-runtime-options'),
|
||||||
},
|
setSecondarySubMode: record('set-secondary-sub-mode'),
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
calls.push('init-runtime-options');
|
|
||||||
},
|
|
||||||
setSecondarySubMode: () => {
|
|
||||||
calls.push('set-secondary-sub-mode');
|
|
||||||
},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
defaultSecondarySubMode: 'hover',
|
||||||
defaultWebsocketPort: 0,
|
defaultWebsocketPort: 0,
|
||||||
defaultAnnotationWebsocketPort: 0,
|
defaultAnnotationWebsocketPort: 0,
|
||||||
defaultTexthookerPort: 0,
|
defaultTexthookerPort: 0,
|
||||||
hasMpvWebsocketPlugin: () => false,
|
hasMpvWebsocketPlugin: () => false,
|
||||||
startSubtitleWebsocket: () => {
|
startSubtitleWebsocket: record('subtitle-ws'),
|
||||||
calls.push('subtitle-ws');
|
startAnnotationWebsocket: record('annotation-ws'),
|
||||||
},
|
startTexthooker: record('texthooker'),
|
||||||
startAnnotationWebsocket: () => {
|
log: record('log'),
|
||||||
calls.push('annotation-ws');
|
createMecabTokenizerAndCheck: recordAsync('mecab'),
|
||||||
},
|
createSubtitleTimingTracker: record('subtitle-timing'),
|
||||||
startTexthooker: () => {
|
createImmersionTracker: record('immersion'),
|
||||||
calls.push('texthooker');
|
startJellyfinRemoteSession: recordAsync('jellyfin'),
|
||||||
},
|
loadYomitanExtension: recordAsync('load-yomitan'),
|
||||||
log: () => {
|
handleFirstRunSetup: recordAsync('first-run'),
|
||||||
calls.push('log');
|
prewarmSubtitleDictionaries: recordAsync('prewarm'),
|
||||||
},
|
startBackgroundWarmups: record('warmups'),
|
||||||
createMecabTokenizerAndCheck: async () => {},
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
calls.push('subtitle-timing');
|
|
||||||
},
|
|
||||||
createImmersionTracker: () => {
|
|
||||||
calls.push('immersion');
|
|
||||||
},
|
|
||||||
startJellyfinRemoteSession: async () => {},
|
|
||||||
loadYomitanExtension: async () => {
|
|
||||||
calls.push('load-yomitan');
|
|
||||||
},
|
|
||||||
handleFirstRunSetup: async () => {
|
|
||||||
calls.push('first-run');
|
|
||||||
},
|
|
||||||
prewarmSubtitleDictionaries: async () => {},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('warmups');
|
|
||||||
},
|
|
||||||
texthookerOnlyMode: false,
|
texthookerOnlyMode: false,
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||||
setVisibleOverlayVisible: () => {
|
setVisibleOverlayVisible: record('visible-overlay'),
|
||||||
calls.push('visible-overlay');
|
initializeOverlayRuntime: record('init-overlay'),
|
||||||
},
|
handleInitialArgs: record('handle-initial-args'),
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
calls.push('init-overlay');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handle-initial-args');
|
|
||||||
},
|
|
||||||
shouldUseMinimalStartup: () => false,
|
shouldUseMinimalStartup: () => false,
|
||||||
shouldSkipHeavyStartup: () => false,
|
shouldSkipHeavyStartup: () => false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { calls, deps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBefore(calls: string[], earlier: string, later: string) {
|
||||||
|
const earlierIndex = calls.indexOf(earlier);
|
||||||
|
const laterIndex = calls.indexOf(later);
|
||||||
|
assert.notEqual(earlierIndex, -1, `${earlier} was not called`);
|
||||||
|
assert.notEqual(laterIndex, -1, `${later} was not called`);
|
||||||
|
assert.ok(earlierIndex < laterIndex, `${earlier} should run before ${later}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while still handling CLI args', async () => {
|
||||||
|
const { calls, deps } = createAppReadyDeps({ shouldUseMinimalStartup: () => true });
|
||||||
|
|
||||||
|
await runAppReadyRuntime(deps);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('handle-initial-args'));
|
||||||
|
for (const skipped of ['load-yomitan', 'first-run', 'create-mpv', 'init-overlay', 'warmups']) {
|
||||||
|
assert.equal(calls.includes(skipped), false, `${skipped} should be skipped`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => {
|
||||||
|
const { calls, deps } = createAppReadyDeps({
|
||||||
|
shouldRunHeadlessInitialCommand: () => true,
|
||||||
|
runHeadlessInitialCommand: async () => {
|
||||||
|
calls.push('run-headless-command');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
await runAppReadyRuntime(deps);
|
||||||
assert.ok(calls.indexOf('warmups') !== -1);
|
|
||||||
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
|
assertBefore(calls, 'init-runtime-options', 'run-headless-command');
|
||||||
|
for (const skipped of ['create-mpv', 'load-yomitan', 'init-overlay', 'handle-initial-args']) {
|
||||||
|
assert.equal(calls.includes(skipped), false, `${skipped} should be skipped`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime loads Yomitan before headless overlay fallback initialization', async () => {
|
||||||
|
const { calls, deps } = createAppReadyDeps({ shouldRunHeadlessInitialCommand: () => true });
|
||||||
|
|
||||||
|
await runAppReadyRuntime(deps);
|
||||||
|
|
||||||
|
assertBefore(calls, 'create-mpv', 'subtitle-timing');
|
||||||
|
assertBefore(calls, 'load-yomitan', 'init-overlay');
|
||||||
|
assertBefore(calls, 'init-overlay', 'handle-initial-args');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
||||||
|
const { calls, deps } = createAppReadyDeps({
|
||||||
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAppReadyRuntime(deps);
|
||||||
|
|
||||||
|
assertBefore(calls, 'init-overlay', 'warmups');
|
||||||
assert.equal(calls.includes('load-yomitan'), false);
|
assert.equal(calls.includes('load-yomitan'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
||||||
const calls: string[] = [];
|
const { calls, deps } = createAppReadyDeps({
|
||||||
|
|
||||||
await runAppReadyRuntime({
|
|
||||||
ensureDefaultConfigBootstrap: () => {
|
|
||||||
calls.push('bootstrap');
|
|
||||||
},
|
|
||||||
loadSubtitlePosition: () => {
|
|
||||||
calls.push('load-subtitle-position');
|
|
||||||
},
|
|
||||||
resolveKeybindings: () => {
|
|
||||||
calls.push('resolve-keybindings');
|
|
||||||
},
|
|
||||||
createMpvClient: () => {
|
|
||||||
calls.push('create-mpv');
|
|
||||||
},
|
|
||||||
reloadConfig: () => {
|
|
||||||
calls.push('reload-config');
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => ({
|
|
||||||
websocket: { enabled: false },
|
|
||||||
annotationWebsocket: { enabled: false },
|
|
||||||
texthooker: { launchAtStartup: false },
|
|
||||||
}),
|
|
||||||
getConfigWarnings: () => [],
|
|
||||||
logConfigWarning: () => {},
|
|
||||||
setLogLevel: () => {
|
|
||||||
calls.push('set-log-level');
|
|
||||||
},
|
|
||||||
initRuntimeOptionsManager: () => {
|
|
||||||
calls.push('init-runtime-options');
|
|
||||||
},
|
|
||||||
setSecondarySubMode: () => {
|
|
||||||
calls.push('set-secondary-sub-mode');
|
|
||||||
},
|
|
||||||
defaultSecondarySubMode: 'hover',
|
|
||||||
defaultWebsocketPort: 0,
|
|
||||||
defaultAnnotationWebsocketPort: 0,
|
|
||||||
defaultTexthookerPort: 0,
|
|
||||||
hasMpvWebsocketPlugin: () => false,
|
|
||||||
startSubtitleWebsocket: () => {},
|
|
||||||
startAnnotationWebsocket: () => {},
|
|
||||||
startTexthooker: () => {},
|
|
||||||
log: () => {
|
|
||||||
calls.push('log');
|
|
||||||
},
|
|
||||||
createMecabTokenizerAndCheck: async () => {},
|
|
||||||
createSubtitleTimingTracker: () => {
|
|
||||||
calls.push('subtitle-timing');
|
|
||||||
},
|
|
||||||
createImmersionTracker: () => {
|
|
||||||
calls.push('immersion');
|
|
||||||
},
|
|
||||||
startJellyfinRemoteSession: async () => {},
|
|
||||||
loadYomitanExtension: async () => {
|
|
||||||
calls.push('load-yomitan-direct');
|
|
||||||
},
|
|
||||||
ensureYomitanExtensionLoaded: async () => {
|
ensureYomitanExtensionLoaded: async () => {
|
||||||
calls.push('load-yomitan-guarded');
|
calls.push('load-yomitan-guarded');
|
||||||
},
|
},
|
||||||
handleFirstRunSetup: async () => {
|
|
||||||
calls.push('first-run');
|
|
||||||
},
|
|
||||||
prewarmSubtitleDictionaries: async () => {},
|
|
||||||
startBackgroundWarmups: () => {
|
|
||||||
calls.push('warmups');
|
|
||||||
},
|
|
||||||
texthookerOnlyMode: false,
|
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
|
||||||
setVisibleOverlayVisible: () => {
|
|
||||||
calls.push('visible-overlay');
|
|
||||||
},
|
|
||||||
initializeOverlayRuntime: () => {
|
|
||||||
calls.push('init-overlay');
|
|
||||||
},
|
|
||||||
handleInitialArgs: () => {
|
|
||||||
calls.push('handle-initial-args');
|
|
||||||
},
|
|
||||||
shouldUseMinimalStartup: () => false,
|
|
||||||
shouldSkipHeavyStartup: () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(calls.includes('load-yomitan-direct'), false);
|
await runAppReadyRuntime(deps);
|
||||||
assert.equal(calls.includes('load-yomitan-guarded'), true);
|
|
||||||
|
assert.equal(calls.includes('load-yomitan'), false);
|
||||||
|
assertBefore(calls, 'warmups', 'load-yomitan-guarded');
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildStatsWindowUrl,
|
buildStatsWindowUrl,
|
||||||
buildStatsWindowOptions,
|
|
||||||
buildStatsNativeConfirmDialogOptions,
|
buildStatsNativeConfirmDialogOptions,
|
||||||
demoteVisibleStatsWindowBelowDialogs,
|
demoteVisibleStatsWindowBelowDialogs,
|
||||||
presentStatsWindow,
|
presentStatsWindow,
|
||||||
@@ -12,60 +11,8 @@ import {
|
|||||||
scheduleStatsWindowPostShowReconciles,
|
scheduleStatsWindowPostShowReconciles,
|
||||||
showStatsNativeConfirmDialog,
|
showStatsNativeConfirmDialog,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
shouldPresentStatsWindowAfterLoad,
|
|
||||||
} from './stats-window-runtime';
|
} from './stats-window-runtime';
|
||||||
|
|
||||||
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => {
|
|
||||||
const options = buildStatsWindowOptions({
|
|
||||||
preloadPath: '/tmp/preload-stats.js',
|
|
||||||
bounds: {
|
|
||||||
x: 120,
|
|
||||||
y: 80,
|
|
||||||
width: 1440,
|
|
||||||
height: 900,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(options.title, 'SubMiner Stats');
|
|
||||||
assert.equal(options.x, 120);
|
|
||||||
assert.equal(options.y, 80);
|
|
||||||
assert.equal(options.width, 1440);
|
|
||||||
assert.equal(options.height, 900);
|
|
||||||
assert.equal(options.frame, false);
|
|
||||||
assert.equal(options.transparent, false);
|
|
||||||
assert.equal(options.backgroundColor, '#24273a');
|
|
||||||
assert.equal(options.resizable, false);
|
|
||||||
assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js');
|
|
||||||
assert.equal(options.webPreferences?.contextIsolation, true);
|
|
||||||
assert.equal(options.webPreferences?.nodeIntegration, false);
|
|
||||||
assert.equal(options.webPreferences?.sandbox, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildStatsWindowOptions uses a fullscreen auxiliary panel on macOS', () => {
|
|
||||||
const options = buildStatsWindowOptions({
|
|
||||||
preloadPath: '/tmp/preload-stats.js',
|
|
||||||
platform: 'darwin',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(options.type, 'panel');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
|
||||||
const options = buildStatsWindowOptions({
|
|
||||||
preloadPath: '/tmp/preload-stats.js',
|
|
||||||
platform: 'linux',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(options.type, undefined);
|
|
||||||
assert.equal(options.roundedCorners, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats panels present after document load on macOS', () => {
|
|
||||||
assert.equal(shouldPresentStatsWindowAfterLoad('darwin'), true);
|
|
||||||
assert.equal(shouldPresentStatsWindowAfterLoad('linux'), false);
|
|
||||||
assert.equal(shouldPresentStatsWindowAfterLoad('win32'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
|
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldHideStatsWindowForInput(
|
shouldHideStatsWindowForInput(
|
||||||
@@ -173,10 +120,6 @@ test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', (
|
|||||||
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1');
|
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildStatsWindowUrl uses the active server port as the document origin', () => {
|
|
||||||
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
|
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
resolveStatsWindowOuterBoundsForContent(
|
resolveStatsWindowOuterBoundsForContent(
|
||||||
|
|||||||
@@ -133,31 +133,6 @@ test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media path
|
|||||||
assert.equal(ffsubsyncAvailable, false);
|
assert.equal(ffsubsyncAvailable, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
|
||||||
const osd: string[] = [];
|
|
||||||
let payloadTrackCount = 0;
|
|
||||||
let spinnerRan = false;
|
|
||||||
|
|
||||||
await triggerSubsyncFromConfig(
|
|
||||||
makeDeps({
|
|
||||||
openManualPicker: (payload) => {
|
|
||||||
payloadTrackCount = payload.subtitleTracks.length;
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => {
|
|
||||||
osd.push(text);
|
|
||||||
},
|
|
||||||
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => {
|
|
||||||
spinnerRan = true;
|
|
||||||
return task();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(payloadTrackCount, 2);
|
|
||||||
assert.equal(spinnerRan, false);
|
|
||||||
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||||
let payloadTrackCount = 0;
|
let payloadTrackCount = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -267,34 +267,6 @@ test('parseAssCues respects dynamic field ordering from the Format row', () => {
|
|||||||
assert.equal(cues[0]!.text, '順番が違う');
|
assert.equal(cues[0]!.text, '順番が違う');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues auto-detects SRT format', () => {
|
|
||||||
const content = ['1', '00:00:01,000 --> 00:00:04,000', 'SRTテスト', ''].join('\n');
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.srt');
|
|
||||||
assert.equal(cues.length, 1);
|
|
||||||
assert.equal(cues[0]!.text, 'SRTテスト');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues auto-detects ASS format', () => {
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,ASSテスト',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.ass');
|
|
||||||
assert.equal(cues.length, 1);
|
|
||||||
assert.equal(cues[0]!.text, 'ASSテスト');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues auto-detects VTT format', () => {
|
|
||||||
const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTテスト', ''].join('\n');
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.vtt');
|
|
||||||
assert.equal(cues.length, 1);
|
|
||||||
assert.equal(cues[0]!.text, 'VTTテスト');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues returns empty for unknown format', () => {
|
test('parseSubtitleCues returns empty for unknown format', () => {
|
||||||
assert.deepEqual(parseSubtitleCues('random content', 'test.xyz'), []);
|
assert.deepEqual(parseSubtitleCues('random content', 'test.xyz'), []);
|
||||||
});
|
});
|
||||||
@@ -449,23 +421,6 @@ test('parseSubtitleCues keeps short styled repeats separate even with richer sty
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
|
|
||||||
// Several characters greeting in turn: distinct utterances that happen to abut.
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Dialogue: 0,0:04:05.67,0:04:06.82,Dial_JP,,0,0,0,,おはよう',
|
|
||||||
'Dialogue: 0,0:04:06.82,0:04:07.56,Dial_JP,,0,0,0,,おはよう',
|
|
||||||
'Dialogue: 0,0:04:07.56,0:04:08.78,Dial_JP,,0,0,0,,おはよう',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.ass');
|
|
||||||
|
|
||||||
assert.equal(cues.length, 3);
|
|
||||||
assert.equal(cues[0]!.endTime, 246.82);
|
|
||||||
assert.equal(cues[2]!.startTime, 247.56);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => {
|
test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
@@ -613,28 +568,6 @@ test('parseSubtitleCues keeps short animated English dialogue as separate cues',
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues does not reconstruct an already canonical English cue', () => {
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF',
|
|
||||||
'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF',
|
|
||||||
'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF',
|
|
||||||
'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
|
||||||
{
|
|
||||||
startTime: 1,
|
|
||||||
endTime: 3,
|
|
||||||
text: 'POOF',
|
|
||||||
source: 'canonical-ass',
|
|
||||||
animationStartTime: 1,
|
|
||||||
animationEndTime: 3,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => {
|
test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
@@ -794,38 +727,6 @@ test('parseSubtitleCues keeps two positioned signs that repeat the same text', (
|
|||||||
assert.equal(cues[1]!.startTime, 63.0);
|
assert.equal(cues[1]!.startTime, 63.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues keeps a run of ordinary positioned lines separate', () => {
|
|
||||||
// Three events is a sequence, but none of them runs at animation-frame speed.
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Dialogue: 0,0:01:00.00,0:01:02.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
|
||||||
'Dialogue: 0,0:01:02.00,0:01:04.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
|
||||||
'Dialogue: 0,0:01:04.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.ass');
|
|
||||||
|
|
||||||
assert.equal(cues.length, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues keeps a short repeated SRT pair without burst evidence', () => {
|
|
||||||
const content = [
|
|
||||||
'1',
|
|
||||||
'00:00:01,000 --> 00:00:01,200',
|
|
||||||
'えっ',
|
|
||||||
'',
|
|
||||||
'2',
|
|
||||||
'00:00:01,200 --> 00:00:01,400',
|
|
||||||
'えっ',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.srt');
|
|
||||||
|
|
||||||
assert.equal(cues.length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues collapses a burst marked only by the Effect column', () => {
|
test('parseSubtitleCues collapses a burst marked only by the Effect column', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
@@ -881,22 +782,6 @@ test('parseSubtitleCues does not merge a burst into unrelated dialogue between f
|
|||||||
assert.equal(cues[0]!.endTime, 3.55);
|
assert.equal(cues[0]!.endTime, 3.55);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues keeps rapid ASS lines from different actors separate', () => {
|
|
||||||
// Three 200ms `えっ` reactions traded between characters. Fast, adjacent and identical,
|
|
||||||
// but authored as three lines: different styles and different actors.
|
|
||||||
const content = [
|
|
||||||
'[Events]',
|
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
|
||||||
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_A,アリス,0,0,0,,えっ',
|
|
||||||
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_B,ボブ,0,0,0,,えっ',
|
|
||||||
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_C,キャロル,0,0,0,,えっ',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.ass');
|
|
||||||
|
|
||||||
assert.equal(cues.length, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => {
|
test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
|
|||||||
@@ -541,20 +541,6 @@ test('cache evicts least recently used entries once the limit is reached', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('default cache limit covers a full-length title without evicting', () => {
|
|
||||||
const controller = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
|
||||||
emitSubtitle: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < 2000; i += 1) {
|
|
||||||
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
|
||||||
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('onSubtitleChange reports whether processing was scheduled', async () => {
|
test('onSubtitleChange reports whether processing was scheduled', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
|
|||||||
+598
-4413
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -99,77 +99,3 @@ test('enrichTokensWithMecabPos1 passes through unchanged when mecab tokens are n
|
|||||||
const emptyResult = enrichTokensWithMecabPos1(tokens, []);
|
const emptyResult = enrichTokensWithMecabPos1(tokens, []);
|
||||||
assert.strictEqual(emptyResult, tokens);
|
assert.strictEqual(emptyResult, tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enrichTokensWithMecabPos1 avoids repeated full scans over distant mecab surfaces', () => {
|
|
||||||
const tokens = Array.from({ length: 12 }, (_, index) =>
|
|
||||||
makeToken({ surface: `w${index}`, startPos: index, endPos: index + 1, pos1: '' }),
|
|
||||||
);
|
|
||||||
const mecabTokens = tokens.map((token) =>
|
|
||||||
makeToken({
|
|
||||||
surface: token.surface,
|
|
||||||
startPos: token.startPos,
|
|
||||||
endPos: token.endPos,
|
|
||||||
pos1: '名詞',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let distantSurfaceReads = 0;
|
|
||||||
const distantToken = makeToken({ surface: '遠', startPos: 500, endPos: 501, pos1: '記号' });
|
|
||||||
Object.defineProperty(distantToken, 'surface', {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
distantSurfaceReads += 1;
|
|
||||||
if (distantSurfaceReads > 3) {
|
|
||||||
throw new Error('repeated full scan detected');
|
|
||||||
}
|
|
||||||
return '遠';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mecabTokens.push(distantToken);
|
|
||||||
|
|
||||||
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
|
||||||
assert.equal(enriched.length, tokens.length);
|
|
||||||
for (const token of enriched) {
|
|
||||||
assert.equal(token.pos1, '名詞');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('enrichTokensWithMecabPos1 avoids repeated active-candidate filter scans', () => {
|
|
||||||
const tokens = Array.from({ length: 8 }, (_, index) =>
|
|
||||||
makeToken({ surface: `u${index}`, startPos: index, endPos: index + 1, pos1: '' }),
|
|
||||||
);
|
|
||||||
const mecabTokens = [
|
|
||||||
makeToken({ surface: 'SENTINEL', startPos: 0, endPos: 100, pos1: '記号' }),
|
|
||||||
...tokens.map((token, index) =>
|
|
||||||
makeToken({
|
|
||||||
surface: `m${index}`,
|
|
||||||
startPos: token.startPos,
|
|
||||||
endPos: token.endPos,
|
|
||||||
pos1: '名詞',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let sentinelFilterCalls = 0;
|
|
||||||
const originalFilter = Array.prototype.filter;
|
|
||||||
Array.prototype.filter = function filterWithSentinelCheck(
|
|
||||||
this: unknown[],
|
|
||||||
...args: any[]
|
|
||||||
): any[] {
|
|
||||||
const target = this as Array<{ surface?: string }>;
|
|
||||||
if (target.some((candidate) => candidate?.surface === 'SENTINEL')) {
|
|
||||||
sentinelFilterCalls += 1;
|
|
||||||
if (sentinelFilterCalls > 2) {
|
|
||||||
throw new Error('repeated active candidate filter scan detected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (originalFilter as (...params: any[]) => any[]).apply(this, args);
|
|
||||||
} as typeof Array.prototype.filter;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
|
||||||
assert.equal(enriched.length, tokens.length);
|
|
||||||
} finally {
|
|
||||||
Array.prototype.filter = originalFilter;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -175,18 +175,6 @@ test('still drops punctuation-only and whitespace-only unparsed runs', () => {
|
|||||||
assert.equal(tokens?.map((token) => token.surface).join(','), '猫,犬');
|
assert.equal(tokens?.map((token) => token.surface).join(','), '猫,犬');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('candidate with only unparsed tokens still yields no dictionary match', () => {
|
|
||||||
const parseResults = [
|
|
||||||
makeParseItem('scanning-parser', [
|
|
||||||
[{ text: '戻', reading: '' }],
|
|
||||||
[{ text: '轟', reading: '' }],
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
|
||||||
assert.equal(tokens, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prefers the longest dictionary headword across merged segments', () => {
|
test('prefers the longest dictionary headword across merged segments', () => {
|
||||||
const parseResults = [
|
const parseResults = [
|
||||||
makeParseItem('scanning-parser', [
|
makeParseItem('scanning-parser', [
|
||||||
@@ -379,50 +367,30 @@ test('merges trailing katakana continuation without headword into previous token
|
|||||||
// is merged with trailing function particles (e.g. かかってこいよ → headword かかってくる).
|
// is merged with trailing function particles (e.g. かかってこいよ → headword かかってくる).
|
||||||
// When a competing multi-token candidate splits content and function separately, the
|
// When a competing multi-token candidate splits content and function separately, the
|
||||||
// multi-token candidate should win so the content token remains frequency-highlightable.
|
// multi-token candidate should win so the content token remains frequency-highlightable.
|
||||||
test('multi-token candidate beats single merged content+function token candidate (frequency regression)', () => {
|
test('multi-token candidate beats single merged content+function token candidate in either input order (frequency regression)', () => {
|
||||||
// Candidate A: single merged token — content verb fused with trailing sentence-final particle
|
// Merged candidate: content verb fused with the trailing sentence-final particle. Downstream
|
||||||
// This is the "bad" candidate: downstream annotation would exclude frequency for the whole
|
// annotation would exclude frequency for the whole token because its merged pos1 contains a
|
||||||
// token because the merged pos1 would contain a function-word component.
|
// function-word component.
|
||||||
const mergedCandidate = makeParseItem('scanning-parser', [
|
const mergedCandidate = makeParseItem('scanning-parser', [
|
||||||
[{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }],
|
[{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }],
|
||||||
]);
|
]);
|
||||||
|
// Split candidate: the content token stays frequency-eligible on its own.
|
||||||
// Candidate B: two tokens — content verb surface + particle separately.
|
|
||||||
// The content token is frequency-eligible on its own.
|
|
||||||
const splitCandidate = makeParseItem('scanning-parser', [
|
const splitCandidate = makeParseItem('scanning-parser', [
|
||||||
[{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }],
|
[{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }],
|
||||||
[{ text: 'よ', reading: 'よ', headword: 'よ' }],
|
[{ text: 'よ', reading: 'よ', headword: 'よ' }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// When merged candidate comes first in the array, multi-token split still wins.
|
for (const candidates of [
|
||||||
const tokens = selectYomitanParseTokens(
|
|
||||||
[mergedCandidate, splitCandidate],
|
[mergedCandidate, splitCandidate],
|
||||||
() => false,
|
|
||||||
'headword',
|
|
||||||
);
|
|
||||||
assert.equal(tokens?.length, 2);
|
|
||||||
assert.equal(tokens?.[0]?.surface, 'かかってこい');
|
|
||||||
assert.equal(tokens?.[0]?.headword, 'かかってくる');
|
|
||||||
assert.equal(tokens?.[1]?.surface, 'よ');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multi-token candidate beats single merged content+function token regardless of input order', () => {
|
|
||||||
const mergedCandidate = makeParseItem('scanning-parser', [
|
|
||||||
[{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const splitCandidate = makeParseItem('scanning-parser', [
|
|
||||||
[{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }],
|
|
||||||
[{ text: 'よ', reading: 'よ', headword: 'よ' }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Split candidate comes first — should still win over merged.
|
|
||||||
const tokens = selectYomitanParseTokens(
|
|
||||||
[splitCandidate, mergedCandidate],
|
[splitCandidate, mergedCandidate],
|
||||||
() => false,
|
]) {
|
||||||
'headword',
|
const tokens = selectYomitanParseTokens(candidates, () => false, 'headword');
|
||||||
);
|
assert.deepEqual(
|
||||||
assert.equal(tokens?.length, 2);
|
tokens?.map((token) => ({ surface: token.surface, headword: token.headword })),
|
||||||
assert.equal(tokens?.[0]?.surface, 'かかってこい');
|
[
|
||||||
assert.equal(tokens?.[1]?.surface, 'よ');
|
{ surface: 'かかってこい', headword: 'かかってくる' },
|
||||||
|
{ surface: 'よ', headword: 'よ' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -4,10 +4,8 @@ import { MergedToken, PartOfSpeech } from '../../../types';
|
|||||||
import {
|
import {
|
||||||
createSubtitleAnnotationRuleContext,
|
createSubtitleAnnotationRuleContext,
|
||||||
shouldExcludeTokenFromSubtitleAnnotations,
|
shouldExcludeTokenFromSubtitleAnnotations,
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS,
|
|
||||||
SUBTITLE_ANNOTATION_RULES,
|
SUBTITLE_ANNOTATION_RULES,
|
||||||
} from './subtitle-annotation-filter';
|
} from './subtitle-annotation-filter';
|
||||||
import { isKanaChar } from './token-classification';
|
|
||||||
|
|
||||||
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
|
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
|
||||||
return {
|
return {
|
||||||
@@ -24,96 +22,6 @@ function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('subtitle annotation rules expose stable ordered provenance', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
SUBTITLE_ANNOTATION_RULES.map(({ id, issueRef }) => ({ id, issueRef })),
|
|
||||||
[
|
|
||||||
{ id: 'unparsed-run', issueRef: '#153' },
|
|
||||||
{ id: 'configured-pos1-exclusion', issueRef: '#19' },
|
|
||||||
{ id: 'configured-pos2-exclusion', issueRef: '#150' },
|
|
||||||
{ id: 'coarse-grammar-pos-fallback', issueRef: '#19' },
|
|
||||||
{ id: 'auxiliary-stem-grammar-tail', issueRef: '#19' },
|
|
||||||
{ id: 'kana-non-independent-noun-helper', issueRef: '#56' },
|
|
||||||
{ id: 'standalone-auxiliary-inflection', issueRef: '#57' },
|
|
||||||
{ id: 'auxiliary-only-helper-span', issueRef: '#57' },
|
|
||||||
{ id: 'standalone-suru-te-helper', issueRef: '#57' },
|
|
||||||
{ id: 'standalone-grammar-particle', issueRef: '#57' },
|
|
||||||
{ id: 'single-kana-fragment', issueRef: '#57' },
|
|
||||||
{ id: 'merged-trailing-quote-particle', issueRef: '#19' },
|
|
||||||
{ id: 'lexical-kureru-keep', issueRef: '#57' },
|
|
||||||
{ id: 'excluded-term-or-pattern', issueRef: '#19, #33, #57' },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert.equal(new Set(SUBTITLE_ANNOTATION_RULES.map(({ id }) => id)).size, 14);
|
|
||||||
assert.ok(SUBTITLE_ANNOTATION_RULES.every(({ description }) => description.length > 0));
|
|
||||||
assert.ok(
|
|
||||||
SUBTITLE_ANNOTATION_RULES.every(
|
|
||||||
({ data }) => Object.isFrozen(data) && Object.values(data).every(Object.isFrozen),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lexical kureru keep rule precedes and overrides the excluded-term rule', () => {
|
|
||||||
const token = makeToken({
|
|
||||||
surface: 'くれ',
|
|
||||||
headword: 'くれる',
|
|
||||||
reading: 'クレ',
|
|
||||||
partOfSpeech: PartOfSpeech.verb,
|
|
||||||
pos1: '動詞',
|
|
||||||
pos2: '自立',
|
|
||||||
});
|
|
||||||
const context = createSubtitleAnnotationRuleContext(token);
|
|
||||||
const keepIndex = SUBTITLE_ANNOTATION_RULES.findIndex(({ id }) => id === 'lexical-kureru-keep');
|
|
||||||
const termIndex = SUBTITLE_ANNOTATION_RULES.findIndex(
|
|
||||||
({ id }) => id === 'excluded-term-or-pattern',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(keepIndex >= 0 && keepIndex < termIndex);
|
|
||||||
assert.ok(SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has('くれ'));
|
|
||||||
assert.equal(SUBTITLE_ANNOTATION_RULES[keepIndex]?.test(context), 'keep');
|
|
||||||
assert.equal(SUBTITLE_ANNOTATION_RULES[termIndex]?.test(context), 'exclude');
|
|
||||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('adding an exported excluded term changes annotation matching', () => {
|
|
||||||
const term = '超語彙';
|
|
||||||
const token = makeToken({
|
|
||||||
surface: term,
|
|
||||||
headword: term,
|
|
||||||
reading: term,
|
|
||||||
pos1: '名詞',
|
|
||||||
pos2: '一般',
|
|
||||||
});
|
|
||||||
|
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term);
|
|
||||||
try {
|
|
||||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.add(term);
|
|
||||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
|
||||||
} finally {
|
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleting an exported excluded term changes annotation matching', () => {
|
|
||||||
const term = 'あなた';
|
|
||||||
const token = makeToken({
|
|
||||||
surface: term,
|
|
||||||
headword: term,
|
|
||||||
reading: term,
|
|
||||||
pos1: '名詞',
|
|
||||||
pos2: '一般',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(term));
|
|
||||||
try {
|
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.delete(term);
|
|
||||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
|
||||||
} finally {
|
|
||||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.add(term);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('configured POS rules consume the context exclusion sets in table order', () => {
|
test('configured POS rules consume the context exclusion sets in table order', () => {
|
||||||
const token = makeToken({ pos1: '名詞', pos2: '一般' });
|
const token = makeToken({ pos1: '名詞', pos2: '一般' });
|
||||||
const context = createSubtitleAnnotationRuleContext(token, {
|
const context = createSubtitleAnnotationRuleContext(token, {
|
||||||
@@ -175,9 +83,3 @@ test('configured POS2 rule preserves supplementary-plane kanji nouns', () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('kana detection excludes katakana punctuation boundaries', () => {
|
|
||||||
assert.equal(isKanaChar('゠'), false);
|
|
||||||
assert.equal(isKanaChar('・'), false);
|
|
||||||
assert.equal(isKanaChar('ァ'), true);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -258,20 +258,6 @@ test('halfwidth voiced kana compose into the reading instead of leaving a stray
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requestYomitanScanTokens falls back to the exhaustive name scan without candidates', async () => {
|
|
||||||
const withoutLookups: string[] = [];
|
|
||||||
const withoutCandidates = await requestYomitanScanTokens(
|
|
||||||
NAME_SCAN_LINE,
|
|
||||||
createNameScanDeps(withoutLookups),
|
|
||||||
{ error: () => undefined },
|
|
||||||
{ includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 1, nameCandidates: null },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(withoutCandidates?.[0]?.isNameMatch, true);
|
|
||||||
// No candidate list means every Japanese position is probed, as before.
|
|
||||||
assert.ok(countTermsFindLookups(withoutLookups, '校に') > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('requestYomitanScanTokens reinstalls name candidates when the media changes', async () => {
|
test('requestYomitanScanTokens reinstalls name candidates when the media changes', async () => {
|
||||||
const lookups: string[] = [];
|
const lookups: string[] = [];
|
||||||
const deps = createNameScanDeps(lookups);
|
const deps = createNameScanDeps(lookups);
|
||||||
@@ -302,3 +288,35 @@ test('requestYomitanScanTokens reinstalls name candidates when the media changes
|
|||||||
assert.equal(correctMedia?.[0]?.surface, 'ミナト');
|
assert.equal(correctMedia?.[0]?.surface, 'ミナト');
|
||||||
assert.equal(correctMedia?.[0]?.isNameMatch, true);
|
assert.equal(correctMedia?.[0]?.isNameMatch, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('requestYomitanScanTokens skips the greedy name pre-pass when name matching is off', async () => {
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
name: 'name-match metadata disabled',
|
||||||
|
dictionaries: undefined,
|
||||||
|
includeNameMatchMetadata: false,
|
||||||
|
expectedNameMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'no enabled character dictionary',
|
||||||
|
dictionaries: ['JMdict'],
|
||||||
|
includeNameMatchMetadata: true,
|
||||||
|
// The main walk still classifies the match; only the pre-pass is gated.
|
||||||
|
expectedNameMatch: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const c of cases) {
|
||||||
|
const lookups: string[] = [];
|
||||||
|
const result = await requestYomitanScanTokens(
|
||||||
|
NAME_SCAN_LINE,
|
||||||
|
createNameScanDeps(lookups, NAME_SCAN_WORDS, c.dictionaries),
|
||||||
|
{ error: () => undefined },
|
||||||
|
{ includeNameMatchMetadata: c.includeNameMatchMetadata },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result?.[0]?.surface, 'ミナト', c.name);
|
||||||
|
assert.equal(result?.[0]?.isNameMatch, c.expectedNameMatch, c.name);
|
||||||
|
// Mid-token positions are only probed by the pre-pass.
|
||||||
|
assert.equal(countTermsFindLookups(lookups, '校に'), 0, c.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -39,7 +39,11 @@ function createYomitanScriptSandbox(handler: (action: string, params: unknown) =
|
|||||||
callback: (response: { result?: unknown; error?: { message?: string } }) => void,
|
callback: (response: { result?: unknown; error?: { message?: string } }) => void,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
callback({ result: handler(payload.action ?? '', payload.params) });
|
// Messages cross a process boundary in Electron; clone params into
|
||||||
|
// the host realm so handlers can compare them structurally.
|
||||||
|
const params =
|
||||||
|
payload.params === undefined ? undefined : structuredClone(payload.params);
|
||||||
|
callback({ result: handler(payload.action ?? '', params) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
callback({ error: { message: (error as Error).message } });
|
callback({ error: { message: (error as Error).message } });
|
||||||
}
|
}
|
||||||
@@ -98,8 +102,128 @@ export function countTermsFindLookups(lookups: string[], prefix: string): number
|
|||||||
return lookups.filter((lookupText) => lookupText.startsWith(prefix)).length;
|
return lookups.filter((lookupText) => lookupText.startsWith(prefix)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TermsFindResult {
|
||||||
|
originalTextLength: number;
|
||||||
|
dictionaryEntries: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// One termsFind dictionary entry whose headword exactly matches `originalText`
|
||||||
|
// (the scanned surface, defaulting to the term). `dictionary` attaches a
|
||||||
|
// definition from that dictionary, e.g. a SubMiner character dictionary.
|
||||||
|
export function termEntry(
|
||||||
|
term: string,
|
||||||
|
reading: string,
|
||||||
|
options: { originalText?: string; isPrimary?: boolean; dictionary?: string } = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
headwords: [
|
||||||
|
{
|
||||||
|
term,
|
||||||
|
reading,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
originalText: options.originalText ?? term,
|
||||||
|
isPrimary: options.isPrimary ?? true,
|
||||||
|
matchType: 'exact',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...(options.dictionary ? { definitions: [{ dictionary: options.dictionary }] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function termsFound(
|
||||||
|
originalTextLength: number,
|
||||||
|
...dictionaryEntries: unknown[]
|
||||||
|
): TermsFindResult {
|
||||||
|
return { originalTextLength, dictionaryEntries };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan deps backed by a fake Yomitan backend. The active profile enables
|
||||||
|
// `dictionaries` (in priority order; omitted means no dictionary list),
|
||||||
|
// getDictionaryInfo returns `dictionaryInfo`, and termsFind answers through
|
||||||
|
// `termsFind` (null/undefined means no match) while recording each text in
|
||||||
|
// `lookups`. `actions` handles any other backend action and overrides the
|
||||||
|
// defaults above; anything else throws. Every action name lands in `actionLog`.
|
||||||
|
export function createBackendDeps(
|
||||||
|
options: {
|
||||||
|
dictionaries?: string[];
|
||||||
|
dictionaryInfo?: unknown[];
|
||||||
|
termsFind?: (text: string) => TermsFindResult | null | undefined;
|
||||||
|
lookups?: string[];
|
||||||
|
actions?: Record<string, (params: unknown) => unknown>;
|
||||||
|
actionLog?: string[];
|
||||||
|
onScript?: (script: string) => void;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return createScanDeps(
|
||||||
|
(action, params) => {
|
||||||
|
options.actionLog?.push(action);
|
||||||
|
const override = options.actions?.[action];
|
||||||
|
if (override) {
|
||||||
|
return override(params);
|
||||||
|
}
|
||||||
|
if (action === 'optionsGetFull') {
|
||||||
|
return {
|
||||||
|
profileCurrent: 0,
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
scanning: { length: 40 },
|
||||||
|
...(options.dictionaries
|
||||||
|
? {
|
||||||
|
dictionaries: options.dictionaries.map((name, id) => ({
|
||||||
|
name,
|
||||||
|
enabled: true,
|
||||||
|
id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === 'getDictionaryInfo') {
|
||||||
|
return options.dictionaryInfo ?? [];
|
||||||
|
}
|
||||||
|
if (action === 'termsFind' && options.termsFind) {
|
||||||
|
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||||
|
options.lookups?.push(text);
|
||||||
|
return options.termsFind(text) ?? termsFound(0);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected action: ${action}`);
|
||||||
|
},
|
||||||
|
{ onScript: options.onScript },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deps whose hidden settings.html window runs injected scripts against a fake
|
||||||
|
// `__subminerYomitanSettingsAutomation` bridge, like the real settings page.
|
||||||
|
export function createSettingsAutomationDeps(automation: Record<string, unknown>) {
|
||||||
|
const context = vm.createContext({
|
||||||
|
__subminerYomitanSettingsAutomation: { ready: true, ...automation },
|
||||||
|
setTimeout,
|
||||||
|
});
|
||||||
|
const settingsWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => undefined,
|
||||||
|
webContents: {
|
||||||
|
executeJavaScript: async (script: string) => await vm.runInContext(script, context),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return createDeps(async () => true, {
|
||||||
|
createYomitanExtensionWindow: async (pageName) =>
|
||||||
|
pageName === 'settings.html' ? settingsWindow : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUBMINER_TEST_CHARACTER_DICTIONARY = 'SubMiner Character Dictionary (AniList 1)';
|
||||||
|
|
||||||
// Backend stub for the greedy name pre-pass: one character name (ミナト) in a
|
// Backend stub for the greedy name pre-pass: one character name (ミナト) in a
|
||||||
// line of ordinary words, with the SubMiner character dictionary enabled.
|
// line of ordinary words, with the SubMiner character dictionary enabled
|
||||||
|
// unless `dictionaries` says otherwise.
|
||||||
export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
|
export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
|
||||||
['ミナト', 'ミナト', 'みなと', true],
|
['ミナト', 'ミナト', 'みなと', true],
|
||||||
['は', 'は', 'は', false],
|
['は', 'は', 'は', false],
|
||||||
@@ -112,57 +236,24 @@ export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
|
|||||||
export function createNameScanDeps(
|
export function createNameScanDeps(
|
||||||
lookups: string[],
|
lookups: string[],
|
||||||
words: Array<[string, string, string, boolean]> = NAME_SCAN_WORDS,
|
words: Array<[string, string, string, boolean]> = NAME_SCAN_WORDS,
|
||||||
|
dictionaries: string[] = ['JMdict', SUBMINER_TEST_CHARACTER_DICTIONARY],
|
||||||
) {
|
) {
|
||||||
return createScanDeps((action, params) => {
|
return createBackendDeps({
|
||||||
if (action === 'optionsGetFull') {
|
dictionaries,
|
||||||
return {
|
lookups,
|
||||||
profileCurrent: 0,
|
termsFind: (text) => {
|
||||||
profiles: [
|
const match = words.find(([surface]) => text.startsWith(surface));
|
||||||
{
|
if (!match) {
|
||||||
options: {
|
return null;
|
||||||
scanning: { length: 40 },
|
|
||||||
dictionaries: [
|
|
||||||
{ name: 'JMdict', enabled: true, id: 0 },
|
|
||||||
{
|
|
||||||
name: 'SubMiner Character Dictionary (AniList 1)',
|
|
||||||
enabled: true,
|
|
||||||
id: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action === 'getDictionaryInfo') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (action !== 'termsFind') {
|
|
||||||
throw new Error(`unexpected action: ${action}`);
|
|
||||||
}
|
|
||||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
|
||||||
lookups.push(text);
|
|
||||||
for (const [surface, term, reading, isName] of words) {
|
|
||||||
if (text.startsWith(surface)) {
|
|
||||||
return {
|
|
||||||
originalTextLength: surface.length,
|
|
||||||
dictionaryEntries: [
|
|
||||||
{
|
|
||||||
headwords: [
|
|
||||||
{
|
|
||||||
term,
|
|
||||||
reading,
|
|
||||||
sources: [{ originalText: surface, isPrimary: true, matchType: 'exact' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
definitions: [
|
|
||||||
{ dictionary: isName ? 'SubMiner Character Dictionary (AniList 1)' : 'JMdict' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
const [surface, term, reading, isName] = match;
|
||||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
return termsFound(
|
||||||
|
surface.length,
|
||||||
|
termEntry(term, reading, {
|
||||||
|
originalText: surface,
|
||||||
|
dictionary: isName ? SUBMINER_TEST_CHARACTER_DICTIONARY : 'JMdict',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2,44 +2,49 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
|
import { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
|
||||||
|
|
||||||
test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => {
|
const cases = [
|
||||||
const calls: string[] = [];
|
{ name: 'a live parser window', window: { destroyed: false }, expectDestroyed: true },
|
||||||
const parserWindow = {
|
{
|
||||||
isDestroyed: () => false,
|
name: 'an already destroyed parser window',
|
||||||
destroy: () => {
|
window: { destroyed: true },
|
||||||
calls.push('destroy');
|
expectDestroyed: false,
|
||||||
},
|
},
|
||||||
};
|
{ name: 'no parser window', window: null, expectDestroyed: false },
|
||||||
|
];
|
||||||
|
|
||||||
clearYomitanParserRuntimeState({
|
for (const c of cases) {
|
||||||
getYomitanParserWindow: () => parserWindow as never,
|
test(`clearYomitanParserRuntimeState clears parser state with ${c.name}`, () => {
|
||||||
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
let destroyCalls = 0;
|
||||||
setYomitanParserReadyPromise: (promise) =>
|
const parserWindow = c.window && {
|
||||||
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
isDestroyed: () => c.window.destroyed,
|
||||||
setYomitanParserInitPromise: (promise) =>
|
destroy: () => {
|
||||||
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
destroyCalls += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const state: {
|
||||||
|
window: unknown;
|
||||||
|
ready: Promise<void> | null;
|
||||||
|
init: Promise<boolean> | null;
|
||||||
|
} = {
|
||||||
|
window: parserWindow,
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
init: Promise.resolve(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
clearYomitanParserRuntimeState({
|
||||||
|
getYomitanParserWindow: () => parserWindow,
|
||||||
|
setYomitanParserWindow: (window) => {
|
||||||
|
state.window = window;
|
||||||
|
},
|
||||||
|
setYomitanParserReadyPromise: (promise) => {
|
||||||
|
state.ready = promise;
|
||||||
|
},
|
||||||
|
setYomitanParserInitPromise: (promise) => {
|
||||||
|
state.init = promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(destroyCalls, c.expectDestroyed ? 1 : 0);
|
||||||
|
assert.deepEqual(state, { window: null, ready: null, init: null });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
assert.deepEqual(calls, ['destroy', 'window:null', 'ready:null', 'init:null']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clearYomitanParserRuntimeState skips destroy when parser window is already gone', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const parserWindow = {
|
|
||||||
isDestroyed: () => true,
|
|
||||||
destroy: () => {
|
|
||||||
calls.push('destroy');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
clearYomitanParserRuntimeState({
|
|
||||||
getYomitanParserWindow: () => parserWindow as never,
|
|
||||||
setYomitanParserWindow: (window) => calls.push(`window:${window === null ? 'null' : 'set'}`),
|
|
||||||
setYomitanParserReadyPromise: (promise) =>
|
|
||||||
calls.push(`ready:${promise === null ? 'null' : 'set'}`),
|
|
||||||
setYomitanParserInitPromise: (promise) =>
|
|
||||||
calls.push(`init:${promise === null ? 'null' : 'set'}`),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['window:null', 'ready:null', 'init:null']);
|
|
||||||
});
|
|
||||||
@@ -2,9 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildYomitanSettingsCloseButtonScript,
|
|
||||||
buildYomitanSettingsWindowMenuTemplate,
|
buildYomitanSettingsWindowMenuTemplate,
|
||||||
buildYomitanSettingsUrl,
|
|
||||||
configureYomitanSettingsWindowChrome,
|
configureYomitanSettingsWindowChrome,
|
||||||
destroyYomitanSettingsWindow,
|
destroyYomitanSettingsWindow,
|
||||||
installYomitanSettingsCloseButton,
|
installYomitanSettingsCloseButton,
|
||||||
@@ -49,15 +47,6 @@ test('yomitan settings close menu skips destroyed windows', () => {
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('yomitan settings close button script installs an idempotent in-page close control', () => {
|
|
||||||
const script = buildYomitanSettingsCloseButtonScript();
|
|
||||||
|
|
||||||
assert.match(script, /subminer-yomitan-settings-close/);
|
|
||||||
assert.match(script, /aria-label', 'Close Yomitan settings'/);
|
|
||||||
assert.match(script, /window\.close\(\)/);
|
|
||||||
assert.match(script, /getElementById\(buttonId\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('yomitan settings close button only installs for Hyprland sessions', () => {
|
test('yomitan settings close button only installs for Hyprland sessions', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||||
@@ -96,13 +85,6 @@ test('yomitan settings close button injection skips non-Hyprland windows', () =>
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('yomitan settings URL disables the embedded popup preview', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildYomitanSettingsUrl('abc123'),
|
|
||||||
'chrome-extension://abc123/settings.html?popup-preview=false',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
|
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
@@ -134,18 +116,6 @@ test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existi
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
const destroyed = destroyYomitanSettingsWindow({
|
|
||||||
isDestroyed: () => false,
|
|
||||||
destroy: () => calls.push('destroy'),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
assert.equal(destroyed, true);
|
|
||||||
assert.deepEqual(calls, ['destroy']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => {
|
test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => {
|
||||||
assert.equal(destroyYomitanSettingsWindow(null), false);
|
assert.equal(destroyYomitanSettingsWindow(null), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { buildYoutubeMetadataProbeArgs } from './metadata-probe';
|
|||||||
import { buildYoutubePlaybackResolveArgs } from './playback-resolve';
|
import { buildYoutubePlaybackResolveArgs } from './playback-resolve';
|
||||||
import { buildDownloadArgs } from './track-download';
|
import { buildDownloadArgs } from './track-download';
|
||||||
import { buildYoutubeTrackProbeArgs } from './track-probe';
|
import { buildYoutubeTrackProbeArgs } from './track-probe';
|
||||||
import { YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
|
||||||
|
|
||||||
// Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole
|
// Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole
|
||||||
// playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call.
|
// playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call.
|
||||||
@@ -26,10 +25,6 @@ const cases: Array<{ name: string; args: string[] }> = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
test('YTDLP_SINGLE_VIDEO_ARG is the yt-dlp flag that disables playlist expansion', () => {
|
|
||||||
assert.equal(YTDLP_SINGLE_VIDEO_ARG, '--no-playlist');
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const { name, args } of cases) {
|
for (const { name, args } of cases) {
|
||||||
test(`${name} passes --no-playlist for playlist-scoped URLs`, () => {
|
test(`${name} passes --no-playlist for playlist-scoped URLs`, () => {
|
||||||
assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`);
|
assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`);
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const DEAD_MODULE_PATHS = [
|
|
||||||
'src/translators/index.ts',
|
|
||||||
'src/subsync/engines.ts',
|
|
||||||
'src/subtitle/pipeline.ts',
|
|
||||||
'src/subtitle/stages/merge.ts',
|
|
||||||
'src/subtitle/stages/normalize.ts',
|
|
||||||
'src/subtitle/stages/normalize.test.ts',
|
|
||||||
'src/subtitle/stages/tokenize.ts',
|
|
||||||
'src/tokenizers/index.ts',
|
|
||||||
'src/token-mergers/index.ts',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const FORBIDDEN_IMPORT_PATTERNS = [
|
|
||||||
/from ['"]\.\.?\/tokenizers['"]/,
|
|
||||||
/from ['"]\.\.?\/token-mergers['"]/,
|
|
||||||
/from ['"]\.\.?\/subtitle\/pipeline['"]/,
|
|
||||||
/from ['"]\.\.?\/subsync\/engines['"]/,
|
|
||||||
/from ['"]\.\.?\/translators['"]/,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function readWorkspaceFile(relativePath: string): string {
|
|
||||||
return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectSourceFiles(rootDir: string): string[] {
|
|
||||||
const absoluteRoot = path.join(process.cwd(), rootDir);
|
|
||||||
const out: string[] = [];
|
|
||||||
|
|
||||||
const visit = (currentDir: string) => {
|
|
||||||
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
||||||
const fullPath = path.join(currentDir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
visit(fullPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(path.relative(process.cwd(), fullPath).replaceAll('\\', '/'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visit(absoluteRoot);
|
|
||||||
out.sort();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('dead registry and pipeline modules stay removed from the repository', () => {
|
|
||||||
for (const relativePath of DEAD_MODULE_PATHS) {
|
|
||||||
assert.equal(
|
|
||||||
fs.existsSync(path.join(process.cwd(), relativePath)),
|
|
||||||
false,
|
|
||||||
`${relativePath} should stay deleted`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('live source tree no longer imports dead registry and pipeline modules', () => {
|
|
||||||
for (const relativePath of collectSourceFiles('src')) {
|
|
||||||
const source = readWorkspaceFile(relativePath);
|
|
||||||
for (const pattern of FORBIDDEN_IMPORT_PATTERNS) {
|
|
||||||
assert.doesNotMatch(source, pattern, `${relativePath} should not import ${pattern.source}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
generateConfigExampleTemplate,
|
|
||||||
resolveConfigExampleOutputPaths,
|
resolveConfigExampleOutputPaths,
|
||||||
writeConfigExampleArtifacts,
|
writeConfigExampleArtifacts,
|
||||||
} from './generate-config-example';
|
} from './generate-config-example';
|
||||||
@@ -81,9 +80,3 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output
|
|||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generateConfigExampleTemplate uses the canonical example socket path', () => {
|
|
||||||
const template = generateConfigExampleTemplate();
|
|
||||||
|
|
||||||
assert.match(template, /"socketPath": "\/tmp\/subminer-socket"/);
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -7,13 +8,10 @@ import { DEFAULT_CONFIG } from './config/definitions';
|
|||||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||||
import {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
exitBackgroundBootstrap,
|
|
||||||
normalizeLaunchMpvExtraArgs,
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
resolveStatsDaemonCommandAction,
|
resolveStatsDaemonCommandAction,
|
||||||
sanitizeHelpEnv,
|
|
||||||
sanitizeLaunchMpvEnv,
|
|
||||||
sanitizeStartupEnv,
|
sanitizeStartupEnv,
|
||||||
sanitizeBackgroundEnv,
|
sanitizeBackgroundEnv,
|
||||||
shouldDetachBackgroundLaunch,
|
shouldDetachBackgroundLaunch,
|
||||||
@@ -24,36 +22,26 @@ import {
|
|||||||
shouldForwardStartupArgvViaAppControl,
|
shouldForwardStartupArgvViaAppControl,
|
||||||
applyBackgroundBootstrapCommandLineSwitches,
|
applyBackgroundBootstrapCommandLineSwitches,
|
||||||
applyEarlyLinuxCommandLineSwitches,
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
resolveAppControlHandoffTimeoutMs,
|
|
||||||
resolveLinuxPasswordStoreValue,
|
resolveLinuxPasswordStoreValue,
|
||||||
spawnDetachedApp,
|
spawnDetachedApp,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
|
|
||||||
test('app-control handoffs allow for macOS application activation latency', () => {
|
test('spawnDetachedApp survives a failed spawn of the detached app', () => {
|
||||||
assert.equal(resolveAppControlHandoffTimeoutMs('darwin'), 3000);
|
const child = Object.assign(new EventEmitter(), { unref: () => {} });
|
||||||
assert.equal(resolveAppControlHandoffTimeoutMs('linux'), 500);
|
const fakeSpawn = (() => child) as unknown as Parameters<typeof spawnDetachedApp>[2];
|
||||||
assert.equal(resolveAppControlHandoffTimeoutMs('win32'), 500);
|
const originalConsoleError = console.error;
|
||||||
});
|
const logged: unknown[][] = [];
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
test('detached app launch policy stays in the startup runtime utilities', () => {
|
logged.push(args);
|
||||||
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
|
};
|
||||||
const runtimeSource = fs.readFileSync(
|
try {
|
||||||
path.join(process.cwd(), 'src/main-entry-runtime.ts'),
|
spawnDetachedApp(['--start'], {}, fakeSpawn);
|
||||||
'utf8',
|
// An unhandled 'error' event would throw here and crash the entry process.
|
||||||
);
|
child.emit('error', new Error('spawn ENOENT'));
|
||||||
|
} finally {
|
||||||
assert.equal(typeof spawnDetachedApp, 'function');
|
console.error = originalConsoleError;
|
||||||
assert.doesNotMatch(entrySource, /function spawnDetachedApp/);
|
}
|
||||||
assert.match(
|
assert.equal(logged.length, 1);
|
||||||
runtimeSource,
|
|
||||||
/child\.once\('error', \(error\) => \{\s*console\.error\([^;]*error\);\s*\}\);\s*child\.unref\(\)/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('background bootstrap exits through Electron so Chromium children shut down', () => {
|
|
||||||
const exitCodes: number[] = [];
|
|
||||||
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
|
|
||||||
assert.deepEqual(exitCodes, [0]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||||
@@ -545,22 +533,6 @@ test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
|
|||||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
|
|
||||||
const env = sanitizeHelpEnv({
|
|
||||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
|
||||||
});
|
|
||||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
|
||||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sanitizeLaunchMpvEnv suppresses warnings and lsfg layer', () => {
|
|
||||||
const env = sanitizeLaunchMpvEnv({
|
|
||||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
|
||||||
});
|
|
||||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
|
||||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
|
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
|
||||||
const env = sanitizeBackgroundEnv({
|
const env = sanitizeBackgroundEnv({
|
||||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||||
|
|||||||
@@ -296,15 +296,20 @@ export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
|
|||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnDetachedApp(childArgs: string[], env: NodeJS.ProcessEnv): void {
|
// spawnProcess is a test seam; production callers use node's spawn.
|
||||||
|
export function spawnDetachedApp(
|
||||||
|
childArgs: string[],
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
spawnProcess: typeof spawn = spawn,
|
||||||
|
): void {
|
||||||
const keepalive = resolveAppImageMountKeepaliveInvocation(env);
|
const keepalive = resolveAppImageMountKeepaliveInvocation(env);
|
||||||
const child = keepalive
|
const child = keepalive
|
||||||
? spawn(keepalive.command, [...keepalive.args, ...childArgs], {
|
? spawnProcess(keepalive.command, [...keepalive.args, ...childArgs], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env,
|
env,
|
||||||
})
|
})
|
||||||
: spawn(process.execPath, childArgs, {
|
: spawnProcess(process.execPath, childArgs, {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env,
|
env,
|
||||||
|
|||||||
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user