186 lines
11 KiB
JavaScript
186 lines
11 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,
|
|
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 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 };
|