From bdbf0c65e39167eb52b2f9ea46a587769f693847 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 3 May 2026 22:55:02 +0200 Subject: [PATCH] refactor(php): rename SongGroup references throughout controllers/services/tests Replace all SongGroup/SongArrangementGroup model usages with Label/SongArrangementLabel after the schema migration to global labels. Updates 12 app files and 11 test files: - SongService: createDefaultGroups now finds-or-creates global Labels by name - ArrangementController: validates label_id (labels are global, no song-ownership) - ProImportService: imports groups as Labels (firstOrCreate by name); does not overwrite existing label colors per spec - ProExportService/SongPdfController/TranslationService/etc: traverse via arrangements -> arrangementLabels -> label -> songSlides chain - All test factories and assertions adapted to label-based schema --- .../Controllers/ArrangementController.php | 68 ++++----- app/Http/Controllers/ProFileController.php | 10 +- app/Http/Controllers/ServiceController.php | 49 +++--- app/Http/Controllers/SongController.php | 74 +++++---- app/Http/Controllers/SongPdfController.php | 20 ++- .../Controllers/TranslationController.php | 61 ++++---- app/Services/ChurchToolsService.php | 3 +- app/Services/PlaylistExportService.php | 22 ++- app/Services/ProBundleExportService.php | 10 +- app/Services/ProExportService.php | 41 +++-- app/Services/ProImportService.php | 49 +++--- app/Services/SongService.php | 56 ++++--- app/Services/TranslationService.php | 68 ++++----- tests/Feature/AgendaItemDownloadTest.php | 18 ++- tests/Feature/ArrangementControllerTest.php | 70 ++++----- tests/Feature/PlaylistExportTest.php | 19 ++- tests/Feature/ProFileExportTest.php | 75 ++++----- tests/Feature/ProFileImportTest.php | 21 ++- tests/Feature/SongControllerTest.php | 62 +++----- tests/Feature/SongEditModalTest.php | 38 +---- tests/Feature/SongPdfTest.php | 94 +++++------- tests/Feature/SongsBlockTest.php | 20 +-- tests/Feature/TranslatePageTest.php | 36 +++-- tests/Feature/TranslationServiceTest.php | 144 ++++++++---------- tests/fixtures/generate-labels-sample.php | 6 +- tests/fixtures/generate-macros-sample.php | 12 +- 26 files changed, 573 insertions(+), 573 deletions(-) diff --git a/app/Http/Controllers/ArrangementController.php b/app/Http/Controllers/ArrangementController.php index d3b7cc8..d9168b4 100644 --- a/app/Http/Controllers/ArrangementController.php +++ b/app/Http/Controllers/ArrangementController.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers; +use App\Models\Label; use App\Models\Song; use App\Models\SongArrangement; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class ArrangementController extends Controller { @@ -23,17 +23,24 @@ public function store(Request $request, Song $song): RedirectResponse 'is_default' => false, ]); - $groups = $song->groups()->orderBy('order')->get(); - $rows = $groups->map(fn ($group, $index) => [ + $defaultArr = $song->arrangements()->where('is_default', true)->first(); + + if ($defaultArr === null) { + return; + } + + $arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get(); + + $rows = $arrangementLabels->values()->map(fn ($al, $index) => [ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group->id, + 'label_id' => $al->label_id, 'order' => $index + 1, 'created_at' => now(), 'updated_at' => now(), ])->all(); if ($rows !== []) { - $arrangement->arrangementGroups()->insert($rows); + $arrangement->arrangementLabels()->insert($rows); } }); @@ -47,14 +54,14 @@ public function clone(Request $request, SongArrangement $arrangement): RedirectR ]); DB::transaction(function () use ($arrangement, $data): void { - $arrangement->loadMissing('arrangementGroups'); + $arrangement->loadMissing('arrangementLabels'); $clone = $arrangement->song->arrangements()->create([ 'name' => $data['name'], 'is_default' => false, ]); - $this->cloneGroups($arrangement, $clone); + $this->cloneArrangementLabels($arrangement, $clone); }); return back()->with('success', 'Arrangement wurde geklont.'); @@ -64,33 +71,22 @@ public function update(Request $request, SongArrangement $arrangement): Redirect { $data = $request->validate([ 'groups' => ['array'], - 'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'], + 'groups.*.label_id' => ['required', 'integer', 'exists:labels,id'], 'groups.*.order' => ['required', 'integer', 'min:1'], 'group_colors' => ['sometimes', 'array'], 'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], ]); - $groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values(); - $uniqueGroupIds = $groupIds->unique()->values(); + $labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values(); - $validGroupIds = $arrangement->song->groups() - ->whereIn('id', $uniqueGroupIds) - ->pluck('id'); + DB::transaction(function () use ($arrangement, $labelIds, $data): void { + $arrangement->arrangementLabels()->delete(); - if ($uniqueGroupIds->count() !== $validGroupIds->count()) { - throw ValidationException::withMessages([ - 'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.', - ]); - } - - DB::transaction(function () use ($arrangement, $groupIds, $data): void { - $arrangement->arrangementGroups()->delete(); - - $rows = $groupIds + $rows = $labelIds ->values() - ->map(fn (int $songGroupId, int $index) => [ + ->map(fn (int $labelId, int $index) => [ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $songGroupId, + 'label_id' => $labelId, 'order' => $index + 1, 'created_at' => now(), 'updated_at' => now(), @@ -98,14 +94,12 @@ public function update(Request $request, SongArrangement $arrangement): Redirect ->all(); if ($rows !== []) { - $arrangement->arrangementGroups()->insert($rows); + $arrangement->arrangementLabels()->insert($rows); } if (! empty($data['group_colors'])) { - foreach ($data['group_colors'] as $groupId => $color) { - $arrangement->song->groups() - ->whereKey((int) $groupId) - ->update(['color' => $color]); + foreach ($data['group_colors'] as $labelId => $color) { + Label::whereKey((int) $labelId)->update(['color' => $color]); } } }); @@ -136,28 +130,28 @@ public function destroy(SongArrangement $arrangement): RedirectResponse return back()->with('success', 'Arrangement wurde gelöscht.'); } - private function cloneGroups(?SongArrangement $source, SongArrangement $target): void + private function cloneArrangementLabels(?SongArrangement $source, SongArrangement $target): void { if ($source === null) { return; } - $groups = $source->arrangementGroups + $arrangementLabels = $source->arrangementLabels ->sortBy('order') ->values(); - $rows = $groups - ->map(fn ($arrangementGroup) => [ + $rows = $arrangementLabels + ->map(fn ($arrangementLabel) => [ 'song_arrangement_id' => $target->id, - 'song_group_id' => $arrangementGroup->song_group_id, - 'order' => $arrangementGroup->order, + 'label_id' => $arrangementLabel->label_id, + 'order' => $arrangementLabel->order, 'created_at' => now(), 'updated_at' => now(), ]) ->all(); if ($rows !== []) { - $target->arrangementGroups()->insert($rows); + $target->arrangementLabels()->insert($rows); } } } diff --git a/app/Http/Controllers/ProFileController.php b/app/Http/Controllers/ProFileController.php index e6949a1..919820d 100644 --- a/app/Http/Controllers/ProFileController.php +++ b/app/Http/Controllers/ProFileController.php @@ -49,7 +49,7 @@ public function importPro(Request $request): JsonResponse public function downloadPro(Song $song): BinaryFileResponse { - if ($song->groups()->count() === 0) { + if ($this->countSongLabels($song) === 0) { abort(422, 'Song hat keine Gruppen oder Slides zum Exportieren.'); } @@ -60,4 +60,12 @@ public function downloadPro(Song $song): BinaryFileResponse return response()->download($tempPath, $filename)->deleteFileAfterSend(true); } + + private function countSongLabels(Song $song): int + { + return $song->arrangements() + ->withCount('arrangementLabels') + ->get() + ->sum('arrangement_labels_count'); + } } diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 8cae6f4..c99af6c 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -129,15 +129,13 @@ public function edit(Service $service): Response $service->load([ 'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs.song', - 'serviceSongs.song.groups', - 'serviceSongs.song.arrangements.arrangementGroups.group', + 'serviceSongs.song.arrangements.arrangementLabels.label', 'serviceSongs.arrangement', 'slides', 'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems.slides', - 'agendaItems.serviceSong.song.groups.slides', - 'agendaItems.serviceSong.song.arrangements.arrangementGroups.group', - 'agendaItems.serviceSong.arrangement.arrangementGroups.group', + 'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides', + 'agendaItems.serviceSong.arrangement.arrangementLabels.label', ]); $songsCatalog = Song::query() @@ -253,15 +251,7 @@ public function edit(Service $service): Response 'title' => $ss->song->title, 'ccli_id' => $ss->song->ccli_id, 'has_translation' => $ss->song->has_translation, - 'groups' => $ss->song->groups - ->sortBy('order') - ->values() - ->map(fn ($group) => [ - 'id' => $group->id, - 'name' => $group->name, - 'color' => $group->color, - 'order' => $group->order, - ]), + 'groups' => $this->collectSongLabels($ss->song), 'arrangements' => $ss->song->arrangements ->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1) ->values() @@ -269,13 +259,13 @@ public function edit(Service $service): Response 'id' => $arrangement->id, 'name' => $arrangement->name, 'is_default' => $arrangement->is_default, - 'groups' => $arrangement->arrangementGroups + 'groups' => $arrangement->arrangementLabels ->sortBy('order') ->values() - ->map(fn ($arrangementGroup) => [ - 'id' => $arrangementGroup->group?->id, - 'name' => $arrangementGroup->group?->name, - 'color' => $arrangementGroup->group?->color, + ->map(fn ($arrangementLabel) => [ + 'id' => $arrangementLabel->label?->id, + 'name' => $arrangementLabel->label?->name, + 'color' => $arrangementLabel->label?->color, ]) ->filter(fn ($group) => $group['id'] !== null) ->values(), @@ -412,4 +402,25 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt ) ->deleteFileAfterSend(true); } + + private function collectSongLabels(Song $song): \Illuminate\Support\Collection + { + $defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); + + if ($defaultArr === null) { + return collect(); + } + + return $defaultArr->arrangementLabels + ->sortBy('order') + ->values() + ->map(fn ($arrangementLabel) => [ + 'id' => $arrangementLabel->label?->id, + 'name' => $arrangementLabel->label?->name, + 'color' => $arrangementLabel->label?->color, + 'order' => $arrangementLabel->order, + ]) + ->filter(fn ($group) => $group['id'] !== null) + ->values(); + } } diff --git a/app/Http/Controllers/SongController.php b/app/Http/Controllers/SongController.php index 70ea0d2..a4c81f1 100644 --- a/app/Http/Controllers/SongController.php +++ b/app/Http/Controllers/SongController.php @@ -15,9 +15,6 @@ public function __construct( private readonly SongService $songService, ) {} - /** - * Alle Songs auflisten (paginiert, durchsuchbar). - */ public function index(Request $request): JsonResponse { $query = Song::query(); @@ -53,15 +50,11 @@ public function index(Request $request): JsonResponse ]); } - /** - * Neuen Song erstellen mit Default-Gruppen und -Arrangement. - */ public function store(SongRequest $request): JsonResponse { $song = DB::transaction(function () use ($request) { $song = Song::create($request->validated()); - $this->songService->createDefaultGroups($song); $this->songService->createDefaultArrangement($song); return $song; @@ -69,16 +62,13 @@ public function store(SongRequest $request): JsonResponse return response()->json([ 'message' => 'Song erfolgreich erstellt', - 'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])), + 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])), ], 201); } - /** - * Song mit Gruppen, Slides und Arrangements anzeigen. - */ public function show(int $id): JsonResponse { - $song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id); + $song = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($id); if (! $song) { return response()->json(['message' => 'Song nicht gefunden'], 404); @@ -89,9 +79,6 @@ public function show(int $id): JsonResponse ]); } - /** - * Song-Metadaten aktualisieren. - */ public function update(SongRequest $request, int $id): JsonResponse { $song = Song::find($id); @@ -104,13 +91,10 @@ public function update(SongRequest $request, int $id): JsonResponse return response()->json([ 'message' => 'Song erfolgreich aktualisiert', - 'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])), + 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])), ]); } - /** - * Song soft-löschen. - */ public function destroy(int $id): JsonResponse { $song = Song::find($id); @@ -126,11 +110,35 @@ public function destroy(int $id): JsonResponse ]); } - /** - * Song-Detail formatieren. - */ private function formatSongDetail(Song $song): array { + $defaultArr = $song->arrangements->firstWhere('is_default', true); + + $groupsPayload = []; + if ($defaultArr !== null) { + $groupsPayload = $defaultArr->arrangementLabels + ->sortBy('order') + ->values() + ->map(fn ($al) => [ + 'id' => $al->label?->id, + 'name' => $al->label?->name, + 'color' => $al->label?->color, + 'order' => $al->order, + 'slides' => $al->label + ? $al->label->songSlides + ->sortBy('order') + ->values() + ->map(fn ($slide) => [ + 'id' => $slide->id, + 'order' => $slide->order, + 'text_content' => $slide->text_content, + 'text_content_translated' => $slide->text_content_translated, + 'notes' => $slide->notes, + ])->toArray() + : [], + ])->toArray(); + } + return [ 'id' => $song->id, 'title' => $song->title, @@ -144,27 +152,15 @@ private function formatSongDetail(Song $song): array 'last_used_in_service' => $song->last_used_in_service, 'created_at' => $song->created_at->toDateTimeString(), 'updated_at' => $song->updated_at->toDateTimeString(), - 'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [ - 'id' => $group->id, - 'name' => $group->name, - 'color' => $group->color, - 'order' => $group->order, - 'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [ - 'id' => $slide->id, - 'order' => $slide->order, - 'text_content' => $slide->text_content, - 'text_content_translated' => $slide->text_content_translated, - 'notes' => $slide->notes, - ])->toArray(), - ])->toArray(), + 'groups' => $groupsPayload, 'arrangements' => $song->arrangements->map(fn ($arr) => [ 'id' => $arr->id, 'name' => $arr->name, 'is_default' => $arr->is_default, - 'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [ - 'id' => $ag->id, - 'song_group_id' => $ag->song_group_id, - 'order' => $ag->order, + 'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [ + 'id' => $al->id, + 'label_id' => $al->label_id, + 'order' => $al->order, ])->toArray(), ])->toArray(), ]; diff --git a/app/Http/Controllers/SongPdfController.php b/app/Http/Controllers/SongPdfController.php index 7614bef..8aed81a 100644 --- a/app/Http/Controllers/SongPdfController.php +++ b/app/Http/Controllers/SongPdfController.php @@ -57,21 +57,25 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso private function buildGroupsInOrder(SongArrangement $arrangement): array { $arrangement->load([ - 'arrangementGroups' => fn ($query) => $query->orderBy('order'), - 'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'), + 'arrangementLabels' => fn ($query) => $query->orderBy('order'), + 'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'), ]); - return $arrangement->arrangementGroups->map(function ($arrangementGroup) { - $group = $arrangementGroup->group; + return $arrangement->arrangementLabels->map(function ($arrangementLabel) { + $label = $arrangementLabel->label; + + if ($label === null) { + return null; + } return [ - 'name' => $group->name, - 'color' => $group->color ?? '#6b7280', - 'slides' => $group->slides->map(fn ($slide) => [ + 'name' => $label->name, + 'color' => $label->color ?? '#6b7280', + 'slides' => $label->songSlides->map(fn ($slide) => [ 'text_content' => $slide->text_content, 'text_content_translated' => $slide->text_content_translated, ])->values()->all(), ]; - })->values()->all(); + })->filter()->values()->all(); } } diff --git a/app/Http/Controllers/TranslationController.php b/app/Http/Controllers/TranslationController.php index 04f192b..c653826 100644 --- a/app/Http/Controllers/TranslationController.php +++ b/app/Http/Controllers/TranslationController.php @@ -18,41 +18,48 @@ public function __construct( public function page(Song $song): Response { $song->load([ - 'groups' => fn ($query) => $query - ->orderBy('order') - ->with([ - 'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'), - ]), + 'arrangements' => fn ($q) => $q->where('is_default', true), + 'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'), + 'arrangements.arrangementLabels.label.songSlides', ]); + $defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); + + $groups = collect(); + if ($defaultArr !== null) { + $groups = $defaultArr->arrangementLabels + ->sortBy('order') + ->values() + ->map(fn ($al) => [ + 'id' => $al->label?->id, + 'name' => $al->label?->name, + 'color' => $al->label?->color, + 'order' => $al->order, + 'slides' => $al->label + ? $al->label->songSlides + ->sortBy('order') + ->values() + ->map(fn ($slide) => [ + 'id' => $slide->id, + 'order' => $slide->order, + 'text_content' => $slide->text_content, + 'text_content_translated' => $slide->text_content_translated, + ])->values() + : collect(), + ]); + } + return Inertia::render('Songs/Translate', [ 'song' => [ 'id' => $song->id, 'title' => $song->title, 'ccli_id' => $song->ccli_id, 'has_translation' => $song->has_translation, - 'groups' => $song->groups->map(fn ($group) => [ - 'id' => $group->id, - 'name' => $group->name, - 'color' => $group->color, - 'order' => $group->order, - 'slides' => $group->slides->map(fn ($slide) => [ - 'id' => $slide->id, - 'order' => $slide->order, - 'text_content' => $slide->text_content, - 'text_content_translated' => $slide->text_content_translated, - ])->values(), - ])->values(), + 'groups' => $groups, ], ]); } - /** - * URL abrufen und Text zum Prüfen zurückgeben. - * - * Der Text wird NICHT automatisch gespeichert — der Benutzer - * prüft ihn zuerst und importiert dann explizit. - */ public function fetchUrl(Request $request): JsonResponse { $request->validate([ @@ -72,11 +79,6 @@ public function fetchUrl(Request $request): JsonResponse ]); } - /** - * Übersetzungstext für einen Song importieren. - * - * Verteilt den Text zeilenweise auf die Slides des Songs. - */ public function import(int $songId, Request $request): JsonResponse { $song = Song::find($songId); @@ -98,9 +100,6 @@ public function import(int $songId, Request $request): JsonResponse ]); } - /** - * Übersetzung eines Songs komplett entfernen. - */ public function destroy(int $songId): JsonResponse { $song = Song::find($songId); diff --git a/app/Services/ChurchToolsService.php b/app/Services/ChurchToolsService.php index dad2e7d..0baffd1 100644 --- a/app/Services/ChurchToolsService.php +++ b/app/Services/ChurchToolsService.php @@ -28,8 +28,7 @@ public function __construct( private readonly ?Closure $songFetcher = null, private readonly ?Closure $agendaFetcher = null, private readonly ?Closure $eventServiceFetcher = null, - ) { - } + ) {} public function sync(): array { diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index e775a2a..30cbab9 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -19,7 +19,11 @@ public function generatePlaylist(Service $service): array $agendaItems = ServiceAgendaItem::where('service_id', $service->id) ->where('is_before_event', false) ->orderBy('sort_order') - ->with(['slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), 'serviceSong.song.groups.slides', 'serviceSong.arrangement.arrangementGroups.group']) + ->with([ + 'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'), + 'serviceSong.song.arrangements.arrangementLabels.label.songSlides', + 'serviceSong.arrangement.arrangementLabels.label', + ]) ->get(); if ($agendaItems->isEmpty()) { @@ -80,7 +84,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if ($serviceSong->song_id && $serviceSong->song) { $song = $serviceSong->song; - if ($song->groups()->count() === 0) { + if ($this->countSongLabels($song) === 0) { $skippedUnmatched++; continue; @@ -161,12 +165,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda */ private function generatePlaylistLegacy(Service $service): array { - $service->loadMissing('serviceSongs.song.groups.slides'); + $service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides'); $matchedSongs = $service->serviceSongs() ->whereNotNull('song_id') ->orderBy('order') - ->with('song.groups.slides') + ->with('song.arrangements.arrangementLabels.label.songSlides') ->get(); $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); @@ -191,7 +195,7 @@ private function generatePlaylistLegacy(Service $service): array foreach ($matchedSongs as $serviceSong) { $song = $serviceSong->song; - if (! $song || $song->groups()->count() === 0) { + if (! $song || $this->countSongLabels($song) === 0) { $skippedEmpty++; continue; @@ -366,6 +370,14 @@ protected function writeProFile(string $path, string $name, array $groups, array ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); } + private function countSongLabels(\App\Models\Song $song): int + { + return $song->arrangements() + ->withCount('arrangementLabels') + ->get() + ->sum('arrangement_labels_count'); + } + protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void { ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles); diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index fbadd56..a4ea129 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -35,8 +35,7 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string { $agendaItem->loadMissing([ 'slides', - 'serviceSong.song.groups.slides', - 'serviceSong.song.arrangements.arrangementGroups.group', + 'serviceSong.song.arrangements.arrangementLabels.label.songSlides', ]); $title = $agendaItem->title ?: 'Ablauf-Element'; @@ -44,7 +43,12 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) { $song = $agendaItem->serviceSong->song; - if ($song->groups()->count() === 0) { + $labelCount = $song->arrangements() + ->withCount('arrangementLabels') + ->get() + ->sum('arrangement_labels_count'); + + if ($labelCount === 0) { throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.'); } diff --git a/app/Services/ProExportService.php b/app/Services/ProExportService.php index 11a0a1b..0820451 100644 --- a/app/Services/ProExportService.php +++ b/app/Services/ProExportService.php @@ -25,7 +25,7 @@ public function generateProFile(Song $song): string public function generateParserSong(Song $song): \ProPresenter\Parser\Song { - $song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']); + $song->loadMissing(['arrangements.arrangementLabels.label.songSlides']); return ProFileGenerator::generate( $song->title, @@ -37,14 +37,34 @@ public function generateParserSong(Song $song): \ProPresenter\Parser\Song private function buildGroups(Song $song): array { - $groups = []; + $defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); + + if ($defaultArr === null) { + return []; + } + + $defaultArr->loadMissing('arrangementLabels.label.songSlides'); + $macroData = $this->buildMacroData(); + $groups = []; + $seenLabelIds = []; - foreach ($song->groups->sortBy('order') as $group) { + foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) { + $label = $arrangementLabel->label; + + if ($label === null) { + continue; + } + + if (in_array($label->id, $seenLabelIds, true)) { + continue; + } + $seenLabelIds[] = $label->id; + + $isCopyrightGroup = strcasecmp($label->name, 'COPYRIGHT') === 0; $slides = []; - $isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0; - foreach ($group->slides->sortBy('order') as $slide) { + foreach ($label->songSlides->sortBy('order') as $slide) { $slideData = ['text' => $slide->text_content ?? '']; if ($slide->text_content_translated) { @@ -59,8 +79,8 @@ private function buildGroups(Song $song): array } $groups[] = [ - 'name' => $group->name, - 'color' => ProImportService::hexToRgba($group->color), + 'name' => $label->name, + 'color' => ProImportService::hexToRgba($label->color ?? '#808080'), 'slides' => $slides, ]; } @@ -88,12 +108,13 @@ private function buildMacroData(): ?array private function buildArrangements(Song $song): array { $arrangements = []; - $groupIdToName = $song->groups->pluck('name', 'id')->toArray(); foreach ($song->arrangements as $arrangement) { - $groupNames = $arrangement->arrangementGroups + $arrangement->loadMissing('arrangementLabels.label'); + + $groupNames = $arrangement->arrangementLabels ->sortBy('order') - ->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null) + ->map(fn ($al) => $al->label?->name) ->filter() ->values() ->toArray(); diff --git a/app/Services/ProImportService.php b/app/Services/ProImportService.php index 978de56..390d2b1 100644 --- a/app/Services/ProImportService.php +++ b/app/Services/ProImportService.php @@ -2,10 +2,11 @@ namespace App\Services; +use App\Models\Label; use App\Models\Song; use App\Models\SongArrangement; -use App\Models\SongArrangementGroup; -use App\Models\SongGroup; +use App\Models\SongArrangementLabel; +use App\Support\MacroColorConverter; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; use ProPresenter\Parser\ProFileReader; @@ -103,28 +104,30 @@ private function upsertSong(ProSong $proSong): Song } $song->arrangements()->each(function (SongArrangement $arr) { - $arr->arrangementGroups()->delete(); + $arr->arrangementLabels()->delete(); }); $song->arrangements()->delete(); - $song->groups()->each(function (SongGroup $group) { - $group->slides()->delete(); - }); - $song->groups()->delete(); $hasTranslation = false; - $groupMap = []; + $labelsByName = []; - foreach ($proSong->getGroups() as $position => $proGroup) { - $color = $proGroup->getColor(); - $hexColor = $color ? self::rgbaToHex($color) : '#808080'; + foreach ($proSong->getGroups() as $proGroup) { + $groupName = $proGroup->getName(); + $existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first(); - $songGroup = $song->groups()->create([ - 'name' => $proGroup->getName(), - 'color' => $hexColor, - 'order' => $position, - ]); + if ($existingLabel === null) { + $color = $proGroup->getColor(); + $hexColor = MacroColorConverter::fromRgba($color) ?? '#808080'; - $groupMap[$proGroup->getName()] = $songGroup; + $existingLabel = Label::create([ + 'name' => $groupName, + 'color' => $hexColor, + ]); + } + + $labelsByName[$groupName] = $existingLabel; + + $existingLabel->songSlides()->delete(); foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) { $translatedText = null; @@ -134,7 +137,7 @@ private function upsertSong(ProSong $proSong): Song $hasTranslation = true; } - $songGroup->slides()->create([ + $existingLabel->songSlides()->create([ 'order' => $slidePosition, 'text_content' => $proSlide->getPlainText(), 'text_content_translated' => $translatedText, @@ -153,19 +156,19 @@ private function upsertSong(ProSong $proSong): Song $groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement); foreach ($groupsInArrangement as $order => $proGroup) { - $songGroup = $groupMap[$proGroup->getName()] ?? null; + $label = $labelsByName[$proGroup->getName()] ?? null; - if ($songGroup) { - SongArrangementGroup::create([ + if ($label) { + SongArrangementLabel::create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $songGroup->id, + 'label_id' => $label->id, 'order' => $order, ]); } } } - return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']); + return $song->fresh(['arrangements.arrangementLabels.label.songSlides']); } public static function rgbaToHex(array $rgba): string diff --git a/app/Services/SongService.php b/app/Services/SongService.php index cca4ac7..4d3b457 100644 --- a/app/Services/SongService.php +++ b/app/Services/SongService.php @@ -2,36 +2,48 @@ namespace App\Services; +use App\Models\Label; use App\Models\Song; use App\Models\SongArrangement; -use App\Models\SongArrangementGroup; -use App\Models\SongGroup; +use App\Models\SongArrangementLabel; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class SongService { /** - * Default-Gruppen für ein neues Lied erstellen. + * Sicherstellen, dass die Default-Labels (Strophe 1, Refrain, Bridge) global existieren. * - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ - public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection + public function createDefaultGroups(Song $song): Collection { $defaults = [ - ['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1], - ['name' => 'Refrain', 'color' => '#10B981', 'order' => 2], - ['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3], + ['name' => 'Strophe 1', 'color' => '#3B82F6'], + ['name' => 'Refrain', 'color' => '#10B981'], + ['name' => 'Bridge', 'color' => '#F59E0B'], ]; - foreach ($defaults as $groupData) { - $song->groups()->create($groupData); + $labels = collect(); + + foreach ($defaults as $data) { + $existing = Label::whereRaw('LOWER(name) = ?', [strtolower($data['name'])])->first(); + + if ($existing === null) { + $existing = Label::create([ + 'name' => $data['name'], + 'color' => $data['color'], + ]); + } + + $labels->push($existing); } - return $song->groups()->orderBy('order')->get(); + return $labels; } /** - * Standard "Normal"-Arrangement mit allen Gruppen erstellen. + * Standard "Normal"-Arrangement mit den Default-Labels erstellen. */ public function createDefaultArrangement(Song $song): SongArrangement { @@ -40,16 +52,16 @@ public function createDefaultArrangement(Song $song): SongArrangement 'is_default' => true, ]); - $groups = $song->groups()->orderBy('order')->get(); + $labels = $this->createDefaultGroups($song); - foreach ($groups as $index => $group) { - $arrangement->arrangementGroups()->create([ - 'song_group_id' => $group->id, + foreach ($labels->values() as $index => $label) { + $arrangement->arrangementLabels()->create([ + 'label_id' => $label->id, 'order' => $index + 1, ]); } - return $arrangement->load('arrangementGroups'); + return $arrangement->load('arrangementLabels.label'); } /** @@ -63,15 +75,15 @@ public function duplicateArrangement(SongArrangement $arrangement, string $name) $clone->is_default = false; $clone->save(); - foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) { - SongArrangementGroup::create([ + foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) { + SongArrangementLabel::create([ 'song_arrangement_id' => $clone->id, - 'song_group_id' => $group->song_group_id, - 'order' => $group->order, + 'label_id' => $arrangementLabel->label_id, + 'order' => $arrangementLabel->order, ]); } - return $clone->load('arrangementGroups'); + return $clone->load('arrangementLabels.label'); }); } } diff --git a/app/Services/TranslationService.php b/app/Services/TranslationService.php index cbfdb4a..d372357 100644 --- a/app/Services/TranslationService.php +++ b/app/Services/TranslationService.php @@ -8,12 +8,6 @@ class TranslationService { - /** - * Text von einer URL abrufen (Best-Effort). - * - * HTML-Tags werden entfernt, nur reiner Text zurückgegeben. - * Bei Fehlern wird null zurückgegeben, ohne Exception. - */ public function fetchFromUrl(string $url): ?string { try { @@ -33,29 +27,30 @@ public function fetchFromUrl(string $url): ?string return null; } - /** - * Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide. - * - * Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert): - * Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat. - * - * Beispiel: - * Slide 1 hat 4 Zeilen → bekommt die nächsten 4 Zeilen der Übersetzung - * Slide 2 hat 2 Zeilen → bekommt die nächsten 2 Zeilen - * Slide 3 hat 4 Zeilen → bekommt die nächsten 4 Zeilen - */ public function importTranslation(Song $song, string $text): void { $translatedLines = explode("\n", $text); $offset = 0; - // Alle Gruppen nach order sortiert laden, mit Slides - $groups = $song->groups()->orderBy('order')->with([ - 'slides' => fn ($query) => $query->orderBy('order'), - ])->get(); + $defaultArr = $song->arrangements() + ->where('is_default', true) + ->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides']) + ->first(); - foreach ($groups as $group) { - foreach ($group->slides as $slide) { + if ($defaultArr === null) { + $this->markAsTranslated($song); + + return; + } + + foreach ($defaultArr->arrangementLabels->sortBy('order') as $arrangementLabel) { + $label = $arrangementLabel->label; + + if ($label === null) { + continue; + } + + foreach ($label->songSlides->sortBy('order') as $slide) { $originalLineCount = count(explode("\n", $slide->text_content ?? '')); $chunk = array_slice($translatedLines, $offset, $originalLineCount); $offset += $originalLineCount; @@ -69,30 +64,25 @@ public function importTranslation(Song $song, string $text): void $this->markAsTranslated($song); } - /** - * Song als "hat Übersetzung" markieren. - */ public function markAsTranslated(Song $song): void { $song->update(['has_translation' => true]); } - /** - * Übersetzung eines Songs komplett entfernen. - * - * Löscht alle text_content_translated Felder und setzt has_translation auf false. - */ public function removeTranslation(Song $song): void { - // Alle Slides des Songs über die Gruppen aktualisieren - $slideIds = SongSlide::whereIn( - 'song_group_id', - $song->groups()->pluck('id') - )->pluck('id'); + $labelIds = $song->arrangements() + ->with('arrangementLabels') + ->get() + ->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id')) + ->unique() + ->values(); - SongSlide::whereIn('id', $slideIds)->update([ - 'text_content_translated' => null, - ]); + if ($labelIds->isNotEmpty()) { + SongSlide::whereIn('label_id', $labelIds)->update([ + 'text_content_translated' => null, + ]); + } $song->update(['has_translation' => false]); } diff --git a/tests/Feature/AgendaItemDownloadTest.php b/tests/Feature/AgendaItemDownloadTest.php index fb107c5..0d1f0b6 100644 --- a/tests/Feature/AgendaItemDownloadTest.php +++ b/tests/Feature/AgendaItemDownloadTest.php @@ -2,12 +2,14 @@ namespace Tests\Feature; +use App\Models\Label; use App\Models\Service; use App\Models\ServiceAgendaItem; use App\Models\ServiceSong; use App\Models\Slide; use App\Models\Song; -use App\Models\SongGroup; +use App\Models\SongArrangement; +use App\Models\SongArrangementLabel; use App\Models\SongSlide; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -102,8 +104,18 @@ public function test_song_agenda_item_liefert_probundle_mit_pro_daten(): void $service = Service::factory()->create(); $song = Song::factory()->create(['title' => 'Amazing Grace']); - $group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Verse 1', 'order' => 1]); - SongSlide::factory()->create(['song_group_id' => $group->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]); + $label = Label::factory()->create(['name' => 'Verse 1']); + SongSlide::factory()->create(['label_id' => $label->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 1]); + + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'is_default' => true, + ]); + SongArrangementLabel::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'label_id' => $label->id, + 'order' => 1, + ]); $serviceSong = ServiceSong::create([ 'service_id' => $service->id, diff --git a/tests/Feature/ArrangementControllerTest.php b/tests/Feature/ArrangementControllerTest.php index 7d4b066..a6749fb 100644 --- a/tests/Feature/ArrangementControllerTest.php +++ b/tests/Feature/ArrangementControllerTest.php @@ -2,10 +2,10 @@ namespace Tests\Feature; +use App\Models\Label; use App\Models\Song; use App\Models\SongArrangement; -use App\Models\SongArrangementGroup; -use App\Models\SongGroup; +use App\Models\SongArrangementLabel; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Auth; @@ -34,19 +34,19 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void $this->assertNotNull($newArrangement); - $defaultGroupOrder = SongGroup::query() - ->where('song_id', $song->id) + $defaultLabelOrder = SongArrangementLabel::query() + ->where('song_arrangement_id', $normal->id) ->orderBy('order') - ->pluck('id') + ->pluck('label_id') ->all(); - $newGroups = SongArrangementGroup::query() + $newLabels = SongArrangementLabel::query() ->where('song_arrangement_id', $newArrangement->id) ->orderBy('order') - ->pluck('song_group_id') + ->pluck('label_id') ->all(); - $this->assertSame($defaultGroupOrder, $newGroups); + $this->assertSame($defaultLabelOrder, $newLabels); } public function test_clone_arrangement_duplicates_current_arrangement_groups(): void @@ -69,19 +69,19 @@ public function test_clone_arrangement_duplicates_current_arrangement_groups(): $this->assertNotNull($clone); $this->assertFalse($clone->is_default); - $originalGroups = SongArrangementGroup::query() + $originalLabels = SongArrangementLabel::query() ->where('song_arrangement_id', $normal->id) ->orderBy('order') - ->pluck('song_group_id') + ->pluck('label_id') ->all(); - $cloneGroups = SongArrangementGroup::query() + $cloneLabels = SongArrangementLabel::query() ->where('song_arrangement_id', $clone->id) ->orderBy('order') - ->pluck('song_group_id') + ->pluck('label_id') ->all(); - $this->assertSame($originalGroups, $cloneGroups); + $this->assertSame($originalLabels, $cloneLabels); } public function test_update_arrangement_reorders_and_persists_groups(): void @@ -92,19 +92,19 @@ public function test_update_arrangement_reorders_and_persists_groups(): void $response = $this->put(route('arrangements.update', $normal), [ 'groups' => [ - ['song_group_id' => $chorus->id, 'order' => 1], - ['song_group_id' => $bridge->id, 'order' => 2], - ['song_group_id' => $verse->id, 'order' => 3], - ['song_group_id' => $chorus->id, 'order' => 4], + ['label_id' => $chorus->id, 'order' => 1], + ['label_id' => $bridge->id, 'order' => 2], + ['label_id' => $verse->id, 'order' => 3], + ['label_id' => $chorus->id, 'order' => 4], ], ]); $response->assertRedirect(); - $updated = SongArrangementGroup::query() + $updated = SongArrangementLabel::query() ->where('song_arrangement_id', $normal->id) ->orderBy('order') - ->pluck('song_group_id') + ->pluck('label_id') ->all(); $this->assertSame([ @@ -136,23 +136,9 @@ private function createSongWithDefaultArrangement(): array { $song = Song::factory()->create(); - $verse = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'name' => 'Verse 1', - 'order' => 1, - ]); - - $chorus = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'name' => 'Chorus', - 'order' => 2, - ]); - - $bridge = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'name' => 'Bridge', - 'order' => 3, - ]); + $verse = Label::factory()->create(['name' => 'Verse 1']); + $chorus = Label::factory()->create(['name' => 'Chorus']); + $bridge = Label::factory()->create(['name' => 'Bridge']); $normal = SongArrangement::factory()->create([ 'song_id' => $song->id, @@ -160,21 +146,21 @@ private function createSongWithDefaultArrangement(): array 'is_default' => true, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $normal->id, - 'song_group_id' => $verse->id, + 'label_id' => $verse->id, 'order' => 1, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $normal->id, - 'song_group_id' => $chorus->id, + 'label_id' => $chorus->id, 'order' => 2, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $normal->id, - 'song_group_id' => $verse->id, + 'label_id' => $verse->id, 'order' => 3, ]); diff --git a/tests/Feature/PlaylistExportTest.php b/tests/Feature/PlaylistExportTest.php index 6aa03a0..ea891c4 100644 --- a/tests/Feature/PlaylistExportTest.php +++ b/tests/Feature/PlaylistExportTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Models\Label; use App\Models\Service; use App\Models\ServiceAgendaItem; use App\Models\ServiceSong; @@ -35,15 +36,21 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl 'copyright_text' => 'Test Publisher', ]); - $verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]); - $verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']); + $verse = Label::firstOrCreate( + ['name' => 'Verse 1 - '.$title], + ['color' => '#2196F3'], + ); + $verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']); - $chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]); - $chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']); + $chorus = Label::firstOrCreate( + ['name' => 'Chorus - '.$title], + ['color' => '#F44336'], + ); + $chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); - $arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]); - $arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]); + $arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]); + $arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]); return $song; } diff --git a/tests/Feature/ProFileExportTest.php b/tests/Feature/ProFileExportTest.php index 6371a8f..f613f32 100644 --- a/tests/Feature/ProFileExportTest.php +++ b/tests/Feature/ProFileExportTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Models\Label; use App\Models\Song; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -22,17 +23,23 @@ private function createSongWithContent(): Song 'publisher' => 'Test Publisher', ]); - $verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]); - $verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']); - $verse->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']); + $verse = Label::firstOrCreate( + ['name' => 'Verse 1 - Export Test Song'], + ['color' => '#2196F3'], + ); + $verse->songSlides()->create(['order' => 0, 'text_content' => 'First line of verse']); + $verse->songSlides()->create(['order' => 1, 'text_content' => 'Second line of verse']); - $chorus = $song->groups()->create(['name' => 'Chorus', 'color' => '#F44336', 'order' => 1]); - $chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']); + $chorus = Label::firstOrCreate( + ['name' => 'Chorus - Export Test Song'], + ['color' => '#F44336'], + ); + $chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); - $arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]); - $arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]); - $arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 2]); + $arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]); + $arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]); + $arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]); return $song; } @@ -82,7 +89,9 @@ public function test_download_pro_roundtrip_import_export(): void $song = Song::find($songId); $this->assertNotNull($song); - $this->assertGreaterThan(0, $song->groups()->count()); + + $labelCount = $song->arrangements()->withCount('arrangementLabels')->get()->sum('arrangement_labels_count'); + $this->assertGreaterThan(0, $labelCount); $exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro"); $exportResponse->assertOk(); @@ -92,7 +101,6 @@ public function test_download_pro_roundtrip_preserves_content(): void { $user = User::factory()->create(); - // 1. Import the reference .pro file $sourcePath = base_path('tests/fixtures/propresenter/Test.pro'); $file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true); @@ -100,43 +108,45 @@ public function test_download_pro_roundtrip_preserves_content(): void $importResponse->assertOk(); $songId = $importResponse->json('songs.0.id'); - $originalSong = Song::with(['groups.slides', 'arrangements.arrangementGroups.group'])->find($songId); + $originalSong = Song::with(['arrangements.arrangementLabels.label.songSlides'])->find($songId); $this->assertNotNull($originalSong); - // Snapshot original data - $originalGroups = $originalSong->groups->sortBy('order')->values(); + $defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first(); + $this->assertNotNull($defaultArr); + + $originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values(); $originalArrangements = $originalSong->arrangements; - // 2. Export as .pro $exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro"); $exportResponse->assertOk(); - // Save exported content to temp file — BinaryFileResponse delivers a real file $tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro'; /** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */ $baseResponse = $exportResponse->baseResponse; copy($baseResponse->getFile()->getPathname(), $tempPath); - // 3. Re-import the exported file as a new song (different ccli to avoid upsert) - // Use the ProPresenter parser directly to read and verify $reImported = \ProPresenter\Parser\ProFileReader::read($tempPath); @unlink($tempPath); - // 4. Assert song name $this->assertSame($originalSong->title, $reImported->getName()); - // 5. Assert groups match (same names, same order) $reImportedGroups = $reImported->getGroups(); - $this->assertCount($originalGroups->count(), $reImportedGroups, 'Group count mismatch'); - foreach ($originalGroups as $index => $originalGroup) { + $uniqueOriginalLabels = $originalArrangementLabels + ->map(fn ($al) => $al->label) + ->filter() + ->unique('id') + ->values(); + + $this->assertCount($uniqueOriginalLabels->count(), $reImportedGroups, 'Group count mismatch'); + + foreach ($uniqueOriginalLabels as $index => $originalLabel) { $reImportedGroup = $reImportedGroups[$index]; - $this->assertSame($originalGroup->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}"); + $this->assertSame($originalLabel->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}"); - // Assert slides within group - $originalSlides = $originalGroup->slides->sortBy('order')->values(); + $originalSlides = $originalLabel->songSlides->sortBy('order')->values(); $reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup); - $this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalGroup->name}'"); + $this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalLabel->name}'"); foreach ($originalSlides as $slideIndex => $originalSlide) { $reImportedSlide = $reImportedSlides[$slideIndex]; @@ -144,32 +154,30 @@ public function test_download_pro_roundtrip_preserves_content(): void $this->assertSame( $originalSlide->text_content, $reImportedSlide->getPlainText(), - "Slide text mismatch for group '{$originalGroup->name}' slide {$slideIndex}" + "Slide text mismatch for group '{$originalLabel->name}' slide {$slideIndex}" ); - // Assert translation if present if ($originalSlide->text_content_translated) { - $this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalGroup->name}' slide {$slideIndex}"); + $this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalLabel->name}' slide {$slideIndex}"); $this->assertSame( $originalSlide->text_content_translated, $reImportedSlide->getTranslation()?->getPlainText(), - "Translation text mismatch for group '{$originalGroup->name}' slide {$slideIndex}" + "Translation text mismatch for group '{$originalLabel->name}' slide {$slideIndex}" ); } } } - // 6. Assert arrangements match (same names, same group order) $reImportedArrangements = $reImported->getArrangements(); $this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch'); - foreach ($originalArrangements as $index => $originalArrangement) { + foreach ($originalArrangements as $originalArrangement) { $reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name); $this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import"); - $originalGroupNames = $originalArrangement->arrangementGroups + $originalGroupNames = $originalArrangement->arrangementLabels ->sortBy('order') - ->map(fn ($ag) => $ag->group?->name) + ->map(fn ($al) => $al->label?->name) ->filter() ->values() ->toArray(); @@ -186,7 +194,6 @@ public function test_download_pro_roundtrip_preserves_content(): void ); } - // 7. Assert CCLI metadata if ($originalSong->ccli_id) { $this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber()); } diff --git a/tests/Feature/ProFileImportTest.php b/tests/Feature/ProFileImportTest.php index fb64718..7acdfb1 100644 --- a/tests/Feature/ProFileImportTest.php +++ b/tests/Feature/ProFileImportTest.php @@ -32,8 +32,10 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo $song = Song::where('title', 'Test')->first(); $this->assertNotNull($song); - $this->assertSame(4, $song->groups()->count()); - $this->assertSame(5, $song->groups()->withCount('slides')->get()->sum('slides_count')); + + $this->assertSame(4, \App\Models\Label::count()); + $this->assertSame(5, \App\Models\SongSlide::count()); + $this->assertSame(2, $song->arrangements()->count()); $this->assertTrue($song->has_translation); } @@ -64,9 +66,18 @@ public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void 'title' => 'Old Title', 'ccli_id' => '999', ]); - $existingSong->groups()->create(['name' => 'Old Group', 'color' => '#FF0000', 'order' => 0]); - $this->assertSame(1, $existingSong->groups()->count()); + $arrangement = $existingSong->arrangements()->create([ + 'name' => 'Normal', + 'is_default' => true, + ]); + $oldLabel = \App\Models\Label::firstOrCreate(['name' => 'Old Group'], ['color' => '#FF0000']); + $arrangement->arrangementLabels()->create([ + 'label_id' => $oldLabel->id, + 'order' => 0, + ]); + + $this->assertSame(1, $arrangement->arrangementLabels()->count()); $existingSong->update(['ccli_id' => '999']); $this->assertSame(1, Song::count()); @@ -114,6 +125,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void $this->assertNotNull($normalArrangement); $this->assertTrue($normalArrangement->is_default); - $this->assertSame(5, $normalArrangement->arrangementGroups()->count()); + $this->assertSame(5, $normalArrangement->arrangementLabels()->count()); } } diff --git a/tests/Feature/SongControllerTest.php b/tests/Feature/SongControllerTest.php index c799884..0c29b96 100644 --- a/tests/Feature/SongControllerTest.php +++ b/tests/Feature/SongControllerTest.php @@ -1,25 +1,17 @@ user = User::factory()->create(); }); -// --- INDEX / LIST --- - test('songs index returns paginated list', function () { Song::factory()->count(3)->create(); @@ -75,8 +67,6 @@ $response->assertUnauthorized(); }); -// --- STORE / CREATE --- - test('store creates song with default groups and arrangement', function () { $response = $this->actingAs($this->user) ->postJson('/api/songs', [ @@ -91,16 +81,12 @@ $song = Song::where('title', 'Neues Lied')->first(); expect($song)->not->toBeNull(); - // Default groups: Strophe 1, Refrain, Bridge - expect($song->groups)->toHaveCount(3); - expect($song->groups->pluck('name')->toArray()) - ->toBe(['Strophe 1', 'Refrain', 'Bridge']); - - // Default "Normal" arrangement $arrangement = $song->arrangements()->where('is_default', true)->first(); expect($arrangement)->not->toBeNull(); expect($arrangement->name)->toBe('Normal'); - expect($arrangement->arrangementGroups)->toHaveCount(3); + expect($arrangement->arrangementLabels)->toHaveCount(3); + expect($arrangement->arrangementLabels->sortBy('order')->pluck('label.name')->toArray()) + ->toBe(['Strophe 1', 'Refrain', 'Bridge']); }); test('store validates required title', function () { @@ -136,12 +122,15 @@ expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull(); }); -// --- SHOW --- - test('show returns song with groups slides and arrangements', function () { $song = Song::factory()->create(); - $group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Strophe 1']); - SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]); + $label = Label::factory()->create(['name' => 'Strophe 1']); + $arrangement = SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]); + SongArrangementLabel::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'label_id' => $label->id, + 'order' => 1, + ]); $response = $this->actingAs($this->user) ->getJson("/api/songs/{$song->id}"); @@ -174,8 +163,6 @@ $response->assertNotFound(); }); -// --- UPDATE --- - test('update modifies song metadata', function () { $song = Song::factory()->create(['title' => 'Old Title']); @@ -197,7 +184,6 @@ $songA = Song::factory()->create(['ccli_id' => '111111']); $songB = Song::factory()->create(['ccli_id' => '222222']); - // Try setting songB's ccli_id to songA's $response = $this->actingAs($this->user) ->putJson("/api/songs/{$songB->id}", [ 'title' => $songB->title, @@ -220,8 +206,6 @@ $response->assertOk(); }); -// --- DESTROY / SOFT DELETE --- - test('destroy soft-deletes a song', function () { $song = Song::factory()->create(); @@ -242,8 +226,6 @@ $response->assertNotFound(); }); -// --- LAST USED IN SERVICE --- - test('last_used_in_service returns correct date from service_songs', function () { $song = Song::factory()->create(); $serviceOld = Service::factory()->create(['date' => '2025-06-01']); @@ -275,26 +257,24 @@ expect($response->json('data.last_used_in_service'))->toBeNull(); }); -// --- SONG SERVICE: DUPLICATE ARRANGEMENT --- - test('duplicate arrangement clones arrangement with groups', function () { $song = Song::factory()->create(); - $group1 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 1]); - $group2 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 2]); + $label1 = Label::factory()->create(); + $label2 = Label::factory()->create(); $arrangement = SongArrangement::factory()->create([ 'song_id' => $song->id, 'name' => 'Original', 'is_default' => true, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group1->id, + 'label_id' => $label1->id, 'order' => 1, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group2->id, + 'label_id' => $label2->id, 'order' => 2, ]); @@ -303,7 +283,7 @@ expect($clone->name)->toBe('Klone'); expect($clone->is_default)->toBeFalse(); - expect($clone->arrangementGroups)->toHaveCount(2); - expect($clone->arrangementGroups->pluck('song_group_id')->toArray()) - ->toBe($arrangement->arrangementGroups->pluck('song_group_id')->toArray()); + expect($clone->arrangementLabels)->toHaveCount(2); + expect($clone->arrangementLabels->pluck('label_id')->toArray()) + ->toBe($arrangement->arrangementLabels->pluck('label_id')->toArray()); }); diff --git a/tests/Feature/SongEditModalTest.php b/tests/Feature/SongEditModalTest.php index 89e00ce..e31e35b 100644 --- a/tests/Feature/SongEditModalTest.php +++ b/tests/Feature/SongEditModalTest.php @@ -1,28 +1,15 @@ user = User::factory()->create(); }); -// --- Modal Data Loading --- - test('show returns song with full detail for modal', function () { $song = Song::factory()->create([ 'title' => 'Großer Gott wir loben Dich', @@ -30,18 +17,14 @@ 'copyright_text' => '© Public Domain', ]); - $group1 = SongGroup::factory()->create([ - 'song_id' => $song->id, + $label1 = Label::factory()->create([ 'name' => 'Strophe 1', 'color' => '#3B82F6', - 'order' => 1, ]); - $group2 = SongGroup::factory()->create([ - 'song_id' => $song->id, + $label2 = Label::factory()->create([ 'name' => 'Refrain', 'color' => '#10B981', - 'order' => 2, ]); $arrangement = SongArrangement::factory()->create([ @@ -50,15 +33,15 @@ 'is_default' => true, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group1->id, + 'label_id' => $label1->id, 'order' => 1, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group2->id, + 'label_id' => $label2->id, 'order' => 2, ]); @@ -77,17 +60,13 @@ ], ]); - // Groups in correct order expect($response->json('data.groups.0.name'))->toBe('Strophe 1'); expect($response->json('data.groups.1.name'))->toBe('Refrain'); - // Arrangement with arrangement_groups expect($response->json('data.arrangements.0.name'))->toBe('Normal'); expect($response->json('data.arrangements.0.arrangement_groups'))->toHaveCount(2); }); -// --- Metadata Auto-Save --- - test('update saves title via auto-save', function () { $song = Song::factory()->create(['title' => 'Original Title']); @@ -155,7 +134,6 @@ test('update returns full song detail with arrangements', function () { $song = Song::factory()->create(); - SongGroup::factory()->create(['song_id' => $song->id]); SongArrangement::factory()->create(['song_id' => $song->id, 'is_default' => true]); $response = $this->actingAs($this->user) diff --git a/tests/Feature/SongPdfTest.php b/tests/Feature/SongPdfTest.php index 798d05f..60d7ce6 100644 --- a/tests/Feature/SongPdfTest.php +++ b/tests/Feature/SongPdfTest.php @@ -1,18 +1,12 @@ user = User::factory()->create(); }); @@ -24,22 +18,20 @@ 'name' => 'Normal', ]); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, + $label = Label::factory()->create([ 'name' => 'Verse 1', 'color' => '#3B82F6', - 'order' => 1, ]); SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Amazing grace how sweet the sound', ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, ]); @@ -73,42 +65,37 @@ 'name' => 'Normal', ]); - $verse = SongGroup::factory()->create([ - 'song_id' => $song->id, + $verse = Label::factory()->create([ 'name' => 'Strophe 1', 'color' => '#3B82F6', - 'order' => 1, ]); - $chorus = SongGroup::factory()->create([ - 'song_id' => $song->id, + $chorus = Label::factory()->create([ 'name' => 'Refrain', 'color' => '#10B981', - 'order' => 2, ]); SongSlide::factory()->create([ - 'song_group_id' => $verse->id, + 'label_id' => $verse->id, 'order' => 1, 'text_content' => 'Großer Gott wir loben dich', ]); SongSlide::factory()->create([ - 'song_group_id' => $chorus->id, + 'label_id' => $chorus->id, 'order' => 1, 'text_content' => 'Heilig heilig heilig', ]); - // Arrangement: Strophe 1 -> Refrain - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $verse->id, + 'label_id' => $verse->id, 'order' => 1, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $chorus->id, + 'label_id' => $chorus->id, 'order' => 2, ]); @@ -130,22 +117,20 @@ 'name' => 'Normal', ]); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, + $label = Label::factory()->create([ 'name' => 'Verse 1', - 'order' => 1, ]); SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Amazing grace how sweet the sound', 'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang', ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, ]); @@ -210,21 +195,19 @@ 'name' => 'Übung', ]); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, + $label = Label::factory()->create([ 'name' => 'Strophe 1', - 'order' => 1, ]); SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Großer Gott wir loben dich', ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, ]); @@ -234,7 +217,6 @@ $response->assertOk(); $response->assertHeader('Content-Type', 'application/pdf'); - // Filename should contain slug with umlauts handled $contentDisposition = $response->headers->get('Content-Disposition'); expect($contentDisposition)->toContain('.pdf'); }); @@ -260,28 +242,24 @@ 'ccli_id' => '123456', ]); - $verse = SongGroup::factory()->create([ - 'song_id' => $song->id, + $verse = Label::factory()->create([ 'name' => 'Strophe 1', 'color' => '#3b82f6', - 'order' => 1, ]); - $chorus = SongGroup::factory()->create([ - 'song_id' => $song->id, + $chorus = Label::factory()->create([ 'name' => 'Refrain', 'color' => '#ef4444', - 'order' => 2, ]); SongSlide::factory()->create([ - 'song_group_id' => $verse->id, + 'label_id' => $verse->id, 'order' => 1, 'text_content' => 'Strophe Text', ]); SongSlide::factory()->create([ - 'song_group_id' => $chorus->id, + 'label_id' => $chorus->id, 'order' => 1, 'text_content' => 'Refrain Text', ]); @@ -291,16 +269,15 @@ 'name' => 'Normal', ]); - // Order: Chorus first, then Verse - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $chorus->id, + 'label_id' => $chorus->id, 'order' => 1, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $verse->id, + 'label_id' => $verse->id, 'order' => 2, ]); @@ -321,7 +298,6 @@ ], ]); - // Chorus should be first (order=1), Verse second (order=2) $data = $response->json(); expect($data['groups'][0]['name'])->toBe('Refrain'); expect($data['groups'][1]['name'])->toBe('Strophe 1'); @@ -331,14 +307,12 @@ test('song preview includes translation text when slides have translations', function () { $song = Song::factory()->create(['title' => 'Lied mit Übersetzung']); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, + $label = Label::factory()->create([ 'name' => 'Verse', - 'order' => 1, ]); SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Original Text', 'text_content_translated' => 'Translated Text', @@ -348,9 +322,9 @@ 'song_id' => $song->id, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, ]); diff --git a/tests/Feature/SongsBlockTest.php b/tests/Feature/SongsBlockTest.php index 3ee98e2..08c083e 100644 --- a/tests/Feature/SongsBlockTest.php +++ b/tests/Feature/SongsBlockTest.php @@ -2,12 +2,12 @@ namespace Tests\Feature; +use App\Models\Label; use App\Models\Service; use App\Models\ServiceSong; use App\Models\Song; use App\Models\SongArrangement; -use App\Models\SongArrangementGroup; -use App\Models\SongGroup; +use App\Models\SongArrangementLabel; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Auth; @@ -79,18 +79,14 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf 'has_translation' => true, ]); - $verse = SongGroup::factory()->create([ - 'song_id' => $song->id, + $verse = Label::factory()->create([ 'name' => 'Strophe 1', 'color' => '#3B82F6', - 'order' => 1, ]); - $chorus = SongGroup::factory()->create([ - 'song_id' => $song->id, + $chorus = Label::factory()->create([ 'name' => 'Refrain', 'color' => '#10B981', - 'order' => 2, ]); $normal = SongArrangement::factory()->create([ @@ -99,15 +95,15 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf 'is_default' => true, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $normal->id, - 'song_group_id' => $verse->id, + 'label_id' => $verse->id, 'order' => 1, ]); - SongArrangementGroup::factory()->create([ + SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $normal->id, - 'song_group_id' => $chorus->id, + 'label_id' => $chorus->id, 'order' => 2, ]); diff --git a/tests/Feature/TranslatePageTest.php b/tests/Feature/TranslatePageTest.php index 2597b40..2c9ab20 100644 --- a/tests/Feature/TranslatePageTest.php +++ b/tests/Feature/TranslatePageTest.php @@ -3,8 +3,10 @@ namespace Tests\Feature; use App\Http\Controllers\TranslationController; +use App\Models\Label; use App\Models\Song; -use App\Models\SongGroup; +use App\Models\SongArrangement; +use App\Models\SongArrangementLabel; use App\Models\SongSlide; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\JsonResponse; @@ -21,36 +23,50 @@ public function test_translate_page_response_contains_ordered_groups_and_slides( 'title' => 'Grosser Gott', ]); - $groupLater = SongGroup::factory()->create([ + $arrangement = SongArrangement::factory()->create([ 'song_id' => $song->id, - 'name' => 'Refrain', - 'color' => '#22c55e', - 'order' => 2, + 'name' => 'Normal', + 'is_default' => true, ]); - $groupFirst = SongGroup::factory()->create([ - 'song_id' => $song->id, + $labelLater = Label::factory()->create([ + 'name' => 'Refrain', + 'color' => '#22c55e', + ]); + + $labelFirst = Label::factory()->create([ 'name' => 'Strophe 1', 'color' => '#0ea5e9', + ]); + + SongArrangementLabel::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'label_id' => $labelFirst->id, 'order' => 1, ]); + SongArrangementLabel::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'label_id' => $labelLater->id, + 'order' => 2, + ]); + SongSlide::factory()->create([ - 'song_group_id' => $groupFirst->id, + 'label_id' => $labelFirst->id, 'order' => 2, 'text_content' => "Zeile A\nZeile B", 'text_content_translated' => "Line A\nLine B", ]); SongSlide::factory()->create([ - 'song_group_id' => $groupFirst->id, + 'label_id' => $labelFirst->id, 'order' => 1, 'text_content' => "Zeile C\nZeile D\nZeile E", 'text_content_translated' => null, ]); SongSlide::factory()->create([ - 'song_group_id' => $groupLater->id, + 'label_id' => $labelLater->id, 'order' => 1, 'text_content' => 'Refrain', 'text_content_translated' => 'Chorus', diff --git a/tests/Feature/TranslationServiceTest.php b/tests/Feature/TranslationServiceTest.php index 66ff353..1bcbfbd 100644 --- a/tests/Feature/TranslationServiceTest.php +++ b/tests/Feature/TranslationServiceTest.php @@ -1,25 +1,19 @@ user = User::factory()->create(); $this->service = app(TranslationService::class); }); -// --- URL FETCH --- - test('fetchFromUrl returns text from successful HTTP response', function () { Http::fake([ 'https://example.com/lyrics' => Http::response('

Zeile 1

Zeile 2

', 200), @@ -30,7 +24,6 @@ expect($result)->not->toBeNull(); expect($result)->toContain('Zeile 1'); expect($result)->toContain('Zeile 2'); - // HTML tags should be stripped expect($result)->not->toContain('

'); expect($result)->not->toContain(''); }); @@ -65,34 +58,56 @@ expect($result)->toBeNull(); }); -// --- IMPORT TRANSLATION (LINE-COUNT DISTRIBUTION) --- - -test('importTranslation distributes lines by slide line counts', function () { +function makeSongWithDefaultArrangement(): array +{ $song = Song::factory()->create(['has_translation' => false]); - - $group = SongGroup::factory()->create([ + $arrangement = SongArrangement::factory()->create([ 'song_id' => $song->id, - 'name' => 'Strophe 1', - 'order' => 1, + 'name' => 'Normal', + 'is_default' => true, ]); - // Slide 1: 4 lines + return [$song, $arrangement]; +} + +function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): Label +{ + $label = Label::firstOrCreate(['name' => $labelName]); + SongArrangementLabel::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'label_id' => $label->id, + 'order' => $arrangementOrder, + ]); + + foreach ($slides as $slide) { + SongSlide::factory()->create(array_merge( + ['label_id' => $label->id], + $slide, + )); + } + + return $label; +} + +test('importTranslation distributes lines by slide line counts', function () { + [$song, $arrangement] = makeSongWithDefaultArrangement(); + + $label = attachLabelWithSlides($arrangement, 'Strophe 1 - dist', [], 1); + $slide1 = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4", ]); - // Slide 2: 2 lines $slide2 = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 2, 'text_content' => "Original 5\nOriginal 6", ]); - // Slide 3: 4 lines $slide3 = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 3, 'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10", ]); @@ -105,37 +120,25 @@ $slide2->refresh(); $slide3->refresh(); - // Slide 1 gets lines 1-4 expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 4"); - // Slide 2 gets lines 5-6 expect($slide2->text_content_translated)->toBe("Zeile 5\nZeile 6"); - // Slide 3 gets lines 7-10 expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10"); }); test('importTranslation distributes across multiple groups', function () { - $song = Song::factory()->create(['has_translation' => false]); + [$song, $arrangement] = makeSongWithDefaultArrangement(); - $group1 = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'name' => 'Strophe 1', - 'order' => 1, - ]); - - $group2 = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'name' => 'Refrain', - 'order' => 2, - ]); + $label1 = attachLabelWithSlides($arrangement, 'Strophe 1 - multi', [], 1); + $label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2); $slide1 = SongSlide::factory()->create([ - 'song_group_id' => $group1->id, + 'label_id' => $label1->id, 'order' => 1, 'text_content' => "Line A\nLine B", ]); $slide2 = SongSlide::factory()->create([ - 'song_group_id' => $group2->id, + 'label_id' => $label2->id, 'order' => 1, 'text_content' => "Line C\nLine D\nLine E", ]); @@ -152,26 +155,22 @@ }); test('importTranslation handles fewer translation lines than original', function () { - $song = Song::factory()->create(['has_translation' => false]); + [$song, $arrangement] = makeSongWithDefaultArrangement(); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'order' => 1, - ]); + $label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1); $slide1 = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => "Line 1\nLine 2\nLine 3", ]); $slide2 = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 2, 'text_content' => "Line 4\nLine 5", ]); - // Only 3 lines for 5 lines total $translatedText = "Zeile 1\nZeile 2\nZeile 3"; $this->service->importTranslation($song, $translatedText); @@ -179,22 +178,16 @@ $slide1->refresh(); $slide2->refresh(); - // Slide 1 gets all 3 available lines expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3"); - // Slide 2 gets empty (no lines left) expect($slide2->text_content_translated)->toBe(''); }); test('importTranslation marks song as translated', function () { - $song = Song::factory()->create(['has_translation' => false]); - - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'order' => 1, - ]); + [$song, $arrangement] = makeSongWithDefaultArrangement(); + $label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1); SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Line 1', ]); @@ -205,8 +198,6 @@ expect($song->has_translation)->toBeTrue(); }); -// --- MARK AS TRANSLATED --- - test('markAsTranslated sets has_translation to true', function () { $song = Song::factory()->create(['has_translation' => false]); @@ -216,25 +207,21 @@ expect($song->has_translation)->toBeTrue(); }); -// --- REMOVE TRANSLATION --- - test('removeTranslation clears all translated text and sets flag to false', function () { - $song = Song::factory()->create(['has_translation' => true]); + [$song, $arrangement] = makeSongWithDefaultArrangement(); + $song->update(['has_translation' => true]); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'order' => 1, - ]); + $label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1); $slide1 = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Original', 'text_content_translated' => 'Übersetzt', ]); $slide2 = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 2, 'text_content' => 'Original 2', 'text_content_translated' => 'Übersetzt 2', @@ -251,8 +238,6 @@ expect($slide2->text_content_translated)->toBeNull(); }); -// --- CONTROLLER ENDPOINTS --- - test('POST translation/fetch-url returns scraped text', function () { Http::fake([ 'https://lyrics.example.com/song' => Http::response('

Liedtext Zeile 1
', 200), @@ -292,15 +277,12 @@ }); test('POST songs/{song}/translation/import distributes and saves translation', function () { - $song = Song::factory()->create(['has_translation' => false]); + [$song, $arrangement] = makeSongWithDefaultArrangement(); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'order' => 1, - ]); + $label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1); $slide = SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => "Line 1\nLine 2", ]); @@ -340,15 +322,13 @@ }); test('DELETE songs/{song}/translation removes translation', function () { - $song = Song::factory()->create(['has_translation' => true]); + [$song, $arrangement] = makeSongWithDefaultArrangement(); + $song->update(['has_translation' => true]); - $group = SongGroup::factory()->create([ - 'song_id' => $song->id, - 'order' => 1, - ]); + $label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1); SongSlide::factory()->create([ - 'song_group_id' => $group->id, + 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Original', 'text_content_translated' => 'Übersetzt', diff --git a/tests/fixtures/generate-labels-sample.php b/tests/fixtures/generate-labels-sample.php index 544981a..6421556 100644 --- a/tests/fixtures/generate-labels-sample.php +++ b/tests/fixtures/generate-labels-sample.php @@ -8,7 +8,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color { - $color = new Color(); + $color = new Color; $color->setRed($r); $color->setGreen($g); $color->setBlue($b); @@ -17,7 +17,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color return $color; } -$doc = new ProLabelsDocument(); +$doc = new ProLabelsDocument; $labels = [ ['name' => 'Copyright', 'r' => 0.8, 'g' => 0.2, 'b' => 0.2], @@ -28,7 +28,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color $labelObjects = []; foreach ($labels as $data) { - $label = new Label(); + $label = new Label; $label->setText($data['name']); $label->setColor(makeColor($data['r'], $data['g'], $data['b'])); $labelObjects[] = $label; diff --git a/tests/fixtures/generate-macros-sample.php b/tests/fixtures/generate-macros-sample.php index 432404b..7ff9b69 100644 --- a/tests/fixtures/generate-macros-sample.php +++ b/tests/fixtures/generate-macros-sample.php @@ -11,7 +11,7 @@ function makeUuid(string $value): UUID { - $uuid = new UUID(); + $uuid = new UUID; $uuid->setString(strtoupper($value)); return $uuid; @@ -19,7 +19,7 @@ function makeUuid(string $value): UUID function makeColor(float $r, float $g, float $b, float $a = 1.0): Color { - $color = new Color(); + $color = new Color; $color->setRed($r); $color->setGreen($g); $color->setBlue($b); @@ -28,7 +28,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color return $color; } -$doc = new MacrosDocument(); +$doc = new MacrosDocument; $macros = [ ['uuid' => 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Copyright Makro', 'r' => 1.0, 'g' => 0.5, 'b' => 0.0], @@ -38,7 +38,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color $macroObjects = []; foreach ($macros as $data) { - $macro = new Macro(); + $macro = new Macro; $macro->setUuid(makeUuid($data['uuid'])); $macro->setName($data['name']); $macro->setColor(makeColor($data['r'], $data['g'], $data['b'])); @@ -48,13 +48,13 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color } $doc->setMacros($macroObjects); -$collection = new MacroCollection(); +$collection = new MacroCollection; $collection->setUuid(makeUuid('8D02FC57-83F8-4042-9B90-81C229728426')); $collection->setName('--MAIN--'); $items = []; foreach ($macros as $data) { - $item = new Item(); + $item = new Item; $item->setMacroId(makeUuid($data['uuid'])); $items[] = $item; }