import test from "node:test"; import assert from "node:assert/strict"; import type { MpvSubtitleRenderMetrics } from "../../types"; import { dispatchMpvProtocolMessage, MPV_REQUEST_ID_TRACK_LIST_SECONDARY, MpvProtocolHandleMessageDeps, splitMpvMessagesFromBuffer, parseVisibilityProperty, asBoolean, } from "./mpv-protocol"; function createDeps(overrides: Partial = {}): { deps: MpvProtocolHandleMessageDeps; state: { subText: string; secondarySubText: string; events: Array; commands: unknown[]; mediaPath: string; restored: number; }; } { const state = { subText: "", secondarySubText: "", events: [] as Array, commands: [] as unknown[], mediaPath: "", restored: 0, }; const metrics: MpvSubtitleRenderMetrics = { subPos: 100, subFontSize: 36, subScale: 1, subMarginY: 0, subMarginX: 0, subFont: "", subSpacing: 0, subBold: false, subItalic: false, subBorderSize: 0, subShadowOffset: 0, subAssOverride: "yes", subScaleByWindow: true, subUseMargins: true, osdHeight: 0, osdDimensions: null, }; return { state, deps: { getResolvedConfig: () => ({ secondarySub: { secondarySubLanguages: ["ja"] } }), getSubtitleMetrics: () => metrics, isVisibleOverlayVisible: () => false, emitSubtitleChange: (payload) => state.events.push(payload), emitSubtitleAssChange: (payload) => state.events.push(payload), emitSubtitleTiming: (payload) => state.events.push(payload), emitSecondarySubtitleChange: (payload) => state.events.push(payload), getCurrentSubText: () => state.subText, setCurrentSubText: (text) => { state.subText = text; }, setCurrentSubStart: () => {}, getCurrentSubStart: () => 0, setCurrentSubEnd: () => {}, getCurrentSubEnd: () => 0, emitMediaPathChange: (payload) => { state.mediaPath = payload.path; }, emitMediaTitleChange: (payload) => state.events.push(payload), emitSubtitleMetricsChange: (payload) => state.events.push(payload), setCurrentSecondarySubText: (text) => { state.secondarySubText = text; }, resolvePendingRequest: () => false, setSecondarySubVisibility: () => {}, syncCurrentAudioStreamIndex: () => {}, setCurrentAudioTrackId: () => {}, setCurrentTimePos: () => {}, getCurrentTimePos: () => 0, getPendingPauseAtSubEnd: () => false, setPendingPauseAtSubEnd: () => {}, getPauseAtTime: () => null, setPauseAtTime: () => {}, autoLoadSecondarySubTrack: () => {}, setCurrentVideoPath: () => {}, emitSecondarySubtitleVisibility: (payload) => state.events.push(payload), setCurrentAudioStreamIndex: () => {}, sendCommand: (payload) => { state.commands.push(payload); return true; }, restorePreviousSecondarySubVisibility: () => { state.restored += 1; }, setPreviousSecondarySubVisibility: () => { // intentionally not tracked in this unit test }, ...overrides, }, }; } test("dispatchMpvProtocolMessage emits subtitle text on property change", async () => { const { deps, state } = createDeps(); await dispatchMpvProtocolMessage( { event: "property-change", name: "sub-text", data: "字幕" }, deps, ); assert.equal(state.subText, "字幕"); assert.deepEqual(state.events, [{ text: "字幕", isOverlayVisible: false }]); }); test("dispatchMpvProtocolMessage sets secondary subtitle track based on track list response", async () => { const { deps, state } = createDeps(); await dispatchMpvProtocolMessage( { request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY, data: [ { type: "audio", id: 1, lang: "eng" }, { type: "sub", id: 2, lang: "ja" }, ], }, deps, ); assert.deepEqual(state.commands, [{ command: ["set_property", "secondary-sid", 2] }]); }); test("dispatchMpvProtocolMessage restores secondary visibility on shutdown", async () => { const { deps, state } = createDeps(); await dispatchMpvProtocolMessage({ event: "shutdown" }, deps); assert.equal(state.restored, 1); }); test("dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set", async () => { let pendingPauseAtSubEnd = true; let pauseAtTime: number | null = null; const { deps, state } = createDeps({ getPendingPauseAtSubEnd: () => pendingPauseAtSubEnd, setPendingPauseAtSubEnd: (next) => { pendingPauseAtSubEnd = next; }, getCurrentSubText: () => "字幕", setCurrentSubEnd: () => {}, getCurrentSubEnd: () => 0, setPauseAtTime: (next) => { pauseAtTime = next; }, }); await dispatchMpvProtocolMessage( { event: "property-change", name: "sub-end", data: 42 }, deps, ); assert.equal(pendingPauseAtSubEnd, false); assert.equal(pauseAtTime, 42); assert.deepEqual(state.events, [{ text: "字幕", start: 0, end: 0 }]); assert.deepEqual( state.commands[state.commands.length - 1], { command: ["set_property", "pause", false] }, ); }); test("splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer", () => { const parsed = splitMpvMessagesFromBuffer( '{"event":"shutdown"}\n{"event":"property-change","name":"media-title","data":"x"}\n{"partial"', ); assert.equal(parsed.messages.length, 2); assert.equal(parsed.nextBuffer, "{\"partial\""); assert.equal(parsed.messages[0].event, "shutdown"); assert.equal(parsed.messages[1].name, "media-title"); }); test("splitMpvMessagesFromBuffer reports invalid JSON lines", () => { const errors: Array<{ line: string; error?: string }> = []; splitMpvMessagesFromBuffer('{"event":"x"}\n{invalid}\n', undefined, (line, error) => { errors.push({ line, error: String(error) }); }); assert.equal(errors.length, 1); assert.equal(errors[0].line, "{invalid}"); }); test("visibility and boolean parsers handle text values", () => { assert.equal(parseVisibilityProperty("true"), true); assert.equal(parseVisibilityProperty("0"), false); assert.equal(parseVisibilityProperty("unknown"), null); assert.equal(asBoolean("yes", false), true); assert.equal(asBoolean("0", true), false); });