b2in/tests/Feature/HomeHeroSlideshowTest.php
Kevin Adametz fcb10be120 Interior-Feinschliff: Hero-Slideshow, Lektorat, Rechtstexte, Aufräumen und Korrekturen 08.10.
Startseite und Heroes
- Hero-Slideshow (11 Motive inkl. Febrü) mit Infokarte, Laufzeit-Linie, größeren Klickflächen,
  Goldrahmen und mobiler Karte unter dem Bild; Zeile über der Überschrift, neue Vertrauenspunkte
- Hero-Stil auf Marken, Über uns und Kontakt übertragen, Breadcrumb entfernt
- Gründer-Leiste, Vision-Box auf Ocker, Showroom-Display in Box mit „Größer ansehen“/Vollbild
- Akzentfarbe #a8741f, größere Serif-Überschriften in der Interior-Welt
- Marken: Bildpause mit Zitat; Vereine aktualisiert und verlinkt

Inhalte
- Lektorat aller angezeigten Texte, /about-Korrekturen, Rechtstexte (DDG, TDDDG, Hosting Hetzner)
- Ablauf Maßanfertigung überall einheitlich, Magazin-Beitrag neu, alte Beiträge offline,
  deutsches Datum/Lesezeit, FAQ Projektausstattung, Otterpohl
- Referenzen-Seite per CMS-Schalter ausblendbar

CMS und Technik
- Tote Sektionen, Felder, Seiten und Komponenten entfernt und archiviert (dev/2026-08-28/archiv-*)
- Entwurf /entwurf, Farb-Schalter und /welcome entfernt; Test-Routen nur noch lokal
- Seitenbaum-Beschreibungen geprüft, fehlende CMS-Felder ergänzt, Markenliste aus „Kernmarken“
- Admin-Bildvorschauen repariert, verwendete Bilder in die Mediathek übernommen
- Seeder für das Deployment siehe dev/2026-08-28/entwicklungsplan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:15 +00:00

200 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
use App\Livewire\Web\Components\Sections\Hero;
use Database\Seeders\HomeHeroSlidesSeeder;
use FluxCms\Core\Models\CmsContent;
use Livewire\Livewire;
/**
* Slideshow im Hero der Startseite (07.10.2026).
*/
function heroHtml(string $html): string
{
$start = strpos($html, 'id="hero"');
return substr($html, $start, strpos($html, '</section>', $start) - $start);
}
it('runs the eleven slideshow images in the homepage hero', function () {
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
expect(substr_count($hero, 'data-hero-slide='))->toBe(11)
->and($hero)->toContain('/img/assets/b2in/slideshow/rotpunkt-fenix-house.webp')
->toContain('/img/assets/b2in/slideshow/rotpunkt-bad-mk04.webp')
->toContain('aria-roledescription="carousel"')
->toContain('interval: 7000')
->toContain('role="tablist"')
->toContain('Räume aus einer Hand.')
->not->toContain('hero-room.jpg');
// Erstes Bild sofort, die übrigen lazy.
expect(substr_count($hero, 'loading="lazy"'))->toBe(10);
});
it('changes the info card with the slide and puts the controls below the whole block', function () {
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
expect(substr_count($hero, 'data-hero-card='))->toBe(11)
->and($hero)->toContain('Ein ganzes Haus, durchgängig eingerichtet')
->toContain('Ankleide Solitär in Eiche Tano')
->toContain('Bad mit Waschtisch in Holz');
// Die Punkte stehen hinter dem Rahmen, nicht im Bild.
$frameEnd = strrpos($hero, 'data-hero-card=');
expect(strpos($hero, 'role="tablist"'))->toBeGreaterThan($frameEnd);
});
it('puts the info card below the image on mobile, over it on desktop, and fades the texts one after another', function () {
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
// Mobil: Bildfläche 4:3 oben, Karte als Streifen darunter. Ab lg schwebt sie unten links.
// Keine Klasse mit „card“ – die Glass-Flow-Regel [class*="card"] würde sie sonst umstellen.
expect($hero)->toContain('class="relative aspect-[4/3] w-full shrink-0 lg:absolute lg:inset-0 lg:aspect-auto" data-hero-stage')
->toMatch('/class="relative bg-white px-5 py-4[^"]*lg:absolute lg:bottom-6 lg:left-6[^"]*" data-hero-info/')
->not->toMatch('/class="[^"]*card[^"]*" data-hero-info/');
// Die Karte steht hinter der Bildfläche, nicht in ihr.
expect(strpos($hero, 'data-hero-info'))->toBeGreaterThan(strpos($hero, 'data-hero-progress'));
expect($hero)
->toContain('data-[active=true]:opacity-100 data-[active=true]:delay-500');
// Nur die erste Karte ist sichtbar; ohne fest gesetzte Zustandsklassen, die Alpine stehen ließe.
preg_match_all('/data-hero-card="\d+"\s+data-active="(true|false)"/', $hero, $states);
expect($states[1])->toBe(['true', ...array_fill(0, 10, 'false')])
->and($hero)->not->toContain('x-bind:class')
// Die Marke in der Karte trägt den Akzent (#a8741f, 07.10.2026).
->toContain('<div class="text-base font-medium text-secondary lg:text-xl">B2in Interior</div>');
expect(file_get_contents(resource_path('css/web/shared-styles.css')))
->toContain('box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 20px 5px;');
});
it('uses the label and caption for the alt text when none is maintained', function () {
expect(Hero::activeSlides([['image' => 'a.webp', 'label' => 'CABINET', 'caption' => 'Ankleide']])[0]['alt'])
->toBe('CABINET – Ankleide')
->and(Hero::activeSlides([['image' => 'a.webp', 'caption' => 'Ankleide']])[0]['alt'])->toBe('Ankleide');
});
it('falls back to the fixed card texts for slides without label and caption', function () {
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
'title' => 'Titel', 'subtitle' => '', 'cta1_text' => 'Marken', 'cta1_link' => '/marken',
'card_title' => 'B2in Interior', 'card_text' => 'Einrichtung, <span class="text-secondary">die bleibt.</span>',
'slides' => [['image' => 'a.webp'], ['image' => 'b.webp', 'label' => 'CABINET', 'caption' => 'Ankleide']],
]]]);
Livewire::test(Hero::class)
->assertSeeHtml('Einrichtung, <span class="text-secondary">die bleibt.</span>')
->assertSee('CABINET')
->assertSee('Ankleide');
});
it('ships existing WebP files for every default slide', function () {
foreach (trans('b2in.themes.b2in.hero.slides', [], 'de') as $slide) {
expect(public_path('img/assets/'.$slide['image']))->toBeFile()
->and($slide['alt'])->not->toBe('');
}
});
it('skips inactive slides and slides without an image', function () {
expect(Hero::activeSlides([
['image' => 'a.webp', 'label' => 'CABINET', 'alt' => 'Ankleide', 'active' => true],
['image' => 'b.webp', 'label' => 'Rotpunkt', 'active' => false],
['image' => 'c.webp', 'label' => 'Febrü', 'active' => '0'],
['image' => '', 'label' => 'Leer'],
['image' => 'd.webp', 'label' => 'Otterpohl'],
]))->toBe([
['image' => 'a.webp', 'alt' => 'Ankleide', 'label' => 'CABINET', 'caption' => ''],
['image' => 'd.webp', 'alt' => 'Otterpohl', 'label' => 'Otterpohl', 'caption' => ''],
]);
});
it('falls back to the single image when no slide is active', function () {
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
'title' => 'Titel', 'subtitle' => '', 'image' => 'b2in/hero-room.jpg', 'image_alt' => 'Raum',
'cta1_text' => 'Marken', 'cta1_link' => '/marken', 'card_title' => '', 'card_text' => '',
'slides' => [['image' => 'x.webp', 'active' => false]],
]]]);
Livewire::test(Hero::class)
->assertSet('slides', [['image' => 'b2in/hero-room.jpg', 'alt' => 'Raum', 'label' => '', 'caption' => '']])
->assertDontSee('aria-roledescription="carousel"', false)
->assertDontSee('role="tablist"', false);
});
it('clamps the interval to 3 to 30 seconds', function (mixed $interval, int $expected) {
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
'title' => 'Titel', 'subtitle' => '', 'cta1_text' => 'Marken', 'cta1_link' => '/marken', 'card_title' => '', 'card_text' => '',
'interval' => $interval, 'slides' => [['image' => 'a.webp'], ['image' => 'b.webp']],
]]]);
expect(Livewire::test(Hero::class)->instance()->intervalMs)->toBe($expected);
})->with([[1, 3000], [7, 7000], ['4.5', 4500], [120, 30000]]);
it('adds missing card captions to slides that were stored before', function () {
$row = CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
'title' => 'Titel',
'slides' => [
['image' => 'b2in/slideshow/rotpunkt-fenix-house.webp', 'label' => 'Rotpunkt Küchen'],
['image' => 'b2in/slideshow/cabinet-flur-schrank-eiche-tano.webp', 'caption' => 'Eigener Text'],
['image' => 'eigenes.webp'],
],
]]]);
$this->seed(HomeHeroSlidesSeeder::class);
expect($row->refresh()->getTranslation('value', 'de')['slides'])->toBe([
['image' => 'b2in/slideshow/rotpunkt-fenix-house.webp', 'label' => 'Rotpunkt Küchen', 'caption' => 'Ein ganzes Haus, durchgängig eingerichtet'],
['image' => 'b2in/slideshow/cabinet-flur-schrank-eiche-tano.webp', 'caption' => 'Eigener Text'],
['image' => 'eigenes.webp'],
]);
});
it('adds the slides to a stored hero once and keeps maintained ones', function () {
$row = CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => [
'de' => ['title' => 'Eigener Titel'],
'en' => ['title' => 'Own title', 'slides' => [['image' => 'eigenes.webp']]],
]]);
$this->seed(HomeHeroSlidesSeeder::class);
$this->seed(HomeHeroSlidesSeeder::class);
$value = $row->refresh()->getTranslations('value');
expect($value['de']['title'])->toBe('Eigener Titel')
->and($value['de']['slides'])->toHaveCount(11)
->and($value['de']['interval'])->toBe(7)
->and($value['en']['slides'])->toBe([['image' => 'eigenes.webp']]);
});
it('frames the hero in a gold gradient and sets the slideshow off with a white hairline', function () {
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
expect($hero)->toContain('bg-hero-container hero-frame')
->toMatch('/rounded-2xl border border-white! bg-muted/');
$css = file_get_contents(resource_path('css/web/theme-b2in.css'));
expect($css)->toContain('.world-interior .variante-glass-flow div.hero-frame')
->toContain('border: 1.5px solid transparent;')
->toContain('linear-gradient(to bottom right, hsl(40, 5.65%, 95.65%), hsl(40, 15.95%, 93.9%)) padding-box')
->toContain(') border-box;');
});
it('shows a progress line in the image and gives the dots a larger click area', function () {
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
expect($hero)->toContain('data-hero-progress')
->toContain('scaleX(${Math.min(elapsed / interval, 1)})')
->toContain('requestAnimationFrame')
->not->toContain('setInterval')
->not->toContain('Höchstens 100 ms');
// Elf Punkte, jeder als Klickfläche mit Abstand um den dünnen Balken.
expect(substr_count($hero, 'data-hero-dot'))->toBe(11)
->and($hero)->toContain('class="group cursor-pointer px-1 py-4"');
expect(file_get_contents(resource_path('css/web/theme-b2in.css')))
->toContain(".variante-glass-flow button[data-hero-dot]:hover {\n box-shadow: none;");
});