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
@@ -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.