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 variantBuilder() { const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( ' Plugin.register(PLUGIN_ID, {', ' globalThis.variantTest = { buildGenderModelVariant, refreshModelVariantFields, createModelVariantBoneSections, buildJemModelVariant };\n Plugin.register(PLUGIN_ID, {' ); const context = { Plugin: { register() {} }, console }; vm.runInNewContext(source, context); return Object.assign(context.variantTest.buildGenderModelVariant, { refresh: context.variantTest.refreshModelVariantFields, sections: context.variantTest.createModelVariantBoneSections, jem: context.variantTest.buildJemModelVariant }); } test('gender variants omit selected bones, descendants, locators and texture overrides without changing source', () => { const build = variantBuilder(); const source = { format_version: '1.12.0', 'minecraft:geometry': [{ description: { identifier: 'geometry.example', texture_width: 64 }, bones: [ { name: 'detail', parent: 'd', cubes: [{ origin: [0, 0, 0] }] }, { name: 'body', cubes: [{}] }, { name: 'd', parent: 'body', cubes: [{}], locators: { D: [0, 1, 0] } }, { name: 'head', parent: 'body', cubes: [{}] }, ], }], afw_bone_textures: { d: 'example:textures/custom.png' } }; const before = JSON.stringify(source); const female = JSON.parse(JSON.stringify(build(source, ['d']))); assert.deepEqual(female['minecraft:geometry'][0].bones.map((bone) => bone.name), ['body', 'head']); assert.deepEqual(female['minecraft:geometry'][0].bones[0].cubes, [{}]); assert.equal(female.afw_bone_textures, undefined); assert.equal(JSON.stringify(build(source, [])), before); assert.equal(JSON.stringify(source), before); }); test('variant subtree propagation handles nested children and unknown saved selections', () => { const build = variantBuilder(); const source = { 'minecraft:geometry': [{ bones: [ { name: 'grandchild', parent: 'child', cubes: [{}] }, { name: 'child', parent: 'root', cubes: [{}] }, { name: 'root', cubes: [{}] }, ] }] }; const hidden = build(source, ['root', 'deleted']); assert.equal(hidden['minecraft:geometry'][0].bones.length, 0); }); test('selected hidden parents disable and clear descendant selections separately for each gender', () => { const build = variantBuilder(); const bones = [{ name: 'body' }, { name: 'child', parent: 'body' }, { name: 'tip', parent: 'child' }]; const fields = {}, updates = []; for (const gender of ['m', 'f', 'mf']) for (let index = 0; index < bones.length; index++) { const input = { disabled: false }; fields[`hide_${gender}_${index}`] = { input, bar: { style: {}, querySelectorAll: () => [input] } }; } const dialog = { form: { form_data: fields, setValues: (values) => updates.push(values) } }; const values = { hide_m_0: true, hide_m_1: true, hide_m_2: true, hide_f_1: true, hide_mf_0: true, hide_mf_2: true }; build.refresh(dialog, bones, values); assert.equal(values.hide_m_1, false); assert.equal(values.hide_m_2, false); assert.equal(fields.hide_m_1.input.disabled, true); assert.equal(fields.hide_m_2.input.disabled, true); assert.equal(fields.hide_f_1.input.disabled, false); assert.equal(fields.hide_f_2.input.disabled, true); assert.equal(values.hide_mf_2, false); assert.equal(fields.hide_mf_2.input.disabled, true); assert.match(fields.hide_m_2.bar.title, /Already excluded/); values.hide_m_0 = false; build.refresh(dialog, bones, values); assert.equal(fields.hide_m_2.input.disabled, false); }); test('each gender bone list gets an independent bounded scroll container', () => { const build = variantBuilder(); const nodes = []; const document = { createElement() { return { style: {}, children: [], appendChild(child) { nodes.splice(nodes.indexOf(child), 1); this.children.push(child); child.parentNode = this; } }; } }; const parent = { insertBefore(node, reference) { nodes.splice(nodes.indexOf(reference), 0, node); } }; const fields = {}; for (const gender of ['m', 'f', 'mf']) for (let index = 0; index < 2; index++) { const bar = { parentNode: parent, ownerDocument: document, style: {}, querySelectorAll: () => [] }; fields[`hide_${gender}_${index}`] = { bar }; nodes.push(bar); } const dialog = { form: { form_data: fields } }; const bones = [{ name: 'body' }, { name: 'head', parent: 'body' }]; build.sections(dialog, bones); assert.equal(nodes.length, 3); for (const gender of ['m', 'f', 'mf']) { const section = dialog.modelVariantBoneSections[gender]; assert.equal(section.children.length, 2); assert.equal(section.style.maxHeight, '160px'); assert.equal(section.style.overflowY, 'auto'); assert.equal(section.children[0], fields[`hide_${gender}_0`].bar); } build.refresh(dialog, bones, { m: true, f: false, mf: true }); assert.equal(dialog.modelVariantBoneSections.f.style.display, 'none'); assert.equal(dialog.modelVariantBoneSections.mf.style.display, ''); }); test('JEM exclusion removes whole subtrees without changing remaining roots or source', () => { const model = { texture: 'custom.png', textureSize: [64, 64], models: [{ part: 'body', id: 'body', boxes: [{}], submodels: [{ id: 'd', boxes: [{}], sprites: [{}], animations: [{ 'd.visible': 'nbt(NeedsOfNatureEnergy,range:70-200)' }], submodel: { id: 'tip', boxes: [{}] } }, { id: 'detail', boxes: [{}] }] }] }; const original = JSON.stringify(model); const output = JSON.parse(JSON.stringify(variantBuilder().jem(model, ['0/0']))); assert.deepEqual(output.models[0].boxes, [{}]); assert.equal(output.models[0].submodels.length, 1); assert.equal(output.models[0].submodels[0].id, 'detail'); assert.deepEqual(output.models[0].submodels[0].boxes, [{}]); assert.equal(output.texture, 'custom.png'); assert.equal(JSON.stringify(model), original); }); test('JEM export opens the shared variant screen directly and writes numbered gender files', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( ' Plugin.register(PLUGIN_ID, {', ' globalThis.variantTest = { showCreateJemModel };\n Plugin.register(PLUGIN_ID, {' ); const model = { models: [{ part: 'body', id: 'body', boxes: [{}], submodels: [{ id: 'd', boxes: [{}] }] }] }; let dialog; const files = [], errors = []; const codec = { compile: () => model, fileName: () => 'wolf' }; const context = { Plugin: { register() {} }, Project: { name: 'wolf' }, Format: { id: 'optifine_entity', codec }, Formats: { optifine_entity: { codec } }, console, Dialog: class { constructor(options) { dialog = options; this.form = { form_data: {} }; } show() {} getFormResult() { return Object.fromEntries(Object.entries(dialog.form).map(([key, field]) => [key, field.value])); } }, Blockbench: { export: (file, callback) => { files.push(file); callback(); }, showMessageBox: (error) => errors.push(error.message) }, }; vm.runInNewContext(source, context); context.variantTest.showCreateJemModel(); assert.equal(dialog.title, 'Export JEM Models'); assert.equal(dialog.form.hide_mf_1.label, 'd'); const result = Object.fromEntries(Object.entries(dialog.form).map(([key, field]) => [key, field.value])); Object.assign(result, { base: false, m: true, f: true, mf: true, hide_f_1: true }); dialog.onConfirm(result); assert.deepEqual(errors, []); assert.deepEqual(files.map((file) => file.name), ['wolf1', 'wolf2', 'wolf3']); assert.deepEqual(Array.from(files[0].extensions), ['jem']); assert.equal(JSON.parse(files[1].content).models[0].submodels.length, 0); assert.deepEqual(JSON.parse(files[2].content), model); assert.equal(JSON.parse(context.Project.animationdirector_jem_variants).hide_f[0], '0/0'); }); test('JEM exclusion supports singular submodels and clears animation targets for removed IDs', () => { const model = { models: [{ id: 'body', submodel: { id: 'd', boxes: [{}] }, animations: [{ 'd.visible': 'true', 'body.visible': 'true' }] }, { id: 'head' }] }; const output = JSON.parse(JSON.stringify(variantBuilder().jem(model, ['0/single', '1']))); assert.equal(output.models.length, 1); assert.equal(output.models[0].submodel, undefined); assert.deepEqual(output.models[0].animations, [{ 'body.visible': 'true' }]); });