Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ controls and handlers a person does; it is NOT a second rendering path.
Colours, fonts and logo placement need no documenting; "Typografie steht
immer in Kombination mit Grün" and the protective margin M = 0.06 x short
edge do.
- `e2e/api-coverage.spec.js` holds the facade to its promise of covering the
whole UI: it walks the wizard's controls and asserts each has a counterpart,
compares every `<option>` against `options()`, and checks each QR content
type can be built. **Add a control to the UI, add it there too** — otherwise
the gap is invisible until somebody asks for the feature.
- `e2e/llms-examples.spec.js` executes every ```js block in `llms.txt` against
the page. A worked example that does not work is worse than none: the model
follows it, hits an error it cannot interpret, and improvises — the failure
this interface exists to prevent. Keep examples complete and runnable, not
fragments.
- `lastAdded()` exists because `canvas.getObjects()` ends with the organisation
LOGO, not with the element just added — `addLogo()` and the QR handler both
call `bringLogoToFront()`. Anything that operates on "the last object" moves
Expand Down
121 changes: 121 additions & 0 deletions e2e/api-coverage.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test';

/**
* The facade's promise is "everything the page can do". This test holds it to
* that literally: it walks the interactive controls of the wizard and asserts
* that each one has a counterpart on window.Bildgenerator.
*
* Without it the promise decays silently — a new button in the UI is simply
* unreachable for callers, and nobody notices until someone asks for it.
*/
test.describe('facade covers the whole UI', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => typeof window.Bildgenerator !== 'undefined');
});

// Every control a person can operate, and the call that replaces it.
const CONTROLS = {
'canvas-template': 'setTemplate',
'logo-selection': 'setLogo',
'logo-toggle': 'setLogoEnabled',
'meme-input': 'setBackground',
text: 'addText',
'text-color': 'addText',
'font-style-select': 'addText',
'line-height': 'addText',
'shadow-depth': 'addText',
'add-text': 'addText',
'add-pink-circle': 'addShape',
'add-cross': 'addShape',
'add-image': 'addImage',
'add-circle': 'clipToCircle',
'circle-radius': 'clipToCircle',
'qr-text': 'addQRCode',
'qr-color': 'addQRCode',
'add-qr-code': 'addQRCode',
scale: 'resize',
'remove-element': 'remove',
'bring-to-front': 'bringToFront',
'image-format': 'export',
'image-quality': 'export',
'generate-meme': 'export',
'start-over': 'reset',
};

test('every control in the wizard has a counterpart on the facade', async ({ page }) => {
const result = await page.evaluate((controls) => {
const missingInUI = [];
const missingInFacade = [];
for (const [id, method] of Object.entries(controls)) {
if (!document.getElementById(id)) missingInUI.push(id);
if (typeof window.Bildgenerator[method] !== 'function') missingInFacade.push(method);
}
return { missingInUI, missingInFacade };
}, CONTROLS);

// A control that vanished from the UI means this map is stale.
expect(result.missingInUI, 'controls named here but gone from the markup').toEqual([]);
expect(result.missingInFacade, 'controls with no facade counterpart').toEqual([]);
});

test('capabilities beyond single controls are reachable too', async ({ page }) => {
const missing = await page.evaluate(() => {
// Things a person does by dragging, clicking or looking — no control id.
const needed = [
'place', // dragging an element
'rotate', // the rotation handle
'select', // clicking an element
'update', // editing a placed element
'objects', // seeing what is on the canvas
'lastAdded',
'protectiveMargin',
'templates', 'logos', 'options',
'render', 'renderQRCode', 'qrPayload',
];
return needed.filter((n) => typeof window.Bildgenerator[n] !== 'function');
});

expect(missing).toEqual([]);
});

test('every selectable value in the UI is offered by options()', async ({ page }) => {
const result = await page.evaluate(() => {
const values = (sel) => [...document.querySelectorAll(`${sel} option`)]
.map((o) => o.value).filter(Boolean);
const o = window.Bildgenerator.options();
const compare = (a, b) => a.every((v) => b.includes(v));
return {
textColors: compare(values('#text-color'), o.textColors),
lineHeights: compare(values('#line-height'), o.lineHeights),
clipSizes: compare(values('#circle-radius'), o.clipSizes),
qrColorsOnImage: compare(values('#qr-color'), o.qrColorsOnImage),
qrColors: compare(values('#qr-color-select'), o.qrColors),
qrBackgrounds: compare(values('#qr-background-select'), o.qrBackgrounds),
};
});

for (const [key, ok] of Object.entries(result)) {
expect(ok, `options().${key} is missing values the UI offers`).toBe(true);
}
});

test('every QR content type of the wizard can be built', async ({ page }) => {
const result = await page.evaluate(() => {
const uiTypes = [...document.querySelectorAll('.app-qr-type-button')]
.map((b) => b.dataset.type);
const B = window.Bildgenerator;
const samples = {
text: { type: 'text', text: 'x' },
url: { type: 'url', url: 'gruene.at' },
email: { type: 'email', email: 'a@gruene.at' },
vcard: { type: 'vcard', firstname: 'A', lastname: 'B' },
};
const unsupported = uiTypes.filter((t) => !samples[t] || !B.qrPayload(samples[t]));
return { uiTypes, unsupported };
});

expect(result.uiTypes.length).toBeGreaterThan(0);
expect(result.unsupported, 'QR types the wizard offers but the facade cannot build').toEqual([]);
});
});
185 changes: 185 additions & 0 deletions e2e/api-editing.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { test, expect } from '@playwright/test';

/**
* Everything to do with elements that are ALREADY on the canvas: looking at
* them, selecting, changing, deleting, reordering.
*
* The wizard can do all of this by clicking an object and touching a control.
* Without it a caller is blind — it can add elements but never check what it
* built, and to change a colour it would have to start over.
*/
test.describe('Bildgenerator facade — inspecting and editing', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => typeof window.Bildgenerator !== 'undefined');
page.on('dialog', (dialog) => dialog.accept());
});

test('objects() reports what is on the canvas and what each thing is', async ({ page }) => {
const objects = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('artikel_23');
await B.setLogo('HERZOGENBURG');
await B.addText('Hallo');
return B.objects();
});

const roles = objects.map((o) => o.role);
expect(roles).toContain('canvas');
expect(roles).toContain('logo');

const text = objects.find((o) => o.type === 'text');
expect(text.text).toBe('Hallo');
expect(text.editable).toBe(true);

// The logo must be marked as off-limits, or a caller will try to move it.
expect(objects.find((o) => o.role === 'logo').editable).toBe(false);
});

test('update() changes a text that is already placed', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.addText('Alt', { color: '#FFFFFF' });
const before = { ...B.objects().find((o) => o.type === 'text') };

await B.update({ text: 'Neu', color: '#FFED00', align: 'right' });
const after = B.objects().find((o) => o.type === 'text');
return { before, after, textAlign: B.lastAdded().textAlign };
});

expect(result.before.text).toBe('Alt');
expect(result.after.text).toBe('Neu');
expect(result.after.color).toBe('#FFED00');
expect(result.textAlign).toBe('right');
});

test('update() re-measures the text instead of keeping stale dimensions', async ({ page }) => {
const sizes = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.addText('Kurz');
const before = B.lastAdded().width;
await B.update({ text: 'Ein deutlich laengerer Text' });
return { before, after: B.lastAdded().width };
});

// Fabric caches char metrics; without initDimensions() the box stays the
// old size and the new text overflows it.
expect(sizes.after).toBeGreaterThan(sizes.before);
});

test('select() addresses an element by its index', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.setLogoEnabled(false);
await B.addText('Eins');
await B.place('top');
await B.addText('Zwei');
await B.place('bottom');

const first = B.objects().find((o) => o.text === 'Eins');
B.select(first.index);
await B.update({ color: '#FFED00' });
return B.objects().filter((o) => o.type === 'text').map((o) => ({ t: o.text, c: o.color }));
});

expect(result.find((o) => o.t === 'Eins').c).toBe('#FFED00');
expect(result.find((o) => o.t === 'Zwei').c).not.toBe('#FFED00');
});

test('remove() deletes an element', async ({ page }) => {
const counts = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.addShape('cross');
const before = B.objects().length;
await B.remove();
return { before, after: B.objects().length };
});

expect(counts.after).toBe(counts.before - 1);
});

test('remove() refuses the logo and the background, as the UI does', async ({ page }) => {
const messages = await page.evaluate(async () => {
const B = window.Bildgenerator;
const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC';
await B.setTemplate('feed_post_45');
await B.setBackground(png);
await B.setLogo('HERZOGENBURG');

const out = {};
for (const role of ['logo', 'background']) {
const item = B.objects().find((o) => o.role === role);
try {
await B.remove(item.index);
out[role] = null;
} catch (e) {
out[role] = e.message;
}
}
return out;
});

expect(messages.logo).toContain('logo');
expect(messages.background).toContain('background');
});

test('bringToFront() reorders but keeps the logo on top', async ({ page }) => {
const result = await page.evaluate(async () => {
const B = window.Bildgenerator;
await B.setTemplate('feed_post_45');
await B.setLogo('HERZOGENBURG');
await B.addText('Hinten');
await B.addShape('pinkCircle');

const text = B.objects().find((o) => o.type === 'text');
await B.bringToFront(text.index);

const after = B.objects();
return {
lastRole: after[after.length - 1].role,
textIndex: after.find((o) => o.type === 'text').index,
circleIndex: after.find((o) => o.type === 'circle').index,
};
});

expect(result.textIndex).toBeGreaterThan(result.circleIndex);
expect(result.lastRole).toBe('logo');
});

test('builds QR payloads for all four content types', async ({ page }) => {
const payloads = await page.evaluate(() => {
const B = window.Bildgenerator;
return {
text: B.qrPayload({ type: 'text', text: 'Hallo' }),
url: B.qrPayload({ type: 'url', url: 'gruene.at' }),
email: B.qrPayload({ type: 'email', email: 'a@gruene.at', subject: 'Hallo Welt' }),
vcard: B.qrPayload({ type: 'vcard', firstname: 'Anna', lastname: 'Muster', email: 'a@gruene.at' }),
};
});

expect(payloads.text).toBe('Hallo');
expect(payloads.url).toContain('gruene.at');
expect(payloads.email.startsWith('mailto:a@gruene.at')).toBe(true);
expect(payloads.email).toContain('subject=Hallo%20Welt');
expect(payloads.vcard).toContain('BEGIN:VCARD');
expect(payloads.vcard).toContain('Anna');
});

test('renderQRCode() takes a typed spec, not just a raw string', async ({ page }) => {
const result = await page.evaluate(async () =>
window.Bildgenerator.renderQRCode({
type: 'email',
email: 'buero@gruene.at',
subject: 'Anfrage',
color: '#257639',
})
);

expect(result.dataURL.startsWith('data:image/png;base64,')).toBe(true);
expect(result.width).toBeGreaterThan(0);
});
});
Loading
Loading