From 87fe81ad3e567cd2bc941ae3505153b5a2afb9e9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 1 Mar 2026 14:03:55 -0800 Subject: [PATCH] fix(subsync): reopen manual modal on sync errors --- ...to-close-after-successful-subtitle-load.md | 48 ++++ ...-rename-subtitle-to-match-current-video.md | 48 ++++ package.json | 4 +- src/core/services/anki-jimaku-ipc.ts | 8 +- .../services/jimaku-download-path.test.ts | 28 +++ src/core/services/jimaku-download-path.ts | 51 ++++ src/renderer/modals/jimaku.test.ts | 149 ++++++++++++ src/renderer/modals/jimaku.ts | 1 + src/renderer/modals/subsync.test.ts | 226 ++++++++++++++++++ src/renderer/modals/subsync.ts | 38 ++- 10 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 backlog/tasks/task-79 - Jimaku-modal-auto-close-after-successful-subtitle-load.md create mode 100644 backlog/tasks/task-80 - Jimaku-download-rename-subtitle-to-match-current-video.md create mode 100644 src/core/services/jimaku-download-path.test.ts create mode 100644 src/core/services/jimaku-download-path.ts create mode 100644 src/renderer/modals/jimaku.test.ts create mode 100644 src/renderer/modals/subsync.test.ts diff --git a/backlog/tasks/task-79 - Jimaku-modal-auto-close-after-successful-subtitle-load.md b/backlog/tasks/task-79 - Jimaku-modal-auto-close-after-successful-subtitle-load.md new file mode 100644 index 0000000..8eee468 --- /dev/null +++ b/backlog/tasks/task-79 - Jimaku-modal-auto-close-after-successful-subtitle-load.md @@ -0,0 +1,48 @@ +--- +id: TASK-79 +title: 'Jimaku modal: auto-close after successful subtitle load' +status: Done +assignee: [] +created_date: '2026-03-01 13:52' +updated_date: '2026-03-01 14:06' +labels: [] +dependencies: [] +priority: medium +ordinal: 10000 +--- + +## Description + + + +Fix Jimaku modal UX so selecting a subtitle file closes the modal automatically once subtitle download+load succeeds. + +Current behavior: +- Subtitle file downloads and loads into mpv. +- Jimaku modal remains open until manual close. + +Expected behavior: +- On successful `jimakuDownloadFile` result, close modal immediately. +- Keep error behavior unchanged (stay open + show error). + + + +## Acceptance Criteria + + + +- [x] #1 Successful subtitle file selection/download in Jimaku closes modal automatically. +- [x] #2 Existing error path keeps modal open and shows error. +- [x] #3 Regression test covers success auto-close behavior. + + + +## Final Summary + + + +Fixed renderer Jimaku success flow to close modal immediately after successful `jimakuDownloadFile` result. Added regression test (`src/renderer/modals/jimaku.test.ts`) that reproduces keyboard file-selection success path and asserts modal close state + `notifyOverlayModalClosed('jimaku')` emission. Kept failure path unchanged. + +Also wired new test into `test:core:src` and `test:core:dist` package scripts. + + diff --git a/backlog/tasks/task-80 - Jimaku-download-rename-subtitle-to-match-current-video.md b/backlog/tasks/task-80 - Jimaku-download-rename-subtitle-to-match-current-video.md new file mode 100644 index 0000000..ab3e5fe --- /dev/null +++ b/backlog/tasks/task-80 - Jimaku-download-rename-subtitle-to-match-current-video.md @@ -0,0 +1,48 @@ +--- +id: TASK-80 +title: 'Jimaku download: rename subtitle to current video basename' +status: Done +assignee: [] +created_date: '2026-03-01 14:17' +updated_date: '2026-03-01 14:19' +labels: [] +dependencies: [] +priority: medium +ordinal: 11000 +--- + +## Description + + + +When user selects a Jimaku subtitle, save subtitle with filename derived from currently playing media filename instead of Jimaku release filename. + +Example: +- Current media: `anime.mkv` +- Downloaded subtitle extension: `.srt` +- Saved subtitle path: `anime.ja.srt` + +Scope: +- Apply in Jimaku download IPC path before writing file. +- Preserve collision-avoidance behavior (suffix with jimaku entry id/counter when target exists). +- Keep mpv load flow unchanged except using renamed path. + + + +## Acceptance Criteria + + + +- [x] #1 Jimaku subtitle destination name uses current media basename plus `.ja` and subtitle extension. +- [x] #2 Existing duplicate filename conflict handling still works. +- [x] #3 Regression tests cover renamed destination path behavior. + + + +## Final Summary + + + +Jimaku download path generation now derives subtitle filename from currently playing media basename and keeps subtitle extension from Jimaku file (`anime.mkv` + `.srt` => `anime.ja.srt`). Added pure helper `buildJimakuSubtitleFilenameFromMediaPath` and routed IPC download flow through it before existing duplicate-path conflict handling. Added regression tests for local path, missing extension fallback, and remote URL media paths. + + diff --git a/package.json b/package.json index 64f148a..8b3ba49 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "test:plugin:src": "lua scripts/test-plugin-start-gate.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", - "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", + "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts index e318750..3106fb2 100644 --- a/src/core/services/anki-jimaku-ipc.ts +++ b/src/core/services/anki-jimaku-ipc.ts @@ -23,6 +23,7 @@ import { parseKikuFieldGroupingChoice, parseKikuMergePreviewRequest, } from '../../shared/ipc/validators'; +import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path'; const logger = createLogger('main:anki-jimaku-ipc'); @@ -148,10 +149,11 @@ export function registerAnkiJimakuIpcHandlers( if (!safeName) { return { ok: false, error: { error: 'Invalid subtitle filename.' } }; } + const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(currentMediaPath, safeName); - const ext = path.extname(safeName); - const baseName = ext ? safeName.slice(0, -ext.length) : safeName; - let targetPath = path.join(mediaDir, safeName); + const ext = path.extname(subtitleFilename); + const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename; + let targetPath = path.join(mediaDir, subtitleFilename); if (fs.existsSync(targetPath)) { targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`); let counter = 2; diff --git a/src/core/services/jimaku-download-path.test.ts b/src/core/services/jimaku-download-path.test.ts new file mode 100644 index 0000000..e9a1d83 --- /dev/null +++ b/src/core/services/jimaku-download-path.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path.js'; + +test('buildJimakuSubtitleFilenameFromMediaPath uses media basename + ja + subtitle extension', () => { + assert.equal( + buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs.Release.1080p.srt'), + 'anime.ja.srt', + ); +}); + +test('buildJimakuSubtitleFilenameFromMediaPath falls back to .srt when subtitle name has no extension', () => { + assert.equal( + buildJimakuSubtitleFilenameFromMediaPath('/videos/anime.mkv', 'Subs Release'), + 'anime.ja.srt', + ); +}); + +test('buildJimakuSubtitleFilenameFromMediaPath supports remote media URLs', () => { + assert.equal( + buildJimakuSubtitleFilenameFromMediaPath( + 'https://cdn.example.org/library/Anime%20Episode%2001.mkv?token=abc', + 'anything.ass', + ), + 'Anime Episode 01.ja.ass', + ); +}); diff --git a/src/core/services/jimaku-download-path.ts b/src/core/services/jimaku-download-path.ts new file mode 100644 index 0000000..9ae1426 --- /dev/null +++ b/src/core/services/jimaku-download-path.ts @@ -0,0 +1,51 @@ +import * as path from 'node:path'; + +const DEFAULT_JIMAKU_LANGUAGE_SUFFIX = 'ja'; +const DEFAULT_SUBTITLE_EXTENSION = '.srt'; + +function stripFileExtension(name: string): string { + const ext = path.extname(name); + return ext ? name.slice(0, -ext.length) : name; +} + +function sanitizeFilenameSegment(value: string, fallback: string): string { + const sanitized = value + .replace(/[\\/:*?"<>|]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return sanitized || fallback; +} + +function resolveMediaFilename(mediaPath: string): string { + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath)) { + return path.basename(path.resolve(mediaPath)); + } + + try { + const parsedUrl = new URL(mediaPath); + const decodedPath = decodeURIComponent(parsedUrl.pathname); + const fromPath = path.basename(decodedPath); + if (fromPath) { + return fromPath; + } + return parsedUrl.hostname.replace(/^www\./, '') || 'subtitle'; + } catch { + return path.basename(mediaPath); + } +} + +export function buildJimakuSubtitleFilenameFromMediaPath( + mediaPath: string, + downloadedSubtitleName: string, + languageSuffix = DEFAULT_JIMAKU_LANGUAGE_SUFFIX, +): string { + const mediaFilename = resolveMediaFilename(mediaPath); + const mediaBasename = sanitizeFilenameSegment(stripFileExtension(mediaFilename), 'subtitle'); + const subtitleName = path.basename(downloadedSubtitleName); + const subtitleExt = path.extname(subtitleName) || DEFAULT_SUBTITLE_EXTENSION; + const normalizedLanguageSuffix = sanitizeFilenameSegment(languageSuffix, 'ja').replace( + /\s+/g, + '-', + ); + return `${mediaBasename}.${normalizedLanguageSuffix}${subtitleExt}`; +} diff --git a/src/renderer/modals/jimaku.test.ts b/src/renderer/modals/jimaku.test.ts new file mode 100644 index 0000000..d3328b8 --- /dev/null +++ b/src/renderer/modals/jimaku.test.ts @@ -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 { + 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 }); + } +}); diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts index de7e440..e6278dc 100644 --- a/src/renderer/modals/jimaku.ts +++ b/src/renderer/modals/jimaku.ts @@ -234,6 +234,7 @@ export function createJimakuModal( if (result.ok) { setJimakuStatus(`Downloaded and loaded: ${result.path}`); + closeJimakuModal(); return; } diff --git a/src/renderer/modals/subsync.test.ts b/src/renderer/modals/subsync.test.ts new file mode 100644 index 0000000..bb64272 --- /dev/null +++ b/src/renderer/modals/subsync.test.ts @@ -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(); + 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(); + 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() { + let resolve!: (value: T) => void; + const promise = new Promise((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 { + 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(); + } +}); diff --git a/src/renderer/modals/subsync.ts b/src/renderer/modals/subsync.ts index acefa2e..21fdc7a 100644 --- a/src/renderer/modals/subsync.ts +++ b/src/renderer/modals/subsync.ts @@ -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 { 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;