Files
mdg-filius/packages/editor/browser/documentation.spec.ts
T
julian.kiedaischandClaude Opus 5.5 44b3f3ccb2 feat: F5.6d a drag is one change of the document; the others see it through the presence
Moving a device, moving or resizing a documentation item went into the
shared document at every pointer move: 59 updates a second, each leaving
about 18 bytes of history (M4 on test-filius). Now the editor keeps the drag
while it lasts, shows it itself and tells the others through the awareness
(moving, at most every 50 ms, the end at once), and writes it once when it
is let go. A drag the browser cancels or a second finger turns into a pinch
is written where it is; WebKit sends a second pointerdown for the first
finger when another comes, which had lost the drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:17:38 +00:00

250 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 { documentUpdates, 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', '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');
// 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);
});