diff --git a/.plans/editor-workflows-inventory.md b/.plans/editor-workflows-inventory.md index 1fc0206..bc09a72 100644 --- a/.plans/editor-workflows-inventory.md +++ b/.plans/editor-workflows-inventory.md @@ -136,7 +136,8 @@ It follows the original rule: identify the smallest reusable workflows first, th - Scene overflow `⋯` → `Rename…`, `⧉ Duplicate Scene`, `★ Set as Base / Clear Base`, `Clear Scene…`, or `Delete…`. #### A31a — Manage Project Root -- Project Tree header → `Manage` → `Create New`, `Open...`, `Toggle Sync Mode`, `Import YAML`, `Export as YAML`, `Rename`, `History`, or `Clear Project ...`. +- Project Tree header → `Manage` → `Create New`, `Open...`, `Toggle Sync Mode`, `Import YAML`, `Export as YAML`, `Project Settings...`, `Rename`, `History`, or `Clear Project ...`. +- `Project Settings...` opens a lightweight dialog for project-wide `Pixels Per Unit`. - `Rename` opens inline rename on the project root row. - `History` swaps the left pane into `Project Revisions`. @@ -165,6 +166,7 @@ It follows the original rule: identify the smallest reusable workflows first, th - Drag an image/spritesheet asset onto the canvas. - Double-click an image/spritesheet in Assets Dock. - Scene graph → `Sprites` → `+ Add ▾` → `Sprite (from Asset)` → choose asset. +- New sprites derive authored `width/height` from natural asset pixels and the current project `Pixels Per Unit`. #### A33 — Create Text Entity - Scene graph → `Text` → `+ Add`. @@ -187,6 +189,7 @@ It follows the original rule: identify the smallest reusable workflows first, th - Drag image/spritesheet asset onto an existing canvas sprite to replace its asset. - Drag image asset onto Background Layers to create/replace a layer. - Drag audio asset onto Scene Music to assign music. +- Creating a sprite from empty canvas space uses project pixel scale for the initial world size. #### A38 — Manage Asset Row Actions - Asset overflow `⋯` → `Rename…`, `Delete…`. @@ -196,6 +199,7 @@ It follows the original rule: identify the smallest reusable workflows first, th #### A39 — Edit Single Entity Properties - In Inspector, edit transform, sprite size, text settings, hitbox, physics, visual settings, asset selection, frame settings, alpha/visibility/depth, and flip. +- For asset-backed sprites, `Sprite Size` also shows `Natural Size`, `Project Scale`, `World Size`, and `Use Project Scale`. - Text entities can also be rasterized to a sprite. - If the entity is in a formation, `Apply Asset to Formation` pushes the chosen asset to sibling members. diff --git a/src/editor/EditorStore.tsx b/src/editor/EditorStore.tsx index a817628..0800e64 100644 --- a/src/editor/EditorStore.tsx +++ b/src/editor/EditorStore.tsx @@ -18,6 +18,7 @@ import { type SpriteAssetSpec, } from '../model/types'; import { createEmptyProject, createEmptyGameScene } from '../model/emptyProject'; +import { deriveWorldUnitsFromNaturalPixels, normalizeProjectPixelsPerUnit } from '../model/projectPixelScale'; import { validateProjectSpec, validateSceneSpec } from '../model/validation'; import { resolveEntityDefaults } from '../model/entityDefaults'; import { applyGroupArrangeLayout, applyGroupGridLayout, applyGroupGridLayoutPreserveMembers, inferGroupGridLayout, type GroupGridLayout } from './formationLayout'; @@ -212,7 +213,7 @@ export type EditorAction = | { type: 'initialize'; project: ProjectSpec; currentSceneId: Id; startupMode: StartupMode; themeMode: ThemeMode; uiScale: number; showHitboxOverlay: boolean; syncMode: ProjectSyncMode; registry: EditorRegistryConfig } | { type: 'set-sync-mode'; syncMode: ProjectSyncMode } | { type: 'reset-project' } - | { type: 'set-project-metadata'; title?: string; publishTitle?: string; publishGithubPagesRepo?: string } + | { type: 'set-project-metadata'; title?: string; publishTitle?: string; publishGithubPagesRepo?: string; pixelsPerUnit?: number } | { type: 'set-theme-mode'; themeMode: ThemeMode } | { type: 'set-ui-scale'; uiScale: number } | { type: 'set-show-hitbox-overlay'; value: boolean } @@ -1092,6 +1093,9 @@ function describeEditorAction(stateBefore: EditorState, stateAfter: EditorState, return 'Imported Demo Pack'; case 'set-project-metadata': if (stateBefore.project.title !== stateAfter.project.title) return `Renamed to ${stateAfter.project.title?.trim() || 'Untitled Project'}`; + if (stateBefore.project.pixelsPerUnit !== stateAfter.project.pixelsPerUnit) { + return `Set project scale to ${stateAfter.project.pixelsPerUnit ?? 1} px/unit`; + } if (stateBefore.project.publishTitle !== stateAfter.project.publishTitle) { return stateAfter.project.publishTitle ? `Set publish title to ${stateAfter.project.publishTitle}` : 'Cleared publish title'; } @@ -1271,6 +1275,14 @@ function buildProjectHistoryEventDraftsForAction( summary: publishRepo ? `Set publish repo to ${publishRepo}` : 'Cleared publish repo', }); } + if (stateBefore.project.pixelsPerUnit !== stateAfter.project.pixelsPerUnit) { + drafts.push({ + kind: 'project.settings.updated', + burstId: `project.settings.updated:${actionBurstToken}`, + scope: { kind: 'project' }, + summary: `Set project scale to ${stateAfter.project.pixelsPerUnit ?? 1} px/unit`, + }); + } return drafts.length > 0 ? drafts : undefined; } case 'add-image-asset-from-file': @@ -2253,6 +2265,9 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { const nextProject: ProjectSpec = { ...state.project, ...(typeof action.title === 'string' ? { title: action.title } : {}), + ...(typeof action.pixelsPerUnit === 'number' + ? { pixelsPerUnit: normalizeProjectPixelsPerUnit(action.pixelsPerUnit) } + : {}), ...(typeof action.publishTitle === 'string' ? { publishTitle: action.publishTitle } : shouldMirrorPublishTitle @@ -3200,18 +3215,21 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { const world = getSceneWorld(scene); const at = action.at ?? { x: world.width / 2, y: world.height / 2 }; const defaultSize = 64; + const pixelsPerUnit = normalizeProjectPixelsPerUnit(state.project.pixelsPerUnit); const image = action.assetKind === 'image' ? state.project.assets.images?.[action.assetId] : undefined; const spritesheet = action.assetKind === 'spritesheet' ? state.project.assets.spriteSheets?.[action.assetId] : undefined; - const width = action.assetKind === 'image' + const naturalWidth = action.assetKind === 'image' ? (image?.width ?? defaultSize) : (spritesheet?.grid?.frameWidth ?? defaultSize); - const height = action.assetKind === 'image' + const naturalHeight = action.assetKind === 'image' ? (image?.height ?? defaultSize) : (spritesheet?.grid?.frameHeight ?? defaultSize); + const width = deriveWorldUnitsFromNaturalPixels(naturalWidth, pixelsPerUnit); + const height = deriveWorldUnitsFromNaturalPixels(naturalHeight, pixelsPerUnit); const entity: EntitySpec = resolveEntityDefaults({ id: entityId, diff --git a/src/editor/EntityList.tsx b/src/editor/EntityList.tsx index b8e253f..e2658d8 100644 --- a/src/editor/EntityList.tsx +++ b/src/editor/EntityList.tsx @@ -11,6 +11,7 @@ import { buildProjectPickerModel, type ProjectPickerFilter } from './projectLibr import { exportYamlToDisk } from './yamlFileExport'; import { getOpenFilePicker, readFileHandleText } from './yamlFileHandles'; import { parseProjectYaml, serializeProjectToYaml } from '../model/serialization'; +import { deriveWorldUnitsFromNaturalPixels, getProjectPixelsPerUnit, normalizeProjectPixelsPerUnit } from '../model/projectPixelScale'; import { EventBus } from '../phaser/EventBus'; import { buildProjectHistoryViewModel, @@ -278,6 +279,8 @@ export function EntityListView({ } | null>(null); const duplicateDialogRootRef = useRef(null); const [copyRevisionName, setCopyRevisionName] = useState(''); + const [projectSettingsDialogOpen, setProjectSettingsDialogOpen] = useState(false); + const [projectPixelsPerUnitDraft, setProjectPixelsPerUnitDraft] = useState(() => String(getProjectPixelsPerUnit(project))); const [expandedRevisionId, setExpandedRevisionId] = useState(null); const [historyPaneMode, setHistoryPaneMode] = useState<'active' | 'archived'>('active'); const [historySelectionMode, setHistorySelectionMode] = useState<'none' | 'archive' | 'delete'>('none'); @@ -312,6 +315,11 @@ export function EntityListView({ setCopyRevisionName(buildCopyRevisionDefaultName(project.title, revision)); }, [archivedRevisions, project.title, revisionDialogs.copyRevisionId, revisions]); + useEffect(() => { + if (!projectSettingsDialogOpen) return; + setProjectPixelsPerUnitDraft(String(getProjectPixelsPerUnit(project))); + }, [project, projectSettingsDialogOpen]); + useEffect(() => { const previousSidebarScope = previousSidebarScopeRef.current; if (normalizedSidebarScope === 'projectRevisions' && previousSidebarScope !== 'projectRevisions') { @@ -347,6 +355,12 @@ export function EntityListView({ setSelectedHistoryRevisionIds([]); }; + const saveProjectSettings = () => { + const pixelsPerUnit = normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft)); + dispatch({ type: 'set-project-metadata', pixelsPerUnit }); + setProjectSettingsDialogOpen(false); + }; + useEffect(() => { if (!menuOpen) return; @@ -1843,6 +1857,79 @@ export function EntityListView({ ) : null} + {projectSettingsDialogOpen ? ( +
+
Project Settings
+
+ +
+ {[1, 2, 4].map((value) => { + const normalizedValue = normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft)); + return ( + + ); + })} +
+
+ 64px art becomes {deriveWorldUnitsFromNaturalPixels(64, Number(projectPixelsPerUnitDraft) || 1)} world units at {normalizeProjectPixelsPerUnit(Number(projectPixelsPerUnitDraft) || 1)} px/unit. +
+
+
+ + +
+
+ ) : null} + {duplicateDialog ? (
Export as YAML +
-
Original (natural): {baseWidth}×{baseHeight} px
+ {naturalSpriteSize && projectScaledSpriteSize ? ( + <> +
Natural Size: {naturalSpriteSize.width}×{naturalSpriteSize.height} px
+
Project Scale: {projectPixelsPerUnit} px/unit
+
World Size: {projectScaledSpriteSize.width}×{projectScaledSpriteSize.height} units
+
+ +
+ + ) : ( +
Original (natural): {baseWidth}×{baseHeight} px
+ )} ) : ( <> @@ -649,7 +676,30 @@ function EntityInspector({
-
Original (natural): {baseWidth}×{baseHeight} px
+ {naturalSpriteSize && projectScaledSpriteSize ? ( + <> +
Natural Size: {naturalSpriteSize.width}×{naturalSpriteSize.height} px
+
Project Scale: {projectPixelsPerUnit} px/unit
+
World Size: {projectScaledSpriteSize.width}×{projectScaledSpriteSize.height} units
+
+ +
+ + ) : ( +
Original (natural): {baseWidth}×{baseHeight} px
+ )} )} diff --git a/src/editor/projectHistoryEvents.ts b/src/editor/projectHistoryEvents.ts index c984cff..4efa05b 100644 --- a/src/editor/projectHistoryEvents.ts +++ b/src/editor/projectHistoryEvents.ts @@ -4,6 +4,7 @@ export type ProjectHistoryEventReason = 'autosave' | 'protective' | 'restore' | export type ProjectHistoryEventKind = | 'project.renamed' + | 'project.settings.updated' | 'publish.title.set' | 'publish.repo.set' | 'project.default-input-map.set' diff --git a/src/model/emptyProject.ts b/src/model/emptyProject.ts index 86fec16..b5e923a 100644 --- a/src/model/emptyProject.ts +++ b/src/model/emptyProject.ts @@ -16,6 +16,7 @@ export function createEmptyProject(): ProjectSpec { const scene = createEmptyGameScene('scene-1'); return { id: 'project-1', + pixelsPerUnit: 1, assets: { images: {}, spriteSheets: {}, fonts: {} }, audio: { sounds: {} }, inputMaps: {}, diff --git a/src/model/projectPixelScale.ts b/src/model/projectPixelScale.ts new file mode 100644 index 0000000..db34eea --- /dev/null +++ b/src/model/projectPixelScale.ts @@ -0,0 +1,48 @@ +import type { ProjectSpec, SpriteAssetSpec } from './types'; + +export const DEFAULT_PROJECT_PIXELS_PER_UNIT = 1; + +export function normalizeProjectPixelsPerUnit(value: unknown, fallback = DEFAULT_PROJECT_PIXELS_PER_UNIT): number { + if (!Number.isFinite(value)) return fallback; + return Math.max(1, Math.round(Number(value))); +} + +export function getProjectPixelsPerUnit(project: Pick): number { + return normalizeProjectPixelsPerUnit(project.pixelsPerUnit, DEFAULT_PROJECT_PIXELS_PER_UNIT); +} + +export function deriveWorldUnitsFromNaturalPixels(naturalPixels: number, pixelsPerUnit: number): number { + const safePixels = Math.max(1, Math.round(Number.isFinite(naturalPixels) ? naturalPixels : 1)); + return Math.max(1, Math.round(safePixels / normalizeProjectPixelsPerUnit(pixelsPerUnit))); +} + +export function getNaturalSpriteSize( + project: Pick, + asset: SpriteAssetSpec | undefined, +): { width: number; height: number } | null { + if (!asset) return null; + if (asset.source.kind !== 'asset') return null; + if (asset.imageType === 'spritesheet') { + const sheet = project.assets.spriteSheets?.[asset.source.assetId]; + const width = sheet?.grid?.frameWidth ?? asset.grid?.frameWidth; + const height = sheet?.grid?.frameHeight ?? asset.grid?.frameHeight; + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + return { width: Math.max(1, Math.round(width)), height: Math.max(1, Math.round(height)) }; + } + const image = project.assets.images?.[asset.source.assetId]; + if (!Number.isFinite(image?.width) || !Number.isFinite(image?.height)) return null; + return { width: Math.max(1, Math.round(image.width)), height: Math.max(1, Math.round(image.height)) }; +} + +export function deriveWorldSpriteSize( + project: Pick, + asset: SpriteAssetSpec | undefined, +): { width: number; height: number } | null { + const natural = getNaturalSpriteSize(project, asset); + if (!natural) return null; + const pixelsPerUnit = getProjectPixelsPerUnit(project); + return { + width: deriveWorldUnitsFromNaturalPixels(natural.width, pixelsPerUnit), + height: deriveWorldUnitsFromNaturalPixels(natural.height, pixelsPerUnit), + }; +} diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index f307ad2..e610d73 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -3,6 +3,7 @@ import { sampleScene } from './sampleScene'; export const sampleProject: ProjectSpec = { id: 'project-1', + pixelsPerUnit: 1, assets: { images: {}, spriteSheets: {}, fonts: {} }, audio: { sounds: {} }, inputMaps: {}, diff --git a/src/model/serialization.ts b/src/model/serialization.ts index 5aeb88a..acc906f 100644 --- a/src/model/serialization.ts +++ b/src/model/serialization.ts @@ -1,4 +1,5 @@ import { parse, stringify } from 'yaml'; +import { normalizeProjectPixelsPerUnit } from './projectPixelScale'; import { CollisionRuleSpec, GameSceneSpec, ProjectSpec, TriggerZoneSpec } from './types'; import { migrateSceneSpec } from './migrateScene'; @@ -262,6 +263,7 @@ export function parseProjectYaml(text: string): ProjectSpec { return { id: typeof raw.id === 'string' ? raw.id : 'project-1', ...(typeof raw.title === 'string' ? { title: raw.title } : {}), + ...(raw.pixelsPerUnit !== undefined ? { pixelsPerUnit: normalizeProjectPixelsPerUnit(raw.pixelsPerUnit) } : {}), ...(typeof raw.publishTitle === 'string' ? { publishTitle: raw.publishTitle } : {}), ...(() => { if (typeof raw.publishGithubPagesRepo === 'string') return { publishGithubPagesRepo: raw.publishGithubPagesRepo }; diff --git a/src/model/types.ts b/src/model/types.ts index 1085910..64d8d13 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -177,6 +177,11 @@ export interface ProjectSpec { * Optional GitHub Pages publish repository name (editor metadata; serialized to YAML). */ publishGithubPagesRepo?: string; + /** + * Optional project-wide pixel scale used to derive authored world size from natural sprite pixels. + * `1` preserves legacy behavior. + */ + pixelsPerUnit?: number; assets: { images: Record; spriteSheets: Record; diff --git a/src/model/validation.ts b/src/model/validation.ts index f7161a9..c108dfb 100644 --- a/src/model/validation.ts +++ b/src/model/validation.ts @@ -15,6 +15,7 @@ import { type AttachmentTriggerSpec, type InlineConditionSpec, } from './types'; +import { normalizeProjectPixelsPerUnit } from './projectPixelScale'; import { resolveEntityDefaults } from './entityDefaults'; export function validateProjectSpec(project: ProjectSpec): void { @@ -24,6 +25,9 @@ export function validateProjectSpec(project: ProjectSpec): void { if (!project.audio || typeof project.audio !== 'object') throw new Error('Project must have audio'); if (!project.inputMaps || typeof project.inputMaps !== 'object') throw new Error('Project must have inputMaps'); if (!project.scenes || typeof project.scenes !== 'object') throw new Error('Project must have scenes'); + if (project.pixelsPerUnit !== undefined && normalizeProjectPixelsPerUnit(project.pixelsPerUnit) !== project.pixelsPerUnit) { + throw new Error('Project pixelsPerUnit must be a positive integer'); + } if (typeof project.initialSceneId !== 'string' || project.initialSceneId.length === 0) { throw new Error('Project must have an initialSceneId'); } diff --git a/tests/e2e/project-tree-history.spec.ts b/tests/e2e/project-tree-history.spec.ts index 8ebbf32..4d5e1f8 100644 --- a/tests/e2e/project-tree-history.spec.ts +++ b/tests/e2e/project-tree-history.spec.ts @@ -21,10 +21,17 @@ test.describe('Project tree + history', () => { await expect(page.getByTestId('project-manage-toggle-sync')).toBeVisible(); await expect(page.getByTestId('project-manage-import-yaml')).toBeVisible(); await expect(page.getByTestId('project-manage-export-yaml')).toBeVisible(); + await expect(page.getByTestId('project-manage-settings')).toBeVisible(); await expect(page.getByTestId('project-manage-rename')).toBeVisible(); await expect(page.getByTestId('project-manage-history')).toBeVisible(); await expect(page.getByTestId('project-manage-clear')).toBeVisible(); + await page.getByTestId('project-manage-settings').click(); + await expect(page.getByTestId('project-settings-dialog')).toBeVisible(); + await page.getByTestId('project-settings-preset-2').click(); + await page.getByTestId('project-settings-save').click(); + + await page.getByTestId('project-tree-manage-button').click(); await page.getByTestId('project-manage-rename').click(); await expect(page.getByTestId('rename-project-input')).toBeVisible(); await page.getByTestId('rename-project-input').fill('History Demo'); diff --git a/tests/editor/assets-store.test.ts b/tests/editor/assets-store.test.ts index a918938..9bef3ac 100644 --- a/tests/editor/assets-store.test.ts +++ b/tests/editor/assets-store.test.ts @@ -35,6 +35,34 @@ describe('EditorStore assets actions', () => { expect(entity.asset?.imageType).toBe('image'); }); + it('derives created sprite world size from the project pixels-per-unit setting', () => { + const state = initState(); + const withScale = reducer(state, { + type: 'set-project-metadata', + pixelsPerUnit: 2, + } as any); + const withAsset = reducer(withScale, { + type: 'add-image-asset-from-file', + file: { + dataUrl: 'data:image/png;base64,AAAA', + originalName: 'player.png', + mimeType: 'image/png', + width: 256, + height: 128, + }, + } as any); + + const withEntity = reducer(withAsset, { type: 'create-entity-from-asset', assetKind: 'image', assetId: 'player', at: { x: 10, y: 20 } } as any); + const scene = sceneOf(withEntity); + const ids = Object.keys(scene.entities); + expect(ids.length).toBe(1); + const entity = scene.entities[ids[0]]; + expect(entity.width).toBe(128); + expect(entity.height).toBe(64); + expect(entity.scaleX ?? 1).toBe(1); + expect(entity.scaleY ?? 1).toBe(1); + }); + it('blocks deletion of referenced assets', () => { const state = initState(); const withAsset = reducer(state, { diff --git a/tests/editor/editor-store.test.ts b/tests/editor/editor-store.test.ts index 165860e..0798bc2 100644 --- a/tests/editor/editor-store.test.ts +++ b/tests/editor/editor-store.test.ts @@ -77,6 +77,24 @@ describe('EditorStore reducer', () => { expect(next.dirty).toBe(true); }); + it('stores project pixels per unit as a positive integer', () => { + const state = seededState(); + + const next = reducer(state, { + type: 'set-project-metadata', + pixelsPerUnit: 2.7, + } as any); + + expect(next.project.pixelsPerUnit).toBe(3); + + const clamped = reducer(next, { + type: 'set-project-metadata', + pixelsPerUnit: 0, + } as any); + + expect(clamped.project.pixelsPerUnit).toBe(1); + }); + it('mirrors a project rename into the publish title only when the publish title is empty', () => { const state = { ...seededState(), diff --git a/tests/editor/entity-inspector.test.tsx b/tests/editor/entity-inspector.test.tsx index 5820ac2..47d9a33 100644 --- a/tests/editor/entity-inspector.test.tsx +++ b/tests/editor/entity-inspector.test.tsx @@ -70,4 +70,62 @@ describe('Entity inspector', () => { expect(visualIndex).toBeGreaterThan(hitboxIndex); expect(eventsIndex).toBeGreaterThan(visualIndex); }); + + it('explains natural size, project scale, and world size for asset-backed sprites', () => { + const projectWithAsset = { + ...project, + pixelsPerUnit: 2, + assets: { + images: { + hero: { + id: 'hero', + width: 64, + height: 64, + source: { + kind: 'embedded', + dataUrl: 'data:image/png;base64,AAAA', + originalName: 'hero.png', + mimeType: 'image/png', + }, + }, + }, + spriteSheets: {}, + fonts: {}, + }, + } as any; + const sceneWithAsset = { + ...sampleScene, + entities: { + ...sampleScene.entities, + hero: { + id: 'hero', + x: 32, + y: 48, + width: 32, + height: 32, + asset: { + source: { kind: 'asset', assetId: 'hero' }, + imageType: 'image', + frame: { kind: 'single' }, + }, + }, + }, + } as any; + + const markup = renderToStaticMarkup( + renderEntityInspector(sceneWithAsset.entities.hero, () => {}, { + ...actionProps, + project: projectWithAsset, + scene: sceneWithAsset, + }) + ); + + expect(markup).toContain('Natural Size'); + expect(markup).toContain('64×64 px'); + expect(markup).toContain('Project Scale'); + expect(markup).toContain('2 px/unit'); + expect(markup).toContain('World Size'); + expect(markup).toContain('32×32 units'); + expect(markup).toContain('Use Project Scale'); + }); }); diff --git a/tests/editor/entity-list-project-settings.test.tsx b/tests/editor/entity-list-project-settings.test.tsx new file mode 100644 index 0000000..69780ac --- /dev/null +++ b/tests/editor/entity-list-project-settings.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { createRoot } from 'react-dom/client'; +import { EntityListView } from '../../src/editor/EntityList'; +import { sampleProject } from '../../src/model/sampleProject'; + +function renderEntityList() { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + + const dispatch = vi.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + const props: React.ComponentProps = { + project: sampleProject, + currentSceneId: sampleProject.initialSceneId, + scene: sampleProject.scenes[sampleProject.initialSceneId], + selection: { kind: 'none' }, + sidebarScope: 'projectTree', + expandedGroups: { 'g-enemies': false }, + mode: 'edit', + dispatch, + }; + + return { container, root, dispatch, props }; +} + +describe('EntityList project settings', () => { + it('opens project settings from Manage and dispatches the saved pixels-per-unit value', async () => { + const { container, root, dispatch, props } = renderEntityList(); + + await React.act(async () => { + root.render(); + }); + + const manageButton = container.querySelector('[data-testid="project-tree-manage-button"]') as HTMLButtonElement | null; + expect(manageButton).not.toBeNull(); + + await React.act(async () => { + manageButton!.click(); + }); + + const settingsButton = container.querySelector('[data-testid="project-manage-settings"]') as HTMLButtonElement | null; + expect(settingsButton).not.toBeNull(); + + await React.act(async () => { + settingsButton!.click(); + }); + + await React.act(async () => { + const preset = container.querySelector('[data-testid="project-settings-preset-2"]') as HTMLButtonElement | null; + expect(preset).not.toBeNull(); + preset!.click(); + }); + + const saveButton = container.querySelector('[data-testid="project-settings-save"]') as HTMLButtonElement | null; + expect(saveButton).not.toBeNull(); + + await React.act(async () => { + saveButton!.click(); + }); + + expect(dispatch).toHaveBeenCalledWith({ type: 'set-project-metadata', pixelsPerUnit: 2 }); + }); +}); diff --git a/tests/model/project-serialization.test.ts b/tests/model/project-serialization.test.ts index f62bc86..91898db 100644 --- a/tests/model/project-serialization.test.ts +++ b/tests/model/project-serialization.test.ts @@ -119,6 +119,7 @@ describe('project YAML serialization', () => { const project = { id: 'project-1', title: 'My Game', + pixelsPerUnit: 2, publishTitle: 'My Published Game', publishGithubPagesRepo: 'mygame', assets: { images: {}, spriteSheets: {}, fonts: {} }, @@ -130,6 +131,7 @@ describe('project YAML serialization', () => { const yaml = serializeProjectToYaml(project as any); expect(yaml).toMatch(/\ntitle:\s*My Game\n/); + expect(yaml).toMatch(/\npixelsPerUnit:\s*2\n/); expect(yaml).toMatch(/\npublishTitle:\s*My Published Game\n/); expect(yaml).toMatch(/\npublishGithubPagesRepo:\s*mygame\n/); expect(parseProjectYaml(yaml)).toEqual(project);