feat(macros): add reader for global Macros file

Importer for ProPresenter's protobuf-encoded `Macros` document. Exposes
each macro's UUID and name plus the collections that group them.

- src/MacroLibrary.php: top-level wrapper indexed by UUID and name
- src/Macro.php, src/MacroCollection.php: per-entry wrappers
- src/MacrosFileReader.php: file -> MacroLibrary entry point
- bin/parse-macros.php: CLI listing macros and collections
- tests/MacrosFileReaderTest.php: 10 tests against reference sample
- doc/api/macros.md: API reference, plus INDEX/keywords updates
This commit is contained in:
Thorsten Bus 2026-05-03 20:38:47 +02:00
parent 22ba4aff7d
commit b30918af41
11 changed files with 740 additions and 6 deletions

View file

@ -19,6 +19,7 @@ All project documentation lives in `doc/`. Load only what you need.
| Parse/modify `.pro` song files | `doc/api/song.md` |
| Parse/modify `.proplaylist` files | `doc/api/playlist.md` |
| Parse/modify `.probundle` files | `doc/api/bundle.md` |
| Read the global `Macros` file | `doc/api/macros.md` |
| Understand `.pro` binary format | `doc/formats/pp_song_spec.md` |
| Understand `.proplaylist` binary format | `doc/formats/pp_playlist_spec.md` |
| Understand `.probundle` binary format | `doc/formats/pp_bundle_spec.md` |
@ -53,12 +54,14 @@ PHP tools for parsing, modifying, and generating ProPresenter 7 files:
- **Songs** (`.pro`) — Protobuf-encoded presentation files with lyrics, groups, slides, arrangements, translations
- **Playlists** (`.proplaylist`) — ZIP64 archives containing playlist metadata and embedded songs
- **Bundles** (`.probundle`) — ZIP archives containing a single presentation with embedded media assets
- **Macros** (`Macros`, no extension) — Global protobuf-encoded macro library with collections
### CLI Tools
```bash
php bin/parse-song.php path/to/song.pro
php bin/parse-playlist.php path/to/playlist.proplaylist
php bin/parse-macros.php path/to/Macros
```
### Key Source Files

66
bin/parse-macros.php Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use ProPresenter\Parser\MacrosFileReader;
if ($argc < 2) {
echo "Usage: parse-macros.php <Macros>\n";
exit(1);
}
$filePath = $argv[1];
try {
$library = MacrosFileReader::read($filePath);
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
$macros = $library->getMacros();
$collections = $library->getCollections();
echo "Macros (" . count($macros) . "):\n";
foreach ($macros as $index => $macro) {
$number = $index + 1;
$name = $macro->getName();
$uuid = $macro->getUuid();
$actionCount = $macro->getActionCount();
$startup = $macro->getTriggerOnStartup() ? ' (startup)' : '';
$memberships = $library->getCollectionsForMacro($macro);
$collectionNames = array_map(fn ($c) => $c->getName(), $memberships);
$collectionSuffix = $collectionNames === [] ? '' : ' [in: ' . implode(', ', $collectionNames) . ']';
$displayName = $name === '' ? '(unnamed)' : $name;
echo " [" . $number . "] " . $displayName . " :: " . $uuid . " (" . $actionCount . " action" . ($actionCount !== 1 ? "s" : "") . ")" . $startup . $collectionSuffix . "\n";
}
echo "\n";
if ($collections === []) {
echo "Collections: (none)\n";
exit(0);
}
echo "Collections (" . count($collections) . "):\n";
foreach ($collections as $index => $collection) {
$number = $index + 1;
$name = $collection->getName();
$uuid = $collection->getUuid();
$resolvedMacros = $library->getMacrosForCollection($collection);
$count = count($resolvedMacros);
$displayName = $name === '' ? '(unnamed)' : $name;
echo " [" . $number . "] " . $displayName . " :: " . $uuid . " (" . $count . " macro" . ($count !== 1 ? "s" : "") . ")\n";
foreach ($resolvedMacros as $macroIndex => $macro) {
$macroNumber = $macroIndex + 1;
$macroName = $macro->getName() === '' ? '(unnamed)' : $macro->getName();
echo " " . $macroNumber . ". " . $macroName . " :: " . $macro->getUuid() . "\n";
}
}

View file

@ -9,6 +9,7 @@
| Parse/modify `.pro` song files | [api/song.md](api/song.md) |
| Parse/modify `.proplaylist` files | [api/playlist.md](api/playlist.md) |
| Parse/modify `.probundle` files | [api/bundle.md](api/bundle.md) |
| Read the global `Macros` file | [api/macros.md](api/macros.md) |
| Understand `.pro` binary format | [formats/pp_song_spec.md](formats/pp_song_spec.md) |
| Understand `.proplaylist` format | [formats/pp_playlist_spec.md](formats/pp_playlist_spec.md) |
| Understand `.probundle` format | [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) |
@ -28,6 +29,7 @@
- [api/song.md](api/song.md) — Song parser API (read, modify, generate `.pro` files)
- [api/playlist.md](api/playlist.md) — Playlist parser API (read, modify, generate `.proplaylist` files)
- [api/bundle.md](api/bundle.md) — Bundle parser API (read, write `.probundle` files with media)
- [api/macros.md](api/macros.md) — Macros library API (read the global `Macros` file)
### Internal Reference
- [internal/learnings.md](internal/learnings.md) — Development learnings and conventions discovered
@ -54,7 +56,8 @@ doc/
├── api/ ← PHP API documentation
│ ├── song.md
│ ├── playlist.md
│ └── bundle.md
│ ├── bundle.md
│ └── macros.md
└── internal/ ← Development notes (optional context)
├── learnings.md
├── decisions.md
@ -123,6 +126,8 @@ This project provides PHP tools to parse, modify, and generate ProPresenter 7 fi
| `src/PresentationBundle.php` | Bundle wrapper (read/write `.probundle` files) |
| `src/ProBundleReader.php` | Read `.probundle` files |
| `src/ProBundleWriter.php` | Write `.probundle` files |
| `src/MacroLibrary.php` | Macros library wrapper (read global `Macros` file) |
| `src/MacrosFileReader.php` | Read global `Macros` file |
### CLI Tools
@ -132,4 +137,7 @@ php bin/parse-song.php path/to/song.pro
# Parse and display playlist structure
php bin/parse-playlist.php path/to/playlist.proplaylist
# Parse and display the global Macros file
php bin/parse-macros.php path/to/Macros
```

151
doc/api/macros.md Normal file
View file

@ -0,0 +1,151 @@
# Macros Library API
> PHP module for reading the global ProPresenter `Macros` file (raw protobuf,
> no extension) and exposing each macro's name, UUID, and collection
> membership.
## Quick Reference
```php
use ProPresenter\Parser\MacrosFileReader;
$library = MacrosFileReader::read('/path/to/Macros');
foreach ($library->getMacros() as $macro) {
$macro->getName(); // "Gottesdienst START"
$macro->getUuid(); // "FA0602E4-EDA2-4457-BB62-68AA17184217"
foreach ($library->getCollectionsForMacro($macro) as $collection) {
$collection->getName(); // "Ablauf"
$collection->getUuid(); // "8D02FC57-83F8-4042-9B90-81C229728426"
}
}
```
---
## File Layout
The `Macros` file is the protobuf-serialised
[`MacrosDocument`](../../proto/macros.proto):
| Field | Type | Description |
|-------|------|-------------|
| `application_info` | message | ProPresenter version + flags that wrote the file |
| `macros` | repeated `Macro` | Definitions: UUID, name, color, actions, icon, startup flag |
| `macro_collections` | repeated `MacroCollection` | UUID, name, ordered list of `macro_id` references |
Macros and collections live at the document root. Membership is by UUID
reference — a macro may appear in zero, one, or multiple collections.
---
## Reading
```php
use ProPresenter\Parser\MacrosFileReader;
$library = MacrosFileReader::read('/Users/me/.../Macros');
```
Throws `InvalidArgumentException` for missing files and `RuntimeException` for
empty / unreadable files.
---
## MacroLibrary
Top-level wrapper around `Rv\Data\MacrosDocument`. Indexes macros and
collections for fast lookup.
```php
$library->getMacros(); // Macro[]
$library->getMacroByUuid('FA06...'); // ?Macro (case-insensitive)
$library->getMacroByName('Lied 1.Folie'); // ?Macro
$library->getCollections(); // MacroCollection[]
$library->getCollectionByUuid('8D02...'); // ?MacroCollection (case-insensitive)
$library->getCollectionByName('Ablauf'); // ?MacroCollection
// Cross-reference helpers
$library->getMacrosForCollection($collection); // Macro[] in declared order
$library->getCollectionsForMacro($macro); // MacroCollection[] (membership)
$library->getDocument(); // \Rv\Data\MacrosDocument (raw protobuf)
```
---
## Macro
```php
$macro->getUuid(); // "FA0602E4-..."
$macro->getName(); // "Gottesdienst START"
$macro->getColor(); // ['r'=>..,'g'=>..,'b'=>..,'a'=>..] | null
$macro->getTriggerOnStartup(); // bool
$macro->getActionCount(); // int — number of attached Action entries
$macro->getImageType(); // int — see Rv\Data\MacrosDocument\Macro\ImageType
$macro->getProto(); // \Rv\Data\MacrosDocument\Macro
```
Action payloads are not unwrapped by this library; reach for `getProto()` and
walk `getActions()` directly when needed.
---
## MacroCollection
```php
$collection->getUuid(); // "8D02FC57-..."
$collection->getName(); // "Ablauf"
$collection->getMacroUuids(); // string[] — referenced macro UUIDs in order
$collection->getProto(); // \Rv\Data\MacrosDocument\MacroCollection
```
Items use a protobuf `oneof ItemType`; only `macro_id` is currently defined.
Items without a populated reference are skipped.
---
## CLI Tool
```bash
php bin/parse-macros.php /path/to/Macros
```
Output:
```
Macros (24):
[1] Gottesdienst START :: FA0602E4-EDA2-4457-BB62-68AA17184217 (1 action) [in: Ablauf]
...
Collections (3):
[1] Ablauf :: 8D02FC57-83F8-4042-9B90-81C229728426 (12 macros)
1. Gottesdienst START :: FA0602E4-EDA2-4457-BB62-68AA17184217
...
```
---
## Key Files
| File | Purpose |
|------|---------|
| `src/MacroLibrary.php` | Document-level wrapper with lookup helpers |
| `src/Macro.php` | Single macro wrapper |
| `src/MacroCollection.php` | Collection wrapper |
| `src/MacrosFileReader.php` | Reads the `Macros` file |
| `bin/parse-macros.php` | CLI tool |
| `proto/macros.proto` | Protobuf schema |
| `generated/Rv/Data/MacrosDocument.php` | Generated message classes |
---
## Scope Notes
This module is read-only by design. Action editing, slide-side macro
references on `.pro` files (see `Slide::getMacroUuid()` /
`Slide::setMacro()`), and writing the `Macros` file back are not implemented
here. Add them by mirroring the `ProFileWriter` / `ProFileGenerator` pattern
when needed.

View file

@ -66,7 +66,10 @@
| Keyword | Document |
|---------|----------|
| macro | [api/song.md](api/song.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5 |
| macro | [api/macros.md](api/macros.md), [api/song.md](api/song.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5 |
| Macros file | [api/macros.md](api/macros.md) |
| MacroCollection | [api/macros.md](api/macros.md) |
| MacroLibrary | [api/macros.md](api/macros.md) |
| media | [api/song.md](api/song.md), [api/bundle.md](api/bundle.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 3 |
| image | [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 3 |
| video | [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 6 |
@ -77,10 +80,11 @@
| Keyword | Document |
|---------|----------|
| read | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md) |
| read | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md) |
| write | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md) |
| generate | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md) |
| parse | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md) |
| parse | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md) |
| MacrosFileReader | [api/macros.md](api/macros.md) |
| ProFileReader | [api/song.md](api/song.md) |
| ProFileWriter | [api/song.md](api/song.md) |
| ProFileGenerator | [api/song.md](api/song.md) |
@ -92,8 +96,8 @@
| PresentationBundle | [api/bundle.md](api/bundle.md) |
| Song | [api/song.md](api/song.md) |
| PlaylistArchive | [api/playlist.md](api/playlist.md) |
| CLI | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md) |
| command line | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md) |
| CLI | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md) |
| command line | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md) |
## Protobuf

Binary file not shown.

95
src/Macro.php Normal file
View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use Rv\Data\MacrosDocument\Macro as MacroProto;
/**
* Wraps a protobuf Macro, exposing its identifying fields (UUID, name) plus
* convenience metadata (color, action count, image type, startup flag).
*
* Macros live in the global ProPresenter `Macros` document and may belong to
* one or more {@see MacroCollection}s. Membership is resolved by
* {@see MacroLibrary}, not by this wrapper.
*/
class Macro
{
public function __construct(
private readonly MacroProto $macro,
) {
}
/**
* Get the macro's UUID as an upper-case string (empty when unset).
*/
public function getUuid(): string
{
return $this->macro->getUuid()?->getString() ?? '';
}
/**
* Get the macro's display name.
*/
public function getName(): string
{
return $this->macro->getName();
}
/**
* Get the macro's color as an associative array, or null when unset.
*
* @return array{r: float, g: float, b: float, a: float}|null
*/
public function getColor(): ?array
{
if (!$this->macro->hasColor()) {
return null;
}
$color = $this->macro->getColor();
return [
'r' => $color->getRed(),
'g' => $color->getGreen(),
'b' => $color->getBlue(),
'a' => $color->getAlpha(),
];
}
/**
* Whether the macro is configured to fire on application startup.
*/
public function getTriggerOnStartup(): bool
{
return $this->macro->getTriggerOnStartup();
}
/**
* Number of action entries attached to this macro.
*
* Action payloads are not exposed by this wrapper; use {@see getProto()} to
* inspect them via the generated protobuf classes.
*/
public function getActionCount(): int
{
return count($this->macro->getActions());
}
/**
* Icon enum value (see {@see \Rv\Data\MacrosDocument\Macro\ImageType}).
*/
public function getImageType(): int
{
return $this->macro->getImageType();
}
/**
* Get the underlying protobuf Macro message.
*/
public function getProto(): MacroProto
{
return $this->macro;
}
}

64
src/MacroCollection.php Normal file
View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use Rv\Data\MacrosDocument\MacroCollection as MacroCollectionProto;
/**
* Wraps a protobuf MacroCollection. A collection groups macros by UUID
* reference; macro definitions themselves live at the document root.
*/
class MacroCollection
{
public function __construct(
private readonly MacroCollectionProto $collection,
) {
}
/**
* Get the collection's UUID as a string (empty when unset).
*/
public function getUuid(): string
{
return $this->collection->getUuid()?->getString() ?? '';
}
/**
* Get the collection's display name (e.g. "Ablauf").
*/
public function getName(): string
{
return $this->collection->getName();
}
/**
* Get the UUIDs of macros referenced by this collection, in order.
*
* Items in the protobuf use a `oneof` ItemType currently only
* `macro_id` is defined. Items without a populated reference are skipped.
*
* @return string[]
*/
public function getMacroUuids(): array
{
$uuids = [];
foreach ($this->collection->getItems() as $item) {
$macroId = $item->getMacroId();
if ($macroId !== null) {
$uuids[] = $macroId->getString();
}
}
return $uuids;
}
/**
* Get the underlying protobuf MacroCollection message.
*/
public function getProto(): MacroCollectionProto
{
return $this->collection;
}
}

163
src/MacroLibrary.php Normal file
View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use Rv\Data\MacrosDocument;
/**
* Wraps a protobuf MacrosDocument the global ProPresenter `Macros` file
* which lists every macro definition and the collections that group them.
*
* Lookup helpers index macros and collections by UUID (case-insensitive) and
* by name, mirroring the convention used by {@see Song}. Collection
* membership is resolved by mapping {@see MacroCollection::getMacroUuids()}
* back through this library.
*/
class MacroLibrary
{
/** @var Macro[] */
private array $macros = [];
/** @var MacroCollection[] */
private array $collections = [];
/** @var array<string, Macro> */
private array $macrosByUuid = [];
/** @var array<string, Macro> */
private array $macrosByName = [];
/** @var array<string, MacroCollection> */
private array $collectionsByUuid = [];
/** @var array<string, MacroCollection> */
private array $collectionsByName = [];
/** @var array<string, MacroCollection[]> */
private array $collectionsByMacroUuid = [];
public function __construct(
private readonly MacrosDocument $document,
) {
foreach ($this->document->getMacros() as $macroProto) {
$macro = new Macro($macroProto);
$this->macros[] = $macro;
$uuid = strtoupper($macro->getUuid());
if ($uuid !== '') {
$this->macrosByUuid[$uuid] = $macro;
}
$name = $macro->getName();
if ($name !== '') {
$this->macrosByName[$name] = $macro;
}
}
foreach ($this->document->getMacroCollections() as $collectionProto) {
$collection = new MacroCollection($collectionProto);
$this->collections[] = $collection;
$uuid = strtoupper($collection->getUuid());
if ($uuid !== '') {
$this->collectionsByUuid[$uuid] = $collection;
}
$name = $collection->getName();
if ($name !== '') {
$this->collectionsByName[$name] = $collection;
}
foreach ($collection->getMacroUuids() as $macroUuid) {
$key = strtoupper($macroUuid);
if ($key === '') {
continue;
}
$this->collectionsByMacroUuid[$key][] = $collection;
}
}
}
/**
* @return Macro[]
*/
public function getMacros(): array
{
return $this->macros;
}
public function getMacroByUuid(string $uuid): ?Macro
{
return $this->macrosByUuid[strtoupper($uuid)] ?? null;
}
public function getMacroByName(string $name): ?Macro
{
return $this->macrosByName[$name] ?? null;
}
/**
* @return MacroCollection[]
*/
public function getCollections(): array
{
return $this->collections;
}
public function getCollectionByUuid(string $uuid): ?MacroCollection
{
return $this->collectionsByUuid[strtoupper($uuid)] ?? null;
}
public function getCollectionByName(string $name): ?MacroCollection
{
return $this->collectionsByName[$name] ?? null;
}
/**
* Resolve a collection's referenced macros to {@see Macro} wrappers.
*
* Unknown UUIDs (referenced but not defined at document root) are skipped.
*
* @return Macro[]
*/
public function getMacrosForCollection(MacroCollection $collection): array
{
$resolved = [];
foreach ($collection->getMacroUuids() as $uuid) {
$macro = $this->getMacroByUuid($uuid);
if ($macro !== null) {
$resolved[] = $macro;
}
}
return $resolved;
}
/**
* Get the collections a macro belongs to (membership is by UUID
* reference). A macro may legally appear in zero, one, or many
* collections.
*
* @return MacroCollection[]
*/
public function getCollectionsForMacro(Macro $macro): array
{
$key = strtoupper($macro->getUuid());
if ($key === '') {
return [];
}
return $this->collectionsByMacroUuid[$key] ?? [];
}
/**
* Get the underlying protobuf MacrosDocument.
*/
public function getDocument(): MacrosDocument
{
return $this->document;
}
}

42
src/MacrosFileReader.php Normal file
View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser;
use InvalidArgumentException;
use RuntimeException;
use Rv\Data\MacrosDocument;
/**
* Reader for the global ProPresenter `Macros` file (a raw protobuf
* serialisation of {@see MacrosDocument}, no extension).
*/
final class MacrosFileReader
{
public static function read(string $filePath): MacroLibrary
{
if ($filePath === '' || !is_file($filePath)) {
throw new InvalidArgumentException(sprintf('Macros file not found: %s', $filePath));
}
$size = filesize($filePath);
if ($size === false) {
throw new RuntimeException(sprintf('Unable to determine file size: %s', $filePath));
}
if ($size === 0) {
throw new RuntimeException(sprintf('Macros file is empty: %s', $filePath));
}
$data = file_get_contents($filePath);
if ($data === false) {
throw new RuntimeException(sprintf('Unable to read macros file: %s', $filePath));
}
$document = new MacrosDocument();
$document->mergeFromString($data);
return new MacroLibrary($document);
}
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\Macro;
use ProPresenter\Parser\MacroCollection;
use ProPresenter\Parser\MacroLibrary;
use ProPresenter\Parser\MacrosFileReader;
class MacrosFileReaderTest extends TestCase
{
private const REFERENCE_PATH = __DIR__ . '/../doc/reference_samples/Macros';
#[Test]
public function readThrowsOnMissingFile(): void
{
$this->expectException(InvalidArgumentException::class);
MacrosFileReader::read(__DIR__ . '/../doc/reference_samples/does-not-exist-macros');
}
#[Test]
public function readReturnsMacroLibraryWithExpectedCounts(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$this->assertInstanceOf(MacroLibrary::class, $library);
$this->assertCount(24, $library->getMacros());
$this->assertCount(3, $library->getCollections());
}
#[Test]
public function macrosExposeNameAndUuid(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$first = $library->getMacros()[0];
$this->assertInstanceOf(Macro::class, $first);
$this->assertSame('Gottesdienst START', $first->getName());
$this->assertSame('FA0602E4-EDA2-4457-BB62-68AA17184217', $first->getUuid());
}
#[Test]
public function macroLookupByUuidIsCaseInsensitive(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$upper = $library->getMacroByUuid('FA0602E4-EDA2-4457-BB62-68AA17184217');
$lower = $library->getMacroByUuid('fa0602e4-eda2-4457-bb62-68aa17184217');
$this->assertNotNull($upper);
$this->assertSame($upper, $lower);
$this->assertSame('Gottesdienst START', $upper->getName());
}
#[Test]
public function macroLookupByNameSucceeds(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$macro = $library->getMacroByName('Predigt - Text Lang');
$this->assertNotNull($macro);
$this->assertSame('0A1543A9-7881-4537-982C-2933AA5472F8', $macro->getUuid());
}
#[Test]
public function collectionsExposeNameUuidAndOrderedMacroUuids(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$ablauf = $library->getCollectionByName('Ablauf');
$this->assertInstanceOf(MacroCollection::class, $ablauf);
$this->assertSame('8D02FC57-83F8-4042-9B90-81C229728426', $ablauf->getUuid());
$uuids = $ablauf->getMacroUuids();
$this->assertCount(12, $uuids);
$this->assertSame('FA0602E4-EDA2-4457-BB62-68AA17184217', $uuids[0]);
$this->assertSame('5ADFBB7A-1529-42B9-A9C6-77B7D01C4715', $uuids[11]);
}
#[Test]
public function collectionLookupByUuidIsCaseInsensitive(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$upper = $library->getCollectionByUuid('AD18A4F6-135F-4A52-B92B-CA6619A55A9B');
$lower = $library->getCollectionByUuid('ad18a4f6-135f-4a52-b92b-ca6619a55a9b');
$this->assertNotNull($upper);
$this->assertSame($upper, $lower);
$this->assertSame('AbsoluteTimer', $upper->getName());
}
#[Test]
public function getMacrosForCollectionResolvesReferences(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$absoluteTimer = $library->getCollectionByName('AbsoluteTimer');
$this->assertNotNull($absoluteTimer);
$macros = $library->getMacrosForCollection($absoluteTimer);
$this->assertCount(2, $macros);
$this->assertSame('Doors Open - 9:45', $macros[0]->getName());
$this->assertSame('Godi START - 10:02', $macros[1]->getName());
}
#[Test]
public function getCollectionsForMacroReturnsMembership(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$macro = $library->getMacroByUuid('FA0602E4-EDA2-4457-BB62-68AA17184217');
$this->assertNotNull($macro);
$collections = $library->getCollectionsForMacro($macro);
$this->assertCount(1, $collections);
$this->assertSame('Ablauf', $collections[0]->getName());
}
#[Test]
public function startupMacroFlagSurfaces(): void
{
$library = MacrosFileReader::read(self::REFERENCE_PATH);
$startup = array_values(array_filter(
$library->getMacros(),
fn (Macro $m) => $m->getTriggerOnStartup(),
));
$this->assertCount(1, $startup);
$this->assertSame('Doors Open - 9:45', $startup[0]->getName());
}
}