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
This commit is contained in:
Thorsten Bus 2026-05-03 22:55:02 +02:00
parent a1612dc3ef
commit bdbf0c65e3
26 changed files with 573 additions and 573 deletions

View file

@ -2,12 +2,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArrangementController extends Controller class ArrangementController extends Controller
{ {
@ -23,17 +23,24 @@ public function store(Request $request, Song $song): RedirectResponse
'is_default' => false, 'is_default' => false,
]); ]);
$groups = $song->groups()->orderBy('order')->get(); $defaultArr = $song->arrangements()->where('is_default', true)->first();
$rows = $groups->map(fn ($group, $index) => [
if ($defaultArr === null) {
return;
}
$arrangementLabels = $defaultArr->arrangementLabels()->orderBy('order')->get();
$rows = $arrangementLabels->values()->map(fn ($al, $index) => [
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id, 'label_id' => $al->label_id,
'order' => $index + 1, 'order' => $index + 1,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
])->all(); ])->all();
if ($rows !== []) { 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 { DB::transaction(function () use ($arrangement, $data): void {
$arrangement->loadMissing('arrangementGroups'); $arrangement->loadMissing('arrangementLabels');
$clone = $arrangement->song->arrangements()->create([ $clone = $arrangement->song->arrangements()->create([
'name' => $data['name'], 'name' => $data['name'],
'is_default' => false, 'is_default' => false,
]); ]);
$this->cloneGroups($arrangement, $clone); $this->cloneArrangementLabels($arrangement, $clone);
}); });
return back()->with('success', 'Arrangement wurde geklont.'); return back()->with('success', 'Arrangement wurde geklont.');
@ -64,33 +71,22 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
{ {
$data = $request->validate([ $data = $request->validate([
'groups' => ['array'], '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'], 'groups.*.order' => ['required', 'integer', 'min:1'],
'group_colors' => ['sometimes', 'array'], 'group_colors' => ['sometimes', 'array'],
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], 'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]); ]);
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values(); $labelIds = collect($data['groups'] ?? [])->pluck('label_id')->values();
$uniqueGroupIds = $groupIds->unique()->values();
$validGroupIds = $arrangement->song->groups() DB::transaction(function () use ($arrangement, $labelIds, $data): void {
->whereIn('id', $uniqueGroupIds) $arrangement->arrangementLabels()->delete();
->pluck('id');
if ($uniqueGroupIds->count() !== $validGroupIds->count()) { $rows = $labelIds
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
->values() ->values()
->map(fn (int $songGroupId, int $index) => [ ->map(fn (int $labelId, int $index) => [
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroupId, 'label_id' => $labelId,
'order' => $index + 1, 'order' => $index + 1,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
@ -98,14 +94,12 @@ public function update(Request $request, SongArrangement $arrangement): Redirect
->all(); ->all();
if ($rows !== []) { if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows); $arrangement->arrangementLabels()->insert($rows);
} }
if (! empty($data['group_colors'])) { if (! empty($data['group_colors'])) {
foreach ($data['group_colors'] as $groupId => $color) { foreach ($data['group_colors'] as $labelId => $color) {
$arrangement->song->groups() Label::whereKey((int) $labelId)->update(['color' => $color]);
->whereKey((int) $groupId)
->update(['color' => $color]);
} }
} }
}); });
@ -136,28 +130,28 @@ public function destroy(SongArrangement $arrangement): RedirectResponse
return back()->with('success', 'Arrangement wurde gelöscht.'); 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) { if ($source === null) {
return; return;
} }
$groups = $source->arrangementGroups $arrangementLabels = $source->arrangementLabels
->sortBy('order') ->sortBy('order')
->values(); ->values();
$rows = $groups $rows = $arrangementLabels
->map(fn ($arrangementGroup) => [ ->map(fn ($arrangementLabel) => [
'song_arrangement_id' => $target->id, 'song_arrangement_id' => $target->id,
'song_group_id' => $arrangementGroup->song_group_id, 'label_id' => $arrangementLabel->label_id,
'order' => $arrangementGroup->order, 'order' => $arrangementLabel->order,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]) ])
->all(); ->all();
if ($rows !== []) { if ($rows !== []) {
$target->arrangementGroups()->insert($rows); $target->arrangementLabels()->insert($rows);
} }
} }
} }

View file

@ -49,7 +49,7 @@ public function importPro(Request $request): JsonResponse
public function downloadPro(Song $song): BinaryFileResponse 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.'); 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); return response()->download($tempPath, $filename)->deleteFileAfterSend(true);
} }
private function countSongLabels(Song $song): int
{
return $song->arrangements()
->withCount('arrangementLabels')
->get()
->sum('arrangement_labels_count');
}
} }

View file

@ -129,15 +129,13 @@ public function edit(Service $service): Response
$service->load([ $service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song', 'serviceSongs.song',
'serviceSongs.song.groups', 'serviceSongs.song.arrangements.arrangementLabels.label',
'serviceSongs.song.arrangements.arrangementGroups.group',
'serviceSongs.arrangement', 'serviceSongs.arrangement',
'slides', 'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'), 'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides', 'agendaItems.slides',
'agendaItems.serviceSong.song.groups.slides', 'agendaItems.serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group', 'agendaItems.serviceSong.arrangement.arrangementLabels.label',
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
]); ]);
$songsCatalog = Song::query() $songsCatalog = Song::query()
@ -253,15 +251,7 @@ public function edit(Service $service): Response
'title' => $ss->song->title, 'title' => $ss->song->title,
'ccli_id' => $ss->song->ccli_id, 'ccli_id' => $ss->song->ccli_id,
'has_translation' => $ss->song->has_translation, 'has_translation' => $ss->song->has_translation,
'groups' => $ss->song->groups 'groups' => $this->collectSongLabels($ss->song),
->sortBy('order')
->values()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
]),
'arrangements' => $ss->song->arrangements 'arrangements' => $ss->song->arrangements
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1) ->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
->values() ->values()
@ -269,13 +259,13 @@ public function edit(Service $service): Response
'id' => $arrangement->id, 'id' => $arrangement->id,
'name' => $arrangement->name, 'name' => $arrangement->name,
'is_default' => $arrangement->is_default, 'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementGroups 'groups' => $arrangement->arrangementLabels
->sortBy('order') ->sortBy('order')
->values() ->values()
->map(fn ($arrangementGroup) => [ ->map(fn ($arrangementLabel) => [
'id' => $arrangementGroup->group?->id, 'id' => $arrangementLabel->label?->id,
'name' => $arrangementGroup->group?->name, 'name' => $arrangementLabel->label?->name,
'color' => $arrangementGroup->group?->color, 'color' => $arrangementLabel->label?->color,
]) ])
->filter(fn ($group) => $group['id'] !== null) ->filter(fn ($group) => $group['id'] !== null)
->values(), ->values(),
@ -412,4 +402,25 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
) )
->deleteFileAfterSend(true); ->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();
}
} }

View file

@ -15,9 +15,6 @@ public function __construct(
private readonly SongService $songService, private readonly SongService $songService,
) {} ) {}
/**
* Alle Songs auflisten (paginiert, durchsuchbar).
*/
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Song::query(); $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 public function store(SongRequest $request): JsonResponse
{ {
$song = DB::transaction(function () use ($request) { $song = DB::transaction(function () use ($request) {
$song = Song::create($request->validated()); $song = Song::create($request->validated());
$this->songService->createDefaultGroups($song);
$this->songService->createDefaultArrangement($song); $this->songService->createDefaultArrangement($song);
return $song; return $song;
@ -69,16 +62,13 @@ public function store(SongRequest $request): JsonResponse
return response()->json([ return response()->json([
'message' => 'Song erfolgreich erstellt', 'message' => 'Song erfolgreich erstellt',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])), 'data' => $this->formatSongDetail($song->fresh(['arrangements.arrangementLabels.label.songSlides'])),
], 201); ], 201);
} }
/**
* Song mit Gruppen, Slides und Arrangements anzeigen.
*/
public function show(int $id): JsonResponse 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) { if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404); 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 public function update(SongRequest $request, int $id): JsonResponse
{ {
$song = Song::find($id); $song = Song::find($id);
@ -104,13 +91,10 @@ public function update(SongRequest $request, int $id): JsonResponse
return response()->json([ return response()->json([
'message' => 'Song erfolgreich aktualisiert', '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 public function destroy(int $id): JsonResponse
{ {
$song = Song::find($id); $song = Song::find($id);
@ -126,11 +110,35 @@ public function destroy(int $id): JsonResponse
]); ]);
} }
/**
* Song-Detail formatieren.
*/
private function formatSongDetail(Song $song): array 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 [ return [
'id' => $song->id, 'id' => $song->id,
'title' => $song->title, 'title' => $song->title,
@ -144,27 +152,15 @@ private function formatSongDetail(Song $song): array
'last_used_in_service' => $song->last_used_in_service, 'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(), 'created_at' => $song->created_at->toDateTimeString(),
'updated_at' => $song->updated_at->toDateTimeString(), 'updated_at' => $song->updated_at->toDateTimeString(),
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [ 'groups' => $groupsPayload,
'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(),
'arrangements' => $song->arrangements->map(fn ($arr) => [ 'arrangements' => $song->arrangements->map(fn ($arr) => [
'id' => $arr->id, 'id' => $arr->id,
'name' => $arr->name, 'name' => $arr->name,
'is_default' => $arr->is_default, 'is_default' => $arr->is_default,
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [ 'arrangement_groups' => $arr->arrangementLabels->sortBy('order')->values()->map(fn ($al) => [
'id' => $ag->id, 'id' => $al->id,
'song_group_id' => $ag->song_group_id, 'label_id' => $al->label_id,
'order' => $ag->order, 'order' => $al->order,
])->toArray(), ])->toArray(),
])->toArray(), ])->toArray(),
]; ];

View file

@ -57,21 +57,25 @@ public function download(Song $song, SongArrangement $arrangement): Response|Jso
private function buildGroupsInOrder(SongArrangement $arrangement): array private function buildGroupsInOrder(SongArrangement $arrangement): array
{ {
$arrangement->load([ $arrangement->load([
'arrangementGroups' => fn ($query) => $query->orderBy('order'), 'arrangementLabels' => fn ($query) => $query->orderBy('order'),
'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'), 'arrangementLabels.label.songSlides' => fn ($query) => $query->orderBy('order'),
]); ]);
return $arrangement->arrangementGroups->map(function ($arrangementGroup) { return $arrangement->arrangementLabels->map(function ($arrangementLabel) {
$group = $arrangementGroup->group; $label = $arrangementLabel->label;
if ($label === null) {
return null;
}
return [ return [
'name' => $group->name, 'name' => $label->name,
'color' => $group->color ?? '#6b7280', 'color' => $label->color ?? '#6b7280',
'slides' => $group->slides->map(fn ($slide) => [ 'slides' => $label->songSlides->map(fn ($slide) => [
'text_content' => $slide->text_content, 'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated, 'text_content_translated' => $slide->text_content_translated,
])->values()->all(), ])->values()->all(),
]; ];
})->values()->all(); })->filter()->values()->all();
} }
} }

View file

@ -18,41 +18,48 @@ public function __construct(
public function page(Song $song): Response public function page(Song $song): Response
{ {
$song->load([ $song->load([
'groups' => fn ($query) => $query 'arrangements' => fn ($q) => $q->where('is_default', true),
->orderBy('order') 'arrangements.arrangementLabels' => fn ($q) => $q->orderBy('order'),
->with([ 'arrangements.arrangementLabels.label.songSlides',
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
]),
]); ]);
$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', [ return Inertia::render('Songs/Translate', [
'song' => [ 'song' => [
'id' => $song->id, 'id' => $song->id,
'title' => $song->title, 'title' => $song->title,
'ccli_id' => $song->ccli_id, 'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation, 'has_translation' => $song->has_translation,
'groups' => $song->groups->map(fn ($group) => [ 'groups' => $groups,
'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(),
], ],
]); ]);
} }
/**
* 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 public function fetchUrl(Request $request): JsonResponse
{ {
$request->validate([ $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 public function import(int $songId, Request $request): JsonResponse
{ {
$song = Song::find($songId); $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 public function destroy(int $songId): JsonResponse
{ {
$song = Song::find($songId); $song = Song::find($songId);

View file

@ -28,8 +28,7 @@ public function __construct(
private readonly ?Closure $songFetcher = null, private readonly ?Closure $songFetcher = null,
private readonly ?Closure $agendaFetcher = null, private readonly ?Closure $agendaFetcher = null,
private readonly ?Closure $eventServiceFetcher = null, private readonly ?Closure $eventServiceFetcher = null,
) { ) {}
}
public function sync(): array public function sync(): array
{ {

View file

@ -19,7 +19,11 @@ public function generatePlaylist(Service $service): array
$agendaItems = ServiceAgendaItem::where('service_id', $service->id) $agendaItems = ServiceAgendaItem::where('service_id', $service->id)
->where('is_before_event', false) ->where('is_before_event', false)
->orderBy('sort_order') ->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(); ->get();
if ($agendaItems->isEmpty()) { if ($agendaItems->isEmpty()) {
@ -80,7 +84,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
if ($serviceSong->song_id && $serviceSong->song) { if ($serviceSong->song_id && $serviceSong->song) {
$song = $serviceSong->song; $song = $serviceSong->song;
if ($song->groups()->count() === 0) { if ($this->countSongLabels($song) === 0) {
$skippedUnmatched++; $skippedUnmatched++;
continue; continue;
@ -161,12 +165,12 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
*/ */
private function generatePlaylistLegacy(Service $service): array private function generatePlaylistLegacy(Service $service): array
{ {
$service->loadMissing('serviceSongs.song.groups.slides'); $service->loadMissing('serviceSongs.song.arrangements.arrangementLabels.label.songSlides');
$matchedSongs = $service->serviceSongs() $matchedSongs = $service->serviceSongs()
->whereNotNull('song_id') ->whereNotNull('song_id')
->orderBy('order') ->orderBy('order')
->with('song.groups.slides') ->with('song.arrangements.arrangementLabels.label.songSlides')
->get(); ->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
@ -191,7 +195,7 @@ private function generatePlaylistLegacy(Service $service): array
foreach ($matchedSongs as $serviceSong) { foreach ($matchedSongs as $serviceSong) {
$song = $serviceSong->song; $song = $serviceSong->song;
if (! $song || $song->groups()->count() === 0) { if (! $song || $this->countSongLabels($song) === 0) {
$skippedEmpty++; $skippedEmpty++;
continue; continue;
@ -366,6 +370,14 @@ protected function writeProFile(string $path, string $name, array $groups, array
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); 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 protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
{ {
ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles); ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);

View file

@ -35,8 +35,7 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
{ {
$agendaItem->loadMissing([ $agendaItem->loadMissing([
'slides', 'slides',
'serviceSong.song.groups.slides', 'serviceSong.song.arrangements.arrangementLabels.label.songSlides',
'serviceSong.song.arrangements.arrangementGroups.group',
]); ]);
$title = $agendaItem->title ?: 'Ablauf-Element'; $title = $agendaItem->title ?: 'Ablauf-Element';
@ -44,7 +43,12 @@ public function generateAgendaItemBundle(ServiceAgendaItem $agendaItem): string
if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) { if ($agendaItem->serviceSong?->song_id && $agendaItem->serviceSong->song) {
$song = $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.'); throw new RuntimeException('Song "'.$song->title.'" hat keine Gruppen.');
} }

View file

@ -25,7 +25,7 @@ public function generateProFile(Song $song): string
public function generateParserSong(Song $song): \ProPresenter\Parser\Song public function generateParserSong(Song $song): \ProPresenter\Parser\Song
{ {
$song->loadMissing(['groups.slides', 'arrangements.arrangementGroups.group']); $song->loadMissing(['arrangements.arrangementLabels.label.songSlides']);
return ProFileGenerator::generate( return ProFileGenerator::generate(
$song->title, $song->title,
@ -37,14 +37,34 @@ public function generateParserSong(Song $song): \ProPresenter\Parser\Song
private function buildGroups(Song $song): array 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(); $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 = []; $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 ?? '']; $slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) { if ($slide->text_content_translated) {
@ -59,8 +79,8 @@ private function buildGroups(Song $song): array
} }
$groups[] = [ $groups[] = [
'name' => $group->name, 'name' => $label->name,
'color' => ProImportService::hexToRgba($group->color), 'color' => ProImportService::hexToRgba($label->color ?? '#808080'),
'slides' => $slides, 'slides' => $slides,
]; ];
} }
@ -88,12 +108,13 @@ private function buildMacroData(): ?array
private function buildArrangements(Song $song): array private function buildArrangements(Song $song): array
{ {
$arrangements = []; $arrangements = [];
$groupIdToName = $song->groups->pluck('name', 'id')->toArray();
foreach ($song->arrangements as $arrangement) { foreach ($song->arrangements as $arrangement) {
$groupNames = $arrangement->arrangementGroups $arrangement->loadMissing('arrangementLabels.label');
$groupNames = $arrangement->arrangementLabels
->sortBy('order') ->sortBy('order')
->map(fn ($ag) => $groupIdToName[$ag->song_group_id] ?? null) ->map(fn ($al) => $al->label?->name)
->filter() ->filter()
->values() ->values()
->toArray(); ->toArray();

View file

@ -2,10 +2,11 @@
namespace App\Services; namespace App\Services;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementGroup; use App\Models\SongArrangementLabel;
use App\Models\SongGroup; use App\Support\MacroColorConverter;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use ProPresenter\Parser\ProFileReader; use ProPresenter\Parser\ProFileReader;
@ -103,28 +104,30 @@ private function upsertSong(ProSong $proSong): Song
} }
$song->arrangements()->each(function (SongArrangement $arr) { $song->arrangements()->each(function (SongArrangement $arr) {
$arr->arrangementGroups()->delete(); $arr->arrangementLabels()->delete();
}); });
$song->arrangements()->delete(); $song->arrangements()->delete();
$song->groups()->each(function (SongGroup $group) {
$group->slides()->delete();
});
$song->groups()->delete();
$hasTranslation = false; $hasTranslation = false;
$groupMap = []; $labelsByName = [];
foreach ($proSong->getGroups() as $position => $proGroup) { foreach ($proSong->getGroups() as $proGroup) {
$color = $proGroup->getColor(); $groupName = $proGroup->getName();
$hexColor = $color ? self::rgbaToHex($color) : '#808080'; $existingLabel = Label::whereRaw('LOWER(name) = ?', [strtolower($groupName)])->first();
$songGroup = $song->groups()->create([ if ($existingLabel === null) {
'name' => $proGroup->getName(), $color = $proGroup->getColor();
'color' => $hexColor, $hexColor = MacroColorConverter::fromRgba($color) ?? '#808080';
'order' => $position,
]);
$groupMap[$proGroup->getName()] = $songGroup; $existingLabel = Label::create([
'name' => $groupName,
'color' => $hexColor,
]);
}
$labelsByName[$groupName] = $existingLabel;
$existingLabel->songSlides()->delete();
foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) { foreach ($proSong->getSlidesForGroup($proGroup) as $slidePosition => $proSlide) {
$translatedText = null; $translatedText = null;
@ -134,7 +137,7 @@ private function upsertSong(ProSong $proSong): Song
$hasTranslation = true; $hasTranslation = true;
} }
$songGroup->slides()->create([ $existingLabel->songSlides()->create([
'order' => $slidePosition, 'order' => $slidePosition,
'text_content' => $proSlide->getPlainText(), 'text_content' => $proSlide->getPlainText(),
'text_content_translated' => $translatedText, 'text_content_translated' => $translatedText,
@ -153,19 +156,19 @@ private function upsertSong(ProSong $proSong): Song
$groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement); $groupsInArrangement = $proSong->getGroupsForArrangement($proArrangement);
foreach ($groupsInArrangement as $order => $proGroup) { foreach ($groupsInArrangement as $order => $proGroup) {
$songGroup = $groupMap[$proGroup->getName()] ?? null; $label = $labelsByName[$proGroup->getName()] ?? null;
if ($songGroup) { if ($label) {
SongArrangementGroup::create([ SongArrangementLabel::create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroup->id, 'label_id' => $label->id,
'order' => $order, 'order' => $order,
]); ]);
} }
} }
} }
return $song->fresh(['groups.slides', 'arrangements.arrangementGroups']); return $song->fresh(['arrangements.arrangementLabels.label.songSlides']);
} }
public static function rgbaToHex(array $rgba): string public static function rgbaToHex(array $rgba): string

View file

@ -2,36 +2,48 @@
namespace App\Services; namespace App\Services;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementGroup; use App\Models\SongArrangementLabel;
use App\Models\SongGroup; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class SongService 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<int, SongGroup> * @return Collection<int, Label>
*/ */
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection public function createDefaultGroups(Song $song): Collection
{ {
$defaults = [ $defaults = [
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1], ['name' => 'Strophe 1', 'color' => '#3B82F6'],
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2], ['name' => 'Refrain', 'color' => '#10B981'],
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3], ['name' => 'Bridge', 'color' => '#F59E0B'],
]; ];
foreach ($defaults as $groupData) { $labels = collect();
$song->groups()->create($groupData);
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 public function createDefaultArrangement(Song $song): SongArrangement
{ {
@ -40,16 +52,16 @@ public function createDefaultArrangement(Song $song): SongArrangement
'is_default' => true, 'is_default' => true,
]); ]);
$groups = $song->groups()->orderBy('order')->get(); $labels = $this->createDefaultGroups($song);
foreach ($groups as $index => $group) { foreach ($labels->values() as $index => $label) {
$arrangement->arrangementGroups()->create([ $arrangement->arrangementLabels()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => $index + 1, '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->is_default = false;
$clone->save(); $clone->save();
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) { foreach ($arrangement->arrangementLabels()->orderBy('order')->get() as $arrangementLabel) {
SongArrangementGroup::create([ SongArrangementLabel::create([
'song_arrangement_id' => $clone->id, 'song_arrangement_id' => $clone->id,
'song_group_id' => $group->song_group_id, 'label_id' => $arrangementLabel->label_id,
'order' => $group->order, 'order' => $arrangementLabel->order,
]); ]);
} }
return $clone->load('arrangementGroups'); return $clone->load('arrangementLabels.label');
}); });
} }
} }

View file

@ -8,12 +8,6 @@
class TranslationService 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 public function fetchFromUrl(string $url): ?string
{ {
try { try {
@ -33,29 +27,30 @@ public function fetchFromUrl(string $url): ?string
return null; 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 public function importTranslation(Song $song, string $text): void
{ {
$translatedLines = explode("\n", $text); $translatedLines = explode("\n", $text);
$offset = 0; $offset = 0;
// Alle Gruppen nach order sortiert laden, mit Slides $defaultArr = $song->arrangements()
$groups = $song->groups()->orderBy('order')->with([ ->where('is_default', true)
'slides' => fn ($query) => $query->orderBy('order'), ->with(['arrangementLabels' => fn ($q) => $q->orderBy('order'), 'arrangementLabels.label.songSlides'])
])->get(); ->first();
foreach ($groups as $group) { if ($defaultArr === null) {
foreach ($group->slides as $slide) { $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 ?? '')); $originalLineCount = count(explode("\n", $slide->text_content ?? ''));
$chunk = array_slice($translatedLines, $offset, $originalLineCount); $chunk = array_slice($translatedLines, $offset, $originalLineCount);
$offset += $originalLineCount; $offset += $originalLineCount;
@ -69,30 +64,25 @@ public function importTranslation(Song $song, string $text): void
$this->markAsTranslated($song); $this->markAsTranslated($song);
} }
/**
* Song als "hat Übersetzung" markieren.
*/
public function markAsTranslated(Song $song): void public function markAsTranslated(Song $song): void
{ {
$song->update(['has_translation' => true]); $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 public function removeTranslation(Song $song): void
{ {
// Alle Slides des Songs über die Gruppen aktualisieren $labelIds = $song->arrangements()
$slideIds = SongSlide::whereIn( ->with('arrangementLabels')
'song_group_id', ->get()
$song->groups()->pluck('id') ->flatMap(fn ($arr) => $arr->arrangementLabels->pluck('label_id'))
)->pluck('id'); ->unique()
->values();
SongSlide::whereIn('id', $slideIds)->update([ if ($labelIds->isNotEmpty()) {
'text_content_translated' => null, SongSlide::whereIn('label_id', $labelIds)->update([
]); 'text_content_translated' => null,
]);
}
$song->update(['has_translation' => false]); $song->update(['has_translation' => false]);
} }

View file

@ -2,12 +2,14 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceAgendaItem; use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong; use App\Models\ServiceSong;
use App\Models\Slide; use App\Models\Slide;
use App\Models\Song; use App\Models\Song;
use App\Models\SongGroup; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide; use App\Models\SongSlide;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; 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(); $service = Service::factory()->create();
$song = Song::factory()->create(['title' => 'Amazing Grace']); $song = Song::factory()->create(['title' => 'Amazing Grace']);
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Verse 1', 'order' => 1]); $label = Label::factory()->create(['name' => 'Verse 1']);
SongSlide::factory()->create(['song_group_id' => $group->id, 'text_content' => 'Amazing grace how sweet the sound', 'order' => 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([ $serviceSong = ServiceSong::create([
'service_id' => $service->id, 'service_id' => $service->id,

View file

@ -2,10 +2,10 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementGroup; use App\Models\SongArrangementLabel;
use App\Models\SongGroup;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -34,19 +34,19 @@ public function test_create_arrangement_uses_default_song_group_ordering(): void
$this->assertNotNull($newArrangement); $this->assertNotNull($newArrangement);
$defaultGroupOrder = SongGroup::query() $defaultLabelOrder = SongArrangementLabel::query()
->where('song_id', $song->id) ->where('song_arrangement_id', $normal->id)
->orderBy('order') ->orderBy('order')
->pluck('id') ->pluck('label_id')
->all(); ->all();
$newGroups = SongArrangementGroup::query() $newLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $newArrangement->id) ->where('song_arrangement_id', $newArrangement->id)
->orderBy('order') ->orderBy('order')
->pluck('song_group_id') ->pluck('label_id')
->all(); ->all();
$this->assertSame($defaultGroupOrder, $newGroups); $this->assertSame($defaultLabelOrder, $newLabels);
} }
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void 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->assertNotNull($clone);
$this->assertFalse($clone->is_default); $this->assertFalse($clone->is_default);
$originalGroups = SongArrangementGroup::query() $originalLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id) ->where('song_arrangement_id', $normal->id)
->orderBy('order') ->orderBy('order')
->pluck('song_group_id') ->pluck('label_id')
->all(); ->all();
$cloneGroups = SongArrangementGroup::query() $cloneLabels = SongArrangementLabel::query()
->where('song_arrangement_id', $clone->id) ->where('song_arrangement_id', $clone->id)
->orderBy('order') ->orderBy('order')
->pluck('song_group_id') ->pluck('label_id')
->all(); ->all();
$this->assertSame($originalGroups, $cloneGroups); $this->assertSame($originalLabels, $cloneLabels);
} }
public function test_update_arrangement_reorders_and_persists_groups(): void 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), [ $response = $this->put(route('arrangements.update', $normal), [
'groups' => [ 'groups' => [
['song_group_id' => $chorus->id, 'order' => 1], ['label_id' => $chorus->id, 'order' => 1],
['song_group_id' => $bridge->id, 'order' => 2], ['label_id' => $bridge->id, 'order' => 2],
['song_group_id' => $verse->id, 'order' => 3], ['label_id' => $verse->id, 'order' => 3],
['song_group_id' => $chorus->id, 'order' => 4], ['label_id' => $chorus->id, 'order' => 4],
], ],
]); ]);
$response->assertRedirect(); $response->assertRedirect();
$updated = SongArrangementGroup::query() $updated = SongArrangementLabel::query()
->where('song_arrangement_id', $normal->id) ->where('song_arrangement_id', $normal->id)
->orderBy('order') ->orderBy('order')
->pluck('song_group_id') ->pluck('label_id')
->all(); ->all();
$this->assertSame([ $this->assertSame([
@ -136,23 +136,9 @@ private function createSongWithDefaultArrangement(): array
{ {
$song = Song::factory()->create(); $song = Song::factory()->create();
$verse = SongGroup::factory()->create([ $verse = Label::factory()->create(['name' => 'Verse 1']);
'song_id' => $song->id, $chorus = Label::factory()->create(['name' => 'Chorus']);
'name' => 'Verse 1', $bridge = Label::factory()->create(['name' => 'Bridge']);
'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,
]);
$normal = SongArrangement::factory()->create([ $normal = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
@ -160,21 +146,21 @@ private function createSongWithDefaultArrangement(): array
'is_default' => true, 'is_default' => true,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id, 'label_id' => $verse->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'song_group_id' => $chorus->id, 'label_id' => $chorus->id,
'order' => 2, 'order' => 2,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id, 'label_id' => $verse->id,
'order' => 3, 'order' => 3,
]); ]);

View file

@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceAgendaItem; use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong; use App\Models\ServiceSong;
@ -35,15 +36,21 @@ private function createSongWithContent(string $title = 'Test Song', ?string $ccl
'copyright_text' => 'Test Publisher', 'copyright_text' => 'Test Publisher',
]); ]);
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]); $verse = Label::firstOrCreate(
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']); ['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 = Label::firstOrCreate(
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']); ['name' => 'Chorus - '.$title],
['color' => '#F44336'],
);
$chorus->songSlides()->create(['order' => 0, 'text_content' => 'Chorus text']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]); $arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]); $arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]); $arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
return $song; return $song;
} }

View file

@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -22,17 +23,23 @@ private function createSongWithContent(): Song
'publisher' => 'Test Publisher', 'publisher' => 'Test Publisher',
]); ]);
$verse = $song->groups()->create(['name' => 'Verse 1', 'color' => '#2196F3', 'order' => 0]); $verse = Label::firstOrCreate(
$verse->slides()->create(['order' => 0, 'text_content' => 'First line of verse']); ['name' => 'Verse 1 - Export Test Song'],
$verse->slides()->create(['order' => 1, 'text_content' => 'Second line of verse']); ['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 = Label::firstOrCreate(
$chorus->slides()->create(['order' => 0, 'text_content' => 'Chorus text']); ['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 = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 0]); $arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 0]);
$arrangement->arrangementGroups()->create(['song_group_id' => $chorus->id, 'order' => 1]); $arrangement->arrangementLabels()->create(['label_id' => $chorus->id, 'order' => 1]);
$arrangement->arrangementGroups()->create(['song_group_id' => $verse->id, 'order' => 2]); $arrangement->arrangementLabels()->create(['label_id' => $verse->id, 'order' => 2]);
return $song; return $song;
} }
@ -82,7 +89,9 @@ public function test_download_pro_roundtrip_import_export(): void
$song = Song::find($songId); $song = Song::find($songId);
$this->assertNotNull($song); $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 = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk(); $exportResponse->assertOk();
@ -92,7 +101,6 @@ public function test_download_pro_roundtrip_preserves_content(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
// 1. Import the reference .pro file
$sourcePath = base_path('tests/fixtures/propresenter/Test.pro'); $sourcePath = base_path('tests/fixtures/propresenter/Test.pro');
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true); $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(); $importResponse->assertOk();
$songId = $importResponse->json('songs.0.id'); $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); $this->assertNotNull($originalSong);
// Snapshot original data $defaultArr = $originalSong->arrangements->firstWhere('is_default', true) ?? $originalSong->arrangements->first();
$originalGroups = $originalSong->groups->sortBy('order')->values(); $this->assertNotNull($defaultArr);
$originalArrangementLabels = $defaultArr->arrangementLabels->sortBy('order')->values();
$originalArrangements = $originalSong->arrangements; $originalArrangements = $originalSong->arrangements;
// 2. Export as .pro
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro"); $exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk(); $exportResponse->assertOk();
// Save exported content to temp file — BinaryFileResponse delivers a real file
$tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro'; $tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro';
/** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */ /** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */
$baseResponse = $exportResponse->baseResponse; $baseResponse = $exportResponse->baseResponse;
copy($baseResponse->getFile()->getPathname(), $tempPath); 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); $reImported = \ProPresenter\Parser\ProFileReader::read($tempPath);
@unlink($tempPath); @unlink($tempPath);
// 4. Assert song name
$this->assertSame($originalSong->title, $reImported->getName()); $this->assertSame($originalSong->title, $reImported->getName());
// 5. Assert groups match (same names, same order)
$reImportedGroups = $reImported->getGroups(); $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]; $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 = $originalLabel->songSlides->sortBy('order')->values();
$originalSlides = $originalGroup->slides->sortBy('order')->values();
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup); $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) { foreach ($originalSlides as $slideIndex => $originalSlide) {
$reImportedSlide = $reImportedSlides[$slideIndex]; $reImportedSlide = $reImportedSlides[$slideIndex];
@ -144,32 +154,30 @@ public function test_download_pro_roundtrip_preserves_content(): void
$this->assertSame( $this->assertSame(
$originalSlide->text_content, $originalSlide->text_content,
$reImportedSlide->getPlainText(), $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) { 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( $this->assertSame(
$originalSlide->text_content_translated, $originalSlide->text_content_translated,
$reImportedSlide->getTranslation()?->getPlainText(), $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(); $reImportedArrangements = $reImported->getArrangements();
$this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch'); $this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch');
foreach ($originalArrangements as $index => $originalArrangement) { foreach ($originalArrangements as $originalArrangement) {
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name); $reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import"); $this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
$originalGroupNames = $originalArrangement->arrangementGroups $originalGroupNames = $originalArrangement->arrangementLabels
->sortBy('order') ->sortBy('order')
->map(fn ($ag) => $ag->group?->name) ->map(fn ($al) => $al->label?->name)
->filter() ->filter()
->values() ->values()
->toArray(); ->toArray();
@ -186,7 +194,6 @@ public function test_download_pro_roundtrip_preserves_content(): void
); );
} }
// 7. Assert CCLI metadata
if ($originalSong->ccli_id) { if ($originalSong->ccli_id) {
$this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber()); $this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber());
} }

View file

@ -32,8 +32,10 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
$song = Song::where('title', 'Test')->first(); $song = Song::where('title', 'Test')->first();
$this->assertNotNull($song); $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->assertSame(2, $song->arrangements()->count());
$this->assertTrue($song->has_translation); $this->assertTrue($song->has_translation);
} }
@ -64,9 +66,18 @@ public function test_import_pro_upsert_mit_ccli_dupliziert_nicht(): void
'title' => 'Old Title', 'title' => 'Old Title',
'ccli_id' => '999', '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']); $existingSong->update(['ccli_id' => '999']);
$this->assertSame(1, Song::count()); $this->assertSame(1, Song::count());
@ -114,6 +125,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void
$this->assertNotNull($normalArrangement); $this->assertNotNull($normalArrangement);
$this->assertTrue($normalArrangement->is_default); $this->assertTrue($normalArrangement->is_default);
$this->assertSame(5, $normalArrangement->arrangementGroups()->count()); $this->assertSame(5, $normalArrangement->arrangementLabels()->count());
} }
} }

View file

@ -1,25 +1,17 @@
<?php <?php
use App\Models\Label;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceSong; use App\Models\ServiceSong;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementGroup; use App\Models\SongArrangementLabel;
use App\Models\SongGroup;
use App\Models\User; use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song CRUD API Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () { beforeEach(function () {
$this->user = User::factory()->create(); $this->user = User::factory()->create();
}); });
// --- INDEX / LIST ---
test('songs index returns paginated list', function () { test('songs index returns paginated list', function () {
Song::factory()->count(3)->create(); Song::factory()->count(3)->create();
@ -75,8 +67,6 @@
$response->assertUnauthorized(); $response->assertUnauthorized();
}); });
// --- STORE / CREATE ---
test('store creates song with default groups and arrangement', function () { test('store creates song with default groups and arrangement', function () {
$response = $this->actingAs($this->user) $response = $this->actingAs($this->user)
->postJson('/api/songs', [ ->postJson('/api/songs', [
@ -91,16 +81,12 @@
$song = Song::where('title', 'Neues Lied')->first(); $song = Song::where('title', 'Neues Lied')->first();
expect($song)->not->toBeNull(); 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(); $arrangement = $song->arrangements()->where('is_default', true)->first();
expect($arrangement)->not->toBeNull(); expect($arrangement)->not->toBeNull();
expect($arrangement->name)->toBe('Normal'); 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 () { test('store validates required title', function () {
@ -136,12 +122,15 @@
expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull(); expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull();
}); });
// --- SHOW ---
test('show returns song with groups slides and arrangements', function () { test('show returns song with groups slides and arrangements', function () {
$song = Song::factory()->create(); $song = Song::factory()->create();
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Strophe 1']); $label = Label::factory()->create(['name' => 'Strophe 1']);
SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]); $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) $response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}"); ->getJson("/api/songs/{$song->id}");
@ -174,8 +163,6 @@
$response->assertNotFound(); $response->assertNotFound();
}); });
// --- UPDATE ---
test('update modifies song metadata', function () { test('update modifies song metadata', function () {
$song = Song::factory()->create(['title' => 'Old Title']); $song = Song::factory()->create(['title' => 'Old Title']);
@ -197,7 +184,6 @@
$songA = Song::factory()->create(['ccli_id' => '111111']); $songA = Song::factory()->create(['ccli_id' => '111111']);
$songB = Song::factory()->create(['ccli_id' => '222222']); $songB = Song::factory()->create(['ccli_id' => '222222']);
// Try setting songB's ccli_id to songA's
$response = $this->actingAs($this->user) $response = $this->actingAs($this->user)
->putJson("/api/songs/{$songB->id}", [ ->putJson("/api/songs/{$songB->id}", [
'title' => $songB->title, 'title' => $songB->title,
@ -220,8 +206,6 @@
$response->assertOk(); $response->assertOk();
}); });
// --- DESTROY / SOFT DELETE ---
test('destroy soft-deletes a song', function () { test('destroy soft-deletes a song', function () {
$song = Song::factory()->create(); $song = Song::factory()->create();
@ -242,8 +226,6 @@
$response->assertNotFound(); $response->assertNotFound();
}); });
// --- LAST USED IN SERVICE ---
test('last_used_in_service returns correct date from service_songs', function () { test('last_used_in_service returns correct date from service_songs', function () {
$song = Song::factory()->create(); $song = Song::factory()->create();
$serviceOld = Service::factory()->create(['date' => '2025-06-01']); $serviceOld = Service::factory()->create(['date' => '2025-06-01']);
@ -275,26 +257,24 @@
expect($response->json('data.last_used_in_service'))->toBeNull(); expect($response->json('data.last_used_in_service'))->toBeNull();
}); });
// --- SONG SERVICE: DUPLICATE ARRANGEMENT ---
test('duplicate arrangement clones arrangement with groups', function () { test('duplicate arrangement clones arrangement with groups', function () {
$song = Song::factory()->create(); $song = Song::factory()->create();
$group1 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 1]); $label1 = Label::factory()->create();
$group2 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 2]); $label2 = Label::factory()->create();
$arrangement = SongArrangement::factory()->create([ $arrangement = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
'name' => 'Original', 'name' => 'Original',
'is_default' => true, 'is_default' => true,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group1->id, 'label_id' => $label1->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group2->id, 'label_id' => $label2->id,
'order' => 2, 'order' => 2,
]); ]);
@ -303,7 +283,7 @@
expect($clone->name)->toBe('Klone'); expect($clone->name)->toBe('Klone');
expect($clone->is_default)->toBeFalse(); expect($clone->is_default)->toBeFalse();
expect($clone->arrangementGroups)->toHaveCount(2); expect($clone->arrangementLabels)->toHaveCount(2);
expect($clone->arrangementGroups->pluck('song_group_id')->toArray()) expect($clone->arrangementLabels->pluck('label_id')->toArray())
->toBe($arrangement->arrangementGroups->pluck('song_group_id')->toArray()); ->toBe($arrangement->arrangementLabels->pluck('label_id')->toArray());
}); });

View file

@ -1,28 +1,15 @@
<?php <?php
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementGroup; use App\Models\SongArrangementLabel;
use App\Models\SongGroup;
use App\Models\User; use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song Edit Modal Tests
|--------------------------------------------------------------------------
|
| Tests verifying the API endpoints used by SongEditModal.vue:
| - GET /api/songs/{id} (show with groups + arrangements)
| - PUT /api/songs/{id} (auto-save metadata)
|
*/
beforeEach(function () { beforeEach(function () {
$this->user = User::factory()->create(); $this->user = User::factory()->create();
}); });
// --- Modal Data Loading ---
test('show returns song with full detail for modal', function () { test('show returns song with full detail for modal', function () {
$song = Song::factory()->create([ $song = Song::factory()->create([
'title' => 'Großer Gott wir loben Dich', 'title' => 'Großer Gott wir loben Dich',
@ -30,18 +17,14 @@
'copyright_text' => '© Public Domain', 'copyright_text' => '© Public Domain',
]); ]);
$group1 = SongGroup::factory()->create([ $label1 = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1', 'name' => 'Strophe 1',
'color' => '#3B82F6', 'color' => '#3B82F6',
'order' => 1,
]); ]);
$group2 = SongGroup::factory()->create([ $label2 = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#10B981', 'color' => '#10B981',
'order' => 2,
]); ]);
$arrangement = SongArrangement::factory()->create([ $arrangement = SongArrangement::factory()->create([
@ -50,15 +33,15 @@
'is_default' => true, 'is_default' => true,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group1->id, 'label_id' => $label1->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group2->id, 'label_id' => $label2->id,
'order' => 2, '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.0.name'))->toBe('Strophe 1');
expect($response->json('data.groups.1.name'))->toBe('Refrain'); 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.name'))->toBe('Normal');
expect($response->json('data.arrangements.0.arrangement_groups'))->toHaveCount(2); expect($response->json('data.arrangements.0.arrangement_groups'))->toHaveCount(2);
}); });
// --- Metadata Auto-Save ---
test('update saves title via auto-save', function () { test('update saves title via auto-save', function () {
$song = Song::factory()->create(['title' => 'Original Title']); $song = Song::factory()->create(['title' => 'Original Title']);
@ -155,7 +134,6 @@
test('update returns full song detail with arrangements', function () { test('update returns full song detail with arrangements', function () {
$song = Song::factory()->create(); $song = Song::factory()->create();
SongGroup::factory()->create(['song_id' => $song->id]);
SongArrangement::factory()->create(['song_id' => $song->id, 'is_default' => true]); SongArrangement::factory()->create(['song_id' => $song->id, 'is_default' => true]);
$response = $this->actingAs($this->user) $response = $this->actingAs($this->user)

View file

@ -1,18 +1,12 @@
<?php <?php
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementGroup; use App\Models\SongArrangementLabel;
use App\Models\SongGroup;
use App\Models\SongSlide; use App\Models\SongSlide;
use App\Models\User; use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song PDF Download Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () { beforeEach(function () {
$this->user = User::factory()->create(); $this->user = User::factory()->create();
}); });
@ -24,22 +18,20 @@
'name' => 'Normal', 'name' => 'Normal',
]); ]);
$group = SongGroup::factory()->create([ $label = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Verse 1', 'name' => 'Verse 1',
'color' => '#3B82F6', 'color' => '#3B82F6',
'order' => 1,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Amazing grace how sweet the sound', 'text_content' => 'Amazing grace how sweet the sound',
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
]); ]);
@ -73,42 +65,37 @@
'name' => 'Normal', 'name' => 'Normal',
]); ]);
$verse = SongGroup::factory()->create([ $verse = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1', 'name' => 'Strophe 1',
'color' => '#3B82F6', 'color' => '#3B82F6',
'order' => 1,
]); ]);
$chorus = SongGroup::factory()->create([ $chorus = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#10B981', 'color' => '#10B981',
'order' => 2,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $verse->id, 'label_id' => $verse->id,
'order' => 1, 'order' => 1,
'text_content' => 'Großer Gott wir loben dich', 'text_content' => 'Großer Gott wir loben dich',
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $chorus->id, 'label_id' => $chorus->id,
'order' => 1, 'order' => 1,
'text_content' => 'Heilig heilig heilig', 'text_content' => 'Heilig heilig heilig',
]); ]);
// Arrangement: Strophe 1 -> Refrain SongArrangementLabel::factory()->create([
SongArrangementGroup::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $verse->id, 'label_id' => $verse->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $chorus->id, 'label_id' => $chorus->id,
'order' => 2, 'order' => 2,
]); ]);
@ -130,22 +117,20 @@
'name' => 'Normal', 'name' => 'Normal',
]); ]);
$group = SongGroup::factory()->create([ $label = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Verse 1', 'name' => 'Verse 1',
'order' => 1,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Amazing grace how sweet the sound', 'text_content' => 'Amazing grace how sweet the sound',
'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang', 'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang',
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
]); ]);
@ -210,21 +195,19 @@
'name' => 'Übung', 'name' => 'Übung',
]); ]);
$group = SongGroup::factory()->create([ $label = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1', 'name' => 'Strophe 1',
'order' => 1,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Großer Gott wir loben dich', 'text_content' => 'Großer Gott wir loben dich',
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
]); ]);
@ -234,7 +217,6 @@
$response->assertOk(); $response->assertOk();
$response->assertHeader('Content-Type', 'application/pdf'); $response->assertHeader('Content-Type', 'application/pdf');
// Filename should contain slug with umlauts handled
$contentDisposition = $response->headers->get('Content-Disposition'); $contentDisposition = $response->headers->get('Content-Disposition');
expect($contentDisposition)->toContain('.pdf'); expect($contentDisposition)->toContain('.pdf');
}); });
@ -260,28 +242,24 @@
'ccli_id' => '123456', 'ccli_id' => '123456',
]); ]);
$verse = SongGroup::factory()->create([ $verse = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1', 'name' => 'Strophe 1',
'color' => '#3b82f6', 'color' => '#3b82f6',
'order' => 1,
]); ]);
$chorus = SongGroup::factory()->create([ $chorus = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#ef4444', 'color' => '#ef4444',
'order' => 2,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $verse->id, 'label_id' => $verse->id,
'order' => 1, 'order' => 1,
'text_content' => 'Strophe Text', 'text_content' => 'Strophe Text',
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $chorus->id, 'label_id' => $chorus->id,
'order' => 1, 'order' => 1,
'text_content' => 'Refrain Text', 'text_content' => 'Refrain Text',
]); ]);
@ -291,16 +269,15 @@
'name' => 'Normal', 'name' => 'Normal',
]); ]);
// Order: Chorus first, then Verse SongArrangementLabel::factory()->create([
SongArrangementGroup::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $chorus->id, 'label_id' => $chorus->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $verse->id, 'label_id' => $verse->id,
'order' => 2, 'order' => 2,
]); ]);
@ -321,7 +298,6 @@
], ],
]); ]);
// Chorus should be first (order=1), Verse second (order=2)
$data = $response->json(); $data = $response->json();
expect($data['groups'][0]['name'])->toBe('Refrain'); expect($data['groups'][0]['name'])->toBe('Refrain');
expect($data['groups'][1]['name'])->toBe('Strophe 1'); expect($data['groups'][1]['name'])->toBe('Strophe 1');
@ -331,14 +307,12 @@
test('song preview includes translation text when slides have translations', function () { test('song preview includes translation text when slides have translations', function () {
$song = Song::factory()->create(['title' => 'Lied mit Übersetzung']); $song = Song::factory()->create(['title' => 'Lied mit Übersetzung']);
$group = SongGroup::factory()->create([ $label = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Verse', 'name' => 'Verse',
'order' => 1,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Original Text', 'text_content' => 'Original Text',
'text_content_translated' => 'Translated Text', 'text_content_translated' => 'Translated Text',
@ -348,9 +322,9 @@
'song_id' => $song->id, 'song_id' => $song->id,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id, 'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
]); ]);

View file

@ -2,12 +2,12 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceSong; use App\Models\ServiceSong;
use App\Models\Song; use App\Models\Song;
use App\Models\SongArrangement; use App\Models\SongArrangement;
use App\Models\SongArrangementGroup; use App\Models\SongArrangementLabel;
use App\Models\SongGroup;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -79,18 +79,14 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
'has_translation' => true, 'has_translation' => true,
]); ]);
$verse = SongGroup::factory()->create([ $verse = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1', 'name' => 'Strophe 1',
'color' => '#3B82F6', 'color' => '#3B82F6',
'order' => 1,
]); ]);
$chorus = SongGroup::factory()->create([ $chorus = Label::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain', 'name' => 'Refrain',
'color' => '#10B981', 'color' => '#10B981',
'order' => 2,
]); ]);
$normal = SongArrangement::factory()->create([ $normal = SongArrangement::factory()->create([
@ -99,15 +95,15 @@ public function test_songs_block_provides_matched_song_data_for_arrangement_conf
'is_default' => true, 'is_default' => true,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id, 'label_id' => $verse->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementGroup::factory()->create([ SongArrangementLabel::factory()->create([
'song_arrangement_id' => $normal->id, 'song_arrangement_id' => $normal->id,
'song_group_id' => $chorus->id, 'label_id' => $chorus->id,
'order' => 2, 'order' => 2,
]); ]);

View file

@ -3,8 +3,10 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Http\Controllers\TranslationController; use App\Http\Controllers\TranslationController;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongGroup; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide; use App\Models\SongSlide;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -21,36 +23,50 @@ public function test_translate_page_response_contains_ordered_groups_and_slides(
'title' => 'Grosser Gott', 'title' => 'Grosser Gott',
]); ]);
$groupLater = SongGroup::factory()->create([ $arrangement = SongArrangement::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
'name' => 'Refrain', 'name' => 'Normal',
'color' => '#22c55e', 'is_default' => true,
'order' => 2,
]); ]);
$groupFirst = SongGroup::factory()->create([ $labelLater = Label::factory()->create([
'song_id' => $song->id, 'name' => 'Refrain',
'color' => '#22c55e',
]);
$labelFirst = Label::factory()->create([
'name' => 'Strophe 1', 'name' => 'Strophe 1',
'color' => '#0ea5e9', 'color' => '#0ea5e9',
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelFirst->id,
'order' => 1, 'order' => 1,
]); ]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'label_id' => $labelLater->id,
'order' => 2,
]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $groupFirst->id, 'label_id' => $labelFirst->id,
'order' => 2, 'order' => 2,
'text_content' => "Zeile A\nZeile B", 'text_content' => "Zeile A\nZeile B",
'text_content_translated' => "Line A\nLine B", 'text_content_translated' => "Line A\nLine B",
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $groupFirst->id, 'label_id' => $labelFirst->id,
'order' => 1, 'order' => 1,
'text_content' => "Zeile C\nZeile D\nZeile E", 'text_content' => "Zeile C\nZeile D\nZeile E",
'text_content_translated' => null, 'text_content_translated' => null,
]); ]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $groupLater->id, 'label_id' => $labelLater->id,
'order' => 1, 'order' => 1,
'text_content' => 'Refrain', 'text_content' => 'Refrain',
'text_content_translated' => 'Chorus', 'text_content_translated' => 'Chorus',

View file

@ -1,25 +1,19 @@
<?php <?php
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Models\SongGroup; use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSlide; use App\Models\SongSlide;
use App\Models\User; use App\Models\User;
use App\Services\TranslationService; use App\Services\TranslationService;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
/*
|--------------------------------------------------------------------------
| Translation Service Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () { beforeEach(function () {
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->service = app(TranslationService::class); $this->service = app(TranslationService::class);
}); });
// --- URL FETCH ---
test('fetchFromUrl returns text from successful HTTP response', function () { test('fetchFromUrl returns text from successful HTTP response', function () {
Http::fake([ Http::fake([
'https://example.com/lyrics' => Http::response('<html><body><p>Zeile 1</p><p>Zeile 2</p></body></html>', 200), 'https://example.com/lyrics' => Http::response('<html><body><p>Zeile 1</p><p>Zeile 2</p></body></html>', 200),
@ -30,7 +24,6 @@
expect($result)->not->toBeNull(); expect($result)->not->toBeNull();
expect($result)->toContain('Zeile 1'); expect($result)->toContain('Zeile 1');
expect($result)->toContain('Zeile 2'); expect($result)->toContain('Zeile 2');
// HTML tags should be stripped
expect($result)->not->toContain('<p>'); expect($result)->not->toContain('<p>');
expect($result)->not->toContain('<html>'); expect($result)->not->toContain('<html>');
}); });
@ -65,34 +58,56 @@
expect($result)->toBeNull(); expect($result)->toBeNull();
}); });
// --- IMPORT TRANSLATION (LINE-COUNT DISTRIBUTION) --- function makeSongWithDefaultArrangement(): array
{
test('importTranslation distributes lines by slide line counts', function () {
$song = Song::factory()->create(['has_translation' => false]); $song = Song::factory()->create(['has_translation' => false]);
$arrangement = SongArrangement::factory()->create([
$group = SongGroup::factory()->create([
'song_id' => $song->id, 'song_id' => $song->id,
'name' => 'Strophe 1', 'name' => 'Normal',
'order' => 1, '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([ $slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4", 'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
]); ]);
// Slide 2: 2 lines
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 2, 'order' => 2,
'text_content' => "Original 5\nOriginal 6", 'text_content' => "Original 5\nOriginal 6",
]); ]);
// Slide 3: 4 lines
$slide3 = SongSlide::factory()->create([ $slide3 = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 3, 'order' => 3,
'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10", 'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10",
]); ]);
@ -105,37 +120,25 @@
$slide2->refresh(); $slide2->refresh();
$slide3->refresh(); $slide3->refresh();
// Slide 1 gets lines 1-4
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 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"); 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"); expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10");
}); });
test('importTranslation distributes across multiple groups', function () { test('importTranslation distributes across multiple groups', function () {
$song = Song::factory()->create(['has_translation' => false]); [$song, $arrangement] = makeSongWithDefaultArrangement();
$group1 = SongGroup::factory()->create([ $label1 = attachLabelWithSlides($arrangement, 'Strophe 1 - multi', [], 1);
'song_id' => $song->id, $label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2);
'name' => 'Strophe 1',
'order' => 1,
]);
$group2 = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain',
'order' => 2,
]);
$slide1 = SongSlide::factory()->create([ $slide1 = SongSlide::factory()->create([
'song_group_id' => $group1->id, 'label_id' => $label1->id,
'order' => 1, 'order' => 1,
'text_content' => "Line A\nLine B", 'text_content' => "Line A\nLine B",
]); ]);
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'song_group_id' => $group2->id, 'label_id' => $label2->id,
'order' => 1, 'order' => 1,
'text_content' => "Line C\nLine D\nLine E", 'text_content' => "Line C\nLine D\nLine E",
]); ]);
@ -152,26 +155,22 @@
}); });
test('importTranslation handles fewer translation lines than original', function () { test('importTranslation handles fewer translation lines than original', function () {
$song = Song::factory()->create(['has_translation' => false]); [$song, $arrangement] = makeSongWithDefaultArrangement();
$group = SongGroup::factory()->create([ $label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1);
'song_id' => $song->id,
'order' => 1,
]);
$slide1 = SongSlide::factory()->create([ $slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => "Line 1\nLine 2\nLine 3", 'text_content' => "Line 1\nLine 2\nLine 3",
]); ]);
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 2, 'order' => 2,
'text_content' => "Line 4\nLine 5", 'text_content' => "Line 4\nLine 5",
]); ]);
// Only 3 lines for 5 lines total
$translatedText = "Zeile 1\nZeile 2\nZeile 3"; $translatedText = "Zeile 1\nZeile 2\nZeile 3";
$this->service->importTranslation($song, $translatedText); $this->service->importTranslation($song, $translatedText);
@ -179,22 +178,16 @@
$slide1->refresh(); $slide1->refresh();
$slide2->refresh(); $slide2->refresh();
// Slide 1 gets all 3 available lines
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3"); 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(''); expect($slide2->text_content_translated)->toBe('');
}); });
test('importTranslation marks song as translated', function () { test('importTranslation marks song as translated', 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 - mark', [], 1);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Line 1', 'text_content' => 'Line 1',
]); ]);
@ -205,8 +198,6 @@
expect($song->has_translation)->toBeTrue(); expect($song->has_translation)->toBeTrue();
}); });
// --- MARK AS TRANSLATED ---
test('markAsTranslated sets has_translation to true', function () { test('markAsTranslated sets has_translation to true', function () {
$song = Song::factory()->create(['has_translation' => false]); $song = Song::factory()->create(['has_translation' => false]);
@ -216,25 +207,21 @@
expect($song->has_translation)->toBeTrue(); expect($song->has_translation)->toBeTrue();
}); });
// --- REMOVE TRANSLATION ---
test('removeTranslation clears all translated text and sets flag to false', function () { 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([ $label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1);
'song_id' => $song->id,
'order' => 1,
]);
$slide1 = SongSlide::factory()->create([ $slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Original', 'text_content' => 'Original',
'text_content_translated' => 'Übersetzt', 'text_content_translated' => 'Übersetzt',
]); ]);
$slide2 = SongSlide::factory()->create([ $slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 2, 'order' => 2,
'text_content' => 'Original 2', 'text_content' => 'Original 2',
'text_content_translated' => 'Übersetzt 2', 'text_content_translated' => 'Übersetzt 2',
@ -251,8 +238,6 @@
expect($slide2->text_content_translated)->toBeNull(); expect($slide2->text_content_translated)->toBeNull();
}); });
// --- CONTROLLER ENDPOINTS ---
test('POST translation/fetch-url returns scraped text', function () { test('POST translation/fetch-url returns scraped text', function () {
Http::fake([ Http::fake([
'https://lyrics.example.com/song' => Http::response('<div>Liedtext Zeile 1</div>', 200), 'https://lyrics.example.com/song' => Http::response('<div>Liedtext Zeile 1</div>', 200),
@ -292,15 +277,12 @@
}); });
test('POST songs/{song}/translation/import distributes and saves translation', function () { 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([ $label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1);
'song_id' => $song->id,
'order' => 1,
]);
$slide = SongSlide::factory()->create([ $slide = SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => "Line 1\nLine 2", 'text_content' => "Line 1\nLine 2",
]); ]);
@ -340,15 +322,13 @@
}); });
test('DELETE songs/{song}/translation removes translation', function () { 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([ $label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1);
'song_id' => $song->id,
'order' => 1,
]);
SongSlide::factory()->create([ SongSlide::factory()->create([
'song_group_id' => $group->id, 'label_id' => $label->id,
'order' => 1, 'order' => 1,
'text_content' => 'Original', 'text_content' => 'Original',
'text_content_translated' => 'Übersetzt', 'text_content_translated' => 'Übersetzt',

View file

@ -8,7 +8,7 @@
function makeColor(float $r, float $g, float $b, float $a = 1.0): Color function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
{ {
$color = new Color(); $color = new Color;
$color->setRed($r); $color->setRed($r);
$color->setGreen($g); $color->setGreen($g);
$color->setBlue($b); $color->setBlue($b);
@ -17,7 +17,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
return $color; return $color;
} }
$doc = new ProLabelsDocument(); $doc = new ProLabelsDocument;
$labels = [ $labels = [
['name' => 'Copyright', 'r' => 0.8, 'g' => 0.2, 'b' => 0.2], ['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 = []; $labelObjects = [];
foreach ($labels as $data) { foreach ($labels as $data) {
$label = new Label(); $label = new Label;
$label->setText($data['name']); $label->setText($data['name']);
$label->setColor(makeColor($data['r'], $data['g'], $data['b'])); $label->setColor(makeColor($data['r'], $data['g'], $data['b']));
$labelObjects[] = $label; $labelObjects[] = $label;

View file

@ -11,7 +11,7 @@
function makeUuid(string $value): UUID function makeUuid(string $value): UUID
{ {
$uuid = new UUID(); $uuid = new UUID;
$uuid->setString(strtoupper($value)); $uuid->setString(strtoupper($value));
return $uuid; return $uuid;
@ -19,7 +19,7 @@ function makeUuid(string $value): UUID
function makeColor(float $r, float $g, float $b, float $a = 1.0): Color function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
{ {
$color = new Color(); $color = new Color;
$color->setRed($r); $color->setRed($r);
$color->setGreen($g); $color->setGreen($g);
$color->setBlue($b); $color->setBlue($b);
@ -28,7 +28,7 @@ function makeColor(float $r, float $g, float $b, float $a = 1.0): Color
return $color; return $color;
} }
$doc = new MacrosDocument(); $doc = new MacrosDocument;
$macros = [ $macros = [
['uuid' => 'AAAAAAAA-1111-2222-3333-FFFFFFFFFFFF', 'name' => 'Copyright Makro', 'r' => 1.0, 'g' => 0.5, 'b' => 0.0], ['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 = []; $macroObjects = [];
foreach ($macros as $data) { foreach ($macros as $data) {
$macro = new Macro(); $macro = new Macro;
$macro->setUuid(makeUuid($data['uuid'])); $macro->setUuid(makeUuid($data['uuid']));
$macro->setName($data['name']); $macro->setName($data['name']);
$macro->setColor(makeColor($data['r'], $data['g'], $data['b'])); $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); $doc->setMacros($macroObjects);
$collection = new MacroCollection(); $collection = new MacroCollection;
$collection->setUuid(makeUuid('8D02FC57-83F8-4042-9B90-81C229728426')); $collection->setUuid(makeUuid('8D02FC57-83F8-4042-9B90-81C229728426'));
$collection->setName('--MAIN--'); $collection->setName('--MAIN--');
$items = []; $items = [];
foreach ($macros as $data) { foreach ($macros as $data) {
$item = new Item(); $item = new Item;
$item->setMacroId(makeUuid($data['uuid'])); $item->setMacroId(makeUuid($data['uuid']));
$items[] = $item; $items[] = $item;
} }