tests: Add NLP text processing, audiobook chapter encoding/decoding, and SHA256 hashing testing.
This commit is contained in:
parent
8dbf904a21
commit
409b25977c
5 changed files with 190 additions and 3 deletions
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
88
tests/unit/nlp.spec.ts
Normal 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
34
tests/unit/sha256.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue