Files
scratch-vm/test/unit/tw_delete_resources.js

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);
});