pp-planer/tests/Feature/PlaylistSequenceTest.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

292 lines
11 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Setting;
use App\Models\Slide;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\PlaylistArchive;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class PlaylistSequenceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded_sermon_slides(): void
{
$this->configureNameTagMacro();
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
$service = Service::factory()->create([
'title' => 'Predigt Sequenz',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
'preacher_name_override' => 'Erika Predigt',
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
$this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0);
$this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$names = $this->entryNames($playlist);
$offset = $names[0] === 'Moderator' ? 1 : 0;
$this->assertSame(['Keyvisual-Predigt', 'Predigername', 'Predigt'], array_slice($names, $offset));
$keyVisualSlides = $this->slidesForEntry($playlist, $entries[$offset]);
$this->assertCount(1, $keyVisualSlides);
$this->assertTrue($keyVisualSlides[0]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $keyVisualSlides[0]->getBackgroundMediaUrl());
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]);
$this->assertCount(1, $nameTagSlides);
$this->assertSame("Erika Predigt\nPredigt", $nameTagSlides[0]->getPlainText());
$this->assertTrue($nameTagSlides[0]->hasMacro());
$sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]);
$this->assertCount(2, $sermonSlides);
$this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel());
$this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel());
$this->assertSame(2, Slide::count());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_moderator_nametag_is_first_presentation_for_first_visible_agenda_item(): void
{
$this->configureNameTagMacro();
$service = Service::factory()->create([
'title' => 'Moderator Sequenz',
'moderator_name' => 'Max Moderation',
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Vorprogramm',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => true,
'responsible' => [['name' => 'Versteckte Person']],
]);
$song = $this->createSongWithContent('Erstes sichtbares Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Erstes sichtbares Lied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Erstes sichtbares Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 2,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist));
$moderatorSlides = $this->slidesForEntry($playlist, $entries[0]);
$this->assertCount(1, $moderatorSlides);
$this->assertSame("Max Moderation\nModeration", $moderatorSlides[0]->getPlainText());
$this->assertTrue($moderatorSlides[0]->hasMacro());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_without_macro_configured_no_nametags_are_added_and_sermon_sequence_keeps_keyvisual_then_slides(): void
{
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
$service = Service::factory()->create([
'title' => 'Ohne Namenseinblender',
'date' => now(),
'key_visual_filename' => 'slides/keyvisual.jpg',
'moderator_name' => 'Max Moderation',
'preacher_name_override' => 'Erika Predigt',
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 1,
'is_before_event' => false,
]);
$this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0);
$this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$this->assertSame(['Keyvisual-Predigt', 'Predigt'], $this->entryNames($playlist));
$this->assertCount(1, $this->slidesForEntry($playlist, $entries[0]));
$sermonSlides = $this->slidesForEntry($playlist, $entries[1]);
$this->assertCount(2, $sermonSlides);
foreach (array_keys($playlist->getEmbeddedProFiles()) as $filename) {
foreach ($this->allParserSlides($playlist->getEmbeddedSong($filename)) as $slide) {
$this->assertFalse($slide->hasMacro());
}
}
$this->cleanupTempDir($result['temp_dir']);
}
public function test_without_moderator_name_no_moderator_nametag_is_added(): void
{
$this->configureNameTagMacro();
$service = Service::factory()->create([
'title' => 'Ohne Moderator',
'moderator_name' => null,
]);
$song = $this->createSongWithContent('Startlied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Startlied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Startlied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
'responsible' => [],
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$this->assertSame(['Startlied'], $this->entryNames($playlist));
$this->assertNull($playlist->getEmbeddedSong('Moderator.pro'));
$this->cleanupTempDir($result['temp_dir']);
}
private function configureNameTagMacro(): void
{
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
Setting::set('namenseinblender_macro_uuid', '11111111-1111-4111-8111-111111111111');
Setting::set('namenseinblender_macro_collection_name', 'Service Macros');
Setting::set('namenseinblender_macro_collection_uuid', '22222222-2222-4222-8222-222222222222');
}
private function createSermonSlide(Service $service, ServiceAgendaItem $agendaItem, string $filename, int $sortOrder): Slide
{
return Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'original_filename' => $filename,
'stored_filename' => 'slides/'.$filename,
'sort_order' => $sortOrder,
]);
}
private function createSongWithContent(string $title): Song
{
$song = Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$label = Label::firstOrCreate(
['name' => 'Verse 1 - '.$title],
['color' => '#2196F3'],
);
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
/** @return array<int, string> */
private function entryNames(PlaylistArchive $playlist): array
{
return array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
}
private function slidesForEntry(PlaylistArchive $playlist, $entry): array
{
$filename = $entry->getDocumentFilename();
$this->assertNotNull($filename);
return $this->allParserSlides($playlist->getEmbeddedSong($filename));
}
private function allParserSlides(?\ProPresenter\Parser\Song $parserSong): array
{
$this->assertNotNull($parserSong);
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
}