302 lines
9.9 KiB
JavaScript
302 lines
9.9 KiB
JavaScript
const crypto = require('crypto');
|
|
const {test} = require('node:test');
|
|
const assert = require('node:assert');
|
|
const JSZip = require('@turbowarp/jszip');
|
|
const VirtualMachine = require('../../src/virtual-machine');
|
|
const FakeRenderer = require('../fixtures/fake-renderer');
|
|
const makeTestStorage = require('../fixtures/make-test-storage');
|
|
|
|
const md5 = buffer => crypto.createHash('md5')
|
|
.update(buffer)
|
|
.digest('hex');
|
|
|
|
const SVG = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>';
|
|
const SVG_MD5 = md5(SVG);
|
|
const WAV = 'RIFF fake wav data';
|
|
const WAV_MD5 = md5(WAV);
|
|
|
|
const makeCostume = name => ({
|
|
name,
|
|
assetId: SVG_MD5,
|
|
md5ext: `${SVG_MD5}.svg`,
|
|
dataFormat: 'svg',
|
|
bitmapResolution: 1,
|
|
rotationCenterX: 5,
|
|
rotationCenterY: 5
|
|
});
|
|
|
|
const makeSound = name => ({
|
|
name,
|
|
assetId: WAV_MD5,
|
|
md5ext: `${WAV_MD5}.wav`,
|
|
dataFormat: 'wav',
|
|
format: '',
|
|
rate: 44100,
|
|
sampleCount: 100
|
|
});
|
|
|
|
const makeSprite = (name, extra) => Object.assign({
|
|
isStage: false,
|
|
name,
|
|
variables: {},
|
|
lists: {},
|
|
broadcasts: {},
|
|
blocks: {},
|
|
comments: {},
|
|
currentCostume: 0,
|
|
costumes: [],
|
|
sounds: [],
|
|
volume: 100,
|
|
visible: true,
|
|
x: 0,
|
|
y: 0,
|
|
size: 100,
|
|
direction: 90,
|
|
draggable: false,
|
|
rotationStyle: 'all around'
|
|
}, extra);
|
|
|
|
const makeProjectZip = () => {
|
|
const projectJson = {
|
|
targets: [
|
|
makeSprite('Stage', {
|
|
isStage: true,
|
|
costumes: [makeCostume('backdrop1')],
|
|
layerOrder: 0
|
|
}),
|
|
makeSprite('Sprite1', {
|
|
costumes: [makeCostume('costume1'), makeCostume('costume2')],
|
|
sounds: [makeSound('sound1'), makeSound('sound2')],
|
|
layerOrder: 1
|
|
})
|
|
],
|
|
monitors: [],
|
|
extensions: [],
|
|
meta: {
|
|
semver: '3.0.0',
|
|
vm: '0.2.0',
|
|
agent: ''
|
|
}
|
|
};
|
|
const zip = new JSZip();
|
|
zip.file('project.json', JSON.stringify(projectJson));
|
|
zip.file(`${SVG_MD5}.svg`, SVG);
|
|
zip.file(`${WAV_MD5}.wav`, WAV);
|
|
return zip.generateAsync({type: 'arraybuffer'});
|
|
};
|
|
|
|
class FakeSoundBank {
|
|
constructor () {
|
|
this.soundPlayers = {};
|
|
this.removedSounds = [];
|
|
this.disposed = false;
|
|
}
|
|
addSoundPlayer (soundPlayer) {
|
|
this.soundPlayers[soundPlayer.id] = soundPlayer;
|
|
}
|
|
removeSoundPlayer (soundId) {
|
|
this.removedSounds.push(soundId);
|
|
delete this.soundPlayers[soundId];
|
|
}
|
|
stopAllSounds () {}
|
|
setEffects () {}
|
|
dispose () {
|
|
this.disposed = true;
|
|
}
|
|
}
|
|
|
|
class FakeAudioEngine {
|
|
constructor () {
|
|
this.decodedSounds = 0;
|
|
}
|
|
createBank () {
|
|
return new FakeSoundBank();
|
|
}
|
|
decodeSoundPlayer () {
|
|
this.decodedSounds++;
|
|
return Promise.resolve({
|
|
id: `player${this.decodedSounds}`,
|
|
buffer: {
|
|
sampleRate: 44100,
|
|
length: 100
|
|
},
|
|
connect: () => {},
|
|
stop: () => {},
|
|
dispose: () => {}
|
|
});
|
|
}
|
|
}
|
|
|
|
const makeVM = async () => {
|
|
const vm = new VirtualMachine();
|
|
const renderer = new FakeRenderer();
|
|
renderer.destroyedSkins = [];
|
|
renderer.destroySkin = skinId => renderer.destroyedSkins.push(skinId);
|
|
renderer.destroyDrawable = () => {};
|
|
vm.attachRenderer(renderer);
|
|
vm.attachAudioEngine(new FakeAudioEngine());
|
|
vm.attachStorage(makeTestStorage());
|
|
await vm.loadProject(await makeProjectZip());
|
|
return vm;
|
|
};
|
|
|
|
test('deleting a costume destroys its skin; restoring re-creates it', async () => {
|
|
const vm = await makeVM();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
const sprite = target.sprite;
|
|
vm.editingTarget = target;
|
|
|
|
const clone = target.makeClone();
|
|
vm.runtime.addTarget(clone);
|
|
clone.setCostume(1);
|
|
|
|
const costume = sprite.costumes[1];
|
|
const skinId = costume.skinId;
|
|
assert.strictEqual(typeof skinId, 'number', 'costume has a skin');
|
|
|
|
const restore = vm.deleteCostume(1);
|
|
assert.deepEqual(sprite.costumes.map(c => c.name), ['costume1'], 'costume removed');
|
|
assert.ok(vm.runtime.renderer.destroyedSkins.includes(skinId), 'skin destroyed');
|
|
assert.strictEqual(clone.currentCostume, 0, 'clone switched costume');
|
|
|
|
await restore();
|
|
assert.deepEqual(sprite.costumes.map(c => c.name), ['costume1', 'costume2'], 'costume restored');
|
|
assert.strictEqual(typeof sprite.costumes[1].skinId, 'number', 'restored costume has a new skin');
|
|
});
|
|
|
|
test('deleting a sound disposes its player; restoring re-decodes it', async () => {
|
|
const vm = await makeVM();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
const sprite = target.sprite;
|
|
vm.editingTarget = target;
|
|
|
|
const sound = sprite.sounds[0];
|
|
const soundId = sound.soundId;
|
|
assert.strictEqual(typeof soundId, 'string', 'sound has a player');
|
|
|
|
const restore = vm.deleteSound(0);
|
|
assert.deepEqual(sprite.sounds.map(s => s.name), ['sound2'], 'sound removed');
|
|
assert.deepEqual(sprite.soundBank.removedSounds, [soundId], 'player removed from the bank');
|
|
|
|
await restore();
|
|
assert.deepEqual(sprite.sounds.map(s => s.name), ['sound2', 'sound1'], 'sound restored');
|
|
const restored = sprite.sounds[1];
|
|
assert.strictEqual(typeof restored.soundId, 'string', 'restored sound has a player');
|
|
assert.ok(sprite.soundBank.soundPlayers[restored.soundId], 'player is in the bank');
|
|
});
|
|
|
|
test('reordering sounds does not dispose their players', async () => {
|
|
const vm = await makeVM();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
const sprite = target.sprite;
|
|
|
|
target.reorderSound(0, 1);
|
|
assert.deepEqual(sprite.sounds.map(s => s.name), ['sound2', 'sound1'], 'sounds reordered');
|
|
assert.deepEqual(sprite.soundBank.removedSounds, [], 'no players disposed');
|
|
});
|
|
|
|
test('deleting a sprite frees all of its resources, including with clones', async () => {
|
|
const vm = await makeVM();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
const sprite = target.sprite;
|
|
|
|
vm.runtime.addTarget(target.makeClone());
|
|
vm.runtime.addTarget(target.makeClone());
|
|
assert.strictEqual(sprite.clones.length, 3, 'sprite has clones');
|
|
|
|
const skinIds = sprite.costumes.map(c => c.skinId);
|
|
const soundBank = sprite.soundBank;
|
|
|
|
vm.deleteSprite(target.id);
|
|
assert.ok(vm.runtime.targets.every(t2 => t2.sprite !== sprite), 'all clones are disposed');
|
|
for (const skinId of skinIds) {
|
|
assert.ok(vm.runtime.renderer.destroyedSkins.includes(skinId), `skin ${skinId} destroyed`);
|
|
}
|
|
assert.ok(soundBank.disposed, 'sound bank disposed');
|
|
});
|
|
|
|
test('loading a new project frees the resources of the previous one', async () => {
|
|
const vm = await makeVM();
|
|
const skinIds = [];
|
|
for (const target of vm.runtime.targets) {
|
|
for (const costume of target.sprite.costumes) {
|
|
skinIds.push(costume.skinId);
|
|
}
|
|
}
|
|
const soundBank = vm.runtime.getSpriteTargetByName('Sprite1').sprite.soundBank;
|
|
assert.ok(skinIds.length >= 3, 'previous project had skins');
|
|
|
|
await vm.loadProject(await makeProjectZip());
|
|
for (const skinId of skinIds) {
|
|
assert.ok(vm.runtime.renderer.destroyedSkins.includes(skinId), `skin ${skinId} destroyed`);
|
|
}
|
|
assert.ok(soundBank.disposed, 'sound bank disposed');
|
|
});
|
|
|
|
test('restoring a costume that has no md5', async () => {
|
|
const vm = await makeVM();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
const sprite = target.sprite;
|
|
vm.editingTarget = target;
|
|
|
|
await vm.addCostume(`${SVG_MD5}.svg`, {
|
|
name: 'library',
|
|
asset: sprite.costumes[0].asset,
|
|
bitmapResolution: 1,
|
|
rotationCenterX: 5,
|
|
rotationCenterY: 5
|
|
});
|
|
assert.ok(!sprite.costumes[2].md5, 'costume has no md5');
|
|
|
|
const restore = vm.deleteCostume(2);
|
|
await restore();
|
|
assert.deepEqual(sprite.costumes.map(c => c.name), ['costume1', 'costume2', 'library'], 'costume restored');
|
|
});
|
|
|
|
test('restoring after the sprite is deleted does not leak', async () => {
|
|
const vm = await makeVM();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
const sprite = target.sprite;
|
|
vm.editingTarget = target;
|
|
|
|
const deletedCostume = sprite.costumes[1];
|
|
const oldSkinId = deletedCostume.skinId;
|
|
const restoreCostume = vm.deleteCostume(1);
|
|
const deletedSound = sprite.sounds[1];
|
|
const restoreSound = vm.deleteSound(1);
|
|
const costumePromise = restoreCostume();
|
|
const soundPromise = restoreSound();
|
|
const soundBank = sprite.soundBank;
|
|
vm.deleteSprite(target.id);
|
|
await costumePromise;
|
|
await soundPromise;
|
|
|
|
assert.deepEqual(sprite.costumes, [], 'costume not added to deleted sprite');
|
|
assert.deepEqual(sprite.sounds, [], 'sound not added to deleted sprite');
|
|
assert.notStrictEqual(deletedCostume.skinId, oldSkinId, 'restore created a new skin');
|
|
assert.ok(vm.runtime.renderer.destroyedSkins.includes(deletedCostume.skinId), 'new skin destroyed');
|
|
assert.ok(soundBank.removedSounds.includes(deletedSound.soundId), 'restored player removed from bank');
|
|
});
|
|
|
|
test('deleting the editing sprite while it has clones', async () => {
|
|
const vm = await makeVM();
|
|
const stage = vm.runtime.getTargetForStage();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
vm.setEditingTarget(target.id);
|
|
vm.runtime.addTarget(target.makeClone());
|
|
|
|
vm.deleteSprite(target.id);
|
|
assert.strictEqual(vm.editingTarget, stage, 'editing target moved to the stage');
|
|
});
|
|
|
|
test('deleting a sprite does not modify previously returned costume or sound lists', async () => {
|
|
const vm = await makeVM();
|
|
const target = vm.runtime.getSpriteTargetByName('Sprite1');
|
|
const costumes = target.getCostumes();
|
|
const sounds = target.getSounds();
|
|
|
|
vm.deleteSprite(target.id);
|
|
assert.strictEqual(costumes.length, 2);
|
|
assert.strictEqual(sounds.length, 2);
|
|
});
|