Initial flame_lua_runtime package

This commit is contained in:
gem
2026-06-07 22:53:58 +08:00
commit 733b2fb798
262 changed files with 28439 additions and 0 deletions

20
test/public_api_test.dart Normal file
View 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');
});
}

View 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();
}
}

View 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();
}
}

View 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);
});
});
}

View 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',
]);
});
});
}

View 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;
}
}

View 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,
);
});
});
}

View 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',
),
);
}
}

View 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',
});
});
});
}

View 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);
});
});
}

View 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);
});
});
}

View 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>()));
});
});
}

View 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);
});
});
}

View 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,
);
});
});
}

View 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,
);
});
});
}

View 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,
);
});
});
}

View File

@@ -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
''';

View 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,
);
});
});
}

View 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')},
);
}

View 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
''';

View 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');
});
}

View 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);
});
});
}

View 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);
});
});
}

View 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));
});
});
}

View 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));
}
}

View 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);
});
});
}

View 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,
),
);
}