pp-planer/tests/Feature/CountdownSlideExportTest.php
Thorsten Bus c3c8f7f50d fix(export): restructure keyvisual/nametag sequence and macro placement
- MacroResolutionService: rank by position specificity (by_label >
  first/last > all_slides) so a broad all_slides macro no longer masks
  first_slide/last_slide assignments
- ProBundleExportService: resolve macros in a second pass over emitted
  slides, so last_slide still matches when files were skipped
- PlaylistExportService: keyvisual is now a transition between content
  items instead of a one-shot fallback; nametags use a unified 3-cue
  sequence (kv -> kv+name 10s auto-next -> kv) for moderator and preacher
- PlaylistExportService: prepend countdown standby slide (clock + kv)
  when the add_countdown_slide setting is enabled
2026-08-03 08:59:54 +02:00

181 lines
5.8 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\Setting;
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 CountdownSlideExportTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
public function test_countdown_slide_is_first_with_clock_and_keyvisual_background(): void
{
Setting::set('add_countdown_slide', '1');
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
$service = $this->serviceWithSong('Mit Countdown', 'slides/kv.jpg');
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$first = $entries[0];
$this->assertSame('Countdown', $first->getName(), 'Countdown must be the very first playlist item');
$this->assertTrue($first->isPresentation());
$countdownSong = $playlist->getEmbeddedSong($first->getDocumentFilename());
$this->assertNotNull($countdownSong, 'Embedded Countdown .pro missing');
$slides = $this->allParserSlides($countdownSong);
$this->assertCount(1, $slides);
$slide = $slides[0];
$this->assertTrue($slide->hasClock(), 'Countdown slide must carry a live clock element');
$this->assertSame('HH:mm', $slide->getClockFormat());
$this->assertTrue($slide->hasBackgroundMedia(), 'Countdown slide must have the key-visual as background');
$this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl());
$embeddedMedia = $playlist->getEmbeddedMediaFiles();
$this->assertArrayHasKey('KEY_VISUAL.jpg', $embeddedMedia, 'Key-visual must be embedded under fixed name');
$this->assertSame('keyvisual-image', $embeddedMedia['KEY_VISUAL.jpg']);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_no_countdown_slide_when_setting_disabled(): void
{
Setting::set('add_countdown_slide', '0');
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
$service = $this->serviceWithSong('Ohne Countdown', 'slides/kv.jpg');
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
$this->assertNotContains('Countdown', $names);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_no_countdown_slide_when_enabled_but_no_keyvisual(): void
{
Setting::set('add_countdown_slide', '1');
$service = $this->serviceWithSong('Countdown ohne KV', null);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
$this->assertNotContains('Countdown', $names);
$this->cleanupTempDir($result['temp_dir']);
}
private function serviceWithSong(string $title, ?string $keyVisualFilename): Service
{
$service = Service::factory()->create([
'title' => $title,
'date' => now(),
'key_visual_filename' => $keyVisualFilename,
'background_filename' => null,
'has_agenda' => true,
]);
$song = $this->createSongWithContent($title.' Lied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => $title.' Lied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => $title.' Lied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
return $service;
}
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);
}
}