Initial flame_lua_runtime package
This commit is contained in:
940
test/runtime/commands/command_executor_test.dart
Normal file
940
test/runtime/commands/command_executor_test.dart
Normal file
@@ -0,0 +1,940 @@
|
||||
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<SequenceEffect>(), 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<SequenceEffect>(), 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 = <MethodCall>[];
|
||||
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<void>.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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
await Future<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void> _waitFor(bool Function() predicate) async {
|
||||
for (var i = 0; i < 20; i++) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await Future<void>.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 = <RuntimeEvent>[];
|
||||
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<GamePackage> _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<GamePackage> _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<void>();
|
||||
List<int>? startedBytes;
|
||||
double? volume;
|
||||
var loop = false;
|
||||
var paused = false;
|
||||
var resumed = false;
|
||||
var stopped = false;
|
||||
var disposed = false;
|
||||
|
||||
@override
|
||||
Future<void> get done => _done.future;
|
||||
|
||||
@override
|
||||
Future<void> 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<void> pause() async {
|
||||
paused = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resume() async {
|
||||
resumed = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
stopped = true;
|
||||
complete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
disposed = true;
|
||||
complete();
|
||||
}
|
||||
}
|
||||
65
test/runtime/commands/runtime_command_registry_test.dart
Normal file
65
test/runtime/commands/runtime_command_registry_test.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flame_lua_runtime/runtime/commands/runtime_command_registry.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeCommandRegistry', () {
|
||||
test('cancels handles by id, group and scope', () {
|
||||
final registry = RuntimeCommandRegistry();
|
||||
var idCancelled = false;
|
||||
var groupCancelled = false;
|
||||
var scopeCancelled = false;
|
||||
|
||||
registry.create(id: 'intro').addCancelCallback(() {
|
||||
idCancelled = true;
|
||||
});
|
||||
registry.create(group: 'scene').addCancelCallback(() {
|
||||
groupCancelled = true;
|
||||
});
|
||||
registry.create(scope: 'panel').addCancelCallback(() {
|
||||
scopeCancelled = true;
|
||||
});
|
||||
|
||||
registry
|
||||
..cancelId('intro')
|
||||
..cancelGroup('scene')
|
||||
..cancelScope('panel');
|
||||
|
||||
expect(idCancelled, isTrue);
|
||||
expect(groupCancelled, isTrue);
|
||||
expect(scopeCancelled, isTrue);
|
||||
expect(registry.activeHandleCount, 0);
|
||||
});
|
||||
|
||||
test('completed handles ignore later cancellation', () {
|
||||
final registry = RuntimeCommandRegistry();
|
||||
var cancelled = false;
|
||||
final handle = registry.create(group: 'scene')
|
||||
..addCancelCallback(() {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
handle.complete();
|
||||
registry.cancelGroup('scene');
|
||||
|
||||
expect(cancelled, isFalse);
|
||||
expect(registry.activeHandleCount, 0);
|
||||
});
|
||||
|
||||
test('dispose cancels all handles', () {
|
||||
final registry = RuntimeCommandRegistry();
|
||||
var cancelCount = 0;
|
||||
|
||||
registry.create(id: 'a').addCancelCallback(() {
|
||||
cancelCount++;
|
||||
});
|
||||
registry.create(group: 'b').addCancelCallback(() {
|
||||
cancelCount++;
|
||||
});
|
||||
|
||||
registry.dispose();
|
||||
|
||||
expect(cancelCount, 2);
|
||||
expect(registry.activeHandleCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user