- 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.
267 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|