feat(export): keyvisual fallback slides
This commit is contained in:
parent
196657b52b
commit
a19c967594
15
.sisyphus/evidence/task-9-fallback.txt
Normal file
15
.sisyphus/evidence/task-9-fallback.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
Task T9 keyvisual fallback evidence
|
||||||
|
|
||||||
|
Scenario: Service has key_visual_filename and a visible non-song agenda item with no uploaded/special slides.
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- PlaylistExportService creates an embedded .pro for the agenda item title.
|
||||||
|
- The generated .pro contains exactly one image-only slide with background media.
|
||||||
|
- Background media URL is Storage::disk('public')->path($keyvisual).
|
||||||
|
- Slide rows are not created; Slide::count() remains unchanged.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- RED before implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` failed with `Keine Songs mit Inhalt zum Exportieren gefunden.` for empty non-song agenda item.
|
||||||
|
- GREEN after implementation: `ddev exec php artisan test tests/Feature/KeyVisualFallbackTest.php` → 4 passed, 16 assertions.
|
||||||
|
- Full suite: `ddev exec php artisan test` → 541 passed, 2699 assertions.
|
||||||
|
- Pint: `ddev exec ./vendor/bin/pint app/Services/PlaylistExportService.php tests/Feature/KeyVisualFallbackTest.php` → PASS, 2 files.
|
||||||
16
.sisyphus/evidence/task-9-no-keyvisual.txt
Normal file
16
.sisyphus/evidence/task-9-no-keyvisual.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
Task T9 no-keyvisual evidence
|
||||||
|
|
||||||
|
Scenario: Service has no keyvisual and a visible non-song agenda item with no uploaded/special slides.
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Empty non-song agenda item adds no playlist entry and no .pro file.
|
||||||
|
- Song agenda items still export through the normal song .pro path.
|
||||||
|
- Uploaded agenda slides still export normally; no keyvisual slide is prepended.
|
||||||
|
- Song agenda items never receive keyvisual fallback slides.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `tests/Feature/KeyVisualFallbackTest.php` covers:
|
||||||
|
- no keyvisual → no empty-item .pro entry,
|
||||||
|
- song item → only song .pro,
|
||||||
|
- uploaded slides → exactly the uploaded slide presentation and no fallback background.
|
||||||
|
- Full suite after implementation: 541 passed, 2699 assertions.
|
||||||
|
|
@ -88,3 +88,10 @@ ## [2026-05-31] Task: T8 — Export background layer
|
||||||
- Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`.
|
- Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`.
|
||||||
- Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression.
|
- Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression.
|
||||||
- Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`.
|
- Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`.
|
||||||
|
|
||||||
|
## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides
|
||||||
|
- `PlaylistExportService` now adds an ephemeral keyvisual fallback presentation only in the agenda export path after song handling and after uploaded agenda slides are considered.
|
||||||
|
- Eligible fallback items: visible agenda items with `service_song_id === null`, no uploaded/special slides, not a nametag/namenseinblender marker, and `ServiceImageResolver::keyVisualFor($service)` resolves an existing storage file.
|
||||||
|
- 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.
|
||||||
|
- 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`.
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,18 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
||||||
$service,
|
$service,
|
||||||
$this->backgroundPartTypeForAgendaItem($item),
|
$this->backgroundPartTypeForAgendaItem($item),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isNameTagAgendaItem($item)) {
|
||||||
|
$this->addKeyVisualFallbackPresentation(
|
||||||
|
$item,
|
||||||
|
$service,
|
||||||
|
$tempDir,
|
||||||
|
$playlistItems,
|
||||||
|
$embeddedFiles,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,6 +394,54 @@ private function addSlidePresentation(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function addKeyVisualFallbackPresentation(
|
||||||
|
ServiceAgendaItem $item,
|
||||||
|
Service $service,
|
||||||
|
string $tempDir,
|
||||||
|
array &$playlistItems,
|
||||||
|
array &$embeddedFiles,
|
||||||
|
): void {
|
||||||
|
$background = $this->keyVisualData($service);
|
||||||
|
|
||||||
|
if ($background === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $item->title ?: 'Keyvisual';
|
||||||
|
$groups = [
|
||||||
|
[
|
||||||
|
'name' => 'Keyvisual',
|
||||||
|
'color' => [0, 0, 0, 1],
|
||||||
|
'slides' => [
|
||||||
|
[
|
||||||
|
'imageOnly' => true,
|
||||||
|
'background' => $background,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$arrangements = [
|
||||||
|
[
|
||||||
|
'name' => 'normal',
|
||||||
|
'groupNames' => ['Keyvisual'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
|
||||||
|
$proFilename = $safeLabel.'.pro';
|
||||||
|
$proPath = $tempDir.'/'.$proFilename;
|
||||||
|
|
||||||
|
$this->writeProFile($proPath, $label, $groups, $arrangements);
|
||||||
|
|
||||||
|
$embeddedFiles[$proFilename] = file_get_contents($proPath);
|
||||||
|
|
||||||
|
$playlistItems[] = [
|
||||||
|
'type' => 'presentation',
|
||||||
|
'name' => $label,
|
||||||
|
'path' => $proFilename,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||||
{
|
{
|
||||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||||
|
|
@ -415,6 +475,22 @@ private function backgroundData(?Service $service): ?array
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function keyVisualData(Service $service): ?array
|
||||||
|
{
|
||||||
|
$keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);
|
||||||
|
|
||||||
|
if ($keyVisual === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => Storage::disk('public')->path($keyVisual),
|
||||||
|
'format' => 'JPG',
|
||||||
|
'width' => 1920,
|
||||||
|
'height' => 1080,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
|
||||||
{
|
{
|
||||||
if ($background === null) {
|
if ($background === null) {
|
||||||
|
|
@ -446,6 +522,17 @@ private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): strin
|
||||||
: 'agenda_item';
|
: 'agenda_item';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isNameTagAgendaItem(ServiceAgendaItem $item): bool
|
||||||
|
{
|
||||||
|
$title = mb_strtolower($item->title ?? '');
|
||||||
|
$type = mb_strtolower($item->type ?? '');
|
||||||
|
|
||||||
|
return str_contains($title, 'nametag')
|
||||||
|
|| str_contains($title, 'namenseinblender')
|
||||||
|
|| str_contains($type, 'nametag')
|
||||||
|
|| str_contains($type, 'namenseinblender');
|
||||||
|
}
|
||||||
|
|
||||||
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
|
||||||
{
|
{
|
||||||
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
|
||||||
|
|
|
||||||
237
tests/Feature/KeyVisualFallbackTest.php
Normal file
237
tests/Feature/KeyVisualFallbackTest.php
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Label;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceAgendaItem;
|
||||||
|
use App\Models\ServiceSong;
|
||||||
|
use App\Models\Slide;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Services\PlaylistExportService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use ProPresenter\Parser\ProPlaylistReader;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class KeyVisualFallbackTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Storage::fake('public');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisual_fallback(): void
|
||||||
|
{
|
||||||
|
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Keyvisual Service',
|
||||||
|
'date' => now(),
|
||||||
|
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Begrüßung',
|
||||||
|
'service_song_id' => null,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$slideCountBefore = Slide::count();
|
||||||
|
|
||||||
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||||
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
|
$embeddedProFiles = $playlist->getEmbeddedProFiles();
|
||||||
|
$fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro');
|
||||||
|
|
||||||
|
$this->assertCount(1, $embeddedProFiles);
|
||||||
|
$this->assertNotNull($fallbackSong);
|
||||||
|
$this->assertSame($slideCountBefore, Slide::count());
|
||||||
|
|
||||||
|
$slides = $this->allParserSlides($fallbackSong);
|
||||||
|
$this->assertCount(1, $slides);
|
||||||
|
$this->assertTrue($slides[0]->hasBackgroundMedia());
|
||||||
|
$this->assertSame(Storage::disk('public')->path('slides/keyvisual.jpg'), $slides[0]->getBackgroundMediaUrl());
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_song_agenda_item_does_not_get_keyvisual_fallback(): void
|
||||||
|
{
|
||||||
|
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Song Service',
|
||||||
|
'date' => now(),
|
||||||
|
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||||
|
]);
|
||||||
|
$song = $this->createSongWithContent('Nur ein Lied');
|
||||||
|
$serviceSong = ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'cts_song_name' => 'Nur ein Lied',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Nur ein Lied',
|
||||||
|
'service_song_id' => $serviceSong->id,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||||
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
|
|
||||||
|
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||||
|
$this->assertNotNull($playlist->getEmbeddedSong('Nur ein Lied.pro'));
|
||||||
|
$this->assertNull($playlist->getEmbeddedSong('Keyvisual.pro'));
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_agenda_item_with_uploaded_slides_exports_normally_without_extra_keyvisual_slide(): void
|
||||||
|
{
|
||||||
|
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||||
|
Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Slides Service',
|
||||||
|
'date' => now(),
|
||||||
|
'key_visual_filename' => 'slides/keyvisual.jpg',
|
||||||
|
]);
|
||||||
|
$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']);
|
||||||
|
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
|
||||||
|
|
||||||
|
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||||
|
$this->assertNotNull($sermonSong);
|
||||||
|
|
||||||
|
$slides = $this->allParserSlides($sermonSong);
|
||||||
|
$this->assertCount(1, $slides);
|
||||||
|
$this->assertFalse($slides[0]->hasBackgroundMedia());
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void
|
||||||
|
{
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'title' => 'Ohne Keyvisual',
|
||||||
|
'date' => now(),
|
||||||
|
'key_visual_filename' => null,
|
||||||
|
]);
|
||||||
|
$song = $this->createSongWithContent('Vorhandenes Lied');
|
||||||
|
$serviceSong = ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'cts_song_name' => 'Vorhandenes Lied',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Begrüßung ohne Folien',
|
||||||
|
'service_song_id' => null,
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
ServiceAgendaItem::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'title' => 'Vorhandenes Lied',
|
||||||
|
'service_song_id' => $serviceSong->id,
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_before_event' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||||
|
$playlist = ProPlaylistReader::read($result['path']);
|
||||||
|
|
||||||
|
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||||
|
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
|
||||||
|
$this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro'));
|
||||||
|
|
||||||
|
$this->cleanupTempDir($result['temp_dir']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
);
|
||||||
|
$label->songSlides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
|
||||||
|
|
||||||
|
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
|
||||||
|
$arrangement->arrangementLabels()->create(['label_id' => $label->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue