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>
This commit is contained in:
@@ -4,9 +4,13 @@
|
||||
// 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; and a phone.
|
||||
// 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> };
|
||||
@@ -50,7 +54,7 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
|
||||
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']);
|
||||
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())!;
|
||||
@@ -159,11 +163,84 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user