mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 06:12:06 -07:00
test: stabilize bun coverage reporting
This commit is contained in:
@@ -85,13 +85,15 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now(),
|
||||
refreshedAtMs: 120_000,
|
||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
@@ -102,12 +104,20 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
);
|
||||
|
||||
manager.startLifecycle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
getMsUntilNextRefresh: () => number;
|
||||
}
|
||||
).getMsUntilNextRefresh() > 0,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
@@ -124,13 +134,15 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now() - 61_000,
|
||||
refreshedAtMs: 59_000,
|
||||
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
@@ -156,6 +168,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
assert.equal(manager.isKnownWord('犬'), true);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
|
||||
@@ -4,35 +4,41 @@ import test from 'node:test';
|
||||
import { PollingRunner } from './polling';
|
||||
|
||||
test('polling runner records newly added cards after initialization', async () => {
|
||||
const originalDateNow = Date.now;
|
||||
const recordedCards: number[] = [];
|
||||
let tracked = new Set<number>();
|
||||
const responses = [
|
||||
[10, 11],
|
||||
[10, 11, 12, 13],
|
||||
];
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
findNotes: async () => responses.shift() ?? [],
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
getTrackedNoteIds: () => tracked,
|
||||
setTrackedNoteIds: (noteIds) => {
|
||||
tracked = noteIds;
|
||||
},
|
||||
showStatusNotification: () => undefined,
|
||||
logDebug: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
});
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
findNotes: async () => responses.shift() ?? [],
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
getTrackedNoteIds: () => tracked,
|
||||
setTrackedNoteIds: (noteIds) => {
|
||||
tracked = noteIds;
|
||||
},
|
||||
showStatusNotification: () => undefined,
|
||||
logDebug: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
});
|
||||
|
||||
await runner.pollOnce();
|
||||
await runner.pollOnce();
|
||||
await runner.pollOnce();
|
||||
await runner.pollOnce();
|
||||
|
||||
assert.deepEqual(recordedCards, [2]);
|
||||
assert.deepEqual(recordedCards, [2]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ import { resolve } from 'node:path';
|
||||
|
||||
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
||||
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
test('ci workflow lints changelog fragments', () => {
|
||||
assert.match(ciWorkflow, /bun run changelog:lint/);
|
||||
@@ -18,3 +22,17 @@ test('ci workflow checks pull requests for required changelog fragments', () =>
|
||||
test('ci workflow verifies generated config examples stay in sync', () => {
|
||||
assert.match(ciWorkflow, /bun run verify:config-example/);
|
||||
});
|
||||
|
||||
test('package scripts expose a sharded maintained source coverage lane with lcov output', () => {
|
||||
assert.equal(
|
||||
packageJson.scripts['test:coverage:src'],
|
||||
'bun run scripts/run-coverage-lane.ts bun-src-full --coverage-dir coverage/test-src',
|
||||
);
|
||||
});
|
||||
|
||||
test('ci workflow runs the maintained source coverage lane and uploads lcov output', () => {
|
||||
assert.match(ciWorkflow, /name: Coverage suite \(maintained source lane\)/);
|
||||
assert.match(ciWorkflow, /run: bun run test:coverage:src/);
|
||||
assert.match(ciWorkflow, /name: Upload coverage artifact/);
|
||||
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY, deepMergeRawConfig } from './definitions';
|
||||
import { generateConfigTemplate } from './template';
|
||||
|
||||
function makeTempDir(): string {
|
||||
@@ -1032,6 +1032,61 @@ test('reloadConfigStrict parse failure does not mutate raw config or warnings',
|
||||
assert.deepEqual(service.getWarnings(), beforeWarnings);
|
||||
});
|
||||
|
||||
test('SM-012 config paths do not use JSON serialize-clone helpers', () => {
|
||||
const definitionsSource = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/config/definitions.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
const serviceSource = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
|
||||
|
||||
assert.equal(definitionsSource.includes('JSON.parse(JSON.stringify('), false);
|
||||
assert.equal(serviceSource.includes('JSON.parse(JSON.stringify('), false);
|
||||
});
|
||||
|
||||
test('getRawConfig returns a detached clone', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"tags": ["SubMiner"]
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const raw = service.getRawConfig();
|
||||
raw.ankiConnect!.tags!.push('mutated');
|
||||
|
||||
assert.deepEqual(service.getRawConfig().ankiConnect?.tags, ['SubMiner']);
|
||||
});
|
||||
|
||||
test('deepMergeRawConfig returns a detached merged clone', () => {
|
||||
const base = {
|
||||
ankiConnect: {
|
||||
tags: ['SubMiner'],
|
||||
behavior: {
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const merged = deepMergeRawConfig(base, {
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
autoUpdateNewCards: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
merged.ankiConnect!.tags!.push('mutated');
|
||||
merged.ankiConnect!.behavior!.autoUpdateNewCards = true;
|
||||
|
||||
assert.deepEqual(base.ankiConnect?.tags, ['SubMiner']);
|
||||
assert.equal(base.ankiConnect?.behavior?.autoUpdateNewCards, true);
|
||||
});
|
||||
|
||||
test('warning emission order is deterministic across reloads', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
|
||||
@@ -84,11 +84,11 @@ export const CONFIG_OPTION_REGISTRY = [
|
||||
export { CONFIG_TEMPLATE_SECTIONS };
|
||||
|
||||
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as ResolvedConfig;
|
||||
return structuredClone(config);
|
||||
}
|
||||
|
||||
export function deepMergeRawConfig(base: RawConfig, patch: RawConfig): RawConfig {
|
||||
const clone = JSON.parse(JSON.stringify(base)) as Record<string, unknown>;
|
||||
const clone = structuredClone(base) as Record<string, unknown>;
|
||||
const patchObject = patch as Record<string, unknown>;
|
||||
|
||||
const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): void => {
|
||||
|
||||
@@ -61,7 +61,7 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
getRawConfig(): RawConfig {
|
||||
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
|
||||
return structuredClone(this.rawConfig);
|
||||
}
|
||||
|
||||
getWarnings(): ConfigValidationWarning[] {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
pruneRawRetention,
|
||||
pruneRollupRetention,
|
||||
runOptimizeMaintenance,
|
||||
toMonthKey,
|
||||
} from './maintenance';
|
||||
import { ensureSchema } from './storage';
|
||||
|
||||
@@ -31,9 +30,9 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const nowMs = 90 * 86_400_000;
|
||||
const staleEndedAtMs = nowMs - 40 * 86_400_000;
|
||||
const keptEndedAtMs = nowMs - 5 * 86_400_000;
|
||||
const nowMs = 1_000_000_000;
|
||||
const staleEndedAtMs = nowMs - 400_000_000;
|
||||
const keptEndedAtMs = nowMs - 50_000_000;
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO imm_videos (
|
||||
@@ -49,14 +48,14 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, ${nowMs - 2 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}),
|
||||
(2, ${nowMs - 12 * 60 * 60 * 1000}, 0, 0, ${nowMs}, ${nowMs});
|
||||
(1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}),
|
||||
(2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs});
|
||||
`);
|
||||
|
||||
const result = pruneRawRetention(db, nowMs, {
|
||||
eventsRetentionMs: 7 * 86_400_000,
|
||||
telemetryRetentionMs: 1 * 86_400_000,
|
||||
sessionsRetentionMs: 30 * 86_400_000,
|
||||
eventsRetentionMs: 120_000_000,
|
||||
telemetryRetentionMs: 80_000_000,
|
||||
sessionsRetentionMs: 300_000_000,
|
||||
});
|
||||
|
||||
const remainingSessions = db
|
||||
@@ -88,9 +87,9 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const nowMs = Date.UTC(2026, 2, 16, 12, 0, 0, 0);
|
||||
const oldDay = Math.floor((nowMs - 90 * 86_400_000) / 86_400_000);
|
||||
const oldMonth = toMonthKey(nowMs - 400 * 86_400_000);
|
||||
const nowMs = 1_000_000_000;
|
||||
const oldDay = Math.floor((nowMs - 200_000_000) / 86_400_000);
|
||||
const oldMonth = 196912;
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO imm_videos (
|
||||
@@ -101,12 +100,12 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
INSERT INTO imm_sessions (
|
||||
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'session-1', 1, ${nowMs - 90 * 86_400_000}, ${nowMs - 90 * 86_400_000 + 1_000}, 2, ${nowMs}, ${nowMs}
|
||||
1, 'session-1', 1, ${nowMs - 200_000_000}, ${nowMs - 199_999_000}, 2, ${nowMs}, ${nowMs}
|
||||
);
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, ${nowMs - 90 * 86_400_000}, 0, 0, ${nowMs}, ${nowMs}
|
||||
1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}
|
||||
);
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
@@ -123,9 +122,9 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
`);
|
||||
|
||||
pruneRawRetention(db, nowMs, {
|
||||
eventsRetentionMs: 7 * 86_400_000,
|
||||
telemetryRetentionMs: 30 * 86_400_000,
|
||||
sessionsRetentionMs: 30 * 86_400_000,
|
||||
eventsRetentionMs: 120_000_000,
|
||||
telemetryRetentionMs: 120_000_000,
|
||||
sessionsRetentionMs: 120_000_000,
|
||||
});
|
||||
|
||||
const rollupsAfterRawPrune = db
|
||||
@@ -139,8 +138,8 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
assert.equal(monthlyAfterRawPrune?.total, 1);
|
||||
|
||||
const rollupPrune = pruneRollupRetention(db, nowMs, {
|
||||
dailyRollupRetentionMs: 30 * 86_400_000,
|
||||
monthlyRollupRetentionMs: 365 * 86_400_000,
|
||||
dailyRollupRetentionMs: 120_000_000,
|
||||
monthlyRollupRetentionMs: 1,
|
||||
});
|
||||
|
||||
const rollupsAfterRollupPrune = db
|
||||
|
||||
@@ -61,19 +61,19 @@ export function pruneRawRetention(
|
||||
const sessionsCutoff = nowMs - policy.sessionsRetentionMs;
|
||||
|
||||
const deletedSessionEvents = (
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff) as {
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(toDbMs(eventCutoff)) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes;
|
||||
const deletedTelemetryRows = (
|
||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff) as {
|
||||
changes: number;
|
||||
}
|
||||
db
|
||||
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||
.run(toDbMs(telemetryCutoff)) as { changes: number }
|
||||
).changes;
|
||||
const deletedEndedSessions = (
|
||||
db
|
||||
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
|
||||
.run(sessionsCutoff) as { changes: number }
|
||||
.run(toDbMs(sessionsCutoff)) as { changes: number }
|
||||
).changes;
|
||||
|
||||
return {
|
||||
|
||||
@@ -263,7 +263,9 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
});
|
||||
const expectedPostedPayload = JSON.parse(JSON.stringify(expectedPayload));
|
||||
const expectedPostedPayload = Object.fromEntries(
|
||||
Object.entries(structuredClone(expectedPayload)).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
|
||||
const ok = await service.reportProgress({
|
||||
itemId: 'movie-2',
|
||||
|
||||
@@ -1255,7 +1255,7 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return JSON.parse(JSON.stringify(optionsFull));
|
||||
return structuredClone(optionsFull);
|
||||
}
|
||||
if (script.includes('setAllSettings')) {
|
||||
return true;
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
|
||||
test('SM-012 controller config update path does not use JSON serialize-clone helpers', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/main/controller-config-update.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
{
|
||||
@@ -52,3 +62,16 @@ test('applyControllerConfigUpdate merges buttonIndices while replacing only upda
|
||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 });
|
||||
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate detaches updated binding values from the patch object', () => {
|
||||
const update = {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button' as const, buttonIndex: 7 },
|
||||
},
|
||||
};
|
||||
|
||||
const next = applyControllerConfigUpdate(undefined, update);
|
||||
update.bindings.toggleLookup.buttonIndex = 99;
|
||||
|
||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export function applyControllerConfigUpdate(
|
||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
||||
>) {
|
||||
if (value === undefined) continue;
|
||||
(nextBindings as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(value));
|
||||
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
|
||||
}
|
||||
|
||||
nextController.bindings = nextBindings;
|
||||
|
||||
@@ -21,7 +21,7 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
||||
now: () => 7,
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', episode: 1 });
|
||||
assert.deepEqual(deps.nextReady(), { key: 'k', title: 't', season: null, episode: 1 });
|
||||
deps.refreshRetryQueueState();
|
||||
deps.setLastAttemptAt(1);
|
||||
deps.setLastError('x');
|
||||
|
||||
@@ -84,51 +84,63 @@ test('findAnilistSetupDeepLinkArgvUrl returns null when missing', () => {
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl persists token and closes window for callback URL', () => {
|
||||
const originalDateNow = Date.now;
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, [
|
||||
'save:saved-token',
|
||||
'cache:saved-token',
|
||||
'state:ok',
|
||||
'opened:false',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, [
|
||||
'save:saved-token',
|
||||
'cache:saved-token',
|
||||
'state:ok',
|
||||
'opened:false',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL', () => {
|
||||
const originalDateNow = Date.now;
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, [
|
||||
'save:saved-token',
|
||||
'cache:saved-token',
|
||||
'state:ok',
|
||||
'opened:false',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, [
|
||||
'save:saved-token',
|
||||
'cache:saved-token',
|
||||
'state:ok',
|
||||
'opened:false',
|
||||
'success',
|
||||
'close',
|
||||
]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
|
||||
|
||||
@@ -36,6 +36,13 @@ test('release workflow verifies generated config examples before packaging artif
|
||||
assert.match(releaseWorkflow, /bun run verify:config-example/);
|
||||
});
|
||||
|
||||
test('release quality gate runs the maintained source coverage lane and uploads lcov output', () => {
|
||||
assert.match(releaseWorkflow, /name: Coverage suite \(maintained source lane\)/);
|
||||
assert.match(releaseWorkflow, /run: bun run test:coverage:src/);
|
||||
assert.match(releaseWorkflow, /name: Upload coverage artifact/);
|
||||
assert.match(releaseWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||
});
|
||||
|
||||
test('release build jobs install and cache stats dependencies before packaging', () => {
|
||||
assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/);
|
||||
assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/);
|
||||
|
||||
64
src/runtime-options.test.ts
Normal file
64
src/runtime-options.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
|
||||
test('SM-012 runtime options path does not use JSON serialize-clone helpers', () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), 'src/runtime-options.ts'), 'utf-8');
|
||||
assert.equal(source.includes('JSON.parse(JSON.stringify('), false);
|
||||
});
|
||||
|
||||
test('RuntimeOptionsManager returns detached effective Anki config copies', () => {
|
||||
const baseConfig = {
|
||||
deck: 'Mining',
|
||||
note: 'Sentence',
|
||||
tags: ['SubMiner'],
|
||||
behavior: {
|
||||
autoUpdateNewCards: true,
|
||||
updateIntervalMs: 5000,
|
||||
},
|
||||
fieldMapping: {
|
||||
sentence: 'Sentence',
|
||||
meaning: 'Meaning',
|
||||
audio: 'Audio',
|
||||
image: 'Image',
|
||||
context: 'Context',
|
||||
source: 'Source',
|
||||
definition: 'Definition',
|
||||
sequence: 'Sequence',
|
||||
contextSecondary: 'ContextSecondary',
|
||||
contextTertiary: 'ContextTertiary',
|
||||
primarySpelling: 'PrimarySpelling',
|
||||
primaryReading: 'PrimaryReading',
|
||||
wordSpelling: 'WordSpelling',
|
||||
wordReading: 'WordReading',
|
||||
},
|
||||
duplicates: {
|
||||
mode: 'note' as const,
|
||||
scope: 'deck' as const,
|
||||
allowedFields: [],
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
model: '',
|
||||
systemPrompt: '',
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new RuntimeOptionsManager(
|
||||
() => structuredClone(baseConfig),
|
||||
{
|
||||
applyAnkiPatch: () => undefined,
|
||||
onOptionsChanged: () => undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const effective = manager.getEffectiveAnkiConnectConfig();
|
||||
effective.tags!.push('mutated');
|
||||
effective.behavior!.autoUpdateNewCards = false;
|
||||
|
||||
assert.deepEqual(baseConfig.tags, ['SubMiner']);
|
||||
assert.equal(baseConfig.behavior.autoUpdateNewCards, true);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from './config';
|
||||
type RuntimeOverrides = Record<string, unknown>;
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function getPathValue(source: Record<string, unknown>, path: string): unknown {
|
||||
|
||||
Reference in New Issue
Block a user