Let menu entries appear as buttons with icon, colours and phone placement
The menu editor gets "Als Button darstellen" per entry: shape, appearance and size from the button design, fill and ink with hover and palette, icon colour, icon from the dialog, motion, icon-only and where the button sits on the phone (in the opened menu, beside the burger, or there as an icon only). Colours go into the page stylesheet under a stable per-button id because the CSP forbids inline styles. ADR 110. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48ae2443cd
commit
13f6759df8
22 files changed
+623
-14
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// Prüft Menü-Buttons (ADR 110) auf einer isolierten Kopie der Pilotdaten: in der Menüverwaltung
|
||||
// einen eigenen Link als Button gestalten (Form, Symbol aus dem Fenster, Palettenfarbe, Telefon-
|
||||
// Anzeige), Vorschau, Speichern, Neuladen - und die öffentliche Seite auf Desktop und Telefon.
|
||||
// Screenshots unter reports/menu-buttons/.
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/menu-buttons'; await mkdir(report, { recursive: true });
|
||||
let browser;
|
||||
const checks = [], errors = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
const page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
await page.locator('summary', { hasText: 'Eigener Link' }).click();
|
||||
await page.locator('#link-label').fill('Angebot anfragen');
|
||||
await page.locator('#link-url').fill('/kontakt');
|
||||
await page.locator('#menu-add-url').click();
|
||||
const row = page.locator('.menu-entry').last();
|
||||
await row.locator('summary').click();
|
||||
// Ohne Häkchen keine Button-Optionen.
|
||||
assert.equal(await row.locator('[data-button-options]').isVisible(), false);
|
||||
await row.getByLabel('Als Button darstellen').check();
|
||||
const options = row.locator('[data-button-options]');
|
||||
await options.waitFor();
|
||||
await options.getByLabel('Form').selectOption('pill');
|
||||
// Symbol über das Fenster.
|
||||
await row.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
|
||||
await dialog.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
assert.equal(await row.locator('.icon-field-name').textContent(), 'Pfeil nach rechts');
|
||||
await row.getByLabel('Symbol steht').selectOption('after');
|
||||
// Fläche aus der Palette, Schrift als eigener Wert.
|
||||
await options.locator('.color-trigger').first().click();
|
||||
await options.locator('.color-choices:not([hidden]) .color-option').nth(1).click();
|
||||
const fill = await row.locator('input[data-key="buttonFill"]').inputValue();
|
||||
assert.match(fill, /^@[a-z]/, 'Palettenfarbe als Verweis gespeichert');
|
||||
await options.getByLabel('Symbol beim Überfahren').selectOption('slide');
|
||||
await options.getByLabel('Auf dem Telefon').selectOption('bar-icon');
|
||||
const sample = row.locator('.menu-button-preview .cms-button').first();
|
||||
assert.equal(await sample.evaluate(el => getComputedStyle(el).borderRadius), '999px');
|
||||
assert.equal(await row.locator('.menu-button-preview .button-icon svg').count(), 2, 'Symbol in Normal- und Hover-Vorschau');
|
||||
const axe = await new AxeBuilder({ page }).include('.menu-entry:last-child').analyze();
|
||||
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe in der Menüzeile');
|
||||
await row.screenshot({ path: `${report}/verwaltung.png` });
|
||||
checks.push('Verwaltung: Häkchen zeigt Optionen, Pille, Symbol aus dem Fenster, Palettenfarbe als Verweis, Vorschau mit Symbol, axe ohne Befund');
|
||||
|
||||
// Zweiter Button bleibt auf dem Telefon im aufgeklappten Menü (Vorgabe).
|
||||
await page.locator('#link-label').fill('Rückruf');
|
||||
await page.locator('#link-url').fill('/rueckruf');
|
||||
await page.locator('#menu-add-url').click();
|
||||
const second = page.locator('.menu-entry').last();
|
||||
await second.locator('summary').click();
|
||||
await second.getByLabel('Als Button darstellen').check();
|
||||
await second.locator('select[data-key="buttonAppearance"]').selectOption('outline');
|
||||
assert.equal(await second.locator('select[data-key="buttonMobile"]').inputValue(), 'menu');
|
||||
|
||||
await page.getByRole('button', { name: 'Menü speichern' }).click();
|
||||
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
|
||||
await page.reload();
|
||||
const saved = page.locator('.menu-entry').filter({ hasText: 'Angebot anfragen' }).last();
|
||||
await saved.locator('summary').click();
|
||||
assert.equal(await saved.getByLabel('Als Button darstellen').isChecked(), true);
|
||||
assert.equal(await saved.locator('select[data-key="buttonShape"]').inputValue(), 'pill');
|
||||
assert.equal(await saved.locator('select[data-key="buttonMobile"]').inputValue(), 'bar-icon');
|
||||
assert.equal(await saved.locator('.icon-field-name').textContent(), 'Pfeil nach rechts');
|
||||
checks.push('gespeichert und nach Neuladen gleich');
|
||||
|
||||
// Öffentliche Seite: Desktop im Menü, Telefon neben dem Menü-Knopf.
|
||||
const site = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
|
||||
site.on('pageerror', error => errors.push(error.message));
|
||||
await site.goto(origin + '/');
|
||||
const desktop = site.locator('.site-header .block-menu > .menu-list a.menu-button');
|
||||
await desktop.first().waitFor();
|
||||
assert.equal(await desktop.first().isVisible(), true);
|
||||
const look = await desktop.first().evaluate(el => ({ radius: getComputedStyle(el).borderRadius, bg: getComputedStyle(el).backgroundColor, deco: getComputedStyle(el).textDecorationLine }));
|
||||
assert.equal(look.radius, '999px'); assert.equal(look.deco, 'none');
|
||||
assert.equal(await site.locator('.site-header .menu-actions').first().isVisible(), false, 'Auf dem Desktop keine Zweitausgabe');
|
||||
await site.locator('.site-header').screenshot({ path: `${report}/desktop.png` });
|
||||
await site.setViewportSize({ width: 390, height: 844 });
|
||||
await site.reload();
|
||||
const bar = site.locator('.site-header .menu-actions a.menu-button');
|
||||
assert.equal(await bar.first().isVisible(), true, 'Auf dem Telefon neben dem Menü-Knopf');
|
||||
assert.match(await bar.first().getAttribute('class'), /icon-only/, 'Dort nur als Symbol');
|
||||
assert.equal(await bar.first().getAttribute('href'), '/kontakt');
|
||||
const line = await site.evaluate(() => { const a = document.querySelector('.site-header .menu-actions a'), s = document.querySelector('.site-header .menu-burger > summary'); return Math.abs(a.getBoundingClientRect().top + a.getBoundingClientRect().height / 2 - (s.getBoundingClientRect().top + s.getBoundingClientRect().height / 2)); });
|
||||
await site.locator('.site-header').screenshot({ path: `${report}/telefon.png` });
|
||||
assert.ok(line < 4, `Symbol und Menü-Knopf nicht in einer Zeile: ${line}px`);
|
||||
await site.locator('.site-header .menu-burger > summary').first().click();
|
||||
assert.equal(await site.locator('.site-header .menu-burger-body .menu-keep-visible').first().isVisible(), false, 'Im aufgeklappten Menü nicht doppelt');
|
||||
const inMenu = site.locator('.site-header .menu-burger-body a.menu-button', { hasText: 'Rückruf' });
|
||||
assert.equal(await inMenu.isVisible(), true, 'Button im aufgeklappten Menü');
|
||||
assert.equal(await site.locator('.site-header .menu-actions a', { hasText: 'Rückruf' }).count(), 0, 'nicht neben dem Menü-Knopf');
|
||||
const fit = await inMenu.evaluate(a => ({ a: a.getBoundingClientRect().width, list: a.closest('.menu-list').getBoundingClientRect().width, border: getComputedStyle(a).borderTopStyle }));
|
||||
assert.ok(Math.abs(fit.a - fit.list) < 2, `volle Breite im Menü: ${fit.a} von ${fit.list}`);
|
||||
assert.equal(fit.border, 'solid', 'Darstellung „Rahmen“ bleibt im Menü');
|
||||
await site.screenshot({ path: `${report}/telefon-offen.png` });
|
||||
const axeSite = await new AxeBuilder({ page: site }).include('.site-header').analyze();
|
||||
assert.deepEqual(axeSite.violations.map(v => v.id), [], 'axe im Kopf der Website');
|
||||
const width = await site.evaluate(() => document.documentElement.scrollWidth);
|
||||
assert.ok(width <= 390, `waagerechtes Scrollen: ${width}`);
|
||||
checks.push('Website: Button im Menü (Pille, ohne Unterstreichung), auf dem Telefon neben dem Menü-Knopf, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite, axe ohne Befund, kein waagerechtes Scrollen');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
|
||||
console.log(checks.map(check => `✓ ${check}`).join('\n'));
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server.kill();
|
||||
}
|
||||
Reference in new issue
Block a user