Files
flutter_lua_runtime/test/runtime/resources/game_resource_manager_test.dart
2026-06-09 12:49:01 +08:00

493 lines
13 KiB
Dart

import 'dart:async' as async;
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' show Rect;
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('loads TexturePacker atlas frames for image resources', () async {
final resources = GameResourceManager();
final package = await _createTextureAtlasPackage('texture_atlas');
await resources.mount(package);
final idle = resources.textureFrame('ui', 'button_idle.png');
final pressed = resources.textureFrame('ui', 'button_pressed.png');
expect(idle?.rect, Rect.fromLTWH(2, 3, 40, 20));
expect(pressed?.rect, Rect.fromLTWH(44, 3, 40, 20));
expect(resources.textureFrame('ui', 'missing.png'), isNull);
});
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> _createTextureAtlasPackage(String name) async {
final root = await Directory.systemTemp.createTemp('resource_${name}_');
Directory('${root.path}/assets').createSync(recursive: true);
File('${root.path}/assets/ui.png').writeAsBytesSync(_pngBytes);
File('${root.path}/assets/ui.json').writeAsStringSync('''
{
"frames": {
"button_idle.png": {
"frame": { "x": 2, "y": 3, "w": 40, "h": 20 },
"rotated": false,
"trimmed": false
}
}
}
''');
addTearDown(() {
if (root.existsSync()) {
root.deleteSync(recursive: true);
}
});
final hashAtlas = RuntimeTextureAtlas.fromJsonString(
File('${root.path}/assets/ui.json').readAsStringSync(),
);
expect(hashAtlas.frames['button_idle.png']?.rect, Rect.fromLTWH(2, 3, 40, 20));
final arrayAtlas = RuntimeTextureAtlas.fromJsonString('''
{
"frames": [
{
"filename": "button_pressed.png",
"frame": { "x": 44, "y": 3, "w": 40, "h": 20 },
"rotated": false,
"trimmed": false
}
]
}
''');
final mergedAtlas = '''
{
"frames": {
"button_idle.png": {
"frame": { "x": 2, "y": 3, "w": 40, "h": 20 },
"rotated": false,
"trimmed": false
},
"button_pressed.png": {
"frame": { "x": ${arrayAtlas.frames['button_pressed.png']!.x}, "y": 3, "w": 40, "h": 20 },
"rotated": false,
"trimmed": false
}
}
}
''';
File('${root.path}/assets/ui.json').writeAsStringSync(mergedAtlas);
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 {
'ui': GameResource(
type: GameResourceType.image,
path: 'assets/ui.png',
atlas: 'assets/ui.json',
preload: GameResourcePreload.lazy,
),
},
),
);
}
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));
}
}