*/ function consoleItems(): array { return collect(Navigation::console())->flatMap(fn (array $g) => $g['items'])->all(); } /** * Die Gruppen-Überschrift, wie x-shell.nav sie zeichnet. * * Auf das Element geprüft und nicht auf das blanke Wort: „Post" steckt auch in * „Posteingang". Ein Teilstring-Vergleich auf ein kurzes Wort besteht aus * Gründen, die mit der Prüfung nichts zu tun haben. */ function navLabel(string $label): string { return 'lbl">'.e($label).'

'; } it('verliert beim Umsortieren keinen Eintrag und legt keinen doppelt an', function () { $routes = array_column(consoleItems(), 0); // Doppelte wären beim Verschieben von Hand die naheliegende Panne: einmal // an der neuen Stelle eingefügt, an der alten vergessen. expect($routes)->toHaveCount(count(array_unique($routes))); // Auch über beide Orte zusammen darf nichts doppelt stehen: eine Seite in // der Leiste UND als Kachel wäre die Verwirrung, die dieser Umbau // abschaffen soll. $alle = consoleRouteNames(); expect($alle)->toHaveCount(count(array_unique($alle))); }); it('laesst keine Seite fallen, die vor dem Umbau in der Leiste stand', function () { // Die achtundzwanzig Einträge von vor dem 4.8.2026, ausgeschrieben statt // aus der Geschichte gelesen: ein Test, der sich seine Erwartung aus // demselben Repository holt, das er prüft, prüft nichts. $vorher = [ 'admin.overview', 'admin.customers', 'admin.instances', 'admin.plans', 'admin.hosts', 'admin.datacenters', 'admin.capacity', 'admin.proxy-hosts', 'admin.vpn', 'admin.provisioning', 'admin.maintenance', 'admin.incidents', 'admin.mail-pace', 'admin.revenue', 'admin.invoices', 'admin.payment-problems', 'admin.finance', 'admin.inbox', 'admin.mail-log', 'admin.templates', 'admin.mail.preview', 'admin.mail', 'admin.integrations', 'admin.readiness', 'admin.dpa', 'admin.settings', 'admin.roles', 'admin.open-work', ]; expect(array_values(array_diff($vorher, consoleRouteNames())))->toBe([]); }); it('fuehrt keine Kachel, deren Route es nicht gibt', function () { foreach (Navigation::setup() as $gruppe => $kacheln) { foreach ($kacheln as [$route, , $key]) { expect(Route::has($route))->toBeTrue("Route {$route} (Kachel {$key} in {$gruppe}) existiert nicht."); expect(__('setup.tile.'.$key))->not->toBe('setup.tile.'.$key, "Kachel {$key} hat keinen Text."); } } }); it('führt keinen Eintrag, dessen Route es nicht gibt', function () { foreach (consoleItems() as [$route, , $key]) { expect(Route::has($route))->toBeTrue("Route {$route} (Eintrag {$key}) existiert nicht."); } }); it('gibt jedem Eintrag eine übersetzte Beschriftung in beiden Sprachen', function () { foreach (['de', 'en'] as $locale) { app()->setLocale($locale); foreach (consoleItems() as [, , $key]) { expect(__('admin.nav.'.$key))->not->toBe('admin.nav.'.$key, "{$key} fehlt in lang/{$locale}/admin.php"); } foreach (Navigation::console() as $group) { if ($group['label'] !== null) { expect($group['label'])->not->toStartWith('admin.nav_group.'); } } } }); it('stellt jede Gruppe unter eine Überschrift — außer der Übersicht', function () { $groups = Navigation::console(); // Die erste Gruppe trägt allein die Übersicht und braucht keine // Überschrift: sie ist die Seite, auf der man landet. expect($groups[0]['label'])->toBeNull() ->and($groups[0]['items'])->toHaveCount(1) ->and($groups[0]['items'][0][0])->toBe('admin.overview'); // Die LETZTE trägt allein die Tür zum Einrichtungsbereich und braucht // ebenfalls keine: eine Überschrift über einem einzigen Eintrag benennt // nur ihn selbst noch einmal. $letzte = $groups[count($groups) - 1]; expect($letzte['label'])->toBeNull() ->and($letzte['items'])->toHaveCount(1) ->and($letzte['items'][0][0])->toBe('admin.setup'); // Alle dazwischen haben eine. Eine namenlose Gruppe MITTEN in der Leiste // wäre wieder der Zustand, aus dem diese Arbeit kommt: schwebende Einträge // ohne Zugehörigkeit. foreach (array_slice($groups, 1, -1) as $group) { expect($group['label'])->not->toBeNull()->and($group['label'])->not->toBe(''); } }); it('lässt keine Gruppe so groß werden, dass sie wieder alles verschluckt', function () { // „Betrieb" hielt zwölf. Die Grenze ist kein Selbstzweck: ab etwa sieben // liest man eine Gruppe nicht mehr auf einen Blick, und dann fängt das // Sortieren von vorne an. foreach (Navigation::console() as $group) { expect(count($group['items']))->toBeLessThanOrEqual(7, "Gruppe „{$group['label']}\" ist auf ".count($group['items']).' Einträge gewachsen.'); } }); it('zeigt einem Betreiber ohne Berechtigungen keine leere Überschrift', function () { // „Post" besteht ausschließlich aus geschützten Einträgen. Vor dieser // Arbeit druckte x-shell.nav die Überschrift bedingungslos — die Gruppe // hätte also als Wort ohne Inhalt dagestanden. // // „Read-only" trägt weder customers.manage noch mail.manage, sieht also // keinen einzigen Eintrag dieser Gruppe. $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator'); $html = $this->get(route('admin.overview'))->assertOk()->getContent(); // Auf das Überschriften-Element geprüft, nicht auf das blanke Wort: // „Post" steckt auch in „Posteingang" und „Postausgang", und ein // Teilstring-Vergleich hätte hier etwas anderes geprüft als behauptet. expect($html)->not->toContain(navLabel(__('admin.nav_group.post'))); }); it('zeigt die Gruppe, sobald der Betreiber einen ihrer Einträge sehen darf', function () { // Die Kehrseite: die Überschrift darf nicht aus Vorsicht verschwinden. $this->actingAs(admin(), 'operator'); $html = $this->get(route('admin.overview'))->assertOk()->getContent(); foreach (Navigation::console() as $group) { if ($group['label'] !== null) { expect($html)->toContain(navLabel($group['label'])); } } }); it('zaehlt die Zahlen neben den Eintraegen nur einmal je Minute', function () { // Diese Leiste rendert auf JEDER Konsolenseite. Ohne Zwischenspeicher wären // das drei Abfragen je Seitenaufruf — eine Abgabe, die man erst sucht, wenn // die Konsole längst langsam ist. Cache::forget('nav.attention'); $erste = Navigation::attentionCounts(); expect(Cache::has('nav.attention'))->toBeTrue() ->and(Navigation::attentionCounts())->toBe($erste); }); it('zeigt keine Plakette, laesst den Eintrag aber stehen, wenn nichts offen ist', function () { // Eine Seite, die verschwindet, sobald nichts offen ist, ist genau dann // nicht erreichbar, wenn man nachsehen will, ob wirklich nichts offen ist. Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0]); $this->actingAs(admin(), 'operator'); $html = $this->get(route('admin.overview'))->assertOk()->getContent(); expect($html)->toContain(navLabel(__('admin.nav_group.attention'))) ->and($html)->toContain(__('admin.nav.incidents')); }); it('nennt die Zahl, sobald etwas offen ist', function () { Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 7, 'payment_problems' => 0]); $this->actingAs(admin(), 'operator'); $html = $this->get(route('admin.overview'))->assertOk()->getContent(); expect($html)->toContain('>7'); }); it('zeigt im Einrichtungsbereich nur Kacheln, die dieser Betreiber oeffnen darf', function () { // Eine Kachel, die zu einer 403 führt, ist schlimmer als eine fehlende: // sie sieht aus wie ein Weg. Livewire::actingAs(admin(), 'operator') ->test(Setup::class) ->assertSee(__('setup.group.delivery')) ->assertSee(__('setup.tile.plans')); $html = Livewire::actingAs(Operator::factory()->role('Read-only')->create(), 'operator') ->test(Setup::class) ->html(); expect($html)->not->toContain(__('setup.tile.plans')); }); it('haelt die Tuer zur Einrichtung markiert, solange man dahinter steht', function () { // Codex R15, P2 am Umbau: die neun verschobenen Seiten stehen nicht mehr in // der Leiste. Ohne diese Regel ist auf keiner von ihnen ein Eintrag // markiert — die Konsole sagt dann nicht mehr, wo man ist. $this->actingAs(admin(), 'operator'); foreach (Navigation::setup() as $kacheln) { foreach ($kacheln as [$route, , $key]) { $this->get(route($route)); expect(Navigation::isCurrent('admin.setup', console: true)) ->toBeTrue("Auf {$key} ({$route}) ist die Einrichtung nicht markiert."); } } }); it('nennt eine verschobene Seite weiter in der Brotkrume', function () { // Derselbe Schaden aus derselben Ursache, nur an der anderen Stelle: // currentLabel() sah nur in der Leiste nach und lieferte fuer die neun // Seiten null — der Kopf haette dort nur noch „Konsole" gesagt. $this->actingAs(admin(), 'operator'); $this->get(route('admin.plans')); expect(Navigation::currentLabel(console: true))->toBe(__('admin.nav.plans')); }); it('markiert die Einrichtung nicht, wenn man ganz woanders steht', function () { // Die Kehrseite: eine Markierung, die immer leuchtet, ist keine. $this->actingAs(admin(), 'operator'); $this->get(route('admin.customers')); expect(Navigation::isCurrent('admin.setup', console: true))->toBeFalse(); }); it('macht die Versionszeile im Fuss zum Weg zur Aktualisierung', function () { // Gemeldet vom Betreiber: „Einstellungen ist in der Einrichtung, wegen den // Versionsupdates — man sieht es nicht, ohne genau hinzuklicken." Die // Version stand im Fuss schon, aber als toter Text. Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0, 'update' => 0]); $this->actingAs(admin(), 'operator'); $html = $this->get(route('admin.overview'))->assertOk()->getContent(); expect($html)->toContain(route('admin.settings').'?tab=installation') // Solange nichts ansteht, bleibt sie leise: eine Zeile, die immer // ruft, ruft nie. ->and($html)->not->toContain(__('admin.nav_update_waiting')); }); it('sagt im Fuss, dass eine Aktualisierung wartet', function () { Cache::forever('nav.attention', ['open_work' => 0, 'incidents' => 0, 'payment_problems' => 0, 'update' => 1]); $this->actingAs(admin(), 'operator'); $html = $this->get(route('admin.overview'))->assertOk()->getContent(); expect($html)->toContain(__('admin.nav_update_waiting')); });