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.
232 lines
9.2 KiB
PHP
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);
|
|
}
|
|
}
|