mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
162
src/core/services/config-hot-reload.test.ts
Normal file
162
src/core/services/config-hot-reload.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import {
|
||||
classifyConfigHotReloadDiff,
|
||||
createConfigHotReloadRuntime,
|
||||
type ConfigHotReloadRuntimeDeps,
|
||||
} from './config-hot-reload';
|
||||
|
||||
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.subtitleStyle.fontSize = prev.subtitleStyle.fontSize + 2;
|
||||
next.websocket.port = prev.websocket.port + 1;
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
|
||||
});
|
||||
|
||||
test('config hot reload runtime debounces rapid watch events', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const pendingTimers = new Map<number, () => void>();
|
||||
let nextTimerId = 1;
|
||||
let reloadCalls = 0;
|
||||
|
||||
const deps: ConfigHotReloadRuntimeDeps = {
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => {
|
||||
reloadCalls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
config: deepCloneConfig(DEFAULT_CONFIG),
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
};
|
||||
},
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
const id = nextTimerId;
|
||||
nextTimerId += 1;
|
||||
pendingTimers.set(id, callback);
|
||||
return id as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: (timeout) => {
|
||||
pendingTimers.delete(timeout as unknown as number);
|
||||
},
|
||||
debounceMs: 25,
|
||||
onHotReloadApplied: () => {},
|
||||
onRestartRequired: () => {},
|
||||
onInvalidConfig: () => {},
|
||||
onValidationWarnings: () => {},
|
||||
};
|
||||
|
||||
const runtime = createConfigHotReloadRuntime(deps);
|
||||
runtime.start();
|
||||
assert.equal(reloadCalls, 1);
|
||||
if (!watchedChangeCallback) {
|
||||
throw new Error('Expected watch callback to be registered.');
|
||||
}
|
||||
const trigger = watchedChangeCallback as () => void;
|
||||
|
||||
trigger();
|
||||
trigger();
|
||||
trigger();
|
||||
assert.equal(pendingTimers.size, 1);
|
||||
|
||||
for (const callback of pendingTimers.values()) {
|
||||
callback();
|
||||
}
|
||||
assert.equal(reloadCalls, 2);
|
||||
});
|
||||
|
||||
test('config hot reload runtime reports invalid config and skips apply', () => {
|
||||
const invalidMessages: string[] = [];
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
|
||||
const runtime = createConfigHotReloadRuntime({
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => ({
|
||||
ok: false,
|
||||
error: 'Invalid JSON',
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 1 as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
debounceMs: 0,
|
||||
onHotReloadApplied: () => {
|
||||
throw new Error('Hot reload should not apply for invalid config.');
|
||||
},
|
||||
onRestartRequired: () => {
|
||||
throw new Error('Restart warning should not trigger for invalid config.');
|
||||
},
|
||||
onInvalidConfig: (message) => {
|
||||
invalidMessages.push(message);
|
||||
},
|
||||
onValidationWarnings: () => {
|
||||
throw new Error('Validation warnings should not trigger for invalid config.');
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
assert.equal(watchedChangeCallback, null);
|
||||
assert.equal(invalidMessages.length, 1);
|
||||
});
|
||||
|
||||
test('config hot reload runtime reports validation warnings from reload', () => {
|
||||
let watchedChangeCallback: (() => void) | null = null;
|
||||
const warningCalls: Array<{ path: string; count: number }> = [];
|
||||
|
||||
const runtime = createConfigHotReloadRuntime({
|
||||
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
reloadConfigStrict: () => ({
|
||||
ok: true,
|
||||
config: deepCloneConfig(DEFAULT_CONFIG),
|
||||
warnings: [
|
||||
{
|
||||
path: 'ankiConnect.openRouter',
|
||||
message: 'Deprecated key; use ankiConnect.ai instead.',
|
||||
value: { enabled: true },
|
||||
fallback: {},
|
||||
},
|
||||
],
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
watchConfigPath: (_path, onChange) => {
|
||||
watchedChangeCallback = onChange;
|
||||
return { close: () => {} };
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 1 as unknown as NodeJS.Timeout;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
debounceMs: 0,
|
||||
onHotReloadApplied: () => {},
|
||||
onRestartRequired: () => {},
|
||||
onInvalidConfig: () => {},
|
||||
onValidationWarnings: (path, warnings) => {
|
||||
warningCalls.push({ path, count: warnings.length });
|
||||
},
|
||||
});
|
||||
|
||||
runtime.start();
|
||||
assert.equal(warningCalls.length, 0);
|
||||
if (!watchedChangeCallback) {
|
||||
throw new Error('Expected watch callback to be registered.');
|
||||
}
|
||||
const trigger = watchedChangeCallback as () => void;
|
||||
trigger();
|
||||
assert.deepEqual(warningCalls, [{ path: '/tmp/config.jsonc', count: 1 }]);
|
||||
});
|
||||
Reference in New Issue
Block a user