import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; import 'package:flame_lua_runtime/runtime/events/runtime_event_dispatcher.dart'; import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart'; import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; import 'package:flame_lua_runtime/runtime/scripting/runtime_script_services.dart'; import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('RuntimeEventDispatcher', () { test('dispatches queued events serially', () async { final session = _activeSession(); final script = _FakeScriptEngine(); final applied = []; final dispatcher = RuntimeEventDispatcher( session: session, scriptEngine: script, isScopeAlive: (_) => true, applyDiff: applied.add, ); dispatcher ..enqueue(const RuntimeEvent(type: 'tap', target: 'a')) ..enqueue(const RuntimeEvent(type: 'tap', target: 'b')); await Future.delayed(Duration.zero); expect(script.events.map((event) => event.target), ['a', 'b']); expect(applied, hasLength(2)); }); test('drops events for removed scope', () async { final session = _activeSession(); final script = _FakeScriptEngine(); var alive = true; final dispatcher = RuntimeEventDispatcher( session: session, scriptEngine: script, isScopeAlive: (_) => alive, applyDiff: (_) {}, ); dispatcher.enqueue(const RuntimeEvent(type: 'tap', scope: 'dialog')); alive = false; await Future.delayed(Duration.zero); expect(script.events, isEmpty); }); test('drops events with stale target epoch', () async { final session = _activeSession(); final script = _FakeScriptEngine(); var currentEpoch = 2; final dispatcher = RuntimeEventDispatcher( session: session, scriptEngine: script, isScopeAlive: (_) => true, isNodeEpochAlive: (_, epoch) => epoch == currentEpoch, applyDiff: (_) {}, ); dispatcher.enqueue( const RuntimeEvent(type: 'tap', target: 'button', targetEpoch: 1), ); currentEpoch = 3; await Future.delayed(Duration.zero); expect(script.events, isEmpty); }); test('drops queued events after dispose', () async { final session = _activeSession(); final script = _FakeScriptEngine(); final dispatcher = RuntimeEventDispatcher( session: session, scriptEngine: script, isScopeAlive: (_) => true, applyDiff: (_) {}, ); dispatcher.enqueue(const RuntimeEvent(type: 'tap')); dispatcher.dispose(); await Future.delayed(Duration.zero); expect(script.events, isEmpty); }); test('drops queued events after session dispose', () async { final session = _activeSession(); final script = _FakeScriptEngine(); final dispatcher = RuntimeEventDispatcher( session: session, scriptEngine: script, isScopeAlive: (_) => true, applyDiff: (_) {}, ); dispatcher.enqueue(const RuntimeEvent(type: 'tap')); session.dispose(); await Future.delayed(Duration.zero); expect(script.events, isEmpty); }); test('continues draining after a script error', () async { final session = _activeSession(); final script = _FakeScriptEngine()..failNext = true; final errors = []; final diagnostics = RuntimeDiagnostics(); final dispatcher = RuntimeEventDispatcher( session: session, scriptEngine: script, isScopeAlive: (_) => true, applyDiff: (_) {}, diagnostics: diagnostics, onError: errors.add, ); dispatcher ..enqueue(const RuntimeEvent(type: 'tap', target: 'bad')) ..enqueue(const RuntimeEvent(type: 'tap', target: 'good')); await Future.delayed(Duration.zero); expect(errors, hasLength(1)); expect(diagnostics.entries, hasLength(1)); expect( diagnostics.entries.single.type, RuntimeDiagnosticType.luaEventError, ); expect(diagnostics.entries.single.context['target'], 'bad'); expect(script.events.map((event) => event.target), ['bad', 'good']); }); }); } RuntimeSession _activeSession() { final session = RuntimeSession(gameId: 'test')..beginLoading(); session.activate(); return session; } class _FakeScriptEngine implements ScriptEngine { final events = []; bool failNext = false; @override Future loadPackage( GamePackage package, { RuntimeScriptServices services = const RuntimeScriptServices(), }) async {} @override bool smokeTest(Map context) => true; @override GameDiff init(Map context) => GameDiff.empty; @override GameDiff dispatchEvent(RuntimeEvent event) { events.add(event); if (failNext) { failNext = false; throw StateError('boom'); } return GameDiff.empty; } }