const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); function harness() { const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( ' Plugin.register(PLUGIN_ID, {', ' globalThis.blockTest = { readBlockRequirementForm, buildBlockRequirementGuideBoxes, stripBlockRequirementGuideBones, showAnimationDefinitionReviewDialog, rebuildBlockRequirementGuides, getAuthoredBlockRequirement, compileGeckoModelJson, createBlockRequirementPanel, hideInternalBlockGuideFaces, refreshBlockGuideFaces, prepareAnimationDefinitionImport, applyAnimationDefinitionImport, importAnimationDefinition, getSavedAnimdef, getPanel: () => blockRequirementPanel };\n Plugin.register(PLUGIN_ID, {' ); const project = { uuid: 'project-one', multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) }; const dialogs = []; const exports = []; const errors = []; const edits = []; const events = new Map(); const previewEvents = new Map(); const timers = new Map(); const previewUpdates = []; const menus = []; let sequence = 0; class Group { static all = []; constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); } init() { Group.all.push(this); return this; } addTo(parent) { this.parent = parent; if (parent instanceof Group) parent.children.push(this); } remove() { for (const child of this.children.slice()) child.remove(); Group.all = Group.all.filter((group) => group !== this); } } class Cube { static all = []; static preview_controller = { on(names, listener) { for (const name of names.split(' ')) { const previous = previewEvents.get(name); previewEvents.set(name, (event) => { previous?.(event); listener(event); }); } }, }; constructor(data) { Object.assign(this, data, { uuid: `cube-${++sequence}`, mesh: {}, faces: Object.fromEntries(['north', 'south', 'east', 'west', 'up', 'down'].map((name) => [name, { texture: false }])) }); } init() { Cube.all.push(this); } addTo(parent) { this.parent = parent; parent.children.push(this); } getAllAncestors() { return [this.parent]; } remove() { Cube.all = Cube.all.filter((cube) => cube !== this); } } const context = { localStorage: { getItem() { return null; }, setItem() {} }, Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } }, Action: class { constructor(id, options) { Object.assign(this, options, { id }); } }, Toggle: class { constructor(id, options) { Object.assign(this, options, { id, value: false }); } }, Timeline: { loop() {} }, Panel: class { constructor(id, options) { this.options = options; this.vue = Object.assign(options.component.data(), options.component.methods); this.vue.$set = (object, key, value) => { object[key] = value; }; } }, setTimeout(callback) { const id = ++sequence; timers.set(id, callback); return id; }, clearTimeout(id) { timers.delete(id); }, MenuBar: { menus: { tools: { addAction(menu) { menus.push(menu); } } }, update() {} }, THREE: { MeshBasicMaterial: class { constructor(options) { Object.assign(this, options); } }, DoubleSide: 2 }, Canvas: { updateAll() { for (const cube of Cube.all) { cube.mesh.material = 'selected-shared-texture'; previewEvents.get('update_faces')?.({ element: cube }); } }, updateAllFaces() { this.updateAll(); }, updateView(options) { previewUpdates.push(options); for (const cube of options.elements || []) { cube.mesh.material = 'selected-shared-texture'; if (options.element_aspects?.faces) previewEvents.get('update_faces')?.({ element: cube }); } }, }, Undo: { initEdit(aspects) { edits.push({ before: project.animationdirector_block_requirements, aspects }); }, finishEdit(action, aspects) { Object.assign(edits.at(-1), { after: project.animationdirector_block_requirements, action, post: aspects }); }, }, Dialog: class { constructor(options) { dialogs.push(options); } show() {} }, Blockbench: { isWeb: false, export(options, callback) { exports.push(JSON.parse(options.content)); callback(); }, on(names, listener) { for (const name of names.split(' ')) events.set(name, listener); }, addCSS() { return { delete() {} }; }, showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } }, Buffer, requireNativeModule: require, console, }; vm.runInNewContext(source, context); return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, previewUpdates, menus, Group, Cube }; } function wallForm(overrides = {}) { return { type: 'wall', blocks: '#minecraft:fences', height_min: 3, height_max: '', width_min: 2, width_max: '', facing: '', clearance_width: 1, clearance_height: 2, clearance_depth: 1, wall_thickness: 1, ...overrides }; } function importAnalysis() { return { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]), stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 }, { stageNumber: 2, name: 'test.p2', loop: false, cycleSeconds: 2 }], warnings: [] }; } test('imported beds and fences receive their editor-only preview dimensions', () => { const cases = [ ['center_support', '#minecraft:beds', 0.5625], ['center_support', 'minecraft:red_bed', 0.5625], ['center_support', ['minecraft:white_bed', 'minecraft:blue_bed'], 0.5625], ['wall', '#minecraft:fences', 0.25], ['wall', 'minecraft:oak_fence', 0.25], ['wall', 'minecraft:nether_brick_fence', 0.25], ['wall', 'minecraft:oak_fence_gate', 1], ['wall', ['minecraft:oak_fence', 'minecraft:stone'], 1], ]; for (const [type, blocks, expected] of cases) { const h = harness(); const requirement = type === 'wall' ? { type, blocks, height: { min: 1 }, width: { min: 1 } } : { type, blocks, support: 'surface', placement: 'surface_footprint' }; const source = { actors: [{ label: 'player' }, { label: 'wolf' }], stages: [{ stage: 1 }, { stage: 2 }], block_requirements: requirement }; h.project.animationdirector_block_requirements = JSON.stringify({ test: { requirement: { type: 'wall', blocks: 'minecraft:stone' }, preview: { surfaceHeight: 1.5, wallThickness: 0.75 } } }); h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source); const entry = h.getAuthoredBlockRequirement('test'); assert.equal(type === 'wall' ? entry.preview.wallThickness : entry.preview.surfaceHeight, expected); assert.equal(h.getSavedAnimdef('test').block_requirements.preview, undefined); if (type === 'center_support') { const surface = h.buildBlockRequirementGuideBoxes(entry).find((box) => box.kind === 'required'); assert.ok(surface); } } }); test('desktop-only tools are visible in desktop Blockbench and hidden in browser Blockbench', () => { const h = harness(); h.context.registration.onload(); const actions = h.menus.flatMap((menu) => menu.children).filter((action) => action && typeof action !== 'string'); const ids = ['animationdirector_automatic_import', 'animationdirector_pack_folders', 'animationdirector_item_props', 'animationdirector_sound_library', 'animationdirector_particle_library', 'animationdirector_create_jem_model']; for (const id of ids) { const action = actions.find((entry) => entry.id === id); assert.ok(action, `${id} is registered`); assert.equal(action.condition(), true, `${id} is visible on desktop`); h.context.Blockbench.isWeb = true; assert.equal(action.condition(), false, `${id} is hidden in browsers`); h.context.Blockbench.isWeb = false; } }); test('animation menu orders import and creation actions and has no deprecated split-file tools', () => { const h = harness(); h.context.registration.onload(); const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims'); assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [ 'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders', '_', 'Actor Preferences', 'Replace Actor Model', '_', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'NoN Particle Library', 'Particle Previews', '_', 'Animation Definition', 'Export Animations', ]); const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8'); assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/); }); test('import prefills generator fields, builds guides and retains fields without UI controls on export', () => { const h = harness(); const source = { display_name: 'Imported animation', animators: ['Animator'], content_tags: ['test'], system_tags: ['manualpeak'], weight: 2.5, required_union_tags: ['example.tag'], join_after: ['example:previous'], match_content_on_join: true, attack_eligible: false, liquid_gain_multiplier: 0.5, manual_peak: { priority: 5 }, speed: 1.5, water: 'surface', position_anchor_actor: 'wolf', camera: { torso_tracking: 'continuous', block_requirement_collision: 'ignore' }, addon_data: { example: { flag: true } }, escapable: false, actors: [ { label: 'player', entity_types: ['minecraft:player'], activity: 'passive', receiver: true, age: 'adult' }, { label: 'wolf', entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'], activity: 'active', injector: 'V', injects_into: 'player', held_item: { items: ['#minecraft:swords'], hand: 'either', prop_slot: 'right' } }, ], stages: [ { stage: 1, loop: true, cycle_seconds: 0.75, allow_join: false, escapable: true, speed: 1.25, stage_duration_multiplier: 0.5, cycle_midpoint_offset_seconds: 0.1, props: { right: { item: 'minecraft:carrot' } } }, { stage: 2, loop: false, cycle_seconds: 1.75, allow_join: true, escapable: false, non_peak: true }, ], block_requirements: { type: 'wall', blocks: '#minecraft:fences', height: { min: 3 }, width: { min: 2, max: 2, both_axes: true }, facing: 'front', clearance: { width: 1, height: 2, depth: 1 } }, }; h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source); const dialog = h.dialogs.at(-1); const values = Object.fromEntries(Object.entries(dialog.form) .filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value])); assert.equal(values.display_name, source.display_name); assert.equal(values.animators, 'Animator'); assert.equal(values.weight, 2.5); assert.equal(values.system_tags, 'manualpeak'); assert.equal(values.manual_peak_priority, 5); assert.equal(values.stub_actor_1_receiver, true); assert.equal(values.stub_actor_2_injector, 'V'); assert.deepEqual(Array.from(values.stub_actor_2_injects_into), ['player']); assert.equal(values.stub_actor_2_held_items, '#minecraft:swords'); assert.equal(values.stub_actor_2_held_hand, 'either'); assert.equal(values.stub_stage_1_duration_multiplier, 0.5); assert.equal(values.stub_stage_1_cycle_seconds, 0.75); assert.equal(values.stub_stage_2_peaked, true); assert.ok(h.Cube.all.length > 0); assert.equal(h.edits.length, 1); assert.equal(h.edits[0].action, 'Import animation definition'); assert.equal(h.edits[0].aspects.animdef_edit, true); assert.equal(dialog.onConfirm(values), true); const exported = h.exports.at(-1); assert.deepEqual(exported.stages[0].props, source.stages[0].props); assert.deepEqual(exported.addon_data, source.addon_data); assert.equal(exported.actors[0].age, 'adult'); assert.equal(exported.actors[1].injects_into, 'player'); assert.equal(exported.block_requirements.width.both_axes, true); assert.equal(h.errors.length, 0); }); test('importing an animdef replaces saved fields instead of retaining old settings', () => { const h = harness(); h.project.multiactor_animdef_animators = 'Old author'; h.project.animationdirector_animdef_definitions = JSON.stringify({ test: { display_name: 'Old', weight: 8, content_tags: ['old'], addon_data: { old: true }, actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1, non_peak: true }, { stage: 2 }], }, other: { display_name: 'Unchanged' } }); const replacement = { display_name: 'Replacement', actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }, { stage: 2, non_peak: true }], }; h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), replacement); const saved = h.getSavedAnimdef('test'); assert.equal(saved.display_name, 'Replacement'); assert.equal(saved.weight, undefined); assert.equal(saved.content_tags, undefined); assert.equal(saved.addon_data, undefined); assert.equal(h.getSavedAnimdef('other').display_name, 'Unchanged'); const dialog = h.dialogs.at(-1); assert.equal(dialog.form.stub_stage_1_peaked.value, false); assert.equal(dialog.form.stub_stage_2_peaked.value, true); assert.equal(dialog.form.animators.value, ''); }); test('import maps explicit labels before positional fallbacks and updates actor references', () => { const h = harness(); const source = { actors: [{ label: 'wolf', injector: 'V', injects_into: 'bottom' }, { label: 'bottom', receiver: true }], stages: [{ stage: 1, props: { bottom: { prop_right: 'minecraft:carrot' } } }, { stage: 2 }], position_anchor_actor: 'bottom' }; const definition = h.prepareAnimationDefinitionImport(source, importAnalysis()); assert.equal(definition.actors[0].label, 'player'); assert.equal(definition.actors[0].receiver, true); assert.equal(definition.actors[1].label, 'wolf'); assert.deepEqual(Array.from(definition.actors[1].injects_into), ['player']); assert.equal(definition.position_anchor_actor, 'player'); assert.equal(definition.stages[0].props.player.prop_right, 'minecraft:carrot'); assert.equal(source.actors[1].label, 'bottom', 'Input JSON is not mutated'); const positional = h.prepareAnimationDefinitionImport({ actors: [{ receiver: true }, { injector: 'V' }], stages: [{ stage: 1 }, { stage: 2 }] }, importAnalysis()); assert.equal(positional.actors[0].label, 'player'); assert.equal(positional.actors[1].label, 'wolf'); }); test('manual definition import embeds item prop previews in the same undoable edit', (t) => { const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-manual-props-')); t.after(() => fs.rmSync(root,{recursive:true,force:true})); const write=(relative,data)=>{ const file=path.join(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true}); fs.writeFileSync(file,typeof data==='string'?data:JSON.stringify(data)); }; write('assets/minecraft/items/carrot.json',{model:{type:'minecraft:model',model:'minecraft:item/carrot'}}); write('assets/minecraft/models/item/carrot.json',{parent:'minecraft:builtin/generated',textures:{layer0:'minecraft:item/carrot'}}); write('assets/minecraft/textures/item/carrot.png','image'); h.applyAnimationDefinitionImport({id:'test'},importAnalysis(),{ actors:[{label:'player',prop_right:'minecraft:carrot'},{label:'wolf'}], stages:[{stage:1},{stage:2,props:{player:{prop_right:null}}}], },[root]); const previews=JSON.parse(h.project.animationdirector_prop_previews); assert.equal(previews['["test","player","right",0]'].item,'minecraft:carrot'); assert.equal(previews['["test","player","right",2]'].mode,'hidden'); assert.equal(h.edits.at(-1).aspects.prop_preview_edit,true); assert.equal(h.edits.at(-1).post.prop_preview_edit,true); }); test('invalid imports leave definitions and guides unchanged', () => { const h = harness(); h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm())); const before = h.project.animationdirector_block_requirements; const source = { actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }] }; for (const invalid of [null, { animations: {} }, { ...source, actors: [{}] }, { ...source, stages: [{ stage: 1 }] }, { ...source, stages: [{ stage: 1 }, { stage: 1 }] }, { ...source, actors: [{ label: 'same' }, { label: 'same' }] }, { ...source, actors: [{ injects_into: 'unknown' }, {}] }, { ...source, content_tags: 'wrong' }, { ...source, block_requirements: { type: 'wall', width: { min: 2, max: 1 } } }]) { assert.throws(() => h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), invalid)); } assert.equal(h.project.animationdirector_block_requirements, before); assert.equal(h.project.animationdirector_animdef_definitions, undefined); assert.equal(h.edits.length, 1); }); test('import without a requirement removes old guides and undo restores definition metadata too', () => { const h = harness(); h.context.registration.onload(); h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm())); const before = {}; h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true, animdef_edit: true } }); h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), { actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }], animation_tags: ['defeat'] }); assert.equal(h.Cube.all.length, 0); assert.equal(h.getAuthoredBlockRequirement('test').requirement, null); assert.deepEqual(Array.from(h.getSavedAnimdef('test').system_tags), ['defeat']); assert.equal(h.dialogs.at(-1).form.stub_stage_2_peaked.value, false, 'Missing peak flags are not invented'); const after = {}; h.events.get('create_undo_save')({ save: after, aspects: { block_requirement_edit: true, animdef_edit: true } }); h.events.get('load_undo_save')({ save: before }); assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall'); assert.equal(Object.keys(h.getSavedAnimdef('test')).length, 0); h.events.get('load_undo_save')({ save: after }); assert.equal(h.getAuthoredBlockRequirement('test').requirement, null); assert.equal(h.getSavedAnimdef('test').actors.length, 2); }); test('building, editing, refreshing and removing guides never resets actor or folder texture overrides', () => { const h = harness(); const actor = new h.Group({ name: 'actor1_body' }); actor.init(); actor.addTo('root'); const cubes = ['actor-texture', 'folder-override-texture'].map((material, index) => { const cube = new h.Cube({ name: `actor1_cube${index}`, from: [0, 0, 0], to: [16, 16, 16] }); cube.init(); cube.addTo(actor); cube.mesh.material = material; for (const face of Object.values(cube.faces)) face.texture = material; return cube; }); const before = cubes.map((cube) => ({ material: cube.mesh.material, faces: JSON.stringify(cube.faces) })); h.context.registration.onload(); h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm())); h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm({ width_min: 3, clearance_depth: 2 }))); h.refreshBlockGuideFaces(); h.rebuildBlockRequirementGuides('test', null); assert.deepEqual(cubes.map((cube) => ({ material: cube.mesh.material, faces: JSON.stringify(cube.faces) })), before); for (const update of h.previewUpdates) { assert.ok(update.elements.every((cube) => !cubes.includes(cube))); assert.ok((update.groups || []).every((group) => group !== actor)); } assert.equal(h.previewUpdates.length, 4); }); test('guide cubes omit internal faces, preserve colored boundaries and restore newly exposed faces', () => { const h = harness(); const make = (kind, from, to) => new h.Cube({ name: `__ad_block_requirement_${kind}_test`, from, to }); const left = make('required', [0, 0, 0], [16, 16, 16]); const right = make('required', [16, 0, 0], [32, 16, 16]); const clearance = make('clearance', [32, 0, 0], [48, 16, 16]); h.hideInternalBlockGuideFaces([left, right, clearance]); assert.equal(left.faces.east.texture, null); assert.equal(right.faces.west.texture, null); assert.equal(left.faces.west.texture, false); assert.equal(right.faces.east.texture, false, 'Blue/red boundaries remain visible'); assert.equal(clearance.faces.west.texture, false); h.hideInternalBlockGuideFaces([left]); assert.equal(left.faces.east.texture, false, 'Removing a neighbor exposes its former shared face'); }); test('several neighbors can cover one larger guide face without hiding partially exposed surfaces', () => { const h = harness(); const make = (from, to) => new h.Cube({ name: '__ad_block_requirement_clearance_test', from, to }); const tall = make([0, 0, 0], [16, 32, 16]); const lower = make([16, 0, 0], [32, 16, 16]); const upper = make([16, 16, 0], [32, 32, 16]); h.hideInternalBlockGuideFaces([tall, lower, upper]); assert.equal(tall.faces.east.texture, null); assert.equal(lower.faces.west.texture, null); assert.equal(upper.faces.west.texture, null); assert.equal(lower.faces.up.texture, null); assert.equal(upper.faces.down.texture, null); h.hideInternalBlockGuideFaces([tall, lower]); assert.equal(tall.faces.east.texture, false, 'The upper exposed half must not disappear'); assert.equal(lower.faces.up.texture, false); }); test('generated and reloaded guides remove their shared faces without affecting another animation', () => { const h = harness(); h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm())); const firstFaces = h.Cube.all.map((cube) => JSON.stringify(cube.faces)); assert.ok(h.Cube.all.some((cube) => Object.values(cube.faces).some((face) => face.texture === null))); h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm())); assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces); for (const cube of h.Cube.all) for (const face of Object.values(cube.faces)) face.texture = false; h.refreshBlockGuideFaces(); assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces); }); test('wall data matches NoN minimum/maximum/facing syntax and ignores inactive fields', () => { const h = harness(); const entry = h.readBlockRequirementForm(wallForm({ width_max: '2', height_max: '3', both_axes: true, include_obstructions: true, facing: 'front', surface_radius: 9, footprint_width: 9 })); assert.deepEqual(JSON.parse(JSON.stringify(entry.requirement)), { type: 'wall', blocks: '#minecraft:fences', height: { min: 3, max: 3 }, width: { min: 2, max: 2, both_axes: true, include_obstructions: true }, facing: 'front', clearance: { width: 1, height: 2, depth: 1 }, }); assert.equal(h.readBlockRequirementForm(wallForm({ both_axes: true })).requirement.width.both_axes, undefined); assert.throws(() => h.readBlockRequirementForm(wallForm({ width_max: 1 })), /Maximum width/); assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: '', facing: 'front' })), /facing requirement/); assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: 'fence' })), /Invalid block/); }); test('wall guides center an even span and reproduce side padding, anchor distance and required floors', () => { const h = harness(); const entry = h.readBlockRequirementForm(wallForm({ clearance_width: 2 })); const boxes = h.buildBlockRequirementGuideBoxes(entry); const walls = boxes.filter((box) => box.label === 'wall'); assert.equal(walls.length, 6); assert.equal(Math.min(...walls.map((box) => box.from[0])), -16); assert.equal(Math.max(...walls.map((box) => box.to[0])), 16); assert.equal(walls[0].from[2], -24); assert.equal(walls[0].to[2], -8); const clearance = boxes.filter((box) => box.kind === 'clearance'); assert.equal(clearance.length, 8); assert.equal(Math.min(...clearance.map((box) => box.from[0])), -32); assert.equal(Math.max(...clearance.map((box) => box.to[0])), 32); assert.equal(clearance[0].from[2], -8); assert.equal(boxes.filter((box) => box.label === 'floor').length, 6); const thin = h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ wall_thickness: 0.25 }))); assert.equal(thin.find((box) => box.label === 'wall').from[2], -12); assert.equal(thin.find((box) => box.kind === 'clearance').from[2], -2); }); test('center footprint supports fractional heights and clearance margins without directional fields', () => { const h = harness(); const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface', blocks: '#minecraft:beds', placement: 'surface_footprint', footprint_width: 2, footprint_depth: 2, footprint_height: 2, footprint_margin: 1, surface_height: 0.5625 }); assert.equal(entry.requirement.clearance, undefined); assert.equal(entry.requirement.height, undefined); const boxes = h.buildBlockRequirementGuideBoxes(entry); assert.equal(boxes.filter((box) => box.kind === 'required').length, 4); assert.equal(boxes.filter((box) => box.kind === 'clearance').length, 32); assert.equal(boxes.find((box) => box.label === 'support').to[1], 9); assert.equal(Math.min(...boxes.filter((box) => box.kind === 'clearance').map((box) => box.from[0])), -24); assert.throws(() => h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface', placement: 'directional_clearance', surface_radius: 0, blocks: '' }), /requires a block/); }); test('directional center guides union overlapping above-surface checks and enforce preview size limits', () => { const h = harness(); const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'slab', placement: 'directional_clearance', surface_radius: 1 }); const boxes = h.buildBlockRequirementGuideBoxes(entry); const front = boxes.filter((box) => box.kind === 'clearance' && box.from[0] === -8 && box.from[2] === 8); assert.equal(front.length, 1); assert.equal(front[0].from[1], 0); assert.equal(front[0].to[1], 40); assert.throws(() => h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ width_min: 32, clearance_width: 32, clearance_depth: 32, clearance_height: 32 }))), /4096/); }); test('editing replaces only target guides, persists metadata and removal overrides previously exported requirements', () => { const h = harness(); h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm())); h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm())); const second = h.Group.all.find((group) => group.name.endsWith('second')); const first = h.Group.all.find((group) => group.name.endsWith('first')); const firstCube = h.Cube.all[0]; h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm({ width_min: 1 }))); assert.equal(h.Group.all.length, 2); assert.ok(h.Group.all.includes(first)); assert.ok(h.Cube.all.includes(firstCube)); assert.ok(h.Group.all.includes(second)); assert.ok(h.Cube.all.every((cube) => cube.export === false)); assert.ok(h.Group.all.every((group) => group.export === false)); assert.equal(h.getAuthoredBlockRequirement('first').requirement.width.min, 1); assert.equal(h.edits.at(-1).aspects.block_requirement_edit, true); assert.equal(h.edits.at(-1).post.block_requirement_edit, true); h.rebuildBlockRequirementGuides('first', null); assert.equal(h.Group.all.length, 1); assert.equal(h.getAuthoredBlockRequirement('first').requirement, null); }); test('live panel coalesces input bursts and leaves invalid edits out of the project and undo history', () => { const h = harness(); h.createBlockRequirementPanel(); const view = h.getPanel().vue; view.load('test'); assert.equal(h.edits.length, 0, 'Opening the editor does not add a requirement'); view.flush(true); const cube = h.Cube.all[0]; const field = view.form.clearance_depth; view.input('clearance_depth', field, { target: { value: '2' } }); view.input('clearance_depth', field, { target: { value: '3' } }); assert.equal(h.timers.size, 1); assert.equal(h.edits.length, 1); for (const callback of h.timers.values()) callback(); assert.equal(h.timers.size, 0); assert.equal(h.edits.length, 2, 'The burst creates one undo entry'); assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3); assert.ok(h.Cube.all.includes(cube)); view.input('clearance_depth', field, { target: { value: '' } }); view.flush(); assert.match(view.error, /last valid preview/); assert.equal(h.timers.size, 0); assert.equal(h.edits.length, 2); assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3); view.input('clearance_depth', field, { target: { value: '3' } }); view.flush(); assert.equal(view.error, ''); assert.equal(h.edits.length, 2, 'Unchanged values do not add undo entries'); }); test('panel switches animations safely, resets on project changes and reloads restored undo metadata', () => { const h = harness(); h.context.registration.onload(); h.createBlockRequirementPanel(); const view = h.getPanel().vue; view.load('first'); view.input('clearance_depth', view.form.clearance_depth, { target: { value: '4' } }); view.selectId({ target: { value: 'second' } }); assert.equal(h.getAuthoredBlockRequirement('first').requirement.clearance.depth, 4); assert.equal(view.animationId, 'second'); assert.equal(h.getAuthoredBlockRequirement('second'), null); view.flush(true); const before = h.project.animationdirector_block_requirements; view.input('clearance_depth', view.form.clearance_depth, { target: { value: '5' } }); view.flush(); h.events.get('load_undo_save')({ save: { animationdirector_block_requirements: before } }); h.events.get('undo')(); assert.equal(view.values.clearance_depth, 1); view.input('clearance_depth', view.form.clearance_depth, { target: { value: '7' } }); h.context.Project = { uuid: 'another-project' }; h.events.get('select_project')(); assert.equal(h.timers.size, 0); assert.equal(view.animationId, ''); assert.equal(h.context.Project.animationdirector_block_requirements, undefined); assert.equal(JSON.parse(h.project.animationdirector_block_requirements).second.requirement.clearance.depth, 1); }); test('panel conditionally exposes wall and center-support sections without exporting preview settings', () => { const h = harness(); h.createBlockRequirementPanel(); const panel = h.getPanel(); const view = panel.vue; view.load('test'); const titles = () => Array.from(panel.options.component.computed.sections.call(view), (section) => section.title); assert.ok(titles().includes('Wall structure')); assert.ok(!titles().includes('Center support')); view.values.type = 'center_support'; view.values.placement = 'surface_footprint'; assert.ok(titles().includes('Center support')); assert.ok(!titles().includes('Wall structure')); assert.ok(!titles().includes('Directional clearance')); view.flush(true); assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'center_support'); view.remove(); assert.equal(h.getAuthoredBlockRequirement('test').requirement, null); assert.equal(h.Cube.all.length, 0); }); test('guide material survives face rebuilds and metadata participates in Blockbench undo saves', () => { const h = harness(); h.context.registration.onload(); const before = {}; h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true } }); h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm())); const post = {}; h.events.get('create_undo_save')({ save: post, aspects: { block_requirement_edit: true } }); for (const kind of ['required', 'clearance']) { const cube = h.Cube.all.find((candidate) => candidate.name.includes(`_${kind}_`)); assert.equal(cube.mesh.material.opacity, 0.22); assert.equal(cube.mesh.material.transparent, true); cube.mesh.material = {}; h.previewEvents.get('update_faces')({ element: cube }); assert.equal(cube.mesh.material.opacity, 0.22); } h.events.get('load_undo_save')({ save: before }); assert.equal(h.getAuthoredBlockRequirement('test'), null); h.events.get('load_undo_save')({ save: post }); assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall'); }); test('generator includes only the selected animation requirement and strips guide bones from exports', () => { const h = harness(); const entry = h.readBlockRequirementForm(wallForm()); h.rebuildBlockRequirementGuides('test', entry); const analysis = { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]), stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 }], warnings: [] }; function generate(id) { h.showAnimationDefinitionReviewDialog({ id }, analysis); const dialog = h.dialogs.at(-1); const values = Object.fromEntries(Object.entries(dialog.form) .filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value])); assert.equal(dialog.onConfirm(values), true); return h.exports.at(-1); } assert.deepEqual(generate('test').block_requirements, JSON.parse(JSON.stringify(entry.requirement))); assert.equal(generate('other').block_requirements, undefined); h.rebuildBlockRequirementGuides('test', null); assert.equal(generate('test').block_requirements, undefined); assert.equal(h.errors.length, 0); const clip = { bones: { __ad_block_requirement_test: { rotation: [1, 2, 3] }, actor1_body: { position: [0, 1, 2] } }, sound_effects: { 0: 'sound' } }; h.stripBlockRequirementGuideBones(clip); assert.deepEqual(Object.keys(clip.bones), ['actor1_body']); assert.ok(clip.sound_effects); h.context.Format = { codec: { compile: () => ({ 'minecraft:geometry': [{ bones: [ { name: '__ad_block_requirement_test' }, { name: 'renamed_guide', parent: '__ad_block_requirement_test' }, { name: 'actor1_body' }, ] }] }) } }; h.context.Codecs = {}; assert.deepEqual(Array.from(h.compileGeckoModelJson()['minecraft:geometry'][0].bones, (bone) => bone.name), ['actor1_body']); });