The icon set grows from 33 to 98 drawings in seven categories (arrows, contact, commerce, files and media, people and security, house and trade, general), including truck, cart, euro, camera, tools and solar. icons.js is the single source; npm run build:icons writes the Twig drawings and the validator list, and a test keeps them equal. Choosing an icon no longer fills the panel with tiles: the panel shows the current icon with "Change", which opens a modal with category tabs and named tiles, keyboard navigable, closing with Escape. Button, icon and icon card share it. A new style group "Symbol" targets the icon inside a button per device and on hover: colour, background, size, padding, radius, rotation, plus the gap to the text. Schema v26 adds hover motion (slide, reveal) and an icon-only button whose label stays as the accessible name and keeps the line height. ADR 109. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
13 KiB
JavaScript
185 lines
13 KiB
JavaScript
// Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl-
|
|
// fenster mit Kategorien, Tastatur, Stellung, Bewegung, nur Symbol, Farbe und Größe unter Design,
|
|
// Rückgängig, Speichern und Neuladen. Screenshots unter reports/button-icons/.
|
|
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/button-icons'; await mkdir(report, { recursive: true });
|
|
let browser, page;
|
|
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: 1600, height: 1050 } });
|
|
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
|
const selectBlock = async id => {
|
|
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
|
await page.locator(`#structure [data-block-id="${id}"]`).click();
|
|
await page.locator(`#structure [data-edit-block="${id}"]`).click();
|
|
};
|
|
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');
|
|
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
|
const data = await (await context.request.get(api)).json();
|
|
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
|
|
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } },
|
|
] });
|
|
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
|
|
|
const inPreview = fn => page.evaluate(fn);
|
|
const waitPreview = async expected => page.waitForFunction(expected => {
|
|
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
|
if (!el) return false;
|
|
const path = el.querySelector('.button-icon svg path')?.getAttribute('d') ?? null;
|
|
const classes = el.className;
|
|
return (expected.path === undefined || (expected.path === null ? path === null : path?.startsWith(expected.path)))
|
|
&& (expected.first === undefined || ((el.firstChild?.nodeType === 1 && el.firstChild.classList.contains('button-icon')) === expected.first))
|
|
&& (expected.has ?? []).every(name => classes.includes(name))
|
|
&& (expected.not ?? []).every(name => !classes.includes(name));
|
|
}, expected);
|
|
|
|
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
|
await selectBlock('qa-icon-button');
|
|
const field = page.locator('.icon-field');
|
|
await field.waitFor();
|
|
assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg');
|
|
assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.');
|
|
await waitPreview({ path: 'M6.5 17.5', first: false });
|
|
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {});
|
|
checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“');
|
|
|
|
// Das Fenster: Kategorien links, Symbole mit Namen, geöffnet bei der Kategorie des Pfeils.
|
|
await page.getByRole('button', { name: /Symbol ändern, aktuell: Pfeil schräg/ }).click();
|
|
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
|
|
await dialog.waitFor();
|
|
assert.equal(await dialog.getByRole('tab').count(), 7);
|
|
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Pfeile');
|
|
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'arrow-up-right', 'Fokus auf dem gewählten Symbol');
|
|
const axe = await new AxeBuilder({ page }).include('.icon-dialog').analyze();
|
|
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe im Auswahlfenster');
|
|
await dialog.getByRole('tab', { name: 'Haus & Handwerk' }).click();
|
|
await dialog.screenshot({ path: `${report}/fenster.png` });
|
|
// Escape schließt ohne Änderung, der Fokus geht zurück zum Knopf.
|
|
await page.keyboard.press('Escape');
|
|
await dialog.waitFor({ state: 'detached' });
|
|
assert.match(await page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? ''), /Symbol ändern/);
|
|
await waitPreview({ path: 'M6.5 17.5' });
|
|
checks.push('Fenster: 7 Kategorien, Fokus auf dem Gewählten, axe ohne Befund, Escape ändert nichts');
|
|
|
|
// Tastatur: Kategorie mit Pfeiltaste wechseln, Symbol wählen.
|
|
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
|
await dialog.getByRole('tab', { name: 'Pfeile' }).focus();
|
|
await page.keyboard.press('ArrowDown');
|
|
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Kontakt & Ort');
|
|
await dialog.getByRole('button', { name: 'Telefon', exact: true }).click();
|
|
await dialog.waitFor({ state: 'detached' });
|
|
await waitPreview({ path: 'M6 3h4', first: false });
|
|
assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon');
|
|
checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt');
|
|
|
|
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
|
|
await waitPreview({ first: true, has: ['icon-start'] });
|
|
await page.getByRole('button', { name: 'Rückt', exact: true }).click();
|
|
await waitPreview({ has: ['icon-motion-slide'] });
|
|
await page.getByRole('button', { name: 'Erscheint', exact: true }).click();
|
|
await waitPreview({ has: ['icon-motion-reveal'], not: ['icon-motion-slide'] });
|
|
await page.locator('#undo').click(); await waitPreview({ has: ['icon-motion-slide'] });
|
|
await page.locator('#redo').click(); await waitPreview({ has: ['icon-motion-reveal'] });
|
|
checks.push('Stellung und Bewegung, Rückgängig und Wiederholen');
|
|
|
|
await page.getByLabel('Nur das Symbol zeigen').check();
|
|
await waitPreview({ has: ['icon-only'] });
|
|
const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent);
|
|
assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware');
|
|
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {});
|
|
checks.push('Nur Symbol: Beschriftung unsichtbar im Link');
|
|
|
|
// Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung.
|
|
await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click();
|
|
const group = page.locator('.design-group[data-group="icon"]');
|
|
await group.waitFor();
|
|
assert.equal(await group.evaluate(el => el.open), true);
|
|
for (const name of ['Symbolfarbe', 'Fläche hinter dem Symbol', 'Symbolgröße', 'Innenabstand der Fläche', 'Ecken der Fläche', 'Drehung', 'Abstand zum Text']) {
|
|
assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`);
|
|
}
|
|
await group.locator('#style-iconSize').fill('24');
|
|
await group.locator('#style-iconSize').press('Enter');
|
|
await page.waitForFunction(() => {
|
|
const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg');
|
|
return svg && Math.round(svg.getBoundingClientRect().width) === 24;
|
|
});
|
|
await group.screenshot({ path: `${report}/design-symbol.png` });
|
|
checks.push('Design → Symbol: alle sieben Werte da, Größe 24 px wirkt in der Vorschau');
|
|
|
|
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
|
const stored = (await (await context.request.get(api)).json()).document;
|
|
const button = stored.blocks[0].children[0];
|
|
assert.equal(stored.schemaVersion, 26);
|
|
assert.deepEqual(button.props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'phone', iconPosition: 'start', iconMotion: 'reveal', iconOnly: true });
|
|
assert.equal(button.style.desktop.iconSize, '24px');
|
|
await page.reload(); await selectBlock('qa-icon-button');
|
|
await page.locator('.icon-field').waitFor();
|
|
assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon');
|
|
assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true);
|
|
checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich');
|
|
|
|
// So sieht es aus: Pfeil in einer runden Fläche, die sich beim Überfahren dreht und umfärbt,
|
|
// daneben ein Button nur aus Symbol.
|
|
const look = (await (await context.request.get(api)).json());
|
|
look.document.blocks[0].children = [
|
|
{ id: 'qa-look-a', type: 'button', props: { text: 'Projekt anfragen', href: '/kontakt', icon: 'arrow-right', iconMotion: 'slide' }, style: { desktop: { iconColor: '#ffffff', iconBackgroundColor: '#b71c22', iconPadding: '6px', iconRadius: '999px', iconSize: '16px', hover: { iconBackgroundColor: '#1e2a20', iconRotate: '-45deg' } } } },
|
|
{ id: 'qa-look-b', type: 'button', props: { text: 'Anrufen', href: 'tel:+4972296992826', icon: 'phone', iconOnly: true } },
|
|
{ id: 'qa-look-c', type: 'button', props: { text: 'Prospekt laden', href: '/', icon: 'download', iconPosition: 'start' } },
|
|
];
|
|
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': look.csrfToken }, data: { document: look.document, expectedVersion: look.version } })).status(), 200);
|
|
await page.reload();
|
|
const frame = page.frameLocator('#preview');
|
|
await frame.locator('#qa-look-a .button-icon').waitFor();
|
|
const section = frame.locator('#qa-icons');
|
|
await section.screenshot({ path: `${report}/aussehen-normal.png` });
|
|
const styles = await frame.locator('#qa-look-a .button-icon').evaluate(el => { const s = getComputedStyle(el); return { bg: s.backgroundColor, radius: s.borderRadius, color: s.color }; });
|
|
assert.deepEqual(styles, { bg: 'rgb(183, 28, 34)', radius: '999px', color: 'rgb(255, 255, 255)' });
|
|
await frame.locator('#qa-look-a').hover();
|
|
await page.waitForTimeout(400);
|
|
await section.screenshot({ path: `${report}/aussehen-hover.png` });
|
|
assert.equal(await frame.locator('#qa-look-a .button-icon').evaluate(el => getComputedStyle(el).rotate), '-45deg');
|
|
const only = await frame.locator('#qa-look-b').evaluate(el => ({ name: el.textContent.trim(), w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height) }));
|
|
assert.equal(only.name, 'Anrufen');
|
|
assert.ok(Math.abs(only.w - only.h) <= 2, `Nur-Symbol-Button ist nicht quadratisch: ${only.w}x${only.h}`);
|
|
checks.push('Aussehen: farbige runde Fläche, Drehung beim Überfahren, quadratischer Nur-Symbol-Button, Symbol vor dem Text');
|
|
await selectBlock('qa-look-c');
|
|
|
|
// Kein Symbol: Nur-Symbol fällt weg, die Einstellungen verschwinden.
|
|
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
|
await dialog.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
|
|
await page.waitForFunction(() => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-look-c'); return el && !el.querySelector('.button-icon'); });
|
|
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0);
|
|
checks.push('Kein Symbol entfernt auch „nur Symbol“');
|
|
|
|
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();
|
|
}
|