PR pixi-reels
Docs · 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

flowchart TB
  TestFile["my-spin.test.ts<br/><i>describe · it · expect · vitest node environment</i>"]

  subgraph TestingModule["pixi-reels/testing"]
    direction TB
    FakeTicker["FakeTicker<br/><i>.tick(ms) · .deltaMS</i>"]
    HeadlessSymbol["HeadlessSymbol<br/><i>no sprite · no texture</i>"]
    CreateTest["createTestReelSet({...})<br/><i>returns { reelSet, ticker, spinAndLand, advance }</i>"]
  end

  TestFile -->|import| TestingModule

  subgraph RealReelSet["Real ReelSet (no renderer)"]
    direction LR
    SpinController["SpinController<br/><i>registered with FakeTicker</i>"]
    Reels["Reel[] × N<br/><i>HeadlessSymbol × V+buffers</i>"]
    Events["EventEmitter<br/><i>real typed events</i>"]
    FrameBuilder["FrameBuilder<br/><i>real middleware pipeline</i>"]
    Flow["reelSet.spin() → setResult() → skipSpin() ⇒ synchronous landing (microtask)<br/><i>no ticker frames needed. skipSpin() force-completes every phase</i>"]
  end

  TestingModule -->|constructs| RealReelSet

  Assertions["Assertions you can make in Node<br/><i>expectGrid · captureEvents · countSymbol · debugSnapshot</i>"]

  RealReelSet --> Assertions
  
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) → skipSpin(). Resolves synchronously because skipSpin() 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/testing';

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.