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>
247 lines
15 KiB
TypeScript
247 lines
15 KiB
TypeScript
// 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);
|
||
});
|