const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const vm = require('node:vm'); const test = require('node:test'); function harness() { const storage = new Map(), blobs = [], revoked = [], undo = []; const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8') .replace(' Plugin.register(PLUGIN_ID, {', ` globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds, applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY, installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper }; Plugin.register(PLUGIN_ID, {`); class EffectAnimator { constructor(animation) { this.animation = animation; this.sound = []; } createKeyframe(data, time) { const frame = { channel: 'sound', time, animator: this, select() {}, extend(data) { this.data_points = data.data_points.map((point) => ({ ...point, getUndoCopy() { return { ...this }; } })); } }; frame.extend(data); this.sound.push(frame); return frame; } select() {} } const context = { Plugin: { register() {} }, Buffer, requireNativeModule: require, Blob, URL: { createObjectURL(blob) { blobs.push(blob); return `blob:test-${blobs.length}`; }, revokeObjectURL(url) { revoked.push(url); } }, localStorage: { getItem(key) { return storage.get(key); }, setItem(key, value) { storage.set(key, value); } }, Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] }, Undo: { initEdit(aspects) { undo.push(aspects.keyframes.slice()); }, finishEdit() {} }, Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} }, Format: { id: 'geckolib_model' }, settings: { volume: { value: 80 } }, console, played: [], coreCalls: [], Audio: class { constructor(file) { this.file = file; } play() { context.played.push(this); return Promise.resolve(); } pause() { this.paused = true; } }, }; vm.runInNewContext(source, context); return { ...context.api, context, storage, blobs, revoked, undo }; } function brokenFigura(h) { vm.runInNewContext(`(() => { const format = { id: 'figura' }; const displayFrame = function (in_loop) { coreCalls.push(in_loop); return 'original-result'; }; EffectAnimator.prototype.displayFrame = function () { if (Format === format) return; displayFrame.call(this); }; })()`, h.context); const animation = { time: 0, length: 2, animators: {}, playing: true }; const effects = new h.context.EffectAnimator(animation); effects.muted = { sound: false }; effects.sound = [{ uuid: 'cue', time: 0.5, data_points: [{ file: 'manual.ogg' }] }]; animation.animators.effects = effects; h.context.Animation.all = [animation]; Object.assign(h.context.Timeline, { playing: true, playback_speed: 100, custom_range: [0, 0], playing_sounds: [] }); return { animation, effects, original: h.context.EffectAnimator.prototype.displayFrame }; } function assets(t, json, files = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-sounds-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const write = (name, content) => { const file = path.join(root, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); }; write('assets/needsofnature/sounds.json', JSON.stringify(json)); for (const [name, content] of Object.entries(files)) write(`assets/needsofnature/sounds/${name}.ogg`, content); return root; } test('sound library reads the real NoN sound registry, lazily maps semantic cues and caches audio', () => { const h = harness(); h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources')); const library = h.loadSoundLibrary(); assert(library.events.has('needsofnature:spray')); assert(library.events.size > 60); assert.equal(h.blobs.length, 0); const audio = h.resolveSoundAudio('reactiveimpact'); assert.equal(audio, h.resolveSoundAudio('needsofnature:impactdry01')); assert.equal(h.blobs.length, 1); assert.equal(h.resolveSoundAudio('reactiveimpact_silent'), null); assert.equal(h.resolveSoundAudio('not_a_sound'), null); h.loadSoundLibrary(); assert(h.revoked.includes(audio)); }); test('event references and random pools resolve safely; cyclic or missing events stay unresolved', (t) => { const h = harness(); h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { one: { sounds: ['needsofnature:one'] }, nested: { sounds: [{ type: 'event', name: 'needsofnature:one' }] }, loop: { sounds: [{ type: 'event', name: 'needsofnature:loop' }] }, unsafe: { sounds: ['needsofnature:../outside'] }, }, { one: 'OggS test' })); h.loadSoundLibrary(); assert.equal(h.resolveSoundAudio('nested'), h.resolveSoundAudio('one')); assert.equal(h.resolveSoundAudio('random(needsofnature:missing, needsofnature:one)'), h.resolveSoundAudio('one')); assert.equal(h.resolveSoundAudio('loop'), null); assert.equal(h.resolveSoundAudio('unsafe'), null); }); test('pack sound priority honors replace without overriding higher-priority entries', (t) => { const h = harness(); const high = assets(t, { one: { replace: true, sounds: ['needsofnature:high'] } }, { high: 'high' }); const low = assets(t, { one: { sounds: ['needsofnature:low'] } }, { low: 'low' }); h.storage.set('animationdirector_pack_folders', JSON.stringify([high])); h.storage.set(h.SOUND_SOURCE_KEY, low); const library = h.loadSoundLibrary(); assert.deepEqual(Array.from(library.events.get('needsofnature:one').entries), ['needsofnature:high']); assert(h.resolveSoundAudio('one')); }); test('mapping retains cue IDs and manual previews, updates managed files and handles missing/silent cues', (t) => { const h = harness(); h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] }, shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' })); h.loadSoundLibrary(); const points = [{ effect: 'reactiveimpact' }, { effect: 'reactiveimpact', file: 'custom.ogg' }, { effect: 'unknown' }, { effect: 'reactiveimpact_silent' }]; const animation = { animators: { effects: { sound: [{ data_points: points }] } } }; h.context.Animation.all = [animation]; h.mapAnimationSounds([animation]); const original = points[0].file; assert(original.startsWith('blob:')); assert.equal(points[0].effect, 'reactiveimpact'); assert.equal(points[1].file, 'custom.ogg'); assert.equal(points[2].file, ''); assert.equal(points[3].file, ''); h.loadSoundLibrary(); h.mapAnimationSounds([animation]); assert.notEqual(points[0].file, original); points[0].effect = 'reactiveimpact_silent'; h.mapAnimationSounds([animation]); assert.equal(points[0].file, ''); }); test('reactive impact uses saved peak-stage metadata separately for each animation stage', (t) => { const h = harness(); h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] }, shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' })); const library = h.loadSoundLibrary(); h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: { stages: [{ stage: 1 }, { stage: 2, non_peak: true }], } }) }; const normal = { name: 'test.p1', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' }] }] } } }; const peaked = { name: 'test.p2', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' }, { effect: 'reactiveimpact', file: 'manual.ogg' }, { effect: 'reactiveimpact_silent' }] }] } } }; h.mapAnimationSounds([normal, peaked]); assert.equal(normal.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01')); assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('shot_in01')); assert.equal(peaked.animators.effects.sound[0].data_points[1].file, 'manual.ogg'); assert.equal(peaked.animators.effects.sound[0].data_points[2].file, ''); h.context.Animation.selected = peaked; h.showSoundLibrary(); h.context.dialog.component.methods.preview.call({ selected: 'reactiveimpact', error: '' }); assert.equal(h.context.played[0].file, h.resolveSoundAudio('shot_in01')); h.context.Project.animationdirector_animdef_definitions = JSON.stringify({ test: { stages: [{ stage: 2, non_peak: false }] } }); h.mapAnimationSounds([peaked]); assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01')); assert.equal(h.resolveSoundAudio('random(needsofnature:reactiveimpact)', library, new Set(), normal), h.resolveSoundAudio('impactdry01')); }); test('missing stage metadata defaults to a normal impact; reused peak stages do not change the source stage', (t) => { const h = harness(); h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] }, shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' })); const library = h.loadSoundLibrary(); h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: { stages: [{ stage: 1 }, { stage: 2, use_stage: 1, non_peak: true }], } }) }; assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p1' }), h.resolveSoundAudio('impactdry01')); assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p2' }), h.resolveSoundAudio('shot_in01')); assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'other.p3' }), h.resolveSoundAudio('impactdry01')); }); test('insertion is undoable and preserves existing cues at the same time; Apply edits selection', (t) => { const h = harness(); h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { one: { sounds: ['needsofnature:one'] } }, { one: 'one' })); h.loadSoundLibrary(); h.applyLibrarySound('one', true); const animation = h.context.Animation.selected, effects = animation.animators.effects; assert.equal(effects.sound.length, 1); assert.equal(effects.sound[0].time, 2); assert(effects.sound[0].data_points[0].file.startsWith('blob:')); h.applyLibrarySound('birth', true); assert.equal(effects.sound.length, 1); assert.equal(effects.sound[0].data_points.length, 2); assert.equal(effects.sound[0].data_points[0].effect, 'one'); h.context.Timeline.selected = [effects.sound[0]]; h.applyLibrarySound('reactiveimpact_silent'); assert.equal(effects.sound[0].data_points[0].effect, 'reactiveimpact_silent'); assert.equal(effects.sound[0].data_points[0].file, ''); assert.equal(h.undo.length, 3); }); test('editor preview fields never appear in exported sound cues', () => { const h = harness(); const clip = { sound_effects: { '0.0': [{ effect: 'reactiveimpact', file: 'blob:test', [h.SOUND_PREVIEW_PROPERTY]: 'blob:test', volume: 0.5 }, { effect: 'custom:sound' }] } }; const result = h.compileConjoinedStage({ name: 'sample.p1', animation: { compileBedrockAnimation() { return clip; } } }); assert.equal(result.clip.sound_effects['0.0'][0].effect, 'reactiveimpact'); assert.equal(result.clip.sound_effects['0.0'][0].volume, 0.5); assert.equal(result.clip.sound_effects['0.0'][0].file, undefined); assert.equal(clip.sound_effects['0.0'][0].file, 'blob:test'); }); test('library UI auditions on a single click without reactive preview controls', () => { const h = harness(); h.showSoundLibrary(); const ui = h.context.dialog.component; assert.match(ui.template, /Search sound IDs/); assert.match(ui.template, /Insert at Playhead/); assert.doesNotMatch(ui.template, /Reactive preview|@dblclick/); assert.match(ui.template, /@click="selected=row.id;preview\(\)"/); assert.equal(typeof ui.methods.preview, 'function'); assert.equal(typeof ui.methods.reload, 'function'); }); test('sound selection hides blacklisted events without disabling existing cue previews', () => { const h = harness(); h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources')); h.showSoundLibrary(); const ui = h.context.dialog.component; const state = ui.data(); assert(state.ids.includes('needsofnature:a')); assert(state.ids.includes('needsofnature:funkytown')); for (const query of ['', 'needsofnature:a', 'funkytown']) { const rows = ui.computed.rows.call({ ...state, query }); assert(!rows.some((row) => row.id === 'needsofnature:a' || row.id === 'needsofnature:funkytown')); } assert(h.resolveSoundAudio('needsofnature:a')); assert(h.resolveSoundAudio('needsofnature:funkytown')); }); test('Figura workaround detects only the known faulty wrapper and restores it on unload', () => { const h = harness(), f = brokenFigura(h); assert(h.isBrokenFiguraSoundWrapper(f.original)); h.installFiguraSoundCompat(); const patched = h.context.EffectAnimator.prototype.displayFrame; assert.notEqual(patched, f.original); h.installFiguraSoundCompat(); assert.equal(h.context.EffectAnimator.prototype.displayFrame, patched); f.animation.time = 0.5; f.effects.displayFrame(true); h.removeFiguraSoundCompat(); assert.equal(h.context.EffectAnimator.prototype.displayFrame, f.original); for (const corrected of [function (in_loop) {}, function (...args) { return args; }]) { h.context.EffectAnimator.prototype.displayFrame = corrected; h.installFiguraSoundCompat(); assert.equal(h.context.EffectAnimator.prototype.displayFrame, corrected); } }); test('Figura sound workaround triggers crossed keyframes even beyond the native 33ms window', () => { const h = harness(), f = brokenFigura(h); h.beginFiguraSoundPlayback(); f.animation.time = 0.45; f.effects.displayFrame(true); f.animation.time = 0.6; f.effects.displayFrame(true); assert.equal(h.context.played.length, 1); assert.equal(h.context.played[0].file, 'manual.ogg'); assert.equal(h.context.played[0].volume, 0.8); assert.equal(h.context.coreCalls.length, 2); assert.equal(h.context.coreCalls[0], undefined); f.animation.time = 0.7; f.effects.displayFrame(true); assert.equal(h.context.played.length, 1); }); test('Figura workaround plays start cues and restarts long sounds on subsequent loops', () => { const h = harness(), f = brokenFigura(h); f.effects.sound[0].time = 0; h.beginFiguraSoundPlayback(); f.animation.time = 0.04; f.effects.displayFrame(true); assert.equal(h.context.played.length, 1); f.animation.time = 1.9; f.effects.displayFrame(true); f.animation.time = 0; f.effects.displayFrame(true); assert.equal(h.context.played.length, 2); assert(h.context.played[0].paused); assert.equal(h.context.Timeline.playing_sounds.length, 1); h.removeFiguraSoundCompat(); assert(h.context.played[1].paused); assert.equal(h.context.Timeline.playing_sounds.length, 0); }); test('Figura workaround respects sound mute, scrubbing, other formats and resumed native audio', () => { const h = harness(), f = brokenFigura(h); h.beginFiguraSoundPlayback(); f.animation.time = 0.7; f.effects.displayFrame(false); assert.equal(h.context.played.length, 0); f.animation.time = 0; h.beginFiguraSoundPlayback(); f.effects.muted.sound = true; f.animation.time = 0.6; f.effects.displayFrame(true); assert.equal(h.context.played.length, 0); f.effects.muted.sound = false; f.animation.time = 0; h.beginFiguraSoundPlayback(); h.context.Format.id = 'figura'; f.animation.time = 0.6; f.effects.displayFrame(true); assert.equal(h.context.played.length, 0); h.context.Format.id = 'geckolib_model'; f.animation.time = 0; h.beginFiguraSoundPlayback(); h.context.Timeline.playing_sounds.push({ keyframe_id: 'cue' }); f.animation.time = 0.6; f.effects.displayFrame(true); assert.equal(h.context.played.length, 0); }); test('Figura workaround honors custom loop ranges and leaves later plugin patches installed', () => { const h = harness(), f = brokenFigura(h); Object.assign(h.context.Timeline, { custom_range: [0.3, 1] }); f.effects.sound = [{ uuid: 'before', time: 0.1, data_points: [{ file: 'excluded.ogg' }] }, { uuid: 'start', time: 0.3, data_points: [{ file: 'included.ogg' }] }, { uuid: 'after', time: 1.2, data_points: [{ file: 'excluded.ogg' }] }]; f.animation.time = 0.3; h.beginFiguraSoundPlayback(); f.animation.time = 0.35; f.effects.displayFrame(true); f.animation.time = 0.95; f.effects.displayFrame(true); f.animation.time = 0.3; f.effects.displayFrame(true); assert.deepEqual(h.context.played.map((media) => media.file), ['included.ogg', 'included.ogg']); const patched = h.context.EffectAnimator.prototype.displayFrame; const later = function (...args) { return patched.apply(this, args); }; h.context.EffectAnimator.prototype.displayFrame = later; h.removeFiguraSoundCompat(); assert.equal(h.context.EffectAnimator.prototype.displayFrame, later); f.animation.time = 0.95; f.effects.displayFrame(true); f.animation.time = 0.3; f.effects.displayFrame(true); assert.equal(h.context.played.length, 2); });