Files
flutter_lua_runtime/test/runtime/rendering/render_tree_controller_test.dart
2026-06-07 22:53:58 +08:00

594 lines
16 KiB
Dart

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