This commit is contained in:
2026-09-28 22:39:00 +02:00
parent 8d1346986b
commit 733b62194a
4 changed files with 867 additions and 2 deletions
+18
View File
@@ -69,6 +69,24 @@ test('metadata-only playback stages remain editable and survive definition expor
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();
+1 -1
View File
@@ -106,7 +106,7 @@ test('animation menu orders import and creation actions and has no deprecated sp
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
assert.deepEqual(Array.from(menu.children, (action) => action.name), [
'Automatically Import Animation', 'Manual Import', 'Actor Preferences',
'Replace Actor Model', 'Export Animations', 'Block Requirements', 'Create Animation Definition',
'Replace Actor Model', 'Export Animations', 'Block Requirements', 'Item Props', 'Create Animation Definition',
]);
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
+182
View File
@@ -0,0 +1,182 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const zlib = require('node:zlib');
function harness() {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {', `globalThis.props = { indexPropZip, propResourcePath, openPropSource,
loadPropAsset, resolvePropModel, propTextureId, propDisplayTransform, manualPropModel,
writePropDefinition, effectivePropEntry, effectivePropItem, propEntryKey, extrudePropPixels, discoverPropSources,
buildPropMesh, refreshPropPreviews, clearPropMeshes, propFaceVertices, propDefaultUv,
applyPropPreview, createPropDialog, showPropDialog, readPropPreviews };
Plugin.register(PLUGIN_ID, {`);
const context = { Buffer, requireNativeModule: require, console, Plugin: { register() {} },
Project: { uuid: 'test' }, Group: { all: [] }, Animation: { all: [] }, localStorage: { getItem() { return null; } } };
vm.runInNewContext(source, context);
return { ...context.props, context };
}
function source(entries) {
return { read(name) { const value = entries[name]; return value == null ? null : Buffer.from(typeof value === 'object' ? JSON.stringify(value) : value); } };
}
function zipFixture(name, content, compressed = false) {
const bytes = Buffer.from(content), data = compressed ? zlib.deflateRawSync(bytes) : bytes, filename = Buffer.from(name);
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(compressed ? 8 : 0, 8);
local.writeUInt32LE(data.length, 18); local.writeUInt32LE(bytes.length, 22); local.writeUInt16LE(filename.length, 26);
const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(compressed ? 8 : 0, 10);
central.writeUInt32LE(data.length, 20); central.writeUInt32LE(bytes.length, 24); central.writeUInt16LE(filename.length, 28);
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10);
end.writeUInt32LE(central.length + filename.length, 12); end.writeUInt32LE(local.length + filename.length + data.length, 16);
return Buffer.concat([local,filename,data,central,filename,end]);
}
test('ZIP asset lookup supports stored/deflated entries and rejects corrupt/oversized archives', () => {
const h = harness(), name = 'assets/minecraft/models/item/example.json';
for (const deflate of [false,true]) assert.equal(h.indexPropZip(zipFixture(name,'{}',deflate)).read(name).toString(), '{}');
assert.throws(() => h.indexPropZip(Buffer.from('not a zip')), /Not a supported/);
const oversized = zipFixture(name,'{}');
oversized.writeUInt32LE(17000000, 30 + name.length + 2 + 24);
assert.throws(() => h.indexPropZip(oversized).read(name), /oversized/);
const broken = zipFixture(name,'{}'); broken.writeUInt32LE(0,0);
assert.throws(() => h.indexPropZip(broken).read(name), /Invalid asset entry/);
});
test('resource paths cannot escape assets and extracted sources resolve textures', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-props-'));
t.after(() => fs.rmSync(root,{ recursive:true,force:true }));
for (const id of ['minecraft:../private','../private','minecraft:item//foo','minecraft:/foo']) assert.throws(() => h.propResourcePath(id,'models','.json'));
fs.mkdirSync(path.join(root,'assets/minecraft/textures'),{recursive:true});
fs.writeFileSync(path.join(root,'assets/minecraft/textures/test.png'),'image');
assert.equal(h.openPropSource(path.join(root,'assets')).read('assets/minecraft/textures/test.png').toString(),'image');
});
test('generated models inherit textures/display and resolve modern item definitions', () => {
const h = harness();
const s = source({
'assets/minecraft/items/carrot.json': {model:{type:'minecraft:model',model:'minecraft:item/carrot'}},
'assets/minecraft/models/item/carrot.json': {parent:'minecraft:item/generated',textures:{layer0:'#actual',actual:'minecraft:item/carrot'}},
'assets/minecraft/models/item/generated.json': {parent:'builtin/generated',display:h.manualPropModel('generated').display},
'assets/minecraft/textures/item/carrot.png':'PNG',
});
const result = h.loadPropAsset(s,'minecraft:carrot','right');
assert.equal(result.parts.length,1);
assert.equal(result.parts[0].generated,true);
assert.deepEqual(Array.from(result.parts[0].transform.scale),[0.55,0.55,0.55]);
assert.equal(result.parts[0].png,'data:image/png;base64,UE5H');
});
test('left-hand and floor transforms follow Minecraft display contexts', () => {
const h = harness(), model = {display:{thirdperson_righthand:{rotation:[10,20,30],translation:[1,2,3],scale:[.4,.5,.6]},ground:{scale:[.5,.5,.5]}}};
const left = h.propDisplayTransform(model,'left');
assert.deepEqual(Array.from(left.rotation),[10,-20,-30]);
assert.deepEqual(Array.from(left.translation),[-1,2,3]);
assert.deepEqual(Array.from(h.propDisplayTransform(model,'floor').scale),[.5,.5,.5]);
const handheld = h.propDisplayTransform(h.manualPropModel('handheld'),'left');
assert.deepEqual(Array.from(handheld.rotation),[0,90,-55]);
assert.deepEqual(Array.from(handheld.translation),[0,4,0.5]);
});
test('3D JSON models keep elements, UVs, rotations and texture aliases', () => {
const h = harness(), s = source({
'assets/minecraft/models/item/test.json': {textures:{all:'minecraft:block/stone'},elements:[{from:[0,0,0],to:[16,16,16],
rotation:{origin:[8,8,8],axis:'y',angle:22.5},faces:{north:{texture:'#all',uv:[0,0,16,16]}}}]},
'assets/minecraft/textures/block/stone.png':'image',
});
const result = h.loadPropAsset(s,'minecraft:test','floor');
assert.equal(result.parts[0].elements[0].faces.north.texture,'minecraft:block/stone');
assert.equal(result.parts[0].elements[0].rotation.angle,22.5);
});
test('conditional appearances use fallback, special renderers fail clearly, cycles are rejected', () => {
const h = harness(), entries = {
'assets/minecraft/items/test.json':{model:{type:'minecraft:condition',on_false:{type:'minecraft:model',model:'minecraft:item/test'}}},
'assets/minecraft/models/item/test.json':{parent:'builtin/generated',textures:{layer0:'minecraft:item/test'}},
'assets/minecraft/textures/item/test.png':'image',
};
assert.match(h.loadPropAsset(source(entries),'minecraft:test','right').warnings[0],/default branch/);
entries['assets/minecraft/items/test.json']={model:{type:'minecraft:special'}};
assert.throws(() => h.loadPropAsset(source(entries),'minecraft:test','right'),/manual texture/);
assert.throws(() => h.resolvePropModel(source({'assets/minecraft/models/item/a.json':{parent:'item/b'},
'assets/minecraft/models/item/b.json':{parent:'item/a'}}),'item/a'),/Cyclic/);
assert.throws(() => h.propTextureId('#a',{a:'#b',b:'#a'}),/Cyclic/);
});
test('default props, stage replacements, hidden slots and inheritance preserve unrelated metadata', () => {
const h = harness(), def = {actors:[{label:'player',injector:'V'},{label:'wolf'}],stages:[{stage:1},{stage:2,use_stage:1,props:null}]};
const defaults = h.writePropDefinition(def,'player','right',0,'item','minecraft:carrot');
assert.equal(defaults.actors[0].prop_right,'minecraft:carrot');
assert.equal(def.actors[0].prop_right,undefined);
const replace = h.writePropDefinition(defaults,'player','right',1,'item','minecraft:diamond_sword');
assert.equal(replace.stages[0].props.player.prop_right,'minecraft:diamond_sword');
const hidden = h.writePropDefinition(replace,'player','right',1,'hidden','');
assert.equal(hidden.stages[0].props.player.prop_right,null);
const inherit = h.writePropDefinition(hidden,'player','right',1,'inherit','');
assert.equal(inherit.stages[0].props,undefined);
const allHidden = h.writePropDefinition(defaults,'player','right',2,'item','minecraft:carrot');
assert.equal(allHidden.stages[1].props.wolf,null);
assert.equal(allHidden.stages[1].props.player.prop_left,null);
assert.equal(allHidden.stages[1].use_stage,1);
});
test('stage preview precedence supports hidden override and default fallback', () => {
const h = harness(), entries = {};
const base = {mode:'item'}, hidden = {mode:'hidden'};
entries[h.propEntryKey('test','player','right',0)] = base;
entries[h.propEntryKey('test','player','right',2)] = hidden;
assert.equal(h.effectivePropEntry(entries,'test','player','right',1),base);
assert.equal(h.effectivePropEntry(entries,'test','player','right',2),hidden);
const def = {actors:[{label:'player',prop_right:'minecraft:carrot'}],stages:[{stage:1},
{stage:2,props:{player:{prop_right:null}}},{stage:3,props:null},{stage:4,props:{player:{prop_right:'minecraft:stick'}}}]};
assert.equal(h.effectivePropItem(def,'player','right',1),'minecraft:carrot');
assert.equal(h.effectivePropItem(def,'player','right',2),null);
assert.equal(h.effectivePropItem(def,'player','right',3),null);
assert.equal(h.effectivePropItem(def,'player','right',4),'minecraft:stick');
});
test('sprite extrusion preserves holes and omits internal boundary faces', () => {
const h = harness(), pixels = new Uint8Array(3*3*4);
for(let i=0;i<9;i++) pixels[i*4+3] = i === 4 ? 0 : 255;
const elements = h.extrudePropPixels(3,3,pixels);
assert.equal(elements.length,9);
assert.equal(elements.slice(1).reduce((sum,e)=>sum+Object.keys(e.faces).length,0),16);
assert.throws(() => h.extrudePropPixels(512,512,new Uint8Array()),/65,536/);
});
test('asset discovery reuses Asset Browser cache without requiring the plugin', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-cache-'));
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
fs.mkdirSync(path.join(root,'minecraft_assets_cache'));
fs.writeFileSync(path.join(root,'minecraft_assets_cache','1.21.11.jar'),'jar');
h.context.SystemInfo = {user_data_directory:root};
const sources = h.discoverPropSources();
assert.equal(sources.length,1);
assert.equal(sources[0].label,'Asset Browser: 1.21.11');
});
test('Item Props uses native dialog form fields and Apply/Cancel actions', () => {
const h = harness();
let configuration;
h.context.Animation.selected = { name: 'test.p1' };
h.context.SystemInfo = { user_data_directory: path.join(os.tmpdir(), 'missing-prop-assets') };
h.context.Dialog = class {
constructor(options) { configuration = options; }
};
h.createPropDialog();
assert.equal(configuration.title,'Item Props');
assert.equal(configuration.width,760);
assert.deepEqual(Array.from(configuration.buttons),['Cancel','Apply prop']);
assert.equal(configuration.component,undefined);
assert.equal(configuration.form.item.type,'text');
assert.equal(configuration.form.source.type,'select');
assert.equal(configuration.form.jar.type,'file');
assert.equal(configuration.form.folder.type,'folder');
assert.equal(configuration.form.texture.type,'file');
assert.equal(typeof configuration.form.item_choice.options,'function');
});
module.exports = { harness };