Bump the propresenter parser to dev-master (20fe32a), which brings the background-layer and nametag-subtitle features onto the consumed bussnet/propresenter7-php-api branch and resolves the Slide::hasBackgroundMedia() regression. Strengthen AbspannExportTest to assert the now-live 10s auto-advance loop (completion AFTER_TIME == 10, target FIRST) and the Dissolve transition (renderId EC52A828-...).
256 lines
9.7 KiB
PHP
256 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Label;
|
|
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\Models\Song;
|
|
use App\Services\PlaylistExportService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use ProPresenter\Parser\ProPlaylistReader;
|
|
use Rv\Data\Cue\CompletionActionType;
|
|
use Rv\Data\Cue\CompletionTargetType;
|
|
use Tests\TestCase;
|
|
|
|
final class AbspannExportTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Storage::fake('public');
|
|
}
|
|
|
|
public function test_abspann_is_last_item_with_keyvisual_first_then_information_slides(): void
|
|
{
|
|
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
|
|
Storage::disk('public')->put('slides/info1.jpg', 'info-image-1');
|
|
Storage::disk('public')->put('slides/info2.jpg', 'info-image-2');
|
|
|
|
$service = $this->serviceWithSong('Abspann Service', 'slides/kv.jpg');
|
|
$this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0);
|
|
$this->createInfoSlide('info2.jpg', 'slides/info2.jpg', 1);
|
|
|
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
|
$playlist = ProPlaylistReader::read($result['path']);
|
|
$entries = $playlist->getEntries();
|
|
$names = array_map(fn ($entry) => $entry->getName(), $entries);
|
|
|
|
// Abspann is the very last playlist item and appears exactly once.
|
|
$last = end($entries);
|
|
$this->assertSame('Abspann', $last->getName(), 'Abspann must be the last playlist item');
|
|
$this->assertSame(1, count(array_filter($names, fn ($n) => $n === 'Abspann')));
|
|
|
|
// The embedded Abspann .pro has 1 (keyvisual) + 2 (information) = 3 cues.
|
|
$abspann = $playlist->getEmbeddedSong($last->getDocumentFilename());
|
|
$this->assertNotNull($abspann);
|
|
$slides = $this->allParserSlides($abspann);
|
|
$this->assertCount(3, $slides);
|
|
|
|
// First cue is the key-visual (image-only background media).
|
|
$this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual');
|
|
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
|
|
$this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media');
|
|
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
|
|
|
// Remaining cues are the information slides (foreground media, labelled by filename).
|
|
$this->assertTrue($slides[1]->hasMedia());
|
|
$this->assertFalse($slides[1]->hasBackgroundMedia());
|
|
$this->assertSame('info1.jpg', $slides[1]->getLabel());
|
|
$this->assertTrue($slides[2]->hasMedia());
|
|
$this->assertSame('info2.jpg', $slides[2]->getLabel());
|
|
|
|
// The Abspann is a self-advancing loop on the live parser round-trip: every cue
|
|
// auto-advances after 10s and the last cue jumps back to the first ("auto repeat all").
|
|
$presentation = $abspann->getPresentation();
|
|
$cues = $presentation->getCues();
|
|
$this->assertCount(3, $cues);
|
|
|
|
foreach ($cues as $cue) {
|
|
$this->assertSame(
|
|
CompletionActionType::COMPLETION_ACTION_TYPE_AFTER_TIME,
|
|
$cue->getCompletionActionType(),
|
|
'Every Abspann cue must auto-advance after a fixed time',
|
|
);
|
|
$this->assertSame(10.0, $cue->getCompletionTime(), 'Auto-advance must fire after 10 seconds');
|
|
}
|
|
|
|
// Leading cues step to the next slide; the final cue loops back to the first.
|
|
$this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[0]->getCompletionTargetType());
|
|
$this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[1]->getCompletionTargetType());
|
|
$this->assertSame(
|
|
CompletionTargetType::COMPLETION_TARGET_TYPE_FIRST,
|
|
$cues[count($cues) - 1]->getCompletionTargetType(),
|
|
'The final Abspann cue must loop back to the first cue',
|
|
);
|
|
|
|
// The presentation carries the Dissolve transition (soft cross-fade between slides).
|
|
$this->assertTrue($presentation->hasTransition(), 'Abspann must use a slide transition');
|
|
$effect = $presentation->getTransition()->getEffect();
|
|
$this->assertNotNull($effect);
|
|
$this->assertSame('EC52A828-AD85-4602-B70C-1DEE7C904DB6', $effect->getRenderId());
|
|
$this->assertSame('Dissolve', $effect->getName());
|
|
$this->assertSame('Dissolves', $effect->getCategory());
|
|
|
|
$this->cleanupTempDir($result['temp_dir']);
|
|
}
|
|
|
|
/**
|
|
* Auto-advance (10s), loop-to-first and the Dissolve transition survive the disk
|
|
* round-trip and are asserted in
|
|
* test_abspann_is_last_item_with_keyvisual_first_then_information_slides. This test
|
|
* stays focused on the per-cue Abspann macro assignment.
|
|
*/
|
|
public function test_abspann_cues_carry_configured_abspann_macro(): void
|
|
{
|
|
Storage::disk('public')->put('slides/kv.jpg', 'keyvisual-image');
|
|
Storage::disk('public')->put('slides/info1.jpg', 'info-image-1');
|
|
|
|
$macro = Macro::factory()->create(['name' => 'Abspann Macro']);
|
|
MacroAssignment::create([
|
|
'part_type' => 'abspann',
|
|
'macro_id' => $macro->id,
|
|
'position' => 'all_slides',
|
|
'order' => 0,
|
|
]);
|
|
|
|
$service = $this->serviceWithSong('Abspann Macro Service', 'slides/kv.jpg');
|
|
$this->createInfoSlide('info1.jpg', 'slides/info1.jpg', 0);
|
|
|
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
|
$playlist = ProPlaylistReader::read($result['path']);
|
|
$entries = $playlist->getEntries();
|
|
$last = end($entries);
|
|
|
|
$abspann = $playlist->getEmbeddedSong($last->getDocumentFilename());
|
|
$slides = $this->allParserSlides($abspann);
|
|
$this->assertCount(2, $slides);
|
|
|
|
foreach ($slides as $slide) {
|
|
$this->assertTrue($slide->hasMacro(), 'Every Abspann cue must carry the abspann macro');
|
|
$this->assertSame('Abspann Macro', $slide->getMacroName());
|
|
}
|
|
|
|
$this->cleanupTempDir($result['temp_dir']);
|
|
}
|
|
|
|
public function test_no_abspann_when_no_keyvisual_and_no_information_slides(): void
|
|
{
|
|
// A service with a song but no key-visual and no information slides.
|
|
$service = $this->serviceWithSong('Ohne Abspann', null);
|
|
|
|
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
|
$playlist = ProPlaylistReader::read($result['path']);
|
|
$names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries());
|
|
|
|
$this->assertNotContains('Abspann', $names, 'No Abspann item without key-visual and information slides');
|
|
|
|
$this->cleanupTempDir($result['temp_dir']);
|
|
}
|
|
|
|
private function serviceWithSong(string $title, ?string $keyVisual): Service
|
|
{
|
|
$service = Service::factory()->create([
|
|
'title' => $title,
|
|
'date' => now(),
|
|
'key_visual_filename' => $keyVisual,
|
|
]);
|
|
|
|
$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 createInfoSlide(string $original, string $stored, int $sortOrder): Slide
|
|
{
|
|
return Slide::factory()->create([
|
|
'type' => 'information',
|
|
'service_id' => null,
|
|
'original_filename' => $original,
|
|
'stored_filename' => $stored,
|
|
'sort_order' => $sortOrder,
|
|
'expire_date' => null,
|
|
'uploaded_at' => now()->subDay(),
|
|
]);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|