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 = []; 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 = []; 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 = []; 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 = []; 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); }); }); }