parse(ccliFixtureContent($filename)); expect($result)->toBeInstanceOf(ParsedCcliSong::class); expect($result->title)->not->toBeEmpty("Fixture {$filename}: title should not be empty"); expect($result->sections)->not->toBeEmpty("Fixture {$filename}: should have at least one section"); foreach ($result->sections as $section) { expect($section)->toBeInstanceOf(ParsedCcliSection::class); expect($section->kind)->not->toBeEmpty("Fixture {$filename}: section kind should not be empty"); expect($section->lines)->not->toBeEmpty("Fixture {$filename}: section should have lines"); } } }); test('english-only-multi-verse.txt parses 4+ sections without translation', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('english-only-multi-verse.txt')); expect(count($result->sections))->toBeGreaterThanOrEqual(4); expect($result->ccliId)->not->toBeNull(); $hasTranslated = false; foreach ($result->sections as $section) { if ($section->linesTranslated !== null) { $hasTranslated = true; } } expect($hasTranslated)->toBeFalse('English-only should have no linesTranslated'); }); test('english-german-side-by-side.txt extracts both languages per section', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('english-german-side-by-side.txt')); $translatedSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->linesTranslated !== null); expect(count($translatedSections))->toBeGreaterThanOrEqual(1, 'Should have at least 1 section with translation'); $first = array_values($translatedSections)[0]; expect($first->lines)->not->toBeEmpty(); expect($first->linesTranslated)->not->toBeEmpty(); }); test('german-only.txt detects German labels and normalizes section kind', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('german-only.txt')); $labels = array_map(fn (ParsedCcliSection $section): string => $section->label, $result->sections); $kinds = array_map(fn (ParsedCcliSection $section): string => $section->kind, $result->sections); expect($labels)->toContain('Strophe 1'); expect($kinds)->toContain('Verse'); expect($kinds)->toContain('Chorus'); }); test('common CCLI metadata formats extract the song ID but not license numbers', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse("Test Song\nTest Artist\n\nVerse 1\nLine\n\nCCLI Song # 1234567\nCCLI License # 111222"); expect($result->ccliId)->toBe('1234567'); $result = $parser->parse("Test Song\nTest Artist\n\nStrophe 1\nZeile\n\nCCLI-Nr. 7654321"); expect($result->ccliId)->toBe('7654321'); }); test('repeat-marker.txt preserves modifier in section DTO', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('repeat-marker.txt')); $repeatSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => $section->modifier !== null); expect(count($repeatSections))->toBeGreaterThanOrEqual(1, 'Should have at least 1 section with Repeat modifier'); }); test('umlauts.txt preserves Unicode characters', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('umlauts.txt')); $allText = $result->title; foreach ($result->sections as $section) { $allText .= implode(' ', $section->lines); } expect((bool) preg_match('/[äöüßÄÖÜ]/u', $allText))->toBeTrue('Umlauts should be preserved'); }); test('missing-copyright.txt returns null copyrightText', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('missing-copyright.txt')); expect($result->ccliId)->not->toBeNull('CCLI ID should still be extracted'); expect($result->copyrightText)->toBeNull('No © line should mean null copyrightText'); expect($result->year)->toBeNull('No © means no year either'); }); test('5-verses.txt handles 5 verse sections correctly', function (): void { $parser = new CcliPasteParser(); $result = $parser->parse(ccliFixtureContent('5-verses.txt')); $verseSections = array_filter($result->sections, fn (ParsedCcliSection $section): bool => in_array(mb_strtolower($section->kind), ['verse', 'strophe'], true)); expect(count($verseSections))->toBeGreaterThanOrEqual(5, 'Should have 5 verse sections'); }); test('parse throws InvalidArgumentException on empty input', function (): void { $parser = new CcliPasteParser(); expect(fn () => $parser->parse(''))->toThrow(InvalidArgumentException::class); }); test('parse throws InvalidArgumentException on text with no section labels', function (): void { $parser = new CcliPasteParser(); expect(fn () => $parser->parse('Just some random text without any section labels'))->toThrow(InvalidArgumentException::class); }); test('parse error messages are in German', function (): void { $parser = new CcliPasteParser(); try { $parser->parse(''); } catch (InvalidArgumentException $exception) { expect($exception->getMessage())->toMatch('/[A-Za-zÄÖÜäöü]/u'); expect($exception->getMessage())->not->toContain('Error:'); } });