Merge remote-tracking branch 'origin/main' into add-hachidori-backend

# Conflicts:
#	launcher/main.test.ts
#	src/cli/help.test.ts
#	src/core/services/__tests__/stats-server.test.ts
#	src/core/services/cli-command.test.ts
#	src/core/services/tokenizer/yomitan-parser-runtime.test.ts
#	src/main/overlay-runtime.test.ts
#	src/main/runtime/character-dictionary-auto-sync.test.ts
#	src/main/runtime/cli-command-context-deps.test.ts
#	src/main/runtime/cli-command-context-factory.test.ts
#	src/main/runtime/cli-command-context-main-deps.test.ts
#	src/main/runtime/composers/cli-startup-composer.test.ts
#	src/main/runtime/stats-server-runtime.test.ts
#	src/main/runtime/tray-main-actions.test.ts
#	src/main/runtime/tray-main-deps.test.ts
#	src/main/runtime/tray-runtime-handlers.test.ts
#	src/main/runtime/tray-runtime.test.ts
#	src/main/runtime/yomitan-anki-server-sync.test.ts
#	src/preload.ts
#	src/renderer/handlers/mouse.test.ts
#	src/renderer/yomitan-popup.test.ts
This commit is contained in:
sudacode committed 2026-10-05 09:01:11 -07:00
commit 20f151482a
417 files changed
+19477 -60921

No files matched your search

@@ -5,30 +5,6 @@ import os from 'node:os';
import path from 'node:path';
import type { DatabaseSync } from '../immersion-tracker/sqlite';
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
type ImmersionTrackerServiceCtor =
typeof import('../immersion-tracker-service').ImmersionTrackerService;
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
async function loadTrackerCtor(): Promise<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 {
db: DatabaseSync;
queue: unknown[];
@@ -42,155 +18,38 @@ interface TrackerInternals {
writeLock: { locked: boolean };
}
test('delete maintenance fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
interface TrackerHarness {
tracker: TrackerInternals;
deleteRunnerCalls: () => number;
}
/** Runs `fn` against a fresh tracker (batchSize 2) seeded with two library entries. */
async function withTracker(fn: (harness: TrackerHarness) => Promise<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;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor(
{ dbPath, policy: { batchSize: 2 } },
{
runDeleteMaintenanceTask: async () => {
deleteRunnerCalls += 1;
},
const service = new ImmersionTrackerService(
{ dbPath: path.join(dir, 'immersion.sqlite'), policy: { batchSize: 2 } },
{
runDeleteMaintenanceTask: async () => {
deleteRunnerCalls += 1;
},
);
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
let flushCalls = 0;
internals.flushNow = () => {
flushCalls += 1;
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
};
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
assert.equal(flushCalls, 1);
assert.equal(deleteRunnerCalls, 0);
assert.equal(internals.writeLock.locked, false);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
},
);
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
/queue did not drain/i,
);
assert.deepEqual(
internals.db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all(),
[
{ animeId: 1, anilistId: null },
{ animeId: 2, anilistId: 123 },
],
);
const tracker = service as unknown as TrackerInternals;
seedTwoEntries(tracker.db);
await fn({ tracker, deleteRunnerCalls: () => deleteRunnerCalls });
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
service.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('mergeAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
assert.deepEqual(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
.all()
.map((row) => (row as { animeId: number }).animeId),
[1, 2],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
assert.equal(
(
internals.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
.get() as {
animeId: number;
}
).animeId,
2,
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 1);
internals.flushNow = () => {};
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
}
function seedTwoEntries(db: DatabaseSync): void {
db.exec(`
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', NULL, 1000, 1000), (2, 'show season 1', 'Show Season 1', 123, 1000, 1000);
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
@@ -244,25 +103,53 @@ function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
});
}
/** The queued telemetry sample only exists in the database once the queue drained fully. */
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
const row = db
.prepare(
`SELECT lines_seen AS linesSeen
FROM imm_session_telemetry
WHERE session_id = ?
ORDER BY sample_ms DESC, telemetry_id DESC
LIMIT 1`,
)
.get(sessionId) as { linesSeen: number } | undefined;
return row ? Number(row.linesSeen) : null;
const SNAPSHOT_TABLES = [
'imm_anime',
'imm_videos',
'imm_sessions',
'imm_subtitle_lines',
'imm_session_telemetry',
'imm_lifetime_global',
'imm_lifetime_anime',
'imm_lifetime_media',
'imm_lifetime_applied_sessions',
];
function snapshotDb(db: DatabaseSync): Record<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 row = db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
.get(animeId) as { total: number };
return Number(row.total);
const guardedOperations: Array<{
name: string;
run: (tracker: TrackerInternals) => Promise<unknown>;
}> = [
{ name: 'deleteSession', run: (tracker) => tracker.deleteSession(1) },
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
{ name: 'rebuildLifetimeSummaries', run: (tracker) => tracker.rebuildLifetimeSummaries() },
{
// Anime 2 already owns AniList id 123, so this would otherwise resolve a conflict.
name: 'reassignAnimeAnilist',
run: (tracker) => tracker.reassignAnimeAnilist(1, { anilistId: 123 }),
},
];
for (const operation of guardedOperations) {
test(`${operation.name} fails closed without touching the database when queued writes cannot drain`, async () => {
await withTracker(async ({ tracker, deleteRunnerCalls }) => {
queueSubtitleLines(tracker, 1);
tracker.flushNow = () => {};
const before = snapshotDb(tracker.db);
await assert.rejects(operation.run(tracker), /queue did not drain/i);
assert.deepEqual(snapshotDb(tracker.db), before);
assert.equal(deleteRunnerCalls(), 0);
assert.equal(tracker.writeLock.locked, false);
});
});
}
/**
@@ -271,52 +158,36 @@ function countLinesForAnime(db: DatabaseSync, animeId: number): number {
* queue, so anything past `batchSize` would still be unwritten when the merge
* repoints rows.
*/
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
const repointingOperations: Array<{
name: string;
run: (tracker: TrackerInternals) => Promise<unknown>;
}> = [
{ name: 'mergeAnime', run: (tracker) => tracker.mergeAnime(1, [2]) },
{ name: 'moveVideoToAnime', run: (tracker) => tracker.moveVideoToAnime(2, 1) },
];
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
for (const operation of repointingOperations) {
test(`${operation.name} drains a queue larger than one batch before repointing rows`, async () => {
await withTracker(async ({ tracker }) => {
queueSubtitleLines(tracker, 8);
queueTelemetry(tracker, 8);
assert.ok(tracker.queue.length > 2, 'expected more queued writes than one batch');
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 8);
queueTelemetry(internals, 8);
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
await operation.run(tracker);
await internals.mergeAnime(1, [2]);
assert.equal(internals.queue.length, 0);
// Every queued line landed, attributed to the surviving entry.
assert.equal(countLinesForAnime(internals.db, 1), 8);
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
const internals = tracker as unknown as TrackerInternals;
seedTwoEntries(internals.db);
queueSubtitleLines(internals, 8);
queueTelemetry(internals, 8);
await internals.moveVideoToAnime(2, 1);
assert.equal(internals.queue.length, 0);
assert.equal(countLinesForAnime(internals.db, 1), 8);
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
assert.equal(tracker.queue.length, 0);
// Every queued line and the trailing telemetry sample landed on the surviving entry.
const lines = tracker.db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = 1')
.get() as { total: number };
assert.equal(Number(lines.total), 8);
const telemetry = tracker.db
.prepare(
`SELECT lines_seen AS linesSeen FROM imm_session_telemetry
WHERE session_id = 1 ORDER BY sample_ms DESC, telemetry_id DESC LIMIT 1`,
)
.get() as { linesSeen: number } | undefined;
assert.equal(Number(telemetry?.linesSeen), 8);
});
});
}
@@ -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
-65
View File
@@ -158,33 +158,6 @@ function createHarness(): RuntimeHarness {
return { options, registered, state };
}
test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
const { registered } = createHarness();
const expected = [
'setAnkiConnectEnabled',
'clearAnkiHistory',
'refreshKnownWords',
'respondFieldGrouping',
'buildKikuMergePreview',
'getJimakuMediaInfo',
'searchJimakuEntries',
'listJimakuFiles',
'resolveJimakuApiKey',
'getCurrentMediaPath',
'isRemoteMediaPath',
'downloadToFile',
'onDownloadedSubtitle',
'searchTsukihimeEntries',
'listTsukihimeFiles',
'downloadTsukihimeSubtitle',
'onDownloadedSecondarySubtitle',
];
for (const key of expected) {
assert.equal(typeof registered[key], 'function', `missing handler: ${key}`);
}
});
test('refreshKnownWords throws when integration is unavailable', async () => {
const { registered } = createHarness();
@@ -196,20 +169,6 @@ test('refreshKnownWords throws when integration is unavailable', async () => {
);
});
test('refreshKnownWords delegates to integration', async () => {
const { registered, state } = createHarness();
let refreshed = 0;
state.ankiIntegration = {
refreshKnownWordCache: async () => {
refreshed += 1;
},
};
await registered.refreshKnownWords!();
assert.equal(refreshed, 1);
});
test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => {
const { registered, state } = createHarness();
let destroyed = 0;
@@ -274,30 +233,6 @@ test('buildKikuMergePreview returns guard error when integration is missing', as
});
});
test('buildKikuMergePreview delegates to integration when available', async () => {
const { registered, state } = createHarness();
const calls: unknown[] = [];
state.ankiIntegration = {
buildFieldGroupingPreview: async (
keepNoteId: number,
deleteNoteId: number,
deleteDuplicate: boolean,
) => {
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
return { ok: true };
},
};
const result = await registered.buildKikuMergePreview!({
keepNoteId: 3,
deleteNoteId: 4,
deleteDuplicate: true,
});
assert.deepEqual(calls, [[3, 4, true]]);
assert.deepEqual(result, { ok: true });
});
test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv', async () => {
const { registered, state } = createHarness();
-27
View File
@@ -103,17 +103,6 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a
);
});
test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => {
const { deps, calls } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
});
await runAppReadyRuntime(deps);
assert.equal(calls.includes('createImmersionTracker'), false);
assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.'));
});
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
@@ -226,22 +215,6 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
);
});
test('runAppReadyRuntime logs when createImmersionTracker dependency is missing', async () => {
const { deps, calls } = makeDeps({
createImmersionTracker: undefined,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('log:Runtime ready: immersion tracker dependency is missing.'));
});
test('runAppReadyRuntime logs defer message when overlay not auto-started', async () => {
const { deps, calls } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'));
});
test('runAppReadyRuntime applies config logging level during app-ready', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
+1 -142
View File
@@ -1,11 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
import {
CliCommandServiceDeps,
createCliCommandDepsRuntime,
handleCliCommand,
} from './cli-command';
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
@@ -561,136 +557,6 @@ test('handleCliCommand applies socket path and connects on start', () => {
assert.ok(calls.includes('connectMpvClient'));
});
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
const calls: string[] = [];
const client = {
setSocketPath: (socketPath: string) => {
calls.push(`setSocketPath:${socketPath}`);
},
connect: () => {
calls.push('connect');
},
reconnect: () => {
calls.push('reconnect');
},
};
const deps = createCliCommandDepsRuntime({
mpv: {
getSocketPath: () => '/tmp/runtime.sock',
setSocketPath: () => {},
getClient: () => client,
showOsd: () => {},
},
texthooker: {
service: { isRunning: () => false, start: () => {} },
getPort: () => 5174,
setPort: () => {},
getWebsocketUrl: () => undefined,
shouldOpenBrowser: () => false,
openInBrowser: () => {},
},
overlay: {
isInitialized: () => true,
initialize: () => {},
toggleVisible: () => {},
togglePrimarySubtitleBar: () => {},
setVisible: () => {},
},
mining: {
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWords: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
},
anilist: {
getStatus: () => ({
tokenStatus: 'not_checked',
tokenSource: 'none',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearToken: () => {},
openSetup: () => {},
getQueueStatus: () => ({
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
},
dictionary: {
generate: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 0,
}),
getSelection: async () => ({
seriesKey: 'test',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setSelection: async () => ({
ok: true,
seriesKey: 'test',
selected: { id: 1, title: 'Test', episodes: null },
staleMediaIds: [],
}),
},
jellyfin: {
openSetup: () => {},
runStatsCommand: async () => {},
runCommand: async () => {},
},
ui: {
openFirstRunSetup: () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
openYoutubeBrowserWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
},
app: {
stop: () => {},
hasMainWindow: () => true,
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
},
dispatchSessionAction: async () => {},
getMultiCopyTimeoutMs: () => 2500,
schedule: () => undefined,
log: () => {},
logDebug: () => {},
warn: () => {},
error: () => {},
});
deps.setMpvClientSocketPath('/tmp/runtime.sock');
deps.connectMpvClient();
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
});
test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
@@ -887,13 +753,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
assert.ok(calls.some((value) => value.startsWith('log:AniList token status:')));
assert.ok(calls.some((value) => value.startsWith('log:AniList queue:')));
});
test('handleCliCommand runs AniList retry command', async () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistRetryQueue: true }), 'initial', deps);
@@ -49,28 +49,6 @@ test('buildDiscordPresenceActivity falls back to idle with default style', () =>
assert.equal(payload.details, 'Sentence Mining');
});
test('buildDiscordPresenceActivity uses meme style fallback', () => {
const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const };
const payload = buildDiscordPresenceActivity(memeConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, 'Mining and crafting (Anki cards)');
assert.equal(payload.smallImageText, 'Sentence Mining');
});
test('buildDiscordPresenceActivity uses japanese style', () => {
const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const };
const payload = buildDiscordPresenceActivity(jpConfig, {
...baseSnapshot,
connected: false,
mediaPath: null,
});
assert.equal(payload.details, '文の採掘中');
assert.equal(payload.smallImageText, 'イマージョン学習');
});
test('buildDiscordPresenceActivity uses minimal style', () => {
const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const };
const payload = buildDiscordPresenceActivity(minConfig, {
@@ -3,78 +3,6 @@ import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice } from '../../types';
import { createFieldGroupingOverlayRuntime } from './field-grouping-overlay';
test('createFieldGroupingOverlayRuntime sends overlay messages and sets restore flag', () => {
const sent: unknown[][] = [];
let visible = false;
const restore = new Set<'runtime-options' | 'subsync'>();
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => ({
isDestroyed: () => false,
webContents: {
isLoading: () => false,
send: (...args: unknown[]) => {
sent.push(args);
},
},
}),
getVisibleOverlayVisible: () => visible,
setVisibleOverlayVisible: (next) => {
visible = next;
},
getResolver: () => null,
setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => restore,
});
const ok = runtime.sendToVisibleOverlay('runtime-options:open', undefined, {
restoreOnModalClose: 'runtime-options',
});
assert.equal(ok, true);
assert.equal(visible, true);
assert.equal(restore.has('runtime-options'), true);
assert.deepEqual(sent, [['runtime-options:open']]);
});
test('createFieldGroupingOverlayRuntime callback cancels when send fails', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const runtime = createFieldGroupingOverlayRuntime<'runtime-options' | 'subsync'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: () => resolver,
setResolver: (next: ((choice: KikuFieldGroupingChoice) => void) | null) => {
resolver = next;
},
getRestoreVisibleOverlayOnModalClose: () => new Set<'runtime-options' | 'subsync'>(),
});
const callback = runtime.createFieldGroupingCallback();
const result = await callback({
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
});
assert.equal(result.cancelled, true);
assert.equal(result.keepNoteId, 0);
assert.equal(result.deleteNoteId, 0);
});
test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay after resolver settles', async () => {
let resolver: unknown = null;
let visible = false;
+38 -103
View File
@@ -98,124 +98,59 @@ test('field grouping callback does not reject the next request after a response'
assert.equal(secondChoice.keepNoteId, 2);
});
test('field grouping callback dismisses the modal UI when the send fails', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
dismissed += 1;
},
});
const dismissCases = [
{ name: 'the send fails', sends: false, responseTimeoutMs: undefined },
{ name: 'the response times out', sends: true, responseTimeoutMs: 5 },
];
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test('field grouping callback handles modal dismiss failures on send failure', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
for (const c of dismissCases) {
test(`field grouping callback dismisses the modal UI when ${c.name}`, async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
sendRequestToVisibleOverlay: () => c.sends,
dismissModalUi: () => {
throw new Error('dismiss failed');
dismissed += 1;
},
responseTimeoutMs: c.responseTimeoutMs,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback handles modal dismiss failures on timeout', async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback reports modal dismiss failures', async () => {
const store = createWrappedResolverStore();
const errors: unknown[] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args);
};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => false,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
});
await callback(makeRequestData());
assert.equal(errors.length, 1);
} finally {
console.error = originalConsoleError;
}
});
test('field grouping callback dismisses the modal UI when the response times out', async () => {
const store = createWrappedResolverStore();
let dismissed = 0;
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => true,
dismissModalUi: () => {
dismissed += 1;
},
responseTimeoutMs: 5,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(dismissed, 1);
assert.equal(store.getResolver(), null);
});
test(`field grouping callback still cancels and clears the resolver when ${c.name} and dismiss throws`, async () => {
const store = createWrappedResolverStore();
const originalConsoleError = console.error;
console.error = () => {};
try {
const callback = createFieldGroupingCallback({
getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {},
getResolver: store.getResolver,
setResolver: store.setResolver,
sendRequestToVisibleOverlay: () => c.sends,
dismissModalUi: () => {
throw new Error('dismiss failed');
},
responseTimeoutMs: c.responseTimeoutMs,
});
const result = await callback(makeRequestData());
assert.equal(result.cancelled, true);
assert.equal(store.getResolver(), null);
} finally {
console.error = originalConsoleError;
}
});
}
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
const store = createWrappedResolverStore();
@@ -499,36 +499,13 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H
});
assert.equal(placed, true);
assert.deepEqual(
calls.map(([, args]) => args),
[
['-j', 'clients'],
['-j', 'status'],
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'],
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })',
],
[
'dispatch',
'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })',
],
['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'],
],
);
const dispatches = calls
.map(([, args]) => args as string[])
.filter(([verb]) => verb === 'dispatch');
assert.ok(dispatches.length > 0);
for (const [, dispatcher] of dispatches) {
assert.ok(dispatcher!.startsWith('hl.dsp.window.'), dispatcher);
}
});
test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => {
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue';
import { toDbTimestamp } from './immersion-tracker/query-shared';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
@@ -137,11 +136,6 @@ test('seam: enqueueWrite drops oldest entries once capacity is exceeded', () =>
assert.equal((queue[1] as Extract<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 () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -528,37 +522,6 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
}
});
test('fresh tracker DB creates lifetime summary tables', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const db = new Database(dbPath);
const tableRows = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
db.close();
const tableNames = new Set(tableRows.map((row) => row.name));
const expectedTables = [
'imm_lifetime_global',
'imm_lifetime_anime',
'imm_lifetime_media',
'imm_lifetime_applied_sessions',
];
for (const tableName of expectedTables) {
assert.ok(tableNames.has(tableName), `Expected ${tableName} to exist`);
}
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('fresh tracker DB skips lexical rollup backfill work', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -3773,138 +3736,6 @@ test('monthly rollups are grouped by calendar month', async () => {
}
});
test('flushSingle reuses cached prepared statements', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
let originalPrepare: DatabaseSync['prepare'] | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as {
db: DatabaseSync;
flushSingle: (write: {
kind: 'telemetry' | 'event';
sessionId: number;
sampleMs: number;
eventType?: number;
lineIndex?: number | null;
segmentStartMs?: number | null;
segmentEndMs?: number | null;
tokensDelta?: number;
cardsDelta?: number;
payloadJson?: string | null;
totalWatchedMs?: number;
activeWatchedMs?: number;
linesSeen?: number;
tokensSeen?: number;
cardsMined?: number;
lookupCount?: number;
lookupHits?: number;
yomitanLookupCount?: number;
pauseCount?: number;
pauseMs?: number;
seekForwardCount?: number;
seekBackwardCount?: number;
mediaBufferEvents?: number;
}) => void;
};
originalPrepare = privateApi.db.prepare;
let prepareCalls = 0;
privateApi.db.prepare = (...args: Parameters<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 () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -5,8 +5,9 @@ import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { ensureSchema } from '../storage.js';
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from '../storage.js';
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
import { pruneRawRetention } from '../maintenance.js';
const DAY_MS = 86_400_000;
const BASE_MS = 1_700_000_000_000;
@@ -30,7 +31,13 @@ function cleanupDbPath(dbPath: string): void {
*/
function seed(
db: DatabaseSync,
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
lines: Array<{
session: 1 | 2;
wordId: number;
dayOffset: number;
msOffset?: number;
count?: number;
}>,
options: { legacyRows?: boolean } = {},
): void {
db.exec(`
@@ -65,7 +72,7 @@ function seed(
let lineId = 0;
for (const line of lines) {
lineId += 1;
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
if (options.legacyRows) {
@@ -83,13 +90,13 @@ function seed(
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
),
first_seen = (
SELECT MIN(sl.CREATED_DATE) / 1000
SELECT MIN(sl.CREATED_DATE) / 1000.0
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id
),
last_seen = (
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE o.word_id = imm_words.id
@@ -200,6 +207,167 @@ test('deleting the latest session moves last_seen back to the surviving line', (
}
});
for (const legacyRows of [false, true]) {
test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
// Stored seen values are fractional seconds; whole-second rounding used to miss
// the removed extreme (or truncate the refreshed one).
const { db, dbPath } = createDb(
[
{ session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
{ session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
],
{ legacyRows },
);
try {
deleteSession(db, 2);
const word = readWord(db, 22);
assert.equal(word?.frequency, 1);
assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
}
test('deleting legacy history preserves first_seen when it predates the flush timestamp', () => {
const { db, dbPath } = createDb([
{ session: 1, wordId: 23, dayOffset: 0, msOffset: 400 },
{ session: 2, wordId: 23, dayOffset: 5, msOffset: 400 },
]);
try {
// A queue delay and retention-pruned history cannot be distinguished from
// the old flush-time timestamp. Preserve the earlier date in either case.
db.prepare('UPDATE imm_words SET first_seen = ? WHERE id = 23').run(BASE_MS / 1000);
deleteSession(db, 1);
assert.equal(readWord(db, 23)?.firstSeen, BASE_MS / 1000);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
for (const legacyRows of [false, true]) {
for (const deletedSession of [2, 3]) {
test(`deleting retained session ${deletedSession} preserves pruned first_seen (legacyRows=${legacyRows})`, () => {
const { db, dbPath } = createDb(
[
{ session: 1, wordId: 24, dayOffset: 0 },
{ session: 2, wordId: 24, dayOffset: 1 },
],
{ legacyRows },
);
try {
const latestMs = BASE_MS + 2 * DAY_MS;
db.exec(`
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (3, 's3', 2, '${latestMs}', '${latestMs + 1000}', 2, ${latestMs}, ${latestMs});
INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (3, 3, 2, 3, '猫', ${latestMs}, ${latestMs});
INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
VALUES (3, 24, 1, ${legacyRows ? 'NULL' : latestMs});
UPDATE imm_words SET frequency = 3, last_seen = ${latestMs / 1000} WHERE id = 24;
INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency)
SELECT 24, '猫', first_seen, last_seen, frequency FROM imm_words WHERE id = 24;
INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
SELECT line_id, 24, occurrence_count, seen_ms FROM imm_word_line_occurrences WHERE word_id = 24;
`);
pruneRawRetention(db, latestMs, {
eventsRetentionMs: Infinity,
telemetryRetentionMs: Infinity,
sessionsRetentionMs: 1.5 * DAY_MS,
});
deleteSession(db, deletedSession);
for (const table of ['imm_words', 'imm_kanji']) {
const row = db
.prepare(
`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table} WHERE id = 24`,
)
.get() as { frequency: number; firstSeen: number; lastSeen: number };
assert.equal(row.frequency, 2);
assert.equal(row.firstSeen, BASE_MS / 1000, `${table} keeps the pruned discovery date`);
assert.equal(row.lastSeen, (BASE_MS + (deletedSession === 2 ? 2 : 1) * DAY_MS) / 1000);
}
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
}
}
test('deleting a queued subtitle uses observation time despite a delayed flush', () => {
const { db, dbPath } = createDb([]);
try {
const stmts = createTrackerPreparedStatements(db);
for (const sessionId of [1, 2]) {
const seenMs = BASE_MS + (sessionId - 1) * DAY_MS + 400;
executeQueuedWrite(
{
kind: 'subtitleLine',
sessionId,
videoId: sessionId,
lineIndex: 1,
segmentStartMs: null,
segmentEndMs: null,
text: '猫',
wordOccurrences: [
{
headword: '猫',
word: '猫',
reading: 'ねこ',
partOfSpeech: 'noun',
pos1: '名詞',
pos2: '一般',
pos3: '',
occurrenceCount: 1,
frequencyRank: null,
},
],
kanjiOccurrences: [{ kanji: '猫', occurrenceCount: 1 }],
firstSeen: seenMs / 1000,
lastSeen: seenMs / 1000,
},
stmts,
);
for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
const row = db
.prepare(`SELECT seen_ms AS seenMs FROM ${table} WHERE line_id = ?`)
.get(sessionId) as { seenMs: number };
assert.equal(row.seenMs, seenMs);
}
}
deleteSession(db, 1);
for (const table of ['imm_words', 'imm_kanji']) {
const row = db
.prepare(`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table}`)
.get() as {
frequency: number;
firstSeen: number;
lastSeen: number;
};
assert.equal(row.frequency, 1);
assert.equal(row.firstSeen, (BASE_MS + DAY_MS + 400) / 1000);
assert.equal(row.lastSeen, (BASE_MS + DAY_MS + 400) / 1000);
}
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
// Session 2 carries the middle occurrence; sessions bracket it in time.
const { db, dbPath } = createDb([
@@ -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', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -2075,20 +2042,6 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
}
});
test('getVocabularyStats returns empty array when no words exist', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const rows = getVocabularyStats(db, 10);
assert.deepEqual(rows, []);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -2524,20 +2477,6 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
}
});
test('getKanjiStats returns empty array when no kanji exist', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const rows = getKanjiStats(db, 10);
assert.deepEqual(rows, []);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -2643,29 +2582,6 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
}
});
test('getSessionEvents returns empty array for session with no events', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/events-empty.mkv', {
canonicalTitle: 'Events Empty',
sourcePath: '/tmp/events-empty.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 6_000_000);
const events = getSessionEvents(db, sessionId, 50);
assert.deepEqual(events, []);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getSessionEvents filters events to the requested session id', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -4567,13 +4483,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
assert.ok(sharedWordRow);
assert.equal(sharedWordRow.frequency, 1);
assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000));
assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000));
assert.equal(sharedWordRow.first_seen, keptTs / 1000);
assert.equal(sharedWordRow.last_seen, keptTs / 1000);
assert.equal(deletedOnlyWordRow ?? null, null);
assert.ok(sharedKanjiRow);
assert.equal(sharedKanjiRow.frequency, 1);
assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000));
assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000));
assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
assert.equal(deletedOnlyKanjiRow ?? null, null);
} finally {
db.close();
@@ -16,7 +16,8 @@ import {
} from './query-lexical';
import { Database } from './sqlite';
import type { DatabaseSync } from './sqlite';
import { ensureSchema } from './storage';
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
@@ -125,6 +126,121 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
}
});
test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
const db = createDb();
try {
const videoId = seedVideo(db, null, 'episode');
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
const firstSeen = BASE_MS / 1000;
// Legacy row whose stored merged-token POS fails the persistence filter.
db.prepare(
`INSERT INTO imm_words(
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
).run(firstSeen, firstSeen);
rebuildLexicalDailyRollups(db);
// The incoming token passes the filter, but the upsert keeps the stored POS.
executeQueuedWrite(
{
kind: 'subtitleLine',
sessionId,
videoId,
lineIndex: 1,
segmentStartMs: null,
segmentEndMs: null,
text: 'にし',
wordOccurrences: [
{
headword: '二死',
word: 'にし',
reading: 'にし',
partOfSpeech: 'verb',
pos1: '動詞',
pos2: '自立',
pos3: '*',
frequencyRank: null,
occurrenceCount: 1,
},
],
kanjiOccurrences: [],
firstSeen: firstSeen + 60,
lastSeen: firstSeen + 60,
},
createTrackerPreparedStatements(db),
);
const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
(sum, day) => sum + day.wordCount,
0,
);
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
assert.equal(plotted, 0);
} finally {
db.close();
}
});
test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
const db = createDb();
try {
const videoId = seedVideo(db, null, 'episode');
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
const lineId = Number(
db
.prepare(
`INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, 1, '猫', ?, ?)`,
)
.run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
);
const seen = (BASE_MS + 251) / 1000;
// first_seen this early may be real history pruned by retention; never touched.
const earlyFirst = seen - 86_400.5;
const staleLast = seen + 86_400.631;
db.prepare(
`INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
).run(earlyFirst, staleLast, seen - 0.1, seen - 0.1);
db.prepare(
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
).run(earlyFirst, staleLast);
db.prepare(
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
db.prepare(
`INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
VALUES (?, 1, 1, ?)`,
).run(lineId, BASE_MS + 251);
rebuildLexicalDailyRollups(db);
const bounds = (table: string): Array<[number, number]> =>
(
db
.prepare(`SELECT first_seen AS first, last_seen AS last FROM ${table} ORDER BY id`)
.all() as Array<{
first: number;
last: number;
}>
).map((row) => [row.first, row.last]);
assert.deepEqual(
bounds('imm_words'),
[
[earlyFirst, seen],
[seen - 0.1, seen - 0.1],
],
'healthy rows are untouched',
);
assert.deepEqual(bounds('imm_kanji'), [[earlyFirst, seen]]);
} finally {
db.close();
}
});
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -268,7 +384,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
state_value INTEGER NOT NULL
);
INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('lexical_daily_rollups_version', 2);
VALUES ('lexical_daily_rollups_version', 3);
`);
assert.equal(areLexicalDailyRollupsReady(db), true);
@@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = `
) - 2440587.5 AS INTEGER)
`;
const LEXICAL_DAILY_ROLLUP_VERSION = '2';
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
// last_seen values left behind by session deletes.
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
@@ -26,6 +28,32 @@ export function localEpochDaySql(value: string): string {
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
}
// Session deletes once rounded removed timestamps to whole seconds, missing
// fractional last_seen extremes that pointed at deleted history. New seen_ms
// values match observation time; legacy values were stamped later at flush.
// Retention prunes the oldest sessions first, so healthy rows never exceed
// their newest surviving occurrence. Rows with undated occurrences are skipped.
//
// first_seen is deliberately not repaired: a stale bound is indistinguishable
// from real history whose occurrences retention pruned, and frequency does not
// reliably tell them apart.
function repairStaleLastSeen(db: DatabaseSync): void {
for (const [table, occurrenceTable, col] of [
['imm_words', 'imm_word_line_occurrences', 'word_id'],
['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
] as const) {
const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
db.exec(`
UPDATE ${table}
SET last_seen = ${newestSeen}
WHERE last_seen > ${newestSeen}
AND NOT EXISTS (
SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
)
`);
}
}
function createWordRollupTriggers(db: DatabaseSync): void {
const dayForNew = localEpochDaySql('NEW.first_seen');
const dayForOld = localEpochDaySql('OLD.first_seen');
@@ -186,6 +214,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
}
repairStaleLastSeen(db);
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.exec(`
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
@@ -4,12 +4,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Database } from './sqlite';
import {
pruneRawRetention,
pruneRollupRetention,
runOptimizeMaintenance,
toMonthKey,
} from './maintenance';
import { pruneRawRetention, pruneRollupRetention, toMonthKey } from './maintenance';
import { ensureSchema } from './storage';
import { toDbTimestamp } from './query-shared';
@@ -225,42 +220,3 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
cleanupDbPath(dbPath);
}
});
test('ensureSchema adds sample_ms index for telemetry rollup scans', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const indexes = db.prepare("PRAGMA index_list('imm_session_telemetry')").all() as Array<{
name: string;
}>;
const hasSampleMsIndex = indexes.some((row) => row.name === 'idx_telemetry_sample_ms');
assert.equal(hasSampleMsIndex, true);
const indexColumns = db.prepare("PRAGMA index_info('idx_telemetry_sample_ms')").all() as Array<{
name: string;
}>;
assert.deepEqual(
indexColumns.map((column) => column.name),
['sample_ms'],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('runOptimizeMaintenance executes PRAGMA optimize', () => {
const executedSql: string[] = [];
const db = {
exec(source: string) {
executedSql.push(source);
return this;
},
} as unknown as Parameters<typeof runOptimizeMaintenance>[0];
runOptimizeMaintenance(db);
assert.deepEqual(executedSql, ['PRAGMA optimize']);
});
@@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos(
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
}
// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
// them, or comparisons against stored extremes miss and refreshes drift.
function toStoredSeenSeconds(ms: number | null): number | null {
if (ms === null || !Number.isFinite(ms)) return null;
return Math.floor(ms / 1000);
return ms / 1000;
}
/**
@@ -320,6 +322,13 @@ export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan)
applyRemovalsForEntity(db, 'kanji', plan.kanji);
}
// A timestamp repair must keep subtraction-exact lifetime counts and any
// first_seen date that the deleted occurrences cannot account for.
type LexicalAggregateOverride = {
frequency: number | null;
firstSeen: number | null;
};
function applyRemovalsForEntity(
db: DatabaseSync,
entity: LexicalEntity,
@@ -358,7 +367,7 @@ function applyRemovalsForEntity(
`UPDATE ${entityTable} SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = ?`,
);
const needsExactRefresh: number[] = [];
const needsExactRefresh = new Map<number, LexicalAggregateOverride>();
for (const removal of removals) {
const current = selectStmt.get(removal.id) as {
@@ -369,17 +378,9 @@ function applyRemovalsForEntity(
if (!current) continue;
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
if (nextFrequency <= 0) {
// The rows in scope are already gone by now, so anything still pointing at
// this entity means the stored frequency was stale rather than exhausted.
if (hasOccurrencesStmt.get(removal.id)) {
needsExactRefresh.push(removal.id);
} else {
deleteStmt.run(removal.id);
}
continue;
}
// Legacy occurrences use flush time, which can follow the actual observation.
// An earlier stored date may also belong to retention-pruned history. Only
// move it when the removed timestamps prove that its observation was deleted.
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
const firstSeenMayHaveMoved =
@@ -388,11 +389,26 @@ function applyRemovalsForEntity(
const lastSeenMayHaveMoved =
current.lastSeen === null ||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
const exactRefreshOverride = {
frequency: nextFrequency > 0 ? nextFrequency : null,
firstSeen: firstSeenMayHaveMoved ? null : current.firstSeen,
};
if (nextFrequency <= 0) {
// The rows in scope are already gone by now, so anything still pointing at
// this entity means the stored frequency was stale rather than exhausted.
if (hasOccurrencesStmt.get(removal.id)) {
needsExactRefresh.set(removal.id, exactRefreshOverride);
} else {
deleteStmt.run(removal.id);
}
continue;
}
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
// Undated pre-migration occurrences are invisible to the seeks below;
// fall back to the full re-aggregate that resolves their dates.
// resolve their dates without discarding retention-pruned aggregates.
if (hasUndatedOccurrenceStmt.get(removal.id)) {
needsExactRefresh.push(removal.id);
needsExactRefresh.set(removal.id, exactRefreshOverride);
continue;
}
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
@@ -400,13 +416,13 @@ function applyRemovalsForEntity(
if (minSeenMs === null || maxSeenMs === null) {
// Frequency says occurrences remain but none exist: stale row, let the
// exact refresh reconcile (it deletes rows with nothing left).
needsExactRefresh.push(removal.id);
needsExactRefresh.set(removal.id, exactRefreshOverride);
continue;
}
updateAggregatesStmt.run(
nextFrequency,
Math.floor(Number(minSeenMs) / 1000),
Math.floor(Number(maxSeenMs) / 1000),
firstSeenMayHaveMoved ? toStoredSeenSeconds(Number(minSeenMs)) : current.firstSeen,
toStoredSeenSeconds(Number(maxSeenMs)),
removal.id,
);
continue;
@@ -416,13 +432,17 @@ function applyRemovalsForEntity(
}
if (entity === 'word') {
refreshWordAggregates(db, needsExactRefresh);
refreshWordAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
} else {
refreshKanjiAggregates(db, needsExactRefresh);
refreshKanjiAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
}
}
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
function refreshWordAggregates(
db: DatabaseSync,
wordIds: number[],
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
): void {
if (wordIds.length === 0) {
return;
}
@@ -470,15 +490,19 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
continue;
}
updateStmt.run(
row.frequency,
Math.floor(row.firstSeen / 1000),
Math.floor(row.lastSeen / 1000),
overrides?.get(row.wordId)?.frequency ?? row.frequency,
overrides?.get(row.wordId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
toStoredSeenSeconds(row.lastSeen),
row.wordId,
);
}
}
function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
function refreshKanjiAggregates(
db: DatabaseSync,
kanjiIds: number[],
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
): void {
if (kanjiIds.length === 0) {
return;
}
@@ -526,9 +550,9 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
continue;
}
updateStmt.run(
row.frequency,
Math.floor(row.firstSeen / 1000),
Math.floor(row.lastSeen / 1000),
overrides?.get(row.kanjiId)?.frequency ?? row.frequency,
overrides?.get(row.kanjiId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
toStoredSeenSeconds(row.lastSeen),
row.kanjiId,
);
}
@@ -59,34 +59,6 @@ function cleanupDbPath(dbPath: string): void {
// 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', () => {
const statements: string[] = [];
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', () => {
const dbPath = makeDbPath();
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', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
+25 -16
View File
@@ -6,6 +6,7 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs, toDbTimestamp } from './query-shared';
@@ -17,7 +18,8 @@ export interface TrackerPreparedStatements {
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
wordIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
wordSelectStmt: ReturnType<DatabaseSync['prepare']>;
wordVisibilityUpdateStmt: ReturnType<DatabaseSync['prepare']>;
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
@@ -1759,7 +1761,6 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
vocabulary_visible = 1,
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
frequency_rank = CASE
@@ -1787,10 +1788,16 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`),
wordIdSelectStmt: db.prepare(`
SELECT id FROM imm_words
wordSelectStmt: db.prepare(`
SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
pos1, pos2, pos3, frequency_rank AS frequencyRank,
vocabulary_visible AS vocabularyVisible
FROM imm_words
WHERE headword = ? AND word = ? AND reading = ?
`),
wordVisibilityUpdateStmt: db.prepare(`
UPDATE imm_words SET vocabulary_visible = ? WHERE id = ?
`),
kanjiIdSelectStmt: db.prepare(`
SELECT id FROM imm_kanji
WHERE kanji = ?
@@ -1842,14 +1849,18 @@ function incrementWordAggregate(
occurrence.frequencyRank ?? null,
);
}
const row = stmts.wordIdSelectStmt.get(
occurrence.headword,
occurrence.word,
occurrence.reading,
) as { id: number } | null;
const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as
| (VocabularyVisibilityRow & { id: number; vocabularyVisible: number })
| null;
if (!row?.id) {
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
}
// The upsert keeps the first-seen POS metadata, so derive visibility from the
// stored row (as the vocabulary summary does) rather than the incoming token.
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
if (row.vocabularyVisible !== visible) {
stmts.wordVisibilityUpdateStmt.run(visible, row.id);
}
return row.id;
}
@@ -1952,18 +1963,16 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
currentMs,
);
const lineId = Number(lineResult.lastInsertRowid);
// Match aggregate bounds to the observation, even if this write was queued
// for a while. Flush time cannot identify which occurrence set first_seen.
const seenMs = toDbTimestamp(Math.round(write.firstSeen * 1000));
for (const occurrence of write.wordOccurrences) {
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, currentMs);
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, seenMs);
}
for (const occurrence of write.kanjiOccurrences) {
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
stmts.kanjiLineOccurrenceUpsertStmt.run(
lineId,
kanjiId,
occurrence.occurrenceCount,
currentMs,
);
stmts.kanjiLineOccurrenceUpsertStmt.run(lineId, kanjiId, occurrence.occurrenceCount, seenMs);
}
return;
}
+25 -40
View File
@@ -129,47 +129,32 @@ test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies
assert.deepEqual(playbackFeedback, []);
});
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
assert.deepEqual(calls, []);
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
assert.deepEqual(osd, []);
});
const specialCommandCases = [
{ name: 'youtube picker open', command: '__youtube-picker-open', expected: 'youtube-picker' },
{ name: 'jimaku open', command: '__jimaku-open', expected: 'jimaku' },
// Legacy Animetosho command is kept as a TsukiHime alias.
{
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', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__youtube-picker-open'], options);
assert.deepEqual(calls, ['youtube-picker']);
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, []);
});
for (const c of specialCommandCases) {
test(`handleMpvCommandFromIpc dispatches special ${c.name} command`, async () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc([c.command], options);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [c.expected]);
assert.deepEqual(sentCommands, []);
assert.deepEqual(osd, []);
});
}
test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => {
const { options, osd } = createOptions({
+37 -253
View File
@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type {
PlaylistBrowserSnapshot,
SessionActionDispatchRequest,
SubtitleSidebarSnapshot,
} from '../../types';
import type { SessionActionDispatchRequest, SubtitleSidebarSnapshot } from '../../types';
interface FakeIpcRegistrar {
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', () => {
const mainWindow = { id: 'main', 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']);
});
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 () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -433,71 +307,6 @@ test('registerIpcHandlers skips AniList update when manual mark watched has no a
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 () => {
const { registrar, handlers } = createFakeIpcRegistrar();
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 () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const requests: unknown[] = [];
@@ -757,28 +527,6 @@ test('registerIpcHandlers validates and forwards timing review text and screensh
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', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
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 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);
});
-35
View File
@@ -276,7 +276,6 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac
test('handleMineSentenceDigit reports async create failures', async () => {
const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = [];
let cardsMined = 0;
handleMineSentenceDigit(2, {
subtitleTimingTracker: {
@@ -296,9 +295,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text),
logError: (message, err) => logs.push({ message, err }),
onCardsMined: (count) => {
cardsMined += count;
},
});
await new Promise((resolve) => setImmediate(resolve));
@@ -307,37 +303,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
assert.equal((logs[0]?.err as Error).message, 'mine boom');
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
assert.equal(cardsMined, 0);
});
test('handleMineSentenceDigit increments successful card count', async () => {
const osd: string[] = [];
let cardsMined = 0;
handleMineSentenceDigit(2, {
subtitleTimingTracker: {
getRecentBlocks: () => ['one', 'two'],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => true,
},
getCurrentSecondarySubText: () => 'sub2',
showMpvOsd: (text) => osd.push(text),
logError: () => {},
onCardsMined: (count) => {
cardsMined += count;
},
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cardsMined, 1);
});
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
+1 -7
View File
@@ -201,7 +201,6 @@ export function handleMineSentenceDigit(
getCurrentSecondarySubText: () => string | undefined;
showMpvOsd: (text: string) => void;
logError: (message: string, err: unknown) => void;
onCardsMined?: (count: number) => void;
},
): void {
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
@@ -233,14 +232,9 @@ export function handleMineSentenceDigit(
entries,
deps.getCurrentSecondarySubText,
);
const cardsToMine = 1;
// Card creation records the mined card (with its note id) itself.
deps.ankiIntegration
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
.then((created) => {
if (created) {
deps.onCardsMined?.(cardsToMine);
}
})
.catch((err) => {
deps.logError('mineSentenceMultiple failed:', err);
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
+1 -33
View File
@@ -1,12 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
playNextSubtitleRuntime,
replayCurrentSubtitleRuntime,
sendMpvCommandRuntime,
setMpvSubVisibilityRuntime,
showMpvOsdRuntime,
} from './mpv';
import { showMpvOsdRuntime } from './mpv';
test('showMpvOsdRuntime sends show-text when connected', () => {
const commands: (string | number)[][] = [];
@@ -55,29 +49,3 @@ test('showMpvOsdRuntime logs fallback when disconnected', () => {
assert.equal(shown, false);
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']);
});
-13
View File
@@ -48,19 +48,6 @@ class HangingSocket extends FakeSocket {
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', () => {
const existing = {} as ReturnType<typeof setTimeout>;
const cleared: Array<ReturnType<typeof setTimeout> | null> = [];
-118
View File
@@ -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', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const calls: string[] = [];
@@ -356,80 +312,6 @@ test('MpvIpcClient failPendingRequests resolves outstanding requests as disconne
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', () => {
const timers: Array<ReturnType<typeof setTimeout> | null> = [];
const client = new MpvIpcClient(
@@ -1,51 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNumericShortcutRuntime, 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'));
});
import { createNumericShortcutSession } from './numeric-shortcut';
test('numeric shortcut session handles digit selection and unregisters shortcuts', () => {
const handlers = new Map<string, () => void>();
+1 -38
View File
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { KikuFieldGroupingChoice } from '../../types';
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
import { sendToVisibleOverlayRuntime } from './overlay-bridge';
test('sendToVisibleOverlayRuntime restores visibility flag when opening hidden overlay modal', () => {
const sent: unknown[][] = [];
@@ -72,39 +71,3 @@ test('sendToVisibleOverlayRuntime waits for overlay page before sending open com
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);
});
+6 -46
View File
@@ -1,37 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
broadcastRuntimeOptionsChangedRuntime,
createOverlayManager,
setOverlayDebugVisualizationEnabledRuntime,
} 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', () => {
const manager = createOverlayManager();
manager.setMainWindow({
@@ -44,13 +17,6 @@ test('overlay manager excludes destroyed windows', () => {
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', () => {
const manager = createOverlayManager();
const calls: unknown[][] = [];
@@ -136,19 +102,13 @@ test('overlay manager can suppress z-order promotion during bounds updates', ()
assert.deepEqual(calls, ['promote:false']);
});
test('runtime-option broadcast still uses expected channel', () => {
const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntime(
() => [],
(channel, ...args) => {
broadcasts.push([channel, ...args]);
},
);
test('setOverlayDebugVisualizationEnabledRuntime only updates state when the value changes', () => {
let state = false;
const changed = setOverlayDebugVisualizationEnabledRuntime(state, true, (enabled) => {
const setState = (enabled: boolean) => {
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.deepEqual(broadcasts, [['runtime-options:changed', []]]);
});
+229 -627
View File
@@ -1,103 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { WindowGeometry } from '../../types';
import {
initializeOverlayAnkiIntegration,
initializeOverlayRuntime,
startOverlayWindowTracker,
} from './overlay-runtime-init';
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
type AnkiOptions = Parameters<typeof initializeOverlayAnkiIntegration>[0];
type RuntimeOptions = Parameters<typeof initializeOverlayRuntime>[0];
type CreateAnkiIntegrationArgs = Parameters<NonNullable<AnkiOptions['createAnkiIntegration']>>[0];
function createFakeTracker(targetMinimized = false) {
return {
onGeometryChange: null as ((geometry: WindowGeometry) => void) | null,
onWindowFound: null as ((geometry: WindowGeometry) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => false,
start: () => {
calls.push('start');
isTargetWindowMinimized: () => targetMinimized,
started: false,
start() {
this.started = true;
},
};
}
const result = startOverlayWindowTracker({
backendOverride: 'windows',
getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket',
createWindowTracker: (override, socketPath) => {
calls.push(`create:${override}:${socketPath}`);
return tracker as never;
},
setWindowTracker: (nextTracker) => {
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,
}),
function createAnkiHarness(overrides: Partial<AnkiOptions> = {}) {
const state = {
created: [] as CreateAnkiIntegrationArgs[],
started: 0,
stored: [] as unknown[],
};
const options: AnkiOptions = {
getResolvedConfig: () => ({ ankiConnect: { enabled: true } as never }),
getSubtitleTimingTracker: () => ({}),
getMpvClient: () => ({
send: () => {},
}),
getMpvClient: () => ({ send: () => {} }),
getRuntimeOptionsManager: () => ({
getEffectiveAnkiConnectConfig: (config) => config as never,
}),
createAnkiIntegration: () => {
createdIntegrations += 1;
createAnkiIntegration: (args) => {
state.created.push(args);
return {
start: () => {
startedIntegrations += 1;
state.started += 1;
},
};
},
setAnkiIntegration: () => {
setIntegrationCalls += 1;
setAnkiIntegration: (integration) => {
state.stored.push(integration);
},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
@@ -107,264 +57,149 @@ test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled i
cancelled: false,
}),
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);
assert.equal(startedIntegrations, 0);
assert.equal(setIntegrationCalls, 0);
});
test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled is true', () => {
let createdIntegrations = 0;
let startedIntegrations = 0;
let setIntegrationCalls = 0;
initializeOverlayRuntime({
const options: RuntimeOptions = {
...createAnkiHarness({
getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }),
}).options,
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
updateVisibleOverlayBounds: (geometry) => {
state.bounds.push(geometry);
},
isVisibleOverlayVisible: () => config.overlayVisible,
updateVisibleOverlayVisibility: () => {
state.visibilityRefreshes += 1;
},
refreshCurrentSubtitle: () => {
state.subtitleRefreshes += 1;
},
getOverlayWindows: () => overlayWindows as never,
syncOverlayShortcuts: () => {
state.shortcutSyncs += 1;
},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => null,
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;
},
};
createWindowTracker: () => tracker as never,
};
initializeOverlayRuntime(options);
state = fresh();
return { tracker, snapshot: () => state };
}
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
const tracker = createFakeTracker();
const created: Array<[string | null | undefined, string | null | undefined]> = [];
const stored: unknown[] = [];
const bounds: WindowGeometry[] = [];
let visibilityRefreshes = 0;
let handlersInstalledAtStart = false;
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: () => {
setIntegrationCalls += 1;
setWindowTracker: (nextTracker) => {
stored.push(nextTracker);
},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
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;
},
};
updateVisibleOverlayBounds: (geometry) => {
bounds.push(geometry);
},
setAnkiIntegration: () => {
setIntegrationCalls += 1;
isVisibleOverlayVisible: () => true,
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: () => [],
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);
assert.equal(startedIntegrations, 0);
assert.equal(setIntegrationCalls, 1);
const geometry = { x: 10, y: 20, width: 300, height: 200 };
tracker.onWindowFound?.(geometry);
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', () => {
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => null,
const ankiNotCreatedCases: Array<{ name: string; overrides: Partial<AnkiOptions> }> = [
{
name: 'ankiConnect is disabled',
overrides: { getResolvedConfig: () => ({ ankiConnect: { enabled: false } as never }) },
},
{ name: 'an integration already exists', overrides: { getAnkiIntegration: () => ({}) } },
];
for (const c of ankiNotCreatedCases) {
test(`initializeOverlayAnkiIntegration returns false when ${c.name}`, () => {
const { options, state } = createAnkiHarness(c.overrides);
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: () => ({
ankiConnect: {
enabled: true,
@@ -383,45 +218,23 @@ test('initializeOverlayRuntime merges shared ai config with Anki overrides', ()
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', () => {
let syncCalls = 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: () => {},
};
test('initializeOverlayRuntime initializes the Anki integration after the window tracker starts', () => {
const { options: ankiOptions, state } = createAnkiHarness();
const tracker = createFakeTracker();
initializeOverlayRuntime({
...ankiOptions,
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
@@ -429,287 +242,76 @@ test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus cha
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
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: () => {},
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.onWindowFocusChange?.(true);
assert.equal(visibilityRefreshCalls, 2);
assert.equal(tracker.started, true);
assert.equal(state.created.length, 1);
assert.equal(state.started, 1);
assert.equal(state.stored.length, 1);
});
test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => {
let subtitleRefreshCalls = 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: () => {},
refreshCurrentSubtitle: () => {
subtitleRefreshCalls += 1;
const trackerEventCases: Array<{
name: string;
overlayVisible: boolean;
targetMinimized?: boolean;
trigger: (tracker: ReturnType<typeof createFakeTracker>) => void;
expected: Partial<ReturnType<ReturnType<typeof createTrackerHarness>['snapshot']>>;
}> = [
{
name: 'refreshes the visible overlay and re-syncs shortcuts when focus changes while shown',
overlayVisible: true,
trigger: (tracker) => tracker.onWindowFocusChange?.(true),
expected: { visibilityRefreshes: 1, shortcutSyncs: 1 },
},
{
name: 'only re-syncs shortcuts when focus changes while the overlay is hidden',
overlayVisible: false,
trigger: (tracker) => tracker.onWindowFocusChange?.(true),
expected: { shortcutSyncs: 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: () => {},
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',
},
{
name: 'hides overlay windows when the tracker loses a minimized target window',
overlayVisible: true,
targetMinimized: true,
trigger: (tracker) => tracker.onWindowLost?.(),
expected: { hiddenWindows: ['visible', 'modal'], shortcutSyncs: 1 },
},
{
name: 'refreshes visibility instead of hiding when the tracker loses a non-minimized target',
overlayVisible: true,
targetMinimized: false,
trigger: (tracker) => tracker.onWindowLost?.(),
expected: { visibilityRefreshes: 1 },
},
];
for (const c of trackerEventCases) {
test(`initializeOverlayRuntime ${c.name}`, () => {
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 };
}
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 () => {
const logs: unknown[][] = [];
const originalError = console.error;
@@ -243,79 +215,58 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
assert.deepEqual(handled, []);
});
test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
toggleSecondarySub: 'Ctrl+2',
const allowWhenRegisteredCases: Array<{
name: string;
shortcut: Partial<ConfiguredShortcuts>;
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', () => {
const shortcuts = makeShortcuts({
+1 -57
View File
@@ -1,11 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import {
registerOverlayShortcuts,
syncOverlayShortcutsRuntime,
unregisterOverlayShortcutsRuntime,
} from './overlay-shortcut';
import { registerOverlayShortcuts } from './overlay-shortcut';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
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', () => {
assert.equal(
registerOverlayShortcuts(createShortcuts(), {
@@ -77,34 +52,3 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
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
+29 -109
View File
@@ -2,19 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
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', () => {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
@@ -31,117 +18,50 @@ test('macOS modal overlay uses a fullscreen auxiliary panel without changing the
assert.equal(modalOptions.type, 'panel');
});
test('non-macOS modal overlay remains a regular window', () => {
const options = buildOverlayWindowOptions('modal', {
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
platform: 'linux',
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
platform: 'linux',
yomitanSession: null,
});
assert.equal(options.type, undefined);
assert.equal(options.roundedCorners, 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);
}
}
assert.equal(visibleOptions.resizable, true);
assert.equal(modalOptions.resizable, false);
});
test('Linux visible overlay window stays managed so native apps can cover it', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
platform: 'linux',
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
platform: 'linux',
yomitanSession: null,
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
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);
}
}
assert.equal(visibleOptions.alwaysOnTop, false);
assert.equal(visibleOptions.focusable, true);
assert.equal(modalOptions.focusable, true);
});
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
platform: 'linux',
linuxX11FullscreenOverlay: true,
yomitanSession: null,
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: 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);
}
}
assert.equal(visibleOptions.alwaysOnTop, true);
assert.equal(visibleOptions.focusable, false);
assert.equal(visibleOptions.resizable, false);
});
test('overlay window config uses the provided Yomitan session when available', () => {
-106
View File
@@ -133,26 +133,6 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h
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', () => {
const calls: string[] = [];
@@ -176,92 +156,6 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
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', () => {
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);
});
});
-14
View File
@@ -81,17 +81,3 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
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', () => {
const expectedSpecialActions: Record<string, string> = {
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
+59 -133
View File
@@ -79,19 +79,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 args = makeArgs({
logLevel: 'debug',
socketPath: '/tmp/custom.sock',
texthookerPort: 9001,
backend: 'x11',
autoStartOverlay: true,
texthooker: true,
});
const args = makeArgs(argsOverrides);
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--log-level', 'debug'],
argv: ['node', 'main.ts'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
@@ -100,6 +95,19 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
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);
@@ -108,141 +116,59 @@ test('runStartupBootstrapRuntime configures startup state and starts lifecycle',
assert.equal(result.backendOverride, 'x11');
assert.equal(result.autoStartOverlay, true);
assert.equal(result.texthookerOnlyMode, true);
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:debug:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
assert.equal(result.backgroundMode, true);
});
test('runStartupBootstrapRuntime keeps log-level precedence for repeated calls', () => {
const calls: string[] = [];
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'),
});
test('runStartupBootstrapRuntime falls back to defaults when args are unset', () => {
const { result } = runBootstrap();
assert.equal(result.mpvSocketPath, '/tmp/default.sock');
assert.equal(result.texthookerPort, 5174);
assert.equal(result.backendOverride, null);
assert.equal(result.autoStartOverlay, false);
assert.equal(result.texthookerOnlyMode, 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 calls: string[] = [];
const args = makeArgs({ background: true });
const logLevelCases: Array<{ name: string; args: Partial<CliArgs>; expected: string[] }> = [
{
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({
argv: ['node', 'main.ts', '--background'],
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'),
for (const c of logLevelCases) {
test(`runStartupBootstrapRuntime log level: ${c.name}`, () => {
const { calls } = runBootstrap(c.args);
assert.deepEqual(
calls.filter((call) => call.startsWith('setLog:')),
c.expected,
);
});
}
assert.equal(result.backgroundMode, true);
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
test('runStartupBootstrapRuntime applies log level and platform setup before starting lifecycle', () => {
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', () => {
const calls: string[] = [];
const args = makeArgs({ update: true });
test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handled', () => {
const { calls } = runBootstrap({ generateConfig: true }, { runGenerateConfigFlow: () => true });
const result = runStartupBootstrapRuntime({
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']);
assert.equal(calls.includes('startLifecycle'), false);
});
+96 -417
View File
@@ -1,447 +1,126 @@
import assert from 'node:assert/strict';
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[] = [];
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');
},
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');
},
const record = (name: string) => () => {
calls.push(name);
};
const recordAsync = (name: string) => async () => {
calls.push(name);
};
const deps: AppReadyRuntimeDeps = {
ensureDefaultConfigBootstrap: record('bootstrap'),
loadSubtitlePosition: record('load-subtitle-position'),
resolveKeybindings: record('resolve-keybindings'),
createMpvClient: record('create-mpv'),
reloadConfig: record('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');
},
logConfigWarning: record('config-warning'),
setLogLevel: record('set-log-level'),
initRuntimeOptionsManager: record('init-runtime-options'),
setSecondarySubMode: record('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 () => {},
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');
},
startSubtitleWebsocket: record('subtitle-ws'),
startAnnotationWebsocket: record('annotation-ws'),
startTexthooker: record('texthooker'),
log: record('log'),
createMecabTokenizerAndCheck: recordAsync('mecab'),
createSubtitleTimingTracker: record('subtitle-timing'),
createImmersionTracker: record('immersion'),
startJellyfinRemoteSession: recordAsync('jellyfin'),
loadYomitanExtension: recordAsync('load-yomitan'),
handleFirstRunSetup: recordAsync('first-run'),
prewarmSubtitleDictionaries: recordAsync('prewarm'),
startBackgroundWarmups: record('warmups'),
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
setVisibleOverlayVisible: () => {
calls.push('visible-overlay');
},
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
handleInitialArgs: () => {
calls.push('handle-initial-args');
},
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: record('visible-overlay'),
initializeOverlayRuntime: record('init-overlay'),
handleInitialArgs: record('handle-initial-args'),
shouldUseMinimalStartup: () => 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);
assert.ok(calls.indexOf('warmups') !== -1);
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
await runAppReadyRuntime(deps);
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);
});
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', 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: () => ({
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');
},
const { calls, deps } = createAppReadyDeps({
ensureYomitanExtensionLoaded: async () => {
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);
assert.equal(calls.includes('load-yomitan-guarded'), true);
await runAppReadyRuntime(deps);
assert.equal(calls.includes('load-yomitan'), false);
assertBefore(calls, 'warmups', 'load-yomitan-guarded');
});
-57
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStatsWindowUrl,
buildStatsWindowOptions,
buildStatsNativeConfirmDialogOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow,
@@ -12,60 +11,8 @@ import {
scheduleStatsWindowPostShowReconciles,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput,
shouldPresentStatsWindowAfterLoad,
} 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', () => {
assert.equal(
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');
});
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', () => {
assert.deepEqual(
resolveStatsWindowOuterBoundsForContent(
-25
View File
@@ -133,31 +133,6 @@ test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media path
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 () => {
let payloadTrackCount = 0;
@@ -267,34 +267,6 @@ test('parseAssCues respects dynamic field ordering from the Format row', () => {
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', () => {
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', () => {
const content = [
'[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', () => {
const content = [
'[Events]',
@@ -794,38 +727,6 @@ test('parseSubtitleCues keeps two positioned signs that repeat the same text', (
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', () => {
const content = [
'[Events]',
@@ -881,22 +782,6 @@ test('parseSubtitleCues does not merge a burst into unrelated dialogue between f
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', () => {
const content = [
'[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 () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
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, []);
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(','), '猫,犬');
});
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', () => {
const parseResults = [
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 かかってくる).
// When a competing multi-token candidate splits content and function separately, the
// 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)', () => {
// Candidate A: single merged token — content verb fused with trailing sentence-final particle
// This is the "bad" candidate: downstream annotation would exclude frequency for the whole
// token because the merged pos1 would contain a function-word component.
test('multi-token candidate beats single merged content+function token candidate in either input order (frequency regression)', () => {
// Merged candidate: content verb fused with the trailing sentence-final particle. Downstream
// annotation would exclude frequency for the whole token because its merged pos1 contains a
// function-word component.
const mergedCandidate = makeParseItem('scanning-parser', [
[{ text: 'かかってこいよ', reading: 'かかってこいよ', headword: 'かかってくる' }],
]);
// Candidate B: two tokens — content verb surface + particle separately.
// The content token is frequency-eligible on its own.
// Split candidate: the content token stays frequency-eligible on its own.
const splitCandidate = makeParseItem('scanning-parser', [
[{ text: 'かかってこい', reading: 'かかってこい', headword: 'かかってくる' }],
[{ text: 'よ', reading: 'よ', headword: 'よ' }],
]);
// When merged candidate comes first in the array, multi-token split still wins.
const tokens = selectYomitanParseTokens(
for (const candidates of [
[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],
() => false,
'headword',
);
assert.equal(tokens?.length, 2);
assert.equal(tokens?.[0]?.surface, 'かかってこい');
assert.equal(tokens?.[1]?.surface, 'よ');
]) {
const tokens = selectYomitanParseTokens(candidates, () => false, 'headword');
assert.deepEqual(
tokens?.map((token) => ({ surface: token.surface, headword: token.headword })),
[
{ surface: 'かかってこい', headword: 'かかってくる' },
{ surface: 'よ', headword: 'よ' },
],
);
}
});
@@ -4,10 +4,8 @@ import { MergedToken, PartOfSpeech } from '../../../types';
import {
createSubtitleAnnotationRuleContext,
shouldExcludeTokenFromSubtitleAnnotations,
SUBTITLE_ANNOTATION_EXCLUDED_TERMS,
SUBTITLE_ANNOTATION_RULES,
} from './subtitle-annotation-filter';
import { isKanaChar } from './token-classification';
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
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', () => {
const token = makeToken({ pos1: '名詞', pos2: '一般' });
const context = createSubtitleAnnotationRuleContext(token, {
@@ -175,9 +83,3 @@ test('configured POS2 rule preserves supplementary-plane kanji nouns', () => {
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 () => {
const lookups: string[] = [];
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]?.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);
}
});
@@ -48,7 +48,11 @@ function createYomitanScriptSandbox(handler: (action: string, params: unknown) =
callback: (response: { result?: unknown; error?: { message?: string } }) => void,
) => {
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) {
callback({ error: { message: (error as Error).message } });
}
@@ -107,8 +111,128 @@ export function countTermsFindLookups(lookups: string[], prefix: string): number
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
// 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]> = [
['ミナト', 'ミナト', 'みなと', true],
['は', 'は', 'は', false],
@@ -121,57 +245,24 @@ export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
export function createNameScanDeps(
lookups: string[],
words: Array<[string, string, string, boolean]> = NAME_SCAN_WORDS,
dictionaries: string[] = ['JMdict', SUBMINER_TEST_CHARACTER_DICTIONARY],
) {
return createScanDeps((action, params) => {
if (action === 'optionsGetFull') {
return {
profileCurrent: 0,
profiles: [
{
options: {
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' },
],
},
],
};
return createBackendDeps({
dictionaries,
lookups,
termsFind: (text) => {
const match = words.find(([surface]) => text.startsWith(surface));
if (!match) {
return null;
}
}
return { originalTextLength: 0, dictionaryEntries: [] };
const [surface, term, reading, isName] = match;
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 { clearYomitanParserRuntimeState } from './yomitan-extension-runtime-state';
test('clearYomitanParserRuntimeState destroys parser window and clears parser promises', () => {
const calls: string[] = [];
const parserWindow = {
isDestroyed: () => false,
destroy: () => {
calls.push('destroy');
},
};
const cases = [
{ name: 'a live parser window', window: { destroyed: false }, expectDestroyed: true },
{
name: 'an already destroyed parser window',
window: { destroyed: true },
expectDestroyed: false,
},
{ name: 'no parser window', window: null, expectDestroyed: false },
];
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'}`),
for (const c of cases) {
test(`clearYomitanParserRuntimeState clears parser state with ${c.name}`, () => {
let destroyCalls = 0;
const parserWindow = c.window && {
isDestroyed: () => c.window.destroyed,
destroy: () => {
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 {
buildYomitanSettingsCloseButtonScript,
buildYomitanSettingsWindowMenuTemplate,
buildYomitanSettingsUrl,
configureYomitanSettingsWindowChrome,
destroyYomitanSettingsWindow,
installYomitanSettingsCloseButton,
@@ -49,15 +47,6 @@ test('yomitan settings close menu skips destroyed windows', () => {
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', () => {
assert.equal(
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
@@ -96,13 +85,6 @@ test('yomitan settings close button injection skips non-Hyprland windows', () =>
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', () => {
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', () => {
assert.equal(destroyYomitanSettingsWindow(null), false);
assert.equal(
@@ -4,7 +4,6 @@ import { buildYoutubeMetadataProbeArgs } from './metadata-probe';
import { buildYoutubePlaybackResolveArgs } from './playback-resolve';
import { buildDownloadArgs } from './track-download';
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
// 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) {
test(`${name} passes --no-playlist for playlist-scoped URLs`, () => {
assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`);