put('slides/sermon-1.jpg', 'sermon-one'); Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two'); Storage::disk('public')->put('slides/sermon-3.jpg', 'sermon-three'); $macroFirst = Macro::factory()->create(['name' => 'Predigt Erste']); $macroLast = Macro::factory()->create(['name' => 'Predigt Letzte']); $macroAll = Macro::factory()->create(['name' => 'Predigt Alle']); // Lower `order` wins when several assignments match one slide. MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroFirst->id, 'position' => 'first_slide', 'order' => 0]); MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroLast->id, 'position' => 'last_slide', 'order' => 1]); MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroAll->id, 'position' => 'all_slides', 'order' => 2]); $service = Service::factory()->create([ 'title' => 'Predigt Macro Service', 'date' => now(), ]); // A song with real (DB-driven) content guarantees the export always has at // least one content presentation, so it never hits the "Keine Songs mit // Inhalt zum Exportieren gefunden" guard regardless of test ordering or // slide-file resolution. `responsible => []` pins the agenda items so the // factory's random responsible data cannot inject stray name-tag slides. $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' => [], ]); $sermonItem = ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, 'title' => 'Predigt', 'service_song_id' => null, 'sort_order' => 2, 'is_before_event' => false, 'responsible' => [], ]); $this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0); $this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1); $this->createSermonSlide($service, $sermonItem, 'sermon-3.jpg', 2); $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $sermon = $playlist->getEmbeddedSong('Predigt.pro'); $this->assertNotNull($sermon, 'Embedded sermon .pro missing'); $slides = $this->allParserSlides($sermon); $this->assertCount(3, $slides); foreach ($slides as $slide) { $this->assertTrue($slide->hasMacro(), 'Every sermon slide must carry a macro (all_slides guarantees at least one)'); } // First slide: first_slide wins over all_slides (lower order). $this->assertSame('Predigt Erste', $slides[0]->getMacroName()); $this->assertSame($macroFirst->uuid, $slides[0]->getMacroUuid()); // Middle slide: only all_slides matches. $this->assertSame('Predigt Alle', $slides[1]->getMacroName()); // Last slide: last_slide wins over all_slides (lower order). $this->assertSame('Predigt Letzte', $slides[2]->getMacroName()); $this->assertSame($macroLast->uuid, $slides[2]->getMacroUuid()); $this->cleanupTempDir($result['temp_dir']); } /** * The legacy (no-agenda) export path emits Information, Moderation and Sermon * presentations with their own explicit part_type. Each must attach the * all_slides macro registered for that part_type onto every slide. */ public function test_legacy_information_moderation_sermon_slides_carry_macros(): void { Storage::disk('public')->put('slides/info-1.jpg', 'info-one'); Storage::disk('public')->put('slides/info-2.jpg', 'info-two'); Storage::disk('public')->put('slides/mod-1.jpg', 'mod-one'); Storage::disk('public')->put('slides/mod-2.jpg', 'mod-two'); Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one'); Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two'); $infoMacro = Macro::factory()->create(['name' => 'Info Macro']); $modMacro = Macro::factory()->create(['name' => 'Moderation Macro']); $sermonMacro = Macro::factory()->create(['name' => 'Predigt Macro']); MacroAssignment::create(['part_type' => 'information', 'macro_id' => $infoMacro->id, 'position' => 'all_slides', 'order' => 0]); MacroAssignment::create(['part_type' => 'moderation', 'macro_id' => $modMacro->id, 'position' => 'all_slides', 'order' => 0]); MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $sermonMacro->id, 'position' => 'all_slides', 'order' => 0]); // No agenda items => the export uses the legacy block-ordered path. $service = Service::factory()->create([ 'title' => 'Legacy Macro Service', 'date' => now(), ]); $this->createInfoSlide('info-1.jpg', 0); $this->createInfoSlide('info-2.jpg', 1); $this->createServiceSlide($service, 'moderation', 'mod-1.jpg', 0); $this->createServiceSlide($service, 'moderation', 'mod-2.jpg', 1); $this->createServiceSlide($service, 'sermon', 'sermon-1.jpg', 0); $this->createServiceSlide($service, 'sermon', 'sermon-2.jpg', 1); $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Informationen.pro'), 'Info Macro'); $this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Moderation.pro'), 'Moderation Macro'); $this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Predigt.pro'), 'Predigt Macro'); $this->cleanupTempDir($result['temp_dir']); } private function assertAllSlidesHaveMacro(?\ProPresenter\Parser\Song $song, string $macroName): void { $this->assertNotNull($song, "Embedded .pro for macro '{$macroName}' missing"); $slides = $this->allParserSlides($song); $this->assertCount(2, $slides); foreach ($slides as $slide) { $this->assertTrue($slide->hasMacro(), "Slide for '{$macroName}' must carry a macro"); $this->assertSame($macroName, $slide->getMacroName()); } } 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 createInfoSlide(string $filename, int $sortOrder): Slide { return Slide::factory()->create([ 'service_id' => null, 'type' => 'information', 'original_filename' => $filename, 'stored_filename' => 'slides/'.$filename, 'sort_order' => $sortOrder, 'expire_date' => null, 'uploaded_at' => now()->subDay(), ]); } private function createServiceSlide(Service $service, string $type, string $filename, int $sortOrder): Slide { return Slide::factory()->create([ 'service_id' => $service->id, 'type' => $type, 'original_filename' => $filename, 'stored_filename' => 'slides/'.$filename, 'sort_order' => $sortOrder, ]); } private function createSongWithContent(string $title): \App\Models\Song { $song = \App\Models\Song::create([ 'title' => $title, 'ccli_id' => fake()->unique()->numerify('#####'), 'author' => 'Test Author', 'copyright_text' => 'Test Publisher', ]); $label = \App\Models\Label::firstOrCreate( ['name' => 'Verse - '.$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); } }