Files
AnimationDirector-Blockbenc…/test/item-props.test.js
T
2026-09-28 23:01:07 +02:00

196 lines
12 KiB
JavaScript

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, propItemOptions,
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]);
const explicit = h.propDisplayTransform({display:{thirdperson_lefthand:{rotation:[5,35,-20],translation:[2,3,4]}}},'left');
assert.deepEqual(Array.from(explicit.rotation),[5,-35,20]);
assert.deepEqual(Array.from(explicit.translation),[-2,3,4]);
});
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 picker includes entries after the first hundred and filters the full source list', () => {
const h = harness();
const items = Array.from({ length: 1505 }, (_, index) => `minecraft:item_${String(index).padStart(4, '0')}`);
const all = h.propItemOptions(items, '');
assert.equal(Object.keys(all).length, 1506);
assert.equal(all['minecraft:item_1504'], 'minecraft:item_1504');
const searched = h.propItemOptions(items, ' ITEM_1504 ');
assert.deepEqual(Object.keys(searched), ['', 'minecraft:item_1504']);
});
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 };