mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 12:11:28 -07:00
Jellyfin and Subsync Fixes (#13)
This commit is contained in:
149
src/renderer/modals/jimaku.test.ts
Normal file
149
src/renderer/modals/jimaku.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ElectronAPI } from '../../types';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createJimakuModal } from './jimaku.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) {
|
||||
tokens.add(entry);
|
||||
}
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) {
|
||||
tokens.delete(entry);
|
||||
}
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
const classList = createClassList();
|
||||
return {
|
||||
textContent: '',
|
||||
className: '',
|
||||
style: {},
|
||||
classList,
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
},
|
||||
addEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createListStub() {
|
||||
return {
|
||||
innerHTML: '',
|
||||
children: [] as unknown[],
|
||||
appendChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncWork(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('successful Jimaku subtitle selection closes modal', async () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
|
||||
const modalCloseNotifications: Array<'runtime-options' | 'subsync' | 'jimaku' | 'kiku'> = [];
|
||||
|
||||
const electronAPI = {
|
||||
jimakuDownloadFile: async () => ({ ok: true, path: '/tmp/subtitles/episode01.ass' }),
|
||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||
modalCloseNotifications.push(modal);
|
||||
},
|
||||
} as unknown as ElectronAPI;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { electronAPI },
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
createElement: () => createElementStub(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const overlayClassList = createClassList(['interactive']);
|
||||
const jimakuModalClassList = createClassList();
|
||||
const jimakuEntriesSectionClassList = createClassList(['hidden']);
|
||||
const jimakuFilesSectionClassList = createClassList();
|
||||
const jimakuBroadenButtonClassList = createClassList(['hidden']);
|
||||
const state = createRendererState();
|
||||
state.jimakuModalOpen = true;
|
||||
state.currentEntryId = 42;
|
||||
state.selectedFileIndex = 0;
|
||||
state.jimakuFiles = [
|
||||
{
|
||||
name: 'episode01.ass',
|
||||
url: 'https://jimaku.cc/files/episode01.ass',
|
||||
size: 1000,
|
||||
last_modified: '2026-03-01',
|
||||
},
|
||||
];
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
jimakuModal: {
|
||||
classList: jimakuModalClassList,
|
||||
setAttribute: () => {},
|
||||
},
|
||||
jimakuTitleInput: { value: '' },
|
||||
jimakuSeasonInput: { value: '' },
|
||||
jimakuEpisodeInput: { value: '' },
|
||||
jimakuSearchButton: { addEventListener: () => {} },
|
||||
jimakuCloseButton: { addEventListener: () => {} },
|
||||
jimakuStatus: { textContent: '', style: { color: '' } },
|
||||
jimakuEntriesSection: { classList: jimakuEntriesSectionClassList },
|
||||
jimakuEntriesList: createListStub(),
|
||||
jimakuFilesSection: { classList: jimakuFilesSectionClassList },
|
||||
jimakuFilesList: createListStub(),
|
||||
jimakuBroadenButton: {
|
||||
classList: jimakuBroadenButtonClassList,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
},
|
||||
state,
|
||||
};
|
||||
|
||||
const jimakuModal = createJimakuModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
let prevented = false;
|
||||
jimakuModal.handleJimakuKeydown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
} as KeyboardEvent);
|
||||
await flushAsyncWork();
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(state.jimakuModalOpen, false);
|
||||
assert.equal(jimakuModalClassList.contains('hidden'), true);
|
||||
assert.equal(overlayClassList.contains('interactive'), false);
|
||||
assert.deepEqual(modalCloseNotifications, ['jimaku']);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
@@ -234,6 +234,7 @@ export function createJimakuModal(
|
||||
|
||||
if (result.ok) {
|
||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||
closeJimakuModal();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
226
src/renderer/modals/subsync.test.ts
Normal file
226
src/renderer/modals/subsync.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createSubsyncModal } from './subsync.js';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function createClassList() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
add: (...tokens: string[]) => {
|
||||
for (const token of tokens) classes.add(token);
|
||||
},
|
||||
remove: (...tokens: string[]) => {
|
||||
for (const token of tokens) classes.delete(token);
|
||||
},
|
||||
toggle: (token: string, force?: boolean) => {
|
||||
if (force === undefined) {
|
||||
if (classes.has(token)) classes.delete(token);
|
||||
else classes.add(token);
|
||||
return classes.has(token);
|
||||
}
|
||||
if (force) classes.add(token);
|
||||
else classes.delete(token);
|
||||
return force;
|
||||
},
|
||||
contains: (token: string) => classes.has(token),
|
||||
};
|
||||
}
|
||||
|
||||
function createEventTarget() {
|
||||
const listeners = new Map<string, Listener[]>();
|
||||
return {
|
||||
addEventListener: (event: string, listener: Listener) => {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
listeners.set(event, existing);
|
||||
},
|
||||
dispatch: (event: string) => {
|
||||
for (const listener of listeners.get(event) ?? []) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) {
|
||||
const overlayClassList = createClassList();
|
||||
const modalClassList = createClassList();
|
||||
const statusClassList = createClassList();
|
||||
const sourceLabelClassList = createClassList();
|
||||
const runButtonEvents = createEventTarget();
|
||||
const closeButtonEvents = createEventTarget();
|
||||
const engineAlassEvents = createEventTarget();
|
||||
const engineFfsubsyncEvents = createEventTarget();
|
||||
|
||||
const sourceOptions: Array<{ value: string; textContent: string }> = [];
|
||||
|
||||
const runButton = {
|
||||
disabled: false,
|
||||
addEventListener: runButtonEvents.addEventListener,
|
||||
dispatch: runButtonEvents.dispatch,
|
||||
};
|
||||
|
||||
const closeButton = {
|
||||
addEventListener: closeButtonEvents.addEventListener,
|
||||
dispatch: closeButtonEvents.dispatch,
|
||||
};
|
||||
|
||||
const subsyncEngineAlass = {
|
||||
checked: false,
|
||||
addEventListener: engineAlassEvents.addEventListener,
|
||||
dispatch: engineAlassEvents.dispatch,
|
||||
};
|
||||
|
||||
const subsyncEngineFfsubsync = {
|
||||
checked: false,
|
||||
addEventListener: engineFfsubsyncEvents.addEventListener,
|
||||
dispatch: engineFfsubsyncEvents.dispatch,
|
||||
};
|
||||
|
||||
const sourceSelect = {
|
||||
innerHTML: '',
|
||||
value: '',
|
||||
disabled: false,
|
||||
appendChild: (option: { value: string; textContent: string }) => {
|
||||
sourceOptions.push(option);
|
||||
if (!sourceSelect.value) {
|
||||
sourceSelect.value = option.value;
|
||||
}
|
||||
return option;
|
||||
},
|
||||
};
|
||||
|
||||
let notifyClosedCalls = 0;
|
||||
let notifyOpenedCalls = 0;
|
||||
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
runSubsyncManual,
|
||||
notifyOverlayModalOpened: () => {
|
||||
notifyOpenedCalls += 1;
|
||||
},
|
||||
notifyOverlayModalClosed: () => {
|
||||
notifyClosedCalls += 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => ({ value: '', textContent: '' }),
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: overlayClassList },
|
||||
subsyncModal: {
|
||||
classList: modalClassList,
|
||||
setAttribute: () => {},
|
||||
},
|
||||
subsyncCloseButton: closeButton,
|
||||
subsyncEngineAlass,
|
||||
subsyncEngineFfsubsync,
|
||||
subsyncSourceLabel: { classList: sourceLabelClassList },
|
||||
subsyncSourceSelect: sourceSelect,
|
||||
subsyncRunButton: runButton,
|
||||
subsyncStatus: {
|
||||
textContent: '',
|
||||
classList: statusClassList,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
subsyncModalOpen: false,
|
||||
subsyncSourceTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
isOverSubtitle: false,
|
||||
},
|
||||
};
|
||||
|
||||
const modal = createSubsyncModal(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
modal,
|
||||
runButton,
|
||||
statusClassList,
|
||||
getNotifyClosedCalls: () => notifyClosedCalls,
|
||||
getNotifyOpenedCalls: () => notifyOpenedCalls,
|
||||
restoreGlobals: () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
test('manual subsync failure closes during run, then reopens modal with error', async () => {
|
||||
const deferred = createDeferred<{ ok: boolean; message: string }>();
|
||||
const harness = createTestHarness(async () => deferred.promise);
|
||||
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openSubsyncModal({
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
});
|
||||
|
||||
harness.runButton.dispatch('click');
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(harness.ctx.state.subsyncModalOpen, false);
|
||||
assert.equal(harness.getNotifyClosedCalls(), 1);
|
||||
assert.equal(harness.getNotifyOpenedCalls(), 0);
|
||||
|
||||
deferred.resolve({
|
||||
ok: false,
|
||||
message: 'alass synchronization failed: code=1 stderr: invalid subtitle format',
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(harness.ctx.state.subsyncModalOpen, true);
|
||||
assert.equal(
|
||||
harness.ctx.dom.subsyncStatus.textContent,
|
||||
'alass synchronization failed: code=1 stderr: invalid subtitle format',
|
||||
);
|
||||
assert.equal(harness.statusClassList.contains('error'), true);
|
||||
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
|
||||
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
||||
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2');
|
||||
assert.equal(harness.getNotifyClosedCalls(), 1);
|
||||
assert.equal(harness.getNotifyOpenedCalls(), 1);
|
||||
} finally {
|
||||
harness.restoreGlobals();
|
||||
}
|
||||
});
|
||||
@@ -71,6 +71,30 @@ export function createSubsyncModal(
|
||||
ctx.dom.subsyncModal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function reopenSubsyncModalWithError(
|
||||
sourceTracks: SubsyncManualPayload['sourceTracks'],
|
||||
engine: 'alass' | 'ffsubsync',
|
||||
sourceTrackId: number | null,
|
||||
message: string,
|
||||
): void {
|
||||
openSubsyncModal({ sourceTracks });
|
||||
|
||||
if (engine === 'alass' && sourceTracks.length > 0) {
|
||||
ctx.dom.subsyncEngineAlass.checked = true;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = false;
|
||||
if (Number.isFinite(sourceTrackId)) {
|
||||
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
|
||||
}
|
||||
} else {
|
||||
ctx.dom.subsyncEngineAlass.checked = false;
|
||||
ctx.dom.subsyncEngineFfsubsync.checked = true;
|
||||
}
|
||||
|
||||
updateSubsyncSourceVisibility();
|
||||
setSubsyncStatus(message, true);
|
||||
window.electronAPI.notifyOverlayModalOpened('subsync');
|
||||
}
|
||||
|
||||
async function runSubsyncManualFromModal(): Promise<void> {
|
||||
if (ctx.state.subsyncSubmitting) return;
|
||||
|
||||
@@ -85,15 +109,25 @@ export function createSubsyncModal(
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
|
||||
ctx.state.subsyncSubmitting = true;
|
||||
ctx.dom.subsyncRunButton.disabled = true;
|
||||
|
||||
closeSubsyncModal();
|
||||
|
||||
try {
|
||||
await window.electronAPI.runSubsyncManual({
|
||||
const result = await window.electronAPI.runSubsyncManual({
|
||||
engine,
|
||||
sourceTrackId,
|
||||
});
|
||||
if (result.ok) return;
|
||||
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
|
||||
} catch (error) {
|
||||
reopenSubsyncModalWithError(
|
||||
sourceTracksSnapshot,
|
||||
engine,
|
||||
sourceTrackId,
|
||||
`Subsync failed: ${(error as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
ctx.state.subsyncSubmitting = false;
|
||||
ctx.dom.subsyncRunButton.disabled = false;
|
||||
|
||||
Reference in New Issue
Block a user