diff --git a/php/src/PlaylistArchive.php b/php/src/PlaylistArchive.php new file mode 100644 index 0000000..f7cae79 --- /dev/null +++ b/php/src/PlaylistArchive.php @@ -0,0 +1,169 @@ + filename => raw bytes */ + private array $embeddedFiles; + + /** @var array 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 filename => raw bytes + */ + public function getEmbeddedFiles(): array + { + return $this->embeddedFiles; + } + + /** + * Only .pro song files from embedded entries. + * + * @return array 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 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]; + } +} diff --git a/php/src/PlaylistEntry.php b/php/src/PlaylistEntry.php new file mode 100644 index 0000000..da748ef --- /dev/null +++ b/php/src/PlaylistEntry.php @@ -0,0 +1,187 @@ +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.0–1.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; + } +} diff --git a/php/src/PlaylistNode.php b/php/src/PlaylistNode.php new file mode 100644 index 0000000..1c10306 --- /dev/null +++ b/php/src/PlaylistNode.php @@ -0,0 +1,119 @@ +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; + } +} diff --git a/php/tests/PlaylistArchiveTest.php b/php/tests/PlaylistArchiveTest.php new file mode 100644 index 0000000..5a230c1 --- /dev/null +++ b/php/tests/PlaylistArchiveTest.php @@ -0,0 +1,338 @@ +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')); + } +} diff --git a/php/tests/PlaylistEntryTest.php b/php/tests/PlaylistEntryTest.php new file mode 100644 index 0000000..de39d49 --- /dev/null +++ b/php/tests/PlaylistEntryTest.php @@ -0,0 +1,361 @@ +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()); + } +} diff --git a/php/tests/PlaylistNodeTest.php b/php/tests/PlaylistNodeTest.php new file mode 100644 index 0000000..dec421d --- /dev/null +++ b/php/tests/PlaylistNodeTest.php @@ -0,0 +1,243 @@ +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()); + } +}