diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index c31a3b8..3085599 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -19,7 +19,7 @@ permissions: contents: read jobs: - build-test: + unit-tests-node: runs-on: ubuntu-latest steps: - name: Checkout @@ -34,11 +34,59 @@ jobs: - name: Install run: npm ci - - name: Unit Tests - run: npm run test:unit + - name: Unit Tests (Node) + run: npm run test:unit:node + + unit-tests-jsdom: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Unit Tests (jsdom) + run: npm run test:unit:jsdom + + storybook-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci - name: Storybook Tests run: npm run test:stories + build-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + - name: Build run: npm run build diff --git a/.plans/demo-pack-path-assets-plan-2026-06-25.md b/.plans/demo-pack-path-assets-plan-2026-06-25.md new file mode 100644 index 0000000..4ae5be2 --- /dev/null +++ b/.plans/demo-pack-path-assets-plan-2026-06-25.md @@ -0,0 +1,325 @@ +# Demo Pack Path Assets Plan — 2026-06-25 + +## Summary +Make `Assets Dock -> + Add -> From demo pack` feel near-instant by importing Demo Pack items as stable local asset references instead of embedding every file as a base64 data URL during editor import. + +This plan preserves three required constraints: + +- Published games must still ship real asset bytes, not editor-only references. +- Image thumbnails in the Assets `Images` tab must keep working. +- [`mainwindow.png`](../res/images/mainwindow.png) must remain in `res/images` because it is used by the root [`README.md`](../README.md). + +It also changes Demo Pack file placement: + +- Keep `res/images/mainwindow.png` in place. +- Move Demo Pack content into a new project-root `assets/` folder. + +## Problem +Current Demo Pack import is slow because the editor does all of the following for every asset: + +- Loads the asset URL. +- Reads the response into a `Blob`. +- Converts the bytes into a base64 data URL. +- For images, reads dimensions before dispatching. +- Dispatches one asset action per item. +- Persists the whole project record after each asset import. + +That turns “add Demo Pack” into repeated fetch/encode/save work instead of a cheap metadata operation. + +## Goals + +- Demo Pack import should feel close to instant. +- The existing user-facing workflow should stay the same: + - `Assets Dock -> + Add -> From demo pack` + - Imported items appear immediately in the same asset tabs. + - Thumbnails remain visible for images. + - Drag/drop, assignment, and scene usage continue to work without extra steps. +- Publish/cloud save must still materialize referenced local assets into real hosted assets. +- Demo Pack references must use stable project-relative paths, not transient hashed bundler URLs. + +## Non-Goals + +- Do not move `mainwindow.png`. +- Do not redesign the Assets Dock workflow. +- Do not add a second “Demo Pack import mode” in the UI. +- Do not make publish depend on the local `assets/` folder existing on the end-user website. + +## Proposed Asset Layout + +- `assets/demo-pack/images/*` +- `assets/demo-pack/audio/*` +- `assets/demo-pack/fonts/*` +- `res/images/mainwindow.png` stays unchanged. + +## Proposed Data Model Strategy + +Use `AssetFileSource.kind = 'path'` for Demo Pack assets imported into the editor. + +Example stored source: + +```ts +source: { + kind: 'path', + path: 'assets/demo-pack/images/player.png', + originalName: 'player.png', + mimeType: 'image/png', +} +``` + +Important rule: + +- Store stable project-relative paths such as `assets/demo-pack/images/foo.png`. +- Do not store Vite `?url` output or hashed build asset URLs in project state. + +## Why This Preserves UX + +### 1) Import speed +Demo Pack import becomes metadata insertion instead of byte copying: + +- no fetch per file +- no blob conversion +- no base64 encoding +- no image decode during import if dimensions come from a manifest + +### 2) Thumbnails +The editor already supports inline preview for `path` sources. Demo Pack image rows can continue to render thumbnails using the resolved path URL. + +### 3) Runtime/editor loading +The editor/runtime asset loaders already understand `path` sources. Demo Pack items can be resolved to URLs at load time instead of being pre-embedded in project state. + +### 4) Publish +Publish must treat local `path` assets as uploadable material, not as final URLs. Before cloud save / publish output generation, the pipeline should read those local files and convert them into real hosted assets. + +## Implementation Plan + +### Phase 1 — Move Demo Pack files and add a manifest +Create a single source of truth for Demo Pack assets under `assets/demo-pack/`. + +Tasks: + +- Move Demo Pack files from `res/images`, `res/audio`, and `res/fonts` into: + - `assets/demo-pack/images` + - `assets/demo-pack/audio` + - `assets/demo-pack/fonts` +- Leave `res/images/mainwindow.png` untouched. +- Add a small manifest module for Demo Pack assets containing: + - relative path + - kind (`image` / `audio` / `font`) + - original filename + - mime type + - for images: width and height + +Notes: + +- The manifest avoids per-image metadata decoding during import. +- The manifest can be generated by script or maintained manually, but generated is safer long-term. + +### Phase 2 — Replace embedded Demo Pack import with path-backed import +Change `From demo pack` to dispatch path-backed assets instead of embedded file assets. + +Tasks: + +- Replace the current `readUrlAsDataUrl` flow for Demo Pack import. +- Add reducer actions for ensuring path-backed image/audio/font assets if missing. +- Use the Demo Pack manifest to populate: + - `assetId` + - `source.kind = 'path'` + - `source.path` + - `originalName` + - `mimeType` + - image `width` / `height` + +Expected result: + +- Demo Pack import becomes a cheap loop over metadata entries. + +### Phase 3 — Batch the import into one state change +The current per-asset dispatch pattern should be replaced with a bulk action. + +Tasks: + +- Add a bulk Demo Pack import action or a generic bulk asset upsert action. +- Ensure undo/redo treats the import as one logical step. +- Persist the project once after the batch rather than once per asset. + +Expected result: + +- Lower persistence cost. +- Cleaner history behavior. +- Better responsiveness for large Demo Packs. + +### Phase 4 — Resolve local paths consistently in editor/runtime +Make sure project-relative asset paths resolve reliably in all local editor contexts. + +Tasks: + +- Add or reuse a helper that converts project-relative asset paths like `assets/demo-pack/images/foo.png` into usable browser URLs. +- Update Demo Pack thumbnail rendering, font loading, and scene/runtime loading to use the same resolution path. +- Keep path resolution stable whether the app runs from `/`, a subpath, or a preview server. + +Important: + +- This resolution layer is where bundler-specific URL mechanics should live. +- Project state should remain bundler-agnostic. + +### Phase 5 — Materialize `path` assets during cloud save / publish +This is the key publish-preservation phase. + +Tasks: + +- Extend the cloud/publish preparation pipeline so `path` assets are uploaded just like embedded assets. +- Read the referenced local file bytes during prepare-for-cloud-save. +- Convert the resulting source to `kind: 'cloud'` for the saved/published project payload. +- Apply this to: + - images + - sprite sheets + - fonts + - audio + +Important behavior: + +- Editor project state may stay `kind: 'path'` locally. +- The transient project prepared for publish/cloud save becomes `kind: 'cloud'`. +- Published websites therefore receive real hosted asset bytes, not editor-local references. + +### Phase 6 — Keep thumbnails fast and reliable +Thumbnails must remain visible in the Assets `Images` tab. + +Tasks: + +- Keep thumbnail generation path-based for Demo Pack images. +- Prefer manifest width/height metadata over runtime image decode where possible. +- Confirm thumbnail toggle behavior is unchanged. + +## TDD / Verification Plan + +### Unit tests + +- Demo Pack manifest helper tests: + - classifies all entries correctly + - preserves stable relative paths + - includes width/height for images +- Reducer tests: + - ensure path-backed image assets + - ensure path-backed audio assets + - ensure path-backed font assets + - bulk import action is idempotent + - re-import does not duplicate existing Demo Pack assets +- Cloud/publish preparation tests: + - `embedded` assets still upload correctly + - `path` assets are read and uploaded correctly + - mixed `embedded` + `path` projects produce cloud-backed sources +- Path resolution tests: + - project-relative asset paths resolve correctly under configured base URL + +### Editor/integration tests + +- Assets Dock Demo Pack import adds expected rows without data URL embedding. +- Image thumbnails appear for path-backed Demo Pack images. +- Dragging imported Demo Pack assets onto the canvas still creates usable entities. +- Fonts and audio remain assignable after path-backed import. + +### E2E +Because this is a GUI/editor workflow change, run Chromium smoke E2E locally before calling the code change complete. + +Suggested coverage: + +- Import Demo Pack. +- Verify images appear quickly in Assets Dock. +- Verify at least one image thumbnail renders. +- Drag one imported image to canvas and confirm entity creation. +- Assign one imported audio asset to scene music. +- Publish-path test coverage should be added at the lowest stable layer available; if true E2E publish is too heavy locally, cover materialization with integration tests and state that explicitly. + +## Key Technical Decisions + +### Decision 1 — Stable relative paths in state +Use `assets/demo-pack/...` in project state, not hashed build URLs. + +Reason: + +- stable across rebuilds +- portable within the project +- clearer publish-time materialization contract + +### Decision 2 — Publish materialization, not runtime website references +Treat local `path` assets as an editor/storage optimization only. + +Reason: + +- satisfies the requirement that published games ship real assets +- avoids broken websites when local files are unavailable remotely + +### Decision 3 — One bulk import action +Prefer a batch import over N individual dispatches. + +Reason: + +- lower save overhead +- better undo behavior +- simpler performance model + +## Risks / Open Questions + +### 1) How should local file bytes be read for `path` assets during publish? +Likely options: + +- client-side fetch of the resolved local asset URL, then upload +- direct file-system read in an Electron-capable path if available + +Preferred initial approach: + +- fetch via resolved local URL inside the existing browser-side/cloud-save path, as long as it is stable and testable + +### 2) Should all `path` assets be publish-materialized, or only Demo Pack ones? +Recommendation: + +- materialize all local `path` assets during publish for consistency + +Reason: + +- avoids split behavior +- future-proofs “From device as reference” if added later + +### 3) Should Demo Pack import remain reversible as one undo step? +Recommendation: + +- yes, one undo step + +### 4) Do any existing tests assume Demo Pack assets are embedded? +Likely yes in reducer/UI tests around asset source shape. + +Plan: + +- update tests to assert path-backed local storage behavior +- add separate publish-prep tests to verify real-byte materialization + +## Acceptance Criteria + +- Demo Pack assets live under `assets/demo-pack/**`. +- `res/images/mainwindow.png` remains in place. +- `From demo pack` import stores path-backed assets, not base64-embedded data URLs. +- Demo Pack import is one logical batch operation. +- Image thumbnails remain visible in the Assets `Images` tab. +- Editor/runtime usage of imported Demo Pack assets still works unchanged from the user’s perspective. +- Cloud save / publish converts local path-backed assets into real hosted/published assets. +- No published game depends on editor-local `assets/demo-pack/**` paths at runtime. + +## Suggested File Touches + +- `src/editor/AssetsDock.tsx` +- `src/editor/demoPackAssets.ts` or a new manifest module +- `src/editor/EditorStore.tsx` +- `src/cloud/projectCloudAssets.ts` +- `src/cloud/assetUrls.ts` +- `src/model/editorConfig.ts` or a new path resolver helper +- tests for reducer, manifest, and cloud materialization + +## Recommended Implementation Order + +1. Move Demo Pack assets to `assets/demo-pack/**` and add manifest coverage. +2. Add reducer support for path-backed ensured assets. +3. Convert Demo Pack import to bulk path-backed import. +4. Wire path resolution for thumbnails/editor/runtime. +5. Extend cloud/publish prep to materialize path-backed assets into hosted assets. +6. Run unit/integration coverage, then Chromium smoke E2E for the GUI change. diff --git a/res/images/License.txt b/assets/demo-pack/License.txt similarity index 100% rename from res/images/License.txt rename to assets/demo-pack/License.txt diff --git a/res/audio/Simulacra-chosic.com_.mp3 b/assets/demo-pack/audio/Simulacra-chosic.com_.mp3 similarity index 100% rename from res/audio/Simulacra-chosic.com_.mp3 rename to assets/demo-pack/audio/Simulacra-chosic.com_.mp3 diff --git a/res/audio/punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3 b/assets/demo-pack/audio/punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3 similarity index 100% rename from res/audio/punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3 rename to assets/demo-pack/audio/punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3 diff --git a/res/audio/sb_indreams(chosic.com).mp3 b/assets/demo-pack/audio/sb_indreams(chosic.com).mp3 similarity index 100% rename from res/audio/sb_indreams(chosic.com).mp3 rename to assets/demo-pack/audio/sb_indreams(chosic.com).mp3 diff --git a/res/images/effect_purple.png b/assets/demo-pack/images/effect_purple.png similarity index 100% rename from res/images/effect_purple.png rename to assets/demo-pack/images/effect_purple.png diff --git a/res/images/effect_yellow.png b/assets/demo-pack/images/effect_yellow.png similarity index 100% rename from res/images/effect_yellow.png rename to assets/demo-pack/images/effect_yellow.png diff --git a/res/images/enemy_A.png b/assets/demo-pack/images/enemy_A.png similarity index 100% rename from res/images/enemy_A.png rename to assets/demo-pack/images/enemy_A.png diff --git a/res/images/enemy_B.png b/assets/demo-pack/images/enemy_B.png similarity index 100% rename from res/images/enemy_B.png rename to assets/demo-pack/images/enemy_B.png diff --git a/res/images/enemy_C.png b/assets/demo-pack/images/enemy_C.png similarity index 100% rename from res/images/enemy_C.png rename to assets/demo-pack/images/enemy_C.png diff --git a/res/images/enemy_D.png b/assets/demo-pack/images/enemy_D.png similarity index 100% rename from res/images/enemy_D.png rename to assets/demo-pack/images/enemy_D.png diff --git a/res/images/enemy_E.png b/assets/demo-pack/images/enemy_E.png similarity index 100% rename from res/images/enemy_E.png rename to assets/demo-pack/images/enemy_E.png diff --git a/res/images/icon_plusLarge.png b/assets/demo-pack/images/icon_plusLarge.png similarity index 100% rename from res/images/icon_plusLarge.png rename to assets/demo-pack/images/icon_plusLarge.png diff --git a/res/images/icon_plusSmall.png b/assets/demo-pack/images/icon_plusSmall.png similarity index 100% rename from res/images/icon_plusSmall.png rename to assets/demo-pack/images/icon_plusSmall.png diff --git a/res/images/meteor_detailedLarge.png b/assets/demo-pack/images/meteor_detailedLarge.png similarity index 100% rename from res/images/meteor_detailedLarge.png rename to assets/demo-pack/images/meteor_detailedLarge.png diff --git a/res/images/meteor_detailedSmall.png b/assets/demo-pack/images/meteor_detailedSmall.png similarity index 100% rename from res/images/meteor_detailedSmall.png rename to assets/demo-pack/images/meteor_detailedSmall.png diff --git a/res/images/meteor_large.png b/assets/demo-pack/images/meteor_large.png similarity index 100% rename from res/images/meteor_large.png rename to assets/demo-pack/images/meteor_large.png diff --git a/res/images/meteor_small.png b/assets/demo-pack/images/meteor_small.png similarity index 100% rename from res/images/meteor_small.png rename to assets/demo-pack/images/meteor_small.png diff --git a/res/images/meteor_squareDetailedLarge.png b/assets/demo-pack/images/meteor_squareDetailedLarge.png similarity index 100% rename from res/images/meteor_squareDetailedLarge.png rename to assets/demo-pack/images/meteor_squareDetailedLarge.png diff --git a/res/images/meteor_squareDetailedSmall.png b/assets/demo-pack/images/meteor_squareDetailedSmall.png similarity index 100% rename from res/images/meteor_squareDetailedSmall.png rename to assets/demo-pack/images/meteor_squareDetailedSmall.png diff --git a/res/images/meteor_squareLarge.png b/assets/demo-pack/images/meteor_squareLarge.png similarity index 100% rename from res/images/meteor_squareLarge.png rename to assets/demo-pack/images/meteor_squareLarge.png diff --git a/res/images/meteor_squareSmall.png b/assets/demo-pack/images/meteor_squareSmall.png similarity index 100% rename from res/images/meteor_squareSmall.png rename to assets/demo-pack/images/meteor_squareSmall.png diff --git a/res/images/satellite_A.png b/assets/demo-pack/images/satellite_A.png similarity index 100% rename from res/images/satellite_A.png rename to assets/demo-pack/images/satellite_A.png diff --git a/res/images/satellite_C.png b/assets/demo-pack/images/satellite_C.png similarity index 100% rename from res/images/satellite_C.png rename to assets/demo-pack/images/satellite_C.png diff --git a/res/images/satellite_D.png b/assets/demo-pack/images/satellite_D.png similarity index 100% rename from res/images/satellite_D.png rename to assets/demo-pack/images/satellite_D.png diff --git a/res/images/ship_sidesA.png b/assets/demo-pack/images/ship_sidesA.png similarity index 100% rename from res/images/ship_sidesA.png rename to assets/demo-pack/images/ship_sidesA.png diff --git a/res/images/ship_sidesB.png b/assets/demo-pack/images/ship_sidesB.png similarity index 100% rename from res/images/ship_sidesB.png rename to assets/demo-pack/images/ship_sidesB.png diff --git a/res/images/ship_sidesC.png b/assets/demo-pack/images/ship_sidesC.png similarity index 100% rename from res/images/ship_sidesC.png rename to assets/demo-pack/images/ship_sidesC.png diff --git a/res/images/ship_sidesD.png b/assets/demo-pack/images/ship_sidesD.png similarity index 100% rename from res/images/ship_sidesD.png rename to assets/demo-pack/images/ship_sidesD.png diff --git a/res/images/station_B.png b/assets/demo-pack/images/station_B.png similarity index 100% rename from res/images/station_B.png rename to assets/demo-pack/images/station_B.png diff --git a/res/images/station_C.png b/assets/demo-pack/images/station_C.png similarity index 100% rename from res/images/station_C.png rename to assets/demo-pack/images/station_C.png diff --git a/package.json b/package.json index 439f8ed..c2af8cb 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,9 @@ "postinstall": "prisma generate", "test": "vitest run", "test:watch": "vitest", - "test:unit": "vitest run", + "test:unit": "vitest run --config vitest.unit.config.mts", + "test:unit:node": "vitest run --config vitest.unit.node.config.mts", + "test:unit:jsdom": "vitest run --config vitest.unit.jsdom.config.mts", "test:persistence:unit": "vitest run tests/util/persistenceDebug.test.ts tests/editor/projectPersistence.test.ts tests/editor/editor-store.test.ts tests/editor/editor-store-history.test.ts tests/editor/editor-store-reset-and-clear.test.ts", "test:persistence:e2e": "node scripts/playwright-no-deprecation.cjs test --project=chromium tests/e2e/reload-recovers-latest-active-snapshot.spec.ts tests/e2e/cloud-workspace-conflict.spec.ts tests/e2e/cloud-reload-preserves-latest-head.spec.ts", "test:persistence": "npm run test:persistence:unit && npm run test:persistence:e2e", diff --git a/src/assets/projectAssetPaths.ts b/src/assets/projectAssetPaths.ts new file mode 100644 index 0000000..98c9b48 --- /dev/null +++ b/src/assets/projectAssetPaths.ts @@ -0,0 +1,18 @@ +const PROJECT_ASSET_URLS = { + ...import.meta.glob('../../assets/**/*.png', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.jpg', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.jpeg', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.webp', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.mp3', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.ogg', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.wav', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.ttf', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.otf', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.woff', { eager: true, query: '?url', import: 'default' }), + ...import.meta.glob('../../assets/**/*.woff2', { eager: true, query: '?url', import: 'default' }), +} as Record; + +export function resolveProjectAssetPathToUrl(path: string): string { + const normalized = path.replace(/^\/+/, ''); + return PROJECT_ASSET_URLS[`../../${normalized}`] ?? normalized; +} diff --git a/src/cloud/assetUrls.ts b/src/cloud/assetUrls.ts index d730d63..6e1b01d 100644 --- a/src/cloud/assetUrls.ts +++ b/src/cloud/assetUrls.ts @@ -1,4 +1,5 @@ import type { AssetFileSource } from '../model/types'; +import { resolveProjectAssetPathToUrl } from '../assets/projectAssetPaths'; import { resolveApiUrl } from './api'; const cloudAssetUrlCache = new Map>(); @@ -20,13 +21,13 @@ export function assetSourceKey(source: AssetFileSource): string { export function inlinePreviewUrlForAssetSource(source: AssetFileSource): string { if (source.kind === 'embedded') return source.dataUrl; - if (source.kind === 'path') return source.path; + if (source.kind === 'path') return resolveProjectAssetPathToUrl(source.path); return ''; } export async function resolveAssetSourceUrl(source: AssetFileSource): Promise { if (source.kind === 'embedded') return source.dataUrl; - if (source.kind === 'path') return source.path; + if (source.kind === 'path') return resolveProjectAssetPathToUrl(source.path); let pending = cloudAssetUrlCache.get(source.assetId); if (!pending) { diff --git a/src/cloud/projectCloudAssets.ts b/src/cloud/projectCloudAssets.ts index 86d8ec5..7396ba7 100644 --- a/src/cloud/projectCloudAssets.ts +++ b/src/cloud/projectCloudAssets.ts @@ -1,8 +1,24 @@ import type { AssetFileSource, ProjectSpec } from '../model/types'; +import { resolveProjectAssetPathToUrl } from '../assets/projectAssetPaths'; type CloudAssetSource = Extract; type EmbeddedAssetSource = Extract; +async function blobToDataUrl(blob: Blob): Promise { + if (typeof FileReader === 'function') { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error('Failed to read asset blob')); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.readAsDataURL(blob); + }); + } + + const bytes = new Uint8Array(await blob.arrayBuffer()); + const base64 = Buffer.from(bytes).toString('base64'); + return `data:${blob.type || 'application/octet-stream'};base64,${base64}`; +} + export async function prepareProjectForCloudSave( project: ProjectSpec, upload: (source: EmbeddedAssetSource) => Promise, @@ -11,11 +27,28 @@ export async function prepareProjectForCloudSave( const nextProject = structuredClone(project); const ensureCloudSource = async (source: AssetFileSource): Promise => { - if (source.kind !== 'embedded') return source; - const cached = cache.get(source.dataUrl); + if (source.kind === 'cloud') return source; + + const cacheKey = source.kind === 'path' ? source.path : source.dataUrl; + const cached = cache.get(cacheKey); if (cached) return cached; - const uploaded = await upload(source); - cache.set(source.dataUrl, uploaded); + + const embeddedSource = source.kind === 'embedded' + ? source + : await (async (): Promise => { + const res = await fetch(resolveProjectAssetPathToUrl(source.path)); + if (!res.ok) throw new Error(`Failed to load local asset at ${source.path}`); + const blob = await res.blob(); + return { + kind: 'embedded', + dataUrl: await blobToDataUrl(blob), + ...(source.originalName ? { originalName: source.originalName } : {}), + mimeType: source.mimeType || blob.type || undefined, + }; + })(); + + const uploaded = await upload(embeddedSource); + cache.set(cacheKey, uploaded); return uploaded; }; diff --git a/src/editor/AssetsDock.tsx b/src/editor/AssetsDock.tsx index c5d2844..89d5451 100644 --- a/src/editor/AssetsDock.tsx +++ b/src/editor/AssetsDock.tsx @@ -2,95 +2,19 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'; import type { ProjectSpec } from '../model/types'; import type { EditorAction, Selection } from './EditorStore'; import { getAssetReferences, type AssetKind } from './assetReferences'; -import { assetIdBaseFromOriginalName, getDemoPackAssetKind } from './demoPackAssets'; +import { DEMO_PACK_ASSET_MANIFEST } from './demoPackAssets'; import { ASSET_DRAG_MIME } from './dragAssets'; import { fileToDataUrl } from './fileDataUrl'; import { loadImageMetadataFromFile, type LoadedImageMetadata } from './imageMetadata'; import { projectPersistence } from './projectPersistence'; import { inlinePreviewUrlForAssetSource } from '../cloud/assetUrls'; -const DEMO_PACK_ASSETS = { - ...import.meta.glob('../../res/images/*.png', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/images/*.jpg', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/images/*.jpeg', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/images/*.webp', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/audio/*.mp3', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/audio/*.ogg', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/audio/*.wav', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/fonts/*.ttf', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/fonts/*.otf', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/fonts/*.woff', { - eager: true, - query: '?url', - import: 'default', - }), - ...import.meta.glob('../../res/fonts/*.woff2', { - eager: true, - query: '?url', - import: 'default', - }), -} as Record; - -const DEMO_PACK_FONT_EXTENSIONS = /\.(ttf|otf|woff|woff2)$/i; - -const DEMO_PACK_IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|webp)$/i; - -const DEMO_PACK_AUDIO_EXTENSIONS = /\.(mp3|ogg|wav)$/i; - const DEVICE_FONT_EXTENSIONS = /\.(ttf|otf|woff|woff2)$/i; function isFontFilename(name: string): boolean { return DEVICE_FONT_EXTENSIONS.test(name); } -function isDemoPackImageFilename(path: string): boolean { - return DEMO_PACK_IMAGE_EXTENSIONS.test(path); -} - -function isDemoPackAudioFilename(path: string): boolean { - return DEMO_PACK_AUDIO_EXTENSIONS.test(path); -} - -function isDemoPackFontFilename(path: string): boolean { - return DEMO_PACK_FONT_EXTENSIONS.test(path); -} - async function readAsDataUrl(file: File): Promise { return fileToDataUrl(file); } @@ -126,14 +50,6 @@ function usageBadgesForAudio(project: ProjectSpec, assetId: string): Array<'MUS' ]; } -async function readUrlAsDataUrl(url: string): Promise<{ dataUrl: string; mimeType?: string }> { - const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to load asset (${res.status})`); - const blob = await res.blob(); - const file = new File([blob], url.split('/').pop() ?? 'asset', { type: blob.type || undefined }); - return { dataUrl: await readAsDataUrl(file), ...(blob.type ? { mimeType: blob.type } : {}) }; -} - export function AssetsDock({ project, sceneId, @@ -255,40 +171,7 @@ export function AssetsDock({ if (demoPackImporting) return; setDemoPackImporting(true); try { - const urls = Object.entries(DEMO_PACK_ASSETS) - .map(([path, url]) => ({ path, url })) - .sort((a, b) => a.path.localeCompare(b.path)); - for (const { path, url } of urls) { - const kind = getDemoPackAssetKind(path); - if (!kind) continue; - const filename = path.split('/').pop() ?? 'image.png'; - const { dataUrl, mimeType } = await readUrlAsDataUrl(url); - if (kind === 'image' && isDemoPackImageFilename(path)) { - const assetId = assetIdBaseFromOriginalName(filename, 'image'); - const meta = toLoadedImage(await loadImageMetadataFromFile(new File([], filename), dataUrl)); - dispatch({ - type: 'ensure-image-asset-from-file', - assetId, - file: { dataUrl, originalName: filename, mimeType, width: meta.width, height: meta.height }, - } as any); - continue; - } - if (kind === 'audio' && isDemoPackAudioFilename(path)) { - dispatch({ - type: 'ensure-audio-asset-from-file', - assetId: assetIdBaseFromOriginalName(filename, 'sound'), - file: { dataUrl, originalName: filename, mimeType }, - } as any); - continue; - } - if (kind === 'font' && isDemoPackFontFilename(path)) { - dispatch({ - type: 'ensure-font-asset-from-file', - assetId: assetIdBaseFromOriginalName(filename, 'font'), - file: { dataUrl, originalName: filename, mimeType }, - } as any); - } - } + dispatch({ type: 'import-demo-pack-assets', entries: DEMO_PACK_ASSET_MANIFEST } as any); } catch (err) { setImportError(err instanceof Error ? err.message : 'Failed to import demo pack'); } finally { diff --git a/src/editor/EditorStore.tsx b/src/editor/EditorStore.tsx index 9b99293..ec878e9 100644 --- a/src/editor/EditorStore.tsx +++ b/src/editor/EditorStore.tsx @@ -47,6 +47,7 @@ import { measureTextEntityPixels, resolveTextEntityDefaults, resolveTextFontFami import { allocDuplicateName } from './duplicateNaming'; import { type ProjectSyncMode, type StoredProjectRecord, buildStoredProjectRecord, projectPersistence } from './projectPersistence'; import type { ProjectLibraryEntry } from './projectLibrary'; +import type { DemoPackAssetManifestEntry } from './demoPackAssets'; import { getGame, listGames, me } from '../cloud/api'; import { appendProjectRevision, @@ -281,6 +282,7 @@ export type EditorAction = | { type: 'add-background-layer-from-file'; file: { dataUrl: string; originalName?: string; mimeType?: string }; defaults?: { layout?: BackgroundLayerSpec['layout'] } } | { type: 'add-image-asset-from-file'; file: { dataUrl: string; originalName?: string; mimeType?: string; width?: number; height?: number } } | { type: 'ensure-image-asset-from-file'; assetId: Id; file: { dataUrl: string; originalName?: string; mimeType?: string; width?: number; height?: number } } + | { type: 'import-demo-pack-assets'; entries: DemoPackAssetManifestEntry[] } | { type: 'add-spritesheet-asset-from-file'; file: { dataUrl: string; originalName?: string; mimeType?: string }; grid: { frameWidth: number; frameHeight: number; columns: number; rows: number } } | { type: 'add-font-asset-from-file'; file: { dataUrl: string; originalName?: string; mimeType?: string } } | { type: 'ensure-font-asset-from-file'; assetId: Id; file: { dataUrl: string; originalName?: string; mimeType?: string } } @@ -424,6 +426,98 @@ function assetIdBaseFromOriginalName(name: string | undefined, fallbackBase: str .replace(/-+$/, '') || 'background'; } +function assetDisplayNameFromOriginalName(name: string | undefined, fallbackName: string): string | undefined { + const rawName = (name ?? fallbackName).replace(/\.[a-z0-9]+$/i, '').trim(); + return rawName || undefined; +} + +function createImageAssetSpec( + assetId: Id, + source: AssetFileSource, + originalName: string | undefined, + width?: number, + height?: number, +) { + const name = assetDisplayNameFromOriginalName(originalName, assetId); + return { + id: assetId, + ...(name ? { name } : {}), + ...(typeof width === 'number' && Number.isFinite(width) && width > 0 ? { width } : {}), + ...(typeof height === 'number' && Number.isFinite(height) && height > 0 ? { height } : {}), + source, + }; +} + +function createFontAssetSpec(assetId: Id, source: AssetFileSource, originalName: string | undefined) { + const name = assetDisplayNameFromOriginalName(originalName, assetId); + return { + id: assetId, + ...(name ? { name } : {}), + source, + }; +} + +function createAudioAssetSpec(assetId: Id, source: AssetFileSource, originalName: string | undefined) { + return { + id: assetId, + source, + }; +} + +function importDemoPackAssets(state: EditorState, entries: DemoPackAssetManifestEntry[]): EditorState { + let changed = false; + const images = { ...(state.project.assets.images ?? {}) }; + const fonts = { ...(state.project.assets.fonts ?? {}) }; + const sounds = { ...(state.project.audio?.sounds ?? {}) }; + + for (const entry of entries) { + const source = { + kind: 'path' as const, + path: entry.path, + originalName: entry.originalName, + mimeType: entry.mimeType, + }; + + if (entry.kind === 'image') { + if (images[entry.assetId]) continue; + images[entry.assetId] = createImageAssetSpec(entry.assetId, source, entry.originalName, entry.width, entry.height); + changed = true; + continue; + } + + if (entry.kind === 'font') { + if (fonts[entry.assetId]) continue; + fonts[entry.assetId] = createFontAssetSpec(entry.assetId, source, entry.originalName); + changed = true; + continue; + } + + if (sounds[entry.assetId]) continue; + sounds[entry.assetId] = createAudioAssetSpec(entry.assetId, source, entry.originalName); + changed = true; + } + + if (!changed) return state; + + return { + ...state, + project: { + ...state.project, + assets: { + ...state.project.assets, + images, + fonts, + }, + audio: { + ...state.project.audio, + sounds, + }, + }, + dirty: true, + error: undefined, + }; +} + function removeGroupKeepMembers( scene: SceneSpec, groupId: Id @@ -765,6 +859,7 @@ function isUndoableAction(action: EditorAction): boolean { case 'load-project': case 'add-background-layer-from-file': case 'add-image-asset-from-file': + case 'import-demo-pack-assets': case 'add-spritesheet-asset-from-file': case 'add-font-asset-from-file': case 'ensure-font-asset-from-file': @@ -809,6 +904,7 @@ function getHistoryScope(action: EditorAction): HistoryScope { case 'clear-scene': case 'add-background-layer-from-file': case 'add-image-asset-from-file': + case 'import-demo-pack-assets': case 'add-spritesheet-asset-from-file': case 'add-font-asset-from-file': case 'ensure-font-asset-from-file': @@ -1543,21 +1639,19 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { const sounds = state.project.audio?.sounds ?? {}; const base = assetIdBaseFromOriginalName(action.file.originalName, 'sound'); const assetId = allocUniqueId(sounds, base); + const source: AssetFileSource = { + kind: 'embedded', + dataUrl: action.file.dataUrl, + ...(action.file.originalName ? { originalName: action.file.originalName } : {}), + ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), + }; const nextProject: ProjectSpec = { ...state.project, audio: { ...state.project.audio, sounds: { ...sounds, - [assetId]: { - id: assetId, - source: { - kind: 'embedded', - dataUrl: action.file.dataUrl, - ...(action.file.originalName ? { originalName: action.file.originalName } : {}), - ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), - }, - }, + [assetId]: createAudioAssetSpec(assetId, source, action.file.originalName), }, }, }; @@ -1573,21 +1667,19 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { if (!assetId) return state; const sounds = state.project.audio?.sounds ?? {}; if (sounds[assetId]) return state; + const source: AssetFileSource = { + kind: 'embedded', + dataUrl: action.file.dataUrl, + ...(action.file.originalName ? { originalName: action.file.originalName } : {}), + ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), + }; const nextProject: ProjectSpec = { ...state.project, audio: { ...state.project.audio, sounds: { ...sounds, - [assetId]: { - id: assetId, - source: { - kind: 'embedded', - dataUrl: action.file.dataUrl, - ...(action.file.originalName ? { originalName: action.file.originalName } : {}), - ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), - }, - }, + [assetId]: createAudioAssetSpec(assetId, source, action.file.originalName), }, }, }; @@ -1848,27 +1940,19 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { const images = state.project.assets.images ?? {}; const base = assetIdBaseFromOriginalName(action.file.originalName, 'image'); const assetId = allocUniqueId(images, base); - const rawName = (action.file.originalName ?? '').replace(/\.[a-z0-9]+$/i, '').trim(); - const width = action.file.width; - const height = action.file.height; + const source: AssetFileSource = { + kind: 'embedded', + dataUrl: action.file.dataUrl, + ...(action.file.originalName ? { originalName: action.file.originalName } : {}), + ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), + }; const nextProject: ProjectSpec = { ...state.project, assets: { ...state.project.assets, images: { ...images, - [assetId]: { - id: assetId, - ...(rawName ? { name: rawName } : {}), - ...(typeof width === 'number' && Number.isFinite(width) && width > 0 ? { width } : {}), - ...(typeof height === 'number' && Number.isFinite(height) && height > 0 ? { height } : {}), - source: { - kind: 'embedded', - dataUrl: action.file.dataUrl, - ...(action.file.originalName ? { originalName: action.file.originalName } : {}), - ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), - }, - }, + [assetId]: createImageAssetSpec(assetId, source, action.file.originalName, action.file.width, action.file.height), }, }, }; @@ -1879,32 +1963,26 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { if (!assetId) return state; const images = state.project.assets.images ?? {}; if (images[assetId]) return state; - const rawName = (action.file.originalName ?? assetId).replace(/\.[a-z0-9]+$/i, '').trim(); - const width = action.file.width; - const height = action.file.height; + const source: AssetFileSource = { + kind: 'embedded', + dataUrl: action.file.dataUrl, + ...(action.file.originalName ? { originalName: action.file.originalName } : {}), + ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), + }; const nextProject: ProjectSpec = { ...state.project, assets: { ...state.project.assets, images: { ...images, - [assetId]: { - id: assetId, - ...(rawName ? { name: rawName } : {}), - ...(typeof width === 'number' && Number.isFinite(width) && width > 0 ? { width } : {}), - ...(typeof height === 'number' && Number.isFinite(height) && height > 0 ? { height } : {}), - source: { - kind: 'embedded', - dataUrl: action.file.dataUrl, - ...(action.file.originalName ? { originalName: action.file.originalName } : {}), - ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), - }, - }, + [assetId]: createImageAssetSpec(assetId, source, action.file.originalName ?? assetId, action.file.width, action.file.height), }, }, }; return { ...state, project: nextProject, dirty: true, error: undefined }; } + case 'import-demo-pack-assets': + return importDemoPackAssets(state, action.entries); case 'add-spritesheet-asset-from-file': { const spriteSheets = state.project.assets.spriteSheets ?? {}; const base = assetIdBaseFromOriginalName(action.file.originalName, 'spritesheet'); @@ -1941,23 +2019,19 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { const fonts = state.project.assets.fonts ?? {}; const base = assetIdBaseFromOriginalName(action.file.originalName, 'font'); const assetId = allocUniqueId(fonts, base); - const rawName = (action.file.originalName ?? '').replace(/\.[a-z0-9]+$/i, '').trim(); + const source: AssetFileSource = { + kind: 'embedded', + dataUrl: action.file.dataUrl, + ...(action.file.originalName ? { originalName: action.file.originalName } : {}), + ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), + }; const nextProject: ProjectSpec = { ...state.project, assets: { ...state.project.assets, fonts: { ...fonts, - [assetId]: { - id: assetId, - ...(rawName ? { name: rawName } : {}), - source: { - kind: 'embedded', - dataUrl: action.file.dataUrl, - ...(action.file.originalName ? { originalName: action.file.originalName } : {}), - ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), - }, - }, + [assetId]: createFontAssetSpec(assetId, source, action.file.originalName), }, }, }; @@ -1968,23 +2042,19 @@ function applyAction(state: EditorState, action: EditorAction): EditorState { if (!assetId) return state; const fonts = state.project.assets.fonts ?? {}; if (fonts[assetId]) return state; - const rawName = (action.file.originalName ?? assetId).replace(/\.[a-z0-9]+$/i, '').trim(); + const source: AssetFileSource = { + kind: 'embedded', + dataUrl: action.file.dataUrl, + ...(action.file.originalName ? { originalName: action.file.originalName } : {}), + ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), + }; const nextProject: ProjectSpec = { ...state.project, assets: { ...state.project.assets, fonts: { ...fonts, - [assetId]: { - id: assetId, - ...(rawName ? { name: rawName } : {}), - source: { - kind: 'embedded', - dataUrl: action.file.dataUrl, - ...(action.file.originalName ? { originalName: action.file.originalName } : {}), - ...(action.file.mimeType ? { mimeType: action.file.mimeType } : {}), - }, - }, + [assetId]: createFontAssetSpec(assetId, source, action.file.originalName ?? assetId), }, }, }; diff --git a/src/editor/demoPackAssets.ts b/src/editor/demoPackAssets.ts index 097a59a..de8999e 100644 --- a/src/editor/demoPackAssets.ts +++ b/src/editor/demoPackAssets.ts @@ -1,9 +1,51 @@ export type DemoPackAssetKind = 'image' | 'audio' | 'font'; +export type DemoPackAssetManifestEntry = { + assetId: string; + kind: DemoPackAssetKind; + path: string; + originalName: string; + mimeType: string; + width?: number; + height?: number; +}; + const DEMO_PACK_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp']); const DEMO_PACK_AUDIO_EXTENSIONS = new Set(['mp3', 'ogg', 'wav']); const DEMO_PACK_FONT_EXTENSIONS = new Set(['ttf', 'otf', 'woff', 'woff2']); +export const DEMO_PACK_ASSET_MANIFEST: DemoPackAssetManifestEntry[] = [ + { assetId: 'effect-purple', kind: 'image', path: 'assets/demo-pack/images/effect_purple.png', originalName: 'effect_purple.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'effect-yellow', kind: 'image', path: 'assets/demo-pack/images/effect_yellow.png', originalName: 'effect_yellow.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'enemy-a', kind: 'image', path: 'assets/demo-pack/images/enemy_A.png', originalName: 'enemy_A.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'enemy-b', kind: 'image', path: 'assets/demo-pack/images/enemy_B.png', originalName: 'enemy_B.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'enemy-c', kind: 'image', path: 'assets/demo-pack/images/enemy_C.png', originalName: 'enemy_C.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'enemy-d', kind: 'image', path: 'assets/demo-pack/images/enemy_D.png', originalName: 'enemy_D.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'enemy-e', kind: 'image', path: 'assets/demo-pack/images/enemy_E.png', originalName: 'enemy_E.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'icon-pluslarge', kind: 'image', path: 'assets/demo-pack/images/icon_plusLarge.png', originalName: 'icon_plusLarge.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'icon-plussmall', kind: 'image', path: 'assets/demo-pack/images/icon_plusSmall.png', originalName: 'icon_plusSmall.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-detailedlarge', kind: 'image', path: 'assets/demo-pack/images/meteor_detailedLarge.png', originalName: 'meteor_detailedLarge.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-detailedsmall', kind: 'image', path: 'assets/demo-pack/images/meteor_detailedSmall.png', originalName: 'meteor_detailedSmall.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-large', kind: 'image', path: 'assets/demo-pack/images/meteor_large.png', originalName: 'meteor_large.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-small', kind: 'image', path: 'assets/demo-pack/images/meteor_small.png', originalName: 'meteor_small.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-squaredetailedlarge', kind: 'image', path: 'assets/demo-pack/images/meteor_squareDetailedLarge.png', originalName: 'meteor_squareDetailedLarge.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-squaredetailedsmall', kind: 'image', path: 'assets/demo-pack/images/meteor_squareDetailedSmall.png', originalName: 'meteor_squareDetailedSmall.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-squarelarge', kind: 'image', path: 'assets/demo-pack/images/meteor_squareLarge.png', originalName: 'meteor_squareLarge.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'meteor-squaresmall', kind: 'image', path: 'assets/demo-pack/images/meteor_squareSmall.png', originalName: 'meteor_squareSmall.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'satellite-a', kind: 'image', path: 'assets/demo-pack/images/satellite_A.png', originalName: 'satellite_A.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'satellite-c', kind: 'image', path: 'assets/demo-pack/images/satellite_C.png', originalName: 'satellite_C.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'satellite-d', kind: 'image', path: 'assets/demo-pack/images/satellite_D.png', originalName: 'satellite_D.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'ship-sidesa', kind: 'image', path: 'assets/demo-pack/images/ship_sidesA.png', originalName: 'ship_sidesA.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'ship-sidesb', kind: 'image', path: 'assets/demo-pack/images/ship_sidesB.png', originalName: 'ship_sidesB.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'ship-sidesc', kind: 'image', path: 'assets/demo-pack/images/ship_sidesC.png', originalName: 'ship_sidesC.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'ship-sidesd', kind: 'image', path: 'assets/demo-pack/images/ship_sidesD.png', originalName: 'ship_sidesD.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'station-b', kind: 'image', path: 'assets/demo-pack/images/station_B.png', originalName: 'station_B.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'station-c', kind: 'image', path: 'assets/demo-pack/images/station_C.png', originalName: 'station_C.png', mimeType: 'image/png', width: 64, height: 64 }, + { assetId: 'simulacra-chosic-com', kind: 'audio', path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', originalName: 'Simulacra-chosic.com_.mp3', mimeType: 'audio/mpeg' }, + { assetId: 'punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix-chosic-com', kind: 'audio', path: 'assets/demo-pack/audio/punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3', originalName: 'punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3', mimeType: 'audio/mpeg' }, + { assetId: 'sb-indreams-chosic-com', kind: 'audio', path: 'assets/demo-pack/audio/sb_indreams(chosic.com).mp3', originalName: 'sb_indreams(chosic.com).mp3', mimeType: 'audio/mpeg' }, +]; + export function assetIdBaseFromOriginalName(name: string | undefined, fallbackBase: string = 'asset'): string { const raw = (name ?? '').trim(); const withoutExt = raw.replace(/\.[a-z0-9]+$/i, ''); @@ -20,8 +62,11 @@ export function getDemoPackAssetKind(path: string): DemoPackAssetKind | null { const match = normalized.match(/\.([a-z0-9]+)$/); const extension = match?.[1]; if (!extension) return null; - if (normalized.includes('/res/images/') && DEMO_PACK_IMAGE_EXTENSIONS.has(extension)) return 'image'; - if (normalized.includes('/res/audio/') && DEMO_PACK_AUDIO_EXTENSIONS.has(extension)) return 'audio'; - if (normalized.includes('/res/fonts/') && DEMO_PACK_FONT_EXTENSIONS.has(extension)) return 'font'; + if (normalized.includes('/assets/demo-pack/images/') && DEMO_PACK_IMAGE_EXTENSIONS.has(extension)) return 'image'; + if (normalized.includes('/assets/demo-pack/audio/') && DEMO_PACK_AUDIO_EXTENSIONS.has(extension)) return 'audio'; + if (normalized.includes('/assets/demo-pack/fonts/') && DEMO_PACK_FONT_EXTENSIONS.has(extension)) return 'font'; + if (normalized.startsWith('assets/demo-pack/images/') && DEMO_PACK_IMAGE_EXTENSIONS.has(extension)) return 'image'; + if (normalized.startsWith('assets/demo-pack/audio/') && DEMO_PACK_AUDIO_EXTENSIONS.has(extension)) return 'audio'; + if (normalized.startsWith('assets/demo-pack/fonts/') && DEMO_PACK_FONT_EXTENSIONS.has(extension)) return 'font'; return null; } diff --git a/tests/cloud/asset-urls.test.ts b/tests/cloud/asset-urls.test.ts new file mode 100644 index 0000000..14db2ad --- /dev/null +++ b/tests/cloud/asset-urls.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { inlinePreviewUrlForAssetSource, resolveAssetSourceUrl } from '../../src/cloud/assetUrls'; + +describe('asset URL helpers', () => { + it('resolves project-relative path assets to usable browser URLs', async () => { + const source = { + kind: 'path' as const, + path: 'assets/demo-pack/images/enemy_A.png', + originalName: 'enemy_A.png', + mimeType: 'image/png', + }; + + const inlineUrl = inlinePreviewUrlForAssetSource(source); + const resolvedUrl = await resolveAssetSourceUrl(source); + + expect(inlineUrl).toContain('enemy_A.png'); + expect(resolvedUrl).toContain('enemy_A.png'); + }); +}); diff --git a/tests/cloud/project-cloud-assets.test.ts b/tests/cloud/project-cloud-assets.test.ts index 60ac95a..24ecf0e 100644 --- a/tests/cloud/project-cloud-assets.test.ts +++ b/tests/cloud/project-cloud-assets.test.ts @@ -4,7 +4,7 @@ import { createEmptyProject } from '../../src/model/emptyProject'; import { prepareProjectForCloudSave } from '../../src/cloud/projectCloudAssets'; describe('prepareProjectForCloudSave', () => { - it('uploads embedded assets and rewrites them to cloud refs while preserving existing refs', async () => { + it('uploads embedded and path assets and rewrites them to cloud refs', async () => { const project = createEmptyProject(); project.assets.images.hero = { id: 'hero', @@ -31,12 +31,16 @@ describe('prepareProjectForCloudSave', () => { name: 'Arcade', source: { kind: 'path', - path: 'assets/fonts/arcade.woff2', + path: 'assets/demo-pack/fonts/arcade.woff2', originalName: 'arcade.woff2', mimeType: 'font/woff2', }, } as any; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: async () => new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'font/woff2' }), + } as Response); const upload = vi.fn(async (source: any) => ({ kind: 'cloud' as const, assetId: `asset-${source.originalName}`, @@ -46,7 +50,8 @@ describe('prepareProjectForCloudSave', () => { const prepared = await prepareProjectForCloudSave(project, upload); - expect(upload).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(upload).toHaveBeenCalledTimes(3); expect(prepared).not.toBe(project); expect(prepared.assets.images.hero.source).toEqual({ kind: 'cloud', @@ -60,7 +65,14 @@ describe('prepareProjectForCloudSave', () => { originalName: 'theme.mp3', mimeType: 'audio/mpeg', }); - expect(prepared.assets.fonts.arcade.source).toEqual(project.assets.fonts.arcade.source); + expect(prepared.assets.fonts.arcade.source).toEqual({ + kind: 'cloud', + assetId: 'asset-arcade.woff2', + originalName: 'arcade.woff2', + mimeType: 'font/woff2', + }); + + fetchSpy.mockRestore(); }); it('reuses cached uploads for identical embedded sources', async () => { @@ -88,4 +100,37 @@ describe('prepareProjectForCloudSave', () => { expect(prepared.audio.sounds.theme.source).toEqual(prepared.audio.sounds.theme2.source); expect(cache.get(shared.dataUrl)).toEqual(prepared.audio.sounds.theme.source); }); + + it('reuses cached uploads for identical path sources', async () => { + const project = createEmptyProject(); + const shared = { + kind: 'path', + path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + originalName: 'Simulacra-chosic.com_.mp3', + mimeType: 'audio/mpeg', + } as const; + project.audio.sounds.theme = { id: 'theme', source: shared } as any; + project.audio.sounds.theme2 = { id: 'theme2', source: shared } as any; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + blob: async () => new Blob([new Uint8Array([9, 9, 9])], { type: 'audio/mpeg' }), + } as Response); + const cache = new Map(); + const upload = vi.fn(async () => ({ + kind: 'cloud' as const, + assetId: 'asset-theme', + originalName: 'Simulacra-chosic.com_.mp3', + mimeType: 'audio/mpeg', + })); + + const prepared = await prepareProjectForCloudSave(project, upload, cache); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(upload).toHaveBeenCalledTimes(1); + expect(prepared.audio.sounds.theme.source).toEqual(prepared.audio.sounds.theme2.source); + expect(cache.get(shared.path)).toEqual(prepared.audio.sounds.theme.source); + + fetchSpy.mockRestore(); + }); }); diff --git a/tests/e2e/app-shell.spec.ts b/tests/e2e/app-shell.spec.ts index d79f530..54ca3b8 100644 --- a/tests/e2e/app-shell.spec.ts +++ b/tests/e2e/app-shell.spec.ts @@ -141,7 +141,7 @@ test('imports embedded sprites into the scene @critical', async ({ page }) => { await gotoStudio(page); await openSceneScope(page); - const { assetId: imageAssetId } = await importImageAssetFromFile(page, 'res/images/enemy_A.png'); + const { assetId: imageAssetId } = await importImageAssetFromFile(page, 'assets/demo-pack/images/enemy_A.png'); await dragAssetToCanvas(page, 'image', imageAssetId); await expect.poll(async () => { @@ -167,7 +167,7 @@ test('imports embedded sprites into the scene @critical', async ({ page }) => { test('removes an imported sprite from the scene graph @critical', async ({ page }) => { await gotoStudio(page); await openSceneScope(page); - const { assetId } = await importImageAssetFromFile(page, 'res/images/enemy_A.png'); + const { assetId } = await importImageAssetFromFile(page, 'assets/demo-pack/images/enemy_A.png'); await dragAssetToCanvas(page, 'image', assetId); const entityId = await page.evaluate(() => { diff --git a/tests/e2e/assets-dock.spec.ts b/tests/e2e/assets-dock.spec.ts index 883d7cb..baacfd9 100644 --- a/tests/e2e/assets-dock.spec.ts +++ b/tests/e2e/assets-dock.spec.ts @@ -25,7 +25,7 @@ test.describe('Assets dock', () => { await expect(page.getByTestId('assets-dock-show-thumbnails')).toBeVisible(); - await page.getByTestId('assets-dock-device-file-input').setInputFiles('res/images/enemy_A.png'); + await page.getByTestId('assets-dock-device-file-input').setInputFiles('assets/demo-pack/images/enemy_A.png'); await expect(page.getByTestId('assets-dock-item-image-enemy-a')).toBeVisible(); @@ -74,7 +74,7 @@ test.describe('Assets dock', () => { await dismissViewHint(page); await openSceneScope(page); - await page.getByTestId('assets-dock-device-file-input').setInputFiles('res/images/enemy_A.png'); + await page.getByTestId('assets-dock-device-file-input').setInputFiles('assets/demo-pack/images/enemy_A.png'); await expect(page.getByTestId('assets-dock-item-image-enemy-a')).toBeVisible(); await page.getByTestId('fit-view-button').click(); @@ -123,7 +123,7 @@ test.describe('Assets dock', () => { await dismissViewHint(page); await openSceneScope(page); - await page.getByTestId('assets-dock-device-file-input').setInputFiles('res/images/enemy_A.png'); + await page.getByTestId('assets-dock-device-file-input').setInputFiles('assets/demo-pack/images/enemy_A.png'); await expect(page.getByTestId('assets-dock-item-image-enemy-a')).toBeVisible(); // Ensure the image exists in state before dragging (some engines render the list row before state settles). await expect.poll(async () => { @@ -156,7 +156,7 @@ test.describe('Assets dock', () => { // Fit view so the sprite is guaranteed to be visible/hit-testable in all engines. await page.getByTestId('fit-view-button').click(); - await page.getByTestId('assets-dock-device-file-input').setInputFiles('res/images/meteor_large.png'); + await page.getByTestId('assets-dock-device-file-input').setInputFiles('assets/demo-pack/images/meteor_large.png'); await expect(page.getByTestId('assets-dock-item-image-meteor-large')).toBeVisible(); // Wait for the imported image asset to be present in state (WebKit can render the list item before metadata/state lands). await expect.poll(async () => { diff --git a/tests/e2e/formation-create.spec.ts b/tests/e2e/formation-create.spec.ts index 391f974..596ec00 100644 --- a/tests/e2e/formation-create.spec.ts +++ b/tests/e2e/formation-create.spec.ts @@ -57,7 +57,7 @@ test('assets menu entrypoint opens a formation draft seeded with that asset @cri await dismissViewHint(page); await openSceneScope(page); - await page.getByTestId('assets-dock-device-file-input').setInputFiles('res/images/enemy_A.png'); + await page.getByTestId('assets-dock-device-file-input').setInputFiles('assets/demo-pack/images/enemy_A.png'); await expect(page.getByTestId('assets-dock-item-image-enemy-a')).toBeVisible(); await page.getByTestId('assets-dock-menu-image-enemy-a').click(); diff --git a/tests/e2e/inspector.spec.ts b/tests/e2e/inspector.spec.ts index da79fcb..5c085f0 100644 --- a/tests/e2e/inspector.spec.ts +++ b/tests/e2e/inspector.spec.ts @@ -303,7 +303,7 @@ test('creates a formation from imported sprites and arranges it into a grid @cri test('assigns a MoveUntil action to an imported sprite @critical', async ({ page }) => { await resetScene(page); - const { assetId } = await importImageAssetFromFile(page, 'res/images/enemy_A.png'); + const { assetId } = await importImageAssetFromFile(page, 'assets/demo-pack/images/enemy_A.png'); await dragAssetToCanvas(page, 'image', assetId); await openSceneScope(page); await expect.poll(async () => { @@ -334,8 +334,8 @@ test('assigns a MoveUntil action to an imported sprite @critical', async ({ page test('reassigns a sprite asset from another sprite via the inspector @critical', async ({ page }) => { await resetScene(page); - const { assetId: assetA } = await importImageAssetFromFile(page, 'res/images/enemy_A.png'); - const { assetId: assetB } = await importImageAssetFromFile(page, 'res/images/enemy_B.png'); + const { assetId: assetA } = await importImageAssetFromFile(page, 'assets/demo-pack/images/enemy_A.png'); + const { assetId: assetB } = await importImageAssetFromFile(page, 'assets/demo-pack/images/enemy_B.png'); await dragAssetToCanvas(page, 'image', assetA, { targetPosition: { x: 200, y: 160 } }); await dragAssetToCanvas(page, 'image', assetB, { targetPosition: { x: 320, y: 160 } }); @@ -461,7 +461,7 @@ test('preview uses edited move velocity and bounce behavior @critical', async ({ test('preview bounce reaches configured bounds edge before reversing @critical', async ({ page }) => { await resetScene(page); - const { assetId } = await importImageAssetFromFile(page, 'res/images/enemy_A.png'); + const { assetId } = await importImageAssetFromFile(page, 'assets/demo-pack/images/enemy_A.png'); await dragAssetToCanvas(page, 'image', assetId); await openSceneScope(page); let entityId: string | null = null; @@ -525,7 +525,7 @@ test('preview bounce reaches configured bounds edge before reversing @critical', test('preview applies wrap behavior for an imported sprite move action @critical', async ({ page }) => { await resetScene(page); - const { assetId } = await importImageAssetFromFile(page, 'res/images/enemy_A.png'); + const { assetId } = await importImageAssetFromFile(page, 'assets/demo-pack/images/enemy_A.png'); await dragAssetToCanvas(page, 'image', assetId); await openSceneScope(page); let entityId: string | null = null; diff --git a/tests/editor/assets-dock-demo-pack.test.tsx b/tests/editor/assets-dock-demo-pack.test.tsx index c8f0bc2..ff443ff 100644 --- a/tests/editor/assets-dock-demo-pack.test.tsx +++ b/tests/editor/assets-dock-demo-pack.test.tsx @@ -4,16 +4,6 @@ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -vi.mock('../../src/editor/imageMetadata', () => ({ - loadImageMetadataFromFile: vi.fn(async (_file: File, dataUrl: string) => ({ - src: dataUrl, - name: 'demo', - mimeType: 'image/png', - width: 16, - height: 16, - })), -})); - import { AssetsDock } from '../../src/editor/AssetsDock'; function renderIntoDom(element: React.ReactElement) { @@ -46,30 +36,9 @@ describe('AssetsDock demo pack import', () => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = undefined; }); - it('imports supported demo pack images and audio without a missing helper error', async () => { + it('imports supported demo pack assets as one path-backed batch', async () => { const dispatch = vi.fn(); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - const url = String(input); - const blob = url.endsWith('.mp3') - ? new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' }) - : url.endsWith('.ogg') - ? new Blob([new Uint8Array([4, 5, 6])], { type: 'audio/ogg' }) - : url.endsWith('.wav') - ? new Blob([new Uint8Array([7, 8, 9])], { type: 'audio/wav' }) - : url.endsWith('.woff2') - ? new Blob([new Uint8Array([10, 11, 12])], { type: 'font/woff2' }) - : url.endsWith('.woff') - ? new Blob([new Uint8Array([13, 14, 15])], { type: 'font/woff' }) - : url.endsWith('.ttf') - ? new Blob([new Uint8Array([16, 17, 18])], { type: 'font/ttf' }) - : url.endsWith('.otf') - ? new Blob([new Uint8Array([19, 20, 21])], { type: 'font/otf' }) - : new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }); - return { - ok: true, - blob: async () => blob, - } as Response; - }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); const view = renderIntoDom( { await new Promise((resolve) => setTimeout(resolve, 0)); }); - expect(fetchSpy).toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'ensure-image-asset-from-file', - file: expect.objectContaining({ - dataUrl: expect.stringContaining('data:image/png;base64,'), - mimeType: 'image/png', - width: 16, - height: 16, - }), - }), - ); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'ensure-audio-asset-from-file', - file: expect.objectContaining({ - dataUrl: expect.stringMatching(/^data:audio\/(mpeg|ogg|wav);base64,/), - }), + type: 'import-demo-pack-assets', + entries: expect.arrayContaining([ + expect.objectContaining({ + kind: 'image', + path: 'assets/demo-pack/images/enemy_A.png', + mimeType: 'image/png', + width: 64, + height: 64, + }), + expect.objectContaining({ + kind: 'audio', + path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + mimeType: 'audio/mpeg', + }), + ]), }), ); - expect(document.body.textContent).not.toContain('readUrlAsDataUrl is not defined'); expect(document.body.textContent).not.toContain('Failed to import demo pack'); } finally { fetchSpy.mockRestore(); diff --git a/tests/editor/assets-store.test.ts b/tests/editor/assets-store.test.ts index ab28e0b..a918938 100644 --- a/tests/editor/assets-store.test.ts +++ b/tests/editor/assets-store.test.ts @@ -159,6 +159,63 @@ describe('EditorStore assets actions', () => { }); }); + it('imports demo pack assets as path-backed library entries and is idempotent', () => { + const state = initState(); + const action = { + type: 'import-demo-pack-assets', + entries: [ + { + kind: 'image', + assetId: 'enemy-a', + path: 'assets/demo-pack/images/enemy_A.png', + originalName: 'enemy_A.png', + mimeType: 'image/png', + width: 64, + height: 64, + }, + { + kind: 'audio', + assetId: 'theme', + path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + originalName: 'Simulacra-chosic.com_.mp3', + mimeType: 'audio/mpeg', + }, + { + kind: 'font', + assetId: 'arcade', + path: 'assets/demo-pack/fonts/Arcade.woff2', + originalName: 'Arcade.woff2', + mimeType: 'font/woff2', + }, + ], + } as any; + + const imported = reducer(state, action); + + expect(imported.project.assets.images['enemy-a']).toMatchObject({ + id: 'enemy-a', + width: 64, + height: 64, + source: { + kind: 'path', + path: 'assets/demo-pack/images/enemy_A.png', + originalName: 'enemy_A.png', + mimeType: 'image/png', + }, + }); + expect(imported.project.audio.sounds.theme.source).toMatchObject({ + kind: 'path', + path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + }); + expect(imported.project.assets.fonts.arcade.source).toMatchObject({ + kind: 'path', + path: 'assets/demo-pack/fonts/Arcade.woff2', + }); + + const reimported = reducer(imported, action); + expect(reimported).toBe(imported); + }); + it('reassigns an entity sprite asset without creating a new entity', () => { const state = initState(); const withPlayer = reducer(state, { diff --git a/tests/editor/demo-pack-assets.test.ts b/tests/editor/demo-pack-assets.test.ts index 810b717..e533ecd 100644 --- a/tests/editor/demo-pack-assets.test.ts +++ b/tests/editor/demo-pack-assets.test.ts @@ -1,27 +1,31 @@ import { describe, expect, it } from 'vitest'; -import { assetIdBaseFromOriginalName, getDemoPackAssetKind } from '../../src/editor/demoPackAssets'; +import { + DEMO_PACK_ASSET_MANIFEST, + assetIdBaseFromOriginalName, + getDemoPackAssetKind, +} from '../../src/editor/demoPackAssets'; describe('demo pack asset helpers', () => { it('classifies the well-supported demo pack asset types by path', () => { - expect(getDemoPackAssetKind('../../res/images/ship.png')).toBe('image'); - expect(getDemoPackAssetKind('../../res/images/background.jpg')).toBe('image'); - expect(getDemoPackAssetKind('../../res/images/background.jpeg')).toBe('image'); - expect(getDemoPackAssetKind('../../res/images/background.webp')).toBe('image'); - expect(getDemoPackAssetKind('../../res/audio/theme.mp3')).toBe('audio'); - expect(getDemoPackAssetKind('../../res/audio/theme.ogg')).toBe('audio'); - expect(getDemoPackAssetKind('../../res/audio/hit.wav')).toBe('audio'); - expect(getDemoPackAssetKind('../../res/fonts/arcade.woff2')).toBe('font'); - expect(getDemoPackAssetKind('../../res/fonts/arcade.woff')).toBe('font'); - expect(getDemoPackAssetKind('../../res/fonts/arcade.ttf')).toBe('font'); - expect(getDemoPackAssetKind('../../res/fonts/arcade.otf')).toBe('font'); + expect(getDemoPackAssetKind('assets/demo-pack/images/ship.png')).toBe('image'); + expect(getDemoPackAssetKind('assets/demo-pack/images/background.jpg')).toBe('image'); + expect(getDemoPackAssetKind('assets/demo-pack/images/background.jpeg')).toBe('image'); + expect(getDemoPackAssetKind('assets/demo-pack/images/background.webp')).toBe('image'); + expect(getDemoPackAssetKind('assets/demo-pack/audio/theme.mp3')).toBe('audio'); + expect(getDemoPackAssetKind('assets/demo-pack/audio/theme.ogg')).toBe('audio'); + expect(getDemoPackAssetKind('assets/demo-pack/audio/hit.wav')).toBe('audio'); + expect(getDemoPackAssetKind('assets/demo-pack/fonts/arcade.woff2')).toBe('font'); + expect(getDemoPackAssetKind('assets/demo-pack/fonts/arcade.woff')).toBe('font'); + expect(getDemoPackAssetKind('assets/demo-pack/fonts/arcade.ttf')).toBe('font'); + expect(getDemoPackAssetKind('assets/demo-pack/fonts/arcade.otf')).toBe('font'); }); it('ignores unsupported or misplaced files', () => { - expect(getDemoPackAssetKind('../../res/images/readme.txt')).toBeNull(); - expect(getDemoPackAssetKind('../../res/audio/theme.flac')).toBeNull(); - expect(getDemoPackAssetKind('../../res/fonts/arcade.eot')).toBeNull(); - expect(getDemoPackAssetKind('../../res/misc/ship.png')).toBeNull(); + expect(getDemoPackAssetKind('assets/demo-pack/images/readme.txt')).toBeNull(); + expect(getDemoPackAssetKind('assets/demo-pack/audio/theme.flac')).toBeNull(); + expect(getDemoPackAssetKind('assets/demo-pack/fonts/arcade.eot')).toBeNull(); + expect(getDemoPackAssetKind('assets/demo-pack/misc/ship.png')).toBeNull(); }); it('builds stable asset ids from filenames', () => { @@ -29,4 +33,20 @@ describe('demo pack asset helpers', () => { expect(assetIdBaseFromOriginalName('Arcade Classic.woff2', 'font')).toBe('arcade-classic'); expect(assetIdBaseFromOriginalName('', 'asset')).toBe('asset'); }); + + it('keeps a stable project-relative manifest with image dimensions', () => { + expect(DEMO_PACK_ASSET_MANIFEST.length).toBeGreaterThan(0); + expect(DEMO_PACK_ASSET_MANIFEST.every((entry) => entry.path.startsWith('assets/demo-pack/'))).toBe(true); + + const enemy = DEMO_PACK_ASSET_MANIFEST.find((entry) => entry.originalName === 'enemy_A.png'); + expect(enemy).toEqual( + expect.objectContaining({ + kind: 'image', + path: 'assets/demo-pack/images/enemy_A.png', + mimeType: 'image/png', + width: 64, + height: 64, + }), + ); + }); }); diff --git a/tests/editor/editor-store-history.test.ts b/tests/editor/editor-store-history.test.ts index 625900a..9f79aac 100644 --- a/tests/editor/editor-store-history.test.ts +++ b/tests/editor/editor-store-history.test.ts @@ -158,4 +158,38 @@ describe('EditorStore history', () => { expect(sceneOf(undone).entities.e2.x).toBe(x2); expect(sceneOf(undone).entities.e2.y).toBe(y2); }); + + it('records demo pack import as a single undo step', () => { + const state0 = seededState(); + + const imported = reducer(state0, { + type: 'import-demo-pack-assets', + entries: [ + { + kind: 'image', + assetId: 'enemy-a', + path: 'assets/demo-pack/images/enemy_A.png', + originalName: 'enemy_A.png', + mimeType: 'image/png', + width: 64, + height: 64, + }, + { + kind: 'audio', + assetId: 'theme', + path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + originalName: 'Simulacra-chosic.com_.mp3', + mimeType: 'audio/mpeg', + }, + ], + } as any); + + expect(imported.history.past).toHaveLength(1); + expect(imported.project.assets.images['enemy-a']).toBeDefined(); + expect(imported.project.audio.sounds.theme).toBeDefined(); + + const undone = reducer(imported, { type: 'history-undo' } as any); + expect(undone.project.assets.images['enemy-a']).toBeUndefined(); + expect(undone.project.audio.sounds.theme).toBeUndefined(); + }); }); diff --git a/tests/editor/image-metadata.test.ts b/tests/editor/image-metadata.test.ts index 662e75d..419f817 100644 --- a/tests/editor/image-metadata.test.ts +++ b/tests/editor/image-metadata.test.ts @@ -4,12 +4,12 @@ import { readFile } from 'node:fs/promises'; describe('imageMetadata', () => { test('parses PNG width/height from bytes', async () => { - const bytes = new Uint8Array(await readFile('res/images/enemy_A.png')); + const bytes = new Uint8Array(await readFile('assets/demo-pack/images/enemy_A.png')); expect(parseImageDimensions(bytes)).toEqual({ width: 64, height: 64 }); }); test('reads PNG width/height from File', async () => { - const bytes = new Uint8Array(await readFile('res/images/enemy_A.png')); + const bytes = new Uint8Array(await readFile('assets/demo-pack/images/enemy_A.png')); const file = new File([bytes], 'enemy_A.png', { type: 'image/png' }); await expect(readImageDimensionsFromFile(file)).resolves.toEqual({ width: 64, height: 64 }); }); diff --git a/tests/vitest-unit-config.test.ts b/tests/vitest-unit-config.test.ts new file mode 100644 index 0000000..3ef229c --- /dev/null +++ b/tests/vitest-unit-config.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import unitConfig from '../vitest.unit.config.mts'; +import unitJsdomConfig from '../vitest.unit.jsdom.config.mts'; +import unitNodeConfig from '../vitest.unit.node.config.mts'; +import { findAllTestFiles, findJsdomTaggedTests, jsdomTaggedTests, nonJsdomTaggedTests, storybookTestExclude } from '../vitest.unit.shared.mts'; + +describe('vitest.unit config', () => { + it('excludes storybook tests from the unit suite', () => { + expect(storybookTestExclude).toEqual(['tests/storybook/**/*.test.ts', 'tests/storybook/**/*.test.tsx']); + expect(unitConfig.test?.exclude).toEqual(expect.arrayContaining(storybookTestExclude)); + }); + + it('detects jsdom-tagged tests from source pragmas', () => { + expect(jsdomTaggedTests).toEqual(findJsdomTaggedTests()); + expect(nonJsdomTaggedTests).toEqual(findAllTestFiles().filter((filePath) => !jsdomTaggedTests.includes(filePath))); + expect(jsdomTaggedTests).toContain('tests/editor/cloud-account-publish-gating.test.tsx'); + expect(jsdomTaggedTests).toContain('tests/storybook/editor-stories.test.tsx'); + expect(jsdomTaggedTests).not.toContain('tests/server/auth.test.ts'); + }); + + it('excludes jsdom-tagged tests from the node unit suite', () => { + expect(unitNodeConfig.test?.exclude).toEqual(expect.arrayContaining(jsdomTaggedTests)); + expect(unitNodeConfig.test?.exclude).toEqual(expect.arrayContaining(storybookTestExclude)); + }); + + it('includes only jsdom-tagged tests in the jsdom unit suite', () => { + expect(unitJsdomConfig.test?.exclude).toEqual(expect.arrayContaining(nonJsdomTaggedTests)); + expect(unitJsdomConfig.test?.exclude).toEqual(expect.arrayContaining(storybookTestExclude)); + }); +}); diff --git a/vitest.unit.config.mts b/vitest.unit.config.mts new file mode 100644 index 0000000..72e9af9 --- /dev/null +++ b/vitest.unit.config.mts @@ -0,0 +1,13 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import baseConfig from './vitest.config.mts'; +import { storybookTestExclude } from './vitest.unit.shared.mts'; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + exclude: storybookTestExclude, + }, + }), +); diff --git a/vitest.unit.jsdom.config.mts b/vitest.unit.jsdom.config.mts new file mode 100644 index 0000000..e88262c --- /dev/null +++ b/vitest.unit.jsdom.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +import baseConfig from './vitest.config.mts'; +import { nonJsdomTaggedTests, storybookTestExclude } from './vitest.unit.shared.mts'; + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + exclude: [...storybookTestExclude, ...nonJsdomTaggedTests], + }, +}); diff --git a/vitest.unit.node.config.mts b/vitest.unit.node.config.mts new file mode 100644 index 0000000..80c0cd0 --- /dev/null +++ b/vitest.unit.node.config.mts @@ -0,0 +1,13 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import baseConfig from './vitest.config.mts'; +import { jsdomTaggedTests, storybookTestExclude } from './vitest.unit.shared.mts'; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + exclude: [...storybookTestExclude, ...jsdomTaggedTests], + }, + }), +); diff --git a/vitest.unit.shared.mts b/vitest.unit.shared.mts new file mode 100644 index 0000000..4b62024 --- /dev/null +++ b/vitest.unit.shared.mts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const storybookTestExclude = ['tests/storybook/**/*.test.ts', 'tests/storybook/**/*.test.tsx']; + +const JSDOM_PRAGMA = '@vitest-environment jsdom'; +const TEST_FILE_PATTERN = /\.test\.tsx?$/; +const ROOT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const TESTS_DIR = path.join(ROOT_DIR, 'tests'); + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, files); + continue; + } + if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) { + files.push(fullPath); + } + } + return files; +} + +export function findJsdomTaggedTests(testsDir = TESTS_DIR): string[] { + return walk(testsDir) + .filter((filePath) => fs.readFileSync(filePath, 'utf8').includes(JSDOM_PRAGMA)) + .map((filePath) => path.relative(ROOT_DIR, filePath).replaceAll(path.sep, '/')) + .sort(); +} + +export function findAllTestFiles(testsDir = TESTS_DIR): string[] { + return walk(testsDir) + .map((filePath) => path.relative(ROOT_DIR, filePath).replaceAll(path.sep, '/')) + .sort(); +} + +export const allTestFiles = findAllTestFiles(); +export const jsdomTaggedTests = findJsdomTaggedTests(); +export const nonJsdomTaggedTests = allTestFiles.filter((filePath) => !jsdomTaggedTests.includes(filePath));