Files
AnimationDirector-Blockbenc…/test/block-requirements.test.js
T
2026-09-28 19:52:09 +02:00

345 lines
18 KiB
JavaScript

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 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, 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();
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(' ')) previewEvents.set(name, listener); },
};
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 = {
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
Action: class { constructor(id, options) { Object.assign(this, options); } },
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() {} } }, update() {} },
THREE: { MeshBasicMaterial: class { constructor(options) { Object.assign(this, options); } }, DoubleSide: 2 },
Canvas: {
updateAll() { for (const cube of Cube.all) previewEvents.get('update_faces')?.({ element: cube }); },
updateAllFaces() { this.updateAll(); },
},
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: { 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); } },
console,
};
vm.runInNewContext(source, context);
return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, 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 };
}
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']);
});