tests: Add NLP text processing, audiobook chapter encoding/decoding, and SHA256 hashing testing.

This commit is contained in:
Richard R 2026-01-19 17:45:55 -07:00
parent 8dbf904a21
commit 409b25977c
5 changed files with 190 additions and 3 deletions

View file

@ -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]);

View file

@ -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,

View file

@ -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 });
});
});

88
tests/unit/nlp.spec.ts Normal file
View file

@ -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');
});
});

34
tests/unit/sha256.spec.ts Normal file
View file

@ -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);
});
});