Files
AnimationDirector-Blockbenc…/test/previous-stage-pose.test.js
T
2026-09-29 13:29:42 +02:00

135 lines
6.8 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
function harness() {
const context = {
Plugin: { register() {} }, Format: { id: 'geckolib_model' }, Project: {},
Animation: { all: [], selected: null }, Group: { all: [] }, Timeline: { time: 0.7 },
Animator: { resetLastValues() {}, preview() {} },
edits: [], Undo: { initEdit(edit) { context.edits.push(edit); }, finishEdit(label) { context.label = label; } },
Blockbench: { showQuickMessage(message) { context.message = message; } }, console,
};
const code = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
.replace(' Plugin.register(PLUGIN_ID, {', ' globalThis.api = { previousStagePoseSource, copyPreviousStagePose, copyFirstPoseToEnd, loopPoseUnavailableReason, refreshPreviousStagePoseButton, setButton(button) { previousStagePoseAction = button; } };\n Plugin.register(PLUGIN_ID, {');
vm.runInNewContext(code, context);
return context;
}
function animation(name, length = 2) {
return { name, length, animators: {}, getBoneAnimator(group) {
return this.animators[group.uuid] ||= animator();
} };
}
function animator() {
return { position: [], rotation: [], scale: [], addKeyframe(data) {
return this.createKeyframe(data, data.time, data.channel);
}, createKeyframe(data, time, channel) {
const frame = { ...data, time, channel, extend(data) { Object.assign(this, data); } };
this[channel].push(frame); return frame;
} };
}
test('previous pose button stays visible and explains why it cannot be used', () => {
const h = harness(), states = [], label = {};
const button = { nodes: [{ classList: { toggle(name, value) { states.push(value); } }, setAttribute() {}, querySelector() { return label; } }] };
h.api.setButton(button);
h.api.refreshPreviousStagePoseButton();
assert.match(label.textContent, /Select an animation stage/);
h.Animation.selected = animation('example.p1'); h.Animation.all = [h.Animation.selected];
h.api.refreshPreviousStagePoseButton();
assert.match(label.textContent, /no previous stage/);
assert.deepEqual(states, [true, true]);
});
test('previous stage uses numeric ordering, same animation ID and saved reused stages', () => {
const h = harness(), first = animation('example.p1'), current = animation('example.p3');
h.Animation.selected = current;
h.Animation.all = [animation('unrelated.p2'), current, first];
h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 1 }, { stage: 2, use_stage: 1 }, { stage: 3 }] } });
assert.equal(h.api.previousStagePoseSource().source, first);
assert.equal(h.api.previousStagePoseSource().previous, 2);
h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 2, use_stage: 9 }] } });
assert.match(h.api.previousStagePoseSource().reason, /missing animation p9/);
});
test('copies evaluated endpoint for all actors, resets absent channels and preserves later frames and effects', () => {
const h = harness(), source = animation('example.p1'), target = animation('example.p2');
h.Animation.selected = target; h.Animation.all = [target, source];
const groups = [{ uuid: 'a', name: 'actor1_body' }, { uuid: 'b', name: 'actor2_head' }, { uuid: 'guide', name: '__ad_block_requirement_x' }];
h.Group.all = groups;
source.animators.a = animator();
source.animators.a.position = [{ time: 1 }];
source.animators.a.interpolate = (channel, allowExpression) => {
assert.equal(h.Timeline.time, 2); assert.equal(allowExpression, false);
return [4, 5, 6];
};
target.animators.a = animator();
const initial = target.animators.a.createKeyframe({ data_points: [{ x: 99 }] }, 0, 'position');
const later = target.animators.a.createKeyframe({ data_points: [{ x: 77 }] }, 1, 'position');
target.animators.effects = { sound: [{ time: 0, effect: 'test' }] };
h.api.copyPreviousStagePose();
assert.equal(h.Timeline.time, 0.7);
assert.equal(initial.data_points[0].x, 4);
assert.equal(later.data_points[0].x, 77);
assert.equal(target.animators.b.scale[0].data_points[0].x, 1);
assert.equal(target.animators.b.rotation[0].data_points[0].x, 0);
assert.equal(target.animators.effects.sound[0].effect, 'test');
assert.equal(target.animators.guide, undefined);
assert.equal(h.edits.length, 1);
});
test('failed endpoint evaluation restores playhead and leaves target unmodified', () => {
const h = harness(), source = animation('example.p1'), target = animation('example.p2');
h.Animation.selected = target; h.Animation.all = [source, target];
h.Group.all = [{ uuid: 'a', name: 'actor1_body' }];
source.animators.a = { position: [{}], interpolate() { return [NaN, 0, 0]; } };
assert.throws(() => h.api.copyPreviousStagePose(), /Cannot evaluate/);
assert.equal(h.Timeline.time, 0.7);
assert.equal(h.edits.length, 0);
assert.deepEqual(target.animators, {});
});
test('loop pose copies time zero to exact endpoint without modifying intermediate keys or source pose', () => {
const h = harness(), clip = animation('example.p1', 2.1234);
h.Animation.selected = clip; h.Animation.all = [clip];
const group = { uuid: 'a', name: 'actor1_body' };
h.Group.all = [group, { uuid: 'empty', name: 'actor2_head' }];
const bone = clip.animators.a = animator();
const first = bone.createKeyframe({ data_points: [{ x: 5, y: 6, z: 7 }] }, 0, 'position');
const middle = bone.createKeyframe({ data_points: [{ x: 99 }] }, 1, 'position');
const last = bone.createKeyframe({ data_points: [{ x: 88 }] }, clip.length, 'position');
bone.interpolate = () => { assert.equal(h.Timeline.time, 0); return [5, 6, 7]; };
h.api.copyFirstPoseToEnd();
assert.equal(first.data_points[0].x, 5);
assert.equal(middle.data_points[0].x, 99);
assert.equal(last.data_points[0].x, 5);
assert.equal(bone.scale.length, 0);
assert.equal(bone.rotation.length, 0);
assert.equal(clip.animators.empty, undefined);
assert.equal(clip.length, 2.1234);
assert.equal(h.Timeline.time, 0.7);
assert.equal(h.edits.length, 1);
});
test('loop pose on an unkeyed clip does not create animators or an undo edit', () => {
const h = harness(), clip = animation('example.p1');
h.Animation.selected = clip; h.Animation.all = [clip];
h.Group.all = [{ uuid: 'a', name: 'actor1_body' }];
h.api.copyFirstPoseToEnd();
assert.deepEqual(clip.animators, {});
assert.equal(h.edits.length, 0);
});
test('loop pose is unavailable without a clip or with a zero length', () => {
const h = harness();
assert.match(h.api.loopPoseUnavailableReason(), /Select an animation/);
h.Animation.selected = animation('example.p1', 0);
h.api.copyFirstPoseToEnd();
assert.match(h.message, /positive animation clip length/);
assert.equal(h.edits.length, 0);
});