Files
mdg-filius/packages/editor/browser/documentation.spec.ts
T
julian.kiedaischandClaude Opus 5.5 7e68414bb2 feat: F4.8c the documentation mode's image export, as Java's
ExportRun measures Java's print panel (GUIContainer.prepareExportPanel) and
image on four projects and an empty one: 10 to spare around, a light grey
footer 20 below the lowest element, 20 below it, a transparent background,
the documentation painted as in the design. The sidebar gains Java's
"Exportieren": the surface painted with Canvas 2D (an SVG with a
foreignObject taints the canvas in WebKit) and saved under the project's
name; Java's message when saving fails. Not adopted: Java's empty image of
an empty project (its footer overflows). The editor takes a projectName;
the capture export shares the download helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:54:09 +00:00

247 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The documentation mode in Chromium (F4.8b), on FILIUS' example
// nat_with_mapped_port, which has documentation: Java's third mode button;
// the sidebar's rectangle dragged onto the surface (its middle under the
// pointer), moved, resized to Java's smallest size, coloured from Java's
// list; the sidebar's text tapped into the view, typed into, made large and
// italic; Java's paint in the documentation mode and in the design; Delete;
// a simulation started ends the mode; „Exportieren“ (F4.8c): the image
// saved under the project's name, cut and painted as Java's, and Java's
// message when saving fails; and a phone.
import { readFileSync } from 'node:fs';
import { expect, test, type Page } from '@playwright/test';
import { JAVA_LABEL_FONT } from '../src/canvas/layout.js';
import { drag, drawing, openEditor, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
const newDoc = async (page: Page, before: ReadonlySet<string>) => {
await expect.poll(async () => (await drawing(page)).docs.filter((item) => !before.has(item.id)).length).toBe(1);
return (await drawing(page)).docs.find((item) => !before.has(item.id))!;
};
const docOf = async (page: Page, id: string) => (await drawing(page)).docs.find((item) => item.id === id);
const fill = (page: Page, id: string) => page.locator(`g[data-doc="${id}"] .doc-rectangle`).evaluate((rect) => getComputedStyle(rect).fill);
async function longPress(page: Page, at: { x: number; y: number }) {
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.waitForTimeout(700);
await page.mouse.up();
}
test('makes, moves, resizes, colours and types documentation as Java does, and paints it per mode', async ({ page }) => {
test.setTimeout(120_000);
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
const canvas = page.locator('svg.filius-canvas');
const existing = new Set((await drawing(page)).docs.map((item) => item.id));
expect(existing.size).toBeGreaterThan(0);
// Java's order: documentation, design, simulation.
expect(await page.locator('.editor-toolbar button[name$="Mode"]').evaluateAll((buttons) => buttons.map((each) => each.getAttribute('name')))).toEqual([
'documentationMode',
'designMode',
'simulationMode',
]);
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0);
const sidebar = page.getByRole('navigation', { name: 'Dokumentation' });
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche', 'Exportieren']);
// A rectangle dragged onto the surface: 100 × 50, its middle where it was dropped, grey, with the focus.
const surface = (await canvas.boundingBox())!;
const drop = { x: surface.x + surface.width * 0.6, y: surface.y + surface.height * 0.3 };
const button = (await sidebar.getByRole('button', { name: 'Gliederungsfläche' }).boundingBox())!;
await drag(page, { x: button.x + button.width / 2, y: button.y + button.height / 2 }, drop);
const rectangle = await newDoc(page, existing);
expect([rectangle.kind, rectangle.width, rectangle.height]).toEqual(['rectangle', 100, 50]);
const focusBox = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
expect(Math.abs(focusBox.x + focusBox.width / 2 - drop.x)).toBeLessThan(2);
expect(Math.abs(focusBox.y + focusBox.height / 2 - drop.y)).toBeLessThan(2);
// Java's paint in the documentation mode: grey at an opacity of 200.
expect(await fill(page, rectangle.id)).toBe('rgba(204, 204, 204, 0.784)');
// Moved by its middle, resized by its corner to Java's smallest (20 × 20).
await drag(page, drop, { x: drop.x + 40, y: drop.y + 30 });
await expect.poll(async () => (await docOf(page, rectangle.id))!.x - rectangle.x).toBeGreaterThan(20);
const moved = (await docOf(page, rectangle.id))!;
const corner = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
await drag(page, { x: corner.x + corner.width - 2, y: corner.y + corner.height - 2 }, { x: corner.x - 50, y: corner.y - 50 });
await expect
.poll(async () => {
const item = (await docOf(page, rectangle.id))!;
return [item.x, item.y, item.width, item.height];
})
.toEqual([moved.x, moved.y, 20, 20]);
// Its menu: only the colour (Java's twelve) and „Löschen“; a colour chosen.
const small = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
await longPress(page, { x: small.x + small.width / 2, y: small.y + small.height / 2 });
const menu = page.getByRole('menu');
await expect(menu.getByRole('group')).toHaveCount(1);
await expect(menu.getByRole('group', { name: 'Farbe' }).getByRole('menuitemradio')).toHaveCount(12);
await expect(menu.getByRole('menuitemradio', { name: 'Farbe 2' })).toHaveAttribute('aria-checked', 'true');
await menu.getByRole('menuitemradio', { name: 'Farbe 5' }).click();
await expect.poll(async () => (await docOf(page, rectangle.id))!.color).toEqual({ red: 1, green: 201 / 255, blue: 201 / 255, alpha: 1 });
// A text tapped from the sidebar: in the middle of the view, typed into at once.
const withRectangle = new Set((await drawing(page)).docs.map((item) => item.id));
await sidebar.getByRole('button', { name: 'Text' }).click();
const text = await newDoc(page, withRectangle);
const field = page.getByRole('textbox', { name: 'Text' });
await expect(field).toBeFocused();
await page.keyboard.type('Netz A');
await page.keyboard.press('Enter');
await page.keyboard.type('zweite Zeile');
await expect.poll(async () => (await docOf(page, text.id))!.text).toBe('Netz A\nzweite Zeile');
// Its menu: size, style, Java's fifteen colours; „Groß“, then „Kursiv“.
const textFocus = (await page.locator(`g[data-doc="${text.id}"] .doc-focus`).boundingBox())!;
const band = { x: textFocus.x + textFocus.width / 2, y: textFocus.y + textFocus.height - 8 };
await longPress(page, band);
await expect(menu.getByRole('group')).toHaveText([/^Schriftgröße/, /^Schriftstil/, /^Farbe/]);
await expect(menu.getByRole('group', { name: 'Farbe' }).getByRole('menuitemradio')).toHaveCount(15);
await expect(menu.getByRole('group', { name: 'Schriftgröße' }).getByRole('menuitemradio', { checked: true })).toHaveAccessibleName('Normal');
await menu.getByRole('menuitemradio', { name: 'Groß', exact: true }).click();
await longPress(page, band);
await menu.getByRole('group', { name: 'Schriftstil' }).getByRole('menuitemradio', { name: 'Kursiv' }).click();
await expect
.poll(async () => {
const item = (await docOf(page, text.id))!;
return [item.size, item.italic, item.bold];
})
.toEqual([18, true, false]);
await expect(field).toHaveCSS('font-style', 'italic');
// Escape ends the focus: the text drawn, with Java's grey border in the documentation mode.
await field.press('Escape');
await expect(field).toHaveCount(0);
const words = page.locator(`g[data-doc="${text.id}"] .doc-text`);
await expect(words).toHaveText('Netz A\nzweite Zeile');
await expect(words).toHaveCSS('border-top-color', 'rgb(179, 179, 179)');
await expect(words).toHaveCSS('font-size', '18px');
await expect(words).toHaveCSS('font-style', 'italic');
// A rectangle without the focus is moved at once (Java gives it the focus with the press).
const before = (await docOf(page, rectangle.id))!;
const unfocused = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-rectangle`).boundingBox())!;
await expect(page.locator(`g[data-doc="${rectangle.id}"]`)).not.toHaveAttribute('data-focused', 'true');
await drag(page, { x: unfocused.x + unfocused.width / 2, y: unfocused.y + unfocused.height / 2 }, { x: unfocused.x + unfocused.width / 2 + 30, y: unfocused.y + unfocused.height / 2 });
await expect.poll(async () => (await docOf(page, rectangle.id))!.x - before.x).toBeGreaterThan(10);
// The design: the rectangle opaque, a fifth of the way to white, below the devices; the text without its border; documentation is not chosen.
await page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click();
await expect(canvas).toHaveAttribute('data-mode', 'design');
expect(await fill(page, rectangle.id)).toBe('rgb(255, 212, 212)');
await expect(page.locator(`g[data-doc="${rectangle.id}"] .doc-rectangle`)).toHaveCSS('stroke', 'rgb(204, 160, 160)');
await expect(words).toHaveCSS('border-top-style', 'none');
const wordsBox = (await words.boundingBox())!;
await page.mouse.click(wordsBox.x + 4, wordsBox.y + 4);
await expect(page.locator('[data-focused="true"]')).toHaveCount(0);
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toBeVisible();
// Back in the documentation mode: Delete removes the focused rectangle.
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
const rectangleBox = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-rectangle`).boundingBox())!;
await page.mouse.click(rectangleBox.x + rectangleBox.width / 2, rectangleBox.y + rectangleBox.height / 2);
await expect(page.locator(`g[data-doc="${rectangle.id}"]`)).toHaveAttribute('data-focused', 'true');
await page.keyboard.press('Delete');
await expect.poll(async () => (await docOf(page, rectangle.id)) === undefined).toBe(true);
// A simulation started ends the documentation mode, as Java's does.
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
await expect(canvas).toHaveAttribute('data-mode', 'simulation');
await expect(sidebar).toHaveCount(0);
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
await expect(page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' })).toHaveAttribute('aria-pressed', 'true');
});
test("exports the surface as Java's image, under the project's name", async ({ page }) => {
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
const button = page.getByRole('navigation', { name: 'Dokumentation' }).getByRole('button', { name: 'Exportieren' });
await expect(button).toHaveAttribute('title', 'Als Bild exportieren');
const saved = page.waitForEvent('download');
await button.click();
const download = await saved;
expect(download.suggestedFilename()).toBe('nat_with_mapped_port.png');
// Ready for the next one.
await expect(button).toBeEnabled();
const base64 = readFileSync((await download.path())!).toString('base64');
// What it should show, from the surface: every device's button and every documentation element, 10 around, the footer 20 below.
const boxes = await page.locator('g.device').evaluateAll((devices) => devices.map((device) => device.getAttribute('data-box')!.split(' ').map(Number)));
const docs = (await drawing(page)).docs;
const parts = [...boxes.map(([x, y, width, height]) => ({ x: x!, y: y!, width: width!, height: height! })), ...docs];
const left = Math.min(...parts.map((part) => part.x));
const top = Math.min(...parts.map((part) => part.y));
const right = Math.max(...parts.map((part) => part.x + part.width));
const bottom = Math.max(...parts.map((part) => part.y + part.height));
const footerWidth = await page.evaluate(
({ font, text }) => {
const context = document.createElement('canvas').getContext('2d')!;
context.font = font;
return Math.round(context.measureText(text).width) + 20;
},
{ font: JAVA_LABEL_FONT, text: 'nat_with_mapped_port.fls' },
);
const rectangle = docs.find((item) => item.kind === 'rectangle')!;
const image = await page.evaluate(
async ({ base64, rectangle, footer }) => {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d')!;
context.drawImage(bitmap, 0, 0);
const pixel = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data];
const footerPixels = context.getImageData(footer.x, footer.y, footer.width, footer.height).data;
let grey = 0;
for (let index = 0; index < footerPixels.length; index += 4) {
if (footerPixels[index] === 192 && footerPixels[index + 1] === 192 && footerPixels[index + 2] === 192 && footerPixels[index + 3] === 255) grey += 1;
}
return { width: bitmap.width, height: bitmap.height, corner: pixel(0, 0), rectangle: pixel(rectangle.x, rectangle.y), grey };
},
{
base64,
rectangle: { x: rectangle.x + 8 - (left - 10), y: rectangle.y + 8 - (top - 10) },
footer: { x: 10, y: bottom + 20 - (top - 10), width: footerWidth, height: 17 },
},
);
expect([image.width, image.height]).toEqual([Math.max(right, left + 10 + footerWidth) + 10 - (left - 10), bottom + 20 + 17 + 20 - (top - 10)]);
// Transparent around; a grey rectangle (204) as the design paints it, a fifth of the way to white; the footer light grey.
expect(image.corner).toEqual([0, 0, 0, 0]);
expect(image.rectangle).toEqual([215, 215, 215, 255]);
expect(image.grey).toBeGreaterThan(20);
});
test("shows Java's message when the image cannot be saved", async ({ page }) => {
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
await page.evaluate(() => {
HTMLCanvasElement.prototype.toBlob = function (callback: BlobCallback) {
callback(null);
};
});
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
const button = page.getByRole('button', { name: 'Exportieren' });
await button.click();
await expect(page.getByRole('alert')).toHaveText('Fehler beim Speichern aufgetreten!');
await expect(button).toBeEnabled();
});
test('keeps the documentation mode within reach on a phone', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 740 });
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
await expect(page.getByRole('button', { name: 'Gliederungsfläche' })).toBeInViewport({ ratio: 1 });
await expect(page.getByRole('button', { name: 'Exportieren' })).toBeInViewport({ ratio: 1 });
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
expect(Math.max(...rights)).toBeLessThanOrEqual(360);
});