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 = [];
|
||||
|
||||
@@ -176,3 +176,38 @@ test('the post list block carries only its filter, lifts the schema to v5 and un
|
||||
assert.deepEqual(history.document, fixture());
|
||||
assert.equal(history.document.schemaVersion, 1);
|
||||
});
|
||||
|
||||
test('v6 containers accept only their own children and an accordion starts with one entry', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
const accordion = history.change(doc => insertBlock(doc, 'accordion', 's2'));
|
||||
const block = locate(history.document.blocks, accordion).block;
|
||||
|
||||
assert.equal(history.document.schemaVersion, 6);
|
||||
// An empty accordion would render as nothing, so it comes with one entry.
|
||||
assert.equal(block.children.length, 1);
|
||||
assert.equal(block.children[0].type, 'accordionitem');
|
||||
assert.deepEqual(block.children[0].props, { title: 'Eine Frage', open: false });
|
||||
|
||||
// Only accordion entries go into an accordion, and an entry goes nowhere else.
|
||||
assert.equal(canContain(block, { type: 'text' }), false);
|
||||
assert.equal(canContain(block, { type: 'accordionitem' }), true);
|
||||
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
|
||||
assert.equal(canContain(block.children[0], { type: 'text' }), true);
|
||||
|
||||
// A logo strip takes images only, like a gallery.
|
||||
const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));
|
||||
const strip = locate(history.document.blocks, logos).block;
|
||||
assert.equal(canContain(strip, { type: 'image' }), true);
|
||||
assert.equal(canContain(strip, { type: 'iconcard' }), false);
|
||||
|
||||
// Leaf blocks stay leaves and keep their defaults.
|
||||
// Two statements on purpose: history.change() replaces the document, so reading
|
||||
// history.document.blocks in the same expression would search the previous snapshot.
|
||||
const statId = history.change(doc => insertBlock(doc, 'stat', 's2'));
|
||||
const stat = locate(history.document.blocks, statId).block;
|
||||
assert.equal(stat.children, undefined);
|
||||
assert.deepEqual(stat.props, { value: '15+', label: 'Jahre Erfahrung' });
|
||||
|
||||
history.undo(); history.undo(); history.undo();
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
Reference in new issue
Block a user