import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; import { AddFrameCommand } from '../../src/core/commands/frameCommands'; import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands'; import { findAsset } from '../../src/core/model/Project'; import type { FlatDef } from '../../src/core/properties/PropertySchema'; import { pictureSubjectOf, previewPicture, sliderRange, startValues, TEST_IMAGE_SIZE, testImagePixels, } from '../../src/editor/shader/previewPicture'; import type { Selection } from '../../src/editor/state'; import { makeSpriteScene, place } from '../helpers'; const FILE = `${'cd'.repeat(32)}.png`; /** A scene with a sprite "Box", a 64×32 texture "Sheet" with a 16×8 frame "Idle", and one placed Box. */ function setup() { const fixture = makeSpriteScene(); const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 }); fixture.stack.execute(texture); const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 8, w: 16, h: 8 }, 'Idle'); fixture.stack.execute(frame); const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 }); const stored = () => fixture.sceneOf().objects.find((o) => o.id === objectId)!; return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId, objectId, stored }; } function numberRow(key: string, extra: Partial> = {}): FlatDef<'number'> { return { key, label: key, kind: 'number', default: 1, min: 0, max: 2, ...extra }; } describe('the picture the Shader tab previews (#168)', () => { it('is named by a selected sprite, placed or not, a texture or a frame', () => { const f = setup(); const object: Selection = { kind: 'object', holderId: f.sceneId, objectId: f.objectId, objectIds: [f.objectId], }; expect(pictureSubjectOf(f.project, object)).toEqual({ kind: 'object', holderId: f.sceneId, objectId: f.objectId, }); expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.spriteId })).toEqual({ kind: 'asset', assetId: f.spriteId, }); expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.textureId })).not.toBeNull(); expect(pictureSubjectOf(f.project, { kind: 'frame', assetId: f.textureId, frameId: f.frameId })).toEqual({ kind: 'frame', frameId: f.frameId, }); }); it('is not named by anything without a picture, so the preview keeps the one before', () => { const f = setup(); const behavior = new CreateAssetCommand('behavior', 'Mover'); f.stack.execute(behavior); expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: behavior.asset.id })).toBeNull(); expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.sceneId })).toBeNull(); expect(pictureSubjectOf(f.project, null)).toBeNull(); const node: Selection = { kind: 'node', assetId: behavior.asset.id, nodeId: 'n', nodeIds: ['n'], annotationIds: [], }; expect(pictureSubjectOf(f.project, node)).toBeNull(); }); it('shows a frame at its own size, a whole texture at the sheet size', () => { const f = setup(); const frame = previewPicture(f.project, { kind: 'frame', frameId: f.frameId })!; expect(frame).toMatchObject({ name: 'Sheet / Idle', w: 16, h: 8, tint: 0xffffff, props: {} }); expect(frame.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 }); const sheet = previewPicture(f.project, { kind: 'asset', assetId: f.textureId })!; expect(sheet).toMatchObject({ name: 'Sheet', w: 64, h: 32 }); expect(sheet.texture?.rect).toBeNull(); }); it('shows a placed sprite as the scene does: its overrides, size and colour', () => { const f = setup(); Object.assign(f.stored().overrides, { texture: f.frameId, width: 48, height: 24, color: 0xff8040 }); const picture = previewPicture(f.project, { kind: 'object', holderId: f.sceneId, objectId: f.objectId })!; expect(picture).toMatchObject({ name: 'Box', w: 48, h: 24, tint: 0xff8040 }); expect(picture.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 }); // The asset itself is untouched by the object's overrides. const asset = previewPicture(f.project, { kind: 'asset', assetId: f.spriteId })!; expect(asset).toMatchObject({ name: 'Box', w: 100, h: 100, texture: null }); }); it('follows an edit, and is gone with its object – the tab then shows the test image', () => { const f = setup(); const subject = { kind: 'object', holderId: f.sceneId, objectId: f.objectId } as const; f.stored().overrides.width = 20; expect(previewPicture(f.project, subject)?.w).toBe(20); f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.objectId])); expect(previewPicture(f.project, subject)).toBeNull(); f.stack.undo(); expect(previewPicture(f.project, subject)?.w).toBe(20); expect(findAsset(f.project, f.spriteId)).toBeDefined(); }); }); describe('the preview sliders (#168)', () => { const defs = [numberRow('glow'), numberRow('speed', { default: 0.5 })]; it('start at the values of a sprite wearing this shader', () => { expect(startValues(defs, { shader: 'neon', glow: 1.75, speed: 0.25 }, 'neon')).toEqual({ glow: 1.75, speed: 0.25, }); // A row the sprite does not carry yet starts at its default. expect(startValues(defs, { shader: 'neon', glow: 0.2 }, 'neon')).toEqual({ glow: 0.2, speed: 0.5 }); }); it('start at the defaults when the picture wears another shader or none', () => { expect(startValues(defs, { shader: 'other', glow: 1.75 }, 'neon')).toEqual({ glow: 1, speed: 0.5 }); expect(startValues(defs, {}, 'neon')).toEqual({ glow: 1, speed: 0.5 }); }); it('reach the declared range, or twice the default without a max', () => { expect(sliderRange(numberRow('a', { min: -1, max: 3 }))).toEqual({ min: -1, max: 3 }); expect(sliderRange(numberRow('b', { max: undefined, default: 1.5 }))).toEqual({ min: 0, max: 3 }); expect(sliderRange(numberRow('c', { min: 2, max: undefined, default: 2 }))).toEqual({ min: 2, max: 3 }); }); }); describe('the built-in test image (#168)', () => { const pixels = testImagePixels(); const at = (x: number, y: number) => Array.from(pixels.slice((y * TEST_IMAGE_SIZE + x) * 4, (y * TEST_IMAGE_SIZE + x) * 4 + 4)); it('runs red from left to right and green from top to bottom', () => { expect(pixels.length).toBe(TEST_IMAGE_SIZE * TEST_IMAGE_SIZE * 4); const [r1, g1, b1] = at(16, 16); const [r2, g2, b2] = at(48, 48); expect(r2).toBeGreaterThan(r1!); expect(g2).toBeGreaterThan(g1!); expect(b2).toBeLessThan(b1!); }); it('has a checker, so a moved UV shows', () => { // Two neighbours across a cell border: same place in the gradient, a different shade. expect(at(24, 20)[0]! / at(23, 20)[0]!).toBeCloseTo(0.55, 1); }); it('fades out at the edge and is opaque inside', () => { expect(at(0, 32)[3]).toBe(16); expect(at(32, TEST_IMAGE_SIZE - 1)[3]).toBe(16); expect(at(4, 32)[3]).toBe(143); expect(at(8, 32)[3]).toBe(255); expect(at(32, 32)[3]).toBe(255); }); });