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 };