const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); function createHarness() { const file = path.join(__dirname, '..', 'AnimationDirector.js'); const source = fs.readFileSync(file, 'utf8').replace( ' Plugin.register(PLUGIN_ID, {', ' globalThis.adTest = { showAnimationDefinitionReviewDialog };\n Plugin.register(PLUGIN_ID, {' ); const dialogs = []; const exports = []; const errors = []; let formResult; const project = { multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) }; const context = { Project: project, Plugin: { register() {} }, Dialog: class { constructor(options) { this.options = options; dialogs.push(options); } show() {} getFormResult() { return formResult ?? defaults(this.options.form); } }, Blockbench: { export(options, callback) { exports.push(JSON.parse(options.content)); callback(); }, showMessageBox(options) { errors.push(options.message); }, showQuickMessage() {}, }, console, }; vm.runInNewContext(source, context, { filename: file }); const group = { id: 'test_animation' }; const analysis = { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]), stages: [ { stageNumber: 1, name: 'test_animation.p1', loop: true, cycleSeconds: 1 }, { stageNumber: 2, name: 'test_animation.p2', loop: false, cycleSeconds: 2 }, ], warnings: [], }; function open() { context.adTest.showAnimationDefinitionReviewDialog(group, analysis); return dialogs.at(-1); } return { open, exports, errors, project, analysis, setFormResult(result) { formResult = result; } }; } function defaults(form) { return Object.fromEntries(Object.entries(form) .filter(([, field]) => field && typeof field === 'object' && 'value' in field) .map(([key, field]) => [key, field.value])); } test('Save retains definition fields in the project without exporting and restores them on reopen', () => { const h = createHarness(), dialog = h.open(), result = defaults(dialog.form); assert.deepEqual(Array.from(dialog.buttons), ['Cancel', 'Save', 'Create Animdef']); assert.equal(dialog.confirmIndex, 2); result.display_name = 'Saved definition'; result.content_tags = 'saved, tags'; result.weight = '3'; result.stub_stage_1_peaked = true; result.stub_stage_2_peaked = false; h.setFormResult(result); assert.equal(dialog.onButton(1), false); assert.equal(h.exports.length, 0); assert.deepEqual(h.errors, []); assert.equal(h.project.saved, false); const reopened = h.open(); assert.equal(reopened.form.display_name.value, result.display_name); assert.equal(reopened.form.content_tags.value, result.content_tags); assert.equal(reopened.form.weight.value, 3); assert.equal(reopened.form.stub_stage_1_peaked.value, true); assert.equal(reopened.form.stub_stage_2_peaked.value, false); assert.equal(reopened.onConfirm(defaults(reopened.form)), true); assert.equal(h.exports[0].display_name, result.display_name); }); test('invalid Save leaves previously saved fields intact', () => { const h = createHarness(), dialog = h.open(), result = defaults(dialog.form); result.display_name = 'Original'; h.setFormResult(result); dialog.onButton(1); const original = h.project.animationdirector_animdef_definitions; h.setFormResult({ ...result, display_name: 'Invalid', weight: '0' }); dialog.onButton(1); assert.equal(h.project.animationdirector_animdef_definitions, original); assert.equal(h.exports.length, 0); assert.equal(h.errors.length, 1); }); test('metadata-only playback stages remain editable and survive definition export', () => { const h = createHarness(); h.analysis.stages = h.analysis.stages.slice(0, 1); h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: { actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1, loop: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }], } }); const dialog = h.open(); assert.equal(dialog.form.stub_stage_2_use_stage.value, '1'); dialog.onConfirm(defaults(dialog.form)); assert.deepEqual(h.errors, []); assert.equal(h.exports[0].stages.length, 2); assert.equal(h.exports[0].stages[1].use_stage, 1); assert.equal(h.exports[0].stages[1].speed, 0.8); assert.equal(h.analysis.stages.length, 1); }); test('prop tool metadata survives generator export including reused-stage overrides', () => { const h = createHarness(); h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: { actors: [{label:'player',entity_types:['minecraft:player'],prop_right:'minecraft:carrot'}, {label:'wolf',entity_types:['minecraft:wolf'],prop_floor:'minecraft:stone'}], stages: [{stage:1,props:{player:{prop_left:'minecraft:stick'}}}, {stage:2,use_stage:1,props:{player:{prop_right:null},wolf:null}}], } }); const dialog=h.open(); dialog.onConfirm(defaults(dialog.form)); assert.deepEqual(h.errors,[]); const def=h.exports[0]; assert.equal(def.actors[0].prop_right,'minecraft:carrot'); assert.equal(def.actors[1].prop_floor,'minecraft:stone'); assert.equal(def.stages[0].props.player.prop_left,'minecraft:stick'); assert.equal(def.stages[1].props.player.prop_right,null); assert.equal(def.stages[1].props.wolf,null); }); test('generates extended animdef fields and restores them on reopen', () => { const harness = createHarness(); const dialog = harness.open(); const result = defaults(dialog.form); result.stub_actor_1_injector = 'V'; result.stub_actor_1_receiver = true; result.stub_actor_1_injects_into = ['wolf']; result.stub_actor_1_held_items = 'minecraft:carrot, #minecraft:swords'; result.stub_actor_1_held_hand = 'either'; result.stub_actor_1_held_prop_slot = 'right'; result.stub_actor_2_receiver = true; result.system_tags = 'manualpeak'; result.content_tags = 'test, tag'; result.required_union_tags = 'example.tag'; result.join_after = 'creator:previous'; result.match_content_on_join = true; result.weight = '2.5'; result.attack_eligible = false; result.manual_peak_priority = '10'; result.liquid_gain_multiplier = '0.5'; result.base_speed = '1.25'; result.water = 'surface'; result.position_anchor_actor = 'wolf'; result.camera_torso_tracking = 'continuous'; result.camera_block_collision = 'ignore'; result.stub_stage_2_use_stage = '1'; assert.equal(dialog.onConfirm(result), true); assert.equal(harness.errors.length, 0); assert.equal(harness.exports.length, 1); const definition = harness.exports[0]; assert.equal(definition.weight, 2.5); assert.equal(definition.attack_eligible, false); assert.deepEqual(Array.from(definition.system_tags), ['manualpeak']); assert.deepEqual(Array.from(definition.content_tags), ['test', 'tag']); assert.deepEqual(Array.from(definition.join_after), ['creator:previous']); assert.equal(definition.manual_peak.priority, 10); assert.equal(definition.actors[0].injects_into, 'wolf'); assert.equal(definition.actors[0].receiver, true); assert.equal(definition.actors[0].held_item.prop_slot, 'right'); assert.equal('prop_floor' in definition.actors[1], false); assert.equal(definition.stages[1].use_stage, 1); assert.equal(definition.camera.torso_tracking, 'continuous'); assert.equal(definition.water, 'surface'); assert.equal(definition.position_anchor_actor, 'wolf'); assert.equal('stage_seconds' in definition, false); assert.equal('escapable' in definition, false); const reopened = harness.open(); assert.equal(reopened.form.weight.value, 2.5); assert.equal(reopened.form.matching_advanced.value, true); assert.equal(reopened.form.system_tags.value, 'manualpeak'); assert.equal(reopened.form.stub_actor_1_injector.value, 'V'); assert.deepEqual(Array.from(reopened.form.stub_actor_1_injects_into.value), ['wolf']); assert.equal(reopened.form.stub_stage_2_use_stage.value, '1'); }); test('rejects nonexistent injection targets and playback stages', () => { const harness = createHarness(); const dialog = harness.open(); const result = defaults(dialog.form); result.stub_actor_1_injector = 'V'; result.stub_actor_1_injects_into = ['missing']; assert.equal(dialog.onConfirm(result), false); assert.match(harness.errors.at(-1), /invalid injection target/); result.stub_actor_1_injects_into = ['wolf']; result.stub_stage_2_use_stage = '99'; assert.equal(dialog.onConfirm(result), false); assert.match(harness.errors.at(-1), /missing playback stage/); assert.equal(harness.exports.length, 0); }); test('groups advanced matching and offers only other actors as injection targets', () => { const form = createHarness().open().form; const keys = Object.keys(form); assert.equal(form.matching_advanced.value, false); for (const key of ['required_union_tags', 'join_after', 'match_content_on_join', 'weight']) { assert.equal(form[key].condition({ matching_advanced: false }), false); assert.equal(form[key].condition({ matching_advanced: true }), true); assert.ok(keys.indexOf(key) > keys.indexOf('matching_advanced')); } assert.equal(form.stub_actor_1_receiver.label, 'Receiver'); assert.equal(form.stub_actor_1_injects_into.type, 'multi_select'); assert.deepEqual(Object.keys(form.stub_actor_1_injects_into.options), ['wolf']); assert.deepEqual(Object.keys(form.stub_actor_2_injects_into.options), ['player']); assert.equal(keys.indexOf('stub_actor_1_injects_into'), keys.indexOf('stub_actor_1_injector') + 1); assert.equal(keys.some((key) => /_prop_(left|right|floor)$/.test(key)), false); assert.equal('default_stage_seconds' in form, false); assert.equal('default_escapable' in form, false); assert.equal('stub_behavior' in form, false); assert.equal(form.system_tags.type, 'select'); assert.deepEqual(Object.keys(form.system_tags.options), [ '', 'manualpeak', 'defeated', 'stuck', 'tease', 'birth', 'fillbottle', 'other', ]); assert.equal(form.stub_stage_2_use_stage.type, 'select'); assert.deepEqual(Object.keys(form.stub_stage_2_use_stage.options), ['1', '2', '']); }); test('keeps minimal definitions minimal and supports specialized behavior', () => { const harness = createHarness(); const dialog = harness.open(); const minimal = defaults(dialog.form); assert.equal(dialog.onConfirm(minimal), true); const first = harness.exports.at(-1); for (const key of ['weight', 'camera', 'water', 'manual_peak', 'block_requirements']) { assert.equal(key in first, false, `${key} should be omitted by default`); } const teaseDialog = harness.open(); const tease = defaults(teaseDialog.form); tease.system_tags = 'tease'; tease.stub_tease_multiplier = 3; assert.equal(teaseDialog.onConfirm(tease), true); const second = harness.exports.at(-1); assert.deepEqual(Array.from(second.system_tags), ['tease']); assert.equal(second.tease.near_animation_energy_multiplier, 3); const stuckDialog = harness.open(); const stuck = defaults(stuckDialog.form); stuck.system_tags = 'stuck'; stuck.content_tags = 'stuckfence'; assert.equal(stuckDialog.onConfirm(stuck), true); const third = harness.exports.at(-1); assert.deepEqual(Array.from(third.system_tags), ['stuck']); assert.deepEqual(Array.from(third.content_tags), ['stuckfence']); assert.equal(third.stuck.damage_behavior, 'stop_on_damage'); }); test('exports one custom behavior through Other', () => { const harness = createHarness(); const dialog = harness.open(); const result = defaults(dialog.form); result.system_tags = 'other'; result.other_system_tags = 'custom_tag'; assert.equal(dialog.onConfirm(result), true); const definition = harness.exports.at(-1); assert.deepEqual(Array.from(definition.system_tags), ['custom_tag']); assert.equal('tease' in definition, false); assert.equal('stuck' in definition, false); const reopened = harness.open(); assert.equal(reopened.form.system_tags.value, 'other'); assert.equal(reopened.form.other_system_tags.value, 'custom_tag'); assert.equal(reopened.form.other_system_tags.condition({ system_tags: 'other' }), true); assert.equal(reopened.form.other_system_tags.condition({ system_tags: '' }), false); });