import { describe, expect, test } from "bun:test"; import { exportFilename, formatSrtTimestamp, transcriptToSrt, transcriptToTxt, } from "../src/lib/export"; import type { TranscriptChunk } from "../src/worker/protocol"; describe("transcriptToTxt", () => { test("trims and appends a single trailing newline", () => { expect(transcriptToTxt({ text: " Hello world. " })).toBe("Hello world.\n"); }); test("empty transcript becomes a bare newline", () => { expect(transcriptToTxt({ text: "" })).toBe("\n"); }); }); describe("formatSrtTimestamp", () => { test("zero", () => { expect(formatSrtTimestamp(0)).toBe("00:00:00,000"); }); test("pads milliseconds to three digits", () => { expect(formatSrtTimestamp(1.5)).toBe("00:00:01,500"); expect(formatSrtTimestamp(1.05)).toBe("00:00:01,050"); expect(formatSrtTimestamp(1.005)).toBe("00:00:01,005"); }); test("rolls over past one hour", () => { expect(formatSrtTimestamp(3661.25)).toBe("01:01:01,250"); }); test("rounds sub-millisecond values", () => { expect(formatSrtTimestamp(0.9996)).toBe("00:00:01,000"); }); test("clamps negatives to zero", () => { expect(formatSrtTimestamp(-1)).toBe("00:00:00,000"); }); }); describe("transcriptToSrt", () => { const chunk = ( text: string, start: number, end: number | null, ): TranscriptChunk => ({ text, timestamp: [start, end] }); test("formats numbered cues separated by blank lines", () => { const srt = transcriptToSrt([ chunk(" Hello.", 0, 2.5), chunk(" World.", 2.5, 4), ]); expect(srt).toBe( "1\n00:00:00,000 --> 00:00:02,500\nHello.\n" + "\n" + "2\n00:00:02,500 --> 00:00:04,000\nWorld.\n", ); }); test("null end borrows the next chunk's start", () => { const srt = transcriptToSrt([ chunk("One", 0, null), chunk("Two", 3, 5), ]); expect(srt).toContain("1\n00:00:00,000 --> 00:00:03,000\nOne"); }); test("null end on the final chunk falls back to start + 2s", () => { const srt = transcriptToSrt([chunk("Tail", 10, null)]); expect(srt).toBe("1\n00:00:10,000 --> 00:00:12,000\nTail\n"); }); test("skips empty and whitespace-only chunks (numbering stays contiguous)", () => { const srt = transcriptToSrt([ chunk(" ", 0, 1), chunk("Kept", 1, null), chunk("", 2, 3), chunk("Also kept", 4, 5), ]); // The null end skips over the empty chunk to the next *captioned* start. expect(srt).toBe( "1\n00:00:01,000 --> 00:00:04,000\nKept\n" + "\n" + "2\n00:00:04,000 --> 00:00:05,000\nAlso kept\n", ); }); test("cue never ends before it starts", () => { // Out-of-order borrow: next captioned chunk starts before this one. const srt = transcriptToSrt([chunk("A", 5, null), chunk("B", 4, 6)]); expect(srt).toContain("00:00:05,000 --> 00:00:05,000"); }); test("no captioned chunks yields an empty string", () => { expect(transcriptToSrt([])).toBe(""); expect(transcriptToSrt([chunk(" ", 0, 1)])).toBe(""); }); }); describe("exportFilename", () => { test("swaps the extension", () => { expect(exportFilename("talk.m4a", "srt")).toBe("talk.srt"); expect(exportFilename("talk.m4a", "txt")).toBe("talk.txt"); }); test("only the last dot counts", () => { expect(exportFilename("2026.07.14 meeting.wav", "srt")).toBe( "2026.07.14 meeting.srt", ); }); test("no extension keeps the whole name", () => { expect(exportFilename("recording", "txt")).toBe("recording.txt"); }); test("leading dot is a hidden file, not an extension", () => { expect(exportFilename(".bashrc", "txt")).toBe(".bashrc.txt"); }); test("null, undefined, and blank fall back to transcript.*", () => { expect(exportFilename(null, "srt")).toBe("transcript.srt"); expect(exportFilename(undefined, "txt")).toBe("transcript.txt"); expect(exportFilename(" ", "srt")).toBe("transcript.srt"); }); });