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 as a BACKGROUND media action (no content element). $this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual'); $this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl()); $this->assertSame([], $this->fillMediaUrls($slides[0]), 'Key-visual cue has no content image element'); $this->assertFalse($this->hasForegroundImageMediaAction($slides[0]), 'No foreground media action may remain'); $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); // Remaining cues are the information slides as content image elements, labelled by filename. $this->assertNotEmpty($this->fillMediaUrls($slides[1])); $this->assertNotContains('KEY_VISUAL.jpg', $this->fillMediaUrls($slides[1])); $this->assertFalse($slides[1]->hasBackgroundMedia()); $this->assertFalse($this->hasImageMediaAction($slides[1])); $this->assertSame('info1.jpg', $slides[1]->getLabel()); $this->assertNotEmpty($this->fillMediaUrls($slides[2])); $this->assertSame('info2.jpg', $slides[2]->getLabel()); // The Abspann is a self-advancing loop on the live parser round-trip: every cue // auto-advances after 10s and the last cue jumps back to the first ("auto repeat all"). $presentation = $abspann->getPresentation(); $cues = $presentation->getCues(); $this->assertCount(3, $cues); foreach ($cues as $cue) { $this->assertSame( CompletionActionType::COMPLETION_ACTION_TYPE_AFTER_TIME, $cue->getCompletionActionType(), 'Every Abspann cue must auto-advance after a fixed time', ); $this->assertSame(10.0, $cue->getCompletionTime(), 'Auto-advance must fire after 10 seconds'); } // Leading cues step to the next slide; the final cue loops back to the first. $this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[0]->getCompletionTargetType()); $this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[1]->getCompletionTargetType()); $this->assertSame( CompletionTargetType::COMPLETION_TARGET_TYPE_FIRST, $cues[count($cues) - 1]->getCompletionTargetType(), 'The final Abspann cue must loop back to the first cue', ); // The presentation carries the Dissolve transition (soft cross-fade between slides). $this->assertTrue($presentation->hasTransition(), 'Abspann must use a slide transition'); $effect = $presentation->getTransition()->getEffect(); $this->assertNotNull($effect); $this->assertSame('EC52A828-AD85-4602-B70C-1DEE7C904DB6', $effect->getRenderId()); $this->assertSame('Dissolve', $effect->getName()); $this->assertSame('Dissolves', $effect->getCategory()); $this->cleanupTempDir($result['temp_dir']); } /** * Auto-advance (10s), loop-to-first and the Dissolve transition survive the disk * round-trip and are asserted in * test_abspann_is_last_item_with_keyvisual_first_then_information_slides. This test * stays focused on the per-cue Abspann macro assignment. */ 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); } }