propresenter-php/tests/ProFileGeneratorTest.php
Thorsten Bus 22ba4aff7d refactor: make repo Composer-compatible by moving php/ to root and ref/ to doc/reference_samples
- Move src/, tests/, bin/, generated/, proto/, composer.json, composer.lock, phpunit.xml from php/ to repo root
- Move ref/ to doc/reference_samples/ for better organization
- Remove vendor/ from git tracking (now properly gitignored)
- Update all test file paths (dirname adjustments and ref/ -> doc/reference_samples/)
- Update all documentation paths (AGENTS.md, doc/*.md)
- Remove php.bak/ directory
- All 252 tests pass
2026-03-30 13:26:29 +02:00

1157 lines
41 KiB
PHP

<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\PresentationBundle;
use ProPresenter\Parser\ProBundleReader;
use ProPresenter\Parser\ProBundleWriter;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\ProFileWriter;
class ProFileGeneratorTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/propresenter-generator-test-' . uniqid();
mkdir($this->tmpDir, 0777, true);
}
protected function tearDown(): void
{
if (!is_dir($this->tmpDir)) {
return;
}
foreach (scandir($this->tmpDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
@unlink($this->tmpDir . '/' . $entry);
}
@rmdir($this->tmpDir);
}
#[Test]
public function testGenerateCreatesValidSong(): void
{
$song = ProFileGenerator::generate(
'Simple Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Hello World'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$this->assertSame('Simple Song', $song->getName());
$this->assertCount(1, $song->getGroups());
$this->assertCount(1, $song->getSlides());
$this->assertSame('Hello World', $song->getSlides()[0]->getPlainText());
$arrangement = $song->getArrangementByName('normal');
$this->assertNotNull($arrangement);
$groups = $song->getGroupsForArrangement($arrangement);
$this->assertCount(1, $groups);
$this->assertSame('Verse 1', $groups[0]->getName());
// Verify HotKey is present on Group
$this->assertNotNull($groups[0]->getHotKey());
// Verify HotKey is present on Cue
$cues = $song->getPresentation()->getCues();
$this->assertCount(1, $cues);
$this->assertNotNull($cues[0]->getHotKey());
// Verify UUID is uppercase
$this->assertMatchesRegularExpression('/^[A-F0-9]{8}-[A-F0-9]{4}-4[A-F0-9]{3}-[89AB][A-F0-9]{3}-[A-F0-9]{12}$/', $song->getUuid());
}
#[Test]
public function testGenerateCreatesSeparatePlatformAndApplicationVersions(): void
{
$song = ProFileGenerator::generate(
'Version Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Test'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
$appInfo = $song->getPresentation()->getApplicationInfo();
$this->assertNotNull($appInfo);
// Verify platform version
$platformVersion = $appInfo->getPlatformVersion();
$this->assertNotNull($platformVersion);
$this->assertSame(14, $platformVersion->getMajorVersion());
$this->assertSame(8, $platformVersion->getMinorVersion());
$this->assertSame(3, $platformVersion->getPatchVersion());
$this->assertSame('', $platformVersion->getBuild());
// Verify application version
$applicationVersion = $appInfo->getApplicationVersion();
$this->assertNotNull($applicationVersion);
$this->assertSame(20, $applicationVersion->getMajorVersion());
$this->assertSame('335544354', $applicationVersion->getBuild());
// Verify they are different objects
$this->assertNotSame($platformVersion, $applicationVersion);
}
#[Test]
public function testGenerateWithMultipleGroupsAndArrangements(): void
{
$song = ProFileGenerator::generate(
'Multi Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'V1.1'],
['text' => 'V1.2'],
],
],
[
'name' => 'Chorus',
'color' => [0.4, 0.5, 0.6, 1.0],
'slides' => [
['text' => 'C1'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1', 'Chorus']],
['name' => 'short', 'groupNames' => ['Chorus']],
],
);
$this->assertSame(['Verse 1', 'Chorus'], array_map(fn ($group) => $group->getName(), $song->getGroups()));
$verse1 = $song->getGroupByName('Verse 1');
$this->assertNotNull($verse1);
$verseSlides = $song->getSlidesForGroup($verse1);
$this->assertSame(['V1.1', 'V1.2'], array_map(fn ($slide) => $slide->getPlainText(), $verseSlides));
$chorus = $song->getGroupByName('Chorus');
$this->assertNotNull($chorus);
$chorusSlides = $song->getSlidesForGroup($chorus);
$this->assertSame(['C1'], array_map(fn ($slide) => $slide->getPlainText(), $chorusSlides));
$normal = $song->getArrangementByName('normal');
$this->assertNotNull($normal);
$this->assertSame(
['Verse 1', 'Chorus'],
array_map(fn ($group) => $group->getName(), $song->getGroupsForArrangement($normal)),
);
$short = $song->getArrangementByName('short');
$this->assertNotNull($short);
$this->assertSame(
['Chorus'],
array_map(fn ($group) => $group->getName(), $song->getGroupsForArrangement($short)),
);
}
#[Test]
public function testGenerateWithTranslation(): void
{
$song = ProFileGenerator::generate(
'Translation Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Original', 'translation' => 'Translated'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$slide = $song->getSlides()[0];
$this->assertTrue($slide->hasTranslation());
$this->assertSame('Translated', $slide->getTranslation()?->getPlainText());
}
#[Test]
public function testGenerateWithCcliMetadata(): void
{
$song = ProFileGenerator::generate(
'CCLI Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Line'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
[
'author' => 'Author Name',
'song_title' => 'Song Title',
'publisher' => 'Publisher Name',
'copyright_year' => 2024,
'song_number' => 12345,
'display' => true,
'artist_credits' => 'Artist Credits',
'album' => 'Album Name',
],
);
$this->assertSame('Author Name', $song->getCcliAuthor());
$this->assertSame('Song Title', $song->getCcliSongTitle());
$this->assertSame('Publisher Name', $song->getCcliPublisher());
$this->assertSame(2024, $song->getCcliCopyrightYear());
$this->assertSame(12345, $song->getCcliSongNumber());
$this->assertTrue($song->getCcliDisplay());
$this->assertSame('Artist Credits', $song->getCcliArtistCredits());
$this->assertSame('Album Name', $song->getCcliAlbum());
}
#[Test]
public function testRoundTripFromTestPro(): void
{
$original = ProFileReader::read(__DIR__ . '/../doc/reference_samples/Test.pro');
$groups = [];
foreach ($original->getGroups() as $group) {
$color = $group->getColor();
$slides = [];
foreach ($original->getSlidesForGroup($group) as $slide) {
$slides[] = [
'text' => $slide->getPlainText(),
'translation' => $slide->hasTranslation() ? $slide->getTranslation()?->getPlainText() : null,
];
}
$groups[] = [
'name' => $group->getName(),
'color' => [
$color['r'] ?? 0.0,
$color['g'] ?? 0.0,
$color['b'] ?? 0.0,
$color['a'] ?? 1.0,
],
'slides' => $slides,
];
}
$arrangements = [];
foreach ($original->getArrangements() as $arrangement) {
$arrangements[] = [
'name' => $arrangement->getName(),
'groupNames' => array_map(
fn ($group) => $group->getName(),
$original->getGroupsForArrangement($arrangement),
),
];
}
$ccli = [
'author' => $original->getCcliAuthor(),
'song_title' => $original->getCcliSongTitle(),
'publisher' => $original->getCcliPublisher(),
'copyright_year' => $original->getCcliCopyrightYear(),
'song_number' => $original->getCcliSongNumber(),
'display' => $original->getCcliDisplay(),
'artist_credits' => $original->getCcliArtistCredits(),
'album' => $original->getCcliAlbum(),
];
$generated = ProFileGenerator::generate($original->getName(), $groups, $arrangements, $ccli);
$filePath = $this->tmpDir . '/test-roundtrip.pro';
ProFileWriter::write($generated, $filePath);
$roundTrip = ProFileReader::read($filePath);
$this->assertSame($original->getName(), $roundTrip->getName());
$this->assertSame(
array_map(fn ($group) => $group->getName(), $original->getGroups()),
array_map(fn ($group) => $group->getName(), $roundTrip->getGroups()),
);
foreach ($original->getGroups() as $group) {
$actualGroup = $roundTrip->getGroupByName($group->getName());
$this->assertNotNull($actualGroup);
$expectedSlides = $original->getSlidesForGroup($group);
$actualSlides = $roundTrip->getSlidesForGroup($actualGroup);
$this->assertCount(count($expectedSlides), $actualSlides);
foreach ($expectedSlides as $index => $expectedSlide) {
$actualSlide = $actualSlides[$index];
$this->assertSame($expectedSlide->getPlainText(), $actualSlide->getPlainText());
$this->assertSame($expectedSlide->hasTranslation(), $actualSlide->hasTranslation());
if ($expectedSlide->hasTranslation()) {
$this->assertSame(
$expectedSlide->getTranslation()?->getPlainText(),
$actualSlide->getTranslation()?->getPlainText(),
);
}
}
}
$this->assertSame(
array_map(fn ($arrangement) => $arrangement->getName(), $original->getArrangements()),
array_map(fn ($arrangement) => $arrangement->getName(), $roundTrip->getArrangements()),
);
foreach ($original->getArrangements() as $arrangement) {
$roundTripArrangement = $roundTrip->getArrangementByName($arrangement->getName());
$this->assertNotNull($roundTripArrangement);
$expectedNames = array_map(
fn ($group) => $group->getName(),
$original->getGroupsForArrangement($arrangement),
);
$actualNames = array_map(
fn ($group) => $group->getName(),
$roundTrip->getGroupsForArrangement($roundTripArrangement),
);
$this->assertSame($expectedNames, $actualNames);
}
}
#[Test]
public function testGenerateAndWriteCreatesFile(): void
{
$filePath = $this->tmpDir . '/generated.pro';
ProFileGenerator::generateAndWrite(
$filePath,
'Write Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Line 1'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$this->assertFileExists($filePath);
$song = ProFileReader::read($filePath);
$this->assertSame('Write Song', $song->getName());
}
#[Test]
public function testGenerateWithMacro(): void
{
$song = ProFileGenerator::generate(
'Macro Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
[
'text' => 'Line 1',
'macro' => [
'name' => 'Macro Name',
'uuid' => '11111111-2222-3333-4444-555555555555',
'collectionName' => '--MAIN--',
'collectionUuid' => '8D02FC57-83F8-4042-9B90-81C229728426',
],
],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$slide = $song->getSlides()[0];
$this->assertTrue($slide->hasMacro());
$this->assertSame('Macro Name', $slide->getMacroName());
$this->assertSame('11111111-2222-3333-4444-555555555555', $slide->getMacroUuid());
}
#[Test]
public function testGenerateMediaSlide(): void
{
$song = ProFileGenerator::generate(
'Media Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
[
'media' => 'file:///tmp/test-image.jpg',
'format' => 'JPG',
],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$slide = $song->getSlides()[0];
$this->assertTrue($slide->hasMedia());
$this->assertSame('file:///tmp/test-image.jpg', $slide->getMediaUrl());
$this->assertSame('JPG', $slide->getMediaFormat());
$this->assertSame('', $slide->getPlainText());
}
#[Test]
public function testGenerateMediaSlideWithLabelAndMacro(): void
{
$song = ProFileGenerator::generate(
'Media Macro Song',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
[
'media' => 'file:///tmp/test-image-2.jpg',
'format' => 'JPG',
'label' => 'Image Slide Label',
'macro' => [
'name' => 'Image Macro',
'uuid' => 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE',
],
],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$slide = $song->getSlides()[0];
$this->assertTrue($slide->hasMedia());
$this->assertSame('Image Slide Label', $slide->getLabel());
$this->assertTrue($slide->hasMacro());
$this->assertSame('Image Macro', $slide->getMacroName());
$this->assertSame('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', $slide->getMacroUuid());
}
#[Test]
public function testGenerateAttributesAreDisabled(): void
{
$song = ProFileGenerator::generate(
'Attributes Test',
[
[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [
['text' => 'Test Text'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['Verse 1']],
],
);
$slide = $song->getSlides()[0];
$elements = $slide->getAllElements();
$this->assertCount(1, $elements);
$element = $elements[0];
$graphicsElement = $element->getGraphicsElement();
// Verify fill is present but disabled
$this->assertNotNull($graphicsElement->getFill());
$this->assertFalse($graphicsElement->getFill()->getEnable());
// Verify stroke is present but disabled
$this->assertNotNull($graphicsElement->getStroke());
$this->assertFalse($graphicsElement->getStroke()->getEnable());
// Verify shadow is present but disabled
$this->assertNotNull($graphicsElement->getShadow());
$this->assertFalse($graphicsElement->getShadow()->getEnable());
// Verify feather is present but disabled
$this->assertNotNull($graphicsElement->getFeather());
$this->assertFalse($graphicsElement->getFeather()->getEnable());
// Verify text scroller is present but disabled (should_scroll = false)
// Access the raw SlideElement protobuf to get TextScroller
$slideElements = $slide->getCue()->getActions()[0]->getSlide()->getPresentation()->getBaseSlide()->getElements();
$this->assertCount(1, $slideElements);
$textScroller = $slideElements[0]->getTextScroller();
$this->assertNotNull($textScroller);
$this->assertFalse($textScroller->getShouldScroll());
}
#[Test]
public function testGenerateSelectsNormalArrangementWhenPresent(): void
{
$song = ProFileGenerator::generate(
'SelectTest',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Hello'],
],
],
],
[
['name' => 'other', 'groupNames' => ['V1']],
['name' => 'normal', 'groupNames' => ['V1']],
],
);
$filePath = $this->tmpDir . '/select-test.pro';
ProFileWriter::write($song, $filePath);
$readSong = ProFileReader::read($filePath);
$selectedUuid = $readSong->getSelectedArrangementUuid();
// Find which arrangement has this UUID
$selectedArrangement = null;
foreach ($readSong->getArrangements() as $arrangement) {
if ($arrangement->getUuid() === $selectedUuid) {
$selectedArrangement = $arrangement;
break;
}
}
$this->assertNotNull($selectedArrangement);
$this->assertSame('normal', $selectedArrangement->getName());
}
#[Test]
public function testGenerateFallsBackToFirstArrangementWhenNoNormal(): void
{
$song = ProFileGenerator::generate(
'FallbackTest',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Hello'],
],
],
],
[
['name' => 'custom', 'groupNames' => ['V1']],
],
);
$filePath = $this->tmpDir . '/fallback-test.pro';
ProFileWriter::write($song, $filePath);
$readSong = ProFileReader::read($filePath);
$selectedUuid = $readSong->getSelectedArrangementUuid();
// Find which arrangement has this UUID
$selectedArrangement = null;
foreach ($readSong->getArrangements() as $arrangement) {
if ($arrangement->getUuid() === $selectedUuid) {
$selectedArrangement = $arrangement;
break;
}
}
$this->assertNotNull($selectedArrangement);
$this->assertSame('custom', $selectedArrangement->getName());
}
#[Test]
public function testTranslatedSlideHasCorrectDualBounds(): void
{
$song = ProFileGenerator::generate(
'TranslateTest',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Amazing Grace', 'translation' => 'Erstaunliche Gnade'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
$filePath = $this->tmpDir . '/translate-test.pro';
ProFileWriter::write($song, $filePath);
$readSong = ProFileReader::read($filePath);
$slides = $readSong->getSlides();
$elements = $slides[0]->getAllElements();
$this->assertCount(2, $elements);
$this->assertSame('Orginal', $elements[0]->getName());
$this->assertSame('Deutsch', $elements[1]->getName());
$bounds0 = $elements[0]->getGraphicsElement()->getBounds();
$bounds1 = $elements[1]->getGraphicsElement()->getBounds();
// Check heights differ and match expected values
$this->assertEqualsWithDelta(182.946, $bounds0->getSize()->getHeight(), 0.01);
$this->assertEqualsWithDelta(113.889, $bounds1->getSize()->getHeight(), 0.01);
// Check Y positions
$this->assertEqualsWithDelta(99.543, $bounds0->getOrigin()->getY(), 0.01);
$this->assertEqualsWithDelta(303.166, $bounds1->getOrigin()->getY(), 0.01);
}
#[Test]
public function testNonTranslatedSlideHasSingleFullBounds(): void
{
$song = ProFileGenerator::generate(
'NoTranslateTest',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Amazing Grace'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
$filePath = $this->tmpDir . '/no-translate-test.pro';
ProFileWriter::write($song, $filePath);
$readSong = ProFileReader::read($filePath);
$slides = $readSong->getSlides();
$elements = $slides[0]->getAllElements();
$this->assertCount(1, $elements);
$this->assertSame('Orginal', $elements[0]->getName());
$bounds = $elements[0]->getGraphicsElement()->getBounds();
// Check full-size bounds
$this->assertEqualsWithDelta(880, $bounds->getSize()->getHeight(), 0.01);
$this->assertEqualsWithDelta(1620, $bounds->getSize()->getWidth(), 0.01);
$this->assertEqualsWithDelta(100, $bounds->getOrigin()->getY(), 0.01);
$this->assertEqualsWithDelta(150, $bounds->getOrigin()->getX(), 0.01);
}
#[Test]
public function testGeneratePresentationFields(): void
{
$song = ProFileGenerator::generate(
'Fields Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Test'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
$p = $song->getPresentation();
$this->assertNotNull($p->getBackground());
$this->assertNotNull($p->getChordChart());
$this->assertNotNull($p->getCcli());
$this->assertNotNull($p->getTimeline());
$this->assertSame(300.0, $p->getTimeline()->getDuration());
}
#[Test]
public function testGeneratePresentationFieldsWithEmptyCcli(): void
{
$song = ProFileGenerator::generate(
'Empty CCLI Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Test'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
[],
);
$p = $song->getPresentation();
$this->assertNotNull($p->getCcli(), 'CCLI should be set even when empty array is passed');
}
#[Test]
public function testGenerateSlideSizeAndChordChart(): void
{
$song = ProFileGenerator::generate(
'Slide Size Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
['text' => 'Test Slide'],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
// Navigate to the first cue's slide action
$cue = $song->getPresentation()->getCues()[0];
$action = $cue->getActions()[0]; // slide action
$baseSlide = $action->getSlide()->getPresentation()->getBaseSlide();
// Verify slide size
$this->assertNotNull($baseSlide->getSize());
$this->assertSame(1920.0, $baseSlide->getSize()->getWidth());
$this->assertSame(1080.0, $baseSlide->getSize()->getHeight());
// Verify PresentationSlide has chordChart
$presentationSlide = $action->getSlide()->getPresentation();
$this->assertNotNull($presentationSlide->getChordChart());
}
#[Test]
public function testBuildLocalRelativePathMappedDirectory(): void
{
// Test: Known directory mapping (Downloads) → ROOT_USER_DOWNLOADS with correct relative path
$song = ProFileGenerator::generate(
'Mapped Path Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
[
'media' => 'file:///Users/testuser/Downloads/test-image.jpg',
'format' => 'JPG',
],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
// Navigate to the first slide's media action
$cue = $song->getPresentation()->getCues()[0];
$mediaAction = null;
foreach ($cue->getActions() as $action) {
if ($action->getType() === 2) { // ACTION_TYPE_MEDIA = 2
$mediaAction = $action;
break;
}
}
$this->assertNotNull($mediaAction);
$url = $mediaAction->getMedia()->getElement()->getUrl();
$this->assertNotNull($url);
$this->assertNotNull($url->getLocal());
$this->assertSame(4, $url->getLocal()->getRoot()); // ROOT_USER_DOWNLOADS = 4
$this->assertSame('test-image.jpg', $url->getLocal()->getPath());
}
#[Test]
public function testBuildLocalRelativePathUnmappedUserDirectory(): void
{
// Test: Unmapped user directory (AI) → ROOT_USER_HOME with full relative path
$song = ProFileGenerator::generate(
'Unmapped Path Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
[
'media' => 'file:///Users/thorsten/AI/propresenter/doc/reference_samples/Media/test.png',
'format' => 'PNG',
],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
// Navigate to the first slide's media action
$cue = $song->getPresentation()->getCues()[0];
$mediaAction = null;
foreach ($cue->getActions() as $action) {
if ($action->getType() === 2) { // ACTION_TYPE_MEDIA = 2
$mediaAction = $action;
break;
}
}
$this->assertNotNull($mediaAction);
$url = $mediaAction->getMedia()->getElement()->getUrl();
$this->assertNotNull($url);
$this->assertNotNull($url->getLocal());
$this->assertSame(2, $url->getLocal()->getRoot()); // ROOT_USER_HOME = 2
$this->assertSame('AI/propresenter/doc/reference_samples/Media/test.png', $url->getLocal()->getPath());
}
#[Test]
public function testBuildLocalRelativePathNonUserPath(): void
{
// Test: Non-user path (tmp) → ROOT_BOOT_VOLUME with full path
$song = ProFileGenerator::generate(
'Non-User Path Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
[
'media' => 'file:///tmp/test-image.jpg',
'format' => 'JPG',
],
],
],
],
[
['name' => 'normal', 'groupNames' => ['V1']],
],
);
// Navigate to the first slide's media action
$cue = $song->getPresentation()->getCues()[0];
$mediaAction = null;
foreach ($cue->getActions() as $action) {
if ($action->getType() === 2) { // ACTION_TYPE_MEDIA = 2
$mediaAction = $action;
break;
}
}
$this->assertNotNull($mediaAction);
$url = $mediaAction->getMedia()->getElement()->getUrl();
$this->assertNotNull($url);
$this->assertNotNull($url->getLocal());
$this->assertSame(1, $url->getLocal()->getRoot()); // ROOT_BOOT_VOLUME = 1
$this->assertSame('tmp/test-image.jpg', $url->getLocal()->getPath());
}
#[Test]
public function testMediaActionHasNameFromFilename(): void
{
$song = ProFileGenerator::generate(
'Media Name Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
[
'text' => 'x',
'media' => 'file:///Users/test/AI/Media/test-image.png',
'format' => 'png',
'mediaWidth' => 200,
'mediaHeight' => 150,
],
],
],
],
[
['name' => 'n', 'groupNames' => ['V1']],
],
);
$mediaAction = $song->getPresentation()->getCues()[0]->getActions()[1];
$this->assertSame('test-image', $mediaAction->getName());
}
#[Test]
public function testMediaActionHasNameFromLabel(): void
{
$song = ProFileGenerator::generate(
'Media Label Name Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
[
'text' => 'x',
'media' => 'file:///Users/test/AI/Media/test-image.png',
'format' => 'png',
'label' => 'My Custom Label',
],
],
],
],
[
['name' => 'n', 'groupNames' => ['V1']],
],
);
$mediaAction = $song->getPresentation()->getCues()[0]->getActions()[1];
$this->assertSame('My Custom Label', $mediaAction->getName());
}
#[Test]
public function testMediaActionHasAudioOnMediaType(): void
{
$song = ProFileGenerator::generate(
'Media Audio Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
[
'text' => 'x',
'media' => 'file:///Users/test/AI/Media/test-image.png',
'format' => 'png',
],
],
],
],
[
['name' => 'n', 'groupNames' => ['V1']],
],
);
$mediaAction = $song->getPresentation()->getCues()[0]->getActions()[1];
$media = $mediaAction->getMedia();
$this->assertNotNull($media->getAudio());
// Element should still be set (audio oneof doesn't clear element)
$this->assertNotNull($media->getElement());
}
#[Test]
public function testMediaActionHasImageDrawingAndFileProperties(): void
{
$song = ProFileGenerator::generate(
'Media Image Props Test',
[
[
'name' => 'V1',
'color' => [0, 0, 0, 1],
'slides' => [
[
'text' => 'x',
'media' => 'file:///Users/test/AI/Media/test-image.png',
'format' => 'png',
'mediaWidth' => 200,
'mediaHeight' => 150,
],
],
],
],
[
['name' => 'n', 'groupNames' => ['V1']],
],
);
$mediaAction = $song->getPresentation()->getCues()[0]->getActions()[1];
$image = $mediaAction->getMedia()->getElement()->getImage();
$this->assertNotNull($image);
// Verify drawing properties
$this->assertNotNull($image->getDrawing());
$this->assertSame(200.0, $image->getDrawing()->getNaturalSize()->getWidth());
$this->assertSame(150.0, $image->getDrawing()->getNaturalSize()->getHeight());
$this->assertNotNull($image->getDrawing()->getCustomImageBounds());
$this->assertNotNull($image->getDrawing()->getCustomImageBounds()->getOrigin());
$this->assertNotNull($image->getDrawing()->getCustomImageBounds()->getSize());
$this->assertNotNull($image->getDrawing()->getCropInsets());
$this->assertSame(1, $image->getDrawing()->getAlphaType()); // ALPHA_TYPE_STRAIGHT = 1
// Verify file properties
$this->assertNotNull($image->getFile());
$this->assertNotNull($image->getFile()->getLocalUrl());
$this->assertSame('file:///Users/test/AI/Media/test-image.png', $image->getFile()->getLocalUrl()->getAbsoluteString());
}
#[Test]
public function testGeneratedProFileMatchesProPresenterNativeStructure(): void
{
$song = ProFileGenerator::generate(
'TestBild',
[[
'name' => 'Verse 1',
'color' => [0.0, 0.0, 0.0, 1.0],
'slides' => [[
'label' => 'test-background',
'media' => 'file:///Users/thorsten/AI/propresenter/doc/reference_samples/Media/test-background.png',
'format' => 'png',
'mediaWidth' => 200,
'mediaHeight' => 150,
]],
]],
[['name' => 'normal', 'groupNames' => ['Verse 1']]],
);
$p = $song->getPresentation();
// Fix 1: UUIDs are uppercase
$this->assertMatchesRegularExpression(
'/^[A-F0-9]{8}-[A-F0-9]{4}-4[A-F0-9]{3}-[89AB][A-F0-9]{3}-[A-F0-9]{12}$/',
$p->getUuid()->getString(),
'Presentation UUID must be uppercase',
);
// Fix 2: Separate platform and application versions
$appInfo = $p->getApplicationInfo();
$this->assertSame(14, $appInfo->getPlatformVersion()->getMajorVersion());
$this->assertSame(8, $appInfo->getPlatformVersion()->getMinorVersion());
$this->assertSame(3, $appInfo->getPlatformVersion()->getPatchVersion());
$this->assertSame(20, $appInfo->getApplicationVersion()->getMajorVersion());
$this->assertSame('335544354', $appInfo->getApplicationVersion()->getBuild());
// Fix 3a: Background present
$this->assertNotNull($p->getBackground(), 'Background must be set');
$this->assertSame(1.0, $p->getBackground()->getColor()->getAlpha());
// Fix 3b: ChordChart on Presentation
$this->assertNotNull($p->getChordChart(), 'Presentation chordChart must be set');
// Fix 3c: CCLI always present (even with no data)
$this->assertNotNull($p->getCcli(), 'CCLI must always be set');
// Fix 3d: Timeline
$this->assertNotNull($p->getTimeline(), 'Timeline must be set');
$this->assertSame(300.0, $p->getTimeline()->getDuration());
// Fix 4: HotKey on Group and Cue
$this->assertNotNull($p->getCueGroups()[0]->getGroup()->getHotKey(), 'Group must have hotKey');
$this->assertNotNull($p->getCues()[0]->getHotKey(), 'Cue must have hotKey');
// Fix 5a: Slide size 1920x1080
$slidePresentation = $p->getCues()[0]->getActions()[0]->getSlide()->getPresentation();
$size = $slidePresentation->getBaseSlide()->getSize();
$this->assertNotNull($size, 'Slide must have size');
$this->assertSame(1920.0, $size->getWidth());
$this->assertSame(1080.0, $size->getHeight());
// Fix 5b: PresentationSlide chordChart
$this->assertNotNull($slidePresentation->getChordChart(), 'PresentationSlide must have chordChart');
// Fix 6: LOCAL PATH ROOT_USER_HOME for /Users/ paths
$mediaUrl = $p->getCues()[0]->getActions()[1]->getMedia()->getElement()->getUrl();
$this->assertSame(2, $mediaUrl->getLocal()->getRoot(), 'Must use ROOT_USER_HOME (2) for /Users/ paths');
$this->assertSame('AI/propresenter/doc/reference_samples/Media/test-background.png', $mediaUrl->getLocal()->getPath());
// Fix 7a: Media action name
$mediaAction = $p->getCues()[0]->getActions()[1];
$this->assertSame('test-background', $mediaAction->getName());
// Fix 7b: MediaType.audio set (oneof discriminator)
$this->assertNotNull($mediaAction->getMedia()->getAudio(), 'MediaType must have audio set');
// Fix 7c: ImageTypeProperties with drawing and file
$img = $mediaAction->getMedia()->getElement()->getImage();
$this->assertNotNull($img->getDrawing(), 'ImageTypeProperties must have drawing');
$this->assertSame(200.0, $img->getDrawing()->getNaturalSize()->getWidth());
$this->assertSame(150.0, $img->getDrawing()->getNaturalSize()->getHeight());
$this->assertNotNull($img->getDrawing()->getCustomImageBounds());
$this->assertNotNull($img->getDrawing()->getCropInsets());
$this->assertNotNull($img->getFile(), 'ImageTypeProperties must have file');
$this->assertNotNull($img->getFile()->getLocalUrl());
$this->assertSame(
'file:///Users/thorsten/AI/propresenter/doc/reference_samples/Media/test-background.png',
$img->getFile()->getLocalUrl()->getAbsoluteString(),
);
}
#[Test]
public function testBundleRoundTripWithGeneratedSong(): void
{
$song = ProFileGenerator::generate(
'TestBild',
[[
'name' => 'Verse 1',
'color' => [0.0, 0.0, 0.0, 1.0],
'slides' => [[
'label' => 'test-background',
'media' => 'file:///Users/thorsten/AI/propresenter/doc/reference_samples/Media/test-background.png',
'format' => 'png',
'mediaWidth' => 200,
'mediaHeight' => 150,
]],
]],
[['name' => 'normal', 'groupNames' => ['Verse 1']]],
);
$fakeImageBytes = "\x89PNG\r\n\x1a\n" . str_repeat("\x00", 100);
$bundle = new PresentationBundle(
$song,
'TestBild.pro',
['test-background.png' => $fakeImageBytes],
);
$path = $this->tmpDir . '/TestBild.probundle';
ProBundleWriter::write($bundle, $path);
$this->assertFileExists($path);
$read = ProBundleReader::read($path);
$this->assertSame('TestBild', $read->getName());
$this->assertSame('TestBild.pro', $read->getProFilename());
$this->assertSame(1, $read->getMediaFileCount());
$this->assertTrue($read->hasMediaFile('test-background.png'));
// UUID preserved through serialization
$this->assertSame(
$song->getPresentation()->getUuid()->getString(),
$read->getSong()->getPresentation()->getUuid()->getString(),
);
}
}