From fa5894426ddbdb7ddf26d43fe59583ea4ef6ce1b Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 19 Sep 2026 17:09:15 +0200 Subject: [PATCH] =?UTF-8?q?M3:=20Reorder=20+=20Inspiration=20=E2=80=93=20S?= =?UTF-8?q?chritt=202=20(UI):=20Planungsliste=20mit=20SortableJS,=20Tagesa?= =?UTF-8?q?nsicht=20/mahlzeiten/tage,=20Inspiration=20/inspiration=20mit?= =?UTF-8?q?=20'Weitere=205'=20+=201-Klick-Adopt,=20Feature-Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Doku/plan.md | 10 +- config/routes.yaml | 24 +++ src/UI/Http/Essen/MahlzeitController.php | 149 ++++++++++++++- templates/layout/base.html.twig | 3 + templates/mahlzeiten/_mahlzeit.html.twig | 11 +- templates/mahlzeiten/index.html.twig | 48 ++++- templates/mahlzeiten/inspiration.html.twig | 61 ++++++ templates/mahlzeiten/tage.html.twig | 31 +++ .../UI/Essen/MahlzeitControllerTest.php | 176 +++++++++++++++++- 9 files changed, 488 insertions(+), 25 deletions(-) create mode 100644 templates/mahlzeiten/inspiration.html.twig create mode 100644 templates/mahlzeiten/tage.html.twig diff --git a/Doku/plan.md b/Doku/plan.md index 0a96d5c..faea34a 100644 --- a/Doku/plan.md +++ b/Doku/plan.md @@ -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 | diff --git a/config/routes.yaml b/config/routes.yaml index 91304df..5db88a7 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -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 diff --git a/src/UI/Http/Essen/MahlzeitController.php b/src/UI/Http/Essen/MahlzeitController.php index 2b9fe9f..864e49e 100644 --- a/src/UI/Http/Essen/MahlzeitController.php +++ b/src/UI/Http/Essen/MahlzeitController.php @@ -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 + */ + 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 + */ + 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} $werte * @return array diff --git a/templates/layout/base.html.twig b/templates/layout/base.html.twig index 7077b81..51c60f6 100644 --- a/templates/layout/base.html.twig +++ b/templates/layout/base.html.twig @@ -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 @@ essen.ljh31.de diff --git a/templates/mahlzeiten/_mahlzeit.html.twig b/templates/mahlzeiten/_mahlzeit.html.twig index 89256ab..ab4ea54 100644 --- a/templates/mahlzeiten/_mahlzeit.html.twig +++ b/templates/mahlzeiten/_mahlzeit.html.twig @@ -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
  • ). @param MahlzeitResponse m + @param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste) #} -
  • +
  • + {% if sortierbar is defined and sortierbar %} + ⋮ + {% endif %} {{ m.name }} {% if m.notiz != '' %}
    {{ m.notiz }}
    diff --git a/templates/mahlzeiten/index.html.twig b/templates/mahlzeiten/index.html.twig index c24a854..8e74bfd 100644 --- a/templates/mahlzeiten/index.html.twig +++ b/templates/mahlzeiten/index.html.twig @@ -7,25 +7,53 @@

    Mahlzeiten

    -
    Tagesansicht nach Wochentag
    +
    Planungsliste – per Drag & Drop sortieren
    {% if total == 0 %} -

    Noch keine Mahlzeiten angelegt.

    +

    Noch keine Mahlzeiten in der Planung.

    {% else %} - {% for tag, mahlzeiten in tage %} -

    {{ tag }}

    -
      - {% for m in mahlzeiten %} - {{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }} - {% endfor %} -
    - {% endfor %} +
      + {% for m in mahlzeiten %} + {{ include('mahlzeiten/_mahlzeit.html.twig', { m: m, sortierbar: true }) }} + {% endfor %} +
    {% endif %}
    {% endblock %} + +{% block javascripts %} + + +{% endblock %} diff --git a/templates/mahlzeiten/inspiration.html.twig b/templates/mahlzeiten/inspiration.html.twig new file mode 100644 index 0000000..b36afa4 --- /dev/null +++ b/templates/mahlzeiten/inspiration.html.twig @@ -0,0 +1,61 @@ +{% extends 'layout/base.html.twig' %} + +{% block title %}Inspiration · svc-essensplanung{% endblock %} + +{% block body %} +
    +
    +
    +

    Inspiration

    +
    Gerichte aus dem Archiv – was kochst du mal wieder?
    +
    + +
    + + {# Für "Weitere 5" / Adopt-Kontext: bisheriger Ausschluss + die IDs dieser Seite #} + {% set alleAusschluss = ausschluss + gezeigte %} + + {% if total == 0 %} +

    + Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag. +

    + {% else %} +
      + {% for v in vorschlaege %} +
    • +
      + {{ v.name }} + {% if v.notiz != '' %} +
      {{ v.notiz }}
      + {% endif %} + + {% if v.gegessenAm %} + zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }} + {% else %} + aus dem Archiv + {% endif %} + · {{ v.auftraete }}× in der Küche + +
      +
      +
      + {% for id in alleAusschluss %} + + {% endfor %} + +
      +
      +
    • + {% endfor %} +
    + {% if mehrVerfuegbar %} +

    + Weitere 5 Vorschläge +

    + {% endif %} + {% endif %} +
    +{% endblock %} diff --git a/templates/mahlzeiten/tage.html.twig b/templates/mahlzeiten/tage.html.twig new file mode 100644 index 0000000..6722cb3 --- /dev/null +++ b/templates/mahlzeiten/tage.html.twig @@ -0,0 +1,31 @@ +{% extends 'layout/base.html.twig' %} + +{% block title %}Tagesansicht · svc-essensplanung{% endblock %} + +{% block body %} +
    +
    +
    +

    Tagesansicht

    +
    Alle Mahlzeiten nach Wochentag
    +
    + +
    + + {% if total == 0 %} +

    Noch keine Mahlzeiten angelegt.

    + {% else %} + {% for tag, mahlzeiten in tage %} +

    {{ tag }}

    +
      + {% for m in mahlzeiten %} + {{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }} + {% endfor %} +
    + {% endfor %} + {% endif %} +
    +{% endblock %} diff --git a/tests/Functional/UI/Essen/MahlzeitControllerTest.php b/tests/Functional/UI/Essen/MahlzeitControllerTest.php index fe9bb4e..5e62498 100644 --- a/tests/Functional/UI/Essen/MahlzeitControllerTest.php +++ b/tests/Functional/UI/Essen/MahlzeitControllerTest.php @@ -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
      -Liste, die direkt unter der angegebenen

      -Überschrift * (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt.