import { describe, expect, it } from "vitest"; import { compareDocuments, convertFrameRate, insertCue, mergeCueWithNext, parseSubtitle, removeCue, serializeSubtitle, snapCuesToFrames, splitCue, transformCueText, transformTiming, validateDocument, } from "../../src/subtitle/model"; const SRT = `1\r\n00:00:01,000 --> 00:00:02,500\r\nHello world\r\n\r\n2\r\n00:00:02,400 --> 00:00:04,000\r\nSecond cue\r\n`; describe("subtitle model", () => { it("parses SRT and reports overlap", () => { const document = parseSubtitle(SRT); expect(document.format).toBe("srt"); expect(document.cues).toHaveLength(2); expect(document.cues[0]).toMatchObject({ startMs: 1000, endMs: 2500, text: "Hello world", }); expect( validateDocument(document).some((issue) => issue.code === "overlap"), ).toBe(true); }); it("round-trips WebVTT identifiers, settings and notes", () => { const source = `WEBVTT - sample\n\nNOTE inert note\nline two\n\nc1\n00:00:01.000 --> 00:00:02.000 align:start\nHello\n`; const document = parseSubtitle(source); expect(document).toMatchObject({ format: "vtt" }); expect(document.cues[0]).toMatchObject({ id: "c1", settings: "align:start", }); const serialized = serializeSubtitle(document, "vtt"); expect(serialized).toContain("NOTE inert note"); expect(serialized).toContain("align:start"); expect(parseSubtitle(serialized).cues[0]?.text).toBe("Hello"); }); it("parses ASS dialogue fields containing commas and preserves styles", () => { const source = `[Script Info]\nScriptType: v4.00+\n\n[V4+ Styles]\nFormat: Name, Fontname\nStyle: Loud,Arial\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:01.00,0:00:03.25,Loud,Ada,0,0,0,,Hello, world\\Nagain\n`; const document = parseSubtitle(source); expect(document.format).toBe("ass"); expect(document.cues[0]).toMatchObject({ startMs: 1000, endMs: 3250, text: "Hello, world\nagain", }); const serialized = serializeSubtitle(document, "ass"); expect(serialized).toContain("Dialogue: 0,0:00:01.00,0:00:03.25,Loud"); expect(serialized).toContain("Hello, world\\Nagain"); }); it("writes an Events format even when only a style format was supplied", () => { const source = `[Script Info]\nScriptType: v4.00+\n\n[V4+ Styles]\nFormat: Name, Fontname\nStyle: Default,Arial\n\n[Events]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hello\n`; const serialized = serializeSubtitle(parseSubtitle(source), "ass"); expect(serialized).toMatch( /\[Events\]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text/u, ); expect(parseSubtitle(serialized).cues).toHaveLength(1); }); it("converts formats and timing without silently creating negatives", () => { const document = parseSubtitle(SRT); expect(() => transformTiming(document, { shiftMs: -2000 })).toThrow( /negative/u, ); const clamped = transformTiming(document, { shiftMs: -2000, clampToZero: true, }); expect(clamped.cues[0]?.startMs).toBe(0); const converted = convertFrameRate(document, 25, 50); expect(converted.cues[0]).toMatchObject({ startMs: 500, endMs: 1250 }); expect(serializeSubtitle(converted, "vtt")).toMatch(/^WEBVTT/u); }); it("compares revisions deterministically", () => { const before = parseSubtitle(SRT); const after = parseSubtitle(SRT.replace("Hello world", "Hello again")); expect(compareDocuments(before, after)).toMatchObject([ { index: 0, kind: "text" }, ]); }); it("imports and exports a bounded flat TTML profile", () => { const source = `

Hello
world

Frames

`; const document = parseSubtitle(source); expect(document.format).toBe("ttml"); expect(document.cues).toMatchObject([ { id: "one", startMs: 1000, endMs: 2500, text: "Hello\nworld" }, { startMs: 3000, endMs: 4000, text: "Frames" }, ]); const exported = serializeSubtitle(document, "ttml"); expect(exported).toContain('xml:id="one"'); expect(exported).toContain("Hello
world"); expect(parseSubtitle(exported).cues).toHaveLength(2); expect(() => parseSubtitle( ']>', "ttml", ), ).toThrow(/entities/iu); }); it("edits cue structure, text and frame timing without mutating input", () => { const original = parseSubtitle(SRT); const inserted = insertCue(original, 1, { startMs: 2600, endMs: 3000, text: " New ", }); expect(original.cues).toHaveLength(2); expect(inserted.cues).toHaveLength(3); const cleaned = transformCueText(inserted, "trim", [1]); expect(cleaned.cues[1]?.text).toBe("New"); const split = splitCue(cleaned, 0, 1500); expect(split.cues).toHaveLength(4); const merged = mergeCueWithNext(split, 0); expect(merged.cues).toHaveLength(3); const removed = removeCue(merged, 1); expect(removed.cues).toHaveLength(2); const snapped = snapCuesToFrames(removed, 25); expect(snapped.cues.every((cue) => cue.startMs % 40 === 0)).toBe(true); }); it("rejects pathological input bounds", () => { expect(() => parseSubtitle("x".repeat(4_000_001))).toThrow(/4,000,000/u); expect(() => parseSubtitle(`1\n00:61:00,000 --> 00:61:01,000\nBad`), ).not.toThrow(); expect( parseSubtitle(`1\n00:61:00,000 --> 00:61:01,000\nBad`).cues, ).toHaveLength(0); }); });