PR pixi-reels
Wiki · Architecture
Harness

Testing model

Why you can run a full spin in Node, with zero renderer.

pixi-reels was designed so mechanics, not pixels, are what you test. The testing module gives you a real ReelSet running against a fake ticker and headless symbols — no canvas, no DOM, no WebGL. Every test in the library's suite runs in Node under vitest; each takes milliseconds.

The data flow

my-spin.test.ts describe · it · expect vitest node environment import pixi-reels/src/testing FakeTicker .tick(ms) · .deltaMS HeadlessSymbol no sprite · no texture createTestReelSet({...}) returns { reelSet, ticker, spinAndLand, advance } constructs REAL REELSET (NO RENDERER) SpinController registered with FakeTicker Reel[] × N HeadlessSymbol × V+buffers EventEmitter real typed events FrameBuilder real middleware pipeline reelSet.spin() → setResult() → skip() ⇒ synchronous landing (microtask) no ticker frames needed — skip() force-completes every phase Assertions you can make in Node expectGrid · captureEvents · countSymbol · debugSnapshot
Test entry + ReelSet core
Testing module (tree-shakeable)
Assertions

Three parts, in detail

FakeTicker

Duck-compatible with PIXI.Ticker: same add/remove/deltaMS surface, no rendering. Tests that want to drive time call ticker.tick(16) or ticker.tickFor(1000, 16) manually. The library never distinguishes a real Ticker from a fake one — the TickerRef wrapper only calls the standard interface.

HeadlessSymbol

Extends ReelSymbol. Creates a PixiJS Container for view (so scene-graph code works) but never draws anything. Every symbol subclass requires the same lifecycle hooks (onActivate, resize, playWin…) so it slots into the factory the same way a SpriteSymbol would.

createTestReelSet

The one-liner. Builds a ReelSet wired to a FakeTicker with HeadlessSymbol-backed entries for every id you pass in. Returns a handle with two convenience methods:

  • advance(ms, stepMs?) — run the FakeTicker for that many ms.
  • spinAndLand(grid)spin() → setResult(grid) → skip(). Resolves synchronously because skip() force-lands without any animation frames. This is how every mechanic test asserts outcomes in milliseconds.

Why this composition is testable

The library has zero renderer assumptions anywhere except in the concrete ReelSymbol subclasses (Sprite/AnimatedSprite/Spine). Control flow sits in SpinController + phases + events, all of which are just JavaScript objects. Swapping the symbols for HeadlessSymbol and the ticker for FakeTicker is enough to run the whole thing in Node.

That's also why the test suite ships as a tree-shakeable sub-module (pixi-reels/src/testing/, exported from the barrel). Production bundles drop it automatically; test bundles keep it.

What a real test looks like

import { createTestReelSet, expectGrid, captureEvents } from 'pixi-reels';

it('hold-and-win persists across spins', async () => {
  const h = createTestReelSet({
    reels: 5, visibleRows: 3,
    symbolIds: ['a', 'b', 'coin'],
  });
  try {
    await h.spinAndLand([
      ['coin','a','b'],
      ['b','coin','a'],
      ['a','b','coin'],
      ['coin','a','b'],
      ['b','coin','a'],
    ]);
    const log = captureEvents(h.reelSet, ['spin:complete']);
    await h.spinAndLand(/* next stage */);
    expect(log).toHaveLength(1);
  } finally {
    h.destroy();
  }
});

This is exactly the shape every mechanic test in tests/integration/mechanics.test.ts follows.