testing/twin_fixtures library

Twin-drift fixture runner — the Dart half of the shared JSON fixtures that keep a version unit's TS and Dart GameRules twins in sync.

One fixture file per concern lives under fixtures/v<N>/*.json and is consumed by both sides: @eigeninteractive/testkit runs each case against the TypeScript unit in the game's Worker (schemas + applyAction + computeObservation + the two predicates), while this library runs the same file against the Dart twin (generated payload parsing, GameRules.isValidAction, GameRules.previewAction, and the predicate twins). A behavioral divergence then fails one side's tests instead of degrading UX in production. The fixture file format is documented in the EigenInteractive testing guide.

Loading and running are separate steps on purpose. A fixture file is hand-written JSON, so loadTwinFixtureSuites validates it into the typed TwinFixtureCase hierarchy first: a missing or mistyped field fails at load with the file, case and field named, rather than surfacing later as a confusing comparison failure blamed on the game's rules. By the time runTwinFixtureCase sees a case, every field it reads is known-present and known-typed, so it performs no casting at all.

This side validates fields it never itself reads (expected.state, participantCount, ...) as well. Those belong to the TS runner, but a game package may ship only a Dart twin — and then this is the only thing standing between a typo and a silently skipped assertion.

Framework-free on purpose (no flutter_test import), so it can live in lib/ and be consumed by any app's test suite:

void main() {
  const module = MyGameModule();
  final root = 'test/fixtures/game';
  for (final suite in loadTwinFixtureSuites(root)) {
    final rules = module.versions[suite.schemaVersion];
    group('twin fixtures v${suite.schemaVersion}', () {
      for (final fixtureCase in suite.cases) {
        test(fixtureCase.name, () {
          expect(rules, isNotNull);
          expect(runTwinFixtureCase(rules!, fixtureCase), isEmpty);
        });
      }
    });
  }
}

The expected.observation comparison relies on value equality (==). EigenInteractive's generated payload classes provide deep equality for collections.

Classes

ActionCase
Exercises the action codec, GameRules.isValidAction and — when the game implements optimism — GameRules.previewAction.
BotSeatableCase
A GameRules.botSeatable predicate case.
RatingPoolCase
A GameRules.ratingPool predicate case.
TwinFixtureCase
One validated fixture case. Sealed, so runTwinFixtureCase switches exhaustively and an added case kind is a compile error rather than a silently unhandled string.
TwinFixtureSuite
One fixture file's cases, all targeting one schemaVersion unit.

Functions

loadTwinFixtureSuites(String rootPath) → List<TwinFixtureSuite>
Loads every fixture file under rootPath (layout: <root>/v<N>/*.json), sorted by path for stable test ordering.
parseTwinFixtureSuite(String path, dynamic json) TwinFixtureSuite
Validates one fixture file's decoded JSON into a typed suite.
runTwinFixtureCase(GameRules rules, TwinFixtureCase fixtureCase) → List<String>
Runs one validated fixture case against the Dart rules twin, returning failure descriptions (empty ⇒ the case passes).