From 409b25977c7c23c58a9640b49fc18b3f92b6a182 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 19 Jan 2026 17:45:55 -0700 Subject: [PATCH] tests: Add NLP text processing, audiobook chapter encoding/decoding, and SHA256 hashing testing. --- src/lib/server/audiobook.ts | 2 +- src/lib/sha256.ts | 2 +- tests/unit/audiobook.spec.ts | 67 ++++++++++++++++++++++++++- tests/unit/nlp.spec.ts | 88 ++++++++++++++++++++++++++++++++++++ tests/unit/sha256.spec.ts | 34 ++++++++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 tests/unit/nlp.spec.ts create mode 100644 tests/unit/sha256.spec.ts diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts index f32a2a5..86993b5 100644 --- a/src/lib/server/audiobook.ts +++ b/src/lib/server/audiobook.ts @@ -57,7 +57,7 @@ export function encodeChapterFileName(index: number, title: string, format: 'mp3 return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`; } -function decodeChapterFileName(fileName: string): { index: number; title: string; format: 'mp3' | 'm4b' } | null { +export function decodeChapterFileName(fileName: string): { index: number; title: string; format: 'mp3' | 'm4b' } | null { const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName); if (!match) return null; const oneBased = Number(match[1]); diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts index d36f055..f4abc38 100644 --- a/src/lib/sha256.ts +++ b/src/lib/sha256.ts @@ -12,7 +12,7 @@ function rotr32(x: number, n: number): number { return ((x >>> n) | (x << (32 - n))) >>> 0; } -function sha256HexSoftwareFromBytes(bytes: Uint8Array): string { +export function sha256HexSoftwareFromBytes(bytes: Uint8Array): string { const K = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, diff --git a/tests/unit/audiobook.spec.ts b/tests/unit/audiobook.spec.ts index 1d990ad..729de08 100644 --- a/tests/unit/audiobook.spec.ts +++ b/tests/unit/audiobook.spec.ts @@ -1,5 +1,11 @@ import { test, expect } from '@playwright/test'; -import { escapeFFMetadata } from '../../src/lib/server/audiobook'; +import { + escapeFFMetadata, + encodeChapterTitleTag, + decodeChapterTitleTag, + encodeChapterFileName, + decodeChapterFileName +} from '../../src/lib/server/audiobook'; test.describe('escapeFFMetadata', () => { test('escapes special characters correctly', () => { @@ -30,3 +36,62 @@ test.describe('escapeFFMetadata', () => { expect(escapeFFMetadata(input)).toBe(input); }); }); + +test.describe('Title Tags', () => { + test('encodeChapterTitleTag formats correctly', () => { + expect(encodeChapterTitleTag(0, 'Intro')).toBe('0001 - Intro'); + expect(encodeChapterTitleTag(9, 'Chapter Ten')).toBe('0010 - Chapter Ten'); + }); + + test('encodeChapterTitleTag sanitizes inputs', () => { + expect(encodeChapterTitleTag(0, 'Line\nBreak')).toBe('0001 - Line Break'); + }); + + test('decodeChapterTitleTag parses correctly', () => { + expect(decodeChapterTitleTag('0001 - Intro')).toEqual({ index: 0, title: 'Intro' }); + expect(decodeChapterTitleTag('10 - Chapter Ten')).toEqual({ index: 9, title: 'Chapter Ten' }); + }); + + test('decodeChapterTitleTag handles flexible separators', () => { + expect(decodeChapterTitleTag('1: Intro')).toEqual({ index: 0, title: 'Intro' }); + expect(decodeChapterTitleTag('1. Intro')).toEqual({ index: 0, title: 'Intro' }); + }); + + test('decodeChapterTitleTag returns null for invalid input', () => { + expect(decodeChapterTitleTag('Not a chapter')).toBeNull(); + expect(decodeChapterTitleTag('0 - Zero index invalid')).toBeNull(); + }); +}); + +test.describe('Chapter File Names', () => { + test('encodeChapterFileName formats correctly', () => { + expect(encodeChapterFileName(0, 'Intro', 'mp3')).toBe('0001__Intro.mp3'); + expect(encodeChapterFileName(1, 'Part 2', 'm4b')).toBe('0002__Part%202.m4b'); + }); + + test('encodeChapterFileName sanitizes dangerous characters', () => { + // slash should be replaced by space -> then encoded + expect(encodeChapterFileName(0, 'Ac/Dc', 'mp3')).toBe('0001__Ac%20Dc.mp3'); + }); + + test('decodeChapterFileName parses correctly', () => { + expect(decodeChapterFileName('0001__Intro.mp3')).toEqual({ index: 0, title: 'Intro', format: 'mp3' }); + expect(decodeChapterFileName('0002__Part%202.m4b')).toEqual({ index: 1, title: 'Part 2', format: 'm4b' }); + }); + + test('decodeChapterFileName handles standard filenames without double-underscore', () => { + // The regex requires double underscore: /^(\d{1,6})__(.+)\.(mp3|m4b)$/i + expect(decodeChapterFileName('0001-chapter.mp3')).toBeNull(); + }); + + test('round trip consistency', () => { + const index = 5; + const title = 'My Cool Chapter'; + const format = 'mp3'; + + const encoded = encodeChapterFileName(index, title, format); + const decoded = decodeChapterFileName(encoded); + + expect(decoded).toEqual({ index, title, format }); + }); +}); diff --git a/tests/unit/nlp.spec.ts b/tests/unit/nlp.spec.ts new file mode 100644 index 0000000..4bb5485 --- /dev/null +++ b/tests/unit/nlp.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; +import { + preprocessSentenceForAudio, + splitIntoSentences, + processTextWithMapping +} from '../../src/lib/nlp'; + +test.describe('preprocessSentenceForAudio', () => { + test('removes URLs', () => { + const input = 'Check out https://example.com/page for more info'; + const expected = 'Check out - (link to example.com) - for more info'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); + + test('removes hyphenation', () => { + const input = 'This is a hyp- henated word'; + const expected = 'This is a hyphenated word'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); + + test('removes asterisks', () => { + const input = 'This is *bold* text'; + const expected = 'This is bold text'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); + + test('collapses whitespace', () => { + const input = 'Multiple spaces'; + const expected = 'Multiple spaces'; + expect(preprocessSentenceForAudio(input)).toBe(expected); + }); +}); + +test.describe('splitIntoSentences', () => { + test('groups short sentences into single block', () => { + const input = 'First sentence. Second sentence.'; + const result = splitIntoSentences(input); + expect(result).toHaveLength(1); + expect(result[0]).toBe('First sentence. Second sentence.'); + }); + + test('merges quoted dialogue (double quotes)', () => { + const input = 'He said, "This should be one block." and walked away.'; + const result = splitIntoSentences(input); + expect(result).toHaveLength(1); + expect(result[0]).toBe('He said, "This should be one block." and walked away.'); + }); + + test('merges quoted dialogue (curly quotes)', () => { + const input = 'She replied, “This also should be merged.” then smiled.'; + const result = splitIntoSentences(input); + expect(result).toHaveLength(1); + expect(result[0]).toBe('She replied, “This also should be merged.” then smiled.'); + }); + + test('respects max block length for long text', () => { + // MAX_BLOCK_LENGTH is 450 in nlp.ts + // We construct distinct sentences. + // If we make sentences short enough individually but long enough combined, + // they should be grouped until the limit is reached. + + const sentence = 'A'.repeat(100) + '.'; // 101 chars + // 4 sentences = 404 chars + 3 spaces = 407 chars (< 450). Should fit in one block. + // 5 sentences = 505 chars + 4 spaces = 509 chars (> 450). Should split. + + const input = Array(5).fill(sentence).join(' '); + const result = splitIntoSentences(input); + + expect(result.length).toBeGreaterThan(1); + // The first block should contain as many as possible + expect(result[0].length).toBeLessThanOrEqual(450); + }); +}); + +test.describe('processTextWithMapping', () => { + test('maps raw sentences to processed ones', () => { + const text = 'First (1). Second (2).'; + const { processedSentences, rawSentences, sentenceMapping } = processTextWithMapping(text); + + expect(processedSentences.length).toBeGreaterThan(0); + expect(rawSentences.length).toBeGreaterThan(0); + expect(sentenceMapping).toHaveLength(processedSentences.length); + + // Check structure of mapping + expect(sentenceMapping[0]).toHaveProperty('processedIndex'); + expect(sentenceMapping[0]).toHaveProperty('rawIndices'); + }); +}); diff --git a/tests/unit/sha256.spec.ts b/tests/unit/sha256.spec.ts new file mode 100644 index 0000000..030122a --- /dev/null +++ b/tests/unit/sha256.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { + sha256HexSoftwareFromBytes, + sha256HexFromString +} from '../../src/lib/sha256'; + +test.describe('SHA256 Software Implementation', () => { + // Known test vectors + // "" -> e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + // "abc" -> ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + + test('hashes empty string correctly', () => { + const input = new Uint8Array([]); + const expected = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + expect(sha256HexSoftwareFromBytes(input)).toBe(expected); + }); + + test('hashes "abc" correctly', () => { + const input = new TextEncoder().encode('abc'); + const expected = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; + expect(sha256HexSoftwareFromBytes(input)).toBe(expected); + }); + + test('matches WebCrypto/fallback output for long strings', async () => { + // Create a longer input + const text = 'a'.repeat(1000); + const input = new TextEncoder().encode(text); + + const software = sha256HexSoftwareFromBytes(input); + const automatic = await sha256HexFromString(text); + + expect(software).toBe(automatic); + }); +});