In the simulation a tap on a device chooses it and opens its menu where the finger was; the three-dot button is gone there, the long press stays. The context menu moves in from the surface's edge instead of being cut off. Undo and redo show drawn arrows: the characters took whatever font the device had and looked squashed on an iPad (the buttons measured 44 x 44 at every width; the size was not the cause). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
351 lines
21 KiB
TypeScript
351 lines
21 KiB
TypeScript
// The editor by finger alone (F4.9, plan §7.2), in Chromium on the desktop
|
||
// and in WebKit on an iPad upright and on its side (playwright.config.ts):
|
||
// the acceptance of F4 as a script — FILIUS' „zwei Rechnernetze“ built
|
||
// anew, set up and pinged across, without a mouse —; two fingers zoom the
|
||
// surface and never the page (M7); every action without a right click
|
||
// (long press, the three-dot button, the documentation's menu); every
|
||
// target at least 44 px, nothing beyond the screen's edge; in the simulation
|
||
// a tap opens a device's menu.
|
||
|
||
import { expect, test, type Page } from '@playwright/test';
|
||
import { drawing, onDevice, openEditor, scaleOf, serveEditor } from './editorPage.js';
|
||
import { fingers, type Fingers, type Point } from './fingers.js';
|
||
|
||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||
|
||
test.use({ hasTouch: true });
|
||
|
||
test.beforeAll(async () => {
|
||
server = await serveEditor();
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
await server.close();
|
||
});
|
||
|
||
const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' });
|
||
const field = (page: Page, name: string) => sheet(page).locator(`input[name="${name}"]`);
|
||
|
||
async function centreOf(page: Page, selector: string): Promise<Point> {
|
||
const box = (await page.locator(selector).first().boundingBox())!;
|
||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||
}
|
||
|
||
/** A point of the surface by its share of the surface's width and height. */
|
||
async function surfaceAt(page: Page, across: number, down: number): Promise<Point> {
|
||
const box = (await page.locator('svg.filius-canvas').boundingBox())!;
|
||
return { x: box.x + box.width * across, y: box.y + box.height * down };
|
||
}
|
||
|
||
/** A device dragged from the palette by finger; its sheet closed again (it opens with the new device chosen). */
|
||
async function place(page: Page, finger: Fingers, kind: string, at: Point): Promise<string> {
|
||
const before = new Set((await drawing(page)).nodes.map((node) => node.id));
|
||
await finger.drag(await centreOf(page, `[data-palette="${kind}"]`), at);
|
||
if (kind === 'router') await page.getByRole('dialog').getByRole('button', { name: '2', exact: true }).tap();
|
||
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(before.size + 1);
|
||
await sheet(page).getByRole('button', { name: 'Schließen' }).tap();
|
||
await expect(sheet(page)).toHaveCount(0);
|
||
return (await drawing(page)).nodes.find((node) => !before.has(node.id))!.id;
|
||
}
|
||
|
||
/** A field of the sheet typed into after a tap, as the iPad's keyboard types. */
|
||
async function type(page: Page, name: string, text: string): Promise<void> {
|
||
await field(page, name).tap();
|
||
await field(page, name).fill(text);
|
||
await field(page, name).press('Enter');
|
||
}
|
||
|
||
test('builds FILIUS\' „zwei Rechnernetze“ by finger and pings across (the acceptance of F4)', async ({ page }) => {
|
||
test.setTimeout(120_000);
|
||
await openEditor(page, server.base, 'new');
|
||
const finger = await fingers(page);
|
||
const pcA = await place(page, finger, 'pc', await surfaceAt(page, 0.18, 0.25));
|
||
const switchA = await place(page, finger, 'networkSwitch', await surfaceAt(page, 0.2, 0.6));
|
||
const router = await place(page, finger, 'router', await surfaceAt(page, 0.5, 0.42));
|
||
const switchB = await place(page, finger, 'networkSwitch', await surfaceAt(page, 0.8, 0.6));
|
||
const pcB = await place(page, finger, 'pc', await surfaceAt(page, 0.82, 0.25));
|
||
|
||
// The cable tool: from device to device, a free port each; tapped again, it is off.
|
||
await page.locator('[data-palette="cable"]').tap();
|
||
for (const [from, to] of [
|
||
[pcA, switchA],
|
||
[switchA, router],
|
||
[router, switchB],
|
||
[switchB, pcB],
|
||
] as const) {
|
||
const count = (await drawing(page)).links.length;
|
||
await finger.drag(await onDevice(page, from), await onDevice(page, to));
|
||
await expect.poll(async () => (await drawing(page)).links.length).toBe(count + 1);
|
||
}
|
||
await page.locator('[data-palette="cable"]').tap();
|
||
await expect(page.locator('[data-palette="cable"]')).toHaveAttribute('aria-pressed', 'false');
|
||
|
||
// The addresses, in the sheet a tap on a device opens.
|
||
for (const [id, address, gateway] of [
|
||
[pcA, '141.99.1.10', '141.99.1.1'],
|
||
[pcB, '141.99.2.10', '141.99.2.1'],
|
||
] as const) {
|
||
await finger.tap(await onDevice(page, id));
|
||
await expect(sheet(page)).toBeVisible();
|
||
await type(page, 'ipAddress', address);
|
||
await type(page, 'subnetMask', '255.255.255.0');
|
||
await type(page, 'defaultGateway', gateway);
|
||
await sheet(page).getByRole('button', { name: 'Schließen' }).tap();
|
||
}
|
||
// The router's first card went to the first switch (the first cable it got), the second to the other.
|
||
await finger.tap(await onDevice(page, router));
|
||
const tabs = sheet(page).getByRole('tab');
|
||
await tabs.nth(1).tap();
|
||
await type(page, 'ipAddress', '141.99.1.1');
|
||
await tabs.nth(2).tap();
|
||
await type(page, 'ipAddress', '141.99.2.1');
|
||
await expect(tabs).toHaveText(['Allgemein', '141.99.1.1', '141.99.2.1', 'Weiterleitungstabelle']);
|
||
await sheet(page).getByRole('button', { name: 'Schließen' }).tap();
|
||
|
||
// The simulation; the command line installed on the first computer; a ping across the router.
|
||
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).tap();
|
||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||
// A tap opens the computer's menu, „Desktop anzeigen“ its desktop as the sheet.
|
||
await finger.tap(await onDevice(page, pcA));
|
||
await page.getByRole('menuitem', { name: 'Desktop anzeigen' }).tap();
|
||
const desktop = page.locator(`section[data-desktop="${pcA}"]`);
|
||
await desktop.locator('[data-program="installation"]').tap();
|
||
const installation = desktop.locator('[data-program-window="installation"]');
|
||
await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: 'Befehlszeile' }).tap();
|
||
await installation.getByRole('button', { name: 'Installieren' }).tap();
|
||
await installation.getByRole('button', { name: 'Änderungen annehmen' }).tap();
|
||
await desktop.locator('[data-program="commandPrompt"]').tap();
|
||
const terminal = desktop.locator('[data-program-window="commandPrompt"]');
|
||
await terminal.getByRole('button', { name: 'ping', exact: true }).tap();
|
||
const input = terminal.locator('input[name="terminalInput"]');
|
||
await expect(input).toHaveValue('ping ');
|
||
await input.pressSequentially('141.99.2.10');
|
||
await input.press('Enter');
|
||
await expect(terminal.locator('.terminal-text')).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 });
|
||
});
|
||
|
||
test('zooms the surface with two fingers and never the page (M7)', async ({ page }) => {
|
||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||
const finger = await fingers(page);
|
||
const canvas = page.locator('svg.filius-canvas');
|
||
await expect(canvas).toHaveCSS('touch-action', 'none');
|
||
const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number);
|
||
const page_ = () => page.evaluate(() => ({ scale: window.visualViewport?.scale ?? 1, x: window.scrollX, y: window.scrollY }));
|
||
const start = await zoom();
|
||
const middle = await surfaceAt(page, 0.4, 0.5);
|
||
await finger.pinch(middle, 40, 80);
|
||
await expect.poll(zoom).toBeGreaterThan(start * 1.5);
|
||
expect(await page_()).toEqual({ scale: 1, x: 0, y: 0 });
|
||
await finger.pinch(middle, 80, 30);
|
||
await expect.poll(zoom).toBeLessThan(start);
|
||
expect(await page_()).toEqual({ scale: 1, x: 0, y: 0 });
|
||
});
|
||
|
||
test('puts a device or an item down where it is when a second finger comes or the browser takes the touch', async ({ page }) => {
|
||
for (const gesture of ['dragThenPinch', 'dragThenCancel'] as const) {
|
||
// Each on a fresh page: the pinch zooms, and the next device would be elsewhere.
|
||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||
const finger = await fingers(page);
|
||
const id = (await drawing(page)).nodes[0]!.id;
|
||
const before = (await drawing(page)).nodes.find((node) => node.id === id)!;
|
||
const from = await onDevice(page, id);
|
||
await finger[gesture](from, { x: from.x + 40, y: from.y + 60 });
|
||
// In the document, not only on the screen: the drag ended there and then.
|
||
await expect
|
||
.poll(async () => {
|
||
const now = (await drawing(page)).nodes.find((node) => node.id === id)!;
|
||
return now.x !== before.x || now.y !== before.y;
|
||
}, { message: gesture })
|
||
.toBe(true);
|
||
const shown = await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box');
|
||
await page.waitForTimeout(300);
|
||
expect(await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box')).toBe(shown);
|
||
}
|
||
// A rectangle of the documentation, its drag taken by the browser: it too stays where it was dragged.
|
||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||
const finger = await fingers(page);
|
||
await page.locator('button[name="documentationMode"]').tap();
|
||
const rectangle = (await drawing(page)).docs.find((item) => item.kind === 'rectangle')!;
|
||
const area = (await page.locator(`g[data-doc="${rectangle.id}"]`).boundingBox())!;
|
||
const middle = { x: area.x + area.width / 2, y: area.y + area.height / 2 };
|
||
await finger.dragThenCancel(middle, { x: middle.x + 40, y: middle.y + 30 });
|
||
await expect.poll(async () => (await drawing(page)).docs.find((item) => item.id === rectangle.id)!.x).not.toBe(rectangle.x);
|
||
});
|
||
|
||
test("resizes a rectangle of the documentation by its handles, and moves and resizes a text by its grip and handles, with a finger", async ({ page }) => {
|
||
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
|
||
const finger = await fingers(page);
|
||
await page.locator('button[name="documentationMode"]').tap();
|
||
const docOf = async (id: string) => (await drawing(page)).docs.find((item) => item.id === id)!;
|
||
const frameOf = async (id: string) => {
|
||
const item = await docOf(id);
|
||
return [item.x, item.y, item.width, item.height].map(Math.round);
|
||
};
|
||
const handle = (name: string) => page.locator(`[data-handle="${name}"]`);
|
||
const scale = await scaleOf(page);
|
||
|
||
// A rectangle tapped: its handles, each a finger's target, the three-dot button clear of them.
|
||
const rectangle = (await drawing(page)).docs.find((item) => item.kind === 'rectangle')!;
|
||
await finger.tap(await centreOf(page, `g[data-doc="${rectangle.id}"] .doc-rectangle`));
|
||
await expect(page.locator(`g[data-doc="${rectangle.id}"]`)).toHaveAttribute('data-focused', 'true');
|
||
const names = await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getAttribute('data-handle')));
|
||
expect(names).toEqual(expect.arrayContaining(['top-left', 'top-right', 'bottom-right', 'bottom-left']));
|
||
for (const box of await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect))) expect([box.width, box.height]).toEqual([44, 44]);
|
||
const menu = (await page.locator('.device-menu-button').boundingBox())!;
|
||
for (const box of await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect))) {
|
||
expect(box.y >= menu.y + menu.height || box.y + box.height <= menu.y || box.x >= menu.x + menu.width || box.x + box.width <= menu.x).toBe(true);
|
||
}
|
||
// The bottom right corner dragged 60 right and 40 down: wider and higher by as much in the drawing, the top left kept.
|
||
const [x, y, width, height] = await frameOf(rectangle.id);
|
||
const bottomRight = await centreOf(page, '[data-handle="bottom-right"]');
|
||
await finger.drag(bottomRight, { x: bottomRight.x + 60, y: bottomRight.y + 40 });
|
||
await expect.poll(() => frameOf(rectangle.id)).toEqual([x, y, Math.round(width! + 60 / scale), Math.round(height! + 40 / scale)]);
|
||
// The top left one 30 left: the right edge kept.
|
||
const wider = await frameOf(rectangle.id);
|
||
const topLeft = await centreOf(page, '[data-handle="top-left"]');
|
||
await finger.drag(topLeft, { x: topLeft.x - 30, y: topLeft.y });
|
||
await expect.poll(async () => (await frameOf(rectangle.id))[0]).toBe(Math.round(wider[0]! - 30 / scale));
|
||
const [left, , across] = await frameOf(rectangle.id);
|
||
expect(Math.abs(left! + across! - (wider[0]! + wider[2]!))).toBeLessThanOrEqual(1);
|
||
|
||
// A long press on a handle opens the rectangle's menu, as on the rectangle.
|
||
await finger.press(await centreOf(page, '[data-handle="top-left"]'));
|
||
await expect(page.getByRole('menu').getByRole('group', { name: 'Farbe' })).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByRole('menu')).toHaveCount(0);
|
||
|
||
// The text in its top edge lies partly under a handle: tapped there, the text is chosen and typed into, not the rectangle.
|
||
const below = (await drawing(page)).docs.find((item) => item.kind === 'text' && item.text.startsWith('Hosts'))!;
|
||
const words = (await page.locator(`g[data-doc="${below.id}"] .doc-text`).boundingBox())!;
|
||
const covering = (await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect)))
|
||
.map((box) => ({ left: Math.max(box.x, words.x), top: Math.max(box.y, words.y), right: Math.min(box.x + box.width, words.x + words.width), bottom: Math.min(box.y + box.height, words.y + words.height) }))
|
||
.find((both) => both.right - both.left > 4 && both.bottom - both.top > 4)!;
|
||
expect(covering).toBeDefined();
|
||
await finger.tap({ x: (covering.left + covering.right) / 2, y: (covering.top + covering.bottom) / 2 });
|
||
await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible();
|
||
await expect(page.locator(`g[data-doc="${below.id}"]`)).toHaveAttribute('data-focused', 'true');
|
||
|
||
// A text with room around it, tapped: a grip above its top left corner moves it — its size kept.
|
||
await page.getByRole('textbox', { name: 'Text' }).press('Escape');
|
||
const text = (await drawing(page)).docs.find((item) => item.kind === 'text' && item.text.startsWith('Traffic'))!;
|
||
await finger.tap(await centreOf(page, `g[data-doc="${text.id}"] .doc-text`));
|
||
await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible();
|
||
await expect(handle('move')).toBeVisible();
|
||
// The handles sit on the blue band around the text (where Java takes hold of it), not on its words.
|
||
const bandBox = (await page.locator(`g[data-doc="${text.id}"] .doc-focus`).boundingBox())!;
|
||
const cornerAt = await centreOf(page, '[data-handle="bottom-right"]');
|
||
expect(Math.abs(cornerAt.x - (bandBox.x + bandBox.width + scale))).toBeLessThanOrEqual(1.5);
|
||
expect(Math.abs(cornerAt.y - (bandBox.y + bandBox.height + scale))).toBeLessThanOrEqual(1.5);
|
||
// Every point of a handle's target takes the finger, also where it lies over the field typed into.
|
||
const hidden = await page.locator('[data-handle]').evaluateAll((all) =>
|
||
all.flatMap((each) => {
|
||
const box = each.getBoundingClientRect();
|
||
const points = [
|
||
[box.left + 3, box.top + 3],
|
||
[box.right - 3, box.top + 3],
|
||
[box.left + 3, box.bottom - 3],
|
||
[box.right - 3, box.bottom - 3],
|
||
];
|
||
return points.filter(([x, y]) => document.elementFromPoint(x!, y!)?.closest('[data-handle]') !== each).map(() => each.getAttribute('data-handle'));
|
||
}),
|
||
);
|
||
expect(hidden).toEqual([]);
|
||
const before = await frameOf(text.id);
|
||
const grip = await centreOf(page, '[data-handle="move"]');
|
||
await finger.drag(grip, { x: grip.x + 50, y: grip.y + 30 });
|
||
await expect.poll(() => frameOf(text.id)).toEqual([Math.round(before[0]! + 50 / scale), Math.round(before[1]! + 30 / scale), before[2], before[3]]);
|
||
// Its bottom right handle: wider and higher; what it says is unchanged.
|
||
const moved = await frameOf(text.id);
|
||
const corner = await centreOf(page, '[data-handle="bottom-right"]');
|
||
await finger.drag(corner, { x: corner.x + 40, y: corner.y + 20 });
|
||
await expect.poll(() => frameOf(text.id)).toEqual([moved[0], moved[1], Math.round(moved[2]! + 40 / scale), Math.round(moved[3]! + 20 / scale)]);
|
||
expect((await docOf(text.id)).text).toBe(text.text);
|
||
|
||
// Two fingers, the second on a handle: they zoom, and the text keeps its size.
|
||
const sized = await frameOf(text.id);
|
||
const onHandle = await centreOf(page, '[data-handle="bottom-right"]');
|
||
await finger.pinch({ x: onHandle.x - 40, y: onHandle.y }, 40, 120);
|
||
await expect.poll(() => scaleOf(page)).toBeGreaterThan(scale * 1.5);
|
||
expect(await frameOf(text.id)).toEqual(sized);
|
||
});
|
||
|
||
test('reaches every action without a right click: a long press, the three-dot button, the documentation', async ({ page }) => {
|
||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||
const finger = await fingers(page);
|
||
const view = await drawing(page);
|
||
const notebook = view.nodes.find((node) => node.name === 'Notebook 1.11')!.id;
|
||
// A long press opens a device's menu; „Löschen“ removes it with its cable.
|
||
await finger.press(await onDevice(page, notebook));
|
||
const menu = page.getByRole('menu');
|
||
await expect(menu).toBeVisible();
|
||
await menu.getByRole('menuitem', { name: 'Löschen' }).tap();
|
||
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(view.nodes.length - 1);
|
||
|
||
// A tap chooses a device; its three-dot button opens the same menu.
|
||
const router = view.nodes.find((node) => node.kind === 'router')!.id;
|
||
await finger.tap(await onDevice(page, router));
|
||
await sheet(page).getByRole('button', { name: 'Schließen' }).tap();
|
||
await finger.tap(await onDevice(page, router));
|
||
await page.getByRole('button', { name: 'Aktionen' }).tap();
|
||
await expect(menu).toBeVisible();
|
||
await expect(menu.getByRole('menuitem', { name: 'Löschen' })).toBeVisible();
|
||
await finger.tap(await surfaceAt(page, 0.5, 0.95));
|
||
await expect(menu).toHaveCount(0);
|
||
|
||
// The documentation mode: a rectangle dragged from its sidebar, its menu by a long press, removed.
|
||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).tap();
|
||
const docs = view.docs.length;
|
||
await finger.drag(await centreOf(page, '[data-docu="rectangle"]'), await surfaceAt(page, 0.5, 0.3));
|
||
await expect.poll(async () => (await drawing(page)).docs.length).toBe(docs + 1);
|
||
const rectangle = (await drawing(page)).docs.find((item) => !view.docs.some((each) => each.id === item.id))!;
|
||
await finger.press(await centreOf(page, `g[data-doc="${rectangle.id}"] .doc-rectangle`));
|
||
await menu.getByRole('menuitem', { name: 'Löschen' }).tap();
|
||
await expect.poll(async () => (await drawing(page)).docs.length).toBe(docs);
|
||
});
|
||
|
||
test('gives every target 44 px at least and keeps the editor within the screen', async ({ page }) => {
|
||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||
const finger = await fingers(page);
|
||
const editor = page.locator('main');
|
||
const fits = () => editor.evaluate((element) => element.scrollWidth <= element.clientWidth && element.getBoundingClientRect().right <= window.innerWidth);
|
||
const small = (selector: string) =>
|
||
page.locator(selector).evaluateAll((elements) =>
|
||
elements
|
||
.filter((element) => (element as HTMLElement).offsetParent !== null)
|
||
.map((element) => ({ name: element.getAttribute('aria-label') ?? element.textContent?.trim() ?? '', box: element.getBoundingClientRect() }))
|
||
.filter(({ box }) => box.width < 44 || box.height < 44)
|
||
.map(({ name, box }) => `${name} ${Math.round(box.width)}×${Math.round(box.height)}`),
|
||
);
|
||
const targets =
|
||
'.editor-toolbar button, [data-palette], [data-docu], nav button, [data-zoom] ~ button, .zoom-bar button, .device-menu-button, [data-handle], aside[aria-label="Einstellungen"] button, aside[aria-label="Einstellungen"] [role="tab"]';
|
||
expect(await fits()).toBe(true);
|
||
expect(await small(targets)).toEqual([]);
|
||
// With a device chosen: its sheet and its three-dot button.
|
||
const router = (await drawing(page)).nodes.find((node) => node.kind === 'router')!.id;
|
||
await finger.tap(await onDevice(page, router));
|
||
await expect(sheet(page)).toBeVisible();
|
||
expect(await fits()).toBe(true);
|
||
expect(await small(targets)).toEqual([]);
|
||
// The documentation mode's sidebar.
|
||
await sheet(page).getByRole('button', { name: 'Schließen' }).tap();
|
||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).tap();
|
||
expect(await fits()).toBe(true);
|
||
expect(await small(targets)).toEqual([]);
|
||
});
|
||
|
||
test('draws undo and redo as arrows of their own, in the middle of their buttons, not as a character of whatever font the device has', async ({ page }) => {
|
||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||
for (const name of ['Rückgängig', 'Wiederholen']) {
|
||
const button = page.getByRole('button', { name, exact: true });
|
||
await expect(button).toHaveText('');
|
||
const [outer, arrow] = [(await button.boundingBox())!, (await button.locator('svg').boundingBox())!];
|
||
expect([arrow.width, arrow.height]).toEqual([20, 20]);
|
||
expect(Math.abs(arrow.x + arrow.width / 2 - (outer.x + outer.width / 2))).toBeLessThanOrEqual(0.5);
|
||
expect(Math.abs(arrow.y + arrow.height / 2 - (outer.y + outer.height / 2))).toBeLessThanOrEqual(0.5);
|
||
expect(Math.min(outer.width, outer.height)).toBeGreaterThanOrEqual(44);
|
||
}
|
||
// Redo is undo's mirror image.
|
||
const paths = await page.locator('.editor-toolbar svg g').evaluateAll((groups) => groups.map((group) => group.getAttribute('transform')));
|
||
expect(paths).toEqual([null, 'matrix(-1 0 0 1 24 0)']);
|
||
});
|