M3: Reorder + Inspiration – Schritt 2 (UI): Planungsliste mit SortableJS, Tagesansicht /mahlzeiten/tage, Inspiration /inspiration mit 'Weitere 5' + 1-Klick-Adopt, Feature-Tests

This commit is contained in:
2026-09-19 17:09:15 +02:00
parent 52a34225fa
commit fa5894426d
9 changed files with 488 additions and 25 deletions
+7 -3
View File
@@ -68,8 +68,12 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
Defaults 0.5/0.3/0.2, `ausschluss`-IDs für „Weitere 5", Sortierung
absteigend nach Score, bei Gleichstand alphabetisch), `MahlzeitAdoptUseCase`
(Quelle → neue `geplant`-Kopie mit `gegessenAm = null`, `MahlzeitAdoptedEvent`).
Schritt 2 (UI): Planungsliste mit SortableJS + `POST /mahlzeiten/reorder`,
Inspiration-View `GET /inspiration` + `POST /mahlzeiten/{id}/adopt`.
Schritt 2 (UI): `GET /mahlzeiten` = flache, per SortableJS sortierbare
Planungsliste (nur `geplant`, Drag-Handle, `POST /mahlzeiten/reorder`
mit JSON/Form `ids` → 204); Tagesansicht ausgelagert nach
`GET /mahlzeiten/tage`; Inspiration unter `GET /inspiration` (5 Vorschläge,
„Weitere 5" via `?ausschluss=…`, 1-Klick-`POST /mahlzeiten/{id}/adopt`
mit Kontext-Erhaltung).
### M4 – Härtung & API
- `X-Api-Token`-Guard (optional, `.env`) für `GET /api/today` (HA-ready)
@@ -97,6 +101,6 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
| M0 | 2026-09-13 | abgenommen |
| M1 | 2026-09-19 | abgenommen |
| M2 | 2026-09-19 | abgenommen |
| M3 | | offen |
| M3 | 2026-09-19 | abgenommen |
| M4 | | offen |
| M5 | | offen |
+24
View File
@@ -63,6 +63,30 @@ mahlzeiten_loeschen:
requirements:
id: \d+
# M3: Tagesansicht (alle Mahlzeiten nach Wochentag) + Reorder (Drag & Drop)
mahlzeiten_tage:
path: /mahlzeiten/tage
controller: App\UI\Http\Essen\MahlzeitController::tage
methods: [GET]
mahlzeiten_reorder:
path: /mahlzeiten/reorder
controller: App\UI\Http\Essen\MahlzeitController::reorder
methods: [POST]
# M3: Inspiration (ADR-006): Vorschläge aus dem Archiv + 1-Klick-Adopt
mahlzeiten_inspiration:
path: /inspiration
controller: App\UI\Http\Essen\MahlzeitController::inspiration
methods: [GET]
mahlzeiten_adopt:
path: /mahlzeiten/{id}/adopt
controller: App\UI\Http\Essen\MahlzeitController::adopt
methods: [POST]
requirements:
id: \d+
# M2: Status-Wechsel als htmx-Partial-Endpunkte (Fragment-Antwort)
mahlzeiten_checkoff:
path: /mahlzeiten/{id}/checkoff
+144 -5
View File
@@ -7,15 +7,20 @@ namespace App\UI\Http\Essen;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitListRequest;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Dto\MahlzeitVorschlagResponse;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\Query\MahlzeitInspirationQuery;
use App\Logic\Essen\Query\MahlzeitListQuery;
use App\Logic\Essen\UseCase\MahlzeitAdoptUseCase;
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
use App\Logic\Essen\UseCase\MahlzeitReorderUseCase;
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -23,13 +28,18 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2) – Web-UI.
* CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2)
* + Reorder & Inspiration (M3) – Web-UI.
*
* - Reads über `MahlzeitListQuery` / `MahlzeitManagerInterface::find`.
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck).
* - Reads über `MahlzeitListQuery` / `MahlzeitInspirationQuery`
* / `MahlzeitManagerInterface::find`.
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck/Reorder/Adopt).
* - M1: klassisches POST/Redirect/Flash (Anlegen/Bearbeiten/Löschen).
* - M2: Check-Off als htmx-Toggle – die Endpunkte liefern nur das geänderte
* Mahlzeiten-Partial zurück (Fragment, kein Voll-HTML).
* - M3: Index = flache, per SortableJS sortierbare Planungsliste
* (`POST /mahlzeiten/reorder` → 204); Tagesansicht unter `/mahlzeiten/tage`;
* Inspiration unter `/inspiration` mit 1-Klick-Adopt.
* - Syntaktische Pflichtfeld-Prüfung (Name) passiert hier im UI-Layer
* (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben
* im UseCase/Model und laufen zentral über den ExceptionSubscriber.
@@ -42,21 +52,89 @@ final class MahlzeitController extends AbstractController
private readonly MahlzeitDeleteUseCase $delete,
private readonly MahlzeitCheckOffUseCase $checkOff,
private readonly MahlzeitUncheckUseCase $uncheck,
private readonly MahlzeitReorderUseCase $reorder,
private readonly MahlzeitAdoptUseCase $adopt,
private readonly MahlzeitListQuery $list,
private readonly MahlzeitInspirationQuery $inspiration,
private readonly MahlzeitManagerInterface $mahlzeiten,
) {}
/** GET /mahlzeiten — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */
/** GET /mahlzeiten — Planungsliste: geplante Mahlzeiten in sortOrder-Reihenfolge (sortable). */
public function index(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Geplant))->items;
return $this->render('mahlzeiten/index.html.twig', [
'mahlzeiten' => $mahlzeiten,
'total' => \count($mahlzeiten),
]);
}
/** GET /mahlzeiten/tage — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */
public function tage(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest())->items;
return $this->render('mahlzeiten/index.html.twig', [
return $this->render('mahlzeiten/tage.html.twig', [
'tage' => $this->gruppierenNachTag($mahlzeiten),
'total' => \count($mahlzeiten),
]);
}
/**
* POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop)
* persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld
* `ids` (Array oder kommagetrennte Liste). Antwort: 204 No Content.
* Wirft 404 bei unbekannten IDs.
*/
public function reorder(Request $request): Response
{
$this->reorder->execute(new MahlzeitReorderRequest($this->bindeReihenfolge($request)));
return new Response(null, Response::HTTP_NO_CONTENT);
}
/**
* GET /inspiration — Vorschläge aus dem Archiv (ADR-006).
* `?ausschluss=1,2,3` (kommagetrennte Repräsentanten-IDs) liefert "Weitere 5".
*/
public function inspiration(Request $request): Response
{
$ausschluss = $this->bindeAusschluss($request->query->all()['ausschluss'] ?? null);
// Proben mit 6: so wissen wir, ob hinter den 5 gezeigten noch mehr liegen.
$vorschlaege = $this->inspiration->execute(6, $ausschluss);
$mehrVerfuegbar = \count($vorschlaege) > 5;
$vorschlaege = array_slice($vorschlaege, 0, 5);
return $this->render('mahlzeiten/inspiration.html.twig', [
'vorschlaege' => $vorschlaege,
'ausschluss' => $ausschluss,
'gezeigte' => array_map(static fn (MahlzeitVorschlagResponse $v): int => $v->id, $vorschlaege),
'mehrVerfuegbar' => $mehrVerfuegbar,
'total' => \count($vorschlaege),
]);
}
/**
* POST /mahlzeiten/{id}/adopt — Gericht als NEUE geplante Mahlzeit übernehmen
* (Kopie, s. `MahlzeitAdoptUseCase`). Redirect zurück zur Inspiration,
* dabei die bisherige Ausschluss-Liste (für "Weitere 5") mitnehmen.
*/
public function adopt(Request $request, int $id): Response
{
$ergebnis = $this->adopt->execute($id);
$this->addFlash('notice', sprintf('Gericht „%s“ in die Planungsliste übernommen.', $ergebnis->name));
$params = [];
$ausschluss = $this->bindeAusschluss($request->request->all()['ausschluss'] ?? null);
if ($ausschluss !== []) {
$params['ausschluss'] = implode(',', $ausschluss);
}
return $this->redirectToRoute('mahlzeiten_inspiration', $params);
}
/** GET /mahlzeiten/archiv — Archiv: gegessene Mahlzeiten. */
public function archiv(): Response
{
@@ -177,6 +255,67 @@ final class MahlzeitController extends AbstractController
return [$name, $notiz, $wochentage];
}
/**
* Bindet die neue ID-Reihenfolge (Drag & Drop) aus dem Request.
* Unterstützt JSON `{"ids": [...]}`, das Formularfeld `ids[]`
* und kommagetrennte `ids=1,2,3`. Ungültige Werte werden verworfen.
*
* @return list<int>
*/
private function bindeReihenfolge(Request $request): array
{
$roh = null;
$json = json_decode((string) $request->getContent(), true);
if (\is_array($json) && \is_array($json['ids'] ?? null)) {
$roh = $json['ids'];
} else {
// Raw-Bag-Zugriff: `get()`/`all(key)` werfen je auf Array/Skalar.
$roh = $request->request->all()['ids'] ?? null;
if (\is_string($roh)) {
$roh = array_filter(explode(',', $roh), static fn (string $teil): bool => trim($teil) !== '');
}
}
if (!\is_array($roh)) {
return [];
}
$ids = [];
foreach ($roh as $wert) {
if ((\is_int($wert) || \is_string($wert)) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
$ids[] = (int) $wert;
}
}
return $ids;
}
/**
* Bindet die Ausschluss-Liste bereits gezeigter Vorschläge ("Weitere 5").
* Akzeptiert ein String-Feld (`?ausschluss=1,2,3`) oder ein Array-Feld
* (`ausschluss[]`); rawer Bag-Zugriff, da `ParameterBag::get()`/`all(key)`
* je auf Skalar/Array werfen.
*
* @return list<int>
*/
private function bindeAusschluss(array|string|null $werte): array
{
if ($werte === null || $werte === '') {
return [];
}
if (\is_string($werte)) {
$werte = array_filter(explode(',', $werte), static fn (string $teil): bool => trim($teil) !== '');
}
$ids = [];
foreach ($werte as $wert) {
if (\is_scalar($wert) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
$ids[] = (int) trim((string) $wert);
}
}
return $ids;
}
/**
* @param array{name: string, notiz: string, wochentage: list<Wochentag>} $werte
* @return array<string, mixed>
+3
View File
@@ -64,6 +64,8 @@
.actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.item { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem; border: 1px solid #eee; border-radius: 0.4rem; align-items: center; flex-wrap: wrap; }
.drag-handle { cursor: grab; color: var(--muted); font-weight: 600; margin-right: 0.55rem; user-select: none; }
.item-drag { opacity: 0.4; }
.tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; }
.tag { background: #eef4ff; color: var(--accent); padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
.status { font-size: 0.8rem; color: var(--muted); }
@@ -91,6 +93,7 @@
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
<nav>
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
<a href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
<a href="{{ path('logout') }}">Abmelden</a>
</nav>
+8 -3
View File
@@ -1,12 +1,17 @@
{#
Mahlzeiten-Partial (eine Zeile). Wird sowohl in der Tagesansicht/Archiv
eingebunden als auch als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
Mahlzeiten-Partial (eine Zeile). Wird in Planungsliste/Tagesansicht/Archiv
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
@param MahlzeitResponse m
@param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste)
#}
<li class="item" id="mahlzeit-{{ m.id }}">
<li class="item" id="mahlzeit-{{ m.id }}"
{% if sortierbar is defined and sortierbar %}data-id="{{ m.id }}"{% endif %}>
<div>
{% if sortierbar is defined and sortierbar %}
<span class="drag-handle" title="Ziehen zum Sortieren">⋮</span>
{% endif %}
<strong>{{ m.name }}</strong>
{% if m.notiz != '' %}
<div class="muted">{{ m.notiz }}</div>
+38 -10
View File
@@ -7,25 +7,53 @@
<div class="row-between">
<div>
<h1 style="margin: 0;">Mahlzeiten</h1>
<div class="muted" style="margin-top: 0.2rem;">Tagesansicht nach Wochentag</div>
<div class="muted" style="margin-top: 0.2rem;">Planungsliste – per Drag &amp; Drop sortieren</div>
</div>
<div class="actions">
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_tage') }}">Tagesansicht</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
</div>
</div>
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten in der Planung.</p>
{% else %}
{% for tag, mahlzeiten in tage %}
<h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
<ul class="list">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
{% endfor %}
</ul>
{% endfor %}
<ul class="list sortierbar">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m, sortierbar: true }) }}
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
{% block javascripts %}
<script src="{{ asset('js/sortable.min.js') }}"></script>
<script>
(function () {
const liste = document.querySelector('ul.sortierbar');
if (!liste || typeof Sortable === 'undefined') { return; }
const url = '{{ path("mahlzeiten_reorder") }}';
Sortable.create(liste, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'item-drag',
onEnd: function () {
const ids = Array.prototype.map.call(
liste.querySelectorAll('li[data-id]'),
function (li) { return Number(li.getAttribute('data-id')); }
);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ids }),
}).then(function (antwort) {
if (!antwort.ok) { window.location.reload(); }
}).catch(function () { window.location.reload(); });
},
});
})();
</script>
{% endblock %}
@@ -0,0 +1,61 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Inspiration · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<div>
<h1 style="margin: 0;">Inspiration</h1>
<div class="muted" style="margin-top: 0.2rem;">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
</div>
</div>
{# Für "Weitere 5" / Adopt-Kontext: bisheriger Ausschluss + die IDs dieser Seite #}
{% set alleAusschluss = ausschluss + gezeigte %}
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
</p>
{% else %}
<ul class="list">
{% for v in vorschlaege %}
<li class="item" id="vorschlag-{{ v.id }}">
<div>
<strong>{{ v.name }}</strong>
{% if v.notiz != '' %}
<div class="muted">{{ v.notiz }}</div>
{% endif %}
<span class="status">
{% if v.gegessenAm %}
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
{% else %}
aus dem Archiv
{% endif %}
· {{ v.auftraete }}× in der Küche
</span>
</div>
<div class="actions">
<form method="post" action="{{ path('mahlzeiten_adopt', { id: v.id }) }}">
{% for id in alleAusschluss %}
<input type="hidden" name="ausschluss[]" value="{{ id }}">
{% endfor %}
<button type="submit" class="btn">Übernehmen</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% if mehrVerfuegbar %}
<p style="margin: 1rem 0 0;">
<a class="btn btn-link"
href="{{ path('mahlzeiten_inspiration', { ausschluss: alleAusschluss|join(',') }) }}">Weitere 5 Vorschläge</a>
</p>
{% endif %}
{% endif %}
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Tagesansicht · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<div>
<h1 style="margin: 0;">Tagesansicht</h1>
<div class="muted" style="margin-top: 0.2rem;">Alle Mahlzeiten nach Wochentag</div>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
</div>
</div>
{% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
{% else %}
{% for tag, mahlzeiten in tage %}
<h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
<ul class="list">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
{% endfor %}
</ul>
{% endfor %}
{% endif %}
</div>
{% endblock %}
@@ -149,14 +149,14 @@ final class MahlzeitControllerTest extends WebTestCase
// ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ----
public function testIndexGroupsMealsByWeekday(): void
public function testTageViewGroupsMealsByWeekday(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'Curry', '', ['di']);
$this->anlegen($client, 'Pizza', '', ['sa']);
$this->anlegen($client, 'Bolognese', '', ['mo', 'mi']);
$client->request('GET', '/mahlzeiten');
$client->request('GET', '/mahlzeiten/tage');
$crawler = $client->getCrawler();
self::assertResponseIsSuccessful();
@@ -176,12 +176,12 @@ final class MahlzeitControllerTest extends WebTestCase
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html());
}
public function testIndexGroupsMealWithoutWeekdayUnderOhneTag(): void
public function testTageViewGroupsMealWithoutWeekdayUnderOhneTag(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'Reis', '', []);
$client->request('GET', '/mahlzeiten');
$client->request('GET', '/mahlzeiten/tage');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html());
@@ -265,6 +265,174 @@ final class MahlzeitControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(404);
}
// ---- M3: Planungsliste (flach/sortierbar), Reorder, Inspiration, Adopt ----
public function testIndexRendersFlatPlannedListOnly(): void
{
$client = $this->vorbereiten();
$geplantId = $this->anlegen($client, 'GeplanteMahlzeit', '', []);
$gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', []);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId));
self::assertResponseIsSuccessful();
$client->request('GET', '/mahlzeiten');
self::assertResponseIsSuccessful();
// Sortierbare Planungsliste mit Drag-Handle + data-id; gegessene Mahlzeit fehlt.
self::assertSelectorExists('ul.sortierbar');
self::assertSelectorExists('li[data-id="' . $geplantId . '"]');
self::assertSelectorExists('li[data-id="' . $geplantId . '"] .drag-handle');
self::assertSelectorNotExists('li[data-id="' . $gegessenId . '"]');
}
public function testIndexKeepsPersistedSortOrder(): void
{
$client = $this->vorbereiten();
$a = $this->anlegen($client, 'Erste', '', []);
$b = $this->anlegen($client, 'Zweite', '', []);
$c = $this->anlegen($client, 'Dritte', '', []);
// Anordnung ändern: C, A, B (Formularfeld `ids[]`).
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [$c, $a, $b]]);
self::assertResponseStatusCodeSame(204);
// Persistenz: sortOrder neu nummeriert.
$this->em()->clear();
$repo = $this->em()->getRepository(MahlzeitEntity::class);
self::assertSame(1, $repo->find($c)->getSortOrder());
self::assertSame(2, $repo->find($a)->getSortOrder());
self::assertSame(3, $repo->find($b)->getSortOrder());
// Index rendert die neue Reihenfolge.
$client->request('GET', '/mahlzeiten');
$reihenfolge = $client->getCrawler()
->filter('li[data-id]')
->each(static fn (Crawler $node): int => (int) $node->attr('data-id'));
self::assertSame([$c, $a, $b], $reihenfolge);
}
public function testReorderUnbekannteIdLiefert404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [99999]]);
self::assertResponseStatusCodeSame(404);
}
public function testReorderAkzeptiertJsonIds(): void
{
$client = $this->vorbereiten();
$a = $this->anlegen($client, 'Erste', '', []);
$b = $this->anlegen($client, 'Zweite', '', []);
// SortableJS schickt JSON: {"ids": [...]} – hier als roher Body.
$client->request('POST', '/mahlzeiten/reorder', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], json_encode(['ids' => [$b, $a]]));
self::assertResponseStatusCodeSame(204);
$this->em()->clear();
$repo = $this->em()->getRepository(MahlzeitEntity::class);
self::assertSame(1, $repo->find($b)->getSortOrder());
self::assertSame(2, $repo->find($a)->getSortOrder());
}
public function testInspirationListetArchivGerichteMitAdopt(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Bolognese', '', ['mo']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
$client->request('GET', '/inspiration');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Inspiration');
self::assertSelectorTextContains('strong', 'Bolognese');
self::assertSelectorExists('form[action$="/adopt"] button[type="submit"]');
}
public function testInspirationLeerOhneArchivEintraege(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'NurGeplant', '', []);
$client->request('GET', '/inspiration');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('p', 'Noch keine Inspiration');
self::assertSelectorNotExists('li[id^="vorschlag-"]');
}
public function testInspirationWeitere5ExkludiertBereitsGezeigte(): void
{
$client = $this->vorbereiten();
foreach (['A', 'B', 'C', 'D', 'E', 'F'] as $buchstabe) {
$id = $this->anlegen($client, sprintf('Gericht%s', $buchstabe), '', []);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
}
$client->request('GET', '/inspiration');
self::assertResponseIsSuccessful();
$ersteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
self::assertCount(5, $ersteSeite);
$ersteSeiteNamen = $ersteSeite->each(static fn (Crawler $n): string => $n->filter('strong')->text());
// "Weitere 5" führt mit Ausschluss-Liste zurück zur Inspiration.
$link = $client->getCrawler()->filter('a:contains("Weitere 5 Vorschläge")');
self::assertCount(1, $link);
self::assertStringContainsString('ausschluss=', (string) $link->link()->getUri());
$client->request('GET', (string) $link->link()->getUri());
$zweiteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
self::assertCount(1, $zweiteSeite);
self::assertNotContains($zweiteSeite->filter('strong')->text(), $ersteSeiteNamen);
}
public function testAdoptErstelltNeuenGeplantEintrag(): void
{
$client = $this->vorbereiten();
$quellId = $this->anlegen($client, 'Pasta', 'mit Knoblauch', ['di']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $quellId));
self::assertResponseIsSuccessful();
$client->request('POST', sprintf('/mahlzeiten/%d/adopt', $quellId));
self::assertResponseRedirects('/inspiration');
$this->em()->clear();
$repo = $this->em()->getRepository(MahlzeitEntity::class);
// Quelle bleibt unverändert im Archiv.
self::assertSame('gegessen', $repo->find($quellId)->getStatus());
// Neue Kopie: gleicher Name/Notiz/Wochentag, aber geplant + ohne gegessenAm.
$kopien = $repo->findBy(['name' => 'Pasta']);
self::assertCount(2, $kopien);
$neue = null;
foreach ($kopien as $kandidat) {
if ((int) $kandidat->getId() !== $quellId) {
$neue = $kandidat;
}
}
self::assertNotNull($neue);
self::assertSame('geplant', $neue->getStatus());
self::assertNull($neue->getGegessenAm());
self::assertSame('mit Knoblauch', $neue->getNotiz());
self::assertSame('di', $neue->getWochentage());
}
public function testAdoptUnbekannteIdLiefert404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/99999/adopt');
self::assertResponseStatusCodeSame(404);
}
/**
* Liefert die <ul>-Liste, die direkt unter der angegebenen <h2>-Überschrift
* (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt.