363 lines
18 KiB
JavaScript
363 lines
18 KiB
JavaScript
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, createInjectorGenderDefaultHandler, remapActorReferences, readStageDefinitionFromForm };\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, helpers: context.adTest, createInjectorGenderDefaultHandler: context.adTest.createInjectorGenderDefaultHandler,
|
|
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('actor renames remap all definitions, metadata and prop previews simultaneously', () => {
|
|
const h = createHarness();
|
|
const metadata = { 1: { injects_into: ['wolf'] } };
|
|
const definition = { actors: [{ label: 'player', injects_into: 'wolf', prop_right: 'minecraft:stick' },
|
|
{ label: 'wolf' }], position_anchor_actor: 'wolf', stages: [{ stage: 1, props: { wolf: null, player: { prop_left: null } } }] };
|
|
h.project.animationdirector_animdef_definitions = JSON.stringify({ first: definition, second: definition });
|
|
h.project.animationdirector_prop_previews = JSON.stringify({ old: { animation: 'first', actor: 'wolf', slot: 'right', stage: 1 } });
|
|
h.helpers.remapActorReferences({ 1: 'player', 2: 'wolf' }, { 1: 'wolf', 2: 'player' }, [1, 2], metadata);
|
|
assert.deepEqual(metadata[1].injects_into, ['player']);
|
|
for (const saved of Object.values(JSON.parse(h.project.animationdirector_animdef_definitions))) {
|
|
assert.equal(saved.actors[0].label, 'wolf');
|
|
assert.equal(saved.actors[0].injects_into, 'player');
|
|
assert.equal(saved.actors[0].prop_right, 'minecraft:stick');
|
|
assert.equal(saved.position_anchor_actor, 'player');
|
|
assert.equal(saved.stages[0].props.player, null);
|
|
assert.deepEqual(saved.stages[0].props.wolf, { prop_left: null });
|
|
}
|
|
const previews = JSON.parse(h.project.animationdirector_prop_previews);
|
|
assert.equal(previews[JSON.stringify(['first', 'player', 'right', 1])].actor, 'player');
|
|
});
|
|
|
|
test('own stage remains explicit and hidden loop controls do not block non-looping stages', () => {
|
|
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
|
|
assert.equal(result.stub_stage_2_use_stage, 'own');
|
|
result.stub_stage_2_midpoint_offset = 'not applicable';
|
|
result.stub_stage_2_duration_multiplier = 'not applicable';
|
|
const stage = h.helpers.readStageDefinitionFromForm(result, h.analysis.stages[1], true);
|
|
assert.equal(stage.use_stage, undefined);
|
|
assert.equal(stage.cycle_midpoint_offset_seconds, undefined);
|
|
assert.equal(stage.stage_duration_multiplier, undefined);
|
|
});
|
|
|
|
test('obsolete injection targets and position anchors remain visible for correction', () => {
|
|
const h = createHarness();
|
|
h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: {
|
|
actors: [{ label: 'player', entity_types: ['minecraft:player'], injector: 'V', injects_into: 'removed' }],
|
|
position_anchor_actor: 'removed', stages: [{ stage: 2, use_stage: 99 }],
|
|
} });
|
|
const dialog = h.open();
|
|
assert.match(dialog.form.stub_actor_1_injects_into.options.removed, /unavailable/);
|
|
assert.match(dialog.form.position_anchor_actor.options.removed, /unavailable/);
|
|
assert.match(dialog.form.stub_stage_2_use_stage.options['99'], /unavailable/);
|
|
});
|
|
|
|
test('selecting an injector adds an editable male tag without enforcing it on export', () => {
|
|
for (const role of ['V', 'A', 'M']) {
|
|
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form), updates = [];
|
|
const instance = { form: { setValues(values, notify) { updates.push(values); assert.equal(notify, false); } } };
|
|
result.stub_actor_1_actor_tags = 'custom.tag';
|
|
result.stub_actor_1_injector = role;
|
|
dialog.onFormChange.call(instance, result);
|
|
assert.equal(result.stub_actor_1_actor_tags, 'custom.tag, gender.male');
|
|
assert.equal(updates.length, 1);
|
|
result.stub_actor_1_actor_tags = 'custom.tag';
|
|
dialog.onFormChange.call(instance, result);
|
|
result.display_name = 'Unrelated edit';
|
|
dialog.onFormChange.call(instance, result);
|
|
assert.equal(updates.length, 1, 'Manual removal survives unrelated changes');
|
|
result.stub_actor_2_entity_types = 'minecraft:wolf';
|
|
dialog.onConfirm(result);
|
|
assert.equal(h.errors.length, 0);
|
|
assert.equal(h.exports[0].actors[0].injector, role);
|
|
assert.deepEqual(h.exports[0].actors[0].actor_tags, ['custom.tag']);
|
|
}
|
|
});
|
|
|
|
test('injector defaults preserve loaded roles, existing tags, and actor preferences edits', () => {
|
|
const h = createHarness();
|
|
const form = {
|
|
preferences_animdef_1_injector: { value: 'V' },
|
|
preferences_animdef_2_injector: { value: '' },
|
|
};
|
|
const update = h.createInjectorGenderDefaultHandler(form), writes = [];
|
|
const dialog = { form: { setValues(values) { writes.push(values); } } };
|
|
const result = { preferences_animdef_1_injector: 'V', preferences_animdef_1_actor_tags: 'custom.tag',
|
|
preferences_animdef_2_injector: '', preferences_animdef_2_actor_tags: 'gender.male, other.tag' };
|
|
update(dialog, result);
|
|
assert.equal(writes.length, 0, 'Opening an imported/saved role does not add a tag');
|
|
result.preferences_animdef_2_injector = 'A'; update(dialog, result);
|
|
assert.equal(writes.length, 0, 'Existing male tags are not duplicated');
|
|
result.preferences_animdef_2_injector = ''; update(dialog, result);
|
|
assert.equal(result.preferences_animdef_2_actor_tags, 'gender.male, other.tag', 'Deselecting a role does not remove explicit tags');
|
|
result.preferences_animdef_2_actor_tags = 'other.tag';
|
|
result.preferences_animdef_2_injector = 'M'; update(dialog, result);
|
|
assert.equal(result.preferences_animdef_2_actor_tags, 'other.tag, gender.male');
|
|
assert.equal(result.preferences_animdef_1_actor_tags, 'custom.tag', 'Only the changed actor is affected');
|
|
});
|
|
|
|
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', 'own']);
|
|
});
|
|
|
|
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);
|
|
});
|