feat: add package source compatibility controls

This commit is contained in:
gem
2026-06-10 17:54:12 +08:00
parent 6608d0a975
commit 79ee35db2f
12 changed files with 611 additions and 30 deletions

View File

@@ -0,0 +1,220 @@
import 'dart:convert';
import 'dart:io';
import 'package:flame_lua_runtime/runtime/game/runtime_options.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/packages/stable_package_store.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
group('FileGamePackageRepository', () {
test('loads a package from a local development directory', () async {
final root = await Directory.systemTemp.createTemp('local_packages_');
addTearDown(() => root.deleteSync(recursive: true));
await _writePackage(root.path, 'gomoku', version: '0.2.0');
final package = await FileGamePackageRepository(
baseDirectory: root.path,
runtimeOptions: const RuntimeOptions(runtimeLuaRoot: 'runtime/lua'),
).load('gomoku');
expect(package.source, GamePackageSource.file);
expect(package.manifest.gameId, 'gomoku');
expect(package.manifest.version, '0.2.0');
expect(package.runtimeLuaRoot, 'runtime/lua');
expect(await package.readText('scripts/main.lua'), contains('init'));
});
});
group('RemoteGamePackageRepository compatibility', () {
test(
'sends host compatibility query and falls back when incompatible',
() async {
late Uri requestedUri;
var downloadedPackage = false;
final fallback = await _createPackage('fallback');
final client = MockClient((request) async {
requestedUri = request.url;
if (request.url.path.endsWith('/remote_manifest.json')) {
return http.Response(
jsonEncode({
'gameId': 'gomoku',
'version': '2.0.0',
'packageUrl': 'http://example.test/packages/gomoku.zip',
'sha256': 'unused',
'compat': {
'runtimeApiVersion': 1,
'minRuntimeVersion': '1.2.0',
'minHostBuild': 200,
'platforms': ['windows'],
'channels': ['prod'],
},
}),
200,
);
}
downloadedPackage = true;
return http.Response('not used', 500);
});
final package = await RemoteGamePackageRepository(
baseUri: Uri.parse('http://example.test/'),
client: client,
fallback: _SinglePackageRepository(fallback),
store: _EmptyStablePackageStore(),
runtimeOptions: const RuntimeOptions(
runtimeVersion: '1.1.0',
hostBuild: 100,
channel: 'prod',
platform: 'windows',
),
).load('gomoku');
expect(package.rootPath, fallback.rootPath);
expect(requestedUri.queryParameters['runtimeApiVersion'], '1');
expect(requestedUri.queryParameters['runtimeVersion'], '1.1.0');
expect(requestedUri.queryParameters['hostBuild'], '100');
expect(requestedUri.queryParameters['platform'], 'windows');
expect(requestedUri.queryParameters['channel'], 'prod');
expect(downloadedPackage, isFalse);
},
);
});
group('StablePackageStore', () {
const channel = MethodChannel('plugins.flutter.io/path_provider');
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('ignores empty or malformed stable marker files', () async {
final support = await Directory.systemTemp.createTemp('support_');
addTearDown(() => support.deleteSync(recursive: true));
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getApplicationSupportDirectory') {
return support.path;
}
return null;
});
final store = const StablePackageStore();
final gameDir = Directory('${support.path}/flame_lua_packages/gomoku')
..createSync(recursive: true);
final marker = File('${gameDir.path}/stable.json');
marker.writeAsStringSync('');
expect(await store.stablePackage('gomoku'), isNull);
expect(await store.previousStablePackage('gomoku'), isNull);
marker.writeAsStringSync('{bad json');
expect(await store.stablePackage('gomoku'), isNull);
expect(await store.previousStablePackage('gomoku'), isNull);
});
test('writes stable marker atomically and reads current package', () async {
final support = await Directory.systemTemp.createTemp('support_');
addTearDown(() => support.deleteSync(recursive: true));
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getApplicationSupportDirectory') {
return support.path;
}
return null;
});
final package = await _createPackage('stable');
final store = const StablePackageStore();
await store.markStable(package);
final marker = File(
'${support.path}/flame_lua_packages/gomoku/stable.json',
);
expect(marker.existsSync(), isTrue);
expect(File('${marker.path}.tmp').existsSync(), isFalse);
final stable = await store.stablePackage('gomoku');
expect(stable?.rootPath, package.rootPath);
});
});
}
Future<GamePackage> _createPackage(String name) async {
final root = await Directory.systemTemp.createTemp('package_${name}_');
addTearDown(() => root.deleteSync(recursive: true));
await _writePackageRoot(root.path, gameId: 'gomoku', version: '1.0.0');
return GamePackage.file(
rootPath: root.path,
manifest: GamePackageManifest.fromJsonString(
await File('${root.path}/manifest.json').readAsString(),
),
);
}
Future<void> _writePackage(
String baseDirectory,
String gameId, {
required String version,
}) async {
final root = Directory('$baseDirectory/$gameId')..createSync(recursive: true);
await _writePackageRoot(root.path, gameId: gameId, version: version);
}
Future<void> _writePackageRoot(
String root, {
required String gameId,
required String version,
}) async {
Directory('$root/scripts').createSync(recursive: true);
await File('$root/scripts/main.lua').writeAsString('''
function smoke_test(ctx) return true end
function init(ctx) return {} end
function on_event(event) return {} end
''');
await File('$root/manifest.json').writeAsString(
jsonEncode({
'gameId': gameId,
'name': gameId,
'version': version,
'runtimeApiVersion': 1,
'entry': 'scripts/main.lua',
'assetsBase': 'assets',
}),
);
}
class _SinglePackageRepository implements GamePackageRepository {
const _SinglePackageRepository(this.package);
final GamePackage package;
@override
Future<GamePackage> load(String gameId) async => package;
}
class _EmptyStablePackageStore implements StablePackageStore {
@override
Future<Directory> cacheRoot() => throw UnimplementedError();
@override
Future<void> markStable(GamePackage package) async {}
@override
Future<GamePackage?> previousStablePackage(String gameId) async => null;
@override
Future<GamePackage?> stablePackage(String gameId) async => null;
@override
Future<Directory> versionDirectory(String gameId, String version) =>
throw UnimplementedError();
}

View File

@@ -4,7 +4,14 @@ 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 fileRepository = FileGamePackageRepository(baseDirectory: 'packages');
const options = RuntimeOptions(
runtimeLuaRoot: 'custom/runtime/lua',
runtimeVersion: '1.2.0',
hostBuild: 12,
platform: 'windows',
channel: 'dev',
);
const widget = LuaGameWidget(
gameId: 'template',
packageRepository: repository,
@@ -14,6 +21,11 @@ void main() {
expect(widget.gameId, 'template');
expect(widget.packageRepository, same(repository));
expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua');
expect(widget.runtimeOptions.runtimeVersion, '1.2.0');
expect(widget.runtimeOptions.hostBuild, 12);
expect(widget.runtimeOptions.platform, 'windows');
expect(widget.runtimeOptions.channel, 'dev');
expect(fileRepository.baseDirectory, 'packages');
expect(LuaDardoScriptEngine.new, isA<ScriptEngine Function()>());
expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans');
});