const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const plugin = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8'); const io = { fs, path }; function harness(capture = false) { const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', ` globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry, resolveImportModel, importModelCandidates, companionImportFiles, definitionActorKeys, defaultImportClipRoles, validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport, resolveDefinitionImportStages }; Plugin.register(PLUGIN_ID, {`).replace(' function commitAutomaticImport(plan) {', ` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`); const storage = new Map(), dialogs = [], errors = [], undo = [], messages = []; let sequence = 0; class Group { static all = []; constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); } init() { Group.all.push(this); } addTo(parent) { this.parent = parent; if (typeof parent === 'object') parent.children.push(this); } } class Texture { static all = []; constructor(data) { Object.assign(this, data); } fromPath(file) { this.path = file; this.name = path.basename(file); return this; } add(undo) { assert.equal(undo, false); Texture.all.push(this); } } const context = { Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] }, Plugin: { register() {} }, requireNativeModule: require, localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) }, Formats: { geckolib_model: { id: 'geckolib_model' } }, newProject(format) { assert.equal(format.id, 'geckolib_model'); context.Project = { uuid: 'new-project' }; return true; }, Undo: { initEdit(aspects) { undo.push({ type: 'begin', aspects }); }, finishEdit(action, aspects) { undo.push({ type: 'finish', action, aspects }); }, cancelEdit() { undo.push({ type: 'cancel' }); Group.all = []; Texture.all = []; context.Animation.all = []; }, }, Animator: { loadFile(file, names) { context.loadedAnimation = JSON.parse(file.content); context.Animation.all = names.map((name) => ({ name, select() {} })); }, }, Animation: { all: [] }, Canvas: { updateAll() {}, updateView() {} }, setTimeout() { return 1; }, clearTimeout() {}, console, Blockbench: { showMessageBox(options) { errors.push(options.message); }, showQuickMessage(message) { messages.push(message); } }, Filesystem: { importFile(options, callback) { callback(context.selectedFile ? [context.selectedFile] : []); } }, Dialog: class { constructor(options) { this.options = options; } show() { dialogs.push(this.options); const result = Object.fromEntries(Object.entries(this.options.form).filter(([, value]) => 'value' in value) .map(([key, value]) => [key, value.value])); const choice = context.answer?.(this.options, result) ?? result; if (choice === false) this.options.onCancel(); else this.options.onConfirm(choice); } }, }; vm.runInNewContext(source, context); return { ...context.api, context, storage, dialogs, errors, undo, messages }; } function fixtures(t) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'animationdirector-import-')); t.after(() => { assert.equal(path.dirname(root), fs.realpathSync(os.tmpdir())); fs.rmSync(root, { recursive: true, force: true }); }); const write = (file, data) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof data === 'string' ? data : JSON.stringify(data)); return file; }; const makePack = (name, metadata = {}) => { const folder = path.join(root, name); write(path.join(folder, 'pack.mcmeta'), { animationframework: { id: `test:${name}`, ...metadata } }); return harness().readImportPack(folder, io); }; const model = (pack, id, description = {}) => { const [namespace, resource] = id.split(':'); return write(path.join(pack.root, 'assets', namespace, 'geckolib', 'models', `${resource}.geo.json`), { ...description, 'minecraft:geometry': [{ description: { texture_width: 64, texture_height: 64 }, bones: [{ name: 'body' }] }] }); }; return { root, write, makePack, model }; } function scopedFilesystem() { return Object.fromEntries(['existsSync', 'readFileSync', 'readdirSync', 'statSync'] .map((method) => [method, fs[method].bind(fs)])); } test('Blockbench scoped filesystem without realpathSync supports folder registration and resource lookup', (t) => { const f = fixtures(t), h = harness(), pack = f.makePack('source'); h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path; const restricted = h.desktopFiles(); assert.equal(restricted.fs.realpathSync, undefined); const model = f.model(pack, 'test:source/entity/wolf.m'); const source = h.readImportPack(pack.root, restricted); assert.equal(source.root, path.resolve(pack.root)); assert.equal(h.findImportPack(model, restricted).root, source.root); assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', restricted).file, model); assert.equal(h.importPackRegistry(source, [source.root], restricted).warnings.length, 0); assert.throws(() => h.insidePack(source.root, '../outside.json', restricted), /escapes/); }); test('scoped filesystem fallback rejects linked paths instead of bypassing containment checks', (t) => { const f = fixtures(t), h = harness(), pack = f.makePack('source'); const link = path.join(pack.root, 'linked'); const target = path.join(f.root, 'outside'); fs.mkdirSync(target); fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir'); assert.throws(() => h.importFilePath(link, { fs: scopedFilesystem(), path }), /Linked paths/); }); test('denied filesystem permission reports an actionable message', () => { const h = harness(); h.context.requireNativeModule = (name) => name === 'fs' ? undefined : path; assert.throws(() => h.desktopFiles(), /Allow filesystem access/); }); test('model lookup respects local/import/shared priority per gender candidate, not per provider', (t) => { const f = fixtures(t), h = harness(); const source = f.makePack('source', { model_imports: [{ pack: 'test:provider', entities: ['minecraft:wolf'] }] }); const provider = f.makePack('provider'); f.model(source, 'test:source/entity/wolf'); const imported = f.model(provider, 'test:provider/entity/wolf.m'); const shared = f.model(provider, 'animationframework:entity/wolf.m'); assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, imported); assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Imported test:provider'); f.model(source, 'test:source/entity/wolf.m'); assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Local'); source.metadata.model_imports = []; fs.unlinkSync(path.join(source.root, 'assets/test/geckolib/models/source/entity/wolf.m.geo.json')); assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, shared); }); test('imports are entity-specific and non-transitive; shared overrides follow folder order', (t) => { const f = fixtures(t), h = harness(); const source = f.makePack('source', { model_imports: [{ pack: 'test:b', entities: ['minecraft:wolf'] }] }); const b = f.makePack('b', { model_imports: [{ pack: 'test:c', entities: ['minecraft:wolf'] }] }); const c = f.makePack('c'); f.model(c, 'test:c/entity/wolf.m'); f.model(b, 'test:b/entity/horse.m'); assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io), null); assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:horse', 'm', '', io), null); const top = f.model(b, 'animationframework:entity/wolf.m'); f.model(c, 'animationframework:entity/wolf.m'); assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io).file, top); }); test('sparse default revisions resolve oldest applicable model before current shared models', (t) => { const f = fixtures(t), h = harness(); const source = f.makePack('source'); const defaults = f.makePack('defaults', { id: 'needsofnature:default_pack', default_model_revision: 4 }); const old = f.model(defaults, 'needsofnature:default_pack_compat/revision_2/entity/horse.m'); const later = f.model(defaults, 'needsofnature:default_pack_compat/revision_3/entity/horse.m'); const current = f.model(defaults, 'animationframework:entity/horse.m'); assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, old); source.metadata.default_model_revision = 3; assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, later); source.metadata.default_model_revision = 4; assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, current); }); test('variant, slim and male+female candidates use NoN fallback order, including legacy shared paths', (t) => { const f = fixtures(t), h = harness(), source = f.makePack('source'); assert.deepEqual(Array.from(h.importModelCandidates('minecraft:wolf', 'mf', 'snow')), [ 'wolf_snow.mf', 'wolf.mf', 'wolf_snow', 'wolf', 'wolf_snow.m', 'wolf.m', 'wolf_snow.f', 'wolf.f', ]); assert.equal(h.importModelCandidates('minecraft:player', 'f', '', true)[0], 'player_slim.f'); const legacy = f.model(source, 'animationframework:wolf.f'); assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', io).file, legacy); }); test('companion lookup accepts either entry point and detects namespace alternatives without broad scans', (t) => { const f = fixtures(t), h = harness(), pack = f.makePack('source'); const def = f.write(path.join(pack.root, 'data/test/afw_animdefs/example.json'), {}); const animation = f.write(path.join(pack.root, 'assets/other/animations/afw/example.animation.json'), {}); assert.equal(h.findImportPack(def, io).root, pack.root); assert.deepEqual(Array.from(h.companionImportFiles(pack, def, io).animations), [animation]); assert.deepEqual(Array.from(h.companionImportFiles(pack, animation, io).definitions), [def]); assert.throws(() => h.companionImportFiles(pack, path.join(pack.root, 'random.json'), io), /Choose data/); const nested = f.write(path.join(pack.root, 'assets/test/geckolib/animations/afw/nested/nested.animation.json'), {}); const nestedDef = f.write(path.join(pack.root, 'data/test/afw_animdefs/nested.json'), {}); assert.equal(h.companionImportFiles(pack, nested, io).id, 'nested'); assert.deepEqual(Array.from(h.companionImportFiles(pack, nested, io).definitions), [nestedDef]); }); test('resource paths cannot escape packs, and invalid/cyclic models are rejected before importing', (t) => { const f = fixtures(t), h = harness(), pack = f.makePack('source'); assert.throws(() => h.insidePack(pack.root, '../escape.json', io), /escapes/); for (const id of ['test:../file', 'test:a/../../file', 'test:/absolute', 'test:a//file']) assert.equal(h.importResourceId(id), null); assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a', parent: 'b' }, { name: 'b', parent: 'a' }] }] }), /Cyclic/); assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a' }, { name: 'a' }] }] }), /unique/); }); test('registry preserves explicit priority, inserts only new source first, and reports unavailable folders', (t) => { const f = fixtures(t), h = harness(), source = f.makePack('source'), top = f.makePack('top'); assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root, source.root], io).packs, (pack) => pack.root), [top.root, source.root]); assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root], io).packs, (pack) => pack.root), [source.root, top.root]); assert.equal(h.importPackRegistry(source, [path.join(f.root, 'missing')], io).warnings.length, 1); }); test('actor keys match NoN including required tags, repeated roles and entity variants', () => { const h = harness(); assert.deepEqual(Array.from(h.definitionActorKeys([ { entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] }, { entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] }, { label: 'Custom Role' }, { entity_types: ['minecraft:fox'], actor_tags_any: ['gender.male', 'gender.female'], entity_variant: 'snow', age: 'baby' }, ])), ['wolf_gender_male', 'wolf_gender_male_2', 'custom_role', 'fox_any_gender_female_gender_male_snow_baby']); }); test('short clip names are inferred only for unambiguous actors; repeated entities require explicit choices', () => { const h = harness(); assert.deepEqual(Array.from(h.defaultImportClipRoles([ { entity_types: ['minecraft:player'] }, { entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] }, ], ['player', 'wolf'])), ['player', 'wolf']); assert.deepEqual(Array.from(h.defaultImportClipRoles([ { entity_types: ['minecraft:player'], actor_tags: ['gender.male'] }, { entity_types: ['minecraft:player'], actor_tags: ['gender.female'] }, ], ['player', 'player2'])), ['', '']); }); test('complete import prepares all stages, actor models, local textures and definition fields without touching the current project', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); const def = { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf', 'minecraft:fox'], actor_tags: ['gender.male'] }], animators: ['Creator'], content_tags: ['example'], stages: [{ stage: 1, non_peak: true }, { stage: 2 }] }; const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), def); f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), { animations: { p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } }, p2_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } } }, } }); f.model(source, 'test:source/entity/wolf.m', { afw_texture: 'test:textures/wolf.png' }); f.write(path.join(source.root, 'assets/test/textures/wolf.png'), 'local image'); h.context.selectedFile = { path: defFile }; h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path; await h.automaticallyImportAnimation(); assert.deepEqual(h.errors, []); assert.equal(h.context.Project.uuid, 'original'); const plan = h.context.importedPlan; assert.equal(plan.rows[0].resolved.kind, 'Local'); assert.equal(plan.rows[0].entity, 'minecraft:wolf'); const actorForm = h.dialogs.find((dialog) => dialog.title.endsWith('Actors')).form; assert.equal(actorForm.entity_0.type, 'select'); assert.equal(actorForm.entity_0.value, 'minecraft:wolf'); assert.equal(actorForm.entity_0.options['minecraft:fox'], 'minecraft:fox'); assert.equal(actorForm.gender_0.value, 'm'); assert.equal(plan.rows[0].textures.size, 1); assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1', 'example.p2']); assert.ok(plan.imported.fileObj.animations['example.p2'].bones.actor1_body); assert.equal(plan.definition.actors[0].label, 'wolf'); assert.equal(plan.definition.stages[1].non_peak, undefined); assert.equal(h.undo.length, 0); assert.ok(h.storage.get('animationdirector_pack_folders').includes(source.root.replace(/\\/g, '\\\\'))); }); test('definition actors exclude extra clips, their effects and stages containing only unused actors', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }], }); f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: { p1_unused: { animation_length: 99, bones: { discarded: { rotation: [1, 2, 3] } }, sound_effects: { '0': { effect: 'discarded_sound' } }, particle_effects: { '0': { effect: 'discarded_particle' } } }, p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } }, p2_unused: { animation_length: 99, bones: {} }, } }); f.model(source, 'test:source/entity/wolf.m'); h.context.selectedFile = { path: file }; h.context.answer = (dialog, result) => dialog.title.endsWith('Actors') ? { ...result, gender_0: 'm' } : result; await h.automaticallyImportAnimation(); assert.deepEqual(h.errors, []); const plan = h.context.importedPlan; assert.equal(plan.rows.length, 1); assert.deepEqual(Array.from(plan.parsed.actorLabels), ['wolf']); assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']); const clip = plan.imported.fileObj.animations['example.p1']; assert.equal(clip.animation_length, 1); assert.deepEqual(Object.keys(clip.bones), ['actor1_body']); assert.equal(clip.sound_effects, undefined); assert.equal(clip.particle_effects, undefined); assert.match(h.dialogs.at(-1).form.info.text, /Ignored animation actors.*unused/); }); test('a definition actor without a distinct clip still requires a valid mapping', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }, { label: 'player', entity_types: ['minecraft:player'] }], stages: [{ stage: 1 }], }); f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: { p1_wolf: { animation_length: 1, bones: {} } }, }); h.context.selectedFile = { path: file }; await h.automaticallyImportAnimation(); assert.match(h.errors[0], /Each definition actor must be assigned a distinct animation actor/); assert.equal(h.context.importedPlan, undefined); assert.equal(h.context.Project.uuid, 'original'); }); test('automatic import retains use_stage metadata without duplicating editor animations', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1, escapable: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }], }); f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: { p1_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } }, sound_effects: { '0': { effect: 'example' } } }, } }); f.model(source, 'test:source/entity/wolf.m'); h.context.selectedFile = { path: file }; await h.automaticallyImportAnimation(); assert.deepEqual(h.errors, []); const plan = h.context.importedPlan; assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']); const clips = plan.imported.fileObj.animations; assert.equal(clips['example.p2'], undefined); assert.ok(clips['example.p1'].bones.actor1_body); assert.equal(plan.definition.stages[1].use_stage, 1); assert.equal(plan.definition.stages[1].speed, 0.8); assert.equal(plan.definition.stages[1].non_peak, true); assert.equal(plan.definition.stages[1].escapable, false); }); test('playback references require declared stages and physical clips, not recursive aliases', () => { const h = harness(); const parsed = { stages: [{ stageNumber: 1, entries: [{ actorLabel: 'wolf', key: 'p1_wolf', animObj: { bones: {} } }] }] }; assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 9 }] }), /missing playback stage p9/); assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1 }, { stage: 1 }] }), /unique positive/); assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 2 }, { stage: 2, use_stage: 1 }] }), /requires animation clips.*p2/); }); test('canceling actor review leaves the open project and saved folders unchanged', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }], }); f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: { p1_wolf: { animation_length: 1, bones: {} } }, }); h.context.selectedFile = { path: file }; h.context.answer = () => false; await h.automaticallyImportAnimation(); assert.equal(h.context.Project.uuid, 'original'); assert.equal(h.context.importedPlan, undefined); assert.equal(h.storage.size, 0); }); test('missing models can be supplied manually, and their locally supplied textures are not downloaded', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }], }); f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), { animations: { p1_wolf: { animation_length: 1, bones: { body: { position: [1, 2, 3] } } } }, }); const model = f.model(source, 'test:unrelated/wolf.m', { afw_texture: 'test:textures/unavailable.png' }); h.context.selectedFile = { path: file }; h.context.answer = (dialog, result) => dialog.title.endsWith('Actors') ? { ...result, gender_0: 'm' } : dialog.title.endsWith('Models') ? { ...result, model_0: { path: model } } : result; await h.automaticallyImportAnimation(); assert.deepEqual(h.errors, []); assert.equal(h.context.importedPlan.rows[0].resolved.kind, 'Manually selected'); assert.equal(h.context.importedPlan.rows[0].textures.size, 0); assert.match(h.dialogs.at(-1).form.info.text, /no base texture found locally/); }); test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => { const h = harness(); const plan = { parsed: { animationId: 'example' }, blockEntry: null, definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] }, imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': { bones: { actor1_body: { rotation: [1, 2, 3] } } } } } }, rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } }, geo: { bones: [{ name: 'body' }] }, defaultTexture: 'test:textures/wolf.png', textures: new Map([['test:textures/wolf.png', { file: 'wolf.png' }]]) }] }; h.commitAutomaticImport(plan); assert.equal(h.context.Project.uuid, 'new-project'); assert.equal(h.context.Group.all[0].name, 'actor1_body'); assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'finish']); assert.equal(h.undo[1].aspects.automatic_animation_import, true); assert.equal(h.undo[1].aspects.groups.length, 1); const override = JSON.parse(h.context.Project.multiactor_bone_texture_overrides); assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png'); assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example); }); test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => { const h = harness(); h.context.Animator.loadFile = () => { throw new Error('load failed'); }; assert.throws(() => h.commitAutomaticImport({ parsed: { animationId: 'example' }, rows: [], definition: {}, imported: { fileObj: {}, stageNames: [] } }), /load failed/); assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']); });