fix(config): align live save feedback with hot reload policy (#255)

This commit is contained in:
2026-09-20 19:51:45 -07:00
committed by GitHub
parent 4f0762e840
commit 76e43d7e6d
8 changed files with 205 additions and 137 deletions
+41 -2
View File
@@ -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', () => {
+4 -83
View File
@@ -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);
}
}