'test-sub', 'auth_name' => 'Jens', 'auth_email' => 'jens@ljh31.de', ]; private KernelBrowser $client; public function testIndexRedirectsToLoginWithoutSession(): void { $client = $this->vorbereiten(false); $client->request('GET', '/mahlzeiten'); self::assertResponseStatusCodeSame(302); self::assertResponseRedirects('/login?redirect=/mahlzeiten'); } public function testIndexRendersEmptyListForAuthedUser(): void { $client = $this->vorbereiten(); $client->request('GET', '/mahlzeiten'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Mahlzeiten'); self::assertSelectorTextContains('p', 'Noch keine Mahlzeiten'); } public function testNeuRendersEmptyForm(): void { $client = $this->vorbereiten(); $client->request('GET', '/mahlzeiten/neu'); self::assertResponseIsSuccessful(); self::assertSelectorExists('input[name="name"]'); self::assertSelectorExists('textarea[name="notiz"]'); self::assertSelectorExists('input[name="wochentage[]"][value="mo"]'); } public function testCreateRedirectsAndPersists(): void { $client = $this->vorbereiten(); $client->request('POST', '/mahlzeiten', [ 'name' => 'Bolognese', 'notiz' => 'mit Basilikum', 'wochentage' => ['mo', 'mi'], ]); self::assertResponseRedirects('/mahlzeiten'); $entity = $this->em()->getRepository(MahlzeitEntity::class)->findOneBy(['name' => 'Bolognese']); self::assertNotNull($entity); self::assertSame('mit Basilikum', $entity->getNotiz()); self::assertSame('mo,mi', $entity->getWochentage()); self::assertSame('geplant', $entity->getStatus()); } public function testCreateWithEmptyNameRendersFormWithError(): void { $client = $this->vorbereiten(); $client->request('POST', '/mahlzeiten', [ 'name' => ' ', 'notiz' => 'x', 'wochentage' => ['di'], ]); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('div', 'Name ist ein Pflichtfeld'); self::assertCount(0, $this->em()->getRepository(MahlzeitEntity::class)->findAll()); } public function testUpdatePersistsChanges(): void { $client = $this->vorbereiten(); $id = $this->anlegen($client, 'Curry', '', []); $client->request('POST', sprintf('/mahlzeiten/%d', $id), [ 'name' => 'Currywurst', 'notiz' => 'scharf', 'wochentage' => ['do'], ]); self::assertResponseRedirects('/mahlzeiten'); $this->em()->clear(); $entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id); self::assertSame('Currywurst', $entity->getName()); self::assertSame('scharf', $entity->getNotiz()); self::assertSame('do', $entity->getWochentage()); } public function testDeleteRemovesMeal(): void { $client = $this->vorbereiten(); $id = $this->anlegen($client, 'Pizza', '', []); $client->request('POST', sprintf('/mahlzeiten/%d/loeschen', $id)); self::assertResponseRedirects('/mahlzeiten'); $this->em()->clear(); self::assertNull($this->em()->getRepository(MahlzeitEntity::class)->find($id)); } public function testBearbeitenUnknownIdReturns404(): void { $client = $this->vorbereiten(); $client->request('GET', '/mahlzeiten/99999/bearbeiten'); self::assertResponseStatusCodeSame(404); } public function testIndexShowsPersistedMealWithActions(): void { $client = $this->vorbereiten(); $this->anlegen($client, 'Lasagne', 'Samstag', ['sa']); $client->request('GET', '/mahlzeiten'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('strong', 'Lasagne'); self::assertSelectorTextContains('span.tag', 'Sa'); self::assertSelectorExists('a[href$="/bearbeiten"]'); self::assertSelectorExists('form[action$="/loeschen"]'); } // ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ---- 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/tage'); $crawler = $client->getCrawler(); self::assertResponseIsSuccessful(); // Curry exakt unter "Di", nicht unter "Sa". $diUl = $this->listeNachHeading($crawler, 'Di'); self::assertStringContainsString('Curry', $diUl->html()); self::assertStringNotContainsString('Pizza', $diUl->html()); // Pizza exakt unter "Sa". $saUl = $this->listeNachHeading($crawler, 'Sa'); self::assertStringContainsString('Pizza', $saUl->html()); self::assertStringNotContainsString('Curry', $saUl->html()); // Bolognese hat zwei Wochentage → erscheint unter "Mo" UND "Mi". self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mo')->html()); self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html()); } public function testTageViewGroupsMealWithoutWeekdayUnderOhneTag(): void { $client = $this->vorbereiten(); $this->anlegen($client, 'Reis', '', []); $client->request('GET', '/mahlzeiten/tage'); self::assertResponseIsSuccessful(); self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html()); } public function testCheckOffReturnsGegessenPartial(): void { $client = $this->vorbereiten(); $id = $this->anlegen($client, 'Pasta', '', ['mo']); $client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id)); self::assertResponseIsSuccessful(); // htmx-Fragment: genau das Mahlzeiten-Partial, keine komplette Seite. self::assertSelectorExists('li#mahlzeit-' . $id); self::assertSelectorTextContains('span.status', 'gegessen'); $content = (string) $client->getResponse()->getContent(); self::assertStringNotContainsString('em()->clear(); $entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id); self::assertSame('gegessen', $entity->getStatus()); self::assertNotNull($entity->getGegessenAm()); } public function testUncheckRestoresGeplantStatus(): void { $client = $this->vorbereiten(); $id = $this->anlegen($client, 'Suppe', '', []); $client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id)); self::assertResponseIsSuccessful(); $client->request('POST', sprintf('/mahlzeiten/%d/uncheck', $id)); self::assertResponseIsSuccessful(); self::assertSelectorExists('li#mahlzeit-' . $id); self::assertSelectorTextContains('span.status', 'geplant'); // Persistenz: Status zurück auf "geplant" + gegessenAm zurückgesetzt. $this->em()->clear(); $entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id); self::assertSame('geplant', $entity->getStatus()); self::assertNull($entity->getGegessenAm()); } public function testArchivShowsOnlyGegessenMeals(): void { $client = $this->vorbereiten(); $this->anlegen($client, 'GeplanteMahlzeit', '', ['mo']); $gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', ['di']); $client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId)); self::assertResponseIsSuccessful(); $client->request('GET', '/mahlzeiten/archiv'); self::assertResponseIsSuccessful(); self::assertSelectorTextContains('h1', 'Archiv'); // Nur die Ergebnisliste prüfen (Flash-Meldungen der Anleg-Requests ausblenden). $liste = $client->getCrawler()->filter('ul')->html(); self::assertStringContainsString('GegesseneMahlzeit', $liste); self::assertStringNotContainsString('GeplanteMahlzeit', $liste); } public function testCheckOffUnknownIdReturns404(): void { $client = $this->vorbereiten(); $client->request('POST', '/mahlzeiten/99999/checkoff'); self::assertResponseStatusCodeSame(404); } public function testUncheckUnknownIdReturns404(): void { $client = $this->vorbereiten(); $client->request('POST', '/mahlzeiten/99999/uncheck'); 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