feat(export): sermon sequence + moderator injection
This commit is contained in:
parent
bb877d16c6
commit
929bda2018
|
|
@ -95,3 +95,16 @@ ## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides
|
||||||
- Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`.
|
- Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`.
|
||||||
- Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged.
|
- Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged.
|
||||||
- Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`.
|
- Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`.
|
||||||
|
|
||||||
|
## [2026-05-31] Task: T10 — NameTagSlideBuilder
|
||||||
|
- New pure service `App\Services\NameTagSlideBuilder` builds ephemeral slideData only; it does not persist slides.
|
||||||
|
- `build()` gates solely on non-empty `Setting::get('namenseinblender_macro_name')`; no configured macro name returns `null` so callers skip nametag slides.
|
||||||
|
- Text contract is plain parser text string with exactly two lines: `$name."\n".$title`; convenience methods use `Moderation` and `Predigt`.
|
||||||
|
- Parser-facing macro uses camelCase keys: `name`, `uuid`, `collectionName`, `collectionUuid`; collection name defaults to `--MAIN--` via `Setting::get('namenseinblender_macro_collection_name', '--MAIN--')`.
|
||||||
|
- Tests: `tests/Feature/NameTagSlideBuilderTest.php` covers no-macro null, configured macro/text shape, and moderator/preacher titles. Full suite green: `ddev exec php artisan test` → 544 passed / 2703 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-10-nametag-macro.txt`, `.sisyphus/evidence/task-10-no-macro.txt`.
|
||||||
|
|
||||||
|
## Task 11: Sermon sequence + moderator injection
|
||||||
|
- Pint auto-removes same-namespace `use App\Services\X` imports (NameTagResolver/NameTagSlideBuilder are already in App\Services); rely on `app(Class::class)` resolution instead of importing.
|
||||||
|
- Sermon item WITH slides now ALWAYS gets a Keyvisual-Predigt .pro prepended (macro-independent), then Predigername nametag (only if macro set), then sermon slides. This changed KeyVisualFallbackTest expectation from 1 to 2 embedded .pro files.
|
||||||
|
- Moderator nametag injected as FIRST playlist entry for the first is_before_event=false agenda item (only if macro set).
|
||||||
|
- PlaylistArchive::getEntries() + entry->getName() is the way to assert playlist ORDER in tests.
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$embeddedFiles = [];
|
$embeddedFiles = [];
|
||||||
$skippedUnmatched = 0;
|
$skippedUnmatched = 0;
|
||||||
|
|
||||||
|
$moderatorSlideData = $this->buildModeratorSlideData($service);
|
||||||
|
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
|
||||||
|
|
||||||
foreach ($agendaItems as $item) {
|
foreach ($agendaItems as $item) {
|
||||||
|
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
|
||||||
|
$this->writeProAndEmbed(
|
||||||
|
'Moderator',
|
||||||
|
$moderatorSlideData,
|
||||||
|
$tempDir,
|
||||||
|
$playlistItems,
|
||||||
|
$embeddedFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) {
|
if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) {
|
||||||
$patterns = array_map('trim', explode(',', $announcementPatterns));
|
$patterns = array_map('trim', explode(',', $announcementPatterns));
|
||||||
$matcher = app(AgendaMatcherService::class);
|
$matcher = app(AgendaMatcherService::class);
|
||||||
|
|
@ -112,6 +125,11 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($item->slides->isNotEmpty()) {
|
if ($item->slides->isNotEmpty()) {
|
||||||
|
if ($this->backgroundPartTypeForAgendaItem($item) === 'sermon') {
|
||||||
|
$this->addKeyVisualSlide($service, $tempDir, $playlistItems, $embeddedFiles, 'Keyvisual-Predigt');
|
||||||
|
$this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
$label = $item->title ?: 'Folien';
|
$label = $item->title ?: 'Folien';
|
||||||
$this->addSlidesFromCollection(
|
$this->addSlidesFromCollection(
|
||||||
$item->slides,
|
$item->slides,
|
||||||
|
|
@ -447,6 +465,58 @@ protected function writeProFile(string $path, string $name, array $groups, array
|
||||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildModeratorSlideData(Service $service): ?array
|
||||||
|
{
|
||||||
|
$name = app(NameTagResolver::class)->moderatorFor($service);
|
||||||
|
if ($name === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(NameTagSlideBuilder::class)->buildModeratorSlide($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPreacherSlideData(Service $service): ?array
|
||||||
|
{
|
||||||
|
$name = app(NameTagResolver::class)->preacherFor($service);
|
||||||
|
if ($name === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(NameTagSlideBuilder::class)->buildPreacherSlide($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addKeyVisualSlide(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles, string $label = 'Keyvisual'): void
|
||||||
|
{
|
||||||
|
$kvData = $this->keyVisualData($service);
|
||||||
|
if ($kvData === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slideData = ['imageOnly' => true, 'background' => $kvData];
|
||||||
|
$this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addPreacherNameTag(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
|
||||||
|
{
|
||||||
|
$slideData = $this->buildPreacherSlideData($service);
|
||||||
|
if ($slideData === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeProAndEmbed('Predigername', $slideData, $tempDir, $playlistItems, $embeddedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
|
||||||
|
{
|
||||||
|
$groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]];
|
||||||
|
$arrangements = [['name' => 'normal', 'groupNames' => [$name]]];
|
||||||
|
$filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro';
|
||||||
|
$path = $tempDir.'/'.$filename;
|
||||||
|
$this->writeProFile($path, $name, $groups, $arrangements);
|
||||||
|
$embeddedFiles[$filename] = file_get_contents($path);
|
||||||
|
$playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
|
||||||
|
}
|
||||||
|
|
||||||
private function countSongLabels(\App\Models\Song $song): int
|
private function countSongLabels(\App\Models\Song $song): int
|
||||||
{
|
{
|
||||||
return $song->arrangements()
|
return $song->arrangements()
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void
|
||||||
$this->cleanupTempDir($result['temp_dir']);
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_agenda_item_with_uploaded_slides_exports_normally_without_extra_keyvisual_slide(): void
|
public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_sequence(): void
|
||||||
{
|
{
|
||||||
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||||
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
|
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
|
||||||
|
|
@ -106,6 +106,8 @@ public function test_agenda_item_with_uploaded_slides_exports_normally_without_e
|
||||||
'title' => 'Slides Service',
|
'title' => 'Slides Service',
|
||||||
'date' => now(),
|
'date' => now(),
|
||||||
'key_visual_filename' => 'slides/keyvisual.jpg',
|
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||||
|
'preacher_name' => 'Pastor Paul',
|
||||||
|
'preacher_name_override' => null,
|
||||||
]);
|
]);
|
||||||
$agendaItem = ServiceAgendaItem::factory()->create([
|
$agendaItem = ServiceAgendaItem::factory()->create([
|
||||||
'service_id' => $service->id,
|
'service_id' => $service->id,
|
||||||
|
|
@ -128,9 +130,16 @@ public function test_agenda_item_with_uploaded_slides_exports_normally_without_e
|
||||||
$playlist = ProPlaylistReader::read($result['path']);
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
|
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
|
||||||
|
|
||||||
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
$this->assertCount(2, $playlist->getEmbeddedProFiles());
|
||||||
$this->assertNotNull($sermonSong);
|
$this->assertNotNull($sermonSong);
|
||||||
|
|
||||||
|
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
||||||
|
$this->assertContains('Keyvisual-Predigt', $names);
|
||||||
|
$this->assertLessThan(
|
||||||
|
array_search('Predigt', $names, true),
|
||||||
|
array_search('Keyvisual-Predigt', $names, true),
|
||||||
|
);
|
||||||
|
|
||||||
$slides = $this->allParserSlides($sermonSong);
|
$slides = $this->allParserSlides($sermonSong);
|
||||||
$this->assertCount(1, $slides);
|
$this->assertCount(1, $slides);
|
||||||
$this->assertFalse($slides[0]->hasBackgroundMedia());
|
$this->assertFalse($slides[0]->hasBackgroundMedia());
|
||||||
|
|
|
||||||
188
tests/Feature/PlaylistSequenceTest.php
Normal file
188
tests/Feature/PlaylistSequenceTest.php
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceAgendaItem;
|
||||||
|
use App\Models\Setting;
|
||||||
|
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 PlaylistSequenceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Storage::fake('public');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sermon_sequence_prepends_keyvisual_and_preacher_nametag_before_slides(): void
|
||||||
|
{
|
||||||
|
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
|
||||||
|
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||||
|
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Predigt Service',
|
||||||
|
'date' => now(),
|
||||||
|
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||||
|
'moderator_name' => null,
|
||||||
|
'preacher_name' => 'Pastor Paul',
|
||||||
|
'preacher_name_override' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agendaItem = ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Predigt',
|
||||||
|
'service_song_id' => null,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'service_agenda_item_id' => $agendaItem->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
'original_filename' => 'sermon.jpg',
|
||||||
|
'stored_filename' => 'slides/sermon.jpg',
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||||
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
|
|
||||||
|
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
||||||
|
|
||||||
|
$kvIndex = array_search('Keyvisual-Predigt', $names, true);
|
||||||
|
$nameTagIndex = array_search('Predigername', $names, true);
|
||||||
|
$sermonIndex = array_search('Predigt', $names, true);
|
||||||
|
|
||||||
|
$this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing');
|
||||||
|
$this->assertNotFalse($nameTagIndex, 'Predigername entry missing');
|
||||||
|
$this->assertNotFalse($sermonIndex, 'Predigt entry missing');
|
||||||
|
|
||||||
|
$this->assertLessThan($nameTagIndex, $kvIndex, 'Keyvisual must come before preacher nametag');
|
||||||
|
$this->assertLessThan($sermonIndex, $nameTagIndex, 'Preacher nametag must come before sermon slides');
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_moderator_nametag_is_first_for_first_visible_agenda_item(): void
|
||||||
|
{
|
||||||
|
Setting::set('namenseinblender_macro_name', 'Namenseinblender');
|
||||||
|
Storage::disk('public')->put('slides/info.jpg', 'info-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Moderator Service',
|
||||||
|
'date' => now(),
|
||||||
|
'key_visual_filename' => null,
|
||||||
|
'moderator_name' => 'Moderator Max',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agendaItem = ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Begrüßung',
|
||||||
|
'service_song_id' => null,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'service_agenda_item_id' => $agendaItem->id,
|
||||||
|
'type' => 'moderation',
|
||||||
|
'original_filename' => 'info.jpg',
|
||||||
|
'stored_filename' => 'slides/info.jpg',
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||||
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
|
|
||||||
|
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
||||||
|
|
||||||
|
$this->assertNotEmpty($names);
|
||||||
|
$this->assertSame('Moderator', $names[0], 'Moderator nametag must be the first playlist entry');
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_without_macro_no_nametags_are_injected(): void
|
||||||
|
{
|
||||||
|
Setting::set('namenseinblender_macro_name', '');
|
||||||
|
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||||
|
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Ohne Macro',
|
||||||
|
'date' => now(),
|
||||||
|
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||||
|
'moderator_name' => 'Moderator Max',
|
||||||
|
'preacher_name' => 'Pastor Paul',
|
||||||
|
'preacher_name_override' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$agendaItem = ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Predigt',
|
||||||
|
'service_song_id' => null,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'service_agenda_item_id' => $agendaItem->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
'original_filename' => 'sermon.jpg',
|
||||||
|
'stored_filename' => 'slides/sermon.jpg',
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||||
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
|
|
||||||
|
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
||||||
|
|
||||||
|
$this->assertNotContains('Moderator', $names);
|
||||||
|
$this->assertNotContains('Predigername', $names);
|
||||||
|
|
||||||
|
$kvIndex = array_search('Keyvisual-Predigt', $names, true);
|
||||||
|
$sermonIndex = array_search('Predigt', $names, true);
|
||||||
|
$this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing');
|
||||||
|
$this->assertNotFalse($sermonIndex, 'Predigt entry missing');
|
||||||
|
$this->assertLessThan($sermonIndex, $kvIndex, 'Keyvisual must come before sermon slides');
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue