pp-planer/tests/Feature/PlaylistSermonMacroTest.php
Thorsten Bus fdabaadb89 fix(export): preserve probundle extension and attach slide macros
- store() persisted uploads under Laravel's MIME-guessed extension,
  turning a .probundle (a ZIP) into .zip; embedExportProFile then
  branched on the stored path, missed the bundle branch and embedded
  the raw ZIP as a broken .pro (ProPresenter showed an empty slide).
  Store with the real client extension and derive the bundle type from
  the original filename, treating .zip as a bundle for legacy uploads.
- Skip export .pro/.probundle files that parse to zero content slides,
  recording a German warning instead of injecting an empty slide.
- Attach position macros (first_slide/last_slide/all_slides) per slide
  for information/moderation/sermon parts in the agenda and legacy paths.
- Cover both with ExportProFileProbundleEmbedTest and PlaylistSermonMacroTest.
2026-07-06 11:19:56 +02:00

267 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 slides.
* The parser stores only one macro per slide (the app keeps $macros[0]); the
* lowest `order` among the matching assignments wins, 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']);
// 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);
}
}