- Record final project status in learnings.md - Add all task evidence files (43 files) - Add work plan with all 29 checkboxes complete - Add boulder state tracking Project complete: 99 tests passing, all deliverables verified
1507 lines
74 KiB
Markdown
1507 lines
74 KiB
Markdown
# ProPresenter .proplaylist PHP Module
|
|
|
|
## TL;DR
|
|
|
|
> **Quick Summary**: Build a complete PHP module for reading, writing, and generating ProPresenter 7 `.proplaylist` files. The format is a ZIP64 archive containing protobuf-encoded playlist metadata (`data` file), embedded `.pro` song files, and media files. Extends the existing `php/src/` parser codebase following the same static-factory + protobuf-wrapper patterns.
|
|
>
|
|
> **Deliverables**:
|
|
> - Proto modification: add undocumented `arrangement_name` field 5 + regenerate PHP classes
|
|
> - ZIP64 patching utility: fix ProPresenter's broken ZIP headers for PHP ZipArchive compatibility
|
|
> - 3 wrapper classes: `PlaylistArchive`, `PlaylistNode`, `PlaylistEntry`
|
|
> - Reader: `ProPlaylistReader` — reads .proplaylist ZIP → wrapper objects
|
|
> - Writer: `ProPlaylistWriter` — serializes wrapper objects → .proplaylist ZIP
|
|
> - Generator: `ProPlaylistGenerator` — creates playlists from scratch
|
|
> - CLI tool: `php/bin/parse-playlist.php` — inspect playlist structure
|
|
> - Spec document: `spec/pp_playlist_spec.md` — format documentation
|
|
> - Full PHPUnit test suite: TDD for all components
|
|
>
|
|
> **Estimated Effort**: Large
|
|
> **Parallel Execution**: YES — 4 waves
|
|
> **Critical Path**: Task 1 → Task 2 → Task 4 → Task 7 → Task 9 → Task 11 → Task 14
|
|
|
|
---
|
|
|
|
## Context
|
|
|
|
### Original Request
|
|
User wants to extend the existing ProPresenter PHP parser to support `.proplaylist` files — ZIP archives that bundle playlist metadata, `.pro` song files, and media files. This is a completely undocumented format that was reverse-engineered from 4 real files.
|
|
|
|
### Interview Summary
|
|
**Key Discussions**:
|
|
- **Scope**: Full Read + Write + Generate support
|
|
- **Testing**: TDD with PHPUnit (existing infrastructure: PHPUnit 11 with `#[Test]` attributes)
|
|
- **Field 5 handling**: Extend proto locally — add `string arrangement_name = 5;` to `PlaylistItem.Presentation`
|
|
- **Writer ZIP quirk**: Write clean, standard-compliant ZIP (don't reproduce PP's 98-byte quirk)
|
|
- **Embedded .pro parsing**: Raw bytes + lazy parsing — store bytes, provide `getEmbeddedSong()` on demand
|
|
|
|
**Research Findings**:
|
|
- No public documentation exists for .proplaylist format
|
|
- Format is ZIP64 with `store` compression; `data` file contains protobuf `PlaylistDocument`
|
|
- ProPresenter exports ZIP with consistently broken Central Directory size fields (off by 98 bytes)
|
|
- PHP's ZipArchive rejects these files (error 21 = ER_INCONS); need patching before reading
|
|
- Undocumented field 5 on `PlaylistItem.Presentation` stores arrangement name as string
|
|
- All proto classes already generated in `php/generated/Rv/Data/`
|
|
- URL paths use `ROOT_USER_HOME (2)` for personal paths, `ROOT_SHOW (10)` for PP library paths
|
|
|
|
### Metis Review
|
|
**Identified Gaps** (addressed):
|
|
- Wrapper class naming collision: Use `PlaylistArchive`, `PlaylistNode`, `PlaylistEntry` (not `Playlist`/`PlaylistItem`)
|
|
- Proto regeneration: Must regenerate after adding field 5 — verified existing Makefile/script patterns
|
|
- ZIP64 patching: Must patch both ZIP64 EOCD (offset +40) and regular EOCD (offset +12) — validated algorithm
|
|
- Test data paths: Use `dirname(__DIR__, 2) . '/ref/...'` pattern matching existing tests
|
|
|
|
---
|
|
|
|
## Work Objectives
|
|
|
|
### Core Objective
|
|
Implement a complete, tested PHP module that can read, write, and generate ProPresenter 7 `.proplaylist` files, following the same architecture patterns as the existing `.pro` file parser.
|
|
|
|
### Concrete Deliverables
|
|
- Modified `php/proto/playlist.proto` with field 5 added
|
|
- Regenerated PHP classes in `php/generated/`
|
|
- `php/src/Zip64Fixer.php` — ZIP64 header patching utility
|
|
- `php/src/PlaylistArchive.php` — top-level playlist wrapper (like `Song.php`)
|
|
- `php/src/PlaylistNode.php` — playlist/folder node wrapper (like `Group.php`)
|
|
- `php/src/PlaylistEntry.php` — playlist item wrapper (header/presentation/placeholder/cue)
|
|
- `php/src/ProPlaylistReader.php` — reads .proplaylist files (like `ProFileReader.php`)
|
|
- `php/src/ProPlaylistWriter.php` — writes .proplaylist files (like `ProFileWriter.php`)
|
|
- `php/src/ProPlaylistGenerator.php` — generates playlists from scratch (like `ProFileGenerator.php`)
|
|
- `php/bin/parse-playlist.php` — CLI tool (like `php/bin/parse-song.php`)
|
|
- `spec/pp_playlist_spec.md` — format specification (like `spec/pp_song_spec.md`)
|
|
- `php/tests/Zip64FixerTest.php` — tests for ZIP patching
|
|
- `php/tests/PlaylistArchiveTest.php` — tests for wrapper class
|
|
- `php/tests/PlaylistNodeTest.php` — tests for node wrapper
|
|
- `php/tests/PlaylistEntryTest.php` — tests for entry wrapper
|
|
- `php/tests/ProPlaylistReaderTest.php` — tests for reader
|
|
- `php/tests/ProPlaylistWriterTest.php` — tests for writer
|
|
- `php/tests/ProPlaylistGeneratorTest.php` — tests for generator
|
|
|
|
### Definition of Done
|
|
- [x] `php vendor/bin/phpunit` from `php/` directory — ALL tests pass (0 failures, 0 errors)
|
|
- [x] `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` — outputs structured playlist data
|
|
- [x] `php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist` — handles large real-world file
|
|
- [x] Round-trip: read → write → read produces identical data
|
|
- [x] Generated playlist can be read back by the reader
|
|
|
|
### Must Have
|
|
- ZIP64 patching that handles ProPresenter's broken archives
|
|
- All 4 PlaylistItem types: header, presentation, placeholder, cue
|
|
- Arrangement name (field 5) read/write support
|
|
- Embedded .pro file access (raw bytes + lazy Song parsing)
|
|
- Media file access from ZIP
|
|
- All URL root types (ROOT_USER_HOME, ROOT_SHOW)
|
|
- CLI tool with structured output matching parse-song.php style
|
|
- Format specification document
|
|
|
|
### Must NOT Have (Guardrails)
|
|
- Do NOT modify generated proto PHP classes directly — only modify `.proto` source and regenerate
|
|
- Do NOT name wrapper classes `Playlist` or `PlaylistItem` — collision with `Rv\Data\Playlist` / `Rv\Data\PlaylistItem`
|
|
- Do NOT auto-parse embedded .pro files during read — use lazy loading only
|
|
- Do NOT reproduce ProPresenter's 98-byte ZIP quirk in writer — write clean, standard ZIP
|
|
- Do NOT add audio playlist support (TYPE_AUDIO) — out of scope
|
|
- Do NOT add PlanningCenter integration — out of scope
|
|
- Do NOT add smart directory support — out of scope
|
|
- Do NOT create abstract base classes or over-abstract — follow the flat, concrete style of existing code
|
|
|
|
---
|
|
|
|
## Verification Strategy
|
|
|
|
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
|
|
|
|
### Test Decision
|
|
- **Infrastructure exists**: YES
|
|
- **Automated tests**: TDD (RED → GREEN → REFACTOR)
|
|
- **Framework**: PHPUnit 11 with `#[Test]` attributes
|
|
- **Each task**: Write failing test first, then implement until tests pass
|
|
|
|
### QA Policy
|
|
Every task MUST include agent-executed QA scenarios.
|
|
Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`.
|
|
|
|
- **PHP Module**: Use Bash — `php vendor/bin/phpunit --filter ClassName` or inline PHP execution
|
|
- **CLI Tool**: Use Bash — run CLI command, verify output format
|
|
- **Proto Regen**: Use Bash — verify classes exist and contain expected methods
|
|
|
|
---
|
|
|
|
## Execution Strategy
|
|
|
|
### Parallel Execution Waves
|
|
|
|
```
|
|
Wave 1 (Foundation — start immediately, all independent):
|
|
├── Task 1: Proto modification + regeneration [quick]
|
|
├── Task 2: Zip64Fixer utility (TDD) [deep]
|
|
└── Task 3: Format specification document [writing]
|
|
|
|
Wave 2 (Wrapper classes — after Wave 1, all parallel):
|
|
├── Task 4: PlaylistEntry wrapper (TDD) [unspecified-high]
|
|
├── Task 5: PlaylistNode wrapper (TDD) [unspecified-high]
|
|
└── Task 6: PlaylistArchive wrapper (TDD) [unspecified-high]
|
|
|
|
Wave 3 (Reader/Writer/Generator — after Wave 2):
|
|
├── Task 7: ProPlaylistReader (TDD) [deep]
|
|
├── Task 8: ProPlaylistWriter (TDD) [deep]
|
|
├── Task 9: ProPlaylistGenerator (TDD) [deep]
|
|
└── Task 10: CLI tool parse-playlist.php [quick]
|
|
|
|
Wave 4 (Integration + verification — after Wave 3):
|
|
├── Task 11: Round-trip integration tests [deep]
|
|
├── Task 12: Large-file / real-world validation [unspecified-high]
|
|
└── Task 13: AGENTS.md documentation update [quick]
|
|
|
|
Wave FINAL (After ALL tasks — independent review, 4 parallel):
|
|
├── Task F1: Plan compliance audit (oracle)
|
|
├── Task F2: Code quality review (unspecified-high)
|
|
├── Task F3: Real manual QA (unspecified-high)
|
|
└── Task F4: Scope fidelity check (deep)
|
|
|
|
Critical Path: Task 1 → Task 2 → Task 4 → Task 7 → Task 9 → Task 11 → F1-F4
|
|
Parallel Speedup: ~60% faster than sequential
|
|
Max Concurrent: 3 (Waves 1, 2, 3)
|
|
```
|
|
|
|
### Dependency Matrix
|
|
|
|
| Task | Depends On | Blocks | Wave |
|
|
|------|-----------|--------|------|
|
|
| 1 | — | 4, 5, 6 | 1 |
|
|
| 2 | — | 7, 8 | 1 |
|
|
| 3 | — | — | 1 |
|
|
| 4 | 1 | 5, 6, 7, 8, 9 | 2 |
|
|
| 5 | 1, 4 | 6, 7, 8, 9 | 2 |
|
|
| 6 | 1, 4, 5 | 7, 8, 9, 10 | 2 |
|
|
| 7 | 2, 6 | 8, 10, 11, 12 | 3 |
|
|
| 8 | 6, 7 | 11, 12 | 3 |
|
|
| 9 | 6 | 11 | 3 |
|
|
| 10 | 7 | 12 | 3 |
|
|
| 11 | 7, 8, 9 | — | 4 |
|
|
| 12 | 7, 10 | — | 4 |
|
|
| 13 | 7, 10 | — | 4 |
|
|
| F1-F4 | ALL | — | FINAL |
|
|
|
|
### Agent Dispatch Summary
|
|
|
|
- **Wave 1**: 3 tasks — T1 → `quick`, T2 → `deep`, T3 → `writing`
|
|
- **Wave 2**: 3 tasks — T4 → `unspecified-high`, T5 → `unspecified-high`, T6 → `unspecified-high`
|
|
- **Wave 3**: 4 tasks — T7 → `deep`, T8 → `deep`, T9 → `deep`, T10 → `quick`
|
|
- **Wave 4**: 3 tasks — T11 → `deep`, T12 → `unspecified-high`, T13 → `quick`
|
|
- **FINAL**: 4 tasks — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep`
|
|
|
|
---
|
|
|
|
## TODOs
|
|
|
|
> Implementation + Test = ONE Task. Never separate.
|
|
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
|
|
|
|
|
|
- [x] 1. Proto modification: Add arrangement_name field 5 + regenerate PHP classes
|
|
|
|
**What to do**:
|
|
- Edit `php/proto/playlist.proto`: in the `PlaylistItem.Presentation` message (line ~89-94), add `string arrangement_name = 5;` after the `user_music_key` field
|
|
- Regenerate PHP protobuf classes by running `protoc` with the same flags used for the existing generation. Check if there's a Makefile or shell script in `php/` or project root. If not, run:
|
|
```
|
|
protoc --php_out=php/generated --proto_path=php/proto php/proto/*.proto
|
|
```
|
|
- Verify the regenerated `php/generated/Rv/Data/PlaylistItem/Presentation.php` now has `getArrangementName()` and `setArrangementName()` methods
|
|
- Run existing tests to ensure nothing broke: `cd php && php vendor/bin/phpunit`
|
|
|
|
**Must NOT do**:
|
|
- Do NOT manually edit any file in `php/generated/` — only modify `.proto` source
|
|
- Do NOT change any existing field numbers or types
|
|
- Do NOT add fields beyond `arrangement_name = 5`
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `quick`
|
|
- Reason: Single proto file edit + running a command to regenerate
|
|
- **Skills**: []
|
|
- No special skills needed — file edit + bash command
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 1 (with Tasks 2, 3)
|
|
- **Blocks**: Tasks 4, 5, 6 (wrapper classes need the updated proto)
|
|
- **Blocked By**: None (can start immediately)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/proto/playlist.proto:89-94` — The `PlaylistItem.Presentation` message where field 5 must be added. Currently has fields 1-4 (document_path, arrangement, content_destination, user_music_key)
|
|
- `php/generated/Rv/Data/PlaylistItem/Presentation.php` — The generated class that will be regenerated. Currently has getters/setters for fields 1-4 only
|
|
|
|
**API/Type References**:
|
|
- `php/generated/Rv/Data/PlaylistDocument.php` — Top-level message class (should NOT change)
|
|
- `php/generated/Rv/Data/PlaylistItem.php` — Parent class with oneof ItemType (should gain no new fields)
|
|
|
|
**External References**:
|
|
- Protobuf proto3 syntax: field 5 is `string arrangement_name = 5;`
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `playlist.proto:89-94`: This is the EXACT location to add the field. The `PlaylistItem.Presentation` message currently ends at field 4. Field 5 is undocumented in the community proto but observed in every real-world .proplaylist file holding an arrangement name string like "normal", "bene", "test2"
|
|
- The generated Presentation.php: After regeneration, verify this file has the new methods — that's the acceptance criterion
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/proto/playlist.proto` contains `string arrangement_name = 5;` inside `PlaylistItem.Presentation` message
|
|
- [ ] `php/generated/Rv/Data/PlaylistItem/Presentation.php` contains methods `getArrangementName()` and `setArrangementName()`
|
|
- [ ] `cd php && php vendor/bin/phpunit` — ALL existing tests still pass (0 failures)
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Proto field 5 present in source
|
|
Tool: Bash (grep)
|
|
Preconditions: Task 1 complete
|
|
Steps:
|
|
1. grep 'arrangement_name' php/proto/playlist.proto
|
|
2. Assert output contains 'string arrangement_name = 5;'
|
|
Expected Result: Exactly one match in PlaylistItem.Presentation message
|
|
Failure Indicators: No match, or field number != 5
|
|
Evidence: .sisyphus/evidence/task-1-proto-field.txt
|
|
|
|
Scenario: Generated PHP class has new methods
|
|
Tool: Bash (grep)
|
|
Preconditions: Proto regeneration complete
|
|
Steps:
|
|
1. grep -c 'getArrangementName\|setArrangementName' php/generated/Rv/Data/PlaylistItem/Presentation.php
|
|
2. Assert count >= 2
|
|
Expected Result: At least 2 matches (getter + setter)
|
|
Failure Indicators: 0 matches means regeneration failed or field not added
|
|
Evidence: .sisyphus/evidence/task-1-generated-methods.txt
|
|
|
|
Scenario: Existing tests still pass
|
|
Tool: Bash
|
|
Preconditions: Regeneration complete
|
|
Steps:
|
|
1. cd php && php vendor/bin/phpunit
|
|
2. Assert exit code 0
|
|
3. Assert output contains 'OK'
|
|
Expected Result: All existing tests pass with 0 failures
|
|
Failure Indicators: Any test failure or error
|
|
Evidence: .sisyphus/evidence/task-1-existing-tests.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `feat(proto): add arrangement_name field 5 to PlaylistItem.Presentation`
|
|
- Files: `php/proto/playlist.proto`, `php/generated/**`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit`
|
|
|
|
- [x] 2. Zip64Fixer utility (TDD)
|
|
|
|
**What to do**:
|
|
- **RED**: Write `php/tests/Zip64FixerTest.php` first with these test cases:
|
|
- `fixReturnsValidZipForProPresenterExport`: Pass `ref/TestPlaylist.proplaylist` bytes → output should open with `ZipArchive::open()` without errors
|
|
- `fixReturnsValidZipForLargePlaylist`: Pass `ref/ExamplePlaylists/Gottesdienst.proplaylist` → same
|
|
- `fixThrowsOnNonZipData`: Pass random bytes → expect RuntimeException
|
|
- `fixThrowsOnTooSmallData`: Pass 10 bytes → expect RuntimeException
|
|
- `fixPreservesAllEntries`: After fix, ZipArchive lists same entries as `unzip -l`
|
|
- `fixIdempotent`: Fixing an already-fixed file produces identical output
|
|
- **GREEN**: Implement `php/src/Zip64Fixer.php` with a single static method:
|
|
```php
|
|
final class Zip64Fixer {
|
|
public static function fix(string $zipData): string
|
|
}
|
|
```
|
|
- Algorithm:
|
|
1. Find EOCD signature (`\x50\x4b\x05\x06`) scanning from end of file
|
|
2. Read CD offset from EOCD (offset +16, 4 bytes, little-endian)
|
|
3. If CD offset == 0xFFFFFFFF, find ZIP64 EOCD locator (signature `\x50\x4b\x06\x07`), then ZIP64 EOCD (signature `\x50\x4b\x06\x06`)
|
|
4. From ZIP64 EOCD: read CD offset (offset +48, 8 bytes LE) and zip64_eocd_position
|
|
5. Calculate `correct_cd_size = zip64_eocd_position - cd_offset`
|
|
6. Patch ZIP64 EOCD field at offset +40 (8 bytes LE) with correct_cd_size
|
|
7. Patch regular EOCD field at offset +12 (4 bytes LE) with `min(correct_cd_size, 0xFFFFFFFF)`
|
|
8. Return patched bytes
|
|
- **REFACTOR**: Extract constants for magic numbers, add doc comments
|
|
|
|
**Must NOT do**:
|
|
- Do NOT modify the original file on disk — work on bytes in memory
|
|
- Do NOT decompress/recompress — only patch header fields
|
|
- Do NOT use external ZIP repair tools
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `deep`
|
|
- Reason: Binary format parsing requires careful byte-level work and thorough testing
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 1 (with Tasks 1, 3)
|
|
- **Blocks**: Tasks 7, 8 (Reader/Writer need the fixer)
|
|
- **Blocked By**: None (can start immediately)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/src/ProFileReader.php:13-37` — Static factory pattern with validation/error handling. Follow the same `InvalidArgumentException`/`RuntimeException` pattern
|
|
- `php/tests/ProFileReaderTest.php` — Test structure with `#[Test]` attributes, `dirname(__DIR__, 2)` for test data paths
|
|
|
|
**API/Type References**:
|
|
- PHP `ZipArchive` class — Used to verify the fix works. `ZipArchive::open()` returns `true` on success, error code on failure. Error 21 = `ER_INCONS` (the broken state)
|
|
|
|
**External References**:
|
|
- ZIP64 format: EOCD at end of file, signature `0x06054b50`. ZIP64 EOCD signature `0x06064b50`. ZIP64 EOCD locator `0x07064b50`
|
|
- The 98-byte discrepancy: ProPresenter writes CD size field as `actual_cd_size + 98` consistently across all exported files
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `ProFileReader.php`: Shows the exact error handling pattern (exception types, message formatting) to replicate
|
|
- `ProFileReaderTest.php`: Shows the exact test structure (namespace, imports, `#[Test]` attribute, `dirname(__DIR__, 2)` for paths) to replicate
|
|
- ZIP64 specification: The byte offsets and signatures are critical — getting any offset wrong by 1 byte breaks everything
|
|
|
|
**Test Data**:
|
|
- `ref/TestPlaylist.proplaylist` — small file, fast to test
|
|
- `ref/ExamplePlaylists/Gottesdienst.proplaylist` — 14MB real-world file
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/tests/Zip64FixerTest.php` exists with ≥6 test methods
|
|
- [ ] `php/src/Zip64Fixer.php` exists with `fix(string $zipData): string` static method
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter Zip64FixerTest` — ALL tests pass
|
|
- [ ] Fixed ZIP data opens with `new ZipArchive()` without errors for all 4 test files
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Fix and open TestPlaylist
|
|
Tool: Bash
|
|
Preconditions: Zip64Fixer implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$data = file_get_contents('ref/TestPlaylist.proplaylist');
|
|
\$fixed = ProPresenter\Parser\Zip64Fixer::fix(\$data);
|
|
\$tmp = tempnam(sys_get_temp_dir(), 'zip');
|
|
file_put_contents(\$tmp, \$fixed);
|
|
\$za = new ZipArchive();
|
|
\$result = \$za->open(\$tmp);
|
|
echo \$result === true ? 'OK' : 'FAIL: ' . \$result;
|
|
unlink(\$tmp);
|
|
"
|
|
2. Assert output is 'OK'
|
|
Expected Result: ZipArchive opens without error
|
|
Failure Indicators: Output contains 'FAIL' or any error code
|
|
Evidence: .sisyphus/evidence/task-2-fix-open.txt
|
|
|
|
Scenario: Error on invalid input
|
|
Tool: Bash
|
|
Preconditions: Zip64Fixer implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
try { ProPresenter\Parser\Zip64Fixer::fix('not a zip'); echo 'NO_EXCEPTION'; }
|
|
catch (RuntimeException \$e) { echo 'OK: ' . \$e->getMessage(); }"
|
|
2. Assert output starts with 'OK:'
|
|
Expected Result: RuntimeException thrown for invalid data
|
|
Failure Indicators: Output is 'NO_EXCEPTION'
|
|
Evidence: .sisyphus/evidence/task-2-error-handling.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `feat(playlist): add Zip64Fixer for ProPresenter ZIP archives`
|
|
- Files: `php/src/Zip64Fixer.php`, `php/tests/Zip64FixerTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit --filter Zip64FixerTest`
|
|
|
|
- [x] 3. Format specification document
|
|
|
|
**What to do**:
|
|
- Create `spec/pp_playlist_spec.md` documenting the .proplaylist file format
|
|
- Follow the structure and style of `spec/pp_song_spec.md`
|
|
- Document:
|
|
- Container format: ZIP64 archive, store compression, ZIP64 EOCD quirk
|
|
- ZIP entry layout: `data` file at root, `.pro` files at root, media files at original paths
|
|
- Protobuf structure: `PlaylistDocument` → `Playlist` (root_node) → `PlaylistArray` → child `Playlist` → `PlaylistItems` → `PlaylistItem[]`
|
|
- All PlaylistItem types: Header (field 3), Presentation (field 4), Cue (field 5), PlanningCenter (field 6), Placeholder (field 8)
|
|
- Presentation details: document_path URL, arrangement UUID, arrangement_name (field 5, undocumented), user_music_key
|
|
- URL root types: ROOT_USER_HOME (2), ROOT_SHOW (10)
|
|
- Deduplication rules: same .pro stored once, media files deduplicated
|
|
- Known values: application_info, type = TYPE_PRESENTATION, root always named "PLAYLIST"
|
|
- Include concrete examples from the reverse-engineered data
|
|
|
|
**Must NOT do**:
|
|
- Do NOT include raw protobuf hex dumps — use structured descriptions
|
|
- Do NOT speculate about unobserved features — document only what was verified
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `writing`
|
|
- Reason: Technical documentation task
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 1 (with Tasks 1, 2)
|
|
- **Blocks**: Nothing (informational document)
|
|
- **Blocked By**: None
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `spec/pp_song_spec.md` — Follow the EXACT same document structure, heading hierarchy, and level of detail. This is the template for the playlist spec
|
|
|
|
**External References**:
|
|
- Draft analysis: `.sisyphus/drafts/proplaylist-format.md` — Contains all reverse-engineered findings. Copy relevant sections and restructure into spec format
|
|
- Proto definition: `php/proto/playlist.proto` — Authoritative message structure
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `pp_song_spec.md`: Establishes the documentation standard for this project. The playlist spec must feel like it belongs alongside the song spec
|
|
- Draft analysis: Contains ALL the raw findings from reverse-engineering. The spec document restructures this into a clean, permanent reference
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `spec/pp_playlist_spec.md` exists
|
|
- [ ] Document covers: container format, ZIP layout, protobuf structure, all item types, URL conventions
|
|
- [ ] Document includes concrete examples
|
|
- [ ] Follows `spec/pp_song_spec.md` style
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Spec document exists and covers key sections
|
|
Tool: Bash (grep)
|
|
Preconditions: Task 3 complete
|
|
Steps:
|
|
1. test -f spec/pp_playlist_spec.md && echo EXISTS
|
|
2. grep -c 'PlaylistDocument\|PlaylistItem\|ZIP64\|arrangement_name\|ROOT_USER_HOME\|Header\|Presentation\|Placeholder' spec/pp_playlist_spec.md
|
|
Expected Result: File exists, grep count >= 8 (all key terms present)
|
|
Failure Indicators: File missing or key terms not mentioned
|
|
Evidence: .sisyphus/evidence/task-3-spec-coverage.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `docs(spec): add .proplaylist format specification`
|
|
- Files: `spec/pp_playlist_spec.md`
|
|
- Pre-commit: —
|
|
|
|
- [x] 4. PlaylistEntry wrapper class (TDD)
|
|
|
|
**What to do**:
|
|
- **RED**: Write `php/tests/PlaylistEntryTest.php` first:
|
|
- `getUuid`: returns item UUID string
|
|
- `getName`: returns item name string
|
|
- `getType`: returns 'header' | 'presentation' | 'placeholder' | 'cue' | 'unknown'
|
|
- `isHeader` / `isPresentation` / `isPlaceholder` / `isCue`: boolean type checks
|
|
- For header items: `getHeaderColor()` returns `[r, g, b, a]` array
|
|
- For presentation items: `getDocumentPath()`, `getArrangementUuid()`, `getArrangementName()`, `hasArrangement()`
|
|
- For presentation items: `getDocumentFilename()` — extracts filename from URL path (e.g., `Song.pro` from full URL)
|
|
- Test with manually constructed protobuf objects (no file I/O needed)
|
|
- **GREEN**: Implement `php/src/PlaylistEntry.php`:
|
|
```php
|
|
namespace ProPresenter\Parser;
|
|
use Rv\Data\PlaylistItem;
|
|
|
|
class PlaylistEntry {
|
|
public function __construct(private readonly PlaylistItem $item) {}
|
|
public function getUuid(): string
|
|
public function getName(): string
|
|
public function getType(): string // 'header'|'presentation'|'placeholder'|'cue'|'unknown'
|
|
public function isHeader(): bool
|
|
public function isPresentation(): bool
|
|
public function isPlaceholder(): bool
|
|
public function isCue(): bool
|
|
public function getHeaderColor(): ?array // [r,g,b,a] or null
|
|
public function getDocumentPath(): ?string // absolute URL string
|
|
public function getDocumentFilename(): ?string // just the filename
|
|
public function getArrangementUuid(): ?string
|
|
public function getArrangementName(): ?string // field 5
|
|
public function hasArrangement(): bool
|
|
public function getPlaylistItem(): PlaylistItem // raw proto access
|
|
}
|
|
```
|
|
- **REFACTOR**: Ensure clean null handling, extract repetitive patterns
|
|
|
|
**Must NOT do**:
|
|
- Do NOT name this class `PlaylistItem` — collision with `Rv\Data\PlaylistItem`
|
|
- Do NOT add file I/O — this is a pure data wrapper
|
|
- Do NOT parse embedded .pro files here — that's the reader's job
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `unspecified-high`
|
|
- Reason: Medium complexity wrapper with multiple type branches and TDD workflow
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 2 (with Tasks 5, 6)
|
|
- **Blocks**: Tasks 5, 6, 7, 8, 9
|
|
- **Blocked By**: Task 1 (needs regenerated proto with field 5)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/src/Slide.php` — Follow this wrapper pattern: constructor takes proto object, provides typed getters, exposes raw proto via `getX()` method. The Slide class wraps a Cue protobuf the same way PlaylistEntry wraps PlaylistItem
|
|
- `php/src/Group.php` — Another wrapper example: simple constructor + getters for UUID, name, plus derived data
|
|
- `php/tests/SlideTest.php` — Test pattern: construct proto objects manually in test, wrap them, assert getter results
|
|
|
|
**API/Type References**:
|
|
- `php/generated/Rv/Data/PlaylistItem.php` — The proto class being wrapped. Key methods: `getItemType()` returns string from oneof ('header'|'presentation'|'cue'|'planning_center'|'placeholder'), `getHeader()`, `getPresentation()`, `getPlaceholder()`, `getCue()`
|
|
- `php/generated/Rv/Data/PlaylistItem/Presentation.php` — Presentation sub-message: `getDocumentPath()`, `getArrangement()`, `getArrangementName()`, `getUserMusicKey()`
|
|
- `php/generated/Rv/Data/PlaylistItem/Header.php` — Header sub-message: `getColor()` returns `Rv\Data\Color`
|
|
- `php/generated/Rv/Data/Color.php` — `getRed()`, `getGreen()`, `getBlue()`, `getAlpha()` all return float
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `Slide.php` + `Group.php`: These ARE the pattern. PlaylistEntry must look and feel like these classes — same constructor style, same getter conventions, same raw-proto-access method
|
|
- `PlaylistItem.php`: The protobuf API surface — you need to know the exact method names to delegate to
|
|
- `SlideTest.php`: Shows how to construct proto objects in tests without file I/O
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/tests/PlaylistEntryTest.php` exists with ≥10 test methods
|
|
- [ ] `php/src/PlaylistEntry.php` exists in namespace `ProPresenter\Parser`
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter PlaylistEntryTest` — ALL tests pass
|
|
- [ ] Tests cover all 4 item types (header, presentation, placeholder, cue)
|
|
- [ ] `getArrangementName()` returns field 5 value for presentation items
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Create a presentation entry and read arrangement name
|
|
Tool: Bash
|
|
Preconditions: PlaylistEntry + proto field 5 implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$pres = new Rv\Data\PlaylistItem\Presentation();
|
|
\$pres->setArrangementName('test-arrangement');
|
|
\$item = new Rv\Data\PlaylistItem();
|
|
\$item->setPresentation(\$pres);
|
|
\$item->setName('TestSong');
|
|
\$entry = new ProPresenter\Parser\PlaylistEntry(\$item);
|
|
echo \$entry->getArrangementName();
|
|
"
|
|
2. Assert output is 'test-arrangement'
|
|
Expected Result: 'test-arrangement' printed
|
|
Failure Indicators: Empty output, exception, or wrong value
|
|
Evidence: .sisyphus/evidence/task-4-arrangement-name.txt
|
|
|
|
Scenario: Type detection for header item
|
|
Tool: Bash
|
|
Preconditions: PlaylistEntry implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$header = new Rv\Data\PlaylistItem\Header();
|
|
\$item = new Rv\Data\PlaylistItem();
|
|
\$item->setHeader(\$header);
|
|
\$entry = new ProPresenter\Parser\PlaylistEntry(\$item);
|
|
echo \$entry->getType() . ' ' . (\$entry->isHeader() ? 'YES' : 'NO');
|
|
"
|
|
2. Assert output is 'header YES'
|
|
Expected Result: Type correctly identified as 'header'
|
|
Failure Indicators: Wrong type string or isHeader() returns false
|
|
Evidence: .sisyphus/evidence/task-4-type-detection.txt
|
|
```
|
|
|
|
**Commit**: YES (groups with T5, T6)
|
|
- Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
|
|
- Files: `php/src/PlaylistEntry.php`, `php/tests/PlaylistEntryTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistEntryTest`
|
|
|
|
- [x] 5. PlaylistNode wrapper class (TDD)
|
|
|
|
**What to do**:
|
|
- **RED**: Write `php/tests/PlaylistNodeTest.php` first:
|
|
- `getUuid`: returns playlist node UUID
|
|
- `getName`: returns playlist name (e.g., "TestPlaylist", "03-01", "2026-02-07")
|
|
- `getType`: returns type string from proto enum
|
|
- `getEntries`: returns `PlaylistEntry[]` for leaf nodes (nodes with `items`)
|
|
- `getChildNodes`: returns `PlaylistNode[]` for container nodes (nodes with `playlists`)
|
|
- `isContainer`: true if node has child playlists (PlaylistArray)
|
|
- `isLeaf`: true if node has items (PlaylistItems)
|
|
- `getEntryCount`: returns number of items
|
|
- Test with manually constructed proto Playlist objects
|
|
- **GREEN**: Implement `php/src/PlaylistNode.php`:
|
|
```php
|
|
namespace ProPresenter\Parser;
|
|
use Rv\Data\Playlist;
|
|
|
|
class PlaylistNode {
|
|
private array $entries = [];
|
|
private array $childNodes = [];
|
|
|
|
public function __construct(private readonly Playlist $playlist) {
|
|
// Parse items into PlaylistEntry objects
|
|
// Parse child playlists into PlaylistNode objects
|
|
}
|
|
public function getUuid(): string
|
|
public function getName(): string
|
|
public function getType(): int
|
|
public function getEntries(): array // PlaylistEntry[]
|
|
public function getChildNodes(): array // PlaylistNode[]
|
|
public function isContainer(): bool
|
|
public function isLeaf(): bool
|
|
public function getEntryCount(): int
|
|
public function getPlaylist(): Playlist // raw proto access
|
|
}
|
|
```
|
|
- **REFACTOR**: Clean up, ensure consistent null safety
|
|
|
|
**Must NOT do**:
|
|
- Do NOT name this class `Playlist` — collision with `Rv\Data\Playlist`
|
|
- Do NOT add any file I/O
|
|
- Do NOT try to resolve document paths to actual files
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `unspecified-high`
|
|
- Reason: Medium complexity wrapper with recursive tree structure
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 2 (with Tasks 4, 6)
|
|
- **Blocks**: Tasks 6, 7, 8, 9
|
|
- **Blocked By**: Tasks 1, 4 (needs proto + PlaylistEntry)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/src/Song.php:21-51` — Constructor pattern: takes proto object, iterates children to build typed wrapper arrays. Song iterates CueGroups to build Group[] and Cues to build Slide[] — PlaylistNode iterates PlaylistItems to build PlaylistEntry[]
|
|
- `php/src/Group.php` — Simple wrapper pattern with UUID + name getters
|
|
|
|
**API/Type References**:
|
|
- `php/generated/Rv/Data/Playlist.php` — The proto class being wrapped. Key oneof `ChildrenType`: `getPlaylists()` returns `PlaylistArray` (container), `getItems()` returns `PlaylistItems` (leaf). Use `getChildrenType()` to determine which
|
|
- `php/generated/Rv/Data/Playlist/PlaylistArray.php` — `getPlaylists()` returns repeated Playlist
|
|
- `php/generated/Rv/Data/Playlist/PlaylistItems.php` — `getItems()` returns repeated PlaylistItem
|
|
- `php/src/PlaylistEntry.php` — The wrapper created in Task 4, used to wrap each PlaylistItem
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `Song.php:21-51`: The constructor loop pattern — iterating proto repeated fields and wrapping each into domain objects. PlaylistNode does the same thing with PlaylistItems
|
|
- `Playlist.php` oneof ChildrenType: CRITICAL — must check `getChildrenType()` to know if node has child playlists or items. Wrong check = empty data
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/tests/PlaylistNodeTest.php` exists with ≥8 test methods
|
|
- [ ] `php/src/PlaylistNode.php` exists in namespace `ProPresenter\Parser`
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter PlaylistNodeTest` — ALL tests pass
|
|
- [ ] Container node returns child PlaylistNode objects
|
|
- [ ] Leaf node returns PlaylistEntry objects
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Leaf node with items returns entries
|
|
Tool: Bash
|
|
Preconditions: PlaylistNode + PlaylistEntry implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$item = new Rv\Data\PlaylistItem();
|
|
\$item->setName('TestItem');
|
|
\$items = new Rv\Data\Playlist\PlaylistItems();
|
|
\$items->setItems([\$item]);
|
|
\$playlist = new Rv\Data\Playlist();
|
|
\$playlist->setName('TestPlaylist');
|
|
\$playlist->setItems(\$items);
|
|
\$node = new ProPresenter\Parser\PlaylistNode(\$playlist);
|
|
echo \$node->getName() . ' ' . \$node->getEntryCount() . ' ' . (\$node->isLeaf() ? 'LEAF' : 'CONTAINER');
|
|
"
|
|
2. Assert output is 'TestPlaylist 1 LEAF'
|
|
Expected Result: Node correctly identified as leaf with 1 entry
|
|
Failure Indicators: Wrong count or wrong type detection
|
|
Evidence: .sisyphus/evidence/task-5-leaf-node.txt
|
|
```
|
|
|
|
**Commit**: YES (groups with T4, T6)
|
|
- Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
|
|
- Files: `php/src/PlaylistNode.php`, `php/tests/PlaylistNodeTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistNodeTest`
|
|
|
|
- [x] 6. PlaylistArchive wrapper class (TDD)
|
|
|
|
**What to do**:
|
|
- **RED**: Write `php/tests/PlaylistArchiveTest.php` first:
|
|
- `getName`: returns the child playlist name (e.g., "TestPlaylist")
|
|
- `getRootNode`: returns the root PlaylistNode (always named "PLAYLIST")
|
|
- `getPlaylistNode`: returns the first child node (the actual named playlist)
|
|
- `getEntries`: shortcut to get all entries from the playlist node
|
|
- `getEntryCount`: total items count
|
|
- `getApplicationInfo`: returns app info string or object
|
|
- `getType`: returns document type (TYPE_PRESENTATION)
|
|
- `getEmbeddedFiles`: returns array of `['filename' => string, 'data' => string]` for all ZIP entries except `data`
|
|
- `getEmbeddedProFiles`: returns only .pro file entries
|
|
- `getEmbeddedMediaFiles`: returns only non-.pro, non-data entries
|
|
- `getEmbeddedSong(string $filename)`: lazy-parses a .pro file into a Song object via ProFileReader
|
|
- Test with manually constructed proto objects (no file I/O in unit tests)
|
|
- **GREEN**: Implement `php/src/PlaylistArchive.php`:
|
|
```php
|
|
namespace ProPresenter\Parser;
|
|
use Rv\Data\PlaylistDocument;
|
|
|
|
class PlaylistArchive {
|
|
private PlaylistNode $rootNode;
|
|
private array $embeddedFiles = []; // filename => raw bytes
|
|
private array $parsedSongs = []; // filename => Song (lazy cache)
|
|
|
|
public function __construct(
|
|
private readonly PlaylistDocument $document,
|
|
array $embeddedFiles = [],
|
|
) {}
|
|
|
|
public function getName(): string // child playlist name
|
|
public function getRootNode(): PlaylistNode
|
|
public function getPlaylistNode(): ?PlaylistNode // first child
|
|
public function getEntries(): array // PlaylistEntry[]
|
|
public function getEntryCount(): int
|
|
public function getType(): int
|
|
public function getEmbeddedFiles(): array
|
|
public function getEmbeddedProFiles(): array
|
|
public function getEmbeddedMediaFiles(): array
|
|
public function getEmbeddedSong(string $filename): ?Song // lazy parse
|
|
public function getDocument(): PlaylistDocument // raw proto
|
|
}
|
|
```
|
|
- **REFACTOR**: Ensure lazy caching works correctly for Song objects
|
|
|
|
**Must NOT do**:
|
|
- Do NOT auto-parse .pro files in constructor — lazy parsing only in `getEmbeddedSong()`
|
|
- Do NOT name this class `PlaylistDocument` — collision with proto
|
|
- Do NOT store file paths — only in-memory bytes
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `unspecified-high`
|
|
- Reason: Top-level wrapper integrating nodes, entries, and embedded files
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 2 (with Tasks 4, 5)
|
|
- **Blocks**: Tasks 7, 8, 9, 10
|
|
- **Blocked By**: Tasks 1, 4, 5 (needs proto + both sub-wrappers)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/src/Song.php` — Top-level wrapper pattern: constructor takes proto object, builds internal indexes, provides convenience methods. PlaylistArchive is the Song equivalent for playlists
|
|
- `php/src/ProFileReader.php:33-36` — Shows how Song is constructed from proto. PlaylistArchive constructor follows same pattern but also receives embedded files
|
|
|
|
**API/Type References**:
|
|
- `php/generated/Rv/Data/PlaylistDocument.php` — The proto class being wrapped. `getRootNode()` returns `Playlist`, `getType()` returns int, `getApplicationInfo()` returns `ApplicationInfo`
|
|
- `php/src/PlaylistNode.php` — Wrapper from Task 5 — used for root node and child nodes
|
|
- `php/src/PlaylistEntry.php` — Wrapper from Task 4 — used via PlaylistNode
|
|
- `php/src/ProFileReader.php` — Used in `getEmbeddedSong()` to lazily parse .pro bytes. Important: ProFileReader reads from file path, so you need to use the `Presentation` proto directly: `(new Presentation())->mergeFromString($bytes)` then `new Song($presentation)`
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `Song.php`: THIS is the class being mirrored. PlaylistArchive should feel identical in API style
|
|
- `PlaylistDocument.php`: The exact proto API surface to delegate to
|
|
- `ProFileReader.php:33-36`: Shows the proto->Song construction pattern. For lazy parsing, replicate this inline rather than going through file I/O
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/tests/PlaylistArchiveTest.php` exists with ≥10 test methods
|
|
- [ ] `php/src/PlaylistArchive.php` exists in namespace `ProPresenter\Parser`
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter PlaylistArchiveTest` — ALL tests pass
|
|
- [ ] `getEmbeddedSong()` returns Song object via lazy parsing
|
|
- [ ] `getEmbeddedProFiles()` and `getEmbeddedMediaFiles()` correctly partition embedded files
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Archive with embedded files partitions correctly
|
|
Tool: Bash
|
|
Preconditions: PlaylistArchive implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$doc = new Rv\Data\PlaylistDocument();
|
|
\$root = new Rv\Data\Playlist();
|
|
\$root->setName('PLAYLIST');
|
|
\$doc->setRootNode(\$root);
|
|
\$files = ['Song.pro' => 'prodata', 'Users/path/image.jpg' => 'imgdata'];
|
|
\$archive = new ProPresenter\Parser\PlaylistArchive(\$doc, \$files);
|
|
echo count(\$archive->getEmbeddedProFiles()) . ' ' . count(\$archive->getEmbeddedMediaFiles());
|
|
"
|
|
2. Assert output is '1 1'
|
|
Expected Result: 1 .pro file and 1 media file correctly partitioned
|
|
Failure Indicators: Wrong counts
|
|
Evidence: .sisyphus/evidence/task-6-embedded-partition.txt
|
|
```
|
|
|
|
**Commit**: YES (groups with T4, T5)
|
|
- Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
|
|
- Files: `php/src/PlaylistArchive.php`, `php/tests/PlaylistArchiveTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistArchiveTest`
|
|
|
|
- [x] 7. ProPlaylistReader (TDD)
|
|
|
|
**What to do**:
|
|
- **RED**: Write `php/tests/ProPlaylistReaderTest.php` first:
|
|
- `readThrowsOnMissingFile`: nonexistent path → InvalidArgumentException
|
|
- `readThrowsOnEmptyFile`: 0-byte file → RuntimeException
|
|
- `readThrowsOnInvalidZip`: non-ZIP content → RuntimeException
|
|
- `readLoadsTestPlaylist`: reads `ref/TestPlaylist.proplaylist`, verifies name, entry count, item types
|
|
- `readLoadsGottesdienst`: reads `ref/ExamplePlaylists/Gottesdienst.proplaylist`, verifies it has >20 entries
|
|
- `readExtractsEmbeddedProFiles`: verifies `.pro` files are available via `getEmbeddedProFiles()`
|
|
- `readExtractsEmbeddedMediaFiles`: verifies media files are available
|
|
- `readParsesAllItemTypes`: verifies headers, presentations, placeholders are correctly typed
|
|
- `readPreservesArrangementName`: reads a file with arrangement, verifies field 5 value
|
|
- `readHandlesAllExamplePlaylists`: loop over all 4 test files, verify each loads without error
|
|
- **GREEN**: Implement `php/src/ProPlaylistReader.php`:
|
|
```php
|
|
namespace ProPresenter\Parser;
|
|
use Rv\Data\PlaylistDocument;
|
|
|
|
final class ProPlaylistReader {
|
|
public static function read(string $filePath): PlaylistArchive {
|
|
// 1. Validate file exists, not empty
|
|
// 2. Read raw bytes
|
|
// 3. Fix ZIP64 headers via Zip64Fixer::fix()
|
|
// 4. Write fixed bytes to temp file
|
|
// 5. Open with ZipArchive
|
|
// 6. Extract 'data' entry -> deserialize as PlaylistDocument
|
|
// 7. Extract all other entries as embedded files
|
|
// 8. Close ZipArchive, delete temp file
|
|
// 9. Return new PlaylistArchive($document, $embeddedFiles)
|
|
}
|
|
}
|
|
```
|
|
- Handle the edge case where a .proplaylist might already have valid ZIP headers (no fix needed)
|
|
- **REFACTOR**: Clean error messages, ensure temp file cleanup in all code paths (try/finally)
|
|
|
|
**Must NOT do**:
|
|
- Do NOT leave temp files behind on error — use try/finally
|
|
- Do NOT decompress/recompress content — only read entries
|
|
- Do NOT auto-parse .pro files — store as raw bytes (lazy parsing is in PlaylistArchive)
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `deep`
|
|
- Reason: Complex I/O with ZIP handling, temp files, error paths, and integration of Zip64Fixer + proto deserialization
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 3 (with Tasks 8, 9, 10)
|
|
- **Blocks**: Tasks 8, 10, 11, 12
|
|
- **Blocked By**: Tasks 2, 6 (needs Zip64Fixer + PlaylistArchive)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/src/ProFileReader.php` — EXACT pattern to follow: static `read(string $filePath)` method, same validation (file exists, not empty), same exception types. The difference is ProPlaylistReader reads a ZIP instead of raw protobuf
|
|
- `php/tests/ProFileReaderTest.php` — Test structure: error cases first (missing file, empty file), then happy paths with real files, then diverse file loading
|
|
|
|
**API/Type References**:
|
|
- `php/src/Zip64Fixer.php` — From Task 2: `Zip64Fixer::fix(string $data): string` — call before opening ZIP
|
|
- `php/src/PlaylistArchive.php` — From Task 6: constructor takes `(PlaylistDocument, array $embeddedFiles)` — this is what we return
|
|
- `php/generated/Rv/Data/PlaylistDocument.php` — `mergeFromString()` to deserialize the `data` entry
|
|
- PHP `ZipArchive` — `open()`, `getFromName()`, `numFiles`, `getNameIndex()`, `close()`
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `ProFileReader.php`: The TEMPLATE. ProPlaylistReader should be structurally identical, just with ZIP extraction added
|
|
- `Zip64Fixer.php`: MUST be called before ZipArchive::open() or it will fail with error 21
|
|
- `PlaylistDocument::mergeFromString()`: The `data` entry is raw protobuf bytes — deserialize with this method
|
|
|
|
**Test Data**:
|
|
- `ref/TestPlaylist.proplaylist` — Small: 2 .pro files, 1 image, ~4 items
|
|
- `ref/ExamplePlaylists/Gottesdienst.proplaylist` — Large: 14MB, 25+ items
|
|
- `ref/ExamplePlaylists/Gottesdienst 2.proplaylist` — 10MB
|
|
- `ref/ExamplePlaylists/Gottesdienst 3.proplaylist` — 16MB
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/tests/ProPlaylistReaderTest.php` exists with ≥10 test methods
|
|
- [ ] `php/src/ProPlaylistReader.php` exists with static `read()` method
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter ProPlaylistReaderTest` — ALL tests pass
|
|
- [ ] All 4 .proplaylist test files load successfully
|
|
- [ ] Embedded .pro files and media files are accessible from returned PlaylistArchive
|
|
- [ ] No temp files left behind after read (success or failure)
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Read TestPlaylist and inspect entries
|
|
Tool: Bash
|
|
Preconditions: ProPlaylistReader + all dependencies implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$archive = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
|
|
echo 'Name: ' . \$archive->getName() . PHP_EOL;
|
|
echo 'Entries: ' . \$archive->getEntryCount() . PHP_EOL;
|
|
echo 'ProFiles: ' . count(\$archive->getEmbeddedProFiles()) . PHP_EOL;
|
|
foreach (\$archive->getEntries() as \$e) { echo \$e->getType() . ': ' . \$e->getName() . PHP_EOL; }"
|
|
2. Assert output contains 'Name: TestPlaylist'
|
|
3. Assert output contains at least 2 entries
|
|
Expected Result: Playlist loaded with correct name and typed entries
|
|
Failure Indicators: Exception, empty entries, wrong name
|
|
Evidence: .sisyphus/evidence/task-7-read-test-playlist.txt
|
|
|
|
Scenario: Error on nonexistent file
|
|
Tool: Bash
|
|
Preconditions: ProPlaylistReader implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
try { ProPresenter\Parser\ProPlaylistReader::read('/nonexistent.proplaylist'); echo 'NO_EXCEPTION'; }
|
|
catch (InvalidArgumentException \$e) { echo 'OK'; }"
|
|
2. Assert output is 'OK'
|
|
Expected Result: InvalidArgumentException thrown
|
|
Failure Indicators: 'NO_EXCEPTION' or different exception type
|
|
Evidence: .sisyphus/evidence/task-7-error-nonexistent.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `feat(playlist): add ProPlaylistReader`
|
|
- Files: `php/src/ProPlaylistReader.php`, `php/tests/ProPlaylistReaderTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistReaderTest`
|
|
|
|
- [x] 8. ProPlaylistWriter (TDD)
|
|
|
|
**What to do**:
|
|
- **RED**: Write `php/tests/ProPlaylistWriterTest.php` first:
|
|
- `writeThrowsOnMissingDirectory`: target dir doesn't exist → InvalidArgumentException
|
|
- `writeCreatesValidZipFile`: write a PlaylistArchive, verify output is valid ZIP
|
|
- `writeContainsDataEntry`: output ZIP has `data` entry with valid protobuf
|
|
- `writeContainsEmbeddedProFiles`: .pro files from archive are in ZIP at root level
|
|
- `writeContainsEmbeddedMediaFiles`: media files are in ZIP at their original paths
|
|
- `writeDeduplicatesFiles`: same filename referenced twice → stored once
|
|
- `writeDataEntryDeserializesToSameDocument`: read back `data` entry, compare with original
|
|
- **GREEN**: Implement `php/src/ProPlaylistWriter.php`:
|
|
```php
|
|
namespace ProPresenter\Parser;
|
|
|
|
final class ProPlaylistWriter {
|
|
public static function write(PlaylistArchive $archive, string $filePath): void {
|
|
// 1. Validate target directory exists
|
|
// 2. Create temp ZipArchive
|
|
// 3. Serialize PlaylistDocument to protobuf -> add as 'data' entry
|
|
// 4. Add each embedded file (pro files at root, media at their paths)
|
|
// 5. Close ZipArchive
|
|
// 6. Move/copy temp file to target path
|
|
}
|
|
}
|
|
```
|
|
- Use `ZipArchive::CM_STORE` for no compression (matching ProPresenter behavior)
|
|
- Write clean, standard-compliant ZIP — do NOT reproduce ProPresenter's 98-byte quirk
|
|
- **REFACTOR**: Ensure temp file cleanup, consistent error messages
|
|
|
|
**Must NOT do**:
|
|
- Do NOT reproduce ProPresenter's broken ZIP64 headers
|
|
- Do NOT compress entries — use store method
|
|
- Do NOT modify the PlaylistArchive or its Document during write
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `deep`
|
|
- Reason: ZIP creation with specific constraints, temp file management, and careful protobuf serialization
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 3 (with Tasks 7, 9, 10)
|
|
- **Blocks**: Tasks 11, 12
|
|
- **Blocked By**: Tasks 6, 7 (needs PlaylistArchive + Reader for verification)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/src/ProFileWriter.php` — EXACT pattern to follow: static `write(Song, string)` method, same validation, same exception types. The difference is writing ZIP instead of raw protobuf
|
|
- `php/tests/ProFileWriterTest.php` — Test pattern for writer tests
|
|
|
|
**API/Type References**:
|
|
- `php/src/PlaylistArchive.php` — `getDocument()` returns `PlaylistDocument` for serialization, `getEmbeddedFiles()` returns `['filename' => bytes]` map
|
|
- PHP `ZipArchive` — `open()` with `ZipArchive::CREATE`, `addFromString($name, $data)`, `setCompressionName($name, ZipArchive::CM_STORE)`, `close()`
|
|
- `PlaylistDocument::serializeToString()` — Serializes proto to bytes for the `data` entry
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `ProFileWriter.php`: Structural template — same validation pattern, same method signature style
|
|
- `PlaylistArchive`: Source of ALL data to write — document for `data` entry, embedded files for other entries
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/tests/ProPlaylistWriterTest.php` exists with ≥7 test methods
|
|
- [ ] `php/src/ProPlaylistWriter.php` exists with static `write()` method
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest` — ALL tests pass
|
|
- [ ] Written ZIP opens with standard `unzip -l` without errors
|
|
- [ ] Written ZIP uses store compression (no deflate)
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Write and verify ZIP structure
|
|
Tool: Bash
|
|
Preconditions: Reader + Writer both implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$archive = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
|
|
ProPresenter\Parser\ProPlaylistWriter::write(\$archive, '/tmp/test-write.proplaylist');
|
|
echo file_exists('/tmp/test-write.proplaylist') ? 'EXISTS' : 'MISSING';"
|
|
2. unzip -l /tmp/test-write.proplaylist 2>&1
|
|
3. Assert ZIP listing shows 'data' entry and .pro files
|
|
4. rm /tmp/test-write.proplaylist
|
|
Expected Result: Valid ZIP with data + embedded files
|
|
Failure Indicators: unzip errors or missing entries
|
|
Evidence: .sisyphus/evidence/task-8-write-verify.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `feat(playlist): add ProPlaylistWriter`
|
|
- Files: `php/src/ProPlaylistWriter.php`, `php/tests/ProPlaylistWriterTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest`
|
|
|
|
- [x] 9. ProPlaylistGenerator (TDD)
|
|
|
|
**What to do**:
|
|
- **RED**: Write `php/tests/ProPlaylistGeneratorTest.php` first:
|
|
- `generateCreatesPlaylistArchive`: returns a PlaylistArchive object
|
|
- `generateSetsPlaylistName`: verify archive name matches input
|
|
- `generateCreatesHeaders`: header items with name and color
|
|
- `generateCreatesPresentationItems`: presentation items with document path, arrangement UUID, arrangement name
|
|
- `generateCreatesPlaceholders`: placeholder items
|
|
- `generateSetsApplicationInfo`: application_info with platform and version
|
|
- `generateSetsTypePresentation`: document type = TYPE_PRESENTATION
|
|
- `generateAndWriteCreatesFile`: writes to disk and verifies readable
|
|
- **GREEN**: Implement `php/src/ProPlaylistGenerator.php`:
|
|
```php
|
|
namespace ProPresenter\Parser;
|
|
|
|
final class ProPlaylistGenerator {
|
|
public static function generate(
|
|
string $name,
|
|
array $items, // [{type: 'header'|'presentation'|'placeholder', ...}]
|
|
array $embeddedFiles = [], // [filename => bytes]
|
|
): PlaylistArchive
|
|
|
|
public static function generateAndWrite(
|
|
string $filePath,
|
|
string $name,
|
|
array $items,
|
|
array $embeddedFiles = [],
|
|
): PlaylistArchive
|
|
}
|
|
```
|
|
- Item array format:
|
|
- Header: `['type' => 'header', 'name' => 'Section Name', 'color' => [r, g, b, a]]`
|
|
- Presentation: `['type' => 'presentation', 'name' => 'Song Title', 'path' => 'file:///...', 'arrangement_uuid' => '...', 'arrangement_name' => 'normal']`
|
|
- Placeholder: `['type' => 'placeholder', 'name' => 'Placeholder1']`
|
|
- Build proper protobuf structure: PlaylistDocument → root Playlist ("PLAYLIST") → child Playlist (name) → PlaylistItems → PlaylistItem[]
|
|
- Set application_info matching ProPresenter defaults (reuse `buildApplicationInfo()` pattern from `ProFileGenerator`)
|
|
- Generate UUIDs for all objects
|
|
- **REFACTOR**: Extract shared UUID/color/appinfo builders or reuse from ProFileGenerator
|
|
|
|
**Must NOT do**:
|
|
- Do NOT auto-embed .pro file content — user provides embedded files explicitly
|
|
- Do NOT validate document_path URLs — accept whatever is provided
|
|
- Do NOT add PlanningCenter or audio playlist support
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `deep`
|
|
- Reason: Complex protobuf construction with nested structure matching exact ProPresenter format
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 3 (with Tasks 7, 8, 10)
|
|
- **Blocks**: Task 11
|
|
- **Blocked By**: Task 6 (needs PlaylistArchive)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/src/ProFileGenerator.php` — THE template. Follow the exact same patterns: static `generate()` + `generateAndWrite()`, private `buildApplicationInfo()`, `newUuid()`, `uuidFromString()`, `colorFromArray()`. Consider reusing these methods or extracting to a trait/shared utility
|
|
- `php/src/ProFileGenerator.php:55-60` — Method signature pattern: `generate(string $name, array $items, ...): Song`. PlaylistGenerator follows this but returns PlaylistArchive
|
|
- `php/src/ProFileGenerator.php:137-149` — `buildApplicationInfo()` method — reuse or duplicate for playlist
|
|
|
|
**API/Type References**:
|
|
- `php/generated/Rv/Data/PlaylistDocument.php` — Top-level container: `setApplicationInfo()`, `setType()`, `setRootNode()`
|
|
- `php/generated/Rv/Data/PlaylistDocument/Type.php` — `TYPE_PRESENTATION = 1`
|
|
- `php/generated/Rv/Data/Playlist.php` — Node: `setUuid()`, `setName()`, `setPlaylists()`, `setItems()`
|
|
- `php/generated/Rv/Data/Playlist/PlaylistArray.php` — `setPlaylists()` takes array of Playlist
|
|
- `php/generated/Rv/Data/Playlist/PlaylistItems.php` — `setItems()` takes array of PlaylistItem
|
|
- `php/generated/Rv/Data/PlaylistItem.php` — `setUuid()`, `setName()`, `setHeader()` / `setPresentation()` / `setPlaceholder()`
|
|
- `php/generated/Rv/Data/PlaylistItem/Header.php` — `setColor()`
|
|
- `php/generated/Rv/Data/PlaylistItem/Presentation.php` — `setDocumentPath()`, `setArrangement()`, `setArrangementName()`, `setUserMusicKey()`
|
|
- `php/generated/Rv/Data/MusicKeyScale.php` — Default music key: `setMusicKey(MusicKeyScale\MusicKey::MUSIC_KEY_C)`
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `ProFileGenerator.php`: The EXACT pattern to replicate. Same static factory, same UUID generation, same color building, same application info
|
|
- All the proto class references: These are the API surface for constructing the nested protobuf tree. Getting any setter name wrong breaks generation
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/tests/ProPlaylistGeneratorTest.php` exists with ≥8 test methods
|
|
- [ ] `php/src/ProPlaylistGenerator.php` exists with `generate()` and `generateAndWrite()` methods
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter ProPlaylistGeneratorTest` — ALL tests pass
|
|
- [ ] Generated playlist has correct structure: PlaylistDocument → root → child → items
|
|
- [ ] Header, presentation, and placeholder items all generate correctly
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Generate a playlist with mixed items
|
|
Tool: Bash
|
|
Preconditions: ProPlaylistGenerator implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$archive = ProPresenter\Parser\ProPlaylistGenerator::generate('TestService', [
|
|
['type' => 'header', 'name' => 'Welcome', 'color' => [0.0, 0.5, 0.8, 1.0]],
|
|
['type' => 'presentation', 'name' => 'Amazing Grace', 'path' => 'file:///song.pro'],
|
|
['type' => 'placeholder', 'name' => 'Slot1'],
|
|
]);
|
|
echo 'Name: ' . \$archive->getName() . PHP_EOL;
|
|
echo 'Entries: ' . \$archive->getEntryCount() . PHP_EOL;
|
|
foreach (\$archive->getEntries() as \$e) { echo \$e->getType() . ': ' . \$e->getName() . PHP_EOL; }"
|
|
2. Assert output contains 'Name: TestService'
|
|
3. Assert 3 entries with types header, presentation, placeholder
|
|
Expected Result: All 3 item types created correctly
|
|
Failure Indicators: Wrong count, wrong types, or exception
|
|
Evidence: .sisyphus/evidence/task-9-generate-mixed.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `feat(playlist): add ProPlaylistGenerator`
|
|
- Files: `php/src/ProPlaylistGenerator.php`, `php/tests/ProPlaylistGeneratorTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistGeneratorTest`
|
|
|
|
- [x] 10. CLI tool: parse-playlist.php
|
|
|
|
**What to do**:
|
|
- Create `php/bin/parse-playlist.php` following the EXACT structure of `php/bin/parse-song.php`:
|
|
- Shebang line: `#!/usr/bin/env php`
|
|
- Require autoloader
|
|
- Check argc, print usage
|
|
- Read file with ProPlaylistReader::read()
|
|
- Display:
|
|
- Playlist name and UUID
|
|
- Application info (platform, version)
|
|
- Document type
|
|
- Embedded file summary: N .pro files, N media files
|
|
- All entries in order, with type-specific details:
|
|
- Header: `[H] Section Name` with color
|
|
- Presentation: `[P] Song Title (arrangement: normal)` with document path
|
|
- Placeholder: `[-] Placeholder Name`
|
|
- Cue: `[C] Cue Name`
|
|
- Embedded .pro file list
|
|
- Embedded media file list
|
|
- Error handling: try/catch with user-friendly error messages
|
|
|
|
**Must NOT do**:
|
|
- Do NOT use colors/ANSI codes — keep plain text like parse-song.php
|
|
- Do NOT auto-parse embedded .pro files — just list filenames
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `quick`
|
|
- Reason: Straightforward CLI script following an exact template
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 3 (with Tasks 7, 8, 9)
|
|
- **Blocks**: Tasks 12, 13
|
|
- **Blocked By**: Task 7 (needs ProPlaylistReader)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/bin/parse-song.php` — EXACT template. Copy structure line by line: shebang, autoload, argc check, try/catch, output formatting. Replace Song-specific output with Playlist-specific output
|
|
|
|
**API/Type References**:
|
|
- `php/src/ProPlaylistReader.php` — `ProPlaylistReader::read($filePath)` returns `PlaylistArchive`
|
|
- `php/src/PlaylistArchive.php` — `getName()`, `getEntries()`, `getEmbeddedProFiles()`, `getEmbeddedMediaFiles()`
|
|
- `php/src/PlaylistEntry.php` — `getType()`, `getName()`, `getArrangementName()`, `getDocumentPath()`, `getHeaderColor()`
|
|
|
|
**WHY Each Reference Matters**:
|
|
- `parse-song.php`: Byte-for-byte template. The playlist CLI should be stylistically identical
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `php/bin/parse-playlist.php` exists with shebang line
|
|
- [ ] `php php/bin/parse-playlist.php` (no args) → usage message + exit code 1
|
|
- [ ] `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` → structured output
|
|
- [ ] `php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist` → output without errors
|
|
- [ ] Output shows entries with type indicators ([H], [P], [-], [C])
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: CLI with TestPlaylist
|
|
Tool: Bash
|
|
Preconditions: parse-playlist.php and all dependencies implemented
|
|
Steps:
|
|
1. php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist
|
|
2. Assert exit code 0
|
|
3. Assert output contains 'Playlist:' or playlist name
|
|
4. Assert output contains at least one '[P]' or '[H]' entry
|
|
Expected Result: Clean structured output with entry type indicators
|
|
Failure Indicators: Non-zero exit, exception trace in output
|
|
Evidence: .sisyphus/evidence/task-10-cli-test-playlist.txt
|
|
|
|
Scenario: CLI with no arguments shows usage
|
|
Tool: Bash
|
|
Preconditions: parse-playlist.php exists
|
|
Steps:
|
|
1. php php/bin/parse-playlist.php 2>&1; echo "EXIT:$?"
|
|
2. Assert output contains 'Usage:'
|
|
3. Assert exit code is 1
|
|
Expected Result: Usage message and exit code 1
|
|
Failure Indicators: No usage message or exit code 0
|
|
Evidence: .sisyphus/evidence/task-10-cli-usage.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `feat(cli): add parse-playlist.php CLI tool`
|
|
- Files: `php/bin/parse-playlist.php`
|
|
- Pre-commit: `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist`
|
|
|
|
- [x] 11. Round-trip integration tests
|
|
|
|
**What to do**:
|
|
- Add integration tests to `php/tests/ProPlaylistWriterTest.php` (or a new `php/tests/ProPlaylistIntegrationTest.php`):
|
|
- `roundTripPreservesPlaylistName`: read → write → read, compare names
|
|
- `roundTripPreservesEntryCount`: same number of entries after round-trip
|
|
- `roundTripPreservesEntryTypes`: all item types preserved (header/presentation/placeholder)
|
|
- `roundTripPreservesArrangementNames`: field 5 values survive round-trip
|
|
- `roundTripPreservesEmbeddedFileCount`: same number of embedded files
|
|
- `roundTripPreservesDocumentPaths`: presentation document_path URLs unchanged
|
|
- `roundTripPreservesHeaderColors`: header color RGBA values unchanged
|
|
- `generatedPlaylistReadableByReader`: generate → write → read back
|
|
- Use `ref/TestPlaylist.proplaylist` as primary test file (small, fast)
|
|
- Write to temp file, read back, compare all fields, clean up temp file
|
|
|
|
**Must NOT do**:
|
|
- Do NOT compare raw bytes (proto serialization may reorder fields) — compare logical values
|
|
- Do NOT modify the original test files
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `deep`
|
|
- Reason: Integration testing requiring coordination of reader + writer + generator with thorough field-level comparison
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 4 (with Tasks 12, 13)
|
|
- **Blocks**: Nothing
|
|
- **Blocked By**: Tasks 7, 8, 9 (needs Reader + Writer + Generator)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/tests/BinaryFidelityTest.php` — If exists, shows existing round-trip or fidelity test patterns
|
|
- `php/tests/ProFileWriterTest.php` — Shows how writer tests are structured
|
|
|
|
**API/Type References**:
|
|
- `php/src/ProPlaylistReader.php` — `read(string): PlaylistArchive`
|
|
- `php/src/ProPlaylistWriter.php` — `write(PlaylistArchive, string): void`
|
|
- `php/src/ProPlaylistGenerator.php` — `generate(string, array): PlaylistArchive`
|
|
- `php/src/PlaylistArchive.php` — All getter methods for comparison
|
|
|
|
**WHY Each Reference Matters**:
|
|
- Reader + Writer + Generator: All three are exercised together. The integration tests prove they interoperate correctly
|
|
- BinaryFidelityTest: If it exists, it shows the project's existing approach to verifying round-trip integrity
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] Integration test file exists with ≥8 test methods
|
|
- [ ] `cd php && php vendor/bin/phpunit --filter Integration` (or --filter ProPlaylistWriter) — ALL pass
|
|
- [ ] Round-trip of TestPlaylist.proplaylist preserves all fields
|
|
- [ ] Generated → written → read back works correctly
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: Full round-trip of TestPlaylist
|
|
Tool: Bash
|
|
Preconditions: Reader + Writer implemented
|
|
Steps:
|
|
1. php -r "
|
|
require 'php/vendor/autoload.php';
|
|
\$a = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
|
|
ProPresenter\Parser\ProPlaylistWriter::write(\$a, '/tmp/rt-test.proplaylist');
|
|
\$b = ProPresenter\Parser\ProPlaylistReader::read('/tmp/rt-test.proplaylist');
|
|
echo (\$a->getName() === \$b->getName() ? 'NAME_OK' : 'NAME_FAIL') . ' ';
|
|
echo (\$a->getEntryCount() === \$b->getEntryCount() ? 'COUNT_OK' : 'COUNT_FAIL');
|
|
unlink('/tmp/rt-test.proplaylist');"
|
|
2. Assert output is 'NAME_OK COUNT_OK'
|
|
Expected Result: Name and entry count preserved through round-trip
|
|
Failure Indicators: Any _FAIL in output
|
|
Evidence: .sisyphus/evidence/task-11-roundtrip.txt
|
|
```
|
|
|
|
**Commit**: YES (groups with T12)
|
|
- Message: `test(playlist): add integration and real-world validation tests`
|
|
- Files: `php/tests/ProPlaylistIntegrationTest.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit`
|
|
|
|
- [x] 12. Large-file / real-world validation tests
|
|
|
|
**What to do**:
|
|
- Add validation tests (in `php/tests/ProPlaylistIntegrationTest.php` or separate file):
|
|
- `readsAllExamplePlaylistsWithoutError`: loop over all 4 .proplaylist files, read each, assert no exceptions
|
|
- `gottesdienstHasExpectedStructure`: the Gottesdienst playlists should have >20 entries, mix of headers and presentations
|
|
- `allPresentationItemsHaveDocumentPath`: every presentation entry has a non-empty document path
|
|
- `embeddedProFilesExistForPresentations`: for each presentation entry, verify corresponding .pro is in embedded files
|
|
- `cliOutputMatchesReaderData`: run parse-playlist.php, verify output entry count matches reader entry count
|
|
- Test all files in `ref/ExamplePlaylists/` plus `ref/TestPlaylist.proplaylist`
|
|
|
|
**Must NOT do**:
|
|
- Do NOT modify the test data files
|
|
- Do NOT hardcode exact entry counts (might vary) — use minimum thresholds
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `unspecified-high`
|
|
- Reason: Validation tests against real-world data files
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 4 (with Tasks 11, 13)
|
|
- **Blocks**: Nothing
|
|
- **Blocked By**: Tasks 7, 10 (needs Reader + CLI)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `php/tests/MassValidationTest.php` — If exists, shows pattern for testing against many files
|
|
- `php/tests/ProFileReaderTest.php:52-78` — `readLoadsDiverseReferenceFilesSuccessfully` — this exact test pattern: loop over real files, verify each loads
|
|
|
|
**API/Type References**:
|
|
- `php/src/ProPlaylistReader.php` — `read()` to load each test file
|
|
- `php/src/PlaylistArchive.php` — Getters for validation assertions
|
|
|
|
**Test Data**:
|
|
- `ref/TestPlaylist.proplaylist`
|
|
- `ref/ExamplePlaylists/Gottesdienst.proplaylist`
|
|
- `ref/ExamplePlaylists/Gottesdienst 2.proplaylist`
|
|
- `ref/ExamplePlaylists/Gottesdienst 3.proplaylist`
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] Validation tests exist with ≥5 test methods
|
|
- [ ] `cd php && php vendor/bin/phpunit` — ALL tests pass including validation
|
|
- [ ] All 4 test files load and validate without errors
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: All example playlists load
|
|
Tool: Bash
|
|
Preconditions: Reader implemented
|
|
Steps:
|
|
1. for f in ref/TestPlaylist.proplaylist ref/ExamplePlaylists/*.proplaylist; do
|
|
php -r "require 'php/vendor/autoload.php'; \$a = ProPresenter\Parser\ProPlaylistReader::read('$f'); echo basename('$f') . ': ' . \$a->getEntryCount() . ' entries' . PHP_EOL;"
|
|
done
|
|
2. Assert all 4 files produce output with entry counts > 0
|
|
Expected Result: All files load with non-zero entry counts
|
|
Failure Indicators: Any exception or 0 entries
|
|
Evidence: .sisyphus/evidence/task-12-all-files.txt
|
|
```
|
|
|
|
**Commit**: YES (groups with T11)
|
|
- Message: `test(playlist): add integration and real-world validation tests`
|
|
- Files: `php/tests/ProPlaylist*Test.php`
|
|
- Pre-commit: `cd php && php vendor/bin/phpunit`
|
|
|
|
- [x] 13. AGENTS.md documentation update
|
|
|
|
**What to do**:
|
|
- Update `AGENTS.md` to document the new .proplaylist module, following the exact style of the existing `.pro` module documentation:
|
|
- Add a new section for `.proplaylist` files
|
|
- Document the PHP module usage:
|
|
- Reading: `ProPlaylistReader::read('path/to/file.proplaylist')` → `PlaylistArchive`
|
|
- Accessing structure: archive → entries → type-specific data
|
|
- Writing: `ProPlaylistWriter::write($archive, 'output.proplaylist')`
|
|
- Generating: `ProPlaylistGenerator::generate(name, items, embeddedFiles)`
|
|
- Lazy Song parsing: `$archive->getEmbeddedSong('filename.pro')`
|
|
- Document CLI tool: `php php/bin/parse-playlist.php path/to/file.proplaylist`
|
|
- Link to format specification: `spec/pp_playlist_spec.md`
|
|
- List key files (all new PHP source files)
|
|
|
|
**Must NOT do**:
|
|
- Do NOT modify existing .pro module documentation
|
|
- Do NOT remove or change any existing content in AGENTS.md
|
|
- Do NOT add excessive detail — match the conciseness of existing sections
|
|
|
|
**Recommended Agent Profile**:
|
|
- **Category**: `quick`
|
|
- Reason: Documentation addition following an exact template
|
|
- **Skills**: []
|
|
|
|
**Parallelization**:
|
|
- **Can Run In Parallel**: YES
|
|
- **Parallel Group**: Wave 4 (with Tasks 11, 12)
|
|
- **Blocks**: Nothing
|
|
- **Blocked By**: Tasks 7, 10 (needs Reader + CLI to be final)
|
|
|
|
**References**:
|
|
|
|
**Pattern References**:
|
|
- `AGENTS.md` — The EXISTING documentation structure. The playlist section must be added in the same style, same heading levels, same code block formatting as the existing "PHP Module Usage" section
|
|
|
|
**Acceptance Criteria**:
|
|
- [ ] `AGENTS.md` contains a new section for `.proplaylist` files
|
|
- [ ] Section includes reading, writing, generating, CLI usage
|
|
- [ ] Section lists all new key files
|
|
- [ ] Existing content unchanged
|
|
|
|
**QA Scenarios:**
|
|
|
|
```
|
|
Scenario: AGENTS.md has playlist documentation
|
|
Tool: Bash (grep)
|
|
Preconditions: Task 13 complete
|
|
Steps:
|
|
1. grep -c 'proplaylist\|ProPlaylistReader\|PlaylistArchive\|parse-playlist' AGENTS.md
|
|
2. Assert count >= 4
|
|
Expected Result: All key terms present in documentation
|
|
Failure Indicators: Key terms missing
|
|
Evidence: .sisyphus/evidence/task-13-agents-md.txt
|
|
```
|
|
|
|
**Commit**: YES
|
|
- Message: `docs(agents): update AGENTS.md with playlist module documentation`
|
|
- Files: `AGENTS.md`
|
|
- Pre-commit: —
|
|
|
|
## Final Verification Wave
|
|
|
|
> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
|
|
|
|
- [x] F1. **Plan Compliance Audit** — `oracle`
|
|
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
|
|
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
|
|
|
|
- [x] F2. **Code Quality Review** — `unspecified-high`
|
|
Run `php vendor/bin/phpunit` from `php/` directory. Review all new files for: hardcoded paths, empty catches, `@` error suppression, unused imports, inconsistent naming. Check AI slop: excessive comments, over-abstraction, generic variable names. Verify PSR-4 autoloading works for all new classes.
|
|
Output: `Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT`
|
|
|
|
- [x] F3. **Real Manual QA** — `unspecified-high`
|
|
Start from clean state. Run `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist`. Run with all 3 Gottesdienst playlists. Verify output is human-readable and complete. Test error cases: nonexistent file, non-ZIP file, empty file. Run round-trip test: read → write → read and compare.
|
|
Output: `CLI [N/N pass] | Error handling [N/N] | Round-trip [PASS/FAIL] | VERDICT`
|
|
|
|
- [x] F4. **Scope Fidelity Check** — `deep`
|
|
For each task: read "What to do", read actual diff. Verify 1:1 — everything in spec was built, nothing beyond spec was built. Check "Must NOT do" compliance. Detect cross-task contamination. Flag unaccounted changes.
|
|
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
|
|
|
|
---
|
|
|
|
## Commit Strategy
|
|
|
|
| Group | Message | Files | Pre-commit |
|
|
|-------|---------|-------|------------|
|
|
| T1 | `feat(proto): add arrangement_name field 5 to PlaylistItem.Presentation` | `php/proto/playlist.proto`, `php/generated/**` | — |
|
|
| T2 | `feat(playlist): add Zip64Fixer for ProPresenter ZIP archives` | `php/src/Zip64Fixer.php`, `php/tests/Zip64FixerTest.php` | `php vendor/bin/phpunit --filter Zip64FixerTest` |
|
|
| T3 | `docs(spec): add .proplaylist format specification` | `spec/pp_playlist_spec.md` | — |
|
|
| T4+T5+T6 | `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers` | `php/src/Playlist*.php`, `php/tests/Playlist*Test.php` | `php vendor/bin/phpunit --filter 'PlaylistEntry\|PlaylistNode\|PlaylistArchive'` |
|
|
| T7 | `feat(playlist): add ProPlaylistReader` | `php/src/ProPlaylistReader.php`, `php/tests/ProPlaylistReaderTest.php` | `php vendor/bin/phpunit --filter ProPlaylistReaderTest` |
|
|
| T8 | `feat(playlist): add ProPlaylistWriter` | `php/src/ProPlaylistWriter.php`, `php/tests/ProPlaylistWriterTest.php` | `php vendor/bin/phpunit --filter ProPlaylistWriterTest` |
|
|
| T9 | `feat(playlist): add ProPlaylistGenerator` | `php/src/ProPlaylistGenerator.php`, `php/tests/ProPlaylistGeneratorTest.php` | `php vendor/bin/phpunit --filter ProPlaylistGeneratorTest` |
|
|
| T10 | `feat(cli): add parse-playlist.php CLI tool` | `php/bin/parse-playlist.php` | `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` |
|
|
| T11+T12 | `test(playlist): add integration and real-world validation tests` | `php/tests/ProPlaylist*Test.php` | `php vendor/bin/phpunit` |
|
|
| T13 | `docs(agents): update AGENTS.md with playlist module documentation` | `AGENTS.md` | — |
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
### Verification Commands
|
|
```bash
|
|
# All tests pass
|
|
cd php && php vendor/bin/phpunit # Expected: OK (N tests, N assertions)
|
|
|
|
# CLI tool works with test file
|
|
php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist # Expected: structured output showing items
|
|
|
|
# CLI tool works with large real-world file
|
|
php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist # Expected: structured output, no errors
|
|
|
|
# Reader handles all example files
|
|
for f in ref/ExamplePlaylists/*.proplaylist; do php php/bin/parse-playlist.php "$f"; done # Expected: all succeed
|
|
```
|
|
|
|
### Final Checklist
|
|
- [x] All "Must Have" present
|
|
- [x] All "Must NOT Have" absent
|
|
- [x] All PHPUnit tests pass
|
|
- [x] CLI tool handles all 4 test files
|
|
- [x] Round-trip fidelity verified
|
|
- [x] Format spec document complete
|
|
- [x] AGENTS.md updated
|