import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand, SetMachineScopeCommand, } from '../../src/core/commands/assetCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { pluginsOf } from '../../src/core/store/projectPlugins'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; describe('serialize', () => { it('round-trips a project', () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 7, y: 8 }); f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'width', 33)); expect(roundTrip(f.project)).toEqual(f.project); }); /** * Which plugins the project switches on (#119). The editor reads them off the raw file before it * deserializes anything, so `pluginsOf` is what both sides use and both have to agree. */ it('reads the plugins of a project the same way before and after deserializing', () => { const raw = { id: 'p', name: 'P', plugins: ['b.js', 'a.js', 'a.js', '', 7] }; expect(pluginsOf(raw)).toEqual(['a.js', 'b.js']); expect(deserializeProject(raw).plugins).toEqual(['a.js', 'b.js']); // A project written before there was such a field, and anything that is not a list of names. expect(pluginsOf({ id: 'p' })).toEqual([]); expect(pluginsOf({ plugins: 'demo.js' })).toEqual([]); expect(pluginsOf(null)).toEqual([]); expect(deserializeProject({ id: 'p' }).plugins).toEqual([]); }); /** A behavior's own rows are not the common ones: coercing against those alone dropped the value. */ it('keeps a behavior running in the editor across a save', () => { const f = makeSpriteScene(); const behavior = makeBehavior(f, 'B'); f.stack.execute(new SetAssetPropertyCommand(behavior.id, 'runInEditor', true)); const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id)!; expect(loaded.props.runInEditor).toBe(true); }); /** The declarations of a Variables asset and their starting values are the whole asset. */ it('round-trips a Variables asset and the scope of a state machine', () => { const f = makeSpriteScene(); const shared = new CreateAssetCommand('variables', 'Score'); f.stack.execute(shared); f.stack.execute(new AddVariableCommand(shared.asset.id, 'score', 'number')); f.stack.execute(new SetAssetPropertyCommand(shared.asset.id, 'score', 7)); const machine = new CreateAssetCommand('statemachine', 'M'); f.stack.execute(machine); f.stack.execute(new SetMachineScopeCommand(machine.asset.id, 'global')); expect(roundTrip(f.project)).toEqual(f.project); }); /** `local` is the absence of the field, so a project made before scopes existed saves back byte-alike. */ it('writes no scope for a local machine and reads an unknown one as local', () => { const f = makeSpriteScene(); makeBehavior(f, 'B', ['score']); f.stack.execute(new CreateAssetCommand('statemachine', 'M')); expect(JSON.stringify(serializeProject(f.project))).not.toContain('scope'); const loaded = deserializeProject({ assets: [{ id: 'm1', type: 'statemachine', name: 'M', scope: 'planet' }], }); const machine = loaded.assets[0]!; if (machine.type !== 'statemachine') throw new Error('wrong types'); expect(machine.scope).toBeUndefined(); }); /** #41: the borders are part of the frame, and a frame nobody sliced still saves without them. */ it('round-trips the nine-slice of a frame and repairs an impossible one', () => { const file = `${'ab'.repeat(32)}.png`; const loaded = deserializeProject({ assets: [ { id: 't1', type: 'texture', name: 'Sheet', file, width: 64, height: 64, frames: [ { id: 'f1', name: 'Button', x: 0, y: 0, w: 24, h: 24, border: { left: 8, right: 8 }, fill: 'stretch', }, { id: 'f2', name: 'Plain', x: 24, y: 0, w: 24, h: 24 }, { id: 'f3', name: 'Greedy', x: 0, y: 24, w: 8, h: 8, border: { top: 99, bottom: 99 }, fill: 'wat', }, ], }, ], }); const texture = loaded.assets[0]!; if (texture.type !== 'texture') throw new Error('wrong types'); const [sliced, plain, greedy] = texture.frames; expect(sliced?.border).toEqual({ left: 8, top: 0, right: 8, bottom: 0 }); // Only the fill that is not the default is written; an unknown one reads as the default. expect(sliced?.fill).toBe('stretch'); expect(plain?.border).toBeUndefined(); expect(plain?.fill).toBeUndefined(); // Measured against the frame, not the image, and never leaving it without a middle. expect(greedy?.border).toEqual({ left: 0, top: 4, right: 0, bottom: 3 }); expect(greedy?.fill).toBeUndefined(); expect(roundTrip(loaded)).toEqual(loaded); }); it('repairs broken documents', () => { const project = deserializeProject( { name: 'Broken', activeSceneId: 'missing', assets: [ { id: 's1', type: 'scene', name: 'Scene', props: { backgroundColor: '#000000', junk: 1 }, objects: [ { id: 'o1', assetId: 'sp1', position: { x: '5', y: null }, overrides: { color: '#00ff00', width: -3, foo: 1 }, }, { id: 'o2', assetId: 'nope', position: {} }, { id: 'o3', assetId: 's1', position: {} }, ], }, { id: 'sp1', type: 'sprite', props: { width: 10 } }, // An asset type this build does not know (a newer document, a renamed type). It used to // be `sound` (known since #45), then `video` (known since #402) – hence a made-up type. { id: 'x', type: 'hologram' }, { type: 'camera' }, ], }, 'fallback-id', ); expect(project.id).toBe('fallback-id'); expect(project.activeSceneId).toBe('s1'); expect(project.assets.map((a) => a.id)).toEqual(['s1', 'sp1']); const scene = project.assets[0]!; expect(scene.type === 'scene' && scene.props).toEqual({ backgroundColor: 0, gravityX: 0, gravityY: 980 }); const objects = scene.type === 'scene' ? scene.objects : []; expect(objects).toHaveLength(1); expect(objects[0]).toEqual({ id: 'o1', assetId: 'sp1', name: '', position: { x: 5, y: 0, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: { color: 0x00ff00, width: 1 }, parentId: '', instanceEdits: {}, }); expect(project.assets[1]!.name).toBe('sprite'); expect(project.assets[1]!.props).toEqual({ enabled: true, keepOnSceneLoad: false, scale: 1, rotation: 0, mirrorX: false, mirrorY: false, renderLayer: -1, width: 10, height: 100, color: 0xffffff, texture: '', videoClock: 'free', videoTime: 0, shader: '', }); }); });