mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
1f7318d615
- Mark logging.level, stats keys, jimaku.*, subsync.*, and granular ankiConnect fields (knownWords, nPlusOne, fields, isLapis, isKiku, behavior) as hot-reloadable - Refactor classifyConfigHotReloadDiff to path-walk diffing instead of per-key branches - Wire setLogLevel, invalidateTokenizationCache, refreshSubtitlePrefetch, refreshCurrentSubtitle into hot-reload applied handler - Exclude ai.* and ankiConnect.ai.* prefixes from config window; hide fields.translation - Update docs and config.example.jsonc hot-reload annotations
235 lines
8.0 KiB
TypeScript
235 lines
8.0 KiB
TypeScript
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('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
|
|
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
|
const next = deepCloneConfig(DEFAULT_CONFIG);
|
|
next.stats.toggleKey = 'F8';
|
|
next.stats.markWatchedKey = 'F9';
|
|
next.logging.level = 'debug';
|
|
next.youtube.primarySubLanguages = ['ja', 'en'];
|
|
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
|
|
next.subsync.defaultMode = prev.subsync.defaultMode === 'auto' ? 'manual' : 'auto';
|
|
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
|
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
|
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
|
next.ankiConnect.knownWords.addMinedWordsImmediately =
|
|
!prev.ankiConnect.knownWords.addMinedWordsImmediately;
|
|
next.ankiConnect.knownWords.matchMode =
|
|
prev.ankiConnect.knownWords.matchMode === 'headword' ? 'surface' : 'headword';
|
|
next.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
|
|
next.ankiConnect.nPlusOne.enabled = !prev.ankiConnect.nPlusOne.enabled;
|
|
next.ankiConnect.nPlusOne.minSentenceWords = prev.ankiConnect.nPlusOne.minSentenceWords + 1;
|
|
next.ankiConnect.fields.word = 'Vocabulary';
|
|
next.ankiConnect.fields.audio = 'SentenceAudioCustom';
|
|
next.ankiConnect.fields.image = 'ScreenshotCustom';
|
|
next.ankiConnect.fields.sentence = 'SentenceCustom';
|
|
next.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
|
next.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
|
next.ankiConnect.isKiku.fieldGrouping =
|
|
prev.ankiConnect.isKiku.fieldGrouping === 'auto' ? 'manual' : 'auto';
|
|
|
|
const diff = classifyConfigHotReloadDiff(prev, next);
|
|
|
|
assert.deepEqual(
|
|
new Set(diff.hotReloadFields),
|
|
new Set([
|
|
'stats.toggleKey',
|
|
'stats.markWatchedKey',
|
|
'logging.level',
|
|
'youtube.primarySubLanguages',
|
|
'jimaku.maxEntryResults',
|
|
'subsync.defaultMode',
|
|
'ankiConnect.behavior.autoUpdateNewCards',
|
|
'ankiConnect.knownWords.highlightEnabled',
|
|
'ankiConnect.knownWords.refreshMinutes',
|
|
'ankiConnect.knownWords.addMinedWordsImmediately',
|
|
'ankiConnect.knownWords.matchMode',
|
|
'ankiConnect.knownWords.decks',
|
|
'ankiConnect.nPlusOne.enabled',
|
|
'ankiConnect.nPlusOne.minSentenceWords',
|
|
'ankiConnect.fields.word',
|
|
'ankiConnect.fields.audio',
|
|
'ankiConnect.fields.image',
|
|
'ankiConnect.fields.sentence',
|
|
'ankiConnect.fields.miscInfo',
|
|
'ankiConnect.isLapis.sentenceCardModel',
|
|
'ankiConnect.isKiku.fieldGrouping',
|
|
]),
|
|
);
|
|
assert.deepEqual(diff.restartRequiredFields, []);
|
|
});
|
|
|
|
test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required', () => {
|
|
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
|
const next = deepCloneConfig(DEFAULT_CONFIG);
|
|
next.stats.serverPort = prev.stats.serverPort + 1;
|
|
next.ankiConnect.url = 'http://127.0.0.1:9999';
|
|
next.ankiConnect.ai.model = 'openrouter/new-model';
|
|
|
|
const diff = classifyConfigHotReloadDiff(prev, next);
|
|
|
|
assert.deepEqual(diff.hotReloadFields, []);
|
|
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
|
|
});
|
|
|
|
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.ai',
|
|
message: 'Expected boolean.',
|
|
value: { enabled: true },
|
|
fallback: false,
|
|
},
|
|
],
|
|
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 }]);
|
|
});
|