Files
mdg-filius/packages/editor/browser/documentation.spec.ts
T
julian.kiedaischandClaude Opus 5.5 8c4e5049f6 feat(editor): handles for a finger on documentation items, and a grip that moves a text
The focused rectangle or text shows 44 px handles on its focus band: corners,
and the middles of long enough sides; on an item smaller than two targets only
the bottom right one, outside its corner. A text gets a grip above its top
left corner, since its middle is the field typed into. The three-dot button
moves above the top right corner; grip and button step aside at the surface's
edge. A tap on a handle is one on what lies under it, a long press opens the
menu, a second finger zooms. WebKit hands a tap's end to the handle despite
the capture, so the handle passes its events on.

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

268 lines
16 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; its handles by mouse; 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 { documentUpdates, drag, drawing, openEditor, scaleOf, 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', { name: 'Leiste einklappen' })).toBeVisible();
await expect(sidebar.locator('.side-bar__items').getByRole('button')).toHaveText(['Text', 'Gliederungsfläche', 'Exportieren', 'Report']);
// 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); a drag is one change of the document, when it is let go.
const changes = await documentUpdates(page);
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);
expect(await documentUpdates(page)).toBe(changes + 1);
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');
// Its handles take the mouse too, even where one jump of it lands over the field typed into: the corner follows.
const sized = (await docOf(page, text.id))!;
const scale = await scaleOf(page);
const cornerHandle = (await page.locator('[data-handle="bottom-right"]').boundingBox())!;
const from = { x: cornerHandle.x + cornerHandle.width / 2, y: cornerHandle.y + cornerHandle.height / 2 };
const fieldBox = (await field.boundingBox())!;
const into = { x: fieldBox.x + fieldBox.width - 4, y: fieldBox.y + fieldBox.height - 4 };
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(into.x, into.y);
await page.mouse.up();
await expect
.poll(async () => {
const item = (await docOf(page, text.id))!;
return [item.width, item.height].map(Math.round);
})
.toEqual([Math.round(Math.max(88, sized.width + (into.x - from.x) / scale)), Math.round(Math.max(20, sized.height + (into.y - from.y) / scale))]);
// 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 });
await expect(page.getByRole('button', { name: 'Report' })).toBeInViewport({ ratio: 1 });
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
expect(Math.max(...rights)).toBeLessThanOrEqual(360);
});