pp-planer/tests/Feature/KeyVisualFallbackTest.php
Thorsten Bus e33418f716 feat: song pre/postfix, settings overhaul, export & schedule fixes
Resolves a batch of bugs and feature requests across songs, services,
settings and export:

Songs & sections
- Every song now carries permanent, empty, locked PREFIX (COPYRIGHT) and
  POSTFIX (BLANK) sections, deduplicated on import; locked sections cannot
  be edited or deleted via UI or API.
- Song edit modal: explicit Speichern/Schließen with dirty-tracking,
  editable section headline (combobox + custom values), and a fix for the
  419 CSRF errors after CCLI "Importieren & Bearbeiten" (token read fresh
  per request).
- CCLI bookmarklet "Importieren & Bearbeiten" now opens the edit dialog.

Service schedule & arrangements
- Fixed assigned songs showing no sections (slides loaded for all
  arrangements, not just the default).
- Added "Song entfernen / neu zuordnen" to reassign an assigned song.
- Worship-leader arrangement is created/selected lazily when the
  arrangement dialog opens (only when not user-overridden); the leader is
  resolved from the "Lobpreis" agenda item, and manual create/clone names
  are prefixed with the leader name.

Navigation
- "/" redirects to the next upcoming service's edit page (or the list).
- Service titles link to the edit page.

Settings
- Renamed "Makro-Import"/"Label-Import" menu items; fixed drag-and-drop
  imports (were downloading the dropped file); added label-import hint;
  made the panel scrollable.
- Nametag now uses a single MacroPicker; added song prefix/postfix label
  defaults (COPYRIGHT #24B34C / BLANK #000000); new "Export-Dateien" menu
  to upload prefix/postfix .pro files added to every export.

Export
- Filenames/playlist names are date-first ("YYYY-MM-DD <Title>").
- Keyvisual slide only for the first content-less item after real content;
  all other content-less items render as headlines.
- New "Vorschau herunterladen" for non-finalized services (filename and
  import name prefixed "Vorschau" with export timestamp).
- Uploaded prefix/postfix .pro files wrap every export.

Tests updated to the new behavior; full suite green (569 passed).
2026-06-01 08:56:20 +02:00

271 lines
9.3 KiB
PHP

<?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_becomes_headline(): 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();
// Content-less item before any real content → HEADLINE (not keyvisual fallback)
$this->assertCount(1, $embeddedProFiles);
$this->assertSame($slideCountBefore, Slide::count());
// Headline filename matches pattern: Begrüßung-headline-<uniqid>.pro
$headlineKey = null;
foreach (array_keys($embeddedProFiles) as $key) {
if (str_contains($key, 'Begrüßung') && str_contains($key, '-headline-')) {
$headlineKey = $key;
break;
}
}
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung');
$this->assertMatchesRegularExpression('/^Begr[üu].*-headline-.*\.pro$/', $headlineKey);
// No KEY_VISUAL.jpg embedded (headline has no background media)
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$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_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_sequence(): 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',
'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']);
$sermonSong = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertCount(2, $playlist->getEmbeddedProFiles());
$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);
$this->assertCount(1, $slides);
$this->assertFalse($slides[0]->hasBackgroundMedia());
$this->cleanupTempDir($result['temp_dir']);
}
public function test_empty_non_song_item_before_content_becomes_headline(): 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']);
$embeddedProFiles = $playlist->getEmbeddedProFiles();
// Content-less item before real content → HEADLINE (adds a playlist entry)
// So we expect 2 embedded pro files: song + headline
$this->assertCount(2, $embeddedProFiles);
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
// Headline filename matches pattern: Begrüßung ohne Folien-headline-<uniqid>.pro
$headlineKey = null;
foreach (array_keys($embeddedProFiles) as $key) {
if (str_contains($key, 'Begrüßung ohne Folien') && str_contains($key, '-headline-')) {
$headlineKey = $key;
break;
}
}
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung ohne Folien');
// No KEY_VISUAL.jpg embedded (no keyvisual configured)
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$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'],
);
$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);
}
}