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; final failedTile = failedResources.single as Map; expect(failedTile['state'], 'failed'); expect(failedTile['error'], isA()); 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()), ); 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 _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 _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 _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(), super.file(rootPath: package.rootPath, manifest: package.manifest); final async.Completer _releaseReads; int readCount = 0; void releaseReads() { if (!_releaseReads.isCompleted) { _releaseReads.complete(); } } @override Future readBytes(String relativeOrAbsolutePath) async { readCount++; await _releaseReads.future; return ByteData.sublistView(Uint8List(0)); } }