feat(character-dictionary): add manager modal and scope name matching to current media (#86)

This commit is contained in:
2026-05-25 18:29:20 -07:00
committed by GitHub
parent 097b619d71
commit 3932e53ced
71 changed files with 1896 additions and 127 deletions
@@ -24,6 +24,7 @@ function createClassList(initialTokens: string[] = []) {
}
function createElementStub() {
const listeners = new Map<string, Array<(event?: { stopPropagation?: () => void }) => void>>();
return {
className: '',
textContent: '',
@@ -35,7 +36,15 @@ function createElementStub() {
append(...children: unknown[]) {
this.children.push(...children);
},
addEventListener: () => {},
addEventListener: (
event: string,
listener: (event?: { stopPropagation?: () => void }) => void,
) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
dispatchEvent: (event: string, payload?: { stopPropagation?: () => void }) => {
for (const listener of listeners.get(event) ?? []) listener(payload);
},
};
}
@@ -157,6 +166,299 @@ test('character dictionary modal announces open before AniList refresh resolves'
}
});
test('character dictionary modal opens manager view with active entries', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const calls: string[] = [];
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const managedEntries = createNodeStub();
const summary = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionaryManagerSnapshot: async () => {
calls.push('snapshot');
return {
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{
mediaId: 115230,
label: '115230 - Tower of God',
title: 'Tower of God',
current: false,
},
],
};
},
removeCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
moveCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
getCharacterDictionarySelection: async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} as never,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: summary,
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: createNodeStub(),
characterDictionarySearchPanel: createNodeStub(),
characterDictionaryManagerPanel: createNodeStub(true),
characterDictionaryOverrideTab: createNodeStub(),
characterDictionaryManageTab: createNodeStub(),
characterDictionaryManagedEntries: managedEntries,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
) as ReturnType<typeof createCharacterDictionaryModal> & {
openCharacterDictionaryManagerModal: () => Promise<void>;
};
await modal.openCharacterDictionaryManagerModal();
assert.equal(state.characterDictionaryModalOpen, true);
assert.deepEqual(calls, ['snapshot']);
assert.equal(managedEntries.children.length, 2);
assert.equal(
summary.textContent,
'2 loaded character dictionaries. Order controls eviction priority; current dictionary stays loaded.',
);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary manager reports failed reorder IPC calls', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const managedEntries = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionaryManagerSnapshot: async () => ({
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{
mediaId: 115230,
label: '115230 - Tower of God',
title: 'Tower of God',
current: false,
},
],
}),
moveCharacterDictionaryManagedEntry: async () => {
throw new Error('move failed');
},
removeCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
getCharacterDictionarySelection: async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} as never,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
characterDictionarySearchPanel: createNodeStub(),
characterDictionaryManagerPanel: createNodeStub(true),
characterDictionaryOverrideTab: createNodeStub(),
characterDictionaryManageTab: createNodeStub(),
characterDictionaryManagedEntries: managedEntries,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryManagerModal();
const secondEntry = managedEntries.children[1] as { children: unknown[] };
const controls = secondEntry.children[1] as {
children: Array<{ dispatchEvent: (event: string, payload?: unknown) => void }>;
};
controls.children[0]?.dispatchEvent('click', { stopPropagation: () => {} });
await flushAsyncWork();
assert.equal(status.textContent, 'move failed');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary manager reports pending refresh after removal', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const managedEntries = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionaryManagerSnapshot: async () => ({
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{
mediaId: 115230,
label: '115230 - Tower of God',
title: 'Tower of God',
current: false,
},
],
}),
moveCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
removeCharacterDictionaryManagedEntry: async () => ({
ok: true,
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
],
rebuildRequired: true,
}),
getCharacterDictionarySelection: async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} as never,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
characterDictionarySearchPanel: createNodeStub(),
characterDictionaryManagerPanel: createNodeStub(true),
characterDictionaryOverrideTab: createNodeStub(),
characterDictionaryManageTab: createNodeStub(),
characterDictionaryManagedEntries: managedEntries,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryManagerModal();
const secondEntry = managedEntries.children[1] as { children: unknown[] };
const controls = secondEntry.children[1] as {
children: Array<{ dispatchEvent: (event: string, payload?: unknown) => void }>;
};
controls.children[3]?.dispatchEvent('click', { stopPropagation: () => {} });
await flushAsyncWork();
assert.equal(status.textContent, 'Entry removed. Merged dictionary will refresh shortly.');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal loads candidates and applies selected override', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;