Zehn Blöcke für die Abschnitte der Pilotseite (Arbeitspaket C)
Dokumentschema v6 ergänzt iconcard, stat, quote, logos, cta, contact, divider, spacer, accordion und accordionitem - die Abschnittsarten, aus denen die Startseite von kant-it.de besteht. Symbole liegen als Inline-SVG aus einer festen Liste im Template: kein Symbolschriftsatz, keine externe Anfrage. Kontaktdaten werden zu tel:- und mailto:-Verweisen mit bereinigter Nummer. Das Akkordeon nutzt details/summary und braucht damit kein öffentliches JavaScript. Tabs sind bewusst nicht umgesetzt: ohne JavaScript gibt es dafür keine zugängliche Lösung, und eine CSS-Variante über Radiobuttons wird als Auswahlfeld statt als Tabliste angekündigt. Begründung in ADR 015. Zwei Fehler von den Tests gefunden und behoben: die Regel "ein Akkordeon-Eintrag gehört in ein Akkordeon" griff nicht auf oberster Dokumentebene, und ein JavaScript-Test suchte im vorherigen Dokumentstand, weil history.document vor history.change ausgewertet wurde. Geprüft: 107 PHP-Tests / 380 Assertions unter PHP 8.2.12 und 8.4.22, 13 JavaScript-Modelltests, Lint/Format/Container. Keine Browser- oder Lighthouse-Läufe; für die neuen Blöcke liegt damit keine visuelle Kontrolle und keine Barrierefreiheitsprüfung vor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ebd5883593
commit
8600384f1a
14 files changed
+442
-27
No files matched your search
@@ -666,6 +666,118 @@ final class CmsTest extends TestCase
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** A page built from the section kinds the pilot start page actually uses. */
|
||||
private static function pilotDocument(): array
|
||||
{
|
||||
return [
|
||||
'schemaVersion' => 6,
|
||||
'title' => 'Kant-IT',
|
||||
'description' => 'Ihr IT-Partner.',
|
||||
'blocks' => [
|
||||
['id' => 'hero', 'type' => 'section', 'children' => [
|
||||
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Digitale Exzellenz', 'level' => 1]],
|
||||
['id' => 'stat1', 'type' => 'stat', 'props' => ['value' => '15+', 'label' => 'Jahre Erfahrung']],
|
||||
['id' => 'sp', 'type' => 'spacer', 'props' => ['size' => 'large']],
|
||||
['id' => 'hr', 'type' => 'divider'],
|
||||
]],
|
||||
['id' => 'leistungen', 'type' => 'section', 'children' => [
|
||||
['id' => 'card1', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'App-Entwicklung', 'text' => 'Für iOS und Android.', 'href' => '/leistungen']],
|
||||
['id' => 'card2', 'type' => 'iconcard', 'props' => ['icon' => 'shop', 'title' => 'E-Commerce', 'text' => '', 'href' => '']],
|
||||
]],
|
||||
['id' => 'stimmen', 'type' => 'section', 'children' => [
|
||||
['id' => 'q1', 'type' => 'quote', 'props' => ['text' => 'Kant-IT denkt mit.', 'author' => 'Malerservice Drews', 'role' => 'Zufriedener Kunde', 'rating' => 5]],
|
||||
['id' => 'logos', 'type' => 'logos', 'children' => [
|
||||
['id' => 'logo1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Partner', 'decorative' => false, 'width' => 200, 'height' => 80]],
|
||||
]],
|
||||
]],
|
||||
['id' => 'fragen', 'type' => 'accordion', 'children' => [
|
||||
['id' => 'f1', 'type' => 'accordionitem', 'props' => ['title' => 'Wie schnell antwortet ihr?', 'open' => true], 'children' => [
|
||||
['id' => 'f1t', 'type' => 'text', 'props' => ['text' => 'In der Regel innerhalb von 24 Stunden.']],
|
||||
]],
|
||||
]],
|
||||
['id' => 'kontakt', 'type' => 'section', 'children' => [
|
||||
['id' => 'cta1', 'type' => 'cta', 'props' => ['title' => 'Bereit?', 'text' => 'Kostenlose Erstberatung.', 'buttonText' => 'Jetzt anfragen', 'href' => '#kontakt']],
|
||||
['id' => 'adr', 'type' => 'contact', 'props' => ['phone' => '+49 7221 99 57 150', 'email' => 'info@kant-it.de', 'address' => 'Ooser Hauptstr. 51, Baden-Baden']],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testPilotSectionsRoundTripAndRenderWithoutJavaScript(): void
|
||||
{
|
||||
$document = self::pilotDocument();
|
||||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
||||
$this->pages->publish('page-1', $revision, 1);
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertSame(200, $this->request('/')->getStatusCode());
|
||||
// Every new block reaches the output.
|
||||
foreach (['block-iconcard', 'block-stat', 'block-quote', 'block-logos', 'block-cta', 'block-contact', 'block-divider', 'block-spacer', 'block-accordionitem'] as $marker) {
|
||||
self::assertStringContainsString($marker, $html, $marker);
|
||||
}
|
||||
// Contact details become real links, with the phone number stripped for the href.
|
||||
self::assertStringContainsString('href="tel:+4972219957150"', $html);
|
||||
self::assertStringContainsString('href="mailto:info@kant-it.de"', $html);
|
||||
// The accordion is a native details element, so it works without scripting.
|
||||
self::assertStringContainsString('<details id="f1" class="block-accordionitem" open>', $html);
|
||||
self::assertStringContainsString('<summary>Wie schnell antwortet ihr?</summary>', $html);
|
||||
// Icons are inline SVG, not an icon font or an external request.
|
||||
self::assertStringContainsString('<svg class="block-icon"', $html);
|
||||
self::assertStringNotContainsString('<script', $html);
|
||||
// The rating is announced for screen readers rather than left as decorative stars.
|
||||
self::assertStringContainsString('Bewertung: 5 von 5', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{array}>
|
||||
*/
|
||||
public static function rejectedV6Blocks(): iterable
|
||||
{
|
||||
$wrap = static function (array $block): array {
|
||||
$document = DemoContent::starter('Titel');
|
||||
$document['schemaVersion'] = 6;
|
||||
$document['blocks'][] = $block;
|
||||
|
||||
return [$document];
|
||||
};
|
||||
yield 'unknown icon' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'rocket', 'title' => 'T', 'text' => '']]);
|
||||
yield 'iconcard without title' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => ' ', 'text' => '']]);
|
||||
yield 'iconcard with unsafe link' => $wrap(['id' => 'x', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '', 'href' => 'javascript:alert(1)']]);
|
||||
yield 'rating out of range' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => 6]]);
|
||||
yield 'rating not a number' => $wrap(['id' => 'x', 'type' => 'quote', 'props' => ['text' => 'T', 'author' => 'A', 'role' => '', 'rating' => '5']]);
|
||||
yield 'empty contact block' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => '', 'address' => '']]);
|
||||
yield 'invalid phone' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => 'ruf mich an', 'email' => '', 'address' => '']]);
|
||||
yield 'invalid email' => $wrap(['id' => 'x', 'type' => 'contact', 'props' => ['phone' => '', 'email' => 'keine-adresse', 'address' => '']]);
|
||||
yield 'unknown spacer size' => $wrap(['id' => 'x', 'type' => 'spacer', 'props' => ['size' => 'gigantic']]);
|
||||
yield 'cta without target' => $wrap(['id' => 'x', 'type' => 'cta', 'props' => ['title' => 'T', 'text' => '', 'buttonText' => 'Los', 'href' => 'ftp://example.com']]);
|
||||
yield 'logos with a text child' => $wrap(['id' => 'x', 'type' => 'logos', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'kein Logo']]]]);
|
||||
yield 'accordion with a text child' => $wrap(['id' => 'x', 'type' => 'accordion', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'direkt']]]]);
|
||||
yield 'accordion entry outside an accordion' => $wrap(['id' => 'x', 'type' => 'accordionitem', 'props' => ['title' => 'T', 'open' => false], 'children' => []]);
|
||||
yield 'divider with children' => $wrap(['id' => 'x', 'type' => 'divider', 'children' => [['id' => 'y', 'type' => 'text', 'props' => ['text' => 'a']]]]);
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedV6Blocks')]
|
||||
public function testRejectsInvalidV6BlocksBeforeWriting(array $document): void
|
||||
{
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testV6BlocksNeedSchemaSixAndStayRejectedInOlderDocuments(): void
|
||||
{
|
||||
$document = DemoContent::starter('Titel');
|
||||
$document['schemaVersion'] = 5;
|
||||
$document['blocks'][] = ['id' => 'karte', 'type' => 'iconcard', 'props' => ['icon' => 'app', 'title' => 'T', 'text' => '']];
|
||||
try {
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
self::fail('Expected schema v5 to reject a v6 block.');
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
$document['schemaVersion'] = 6;
|
||||
self::assertNotEmpty($this->pages->saveDraft('page-1', $document, 0));
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
|
||||
Reference in new issue
Block a user