This commit is contained in:
seb
2026-08-09 01:22:49 +02:00
parent 4f0d73e0ea
commit f0e7a523da
22 changed files with 516 additions and 3 deletions

6
.gitignore vendored
View File

@@ -2,3 +2,9 @@ node_modules/
dist/
*.log
.DS_Store
# Playwright screenshot testing
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -42,7 +42,39 @@ npm run build # bundle the dev harness (dist/)
npm run lint
```
## Screenshot testing
Variations of the GUI elements are covered by **reference screenshot tests**
(Playwright). Each variation is rendered by the static harness at
[`/screenshots.html`](screenshots.html) (`src/dev/screenshots/ScreenshotHarness.jsx`)
inside a `data-shot` section, and every section is compared against a committed
reference image in `tests/screenshots/reference/`.
```sh
npm run test:screenshots # compare against references
npm run test:screenshots:update # regenerate references (after intentional changes)
```
Notes:
- The tests reuse the running dev server (`npm run dev` on port 5199) or start
it automatically.
- Reference images are committed so visual regressions fail the run; the
actual + diff images land in `test-results/` (gitignored).
- Animations are frozen during capture so spinners/transitions render
deterministically.
- Interactive states (e.g. the `EditablePanelTile` edit/dirty rings) are driven
from the test itself, on top of the static sections.
### Adding a new variation
1. Add a `<Section id="…">` block to `ScreenshotHarness.jsx`.
2. Add the id to the test in `tests/screenshot.spec.js` (or write a dedicated
interaction test).
3. Run `npm run test:screenshots:update` to generate the new reference, review
it, and commit it.
## Future
- Test cases (unit + component tests) with mock pages will be added under
`dev/` / `src/__tests__/` as they are written.
- Unit + component tests with mock pages will be added under `dev/` /
`src/__tests__/` as they are written.

64
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.3.1",
"@mui/material": "^9.3.1",
"@playwright/test": "^1.62.1",
"@vitejs/plugin-react": "6.0.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -625,6 +626,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1654,6 +1671,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",

View File

@@ -22,7 +22,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
"lint": "eslint .",
"test:screenshots": "playwright test",
"test:screenshots:update": "playwright test --update-snapshots"
},
"peerDependencies": {
"@emotion/react": "^11.14.0",
@@ -37,6 +39,7 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.3.1",
"@mui/material": "^9.3.1",
"@playwright/test": "^1.62.1",
"@vitejs/plugin-react": "6.0.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",

28
playwright.config.js Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from '@playwright/test';
// Screenshot testing against the existing Vite dev harness (localhost:5199).
// Each `data-shot` section of /screenshots.html becomes one reference image
// under tests/screenshots/reference/. Refs are generated on first run; on
// changes regenerate them with `npm run test:screenshots:update`.
export default defineConfig({
testDir: './tests',
fullyParallel: false,
snapshotPathTemplate: '{testDir}/screenshots/reference/{arg}{ext}',
expect: {
toHaveScreenshot: {
// Freeze spinners / transitions so animated states render deterministically.
animations: 'disabled',
},
},
use: {
baseURL: 'http://localhost:5199',
viewport: { width: 1200, height: 800 },
screenshot: 'off',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5199',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});

12
screenshots.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dashgui screenshot harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dev/screenshotsMain.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,300 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import FolderIcon from '@mui/icons-material/FolderOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../../panels';
import RecordList from '../../lists/RecordList';
import { Tile, tileBaseSx } from '../../tiles';
import ElevatedPill, {
ActionElevatedPill,
ToggleElevatedPill,
} from '../../ElevatedPill';
import SingleLineTextEdit from '../../SingleLineTextEdit';
const MOCK_ROWS = [
{
id: 1,
number: 'L-1001',
date: '2026-08-01',
party: 'Müller GmbH',
status: 'fertig',
},
{
id: 2,
number: 'L-1002',
date: '2026-08-03',
party: 'Schmidt & Co',
status: 'offen',
},
{
id: 3,
number: 'L-1003',
date: '2026-08-05',
party: 'Weber OHG',
status: 'offen',
},
];
const COLUMNS = [
{ key: 'number', label: 'Nummer' },
{ key: 'date', label: 'Lieferdatum' },
{ key: 'party', label: 'Lieferant' },
{
key: 'status',
label: 'Status',
render: (row) => (
<Chip
size="small"
label={row.status}
color={row.status === 'fertig' ? 'success' : 'default'}
/>
),
},
];
class MockFolderTile extends Tile {
renderIcon() {
return <FolderIcon color="action" sx={{ fontSize: 40 }} />;
}
}
class MockAddTile extends Tile {
renderIcon() {
return <AddIcon color="primary" sx={{ fontSize: 40 }} />;
}
}
/**
* Fixed, deterministic gallery of every GUI variation — one `data-shot`
* section per reference screenshot. Served under /screenshots.html and
* captured by the Playwright screenshot tests (tests/screenshot.spec.js).
*
* Deliberately static: no timers, no routing, no user state. Only stable
* props, so references stay comparable across runs. Interactive states
* (EditablePanelTile editing/dirty) are driven from the test, not here.
*/
function Section({ id, label, children }) {
return (
<Box
component="section"
data-shot={id}
sx={{ background: '#f4f4f5', p: 2, borderRadius: 1, mb: 3 }}
>
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1.5 }}
>
{label}
</Typography>
{children}
</Box>
);
}
class ScreenshotHarness extends Component {
render() {
return (
<Box sx={{ width: 1000, mx: 'auto', py: 3, px: 1 }}>
<Section id="pills" label="ElevatedPill / Toggle / Action">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<ElevatedPill label="Statisch" />
<ElevatedPill label="Aktiv" active />
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<ToggleElevatedPill label="Aus" checked={false} onToggle={() => {}} />
<ToggleElevatedPill label="An" checked onToggle={() => {}} />
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<ActionElevatedPill
label="Inaktiv"
icon={<ToggleOnIcon sx={{ fontSize: 18 }} />}
onClick={() => {}}
/>
<ActionElevatedPill
label="Aktiv"
active
icon={<ToggleOnIcon sx={{ fontSize: 18 }} />}
onClick={() => {}}
/>
</Box>
</Box>
</Section>
<Section id="textfields" label="SingleLineTextEdit">
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
maxWidth: 480,
}}
>
<SingleLineTextEdit label="Name" value="ACME GmbH" onChange={() => {}} />
<SingleLineTextEdit
label="Name"
value=""
onChange={() => {}}
helperText="Mit Hilfetext"
/>
<SingleLineTextEdit
label="Nur lesen"
value="Schreibgeschützt"
readOnly
onChange={() => {}}
/>
<SingleLineTextEdit
label="Deaktiviert"
value="Ausgegraut"
disabled
onChange={() => {}}
/>
<SingleLineTextEdit
label="Klein"
value="Small"
size="small"
onChange={() => {}}
/>
</Box>
</Section>
<Section id="tiles" label="Tile / tileBaseSx">
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 140px)',
gap: 2,
}}
>
<MockFolderTile label="Ordner" secondary="3 Artikel" />
<MockAddTile label="Neu" secondary="anlegen" />
<MockFolderTile label="Deaktiviert" secondary="nicht klickbar" disabled />
<Box sx={{ ...tileBaseSx, bgcolor: 'primary.main', color: 'white' }}>
<Typography variant="body2" fontWeight={600}>
tileBaseSx direkt
</Typography>
</Box>
</Box>
</Section>
<Section id="panels-basic" label="PanelTile">
<PanelTileGrid>
<PanelTile wide={2} title="Firma">
<Typography variant="body1">ACME GmbH</Typography>
</PanelTile>
<PanelTile wide={1} title="Farbe" color="#fef3c7">
<Typography variant="body2">Mit Hintergrundfarbe</Typography>
</PanelTile>
<PanelTile
wide={1}
title="Mit Aktion"
action={{ label: 'Speichern', icon: SaveOutlinedIcon, onClick: () => {} }}
>
<Typography variant="body2">Idle Aktion</Typography>
</PanelTile>
</PanelTileGrid>
</Section>
<Section id="panels-busy" label="PanelTile – Aktion busy">
<PanelTileGrid>
<PanelTile
wide={2}
title="Speichern läuft"
action={{
label: 'Speichern',
icon: SaveOutlinedIcon,
busy: true,
onClick: () => {},
}}
>
<Typography variant="body1">Wird gespeichert …</Typography>
</PanelTile>
</PanelTileGrid>
</Section>
<Section id="editable" label="EditablePanelTile">
<PanelTileGrid>
<EditablePanelTile
wide={2}
title="Firma"
value="ACME GmbH"
onSave={async () => {}}
onError={() => {}}
display={<Typography variant="body1">ACME GmbH</Typography>}
edit={({ draft, onChange, inputRef }) => (
<SingleLineTextEdit
label="Name"
value={draft}
onChange={onChange}
inputRef={inputRef}
/>
)}
/>
</PanelTileGrid>
</Section>
<Section id="lists-ready" label="RecordList – ready">
<RecordList
status="ready"
rows={MOCK_ROWS}
columns={COLUMNS}
createLabel="Neue Lieferung"
onCreate={() => {}}
onEdit={() => {}}
onDelete={() => {}}
emptyMessage="Noch keine Lieferungen."
color="#fed7aa"
/>
</Section>
<Section id="lists-empty" label="RecordList – leer">
<RecordList
status="ready"
rows={[]}
columns={COLUMNS}
createLabel="Neue Lieferung"
onCreate={() => {}}
onEdit={() => {}}
onDelete={() => {}}
emptyMessage="Noch keine Lieferungen."
color="#fed7aa"
/>
</Section>
<Section id="lists-loading" label="RecordList – laden">
<RecordList status="loading" rows={[]} columns={COLUMNS} />
</Section>
<Section id="lists-failed" label="RecordList – Fehler">
<RecordList
status="failed"
rows={[]}
columns={COLUMNS}
error={
<Alert severity="error">
Backend-Fehler: Verbindung fehlgeschlagen.
</Alert>
}
/>
</Section>
<Section id="lists-no-actions" label="RecordList – ohne Aktionen">
<RecordList
status="ready"
rows={MOCK_ROWS}
columns={COLUMNS}
color="#e2e8f0"
/>
</Section>
</Box>
);
}
}
export { ScreenshotHarness };

View File

@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { ScreenshotHarness } from './screenshots/ScreenshotHarness';
// Standalone root for the screenshot harness (/screenshots.html). Mirrors the
// dev gallery's theme so reference screenshots match real-app rendering.
const theme = createTheme({
shape: { borderRadius: 4 },
});
createRoot(document.getElementById('root')).render(
<ThemeProvider theme={theme}>
<CssBaseline />
<ScreenshotHarness />
</ThemeProvider>,
);

51
tests/screenshot.spec.js Normal file
View File

@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
/**
* Screenshot tests for the GUI variations rendered by the harness
* (/screenshots.html). Every `data-shot` section becomes one reference image;
* interactive states (EditablePanelTile editing/dirty) are driven here.
*
* First run generates the reference images (and passes). After that the test
* fails on any visual drift; see the actual/diff images in test-results/.
* Regenerate intentionally changed references with:
* npm run test:screenshots:update
*/
const STATIC_SECTIONS = [
'pills',
'textfields',
'tiles',
'panels-basic',
'panels-busy',
'lists-ready',
'lists-empty',
'lists-loading',
'lists-failed',
'lists-no-actions',
];
test.beforeEach(async ({ page }) => {
await page.goto('/screenshots.html');
});
for (const id of STATIC_SECTIONS) {
test(`screenshot ${id}`, async ({ page }) => {
await expect(page.locator(`[data-shot="${id}"]`)).toHaveScreenshot(
`${id}.png`,
);
});
}
test('editable tile: read, edit, dirty', async ({ page }) => {
const section = page.locator('[data-shot="editable"]');
// Read state — edit button visible, no ring.
await expect(section).toHaveScreenshot('editable-read.png');
// Editing state — clean ring, input focused, save/cancel buttons.
await section.getByRole('button', { name: 'Bearbeiten' }).click();
await expect(section).toHaveScreenshot('editable-editing.png');
// Dirty state — rose pending ring once the draft differs from the saved value.
await section.locator('input').fill('ACME GmbH 2');
await expect(section).toHaveScreenshot('editable-dirty.png');
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB