mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
fix: avoid merged dictionary rebuilds on revisits
This commit is contained in:
@@ -2206,6 +2206,7 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary
|
||||
await runtime.getOrCreateCurrentSnapshot();
|
||||
|
||||
const merged = await runtime.buildMergedDictionary([21, 130298]);
|
||||
const mergedReordered = await runtime.buildMergedDictionary([130298, 21]);
|
||||
const index = JSON.parse(readStoredZipEntry(merged.zipPath, 'index.json').toString('utf8')) as {
|
||||
title: string;
|
||||
};
|
||||
@@ -2228,6 +2229,7 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary
|
||||
|
||||
assert.equal(index.title, 'SubMiner Character Dictionary');
|
||||
assert.equal(merged.entryCount >= 2, true);
|
||||
assert.equal(merged.revision, mergedReordered.revision);
|
||||
assert.ok(frieren);
|
||||
assert.ok(alpha);
|
||||
assert.equal((frieren[5][0] as { type?: string }).type, 'structured-content');
|
||||
|
||||
@@ -1980,6 +1980,16 @@ function buildMergedRevision(mediaIds: number[], snapshots: CharacterDictionaryS
|
||||
return hash.digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function normalizeMergedMediaIds(mediaIds: number[]): number[] {
|
||||
return [
|
||||
...new Set(
|
||||
mediaIds
|
||||
.filter((mediaId) => Number.isFinite(mediaId) && mediaId > 0)
|
||||
.map((mediaId) => Math.floor(mediaId)),
|
||||
),
|
||||
].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): {
|
||||
getOrCreateCurrentSnapshot: (
|
||||
targetPath?: string,
|
||||
@@ -2154,9 +2164,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
);
|
||||
},
|
||||
buildMergedDictionary: async (mediaIds: number[]) => {
|
||||
const normalizedMediaIds = mediaIds
|
||||
.filter((mediaId) => Number.isFinite(mediaId) && mediaId > 0)
|
||||
.map((mediaId) => Math.floor(mediaId));
|
||||
const normalizedMediaIds = normalizeMergedMediaIds(mediaIds);
|
||||
const snapshotResults = await Promise.all(
|
||||
normalizedMediaIds.map((mediaId) => getOrCreateSnapshot(mediaId)),
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ test('auto sync skips rebuild/import on unchanged revisit when merged dictionary
|
||||
assert.deepEqual(imports, ['/tmp/merged.zip']);
|
||||
});
|
||||
|
||||
test('auto sync rebuilds merged dictionary when MRU order changes', async () => {
|
||||
test('auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const sequence = [1, 2, 1];
|
||||
const mergedBuilds: number[][] = [];
|
||||
@@ -207,8 +207,14 @@ test('auto sync rebuilds merged dictionary when MRU order changes', async () =>
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[1], [2, 1], [1, 2]]);
|
||||
assert.ok(deleted.length >= 2);
|
||||
assert.deepEqual(mergedBuilds, [[1], [2, 1]]);
|
||||
assert.equal(deleted.length, 1);
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [1, 2]);
|
||||
});
|
||||
|
||||
test('auto sync evicts least recently used media from merged set', async () => {
|
||||
@@ -276,6 +282,137 @@ test('auto sync evicts least recently used media from merged set', async () => {
|
||||
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
|
||||
});
|
||||
|
||||
test('auto sync keeps revisited media retained when a new title is added afterward', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const sequence = [1, 2, 3, 1, 4, 1];
|
||||
const mergedBuilds: number[][] = [];
|
||||
let runIndex = 0;
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => {
|
||||
const mediaId = sequence[Math.min(runIndex, sequence.length - 1)]!;
|
||||
runIndex += 1;
|
||||
return {
|
||||
mediaId,
|
||||
mediaTitle: `Title ${mediaId}`,
|
||||
entryCount: 10,
|
||||
fromCache: true,
|
||||
updatedAt: mediaId,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
mergedBuilds.push([...mediaIds]);
|
||||
const revision = `rev-${mediaIds.join('-')}`;
|
||||
return {
|
||||
zipPath: `/tmp/${revision}.zip`,
|
||||
revision,
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: mediaIds.length * 10,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
importedRevision = path.basename(zipPath, '.zip');
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => {
|
||||
importedRevision = null;
|
||||
return true;
|
||||
},
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => Date.now(),
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(mergedBuilds, [[1], [2, 1], [3, 2, 1], [4, 1, 3]]);
|
||||
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||
activeMediaIds: number[];
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [1, 4, 3]);
|
||||
});
|
||||
|
||||
test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dictionariesDir, 'auto-sync-state.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
activeMediaIds: [2, 3, 4],
|
||||
mergedRevision: 'rev-2-3-4',
|
||||
mergedDictionaryTitle: 'SubMiner Character Dictionary',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 3,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Title 1',
|
||||
entryCount: 10,
|
||||
fromCache: true,
|
||||
updatedAt: 1,
|
||||
}),
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
assert.deepEqual(mediaIds, [1, 2, 3]);
|
||||
return {
|
||||
zipPath: '/tmp/rev-1-2-3.zip',
|
||||
revision: 'rev-1-2-3',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 30,
|
||||
};
|
||||
},
|
||||
waitForYomitanMutationReady: async () => undefined,
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async () => {
|
||||
throw new Error('import failed');
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.runSyncNow(), /import failed/);
|
||||
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'),
|
||||
) as {
|
||||
activeMediaIds: number[];
|
||||
mergedRevision: string | null;
|
||||
mergedDictionaryTitle: string | null;
|
||||
};
|
||||
assert.deepEqual(state.activeMediaIds, [1, 2, 3]);
|
||||
assert.equal(state.mergedRevision, 'rev-1-2-3');
|
||||
assert.equal(state.mergedDictionaryTitle, 'SubMiner Character Dictionary');
|
||||
});
|
||||
|
||||
test('auto sync invokes completion callback after successful sync', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const completions: Array<{ mediaId: number; mediaTitle: string; changed: boolean }> = [];
|
||||
|
||||
@@ -107,6 +107,13 @@ function arraysEqual(left: number[], right: number[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function sameMembership(left: number[], right: number[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
const leftSorted = [...left].sort((a, b) => a - b);
|
||||
const rightSorted = [...right].sort((a, b) => a - b);
|
||||
return arraysEqual(leftSorted, rightSorted);
|
||||
}
|
||||
|
||||
function buildSyncingMessage(mediaTitle: string): string {
|
||||
return `Updating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
@@ -223,10 +230,11 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
`[dictionary:auto-sync] active AniList media set: ${nextActiveMediaIds.join(', ')}`,
|
||||
);
|
||||
|
||||
const retainedChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
|
||||
const retainedOrderChanged = !arraysEqual(nextActiveMediaIds, state.activeMediaIds);
|
||||
const retainedMembershipChanged = !sameMembership(nextActiveMediaIds, state.activeMediaIds);
|
||||
let merged: MergedCharacterDictionaryBuildResult | null = null;
|
||||
if (
|
||||
retainedChanged ||
|
||||
retainedMembershipChanged ||
|
||||
!state.mergedRevision ||
|
||||
!state.mergedDictionaryTitle ||
|
||||
!snapshot.fromCache
|
||||
@@ -247,6 +255,12 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
throw new Error('Merged character dictionary state is incomplete.');
|
||||
}
|
||||
|
||||
writeAutoSyncState(statePath, {
|
||||
activeMediaIds: nextActiveMediaIds,
|
||||
mergedRevision: merged?.revision ?? revision,
|
||||
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
|
||||
});
|
||||
|
||||
await deps.waitForYomitanMutationReady?.();
|
||||
|
||||
const dictionaryInfo = await withOperationTimeout(
|
||||
@@ -263,7 +277,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
existing === null ||
|
||||
existingRevision === null ||
|
||||
existingRevision !== revision;
|
||||
let changed = merged !== null;
|
||||
let changed = merged !== null || retainedOrderChanged;
|
||||
|
||||
if (shouldImport) {
|
||||
deps.onSyncStatus?.({
|
||||
@@ -299,11 +313,6 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
);
|
||||
changed = changed || settingsUpdated === true;
|
||||
|
||||
writeAutoSyncState(statePath, {
|
||||
activeMediaIds: nextActiveMediaIds,
|
||||
mergedRevision: merged?.revision ?? revision,
|
||||
mergedDictionaryTitle: merged?.dictionaryTitle ?? dictionaryTitle,
|
||||
});
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user