Initial flame_lua_runtime package
This commit is contained in:
593
test/runtime/rendering/render_tree_controller_test.dart
Normal file
593
test/runtime/rendering/render_tree_controller_test.dart
Normal file
@@ -0,0 +1,593 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user