import 'dart:async' as async; import 'dart:io'; import 'package:flame/components.dart'; import 'package:flame/effects.dart'; import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart'; import 'package:flame_lua_runtime/runtime/audio/runtime_audio_player.dart'; import 'package:flame_lua_runtime/runtime/commands/command_executor.dart'; import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_command.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; import 'package:flame_lua_runtime/runtime/models/runtime_node.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; import 'package:flame_lua_runtime/runtime/rendering/render_tree_controller.dart'; import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('CommandExecutor', () { test('adds move_path sequence effect to target component', () { final harness = _CommandHarness(); harness.createNode('piece'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.movePath, target: 'piece', payload: { 'path': [ {'x': 10, 'y': 20}, {'x': 30, 'y': 40}, ], 'duration': 0.6, 'onComplete': 'done', }, ), ); final component = harness.controller.componentById('piece')!; expect(component.children.whereType(), isNotEmpty); }); test('adds generic transform effects to target component', () { final harness = _CommandHarness(); harness.createNode('node'); for (final command in const [ RuntimeCommand( type: RuntimeCommandType.moveTo, target: 'node', payload: {'x': 10, 'y': 20}, ), RuntimeCommand( type: RuntimeCommandType.fadeTo, target: 'node', payload: {'alpha': 0.5}, ), RuntimeCommand( type: RuntimeCommandType.scaleTo, target: 'node', payload: {'scale': 1.5}, ), RuntimeCommand( type: RuntimeCommandType.rotateTo, target: 'node', payload: {'angle': 1.2}, ), ]) { harness.executor.execute(command); } final component = harness.controller.componentById('node')!; expect(component.children.whereType(), hasLength(4)); }); test( 'remove_node removes target and emits completion event immediately', () { final harness = _CommandHarness(); harness.createNode('node'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.removeNode, target: 'node', payload: {'onComplete': 'removed'}, ), ); expect(harness.controller.componentById('node'), isNull); expect(harness.events.map((event) => event.toMap()), [ { 'type': RuntimeEventType.animationDone, 'target': 'node', 'handler': 'removed', }, ]); }, ); test('ignores transform commands for missing targets', () { final harness = _CommandHarness(); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.moveTo, target: 'missing', payload: {'x': 1, 'y': 2}, ), ); expect(harness.events, isEmpty); }); test('copy_text writes text to the platform clipboard', () async { final harness = _CommandHarness(); final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, (call) async { calls.add(call); return null; }); addTearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, null); }); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.copyText, payload: {'text': 'copy me'}, ), ); await Future.delayed(Duration.zero); expect(calls.single.method, 'Clipboard.setData'); expect(calls.single.arguments, {'text': 'copy me'}); }); test('toast creates temporary overlay and emits completion', () async { final harness = _CommandHarness(); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.toast, payload: { 'text': 'Hello toast', 'duration': 0.01, 'onComplete': 'toast_done', }, ), ); expect(harness.controller.componentById('runtime_toast_1'), isNotNull); final text = harness.controller.componentById('runtime_toast_1_text')!; expect(text.node.text, 'Hello toast'); expect(text.node.parent, 'runtime_toast_1'); await Future.delayed(const Duration(milliseconds: 30)); expect(harness.controller.componentById('runtime_toast_1'), isNull); expect(harness.controller.componentById('runtime_toast_1_text'), isNull); expect(harness.events.map((event) => event.toMap()), [ {'type': RuntimeEventType.animationDone, 'handler': 'toast_done'}, ]); }); test('cancel_commands removes active toast overlay', () async { final harness = _CommandHarness(); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.toast, payload: {'text': 'Cancel me', 'duration': 10, 'id': 'toast_a'}, ), ); expect(harness.controller.componentById('runtime_toast_1'), isNotNull); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.cancelCommands, payload: {'id': 'toast_a'}, ), ); await Future.delayed(Duration.zero); expect(harness.controller.componentById('runtime_toast_1'), isNull); }); test('runs sequence commands in order', () async { final harness = _CommandHarness(); harness ..createNode('first') ..createNode('second'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.sequence, payload: { 'commands': [ {'type': RuntimeCommandType.removeNode, 'target': 'first'}, {'type': RuntimeCommandType.removeNode, 'target': 'second'}, ], 'onComplete': 'sequence_done', }, ), ); expect(harness.controller.componentById('first'), isNull); expect(harness.controller.componentById('second'), isNotNull); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); expect(harness.controller.componentById('second'), isNull); expect(harness.events.map((event) => event.toMap()), [ {'type': RuntimeEventType.animationDone, 'handler': 'sequence_done'}, ]); }); test('runs parallel commands together', () async { final harness = _CommandHarness(); harness ..createNode('first') ..createNode('second'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.parallel, payload: { 'commands': [ {'type': RuntimeCommandType.removeNode, 'target': 'first'}, {'type': RuntimeCommandType.removeNode, 'target': 'second'}, ], 'onComplete': 'parallel_done', }, ), ); expect(harness.controller.componentById('first'), isNull); expect(harness.controller.componentById('second'), isNull); await Future.delayed(Duration.zero); expect(harness.events.map((event) => event.toMap()), [ {'type': RuntimeEventType.animationDone, 'handler': 'parallel_done'}, ]); }); test('plays sound and emits completion after playback ends', () async { final players = <_FakeRuntimeAudioPlayer>[]; final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(await _createAudioPackage('play_sound')); final harness = _CommandHarness(audio: audio); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playSound, payload: {'asset': 'dice', 'volume': 0.4, 'onComplete': 'sound_done'}, ), ); await _waitFor(() => players.isNotEmpty); expect(players.single.startedBytes, _audioBytes); expect(players.single.volume, 0.4); expect(harness.events, isEmpty); players.single.complete(); await Future.delayed(Duration.zero); expect(harness.events.map((event) => event.toMap()), [ {'type': RuntimeEventType.animationDone, 'handler': 'sound_done'}, ]); audio.dispose(); }); test('cancels scoped sound when scope is removed', () async { final players = <_FakeRuntimeAudioPlayer>[]; final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(await _createAudioPackage('scoped_sound')); final harness = _CommandHarness(audio: audio); harness.createNode('panel'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playSound, payload: { 'asset': 'dice', 'scope': 'panel', 'onComplete': 'sound_done', }, ), ); await _waitFor(() => players.isNotEmpty); harness.controller.removeById('panel'); await Future.delayed(Duration.zero); expect(players.single.disposed, isTrue); expect(harness.events, isEmpty); audio.dispose(); }); test('starts bgm command and controls channel', () async { final players = <_FakeRuntimeAudioPlayer>[]; final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(await _createAudioPackage('bgm_command')); final harness = _CommandHarness(audio: audio); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playBgm, payload: { 'asset': 'dice', 'channel': 'music', 'volume': 0.2, 'loop': true, 'onComplete': 'bgm_started', }, ), ); await _waitFor(() => players.isNotEmpty); await Future.delayed(Duration.zero); expect(players.single.startedBytes, _audioBytes); expect(players.single.volume, 0.2); expect(players.single.loop, isTrue); expect(audio.hasBgm(channel: 'music'), isTrue); expect(harness.events.map((event) => event.toMap()), [ {'type': RuntimeEventType.animationDone, 'handler': 'bgm_started'}, ]); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.pauseBgm, payload: {'channel': 'music'}, ), ); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.resumeBgm, payload: {'channel': 'music'}, ), ); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.stopBgm, payload: {'channel': 'music'}, ), ); await Future.delayed(Duration.zero); expect(players.single.paused, isTrue); expect(players.single.resumed, isTrue); expect(players.single.stopped, isTrue); expect(audio.hasBgm(channel: 'music'), isFalse); audio.dispose(); }); test('stops scoped bgm when scope is removed after start', () async { final players = <_FakeRuntimeAudioPlayer>[]; final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(await _createAudioPackage('scoped_bgm_command')); final harness = _CommandHarness(audio: audio); harness.createNode('panel'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playBgm, payload: {'asset': 'dice', 'channel': 'music', 'scope': 'panel'}, ), ); await _waitFor(() => players.isNotEmpty); harness.controller.removeById('panel'); await Future.delayed(Duration.zero); expect(players.single.stopped, isTrue); expect(audio.hasBgm(channel: 'music'), isFalse); audio.dispose(); }); test('dispose stops owned bgm channels', () async { final players = <_FakeRuntimeAudioPlayer>[]; final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(await _createAudioPackage('dispose_bgm_command')); final harness = _CommandHarness(audio: audio); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playBgm, payload: {'asset': 'dice', 'channel': 'music'}, ), ); await _waitFor(() => players.isNotEmpty); harness.executor.dispose(); await Future.delayed(Duration.zero); expect(players.single.stopped, isTrue); expect(audio.hasBgm(channel: 'music'), isFalse); audio.dispose(); }); test( 'preloads and evicts resource groups with completion events', () async { final audio = RuntimeAudioManager(); await audio.mount( await _createGroupedAudioPackage('resource_commands'), ); final harness = _CommandHarness(audio: audio); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.preloadResources, payload: {'group': 'scene', 'onComplete': 'resources_ready'}, ), ); await _waitFor(() => harness.events.isNotEmpty); expect(audio.audioState('dice'), GameResourceState.ready); expect(harness.events.single.handler, 'resources_ready'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.evictResources, payload: {'group': 'scene', 'onComplete': 'resources_evicted'}, ), ); await _waitFor(() => harness.events.length == 2); expect(audio.audioState('dice'), GameResourceState.idle); expect(harness.events.last.handler, 'resources_evicted'); audio.dispose(); }, ); test('runs delay completion asynchronously', () async { final harness = _CommandHarness(); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.delay, payload: {'duration': 0, 'onComplete': 'delay_done'}, ), ); expect(harness.events, isEmpty); await Future.delayed(Duration.zero); expect(harness.events.map((event) => event.toMap()), [ {'type': RuntimeEventType.animationDone, 'handler': 'delay_done'}, ]); }); test('drops delayed completion when scope node was removed', () async { final harness = _CommandHarness(); harness.createNode('panel'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.delay, payload: {'duration': 0, 'onComplete': 'late_done', 'scope': 'panel'}, ), ); harness.controller.removeById('panel'); await Future.delayed(Duration.zero); expect(harness.events, isEmpty); }); test('scope removal cancels inherited pending sequence commands', () async { final harness = _CommandHarness(); harness ..createNode('panel') ..createNode('second'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.sequence, payload: { 'scope': 'panel', 'commands': [ {'type': RuntimeCommandType.delay, 'duration': 0.02}, {'type': RuntimeCommandType.removeNode, 'target': 'second'}, ], 'onComplete': 'sequence_done', }, ), ); harness.controller.removeById('panel'); await Future.delayed(const Duration(milliseconds: 40)); expect(harness.controller.componentById('second'), isNotNull); expect(harness.events, isEmpty); }); test('dispose cancels pending delayed completion', () async { final harness = _CommandHarness(); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.delay, payload: {'duration': 0, 'onComplete': 'late_done'}, ), ); harness.executor.dispose(); await Future.delayed(Duration.zero); expect(harness.events, isEmpty); }); test('cancel_commands cancels pending command by id', () async { final harness = _CommandHarness(); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.delay, payload: { 'id': 'intro_delay', 'duration': 0.02, 'onComplete': 'late_done', }, ), ); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.cancelCommands, payload: {'id': 'intro_delay'}, ), ); await Future.delayed(const Duration(milliseconds: 40)); expect(harness.events, isEmpty); }); test('cancel_commands cancels inherited command group', () async { final harness = _CommandHarness(); harness.createNode('node'); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.sequence, payload: { 'group': 'intro', 'commands': [ {'type': RuntimeCommandType.delay, 'duration': 0.02}, {'type': RuntimeCommandType.removeNode, 'target': 'node'}, ], 'onComplete': 'sequence_done', }, ), ); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.cancelCommands, payload: {'group': 'intro'}, ), ); await Future.delayed(const Duration(milliseconds: 40)); expect(harness.controller.componentById('node'), isNotNull); expect(harness.events, isEmpty); }); test('cancel_commands cancels scoped sound by command group', () async { final players = <_FakeRuntimeAudioPlayer>[]; final audio = RuntimeAudioManager( playerFactory: () { final player = _FakeRuntimeAudioPlayer(); players.add(player); return player; }, ); await audio.mount(await _createAudioPackage('cancel_sound_group')); final harness = _CommandHarness(audio: audio); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playSound, payload: { 'asset': 'dice', 'group': 'sfx_intro', 'onComplete': 'sound_done', }, ), ); await _waitFor(() => players.isNotEmpty); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.cancelCommands, payload: {'group': 'sfx_intro'}, ), ); await Future.delayed(Duration.zero); expect(players.single.disposed, isTrue); expect(harness.events, isEmpty); audio.dispose(); }); test( 'resource commands use commandGroup without confusing resource group', () async { final audio = RuntimeAudioManager(); await audio.mount( await _createGroupedAudioPackage('resource_group_safe'), ); final harness = _CommandHarness(audio: audio); harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.preloadResources, payload: { 'group': 'scene', 'commandGroup': 'loading', 'onComplete': 'resources_ready', }, ), ); await _waitFor(() => harness.events.isNotEmpty); expect(audio.audioState('dice'), GameResourceState.ready); expect(harness.events.single.handler, 'resources_ready'); audio.dispose(); }, ); test('validates composite commands before executing children', () { final harness = _CommandHarness(); harness ..createNode('first') ..createNode('second'); expect( () => harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.sequence, payload: { 'commands': [ {'type': RuntimeCommandType.removeNode, 'target': 'first'}, { 'type': RuntimeCommandType.fadeTo, 'target': 'second', 'alpha': 2, }, ], }, ), ), throwsFormatException, ); expect(harness.controller.componentById('first'), isNotNull); expect(harness.controller.componentById('second'), isNotNull); }); test('validates required command payloads', () { final harness = _CommandHarness(); harness.createNode('node'); expect( () => harness.executor.execute( const RuntimeCommand(type: RuntimeCommandType.moveTo, target: 'node'), ), throwsFormatException, ); expect( () => harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.fadeTo, target: 'node', payload: {'alpha': 2}, ), ), throwsFormatException, ); expect( () => harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.scaleTo, target: 'node', payload: {'scale': 'big'}, ), ), throwsFormatException, ); expect( () => harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.rotateTo, target: 'node', payload: {'angle': 0, 'duration': -1}, ), ), throwsFormatException, ); expect( () => harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playSpineAnimation, target: 'node', ), ), throwsFormatException, ); expect( () => harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.playSpineAnimation, target: 'node', payload: {'animation': 'walk', 'track': -1}, ), ), throwsFormatException, ); expect( () => harness.executor.execute( const RuntimeCommand( type: RuntimeCommandType.moveTo, target: 'node', payload: {'x': 1, 'y': 2, 'duraton': 0.2}, ), ), throwsFormatException, ); }); }); } Future _waitFor(bool Function() predicate) async { for (var i = 0; i < 20; i++) { if (predicate()) { return; } await Future.delayed(Duration.zero); } throw StateError('Timed out waiting for test condition'); } class _CommandHarness { _CommandHarness({ RuntimeAudioManager? audio, GameResourceManager? resources, }) { final activeResources = resources ?? GameResourceManager(); controller = RenderTreeController( root: Component(), resources: activeResources, eventSink: events.add, ); executor = CommandExecutor( renderTree: controller, eventSink: events.add, audio: audio, resources: activeResources, ); controller.onScopeRemoved = executor.cancelScope; } final events = []; late final RenderTreeController controller; late final CommandExecutor executor; void createNode(String id) { controller.apply( NodeDiff( creates: [ RuntimeNode( id: id, type: RuntimeNodeType.rect, width: 100, height: 100, ), ], ), ); } } const _audioBytes = [9, 8, 7, 6]; Future _createAudioPackage(String name) async { final root = await Directory.systemTemp.createTemp('command_audio_${name}_'); Directory('${root.path}/assets').createSync(recursive: true); File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); addTearDown(() { if (root.existsSync()) { root.deleteSync(recursive: true); } }); return GamePackage.file( rootPath: root.path, manifest: const GamePackageManifest( gameId: 'test', name: 'Test', version: '0.1.0', runtimeApiVersion: 1, entry: 'scripts/main.lua', assetsBase: 'assets', resources: { 'dice': GameResource( type: GameResourceType.audio, path: 'assets/dice.wav', preload: GameResourcePreload.lazy, ), }, ), ); } Future _createGroupedAudioPackage(String name) async { final root = await Directory.systemTemp.createTemp('command_audio_${name}_'); Directory('${root.path}/assets').createSync(recursive: true); File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); addTearDown(() { if (root.existsSync()) { root.deleteSync(recursive: true); } }); return GamePackage.file( rootPath: root.path, manifest: const GamePackageManifest( gameId: 'test', name: 'Test', version: '0.1.0', runtimeApiVersion: 1, entry: 'scripts/main.lua', assetsBase: 'assets', resources: { 'dice': GameResource( type: GameResourceType.audio, path: 'assets/dice.wav', preload: GameResourcePreload.lazy, group: 'scene', ), }, ), ); } class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer { final _done = async.Completer(); List? startedBytes; double? volume; var loop = false; var paused = false; var resumed = false; var stopped = false; var disposed = false; @override Future get done => _done.future; @override Future start( Uint8List bytes, { required double volume, bool loop = false, }) async { startedBytes = bytes.toList(growable: false); this.volume = volume; this.loop = loop; } void complete() { if (!_done.isCompleted) { _done.complete(); } } @override Future pause() async { paused = true; } @override Future resume() async { resumed = true; } @override Future stop() async { stopped = true; complete(); } @override Future dispose() async { disposed = true; complete(); } }