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
schemaVersionunit.
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
rulestwin, returning failure descriptions (empty ⇒ the case passes).