941 lines
27 KiB
Dart
941 lines
27 KiB
Dart
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();
|
|
}
|
|
}
|