pp-planer/tests/Feature/PlaylistSermonMacroTest.php
Thorsten Bus c3c8f7f50d fix(export): restructure keyvisual/nametag sequence and macro placement
- MacroResolutionService: rank by position specificity (by_label >
  first/last > all_slides) so a broad all_slides macro no longer masks
  first_slide/last_slide assignments
- ProBundleExportService: resolve macros in a second pass over emitted
  slides, so last_slide still matches when files were skipped
- PlaylistExportService: keyvisual is now a transition between content
  items instead of a one-shot fallback; nametags use a unified 3-cue
  sequence (kv -> kv+name 10s auto-next -> kv) for moderator and preacher
- PlaylistExportService: prepend countdown standby slide (clock + kv)
  when the add_countdown_slide setting is enabled
2026-08-03 08:59:54 +02:00

269 lines
11 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class PlaylistSermonMacroTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
/**
* Agenda-based sermon export must attach position macros (first_slide /
* last_slide / all_slides) for part_type='sermon' onto the sermon CONTENT
* slides. The parser stores only one macro per slide (the app keeps
* $macros[0]); the MOST SPECIFIC position wins (by_label > first/last >
* all_slides, ties break by `order`), so first/last override all_slides on
* the first/last slide while the middle slide keeps all_slides.
*/
public function test_playlist_sermon_slides_carry_position_macros(): void
{
Storage::disk('public')->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']);
// Most specific position wins when several assignments match one slide
// (first/last beat all_slides regardless of `order`); see MacroResolutionService.
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 (more specific position).
$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 (more specific position).
$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);
}
}