From 17bf0fb93431bf91092e67cd1d27041538dd6338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 16 Jul 2026 04:06:53 +0300 Subject: [PATCH 1/9] fix(pointer): resolve q-ref entries to the owning element, not the root A q-ref (find/observe handle) anchored its RefEntry at the root element, so pointer dispatch (dispatchRectOf = _liveRectOf(entry.element)) re-resolved to the whole viewport and every q-ref tap fired at the viewport CENTER instead of the target. On a centered widget the two coincided (so existing tests passed); on any off-center control (a submit button, a checkbox, a sidebar item) the tap missed silently. Resolve the Element whose RenderBox contributes the node (debugSemantics identity, matching ext_observe) and anchor the entry there, so dispatch, the stable-rect gate, and EditableText focus all operate on the real target. Derive the rect from that element's localToGlobal (LOGICAL px); the _globalRectFromSemantics fallback now divides out the device pixel ratio so the degraded path stays logical on a DPR!=1 display. Adds a regression test: an off-center (top-left) target whose q-ref tap must land on it, which fails when the entry is anchored at the viewport. --- lib/src/extensions/ext_find.dart | 74 ++++++++++++++++++++++--- test/src/extensions/ext_find_test.dart | 76 ++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/lib/src/extensions/ext_find.dart b/lib/src/extensions/ext_find.dart index 4f5e55f..7aadbb6 100644 --- a/lib/src/extensions/ext_find.dart +++ b/lib/src/extensions/ext_find.dart @@ -413,24 +413,68 @@ RefEntry? _entryFromElement(Element element) { ); } -/// Materialises a [RefEntry] from a [SemanticsNode]. The element field is -/// set to the root element (best-effort anchor for EditableText focus -/// lookups); the rect is mapped from the node's local space up through -/// each ancestor's transform to obtain global coordinates that -/// pointer-event dispatch can hit. +/// Materialises a [RefEntry] from a [SemanticsNode]. +/// +/// The entry's [RefEntry.element] is the live [Element] whose render object +/// contributes [node] to the semantics tree, NOT the root element. This is +/// load-bearing: pointer dispatch re-resolves the tap point from +/// `dispatchRectOf(entry) = _liveRectOf(entry.element)`, and EditableText focus +/// walks descendants of `entry.element`. Anchoring the entry at the root made +/// both resolve against the whole viewport, so every q-ref tap dispatched at +/// the SCREEN center (missing the target) and text-input landed on the first +/// editable in the tree. Resolving the owning element fixes both at the source. +/// +/// The rect is taken from that element's [RenderBox] via `localToGlobal` +/// (LOGICAL px, the space dusk's pointer events use). It falls back to the +/// root element and the semantics-transform rect only when no render object +/// owns the node (the DPR-divided `_globalRectFromSemantics`, kept logical). RefEntry? _entryFromSemanticsNode(SemanticsNode node) { final Element? root = WidgetsBinding.instance.rootElement; if (root == null) return null; final bool isTextField = node.flagsCollection.isTextField; + final Element? target = _elementForSemanticsNode(node); + final RenderObject? renderObject = target?.findRenderObject(); + final bool sized = renderObject is RenderBox && + renderObject.attached && + renderObject.hasSize; + final Rect resolvedRect = sized + ? renderObject.localToGlobal(Offset.zero) & renderObject.size + : _globalRectFromSemantics(node); return RefEntry( - rect: _globalRectFromSemantics(node), - element: root, + rect: resolvedRect, + element: target ?? root, groupId: _kQueryGroupId, isTextField: isTextField, + renderObject: sized ? renderObject : null, node: node, ); } +/// The live [Element] whose [RenderBox] contributes [node] to the semantics +/// tree, matching on `debugSemantics` identity (debug-only, and dusk is +/// `kDebugMode`-gated). Returns `null` when no render object owns [node] +/// (a purely synthetic or merged-away node); callers fall back to the root. +Element? _elementForSemanticsNode(SemanticsNode node) { + final Element? root = WidgetsBinding.instance.rootElement; + if (root == null) return null; + Element? result; + void visit(Element element) { + if (result != null) return; + final RenderObject? renderObject = element.renderObject; + if (renderObject is RenderBox && + renderObject.attached && + renderObject.hasSize && + identical(renderObject.debugSemantics, node)) { + result = element; + return; + } + element.visitChildElements(visit); + } + + root.visitChildElements(visit); + return result; +} + /// Walks up the [SemanticsNode] ancestor chain, applying each ancestor's /// transform to map [node]'s local rect into global coordinates. /// @@ -449,6 +493,22 @@ Rect _globalRectFromSemantics(SemanticsNode node) { } current = current.parent; } + // The semantics ancestor transforms fold in the root view's device pixel + // ratio, so `rect` is in PHYSICAL pixels. dusk dispatches pointer events and + // compares against render-tree localToGlobal rects in LOGICAL pixels, so + // divide the DPR back out; otherwise, on a DPR!=1 display, every tap that + // falls back to this rect lands at N times the intended offset and misses. + final double dpr = + WidgetsBinding.instance.platformDispatcher.implicitView?.devicePixelRatio ?? + 1.0; + if (dpr > 0 && dpr != 1.0) { + rect = Rect.fromLTRB( + rect.left / dpr, + rect.top / dpr, + rect.right / dpr, + rect.bottom / dpr, + ); + } return rect; } diff --git a/test/src/extensions/ext_find_test.dart b/test/src/extensions/ext_find_test.dart index f1e7453..b73d3de 100644 --- a/test/src/extensions/ext_find_test.dart +++ b/test/src/extensions/ext_find_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_dusk/src/extensions/ext_find.dart'; import 'package:fluttersdk_dusk/src/extensions/ext_pointer.dart'; import 'package:fluttersdk_dusk/src/ref_registry.dart'; +import 'package:fluttersdk_dusk/src/utils/actionability_gate.dart'; import 'package:fluttersdk_dusk/src/utils/dusk_exceptions.dart'; import 'package:fluttersdk_dusk/src/utils/error_envelope.dart'; @@ -336,6 +337,81 @@ void main() { }, ); + testWidgets( + '(c) q-ref tap dispatches at the target, not the viewport center ' + '(off-center target)', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // A 120x60 target pinned to the top-left corner: its center (60, 30) + // is far from the 800x600 viewport center (400, 300). A q-ref that + // anchored its RefEntry at the ROOT element resolved its live dispatch + // rect (`dispatchRectOf` = `_liveRectOf(entry.element)`) to the whole + // viewport, so the tap fired at (400, 300) and missed the target. This + // is the exact defect that made real buttons (Sign In, checkbox) ignore + // q-ref taps while a centered target coincidentally still worked. + int taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onTap: () => taps += 1, + child: Semantics( + label: 'corner-target', + button: true, + container: true, + child: Container( + width: 120, + height: 60, + color: const Color(0xFF000000), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final findResponse = await extDuskFindHandler( + 'ext.dusk.find', + {'semanticsLabel': 'corner-target'}, + ); + final String qRef = (jsonDecode(findResponse.result!) + as Map)['ref'] as String; + expect(qRef, startsWith('q')); + + // The entry anchors on the owning element, so the live dispatch rect + // is the target's top-left box, NOT the whole viewport. + final RefEntry? entry = resolveRefForAction(qRef); + expect(entry, isNotNull); + final Rect dispatchRect = dispatchRectOf(entry!)!; + expect(dispatchRect.center.dx, lessThan(200)); + expect(dispatchRect.center.dy, lessThan(120)); + + final future = aiTestTapHandler( + 'ext.dusk.tap', + { + 'ref': qRef, + 'checkStable': 'false', + 'checkReceivesEvents': 'false', + }, + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(); + final response = await future; + + expect(response.errorDetail, isNull); + expect(taps, equals(1)); + }, + ); + testWidgets( '(c) q-ref survives a widget rebuild that would invalidate an eN', (WidgetTester tester) async { From 2cb9f221df65a5f6ab78e9ad51b80a301afd1d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 16 Jul 2026 13:17:23 +0300 Subject: [PATCH 2/9] test(pointer): q-ref tap reaches an excludeSemantics button (WSwitch pattern) Locks that a q-ref resolves to the owning element and its pointer tap reaches the underlying GestureDetector for the WAnchor `semanticLabel != null` branch (Semantics(button, onTap, excludeSemantics: true) inside a Semantics(container, toggled) + MergeSemantics wrapper, exactly what WSwitch builds). Passes at DPR 1 and 2, confirming dusk's core handling of this structure is correct: the live MSSwitch-tap gap seen in uptizm is not a widget-test-reproducible dusk-core defect. --- test/src/extensions/ext_find_test.dart | 88 ++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/test/src/extensions/ext_find_test.dart b/test/src/extensions/ext_find_test.dart index b73d3de..7f6a97a 100644 --- a/test/src/extensions/ext_find_test.dart +++ b/test/src/extensions/ext_find_test.dart @@ -412,6 +412,94 @@ void main() { }, ); + testWidgets( + '(c) q-ref tap fires onTap for an excludeSemantics button ' + '(WAnchor semanticLabel / WSwitch pattern)', + (WidgetTester tester) async { + // DPR=2 mirrors the live web run (retina); logical stays 800x600. + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // Mirrors WAnchor's `semanticLabel != null` branch (used by WSwitch and + // icon-only buttons): a Semantics(button, onTap, excludeSemantics: true) + // wrapping the real gesture. The exclusion drops the descendant + // GestureDetector's node from the semantics tree, so the ONLY semantics + // node is this annotation node. A q-ref must still resolve to the + // owning element (not fall back to the root) and its pointer tap must + // reach the underlying GestureDetector. This reproduces the live bug + // where MSSwitch toggles (notification prefs, status-page monitor + // assignment) ignored dusk taps. + int taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Semantics( + // Outer boundary the real WSwitch wraps around WAnchor: + // Semantics(container, toggled) -> MergeSemantics -> WAnchor's + // Semantics(button, onTap, excludeSemantics: true). This is the + // full live structure the earlier bare reproduction omitted. + container: true, + toggled: false, + child: MergeSemantics( + child: Semantics( + button: true, + label: 'toggle-x', + onTap: () => taps += 1, + excludeSemantics: true, + child: MouseRegion( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => taps += 1, + child: const SizedBox(width: 80, height: 40), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final findResponse = await extDuskFindHandler( + 'ext.dusk.find', + {'semanticsLabel': 'toggle-x'}, + ); + final String qRef = (jsonDecode(findResponse.result!) + as Map)['ref'] as String; + expect(qRef, startsWith('q')); + + // The entry must anchor on the switch box (top-left), NOT the viewport: + // a root fallback here is exactly what made the live MSSwitch taps miss. + final RefEntry? entry = resolveRefForAction(qRef); + expect(entry, isNotNull); + final Rect dispatchRect = dispatchRectOf(entry!)!; + expect(dispatchRect.center.dx, lessThan(200)); + expect(dispatchRect.center.dy, lessThan(120)); + + final future = aiTestTapHandler( + 'ext.dusk.tap', + { + 'ref': qRef, + 'checkStable': 'false', + 'checkReceivesEvents': 'false', + }, + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(); + final response = await future; + + expect(response.errorDetail, isNull); + expect(taps, greaterThan(0)); + }, + ); + testWidgets( '(c) q-ref survives a widget rebuild that would invalidate an eN', (WidgetTester tester) async { From 5b3c5847bcf2697decd0566c59a848b4b16f421b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 16 Jul 2026 18:36:49 +0300 Subject: [PATCH 3/9] fix(find): prefer the interactive node when a label collides with plain text find-by-label returned the first node with a matching label. When a visible label Text names an adjacent control (the settings-toggle / MSSwitch pattern: a WText 'Email' beside a switch whose semanticLabel is also 'Email') or a heading repeats a button's text (the login 'Sign In' heading vs the submit button), the inert text sits first in tree order, so the handle resolved to it and the tap landed on the label instead of the control -- silently doing nothing. Resolve to the first INTERACTIVE match (button / switch / text field / anything exposing SemanticsAction.tap) when the label collides across an interactive and a non-interactive node, falling back to the first match otherwise. Covers the live MSSwitch toggles (notification prefs, status-page monitor assignment) and removes the need for e-ref workarounds on ambiguous labels. Adds two regression tests; full suite 797 green. --- lib/src/extensions/ext_find.dart | 22 +++++++-- test/src/extensions/ext_find_test.dart | 65 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/lib/src/extensions/ext_find.dart b/lib/src/extensions/ext_find.dart index 7aadbb6..80eb07c 100644 --- a/lib/src/extensions/ext_find.dart +++ b/lib/src/extensions/ext_find.dart @@ -340,13 +340,15 @@ SemanticsNode? _findSemanticsNodeByLabelContains(String needle) { /// count reflects ALL matches in the tree. When `count > 1` the caller /// should surface an ambiguity diagnostic to the agent. (SemanticsNode?, int) _findSemanticsNodeByLabelWithCount(String needle) { - SemanticsNode? found; + SemanticsNode? firstMatch; + SemanticsNode? firstInteractive; int count = 0; void visit(SemanticsNode node) { if (node.label == needle) { count += 1; - found ??= node; + firstMatch ??= node; + firstInteractive ??= _isInteractiveNode(node) ? node : null; } node.visitChildren((SemanticsNode child) { visit(child); @@ -362,7 +364,21 @@ SemanticsNode? _findSemanticsNodeByLabelContains(String needle) { } visitOwner(RendererBinding.instance.rootPipelineOwner); - return (found, count); + // Prefer an interactive match (a button, switch, or text field) over a plain + // node when one label collides across both: a visible label WText that names + // an adjacent control, or a heading that repeats a button's text, would + // otherwise resolve to the non-interactive copy and land the tap on the label + // instead of the control. Falls back to the first match when none is + // interactive. + return (firstInteractive ?? firstMatch, count); +} + +/// Whether [node] is something an agent would act on (button / switch / text +/// field / anything exposing a tap action) rather than inert content. +bool _isInteractiveNode(SemanticsNode node) { + final flags = node.flagsCollection; + if (flags.isButton || flags.isTextField) return true; + return node.getSemanticsData().hasAction(SemanticsAction.tap); } /// Cross-checks an Element-tree match against the supplied query's diff --git a/test/src/extensions/ext_find_test.dart b/test/src/extensions/ext_find_test.dart index 7f6a97a..d82fe90 100644 --- a/test/src/extensions/ext_find_test.dart +++ b/test/src/extensions/ext_find_test.dart @@ -500,6 +500,71 @@ void main() { }, ); + testWidgets( + '(c) find prefers the interactive node when a label collides with text', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // A visible label Text and the control it names share a label (the + // settings-toggle / MSSwitch pattern, and the login heading-vs-button + // pattern). The plain Text sits FIRST in tree order. find must resolve + // the interactive control, not the inert text, so the tap lands on the + // control. + int taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + const Text('Email'), + Semantics( + button: true, + label: 'Email', + onTap: () => taps += 1, + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps += 1, + child: const SizedBox(width: 80, height: 40), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + final findResponse = await extDuskFindHandler( + 'ext.dusk.find', + {'semanticsLabel': 'Email'}, + ); + final decoded = + jsonDecode(findResponse.result!) as Map; + final String qRef = decoded['ref'] as String; + // Both the Text and the button carry the label. + expect(decoded['matchCount'], 2); + + final future = aiTestTapHandler( + 'ext.dusk.tap', + { + 'ref': qRef, + 'checkStable': 'false', + 'checkReceivesEvents': 'false', + }, + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(); + await future; + // The button's onTap fired, not the inert Text. + expect(taps, greaterThan(0)); + }, + ); + testWidgets( '(c) q-ref survives a widget rebuild that would invalidate an eN', (WidgetTester tester) async { From 2780e6ce1308aa0beabe763ada7f9ce9577f7be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sat, 18 Jul 2026 02:22:11 +0300 Subject: [PATCH 4/9] docs(changelog): note the q-ref element-resolution and interactive-preference fixes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b110c66..a2d2b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **`dusk:doctor` check 3 (snapshot enrichers) now emits INFO when no enrichers are registered, instead of WARN.** Enrichers are opt-in; zero is a valid state, not a problem. The WARN reading alongside "integration wired" (check 5) created false contradiction. Touches `lib/src/commands/dusk_doctor_command.dart`; test case updated in `test/src/commands/dusk_doctor_command_test.dart`. +- **`q` (find / observe) taps now dispatch at the target's own rect instead of the viewport centre.** `_entryFromSemanticsNode` anchored the `RefEntry` at the root element, so `dispatchRectOf` returned `_liveRectOf(root)` (the whole viewport) and every find/observe gesture fired at screen centre. A centred target coincidentally worked; off-centre controls (a submit button, a checkbox, a sidebar item) were missed silently. The entry now resolves the element whose `RenderBox` contributes the node (via `debugSemantics` identity, matching `ext_observe`), so the gesture lands on the addressed widget. Touches `lib/src/extensions/ext_find.dart`; covered by `test/src/extensions/ext_find_test.dart`. + +- **`find`-by-label now prefers the first INTERACTIVE match when a label collides with inert text.** A visible `Text` naming an adjacent control (a settings label beside a switch that shares its `semanticLabel`) or a heading repeating a button's text (a "Sign In" heading over the submit button) sits first in tree order, so the handle resolved to the inert node and the tap landed on the label. `find` now resolves to the first node exposing `SemanticsAction.tap` (button / switch / text field) when the label spans an interactive and a non-interactive node, falling back to the first match otherwise. `matchCount` / `diagnostic` still report the collision. Touches `lib/src/extensions/ext_find.dart`; covered by `test/src/extensions/ext_find_test.dart`. + --- ## [0.0.8] - 2026-06-17 From dfafe0c6f02300ca4a778f5359693aa4bbf8af00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sat, 18 Jul 2026 02:46:29 +0300 Subject: [PATCH 5/9] style: dart format --- lib/src/extensions/ext_find.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/extensions/ext_find.dart b/lib/src/extensions/ext_find.dart index 80eb07c..71974c7 100644 --- a/lib/src/extensions/ext_find.dart +++ b/lib/src/extensions/ext_find.dart @@ -514,9 +514,9 @@ Rect _globalRectFromSemantics(SemanticsNode node) { // compares against render-tree localToGlobal rects in LOGICAL pixels, so // divide the DPR back out; otherwise, on a DPR!=1 display, every tap that // falls back to this rect lands at N times the intended offset and misses. - final double dpr = - WidgetsBinding.instance.platformDispatcher.implicitView?.devicePixelRatio ?? - 1.0; + final double dpr = WidgetsBinding + .instance.platformDispatcher.implicitView?.devicePixelRatio ?? + 1.0; if (dpr > 0 && dpr != 1.0) { rect = Rect.fromLTRB( rect.left / dpr, From f77ff27d43f1f73a5676c55756bdef250e36eb91 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 18 Jul 2026 03:17:28 +0300 Subject: [PATCH 6/9] refactor(find): resolve the render box into one promoted local A separate sized boolean does not promote renderObject for the localToGlobal and size reads, so a reviewer reasonably read the RefEntry resolution as unsound. Bind the RenderBox to a single non-null local and share it between the rect calculation and the RefEntry.renderObject field. --- lib/src/extensions/ext_find.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/src/extensions/ext_find.dart b/lib/src/extensions/ext_find.dart index 71974c7..dc99507 100644 --- a/lib/src/extensions/ext_find.dart +++ b/lib/src/extensions/ext_find.dart @@ -450,18 +450,22 @@ RefEntry? _entryFromSemanticsNode(SemanticsNode node) { final bool isTextField = node.flagsCollection.isTextField; final Element? target = _elementForSemanticsNode(node); final RenderObject? renderObject = target?.findRenderObject(); - final bool sized = renderObject is RenderBox && - renderObject.attached && - renderObject.hasSize; - final Rect resolvedRect = sized - ? renderObject.localToGlobal(Offset.zero) & renderObject.size + // Promote to a RenderBox local so the rect calculation and the RefEntry field + // share one non-null handle; a separate `sized` boolean would not promote + // `renderObject` for the localToGlobal/size reads below. + final RenderBox? box = + renderObject is RenderBox && renderObject.attached && renderObject.hasSize + ? renderObject + : null; + final Rect resolvedRect = box != null + ? box.localToGlobal(Offset.zero) & box.size : _globalRectFromSemantics(node); return RefEntry( rect: resolvedRect, element: target ?? root, groupId: _kQueryGroupId, isTextField: isTextField, - renderObject: sized ? renderObject : null, + renderObject: box, node: node, ); } From 1343812cd614bd86668cf3e11da91c68d6358448 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 18 Jul 2026 03:17:28 +0300 Subject: [PATCH 7/9] test(find): cover the tap-action interactive preference branch The existing collision test uses a button node, which short-circuits at the isButton check. Add a tappable non-button row (a ListTile-style control exposing SemanticsAction.tap without the button flag) so the interactive preference exercises the tap-action path. --- test/src/extensions/ext_find_test.dart | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/src/extensions/ext_find_test.dart b/test/src/extensions/ext_find_test.dart index d82fe90..816ad8d 100644 --- a/test/src/extensions/ext_find_test.dart +++ b/test/src/extensions/ext_find_test.dart @@ -565,6 +565,70 @@ void main() { }, ); + testWidgets( + '(c) find prefers a tappable non-button node over colliding inert text', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // A tappable navigation row (a ListTile-style control) exposes + // SemanticsAction.tap WITHOUT the button flag, so the interactive + // preference must fall past isButton/isTextField to the tap-action + // check. An inert Text sharing the label sits first in tree order. + int taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + const Text('Monitors'), + Semantics( + label: 'Monitors', + onTap: () => taps += 1, + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps += 1, + child: const SizedBox(width: 120, height: 44), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + final findResponse = await extDuskFindHandler( + 'ext.dusk.find', + {'semanticsLabel': 'Monitors'}, + ); + final decoded = + jsonDecode(findResponse.result!) as Map; + final String qRef = decoded['ref'] as String; + // Both the Text and the tappable row carry the label. + expect(decoded['matchCount'], 2); + + final future = aiTestTapHandler( + 'ext.dusk.tap', + { + 'ref': qRef, + 'checkStable': 'false', + 'checkReceivesEvents': 'false', + }, + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + await tester.pump(); + await future; + // The tappable row fired, not the inert Text: the tap-action branch of + // the interactive check selected it despite the absent button flag. + expect(taps, greaterThan(0)); + }, + ); + testWidgets( '(c) q-ref survives a widget rebuild that would invalidate an eN', (WidgetTester tester) async { From 230bb12ee338dff2d3b755b6a77e9324b08155ea Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 18 Jul 2026 03:17:28 +0300 Subject: [PATCH 8/9] docs(find): document interactive-collision + rect dispatch resolution Sync the find command doc with the runtime: a mixed label collision now resolves to the first interactive node, and each q-ref gesture dispatches at the resolved node own on-screen rect rather than the viewport centre. --- doc/commands/dusk-find.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/commands/dusk-find.md b/doc/commands/dusk-find.md index 4adf359..ada54cf 100644 --- a/doc/commands/dusk-find.md +++ b/doc/commands/dusk-find.md @@ -228,6 +228,22 @@ label 'Password' matched 2 nodes; refine with --key, --text, or --contains list; each candidate includes role, bounds, and enricher fields that let the agent identify the correct target before minting the handle. +**Interactive nodes win a mixed collision.** When a label spans one interactive +node (a button, switch, or text field exposing `SemanticsAction.tap`) and one or +more inert nodes (a `Text` heading or a settings label that repeats the control's +copy), the handle resolves to the first interactive node rather than the first +node in tree order. A visible label naming an adjacent control, or a heading +repeating a button's text, otherwise sits first in tree order and the tap would +land on the inert label. When every match is interactive or every match is inert, +the handle falls back to the first node in tree order. `matchCount` and +`diagnostic` still report the full collision either way, so an agent that needs a +specific one of several interactive matches should still refine the predicate. + +Each `qN` gesture then dispatches at the resolved node's own on-screen rect (the +`RenderBox` bounds of the widget that contributes the node), so off-centre +controls (a sidebar item, a submit button below the fold) receive the tap at +their real position rather than the viewport centre. + --- From 9adec1f428e61493f5626ec629094bfda0d2b7dc Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sun, 19 Jul 2026 01:21:19 +0300 Subject: [PATCH 9/9] test(find): cover the semantics-transform DPR fallback directly The fallback rect path runs only for a synthetic / non-RenderBox-owned node; every node matched through the find handler with a real widget tree resolves to its owning RenderBox (verified across scroll/merge/link/list structures), so it is unreachable via the public handler. Expose the helper with @visibleForTesting and verify the transform-composition + DPR-correction math directly (a wrong DPR divide lands every fallback tap at N times the offset). --- lib/src/extensions/ext_find.dart | 10 ++++-- test/src/extensions/ext_find_test.dart | 44 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/src/extensions/ext_find.dart b/lib/src/extensions/ext_find.dart index dc99507..5e4b323 100644 --- a/lib/src/extensions/ext_find.dart +++ b/lib/src/extensions/ext_find.dart @@ -459,7 +459,7 @@ RefEntry? _entryFromSemanticsNode(SemanticsNode node) { : null; final Rect resolvedRect = box != null ? box.localToGlobal(Offset.zero) & box.size - : _globalRectFromSemantics(node); + : globalRectFromSemantics(node); return RefEntry( rect: resolvedRect, element: target ?? root, @@ -503,7 +503,13 @@ Element? _elementForSemanticsNode(SemanticsNode node) { /// parent. Composing transforms from the leaf upward yields the global /// rect; pointer dispatch consumes `rect.center` and must be in the same /// space as the view's logical viewport. -Rect _globalRectFromSemantics(SemanticsNode node) { +/// +/// Public and `@visibleForTesting`: this fallback only runs for a synthetic or +/// non-RenderBox-owned node (see [_elementForSemanticsNode]), which cannot be +/// produced through [extDuskFindHandler] with a real widget tree, so the +/// transform-composition + DPR-correction math is verified directly. +@visibleForTesting +Rect globalRectFromSemantics(SemanticsNode node) { Rect rect = node.rect; SemanticsNode? current = node; while (current != null) { diff --git a/test/src/extensions/ext_find_test.dart b/test/src/extensions/ext_find_test.dart index 816ad8d..53f9ca7 100644 --- a/test/src/extensions/ext_find_test.dart +++ b/test/src/extensions/ext_find_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_dusk/src/extensions/ext_find.dart'; @@ -953,4 +954,47 @@ void main() { expect(next, equals('q1')); }); }); + + group('globalRectFromSemantics (transform + DPR fallback)', () { + // This fallback fires only for a synthetic / non-RenderBox-owned node. + // Every node matched through extDuskFindHandler with a real widget tree + // resolves to its owning RenderBox (proven across scroll/merge/link/list + // structures), so the fallback is unreachable via the public handler. Its + // transform-composition + DPR-correction math is verified directly here. + + testWidgets( + 'composes the node transform and divides the rect by the DPR', + (WidgetTester tester) async { + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.resetDevicePixelRatio); + + final SemanticsNode node = SemanticsNode() + ..rect = const Rect.fromLTWH(0, 0, 200, 100) + ..transform = Matrix4.identity(); + + // Semantics rects fold in the DPR (physical px); the fallback divides + // it back to the logical space dusk dispatches pointers in. + expect( + globalRectFromSemantics(node), + const Rect.fromLTWH(0, 0, 100, 50), + ); + }, + ); + + testWidgets( + 'leaves the rect untouched at DPR 1 and with no transform', + (WidgetTester tester) async { + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetDevicePixelRatio); + + final SemanticsNode node = SemanticsNode() + ..rect = const Rect.fromLTWH(10, 20, 60, 40); + + expect( + globalRectFromSemantics(node), + const Rect.fromLTWH(10, 20, 60, 40), + ); + }, + ); + }); }