import { describe, expect, it, vi } from 'vitest'; import type { TextureAsset } from '../../src/core/model/Asset'; import { createEmptyProject } from '../../src/core/model/Project'; import type { TextureStore } from '../../src/core/store/TextureStore'; import { EditorState } from '../../src/editor/state'; import { encodeWithinBudget, type ImportOutcome, importNotice, importStoredImage, MAX_TEXTURE_SIDE, MAX_UPLOAD_BYTES, planImageFit, } from '../../src/editor/textureImport'; /** A blob of exactly `size` bytes – `encodeWithinBudget` only ever looks at the size. */ function blobOf(size: number): Blob { return new Blob([new Uint8Array(size)]); } function outcome(name: string, error: string | null = null): ImportOutcome { return { name, assetId: error ? null : `id-${name}`, error, reused: false }; } /** A file whose picture the project already had, so no asset was created for it. */ function reused(name: string): ImportOutcome { return { name, assetId: `id-${name}`, error: null, reused: true }; } describe('planImageFit', () => { it('leaves an image alone that fits in both senses', () => { expect(planImageFit(32, 32, 900)).toEqual({ kind: 'keep' }); expect(planImageFit(MAX_TEXTURE_SIDE, 1024, MAX_UPLOAD_BYTES)).toEqual({ kind: 'keep' }); }); it('caps the longest side and keeps the aspect ratio', () => { expect(planImageFit(4000, 3000, 3_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1536 }); expect(planImageFit(1000, 4096, 100)).toEqual({ kind: 'reencode', width: 500, height: 2048 }); }); it('re-encodes at the original size when only the byte count is over budget', () => { expect(planImageFit(800, 600, MAX_UPLOAD_BYTES + 1)).toEqual({ kind: 'reencode', width: 800, height: 600, }); }); it('never scales a picture away to nothing', () => { expect(planImageFit(20000, 3, 9_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1 }); }); }); describe('encodeWithinBudget', () => { it('stops at the first quality that fits', async () => { const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES - 1))); const blob = await encodeWithinBudget(encode); expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1); expect(encode).toHaveBeenCalledTimes(1); }); it('steps the quality down until the result fits', async () => { const sizes = [MAX_UPLOAD_BYTES + 1000, MAX_UPLOAD_BYTES - 1000]; const encode = vi.fn((_quality: number) => Promise.resolve(blobOf(sizes.shift() ?? 0))); const blob = await encodeWithinBudget(encode); expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1000); expect(encode).toHaveBeenCalledTimes(2); // Decreasing quality, so a later attempt is never larger than an earlier one. const qualities = encode.mock.calls.map(([quality]) => quality); expect(qualities).toEqual([...qualities].sort((a, b) => b - a)); }); it('returns the last attempt when nothing fits, so the caller can say how far off it was', async () => { const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES + 5))); const blob = await encodeWithinBudget(encode); expect(blob.size).toBe(MAX_UPLOAD_BYTES + 5); expect(encode.mock.calls.length).toBeGreaterThan(1); }); }); describe('importNotice', () => { it('counts what arrived', () => { expect(importNotice([outcome('a.png')])).toBe('Imported 1 image.'); expect(importNotice([outcome('a.png'), outcome('b.png')])).toBe('Imported 2 images.'); }); it('names the first casualty and how many followed', () => { expect(importNotice([outcome('a.png', 'too large')])).toBe('Could not import "a.png": too large'); expect(importNotice([outcome('a.png', 'too large'), outcome('b.png', 'broken')])).toBe( 'Could not import "a.png": too large (and 1 more)', ); }); it('reports a partial import as both', () => { expect(importNotice([outcome('a.png'), outcome('b.png', 'too large')])).toBe( 'Imported 1 of 2. Could not import "b.png": too large', ); }); it('says so when the picture was already in the project', () => { // "Imported 1 image" would send the user looking for an asset that was never created. expect(importNotice([reused('a.png')])).toBe('That image is already in the project.'); expect(importNotice([reused('a.png'), reused('b.png')])).toBe( 'Those 2 images are already in the project.', ); expect(importNotice([outcome('a.png'), reused('b.png')])).toBe( 'Imported 1 image. One was already in the project.', ); expect(importNotice([outcome('a.png'), reused('b.png'), outcome('c.png', 'broken')])).toBe( 'Imported 2 of 3. One was already in the project. Could not import "c.png": broken', ); }); }); describe('importStoredImage', () => { const FILE = `${'a'.repeat(64)}.png`; const image = { file: FILE, width: 64, height: 48, bytes: 900 }; /** The picker takes the image from the list; nothing here ever uploads. */ const textures = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as TextureStore; function setup(): EditorState { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); return state; } it('makes an asset out of a picture the server already holds', () => { const state = setup(); const outcome = importStoredImage(image, { state, textures }); const asset = state.project.assets.find((a): a is TextureAsset => a.type === 'texture'); expect(asset).toMatchObject({ file: FILE, width: 64, height: 48, name: 'Texture' }); expect(outcome).toMatchObject({ assetId: asset?.id, error: null, reused: false }); expect(state.selection).toEqual({ kind: 'asset', assetId: asset?.id }); expect(state.notice).toBe('Added the image to the project.'); // Adopting is a command like any other, so it can be taken back. state.undo(); expect(state.project.assets.filter((a) => a.type === 'texture')).toEqual([]); }); it('reuses the asset that already names the file instead of making a second one', () => { const state = setup(); const first = importStoredImage(image, { state, textures }); const again = importStoredImage(image, { state, textures }); expect(again.assetId).toBe(first.assetId); expect(again.reused).toBe(true); // Two assets on one file would disagree about `filter`, which is a property of the picture. expect(state.project.assets.filter((a) => a.type === 'texture')).toHaveLength(1); expect(state.notice).toBe('That image is already in the project.'); }); });