diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 000000000..c89528b6c --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,107 @@ +# OpenCut Test Coverage - Agent Handoff + +## Workflow Summary + +Agent wrote comprehensive test files for 7 modules in `apps/web/src/` that previously lacked tests. All tests use `bun:test` and pass (150 tests, 0 failures). + +## What's Done ✅ + +### 1. Subtitles SRT Parser (`subtitles/__tests__/srt.test.ts`) — 22 tests +- Basic multi-cue parsing +- Timestamp formats: comma (`HH:MM:SS,mmm`), dot (`HH:MM:SS.mmm`), mixed, 1-2 digit ms +- Multi-line cue text +- Sequence numbers (with/without) +- Empty input → empty result +- Malformed blocks (missing timestamp, bad format, single line, empty text) +- Non-positive duration (end ≤ start) +- `\r\n` and `\r` line endings +- Whitespace handling (trim, extra blanks, spaced arrows) + +### 2. Subtitles ASS Parser (`subtitles/__tests__/ass.test.ts`) — 17 tests +- Basic ASS parsing (Script Info + Styles + Events) +- Default play resolution (384×288) +- Custom play resolution from PlayResX/PlayResY +- V4+ style properties (fontFamily, bold, italic, letterSpacing, textAlign, verticalAlign) +- Fallback to default style for missing style names +- Extra comma in text field (last field captures remainder) +- Override tag stripping (`{\b1}`, `\\N` → newline, `\\h` → space) +- Empty input +- Alignment mapping (2→center/bottom, 5→center/middle, 9→right/top) +- Non-dialogue events (Comment ignored) +- Effects field handling + +### 3. Subtitles Parse Dispatcher (`subtitles/__tests__/parse.test.ts`) — 8 tests +- Dispatches `.srt` → `parseSrt` +- Dispatches `.ass` → `parseAss` +- Throws for `.txt`, `.vtt`, `.sub` +- Case-insensitive extension matching (`test.SRT`, `test.ASS`) +- Multiple dots in filename +- No extension throws + +### 4. Math Utils (`utils/__tests__/math-extended.test.ts`) — 35 tests +- `clamp`: in-range, below min, above max, boundaries, negative ranges, min > max +- `clampRound`: rounds then clamps, boundaries, negative values +- `getFractionDigitsForStep`: integer, decimal, scientific notation +- `snapToStep`: nearest step, step=0 (unchanged), negative step, boundaries, negative values +- `isNearlyEqual`: equal, within epsilon, outside epsilon, custom epsilon, negative, zero +- `formatNumberForDisplay`: integer, decimals, trailing zeros, fractionDigits, min/max fraction, negative, zero + +### 5. FPS Utils (`fps/__tests__/fps-extended.test.ts`) — 16 tests +- `frameRateToFloat`: standard (24/30/60), NTSC (24000/1001, 30000/1001, 60000/1001) +- `frameRatesEqual`: identical, different, same float different fractions +- `floatToFrameRate`: standard rates, NTSC mapping, integer arbitrary, GCD reduction, 0/negative/large + +### 6. Rendering Utils (`rendering/__tests__/rendering.test.ts`) — 15 tests +- `buildTransformFromParams`: full params, empty (defaults), partial, non-number fallback, negative, zero +- `readOpacityFromParams`: present, missing (default 1), non-number +- `readBlendModeFromParams`: valid mode, missing (normal), invalid, non-string, all 17 modes + +### 7. Params System (`params/__tests__/params.test.ts`) — 32 tests +- `coerceParamValue`: number (range, clamp, snap, NaN, non-number, integer step, unbounded), boolean, color, text, select (valid/invalid) +- `getParamValueKind`: number→number, boolean→discrete, color→color, text/select→discrete +- `getParamDefaultInterpolation`: number→linear, boolean/text→hold, color→linear +- `getParamNumericRange`: number→range object, non-number→undefined, no-max handling + +## Issues Encountered & Fixed + +1. **Bash heredoc mangled backticks** — Template literals with backticks in heredoc were interpreted as command substitution. Fixed by writing files with Python instead. +2. **JavaScript `-0` vs `0`** — `Math.round(-0.4)` returns `-0`. `toBe()` uses `Object.is()` so `-0 !== 0`. Fixed with `toBeCloseTo(0)`. +3. **ASS effect field comma** — `splitAssFields` splits on comma, so `fade(200,200,Text)` → effect=`fade(200`, text=`200,Text`. Adjusted expected value in test. + +## What Needs To Be Done Next 🔲 + +### High Priority +- [ ] **Silence/VAD auto-cut** — No silence detection or voice activity detection exists. Could use Silero VAD (ONNX) to detect speech segments and auto-trim dead air. Modules: `transcription/`, new `vad/` module. +- [ ] **More rendering tests** — `readBlendModeFromParams` covers valid modes but integration with actual rendering pipeline untested. +- [ ] **Animation tests** — `animation/animated-params.test.ts` exists but coverage is thin. Keyframe interpolation, easing curves need more tests. +- [ ] **Timeline tests** — Several timeline test files exist (`snap.test.ts`, `tracks.test.ts`, etc.) but edge cases for drag/drop, group operations, and multi-track scenarios could be expanded. + +### Medium Priority +- [ ] **Export pipeline tests** — `export/__tests__/` exists but WASM compositor integration, format support, and error handling need coverage. +- [ ] **Media processing tests** — Audio waveform, thumbnail generation, media utils lack unit tests. +- [ ] **Auth/DB tests** — `auth/` and `db/` modules have no test coverage. +- [ ] **Effects registry tests** — Effect definitions, registration, and parameter validation untested. + +### Low Priority +- [ ] **Component tests** — React components in `components/`, `panels/`, `editor/` have no unit tests (would need React Testing Library). +- [ ] **E2E tests** — No end-to-end test suite exists. +- [ ] **Performance tests** — No benchmarks for timeline rendering, WASM compositor, or large project handling. + +## Technical Notes + +- **Test runner**: `bun test` (bun@1.3.14+) +- **Install deps first**: `cd /tmp/OpenCut && bun install` +- **Run all new tests**: `bun test apps/web/src/subtitles/__tests__/ apps/web/src/utils/__tests__/math-extended.test.ts apps/web/src/fps/__tests__/fps-extended.test.ts apps/web/src/rendering/__tests__/ apps/web/src/params/__tests__/params.test.ts` +- **Run single file**: `bun test apps/web/src/subtitles/__tests__/srt.test.ts` + +## Files Created + +``` +apps/web/src/subtitles/__tests__/srt.test.ts +apps/web/src/subtitles/__tests__/ass.test.ts +apps/web/src/subtitles/__tests__/parse.test.ts +apps/web/src/utils/__tests__/math-extended.test.ts +apps/web/src/fps/__tests__/fps-extended.test.ts +apps/web/src/rendering/__tests__/rendering.test.ts +apps/web/src/params/__tests__/params.test.ts +``` diff --git a/apps/web/src/fps/__tests__/fps-extended.test.ts b/apps/web/src/fps/__tests__/fps-extended.test.ts new file mode 100644 index 000000000..d63418432 --- /dev/null +++ b/apps/web/src/fps/__tests__/fps-extended.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { + frameRateToFloat, + frameRatesEqual, + floatToFrameRate, +} from "../utils"; + +describe("frameRateToFloat", () => { + test("converts standard integer frame rates", () => { + expect(frameRateToFloat({ numerator: 24, denominator: 1 })).toBe(24); + expect(frameRateToFloat({ numerator: 30, denominator: 1 })).toBe(30); + expect(frameRateToFloat({ numerator: 60, denominator: 1 })).toBe(60); + }); + + test("converts fractional frame rates", () => { + const result = frameRateToFloat({ numerator: 24000, denominator: 1001 }); + expect(result).toBeCloseTo(23.976, 2); + }); + + test("converts NTSC 30fps", () => { + const result = frameRateToFloat({ numerator: 30000, denominator: 1001 }); + expect(result).toBeCloseTo(29.97, 2); + }); + + test("handles 1 fps", () => { + expect(frameRateToFloat({ numerator: 1, denominator: 1 })).toBe(1); + }); + + test("handles arbitrary fractions", () => { + expect(frameRateToFloat({ numerator: 120, denominator: 2 })).toBe(60); + }); +}); + +describe("frameRatesEqual", () => { + test("returns true for identical rates", () => { + expect( + frameRatesEqual({ + a: { numerator: 30, denominator: 1 }, + b: { numerator: 30, denominator: 1 }, + }), + ).toBe(true); + }); + + test("returns false for different rates", () => { + expect( + frameRatesEqual({ + a: { numerator: 30, denominator: 1 }, + b: { numerator: 60, denominator: 1 }, + }), + ).toBe(false); + }); + + test("returns false for same float but different fractions", () => { + expect( + frameRatesEqual({ + a: { numerator: 30, denominator: 1 }, + b: { numerator: 60, denominator: 2 }, + }), + ).toBe(false); + }); + + test("returns true for equal fractional rates", () => { + expect( + frameRatesEqual({ + a: { numerator: 24000, denominator: 1001 }, + b: { numerator: 24000, denominator: 1001 }, + }), + ).toBe(true); + }); +}); + +describe("floatToFrameRate", () => { + test("maps standard integer fps to standard rates", () => { + expect(floatToFrameRate(24)).toEqual({ numerator: 24, denominator: 1 }); + expect(floatToFrameRate(30)).toEqual({ numerator: 30, denominator: 1 }); + expect(floatToFrameRate(60)).toEqual({ numerator: 60, denominator: 1 }); + }); + + test("maps NTSC-like fps to standard fractional rates", () => { + const result = floatToFrameRate(23.976); + expect(result.numerator).toBe(24000); + expect(result.denominator).toBe(1001); + }); + + test("maps 29.97 to NTSC 30fps", () => { + const result = floatToFrameRate(29.97); + expect(result.numerator).toBe(30000); + expect(result.denominator).toBe(1001); + }); + + test("handles arbitrary integer fps", () => { + const result = floatToFrameRate(120); + expect(result).toEqual({ numerator: 120, denominator: 1 }); + }); + + test("reduces arbitrary fractional fps with GCD", () => { + const result = floatToFrameRate(25); + expect(result).toEqual({ numerator: 25, denominator: 1 }); + }); + + test("handles 1 fps", () => { + expect(floatToFrameRate(1)).toEqual({ numerator: 1, denominator: 1 }); + }); + + test("handles fps with many decimal places", () => { + const result = floatToFrameRate(29.97002997); + // Should map to a standard rate or produce a reduced fraction + expect(result.numerator).toBeGreaterThan(0); + expect(result.denominator).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/src/params/__tests__/params.test.ts b/apps/web/src/params/__tests__/params.test.ts new file mode 100644 index 000000000..a5d61b4a6 --- /dev/null +++ b/apps/web/src/params/__tests__/params.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, test } from "bun:test"; +import { + coerceParamValue, + getParamValueKind, + getParamDefaultInterpolation, + getParamNumericRange, +} from "../index"; +import type { ParamDefinition } from "../index"; + +describe("coerceParamValue", () => { + describe("number params", () => { + const numberParam: ParamDefinition = { + key: "opacity", + label: "Opacity", + type: "number", + default: 1, + min: 0, + max: 1, + step: 0.01, + }; + + test("returns valid number within range", () => { + expect(coerceParamValue({ param: numberParam, value: 0.5 })).toBe(0.5); + }); + + test("clamps to min", () => { + expect(coerceParamValue({ param: numberParam, value: -1 })).toBe(0); + }); + + test("clamps to max", () => { + expect(coerceParamValue({ param: numberParam, value: 2 })).toBe(1); + }); + + test("snaps to step", () => { + expect(coerceParamValue({ param: numberParam, value: 0.123 })).toBe(0.12); + }); + + test("returns null for NaN", () => { + expect(coerceParamValue({ param: numberParam, value: Number.NaN })).toBeNull(); + }); + + test("returns null for non-number", () => { + expect(coerceParamValue({ param: numberParam, value: "0.5" })).toBeNull(); + expect(coerceParamValue({ param: numberParam, value: true })).toBeNull(); + expect(coerceParamValue({ param: numberParam, value: null })).toBeNull(); + expect(coerceParamValue({ param: numberParam, value: undefined })).toBeNull(); + }); + + test("handles integer step", () => { + const intParam: ParamDefinition = { + key: "count", + label: "Count", + type: "number", + default: 0, + min: 0, + max: 100, + step: 1, + }; + expect(coerceParamValue({ param: intParam, value: 3.7 })).toBe(4); + expect(coerceParamValue({ param: intParam, value: 3.2 })).toBe(3); + }); + + test("handles param without max (unbounded above)", () => { + const unboundedParam: ParamDefinition = { + key: "val", + label: "Val", + type: "number", + default: 0, + min: 0, + step: 1, + }; + expect(coerceParamValue({ param: unboundedParam, value: 9999 })).toBe(9999); + }); + }); + + describe("boolean params", () => { + const boolParam: ParamDefinition = { + key: "visible", + label: "Visible", + type: "boolean", + default: true, + }; + + test("returns true for true", () => { + expect(coerceParamValue({ param: boolParam, value: true })).toBe(true); + }); + + test("returns false for false", () => { + expect(coerceParamValue({ param: boolParam, value: false })).toBe(false); + }); + + test("returns null for non-boolean", () => { + expect(coerceParamValue({ param: boolParam, value: 1 })).toBeNull(); + expect(coerceParamValue({ param: boolParam, value: "true" })).toBeNull(); + expect(coerceParamValue({ param: boolParam, value: 0 })).toBeNull(); + expect(coerceParamValue({ param: boolParam, value: null })).toBeNull(); + }); + }); + + describe("color params", () => { + const colorParam: ParamDefinition = { + key: "color", + label: "Color", + type: "color", + default: "#ffffff", + }; + + test("returns string value", () => { + expect(coerceParamValue({ param: colorParam, value: "#ff0000" })).toBe("#ff0000"); + }); + + test("returns empty string (valid string)", () => { + expect(coerceParamValue({ param: colorParam, value: "" })).toBe(""); + }); + + test("returns null for non-string", () => { + expect(coerceParamValue({ param: colorParam, value: 0xff0000 })).toBeNull(); + expect(coerceParamValue({ param: colorParam, value: true })).toBeNull(); + }); + }); + + describe("text params", () => { + const textParam: ParamDefinition = { + key: "text", + label: "Text", + type: "text", + default: "", + }; + + test("returns string value", () => { + expect(coerceParamValue({ param: textParam, value: "hello" })).toBe("hello"); + }); + + test("returns null for non-string", () => { + expect(coerceParamValue({ param: textParam, value: 123 })).toBeNull(); + }); + }); + + describe("select params", () => { + const selectParam: ParamDefinition = { + key: "align", + label: "Alignment", + type: "select", + default: "left", + options: [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + { value: "right", label: "Right" }, + ], + }; + + test("returns value if it matches an option", () => { + expect(coerceParamValue({ param: selectParam, value: "center" })).toBe("center"); + }); + + test("returns null if value is not in options", () => { + expect(coerceParamValue({ param: selectParam, value: "justify" })).toBeNull(); + }); + + test("returns null for non-string", () => { + expect(coerceParamValue({ param: selectParam, value: 0 })).toBeNull(); + }); + }); +}); + +describe("getParamValueKind", () => { + test("returns 'number' for number params", () => { + const param: ParamDefinition = { + key: "x", + label: "X", + type: "number", + default: 0, + min: 0, + max: 100, + step: 1, + }; + expect(getParamValueKind({ param })).toBe("number"); + }); + + test("returns 'discrete' for boolean params", () => { + const param: ParamDefinition = { + key: "vis", + label: "Visible", + type: "boolean", + default: true, + }; + expect(getParamValueKind({ param })).toBe("discrete"); + }); + + test("returns 'color' for color params", () => { + const param: ParamDefinition = { + key: "color", + label: "Color", + type: "color", + default: "#fff", + }; + expect(getParamValueKind({ param })).toBe("color"); + }); + + test("returns 'discrete' for text params", () => { + const param: ParamDefinition = { + key: "text", + label: "Text", + type: "text", + default: "", + }; + expect(getParamValueKind({ param })).toBe("discrete"); + }); + + test("returns 'discrete' for select params", () => { + const param: ParamDefinition = { + key: "align", + label: "Alignment", + type: "select", + default: "left", + options: [{ value: "left", label: "Left" }], + }; + expect(getParamValueKind({ param })).toBe("discrete"); + }); +}); + +describe("getParamDefaultInterpolation", () => { + test("returns 'linear' for number params", () => { + const param: ParamDefinition = { + key: "x", + label: "X", + type: "number", + default: 0, + min: 0, + max: 100, + step: 1, + }; + expect(getParamDefaultInterpolation({ param })).toBe("linear"); + }); + + test("returns 'hold' for boolean params", () => { + const param: ParamDefinition = { + key: "vis", + label: "Visible", + type: "boolean", + default: true, + }; + expect(getParamDefaultInterpolation({ param })).toBe("hold"); + }); + + test("returns 'hold' for text params", () => { + const param: ParamDefinition = { + key: "text", + label: "Text", + type: "text", + default: "", + }; + expect(getParamDefaultInterpolation({ param })).toBe("hold"); + }); + + test("returns 'linear' for color params (first component)", () => { + const param: ParamDefinition = { + key: "color", + label: "Color", + type: "color", + default: "#fff", + }; + expect(getParamDefaultInterpolation({ param })).toBe("linear"); + }); +}); + +describe("getParamNumericRange", () => { + test("returns range for number params", () => { + const param: ParamDefinition = { + key: "opacity", + label: "Opacity", + type: "number", + default: 1, + min: 0, + max: 1, + step: 0.01, + }; + expect(getParamNumericRange({ param })).toEqual({ min: 0, max: 1, step: 0.01 }); + }); + + test("returns undefined for non-number params", () => { + const boolParam: ParamDefinition = { + key: "vis", + label: "Visible", + type: "boolean", + default: true, + }; + expect(getParamNumericRange({ param: boolParam })).toBeUndefined(); + }); + + test("returns undefined for color params", () => { + const colorParam: ParamDefinition = { + key: "color", + label: "Color", + type: "color", + default: "#fff", + }; + expect(getParamNumericRange({ param: colorParam })).toBeUndefined(); + }); + + test("handles number param without max", () => { + const param: ParamDefinition = { + key: "val", + label: "Val", + type: "number", + default: 0, + min: 0, + step: 1, + }; + expect(getParamNumericRange({ param })).toEqual({ min: 0, max: undefined, step: 1 }); + }); +}); diff --git a/apps/web/src/rendering/__tests__/rendering.test.ts b/apps/web/src/rendering/__tests__/rendering.test.ts new file mode 100644 index 000000000..b4ef44664 --- /dev/null +++ b/apps/web/src/rendering/__tests__/rendering.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import { + buildTransformFromParams, + readOpacityFromParams, + readBlendModeFromParams, +} from ".."; +import type { ParamValues } from "@/params"; + +describe("buildTransformFromParams", () => { + test("returns default transform for empty params", () => { + const result = buildTransformFromParams({ params: {} }); + expect(result).toEqual({ + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }); + }); + + test("reads all transform values from params", () => { + const params: ParamValues = { + "transform.scaleX": 2, + "transform.scaleY": 0.5, + "transform.positionX": 100, + "transform.positionY": -50, + "transform.rotate": 45, + }; + const result = buildTransformFromParams({ params }); + expect(result).toEqual({ + scaleX: 2, + scaleY: 0.5, + position: { x: 100, y: -50 }, + rotate: 45, + }); + }); + + test("uses defaults for missing transform keys", () => { + const params: ParamValues = { + "transform.scaleX": 3, + }; + const result = buildTransformFromParams({ params }); + expect(result.scaleX).toBe(3); + expect(result.scaleY).toBe(1); + expect(result.position.x).toBe(0); + expect(result.position.y).toBe(0); + expect(result.rotate).toBe(0); + }); + + test("ignores non-number values and uses defaults", () => { + const params: ParamValues = { + "transform.scaleX": "invalid" as unknown as number, + "transform.rotate": true as unknown as number, + }; + const result = buildTransformFromParams({ params }); + expect(result.scaleX).toBe(1); + expect(result.rotate).toBe(0); + }); + + test("handles zero values", () => { + const params: ParamValues = { + "transform.scaleX": 0, + "transform.scaleY": 0, + "transform.positionX": 0, + "transform.positionY": 0, + "transform.rotate": 0, + }; + const result = buildTransformFromParams({ params }); + expect(result).toEqual({ + scaleX: 0, + scaleY: 0, + position: { x: 0, y: 0 }, + rotate: 0, + }); + }); + + test("handles negative values", () => { + const params: ParamValues = { + "transform.scaleX": -1, + "transform.positionX": -200, + "transform.rotate": -90, + }; + const result = buildTransformFromParams({ params }); + expect(result.scaleX).toBe(-1); + expect(result.position.x).toBe(-200); + expect(result.rotate).toBe(-90); + }); +}); + +describe("readOpacityFromParams", () => { + test("returns 1 for empty params", () => { + expect(readOpacityFromParams({ params: {} })).toBe(1); + }); + + test("reads opacity from params", () => { + expect( + readOpacityFromParams({ params: { opacity: 0.5 } }), + ).toBe(0.5); + }); + + test("returns default for non-number opacity", () => { + expect( + readOpacityFromParams({ params: { opacity: "half" as unknown as number } }), + ).toBe(1); + }); + + test("handles zero opacity", () => { + expect( + readOpacityFromParams({ params: { opacity: 0 } }), + ).toBe(0); + }); +}); + +describe("readBlendModeFromParams", () => { + test("returns 'normal' for empty params", () => { + expect(readBlendModeFromParams({ params: {} })).toBe("normal"); + }); + + test("returns 'normal' for non-string blend mode", () => { + expect( + readBlendModeFromParams({ params: { blendMode: 123 as unknown as string } }), + ).toBe("normal"); + }); + + test("reads valid blend modes", () => { + expect( + readBlendModeFromParams({ params: { blendMode: "multiply" } }), + ).toBe("multiply"); + expect( + readBlendModeFromParams({ params: { blendMode: "screen" } }), + ).toBe("screen"); + expect( + readBlendModeFromParams({ params: { blendMode: "overlay" } }), + ).toBe("overlay"); + }); + + test("returns 'normal' for invalid blend mode string", () => { + expect( + readBlendModeFromParams({ params: { blendMode: "invalid-mode" } }), + ).toBe("normal"); + }); + + test("reads all valid blend modes", () => { + const validModes = [ + "normal", "darken", "multiply", "color-burn", "lighten", "screen", + "plus-lighter", "color-dodge", "overlay", "soft-light", "hard-light", + "difference", "exclusion", "hue", "saturation", "color", "luminosity", + ]; + for (const mode of validModes) { + expect( + readBlendModeFromParams({ params: { blendMode: mode } }), + ).toBe(mode); + } + }); +}); diff --git a/apps/web/src/subtitles/__tests__/ass.test.ts b/apps/web/src/subtitles/__tests__/ass.test.ts new file mode 100644 index 000000000..cc333ccec --- /dev/null +++ b/apps/web/src/subtitles/__tests__/ass.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, test } from "bun:test"; +import { parseAss } from "../ass"; + +describe("parseAss", () => { + const MINIMAL_ASS = `[Script Info] +Title: Test +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello World`; + + describe("basic parsing", () => { + test("parses a minimal ASS file with one dialogue", () => { + const result = parseAss({ input: MINIMAL_ASS }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Hello World"); + expect(result.captions[0].startTime).toBe(1); + expect(result.captions[0].duration).toBe(2); + expect(result.skippedCueCount).toBe(0); + }); + + test("parses multiple dialogue lines", () => { + const input = `${MINIMAL_ASS} +Dialogue: 0,0:00:05.00,0:00:07.00,Default,,0,0,0,,Second line`; + const result = parseAss({ input }); + expect(result.captions).toHaveLength(2); + expect(result.captions[1].text).toBe("Second line"); + expect(result.captions[1].startTime).toBe(5); + expect(result.captions[1].duration).toBe(2); + }); + }); + + describe("play resolution", () => { + test("uses default resolution 384x288 when not specified", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Test`; + const result = parseAss({ input }); + expect(result.captions[0].style?.fontSizeRatioOfPlayHeight).toBeCloseTo(20 / 288, 3); + }); + + test("uses custom play resolution from Script Info", () => { + const input = `[Script Info] +Title: Test +PlayResX: 1920 +PlayResY: 1080 +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,100,100,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Custom res`; + const result = parseAss({ input }); + expect(result.captions[0].style?.fontSizeRatioOfPlayHeight).toBeCloseTo(48 / 1080, 3); + }); + }); + + describe("style parsing", () => { + test("parses v4+ style properties", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Helvetica,36,&H0000FFFF,&H000000FF,&H00000000,&H00000000,-1,-1,0,0,100,100,2,0,1,2,0,5,20,20,15,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Styled text`; + const result = parseAss({ input }); + const style = result.captions[0].style; + expect(style).toBeDefined(); + expect(style?.fontFamily).toBe("Helvetica"); + expect(style?.fontWeight).toBe("bold"); + expect(style?.fontStyle).toBe("italic"); + expect(style?.letterSpacing).toBe(2); + expect(style?.textAlign).toBe("center"); + expect(style?.placement?.verticalAlign).toBe("middle"); + }); + + test("falls back to default style for missing style name", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,NonExistent,,0,0,0,,Fallback test`; + const result = parseAss({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].style).toBeDefined(); + expect(result.warnings.some((w) => w.includes("missing ASS styles"))).toBe(true); + }); + }); + + describe("event format fields", () => { + test("handles extra comma in text field (last field captures remainder)", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello, world`; + const result = parseAss({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Hello, world"); + }); + }); + + describe("override tag stripping", () => { + test("strips inline override tags from dialogue text", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\b1}Bold text{\b0}`; + const result = parseAss({ input }); + expect(result.captions[0].text).toBe("Bold text"); + expect(result.warnings.some((w) => w.includes("inline override tags"))).toBe(true); + }); + + test("converts \\\\N to newline", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Line one\\NLine two`; + const result = parseAss({ input }); + expect(result.captions[0].text).toBe("Line one\nLine two"); + }); + + test("converts \\\\h to non-breaking space", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello\\hWorld`; + const result = parseAss({ input }); + expect(result.captions[0].text).toBe("Hello World"); + }); + }); + + describe("empty input", () => { + test("returns empty captions for empty string", () => { + const result = parseAss({ input: "" }); + expect(result.captions).toEqual([]); + expect(result.skippedCueCount).toBe(0); + expect(result.warnings).toEqual([]); + }); + + test("returns empty captions for whitespace-only input", () => { + const result = parseAss({ input: " \n\n " }); + expect(result.captions).toEqual([]); + }); + }); + + describe("alignment mapping", () => { + test("maps ASS alignment 2 to center/bottom", () => { + const result = parseAss({ input: MINIMAL_ASS }); + expect(result.captions[0].style?.textAlign).toBe("center"); + expect(result.captions[0].style?.placement?.verticalAlign).toBe("bottom"); + }); + + test("maps ASS alignment 5 to center/middle", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,5,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Align 5`; + const result = parseAss({ input }); + expect(result.captions[0].style?.textAlign).toBe("center"); + expect(result.captions[0].style?.placement?.verticalAlign).toBe("middle"); + }); + + test("maps ASS alignment 9 to right/top", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,9,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Align 9`; + const result = parseAss({ input }); + expect(result.captions[0].style?.textAlign).toBe("right"); + expect(result.captions[0].style?.placement?.verticalAlign).toBe("top"); + }); + }); + + describe("non-dialogue events", () => { + test("ignores non-dialogue events (Comment, etc.)", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Comment: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,This is a comment +Dialogue: 0,0:00:05.00,0:00:07.00,Default,,0,0,0,,Real dialogue`; + const result = parseAss({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Real dialogue"); + expect(result.warnings.some((w) => w.includes("non-dialogue"))).toBe(true); + }); + }); + + describe("effects", () => { + test("ignores event effects field", () => { + const input = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,fade(200,200,Effect text`; + const result = parseAss({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("200,Effect text"); + expect(result.warnings.some((w) => w.includes("effects"))).toBe(true); + }); + }); +}); diff --git a/apps/web/src/subtitles/__tests__/parse.test.ts b/apps/web/src/subtitles/__tests__/parse.test.ts new file mode 100644 index 000000000..37f111231 --- /dev/null +++ b/apps/web/src/subtitles/__tests__/parse.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { parseSubtitleFile } from "../parse"; + +describe("parseSubtitleFile", () => { + const SRT_INPUT = `1 +00:00:01,000 --> 00:00:02,000 +Hello`; + + const ASS_INPUT = `[Script Info] +ScriptType: v4.00+ + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Hello`; + + test("dispatches .srt files to parseSrt", () => { + const result = parseSubtitleFile({ fileName: "test.srt", input: SRT_INPUT }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Hello"); + }); + + test("dispatches .ass files to parseAss", () => { + const result = parseSubtitleFile({ fileName: "test.ass", input: ASS_INPUT }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Hello"); + }); + + test("throws for unsupported extension .txt", () => { + expect(() => parseSubtitleFile({ fileName: "test.txt", input: "some text" })).toThrow( + "Unsupported subtitle format", + ); + }); + + test("throws for unsupported extension .vtt", () => { + expect(() => parseSubtitleFile({ fileName: "test.vtt", input: "WEBVTT" })).toThrow( + "Unsupported subtitle format", + ); + }); + + test("throws for unsupported extension .sub", () => { + expect(() => parseSubtitleFile({ fileName: "test.sub", input: "" })).toThrow( + "Unsupported subtitle format", + ); + }); + + test("matches extensions case-insensitively", () => { + const result1 = parseSubtitleFile({ fileName: "test.SRT", input: SRT_INPUT }); + expect(result1.captions).toHaveLength(1); + + const result2 = parseSubtitleFile({ fileName: "test.ASS", input: ASS_INPUT }); + expect(result2.captions).toHaveLength(1); + + const result3 = parseSubtitleFile({ fileName: "test.Srt", input: SRT_INPUT }); + expect(result3.captions).toHaveLength(1); + }); + + test("handles filenames with multiple dots", () => { + const result = parseSubtitleFile({ fileName: "my.project.v2.srt", input: SRT_INPUT }); + expect(result.captions).toHaveLength(1); + }); + + test("throws for file with no extension", () => { + expect(() => parseSubtitleFile({ fileName: "noextension", input: SRT_INPUT })).toThrow( + "Unsupported subtitle format", + ); + }); +}); diff --git a/apps/web/src/subtitles/__tests__/srt.test.ts b/apps/web/src/subtitles/__tests__/srt.test.ts new file mode 100644 index 000000000..927813350 --- /dev/null +++ b/apps/web/src/subtitles/__tests__/srt.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from "bun:test"; +import { parseSrt } from "../srt"; + +describe("parseSrt", () => { + describe("basic parsing", () => { + test("parses multiple cues with standard format", () => { + const input = `1 +00:00:01,000 --> 00:00:02,000 +Hello + +2 +00:00:03,000 --> 00:00:05,000 +World`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(2); + expect(result.captions[0]).toEqual({ + text: "Hello", + startTime: 1, + duration: 1, + }); + expect(result.captions[1]).toEqual({ + text: "World", + startTime: 3, + duration: 2, + }); + expect(result.skippedCueCount).toBe(0); + expect(result.warnings).toEqual([]); + }); + }); + + describe("timestamp formats", () => { + test("parses HH:MM:SS,mmm timestamps (comma separator)", () => { + const input = `1 +00:01:30,500 --> 00:01:31,750 +Test`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].startTime).toBe(90.5); + expect(result.captions[0].duration).toBeCloseTo(1.25); + }); + + test("parses HH:MM:SS.mmm timestamps (dot separator)", () => { + const input = `1 +00:01:30.500 --> 00:01:31.750 +Test`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].startTime).toBe(90.5); + expect(result.captions[0].duration).toBeCloseTo(1.25); + }); + + test("handles mixed comma and dot separators", () => { + const input = `1 +00:00:01,000 --> 00:00:02.500 +Mixed`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].startTime).toBe(1); + expect(result.captions[0].duration).toBeCloseTo(1.5); + }); + + test("handles 1-digit millisecond values", () => { + const input = `1 +00:00:01,5 --> 00:00:02,0 +Short ms`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].startTime).toBe(1.5); + expect(result.captions[0].duration).toBeCloseTo(0.5); + }); + + test("handles 2-digit millisecond values", () => { + const input = `1 +00:00:01,50 --> 00:00:02,00 +Two digit ms`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].startTime).toBe(1.5); + }); + }); + + describe("multi-line text", () => { + test("parses multi-line cue text", () => { + const input = `1 +00:00:01,000 --> 00:00:05,000 +Line one +Line two +Line three`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Line one\nLine two\nLine three"); + }); + }); + + describe("sequence numbers", () => { + test("parses cues with sequence numbers", () => { + const input = `1 +00:00:01,000 --> 00:00:02,000 +First + +42 +00:00:03,000 --> 00:00:04,000 +Second`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(2); + }); + + test("parses cues without sequence numbers (timestamp on first line)", () => { + const input = `00:00:01,000 --> 00:00:02,000 +No sequence number`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("No sequence number"); + }); + }); + + describe("empty input", () => { + test("returns empty captions for empty string", () => { + const result = parseSrt({ input: "" }); + expect(result.captions).toEqual([]); + expect(result.skippedCueCount).toBe(0); + expect(result.warnings).toEqual([]); + }); + + test("returns empty captions for whitespace-only input", () => { + const result = parseSrt({ input: " \n\n " }); + expect(result.captions).toEqual([]); + expect(result.skippedCueCount).toBe(0); + }); + }); + + describe("malformed blocks", () => { + test("skips block with missing timestamp line", () => { + const input = `1 +No timestamp here + +2 +00:00:01,000 --> 00:00:02,000 +Valid`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Valid"); + expect(result.skippedCueCount).toBeGreaterThanOrEqual(1); + }); + + test("skips block with bad timestamp format", () => { + const input = `1 +00:00:01 --> 00:00:02 +Missing milliseconds`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(0); + expect(result.skippedCueCount).toBeGreaterThanOrEqual(1); + }); + + test("skips block with only one line", () => { + const input = `1 +00:00:01,000 --> 00:00:02,000 +Valid + +Orphan line`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Valid"); + }); + + test("skips block with empty text after timestamp", () => { + const input = `1 +00:00:01,000 --> 00:00:02,000 + +2 +00:00:03,000 --> 00:00:04,000 +Has text`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Has text"); + expect(result.skippedCueCount).toBeGreaterThanOrEqual(1); + }); + }); + + describe("non-positive duration", () => { + test("skips cue where end equals start (zero duration)", () => { + const input = `1 +00:00:01,000 --> 00:00:01,000 +Zero duration + +2 +00:00:05,000 --> 00:00:06,000 +Valid`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Valid"); + expect(result.skippedCueCount).toBeGreaterThanOrEqual(1); + }); + + test("skips cue where end is before start (negative duration)", () => { + const input = `1 +00:00:05,000 --> 00:00:03,000 +Negative duration`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(0); + expect(result.skippedCueCount).toBeGreaterThanOrEqual(1); + }); + }); + + describe("line ending normalization", () => { + test("handles \\r\\n line endings", () => { + const input = "1\r\n00:00:01,000 --> 00:00:02,000\r\nHello\r\n\r\n2\r\n00:00:03,000 --> 00:00:04,000\r\nWorld"; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(2); + expect(result.captions[0].text).toBe("Hello"); + expect(result.captions[1].text).toBe("World"); + }); + + test("handles \\r line endings (classic Mac)", () => { + const input = "1\r00:00:01,000 --> 00:00:02,000\rHello\r\r2\r00:00:03,000 --> 00:00:04,000\rWorld"; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(2); + expect(result.captions[0].text).toBe("Hello"); + expect(result.captions[1].text).toBe("World"); + }); + }); + + describe("whitespace handling", () => { + test("trims leading/trailing whitespace from cue text", () => { + const input = `1 +00:00:01,000 --> 00:00:02,000 + Padded text `; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].text).toBe("Padded text"); + }); + + test("handles extra blank lines between cues", () => { + const input = `1 +00:00:01,000 --> 00:00:02,000 +First + + +2 +00:00:03,000 --> 00:00:04,000 +Second`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(2); + }); + + test("handles whitespace in timestamp arrows", () => { + const input = `1 +00:00:01,000 --> 00:00:02,000 +Spaced arrow`; + const result = parseSrt({ input }); + expect(result.captions).toHaveLength(1); + expect(result.captions[0].startTime).toBe(1); + }); + }); +}); diff --git a/apps/web/src/utils/__tests__/math-extended.test.ts b/apps/web/src/utils/__tests__/math-extended.test.ts new file mode 100644 index 000000000..f8b2cd9dc --- /dev/null +++ b/apps/web/src/utils/__tests__/math-extended.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { + clamp, + clampRound, + getFractionDigitsForStep, + snapToStep, + isNearlyEqual, + formatNumberForDisplay, +} from "../math"; + +describe("clamp", () => { + test("returns value when within range", () => { + expect(clamp({ value: 5, min: 0, max: 10 })).toBe(5); + }); + + test("clamps to min when value is below range", () => { + expect(clamp({ value: -5, min: 0, max: 10 })).toBe(0); + }); + + test("clamps to max when value is above range", () => { + expect(clamp({ value: 15, min: 0, max: 10 })).toBe(10); + }); + + test("returns min when value equals min", () => { + expect(clamp({ value: 0, min: 0, max: 10 })).toBe(0); + }); + + test("returns max when value equals max", () => { + expect(clamp({ value: 10, min: 0, max: 10 })).toBe(10); + }); + + test("works with negative ranges", () => { + expect(clamp({ value: 0, min: -10, max: -1 })).toBe(-1); + expect(clamp({ value: -15, min: -10, max: -1 })).toBe(-10); + }); + + test("handles min > max (returns min)", () => { + expect(clamp({ value: 5, min: 10, max: 0 })).toBe(10); + }); +}); + +describe("clampRound", () => { + test("rounds then clamps value within range", () => { + expect(clampRound({ value: 5.6, min: 0, max: 10 })).toBe(6); + expect(clampRound({ value: 5.4, min: 0, max: 10 })).toBe(5); + }); + + test("clamps after rounding", () => { + expect(clampRound({ value: 9.7, min: 0, max: 10 })).toBe(10); + expect(clampRound({ value: 0.3, min: 0, max: 10 })).toBe(0); + }); + + test("handles values already at boundaries", () => { + expect(clampRound({ value: 0, min: 0, max: 10 })).toBe(0); + expect(clampRound({ value: 10, min: 0, max: 10 })).toBe(10); + }); + + test("rounds negative values correctly", () => { + expect(clampRound({ value: -0.4, min: -10, max: 10 })).toBeCloseTo(0); + expect(clampRound({ value: -0.6, min: -10, max: 10 })).toBe(-1); + }); +}); + +describe("getFractionDigitsForStep", () => { + test("returns 0 for integer step", () => { + expect(getFractionDigitsForStep({ step: 1 })).toBe(0); + expect(getFractionDigitsForStep({ step: 10 })).toBe(0); + expect(getFractionDigitsForStep({ step: 100 })).toBe(0); + }); + + test("returns correct digits for decimal step", () => { + expect(getFractionDigitsForStep({ step: 0.1 })).toBe(1); + expect(getFractionDigitsForStep({ step: 0.01 })).toBe(2); + expect(getFractionDigitsForStep({ step: 0.001 })).toBe(3); + expect(getFractionDigitsForStep({ step: 0.5 })).toBe(1); + expect(getFractionDigitsForStep({ step: 0.25 })).toBe(2); + }); + + test("handles scientific notation", () => { + expect(getFractionDigitsForStep({ step: 1e-2 })).toBe(2); + expect(getFractionDigitsForStep({ step: 1e-3 })).toBe(3); + expect(getFractionDigitsForStep({ step: 1e-6 })).toBe(6); + }); +}); + +describe("snapToStep", () => { + test("snaps value to nearest step", () => { + // Math.round(1.3/0.5)*0.5 = Math.round(2.6)*0.5 = 3*0.5 = 1.5 + expect(snapToStep({ value: 1.3, step: 0.5 })).toBe(1.5); + // Math.round(1.2/0.5)*0.5 = Math.round(2.4)*0.5 = 2*0.5 = 1.0 + expect(snapToStep({ value: 1.2, step: 0.5 })).toBe(1.0); + // Math.round(0.12/0.25)*0.25 = Math.round(0.48)*0.25 = 0*0.25 = 0 + expect(snapToStep({ value: 0.12, step: 0.25 })).toBe(0); + // Math.round(7/3)*3 = Math.round(2.333)*3 = 2*3 = 6 + expect(snapToStep({ value: 7, step: 3 })).toBe(6); + // Math.round(8/3)*3 = Math.round(2.667)*3 = 3*3 = 9 + expect(snapToStep({ value: 8, step: 3 })).toBe(9); + }); + + test("snaps integer steps correctly", () => { + expect(snapToStep({ value: 3.7, step: 1 })).toBe(4); + expect(snapToStep({ value: 3.2, step: 1 })).toBe(3); + }); + + test("returns value unchanged when step is 0", () => { + expect(snapToStep({ value: 1.234, step: 0 })).toBe(1.234); + expect(snapToStep({ value: -5.5, step: 0 })).toBe(-5.5); + }); + + test("returns value unchanged when step is negative", () => { + expect(snapToStep({ value: 1.234, step: -1 })).toBe(1.234); + }); + + test("handles value exactly on step boundary", () => { + expect(snapToStep({ value: 1.0, step: 0.5 })).toBe(1.0); + expect(snapToStep({ value: 3, step: 3 })).toBe(3); + }); + + test("handles negative values", () => { + // Math.round(-0.12/0.25)*0.25 = Math.round(-0.48)*0.25 = 0*0.25 = 0 + expect(snapToStep({ value: -0.12, step: 0.25 })).toBe(0); + // Math.round(-0.37/0.25)*0.25 = Math.round(-1.48)*0.25 = -1*0.25 = -0.25 + expect(snapToStep({ value: -0.37, step: 0.25 })).toBe(-0.25); + // Math.round(-1.3/0.5)*0.5 = Math.round(-2.6)*0.5 = -3*0.5 = -1.5 + expect(snapToStep({ value: -1.3, step: 0.5 })).toBe(-1.5); + }); +}); + +describe("isNearlyEqual", () => { + test("returns true for equal values", () => { + expect(isNearlyEqual({ leftValue: 1, rightValue: 1 })).toBe(true); + }); + + test("returns true for values within default epsilon", () => { + expect(isNearlyEqual({ leftValue: 1.0, rightValue: 1.00005 })).toBe(true); + }); + + test("returns false for values outside default epsilon", () => { + expect(isNearlyEqual({ leftValue: 1.0, rightValue: 1.1 })).toBe(false); + }); + + test("respects custom epsilon", () => { + expect(isNearlyEqual({ leftValue: 1.0, rightValue: 1.05, epsilon: 0.1 })).toBe(true); + expect(isNearlyEqual({ leftValue: 1.0, rightValue: 1.05, epsilon: 0.01 })).toBe(false); + }); + + test("handles negative values", () => { + expect(isNearlyEqual({ leftValue: -1.0, rightValue: -1.0 })).toBe(true); + expect(isNearlyEqual({ leftValue: -1.0, rightValue: -1.00005 })).toBe(true); + }); + + test("handles zero values", () => { + expect(isNearlyEqual({ leftValue: 0, rightValue: 0 })).toBe(true); + expect(isNearlyEqual({ leftValue: 0, rightValue: 0.00005 })).toBe(true); + }); +}); + +describe("formatNumberForDisplay", () => { + test("formats integer values", () => { + expect(formatNumberForDisplay({ value: 42 })).toBe("42"); + expect(formatNumberForDisplay({ value: 0 })).toBe("0"); + }); + + test("formats decimal values and strips trailing zeros", () => { + expect(formatNumberForDisplay({ value: 1.5 })).toBe("1.5"); + expect(formatNumberForDisplay({ value: 1.1 })).toBe("1.1"); + }); + + test("strips trailing zeros from fixed decimals", () => { + expect(formatNumberForDisplay({ value: 1.1 })).toBe("1.1"); + expect(formatNumberForDisplay({ value: 1.0 })).toBe("1"); + }); + + test("respects fractionDigits parameter", () => { + expect(formatNumberForDisplay({ value: 1.23456, fractionDigits: 2 })).toBe("1.23"); + // fractionDigits=3, maxFraction=3, minFraction=3, so trailing zeros preserved + expect(formatNumberForDisplay({ value: 1.5, fractionDigits: 3 })).toBe("1.500"); + }); + + test("respects minFractionDigits", () => { + // minFractionDigits=2 means at least 2 decimal places kept + expect(formatNumberForDisplay({ value: 1, minFractionDigits: 2 })).toBe("1.00"); + expect(formatNumberForDisplay({ value: 1.5, minFractionDigits: 3 })).toBe("1.500"); + }); + + test("respects maxFractionDigits", () => { + expect(formatNumberForDisplay({ value: 1.123456789, maxFractionDigits: 3 })).toBe("1.123"); + }); + + test("handles negative numbers", () => { + expect(formatNumberForDisplay({ value: -42 })).toBe("-42"); + expect(formatNumberForDisplay({ value: -1.5 })).toBe("-1.5"); + }); + + test("handles zero with fraction digits", () => { + // fractionDigits=2 → maxFrac=2, minFrac=2, so "0.00" not "0" + expect(formatNumberForDisplay({ value: 0, fractionDigits: 2 })).toBe("0.00"); + expect(formatNumberForDisplay({ value: 0, minFractionDigits: 2 })).toBe("0.00"); + }); + + test("fractionDigits overrides min and max", () => { + expect( + formatNumberForDisplay({ + value: 1.23456, + fractionDigits: 2, + minFractionDigits: 4, + maxFractionDigits: 6, + }), + ).toBe("1.23"); + }); +});