put('slides/kv.jpg', 'keyvisual-image'); Storage::disk('public')->put('slides/info1.jpg', 'info-image-1'); Storage::disk('public')->put('slides/info2.jpg', 'info-image-2'); $service = $this->serviceWithSong('Abspann Service', 'slides/kv.jpg'); $this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0); $this->createInfoSlide('info2.jpg', 'slides/info2.jpg', 1); $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $entries = $playlist->getEntries(); $names = array_map(fn ($entry) => $entry->getName(), $entries); // Abspann is the very last playlist item and appears exactly once. $last = end($entries); $this->assertSame('Abspann', $last->getName(), 'Abspann must be the last playlist item'); $this->assertSame(1, count(array_filter($names, fn ($n) => $n === 'Abspann'))); // The embedded Abspann .pro has 1 (keyvisual) + 2 (information) = 3 cues. $abspann = $playlist->getEmbeddedSong($last->getDocumentFilename()); $this->assertNotNull($abspann); $slides = $this->allParserSlides($abspann); $this->assertCount(3, $slides); // First cue is the key-visual (image-only background media). $this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual'); $this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl()); $this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media'); $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); // Remaining cues are the information slides (foreground media, labelled by filename). $this->assertTrue($slides[1]->hasMedia()); $this->assertFalse($slides[1]->hasBackgroundMedia()); $this->assertSame('info1.jpg', $slides[1]->getLabel()); $this->assertTrue($slides[2]->hasMedia()); $this->assertSame('info2.jpg', $slides[2]->getLabel()); $this->cleanupTempDir($result['temp_dir']); } /** * Auto-advance (10s), loop-to-first and the Dissolve transition are emitted into * the slideData by addAbspannPresentation and are covered by the parser repo's * in-memory ProFileGeneratorCompletionTest. They are intentionally NOT asserted * here: the parser's generated protobuf descriptor does not (yet) register the * Cue.completion_* and Presentation.transition fields, so serializeToString() * drops them on the disk round-trip that every export performs. Once the parser * stubs are regenerated these become observable on the exported .proplaylist and * this note can be replaced with real assertions. */ public function test_abspann_cues_carry_configured_abspann_macro(): void { Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image'); Storage::disk('public')->put('slides/info1.jpg', 'info-image-1'); $macro = Macro::factory()->create(['name' => 'Abspann Macro']); MacroAssignment::create([ 'part_type' => 'abspann', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0, ]); $service = $this->serviceWithSong('Abspann Macro Service', 'slides/kv.jpg'); $this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0); $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $entries = $playlist->getEntries(); $last = end($entries); $abspann = $playlist->getEmbeddedSong($last->getDocumentFilename()); $slides = $this->allParserSlides($abspann); $this->assertCount(2, $slides); foreach ($slides as $slide) { $this->assertTrue($slide->hasMacro(), 'Every Abspann cue must carry the abspann macro'); $this->assertSame('Abspann Macro', $slide->getMacroName()); } $this->cleanupTempDir($result['temp_dir']); } public function test_no_abspann_when_no_keyvisual_and_no_information_slides(): void { // A service with a song but no key-visual and no information slides. $service = $this->serviceWithSong('Ohne Abspann', null); $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); $this->assertNotContains('Abspann', $names, 'No Abspann item without key-visual and information slides'); $this->cleanupTempDir($result['temp_dir']); } private function serviceWithSong(string $title, ?string $keyVisual): Service { $service = Service::factory()->create([ 'title' => $title, 'date' => now(), 'key_visual_filename' => $keyVisual, ]); $song = $this->createSongWithContent($title.' Lied'); $serviceSong = ServiceSong::create([ 'service_id' => $service->id, 'song_id' => $song->id, 'cts_song_name' => $title.' Lied', 'order' => 1, ]); ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, 'title' => $title.' Lied', 'service_song_id' => $serviceSong->id, 'sort_order' => 1, 'is_before_event' => false, ]); return $service; } private function createInfoSlide(string $original, string $stored, int $sortOrder): Slide { return Slide::factory()->create([ 'type' => 'information', 'service_id' => null, 'original_filename' => $original, 'stored_filename' => $stored, 'sort_order' => $sortOrder, 'expire_date' => null, 'uploaded_at' => now()->subDay(), ]); } 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; } private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array { $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); } }