diff --git a/README.md b/README.md index efdcad47..354ea921 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1374 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1375 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/projectCommands.ts b/src/core/commands/projectCommands.ts index 2f68a6d2..cb0b23a1 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -1,5 +1,6 @@ import { collisionLayersOf } from '../model/collisionLayers'; import type { Project } from '../model/Project'; +import { clampSymbolScale } from '../model/symbolScale'; import type { Command } from './Command'; export class RenameProjectCommand implements Command { @@ -38,3 +39,20 @@ export class SetCollisionLayersCommand implements Command { project.collisionLayers = this.previous; } } + +/** Size of the scene view's symbol icons, kept inside its range like a loaded project's. */ +export class SetSymbolScaleCommand implements Command { + readonly label = 'Symbol scale'; + private previous = 1; + + constructor(private readonly scale: number) {} + + execute(project: Project): void { + this.previous = project.symbolScale; + project.symbolScale = clampSymbolScale(this.scale); + } + + undo(project: Project): void { + project.symbolScale = this.previous; + } +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 3d8ad412..9b26956b 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -7,6 +7,7 @@ import { type SceneAsset, } from './Asset'; import { DEFAULT_LAYER_NAME } from './collisionLayers'; +import { DEFAULT_SYMBOL_SCALE } from './symbolScale'; /** 2: a placed container carries no copy of its template. */ export const PROJECT_FORMAT_VERSION = 2; @@ -36,6 +37,11 @@ export interface Project { * which is also what every collider starts on, so nothing collides differently than it did. */ collisionLayers: string[]; + /** + * Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project + * written before there was one reads as 1, the size the icons always had. + */ + symbolScale: number; } export function createEmptyProject(id: string, name: string): Project { @@ -48,6 +54,7 @@ export function createEmptyProject(id: string, name: string): Project { startSceneId: '', plugins: [], collisionLayers: [DEFAULT_LAYER_NAME], + symbolScale: DEFAULT_SYMBOL_SCALE, }; } diff --git a/src/core/model/symbolScale.ts b/src/core/model/symbolScale.ts new file mode 100644 index 00000000..cc0ab113 --- /dev/null +++ b/src/core/model/symbolScale.ts @@ -0,0 +1,19 @@ +/** + * How large the scene view draws its symbol icons, as a factor of their normal size. A project of + * 16-pixel art wants them smaller than one of 64-pixel art, which is why it belongs to the project + * and not to the browser. + */ +export const SYMBOL_SCALE_MIN = 0.1; +export const SYMBOL_SCALE_MAX = 4; +export const DEFAULT_SYMBOL_SCALE = 1; + +/** Keeps a factor inside the range; anything that is not a finite number is the default. */ +export function clampSymbolScale(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_SYMBOL_SCALE; + return Math.min(SYMBOL_SCALE_MAX, Math.max(SYMBOL_SCALE_MIN, value)); +} + +/** The factor as stored, read off untrusted JSON. A project written before there was one has 1. */ +export function symbolScaleOf(raw: unknown): number { + return clampSymbolScale((raw as { symbolScale?: unknown } | null)?.symbolScale); +} diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index ff5c6874..ab1539a6 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -114,6 +114,22 @@ export function footprintCorners( return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0); } +/** + * The upright square of a symbol that is only its icon, centred on where the icon stands and grown + * with the project's symbol scale like the icon, plus `pad` world units on every side. + */ +export function iconCorners(type: AssetType, props: PropertyBag, at: Vec2, scale: number, pad = 0): Vec2[] { + const { w, h } = footprintOf(type, props); + const hw = (w * scale) / 2 + pad; + const hh = (h * scale) / 2 + pad; + return [ + { x: at.x - hw, y: at.y - hh }, + { x: at.x + hw, y: at.y - hh }, + { x: at.x + hw, y: at.y + hh }, + { x: at.x - hw, y: at.y + hh }, + ]; +} + /** The world rectangle around the drawn footprint – what culling, framing and labels need. */ export function footprintExtent( type: AssetType, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 0f7fc2ce..4ce990b5 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -39,6 +39,7 @@ import type { import { parseCellKey } from '../model/tilemap'; import { pluginsOf } from './projectPlugins'; import { collisionLayersOf } from '../model/collisionLayers'; +import { symbolScaleOf } from '../model/symbolScale'; import { decodeCells, encodeCells } from './tilemapCells'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; @@ -248,6 +249,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { // is actually running. plugins: pluginsOf(r), collisionLayers: collisionLayersOf(r), + symbolScale: symbolScaleOf(r), }; return project; } diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 46e45823..162e8b2b 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -1,7 +1,11 @@ /** * Loading, creating, switching and deleting projects, plus the top bar's menu that triggers it. */ -import { RenameProjectCommand, SetCollisionLayersCommand } from '../core/commands/projectCommands'; +import { + RenameProjectCommand, + SetCollisionLayersCommand, + SetSymbolScaleCommand, +} from '../core/commands/projectCommands'; import type { ProjectStore } from '../core/store/ProjectStore'; import { Dialog, Menu, type MenuEntry, type Widget } from '../ui'; import { dialogSlot } from './FormDialog'; @@ -137,9 +141,10 @@ export class ProjectSession { } /** - * Name, the scene Play starts and the collision layers (#152) in one dialog (#183). The name and - * the layers are undo steps of their own – one each, and only for what actually changed; which - * scene Play starts is a preference of this browser, not of the project (see `settings.ts`). + * Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog + * (#183). Name, symbol scale and layers are undo steps of their own – one each, and only for what + * actually changed; which scene Play starts is a preference of this browser, not of the project + * (see `settings.ts`). */ openProjectSettings(): ProjectSettingsDialog { const { project } = this.state; @@ -147,6 +152,7 @@ export class ProjectSession { name: project.name, playFrom: this.state.playFrom, collisionLayers: [...project.collisionLayers], + symbolScale: project.symbolScale, }; return this.settingsDialog.open( new ProjectSettingsDialog(before, (settings) => { @@ -154,6 +160,8 @@ export class ProjectSession { if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name)); if (change.collisionLayers !== undefined) this.state.execute(new SetCollisionLayersCommand(change.collisionLayers)); + if (change.symbolScale !== undefined) + this.state.execute(new SetSymbolScaleCommand(change.symbolScale)); this.state.setPlayFrom(change.playFrom); }), ); diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index 11a76b4c..08c94a66 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -1,5 +1,6 @@ import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; -import { Button, HStack, Label, Menu, TextField, Theme, VStack } from '../ui'; +import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale'; +import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack } from '../ui'; import type { PlayFrom } from './settings'; import { FormDialog } from './FormDialog'; @@ -8,6 +9,7 @@ export interface ProjectSettings { name: string; playFrom: PlayFrom; collisionLayers: string[]; + symbolScale: number; } /** @@ -21,13 +23,15 @@ export interface ProjectSettings { export function settingsChanges( before: ProjectSettings, after: ProjectSettings, -): { name?: string; collisionLayers?: string[]; playFrom: PlayFrom } { +): { name?: string; collisionLayers?: string[]; symbolScale?: number; playFrom: PlayFrom } { const name = after.name.trim() || before.name; + const symbolScale = clampSymbolScale(after.symbolScale); return { ...(name === before.name ? {} : { name }), ...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers) ? {} : { collisionLayers: [...after.collisionLayers] }), + ...(symbolScale === before.symbolScale ? {} : { symbolScale }), playFrom: after.playFrom, }; } @@ -38,9 +42,9 @@ const PLAY_FROM_LABEL: Record = { }; /** - * Everything about the project that is not an asset (#183): its name, which scene Play starts, and - * the collision layers (#152). The top bar carries icons only, so this is the one place the three - * are edited. + * Everything about the project that is not an asset (#183): its name, which scene Play starts, the + * size of the scene view's symbol icons, and the collision layers (#152). The top bar carries icons + * only, so this is the one place they are edited. * * Colliders name a layer by its *number*, so a layer taken out of the middle would silently move * every collider below it onto another one; only the last layer can be removed, and the list is @@ -50,8 +54,10 @@ export class ProjectSettingsDialog extends FormDialog { private projectName: string; private playFrom: PlayFrom; private layers: string[]; + private symbolScale: number; private readonly nameField: TextField; private readonly playFromButton: Button; + private readonly symbolScaleField: NumberField; private readonly rows = new VStack({ gap: Theme.gap }); private readonly addButton: Button; @@ -60,6 +66,7 @@ export class ProjectSettingsDialog extends FormDialog { this.projectName = settings.name; this.playFrom = settings.playFrom; this.layers = [...settings.collisionLayers]; + this.symbolScale = clampSymbolScale(settings.symbolScale); this.nameField = new TextField(this.projectName, { placeholder: 'Project name', @@ -82,6 +89,26 @@ export class ProjectSettingsDialog extends FormDialog { }), ); + this.symbolScaleField = new NumberField(this.symbolScale, { + width: 150, + min: SYMBOL_SCALE_MIN, + max: SYMBOL_SCALE_MAX, + step: 0.01, + decimals: 2, + onInput: (value) => { + this.symbolScale = value; + }, + onChange: (value) => { + this.symbolScale = value; + }, + }); + this.content.add(this.formRow(this.fieldLabel('Symbol scale'), this.symbolScaleField)); + this.content.add( + new Label(`Size of the icons in the scene view, ${SYMBOL_SCALE_MIN} to ${SYMBOL_SCALE_MAX}.`, { + style: 'muted', + }), + ); + this.content.add(new Label('Collision layers', { style: 'bodyBold' })); this.content.add( new Label('Two colliders meet only when each one names the layer of the other.', { @@ -97,6 +124,7 @@ export class ProjectSettingsDialog extends FormDialog { name: this.projectName, playFrom: this.playFrom, collisionLayers: this.layers, + symbolScale: this.symbolScale, }), ); this.buildLayers(); @@ -107,9 +135,9 @@ export class ProjectSettingsDialog extends FormDialog { return [...this.layers]; } - /** Name and start-scene switch as they stand (test hook). */ - values(): { name: string; playFrom: PlayFrom } { - return { name: this.projectName, playFrom: this.playFrom }; + /** Name, start-scene switch and symbol scale as they stand (test hook). */ + values(): { name: string; playFrom: PlayFrom; symbolScale: number } { + return { name: this.projectName, playFrom: this.playFrom, symbolScale: this.symbolScale }; } /** The start-scene switch itself, so a check can click it. */ @@ -117,6 +145,11 @@ export class ProjectSettingsDialog extends FormDialog { return this.playFromButton; } + /** The symbol scale field, so a check can type into it. */ + get symbolScaleControl(): NumberField { + return this.symbolScaleField; + } + /** A label and a field that takes the rest of the row – the layer rows are built the same way. */ private labelledRow(text: string, field: TextField): HStack { const row = new HStack({ gap: Theme.gap, align: 'center' }); diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index ea35627e..07a87b58 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -220,13 +220,18 @@ export function uiHooks({ app, state, editor }: HookContext) { project: [...state.project.collisionLayers], dialog: editor.projectSettingsDialog()?.layerNames() ?? null, }), - /** The "Project settings" dialog (#183): name and start-scene switch as its fields stand. */ + /** The "Project settings" dialog (#183): name, start-scene switch and symbol scale as its fields stand. */ projectSettings: () => editor.projectSettingsDialog()?.values() ?? null, /** Where to click the start-scene switch inside that dialog. */ projectSettingsPlayFromAt: () => { const dialog = editor.projectSettingsDialog(); return dialog ? centreOf(dialog.playFromControl) : null; }, + /** Where to click the symbol scale field inside that dialog. */ + projectSettingsSymbolScaleAt: () => { + const dialog = editor.projectSettingsDialog(); + return dialog ? centreOf(dialog.symbolScaleControl) : null; + }, /** The "Open project" dialog (#183): the projects it lists and the row picked. */ projectPicker: () => editor.projectPickerDialog()?.info() ?? null, /** The dividers left and right of the centre column – dragging them narrows the tab row. */ diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 87a7ebe8..78de58d9 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -16,13 +16,12 @@ import { invert, linearOf, type Mat, - translation, } from '../../core/model/matrix'; import { placementRejection } from '../../core/model/placement'; import type { Position, Vec2 } from '../../core/model/Properties'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { cellAtPoint } from '../../core/model/tilemap'; -import { footprintCorners } from '../../core/properties/bounds'; +import { footprintCorners, iconCorners } from '../../core/properties/bounds'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; import { FramePreview } from '../framePreview'; import { type EditorState, objectSelection } from '../state'; @@ -582,7 +581,7 @@ export class SceneViewport extends FreeViewport { return this.solids.info(this.lastItems.find((i) => i.id === id)); } - /** The icon of a symbol in world units, the name beside it and whether it hangs off its owner (test hook). */ + /** The icon of a symbol in world units (its side as `size`), the name beside it and whether it hangs off its owner (test hook). */ iconInfo(id: string): { layer: string; visible: boolean; @@ -592,6 +591,7 @@ export class SceneViewport extends FreeViewport { y: number; screen: Vec2; clickable: boolean; + size: number; } | null { const icon = this.icons.get(id); if (!icon) return null; @@ -606,6 +606,7 @@ export class SceneViewport extends FreeViewport { y: icon.position.y, screen: { x: screen.x, y: screen.y }, clickable: icon.eventMode !== 'none', + size: icon.scale.x * SYMBOL_GRID, }; } @@ -628,7 +629,6 @@ export class SceneViewport extends FreeViewport { if (!icon) { icon = new Graphics(); drawSymbolIcon(icon, item.type as SymbolIcon); - icon.scale.set(SYMBOL_SIZE / SYMBOL_GRID); const half = SYMBOL_GRID / 2 + SYMBOL_OUTLINE; icon.hitArea = new Rectangle(-half, -half, half * 2, half * 2); icon.cursor = 'move'; @@ -636,10 +636,16 @@ export class SceneViewport extends FreeViewport { this.icons.set(item.id, icon); this.iconLayer.addChild(icon); } + icon.scale.set((SYMBOL_SIZE * this.symbolScale()) / SYMBOL_GRID); icon.visible = true; icon.eventMode = 'static'; } + /** The project's factor for icon size, spacing and the outline of an icon-only symbol. */ + private symbolScale(): number { + return this.state.project.symbolScale; + } + /** * Puts every icon where `layoutSymbols` says – on its object, or on a ring round a spot several * want – and each name beside its icon, on the outer side of a ring. Switched-off objects draw no @@ -652,8 +658,8 @@ export class SceneViewport extends FreeViewport { const anchors = items .filter((item) => item.enabled && hasSceneIcon(item.type)) .map((item) => ({ id: item.id, x: item.x, y: item.y })); - this.spots = layoutSymbols(anchors, SYMBOL_SPACING); - const gap = SYMBOL_SIZE / 2 + 4 / scale; + this.spots = layoutSymbols(anchors, SYMBOL_SPACING * this.symbolScale()); + const gap = (SYMBOL_SIZE * this.symbolScale()) / 2 + 4 / scale; for (const [id, spot] of this.spots) { this.icons.get(id)?.position.set(spot.x, spot.y); const label = this.labels.get(id); @@ -756,11 +762,13 @@ export class SceneViewport extends FreeViewport { const item = items.find((i) => i.id === id); if (!item) continue; // Two screen pixels outside the footprint, whichever way it is turned. A symbol that is - // only its icon is outlined where the icon stands, which need not be its position. + // only its icon is outlined where the icon stands, which need not be its position, and + // at the icon's size. const spot = ICON_ONLY.has(item.type) ? this.spots.get(id) : undefined; - const m = spot ? translation(spot.x, spot.y) : item.m; const corners = this.screenCorners( - footprintCorners(item.type, item.props, m, 2 / view.scale, item.frame), + spot + ? iconCorners(item.type, item.props, spot, this.symbolScale(), 2 / view.scale) + : footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame), view, ); // A switched-off object draws nothing, so its outline is the only way to see where it diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 52612af2..1a6fed38 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -151,6 +151,7 @@ describe('CommandStack', () => { startSceneId: '', plugins: [], collisionLayers: ['Default'], + symbolScale: 1, }; const stack = new CommandStack(project); let failing = false; diff --git a/tests/core/symbol-scale.test.ts b/tests/core/symbol-scale.test.ts new file mode 100644 index 00000000..c1c34648 --- /dev/null +++ b/tests/core/symbol-scale.test.ts @@ -0,0 +1,51 @@ +/** + * The project's symbol scale: how large the scene view draws its icons, 0.1 to 4, default 1. + */ +import { describe, expect, it } from 'vitest'; +import { SetSymbolScaleCommand } from '../../src/core/commands/projectCommands'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { symbolScaleOf } from '../../src/core/model/symbolScale'; +import { footprintOf, iconCorners } from '../../src/core/properties/bounds'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; + +describe('symbol scale', () => { + it('is 1 in a new project and in one written before there was any', () => { + expect(createEmptyProject('p', 'P').symbolScale).toBe(1); + expect(deserializeProject({ id: 'p', name: 'P', assets: [] }).symbolScale).toBe(1); + }); + + it('survives a save and a load', () => { + const project = { ...createEmptyProject('p', 'P'), symbolScale: 0.25 }; + expect(deserializeProject(serializeProject(project)).symbolScale).toBe(0.25); + }); + + it('reads untrusted JSON into the range 0.1 to 4', () => { + expect(symbolScaleOf({ symbolScale: 0.05 })).toBe(0.1); + expect(symbolScaleOf({ symbolScale: 12 })).toBe(4); + expect(symbolScaleOf({ symbolScale: 2.5 })).toBe(2.5); + expect(symbolScaleOf({ symbolScale: '2' })).toBe(1); + expect(symbolScaleOf({ symbolScale: Number.NaN })).toBe(1); + expect(symbolScaleOf(null)).toBe(1); + }); + + it('is one undo step, clamped like a loaded project', () => { + const project = createEmptyProject('p', 'P'); + const command = new SetSymbolScaleCommand(7); + command.execute(project); + expect(project.symbolScale).toBe(4); + command.undo(project); + expect(project.symbolScale).toBe(1); + }); + + it('grows the outline of an icon-only symbol with the icon, the padding stays', () => { + const { w } = footprintOf('behavior', {}); + const at = { x: 100, y: 50 }; + const half = (corners: { x: number }[]) => (corners[1]!.x - corners[0]!.x) / 2; + expect(half(iconCorners('behavior', {}, at, 1))).toBe(w / 2); + expect(half(iconCorners('behavior', {}, at, 0.5))).toBe(w / 4); + expect(half(iconCorners('behavior', {}, at, 2, 3))).toBe(w + 3); + const corners = iconCorners('behavior', {}, at, 0.5); + expect((corners[0]!.x + corners[2]!.x) / 2).toBe(100); + expect((corners[0]!.y + corners[2]!.y) / 2).toBe(50); + }); +}); diff --git a/tests/editor/project-settings.test.ts b/tests/editor/project-settings.test.ts index 5b6ad42e..3aca42ce 100644 --- a/tests/editor/project-settings.test.ts +++ b/tests/editor/project-settings.test.ts @@ -9,6 +9,7 @@ const before: ProjectSettings = { name: 'Demo project', playFrom: 'start', collisionLayers: ['Default', 'Enemies'], + symbolScale: 1, }; describe('project settings', () => { @@ -46,6 +47,14 @@ describe('project settings', () => { expect(change.collisionLayers).toEqual(['Default', 'Foes']); }); + it('writes the symbol scale only when it changed, kept inside 0.1 to 4', () => { + expect(settingsChanges(before, { ...before, symbolScale: 1 }).symbolScale).toBeUndefined(); + expect(settingsChanges(before, { ...before, symbolScale: 0.5 }).symbolScale).toBe(0.5); + expect(settingsChanges(before, { ...before, symbolScale: 0.01 }).symbolScale).toBe(0.1); + expect(settingsChanges(before, { ...before, symbolScale: 9 }).symbolScale).toBe(4); + expect(settingsChanges(before, { ...before, symbolScale: Number.NaN }).symbolScale).toBeUndefined(); + }); + it('always reports the start-scene switch – it is a preference, not an undo step', () => { expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active'); expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBe('start'); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index ecef386f..d837d31e 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -133,5 +133,38 @@ export async function topbar(t) { (await json('__gm.state.project.name')) === projectName, await json('__gm.state.project.name'), ); + + // 46f. The symbol scale in the same dialog: 1 by default, typed down to 0.5, and every icon of the + // scene drawn at half its side (18 → 9 world units), undone in one step. + const iconSize = () => + json(`(() => { + const p = __gm.state.project; + const scene = p.assets.find((a) => a.id === p.activeSceneId); + const id = (scene?.objects ?? []).map((o) => o.id).find((id) => __gm.sceneIcon(id)?.visible); + return id ? __gm.sceneIcon(id).size : null; + })()`); + await clickToolbar('menu'); + await clickText('Project settings…'); + const opened = await json('__gm.projectSettings()'); + const field = await json('__gm.projectSettingsSymbolScaleAt()'); + await t.click(field.x, field.y); + await press('a', { ctrl: true }); + await typeText('0.5'); + await press('Enter'); + await clickText('Apply'); + await sleep(200); + const scaled = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() }; + await clickToolbar('undo'); + await sleep(200); + const restored = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() }; + check( + 'the symbol scale starts at 1, Apply halves every scene icon, and undo gives the size back', + opened?.symbolScale === 1 && + scaled.project === 0.5 && + Math.abs(scaled.icon - 9) < 1e-9 && + restored.project === 1 && + Math.abs(restored.icon - 18) < 1e-9, + JSON.stringify({ opened, scaled, restored }), + ); await t.saved(); }