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); // A moderator name tag may precede the sermon intro when the (factory-random) // responsible person resolves to a moderator; skip it if present. $offset = str_starts_with((string) ($names[0] ?? ''), 'Moderator') ? 1 : 0; // A looping "Abspann" (key-visual) presentation is appended as the last item. $this->assertSame(['Prediger - Erika Predigt', 'Predigt', 'Abspann'], array_slice($names, $offset)); // The sermon intro is ONE presentation with two cues: the key-visual alone, // then the same key-visual with the preacher name tag rendered on top. $introSlides = $this->slidesForEntry($playlist, $entries[$offset]); $this->assertCount(2, $introSlides); $this->assertTrue($introSlides[0]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl()); $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); $this->assertTrue($introSlides[1]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl()); // Name and role are split: the name is the main (\fs84) run and the // role is a separate smaller, non-bold (\b0\fs50) subtitle run. [$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]); $this->assertSame('Erika Predigt', $name); $this->assertSame('Predigt', $subtitle); $this->assertTrue($introSlides[1]->hasMacro()); $sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]); $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 - Max Moderation', 'Erstes sichtbares Lied'], $this->entryNames($playlist)); $moderatorSlides = $this->slidesForEntry($playlist, $entries[0]); $this->assertCount(1, $moderatorSlides); // Name and role are split: the name is the main run, the role the subtitle run. [$name, $subtitle] = $this->nameTagNameAndSubtitle($moderatorSlides[0]); $this->assertSame('Max Moderation', $name); $this->assertSame('Moderation', $subtitle); $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(); // A looping "Abspann" (key-visual) presentation is appended as the last item. $this->assertSame(['Keyvisual-Predigt', 'Predigt', 'Abspann'], $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']); } public function test_nametag_presentations_are_named_by_role_and_person(): void { $this->configureNameTagMacro(); Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image'); $service = Service::factory()->create([ 'title' => 'Rollen Benennung', 'date' => now(), 'key_visual_filename' => 'slides/keyvisual.jpg', 'moderator_name' => 'Max Moderation', 'preacher_name_override' => 'Erika Predigt', ]); $song = $this->createSongWithContent('Eröffnungslied'); $serviceSong = ServiceSong::create([ 'service_id' => $service->id, 'song_id' => $song->id, 'cts_song_name' => 'Eröffnungslied', 'order' => 1, ]); ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, 'title' => 'Eröffnungslied', 'service_song_id' => $serviceSong->id, 'sort_order' => 1, 'is_before_event' => false, ]); $sermonItem = ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, 'title' => 'Predigt', 'service_song_id' => null, 'sort_order' => 2, 'is_before_event' => false, ]); $this->createSermonSlide($service, $sermonItem, 'sermon.jpg', 0); $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $names = $this->entryNames($playlist); $this->assertContains('Moderator - Max Moderation', $names); $this->assertContains('Prediger - Erika Predigt', $names); $this->cleanupTempDir($result['temp_dir']); } public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_no_images_presentation(): void { $this->configureNameTagMacro(); Setting::set('agenda_sermon_matching', 'Predigt'); Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); $service = Service::factory()->create([ 'title' => 'Predigt ohne Folien', 'date' => now(), 'key_visual_filename' => 'slides/keyvisual.jpg', 'preacher_name_override' => 'Erika Predigt', ]); ServiceAgendaItem::factory()->create([ 'service_id' => $service->id, 'title' => 'Predigt', 'service_song_id' => null, 'sort_order' => 1, 'is_before_event' => false, 'responsible' => [], ]); $result = app(PlaylistExportService::class)->generatePlaylist($service); $playlist = ProPlaylistReader::read($result['path']); $entries = $playlist->getEntries(); // A sermon item with no uploaded slides emits ONLY the intro presentation, // plus the looping "Abspann" (key-visual) presentation appended as the last item. $this->assertSame(['Prediger - Erika Predigt', 'Abspann'], $this->entryNames($playlist)); // The intro .pro carries both cues: key-visual alone + key-visual with name tag. $introSlides = $this->slidesForEntry($playlist, $entries[0]); $this->assertCount(2, $introSlides); $this->assertTrue($introSlides[0]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl()); $this->assertTrue($introSlides[1]->hasBackgroundMedia()); $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl()); [$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]); $this->assertSame('Erika Predigt', $name); $this->assertSame('Predigt', $subtitle); $this->assertTrue($introSlides[1]->hasMacro()); // No separate sermon-images presentation is emitted. $this->assertNull($playlist->getEmbeddedSong('Predigt.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 */ 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)); } /** * Split a name-tag slide into its [name, subtitle] parts. The generator emits * the name as the main (\fs84) run and the role as a separate smaller, * non-bold (\b0\fs50) subtitle run on a new line inside the same RTF element. * * @return array{0: string, 1: string} */ private function nameTagNameAndSubtitle(\ProPresenter\Parser\Slide $slide): array { $elements = $slide->getTextElements(); $this->assertNotEmpty($elements); $rtf = $elements[0]->getRtfData(); $this->assertStringContainsString('\b0\fs50', $rtf, 'Name-tag slide is missing the subtitle run'); // getPlainText() returns both runs joined by a newline ("Name\nRole"). $plain = $slide->getPlainText(); $parts = explode("\n", $plain, 2); $this->assertCount(2, $parts, 'Name-tag slide does not contain a name and a subtitle line'); return [trim($parts[0]), trim($parts[1])]; } 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); } }