594 lines
16 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|