pp-planer/tests/Feature/ArrangementSelectionExportTest.php
Thorsten Bus 4e548bbeed feat(export): honor per-service arrangement
ProPresenter export ignored service_songs.song_arrangement_id and
always used the default/is_default arrangement. Now the chosen
arrangement is pre-selected while ALL arrangements stay in the file,
so users can still switch in ProPresenter.

Import-stable, by NAME (avoids the fragile playlist arrangement UUID
that breaks on import):
- .pro: set selected_arrangement (protobuf field 10) to the chosen
  arrangement via Song::setSelectedArrangementUuid(getArrangementByName).
- .proplaylist: set the presentation item arrangement_name (field 5).

buildGroups now emits the UNION of sections across all arrangements
(selected first, in play order, with first/last-slide macro anchoring;
other arrangements' sections appended without position macros) so every
arrangement's group-name refs resolve instead of being silently dropped.

No parser package changes; installed public API only. Library/no-service
context keeps default-arrangement behaviour unchanged.
2026-07-05 23:16:59 +02:00

232 lines
9.2 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Services\PlaylistExportService;
use App\Services\ProBundleExportService;
use App\Services\ProExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProBundleReader;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
/**
* Export must honour the per-service selected arrangement:
* - .pro file: selected_arrangement (protobuf field 10) set to the chosen arrangement by name.
* - .proplaylist: presentation item arrangement_name (field 5) set to the chosen arrangement name.
* ALL arrangements stay in the .pro; only the pre-selection changes.
*/
final class ArrangementSelectionExportTest extends TestCase
{
use RefreshDatabase;
/**
* Song with three sections and two arrangements whose section sets DIFFER:
* - "Normal" (default): Strophe → Refrain
* - "Bene" (chosen): Refrain → Bridge
* Union of both arrangements = {Strophe, Refrain, Bridge}.
*
* @return array{0: Song, 1: SongArrangement, 2: SongArrangement}
*/
private function createSongWithTwoArrangements(string $title = 'Union Test'): array
{
$song = Song::create([
'title' => $title,
'ccli_id' => (string) fake()->unique()->numerify('#####'),
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$verseLabel = Label::firstOrCreate(['name' => 'Strophe 1 - '.$title], ['color' => '#2196F3']);
$chorusLabel = Label::firstOrCreate(['name' => 'Refrain - '.$title], ['color' => '#F44336']);
$bridgeLabel = Label::firstOrCreate(['name' => 'Bridge - '.$title], ['color' => '#4CAF50']);
$verse = $song->sections()->create(['label_id' => $verseLabel->id, 'order' => 0]);
$verse->slides()->create(['order' => 0, 'text_content' => 'Strophe Zeile']);
$chorus = $song->sections()->create(['label_id' => $chorusLabel->id, 'order' => 1]);
$chorus->slides()->create(['order' => 0, 'text_content' => 'Refrain Zeile']);
$bridge = $song->sections()->create(['label_id' => $bridgeLabel->id, 'order' => 2]);
$bridge->slides()->create(['order' => 0, 'text_content' => 'Bridge Zeile']);
$normal = $song->arrangements()->create(['name' => 'Normal', 'is_default' => true]);
$normal->arrangementSections()->create(['song_section_id' => $verse->id, 'order' => 0]);
$normal->arrangementSections()->create(['song_section_id' => $chorus->id, 'order' => 1]);
$bene = $song->arrangements()->create(['name' => 'Bene', 'is_default' => false]);
$bene->arrangementSections()->create(['song_section_id' => $chorus->id, 'order' => 0]);
$bene->arrangementSections()->create(['song_section_id' => $bridge->id, 'order' => 1]);
return [$song->fresh(), $normal, $bene];
}
public function test_generate_parser_song_mit_gewaehltem_arrangement_setzt_selected_und_behaelt_alle(): void
{
[$song, $normal, $bene] = $this->createSongWithTwoArrangements();
$parserSong = app(ProExportService::class)->generateParserSong($song, null, $bene);
$beneParser = $parserSong->getArrangementByName('Bene');
$this->assertNotNull($beneParser, 'Gewähltes Arrangement "Bene" muss im .pro vorhanden sein');
$this->assertSame(
$beneParser->getUuid(),
$parserSong->getSelectedArrangementUuid(),
'selected_arrangement muss auf das gewählte Arrangement "Bene" zeigen',
);
$names = array_map(fn ($arr) => $arr->getName(), $parserSong->getArrangements());
$this->assertContains('Normal', $names, 'Default-Arrangement muss erhalten bleiben');
$this->assertContains('Bene', $names, 'Gewähltes Arrangement muss erhalten bleiben');
}
public function test_generate_parser_song_ohne_auswahl_nutzt_default_arrangement(): void
{
[$song, $normal] = $this->createSongWithTwoArrangements();
$parserSong = app(ProExportService::class)->generateParserSong($song, null, null);
$normalParser = $parserSong->getArrangementByName('Normal');
$this->assertNotNull($normalParser);
$this->assertSame(
$normalParser->getUuid(),
$parserSong->getSelectedArrangementUuid(),
'Ohne explizite Auswahl muss das Default-Arrangement vorausgewählt sein',
);
}
public function test_union_der_sektionen_laesst_jedes_arrangement_gruppen_rendern(): void
{
[$song, $normal, $bene] = $this->createSongWithTwoArrangements();
// Auswahl "Bene" (Refrain, Bridge) — ohne Union würde "Normal" (Strophe) leer/unvollständig rendern.
$parserSong = app(ProExportService::class)->generateParserSong($song, null, $bene);
// Alle drei Sektionen (Strophe, Refrain, Bridge) als Gruppen emittiert.
$this->assertCount(3, $parserSong->getGroups(), 'Union muss alle referenzierten Sektionen als Gruppen emittieren');
foreach ($parserSong->getArrangements() as $arrangement) {
$resolved = $parserSong->getGroupsForArrangement($arrangement);
$this->assertCount(
2,
$resolved,
"Arrangement '{$arrangement->getName()}' muss alle 2 Gruppen-Referenzen auflösen können",
);
}
}
public function test_playlist_export_setzt_arrangement_name_auf_gewaehltes_arrangement(): void
{
Storage::fake('public');
$service = Service::factory()->create(['title' => 'Arrangement Service', 'date' => now()]);
[$song, $normal, $bene] = $this->createSongWithTwoArrangements('Playlist Song');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $bene->id,
'cts_song_name' => 'Playlist Song',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Playlist Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$songEntry = null;
foreach ($playlist->getEntries() as $entry) {
if ($entry->getName() === 'Playlist Song') {
$songEntry = $entry;
break;
}
}
$this->assertNotNull($songEntry, 'Song-Eintrag muss in der Playlist vorhanden sein');
$this->assertSame('Bene', $songEntry->getArrangementName(), 'arrangement_name muss das gewählte Arrangement sein');
// Und der eingebettete .pro trägt selected_arrangement = "Bene".
$embedded = $playlist->getEmbeddedSong('Playlist Song.pro');
$this->assertNotNull($embedded);
$this->assertSame(
$embedded->getArrangementByName('Bene')->getUuid(),
$embedded->getSelectedArrangementUuid(),
);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_agenda_bundle_export_selektiert_gewaehltes_arrangement(): void
{
Storage::fake('public');
$service = Service::factory()->create(['title' => 'Bundle Service', 'date' => now()]);
[$song, $normal, $bene] = $this->createSongWithTwoArrangements('Bundle Song');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $bene->id,
'cts_song_name' => 'Bundle Song',
'order' => 1,
]);
$agendaItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Bundle Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$bundlePath = app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
$bundleSong = ProBundleReader::read($bundlePath)->getSong();
$beneParser = $bundleSong->getArrangementByName('Bene');
$this->assertNotNull($beneParser, 'Bundle .pro muss das Arrangement "Bene" enthalten');
$this->assertSame(
$beneParser->getUuid(),
$bundleSong->getSelectedArrangementUuid(),
'Bundle .pro selected_arrangement muss "Bene" sein',
);
$this->assertNotNull($bundleSong->getArrangementByName('Normal'), 'Alle Arrangements bleiben erhalten');
@unlink($bundlePath);
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
}