mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor(main): introduce explicit AniList runtime transitions
This commit is contained in:
@@ -53,6 +53,40 @@ test('reset anilist media tracking clears duration/guess/probe state', () => {
|
||||
assert.equal(lastDurationProbeAtMs, 0);
|
||||
});
|
||||
|
||||
test('reset anilist media tracking is idempotent', () => {
|
||||
const state = {
|
||||
mediaKey: 'old' as string | null,
|
||||
mediaDurationSec: 123 as number | null,
|
||||
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 999,
|
||||
};
|
||||
|
||||
const reset = createResetAnilistMediaTrackingHandler({
|
||||
setMediaKey: (value) => {
|
||||
state.mediaKey = value;
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
state.mediaDurationSec = value;
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
state.lastDurationProbeAtMs = value;
|
||||
},
|
||||
});
|
||||
|
||||
reset('/new/media');
|
||||
const afterFirstReset = { ...state };
|
||||
reset('/new/media');
|
||||
|
||||
assert.deepEqual(state, afterFirstReset);
|
||||
});
|
||||
|
||||
test('get/set anilist media guess runtime state round-trips fields', () => {
|
||||
let state = {
|
||||
mediaKey: null as string | null,
|
||||
@@ -106,19 +140,27 @@ test('get/set anilist media guess runtime state round-trips fields', () => {
|
||||
});
|
||||
|
||||
test('reset anilist media guess state clears guess and in-flight promise', () => {
|
||||
let mediaGuess: { title: string } | null = { title: 'guess' };
|
||||
let mediaGuessPromise: Promise<unknown> | null = Promise.resolve(null);
|
||||
const state = {
|
||||
mediaKey: '/tmp/video.mkv' as string | null,
|
||||
mediaDurationSec: 240 as number | null,
|
||||
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 321,
|
||||
};
|
||||
|
||||
const resetGuessState = createResetAnilistMediaGuessStateHandler({
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuess = value as { title: string } | null;
|
||||
state.mediaGuess = value as { title: string } | null;
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessPromise = value;
|
||||
state.mediaGuessPromise = value;
|
||||
},
|
||||
});
|
||||
|
||||
resetGuessState();
|
||||
assert.equal(mediaGuess, null);
|
||||
assert.equal(mediaGuessPromise, null);
|
||||
assert.equal(state.mediaGuess, null);
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
assert.equal(state.mediaKey, '/tmp/video.mkv');
|
||||
assert.equal(state.mediaDurationSec, 240);
|
||||
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
|
||||
@@ -34,8 +34,6 @@ function createRuntime() {
|
||||
pending: 7,
|
||||
ready: 8,
|
||||
deadLetter: 9,
|
||||
lastAttemptAt: 3000,
|
||||
lastError: 'boom' as string | null,
|
||||
}),
|
||||
clearStoredToken: () => {
|
||||
clearedStoredToken = true;
|
||||
@@ -71,7 +69,7 @@ test('setClientSecretState merges partial updates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('refresh/get queue snapshot uses update queue snapshot', () => {
|
||||
test('queue refresh preserves metadata while syncing counts', () => {
|
||||
const harness = createRuntime();
|
||||
const snapshot = harness.runtime.getQueueStatusSnapshot();
|
||||
|
||||
@@ -79,14 +77,15 @@ test('refresh/get queue snapshot uses update queue snapshot', () => {
|
||||
pending: 7,
|
||||
ready: 8,
|
||||
deadLetter: 9,
|
||||
lastAttemptAt: 3000,
|
||||
lastError: 'boom',
|
||||
lastAttemptAt: 2000,
|
||||
lastError: 'none',
|
||||
});
|
||||
assert.deepEqual(harness.getQueueState(), snapshot);
|
||||
});
|
||||
|
||||
test('clearTokenState resets token state and clears caches', () => {
|
||||
const harness = createRuntime();
|
||||
const queueBeforeClear = { ...harness.getQueueState() };
|
||||
harness.runtime.clearTokenState();
|
||||
|
||||
assert.equal(harness.getClearedStoredToken(), true);
|
||||
@@ -98,4 +97,5 @@ test('clearTokenState resets token state and clears caches', () => {
|
||||
resolvedAt: null,
|
||||
errorAt: null,
|
||||
});
|
||||
assert.deepEqual(harness.getQueueState(), queueBeforeClear);
|
||||
});
|
||||
|
||||
93
src/main/state.test.ts
Normal file
93
src/main/state.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
createInitialAnilistMediaGuessRuntimeState,
|
||||
createInitialAnilistUpdateInFlightState,
|
||||
transitionAnilistClientSecretState,
|
||||
transitionAnilistMediaGuessRuntimeState,
|
||||
transitionAnilistRetryQueueLastAttemptAt,
|
||||
transitionAnilistRetryQueueLastError,
|
||||
transitionAnilistUpdateInFlightState,
|
||||
} from './state';
|
||||
|
||||
test('transitionAnilistClientSecretState replaces state object', () => {
|
||||
const current = {
|
||||
status: 'not_checked',
|
||||
source: 'none',
|
||||
message: null,
|
||||
resolvedAt: null,
|
||||
errorAt: null,
|
||||
} as const;
|
||||
const next = {
|
||||
status: 'resolved',
|
||||
source: 'stored',
|
||||
message: 'ok',
|
||||
resolvedAt: 123,
|
||||
errorAt: null,
|
||||
} as const;
|
||||
|
||||
const transitioned = transitionAnilistClientSecretState(current, next);
|
||||
|
||||
assert.deepEqual(transitioned, next);
|
||||
assert.equal(transitioned, next);
|
||||
});
|
||||
|
||||
test('retry queue metadata transitions preserve queue counts', () => {
|
||||
const queue = {
|
||||
pending: 2,
|
||||
ready: 1,
|
||||
deadLetter: 4,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
const attempted = transitionAnilistRetryQueueLastAttemptAt(queue, 999);
|
||||
const failed = transitionAnilistRetryQueueLastError(attempted, 'boom');
|
||||
|
||||
assert.deepEqual(attempted, {
|
||||
pending: 2,
|
||||
ready: 1,
|
||||
deadLetter: 4,
|
||||
lastAttemptAt: 999,
|
||||
lastError: null,
|
||||
});
|
||||
assert.deepEqual(failed, {
|
||||
pending: 2,
|
||||
ready: 1,
|
||||
deadLetter: 4,
|
||||
lastAttemptAt: 999,
|
||||
lastError: 'boom',
|
||||
});
|
||||
assert.notEqual(attempted, queue);
|
||||
assert.notEqual(failed, attempted);
|
||||
});
|
||||
|
||||
test('transitionAnilistMediaGuessRuntimeState applies partial updates', () => {
|
||||
const current = createInitialAnilistMediaGuessRuntimeState();
|
||||
const promise = Promise.resolve(null);
|
||||
|
||||
const transitioned = transitionAnilistMediaGuessRuntimeState(current, {
|
||||
mediaKey: '/tmp/media.mkv',
|
||||
mediaGuessPromise: promise,
|
||||
lastDurationProbeAtMs: 500,
|
||||
});
|
||||
|
||||
assert.deepEqual(transitioned, {
|
||||
mediaKey: '/tmp/media.mkv',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: promise,
|
||||
lastDurationProbeAtMs: 500,
|
||||
});
|
||||
assert.notEqual(transitioned, current);
|
||||
});
|
||||
|
||||
test('transitionAnilistUpdateInFlightState updates inFlight only', () => {
|
||||
const current = createInitialAnilistUpdateInFlightState();
|
||||
const transitioned = transitionAnilistUpdateInFlightState(current, true);
|
||||
|
||||
assert.deepEqual(current, { inFlight: false });
|
||||
assert.deepEqual(transitioned, { inFlight: true });
|
||||
assert.notEqual(transitioned, current);
|
||||
});
|
||||
@@ -12,13 +12,14 @@ import type {
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import type { AnkiIntegration } from '../anki-integration';
|
||||
import type { ImmersionTrackerService } from '../core/services';
|
||||
import type { MpvIpcClient } from '../core/services';
|
||||
import type { JellyfinRemoteSessionService } from '../core/services';
|
||||
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services';
|
||||
import type { ImmersionTrackerService } from '../core/services/immersion-tracker-service';
|
||||
import type { MpvIpcClient } from '../core/services/mpv';
|
||||
import type { JellyfinRemoteSessionService } from '../core/services/jellyfin-remote';
|
||||
import { DEFAULT_MPV_SUBTITLE_RENDER_METRICS } from '../core/services/mpv-render-metrics';
|
||||
import type { RuntimeOptionsManager } from '../runtime-options';
|
||||
import type { MecabTokenizer } from '../mecab-tokenizer';
|
||||
import type { BaseWindowTracker } from '../window-trackers';
|
||||
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
|
||||
|
||||
export interface AnilistSecretResolutionState {
|
||||
status: 'not_checked' | 'resolved' | 'error';
|
||||
@@ -36,6 +37,108 @@ export interface AnilistRetryQueueState {
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface AnilistMediaGuessRuntimeState {
|
||||
mediaKey: string | null;
|
||||
mediaDurationSec: number | null;
|
||||
mediaGuess: AnilistMediaGuess | null;
|
||||
mediaGuessPromise: Promise<AnilistMediaGuess | null> | null;
|
||||
lastDurationProbeAtMs: number;
|
||||
}
|
||||
|
||||
export interface AnilistUpdateInFlightState {
|
||||
inFlight: boolean;
|
||||
}
|
||||
|
||||
export function createInitialAnilistSecretResolutionState(): AnilistSecretResolutionState {
|
||||
return {
|
||||
status: 'not_checked',
|
||||
source: 'none',
|
||||
message: null,
|
||||
resolvedAt: null,
|
||||
errorAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialAnilistRetryQueueState(): AnilistRetryQueueState {
|
||||
return {
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialAnilistMediaGuessRuntimeState(): AnilistMediaGuessRuntimeState {
|
||||
return {
|
||||
mediaKey: null,
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialAnilistUpdateInFlightState(): AnilistUpdateInFlightState {
|
||||
return {
|
||||
inFlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionAnilistClientSecretState(
|
||||
_current: AnilistSecretResolutionState,
|
||||
next: AnilistSecretResolutionState,
|
||||
): AnilistSecretResolutionState {
|
||||
return next;
|
||||
}
|
||||
|
||||
export function transitionAnilistRetryQueueState(
|
||||
_current: AnilistRetryQueueState,
|
||||
next: AnilistRetryQueueState,
|
||||
): AnilistRetryQueueState {
|
||||
return next;
|
||||
}
|
||||
|
||||
export function transitionAnilistRetryQueueLastAttemptAt(
|
||||
current: AnilistRetryQueueState,
|
||||
lastAttemptAt: number | null,
|
||||
): AnilistRetryQueueState {
|
||||
return {
|
||||
...current,
|
||||
lastAttemptAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionAnilistRetryQueueLastError(
|
||||
current: AnilistRetryQueueState,
|
||||
lastError: string | null,
|
||||
): AnilistRetryQueueState {
|
||||
return {
|
||||
...current,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionAnilistMediaGuessRuntimeState(
|
||||
current: AnilistMediaGuessRuntimeState,
|
||||
partial: Partial<AnilistMediaGuessRuntimeState>,
|
||||
): AnilistMediaGuessRuntimeState {
|
||||
return {
|
||||
...current,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionAnilistUpdateInFlightState(
|
||||
current: AnilistUpdateInFlightState,
|
||||
inFlight: boolean,
|
||||
): AnilistUpdateInFlightState {
|
||||
return {
|
||||
...current,
|
||||
inFlight,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
yomitanExt: Extension | null;
|
||||
yomitanSettingsWindow: BrowserWindow | null;
|
||||
@@ -123,13 +226,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
currentMediaPath: null,
|
||||
currentMediaTitle: null,
|
||||
pendingSubtitlePosition: null,
|
||||
anilistClientSecretState: {
|
||||
status: 'not_checked',
|
||||
source: 'none',
|
||||
message: null,
|
||||
resolvedAt: null,
|
||||
errorAt: null,
|
||||
},
|
||||
anilistClientSecretState: createInitialAnilistSecretResolutionState(),
|
||||
mecabTokenizer: null,
|
||||
keybindings: [],
|
||||
subtitleTimingTracker: null,
|
||||
@@ -159,13 +256,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
jlptLevelLookup: () => null,
|
||||
frequencyRankLookup: () => null,
|
||||
anilistSetupPageOpened: false,
|
||||
anilistRetryQueueState: {
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
anilistRetryQueueState: createInitialAnilistRetryQueueState(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user