mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(config): align live save feedback with hot reload policy (#255)
This commit is contained in:
@@ -1,12 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import { buildConfigSettingsRegistry, getConfigValueAtPath } from '../../config/settings/registry';
|
||||
import {
|
||||
classifyConfigHotReloadDiff,
|
||||
createConfigHotReloadRuntime,
|
||||
type ConfigHotReloadRuntimeDeps,
|
||||
} from './config-hot-reload';
|
||||
|
||||
test('every LIVE settings field is classified without a restart warning', () => {
|
||||
for (const field of buildConfigSettingsRegistry(DEFAULT_CONFIG)) {
|
||||
if (field.restartBehavior !== 'hot-reload') continue;
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const segments = field.configPath.split('.');
|
||||
const leaf = segments.pop();
|
||||
assert.ok(leaf);
|
||||
const parent = segments.length ? getConfigValueAtPath(next, segments.join('.')) : next;
|
||||
assert.ok(parent && typeof parent === 'object', field.configPath);
|
||||
// The classifier compares structure; validation of field values is tested separately.
|
||||
Object.defineProperty(parent, leaf, {
|
||||
value: getConfigValueAtPath(next, field.configPath) === null ? 'changed' : null,
|
||||
enumerable: true,
|
||||
});
|
||||
const diff = classifyConfigHotReloadDiff(DEFAULT_CONFIG, next);
|
||||
assert.deepEqual(diff.restartRequiredFields, [], field.configPath);
|
||||
assert.ok(diff.hotReloadFields.length > 0, field.configPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('live notifications and subtitle generation changes preserve unrelated restart warnings', () => {
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
next.notifications.overlayPosition = 'top';
|
||||
next.subtitleGeneration.threads += 1;
|
||||
next.websocket.port += 1;
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(DEFAULT_CONFIG, next);
|
||||
assert.deepEqual(
|
||||
new Set(diff.hotReloadFields),
|
||||
new Set(['notifications.overlayPosition', 'subtitleGeneration.threads']),
|
||||
);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['websocket.port']);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
|
||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||
@@ -15,7 +50,7 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
|
||||
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['websocket.port']);
|
||||
});
|
||||
|
||||
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
|
||||
@@ -101,7 +136,11 @@ test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required'
|
||||
const diff = classifyConfigHotReloadDiff(prev, next);
|
||||
|
||||
assert.deepEqual(diff.hotReloadFields, []);
|
||||
assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']);
|
||||
assert.deepEqual(diff.restartRequiredFields, [
|
||||
'ankiConnect.url',
|
||||
'ankiConnect.ai.model',
|
||||
'stats.serverPort',
|
||||
]);
|
||||
});
|
||||
|
||||
test('config hot reload runtime debounces rapid watch events', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getConfigHotReloadField } from '../../config/hot-reload';
|
||||
import { type ReloadConfigStrictResult } from '../../config';
|
||||
import type { ConfigValidationWarning } from '../../types';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
@@ -33,10 +34,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[] {
|
||||
if (isEqual(prev, next)) {
|
||||
return [];
|
||||
@@ -52,60 +49,6 @@ function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[
|
||||
);
|
||||
}
|
||||
|
||||
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'ankiConnect.ai.enabled',
|
||||
'stats.toggleKey',
|
||||
'stats.markWatchedKey',
|
||||
'logging.level',
|
||||
'logging.rotation',
|
||||
'logging.files',
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku',
|
||||
'subsync',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'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',
|
||||
'ankiConnect.isSenren.fieldGrouping',
|
||||
'ankiConnect.lapisKiku.wordCardKind',
|
||||
] as const;
|
||||
|
||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||
for (const root of HOT_RELOAD_ROOTS) {
|
||||
if (pathStartsWith(path, root)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) {
|
||||
if (pathStartsWith(path, hotPath)) {
|
||||
return hotPath === 'jimaku' || hotPath === 'subsync' ? path : hotPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
|
||||
const hotReloadFields: string[] = [];
|
||||
const restartRequiredFields: string[] = [];
|
||||
@@ -113,33 +56,11 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
|
||||
const changedPaths = collectChangedPaths(prev, next);
|
||||
|
||||
for (const path of changedPaths) {
|
||||
const hotReloadField = hotReloadFieldForChangedPath(path);
|
||||
const hotReloadField = getConfigHotReloadField(path);
|
||||
if (hotReloadField) {
|
||||
hotReloadFieldSet.add(hotReloadField);
|
||||
}
|
||||
}
|
||||
|
||||
const keys = new Set([
|
||||
...(Object.keys(prev) as Array<keyof ResolvedConfig>),
|
||||
...(Object.keys(next) as Array<keyof ResolvedConfig>),
|
||||
]);
|
||||
|
||||
for (const key of keys) {
|
||||
if (
|
||||
key === 'subtitleStyle' ||
|
||||
key === 'keybindings' ||
|
||||
key === 'shortcuts' ||
|
||||
key === 'subtitleSidebar'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const changedPathsForKey = changedPaths.filter((path) => pathStartsWith(path, String(key)));
|
||||
const hasRestartRequiredChange = changedPathsForKey.some(
|
||||
(path) => !hotReloadFieldForChangedPath(path),
|
||||
);
|
||||
if (hasRestartRequiredChange) {
|
||||
restartRequiredFields.push(String(key));
|
||||
} else {
|
||||
restartRequiredFields.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user