Files
hyperframes/packages/core/src/runtime/adapters/animejs.test.ts
T
Miguel Ángel f9d38a1542 feat(core): add anime.js runtime adapter (#569)
## Summary
- Adds a `RuntimeDeterministicAdapter` for anime.js v4+ alongside existing Lottie, Three.js, WAAPI, and CSS adapters
- Enables frame-accurate rendering of anime.js animations — the adapter converts seek time (seconds) to milliseconds and calls `.seek(timeMs)` on registered instances
- Auto-discovers running instances via `anime.running`; compositions can also register manually via `window.__hfAnime`

## Usage in compositions

```html
<script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script>
<script>
  const anim = anime({
    targets: '.box',
    translateX: 250,
    rotate: '1turn',
    duration: 2000,
    autoplay: false,
  });
  window.__hfAnime = window.__hfAnime || [];
  window.__hfAnime.push(anim);
</script>
```

## Files changed
- `packages/core/src/runtime/adapters/animejs.ts` — adapter implementation
- `packages/core/src/runtime/adapters/animejs.test.ts` — 15 unit tests
- `packages/core/src/runtime/init.ts` — register adapter in runtime init
- `packages/core/src/runtime/window.d.ts` — add `anime` and `__hfAnime` globals

## Test plan
- [x] All 15 unit tests pass (`bun run --cwd packages/core test -- --run adapters/animejs`)
- [x] Build passes (`bun run build`)
- [x] Pre-commit hooks pass (lint, format, typecheck, commitlint)
- [x] Manual test: render a composition using anime.js animations
2026-04-29 21:30:17 +02:00

156 lines
4.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createAnimeJsAdapter } from "./animejs";
const animeWindow = window as Window & {
anime?: {
running: unknown[];
};
__hfAnime?: unknown[];
};
function createAnimeInstance(opts?: { duration?: number }) {
return {
seek: vi.fn(),
pause: vi.fn(),
play: vi.fn(),
duration: opts?.duration ?? 2000,
};
}
describe("animejs adapter", () => {
beforeEach(() => {
delete animeWindow.anime;
delete animeWindow.__hfAnime;
});
afterEach(() => {
delete animeWindow.anime;
delete animeWindow.__hfAnime;
});
it("has correct name", () => {
expect(createAnimeJsAdapter().name).toBe("animejs");
});
describe("discover", () => {
it("auto-discovers from anime.running", () => {
const instance = createAnimeInstance();
animeWindow.anime = { running: [instance] };
animeWindow.__hfAnime = [];
const adapter = createAnimeJsAdapter();
adapter.discover();
expect(animeWindow.__hfAnime).toContain(instance);
});
it("does not duplicate existing instances", () => {
const instance = createAnimeInstance();
animeWindow.anime = { running: [instance] };
animeWindow.__hfAnime = [instance];
const adapter = createAnimeJsAdapter();
adapter.discover();
expect(animeWindow.__hfAnime).toHaveLength(1);
});
it("handles no global anime", () => {
const adapter = createAnimeJsAdapter();
expect(() => adapter.discover()).not.toThrow();
});
it("handles empty running array", () => {
animeWindow.anime = { running: [] };
const adapter = createAnimeJsAdapter();
expect(() => adapter.discover()).not.toThrow();
});
});
describe("seek", () => {
it("seeks with time in milliseconds", () => {
const instance = createAnimeInstance();
animeWindow.__hfAnime = [instance];
const adapter = createAnimeJsAdapter();
adapter.seek({ time: 2 });
expect(instance.seek).toHaveBeenCalledWith(2000);
});
it("seeks fractional seconds accurately", () => {
const instance = createAnimeInstance();
animeWindow.__hfAnime = [instance];
const adapter = createAnimeJsAdapter();
adapter.seek({ time: 0.5 });
expect(instance.seek).toHaveBeenCalledWith(500);
});
it("clamps negative time to 0", () => {
const instance = createAnimeInstance();
animeWindow.__hfAnime = [instance];
const adapter = createAnimeJsAdapter();
adapter.seek({ time: -3 });
expect(instance.seek).toHaveBeenCalledWith(0);
});
it("does nothing with no instances", () => {
const adapter = createAnimeJsAdapter();
expect(() => adapter.seek({ time: 1 })).not.toThrow();
});
it("seeks multiple instances", () => {
const a = createAnimeInstance();
const b = createAnimeInstance();
animeWindow.__hfAnime = [a, b];
const adapter = createAnimeJsAdapter();
adapter.seek({ time: 1.5 });
expect(a.seek).toHaveBeenCalledWith(1500);
expect(b.seek).toHaveBeenCalledWith(1500);
});
it("continues seeking remaining instances if one throws", () => {
const bad = {
seek: vi.fn(() => {
throw new Error("boom");
}),
pause: vi.fn(),
play: vi.fn(),
};
const good = createAnimeInstance();
animeWindow.__hfAnime = [bad, good];
const adapter = createAnimeJsAdapter();
adapter.seek({ time: 1 });
expect(good.seek).toHaveBeenCalledWith(1000);
});
});
describe("pause", () => {
it("pauses all instances", () => {
const a = createAnimeInstance();
const b = createAnimeInstance();
animeWindow.__hfAnime = [a, b];
const adapter = createAnimeJsAdapter();
adapter.pause();
expect(a.pause).toHaveBeenCalled();
expect(b.pause).toHaveBeenCalled();
});
it("does nothing with no instances", () => {
const adapter = createAnimeJsAdapter();
expect(() => adapter.pause()).not.toThrow();
});
});
describe("play", () => {
it("plays all instances", () => {
const a = createAnimeInstance();
animeWindow.__hfAnime = [a];
const adapter = createAnimeJsAdapter();
adapter.play!();
expect(a.play).toHaveBeenCalled();
});
});
describe("revert", () => {
it("does not throw", () => {
const adapter = createAnimeJsAdapter();
expect(() => adapter.revert!()).not.toThrow();
});
});
});