test/subtitles.test.ts (4363 bytes)
1 import fc from 'fast-check'; 2 import { describe, expect, it } from 'vitest'; 3 import { decodeSubtitles, lastCueAt, parseSubtitles } from '../src/subtitles'; 4 5 const SRT = `1 6 00:00:01,000 --> 00:00:03,500 7 The morning sun 8 9 2 10 00:00:03,500 --> 00:00:06,000 11 was clear and bright, 12 over the hills. 13 `; 14 15 const VTT = `WEBVTT 16 17 NOTE a comment block 18 19 00:01.000 --> 00:03.500 align:start 20 <v Narrator>The morning sun</v> 21 22 id-2 23 00:03.500 --> 00:06.000 24 was clear and bright 25 `; 26 27 describe('parseSubtitles', () => { 28 it('parses SRT with multi-line text', () => { 29 expect(parseSubtitles(SRT)).toEqual([ 30 { start: 1, end: 3.5, text: 'The morning sun' }, 31 { start: 3.5, end: 6, text: 'was clear and bright,\nover the hills.' }, 32 ]); 33 }); 34 35 it('parses WebVTT, skips the header and notes, strips tags', () => { 36 expect(parseSubtitles(VTT)).toEqual([ 37 { start: 1, end: 3.5, text: 'The morning sun' }, 38 { start: 3.5, end: 6, text: 'was clear and bright' }, 39 ]); 40 }); 41 42 it('accepts CRLF and a BOM', () => { 43 const text = '' + SRT.replace(/\n/g, '\r\n'); 44 expect(parseSubtitles(text)).toHaveLength(2); 45 }); 46 47 it('parses hours', () => { 48 const cues = parseSubtitles('1\n01:02:03,004 --> 01:02:04,000\nx\n'); 49 expect(cues[0]?.start).toBeCloseTo(3723.004, 6); 50 }); 51 }); 52 53 const NEKO = '吾輩は猫である。'; 54 55 /** NEKO in Shift_JIS. .NET made these bytes with Encoding.GetEncoding(932). */ 56 const NEKO_SHIFT_JIS = [ 57 0x8c, 0xe1, 0x94, 0x79, 0x82, 0xcd, 0x94, 0x4c, 0x82, 0xc5, 0x82, 0xa0, 0x82, 0xe9, 0x81, 0x42, 58 ]; 59 60 function utf8(text: string): ArrayBuffer { 61 const bytes = new TextEncoder().encode(text); 62 return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); 63 } 64 65 function utf16(text: string, littleEndian: boolean): ArrayBuffer { 66 const view = new DataView(new ArrayBuffer(text.length * 2)); 67 for (let i = 0; i < text.length; i++) view.setUint16(2 * i, text.charCodeAt(i), littleEndian); 68 return view.buffer; 69 } 70 71 describe('decodeSubtitles', () => { 72 it('decodes UTF-8 without a BOM', () => { 73 expect(decodeSubtitles(utf8(NEKO))).toBe(NEKO); 74 }); 75 76 it('decodes UTF-8 with a BOM, and removes the BOM', () => { 77 expect(decodeSubtitles(utf8('' + NEKO))).toBe(NEKO); 78 }); 79 80 it('keeps UTF-8 for a file with a UTF-8 BOM and a bad byte', () => { 81 const bytes = new Uint8Array([...new Uint8Array(utf8('' + NEKO)), 0xff]); 82 expect(decodeSubtitles(bytes.buffer)).toBe(NEKO + '�'); 83 }); 84 85 it('decodes UTF-16 with a BOM, and removes the BOM', () => { 86 expect(decodeSubtitles(utf16('' + NEKO, true))).toBe(NEKO); 87 expect(decodeSubtitles(utf16('' + NEKO, false))).toBe(NEKO); 88 }); 89 90 it('decodes Shift_JIS', () => { 91 expect(decodeSubtitles(new Uint8Array(NEKO_SHIFT_JIS).buffer)).toBe(NEKO); 92 }); 93 94 it('gives the cues of a Shift_JIS file', () => { 95 const time = new TextEncoder().encode('1\r\n00:00:00,000 --> 00:00:02,500\r\n'); 96 const file = new Uint8Array([...time, ...NEKO_SHIFT_JIS, 0x0d, 0x0a]); 97 expect(parseSubtitles(decodeSubtitles(file.buffer))).toEqual([ 98 { start: 0, end: 2.5, text: NEKO }, 99 ]); 100 }); 101 102 it('gives back each text that is encoded as UTF-8', () => { 103 fc.assert( 104 fc.property(fc.string({ unit: 'binary' }), (text) => { 105 // A BOM at the start is not text, so the decoder removes it. 106 expect(decodeSubtitles(utf8(text))).toBe(text.replace(/^/, '')); 107 }), 108 ); 109 }); 110 }); 111 112 describe('lastCueAt', () => { 113 const cues = [ 114 { start: 1, end: 2 }, 115 { start: 3, end: 4 }, 116 ]; 117 118 it('gives the last cue that started, also in the silence after it', () => { 119 expect(lastCueAt(cues, 0.5)).toBe(-1); 120 expect(lastCueAt(cues, 1)).toBe(0); 121 expect(lastCueAt(cues, 2.5)).toBe(0); 122 expect(lastCueAt(cues, 3)).toBe(1); 123 expect(lastCueAt(cues, 99)).toBe(1); 124 expect(lastCueAt([], 1)).toBe(-1); 125 }); 126 127 it('agrees with a linear scan', () => { 128 const starts = fc 129 .array(fc.nat({ max: 100 }), { maxLength: 30 }) 130 .map((s) => s.sort((a, b) => a - b)); 131 fc.assert( 132 fc.property(starts, fc.integer({ min: -5, max: 110 }), (sorted, t) => { 133 const list = sorted.map((start) => ({ start })); 134 let linear = -1; 135 list.forEach((c, i) => { 136 if (c.start <= t) linear = i; 137 }); 138 expect(lastCueAt(list, t)).toBe(linear); 139 }), 140 ); 141 }); 142 });