Files
SubMiner/src/config/config.test.ts

1839 lines
56 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ConfigService, ConfigStartupParseError } from './service';
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from './definitions';
import { generateConfigTemplate } from './template';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
}
test('loads defaults when config is missing', () => {
const dir = makeTempDir();
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.annotationWebsocket.enabled, DEFAULT_CONFIG.annotationWebsocket.enabled);
assert.equal(config.annotationWebsocket.port, DEFAULT_CONFIG.annotationWebsocket.port);
assert.equal(config.texthooker.launchAtStartup, true);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.anilist.enabled, false);
assert.equal(config.anilist.characterDictionary.enabled, false);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
assert.equal(config.yomitan.externalProfilePath, '');
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, '');
assert.deepEqual(config.ankiConnect.ai, {
enabled: false,
model: '',
systemPrompt: '',
});
assert.equal(config.startupWarmups.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.discordPresence.enabled, false);
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(
config.subtitleStyle.fontFamily,
'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
);
assert.equal(config.subtitleStyle.fontWeight, '600');
assert.equal(config.subtitleStyle.lineHeight, 1.35);
assert.equal(config.subtitleStyle.letterSpacing, '-0.01em');
assert.equal(config.subtitleStyle.wordSpacing, 0);
assert.equal(config.subtitleStyle.fontKerning, 'normal');
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)');
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
assert.equal(
config.subtitleStyle.secondary.fontFamily,
'Inter, Noto Sans, Helvetica Neue, sans-serif',
);
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
assert.equal(
config.subtitleStyle.secondary.textShadow,
'0 2px 4px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.8), 0 0 16px rgba(0,0,0,0.55)',
);
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'rgba(20, 22, 34, 0.78)');
assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, '');
assert.equal(config.immersionTracking.batchSize, 25);
assert.equal(config.immersionTracking.flushIntervalMs, 500);
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
});
test('throws actionable startup parse error for malformed config at construction time', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(configPath, '{"websocket":', 'utf-8');
assert.throws(
() => new ConfigService(dir),
(error: unknown) => {
assert.ok(error instanceof ConfigStartupParseError);
assert.equal(error.path, configPath);
assert.ok(error.parseError.length > 0);
assert.ok(error.message.includes(configPath));
assert.ok(error.message.includes(error.parseError));
return true;
},
);
});
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"preserveLineBreaks": true
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.preserveLineBreaks, true);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"preserveLineBreaks": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.preserveLineBreaks,
DEFAULT_CONFIG.subtitleStyle.preserveLineBreaks,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.preserveLineBreaks'),
);
});
test('parses texthooker.launchAtStartup and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"texthooker": {
"launchAtStartup": false
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().texthooker.launchAtStartup, false);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"texthooker": {
"launchAtStartup": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().texthooker.launchAtStartup,
DEFAULT_CONFIG.texthooker.launchAtStartup,
);
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'texthooker.launchAtStartup'),
);
});
test('parses annotationWebsocket settings and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"annotationWebsocket": {
"enabled": false,
"port": 7788
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().annotationWebsocket.enabled, false);
assert.equal(validService.getConfig().annotationWebsocket.port, 7788);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"annotationWebsocket": {
"enabled": "yes",
"port": "bad"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().annotationWebsocket.enabled,
DEFAULT_CONFIG.annotationWebsocket.enabled,
);
assert.equal(
invalidService.getConfig().annotationWebsocket.port,
DEFAULT_CONFIG.annotationWebsocket.port,
);
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'annotationWebsocket.enabled'),
);
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'annotationWebsocket.port'),
);
});
test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnHover": true
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnHover, true);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnHover": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.autoPauseVideoOnHover,
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnHover,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnHover'),
);
});
test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnYomitanPopup": true
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup, true);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"autoPauseVideoOnYomitanPopup": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.autoPauseVideoOnYomitanPopup,
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnYomitanPopup,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnYomitanPopup'),
);
});
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenColor": "#c6a0f6"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenColor, '#c6a0f6');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenColor": "purple"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.hoverTokenColor,
DEFAULT_CONFIG.subtitleStyle.hoverTokenColor,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.hoverTokenColor'),
);
});
test('parses subtitleStyle.nameMatchColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"nameMatchColor": "#eed49f"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(
((validService.getConfig().subtitleStyle as unknown as Record<string, unknown>)
.nameMatchColor ?? null) as string | null,
'#eed49f',
);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"nameMatchColor": "pink"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
((invalidService.getConfig().subtitleStyle as unknown as Record<string, unknown>)
.nameMatchColor ?? null) as string | null,
'#f5bde6',
);
assert.ok(
invalidService.getWarnings().some((warning) => warning.path === 'subtitleStyle.nameMatchColor'),
);
});
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": "#363a4fd6"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": true
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
);
});
test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"nameMatchEnabled": false
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.nameMatchEnabled, false);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"nameMatchEnabled": "no"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.nameMatchEnabled,
DEFAULT_CONFIG.subtitleStyle.nameMatchEnabled,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.nameMatchEnabled'),
);
});
test('parses anilist.enabled and warns for invalid value', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"anilist": {
"enabled": "yes"
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.anilist.enabled, DEFAULT_CONFIG.anilist.enabled);
assert.ok(warnings.some((warning) => warning.path === 'anilist.enabled'));
service.patchRawConfig({ anilist: { enabled: true } });
assert.equal(service.getConfig().anilist.enabled, true);
});
test('parses anilist.characterDictionary config with clamping and enum validation', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"anilist": {
"characterDictionary": {
"enabled": true,
"refreshTtlHours": 0,
"maxLoaded": 1000,
"evictionPolicy": "remove",
"profileScope": "everywhere"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.anilist.characterDictionary.enabled, true);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
assert.ok(
warnings.some((warning) => warning.path === 'anilist.characterDictionary.refreshTtlHours'),
);
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.maxLoaded'));
assert.ok(
warnings.some((warning) => warning.path === 'anilist.characterDictionary.evictionPolicy'),
);
assert.ok(
warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'),
);
});
test('parses anilist.characterDictionary.collapsibleSections booleans and warns on invalid values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"anilist": {
"characterDictionary": {
"collapsibleSections": {
"description": true,
"characterInformation": "yes",
"voicedBy": true
}
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, true);
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, true);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'anilist.characterDictionary.collapsibleSections.characterInformation',
),
);
});
test('parses jellyfin remote control fields', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"jellyfin": {
"enabled": true,
"serverUrl": "http://127.0.0.1:8096",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
"autoAnnounce": true,
"remoteControlDeviceName": "SubMiner"
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.jellyfin.enabled, true);
assert.equal(config.jellyfin.serverUrl, 'http://127.0.0.1:8096');
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, true);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
});
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
const disabledDir = makeTempDir();
fs.writeFileSync(
path.join(disabledDir, 'config.jsonc'),
`{
"jellyfin": {
"enabled": false,
"remoteControlEnabled": false
}
}`,
'utf-8',
);
const disabledService = new ConfigService(disabledDir);
const disabledConfig = disabledService.getConfig();
assert.equal(disabledConfig.jellyfin.enabled, false);
assert.equal(disabledConfig.jellyfin.remoteControlEnabled, false);
assert.equal(
disabledService
.getWarnings()
.some(
(warning) =>
warning.path === 'jellyfin.enabled' || warning.path === 'jellyfin.remoteControlEnabled',
),
false,
);
const mixedDir = makeTempDir();
fs.writeFileSync(
path.join(mixedDir, 'config.jsonc'),
`{
"jellyfin": {
"enabled": true,
"remoteControlEnabled": false
}
}`,
'utf-8',
);
const mixedService = new ConfigService(mixedDir);
const mixedConfig = mixedService.getConfig();
assert.equal(mixedConfig.jellyfin.enabled, true);
assert.equal(mixedConfig.jellyfin.remoteControlEnabled, false);
assert.equal(
mixedService
.getWarnings()
.some(
(warning) =>
warning.path === 'jellyfin.enabled' || warning.path === 'jellyfin.remoteControlEnabled',
),
false,
);
});
test('parses startup warmup toggles and low-power mode', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"startupWarmups": {
"lowPowerMode": true,
"mecab": false,
"yomitanExtension": true,
"subtitleDictionaries": false,
"jellyfinRemoteSession": false
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.startupWarmups.lowPowerMode, true);
assert.equal(config.startupWarmups.mecab, false);
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, false);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
});
test('invalid startup warmup values warn and keep defaults', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"startupWarmups": {
"lowPowerMode": "yes",
"mecab": 1,
"yomitanExtension": null,
"subtitleDictionaries": "no",
"jellyfinRemoteSession": []
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.startupWarmups.lowPowerMode, DEFAULT_CONFIG.startupWarmups.lowPowerMode);
assert.equal(config.startupWarmups.mecab, DEFAULT_CONFIG.startupWarmups.mecab);
assert.equal(
config.startupWarmups.yomitanExtension,
DEFAULT_CONFIG.startupWarmups.yomitanExtension,
);
assert.equal(
config.startupWarmups.subtitleDictionaries,
DEFAULT_CONFIG.startupWarmups.subtitleDictionaries,
);
assert.equal(
config.startupWarmups.jellyfinRemoteSession,
DEFAULT_CONFIG.startupWarmups.jellyfinRemoteSession,
);
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.lowPowerMode'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.mecab'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.yomitanExtension'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.subtitleDictionaries'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.jellyfinRemoteSession'));
});
test('parses discordPresence fields and warns for invalid types', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"discordPresence": {
"enabled": true,
"updateIntervalMs": 3000,
"debounceMs": 250
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3000);
assert.equal(config.discordPresence.debounceMs, 250);
service.patchRawConfig({ discordPresence: { enabled: 'yes' as never } });
assert.equal(service.getConfig().discordPresence.enabled, DEFAULT_CONFIG.discordPresence.enabled);
assert.ok(service.getWarnings().some((warning) => warning.path === 'discordPresence.enabled'));
});
test('accepts immersion tracking config values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"immersionTracking": {
"enabled": false,
"dbPath": "/tmp/immersions/custom.sqlite",
"batchSize": 50,
"flushIntervalMs": 750,
"queueCap": 2000,
"payloadCapBytes": 512,
"maintenanceIntervalMs": 3600000,
"retention": {
"eventsDays": 14,
"telemetryDays": 45,
"dailyRollupsDays": 730,
"monthlyRollupsDays": 3650,
"vacuumIntervalDays": 14
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.immersionTracking.enabled, false);
assert.equal(config.immersionTracking.dbPath, '/tmp/immersions/custom.sqlite');
assert.equal(config.immersionTracking.batchSize, 50);
assert.equal(config.immersionTracking.flushIntervalMs, 750);
assert.equal(config.immersionTracking.queueCap, 2000);
assert.equal(config.immersionTracking.payloadCapBytes, 512);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 3_600_000);
assert.equal(config.immersionTracking.retention.eventsDays, 14);
assert.equal(config.immersionTracking.retention.telemetryDays, 45);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 730);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 3650);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 14);
});
test('falls back for invalid immersion tracking tuning values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"immersionTracking": {
"batchSize": 0,
"flushIntervalMs": 1,
"queueCap": 5,
"payloadCapBytes": 16,
"maintenanceIntervalMs": 1000,
"retention": {
"eventsDays": 0,
"telemetryDays": 99999,
"dailyRollupsDays": 0,
"monthlyRollupsDays": 999999,
"vacuumIntervalDays": 0
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.immersionTracking.batchSize, 25);
assert.equal(config.immersionTracking.flushIntervalMs, 500);
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.batchSize'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.flushIntervalMs'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.queueCap'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.payloadCapBytes'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.maintenanceIntervalMs'));
assert.ok(warnings.some((warning) => warning.path === 'immersionTracking.retention.eventsDays'));
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.telemetryDays'),
);
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.dailyRollupsDays'),
);
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.monthlyRollupsDays'),
);
assert.ok(
warnings.some((warning) => warning.path === 'immersionTracking.retention.vacuumIntervalDays'),
);
});
test('parses jsonc and warns/falls back on invalid value', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
// invalid websocket port
"websocket": { "port": "bad" }
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
});
test('accepts trailing commas in jsonc', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"websocket": {
"enabled": "auto",
"port": 7788,
},
"youtubeSubgen": {
"primarySubLanguages": ["ja", "en",],
},
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, 7788);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'en']);
});
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"logging": {
"level": "warn"
}
}`,
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'warn');
fs.writeFileSync(
configPath,
`{
"logging":`,
);
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSONC.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'warn');
});
test('reloadConfigStrict rejects invalid json and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'error');
fs.writeFileSync(configPath, '{"logging":');
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSON.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'error');
});
test('prefers config.jsonc over config.json when both exist', () => {
const dir = makeTempDir();
const jsonPath = path.join(dir, 'config.json');
const jsoncPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(jsonPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
fs.writeFileSync(
jsoncPath,
`{
"logging": {
"level": "warn"
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'warn');
assert.equal(service.getConfigPath(), jsoncPath);
});
test('reloadConfigStrict parse failure does not mutate raw config or warnings', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"logging": {
"level": "warn"
},
"websocket": {
"port": "bad"
}
}`,
);
const service = new ConfigService(dir);
const beforePath = service.getConfigPath();
const beforeConfig = service.getConfig();
const beforeRaw = service.getRawConfig();
const beforeWarnings = service.getWarnings();
fs.writeFileSync(configPath, '{"logging":');
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
assert.equal(service.getConfigPath(), beforePath);
assert.deepEqual(service.getConfig(), beforeConfig);
assert.deepEqual(service.getRawConfig(), beforeRaw);
assert.deepEqual(service.getWarnings(), beforeWarnings);
});
test('warning emission order is deterministic across reloads', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"unknownFeature": true,
"websocket": {
"enabled": "sometimes",
"port": -1
},
"annotationWebsocket": {
"enabled": "sometimes",
"port": -1
},
"logging": {
"level": "trace"
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const firstWarnings = service.getWarnings();
service.reloadConfig();
const secondWarnings = service.getWarnings();
assert.deepEqual(secondWarnings, firstWarnings);
assert.deepEqual(
firstWarnings.map((warning) => warning.path),
[
'unknownFeature',
'websocket.enabled',
'websocket.port',
'annotationWebsocket.enabled',
'annotationWebsocket.port',
'logging.level',
],
);
});
test('accepts valid logging.level', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"level": "warn"
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.logging.level, 'warn');
});
test('falls back for invalid logging.level and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"level": "trace"
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level);
assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
});
test('warns and ignores unknown top-level config keys', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"websocket": {
"port": 7788
},
"unknownFeatureFlag": {
"enabled": true
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.websocket.port, 7788);
assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
});
test('parses global shortcuts and startup settings', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ai": {
"enabled": true,
"apiKeyCommand": "pass show subminer/ai",
"model": "openai/gpt-4o-mini"
},
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
"openJimaku": "Ctrl+Alt+J"
},
"youtubeSubgen": {
"primarySubLanguages": ["ja", "jpn", "jp"],
"whisperVadModel": "/models/vad.bin",
"whisperThreads": 12,
"fixWithAi": true
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ai.enabled, true);
assert.equal(config.ai.apiKeyCommand, 'pass show subminer/ai');
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
assert.equal(config.youtubeSubgen.whisperVadModel, '/models/vad.bin');
assert.equal(config.youtubeSubgen.whisperThreads, 12);
assert.equal(config.youtubeSubgen.fixWithAi, true);
});
test('parses controller settings with logical bindings and tuning knobs', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"enabled": true,
"preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
"preferredGamepadLabel": "Xbox Wireless Controller",
"smoothScroll": false,
"scrollPixelsPerSecond": 1440,
"horizontalJumpPixels": 180,
"stickDeadzone": 0.3,
"triggerInputMode": "analog",
"triggerDeadzone": 0.4,
"repeatDelayMs": 220,
"repeatIntervalMs": 70,
"buttonIndices": {
"select": 6,
"leftStickPress": 9,
"rightStickPress": 10
},
"bindings": {
"toggleLookup": "buttonWest",
"closeLookup": "buttonEast",
"toggleKeyboardOnlyMode": "buttonNorth",
"mineCard": "buttonSouth",
"quitMpv": "select",
"previousAudio": "leftShoulder",
"nextAudio": "rightShoulder",
"playCurrentAudio": "none",
"toggleMpvPause": "leftStickPress",
"leftStickHorizontal": "rightStickX",
"leftStickVertical": "rightStickY",
"rightStickHorizontal": "leftStickX",
"rightStickVertical": "leftStickY"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.controller.enabled, true);
assert.equal(
config.controller.preferredGamepadId,
'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)',
);
assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller');
assert.equal(config.controller.smoothScroll, false);
assert.equal(config.controller.scrollPixelsPerSecond, 1440);
assert.equal(config.controller.horizontalJumpPixels, 180);
assert.equal(config.controller.stickDeadzone, 0.3);
assert.equal(config.controller.triggerInputMode, 'analog');
assert.equal(config.controller.triggerDeadzone, 0.4);
assert.equal(config.controller.repeatDelayMs, 220);
assert.equal(config.controller.repeatIntervalMs, 70);
assert.equal(config.controller.buttonIndices.select, 6);
assert.equal(config.controller.buttonIndices.leftStickPress, 9);
assert.equal(config.controller.bindings.toggleLookup, 'buttonWest');
assert.equal(config.controller.bindings.quitMpv, 'select');
assert.equal(config.controller.bindings.playCurrentAudio, 'none');
assert.equal(config.controller.bindings.toggleMpvPause, 'leftStickPress');
assert.equal(config.controller.bindings.leftStickHorizontal, 'rightStickX');
assert.equal(config.controller.bindings.rightStickVertical, 'leftStickY');
});
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"scrollPixelsPerSecond": 0.5,
"horizontalJumpPixels": 0.2,
"repeatDelayMs": 0.9,
"repeatIntervalMs": 0.1
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
});
test('controller button index config rejects fractional values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"buttonIndices": {
"select": 6.5,
"leftStickPress": 9.1
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
assert.equal(
config.controller.buttonIndices.leftStickPress,
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
);
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
true,
);
});
test('runtime options registry is centralized', () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, [
'anki.autoUpdateNewCards',
'subtitle.annotation.nPlusOne',
'subtitle.annotation.jlpt',
'subtitle.annotation.frequency',
'anki.nPlusOneMatchMode',
'anki.kikuFieldGrouping',
]);
});
test('validates ankiConnect n+1 behavior values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": "yes",
"refreshMinutes": -5
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
);
assert.equal(
config.ankiConnect.nPlusOne.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.refreshMinutes'));
});
test('accepts valid ankiConnect n+1 behavior values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
"refreshMinutes": 120
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 120);
});
test('validates ankiConnect n+1 minimum sentence word count', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"minSentenceWords": 0
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.minSentenceWords,
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords'));
});
test('accepts valid ankiConnect n+1 minimum sentence word count', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"minSentenceWords": 4
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.minSentenceWords, 4);
});
test('validates ankiConnect n+1 match mode values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"matchMode": "bad-mode"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(
config.ankiConnect.nPlusOne.matchMode,
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.matchMode'));
});
test('accepts valid ankiConnect n+1 match mode values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"matchMode": "surface"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface');
});
test('validates ankiConnect n+1 color values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "not-a-color",
"knownWord": 123
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
assert.equal(
config.ankiConnect.nPlusOne.knownWord,
DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.knownWord'));
});
test('accepts valid ankiConnect n+1 color values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "#c6a0f6",
"knownWord": "#a6da95"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
assert.equal(config.ankiConnect.nPlusOne.knownWord, '#a6da95');
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"behavior": {
"nPlusOneHighlightEnabled": true,
"nPlusOneRefreshMinutes": 90,
"nPlusOneMatchMode": "surface"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.refreshMinutes, 90);
assert.equal(config.ankiConnect.nPlusOne.matchMode, 'surface');
assert.ok(
warnings.some(
(warning) =>
warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled' ||
warning.path === 'ankiConnect.behavior.nPlusOneRefreshMinutes' ||
warning.path === 'ankiConnect.behavior.nPlusOneMatchMode',
),
);
});
test('accepts top-level ai config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ai": {
"enabled": true,
"apiKey": "abc123",
"apiKeyCommand": "pass show subminer/ai",
"baseUrl": "https://openrouter.ai/api",
"model": "openrouter/test-model",
"systemPrompt": "Return only fixed subtitles.",
"requestTimeoutMs": 20000
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ai.enabled, true);
assert.equal(config.ai.apiKey, 'abc123');
assert.equal(config.ai.apiKeyCommand, 'pass show subminer/ai');
assert.equal(config.ai.baseUrl, 'https://openrouter.ai/api');
assert.equal(config.ai.model, 'openrouter/test-model');
assert.equal(config.ai.systemPrompt, 'Return only fixed subtitles.');
assert.equal(config.ai.requestTimeoutMs, 20000);
});
test('accepts per-feature ai overrides for anki and youtube subtitle generation', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ai": {
"enabled": true,
"apiKeyCommand": "pass show subminer/ai",
"baseUrl": "https://openrouter.ai/api",
"model": "openrouter/shared-model",
"systemPrompt": "Legacy shared prompt."
},
"ankiConnect": {
"ai": {
"enabled": true,
"model": "openrouter/anki-model",
"systemPrompt": "Translate mined sentence text."
}
},
"youtubeSubgen": {
"ai": {
"model": "openrouter/subgen-model",
"systemPrompt": "Fix subtitle mistakes only."
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ai.enabled, true);
assert.equal(config.ai.model, 'openrouter/shared-model');
assert.equal(config.ankiConnect.ai.enabled, true);
assert.equal(config.ankiConnect.ai.model, 'openrouter/anki-model');
assert.equal(config.ankiConnect.ai.systemPrompt, 'Translate mined sentence text.');
assert.equal(config.youtubeSubgen.ai.model, 'openrouter/subgen-model');
assert.equal(config.youtubeSubgen.ai.systemPrompt, 'Fix subtitle mistakes only.');
});
test('warns and falls back when ankiConnect.ai override values are invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"ai": {
"enabled": "yes",
"model": 123,
"systemPrompt": true
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.ai, DEFAULT_CONFIG.ankiConnect.ai);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.model'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.ai.systemPrompt'));
});
test('falls back and warns when legacy ankiConnect migration values are invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"audioField": 123,
"generateAudio": "yes",
"imageType": "gif",
"imageQuality": -1,
"mediaInsertMode": "middle",
"notificationType": "toast"
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.fields.audio, DEFAULT_CONFIG.ankiConnect.fields.audio);
assert.equal(
config.ankiConnect.media.generateAudio,
DEFAULT_CONFIG.ankiConnect.media.generateAudio,
);
assert.equal(config.ankiConnect.media.imageType, DEFAULT_CONFIG.ankiConnect.media.imageType);
assert.equal(
config.ankiConnect.media.imageQuality,
DEFAULT_CONFIG.ankiConnect.media.imageQuality,
);
assert.equal(
config.ankiConnect.behavior.mediaInsertMode,
DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode,
);
assert.equal(
config.ankiConnect.behavior.notificationType,
DEFAULT_CONFIG.ankiConnect.behavior.notificationType,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.audioField'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.generateAudio'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageType'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageQuality'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.mediaInsertMode'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.notificationType'));
});
test('maps valid legacy ankiConnect values to equivalent modern config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"audioField": "AudioLegacy",
"imageField": "ImageLegacy",
"generateAudio": false,
"imageType": "avif",
"imageFormat": "webp",
"imageQuality": 88,
"mediaInsertMode": "prepend",
"notificationType": "both",
"autoUpdateNewCards": false
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.ankiConnect.fields.audio, 'AudioLegacy');
assert.equal(config.ankiConnect.fields.image, 'ImageLegacy');
assert.equal(config.ankiConnect.media.generateAudio, false);
assert.equal(config.ankiConnect.media.imageType, 'avif');
assert.equal(config.ankiConnect.media.imageFormat, 'webp');
assert.equal(config.ankiConnect.media.imageQuality, 88);
assert.equal(config.ankiConnect.behavior.mediaInsertMode, 'prepend');
assert.equal(config.ankiConnect.behavior.notificationType, 'both');
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, false);
});
test('ignores deprecated isLapis sentence-card field overrides', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"isLapis": {
"enabled": true,
"sentenceCardModel": "Japanese sentences",
"sentenceCardSentenceField": "CustomSentence",
"sentenceCardAudioField": "CustomAudio"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
const lapisConfig = config.ankiConnect.isLapis as Record<string, unknown>;
assert.equal(lapisConfig.sentenceCardSentenceField, undefined);
assert.equal(lapisConfig.sentenceCardAudioField, undefined);
assert.ok(
warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardSentenceField'),
);
assert.ok(
warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardAudioField'),
);
});
test('accepts valid ankiConnect n+1 deck list', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"decks": ["Deck One", "Deck Two"]
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']);
});
test('accepts valid ankiConnect tags list', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"tags": ["SubMiner", "Mining"]
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']);
});
test('falls back to default when ankiConnect tags list is invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"tags": ["SubMiner", 123]
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags'));
});
test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"nPlusOne": {
"decks": "not-an-array"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.nPlusOne.decks, []);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks'));
});
test('template generator includes known keys', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
assert.match(output, /"ai":/);
assert.match(output, /"ankiConnect":/);
assert.match(output, /"controller":/);
assert.match(output, /"logging":/);
assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
assert.match(output, /"startupWarmups":/);
assert.match(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"nPlusOne"\s*:\s*\{/);
assert.match(output, /"nPlusOne": "#c6a0f6"/);
assert.match(output, /"knownWord": "#a6da95"/);
assert.match(output, /"minSentenceWords": 3/);
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match(
output,
/"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
);
assert.match(
output,
/"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
);
assert.match(
output,
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
);
assert.match(
output,
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
);
assert.match(
output,
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
);
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match(
output,
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
);
assert.match(
output,
/"enabled": false,? \/\/ Enable AI provider usage for Anki translation\/enrichment flows\. Values: true \| false/,
);
assert.match(
output,
/"model": "",? \/\/ Optional model override for Anki AI translation\/enrichment flows\./,
);
assert.match(
output,
/"enabled": false,? \/\/ Enable shared OpenAI-compatible AI provider features\. Values: true \| false/,
);
assert.match(
output,
/"fixWithAi": false,? \/\/ Use shared AI provider to post-process whisper-generated YouTube subtitles\. Values: true \| false/,
);
assert.match(
output,
/"systemPrompt": "",? \/\/ Optional system prompt override for YouTube subtitle AI post-processing\./,
);
assert.doesNotMatch(output, /"mode": "automatic"/);
assert.match(
output,
/"whisperThreads": 4,? \/\/ Thread count passed to whisper\.cpp subtitle generation runs\./,
);
assert.match(
output,
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
);
});