Initial flame_lua_runtime package
This commit is contained in:
484
test/runtime/audio/runtime_audio_manager_test.dart
Normal file
484
test/runtime/audio/runtime_audio_manager_test.dart
Normal file
@@ -0,0 +1,484 @@
|
||||
import 'dart:async' as async;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/diagnostics/runtime_diagnostics.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/resources/game_resource_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeAudioManager', () {
|
||||
test('preloads required audio and exposes ready state', () async {
|
||||
final package = await _createPackage(
|
||||
'required_audio',
|
||||
preload: GameResourcePreload.required,
|
||||
);
|
||||
final audio = RuntimeAudioManager();
|
||||
|
||||
await audio.mount(package);
|
||||
|
||||
expect(audio.audioState('dice'), GameResourceState.ready);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test('lazy audio plays from package bytes', () async {
|
||||
final players = <_FakeRuntimeAudioPlayer>[];
|
||||
final package = await _createPackage('lazy_audio');
|
||||
final audio = RuntimeAudioManager(
|
||||
playerFactory: () {
|
||||
final player = _FakeRuntimeAudioPlayer();
|
||||
players.add(player);
|
||||
return player;
|
||||
},
|
||||
);
|
||||
await audio.mount(package);
|
||||
|
||||
final playback = await audio.play('dice', volume: 0.5);
|
||||
|
||||
expect(playback, isNotNull);
|
||||
expect(audio.audioState('dice'), GameResourceState.ready);
|
||||
expect(players.single.startedBytes, _audioBytes);
|
||||
expect(players.single.volume, 0.5);
|
||||
|
||||
players.single.complete();
|
||||
await playback!.done;
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test('lazy audio failure records state, error and diagnostics', () async {
|
||||
final diagnostics = RuntimeDiagnostics();
|
||||
final package = await _createPackage('missing_audio', writeAudio: false);
|
||||
final audio = RuntimeAudioManager(diagnostics: diagnostics);
|
||||
|
||||
await audio.mount(package);
|
||||
final playback = await audio.play('dice');
|
||||
|
||||
expect(playback, isNull);
|
||||
expect(audio.audioState('dice'), GameResourceState.failed);
|
||||
expect(audio.audioError('dice'), isNotNull);
|
||||
expect(
|
||||
diagnostics.entries.single.type,
|
||||
RuntimeDiagnosticType.resourceLoadError,
|
||||
);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'exports audio debug json, evicts and retries failed records',
|
||||
() async {
|
||||
final root = await Directory.systemTemp.createTemp('audio_retry_');
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
final package = _createPackageAt(
|
||||
root,
|
||||
preload: GameResourcePreload.lazy,
|
||||
);
|
||||
final audio = RuntimeAudioManager();
|
||||
|
||||
await audio.mount(package);
|
||||
|
||||
expect(audio.audioDebugJson(), {
|
||||
'generation': 1,
|
||||
'hasPackage': true,
|
||||
'count': 1,
|
||||
'activeLoads': 0,
|
||||
'pendingLoads': 0,
|
||||
'activePlayers': 0,
|
||||
'pooledPlayers': 0,
|
||||
'channels': <String>[],
|
||||
'resources': [
|
||||
{
|
||||
'key': 'dice',
|
||||
'path': endsWith('/assets/dice.wav'),
|
||||
'type': GameResourceType.audio,
|
||||
'declared': true,
|
||||
'preload': GameResourcePreload.lazy,
|
||||
'state': 'idle',
|
||||
'loading': false,
|
||||
'ready': false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(await audio.retryAudio('dice'), isFalse);
|
||||
expect(audio.audioState('dice'), GameResourceState.failed);
|
||||
expect(audio.audioDebugJson().toString(), contains('error'));
|
||||
|
||||
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
|
||||
expect(await audio.retryAudio('dice'), isTrue);
|
||||
expect(audio.audioState('dice'), GameResourceState.ready);
|
||||
|
||||
expect(audio.evictAudio('dice'), isTrue);
|
||||
expect(audio.audioState('dice'), GameResourceState.idle);
|
||||
expect(audio.evictAudio('dice'), isFalse);
|
||||
audio.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('preloads and evicts audio resource groups', () async {
|
||||
final package = await _createMultiAudioPackage('audio_group');
|
||||
final audio = RuntimeAudioManager();
|
||||
|
||||
await audio.mount(package);
|
||||
await audio.preloadGroup('scene');
|
||||
|
||||
expect(audio.audioState('dice'), GameResourceState.ready);
|
||||
expect(audio.audioState('click'), GameResourceState.ready);
|
||||
expect(audio.audioState('bgm'), GameResourceState.idle);
|
||||
expect(audio.evictGroup('scene'), 2);
|
||||
expect(audio.audioState('dice'), GameResourceState.idle);
|
||||
expect(audio.audioState('click'), GameResourceState.idle);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test('limits concurrent audio reads during group preload', () async {
|
||||
final package = _CountingAudioPackage(
|
||||
await _createMultiAudioPackage('audio_concurrency'),
|
||||
);
|
||||
final audio = RuntimeAudioManager(maxConcurrentLoads: 1);
|
||||
|
||||
await audio.mount(package);
|
||||
await audio.preloadGroup('scene');
|
||||
|
||||
expect(package.maxActiveReads, 1);
|
||||
expect(audio.audioState('dice'), GameResourceState.ready);
|
||||
expect(audio.audioState('click'), GameResourceState.ready);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'drops stale audio load result after dispose without diagnostics',
|
||||
() async {
|
||||
final diagnostics = RuntimeDiagnostics();
|
||||
final package = _BlockingAudioPackage(
|
||||
await _createPackage('stale_audio'),
|
||||
);
|
||||
final audio = RuntimeAudioManager(diagnostics: diagnostics);
|
||||
|
||||
await audio.mount(package);
|
||||
final playback = audio.play('dice');
|
||||
|
||||
audio.dispose();
|
||||
package.releaseReads();
|
||||
|
||||
expect(await playback, isNull);
|
||||
expect(diagnostics.entries, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('audio LRU evicts least recently used bytes', () async {
|
||||
final package = await _createMultiAudioPackage('audio_lru');
|
||||
final audio = RuntimeAudioManager(maxCacheEntries: 1);
|
||||
|
||||
await audio.mount(package);
|
||||
expect(await audio.retryAudio('dice'), isTrue);
|
||||
expect(audio.audioState('dice'), GameResourceState.ready);
|
||||
|
||||
expect(await audio.retryAudio('bgm'), isTrue);
|
||||
|
||||
expect(audio.audioState('dice'), GameResourceState.idle);
|
||||
expect(audio.audioState('bgm'), GameResourceState.ready);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test('reuses pooled one-shot audio players', () async {
|
||||
final players = <_FakeRuntimeAudioPlayer>[];
|
||||
final package = await _createPackage('sfx_pool');
|
||||
final audio = RuntimeAudioManager(
|
||||
maxSfxPoolSize: 1,
|
||||
playerFactory: () {
|
||||
final player = _FakeRuntimeAudioPlayer();
|
||||
players.add(player);
|
||||
return player;
|
||||
},
|
||||
);
|
||||
await audio.mount(package);
|
||||
|
||||
final first = await audio.play('dice');
|
||||
players.single.complete();
|
||||
await first!.done;
|
||||
expect(audio.audioDebugJson()['pooledPlayers'], 1);
|
||||
|
||||
final second = await audio.play('dice');
|
||||
players.single.complete();
|
||||
await second!.done;
|
||||
|
||||
expect(players, hasLength(1));
|
||||
expect(players.single.startCount, 2);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test('plays, pauses, resumes and stops bgm channel', () async {
|
||||
final players = <_FakeRuntimeAudioPlayer>[];
|
||||
final package = await _createPackage('bgm_audio');
|
||||
final audio = RuntimeAudioManager(
|
||||
playerFactory: () {
|
||||
final player = _FakeRuntimeAudioPlayer();
|
||||
players.add(player);
|
||||
return player;
|
||||
},
|
||||
);
|
||||
await audio.mount(package);
|
||||
|
||||
final playback = await audio.playBgm(
|
||||
'dice',
|
||||
channel: 'music',
|
||||
volume: 0.3,
|
||||
loop: true,
|
||||
);
|
||||
|
||||
expect(playback, isNotNull);
|
||||
expect(audio.hasBgm(channel: 'music'), isTrue);
|
||||
expect(players.single.startedBytes, _audioBytes);
|
||||
expect(players.single.volume, 0.3);
|
||||
expect(players.single.loop, isTrue);
|
||||
|
||||
await audio.pauseBgm(channel: 'music');
|
||||
await audio.resumeBgm(channel: 'music');
|
||||
await audio.stopBgm(channel: 'music');
|
||||
|
||||
expect(players.single.paused, isTrue);
|
||||
expect(players.single.resumed, isTrue);
|
||||
expect(players.single.stopped, isTrue);
|
||||
expect(audio.hasBgm(channel: 'music'), isFalse);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test('starting same bgm channel replaces previous playback', () async {
|
||||
final players = <_FakeRuntimeAudioPlayer>[];
|
||||
final package = await _createPackage('replace_bgm_audio');
|
||||
final audio = RuntimeAudioManager(
|
||||
playerFactory: () {
|
||||
final player = _FakeRuntimeAudioPlayer();
|
||||
players.add(player);
|
||||
return player;
|
||||
},
|
||||
);
|
||||
await audio.mount(package);
|
||||
|
||||
await audio.playBgm('dice', channel: 'music');
|
||||
await audio.playBgm('dice', channel: 'music');
|
||||
|
||||
expect(players, hasLength(2));
|
||||
expect(players.first.stopped, isTrue);
|
||||
expect(audio.hasBgm(channel: 'music'), isTrue);
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
test('required audio preload failure fails mount', () async {
|
||||
final package = await _createPackage(
|
||||
'required_missing_audio',
|
||||
preload: GameResourcePreload.required,
|
||||
writeAudio: false,
|
||||
);
|
||||
final audio = RuntimeAudioManager();
|
||||
|
||||
await expectLater(
|
||||
audio.mount(package),
|
||||
throwsA(isA<ResourceLoadException>()),
|
||||
);
|
||||
expect(audio.audioState('dice'), GameResourceState.failed);
|
||||
audio.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const _audioBytes = [1, 2, 3, 4, 5];
|
||||
|
||||
Future<GamePackage> _createPackage(
|
||||
String name, {
|
||||
String preload = GameResourcePreload.lazy,
|
||||
bool writeAudio = true,
|
||||
}) async {
|
||||
final root = await Directory.systemTemp.createTemp('audio_${name}_');
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
if (writeAudio) {
|
||||
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
|
||||
}
|
||||
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
return _createPackageAt(root, preload: preload);
|
||||
}
|
||||
|
||||
Future<GamePackage> _createMultiAudioPackage(String name) async {
|
||||
final root = await Directory.systemTemp.createTemp('audio_${name}_');
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes);
|
||||
File('${root.path}/assets/click.wav').writeAsBytesSync([6, 7, 8]);
|
||||
File('${root.path}/assets/bgm.wav').writeAsBytesSync([9, 10, 11, 12]);
|
||||
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: GamePackageManifest(
|
||||
gameId: 'test',
|
||||
name: 'Test',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
resources: const {
|
||||
'dice': GameResource(
|
||||
type: GameResourceType.audio,
|
||||
path: 'assets/dice.wav',
|
||||
preload: GameResourcePreload.lazy,
|
||||
group: 'scene',
|
||||
),
|
||||
'click': GameResource(
|
||||
type: GameResourceType.audio,
|
||||
path: 'assets/click.wav',
|
||||
preload: GameResourcePreload.lazy,
|
||||
group: 'scene',
|
||||
),
|
||||
'bgm': GameResource(
|
||||
type: GameResourceType.audio,
|
||||
path: 'assets/bgm.wav',
|
||||
preload: GameResourcePreload.lazy,
|
||||
group: 'music',
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GamePackage _createPackageAt(
|
||||
Directory root, {
|
||||
String preload = GameResourcePreload.lazy,
|
||||
}) {
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: 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: preload,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CountingAudioPackage extends GamePackage {
|
||||
_CountingAudioPackage(GamePackage package)
|
||||
: super.file(rootPath: package.rootPath, manifest: package.manifest);
|
||||
|
||||
var activeReads = 0;
|
||||
var maxActiveReads = 0;
|
||||
|
||||
@override
|
||||
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
||||
activeReads++;
|
||||
if (activeReads > maxActiveReads) {
|
||||
maxActiveReads = activeReads;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
try {
|
||||
return await super.readBytes(relativeOrAbsolutePath);
|
||||
} finally {
|
||||
activeReads--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockingAudioPackage extends GamePackage {
|
||||
_BlockingAudioPackage(GamePackage package)
|
||||
: _releaseReads = async.Completer<void>(),
|
||||
super.file(rootPath: package.rootPath, manifest: package.manifest);
|
||||
|
||||
final async.Completer<void> _releaseReads;
|
||||
|
||||
void releaseReads() {
|
||||
if (!_releaseReads.isCompleted) {
|
||||
_releaseReads.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
||||
await _releaseReads.future;
|
||||
return super.readBytes(relativeOrAbsolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer {
|
||||
async.Completer<void> _done = async.Completer<void>();
|
||||
List<int>? startedBytes;
|
||||
double? volume;
|
||||
var loop = false;
|
||||
var paused = false;
|
||||
var resumed = false;
|
||||
var stopped = false;
|
||||
var disposed = false;
|
||||
var startCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> get done => _done.future;
|
||||
|
||||
@override
|
||||
Future<void> start(
|
||||
Uint8List bytes, {
|
||||
required double volume,
|
||||
bool loop = false,
|
||||
}) async {
|
||||
if (_done.isCompleted) {
|
||||
_done = async.Completer<void>();
|
||||
}
|
||||
startCount++;
|
||||
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();
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
120
test/runtime/diagnostics/runtime_diagnostics_test.dart
Normal file
120
test/runtime/diagnostics/runtime_diagnostics_test.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeDiagnostics', () {
|
||||
test('keeps entries in insertion order', () {
|
||||
final diagnostics = RuntimeDiagnostics();
|
||||
|
||||
diagnostics
|
||||
..record(
|
||||
type: RuntimeDiagnosticType.packageActivationError,
|
||||
message: 'activation failed',
|
||||
)
|
||||
..record(
|
||||
type: RuntimeDiagnosticType.luaEventError,
|
||||
message: 'event failed',
|
||||
error: StateError('boom'),
|
||||
context: {'eventType': 'tap'},
|
||||
);
|
||||
|
||||
expect(diagnostics.entries, hasLength(2));
|
||||
expect(
|
||||
diagnostics.entries.first.type,
|
||||
RuntimeDiagnosticType.packageActivationError,
|
||||
);
|
||||
expect(diagnostics.entries.last.error, isA<StateError>());
|
||||
expect(diagnostics.entries.last.context, {'eventType': 'tap'});
|
||||
});
|
||||
|
||||
test('exports debug json with sanitized context and errors', () {
|
||||
final diagnostics = RuntimeDiagnostics(maxEntries: 3)
|
||||
..record(
|
||||
type: RuntimeDiagnosticType.resourceLoadError,
|
||||
message: 'audio failed',
|
||||
error: StateError('missing'),
|
||||
context: {
|
||||
'asset': 'bgm',
|
||||
'attempt': 2,
|
||||
'nested': {'z': 1, 'a': DateTime.utc(2026)},
|
||||
'values': [StateError('bad'), true],
|
||||
},
|
||||
);
|
||||
|
||||
final json = diagnostics.toDebugJson();
|
||||
|
||||
expect(json['maxEntries'], 3);
|
||||
expect(json['count'], 1);
|
||||
final entries = json['entries'] as List<Object?>;
|
||||
final entry = entries.single as Map<String, Object?>;
|
||||
expect(entry['type'], 'resourceLoadError');
|
||||
expect(entry['message'], 'audio failed');
|
||||
expect(entry['error'], contains('missing'));
|
||||
expect(entry['timestamp'], isA<String>());
|
||||
expect(entry['context'], {
|
||||
'asset': 'bgm',
|
||||
'attempt': 2,
|
||||
'nested': {'a': '2026-01-01T00:00:00.000Z', 'z': 1},
|
||||
'values': ['Bad state: bad', true],
|
||||
});
|
||||
});
|
||||
|
||||
test('dumps readable text for empty and populated diagnostics', () {
|
||||
final diagnostics = RuntimeDiagnostics(maxEntries: 2);
|
||||
|
||||
expect(diagnostics.dumpText(), 'RuntimeDiagnostics: no entries');
|
||||
|
||||
diagnostics.record(
|
||||
type: RuntimeDiagnosticType.commandError,
|
||||
message: 'command failed',
|
||||
error: ArgumentError('bad command'),
|
||||
context: {'command': 'play_bgm'},
|
||||
);
|
||||
|
||||
final dump = diagnostics.dumpText();
|
||||
|
||||
expect(dump, contains('RuntimeDiagnostics (1/2)'));
|
||||
expect(dump, contains('commandError: command failed'));
|
||||
expect(dump, contains('error: Invalid argument(s): bad command'));
|
||||
expect(dump, contains('context: {"command":"play_bgm"}'));
|
||||
});
|
||||
|
||||
test('entry debug helpers support direct use', () {
|
||||
final entry = RuntimeDiagnosticEntry(
|
||||
type: RuntimeDiagnosticType.luaEventError,
|
||||
message: 'lua failed',
|
||||
timestamp: DateTime.utc(2026, 6, 4, 12),
|
||||
error: 'boom',
|
||||
context: {'event': 'tap'},
|
||||
);
|
||||
|
||||
expect(entry.toDebugJson(), {
|
||||
'timestamp': '2026-06-04T12:00:00.000Z',
|
||||
'type': 'luaEventError',
|
||||
'message': 'lua failed',
|
||||
'error': 'boom',
|
||||
'context': {'event': 'tap'},
|
||||
});
|
||||
expect(
|
||||
entry.dumpText(),
|
||||
'[2026-06-04T12:00:00.000Z] luaEventError: lua failed\n'
|
||||
' error: boom\n'
|
||||
' context: {"event":"tap"}',
|
||||
);
|
||||
});
|
||||
|
||||
test('evicts oldest entries when max size is reached', () {
|
||||
final diagnostics = RuntimeDiagnostics(maxEntries: 2);
|
||||
|
||||
diagnostics
|
||||
..record(type: RuntimeDiagnosticType.commandError, message: 'one')
|
||||
..record(type: RuntimeDiagnosticType.commandError, message: 'two')
|
||||
..record(type: RuntimeDiagnosticType.commandError, message: 'three');
|
||||
|
||||
expect(diagnostics.entries.map((entry) => entry.message), [
|
||||
'two',
|
||||
'three',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
170
test/runtime/events/runtime_event_dispatcher_test.dart
Normal file
170
test/runtime/events/runtime_event_dispatcher_test.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
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/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 = <GameDiff>[];
|
||||
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<void>.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<void>.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<void>.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<void>.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<void>.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 = <Object>[];
|
||||
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<void>.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 = <RuntimeEvent>[];
|
||||
bool failNext = false;
|
||||
|
||||
@override
|
||||
Future<void> loadPackage(GamePackage package) async {}
|
||||
|
||||
@override
|
||||
bool smokeTest(Map<String, Object?> context) => true;
|
||||
|
||||
@override
|
||||
GameDiff init(Map<String, Object?> context) => GameDiff.empty;
|
||||
|
||||
@override
|
||||
GameDiff dispatchEvent(RuntimeEvent event) {
|
||||
events.add(event);
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
throw StateError('boom');
|
||||
}
|
||||
return GameDiff.empty;
|
||||
}
|
||||
}
|
||||
84
test/runtime/events/runtime_event_gate_test.dart
Normal file
84
test/runtime/events/runtime_event_gate_test.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flame_lua_runtime/runtime/events/runtime_event_gate.dart';
|
||||
import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart';
|
||||
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeEventGate', () {
|
||||
test('attaches session id without exposing lifecycle fields to Lua', () {
|
||||
final session = RuntimeSession(gameId: 'game')..activate();
|
||||
final gate = RuntimeEventGate(
|
||||
session: session,
|
||||
isScopeAlive: (_) => true,
|
||||
);
|
||||
|
||||
final event = gate.attachSession(const RuntimeEvent(type: 'tap'));
|
||||
|
||||
expect(event.sessionId, session.id);
|
||||
expect(event.toMap(), {'type': 'tap'});
|
||||
});
|
||||
|
||||
test('accepts only active matching sessions', () {
|
||||
final session = RuntimeSession(gameId: 'game')..activate();
|
||||
final gate = RuntimeEventGate(
|
||||
session: session,
|
||||
isScopeAlive: (_) => true,
|
||||
);
|
||||
|
||||
expect(
|
||||
gate.accepts(RuntimeEvent(type: 'tap', sessionId: session.id)),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
gate.accepts(RuntimeEvent(type: 'tap', sessionId: session.id + 1)),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
session.beginDisposing();
|
||||
expect(
|
||||
gate.accepts(RuntimeEvent(type: 'tap', sessionId: session.id)),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('drops dead scopes and stale epochs', () {
|
||||
final session = RuntimeSession(gameId: 'game')..activate();
|
||||
final gate = RuntimeEventGate(
|
||||
session: session,
|
||||
isScopeAlive: (scope) => scope == 'alive',
|
||||
isNodeEpochAlive: (id, epoch) => epoch == 2,
|
||||
);
|
||||
|
||||
expect(
|
||||
gate.accepts(
|
||||
RuntimeEvent(
|
||||
type: 'tap',
|
||||
sessionId: session.id,
|
||||
scope: 'alive',
|
||||
scopeEpoch: 2,
|
||||
target: 'button',
|
||||
targetEpoch: 2,
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
gate.accepts(
|
||||
RuntimeEvent(type: 'tap', sessionId: session.id, scope: 'dead'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
gate.accepts(
|
||||
RuntimeEvent(
|
||||
type: 'tap',
|
||||
sessionId: session.id,
|
||||
target: 'button',
|
||||
targetEpoch: 1,
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
72
test/runtime/game/flame_lua_game_test.dart
Normal file
72
test/runtime/game/flame_lua_game_test.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart';
|
||||
import 'package:flame_lua_runtime/runtime/game/flame_lua_game.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/packages/game_package_manifest.dart';
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart';
|
||||
import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FlameLuaGame diagnostics debug access', () {
|
||||
test('exposes diagnostics entries, dump text and debug json', () {
|
||||
final diagnostics = RuntimeDiagnostics()
|
||||
..record(
|
||||
type: RuntimeDiagnosticType.commandError,
|
||||
message: 'command failed',
|
||||
context: {'command': 'play_bgm'},
|
||||
);
|
||||
final game = FlameLuaGame(
|
||||
scriptEngine: _FakeScriptEngine(),
|
||||
packageRepository: _FakePackageRepository(),
|
||||
gameId: 'ludo',
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
|
||||
expect(game.diagnosticEntries, hasLength(1));
|
||||
expect(game.diagnosticsDumpText(), contains('command failed'));
|
||||
expect(game.diagnosticsDebugJson()['count'], 1);
|
||||
expect(game.resourcesDebugJson(), {'initialized': false});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeScriptEngine implements ScriptEngine {
|
||||
@override
|
||||
Future<void> loadPackage(GamePackage package) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
GameDiff dispatchEvent(RuntimeEvent event) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
GameDiff init(Map<String, Object?> context) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
bool smokeTest(Map<String, Object?> context) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakePackageRepository implements GamePackageRepository {
|
||||
@override
|
||||
Future<GamePackage> load(String gameId) async {
|
||||
return GamePackage.asset(
|
||||
rootPath: 'example/assets/games/$gameId',
|
||||
manifest: GamePackageManifest(
|
||||
gameId: gameId,
|
||||
name: gameId,
|
||||
version: 'test',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
63
test/runtime/game/runtime_locale_test.dart
Normal file
63
test/runtime/game/runtime_locale_test.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:flame_lua_runtime/runtime/game/runtime_locale.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeLocaleResolver', () {
|
||||
test('normalizes locale tags', () {
|
||||
expect(RuntimeLocaleResolver.normalizeTag('zh_hans_cn'), 'zh-Hans-CN');
|
||||
expect(RuntimeLocaleResolver.normalizeTag('EN-us'), 'en-US');
|
||||
expect(RuntimeLocaleResolver.tagOf(const Locale('zh', 'CN')), 'zh-CN');
|
||||
});
|
||||
|
||||
test('resolves exact, language and fallback locales', () {
|
||||
expect(
|
||||
RuntimeLocaleResolver.resolve(
|
||||
requested: const Locale.fromSubtags(
|
||||
languageCode: 'zh',
|
||||
scriptCode: 'Hans',
|
||||
),
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: const ['zh-Hans', 'en'],
|
||||
).resolved,
|
||||
'zh-Hans',
|
||||
);
|
||||
|
||||
expect(
|
||||
RuntimeLocaleResolver.resolve(
|
||||
requested: const Locale('en', 'US'),
|
||||
defaultLocale: 'zh-Hans',
|
||||
supportedLocales: const ['zh-Hans', 'en'],
|
||||
).resolved,
|
||||
'en',
|
||||
);
|
||||
|
||||
expect(
|
||||
RuntimeLocaleResolver.resolve(
|
||||
requested: const Locale('fr', 'FR'),
|
||||
defaultLocale: 'zh-Hans',
|
||||
supportedLocales: const ['zh-Hans', 'en'],
|
||||
).resolved,
|
||||
'zh-Hans',
|
||||
);
|
||||
});
|
||||
|
||||
test('exports locale context for Lua', () {
|
||||
final info = RuntimeLocaleResolver.resolve(
|
||||
requested: const Locale('en', 'US'),
|
||||
defaultLocale: 'zh-Hans',
|
||||
supportedLocales: const ['zh-Hans', 'en'],
|
||||
);
|
||||
|
||||
expect(info.toMap(), {
|
||||
'requested': 'en-US',
|
||||
'resolved': 'en',
|
||||
'default': 'zh-Hans',
|
||||
'supported': ['zh-Hans', 'en'],
|
||||
'languageCode': 'en',
|
||||
'countryCode': 'US',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
36
test/runtime/lifecycle/runtime_async_gate_test.dart
Normal file
36
test/runtime/lifecycle/runtime_async_gate_test.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flame_lua_runtime/runtime/lifecycle/runtime_async_gate.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeAsyncGate', () {
|
||||
test('accepts only current open generation tokens', () {
|
||||
final gate = RuntimeAsyncGate();
|
||||
final first = gate.activate();
|
||||
|
||||
expect(gate.accepts(first), isTrue);
|
||||
expect(first.isAccepted, isTrue);
|
||||
|
||||
final second = gate.advance();
|
||||
|
||||
expect(gate.accepts(first), isFalse);
|
||||
expect(gate.accepts(second), isTrue);
|
||||
});
|
||||
|
||||
test('close rejects existing and future checks until activated again', () {
|
||||
final gate = RuntimeAsyncGate();
|
||||
final first = gate.activate();
|
||||
|
||||
gate.close();
|
||||
|
||||
expect(gate.isClosed, isTrue);
|
||||
expect(gate.accepts(first), isFalse);
|
||||
expect(gate.acceptsGeneration(gate.generation), isFalse);
|
||||
|
||||
final second = gate.activate();
|
||||
|
||||
expect(gate.isOpen, isTrue);
|
||||
expect(gate.accepts(second), isTrue);
|
||||
expect(gate.accepts(first), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
59
test/runtime/lifecycle/runtime_serial_queue_test.dart
Normal file
59
test/runtime/lifecycle/runtime_serial_queue_test.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flame_lua_runtime/runtime/lifecycle/runtime_serial_queue.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeSerialQueue', () {
|
||||
test('drains queued items in order on a microtask', () async {
|
||||
final handled = <int>[];
|
||||
final queue = RuntimeSerialQueue<int>(onItem: handled.add);
|
||||
|
||||
queue
|
||||
..enqueue(1)
|
||||
..enqueue(2)
|
||||
..enqueue(3);
|
||||
|
||||
expect(handled, isEmpty);
|
||||
expect(queue.pendingCount, 3);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(handled, [1, 2, 3]);
|
||||
expect(queue.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('stops draining when shouldContinue turns false', () async {
|
||||
final handled = <int>[];
|
||||
var active = true;
|
||||
late final RuntimeSerialQueue<int> queue;
|
||||
queue = RuntimeSerialQueue<int>(
|
||||
shouldContinue: () => active,
|
||||
onItem: (item) {
|
||||
handled.add(item);
|
||||
active = false;
|
||||
},
|
||||
);
|
||||
|
||||
queue
|
||||
..enqueue(1)
|
||||
..enqueue(2);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(handled, [1]);
|
||||
expect(queue.pendingCount, 1);
|
||||
});
|
||||
|
||||
test('dispose drops pending items', () async {
|
||||
final handled = <int>[];
|
||||
final queue = RuntimeSerialQueue<int>(onItem: handled.add);
|
||||
|
||||
queue.enqueue(1);
|
||||
queue.dispose();
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(handled, isEmpty);
|
||||
expect(queue.pendingCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
38
test/runtime/lifecycle/runtime_session_test.dart
Normal file
38
test/runtime/lifecycle/runtime_session_test.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeSession', () {
|
||||
test('moves through loading, active, disposing and disposed states', () {
|
||||
final session = RuntimeSession(gameId: 'game');
|
||||
|
||||
expect(session.state, RuntimeSessionState.created);
|
||||
expect(session.acceptsWork, isTrue);
|
||||
expect(session.isActive, isFalse);
|
||||
|
||||
session.beginLoading();
|
||||
expect(session.state, RuntimeSessionState.loading);
|
||||
expect(session.acceptsWorkFor(session.id), isTrue);
|
||||
expect(session.accepts(session.id), isFalse);
|
||||
|
||||
session.activate();
|
||||
expect(session.state, RuntimeSessionState.active);
|
||||
expect(session.accepts(session.id), isTrue);
|
||||
|
||||
session.beginDisposing();
|
||||
expect(session.state, RuntimeSessionState.disposing);
|
||||
expect(session.acceptsWork, isFalse);
|
||||
expect(session.accepts(session.id), isFalse);
|
||||
|
||||
session.dispose();
|
||||
expect(session.state, RuntimeSessionState.disposed);
|
||||
expect(session.acceptsWork, isFalse);
|
||||
});
|
||||
|
||||
test('rejects invalid transitions', () {
|
||||
final session = RuntimeSession(gameId: 'game')..activate();
|
||||
|
||||
expect(session.beginLoading, throwsA(isA<StateError>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
61
test/runtime/lifecycle/runtime_task_registry_test.dart
Normal file
61
test/runtime/lifecycle/runtime_task_registry_test.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'dart:async' as async;
|
||||
|
||||
import 'package:flame_lua_runtime/runtime/lifecycle/runtime_task_registry.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeTaskRegistry', () {
|
||||
test('cancels tasks by scope', () async {
|
||||
final registry = RuntimeTaskRegistry<String>(cancelledValue: 'cancelled');
|
||||
final scoped = registry.create(scope: 'panel');
|
||||
final other = registry.create(scope: 'other');
|
||||
|
||||
registry.cancelScope('panel');
|
||||
|
||||
expect(await scoped.future, 'cancelled');
|
||||
expect(scoped.isCancelled, isTrue);
|
||||
expect(other.isCancelled, isFalse);
|
||||
expect(registry.scopedTaskCount('panel'), 0);
|
||||
expect(registry.scopedTaskCount('other'), 1);
|
||||
|
||||
other.complete('done');
|
||||
expect(await other.future, 'done');
|
||||
expect(registry.activeTaskCount, 0);
|
||||
});
|
||||
|
||||
test('cancel runs callbacks and cancels timers', () async {
|
||||
final registry = RuntimeTaskRegistry<String>(cancelledValue: 'cancelled');
|
||||
final task = registry.create(scope: 'panel');
|
||||
var callbackCalled = false;
|
||||
var timerFired = false;
|
||||
final timer = async.Timer(const Duration(milliseconds: 30), () {
|
||||
timerFired = true;
|
||||
});
|
||||
|
||||
task
|
||||
..addTimer(timer)
|
||||
..addCancelCallback(() {
|
||||
callbackCalled = true;
|
||||
})
|
||||
..cancel();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 40));
|
||||
|
||||
expect(await task.future, 'cancelled');
|
||||
expect(callbackCalled, isTrue);
|
||||
expect(timerFired, isFalse);
|
||||
});
|
||||
|
||||
test('dispose cancels all active tasks', () async {
|
||||
final registry = RuntimeTaskRegistry<String>(cancelledValue: 'cancelled');
|
||||
final first = registry.create();
|
||||
final second = registry.create(scope: 'panel');
|
||||
|
||||
registry.dispose();
|
||||
|
||||
expect(await first.future, 'cancelled');
|
||||
expect(await second.future, 'cancelled');
|
||||
expect(registry.activeTaskCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
121
test/runtime/models/game_diff_test.dart
Normal file
121
test/runtime/models/game_diff_test.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
import 'package:flame_lua_runtime/runtime/models/game_diff.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('GameDiff', () {
|
||||
test('parses render, ui and commands', () {
|
||||
final diff = GameDiff.fromMap({
|
||||
'render': {
|
||||
'creates': [
|
||||
{'id': 'board', 'type': 'image', 'asset': 'board'},
|
||||
],
|
||||
'updates': [
|
||||
{
|
||||
'id': 'piece_red_1',
|
||||
'props': {'x': 100, 'y': 120},
|
||||
},
|
||||
],
|
||||
'removes': ['old_piece'],
|
||||
},
|
||||
'ui': {
|
||||
'creates': [
|
||||
{'id': 'dice_button', 'type': 'button', 'text': 'Roll'},
|
||||
],
|
||||
},
|
||||
'commands': [
|
||||
{'type': 'move_path', 'target': 'piece_red_1', 'duration': 0.5},
|
||||
],
|
||||
});
|
||||
|
||||
expect(diff.render.creates.single.id, 'board');
|
||||
expect(diff.render.updates.single.id, 'piece_red_1');
|
||||
expect(diff.render.updates.single.props['x'], 100);
|
||||
expect(diff.render.removes.single.id, 'old_piece');
|
||||
expect(diff.ui.creates.single.id, 'dice_button');
|
||||
expect(diff.commands.single.type, 'move_path');
|
||||
expect(diff.commands.single.target, 'piece_red_1');
|
||||
expect(diff.commands.single.payload['duration'], 0.5);
|
||||
});
|
||||
|
||||
test('treats missing sections as empty diffs', () {
|
||||
final diff = GameDiff.fromMap({});
|
||||
|
||||
expect(diff.render.creates, isEmpty);
|
||||
expect(diff.render.updates, isEmpty);
|
||||
expect(diff.render.removes, isEmpty);
|
||||
expect(diff.ui.creates, isEmpty);
|
||||
expect(diff.commands, isEmpty);
|
||||
});
|
||||
|
||||
test('accepts Lua numeric-key tables as lists', () {
|
||||
final diff = GameDiff.fromMap({
|
||||
'render': {
|
||||
'creates': {
|
||||
2: {'id': 'b', 'type': 'text'},
|
||||
1: {'id': 'a', 'type': 'text'},
|
||||
},
|
||||
'updates': {},
|
||||
'removes': {
|
||||
1: {'id': 'old_a'},
|
||||
2: 'old_b',
|
||||
},
|
||||
},
|
||||
'commands': {
|
||||
1: {'type': 'toast', 'message': 'hi'},
|
||||
},
|
||||
});
|
||||
|
||||
expect(diff.render.creates.map((node) => node.id), ['a', 'b']);
|
||||
expect(diff.render.removes.map((node) => node.id), ['old_a', 'old_b']);
|
||||
expect(diff.commands.single.type, 'toast');
|
||||
expect(diff.commands.single.payload['message'], 'hi');
|
||||
});
|
||||
|
||||
test('rejects malformed diff fields', () {
|
||||
expect(
|
||||
() => GameDiff.fromMap({
|
||||
'render': {'creates': 'bad'},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => GameDiff.fromMap({
|
||||
'render': {
|
||||
'updates': [
|
||||
{'id': 'node', 'props': 'bad'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => GameDiff.fromMap({
|
||||
'commands': [
|
||||
{'type': ''},
|
||||
],
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(() => GameDiff.fromMap({'unknown': {}}), throwsFormatException);
|
||||
expect(
|
||||
() => GameDiff.fromMap({
|
||||
'render': {'createz': []},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => GameDiff.fromMap({
|
||||
'render': {
|
||||
'updates': [
|
||||
{
|
||||
'id': 'node',
|
||||
'props': {'interative': true},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
77
test/runtime/models/runtime_event_command_test.dart
Normal file
77
test/runtime/models/runtime_event_command_test.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flame_lua_runtime/runtime/models/runtime_command.dart';
|
||||
import 'package:flame_lua_runtime/runtime/models/runtime_event.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeEvent', () {
|
||||
test('serializes only present fields', () {
|
||||
final event = RuntimeEvent(
|
||||
type: 'tap',
|
||||
target: 'dice_button',
|
||||
handler: 'roll_dice',
|
||||
x: 10,
|
||||
y: 20,
|
||||
data: {'pointer': 1},
|
||||
);
|
||||
|
||||
expect(event.toMap(), {
|
||||
'type': 'tap',
|
||||
'target': 'dice_button',
|
||||
'handler': 'roll_dice',
|
||||
'x': 10,
|
||||
'y': 20,
|
||||
'data': {'pointer': 1},
|
||||
});
|
||||
});
|
||||
|
||||
test('omits null and empty optional fields', () {
|
||||
expect(const RuntimeEvent(type: 'animation_done').toMap(), {
|
||||
'type': 'animation_done',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('RuntimeCommand', () {
|
||||
test('parses command payload without type and target', () {
|
||||
final command = RuntimeCommand.fromMap({
|
||||
'type': 'move_path',
|
||||
'target': 'piece_red_1',
|
||||
'duration': 0.5,
|
||||
'onComplete': 'done',
|
||||
});
|
||||
|
||||
expect(command.type, 'move_path');
|
||||
expect(command.target, 'piece_red_1');
|
||||
expect(command.payload, {'duration': 0.5, 'onComplete': 'done'});
|
||||
});
|
||||
|
||||
test('rejects invalid command shape', () {
|
||||
expect(() => RuntimeCommand.fromMap({'type': ''}), throwsFormatException);
|
||||
expect(
|
||||
() => RuntimeCommand.fromMap({'type': 'toast', 'target': 1}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeCommand.fromMap({'type': 'unknown'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeCommand.fromMap({
|
||||
'type': 'move_to',
|
||||
'target': 'piece',
|
||||
'x': 1,
|
||||
'y': 2,
|
||||
'duraton': 0.5,
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeCommand.fromMap({
|
||||
'type': 'preload_resources',
|
||||
'groups': 'pieces',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
324
test/runtime/models/runtime_node_test.dart
Normal file
324
test/runtime/models/runtime_node_test.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
import 'package:flame_lua_runtime/runtime/models/runtime_node.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeNode', () {
|
||||
test('parses required and optional fields', () {
|
||||
final node = RuntimeNode.fromMap({
|
||||
'id': 'dice_button',
|
||||
'type': 'button',
|
||||
'parent': 'top_bar',
|
||||
'asset': 'dice_normal',
|
||||
'pressedAsset': 'dice_pressed',
|
||||
'disabledAsset': 'dice_disabled',
|
||||
'animation': 'idle',
|
||||
'skin': 'red',
|
||||
'loop': false,
|
||||
'text': 'Roll',
|
||||
'x': 10,
|
||||
'y': 20.5,
|
||||
'width': 120,
|
||||
'height': 48,
|
||||
'paddingLeft': 4,
|
||||
'paddingTop': 5,
|
||||
'paddingRight': 6,
|
||||
'paddingBottom': 7,
|
||||
'anchor': 'center',
|
||||
'layer': 3,
|
||||
'visible': false,
|
||||
'alpha': 0.7,
|
||||
'scale': 1.2,
|
||||
'rotation': 0.4,
|
||||
'color': '#112233',
|
||||
'fontSize': 18,
|
||||
'textAlign': 'left',
|
||||
'radius': 10,
|
||||
'strokeWidth': 3,
|
||||
'value': 0.6,
|
||||
'scrollX': 15,
|
||||
'scrollY': 20,
|
||||
'contentWidth': 220,
|
||||
'contentHeight': 180,
|
||||
'virtualized': true,
|
||||
'cacheExtent': 12,
|
||||
'inertia': false,
|
||||
'scrollbarThumbColor': '#abcdef',
|
||||
'scrollbarTrackColor': '#123456',
|
||||
'scrollbarThickness': 6,
|
||||
'scrollbarVisible': false,
|
||||
'interactive': true,
|
||||
'onTap': 'roll_dice',
|
||||
'onScroll': 'list_scrolled',
|
||||
'preset': 'burst',
|
||||
'count': 32,
|
||||
'duration': 0.6,
|
||||
'speedMin': 60,
|
||||
'speedMax': 180,
|
||||
'gravityX': 0,
|
||||
'gravityY': 120,
|
||||
'spread': 360,
|
||||
'colorTo': '#00ffcc33',
|
||||
'radiusTo': 0,
|
||||
'autoRemove': false,
|
||||
'fadeOut': false,
|
||||
});
|
||||
|
||||
expect(node.id, 'dice_button');
|
||||
expect(node.type, 'button');
|
||||
expect(node.parent, 'top_bar');
|
||||
expect(node.asset, 'dice_normal');
|
||||
expect(node.pressedAsset, 'dice_pressed');
|
||||
expect(node.disabledAsset, 'dice_disabled');
|
||||
expect(node.animation, 'idle');
|
||||
expect(node.skin, 'red');
|
||||
expect(node.loop, isFalse);
|
||||
expect(node.text, 'Roll');
|
||||
expect(node.x, 10);
|
||||
expect(node.y, 20.5);
|
||||
expect(node.width, 120);
|
||||
expect(node.height, 48);
|
||||
expect(node.paddingLeft, 4);
|
||||
expect(node.paddingTop, 5);
|
||||
expect(node.paddingRight, 6);
|
||||
expect(node.paddingBottom, 7);
|
||||
expect(node.anchor, 'center');
|
||||
expect(node.layer, 3);
|
||||
expect(node.visible, isFalse);
|
||||
expect(node.alpha, 0.7);
|
||||
expect(node.scale, 1.2);
|
||||
expect(node.rotation, 0.4);
|
||||
expect(node.color, const Color(0xff112233));
|
||||
expect(node.fontSize, 18);
|
||||
expect(node.textAlign, 'left');
|
||||
expect(node.radius, 10);
|
||||
expect(node.strokeWidth, 3);
|
||||
expect(node.value, 0.6);
|
||||
expect(node.scrollX, 15);
|
||||
expect(node.scrollY, 20);
|
||||
expect(node.contentWidth, 220);
|
||||
expect(node.contentHeight, 180);
|
||||
expect(node.virtualized, isTrue);
|
||||
expect(node.cacheExtent, 12);
|
||||
expect(node.inertia, isFalse);
|
||||
expect(node.scrollbarThumbColor, const Color(0xffabcdef));
|
||||
expect(node.scrollbarTrackColor, const Color(0xff123456));
|
||||
expect(node.scrollbarThickness, 6);
|
||||
expect(node.scrollbarVisible, isFalse);
|
||||
expect(node.interactive, isTrue);
|
||||
expect(node.onTap, 'roll_dice');
|
||||
expect(node.onScroll, 'list_scrolled');
|
||||
expect(node.preset, 'burst');
|
||||
expect(node.count, 32);
|
||||
expect(node.duration, 0.6);
|
||||
expect(node.speedMin, 60);
|
||||
expect(node.speedMax, 180);
|
||||
expect(node.gravityX, 0);
|
||||
expect(node.gravityY, 120);
|
||||
expect(node.spread, 360);
|
||||
expect(node.colorTo, const Color(0x00ffcc33));
|
||||
expect(node.radiusTo, 0);
|
||||
expect(node.autoRemove, isFalse);
|
||||
expect(node.fadeOut, isFalse);
|
||||
});
|
||||
|
||||
test('applies default values', () {
|
||||
final node = RuntimeNode.fromMap({'id': 'label', 'type': 'text'});
|
||||
|
||||
expect(node.x, 0);
|
||||
expect(node.y, 0);
|
||||
expect(node.anchor, 'topLeft');
|
||||
expect(node.layer, 0);
|
||||
expect(node.visible, isTrue);
|
||||
expect(node.alpha, 1);
|
||||
expect(node.scale, 1);
|
||||
expect(node.rotation, 0);
|
||||
expect(node.loop, isTrue);
|
||||
expect(node.textAlign, 'center');
|
||||
expect(node.scrollbarVisible, isTrue);
|
||||
expect(node.paddingLeft, 0);
|
||||
expect(node.paddingTop, 0);
|
||||
expect(node.paddingRight, 0);
|
||||
expect(node.paddingBottom, 0);
|
||||
expect(node.autoRemove, isTrue);
|
||||
expect(node.fadeOut, isTrue);
|
||||
expect(node.interactive, isFalse);
|
||||
});
|
||||
|
||||
test('copies only provided props', () {
|
||||
final node = RuntimeNode.fromMap({
|
||||
'id': 'piece',
|
||||
'type': 'circle',
|
||||
'x': 1,
|
||||
'y': 2,
|
||||
'color': '#ff0000',
|
||||
});
|
||||
|
||||
final updated = node.copyWithProps({
|
||||
'x': 20,
|
||||
'parent': 'board',
|
||||
'visible': false,
|
||||
'color': '#8000ff00',
|
||||
'radius': 8,
|
||||
'strokeWidth': 2,
|
||||
'value': 0.75,
|
||||
'width': 70,
|
||||
'height': 60,
|
||||
'paddingLeft': 8,
|
||||
'paddingTop': 9,
|
||||
'paddingRight': 10,
|
||||
'paddingBottom': 11,
|
||||
'contentWidth': 120,
|
||||
'contentHeight': 100,
|
||||
'pressedAsset': 'button_pressed',
|
||||
'disabledAsset': 'button_disabled',
|
||||
'scrollX': 90,
|
||||
'scrollY': 80,
|
||||
'textAlign': 'right',
|
||||
'preset': 'trail',
|
||||
'count': 12,
|
||||
});
|
||||
|
||||
expect(updated.id, 'piece');
|
||||
expect(updated.type, 'circle');
|
||||
expect(updated.parent, 'board');
|
||||
expect(updated.x, 20);
|
||||
expect(updated.y, 2);
|
||||
expect(updated.visible, isFalse);
|
||||
expect(updated.color, const Color(0x8000ff00));
|
||||
expect(updated.radius, 8);
|
||||
expect(updated.strokeWidth, 2);
|
||||
expect(updated.value, 0.75);
|
||||
expect(updated.width, 70);
|
||||
expect(updated.height, 60);
|
||||
expect(updated.paddingLeft, 8);
|
||||
expect(updated.paddingTop, 9);
|
||||
expect(updated.paddingRight, 10);
|
||||
expect(updated.paddingBottom, 11);
|
||||
expect(updated.contentWidth, 120);
|
||||
expect(updated.contentHeight, 100);
|
||||
expect(updated.pressedAsset, 'button_pressed');
|
||||
expect(updated.disabledAsset, 'button_disabled');
|
||||
expect(updated.scrollX, 68);
|
||||
expect(updated.scrollY, 60);
|
||||
expect(updated.textAlign, 'right');
|
||||
expect(updated.preset, 'trail');
|
||||
expect(updated.count, 12);
|
||||
});
|
||||
|
||||
test('rejects invalid values', () {
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': '', 'type': 'text'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'text', 'x': 'bad'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'text', 'color': 'red'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'unknown'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'text',
|
||||
'anchor': 'middle',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'progress', 'value': 2}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'text',
|
||||
'textAlign': 'justify',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() =>
|
||||
RuntimeNode.fromMap({'id': 'a', 'type': 'listView', 'scrollY': -1}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'particle',
|
||||
'preset': 'unknown',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'particle', 'count': 0}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'listView',
|
||||
'paddingTop': -1,
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'listView',
|
||||
'cacheExtent': -1,
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'rect',
|
||||
'interactive': 'yes',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'button',
|
||||
'pressedAsset': 1,
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'rect', 'parent': 1}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'spine', 'loop': 'yes'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({'id': 'a', 'type': 'rect', 'parent': 'a'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'rect',
|
||||
'interative': true,
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RuntimeNode.fromMap({
|
||||
'id': 'a',
|
||||
'type': 'rect',
|
||||
}).copyWithProps({'screenWitdh': 720}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.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/packages/game_package_activation_controller.dart';
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart';
|
||||
import 'package:flame_lua_runtime/runtime/packages/stable_package_store.dart';
|
||||
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
|
||||
import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('PackageActivationController', () {
|
||||
test(
|
||||
'activates repository candidate and marks stable after init',
|
||||
() async {
|
||||
final candidate = await _createPackage('candidate');
|
||||
final store = _FakeStablePackageStore();
|
||||
final scriptEngine = _FakeScriptEngine();
|
||||
|
||||
final result = await PackageActivationController(
|
||||
repository: _FakeRepository(candidate),
|
||||
resources: GameResourceManager(),
|
||||
scriptEngine: scriptEngine,
|
||||
store: store,
|
||||
assetFallback: _FakeRepository(await _createPackage('asset')),
|
||||
).activate(gameId: 'ludo', contextBuilder: _context);
|
||||
|
||||
expect(result.package.rootPath, candidate.rootPath);
|
||||
expect(scriptEngine.loadedPackages, [candidate.rootPath]);
|
||||
expect(scriptEngine.initPackages, [candidate.rootPath]);
|
||||
expect(store.markedPackages, [candidate.rootPath]);
|
||||
},
|
||||
);
|
||||
|
||||
test('falls back to stable when repository candidate is invalid', () async {
|
||||
final invalidCandidate = await _createPackage('invalid', script: 'bad');
|
||||
final stable = await _createPackage('stable');
|
||||
final store = _FakeStablePackageStore(stable: stable);
|
||||
final scriptEngine = _FakeScriptEngine();
|
||||
|
||||
final result = await PackageActivationController(
|
||||
repository: _FakeRepository(invalidCandidate),
|
||||
resources: GameResourceManager(),
|
||||
scriptEngine: scriptEngine,
|
||||
store: store,
|
||||
assetFallback: _FakeRepository(await _createPackage('asset')),
|
||||
).activate(gameId: 'ludo', contextBuilder: _context);
|
||||
|
||||
expect(result.package.rootPath, stable.rootPath);
|
||||
expect(scriptEngine.loadedPackages, [stable.rootPath]);
|
||||
expect(store.markedPackages, [stable.rootPath]);
|
||||
});
|
||||
|
||||
test(
|
||||
'falls back to previous stable when current stable is invalid',
|
||||
() async {
|
||||
final invalidCandidate = await _createPackage(
|
||||
'invalid_candidate',
|
||||
script: 'bad',
|
||||
);
|
||||
final invalidStable = await _createPackage(
|
||||
'invalid_stable',
|
||||
script: 'bad',
|
||||
);
|
||||
final previous = await _createPackage('previous');
|
||||
final store = _FakeStablePackageStore(
|
||||
stable: invalidStable,
|
||||
previous: previous,
|
||||
);
|
||||
final scriptEngine = _FakeScriptEngine();
|
||||
|
||||
final result = await PackageActivationController(
|
||||
repository: _FakeRepository(invalidCandidate),
|
||||
resources: GameResourceManager(),
|
||||
scriptEngine: scriptEngine,
|
||||
store: store,
|
||||
assetFallback: _FakeRepository(await _createPackage('asset')),
|
||||
).activate(gameId: 'ludo', contextBuilder: _context);
|
||||
|
||||
expect(result.package.rootPath, previous.rootPath);
|
||||
expect(scriptEngine.loadedPackages, [previous.rootPath]);
|
||||
expect(store.markedPackages, [previous.rootPath]);
|
||||
},
|
||||
);
|
||||
|
||||
test('falls back to asset when repository load fails', () async {
|
||||
final failedCandidate = await _createPackage('failed_candidate');
|
||||
final fallback = await _createPackage('asset');
|
||||
final store = _FakeStablePackageStore();
|
||||
final scriptEngine = _FakeScriptEngine();
|
||||
|
||||
final result = await PackageActivationController(
|
||||
repository: _FakeRepository(
|
||||
failedCandidate,
|
||||
error: StateError('network failed'),
|
||||
),
|
||||
resources: GameResourceManager(),
|
||||
scriptEngine: scriptEngine,
|
||||
store: store,
|
||||
assetFallback: _FakeRepository(fallback),
|
||||
).activate(gameId: 'ludo', contextBuilder: _context);
|
||||
|
||||
expect(result.package.rootPath, fallback.rootPath);
|
||||
expect(scriptEngine.loadedPackages, [fallback.rootPath]);
|
||||
expect(store.markedPackages, [fallback.rootPath]);
|
||||
});
|
||||
|
||||
test('stops activation when cancellation guard turns false', () async {
|
||||
final candidate = await _createPackage('candidate');
|
||||
final fallback = await _createPackage('asset');
|
||||
final store = _FakeStablePackageStore();
|
||||
final scriptEngine = _FakeScriptEngine();
|
||||
var active = false;
|
||||
|
||||
await expectLater(
|
||||
PackageActivationController(
|
||||
repository: _FakeRepository(candidate),
|
||||
resources: GameResourceManager(),
|
||||
scriptEngine: scriptEngine,
|
||||
store: store,
|
||||
assetFallback: _FakeRepository(fallback),
|
||||
).activate(
|
||||
gameId: 'ludo',
|
||||
contextBuilder: _context,
|
||||
shouldContinue: () => active,
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
|
||||
expect(scriptEngine.loadedPackages, isEmpty);
|
||||
expect(store.markedPackages, isEmpty);
|
||||
});
|
||||
|
||||
test('uses staging resources and script engine before commit', () async {
|
||||
final candidate = await _createPackage('candidate');
|
||||
final activeResources = _RecordingResourceManager();
|
||||
final activeScriptEngine = _FakeScriptEngine();
|
||||
final stagingResources = <_RecordingResourceManager>[];
|
||||
final stagingEngines = <_FakeScriptEngine>[];
|
||||
|
||||
final result = await PackageActivationController(
|
||||
repository: _FakeRepository(candidate),
|
||||
resources: activeResources,
|
||||
scriptEngine: activeScriptEngine,
|
||||
store: _FakeStablePackageStore(),
|
||||
assetFallback: _FakeRepository(await _createPackage('asset')),
|
||||
resourceManagerFactory: () {
|
||||
final resources = _RecordingResourceManager();
|
||||
stagingResources.add(resources);
|
||||
return resources;
|
||||
},
|
||||
scriptEngineFactory: () {
|
||||
final engine = _FakeScriptEngine();
|
||||
stagingEngines.add(engine);
|
||||
return engine;
|
||||
},
|
||||
).activate(gameId: 'ludo', contextBuilder: _context);
|
||||
|
||||
expect(activeResources.mountedPackages, isEmpty);
|
||||
expect(activeScriptEngine.loadedPackages, isEmpty);
|
||||
expect(result.resources, same(stagingResources.single));
|
||||
expect(result.scriptEngine, same(stagingEngines.single));
|
||||
expect(stagingResources.single.mountedPackages, [candidate.rootPath]);
|
||||
expect(stagingEngines.single.loadedPackages, [candidate.rootPath]);
|
||||
});
|
||||
|
||||
test('falls back when required audio preload fails', () async {
|
||||
final candidate = await _createAudioPackage(
|
||||
'candidate_bad_audio',
|
||||
writeAudio: false,
|
||||
);
|
||||
final fallback = await _createAudioPackage('asset_audio');
|
||||
final store = _FakeStablePackageStore();
|
||||
final scriptEngine = _FakeScriptEngine();
|
||||
|
||||
final result = await PackageActivationController(
|
||||
repository: _FakeRepository(candidate),
|
||||
resources: GameResourceManager(),
|
||||
scriptEngine: scriptEngine,
|
||||
audio: RuntimeAudioManager(),
|
||||
store: store,
|
||||
assetFallback: _FakeRepository(fallback),
|
||||
audioManagerFactory: RuntimeAudioManager.new,
|
||||
).activate(gameId: 'ludo', contextBuilder: _context);
|
||||
|
||||
expect(result.package.rootPath, fallback.rootPath);
|
||||
expect(result.audio, isNotNull);
|
||||
expect(store.markedPackages, [fallback.rootPath]);
|
||||
});
|
||||
|
||||
test('does not mark a package when smoke_test fails', () async {
|
||||
final candidate = await _createPackage('candidate');
|
||||
final fallback = await _createPackage('asset');
|
||||
final store = _FakeStablePackageStore();
|
||||
final scriptEngine = _FakeScriptEngine(
|
||||
smokeFailures: {candidate.rootPath},
|
||||
);
|
||||
|
||||
final result = await PackageActivationController(
|
||||
repository: _FakeRepository(candidate),
|
||||
resources: GameResourceManager(),
|
||||
scriptEngine: scriptEngine,
|
||||
store: store,
|
||||
assetFallback: _FakeRepository(fallback),
|
||||
).activate(gameId: 'ludo', contextBuilder: _context);
|
||||
|
||||
expect(result.package.rootPath, fallback.rootPath);
|
||||
expect(scriptEngine.loadedPackages, [
|
||||
candidate.rootPath,
|
||||
fallback.rootPath,
|
||||
]);
|
||||
expect(store.markedPackages, [fallback.rootPath]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object?> _context(GamePackage package) {
|
||||
return {
|
||||
'runtimeApiVersion': 1,
|
||||
'gameId': package.manifest.gameId,
|
||||
'gameVersion': package.manifest.version,
|
||||
};
|
||||
}
|
||||
|
||||
Future<GamePackage> _createPackage(
|
||||
String name, {
|
||||
String script = _validScript,
|
||||
}) async {
|
||||
final root = await Directory.systemTemp.createTemp('activation_${name}_');
|
||||
Directory('${root.path}/scripts').createSync(recursive: true);
|
||||
File('${root.path}/scripts/main.lua').writeAsStringSync(script);
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: const GamePackageManifest(
|
||||
gameId: 'ludo',
|
||||
name: 'Ludo',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<GamePackage> _createAudioPackage(
|
||||
String name, {
|
||||
bool writeAudio = true,
|
||||
}) async {
|
||||
final root = await Directory.systemTemp.createTemp('activation_${name}_');
|
||||
Directory('${root.path}/scripts').createSync(recursive: true);
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
File('${root.path}/scripts/main.lua').writeAsStringSync(_validScript);
|
||||
if (writeAudio) {
|
||||
File('${root.path}/assets/dice.wav').writeAsBytesSync(const [1, 2, 3]);
|
||||
}
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: const GamePackageManifest(
|
||||
gameId: 'ludo',
|
||||
name: 'Ludo',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
resources: {
|
||||
'dice': GameResource(
|
||||
type: GameResourceType.audio,
|
||||
path: 'assets/dice.wav',
|
||||
preload: GameResourcePreload.required,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeRepository implements GamePackageRepository {
|
||||
const _FakeRepository(this.package, {this.error});
|
||||
|
||||
final GamePackage package;
|
||||
final Object? error;
|
||||
|
||||
@override
|
||||
Future<GamePackage> load(String gameId) async {
|
||||
final value = error;
|
||||
if (value != null) {
|
||||
throw value;
|
||||
}
|
||||
return package;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeStablePackageStore implements StablePackageStore {
|
||||
_FakeStablePackageStore({this.stable, this.previous});
|
||||
|
||||
final GamePackage? stable;
|
||||
final GamePackage? previous;
|
||||
final markedPackages = <String>[];
|
||||
|
||||
@override
|
||||
Future<Directory> cacheRoot() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> markStable(GamePackage package) async {
|
||||
markedPackages.add(package.rootPath);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GamePackage?> previousStablePackage(String gameId) async => previous;
|
||||
|
||||
@override
|
||||
Future<GamePackage?> stablePackage(String gameId) async => stable;
|
||||
|
||||
@override
|
||||
Future<Directory> versionDirectory(String gameId, String version) =>
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
class _RecordingResourceManager extends GameResourceManager {
|
||||
final mountedPackages = <String>[];
|
||||
var disposed = false;
|
||||
|
||||
@override
|
||||
Future<void> mount(GamePackage package) async {
|
||||
mountedPackages.add(package.rootPath);
|
||||
await super.mount(package);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeScriptEngine implements ScriptEngine {
|
||||
_FakeScriptEngine({this.smokeFailures = const {}});
|
||||
|
||||
final Set<String> smokeFailures;
|
||||
final loadedPackages = <String>[];
|
||||
final initPackages = <String>[];
|
||||
GamePackage? _package;
|
||||
|
||||
@override
|
||||
Future<void> loadPackage(GamePackage package) async {
|
||||
_package = package;
|
||||
loadedPackages.add(package.rootPath);
|
||||
}
|
||||
|
||||
@override
|
||||
bool smokeTest(Map<String, Object?> context) {
|
||||
return !smokeFailures.contains(_package?.rootPath);
|
||||
}
|
||||
|
||||
@override
|
||||
GameDiff init(Map<String, Object?> context) {
|
||||
final package = _package;
|
||||
if (package != null) {
|
||||
initPackages.add(package.rootPath);
|
||||
}
|
||||
return GameDiff.empty;
|
||||
}
|
||||
|
||||
@override
|
||||
GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty;
|
||||
}
|
||||
|
||||
const _validScript = '''
|
||||
function smoke_test(ctx)
|
||||
return true
|
||||
end
|
||||
|
||||
function init(ctx)
|
||||
return {}
|
||||
end
|
||||
|
||||
function on_event(event)
|
||||
return {}
|
||||
end
|
||||
''';
|
||||
236
test/runtime/packages/game_package_manifest_test.dart
Normal file
236
test/runtime/packages/game_package_manifest_test.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('GamePackageManifest', () {
|
||||
test('parses manifest with resources', () {
|
||||
final manifest = GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'assetsBase': 'assets',
|
||||
'defaultLocale': 'zh-Hans',
|
||||
'supportedLocales': ['zh-Hans', 'en'],
|
||||
'display': {
|
||||
'designWidth': 720,
|
||||
'designHeight': 1280,
|
||||
'scaleMode': 'fit',
|
||||
},
|
||||
'modules': {
|
||||
'runtime_ui': 'runtime:runtime_ui.lua',
|
||||
'theme': 'scripts/theme.lua',
|
||||
},
|
||||
'resources': {
|
||||
'board': {
|
||||
'type': 'image',
|
||||
'path': 'assets/board.png',
|
||||
'preload': 'lazy',
|
||||
'group': 'board',
|
||||
},
|
||||
'roll': {'type': 'audio', 'path': 'assets/roll.mp3'},
|
||||
'hero': {
|
||||
'type': 'spine',
|
||||
'atlas': 'assets/hero.atlas',
|
||||
'skeleton': 'assets/hero.skel',
|
||||
'preload': 'lazy',
|
||||
'group': 'actors',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(manifest.gameId, 'ludo');
|
||||
expect(manifest.name, 'Ludo');
|
||||
expect(manifest.version, '0.1.0');
|
||||
expect(manifest.runtimeApiVersion, 1);
|
||||
expect(manifest.entry, 'scripts/main.lua');
|
||||
expect(manifest.assetsBase, 'assets');
|
||||
expect(manifest.defaultLocale, 'zh-Hans');
|
||||
expect(manifest.supportedLocales, ['zh-Hans', 'en']);
|
||||
expect(manifest.display.designWidth, 720);
|
||||
expect(manifest.display.designHeight, 1280);
|
||||
expect(manifest.display.scaleMode, 'fit');
|
||||
expect(manifest.resources['board']?.type, 'image');
|
||||
expect(manifest.resources['board']?.path, 'assets/board.png');
|
||||
expect(manifest.resources['board']?.preload, GameResourcePreload.lazy);
|
||||
expect(manifest.resources['board']?.group, 'board');
|
||||
expect(manifest.resources['roll']?.type, GameResourceType.audio);
|
||||
expect(manifest.resources['roll']?.preload, GameResourcePreload.required);
|
||||
expect(manifest.resources['hero']?.type, GameResourceType.spine);
|
||||
expect(manifest.resources['hero']?.atlas, 'assets/hero.atlas');
|
||||
expect(manifest.resources['hero']?.skeleton, 'assets/hero.skel');
|
||||
expect(manifest.resources['hero']?.path, isEmpty);
|
||||
expect(manifest.modules, {
|
||||
'runtime_ui': 'runtime:runtime_ui.lua',
|
||||
'theme': 'scripts/theme.lua',
|
||||
});
|
||||
});
|
||||
|
||||
test('defaults assetsBase to assets', () {
|
||||
final manifest = GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
});
|
||||
|
||||
expect(manifest.assetsBase, 'assets');
|
||||
expect(manifest.defaultLocale, 'en');
|
||||
expect(manifest.supportedLocales, ['en']);
|
||||
expect(manifest.display.designWidth, 720);
|
||||
expect(manifest.display.designHeight, 720);
|
||||
expect(manifest.display.scaleMode, 'fit');
|
||||
expect(manifest.resources, isEmpty);
|
||||
expect(manifest.modules, isEmpty);
|
||||
});
|
||||
|
||||
test('rejects invalid required fields and resources', () {
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': '',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': '1',
|
||||
'entry': 'scripts/main.lua',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'resources': {
|
||||
'board': {'type': '', 'path': 'assets/board.png'},
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'resources': {
|
||||
'roll': {'type': 'sound', 'path': 'assets/roll.mp3'},
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'modules': {'theme': 1},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'resources': {
|
||||
'board': {
|
||||
'type': 'image',
|
||||
'path': 'assets/board.png',
|
||||
'preload': 'eager',
|
||||
},
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'resources': {
|
||||
'board': {'type': 'image', 'path': 'assets/board.png', 'group': ''},
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'resources': {
|
||||
'hero': {'type': 'spine', 'atlas': 'assets/hero.atlas'},
|
||||
},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'defaultLocale': 'zh-Hans',
|
||||
'supportedLocales': ['en'],
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'display': {'designWidth': 0},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => GamePackageManifest.fromMap({
|
||||
'gameId': 'ludo',
|
||||
'name': 'Ludo',
|
||||
'version': '0.1.0',
|
||||
'runtimeApiVersion': 1,
|
||||
'entry': 'scripts/main.lua',
|
||||
'display': {'scaleMode': 'zoom'},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
84
test/runtime/packages/game_package_test.dart
Normal file
84
test/runtime/packages/game_package_test.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package.dart';
|
||||
import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('GamePackage', () {
|
||||
test('resolves manifest resource keys', () {
|
||||
final package = _package();
|
||||
|
||||
expect(
|
||||
package.resolveResourcePath('board'),
|
||||
'example/assets/games/ludo/assets/board.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves package-relative paths and assetsBase fallback', () {
|
||||
final package = _package();
|
||||
|
||||
expect(
|
||||
package.resolveResourcePath('scripts/main.lua'),
|
||||
'example/assets/games/ludo/scripts/main.lua',
|
||||
);
|
||||
expect(
|
||||
package.resolveResourcePath('unknown.png'),
|
||||
'example/assets/games/ludo/assets/unknown.png',
|
||||
);
|
||||
expect(
|
||||
package.resolveResourcePath(
|
||||
'example/assets/games/ludo/assets/board.png',
|
||||
),
|
||||
'example/assets/games/ludo/assets/board.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps runtime Lua root configurable per package', () {
|
||||
final package = GamePackage.asset(
|
||||
rootPath: 'example/assets/games/ludo',
|
||||
manifest: _manifest(),
|
||||
runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua',
|
||||
);
|
||||
|
||||
expect(
|
||||
package.runtimeLuaRoot,
|
||||
'packages/flame_lua_runtime/assets/runtime/lua',
|
||||
);
|
||||
});
|
||||
|
||||
test('reads file package text and bytes', () async {
|
||||
final root = await Directory.systemTemp.createTemp('game_package_test_');
|
||||
addTearDown(() => root.deleteSync(recursive: true));
|
||||
Directory('${root.path}/scripts').createSync(recursive: true);
|
||||
File('${root.path}/scripts/main.lua').writeAsStringSync('return true');
|
||||
|
||||
final package = GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: _manifest(),
|
||||
);
|
||||
|
||||
expect(await package.readText('scripts/main.lua'), 'return true');
|
||||
expect((await package.readBytes('scripts/main.lua')).lengthInBytes, 11);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
GamePackage _package() {
|
||||
return GamePackage.asset(
|
||||
rootPath: 'example/assets/games/ludo',
|
||||
manifest: _manifest(),
|
||||
);
|
||||
}
|
||||
|
||||
GamePackageManifest _manifest() {
|
||||
return const GamePackageManifest(
|
||||
gameId: 'ludo',
|
||||
name: 'Ludo',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
resources: {'board': GameResource(type: 'image', path: 'assets/board.png')},
|
||||
);
|
||||
}
|
||||
144
test/runtime/packages/package_verifier_test.dart
Normal file
144
test/runtime/packages/package_verifier_test.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'dart:io';
|
||||
|
||||
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/packages/package_verifier.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('PackageVerifier', () {
|
||||
test('accepts a valid file package', () async {
|
||||
final package = await _createPackage();
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts runtime framework module paths', () async {
|
||||
final package = await _createPackage(
|
||||
modules: {'runtime_ui': 'runtime:runtime_ui.lua'},
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unsupported runtimeApiVersion', () async {
|
||||
final package = await _createPackage(runtimeApiVersion: 2);
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing Lua entry functions', () async {
|
||||
final package = await _createPackage(script: 'function init(ctx) end');
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unsafe declared modules', () async {
|
||||
final package = await _createPackage(
|
||||
modules: {'../theme': 'scripts/theme.lua'},
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects module paths outside scripts directory', () async {
|
||||
final package = await _createPackage(
|
||||
modules: {'theme': 'assets/theme.lua'},
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing declared resources', () async {
|
||||
final package = await _createPackage(writeResource: false);
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects resource paths that escape package root', () async {
|
||||
final package = await _createPackage(resourcePath: '../outside.png');
|
||||
|
||||
await expectLater(
|
||||
const PackageVerifier(runtimeApiVersion: 1).verify(package),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<GamePackage> _createPackage({
|
||||
int runtimeApiVersion = 1,
|
||||
String script = _validScript,
|
||||
String resourcePath = 'assets/board.png',
|
||||
bool writeResource = true,
|
||||
Map<String, String> modules = const {'theme': 'scripts/theme.lua'},
|
||||
}) async {
|
||||
final root = await Directory.systemTemp.createTemp('package_verifier_test_');
|
||||
Directory('${root.path}/scripts').createSync(recursive: true);
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
File('${root.path}/scripts/main.lua').writeAsStringSync(script);
|
||||
File('${root.path}/scripts/theme.lua').writeAsStringSync('return {}');
|
||||
if (writeResource && !resourcePath.contains('..')) {
|
||||
File('${root.path}/$resourcePath')
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync([1, 2, 3]);
|
||||
}
|
||||
|
||||
final package = GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: GamePackageManifest(
|
||||
gameId: 'ludo',
|
||||
name: 'Ludo',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: runtimeApiVersion,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
resources: {'board': GameResource(type: 'image', path: resourcePath)},
|
||||
modules: modules,
|
||||
),
|
||||
);
|
||||
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
return package;
|
||||
}
|
||||
|
||||
const _validScript = '''
|
||||
function smoke_test(ctx)
|
||||
return true
|
||||
end
|
||||
|
||||
function init(ctx)
|
||||
return {}
|
||||
end
|
||||
|
||||
function on_event(event)
|
||||
return {}
|
||||
end
|
||||
''';
|
||||
20
test/runtime/public_api_test.dart
Normal file
20
test/runtime/public_api_test.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flame_lua_runtime/flame_lua_runtime.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('public runtime API exposes minimal integration surface', () {
|
||||
const repository = AssetGamePackageRepository();
|
||||
const options = RuntimeOptions(runtimeLuaRoot: 'custom/runtime/lua');
|
||||
const widget = LuaGameWidget(
|
||||
gameId: 'template',
|
||||
packageRepository: repository,
|
||||
runtimeOptions: options,
|
||||
);
|
||||
|
||||
expect(widget.gameId, 'template');
|
||||
expect(widget.packageRepository, same(repository));
|
||||
expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua');
|
||||
expect(LuaDardoScriptEngine.new, isA<ScriptEngine Function()>());
|
||||
expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans');
|
||||
});
|
||||
}
|
||||
593
test/runtime/rendering/render_tree_controller_test.dart
Normal file
593
test/runtime/rendering/render_tree_controller_test.dart
Normal file
@@ -0,0 +1,593 @@
|
||||
import 'package:flame/components.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/models/runtime_node.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_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RenderTreeController', () {
|
||||
test('creates, updates and removes components by id', () {
|
||||
final events = <RuntimeEvent>[];
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: events.add,
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'panel',
|
||||
type: RuntimeNodeType.rect,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
layer: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final created = controller.componentById('panel');
|
||||
expect(created, isNotNull);
|
||||
expect(created!.node.x, 10);
|
||||
expect(created.node.y, 20);
|
||||
expect(created.node.width, 100);
|
||||
expect(created.node.height, 80);
|
||||
expect(created.priority, 4);
|
||||
|
||||
controller.apply(
|
||||
NodeDiff(
|
||||
updates: [
|
||||
NodeUpdate(
|
||||
id: 'panel',
|
||||
props: {'x': 30, 'visible': false, 'layer': 8},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final updated = controller.componentById('panel');
|
||||
expect(updated, same(created));
|
||||
expect(updated!.node.x, 30);
|
||||
expect(updated.node.y, 20);
|
||||
expect(updated.node.visible, isFalse);
|
||||
expect(updated.priority, 8);
|
||||
|
||||
controller.apply(const NodeDiff(removes: [NodeRemove(id: 'panel')]));
|
||||
|
||||
expect(controller.componentById('panel'), isNull);
|
||||
});
|
||||
|
||||
test('replaces an existing component when create uses same id', () {
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)],
|
||||
),
|
||||
);
|
||||
final first = controller.componentById('node');
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(id: 'node', type: RuntimeNodeType.circle, layer: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
final second = controller.componentById('node');
|
||||
|
||||
expect(first, isNotNull);
|
||||
expect(second, isNotNull);
|
||||
expect(second, isNot(same(first)));
|
||||
expect(second!.node.type, RuntimeNodeType.circle);
|
||||
expect(second.priority, 2);
|
||||
});
|
||||
|
||||
test('mounts nodes under declared parent and supports reparenting', () {
|
||||
final root = Component();
|
||||
final controller = RenderTreeController(
|
||||
root: root,
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'panel',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final panel = controller.componentById('panel')!;
|
||||
final button = controller.componentById('button')!;
|
||||
expect(panel.parent, root);
|
||||
expect(button.parent, panel);
|
||||
|
||||
controller.apply(
|
||||
NodeDiff(
|
||||
updates: [
|
||||
NodeUpdate(id: 'button', props: {'parent': ''}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(button.parent, root);
|
||||
expect(button.node.parent, isNull);
|
||||
});
|
||||
|
||||
test('reattaches child when parent is created later', () {
|
||||
final root = Component();
|
||||
final controller = RenderTreeController(
|
||||
root: root,
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'panel',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final button = controller.componentById('button')!;
|
||||
expect(button.parent, root);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [RuntimeNode(id: 'panel', type: RuntimeNodeType.panel)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(button.parent, controller.componentById('panel'));
|
||||
});
|
||||
|
||||
test('scrolls listView by id or point and offsets direct children', () {
|
||||
final root = PositionComponent();
|
||||
final events = <RuntimeEvent>[];
|
||||
final controller = RenderTreeController(
|
||||
root: root,
|
||||
resources: GameResourceManager(),
|
||||
eventSink: events.add,
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'list',
|
||||
type: RuntimeNodeType.listView,
|
||||
width: 160,
|
||||
height: 60,
|
||||
contentWidth: 220,
|
||||
contentHeight: 140,
|
||||
scrollX: 10,
|
||||
scrollY: 20,
|
||||
onScroll: 'list_scrolled',
|
||||
),
|
||||
RuntimeNode(
|
||||
id: 'row',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'list',
|
||||
x: 40,
|
||||
y: 50,
|
||||
width: 140,
|
||||
height: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final list = controller.componentById('list')!;
|
||||
final row = controller.componentById('row')!;
|
||||
expect(row.parent, list);
|
||||
expect(row.position, Vector2(30, 30));
|
||||
expect(controller.listViewAt(Vector2(10, 10)), 'list');
|
||||
|
||||
expect(controller.scrollListView('list', deltaX: 30, deltaY: 50), isTrue);
|
||||
expect(controller.componentById('list')!.node.scrollX, 40);
|
||||
expect(controller.componentById('list')!.node.scrollY, 70);
|
||||
expect(row.position, Vector2(0, -20));
|
||||
expect(events.last.type, RuntimeEventType.scroll);
|
||||
expect(events.last.handler, 'list_scrolled');
|
||||
expect(events.last.data['scrollX'], 40);
|
||||
expect(events.last.data['scrollY'], 70);
|
||||
|
||||
expect(
|
||||
controller.scrollListViewAt(Vector2(10, 10), deltaY: 1000),
|
||||
isTrue,
|
||||
);
|
||||
expect(controller.componentById('list')!.node.scrollY, 80);
|
||||
expect(row.position, Vector2(0, -30));
|
||||
expect(
|
||||
controller.scrollListViewAt(Vector2(500, 500), deltaY: 20),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('listView padding offsets children and reduces scroll viewport', () {
|
||||
final root = PositionComponent();
|
||||
final controller = RenderTreeController(
|
||||
root: root,
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'list',
|
||||
type: RuntimeNodeType.listView,
|
||||
width: 120,
|
||||
height: 80,
|
||||
contentWidth: 200,
|
||||
contentHeight: 168,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 12,
|
||||
paddingRight: 8,
|
||||
paddingBottom: 6,
|
||||
scrollbarVisible: false,
|
||||
),
|
||||
RuntimeNode(
|
||||
id: 'row',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'list',
|
||||
x: 4,
|
||||
y: 5,
|
||||
width: 40,
|
||||
height: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final row = controller.componentById('row')!;
|
||||
expect(row.position, Vector2(14, 17));
|
||||
expect(
|
||||
controller.scrollListView('list', deltaX: 200, deltaY: 200),
|
||||
isTrue,
|
||||
);
|
||||
expect(controller.componentById('list')!.node.scrollX, 98);
|
||||
expect(controller.componentById('list')!.node.scrollY, 106);
|
||||
expect(row.position, Vector2(-84, -89));
|
||||
});
|
||||
|
||||
test('virtualized listView culls direct children outside cache window', () {
|
||||
final root = PositionComponent();
|
||||
final controller = RenderTreeController(
|
||||
root: root,
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'list',
|
||||
type: RuntimeNodeType.listView,
|
||||
width: 120,
|
||||
height: 60,
|
||||
contentHeight: 400,
|
||||
virtualized: true,
|
||||
cacheExtent: 0,
|
||||
),
|
||||
RuntimeNode(
|
||||
id: 'visible_row',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'list',
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 20,
|
||||
),
|
||||
RuntimeNode(
|
||||
id: 'culled_row',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'list',
|
||||
y: 180,
|
||||
width: 100,
|
||||
height: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(controller.componentById('visible_row')!.isVisible, isTrue);
|
||||
expect(controller.componentById('culled_row')!.isVisible, isFalse);
|
||||
|
||||
controller.scrollListView('list', deltaY: 150);
|
||||
|
||||
expect(controller.componentById('visible_row')!.isVisible, isFalse);
|
||||
expect(controller.componentById('culled_row')!.isVisible, isTrue);
|
||||
});
|
||||
|
||||
test('removes descendants when removing parent', () {
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'panel',
|
||||
),
|
||||
RuntimeNode(
|
||||
id: 'label',
|
||||
type: RuntimeNodeType.text,
|
||||
parent: 'button',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
controller.apply(const NodeDiff(removes: [NodeRemove(id: 'panel')]));
|
||||
|
||||
expect(controller.componentById('panel'), isNull);
|
||||
expect(controller.componentById('button'), isNull);
|
||||
expect(controller.componentById('label'), isNull);
|
||||
});
|
||||
|
||||
test('rejects parent cycles', () {
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'panel',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => controller.apply(
|
||||
NodeDiff(
|
||||
updates: [
|
||||
NodeUpdate(id: 'panel', props: {'parent': 'button'}),
|
||||
],
|
||||
),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(controller.componentById('panel')!.node.parent, isNull);
|
||||
});
|
||||
|
||||
test('rejects invalid diff before applying any partial mutation', () {
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(id: 'toast', type: RuntimeNodeType.panel),
|
||||
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'panel',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => controller.apply(
|
||||
NodeDiff(
|
||||
removes: const [NodeRemove(id: 'toast')],
|
||||
updates: [
|
||||
NodeUpdate(id: 'panel', props: {'parent': 'button'}),
|
||||
],
|
||||
),
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(controller.componentById('toast'), isNotNull);
|
||||
expect(controller.componentById('panel')!.node.parent, isNull);
|
||||
expect(controller.componentById('button')!.node.parent, 'panel');
|
||||
});
|
||||
|
||||
test('clear removes all tracked components', () {
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(id: 'panel', type: RuntimeNodeType.panel),
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
parent: 'panel',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
controller.clear();
|
||||
|
||||
expect(controller.componentById('panel'), isNull);
|
||||
expect(controller.componentById('button'), isNull);
|
||||
});
|
||||
|
||||
test('ignores tap callback from stale replaced component', () {
|
||||
final events = <RuntimeEvent>[];
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: events.add,
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
interactive: true,
|
||||
onTap: 'old_tap',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final stale = controller.componentById('button')!;
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
interactive: true,
|
||||
onTap: 'new_tap',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
stale.onNodeTap(stale.node, Vector2(1, 2));
|
||||
controller
|
||||
.componentById('button')!
|
||||
.onNodeTap(controller.componentById('button')!.node, Vector2(3, 4));
|
||||
|
||||
expect(events.map((event) => event.toMap()), [
|
||||
{
|
||||
'type': RuntimeEventType.tap,
|
||||
'target': 'button',
|
||||
'handler': 'new_tap',
|
||||
'x': 3.0,
|
||||
'y': 4.0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('emits tap event from interactive node callback', () {
|
||||
final events = <RuntimeEvent>[];
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: events.add,
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [
|
||||
RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
interactive: true,
|
||||
onTap: 'roll_dice',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final component = controller.componentById('button')!;
|
||||
component.onNodeTap(component.node, Vector2(3, 4));
|
||||
|
||||
expect(events, hasLength(1));
|
||||
expect(events.single.toMap(), {
|
||||
'type': RuntimeEventType.tap,
|
||||
'target': 'button',
|
||||
'handler': 'roll_dice',
|
||||
'x': 3.0,
|
||||
'y': 4.0,
|
||||
});
|
||||
expect(events.single.targetEpoch, controller.epochOf('button'));
|
||||
expect(events.single.scopeEpoch, controller.epochOf('button'));
|
||||
});
|
||||
|
||||
test('increments epoch when node is recreated', () {
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)],
|
||||
),
|
||||
);
|
||||
final firstEpoch = controller.epochOf('node');
|
||||
|
||||
controller.apply(const NodeDiff(removes: [NodeRemove(id: 'node')]));
|
||||
final removedEpoch = controller.epochOf('node');
|
||||
|
||||
controller.apply(
|
||||
const NodeDiff(
|
||||
creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)],
|
||||
),
|
||||
);
|
||||
final recreatedEpoch = controller.epochOf('node');
|
||||
|
||||
expect(firstEpoch, 1);
|
||||
expect(removedEpoch, greaterThan(firstEpoch));
|
||||
expect(recreatedEpoch, greaterThan(removedEpoch));
|
||||
expect(controller.isNodeEpochAlive('node', firstEpoch), isFalse);
|
||||
expect(controller.isNodeEpochAlive('node', recreatedEpoch), isTrue);
|
||||
});
|
||||
|
||||
test('ignores updates and removes for unknown ids', () {
|
||||
final controller = RenderTreeController(
|
||||
root: Component(),
|
||||
resources: GameResourceManager(),
|
||||
eventSink: (_) {},
|
||||
);
|
||||
|
||||
controller.apply(
|
||||
NodeDiff(
|
||||
updates: [
|
||||
NodeUpdate(id: 'missing', props: {'x': 1}),
|
||||
],
|
||||
removes: const [NodeRemove(id: 'missing')],
|
||||
),
|
||||
);
|
||||
|
||||
expect(controller.componentById('missing'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
165
test/runtime/rendering/runtime_component_test.dart
Normal file
165
test/runtime/rendering/runtime_component_test.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame_lua_runtime/runtime/models/runtime_node.dart';
|
||||
import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart';
|
||||
import 'package:flame_lua_runtime/runtime/rendering/runtime_component.dart';
|
||||
import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeComponent', () {
|
||||
test('applies base transform and priority from node', () {
|
||||
final component = RuntimeComponent(
|
||||
node: const RuntimeNode(
|
||||
id: 'rect',
|
||||
type: RuntimeNodeType.rect,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 120,
|
||||
height: 48,
|
||||
scale: 1.5,
|
||||
rotation: 0.25,
|
||||
anchor: RuntimeAnchorValue.center,
|
||||
layer: 7,
|
||||
),
|
||||
resources: GameResourceManager(),
|
||||
onNodeTap: (_, __) {},
|
||||
);
|
||||
|
||||
expect(component.position, Vector2(10, 20));
|
||||
expect(component.size, Vector2(120, 48));
|
||||
expect(component.scale, Vector2.all(1.5));
|
||||
expect(component.angle, 0.25);
|
||||
expect(component.anchor, Anchor.center);
|
||||
expect(component.priority, 7);
|
||||
expect(component.isVisible, isTrue);
|
||||
});
|
||||
|
||||
test('updates node and transform', () {
|
||||
final component = RuntimeComponent(
|
||||
node: const RuntimeNode(id: 'node', type: RuntimeNodeType.rect),
|
||||
resources: GameResourceManager(),
|
||||
onNodeTap: (_, __) {},
|
||||
);
|
||||
|
||||
component.updateNode(
|
||||
const RuntimeNode(
|
||||
id: 'node',
|
||||
type: RuntimeNodeType.progress,
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 200,
|
||||
height: 16,
|
||||
value: 0.5,
|
||||
layer: 3,
|
||||
),
|
||||
);
|
||||
|
||||
expect(component.node.type, RuntimeNodeType.progress);
|
||||
expect(component.node.value, 0.5);
|
||||
expect(component.position, Vector2(30, 40));
|
||||
expect(component.size, Vector2(200, 16));
|
||||
expect(component.priority, 3);
|
||||
expect(component.isVisible, isTrue);
|
||||
});
|
||||
|
||||
test('visibility hides component subtree and disables hit testing', () {
|
||||
final component = RuntimeComponent(
|
||||
node: const RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
text: 'Hidden',
|
||||
width: 100,
|
||||
height: 40,
|
||||
visible: false,
|
||||
interactive: true,
|
||||
),
|
||||
resources: GameResourceManager(),
|
||||
onNodeTap: (_, __) {},
|
||||
);
|
||||
|
||||
expect(component.isVisible, isFalse);
|
||||
expect(component.containsLocalPoint(Vector2(10, 10)), isFalse);
|
||||
|
||||
component.updateNode(
|
||||
const RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
text: 'Shown',
|
||||
width: 100,
|
||||
height: 40,
|
||||
visible: true,
|
||||
interactive: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(component.isVisible, isTrue);
|
||||
expect(component.containsLocalPoint(Vector2(10, 10)), isTrue);
|
||||
});
|
||||
|
||||
test('supports runtime alpha override for fade commands', () {
|
||||
final component = RuntimeComponent(
|
||||
node: const RuntimeNode(
|
||||
id: 'panel',
|
||||
type: RuntimeNodeType.rect,
|
||||
alpha: 0.8,
|
||||
),
|
||||
resources: GameResourceManager(),
|
||||
onNodeTap: (_, __) {},
|
||||
);
|
||||
|
||||
expect(component.renderAlpha, 0.8);
|
||||
component.setRuntimeAlpha(0.25);
|
||||
expect(component.renderAlpha, 0.25);
|
||||
component.setRuntimeAlpha(2);
|
||||
expect(component.renderAlpha, 1);
|
||||
});
|
||||
|
||||
test('multi-line non-button text is top aligned', () {
|
||||
final component = RuntimeComponent(
|
||||
node: const RuntimeNode(
|
||||
id: 'text',
|
||||
type: RuntimeNodeType.text,
|
||||
text: 'line1\nline2',
|
||||
width: 120,
|
||||
height: 80,
|
||||
textAlign: RuntimeTextAlignValue.left,
|
||||
),
|
||||
resources: GameResourceManager(),
|
||||
onNodeTap: (_, __) {},
|
||||
);
|
||||
|
||||
final text = component.children.whereType<TextComponent>().single;
|
||||
expect(text.anchor, Anchor.topLeft);
|
||||
expect(text.position, Vector2.zero());
|
||||
});
|
||||
|
||||
test('only interactive nodes contain local points', () {
|
||||
final passive = RuntimeComponent(
|
||||
node: const RuntimeNode(
|
||||
id: 'passive',
|
||||
type: RuntimeNodeType.rect,
|
||||
width: 100,
|
||||
height: 40,
|
||||
),
|
||||
resources: GameResourceManager(),
|
||||
onNodeTap: (_, __) {},
|
||||
);
|
||||
final interactive = RuntimeComponent(
|
||||
node: const RuntimeNode(
|
||||
id: 'button',
|
||||
type: RuntimeNodeType.button,
|
||||
width: 100,
|
||||
height: 40,
|
||||
interactive: true,
|
||||
),
|
||||
resources: GameResourceManager(),
|
||||
onNodeTap: (_, __) {},
|
||||
);
|
||||
|
||||
expect(passive.containsLocalPoint(Vector2(10, 10)), isFalse);
|
||||
expect(interactive.containsLocalPoint(Vector2(10, 10)), isTrue);
|
||||
expect(interactive.containsLocalPoint(Vector2(101, 10)), isFalse);
|
||||
expect(interactive.containsLocalPoint(Vector2(10, 41)), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
98
test/runtime/rendering/runtime_viewport_test.dart
Normal file
98
test/runtime/rendering/runtime_viewport_test.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame_lua_runtime/runtime/display/runtime_viewport.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RuntimeViewport', () {
|
||||
test('fits design size inside screen with letterboxing', () {
|
||||
final transform = RuntimeViewport.compute(
|
||||
screenSize: Vector2(1280, 720),
|
||||
config: const RuntimeViewportConfig(
|
||||
designWidth: 720,
|
||||
designHeight: 720,
|
||||
),
|
||||
);
|
||||
|
||||
expect(transform.x, 280);
|
||||
expect(transform.y, 0);
|
||||
expect(transform.width, 720);
|
||||
expect(transform.height, 720);
|
||||
expect(transform.scaleX, 1);
|
||||
expect(transform.scaleY, 1);
|
||||
});
|
||||
|
||||
test('fills screen by preserving aspect ratio and cropping', () {
|
||||
final transform = RuntimeViewport.compute(
|
||||
screenSize: Vector2(1280, 720),
|
||||
config: const RuntimeViewportConfig(
|
||||
designWidth: 720,
|
||||
designHeight: 720,
|
||||
scaleMode: RuntimeScaleMode.fill,
|
||||
),
|
||||
);
|
||||
|
||||
expect(transform.x, 0);
|
||||
expect(transform.y, -280);
|
||||
expect(transform.width, 1280);
|
||||
expect(transform.height, 1280);
|
||||
expect(transform.scaleX, closeTo(1.777777, 0.00001));
|
||||
expect(transform.scaleY, closeTo(1.777777, 0.00001));
|
||||
});
|
||||
|
||||
test('stretches design independently on both axes', () {
|
||||
final transform = RuntimeViewport.compute(
|
||||
screenSize: Vector2(1440, 720),
|
||||
config: const RuntimeViewportConfig(
|
||||
designWidth: 720,
|
||||
designHeight: 720,
|
||||
scaleMode: RuntimeScaleMode.stretch,
|
||||
),
|
||||
);
|
||||
|
||||
expect(transform.x, 0);
|
||||
expect(transform.y, 0);
|
||||
expect(transform.width, 1440);
|
||||
expect(transform.height, 720);
|
||||
expect(transform.scaleX, 2);
|
||||
expect(transform.scaleY, 1);
|
||||
});
|
||||
|
||||
test('centers design without scaling', () {
|
||||
final transform = RuntimeViewport.compute(
|
||||
screenSize: Vector2(1000, 800),
|
||||
config: const RuntimeViewportConfig(
|
||||
designWidth: 720,
|
||||
designHeight: 720,
|
||||
scaleMode: RuntimeScaleMode.none,
|
||||
),
|
||||
);
|
||||
|
||||
expect(transform.x, 140);
|
||||
expect(transform.y, 40);
|
||||
expect(transform.width, 720);
|
||||
expect(transform.height, 720);
|
||||
expect(transform.scaleX, 1);
|
||||
expect(transform.scaleY, 1);
|
||||
});
|
||||
|
||||
test('applies transform to root component', () {
|
||||
final root = PositionComponent();
|
||||
RuntimeViewport.apply(
|
||||
root,
|
||||
const RuntimeViewportTransform(
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 300,
|
||||
height: 400,
|
||||
scaleX: 2,
|
||||
scaleY: 3,
|
||||
scaleMode: RuntimeScaleMode.stretch,
|
||||
),
|
||||
);
|
||||
|
||||
expect(root.position, Vector2(10, 20));
|
||||
expect(root.size, Vector2(300, 400));
|
||||
expect(root.scale, Vector2(2, 3));
|
||||
});
|
||||
});
|
||||
}
|
||||
401
test/runtime/resources/game_resource_manager_test.dart
Normal file
401
test/runtime/resources/game_resource_manager_test.dart
Normal file
@@ -0,0 +1,401 @@
|
||||
import 'dart:async' as async;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.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/resources/game_resource_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('GameResourceManager', () {
|
||||
test(
|
||||
'advances generation and clears cache when mounting package',
|
||||
() async {
|
||||
final resources = GameResourceManager();
|
||||
final first = await _createPackage('first');
|
||||
final second = await _createPackage('second');
|
||||
|
||||
expect(resources.generation, 0);
|
||||
|
||||
await resources.mount(first);
|
||||
expect(resources.generation, 1);
|
||||
|
||||
await resources.mount(second);
|
||||
expect(resources.generation, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test('resolves resource keys from active package', () async {
|
||||
final resources = GameResourceManager();
|
||||
final package = await _createPackage('resources');
|
||||
|
||||
await resources.mount(package);
|
||||
|
||||
expect(resources.resolve('tile'), endsWith('/assets/tile.png'));
|
||||
});
|
||||
|
||||
test(
|
||||
'lazy image load records failed state, error and diagnostics',
|
||||
() async {
|
||||
final diagnostics = RuntimeDiagnostics();
|
||||
final resources = GameResourceManager(diagnostics: diagnostics);
|
||||
final package = await _createPackage('lazy_failed');
|
||||
|
||||
await resources.mount(package);
|
||||
expect(resources.imageState('tile'), GameResourceState.idle);
|
||||
|
||||
final image = await resources.loadImage('tile');
|
||||
|
||||
expect(image, isNull);
|
||||
expect(resources.imageState('tile'), GameResourceState.failed);
|
||||
expect(resources.imageError('tile'), isNotNull);
|
||||
expect(diagnostics.entries, hasLength(1));
|
||||
expect(
|
||||
diagnostics.entries.single.type,
|
||||
RuntimeDiagnosticType.resourceLoadError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('exports image debug json and evicts failed records', () async {
|
||||
final resources = GameResourceManager();
|
||||
final package = await _createPackage('debug_json');
|
||||
|
||||
await resources.mount(package);
|
||||
|
||||
expect(resources.imagesDebugJson(), {
|
||||
'generation': 1,
|
||||
'hasPackage': true,
|
||||
'count': 1,
|
||||
'activeLoads': 0,
|
||||
'pendingLoads': 0,
|
||||
'resources': [
|
||||
{
|
||||
'key': 'tile',
|
||||
'path': endsWith('/assets/tile.png'),
|
||||
'type': GameResourceType.image,
|
||||
'declared': true,
|
||||
'preload': GameResourcePreload.lazy,
|
||||
'state': 'idle',
|
||||
'loading': false,
|
||||
'ready': false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await resources.loadImage('tile');
|
||||
final failedJson = resources.imagesDebugJson();
|
||||
final failedResources = failedJson['resources'] as List<Object?>;
|
||||
final failedTile = failedResources.single as Map<String, Object?>;
|
||||
|
||||
expect(failedTile['state'], 'failed');
|
||||
expect(failedTile['error'], isA<String>());
|
||||
|
||||
expect(resources.evictImage('tile'), isTrue);
|
||||
expect(resources.imageState('tile'), GameResourceState.idle);
|
||||
expect(resources.evictImage('tile'), isFalse);
|
||||
});
|
||||
|
||||
test('preloads and evicts image resource groups', () async {
|
||||
final resources = GameResourceManager();
|
||||
final package = await _createMultiImagePackage('image_group');
|
||||
|
||||
await resources.mount(package);
|
||||
await resources.preloadGroup('board');
|
||||
|
||||
expect(resources.imageState('board'), GameResourceState.ready);
|
||||
expect(resources.imageState('piece'), GameResourceState.ready);
|
||||
expect(resources.imageState('avatar'), GameResourceState.idle);
|
||||
expect(resources.evictGroup('board'), 2);
|
||||
expect(resources.imageState('board'), GameResourceState.idle);
|
||||
expect(resources.imageState('piece'), GameResourceState.idle);
|
||||
});
|
||||
|
||||
test('image LRU evicts least recently used unretained images', () async {
|
||||
final resources = GameResourceManager(maxCacheEntries: 1);
|
||||
final package = await _createMultiImagePackage('image_lru');
|
||||
|
||||
await resources.mount(package);
|
||||
await resources.loadImage('board');
|
||||
expect(resources.imageState('board'), GameResourceState.ready);
|
||||
|
||||
await resources.loadImage('avatar');
|
||||
|
||||
expect(resources.imageState('board'), GameResourceState.idle);
|
||||
expect(resources.imageState('avatar'), GameResourceState.ready);
|
||||
});
|
||||
|
||||
test('image LRU keeps retained images until released', () async {
|
||||
final resources = GameResourceManager(maxCacheEntries: 1);
|
||||
final package = await _createMultiImagePackage('image_lru_retained');
|
||||
|
||||
await resources.mount(package);
|
||||
await resources.loadImage('board', retain: true);
|
||||
await resources.loadImage('avatar');
|
||||
|
||||
expect(resources.imageState('board'), GameResourceState.ready);
|
||||
expect(resources.imageState('avatar'), GameResourceState.idle);
|
||||
|
||||
resources.releaseImage('board');
|
||||
await resources.loadImage('avatar');
|
||||
|
||||
expect(resources.imageState('board'), GameResourceState.idle);
|
||||
expect(resources.imageState('avatar'), GameResourceState.ready);
|
||||
});
|
||||
|
||||
test('deduplicates concurrent image load requests', () async {
|
||||
final resources = GameResourceManager();
|
||||
final package = await _createPackage('dedupe');
|
||||
final countingPackage = _CountingPackage(package);
|
||||
|
||||
await resources.mount(countingPackage);
|
||||
|
||||
final first = resources.loadImage('tile');
|
||||
final second = resources.loadImage('tile');
|
||||
|
||||
expect(countingPackage.readCount, 1);
|
||||
countingPackage.releaseReads();
|
||||
|
||||
await Future.wait([first, second]);
|
||||
|
||||
expect(countingPackage.readCount, 1);
|
||||
expect(resources.imageState('tile'), GameResourceState.failed);
|
||||
});
|
||||
|
||||
test(
|
||||
'drops stale image load result after dispose without diagnostics',
|
||||
() async {
|
||||
final diagnostics = RuntimeDiagnostics();
|
||||
final resources = GameResourceManager(diagnostics: diagnostics);
|
||||
final package = await _createPackage('stale_image');
|
||||
final countingPackage = _CountingPackage(package);
|
||||
|
||||
await resources.mount(countingPackage);
|
||||
final image = resources.loadImage('tile');
|
||||
|
||||
resources.dispose();
|
||||
countingPackage.releaseReads();
|
||||
|
||||
expect(await image, isNull);
|
||||
expect(diagnostics.entries, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('optional preload failure does not fail mount', () async {
|
||||
final resources = GameResourceManager();
|
||||
final package = await _createPackage(
|
||||
'optional_failed',
|
||||
preload: GameResourcePreload.optional,
|
||||
);
|
||||
|
||||
await resources.mount(package);
|
||||
|
||||
expect(resources.imageState('tile'), GameResourceState.failed);
|
||||
expect(resources.imageError('tile'), isNotNull);
|
||||
});
|
||||
|
||||
test('required preload failure fails mount', () async {
|
||||
final resources = GameResourceManager();
|
||||
final package = await _createPackage(
|
||||
'required_failed',
|
||||
preload: GameResourcePreload.required,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
resources.mount(package),
|
||||
throwsA(isA<ResourceLoadException>()),
|
||||
);
|
||||
expect(resources.imageState('tile'), GameResourceState.failed);
|
||||
expect(resources.imageError('tile'), isNotNull);
|
||||
});
|
||||
|
||||
test('dispose clears active package and advances generation', () async {
|
||||
final resources = GameResourceManager();
|
||||
final package = await _createPackage('dispose');
|
||||
|
||||
await resources.mount(package);
|
||||
expect(resources.generation, 1);
|
||||
|
||||
resources.dispose();
|
||||
|
||||
expect(resources.generation, 2);
|
||||
expect(() => resources.package, throwsStateError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const _pngBytes = [
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x08,
|
||||
0x06,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1f,
|
||||
0x15,
|
||||
0xc4,
|
||||
0x89,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0a,
|
||||
0x49,
|
||||
0x44,
|
||||
0x41,
|
||||
0x54,
|
||||
0x78,
|
||||
0x9c,
|
||||
0x63,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x2d,
|
||||
0xb4,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4e,
|
||||
0x44,
|
||||
0xae,
|
||||
0x42,
|
||||
0x60,
|
||||
0x82,
|
||||
];
|
||||
|
||||
Future<GamePackage> _createPackage(
|
||||
String name, {
|
||||
String preload = GameResourcePreload.lazy,
|
||||
}) async {
|
||||
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
File('${root.path}/assets/tile.png').writeAsBytesSync(const []);
|
||||
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: GamePackageManifest(
|
||||
gameId: 'test',
|
||||
name: 'Test',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
resources: {
|
||||
'tile': GameResource(
|
||||
type: 'image',
|
||||
path: 'assets/tile.png',
|
||||
preload: preload,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<GamePackage> _createMultiImagePackage(String name) async {
|
||||
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
for (final file in ['board.png', 'piece.png', 'avatar.png']) {
|
||||
File('${root.path}/assets/$file').writeAsBytesSync(_pngBytes);
|
||||
}
|
||||
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: GamePackageManifest(
|
||||
gameId: 'test',
|
||||
name: 'Test',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
resources: const {
|
||||
'board': GameResource(
|
||||
type: GameResourceType.image,
|
||||
path: 'assets/board.png',
|
||||
preload: GameResourcePreload.lazy,
|
||||
group: 'board',
|
||||
),
|
||||
'piece': GameResource(
|
||||
type: GameResourceType.image,
|
||||
path: 'assets/piece.png',
|
||||
preload: GameResourcePreload.lazy,
|
||||
group: 'board',
|
||||
),
|
||||
'avatar': GameResource(
|
||||
type: GameResourceType.image,
|
||||
path: 'assets/avatar.png',
|
||||
preload: GameResourcePreload.lazy,
|
||||
group: 'hud',
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CountingPackage extends GamePackage {
|
||||
_CountingPackage(GamePackage package)
|
||||
: _releaseReads = async.Completer<void>(),
|
||||
super.file(rootPath: package.rootPath, manifest: package.manifest);
|
||||
|
||||
final async.Completer<void> _releaseReads;
|
||||
int readCount = 0;
|
||||
|
||||
void releaseReads() {
|
||||
if (!_releaseReads.isCompleted) {
|
||||
_releaseReads.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ByteData> readBytes(String relativeOrAbsolutePath) async {
|
||||
readCount++;
|
||||
await _releaseReads.future;
|
||||
return ByteData.sublistView(Uint8List(0));
|
||||
}
|
||||
}
|
||||
62
test/runtime/resources/resource_load_limiter_test.dart
Normal file
62
test/runtime/resources/resource_load_limiter_test.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'dart:async' as async;
|
||||
|
||||
import 'package:flame_lua_runtime/runtime/resources/resource_load_limiter.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ResourceLoadLimiter', () {
|
||||
test('limits concurrent tasks', () async {
|
||||
final limiter = ResourceLoadLimiter(2);
|
||||
var active = 0;
|
||||
var maxActive = 0;
|
||||
|
||||
Future<int> task(int value) async {
|
||||
active++;
|
||||
if (active > maxActive) {
|
||||
maxActive = active;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
active--;
|
||||
return value;
|
||||
}
|
||||
|
||||
final results = await Future.wait([
|
||||
limiter.run(() => task(1)),
|
||||
limiter.run(() => task(2)),
|
||||
limiter.run(() => task(3)),
|
||||
limiter.run(() => task(4)),
|
||||
]);
|
||||
|
||||
expect(results, [1, 2, 3, 4]);
|
||||
expect(maxActive, 2);
|
||||
expect(limiter.activeCount, 0);
|
||||
expect(limiter.pendingCount, 0);
|
||||
});
|
||||
|
||||
test('clearPending cancels queued tasks', () async {
|
||||
final limiter = ResourceLoadLimiter(1);
|
||||
final hold = async.Completer<void>();
|
||||
|
||||
final first = limiter.run(() => hold.future);
|
||||
final second = limiter.run(() async => 2);
|
||||
|
||||
expect(limiter.activeCount, 1);
|
||||
expect(limiter.pendingCount, 1);
|
||||
|
||||
final secondExpectation = expectLater(
|
||||
second,
|
||||
throwsA(isA<ResourceLoadCancelledException>()),
|
||||
);
|
||||
|
||||
limiter.clearPending();
|
||||
hold.complete();
|
||||
|
||||
await first;
|
||||
await secondExpectation;
|
||||
});
|
||||
|
||||
test('rejects invalid concurrency', () {
|
||||
expect(() => ResourceLoadLimiter(0), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
}
|
||||
962
test/runtime/scripting/lua_dardo_script_engine_test.dart
Normal file
962
test/runtime/scripting/lua_dardo_script_engine_test.dart
Normal file
@@ -0,0 +1,962 @@
|
||||
import 'dart:io';
|
||||
|
||||
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/models/runtime_event.dart';
|
||||
import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart';
|
||||
import 'package:flame_lua_runtime/runtime/scripting/lua_dardo_script_engine.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<GamePackage> _loadExamplePackage(String gameId) async {
|
||||
final root = 'example/assets/games/$gameId';
|
||||
final manifest = await File('$root/manifest.json').readAsString();
|
||||
return GamePackage.file(
|
||||
rootPath: root,
|
||||
manifest: GamePackageManifest.fromJsonString(manifest),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('LuaDardoScriptEngine runtime.import', () {
|
||||
test('loads bundled Ludo module graph', () async {
|
||||
final package = await _loadExamplePackage('ludo');
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||
final diff = engine.init({'runtimeApiVersion': 1});
|
||||
expect(diff.render.creates, isNotEmpty);
|
||||
expect(diff.ui.creates, isNotEmpty);
|
||||
expect(
|
||||
diff.render.creates.map((node) => node.id),
|
||||
contains('board_panel'),
|
||||
);
|
||||
expect(diff.ui.creates.map((node) => node.id), contains('dice_button'));
|
||||
final diceButton = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'dice_button',
|
||||
);
|
||||
expect(diceButton.parent, 'top_bar');
|
||||
expect(diceButton.x, 540);
|
||||
expect(diceButton.y, 14);
|
||||
expect(diceButton.onTap, 'roll_dice');
|
||||
|
||||
final eventDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'dice_button',
|
||||
handler: 'roll_dice',
|
||||
),
|
||||
);
|
||||
final sound = eventDiff.commands.single;
|
||||
expect(sound.type, RuntimeCommandType.playSound);
|
||||
expect(sound.payload['asset'], 'dice');
|
||||
});
|
||||
|
||||
test('loads bundled Ludo with English localization context', () async {
|
||||
final package = await _loadExamplePackage('ludo');
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
final context = {
|
||||
'runtimeApiVersion': 1,
|
||||
'locale': {
|
||||
'requested': 'en-US',
|
||||
'resolved': 'en',
|
||||
'default': 'zh-Hans',
|
||||
'supported': ['zh-Hans', 'en'],
|
||||
'languageCode': 'en',
|
||||
'countryCode': 'US',
|
||||
},
|
||||
};
|
||||
expect(engine.smokeTest(context), isTrue);
|
||||
final diff = engine.init(context);
|
||||
|
||||
expect(
|
||||
diff.render.creates
|
||||
.singleWhere((node) => node.id == 'board_title')
|
||||
.text,
|
||||
'Lua Ludo',
|
||||
);
|
||||
expect(
|
||||
diff.ui.creates.singleWhere((node) => node.id == 'dice_button').text,
|
||||
'Roll',
|
||||
);
|
||||
expect(
|
||||
diff.ui.creates.singleWhere((node) => node.id == 'turn_text').text,
|
||||
'Current player: Red',
|
||||
);
|
||||
});
|
||||
|
||||
test('loads bundled Flight module graph and basic dice flow', () async {
|
||||
final package = await _loadExamplePackage('flight');
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||
final diff = engine.init({'runtimeApiVersion': 1});
|
||||
expect(
|
||||
diff.render.creates.map((node) => node.id),
|
||||
contains('board_panel'),
|
||||
);
|
||||
expect(diff.render.creates.map((node) => node.id), contains('red_1'));
|
||||
expect(diff.ui.creates.map((node) => node.id), contains('dice_button'));
|
||||
|
||||
final rollDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'dice_button',
|
||||
handler: 'roll_dice',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
rollDiff.render.updates.map((update) => update.id),
|
||||
contains('red_1'),
|
||||
);
|
||||
|
||||
final moveDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'red_1',
|
||||
handler: 'piece_tap',
|
||||
),
|
||||
);
|
||||
expect(moveDiff.commands.single.type, RuntimeCommandType.movePath);
|
||||
expect(moveDiff.commands.single.target, 'red_1');
|
||||
});
|
||||
|
||||
test(
|
||||
'loads bundled Template package as minimal integration starter',
|
||||
() async {
|
||||
final package = await _loadExamplePackage('template');
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||
final diff = engine.init({
|
||||
'runtimeApiVersion': 1,
|
||||
'gameId': 'template',
|
||||
});
|
||||
expect(
|
||||
diff.render.creates.map((node) => node.id),
|
||||
contains('template_bg'),
|
||||
);
|
||||
expect(
|
||||
diff.render.creates.map((node) => node.id),
|
||||
contains('template_start'),
|
||||
);
|
||||
|
||||
final tapDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'template_start',
|
||||
handler: 'template_start',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
tapDiff.ui.updates.map((update) => update.id),
|
||||
containsAll([
|
||||
'template_start',
|
||||
'template_counter',
|
||||
'template_status',
|
||||
]),
|
||||
);
|
||||
expect(tapDiff.commands.single.type, RuntimeCommandType.toast);
|
||||
},
|
||||
);
|
||||
|
||||
test('loads bundled Showcase module graph and command examples', () async {
|
||||
final package = await _loadExamplePackage('showcase');
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||
final diff = engine.init({'runtimeApiVersion': 1});
|
||||
final nodeIds = diff.render.creates.map((node) => node.id);
|
||||
expect(nodeIds, contains('example_list_panel'));
|
||||
expect(nodeIds, contains('example_nodes'));
|
||||
expect(nodeIds, contains('example_text_demo'));
|
||||
expect(nodeIds, contains('example_buttons'));
|
||||
expect(nodeIds, contains('example_button_images'));
|
||||
expect(nodeIds, contains('example_sprites'));
|
||||
expect(nodeIds, contains('example_radio_group'));
|
||||
expect(nodeIds, contains('example_list_view'));
|
||||
expect(nodeIds, contains('example_layout_demo'));
|
||||
expect(nodeIds, contains('example_commands'));
|
||||
expect(nodeIds, contains('example_i18n'));
|
||||
expect(nodeIds, contains('example_responsive'));
|
||||
expect(nodeIds, contains('detail_panel'));
|
||||
expect(nodeIds, contains('detail_action_1'));
|
||||
expect(nodeIds, contains('sample_rect'));
|
||||
expect(nodeIds, contains('sample_circle'));
|
||||
expect(nodeIds, contains('sample_image_node'));
|
||||
expect(nodeIds, contains('sample_sprite_node'));
|
||||
expect(nodeIds, contains('sample_progress'));
|
||||
expect(nodeIds, contains('text_plain_title'));
|
||||
expect(nodeIds, contains('button_primary'));
|
||||
expect(nodeIds, contains('image_button_normal'));
|
||||
expect(nodeIds, contains('image_button_toggle'));
|
||||
expect(nodeIds, contains('image_button_disabled'));
|
||||
expect(nodeIds, contains('sprite_sprite_demo'));
|
||||
expect(nodeIds, contains('radio_value_text'));
|
||||
expect(nodeIds, contains('list_row_1'));
|
||||
expect(nodeIds, contains('particle_burst'));
|
||||
expect(nodeIds, contains('particle_trail'));
|
||||
expect(nodeIds, contains('particle_snow'));
|
||||
expect(nodeIds, contains('layout_chip_1'));
|
||||
expect(nodeIds, contains('layout_chip_4'));
|
||||
final imageButton = diff.render.creates.singleWhere(
|
||||
(node) => node.id == 'image_button_normal',
|
||||
);
|
||||
expect(imageButton.asset, 'button_normal');
|
||||
expect(imageButton.pressedAsset, 'button_pressed');
|
||||
expect(imageButton.disabledAsset, 'button_disabled');
|
||||
final disabledImageButton = diff.render.creates.singleWhere(
|
||||
(node) => node.id == 'image_button_disabled',
|
||||
);
|
||||
expect(disabledImageButton.interactive, isFalse);
|
||||
expect(diff.commands, isEmpty);
|
||||
|
||||
final imageButtonSelectDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_button_images',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
imageButtonSelectDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['detail_title', 'detail_code', 'image_button_normal']),
|
||||
);
|
||||
final imageButtonToggleDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_2',
|
||||
handler: 'demo_button_image_toggle',
|
||||
),
|
||||
);
|
||||
final toggleUpdate = imageButtonToggleDiff.ui.updates.singleWhere(
|
||||
(update) => update.id == 'image_button_toggle',
|
||||
);
|
||||
expect(toggleUpdate.props, containsPair('interactive', false));
|
||||
|
||||
final selectDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_commands',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
selectDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['detail_title', 'detail_code', 'detail_action_1']),
|
||||
);
|
||||
|
||||
final paramsDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_tab_params',
|
||||
handler: 'detail_tab_params',
|
||||
),
|
||||
);
|
||||
final detailCodeUpdate = paramsDiff.ui.updates.firstWhere(
|
||||
(update) => update.id == 'detail_code',
|
||||
);
|
||||
final paramsText = detailCodeUpdate.props['text'];
|
||||
expect(paramsText, contains('参数说明'));
|
||||
expect(
|
||||
paramsDiff.ui.updates
|
||||
.firstWhere(
|
||||
(update) =>
|
||||
update.id == 'detail_code' &&
|
||||
update.props['textAlign'] != null,
|
||||
)
|
||||
.props['textAlign'],
|
||||
'left',
|
||||
);
|
||||
expect(
|
||||
paramsDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'detail_tab_params')
|
||||
.props['onTap'],
|
||||
'detail_tab_params',
|
||||
);
|
||||
|
||||
final listPickDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'list_row_3',
|
||||
handler: 'demo_list_pick_3',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
listPickDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'list_panel')
|
||||
.props['scrollX'],
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
listPickDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'list_row_3')
|
||||
.props['x'],
|
||||
8,
|
||||
);
|
||||
expect(
|
||||
listPickDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'list_row_3_text')
|
||||
.props['textAlign'],
|
||||
'left',
|
||||
);
|
||||
|
||||
final selectLayoutDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_layout_demo',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
selectLayoutDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'detail_action_3')
|
||||
.props['onTap'],
|
||||
'demo_layout_box',
|
||||
);
|
||||
final boxLayoutDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_3',
|
||||
handler: 'demo_layout_box',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
boxLayoutDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'layout_chip_3')
|
||||
.props,
|
||||
containsPair('y', 90),
|
||||
);
|
||||
expect(
|
||||
boxLayoutDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'layout_label')
|
||||
.props['text'],
|
||||
contains('2 行 × 2 列'),
|
||||
);
|
||||
|
||||
engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_particles',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
final particleParamsDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_tab_params',
|
||||
handler: 'detail_tab_params',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
particleParamsDiff.ui.updates
|
||||
.firstWhere(
|
||||
(update) =>
|
||||
update.id == 'detail_code' && update.props['height'] != null,
|
||||
)
|
||||
.props['height'],
|
||||
greaterThan(400),
|
||||
);
|
||||
expect(
|
||||
particleParamsDiff.ui.updates
|
||||
.firstWhere((update) => update.id == 'code_panel')
|
||||
.props['contentHeight'],
|
||||
greaterThan(450),
|
||||
);
|
||||
|
||||
final animDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_1',
|
||||
handler: 'demo_anim',
|
||||
),
|
||||
);
|
||||
expect(animDiff.commands.single.type, RuntimeCommandType.sequence);
|
||||
expect(animDiff.commands.single.payload['commands'], isNotEmpty);
|
||||
|
||||
final dialogDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_1',
|
||||
handler: 'demo_dialog',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
dialogDiff.ui.creates.map((node) => node.id),
|
||||
contains('sample_dialog'),
|
||||
);
|
||||
|
||||
final soundDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_1',
|
||||
handler: 'demo_sound',
|
||||
),
|
||||
);
|
||||
expect(soundDiff.commands.single.type, RuntimeCommandType.playSound);
|
||||
expect(soundDiff.commands.single.payload['asset'], 'click');
|
||||
|
||||
final textSelectDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_text_demo',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
textSelectDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['text_plain_title', 'text_rich_note']),
|
||||
);
|
||||
|
||||
final textStyleDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_2',
|
||||
handler: 'demo_text_style',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
textStyleDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['text_plain_title', 'text_style_badge']),
|
||||
);
|
||||
|
||||
final buttonSelectDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_buttons',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
buttonSelectDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['button_primary', 'button_state_text']),
|
||||
);
|
||||
|
||||
final buttonToggleDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_2',
|
||||
handler: 'demo_button_toggle',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
buttonToggleDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['button_primary', 'button_state_text']),
|
||||
);
|
||||
|
||||
final spriteDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_1',
|
||||
handler: 'demo_sprite_anim',
|
||||
),
|
||||
);
|
||||
expect(spriteDiff.commands.single.type, RuntimeCommandType.parallel);
|
||||
|
||||
final radioSelectDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_radio_group',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
radioSelectDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['radio_audio_dot', 'radio_value_text']),
|
||||
);
|
||||
|
||||
final radioDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_2',
|
||||
handler: 'demo_radio_spine',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
radioDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['radio_spine_dot', 'radio_value_text']),
|
||||
);
|
||||
|
||||
final listSelectDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_list_view',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
listSelectDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['list_row_1', 'list_value_text']),
|
||||
);
|
||||
|
||||
final listDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_2',
|
||||
handler: 'demo_list_next',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
listDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['list_row_1', 'list_row_2', 'list_value_text']),
|
||||
);
|
||||
|
||||
final layoutDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_2',
|
||||
handler: 'demo_layout_column',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
layoutDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['layout_chip_1', 'layout_chip_2', 'layout_label']),
|
||||
);
|
||||
|
||||
final i18nDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'example_i18n',
|
||||
handler: 'select_example',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
i18nDiff.ui.updates
|
||||
.singleWhere((update) => update.id == 'detail_title')
|
||||
.props['text'],
|
||||
'Lua 多语言 Showcase',
|
||||
);
|
||||
|
||||
final localeDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_1',
|
||||
handler: 'demo_i18n_toggle',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
localeDiff.ui.updates
|
||||
.singleWhere((update) => update.id == 'detail_title')
|
||||
.props['text'],
|
||||
'Lua-owned localization',
|
||||
);
|
||||
expect(localeDiff.commands.single.type, RuntimeCommandType.toast);
|
||||
|
||||
final responsiveDiff = engine.dispatchEvent(
|
||||
const RuntimeEvent(
|
||||
type: RuntimeEventType.tap,
|
||||
target: 'detail_action_1',
|
||||
handler: 'demo_responsive_phone',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
responsiveDiff.ui.updates.map((update) => update.id),
|
||||
containsAll(['responsive_info', 'responsive_device']),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'loads manifest-declared modules and caches returned values',
|
||||
() async {
|
||||
final package = await _createPackage(
|
||||
mainScript: '''
|
||||
local theme_a = runtime.import("theme")
|
||||
local theme_b = runtime.import("theme")
|
||||
|
||||
function smoke_test(ctx)
|
||||
return theme_a == theme_b and load_count == 1
|
||||
end
|
||||
|
||||
function init(ctx)
|
||||
return {
|
||||
ui = {
|
||||
creates = {
|
||||
{ id = "title", type = "text", text = theme_a.title }
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
function on_event(event)
|
||||
return {}
|
||||
end
|
||||
''',
|
||||
modules: {
|
||||
'theme': '''
|
||||
load_count = (load_count or 0) + 1
|
||||
return { title = "Imported Theme" }
|
||||
''',
|
||||
},
|
||||
);
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||
final diff = engine.init({'runtimeApiVersion': 1});
|
||||
expect(diff.ui.creates.single.text, 'Imported Theme');
|
||||
},
|
||||
);
|
||||
|
||||
test('runtime_ui supports table-style component options', () async {
|
||||
final runtimeUi = File(
|
||||
'assets/runtime/lua/runtime_ui.lua',
|
||||
).readAsStringSync();
|
||||
final package = await _createPackage(
|
||||
mainScript: '''
|
||||
local ui = runtime.import("runtime_ui")
|
||||
|
||||
function smoke_test(ctx)
|
||||
return ui.rect ~= nil and ui.button ~= nil
|
||||
end
|
||||
|
||||
function init(ctx)
|
||||
return {
|
||||
ui = {
|
||||
creates = {
|
||||
ui.rect("card", { x = 10, y = 20, w = 120, h = 44, radius = 8 }),
|
||||
ui.circle("dot", { x = 5, y = 6, size = 18, color = "#ffffffff" }),
|
||||
ui.button("ok", { text = "OK", x = 1, y = 2, w = 60, h = 24, handler = "submit", asset = "button_normal", pressedAsset = "button_pressed", disabledAsset = "button_disabled" })
|
||||
},
|
||||
updates = {
|
||||
ui.update("card", { w = 140, h = 48, onClick = "tap_card" })
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
function on_event(event)
|
||||
return {}
|
||||
end
|
||||
''',
|
||||
modules: {'runtime_ui': runtimeUi},
|
||||
);
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||
final diff = engine.init({'runtimeApiVersion': 1});
|
||||
final card = diff.ui.creates.singleWhere((node) => node.id == 'card');
|
||||
expect(card.width, 120);
|
||||
expect(card.height, 44);
|
||||
final dot = diff.ui.creates.singleWhere((node) => node.id == 'dot');
|
||||
expect(dot.width, 18);
|
||||
expect(dot.height, 18);
|
||||
final button = diff.ui.creates.singleWhere((node) => node.id == 'ok');
|
||||
expect(button.onTap, 'submit');
|
||||
expect(button.width, 60);
|
||||
expect(button.asset, 'button_normal');
|
||||
expect(button.pressedAsset, 'button_pressed');
|
||||
expect(button.disabledAsset, 'button_disabled');
|
||||
final update = diff.ui.updates.single;
|
||||
expect(update.props, containsPair('width', 140));
|
||||
expect(update.props, containsPair('height', 48));
|
||||
expect(update.props, containsPair('onTap', 'tap_card'));
|
||||
expect(update.props.containsKey('w'), isFalse);
|
||||
expect(update.props.containsKey('h'), isFalse);
|
||||
expect(update.props.containsKey('onClick'), isFalse);
|
||||
});
|
||||
|
||||
test('runtime_widgets exposes common lightweight components', () async {
|
||||
final runtimeUi = File(
|
||||
'assets/runtime/lua/runtime_ui.lua',
|
||||
).readAsStringSync();
|
||||
final runtimeWidgets = File(
|
||||
'assets/runtime/lua/runtime_widgets.lua',
|
||||
).readAsStringSync();
|
||||
final package = await _createPackage(
|
||||
mainScript: '''
|
||||
local widgets = runtime.import("runtime_widgets")
|
||||
|
||||
widgets.configure({
|
||||
primary = "#ff010203",
|
||||
secondary = "#ff040506",
|
||||
surface = "#ff070809",
|
||||
surfaceAlt = "#ff0a0b0c",
|
||||
text = "#ff0d0e0f",
|
||||
muted = "#ff101112",
|
||||
transparent = "#00000000"
|
||||
})
|
||||
|
||||
function smoke_test(ctx)
|
||||
return widgets.label ~= nil and widgets.pill ~= nil and widgets.text_button ~= nil and widgets.tabs ~= nil and widgets.list_item ~= nil and widgets.action_row ~= nil and widgets.panel_header ~= nil
|
||||
end
|
||||
|
||||
function init(ctx)
|
||||
local pill = widgets.pill("status", {
|
||||
text = "Ready",
|
||||
x = 10,
|
||||
y = 20,
|
||||
w = 72,
|
||||
h = 24,
|
||||
color = "#ff000000",
|
||||
textStyle = { color = "#ffffffff" }
|
||||
})
|
||||
local tabs = widgets.tabs("tabs", {
|
||||
{ key = "code", text = "Code", handler = "show_code" },
|
||||
{ key = "params", text = "Params", handler = "show_params" }
|
||||
}, {
|
||||
x = 8,
|
||||
y = 86,
|
||||
itemWidth = 70,
|
||||
itemHeight = 22,
|
||||
selected = "params",
|
||||
parent = "toolbar",
|
||||
layer = 9
|
||||
})
|
||||
local actions = widgets.action_row("actions", {
|
||||
{ text = "Run", handler = "run" },
|
||||
{ text = "Stop", handler = "stop", visible = false }
|
||||
}, {
|
||||
x = 10,
|
||||
y = 142,
|
||||
width = 170,
|
||||
itemHeight = 24,
|
||||
gap = 10,
|
||||
layer = 7
|
||||
})
|
||||
local header = widgets.panel_header("header", {
|
||||
eyebrow = "Runtime",
|
||||
title = "Panel",
|
||||
summary = "Composable header",
|
||||
x = 6,
|
||||
y = 172,
|
||||
w = 180,
|
||||
parent = "card",
|
||||
layer = 6
|
||||
})
|
||||
return {
|
||||
ui = {
|
||||
creates = {
|
||||
widgets.section_title("title", { text = "Overview", x = 4, y = 6, w = 160, h = 28 }),
|
||||
pill[1],
|
||||
pill[2],
|
||||
widgets.text_button("more", { text = "More", x = 10, y = 52, w = 80, h = 28, handler = "open", variant = "ghost" }),
|
||||
widgets.list_item("row", { text = "Row", x = 10, y = 112, w = 100, h = 24, handler = "select_row", selected = true }),
|
||||
tabs[1],
|
||||
tabs[2],
|
||||
actions[1],
|
||||
actions[2],
|
||||
header[1],
|
||||
header[2],
|
||||
header[3]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
function on_event(event)
|
||||
return {}
|
||||
end
|
||||
''',
|
||||
modules: {'runtime_ui': runtimeUi, 'runtime_widgets': runtimeWidgets},
|
||||
);
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue);
|
||||
final diff = engine.init({'runtimeApiVersion': 1});
|
||||
expect(
|
||||
diff.ui.creates.map((node) => node.id),
|
||||
containsAll([
|
||||
'title',
|
||||
'status',
|
||||
'status_text',
|
||||
'more',
|
||||
'row',
|
||||
'tabs_code',
|
||||
'tabs_params',
|
||||
'actions_1',
|
||||
'actions_2',
|
||||
'header_eyebrow',
|
||||
'header_title',
|
||||
'header_summary',
|
||||
]),
|
||||
);
|
||||
final title = diff.ui.creates.singleWhere((node) => node.id == 'title');
|
||||
expect(title.fontSize, 18);
|
||||
final pillText = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'status_text',
|
||||
);
|
||||
expect(pillText.parent, 'status');
|
||||
final more = diff.ui.creates.singleWhere((node) => node.id == 'more');
|
||||
expect(more.onTap, 'open');
|
||||
expect(more.color?.toARGB32(), 0x00000000);
|
||||
final row = diff.ui.creates.singleWhere((node) => node.id == 'row');
|
||||
expect(row.onTap, 'select_row');
|
||||
expect(row.color?.toARGB32(), 0xff010203);
|
||||
final codeTab = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'tabs_code',
|
||||
);
|
||||
expect(codeTab.parent, 'toolbar');
|
||||
expect(codeTab.x, 8);
|
||||
expect(codeTab.y, 86);
|
||||
expect(codeTab.onTap, 'show_code');
|
||||
expect(codeTab.color?.toARGB32(), 0xff070809);
|
||||
final paramsTab = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'tabs_params',
|
||||
);
|
||||
expect(paramsTab.x, 84);
|
||||
expect(paramsTab.onTap, 'show_params');
|
||||
expect(paramsTab.color?.toARGB32(), 0xff010203);
|
||||
final action1 = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'actions_1',
|
||||
);
|
||||
final action2 = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'actions_2',
|
||||
);
|
||||
expect(action1.width, 80);
|
||||
expect(action1.onTap, 'run');
|
||||
expect(action1.layer, 7);
|
||||
expect(action2.visible, isFalse);
|
||||
expect(action2.onTap, 'noop');
|
||||
final eyebrow = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'header_eyebrow',
|
||||
);
|
||||
final headerTitle = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'header_title',
|
||||
);
|
||||
final summary = diff.ui.creates.singleWhere(
|
||||
(node) => node.id == 'header_summary',
|
||||
);
|
||||
expect(eyebrow.parent, 'card');
|
||||
expect(eyebrow.y, 172);
|
||||
expect(headerTitle.y, 196);
|
||||
expect(summary.y, 228);
|
||||
});
|
||||
|
||||
test('layout supports spacing and grid aliases', () async {
|
||||
final runtimeUi = File(
|
||||
'assets/runtime/lua/runtime_ui.lua',
|
||||
).readAsStringSync();
|
||||
final layout = File('assets/runtime/lua/layout.lua').readAsStringSync();
|
||||
final package = await _createPackage(
|
||||
mainScript: '''
|
||||
local ui = runtime.import("runtime_ui")
|
||||
local layout = runtime.import("layout")
|
||||
|
||||
function smoke_test(ctx)
|
||||
return layout.box ~= nil and layout.item ~= nil
|
||||
end
|
||||
|
||||
function init(ctx)
|
||||
local nodes = layout.box("panel", {
|
||||
layout.item(ui.rect("a", { w = 20, h = 10 }), { mx = 2, my = 1 }),
|
||||
ui.rect("b", { w = 20, h = 10 }),
|
||||
ui.rect("c", { w = 20, h = 10 })
|
||||
}, {
|
||||
x = 10,
|
||||
y = 20,
|
||||
cols = 2,
|
||||
cellW = 30,
|
||||
cellH = 20,
|
||||
gap = 5,
|
||||
padding = 3,
|
||||
align = "center",
|
||||
valign = "center"
|
||||
})
|
||||
return { ui = { creates = nodes } }
|
||||
end
|
||||
|
||||
function on_event(event)
|
||||
return {}
|
||||
end
|
||||
''',
|
||||
modules: {'runtime_ui': runtimeUi, 'layout': layout},
|
||||
);
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await engine.loadPackage(package);
|
||||
|
||||
final diff = engine.init({'runtimeApiVersion': 1});
|
||||
final a = diff.ui.creates.singleWhere((node) => node.id == 'a');
|
||||
final b = diff.ui.creates.singleWhere((node) => node.id == 'b');
|
||||
final c = diff.ui.creates.singleWhere((node) => node.id == 'c');
|
||||
expect(a.parent, 'panel');
|
||||
expect(a.x, 18);
|
||||
expect(a.y, 28);
|
||||
expect(b.x, 53);
|
||||
expect(b.y, 28);
|
||||
expect(c.x, 18);
|
||||
expect(c.y, 53);
|
||||
});
|
||||
|
||||
test('rejects undeclared module imports', () async {
|
||||
final package = await _createPackage(
|
||||
mainScript: '''
|
||||
runtime.import("missing")
|
||||
function smoke_test(ctx) return true end
|
||||
function init(ctx) return {} end
|
||||
function on_event(event) return {} end
|
||||
''',
|
||||
);
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await expectLater(engine.loadPackage(package), throwsStateError);
|
||||
});
|
||||
|
||||
test('rejects unsafe module names', () async {
|
||||
final package = await _createPackage(
|
||||
mainScript: '''
|
||||
runtime.import("../theme")
|
||||
function smoke_test(ctx) return true end
|
||||
function init(ctx) return {} end
|
||||
function on_event(event) return {} end
|
||||
''',
|
||||
modules: {'theme': 'return {}'},
|
||||
);
|
||||
final engine = LuaDardoScriptEngine();
|
||||
|
||||
await expectLater(engine.loadPackage(package), throwsStateError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<GamePackage> _createPackage({
|
||||
required String mainScript,
|
||||
Map<String, String> modules = const {},
|
||||
}) async {
|
||||
final root = await Directory.systemTemp.createTemp('lua_engine_test_');
|
||||
Directory('${root.path}/scripts').createSync(recursive: true);
|
||||
File('${root.path}/scripts/main.lua').writeAsStringSync(mainScript);
|
||||
|
||||
final manifestModules = <String, String>{};
|
||||
for (final entry in modules.entries) {
|
||||
final path = 'scripts/${entry.key}.lua';
|
||||
File('${root.path}/$path').writeAsStringSync(entry.value);
|
||||
manifestModules[entry.key] = path;
|
||||
}
|
||||
|
||||
addTearDown(() {
|
||||
if (root.existsSync()) {
|
||||
root.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
return GamePackage.file(
|
||||
rootPath: root.path,
|
||||
manifest: GamePackageManifest(
|
||||
gameId: 'test',
|
||||
name: 'Test',
|
||||
version: '0.1.0',
|
||||
runtimeApiVersion: 1,
|
||||
entry: 'scripts/main.lua',
|
||||
assetsBase: 'assets',
|
||||
modules: manifestModules,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user