pp-planer/tests/Feature/CcliTranslationPairingServiceTest.php
Thorsten Bus ae42b48753 feat(songs): per-song sections + section editing; fix CCLI import bugs
Refactor lyric storage so each song owns its sections instead of sharing
global labels. Adds song_sections (per song+label) owning song_slides;
labels stay global ProPresenter group tags (name/color/macro). Arrangements
now reference sections, so editing/importing one song no longer corrupts
others that share a label name.

- New: song_sections table + migration with safe backfill; SongSection,
  SongArrangementSection models; SongSectionController (edit/add/delete
  sections, immediate persistence) wired into SongEditModal.
- Refactor writers/readers: CcliImport, ProImport, SongService,
  ArrangementController, SongController, ProExport, PDF, Translation
  (translation reset now section-scoped), CCLI pairing.
- CCLI import fixes: parse SongSelect copy-icon format (German "Vers"
  abbrev + trailing author), fill empty CTS-synced songs instead of
  blocking as duplicate, distinct label colors per section kind,
  import&edit/existing-song open the edit modal (no 404/405), teleport
  paste dialog above assign dialog, preview shows section content,
  correct SongSelect search URL, copy-icon instructions.
- Bookmarklet clicks #generalCopyLyricsButton and captures clipboard;
  serves correct host from request.
- Export: embed key-visual/background under fixed bundle-relative names.
- Tests updated for the section model; new section + isolation coverage.
2026-05-31 14:45:47 +02:00

136 lines
5.1 KiB
PHP

<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Models\SongSlide;
use App\Services\CcliTranslationPairingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeLocalSongForCcliPairing(array $labelConfig): Song
{
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'normal',
'is_default' => true,
]);
foreach ($labelConfig as $order => $config) {
['label_name' => $labelName, 'slide_count' => $slideCount] = $config;
$label = Label::firstOrCreate(
['name' => $labelName],
['color' => '#3B82F6'],
);
$section = SongSection::create([
'song_id' => $song->id,
'label_id' => $label->id,
'order' => $order + 1,
]);
SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => $order + 1,
]);
for ($i = 0; $i < $slideCount; $i++) {
SongSlide::create([
'song_section_id' => $section->id,
'order' => $i + 1,
'text_content' => "Original line $i for $labelName",
]);
}
}
return $song;
}
test('pairs matching English labels with CCLI sections', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
['label_name' => 'Verse 2', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nCCLI line 1\nCCLI line 2\n\nChorus\nCCLI chorus\n\nVerse 2\nCCLI v2 line1\nCCLI v2 line2\n\n© 2024 Test\nCCLI # 9999001";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toBeEmpty('All labels should match')
->and($result['mapping'])->toHaveCount(3)
->and($result['distributed_text'])->not->toBeEmpty();
foreach ($result['mapping'] as $entry) {
expect($entry['ccli_label'])->not->toBeNull("Label {$entry['local_label']} should have a CCLI match");
}
});
test('pairs German local labels with English CCLI labels via normalization', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Strophe 1', 'slide_count' => 2],
['label_name' => 'Refrain', 'slide_count' => 2],
['label_name' => 'Strophe 2', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nEN line 1\nEN line 2\n\nChorus\nEN chorus 1\nEN chorus 2\n\nVerse 2\nEN v2 1\nEN v2 2\n\n© 2024 Test\nCCLI # 9999002";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toBeEmpty('German labels should normalize to match English CCLI labels')
->and($result['mapping'])->toHaveCount(3)
->and($result['mapping'][0]['ccli_label'])->toBe('Verse 1')
->and($result['mapping'][1]['ccli_label'])->toBe('Chorus');
});
test('returns unmatched_labels for sections not in CCLI', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
['label_name' => 'Bridge', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nLine 1\nLine 2\n\nChorus\nChorus line\n\n© 2024 Test\nCCLI # 9999003";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['unmatched_labels'])->toContain('Bridge')
->and($result['mapping'])->toHaveCount(3)
->and($result['mapping'][2]['ccli_label'])->toBeNull()
->and($result['mapping'][2]['distributed_lines'])->toBe(['', '']);
});
test('distributes lines preserving local slide count', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
]);
$ccliText = "Test Song\nTest Artist\n\nVerse 1\nLine 1\nLine 2\nLine 3\nLine 4\n\n© 2024\nCCLI # 9999004";
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['mapping'][0]['distributed_lines'])->toHaveCount(2)
->and($result['distributed_text'])->toContain('Line 4');
});
test('uses linesTranslated from CCLI when available', function (): void {
$song = makeLocalSongForCcliPairing([
['label_name' => 'Verse 1', 'slide_count' => 2],
['label_name' => 'Chorus', 'slide_count' => 1],
]);
$ccliText = file_get_contents(base_path('tests/fixtures/ccli/english-german-side-by-side.txt'));
$result = app(CcliTranslationPairingService::class)->pair($song, $ccliText);
expect($result['song'])->toBeInstanceOf(Song::class)
->and($result['mapping'])->toBeArray()
->and($result['distributed_text'])->toContain('Deutsche Liedzeile')
->and($result['distributed_text'])->toContain('Deutscher Refrain');
});