pp-planer/tests/Feature/SlideMediaElementTest.php
Thorsten Bus 94170857b6 feat(export): render uploaded slide images as slide content, keep background/keyvisual as background media action
- New SlideMediaElementBuilder converts FOREGROUND (uploaded) image media
  actions into slide-content fill.media elements placed at the top of the
  slide content, so uploaded information/moderation/sermon images render as
  real content rather than a media-layer action.
- Service background and key-visual images remain BACKGROUND media actions on
  the bottom layer; song text and name-tag stay text content elements.
- Wire the builder into ProExportService and ProBundleExportService (after
  generate) and split PlaylistExportService::writeProFile into
  generate -> inject -> write via ProFileWriter so the playlist path applies
  the same rule.
- Parser library unchanged.
- Migrate export tests to the combined rule via a shared InspectsSlideFillMedia
  support trait and add SlideMediaElementTest.
2026-07-07 22:15:12 +02:00

221 lines
9.1 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\Song;
use App\Services\ProExportService;
use App\Services\SlideMediaElementBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\Slide as ParserSlide;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
/**
* Verifies the COMBINED image rule enforced by SlideMediaElementBuilder:
*
* 1. Song slide → text element + BACKGROUND media action, NO image element.
* 2. Uploaded content slide → full-frame fill.media content element, NO foreground
* action (a service background, if set, stays a
* BACKGROUND media action behind the element).
* 3. Key-visual slide → BACKGROUND media action only, NO content element.
* 4. Name-tag slide → text element + BACKGROUND media action + macro.
*/
final class SlideMediaElementTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
private function bgData(string $name = 'BACKGROUND.jpg'): array
{
return ['path' => $name, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, 'bundleRelative' => true];
}
/** Generate a one-slide song and run the production builder over it. */
private function build(array $slideData, string $name = 'Test'): ParserSlide
{
$groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]];
$arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]];
$song = ProFileGenerator::generate($name, $groups, $arrangements);
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
return $song->getSlides()[0];
}
public function test_song_slide_keeps_background_action_and_text_without_image_element(): void
{
$groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [[
'text' => 'Textzeile',
'background' => $this->bgData(),
]]]];
$arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]];
$song = ProFileGenerator::generate('Song', $groups, $arrangements);
// Sanity: the generator emits a BACKGROUND media action before conversion.
$this->assertTrue($song->getSlides()[0]->hasBackgroundMedia());
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
$slide = $song->getSlides()[0];
// Background stays a BACKGROUND media action (NOT converted to an element).
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
// Text element preserved; no content fill.media element exists.
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertSame([], $this->fillMediaUrls($slide));
// No foreground image action.
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
$this->assertFalse($slide->hasMedia());
}
public function test_uploaded_content_slide_becomes_full_frame_element_without_foreground_action(): void
{
// Production uploaded-slide data: media + format + label + bundleRelative (no text).
$slide = $this->build([
'media' => 'UPLOAD.jpg',
'format' => 'JPG',
'label' => 'upload.jpg',
'bundleRelative' => true,
]);
// A single full-frame content fill.media element, no text.
$this->assertCount(1, $this->baseSlideElements($slide));
$this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0));
$this->assertTrue($this->isFullFrameAt($slide, 0));
$this->assertFalse($this->elementHasTextAt($slide, 0));
// No foreground media action remains; no background configured at all.
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
$this->assertFalse($slide->hasBackgroundMedia());
$this->assertFalse($this->hasImageMediaAction($slide));
}
public function test_uploaded_content_slide_with_service_background_keeps_background_action_behind_element(): void
{
$slide = $this->build([
'media' => 'UPLOAD.jpg',
'format' => 'JPG',
'label' => 'upload.jpg',
'bundleRelative' => true,
'background' => $this->bgData(),
]);
// Uploaded image is a full-frame content element on top.
$this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0));
$this->assertTrue($this->isFullFrameAt($slide, 0));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
// Service background stays a BACKGROUND media action behind the element.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
}
public function test_key_visual_slide_is_background_action_only_without_content_element(): void
{
// Production key-visual slide data: imageOnly + background.
$slide = $this->build([
'imageOnly' => true,
'background' => $this->bgData('KEY_VISUAL.jpg'),
]);
// Key-visual is a BACKGROUND media action, not a content element.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl());
// No content fill.media element and no foreground action.
$this->assertSame([], $this->fillMediaUrls($slide));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
}
public function test_name_tag_slide_keeps_text_background_action_and_macro(): void
{
$slide = $this->build([
'text' => 'Erika Predigt',
'subtitle' => 'Predigt',
'background' => $this->bgData('KEY_VISUAL.jpg'),
'macro' => [
'name' => 'Namenseinblender',
'uuid' => '11111111-1111-4111-8111-111111111111',
'collectionName' => '--MAIN--',
'collectionUuid' => null,
],
]);
// Text element preserved (name-tag text sits above the background).
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertSame([], $this->fillMediaUrls($slide));
// Key-visual behind the name tag stays a BACKGROUND media action.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl());
// Macro action preserved.
$this->assertTrue($slide->hasMacro());
$this->assertSame('Namenseinblender', $slide->getMacroName());
// No image content element, no foreground action.
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
}
public function test_exported_song_pro_roundtrip_keeps_background_action_and_text(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
$service = Service::factory()->create(['background_filename' => 'slides/background.jpg']);
$song = $this->createSongWithContent();
$path = app(ProExportService::class)->generateProFile($song, $service);
$parserSong = ProFileReader::read($path);
@unlink($path);
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
$this->assertNotEmpty($slides);
foreach ($slides as $slide) {
// Background survives the .pro write+read as a BACKGROUND media action.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
// Text preserved; no content image element; no foreground action.
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertNotSame('', $slide->getPlainText());
$this->assertSame([], $this->fillMediaUrls($slide));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
}
}
private function createSongWithContent(): Song
{
$song = Song::create([
'title' => 'Fill Media Song',
'ccli_id' => '99887',
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$verse = Label::firstOrCreate(['name' => 'Verse 1 - Fill Media Song'], ['color' => '#2196F3']);
$section = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$section->slides()->create(['order' => 1, 'text_content' => 'Zweite Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
}