import { describe, expect, it } from 'vitest';
import { samplePose, validateClip, footMinY } from '../clip';
import type { AnimClip } from '../clip';
import { makeRig } from '../rig';
/** Простой клип-ходьба: кость «leg» качается, кость «root» приседает. */
function walkClip(): AnimClip {
const bone = (rotX: number, rootY: number) => ({
bones: { leg: { rot: [rotX, 0, 0] as const }, root: { pos: [0, rootY, 0] as const } },
});
return {
name: 'walk',
duration: 1,
loop: true,
keys: [
{ t: 0, ...bone(0, 0) },
{ t: 0.25, ...bone(0.5, -0.3) },
{ t: 0.5, ...bone(0, 0) },
{ t: 0.75, ...bone(-0.5, -0.3) },
{ t: 1, ...bone(0, 0) },
],
};
}
describe('samplePose (семплинг позы)', () => {
it('в ключах — точные значения', () => {
const pose = samplePose(walkClip(), 0.25);
expect(pose.leg?.rot).toEqual([0.5, 0, 0]);
expect(pose.root?.pos).toEqual([0, -0.3, 0]);
});
it('между ключами — линейная интерполяция по каналам', () => {
const pose = samplePose(walkClip(), 0.375); // середина между 0.25 и 0.5
expect(pose.leg?.rot?.[0]).toBeCloseTo(0.25);
expect(pose.root?.pos?.[1]).toBeCloseTo(-0.15);
});
it('зацикленный клип: t за пределами длительности заворачивается', () => {
const a = samplePose(walkClip(), 1.25);
const b = samplePose(walkClip(), 0.25);
expect(a.leg?.rot?.[0]).toBeCloseTo(b.leg?.rot?.[0] ?? NaN);
const c = samplePose(walkClip(), -0.75);
expect(c.leg?.rot?.[0]).toBeCloseTo(b.leg?.rot?.[0] ?? NaN);
});
it('незацикленный клип: за концом держит последний ключ', () => {
const clip = { ...walkClip(), loop: false };
expect(samplePose(clip, 5).leg?.rot?.[0]).toBeCloseTo(0);
});
it('кость только в одном из соседних ключей — берётся из ближнего целиком', () => {
const clip: AnimClip = {
name: 'gaps', duration: 1, loop: false,
keys: [
{ t: 0, bones: { arm: { rot: [1, 0, 0] } } },
{ t: 1, bones: {} },
],
};
const mid = samplePose(clip, 0.9);
expect(mid.arm?.rot).toEqual([1, 0, 0]); // ближний ключ 0 несёт канал целиком
});
});
describe('validateClip (числовая валидация)', () => {
it('корректный цикл — пустой список проблем', () => {
expect(validateClip(walkClip())).toEqual([]);
});
it('ловит разрыв цикла на стыке', () => {
const clip = walkClip();
clip.keys[clip.keys.length - 1] = { t: 1, bones: { leg: { rot: [0.3, 0, 0] } } };
const problems = validateClip(clip);
expect(problems.some((p) => p.includes('рвётся'))).toBe(true);
});
it('ловит немонотонность и выход t за границы', () => {
const clip = walkClip();
clip.keys[2].t = 0.1;
const problems = validateClip(clip);
expect(problems.some((p) => p.includes('не больше предыдущего'))).toBe(true);
clip.keys[2].t = 2;
expect(validateClip(clip).some((p) => p.includes('вне'))).toBe(true);
});
it('ловит пустой клип и нулевую длительность', () => {
expect(validateClip({ name: 'e', duration: 0, loop: false, keys: [] }).length).toBe(2);
});
});
describe('footMinY (контакт стопы)', () => {
it('кость, поднятая позой, поднимает свои воксели', () => {
// модель 1×2×2: воксели (0,0,0) кость foot, (0,1,0) кость body
const model = {
size: [1, 2, 2] as [number, number, number],
palette: { 1: '#101010', 2: '#202020' },
data: btoa(String.fromCharCode(1, 2, 0, 0)),
};
const rig = makeRig(
[
{ name: 'root', parent: -1, pivot: [0.5, 1, 0.5] },
{ name: 'foot', parent: 0, pivot: [0.5, 0.5, 0.5] },
],
model,
[1, 0, -1, -1],
);
const clip: AnimClip = {
name: 'hop', duration: 1, loop: true,
keys: [
{ t: 0, bones: { foot: { pos: [0, 0, 0] } } },
{ t: 0.5, bones: { foot: { pos: [0, 2, 0] } } },
{ t: 1, bones: { foot: { pos: [0, 0, 0] } } },
],
};
expect(footMinY(rig, model, clip, 'foot', 0)).toBeCloseTo(0.5); // центр вокселя на y=0.5
expect(footMinY(rig, model, clip, 'foot', 0.25)).toBeCloseTo(1.5); // интерполяция +1
});
});