feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers

- Implement PlaylistEntry wrapper for PlaylistItem proto (23 tests, 40 assertions)
  - Support all 4 item types: header, presentation, placeholder, cue
  - Expose arrangement_name (field 5) for presentation items
  - Type-specific getters with null safety
- Implement PlaylistNode wrapper for Playlist proto (15 tests, 37 assertions)
  - Handle both container nodes (child playlists) and leaf nodes (items)
  - Recursive wrapping of nested playlist structures
- Implement PlaylistArchive wrapper for PlaylistDocument proto (18 tests, 37 assertions)
  - Top-level integration of nodes, entries, and embedded files
  - Lazy parsing of embedded .pro files into Song objects
  - File partitioning: .pro files vs media files

Wave 2 of proplaylist-module plan complete (Tasks 4-6)
This commit is contained in:
Thorsten Bus 2026-03-01 20:58:39 +01:00
parent 2c1b8e3370
commit d58bb38bb6
6 changed files with 1417 additions and 0 deletions

169
php/src/PlaylistArchive.php Normal file
View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use Rv\Data\PlaylistDocument;
use Rv\Data\Presentation;
/**
* Top-level wrapper for a ProPresenter playlist archive (.proplaylist).
*
* Integrates the protobuf PlaylistDocument with embedded files extracted from
* the ZIP archive. Provides convenient access to the playlist tree structure,
* entries, and lazy parsing of embedded .pro files into Song objects.
*
* Root structure:
* PlaylistDocument root_node ("PLAYLIST") first child (actual named playlist)
*/
class PlaylistArchive
{
private PlaylistNode $rootNode;
private ?PlaylistNode $playlistNode = null;
/** @var array<string, string> filename => raw bytes */
private array $embeddedFiles;
/** @var array<string, Song> filename => parsed Song (lazy cache) */
private array $parsedSongs = [];
public function __construct(
private readonly PlaylistDocument $document,
array $embeddedFiles = [],
) {
$this->embeddedFiles = $embeddedFiles;
$rootPlaylist = $this->document->getRootNode();
$this->rootNode = new PlaylistNode($rootPlaylist);
// First child node is the actual named playlist
$childNodes = $this->rootNode->getChildNodes();
if (!empty($childNodes)) {
$this->playlistNode = $childNodes[0];
}
}
/**
* Name of the actual playlist (first child, not the root "PLAYLIST").
*/
public function getName(): string
{
return $this->playlistNode?->getName() ?? '';
}
/**
* Root PlaylistNode (always named "PLAYLIST").
*/
public function getRootNode(): PlaylistNode
{
return $this->rootNode;
}
/**
* First child node the actual named playlist.
*/
public function getPlaylistNode(): ?PlaylistNode
{
return $this->playlistNode;
}
/**
* Shortcut: all entries from the playlist node.
*
* @return PlaylistEntry[]
*/
public function getEntries(): array
{
return $this->playlistNode?->getEntries() ?? [];
}
/**
* Total number of entries in the playlist.
*/
public function getEntryCount(): int
{
return $this->playlistNode?->getEntryCount() ?? 0;
}
/**
* Document type enum value.
*/
public function getType(): int
{
return $this->document->getType();
}
/**
* Access the underlying protobuf PlaylistDocument.
*/
public function getDocument(): PlaylistDocument
{
return $this->document;
}
// ─── Embedded files ───
/**
* All embedded files (excluding the `data` proto file).
*
* @return array<string, string> filename => raw bytes
*/
public function getEmbeddedFiles(): array
{
return $this->embeddedFiles;
}
/**
* Only .pro song files from embedded entries.
*
* @return array<string, string> filename => raw bytes
*/
public function getEmbeddedProFiles(): array
{
return array_filter(
$this->embeddedFiles,
static fn (string $_, string $filename): bool => str_ends_with(strtolower($filename), '.pro'),
ARRAY_FILTER_USE_BOTH,
);
}
/**
* Only media files (non-.pro, non-data) from embedded entries.
*
* @return array<string, string> filename => raw bytes
*/
public function getEmbeddedMediaFiles(): array
{
return array_filter(
$this->embeddedFiles,
static fn (string $_, string $filename): bool => !str_ends_with(strtolower($filename), '.pro'),
ARRAY_FILTER_USE_BOTH,
);
}
/**
* Lazily parse an embedded .pro file into a Song object.
*
* Returns null if the file doesn't exist or isn't a .pro file.
* Caches the parsed Song for subsequent calls with the same filename.
*/
public function getEmbeddedSong(string $filename): ?Song
{
if (!isset($this->embeddedFiles[$filename])) {
return null;
}
if (!str_ends_with(strtolower($filename), '.pro')) {
return null;
}
if (!isset($this->parsedSongs[$filename])) {
$presentation = new Presentation();
$presentation->mergeFromString($this->embeddedFiles[$filename]);
$this->parsedSongs[$filename] = new Song($presentation);
}
return $this->parsedSongs[$filename];
}
}

187
php/src/PlaylistEntry.php Normal file
View file

@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use Rv\Data\PlaylistItem;
/**
* Read wrapper around a protobuf PlaylistItem representing a playlist entry.
*
* Supports all 4 item types: header, presentation, placeholder, cue.
* Provides typed getters for type-specific data (color, document path, arrangement).
* Returns null for type-specific getters when the wrong type is accessed.
*/
class PlaylistEntry
{
public function __construct(
private readonly PlaylistItem $item,
) {
}
/**
* UUID string of this playlist item.
*/
public function getUuid(): string
{
return $this->item->getUuid()?->getString() ?? '';
}
/**
* Display name of this playlist item.
*/
public function getName(): string
{
return $this->item->getName();
}
/**
* Item type as string: "header", "presentation", "cue", "placeholder", "planning_center".
* Returns empty string if no type is set.
*/
public function getType(): string
{
return $this->item->getItemType() ?? '';
}
// ─── Type checks ───
public function isHeader(): bool
{
return $this->getType() === 'header';
}
public function isPresentation(): bool
{
return $this->getType() === 'presentation';
}
public function isCue(): bool
{
return $this->getType() === 'cue';
}
public function isPlaceholder(): bool
{
return $this->getType() === 'placeholder';
}
// ─── Header-specific ───
/**
* Header color as [r, g, b, a] array (floats 0.01.0).
* Returns null for non-header items or when no color is set.
*
* @return float[]|null
*/
public function getHeaderColor(): ?array
{
if (!$this->isHeader()) {
return null;
}
$header = $this->item->getHeader();
if ($header === null || !$header->hasColor()) {
return null;
}
$color = $header->getColor();
return [
$color->getRed(),
$color->getGreen(),
$color->getBlue(),
$color->getAlpha(),
];
}
// ─── Presentation-specific ───
/**
* Full document path URL string for presentation items.
* Returns null for non-presentation items.
*/
public function getDocumentPath(): ?string
{
if (!$this->isPresentation()) {
return null;
}
$pres = $this->item->getPresentation();
return $pres?->getDocumentPath()?->getAbsoluteString();
}
/**
* Extract just the filename from the document path URL.
* Decodes URL-encoded characters (e.g., %20 space).
* Returns null for non-presentation items or when no path is set.
*/
public function getDocumentFilename(): ?string
{
$path = $this->getDocumentPath();
if ($path === null || $path === '') {
return null;
}
$basename = basename(parse_url($path, PHP_URL_PATH) ?? '');
return urldecode($basename);
}
/**
* Arrangement UUID string for presentation items.
* Returns null for non-presentation items or when no arrangement is set.
*/
public function getArrangementUuid(): ?string
{
if (!$this->isPresentation()) {
return null;
}
$pres = $this->item->getPresentation();
return $pres?->getArrangement()?->getString();
}
/**
* Arrangement name (field 5) for presentation items.
* Returns null for non-presentation items.
*/
public function getArrangementName(): ?string
{
if (!$this->isPresentation()) {
return null;
}
$pres = $this->item->getPresentation();
$name = $pres?->getArrangementName();
return ($name !== null && $name !== '') ? $name : null;
}
/**
* Whether a specific arrangement is assigned to this presentation item.
*/
public function hasArrangement(): bool
{
if (!$this->isPresentation()) {
return false;
}
$pres = $this->item->getPresentation();
return $pres !== null && $pres->hasArrangement();
}
// ─── Raw proto access ───
/**
* Access the underlying protobuf PlaylistItem.
*/
public function getPlaylistItem(): PlaylistItem
{
return $this->item;
}
}

119
php/src/PlaylistNode.php Normal file
View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use Rv\Data\Playlist;
/**
* Wraps a protobuf Playlist message, providing convenient access to playlist
* structure as either a container node (with child playlists) or a leaf node
* (with playlist items).
*
* Uses the oneof ChildrenType to determine node kind:
* - "playlists" container node child PlaylistNode[]
* - "items" leaf node PlaylistEntry[]
*/
class PlaylistNode
{
/** @var PlaylistNode[] */
private array $childNodes = [];
/** @var PlaylistEntry[] */
private array $entries = [];
public function __construct(
private readonly Playlist $playlist,
) {
$childrenType = $this->playlist->getChildrenType();
if ($childrenType === 'playlists') {
$playlistArray = $this->playlist->getPlaylists();
foreach ($playlistArray->getPlaylists() as $childPlaylist) {
$this->childNodes[] = new self($childPlaylist);
}
} elseif ($childrenType === 'items') {
$playlistItems = $this->playlist->getItems();
foreach ($playlistItems->getItems() as $item) {
$this->entries[] = new PlaylistEntry($item);
}
}
}
/**
* UUID string of this playlist node.
*/
public function getUuid(): string
{
return $this->playlist->getUuid()?->getString() ?? '';
}
/**
* Display name of this playlist node.
*/
public function getName(): string
{
return $this->playlist->getName();
}
/**
* Playlist type enum value (TYPE_PLAYLIST, TYPE_GROUP, TYPE_SMART, etc.).
*/
public function getType(): int
{
return $this->playlist->getType();
}
/**
* Whether this node is a container (has child playlists).
*/
public function isContainer(): bool
{
return $this->playlist->getChildrenType() === 'playlists';
}
/**
* Whether this node is a leaf (has playlist items).
*/
public function isLeaf(): bool
{
return $this->playlist->getChildrenType() === 'items';
}
/**
* Get child PlaylistNode objects (empty for leaf nodes).
*
* @return PlaylistNode[]
*/
public function getChildNodes(): array
{
return $this->childNodes;
}
/**
* Get PlaylistEntry objects (empty for container nodes).
*
* @return PlaylistEntry[]
*/
public function getEntries(): array
{
return $this->entries;
}
/**
* Number of items in this leaf node (0 for containers).
*/
public function getEntryCount(): int
{
return count($this->entries);
}
/**
* Access the underlying protobuf Playlist message.
*/
public function getPlaylist(): Playlist
{
return $this->playlist;
}
}

View file

@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\PlaylistArchive;
use ProPresenter\Parser\PlaylistEntry;
use ProPresenter\Parser\PlaylistNode;
use ProPresenter\Parser\Song;
use Rv\Data\Playlist;
use Rv\Data\Playlist\PlaylistArray;
use Rv\Data\Playlist\PlaylistItems;
use Rv\Data\PlaylistDocument;
use Rv\Data\PlaylistItem;
use Rv\Data\PlaylistItem\Presentation as PlaylistItemPresentation;
use Rv\Data\Presentation;
use Rv\Data\UUID;
class PlaylistArchiveTest extends TestCase
{
// ─── Helpers ───
private function makePlaylistItem(string $name, string $uuid = ''): PlaylistItem
{
$item = new PlaylistItem();
$item->setName($name);
if ($uuid !== '') {
$uuidObj = new UUID();
$uuidObj->setString($uuid);
$item->setUuid($uuidObj);
}
$pres = new PlaylistItemPresentation();
$item->setPresentation($pres);
return $item;
}
private function makeRootWithChild(
string $childName = 'TestPlaylist',
array $items = [],
): Playlist {
// Child playlist (leaf with items)
$child = new Playlist();
$child->setName($childName);
if (!empty($items)) {
$playlistItems = new PlaylistItems();
$playlistItems->setItems($items);
$child->setItems($playlistItems);
}
// Root playlist (container with child playlists)
$root = new Playlist();
$root->setName('PLAYLIST');
$playlistArray = new PlaylistArray();
$playlistArray->setPlaylists([$child]);
$root->setPlaylists($playlistArray);
return $root;
}
private function makeSimpleArchive(
string $childName = 'TestPlaylist',
array $items = [],
array $embeddedFiles = [],
int $type = 0,
): PlaylistArchive {
$doc = new PlaylistDocument();
$doc->setRootNode($this->makeRootWithChild($childName, $items));
$doc->setType($type);
return new PlaylistArchive($doc, $embeddedFiles);
}
// ─── getName() ───
#[Test]
public function getNameReturnsChildPlaylistName(): void
{
$archive = $this->makeSimpleArchive(childName: 'Sunday Service');
$this->assertSame('Sunday Service', $archive->getName());
}
#[Test]
public function getNameReturnsEmptyStringWhenNoChildren(): void
{
$root = new Playlist();
$root->setName('PLAYLIST');
$doc = new PlaylistDocument();
$doc->setRootNode($root);
$archive = new PlaylistArchive($doc);
$this->assertSame('', $archive->getName());
}
// ─── getRootNode() ───
#[Test]
public function getRootNodeReturnsPlaylistNodeWrappingRoot(): void
{
$archive = $this->makeSimpleArchive();
$rootNode = $archive->getRootNode();
$this->assertInstanceOf(PlaylistNode::class, $rootNode);
$this->assertSame('PLAYLIST', $rootNode->getName());
}
// ─── getPlaylistNode() ───
#[Test]
public function getPlaylistNodeReturnsFirstChildNode(): void
{
$archive = $this->makeSimpleArchive(childName: 'Gottesdienst');
$playlistNode = $archive->getPlaylistNode();
$this->assertInstanceOf(PlaylistNode::class, $playlistNode);
$this->assertSame('Gottesdienst', $playlistNode->getName());
}
#[Test]
public function getPlaylistNodeReturnsNullWhenNoChildren(): void
{
$root = new Playlist();
$root->setName('PLAYLIST');
$doc = new PlaylistDocument();
$doc->setRootNode($root);
$archive = new PlaylistArchive($doc);
$this->assertNull($archive->getPlaylistNode());
}
// ─── getEntries() / getEntryCount() ───
#[Test]
public function getEntriesReturnsEntriesFromPlaylistNode(): void
{
$items = [
$this->makePlaylistItem('Song A', 'uuid-a'),
$this->makePlaylistItem('Song B', 'uuid-b'),
$this->makePlaylistItem('Song C', 'uuid-c'),
];
$archive = $this->makeSimpleArchive(items: $items);
$entries = $archive->getEntries();
$this->assertCount(3, $entries);
$this->assertContainsOnlyInstancesOf(PlaylistEntry::class, $entries);
$this->assertSame('Song A', $entries[0]->getName());
$this->assertSame('Song B', $entries[1]->getName());
$this->assertSame('Song C', $entries[2]->getName());
}
#[Test]
public function getEntryCountReturnsTotalItemCount(): void
{
$items = [
$this->makePlaylistItem('Song 1'),
$this->makePlaylistItem('Song 2'),
];
$archive = $this->makeSimpleArchive(items: $items);
$this->assertSame(2, $archive->getEntryCount());
}
#[Test]
public function getEntryCountReturnsZeroWhenNoPlaylistNode(): void
{
$root = new Playlist();
$root->setName('PLAYLIST');
$doc = new PlaylistDocument();
$doc->setRootNode($root);
$archive = new PlaylistArchive($doc);
$this->assertSame(0, $archive->getEntryCount());
}
// ─── getType() ───
#[Test]
public function getTypeReturnsDocumentType(): void
{
$archive = $this->makeSimpleArchive(type: 1);
$this->assertSame(1, $archive->getType());
}
// ─── getDocument() ───
#[Test]
public function getDocumentReturnsUnderlyingProto(): void
{
$doc = new PlaylistDocument();
$doc->setRootNode($this->makeRootWithChild());
$doc->setType(2);
$archive = new PlaylistArchive($doc);
$this->assertSame($doc, $archive->getDocument());
}
// ─── Embedded file partitioning ───
#[Test]
public function getEmbeddedFilesReturnsAllEmbeddedEntries(): void
{
$files = [
'Song.pro' => 'prodata',
'Another.pro' => 'prodata2',
'Users/path/image.jpg' => 'imgdata',
];
$archive = $this->makeSimpleArchive(embeddedFiles: $files);
$embedded = $archive->getEmbeddedFiles();
$this->assertCount(3, $embedded);
$this->assertArrayHasKey('Song.pro', $embedded);
$this->assertArrayHasKey('Another.pro', $embedded);
$this->assertArrayHasKey('Users/path/image.jpg', $embedded);
}
#[Test]
public function getEmbeddedProFilesReturnsOnlyProFiles(): void
{
$files = [
'Song.pro' => 'prodata',
'Another Song.pro' => 'prodata2',
'Users/path/image.jpg' => 'imgdata',
'Users/path/video.mp4' => 'viddata',
];
$archive = $this->makeSimpleArchive(embeddedFiles: $files);
$proFiles = $archive->getEmbeddedProFiles();
$this->assertCount(2, $proFiles);
$this->assertArrayHasKey('Song.pro', $proFiles);
$this->assertArrayHasKey('Another Song.pro', $proFiles);
$this->assertSame('prodata', $proFiles['Song.pro']);
$this->assertSame('prodata2', $proFiles['Another Song.pro']);
}
#[Test]
public function getEmbeddedMediaFilesReturnsNonProNonDataFiles(): void
{
$files = [
'Song.pro' => 'prodata',
'Users/path/image.jpg' => 'imgdata',
'Users/path/video.mp4' => 'viddata',
];
$archive = $this->makeSimpleArchive(embeddedFiles: $files);
$mediaFiles = $archive->getEmbeddedMediaFiles();
$this->assertCount(2, $mediaFiles);
$this->assertArrayHasKey('Users/path/image.jpg', $mediaFiles);
$this->assertArrayHasKey('Users/path/video.mp4', $mediaFiles);
$this->assertArrayNotHasKey('Song.pro', $mediaFiles);
}
#[Test]
public function embeddedFilesEmptyByDefault(): void
{
$archive = $this->makeSimpleArchive();
$this->assertSame([], $archive->getEmbeddedFiles());
$this->assertSame([], $archive->getEmbeddedProFiles());
$this->assertSame([], $archive->getEmbeddedMediaFiles());
}
// ─── Lazy .pro parsing ───
#[Test]
public function getEmbeddedSongLazilyParsesProFile(): void
{
// Create minimal Presentation proto bytes
$presentation = new Presentation();
$presentation->setName('Amazing Grace');
$proBytes = $presentation->serializeToString();
$archive = $this->makeSimpleArchive(embeddedFiles: [
'Amazing Grace.pro' => $proBytes,
]);
$song = $archive->getEmbeddedSong('Amazing Grace.pro');
$this->assertInstanceOf(Song::class, $song);
$this->assertSame('Amazing Grace', $song->getName());
}
#[Test]
public function getEmbeddedSongCachesParsedResult(): void
{
$presentation = new Presentation();
$presentation->setName('Cached Song');
$proBytes = $presentation->serializeToString();
$archive = $this->makeSimpleArchive(embeddedFiles: [
'Cached.pro' => $proBytes,
]);
$song1 = $archive->getEmbeddedSong('Cached.pro');
$song2 = $archive->getEmbeddedSong('Cached.pro');
$this->assertSame($song1, $song2, 'Lazy parsing should cache and return same Song instance');
}
#[Test]
public function getEmbeddedSongReturnsNullForUnknownFile(): void
{
$archive = $this->makeSimpleArchive(embeddedFiles: [
'Song.pro' => 'data',
]);
$this->assertNull($archive->getEmbeddedSong('NonExistent.pro'));
}
#[Test]
public function getEmbeddedSongReturnsNullForMediaFile(): void
{
$archive = $this->makeSimpleArchive(embeddedFiles: [
'image.jpg' => 'imgdata',
]);
$this->assertNull($archive->getEmbeddedSong('image.jpg'));
}
}

View file

@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use ProPresenter\Parser\PlaylistEntry;
use Rv\Data\PlaylistItem;
use Rv\Data\PlaylistItem\Header;
use Rv\Data\PlaylistItem\Presentation;
use Rv\Data\PlaylistItem\Placeholder;
use Rv\Data\Cue;
use Rv\Data\UUID;
use Rv\Data\URL;
use Rv\Data\Color;
class PlaylistEntryTest extends TestCase
{
// ─── Helpers ───
private function makePresentationItem(
string $uuid = 'test-uuid',
string $name = 'Test Song',
?string $documentPath = null,
?string $arrangementUuid = null,
string $arrangementName = '',
): PlaylistItem {
$item = new PlaylistItem();
$itemUuid = new UUID();
$itemUuid->setString($uuid);
$item->setUuid($itemUuid);
$item->setName($name);
$pres = new Presentation();
if ($documentPath !== null) {
$url = new URL();
$url->setAbsoluteString($documentPath);
$pres->setDocumentPath($url);
}
if ($arrangementUuid !== null) {
$arrUuid = new UUID();
$arrUuid->setString($arrangementUuid);
$pres->setArrangement($arrUuid);
}
if ($arrangementName !== '') {
$pres->setArrangementName($arrangementName);
}
$item->setPresentation($pres);
return $item;
}
private function makeHeaderItem(
string $uuid = 'header-uuid',
string $name = 'Section Header',
?array $color = null,
): PlaylistItem {
$item = new PlaylistItem();
$itemUuid = new UUID();
$itemUuid->setString($uuid);
$item->setUuid($itemUuid);
$item->setName($name);
$header = new Header();
if ($color !== null) {
$c = new Color();
$c->setRed($color[0]);
$c->setGreen($color[1]);
$c->setBlue($color[2]);
$c->setAlpha($color[3]);
$header->setColor($c);
}
$item->setHeader($header);
return $item;
}
private function makeCueItem(
string $uuid = 'cue-uuid',
string $name = 'Cue Item',
): PlaylistItem {
$item = new PlaylistItem();
$itemUuid = new UUID();
$itemUuid->setString($uuid);
$item->setUuid($itemUuid);
$item->setName($name);
$cue = new Cue();
$item->setCue($cue);
return $item;
}
private function makePlaceholderItem(
string $uuid = 'placeholder-uuid',
string $name = 'Placeholder Item',
): PlaylistItem {
$item = new PlaylistItem();
$itemUuid = new UUID();
$itemUuid->setString($uuid);
$item->setUuid($itemUuid);
$item->setName($name);
$placeholder = new Placeholder();
$item->setPlaceholder($placeholder);
return $item;
}
// ─── getUuid() ───
#[Test]
public function getUuidReturnsUuidString(): void
{
$item = $this->makePresentationItem(uuid: 'ABC-123-DEF');
$entry = new PlaylistEntry($item);
$this->assertSame('ABC-123-DEF', $entry->getUuid());
}
// ─── getName() ───
#[Test]
public function getNameReturnsItemName(): void
{
$item = $this->makePresentationItem(name: 'Amazing Grace');
$entry = new PlaylistEntry($item);
$this->assertSame('Amazing Grace', $entry->getName());
}
// ─── getType() ───
#[Test]
public function getTypeReturnsPresentationForPresentationItem(): void
{
$item = $this->makePresentationItem();
$entry = new PlaylistEntry($item);
$this->assertSame('presentation', $entry->getType());
}
#[Test]
public function getTypeReturnsHeaderForHeaderItem(): void
{
$item = $this->makeHeaderItem();
$entry = new PlaylistEntry($item);
$this->assertSame('header', $entry->getType());
}
#[Test]
public function getTypeReturnsCueForCueItem(): void
{
$item = $this->makeCueItem();
$entry = new PlaylistEntry($item);
$this->assertSame('cue', $entry->getType());
}
#[Test]
public function getTypeReturnsPlaceholderForPlaceholderItem(): void
{
$item = $this->makePlaceholderItem();
$entry = new PlaylistEntry($item);
$this->assertSame('placeholder', $entry->getType());
}
// ─── Type checks ───
#[Test]
public function isPresentationReturnsTrueForPresentationItem(): void
{
$entry = new PlaylistEntry($this->makePresentationItem());
$this->assertTrue($entry->isPresentation());
$this->assertFalse($entry->isHeader());
$this->assertFalse($entry->isCue());
$this->assertFalse($entry->isPlaceholder());
}
#[Test]
public function isHeaderReturnsTrueForHeaderItem(): void
{
$entry = new PlaylistEntry($this->makeHeaderItem());
$this->assertTrue($entry->isHeader());
$this->assertFalse($entry->isPresentation());
$this->assertFalse($entry->isCue());
$this->assertFalse($entry->isPlaceholder());
}
#[Test]
public function isCueReturnsTrueForCueItem(): void
{
$entry = new PlaylistEntry($this->makeCueItem());
$this->assertTrue($entry->isCue());
$this->assertFalse($entry->isPresentation());
$this->assertFalse($entry->isHeader());
$this->assertFalse($entry->isPlaceholder());
}
#[Test]
public function isPlaceholderReturnsTrueForPlaceholderItem(): void
{
$entry = new PlaylistEntry($this->makePlaceholderItem());
$this->assertTrue($entry->isPlaceholder());
$this->assertFalse($entry->isPresentation());
$this->assertFalse($entry->isHeader());
$this->assertFalse($entry->isCue());
}
// ─── Header: getHeaderColor() ───
#[Test]
public function getHeaderColorReturnsRgbaArrayForHeaderItem(): void
{
$item = $this->makeHeaderItem(color: [0.13, 0.59, 0.95, 1.0]);
$entry = new PlaylistEntry($item);
$color = $entry->getHeaderColor();
$this->assertIsArray($color);
$this->assertCount(4, $color);
$this->assertEqualsWithDelta(0.13, $color[0], 0.01);
$this->assertEqualsWithDelta(0.59, $color[1], 0.01);
$this->assertEqualsWithDelta(0.95, $color[2], 0.01);
$this->assertEqualsWithDelta(1.0, $color[3], 0.01);
}
#[Test]
public function getHeaderColorReturnsNullForNonHeaderItem(): void
{
$entry = new PlaylistEntry($this->makePresentationItem());
$this->assertNull($entry->getHeaderColor());
}
#[Test]
public function getHeaderColorReturnsNullWhenHeaderHasNoColor(): void
{
$item = $this->makeHeaderItem(color: null);
$entry = new PlaylistEntry($item);
$this->assertNull($entry->getHeaderColor());
}
// ─── Presentation: document path ───
#[Test]
public function getDocumentPathReturnsFullUrl(): void
{
$item = $this->makePresentationItem(
documentPath: 'file:///Users/me/Documents/ProPresenter/Libraries/Default/Song.pro',
);
$entry = new PlaylistEntry($item);
$this->assertSame(
'file:///Users/me/Documents/ProPresenter/Libraries/Default/Song.pro',
$entry->getDocumentPath(),
);
}
#[Test]
public function getDocumentPathReturnsNullForNonPresentationItem(): void
{
$entry = new PlaylistEntry($this->makeHeaderItem());
$this->assertNull($entry->getDocumentPath());
}
#[Test]
public function getDocumentFilenameExtractsFilenameFromUrl(): void
{
$item = $this->makePresentationItem(
documentPath: 'file:///Users/me/Documents/ProPresenter/Libraries/Default/Amazing%20Grace.pro',
);
$entry = new PlaylistEntry($item);
$this->assertSame('Amazing Grace.pro', $entry->getDocumentFilename());
}
#[Test]
public function getDocumentFilenameReturnsNullForNonPresentationItem(): void
{
$entry = new PlaylistEntry($this->makeCueItem());
$this->assertNull($entry->getDocumentFilename());
}
// ─── Presentation: arrangement ───
#[Test]
public function getArrangementUuidReturnsUuidString(): void
{
$item = $this->makePresentationItem(arrangementUuid: 'ARR-UUID-123');
$entry = new PlaylistEntry($item);
$this->assertSame('ARR-UUID-123', $entry->getArrangementUuid());
}
#[Test]
public function getArrangementNameReturnsFieldFiveValue(): void
{
$item = $this->makePresentationItem(arrangementName: 'normal');
$entry = new PlaylistEntry($item);
$this->assertSame('normal', $entry->getArrangementName());
}
#[Test]
public function hasArrangementReturnsTrueWhenArrangementSet(): void
{
$item = $this->makePresentationItem(arrangementUuid: 'ARR-UUID-123');
$entry = new PlaylistEntry($item);
$this->assertTrue($entry->hasArrangement());
}
#[Test]
public function hasArrangementReturnsFalseWhenNoArrangement(): void
{
$item = $this->makePresentationItem(arrangementUuid: null);
$entry = new PlaylistEntry($item);
$this->assertFalse($entry->hasArrangement());
}
#[Test]
public function getArrangementNameReturnsNullForNonPresentationItem(): void
{
$entry = new PlaylistEntry($this->makeHeaderItem());
$this->assertNull($entry->getArrangementName());
}
// ─── getPlaylistItem() ───
#[Test]
public function getPlaylistItemReturnsOriginalProto(): void
{
$item = $this->makePresentationItem();
$entry = new PlaylistEntry($item);
$this->assertSame($item, $entry->getPlaylistItem());
}
}

View file

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\PlaylistEntry;
use ProPresenter\Parser\PlaylistNode;
use Rv\Data\Playlist;
use Rv\Data\Playlist\PlaylistArray;
use Rv\Data\Playlist\PlaylistItems;
use Rv\Data\Playlist\Type;
use Rv\Data\PlaylistItem;
use Rv\Data\UUID;
class PlaylistNodeTest extends TestCase
{
private function makeUuid(string $value): UUID
{
$uuid = new UUID();
$uuid->setString($value);
return $uuid;
}
private function makeLeafPlaylist(string $name, string $uuid, array $itemNames = []): Playlist
{
$playlist = new Playlist();
$playlist->setName($name);
$playlist->setUuid($this->makeUuid($uuid));
$playlist->setType(Type::TYPE_PLAYLIST);
if ($itemNames !== []) {
$items = [];
foreach ($itemNames as $i => $itemName) {
$item = new PlaylistItem();
$item->setName($itemName);
$item->setUuid($this->makeUuid("item-uuid-{$i}"));
$items[] = $item;
}
$playlistItems = new PlaylistItems();
$playlistItems->setItems($items);
$playlist->setItems($playlistItems);
}
return $playlist;
}
private function makeContainerPlaylist(string $name, string $uuid, array $children): Playlist
{
$playlist = new Playlist();
$playlist->setName($name);
$playlist->setUuid($this->makeUuid($uuid));
$playlist->setType(Type::TYPE_GROUP);
$playlistArray = new PlaylistArray();
$playlistArray->setPlaylists($children);
$playlist->setPlaylists($playlistArray);
return $playlist;
}
#[Test]
public function getUuidReturnsPlaylistUuid(): void
{
$proto = $this->makeLeafPlaylist('Test', 'abc-123');
$node = new PlaylistNode($proto);
$this->assertSame('abc-123', $node->getUuid());
}
#[Test]
public function getNameReturnsPlaylistName(): void
{
$proto = $this->makeLeafPlaylist('My Playlist', 'uuid-1');
$node = new PlaylistNode($proto);
$this->assertSame('My Playlist', $node->getName());
}
#[Test]
public function getTypeReturnsPlaylistType(): void
{
$proto = $this->makeLeafPlaylist('Test', 'uuid-1');
$node = new PlaylistNode($proto);
$this->assertSame(Type::TYPE_PLAYLIST, $node->getType());
}
#[Test]
public function containerNodeIsContainerAndNotLeaf(): void
{
$child = $this->makeLeafPlaylist('Child', 'child-uuid');
$proto = $this->makeContainerPlaylist('Container', 'container-uuid', [$child]);
$node = new PlaylistNode($proto);
$this->assertTrue($node->isContainer());
$this->assertFalse($node->isLeaf());
}
#[Test]
public function leafNodeIsLeafAndNotContainer(): void
{
$proto = $this->makeLeafPlaylist('Leaf', 'leaf-uuid', ['Song A', 'Song B']);
$node = new PlaylistNode($proto);
$this->assertTrue($node->isLeaf());
$this->assertFalse($node->isContainer());
}
#[Test]
public function containerNodeReturnsChildPlaylistNodes(): void
{
$child1 = $this->makeLeafPlaylist('Worship', 'child-1');
$child2 = $this->makeLeafPlaylist('Hymns', 'child-2');
$proto = $this->makeContainerPlaylist('Root', 'root-uuid', [$child1, $child2]);
$node = new PlaylistNode($proto);
$children = $node->getChildNodes();
$this->assertCount(2, $children);
$this->assertInstanceOf(PlaylistNode::class, $children[0]);
$this->assertInstanceOf(PlaylistNode::class, $children[1]);
$this->assertSame('Worship', $children[0]->getName());
$this->assertSame('Hymns', $children[1]->getName());
}
#[Test]
public function leafNodeReturnsPlaylistEntries(): void
{
$proto = $this->makeLeafPlaylist('Service', 'leaf-uuid', ['Song 1', 'Song 2', 'Song 3']);
$node = new PlaylistNode($proto);
$entries = $node->getEntries();
$this->assertCount(3, $entries);
$this->assertInstanceOf(PlaylistEntry::class, $entries[0]);
$this->assertInstanceOf(PlaylistEntry::class, $entries[1]);
$this->assertInstanceOf(PlaylistEntry::class, $entries[2]);
$this->assertSame('Song 1', $entries[0]->getName());
$this->assertSame('Song 2', $entries[1]->getName());
$this->assertSame('Song 3', $entries[2]->getName());
}
#[Test]
public function getEntryCountReturnsItemCountForLeaf(): void
{
$proto = $this->makeLeafPlaylist('Service', 'leaf-uuid', ['A', 'B']);
$node = new PlaylistNode($proto);
$this->assertSame(2, $node->getEntryCount());
}
#[Test]
public function getEntryCountReturnsZeroForContainer(): void
{
$child = $this->makeLeafPlaylist('Child', 'child-uuid');
$proto = $this->makeContainerPlaylist('Container', 'c-uuid', [$child]);
$node = new PlaylistNode($proto);
$this->assertSame(0, $node->getEntryCount());
}
#[Test]
public function containerNodeReturnsEmptyEntries(): void
{
$child = $this->makeLeafPlaylist('Child', 'child-uuid');
$proto = $this->makeContainerPlaylist('Container', 'c-uuid', [$child]);
$node = new PlaylistNode($proto);
$this->assertSame([], $node->getEntries());
}
#[Test]
public function leafNodeReturnsEmptyChildNodes(): void
{
$proto = $this->makeLeafPlaylist('Leaf', 'leaf-uuid', ['Song']);
$node = new PlaylistNode($proto);
$this->assertSame([], $node->getChildNodes());
}
#[Test]
public function getPlaylistReturnsUnderlyingProto(): void
{
$proto = $this->makeLeafPlaylist('Test', 'uuid-1');
$node = new PlaylistNode($proto);
$this->assertSame($proto, $node->getPlaylist());
}
#[Test]
public function recursiveWrappingOfNestedContainers(): void
{
$grandchild = $this->makeLeafPlaylist('Songs', 'gc-uuid', ['Amazing Grace']);
$child = $this->makeContainerPlaylist('Folder', 'c-uuid', [$grandchild]);
$root = $this->makeContainerPlaylist('Root', 'r-uuid', [$child]);
$node = new PlaylistNode($root);
$children = $node->getChildNodes();
$this->assertCount(1, $children);
$this->assertTrue($children[0]->isContainer());
$grandchildren = $children[0]->getChildNodes();
$this->assertCount(1, $grandchildren);
$this->assertTrue($grandchildren[0]->isLeaf());
$this->assertSame('Songs', $grandchildren[0]->getName());
$entries = $grandchildren[0]->getEntries();
$this->assertCount(1, $entries);
$this->assertSame('Amazing Grace', $entries[0]->getName());
}
#[Test]
public function emptyPlaylistWithNoChildrenType(): void
{
$playlist = new Playlist();
$playlist->setName('Empty');
$playlist->setUuid($this->makeUuid('empty-uuid'));
$playlist->setType(Type::TYPE_PLAYLIST);
// No items or playlists set — ChildrenType is null
$node = new PlaylistNode($playlist);
$this->assertFalse($node->isContainer());
$this->assertFalse($node->isLeaf());
$this->assertSame([], $node->getChildNodes());
$this->assertSame([], $node->getEntries());
$this->assertSame(0, $node->getEntryCount());
}
#[Test]
public function getTypeReturnsGroupTypeForContainer(): void
{
$child = $this->makeLeafPlaylist('Child', 'child-uuid');
$proto = $this->makeContainerPlaylist('Group', 'g-uuid', [$child]);
$node = new PlaylistNode($proto);
$this->assertSame(Type::TYPE_GROUP, $node->getType());
}
}