diff --git a/README.md b/README.md index 6869536a..e5d11d12 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1668 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1671 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 ff3ddd83..677ffab4 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -1,5 +1,11 @@ import { collisionLayersOf } from '../model/collisionLayers'; -import { DEFAULT_WHILE_PLAYING, type PlayFrom, type WhilePlaying } from '../model/playSettings'; +import { + DEFAULT_GAME_LINK_ZOOM, + DEFAULT_WHILE_PLAYING, + type GameLinkZoom, + type PlayFrom, + type WhilePlaying, +} from '../model/playSettings'; import type { Project } from '../model/Project'; import { clampSymbolScale } from '../model/symbolScale'; import type { Command } from './Command'; @@ -91,3 +97,20 @@ export class SetWhilePlayingCommand implements Command { project.whilePlaying = this.previous; } } + +/** How the page of the released game sizes the camera frame (#279). */ +export class SetGameLinkZoomCommand implements Command { + readonly label = 'Game link zoom'; + private previous: GameLinkZoom = DEFAULT_GAME_LINK_ZOOM; + + constructor(private readonly zoom: GameLinkZoom) {} + + execute(project: Project): void { + this.previous = project.gameLinkZoom; + project.gameLinkZoom = this.zoom; + } + + undo(project: Project): void { + project.gameLinkZoom = this.previous; + } +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index aa51a02c..0fec6984 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -8,9 +8,11 @@ import { } from './Asset'; import { DEFAULT_LAYER_NAME } from './collisionLayers'; import { + DEFAULT_GAME_LINK_ZOOM, DEFAULT_GAME_SCALE, DEFAULT_PLAY_FROM, DEFAULT_WHILE_PLAYING, + type GameLinkZoom, type GameScale, type PlayFrom, type WhilePlaying, @@ -57,6 +59,8 @@ export interface Project { whilePlaying: WhilePlaying; /** The scale the game view draws the camera at. Saved, but not an undo step: it is how you look. */ gameScale: GameScale; + /** How the page of the released game sizes the camera frame (see `playSettings.ts`). */ + gameLinkZoom: GameLinkZoom; /** Which symbols, names and collider outlines the scene view shows, per type. Saved like `gameScale`. */ sceneVisibility: SceneVisibility; } @@ -75,6 +79,7 @@ export function createEmptyProject(id: string, name: string): Project { playFrom: DEFAULT_PLAY_FROM, whilePlaying: DEFAULT_WHILE_PLAYING, gameScale: DEFAULT_GAME_SCALE, + gameLinkZoom: DEFAULT_GAME_LINK_ZOOM, sceneVisibility: defaultSceneVisibility(), }; } diff --git a/src/core/model/playSettings.ts b/src/core/model/playSettings.ts index 5c3f0e94..ab530537 100644 --- a/src/core/model/playSettings.ts +++ b/src/core/model/playSettings.ts @@ -20,6 +20,24 @@ function isGameScale(value: unknown): value is GameScale { return value === 'auto' || value === 'fit' || GAME_SCALE_STEPS.includes(value as number); } +/** + * How the page of a released game sizes the camera frame (#279): the largest step that fits, or + * fitted. A setting of its own rather than `gameScale`, which is how the editor looks at the game + * and changes while working; fixed steps make no sense for a window nobody knows the size of. + */ +export type GameLinkZoom = 'auto' | 'fit'; + +export const GAME_LINK_ZOOMS: readonly GameLinkZoom[] = ['auto', 'fit']; + +/** Also what a project written before there was a choice reads as: the page always drew at Auto. */ +export const DEFAULT_GAME_LINK_ZOOM: GameLinkZoom = 'auto'; + +/** Read off untrusted JSON; anything that is not one of the choices is the default. */ +export function gameLinkZoomOf(raw: unknown): GameLinkZoom { + const value = (raw as { gameLinkZoom?: unknown } | null)?.gameLinkZoom; + return GAME_LINK_ZOOMS.includes(value as GameLinkZoom) ? (value as GameLinkZoom) : DEFAULT_GAME_LINK_ZOOM; +} + /** * How closely the editor's panels follow a running game (#265). The game itself runs every frame * in each of them; only what the hierarchy, the inspector and the graphs show of it is held back. diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index a368dee7..5a6f885c 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -45,7 +45,7 @@ import type { import { parseCellKey } from '../model/tilemap'; import { pluginsOf } from './projectPlugins'; import { collisionLayersOf } from '../model/collisionLayers'; -import { gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings'; +import { gameLinkZoomOf, gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings'; import { sceneVisibilityOf } from '../model/sceneVisibility'; import { symbolScaleOf } from '../model/symbolScale'; import { decodeCells, encodeCells } from './tilemapCells'; @@ -282,6 +282,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { playFrom: playFromOf(r), whilePlaying: whilePlayingOf(r), gameScale: gameScaleOf(r), + gameLinkZoom: gameLinkZoomOf(r), sceneVisibility: sceneVisibilityOf(r), }; // A hand-edited file may have copies that no longer match their Struct asset. diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 96dc1909..085ea37e 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -4,6 +4,7 @@ import { RenameProjectCommand, SetCollisionLayersCommand, + SetGameLinkZoomCommand, SetPlayFromCommand, SetSymbolScaleCommand, SetWhilePlayingCommand, @@ -321,7 +322,8 @@ export class ProjectSession { } /** - * Name, the scene Play starts, how closely the panels follow it, the symbol scale and the collision layers (#152) in one dialog + * Name, the scene Play starts, how closely the panels follow it, the zoom of the game link, the + * symbol scale and the collision layers (#152) in one dialog * (#183). Each is an undo step of its own – one each, and only for what actually changed. */ openProjectSettings(): ProjectSettingsDialog { @@ -330,6 +332,7 @@ export class ProjectSession { name: project.name, playFrom: this.state.playFrom, whilePlaying: project.whilePlaying, + gameLinkZoom: project.gameLinkZoom, collisionLayers: [...project.collisionLayers], symbolScale: project.symbolScale, }; @@ -344,6 +347,8 @@ export class ProjectSession { if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom)); if (change.whilePlaying !== undefined) this.state.execute(new SetWhilePlayingCommand(change.whilePlaying)); + if (change.gameLinkZoom !== undefined) + this.state.execute(new SetGameLinkZoomCommand(change.gameLinkZoom)); }), ); } diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index 82e813fe..a701e240 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -1,7 +1,13 @@ import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; 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, WHILE_PLAYING, type WhilePlaying } from '../core/model/playSettings'; +import { + GAME_LINK_ZOOMS, + type GameLinkZoom, + type PlayFrom, + WHILE_PLAYING, + type WhilePlaying, +} from '../core/model/playSettings'; import { FormDialog } from './FormDialog'; /** What the dialog hands back when Apply is pressed. */ @@ -9,6 +15,7 @@ export interface ProjectSettings { name: string; playFrom: PlayFrom; whilePlaying: WhilePlaying; + gameLinkZoom: GameLinkZoom; collisionLayers: string[]; symbolScale: number; } @@ -29,6 +36,7 @@ export function settingsChanges( symbolScale?: number; playFrom?: PlayFrom; whilePlaying?: WhilePlaying; + gameLinkZoom?: GameLinkZoom; } { const name = after.name.trim() || before.name; const symbolScale = clampSymbolScale(after.symbolScale); @@ -40,6 +48,7 @@ export function settingsChanges( ...(symbolScale === before.symbolScale ? {} : { symbolScale }), ...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }), ...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }), + ...(after.gameLinkZoom === before.gameLinkZoom ? {} : { gameLinkZoom: after.gameLinkZoom }), }; } @@ -54,9 +63,14 @@ const WHILE_PLAYING_LABEL: Record = { minimal: 'Minimal', }; +const GAME_LINK_ZOOM_LABEL: Record = { + auto: 'Auto', + fit: 'Fit', +}; + /** * Everything about the project that is not an asset (#183): its name, which scene Play starts and - * how closely the panels follow it (#265), the + * how closely the panels follow it (#265), the zoom of the game link (#279), 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. * @@ -68,11 +82,13 @@ export class ProjectSettingsDialog extends FormDialog { private projectName: string; private playFrom: PlayFrom; private whilePlaying: WhilePlaying; + private gameLinkZoom: GameLinkZoom; private layers: string[]; private symbolScale: number; private readonly nameField: TextField; private readonly playFromButton: Button; private readonly whilePlayingButton: Button; + private readonly gameLinkZoomButton: Button; private readonly symbolScaleField: NumberField; private readonly rows = new VStack({ gap: Theme.gap }); private readonly addButton: Button; @@ -82,6 +98,7 @@ export class ProjectSettingsDialog extends FormDialog { this.projectName = settings.name; this.playFrom = settings.playFrom; this.whilePlaying = settings.whilePlaying; + this.gameLinkZoom = settings.gameLinkZoom; this.layers = [...settings.collisionLayers]; this.symbolScale = clampSymbolScale(settings.symbolScale); @@ -118,6 +135,18 @@ export class ProjectSettingsDialog extends FormDialog { }), ); + this.gameLinkZoomButton = new Button(GAME_LINK_ZOOM_LABEL[this.gameLinkZoom], { + width: 150, + align: 'left', + onClick: () => this.openGameLinkZoomMenu(), + }); + this.content.add(this.formRow(this.fieldLabel('Link zoom'), this.gameLinkZoomButton)); + this.content.add( + new Label('The released game at the largest whole step that fits, or filling the window.', { + style: 'muted', + }), + ); + this.symbolScaleField = new NumberField(this.symbolScale, { width: 150, min: SYMBOL_SCALE_MIN, @@ -153,6 +182,7 @@ export class ProjectSettingsDialog extends FormDialog { name: this.projectName, playFrom: this.playFrom, whilePlaying: this.whilePlaying, + gameLinkZoom: this.gameLinkZoom, collisionLayers: this.layers, symbolScale: this.symbolScale, }), @@ -165,12 +195,19 @@ export class ProjectSettingsDialog extends FormDialog { return [...this.layers]; } - /** Name, start-scene switch, panel preset and symbol scale as they stand (test hook). */ - values(): { name: string; playFrom: PlayFrom; whilePlaying: WhilePlaying; symbolScale: number } { + /** Name, start-scene switch, panel preset, link zoom and symbol scale as they stand (test hook). */ + values(): { + name: string; + playFrom: PlayFrom; + whilePlaying: WhilePlaying; + gameLinkZoom: GameLinkZoom; + symbolScale: number; + } { return { name: this.projectName, playFrom: this.playFrom, whilePlaying: this.whilePlaying, + gameLinkZoom: this.gameLinkZoom, symbolScale: this.symbolScale, }; } @@ -185,6 +222,11 @@ export class ProjectSettingsDialog extends FormDialog { return this.whilePlayingButton; } + /** The game link zoom switch, so a check can click it. */ + get gameLinkZoomControl(): Button { + return this.gameLinkZoomButton; + } + /** The symbol scale field, so a check can type into it. */ get symbolScaleControl(): NumberField { return this.symbolScaleField; @@ -224,6 +266,19 @@ export class ProjectSettingsDialog extends FormDialog { ).openBelow(this.whilePlayingButton); } + private openGameLinkZoomMenu(): void { + new Menu( + GAME_LINK_ZOOMS.map((zoom) => ({ + label: GAME_LINK_ZOOM_LABEL[zoom], + current: this.gameLinkZoom === zoom, + onSelect: () => { + this.gameLinkZoom = zoom; + this.gameLinkZoomButton.setText(GAME_LINK_ZOOM_LABEL[zoom]); + }, + })), + ).openBelow(this.gameLinkZoomButton); + } + private addLayer(): void { if (this.layers.length >= MAX_COLLISION_LAYERS) return; this.layers.push(layerFallback(this.layers.length)); diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 317d48f9..d3116cfe 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -303,6 +303,11 @@ export function uiHooks({ app, state, editor }: HookContext) { const dialog = editor.projectSettingsDialog(); return dialog ? centreOf(dialog.whilePlayingControl) : null; }, + /** Where to click the Game link zoom switch inside that dialog (#279). */ + projectSettingsGameLinkZoomAt: () => { + const dialog = editor.projectSettingsDialog(); + return dialog ? centreOf(dialog.gameLinkZoomControl) : null; + }, /** Where to click the symbol scale field inside that dialog. */ projectSettingsSymbolScaleAt: () => { const dialog = editor.projectSettingsDialog(); diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts index 5dcd360c..66bd0ed6 100644 --- a/src/player/GameScreen.ts +++ b/src/player/GameScreen.ts @@ -9,8 +9,9 @@ import { cameraView, type View } from '../viewport/view'; import { type Frame, type ViewSource, WorldViewport } from '../viewport/WorldViewport'; /** - * A released game on screen (#266): the running world through its active camera, at the largest - * zoom step that fits (Auto), letterboxed in the page colour. Before the game runs, and after it + * A released game on screen (#266): the running world through its active camera, at the zoom the + * project names for its link (#279: the largest step that fits, or fitted), letterboxed in the + * page colour. Before the game runs, and after it * stopped itself, it shows a line of text instead – "Click to play", or why there is nothing to play. */ @@ -89,7 +90,8 @@ export class GameScreen extends WorldViewport { protected override currentView(): View | null { const camera = this.running ? cameraOfWorld(this.running) : null; - return camera ? cameraView(camera, this.w, this.h, 'auto', ui().app.renderer.resolution) : null; + const zoom = this.state.project.gameLinkZoom; + return camera ? cameraView(camera, this.w, this.h, zoom, ui().app.renderer.resolution) : null; } protected override collectFrame(): Frame { diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 53a414ee..3a38542a 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -156,6 +156,7 @@ describe('CommandStack', () => { playFrom: 'start' as const, whilePlaying: 'live' as const, gameScale: 1, + gameLinkZoom: 'auto' as const, sceneVisibility: defaultSceneVisibility(), }; const stack = new CommandStack(project); diff --git a/tests/editor/play-settings.test.ts b/tests/editor/play-settings.test.ts index 375f16fc..06d164c4 100644 --- a/tests/editor/play-settings.test.ts +++ b/tests/editor/play-settings.test.ts @@ -4,11 +4,12 @@ * saved but never lands on the undo stack. */ import { describe, expect, it } from 'vitest'; -import { SetPlayFromCommand } from '../../src/core/commands/projectCommands'; -import { gameScaleOf, playFromOf } from '../../src/core/model/playSettings'; +import { SetGameLinkZoomCommand, SetPlayFromCommand } from '../../src/core/commands/projectCommands'; +import { gameLinkZoomOf, gameScaleOf, playFromOf } from '../../src/core/model/playSettings'; import { createEmptyProject } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { EditorState } from '../../src/editor/state'; +import { cameraView } from '../../src/viewport/view'; function loaded() { const state = new EditorState(); @@ -74,3 +75,64 @@ describe('play settings in the project', () => { expect(state.saveStatus).toBe('dirty'); }); }); + +describe('the zoom of the game link (#279)', () => { + it('is Auto in a new project and in one written before there was a choice', () => { + expect(createEmptyProject('p', 'P').gameLinkZoom).toBe('auto'); + expect(deserializeProject({ id: 'p', name: 'P', assets: [] }).gameLinkZoom).toBe('auto'); + }); + + it('survives a save and a load, apart from the scale of the game view', () => { + const project = { ...createEmptyProject('p', 'P'), gameLinkZoom: 'fit' as const, gameScale: 2 }; + const back = deserializeProject(serializeProject(project)); + expect([back.gameLinkZoom, back.gameScale]).toEqual(['fit', 2]); + }); + + it('reads untrusted JSON as Auto unless it is one of the two choices', () => { + expect(gameLinkZoomOf({ gameLinkZoom: 'fit' })).toBe('fit'); + expect(gameLinkZoomOf({ gameLinkZoom: 'auto' })).toBe('auto'); + expect(gameLinkZoomOf({ gameLinkZoom: 2 })).toBe('auto'); + expect(gameLinkZoomOf({ gameLinkZoom: 'Fit' })).toBe('auto'); + expect(gameLinkZoomOf({ gameScale: 'fit' })).toBe('auto'); + expect(gameLinkZoomOf(null)).toBe('auto'); + }); + + it('is one undo step', () => { + const state = loaded(); + state.execute(new SetGameLinkZoomCommand('fit')); + expect(state.project.gameLinkZoom).toBe('fit'); + expect(state.saveStatus).toBe('dirty'); + state.undo(); + expect(state.project.gameLinkZoom).toBe('auto'); + state.redo(); + expect(state.project.gameLinkZoom).toBe('fit'); + }); + + it('leaves the scale of the game view alone', () => { + const state = loaded(); + state.setGameScale(3); + state.execute(new SetGameLinkZoomCommand('fit')); + expect(state.gameScale).toBe(3); + }); + + it('fills the window at Fit where Auto stops at the step below', () => { + const camera = { x: 0, y: 0, props: { width: 300, height: 180, zoom: 1 } }; + const auto = cameraView(camera, 1000, 700, 'auto'); + const fit = cameraView(camera, 1000, 700, 'fit'); + expect(auto.scale).toBe(3); + expect(auto.rect).toEqual({ x: 50, y: 80, w: 900, h: 540 }); + expect(fit.scale).toBeCloseTo(10 / 3, 9); + expect(fit.rect.x).toBeCloseTo(0, 9); + expect(fit.rect.w).toBeCloseTo(1000, 9); + expect(fit.rect.h).toBeCloseTo(600, 9); + expect(fit.rect.y).toBeCloseTo(50, 9); + }); + + it('shrinks the picture at Fit in a window smaller than the camera', () => { + const camera = { x: 0, y: 0, props: { width: 300, height: 180, zoom: 1 } }; + const fit = cameraView(camera, 200, 400, 'fit'); + expect(fit.scale).toBeCloseTo(2 / 3, 9); + expect(fit.rect.w).toBeCloseTo(200, 9); + expect(fit.rect.h).toBeCloseTo(120, 9); + }); +}); diff --git a/tests/editor/project-settings.test.ts b/tests/editor/project-settings.test.ts index 930f0519..afbbad5f 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', whilePlaying: 'balanced', + gameLinkZoom: 'auto', collisionLayers: ['Default', 'Enemies'], symbolScale: 1, }; @@ -63,4 +64,9 @@ describe('project settings', () => { expect(settingsChanges(before, { ...before, whilePlaying: 'live' }).whilePlaying).toBe('live'); expect(settingsChanges(before, { ...before, whilePlaying: 'balanced' }).whilePlaying).toBeUndefined(); }); + + it('writes the zoom of the game link only when it changed (#279)', () => { + expect(settingsChanges(before, { ...before, gameLinkZoom: 'fit' }).gameLinkZoom).toBe('fit'); + expect(settingsChanges(before, { ...before, gameLinkZoom: 'auto' }).gameLinkZoom).toBeUndefined(); + }); }); diff --git a/tools/e2e/60-release.mjs b/tools/e2e/60-release.mjs index 2ce6d9cb..7d856660 100644 --- a/tools/e2e/60-release.mjs +++ b/tools/e2e/60-release.mjs @@ -77,8 +77,8 @@ function game(id) { /** * Releasing a project (#266): published from the main menu, played from the link without a - * session – only the game, zoomed to Auto, on the page colour, with a fullscreen button in the - * corner (#267) – updated under the same link, and taken down. + * session – only the game, zoomed to Auto or, once the project says so, to Fit (#279), on the page + * colour, with a fullscreen button in the corner (#267) – updated under the same link, and taken down. * * Works on a throwaway project of its own, like step 50. The last thing it does is delete that * project and open the fixture again. @@ -303,6 +303,80 @@ export async function release(t) { ); check('the play page threw nothing', exceptions().length === 0, exceptions().join(' | ')); + // 60i (#279). Project settings switch the link from Auto to Fit, with the mouse and as one undo + // step; once the release is updated, the same link fills the window at a scale between the steps. + await t.signIn(); + await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` }); + await sleep(4000); + await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, { + timeout: 15000, + label: 'the throwaway project is open for its settings', + }); + const undoBefore = await json('__gm.state.commands.undoStack.length'); + await clickToolbar('menu'); + await clickText('Project settings…'); + await settle(); + const shown = (await json('__gm.projectSettings()')).gameLinkZoom; + const zoomAt = await json('__gm.projectSettingsGameLinkZoomAt()'); + await click(zoomAt.x, zoomAt.y); + await clickLast('Fit'); + await settle(); + const chosen = (await json('__gm.projectSettings()')).gameLinkZoom; + await shot('60-release-link-zoom'); + await clickLast('Apply'); + await settle(); + const stored279 = await json('__gm.state.project.gameLinkZoom'); + const undoAfter = await json('__gm.state.commands.undoStack.length'); + check( + 'the Project settings show the link zoom as Auto and take Fit from the menu, as one undo step', + shown === 'auto' && chosen === 'fit' && stored279 === 'fit' && undoAfter === undoBefore + 1, + JSON.stringify({ shown, chosen, stored: stored279, undoBefore, undoAfter }), + ); + await saved(); + const changed = await openRelease(); + await clickLast('Update release'); + await waitUntil('__gm.release()?.state.endsWith("as saved")', { label: 'the release carries the zoom' }); + await clickLast('Cancel'); + const releasedZoom = (await (await send('GET', `releases/${token}`, undefined, false)).json()).project + .gameLinkZoom; + await openLink(after.link); + await waitUntil('__gameMakerPlay.screen().caption === "Click to play"', { + timeout: 15000, + label: 'the fitted game offers to start', + }); + await click(size.w / 2, size.h / 2); + await waitUntil('__gameMakerPlay.running()', { label: 'the fitted game runs' }); + await sleep(300); + const fitted = await player('screen()'); + const fit = Math.min(size.w / CAMERA.width, size.h / CAMERA.height); + const near = (a, b) => Math.abs(a - b) < 0.01; + const fittedCentre = { + x: fitted.frame.x + fitted.frame.w / 2, + y: fitted.frame.y + fitted.frame.h / 2, + }; + const fittedPixels = { + centre: await player(`pixelAt(${fittedCentre.x}, ${fittedCentre.y})`), + beside: await player(`pixelAt(${fittedCentre.x - 40 * fitted.scale}, ${fittedCentre.y})`), + }; + check( + 'with Fit the link fills the window, at a scale between the steps', + changed.state.endsWith('the project has changed since') && + releasedZoom === 'fit' && + !STEPS.includes(fit) && + fit > auto && + near(fitted.scale, fit) && + near(fitted.frame.w, CAMERA.width * fit) && + near(fitted.frame.h, CAMERA.height * fit) && + (near(fitted.frame.w, size.w) || near(fitted.frame.h, size.h)) && + fittedPixels.centre === RED && + fittedPixels.beside === BACKGROUND, + JSON.stringify({ size, auto, fit, fitted, fittedPixels, releasedZoom, changed: changed.state }), + ); + await shot('60-release-fit'); + await press('s'); + await sleep(400); + check('the fitted play page threw nothing', exceptions().length === 0, exceptions().join(' | ')); + // 60f. Take down asks, then the link is dead – for the API and for the page. await t.signIn(); await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` });