// 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 }; test.beforeAll(async () => { server = await serveEditor(); }); test.afterAll(async () => { await server.close(); }); const newDoc = async (page: Page, before: ReadonlySet) => { 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'); // 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); });