ReportRun reads Java's report back with iText: every piece of text with its place, font, size and colour. The editor lays the report out by iText's rules (A4, leading 1.5, loose chunks in the current line, tables 80 % wide with padding 2, fixed traffic columns, page breaks) and draws it with pdf-lib in the PDF's own Helvetica, widths without kerning as iText measures. All device pages of Internet_Komplett, the first page and the data exchange of einfaches_rechnernetz_komplett match Java piece by piece; Java's iText reads our PDF with the same text in the same places. The sidebar gains Java's "Report"; the frames come from the room by a short subscription. Not adopted: true/false for DHCP (Java's own Ja/Nein), hash table orders, an image running off the page, the report writing back into the model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
15 KiB
TypeScript
248 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', '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).
|
||
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 });
|
||
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);
|
||
});
|