mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 18:22:41 -08:00
fix(subsync): reopen manual modal on sync errors
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"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: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: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/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: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: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:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
parseKikuFieldGroupingChoice,
|
parseKikuFieldGroupingChoice,
|
||||||
parseKikuMergePreviewRequest,
|
parseKikuMergePreviewRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
|
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
|
||||||
|
|
||||||
const logger = createLogger('main:anki-jimaku-ipc');
|
const logger = createLogger('main:anki-jimaku-ipc');
|
||||||
|
|
||||||
@@ -148,10 +149,11 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
if (!safeName) {
|
if (!safeName) {
|
||||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
||||||
}
|
}
|
||||||
|
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(currentMediaPath, safeName);
|
||||||
|
|
||||||
const ext = path.extname(safeName);
|
const ext = path.extname(subtitleFilename);
|
||||||
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
|
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
|
||||||
let targetPath = path.join(mediaDir, safeName);
|
let targetPath = path.join(mediaDir, subtitleFilename);
|
||||||
if (fs.existsSync(targetPath)) {
|
if (fs.existsSync(targetPath)) {
|
||||||
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
||||||
let counter = 2;
|
let counter = 2;
|
||||||
|
|||||||
28
src/core/services/jimaku-download-path.test.ts
Normal file
28
src/core/services/jimaku-download-path.test.ts
Normal file
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
51
src/core/services/jimaku-download-path.ts
Normal file
51
src/core/services/jimaku-download-path.ts
Normal file
@@ -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}`;
|
||||||
|
}
|
||||||
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) {
|
if (result.ok) {
|
||||||
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
|
||||||
|
closeJimakuModal();
|
||||||
return;
|
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');
|
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> {
|
async function runSubsyncManualFromModal(): Promise<void> {
|
||||||
if (ctx.state.subsyncSubmitting) return;
|
if (ctx.state.subsyncSubmitting) return;
|
||||||
|
|
||||||
@@ -85,15 +109,25 @@ export function createSubsyncModal(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
|
||||||
ctx.state.subsyncSubmitting = true;
|
ctx.state.subsyncSubmitting = true;
|
||||||
ctx.dom.subsyncRunButton.disabled = true;
|
ctx.dom.subsyncRunButton.disabled = true;
|
||||||
|
|
||||||
closeSubsyncModal();
|
closeSubsyncModal();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.runSubsyncManual({
|
const result = await window.electronAPI.runSubsyncManual({
|
||||||
engine,
|
engine,
|
||||||
sourceTrackId,
|
sourceTrackId,
|
||||||
});
|
});
|
||||||
|
if (result.ok) return;
|
||||||
|
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
|
||||||
|
} catch (error) {
|
||||||
|
reopenSubsyncModalWithError(
|
||||||
|
sourceTracksSnapshot,
|
||||||
|
engine,
|
||||||
|
sourceTrackId,
|
||||||
|
`Subsync failed: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
ctx.state.subsyncSubmitting = false;
|
ctx.state.subsyncSubmitting = false;
|
||||||
ctx.dom.subsyncRunButton.disabled = false;
|
ctx.dom.subsyncRunButton.disabled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user