From b5a99864de4b40bb922d7745b4219b47c8860541 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 16:22:16 -0400 Subject: [PATCH 1/6] test: record context menu accessibility spike --- .../context_menu_accessibility_at_results.md | 122 +++ ...context_menu_accessibility_spike_test.dart | 176 +++++ .../lib/context_menu_accessibility_spike.dart | 185 +++++ .../context_menu_accessibility_spike.dart | 544 +++++++++++++ ...context_menu_accessibility_spike_test.dart | 740 ++++++++++++++++++ 5 files changed, 1767 insertions(+) create mode 100644 packages/example/integration_test/spikes/context_menu_accessibility_at_results.md create mode 100644 packages/example/integration_test/spikes/context_menu_accessibility_spike_test.dart create mode 100644 packages/example/lib/context_menu_accessibility_spike.dart create mode 100644 packages/example/lib/src/testing/context_menu_accessibility_spike.dart create mode 100644 packages/example/test/context_menu_accessibility_spike_test.dart diff --git a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md new file mode 100644 index 00000000..958d7c95 --- /dev/null +++ b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md @@ -0,0 +1,122 @@ +# Context Menu accessibility spike — D-03 evidence + +Status: **D-03 OPEN; production Context Menu API NOT AUTHORIZED** +Spike date: 2026-07-13 +Fixture: `packages/example/lib/context_menu_accessibility_spike.dart` +Baseline: Link PR #65 head `8084ecf`, Flutter 3.41.2 workspace pin + +This is a disposable V0/V1 evidence record. Automated semantics can establish +tree shape, actions, behavior, focus, and geometry. It cannot establish spoken +output, discoverability, or usability with assistive technology. + +## Automated findings + +| Surface | V0 | V1 | Result | +|---|---|---|---| +| NakedLink | One labeled Link node with tap; no long-press action | Same node/role/label/native actions plus one long-press action | Automated contract passes for this child only | +| SelectableText | One native text-field node with its own long-press action | Native node remains, plus a second unlabeled/valueless role-neutral long-press node | **V1 contract failure: duplicate long-press action and unlabeled action node** | +| Generic focusable row | Labeled row node plus its existing focus node; no long-press action | Row node remains, but the long-press action lands on the existing unlabeled focus node | **V1 contract failure: unlabeled action node** | + +The physical `GestureDetector` is excluded from semantics. V2 was not built or +run because primary SelectableText selection and pre-threshold scrolling both +remain functional under V1; no destructive gesture failure justified the +passive-listener probe. + +Additional automated observations: + +- Secondary click, physical long press, Shift+F10, and the exposed Context Menu + key each request one open through the shared path. +- Secondary pointer-down produces zero requests and zero opens; secondary + pointer-up produces exactly one request and one actual open. +- A touch pointer held past the long-press threshold opens once, but dragging + that same pointer over Rename and releasing produces zero selections and + zero closes; the menu remains open until a separate Escape. The disposable + scaffold therefore does **not** demonstrate Cupertino-style same-gesture + item activation. +- Semantic activation on the Link requests and actually opens exactly once. +- Open requests, actual opens, close requests, actual closes, menu selections, + and primary child activations are counted independently. +- Item selection and outside/Escape dismissal close once. A second secondary + click on the trigger while open is classified outside the menu on pointer + down, closes once, then reopens once on secondary tap-up. Repeated Escape + after close does not produce another close callback. +- Current NakedMenu autofocus lands on its boundary. The test-only + first-enabled probe focuses Delete when Rename is disabled. Focus returns to + a mounted Link and removal of an open trigger does not request stale focus. +- The separate geometry probe clamps at all four edges. Anchor-local + `RawMenuOverlayInfo.position` is preserved. Under scale, naïve + `anchorRect.topLeft + position` drifts; converting the pointer global point + into Overlay coordinates before `OverlayPositioner` remains accurate. +- Scroll offset, translated ancestry, RTL, and 200% text do not change the + resolved invocation point in the fixed geometry probe. + +### Automated SDK matrix + +| Flutter | Focused widget | Flutter-tester integration | Menu/Select/Popover regressions | Result | +|---|---:|---:|---:|---| +| 3.41.0 declared minimum | 15/15 | 4/4 | 67/67 | PASS | +| 3.41.2 workspace pin | 15/15 | 4/4 | 67/67 | PASS; full analyze clean | +| 3.44.6 current stable | 15/15 | 4/4 | 67/67 | PASS | + +The rapid-reopen assertion records the full ordered sequence. Each secondary +pointer interaction contributes one `open-request` then one `actual-open`; the +second click while open contributes one `close-request`/`actual-close` before +that reopen. The final Escape contributes one close pair, and the repeated +Escape contributes none. Totals are exactly 3/3 opens and 3/3 closes, with no +duplicate callback hidden by aggregate counts. + +## Human AT sessions + +No spoken output or browser accessibility result has been inferred from the +automated tests. + +| Target | Operator/date | Build/device | V0 result | V1 result | Status | +|---|---|---|---|---|---| +| VoiceOver on macOS | — | — | — | — | **UNRUN** | +| TalkBack on Android | — | — | — | — | **UNRUN** | +| Chrome accessibility tree + keyboard | — | — | — | — | **UNRUN** | + +### VoiceOver/macOS run sheet — UNRUN + +```sh +fvm flutter run -d macos -t packages/example/lib/context_menu_accessibility_spike.dart +``` + +For Link, SelectableText, and row in both V0 and V1, record verbatim only what +the operator hears: role/name, whether a context-menu action is discoverable, +how it is invoked, whether the menu and first focus target are announced, and +the announcement after Escape or selection. Also run mouse secondary-click, +Shift+F10, and the Context Menu key if macOS exposes it. Edge screenshots: +**UNRUN / not attached**. + +### TalkBack/Android run sheet — UNRUN + +```sh +fvm flutter run -d android -t packages/example/lib/context_menu_accessibility_spike.dart +``` + +For all three children and both variants, record the actual TalkBack local +context/actions UI, spoken role/name/action, one-finger long press, focus after +open, selection exactly once, Escape/back dismissal, and restoration. Repeat +with 200% text and near every viewport edge. Screenshots: **UNRUN / not +attached**. + +### Chrome run sheet — UNRUN + +```sh +fvm flutter run -d chrome -t packages/example/lib/context_menu_accessibility_spike.dart +``` + +Inspect the headed Chrome accessibility tree for node count, role, name, and +actions in V0 and V1. Exercise secondary-click, Shift+F10, and Context Menu key +when reported by the browser/platform. Record actual tree output and keyboard +behavior; do not translate widget-test semantics into browser claims. +Screenshots/log: **UNRUN / not attached**. + +## Gate conclusion + +D-03 remains open because V1 fails the automated SelectableText and row action +node contract, the scaffold fails same-gesture touch selection, and all three +required human AT sessions are UNRUN. This evidence does not authorize exports, +production package code, a public constructor, registry changes, +aggregate-runner changes, docs, changelog, or goldens. diff --git a/packages/example/integration_test/spikes/context_menu_accessibility_spike_test.dart b/packages/example/integration_test/spikes/context_menu_accessibility_spike_test.dart new file mode 100644 index 00000000..2809e8c8 --- /dev/null +++ b/packages/example/integration_test/spikes/context_menu_accessibility_spike_test.dart @@ -0,0 +1,176 @@ +import 'package:example/src/testing/context_menu_accessibility_spike.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('secondary click selects and closes exactly once', ( + tester, + ) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters); + + await tester.tap( + find.byKey(ContextMenuSpikeKeys.triggerLink), + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.tap(find.byKey(ContextMenuSpikeKeys.itemRename)); + await tester.pump(); + + expect(counters.openRequests, 1); + expect(counters.actualOpens, 1); + expect(counters.closeRequests, 1); + expect(counters.actualCloses, 1); + expect(counters.selections, 1); + expect(counters.childActivations, 0); + }); + + testWidgets('Link semantic and keyboard entries each open once', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters); + final link = find.byKey(ContextMenuSpikeKeys.triggerLink); + final node = tester.getSemantics(link); + expect( + node.getSemanticsData().hasAction(SemanticsAction.longPress), + isTrue, + ); + node.owner!.performAction(node.id, SemanticsAction.longPress); + await tester.pump(); + expect(counters.openRequests, 1); + expect(counters.actualOpens, 1); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.f10); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(counters.lastOpenSource, ContextMenuSpikeOpenSource.shiftF10); + expect(counters.openRequests, 2); + expect(counters.actualOpens, 2); + } finally { + semantics.dispose(); + } + }); + + testWidgets('SelectableText V1 records the duplicate semantic action', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ContextMenuSpikeTrigger( + variant: ContextMenuSpikeVariant.v1SemanticLongPress, + childKind: ContextMenuSpikeChildKind.selectableText, + counters: ContextMenuSpikeCounters(), + ), + ), + ), + ); + await tester.pump(); + + final actionNodes = _semanticNodes(tester) + .where( + (node) => + node.getSemanticsData().hasAction(SemanticsAction.longPress), + ) + .toList(); + expect(actionNodes, hasLength(2)); + expect( + actionNodes.where((node) { + final data = node.getSemanticsData(); + return data.label.isEmpty && data.value.isEmpty; + }), + hasLength(1), + ); + } finally { + semantics.dispose(); + } + }); + + testWidgets('bottom-right point geometry clamps into the viewport', ( + tester, + ) async { + final observations = ContextMenuGeometryObservations(); + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + Positioned( + right: 0, + bottom: 0, + child: ContextMenuGeometryProbe(observations: observations), + ), + ], + ), + ), + ); + await tester.pump(); + final anchor = find.byKey(ContextMenuSpikeKeys.geometryAnchor); + await tester.tapAt( + tester.getCenter(anchor), + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + final overlay = tester.getRect( + find.byKey(ContextMenuSpikeKeys.geometryOverlay), + ); + final view = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(overlay.left, greaterThanOrEqualTo(0)); + expect(overlay.top, greaterThanOrEqualTo(0)); + expect(overlay.right, lessThanOrEqualTo(view.width)); + expect(overlay.bottom, lessThanOrEqualTo(view.height)); + expect(observations.openRequests, 1); + expect(observations.actualOpens, 1); + }); +} + +Future _pumpTrigger( + WidgetTester tester, + ContextMenuSpikeCounters counters, +) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ContextMenuSpikeTrigger( + variant: ContextMenuSpikeVariant.v1SemanticLongPress, + childKind: ContextMenuSpikeChildKind.link, + counters: counters, + ), + ), + ), + ); + await tester.pump(); +} + +Iterable _semanticNodes(WidgetTester tester) sync* { + // Flutter 3.41.0 attaches test semantics to this child PipelineOwner rather + // than rootPipelineOwner; the deprecated accessor is required by the spike's + // declared-minimum matrix only. + // ignore: deprecated_member_use + final root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + final pending = [root]; + while (pending.isNotEmpty) { + final node = pending.removeLast(); + yield node; + node.visitChildren((child) { + pending.add(child); + return true; + }); + } +} diff --git a/packages/example/lib/context_menu_accessibility_spike.dart b/packages/example/lib/context_menu_accessibility_spike.dart new file mode 100644 index 00000000..62afc6f9 --- /dev/null +++ b/packages/example/lib/context_menu_accessibility_spike.dart @@ -0,0 +1,185 @@ +import 'package:example/src/testing/context_menu_accessibility_spike.dart'; +import 'package:flutter/material.dart'; + +void main() => runApp(const ContextMenuAccessibilitySpikeApp()); + +/// Standalone direct-target runner for the disposable D-03 evidence spike. +class ContextMenuAccessibilitySpikeApp extends StatelessWidget { + const ContextMenuAccessibilitySpikeApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: _ContextMenuAccessibilitySpikePage(), + ); + } +} + +class _ContextMenuAccessibilitySpikePage extends StatefulWidget { + const _ContextMenuAccessibilitySpikePage(); + + @override + State<_ContextMenuAccessibilitySpikePage> createState() => + _ContextMenuAccessibilitySpikePageState(); +} + +class _ContextMenuAccessibilitySpikePageState + extends State<_ContextMenuAccessibilitySpikePage> { + final _link = ContextMenuSpikeCounters(); + final _selectable = ContextMenuSpikeCounters(); + final _row = ContextMenuSpikeCounters(); + final _geometry = ContextMenuGeometryObservations(); + + ContextMenuSpikeVariant _variant = + ContextMenuSpikeVariant.v1SemanticLongPress; + ContextMenuSpikeInitialFocus _initialFocus = + ContextMenuSpikeInitialFocus.boundary; + bool _enabled = true; + bool _disableFirstItem = false; + bool _rtl = false; + bool _largeText = false; + + @override + void dispose() { + _link.dispose(); + _selectable.dispose(); + _row.dispose(); + _geometry.dispose(); + super.dispose(); + } + + void _reset() { + _link.reset(); + _selectable.reset(); + _row.reset(); + } + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + return MediaQuery( + data: media.copyWith( + textScaler: _largeText + ? const TextScaler.linear(2) + : TextScaler.noScaling, + ), + child: Directionality( + textDirection: _rtl ? TextDirection.rtl : TextDirection.ltr, + child: Scaffold( + appBar: AppBar(title: const Text('Context Menu D-03 spike')), + body: ListView( + key: ContextMenuSpikeKeys.scroll, + padding: const EdgeInsets.all(24), + children: [ + const Text( + 'Disposable evidence fixture — not a production Context Menu API.', + ), + const SizedBox(height: 16), + DropdownButton( + key: ContextMenuSpikeKeys.variant, + value: _variant, + items: ContextMenuSpikeVariant.values + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(value.name), + ), + ) + .toList(), + onChanged: (value) => setState(() => _variant = value!), + ), + DropdownButton( + value: _initialFocus, + items: ContextMenuSpikeInitialFocus.values + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(value.name), + ), + ) + .toList(), + onChanged: (value) => setState(() => _initialFocus = value!), + ), + SwitchListTile( + key: ContextMenuSpikeKeys.disable, + title: const Text('Context trigger enabled'), + value: _enabled, + onChanged: (value) => setState(() => _enabled = value), + ), + CheckboxListTile( + title: const Text('Disable first menu item'), + value: _disableFirstItem, + onChanged: (value) => + setState(() => _disableFirstItem = value!), + ), + CheckboxListTile( + title: const Text('RTL'), + value: _rtl, + onChanged: (value) => setState(() => _rtl = value!), + ), + CheckboxListTile( + title: const Text('200% text'), + value: _largeText, + onChanged: (value) => setState(() => _largeText = value!), + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + key: ContextMenuSpikeKeys.reset, + onPressed: _reset, + child: const Text('Reset counters'), + ), + ), + const Divider(), + const Text('Real NakedLink'), + ContextMenuSpikeTrigger( + variant: _variant, + childKind: ContextMenuSpikeChildKind.link, + counters: _link, + initialFocus: _initialFocus, + enabled: _enabled, + disableFirstItem: _disableFirstItem, + ), + const SizedBox(height: 28), + const Text('Real SelectableText'), + ContextMenuSpikeTrigger( + variant: _variant, + childKind: ContextMenuSpikeChildKind.selectableText, + counters: _selectable, + initialFocus: _initialFocus, + enabled: _enabled, + disableFirstItem: _disableFirstItem, + ), + const SizedBox(height: 28), + const Text('Generic list row'), + ContextMenuSpikeTrigger( + variant: _variant, + childKind: ContextMenuSpikeChildKind.row, + counters: _row, + initialFocus: _initialFocus, + enabled: _enabled, + disableFirstItem: _disableFirstItem, + ), + const SizedBox(height: 28), + const Text('Independent point-geometry probe'), + Align( + alignment: AlignmentDirectional.centerStart, + child: ContextMenuGeometryProbe(observations: _geometry), + ), + const SizedBox(height: 28), + ListenableBuilder( + listenable: Listenable.merge([_link, _selectable, _row]), + builder: (context, child) => SelectableText( + key: ContextMenuSpikeKeys.state, + 'Link: $_link\nSelectable: $_selectable\nRow: $_row', + ), + ), + const SizedBox(height: 400), + ], + ), + ), + ), + ); + } +} diff --git a/packages/example/lib/src/testing/context_menu_accessibility_spike.dart b/packages/example/lib/src/testing/context_menu_accessibility_spike.dart new file mode 100644 index 00000000..015dec6f --- /dev/null +++ b/packages/example/lib/src/testing/context_menu_accessibility_spike.dart @@ -0,0 +1,544 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:naked_ui/naked_ui.dart'; + +/// The two approved trigger-semantics variants for the disposable spike. +enum ContextMenuSpikeVariant { v0PhysicalAndKeyboard, v1SemanticLongPress } + +/// Real child surfaces exercised by the spike. +enum ContextMenuSpikeChildKind { link, selectableText, row } + +/// Initial-focus alternatives measured independently from trigger semantics. +enum ContextMenuSpikeInitialFocus { boundary, firstEnabledItem } + +/// Entry paths recorded without conflating input requests with actual opens. +enum ContextMenuSpikeOpenSource { + secondaryPointer, + physicalLongPress, + shiftF10, + contextMenuKey, + semanticLongPress, +} + +/// Stable fixture keys for automated and manual evidence. +abstract final class ContextMenuSpikeKeys { + static const variant = ValueKey('context-menu-spike.variant'); + static const triggerLink = ValueKey( + 'context-menu-spike.trigger.link', + ); + static const triggerSelectable = ValueKey( + 'context-menu-spike.trigger.selectable', + ); + static const triggerRow = ValueKey('context-menu-spike.trigger.row'); + static const scroll = ValueKey('context-menu-spike.scroll'); + static const menu = ValueKey('context-menu-spike.menu'); + static const itemRename = ValueKey('context-menu-spike.item.rename'); + static const itemDelete = ValueKey('context-menu-spike.item.delete'); + static const state = ValueKey('context-menu-spike.state'); + static const disable = ValueKey('context-menu-spike.disable'); + static const reset = ValueKey('context-menu-spike.reset'); + static const geometryAnchor = ValueKey( + 'context-menu-spike.geometry.anchor', + ); + static const geometryOverlay = ValueKey( + 'context-menu-spike.geometry.overlay', + ); +} + +/// Independent lifecycle and activation observations for a single trigger. +class ContextMenuSpikeCounters extends ChangeNotifier { + int openRequests = 0; + int actualOpens = 0; + int closeRequests = 0; + int actualCloses = 0; + int selections = 0; + int childActivations = 0; + int textSelectionChanges = 0; + + ContextMenuSpikeOpenSource? lastOpenSource; + Offset? lastLocalInvocation; + String? lastSelection; + String? focusedItem; + String? initialFocusObservation; + TextSelection? lastTextSelection; + + final List events = []; + + void recordOpenRequest( + ContextMenuSpikeOpenSource source, + Offset? localPosition, + ) { + openRequests += 1; + lastOpenSource = source; + lastLocalInvocation = localPosition; + events.add('open-request:${source.name}'); + notifyListeners(); + } + + void recordActualOpen() { + actualOpens += 1; + events.add('actual-open'); + notifyListeners(); + } + + void recordCloseRequest() { + closeRequests += 1; + events.add('close-request'); + notifyListeners(); + } + + void recordActualClose() { + actualCloses += 1; + events.add('actual-close'); + notifyListeners(); + } + + void recordSelection(String value) { + selections += 1; + lastSelection = value; + events.add('selection:$value'); + notifyListeners(); + } + + void recordChildActivation() { + childActivations += 1; + events.add('child-activation'); + notifyListeners(); + } + + void recordTextSelection(TextSelection selection, SelectionChangedCause? _) { + textSelectionChanges += 1; + lastTextSelection = selection; + events.add('text-selection:${selection.start}-${selection.end}'); + } + + // Focus state is observed from NakedMenuItem's existing state builders. + // Deliberately avoid notifications while those builders are executing. + void observeItemFocus(String value, bool focused) { + if (focused) { + focusedItem = value; + } else if (focusedItem == value) { + focusedItem = null; + } + } + + void observeInitialFocus(String value) { + initialFocusObservation = value; + events.add('initial-focus:$value'); + } + + void reset() { + openRequests = 0; + actualOpens = 0; + closeRequests = 0; + actualCloses = 0; + selections = 0; + childActivations = 0; + textSelectionChanges = 0; + lastOpenSource = null; + lastLocalInvocation = null; + lastSelection = null; + focusedItem = null; + initialFocusObservation = null; + lastTextSelection = null; + events.clear(); + notifyListeners(); + } + + @override + String toString() => + 'open $openRequests/$actualOpens; ' + 'close $closeRequests/$actualCloses; ' + 'selection $selections; child $childActivations; ' + 'source ${lastOpenSource?.name ?? '-'}; ' + 'focus ${focusedItem ?? initialFocusObservation ?? '-'}'; +} + +class _OpenContextMenuIntent extends Intent { + const _OpenContextMenuIntent(this.source); + + final ContextMenuSpikeOpenSource source; +} + +/// One disposable trigger fixture. The supplied child kind is rendered once. +/// +/// Existing [NakedMenu] item, role, outside-dismissal, and selection behavior +/// is reused behind a semantics-excluded trigger that sits below the real +/// child in hit-test order. This is intentionally not production structure. +class ContextMenuSpikeTrigger extends StatefulWidget { + const ContextMenuSpikeTrigger({ + super.key, + required this.variant, + required this.childKind, + required this.counters, + this.initialFocus = ContextMenuSpikeInitialFocus.boundary, + this.enabled = true, + this.disableFirstItem = false, + }); + + final ContextMenuSpikeVariant variant; + final ContextMenuSpikeChildKind childKind; + final ContextMenuSpikeCounters counters; + final ContextMenuSpikeInitialFocus initialFocus; + final bool enabled; + final bool disableFirstItem; + + @override + State createState() => + _ContextMenuSpikeTriggerState(); +} + +class _ContextMenuSpikeTriggerState extends State { + final MenuController _menuController = MenuController(); + late final FocusNode _triggerFocusNode = FocusNode( + debugLabel: 'context-menu-spike.${widget.childKind.name}', + ); + final GlobalKey _renameFocusProbeKey = GlobalKey( + debugLabel: 'context-menu-spike.rename.focus-probe', + ); + final GlobalKey _deleteFocusProbeKey = GlobalKey( + debugLabel: 'context-menu-spike.delete.focus-probe', + ); + bool _closeInFlight = false; + int _openGeneration = 0; + + @override + void dispose() { + _triggerFocusNode.dispose(); + super.dispose(); + } + + void _requestOpen(ContextMenuSpikeOpenSource source, Offset? localPosition) { + if (!widget.enabled) return; + widget.counters.recordOpenRequest(source, localPosition); + if (_menuController.isOpen) return; + _menuController.open(position: localPosition); + } + + void _handleOpen() { + _closeInFlight = false; + final generation = ++_openGeneration; + widget.counters.recordActualOpen(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + !_menuController.isOpen || + generation != _openGeneration) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + !_menuController.isOpen || + generation != _openGeneration) { + return; + } + _measureOrApplyInitialFocus(); + }); + }); + } + + void _measureOrApplyInitialFocus() { + if (widget.initialFocus == ContextMenuSpikeInitialFocus.boundary) { + widget.counters.observeInitialFocus( + widget.counters.focusedItem ?? 'boundary', + ); + return; + } + + final targetContext = widget.disableFirstItem + ? _deleteFocusProbeKey.currentContext + : _renameFocusProbeKey.currentContext; + if (targetContext == null) { + widget.counters.observeInitialFocus('none'); + return; + } + Focus.of(targetContext).requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_menuController.isOpen) return; + widget.counters.observeInitialFocus( + widget.counters.focusedItem ?? 'none', + ); + }); + } + + void _handleCloseRequest(VoidCallback hide) { + widget.counters.recordCloseRequest(); + if (!_menuController.isOpen || _closeInFlight) return; + _closeInFlight = true; + hide(); + } + + void _handleClose() { + _closeInFlight = false; + final closedGeneration = _openGeneration; + widget.counters.recordActualClose(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + _menuController.isOpen || + closedGeneration != _openGeneration || + _triggerFocusNode.context == null) { + return; + } + _triggerFocusNode.requestFocus(); + }); + } + + Widget _buildRealChild() { + switch (widget.childKind) { + case ContextMenuSpikeChildKind.link: + return NakedLink( + key: ContextMenuSpikeKeys.triggerLink, + linkUrl: Uri.parse('https://example.com/naked-ui'), + focusNode: _triggerFocusNode, + semanticLabel: 'Naked UI documentation', + onPressed: widget.counters.recordChildActivation, + child: const Padding( + padding: EdgeInsets.all(12), + child: Text('Naked UI documentation'), + ), + ); + case ContextMenuSpikeChildKind.selectableText: + return SelectableText( + 'Selectable release notes for Naked UI', + key: ContextMenuSpikeKeys.triggerSelectable, + focusNode: _triggerFocusNode, + onSelectionChanged: widget.counters.recordTextSelection, + ); + case ContextMenuSpikeChildKind.row: + return Focus( + focusNode: _triggerFocusNode, + child: Semantics( + key: ContextMenuSpikeKeys.triggerRow, + container: true, + child: const SizedBox( + width: 240, + height: 48, + child: Row( + children: [ + Icon(Icons.folder_outlined), + SizedBox(width: 8), + Expanded(child: Text('Project Alpha')), + ], + ), + ), + ), + ); + } + } + + Widget _buildMenu() { + return ConstrainedBox( + key: ContextMenuSpikeKeys.menu, + constraints: const BoxConstraints.tightFor(width: 184), + child: ColoredBox( + color: const Color(0xFFF5F5F5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + NakedMenuItem( + key: ContextMenuSpikeKeys.itemRename, + value: 'rename', + enabled: !widget.disableFirstItem, + semanticLabel: 'Rename', + builder: (context, state, child) { + widget.counters.observeItemFocus('rename', state.isFocused); + return Padding( + key: _renameFocusProbeKey, + padding: const EdgeInsets.all(12), + child: child, + ); + }, + child: const Text('Rename'), + ), + NakedMenuItem( + key: ContextMenuSpikeKeys.itemDelete, + value: 'delete', + semanticLabel: 'Delete', + builder: (context, state, child) { + widget.counters.observeItemFocus('delete', state.isFocused); + return Padding( + key: _deleteFocusProbeKey, + padding: const EdgeInsets.all(12), + child: child, + ); + }, + child: const Text('Delete'), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final semanticLongPress = + widget.enabled && + widget.variant == ContextMenuSpikeVariant.v1SemanticLongPress + ? () => _requestOpen(ContextMenuSpikeOpenSource.semanticLongPress, null) + : null; + + final menuScaffold = Positioned.fill( + child: ExcludeFocusTraversal( + child: NakedMenu( + controller: _menuController, + excludeSemantics: true, + consumeOutsideTaps: false, + onOpen: _handleOpen, + onClose: _handleClose, + onCloseRequested: _handleCloseRequest, + onSelected: widget.counters.recordSelection, + overlayBuilder: (context, info) => _buildMenu(), + child: const SizedBox.expand(), + ), + ), + ); + + final trigger = Semantics( + onLongPress: semanticLongPress, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onSecondaryTapUp: widget.enabled + ? (details) => _requestOpen( + ContextMenuSpikeOpenSource.secondaryPointer, + details.localPosition, + ) + : null, + onLongPressStart: widget.enabled + ? (details) => _requestOpen( + ContextMenuSpikeOpenSource.physicalLongPress, + details.localPosition, + ) + : null, + child: Stack( + fit: StackFit.passthrough, + children: [menuScaffold, _buildRealChild()], + ), + ), + ); + return Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.f10, shift: true): + _OpenContextMenuIntent(ContextMenuSpikeOpenSource.shiftF10), + SingleActivator(LogicalKeyboardKey.contextMenu): _OpenContextMenuIntent( + ContextMenuSpikeOpenSource.contextMenuKey, + ), + }, + child: Actions( + actions: >{ + _OpenContextMenuIntent: CallbackAction<_OpenContextMenuIntent>( + onInvoke: (intent) { + _requestOpen(intent.source, null); + return null; + }, + ), + }, + child: trigger, + ), + ); + } +} + +/// Geometry-only observations kept separate from menu semantics and focus. +class ContextMenuGeometryObservations extends ChangeNotifier { + int openRequests = 0; + int actualOpens = 0; + Offset? requestedLocalPosition; + Offset? rawMenuPosition; + Offset? resolvedOverlayPoint; + Offset? naiveAnchorPlusLocalPoint; + Rect? anchorRect; + + void recordRequest(Offset position) { + openRequests += 1; + requestedLocalPosition = position; + notifyListeners(); + } + + void recordOpen() { + actualOpens += 1; + notifyListeners(); + } + + void recordOverlay(RawMenuOverlayInfo info, Offset resolvedPoint) { + rawMenuPosition = info.position; + anchorRect = info.anchorRect; + resolvedOverlayPoint = resolvedPoint; + naiveAnchorPlusLocalPoint = info.position == null + ? null + : info.anchorRect.topLeft + info.position!; + } +} + +/// A point-positioning probe using only RawMenuAnchor and OverlayPositioner. +/// +/// Pointer global coordinates are converted to the target Overlay's coordinate +/// space before opening. The raw anchor-local position is recorded separately +/// so transformed-anchor drift in `anchorRect.topLeft + position` is visible. +class ContextMenuGeometryProbe extends StatefulWidget { + const ContextMenuGeometryProbe({ + super.key, + required this.observations, + this.anchorSize = const Size(120, 56), + this.overlaySize = const Size(184, 88), + }); + + final ContextMenuGeometryObservations observations; + final Size anchorSize; + final Size overlaySize; + + @override + State createState() => + _ContextMenuGeometryProbeState(); +} + +class _ContextMenuGeometryProbeState extends State { + final MenuController _controller = MenuController(); + Offset? _overlayPoint; + + void _openAt(TapUpDetails details) { + final overlayBox = + Overlay.of(context).context.findRenderObject()! as RenderBox; + _overlayPoint = overlayBox.globalToLocal(details.globalPosition); + widget.observations.recordRequest(details.localPosition); + _controller.open(position: details.localPosition); + } + + @override + Widget build(BuildContext context) { + return RawMenuAnchor( + controller: _controller, + consumeOutsideTaps: false, + onOpen: widget.observations.recordOpen, + onOpenRequested: (info, show) => show(), + onCloseRequested: (hide) => hide(), + overlayBuilder: (context, info) { + final resolvedPoint = _overlayPoint ?? info.anchorRect.bottomLeft; + widget.observations.recordOverlay(info, resolvedPoint); + return OverlayPositioner( + targetRect: Rect.fromLTWH(resolvedPoint.dx, resolvedPoint.dy, 0, 0), + positioning: const OverlayPositionConfig( + targetAnchor: Alignment.topLeft, + followerAnchor: Alignment.topLeft, + ), + child: SizedBox.fromSize( + key: ContextMenuSpikeKeys.geometryOverlay, + size: widget.overlaySize, + child: const ColoredBox(color: Color(0xFFE0E0E0)), + ), + ); + }, + child: GestureDetector( + key: ContextMenuSpikeKeys.geometryAnchor, + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onSecondaryTapUp: _openAt, + child: SizedBox.fromSize( + size: widget.anchorSize, + child: const ColoredBox(color: Color(0xFFBDBDBD)), + ), + ), + ); + } +} diff --git a/packages/example/test/context_menu_accessibility_spike_test.dart b/packages/example/test/context_menu_accessibility_spike_test.dart new file mode 100644 index 00000000..24a52e5a --- /dev/null +++ b/packages/example/test/context_menu_accessibility_spike_test.dart @@ -0,0 +1,740 @@ +import 'package:example/src/testing/context_menu_accessibility_spike.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'V1 adds exactly one longPress action without changing the Link node', + (tester) async { + final semantics = tester.ensureSemantics(); + final counters = ContextMenuSpikeCounters(); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ContextMenuSpikeTrigger( + variant: ContextMenuSpikeVariant.v1SemanticLongPress, + childKind: ContextMenuSpikeChildKind.link, + counters: counters, + ), + ), + ), + ); + await tester.pump(); + + final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); + expect(trigger, findsOneWidget); + final data = tester.getSemantics(trigger).getSemanticsData(); + expect(data.flagsCollection.isLink, isTrue); + expect(data.flagsCollection.isButton, isFalse); + expect(data.label, 'Naked UI documentation'); + expect(data.hasAction(SemanticsAction.tap), isTrue); + expect(data.hasAction(SemanticsAction.longPress), isTrue); + + final longPressNodes = _semanticNodes(tester) + .where( + (node) => + node.getSemanticsData().hasAction(SemanticsAction.longPress), + ) + .toList(); + expect(longPressNodes, hasLength(1)); + + final node = tester.getSemantics(trigger); + node.owner!.performAction(node.id, SemanticsAction.longPress); + await tester.pump(); + + expect(counters.openRequests, 1); + expect(counters.actualOpens, 1); + expect(find.byKey(ContextMenuSpikeKeys.menu), findsOneWidget); + semantics.dispose(); + }, + ); + + testWidgets('V0 to V1 records exact node-level contract failures', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + for (final childKind in ContextMenuSpikeChildKind.values) { + await _pumpTrigger( + tester, + counters: ContextMenuSpikeCounters(), + childKind: childKind, + variant: ContextMenuSpikeVariant.v0PhysicalAndKeyboard, + ); + final v0 = _treeSnapshot(tester); + final v0LongPressCount = _semanticNodes(tester) + .where( + (node) => + node.getSemanticsData().hasAction(SemanticsAction.longPress), + ) + .length; + + await _pumpTrigger( + tester, + counters: ContextMenuSpikeCounters(), + childKind: childKind, + variant: ContextMenuSpikeVariant.v1SemanticLongPress, + ); + final actionNodes = _semanticNodes(tester) + .where( + (node) => + node.getSemanticsData().hasAction(SemanticsAction.longPress), + ) + .toList(); + + final v1 = _treeSnapshot(tester); + if (childKind == ContextMenuSpikeChildKind.link) { + expect(v0LongPressCount, 0); + expect(actionNodes, hasLength(1)); + expect(v1, v0, reason: 'Link must keep its single native node'); + expect( + actionNodes.single.getSemanticsData().label, + 'Naked UI documentation', + ); + } else { + final expectedNativeActions = + childKind == ContextMenuSpikeChildKind.selectableText ? 1 : 0; + expect(v0LongPressCount, expectedNativeActions); + expect(actionNodes, hasLength(expectedNativeActions + 1)); + final unlabeledActionNodes = actionNodes.where((node) { + final data = node.getSemanticsData(); + return data.label.isEmpty && data.value.isEmpty; + }).toList(); + expect(unlabeledActionNodes, hasLength(1)); + final actionData = unlabeledActionNodes.single.getSemanticsData(); + expect(actionData.label, isEmpty); + expect(actionData.role, SemanticsRole.none); + expect(actionData.flagsCollection.isButton, isFalse); + if (childKind == ContextMenuSpikeChildKind.selectableText) { + expect(v1, hasLength(v0.length + 1)); + expect( + _treeSnapshot(tester, omitUnlabeledLongPressNode: true), + v0, + reason: 'SelectableText native node must otherwise stay intact', + ); + } else { + expect( + v1, + v0, + reason: 'row must keep its existing focus and label nodes', + ); + } + } + } + semantics.dispose(); + }); + + testWidgets( + 'secondary click opens once and primary Link tap passes through', + (tester) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: counters); + final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); + + await tester.tap(trigger); + await tester.pump(); + expect(counters.childActivations, 1); + expect(counters.openRequests, 0); + + final secondary = await tester.createGesture( + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + final center = tester.getCenter(trigger); + await secondary.addPointer(location: center); + await secondary.down(center); + await tester.pump(); + expect(counters.openRequests, 0); + expect(counters.actualOpens, 0); + + await secondary.up(); + await tester.pump(); + await secondary.removePointer(); + expect(counters.openRequests, 1); + expect(counters.actualOpens, 1); + expect(counters.childActivations, 1); + expect( + counters.lastOpenSource, + ContextMenuSpikeOpenSource.secondaryPointer, + ); + expect(counters.lastLocalInvocation, isNotNull); + }, + ); + + testWidgets( + 'physical long press opens, while a pre-threshold scroll cancels', + (tester) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger( + tester, + counters: counters, + childKind: ContextMenuSpikeChildKind.row, + ); + await tester.longPress(find.byKey(ContextMenuSpikeKeys.triggerRow)); + await tester.pump(); + expect(counters.openRequests, 1); + expect(counters.actualOpens, 1); + expect( + counters.lastOpenSource, + ContextMenuSpikeOpenSource.physicalLongPress, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + final scrollCounters = ContextMenuSpikeCounters(); + await tester.pumpWidget( + MaterialApp( + home: ListView( + key: ContextMenuSpikeKeys.scroll, + children: [ + ContextMenuSpikeTrigger( + variant: ContextMenuSpikeVariant.v1SemanticLongPress, + childKind: ContextMenuSpikeChildKind.row, + counters: scrollCounters, + ), + const SizedBox(height: 1200), + ], + ), + ), + ); + await tester.pump(); + final row = find.byKey(ContextMenuSpikeKeys.triggerRow); + final scrollable = Scrollable.of(tester.element(row)); + await tester.timedDrag( + row, + const Offset(0, -300), + kLongPressTimeout ~/ 4, + ); + await tester.pump(kLongPressTimeout); + + expect(scrollable.position.pixels, greaterThan(0)); + expect(scrollCounters.openRequests, 0); + expect(scrollCounters.actualOpens, 0); + }, + ); + + testWidgets('same long-press gesture cannot activate an inserted item', ( + tester, + ) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger( + tester, + counters: counters, + childKind: ContextMenuSpikeChildKind.row, + ); + final row = find.byKey(ContextMenuSpikeKeys.triggerRow); + final gesture = await tester.startGesture( + tester.getCenter(row), + kind: PointerDeviceKind.touch, + ); + + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + expect(counters.openRequests, 1); + expect(counters.actualOpens, 1); + final item = find.byKey(ContextMenuSpikeKeys.itemRename); + expect(item, findsOneWidget); + + await gesture.moveTo(tester.getCenter(item)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(counters.selections, 0); + expect(counters.closeRequests, 0); + expect(counters.actualCloses, 0); + expect(find.byKey(ContextMenuSpikeKeys.menu), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(counters.closeRequests, 1); + expect(counters.actualCloses, 1); + }); + + testWidgets('primary selectable-text drag remains non-destructive', ( + tester, + ) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger( + tester, + counters: counters, + childKind: ContextMenuSpikeChildKind.selectableText, + ); + final selectable = find.byKey(ContextMenuSpikeKeys.triggerSelectable); + + await tester.drag( + selectable, + const Offset(120, 0), + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + expect(counters.openRequests, 0); + expect(counters.textSelectionChanges, greaterThan(0)); + expect(counters.lastTextSelection!.isCollapsed, isFalse); + }); + + testWidgets( + 'Shift+F10 and Context Menu key clear stale pointer coordinates', + (tester) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: counters); + final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); + await _focusFirst(tester); + + await tester.tap( + trigger, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + expect(counters.lastLocalInvocation, isNotNull); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + await _sendShiftF10(tester); + await tester.pump(); + expect(counters.lastOpenSource, ContextMenuSpikeOpenSource.shiftF10); + expect(counters.lastLocalInvocation, isNull); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); + await tester.pump(); + expect( + counters.lastOpenSource, + ContextMenuSpikeOpenSource.contextMenuKey, + ); + expect(counters.lastLocalInvocation, isNull); + expect(counters.openRequests, 3); + expect(counters.actualOpens, 3); + expect(counters.closeRequests, 2); + expect(counters.actualCloses, 2); + }, + ); + + testWidgets('selection and close lifecycle callbacks are exactly once', ( + tester, + ) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: counters); + final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); + await tester.tap( + trigger, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.tap(find.byKey(ContextMenuSpikeKeys.itemRename)); + await tester.pump(); + + expect(counters.openRequests, 1); + expect(counters.actualOpens, 1); + expect(counters.closeRequests, 1); + expect(counters.actualCloses, 1); + expect(counters.selections, 1); + expect(counters.childActivations, 0); + expect(counters.lastSelection, 'rename'); + expect(counters.events, [ + 'open-request:secondaryPointer', + 'actual-open', + 'selection:rename', + 'close-request', + 'actual-close', + ]); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(counters.closeRequests, 1); + expect(counters.actualCloses, 1); + }); + + testWidgets('outside close and rapid reopen remain idempotent', ( + tester, + ) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: counters); + final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); + + await tester.tap( + trigger, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.tapAt(const Offset(12, 12)); + await tester.pump(); + expect(counters.closeRequests, 1); + expect(counters.actualCloses, 1); + + await tester.tap( + trigger, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + await tester.tap( + trigger, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + expect(counters.openRequests, 3); + expect(counters.actualOpens, 3); + expect(counters.closeRequests, 2); + expect(counters.actualCloses, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(counters.closeRequests, 3); + expect(counters.actualCloses, 3); + expect(counters.events, [ + 'open-request:secondaryPointer', + 'actual-open', + 'close-request', + 'actual-close', + 'open-request:secondaryPointer', + 'actual-open', + 'close-request', + 'actual-close', + 'open-request:secondaryPointer', + 'actual-open', + 'close-request', + 'actual-close', + ]); + }); + + testWidgets('boundary and first-enabled initial-focus axes stay separate', ( + tester, + ) async { + final boundary = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: boundary); + await _focusFirst(tester); + await _sendShiftF10(tester); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(boundary.initialFocusObservation, 'boundary'); + expect(boundary.focusedItem, isNull); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + await tester.pump(); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'context-menu-spike.link', + ); + + final firstEnabled = ContextMenuSpikeCounters(); + await _pumpTrigger( + tester, + counters: firstEnabled, + initialFocus: ContextMenuSpikeInitialFocus.firstEnabledItem, + disableFirstItem: true, + ); + await _focusFirst(tester); + await _sendShiftF10(tester); + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(firstEnabled.initialFocusObservation, 'delete'); + expect(firstEnabled.focusedItem, 'delete'); + }); + + testWidgets('disabled wrapper has no trigger action but Link stays native', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: counters, enabled: false); + final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); + final data = tester.getSemantics(trigger).getSemanticsData(); + expect(data.flagsCollection.isLink, isTrue); + expect(data.hasAction(SemanticsAction.longPress), isFalse); + + await tester.tap( + trigger, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + expect(counters.openRequests, 0); + await tester.tap(trigger); + await tester.pump(); + expect(counters.childActivations, 1); + semantics.dispose(); + }); + + testWidgets('open trigger can be removed without stale focus or callbacks', ( + tester, + ) async { + final counters = ContextMenuSpikeCounters(); + var show = true; + late StateSetter setState; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, update) { + setState = update; + return show + ? ContextMenuSpikeTrigger( + variant: ContextMenuSpikeVariant.v1SemanticLongPress, + childKind: ContextMenuSpikeChildKind.link, + counters: counters, + ) + : const SizedBox(); + }, + ), + ), + ); + await tester.tap( + find.byKey(ContextMenuSpikeKeys.triggerLink), + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + setState(() => show = false); + await tester.pump(); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(find.byKey(ContextMenuSpikeKeys.menu), findsNothing); + expect(counters.actualCloses, lessThanOrEqualTo(1)); + }); + + testWidgets('geometry stays in bounds at all four viewport edges', ( + tester, + ) async { + const anchorSize = Size(80, 48); + const overlaySize = Size(184, 88); + const viewport = Size(800, 600); + final cases = [ + Offset.zero, + Offset(viewport.width - anchorSize.width, 0), + Offset(0, viewport.height - anchorSize.height), + Offset( + viewport.width - anchorSize.width, + viewport.height - anchorSize.height, + ), + ]; + + for (final anchorOffset in cases) { + final observations = ContextMenuGeometryObservations(); + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + Positioned( + left: anchorOffset.dx, + top: anchorOffset.dy, + child: ContextMenuGeometryProbe( + observations: observations, + anchorSize: anchorSize, + overlaySize: overlaySize, + ), + ), + ], + ), + ), + ); + await tester.pump(); + final anchor = find.byKey(ContextMenuSpikeKeys.geometryAnchor); + await tester.tapAt( + tester.getCenter(anchor), + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + final rect = tester.getRect( + find.byKey(ContextMenuSpikeKeys.geometryOverlay), + ); + expect(rect.left, greaterThanOrEqualTo(0)); + expect(rect.top, greaterThanOrEqualTo(0)); + expect(rect.right, lessThanOrEqualTo(viewport.width)); + expect(rect.bottom, lessThanOrEqualTo(viewport.height)); + expect(observations.openRequests, 1); + expect(observations.actualOpens, 1); + } + }); + + testWidgets('geometry converts transformed local input to overlay space', ( + tester, + ) async { + final observations = ContextMenuGeometryObservations(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Transform.scale( + scale: 1.5, + alignment: Alignment.topLeft, + child: ContextMenuGeometryProbe(observations: observations), + ), + ), + ), + ); + await tester.pump(); + final anchor = find.byKey(ContextMenuSpikeKeys.geometryAnchor); + final point = tester.getTopLeft(anchor) + const Offset(75, 30); + await tester.tapAt( + point, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + final overlay = tester.getTopLeft( + find.byKey(ContextMenuSpikeKeys.geometryOverlay), + ); + expect(observations.rawMenuPosition, observations.requestedLocalPosition); + expect(overlay, offsetMoreOrLessEquals(point, epsilon: 0.01)); + expect( + observations.naiveAnchorPlusLocalPoint, + isNot(offsetMoreOrLessEquals(point, epsilon: 1)), + ); + }); + + testWidgets('geometry survives scroll, translation, RTL, and 200% text', ( + tester, + ) async { + final observations = ContextMenuGeometryObservations(); + final scrollController = ScrollController(initialScrollOffset: 120); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: Directionality( + textDirection: TextDirection.rtl, + child: SingleChildScrollView( + controller: scrollController, + child: SizedBox( + height: 1000, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 240), + child: Transform.translate( + offset: const Offset(36, 24), + child: ContextMenuGeometryProbe( + observations: observations, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + final anchor = find.byKey(ContextMenuSpikeKeys.geometryAnchor); + final point = tester.getCenter(anchor); + await tester.tapAt( + point, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + + expect(scrollController.offset, 120); + expect( + tester.getTopLeft(find.byKey(ContextMenuSpikeKeys.geometryOverlay)), + offsetMoreOrLessEquals(point, epsilon: 0.01), + ); + expect(observations.rawMenuPosition, observations.requestedLocalPosition); + }); +} + +Future _pumpTrigger( + WidgetTester tester, { + required ContextMenuSpikeCounters counters, + ContextMenuSpikeVariant variant = ContextMenuSpikeVariant.v1SemanticLongPress, + ContextMenuSpikeChildKind childKind = ContextMenuSpikeChildKind.link, + ContextMenuSpikeInitialFocus initialFocus = + ContextMenuSpikeInitialFocus.boundary, + bool enabled = true, + bool disableFirstItem = false, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ContextMenuSpikeTrigger( + variant: variant, + childKind: childKind, + counters: counters, + initialFocus: initialFocus, + enabled: enabled, + disableFirstItem: disableFirstItem, + ), + ), + ), + ); + await tester.pump(); +} + +Future _focusFirst(WidgetTester tester) async { + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); +} + +Future _sendShiftF10(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.f10); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); +} + +List _treeSnapshot( + WidgetTester tester, { + bool omitUnlabeledLongPressNode = false, +}) => _semanticNodes(tester) + .where((node) { + final data = node.getSemanticsData(); + return !omitUnlabeledLongPressNode || + data.label.isNotEmpty || + data.value.isNotEmpty || + !data.hasAction(SemanticsAction.longPress); + }) + .map((node) { + final data = node.getSemanticsData(); + final actionsWithoutLongPress = + data.actions & ~SemanticsAction.longPress.index; + return [ + data.role, + data.flagsCollection.toString(), + actionsWithoutLongPress, + data.label, + data.value, + data.hint, + data.tooltip, + data.textDirection, + node.rect, + ].join('|'); + }) + .toList(); + +Iterable _semanticNodes(WidgetTester tester) sync* { + // Flutter 3.41.0 attaches test semantics to this child PipelineOwner rather + // than rootPipelineOwner; the deprecated accessor is required by the spike's + // declared-minimum matrix only. + // ignore: deprecated_member_use + final root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + final pending = [root]; + while (pending.isNotEmpty) { + final node = pending.removeLast(); + yield node; + node.visitChildren((child) { + pending.add(child); + return true; + }); + } +} From f79567de4da8d7e15c5c4a1183f9c750970a3f6a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 16:52:36 -0400 Subject: [PATCH 2/6] test: tighten context menu spike evidence --- .../context_menu_accessibility_at_results.md | 56 ++- .../context_menu_accessibility_spike.dart | 21 +- ...context_menu_accessibility_spike_test.dart | 422 +++++++++++------- 3 files changed, 307 insertions(+), 192 deletions(-) diff --git a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md index 958d7c95..f71616c5 100644 --- a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md +++ b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md @@ -6,28 +6,37 @@ Fixture: `packages/example/lib/context_menu_accessibility_spike.dart` Baseline: Link PR #65 head `8084ecf`, Flutter 3.41.2 workspace pin This is a disposable V0/V1 evidence record. Automated semantics can establish -tree shape, actions, behavior, focus, and geometry. It cannot establish spoken -output, discoverability, or usability with assistive technology. +serialized node count/order/properties, selected node flags/actions, behavior, +focus, and geometry. It cannot establish spoken output, discoverability, or +usability with assistive technology. ## Automated findings | Surface | V0 | V1 | Result | |---|---|---|---| -| NakedLink | One labeled Link node with tap; no long-press action | Same node/role/label/native actions plus one long-press action | Automated contract passes for this child only | -| SelectableText | One native text-field node with its own long-press action | Native node remains, plus a second unlabeled/valueless role-neutral long-press node | **V1 contract failure: duplicate long-press action and unlabeled action node** | -| Generic focusable row | Labeled row node plus its existing focus node; no long-press action | Row node remains, but the long-press action lands on the existing unlabeled focus node | **V1 contract failure: unlabeled action node** | +| NakedLink | One labeled Link node with tap; no long-press action | Same serialized node count/order/role/label/native actions plus one long-press action on the labeled Link | Automated contract passes for this child only | +| SelectableText | One node flagged as a text field with its own long-press action | That text-field node retains its native action and serialized properties; a second unlabeled/valueless role-neutral long-press node is added | **V1 contract failure: duplicate long-press action and unlabeled action node** | +| Generic focusable row | Labeled row node plus an unlabeled focusable node; no long-press action | Serialized nodes remain unchanged except that long press lands on the unlabeled role-neutral focusable node | **V1 contract failure: unlabeled action node** | The physical `GestureDetector` is excluded from semantics. V2 was not built or -run because primary SelectableText selection and pre-threshold scrolling both -remain functional under V1; no destructive gesture failure justified the -passive-listener probe. +run. Primary SelectableText mouse selection and pre-threshold scrolling remain +functional, but the crossed child matrix now records a different negative: +SelectableText's native recognizers win both secondary click and physical long +press, so those inputs never reach the wrapper open path. That failure is +evidence against the scaffold, not authorization for a harness workaround or a +V2 passive-listener probe. Additional automated observations: -- Secondary click, physical long press, Shift+F10, and the exposed Context Menu - key each request one open through the shared path. -- Secondary pointer-down produces zero requests and zero opens; secondary - pointer-up produces exactly one request and one actual open. +- On Link and row, secondary pointer-down produces zero requests/opens and + pointer-up produces exactly one request and one actual open. On + SelectableText, secondary down/up produces zero requests/opens and its native + selection callback reports a collapsed selection instead. +- Physical long press requests and opens exactly once on Link and row. On + SelectableText it produces zero requests/opens while the native selection + callback reports a non-collapsed selection. +- On Link, Shift+F10 and the exposed Context Menu key each request one open + through the shared path. - A touch pointer held past the long-press threshold opens once, but dragging that same pointer over Rename and releasing produces zero selections and zero closes; the menu remains open until a separate Escape. The disposable @@ -40,15 +49,19 @@ Additional automated observations: click on the trigger while open is classified outside the menu on pointer down, closes once, then reopens once on secondary tap-up. Repeated Escape after close does not produce another close callback. -- Current NakedMenu autofocus lands on its boundary. The test-only - first-enabled probe focuses Delete when Rename is disabled. Focus returns to - a mounted Link and removal of an open trigger does not request stale focus. +- Current NakedMenu autofocus lands on its boundary: the test directly compares + the menu's nearest Focus node with `FocusManager.instance.primaryFocus`. The + test-only first-enabled probe focuses Delete when Rename is disabled. Focus + returns to a mounted Link and removal of an open trigger does not request + stale focus. - The separate geometry probe clamps at all four edges. Anchor-local `RawMenuOverlayInfo.position` is preserved. Under scale, naïve `anchorRect.topLeft + position` drifts; converting the pointer global point into Overlay coordinates before `OverlayPositioner` remains accurate. -- Scroll offset, translated ancestry, RTL, and 200% text do not change the - resolved invocation point in the fixed geometry probe. +- Scroll offset and translated ancestry preserve point conversion. Ambient RTL + and 200% text do not perturb that conversion in the fixed-size, + direction-neutral probe; this does not exercise direction-sensitive or + text-responsive menu layout. ### Automated SDK matrix @@ -116,7 +129,8 @@ Screenshots/log: **UNRUN / not attached**. ## Gate conclusion D-03 remains open because V1 fails the automated SelectableText and row action -node contract, the scaffold fails same-gesture touch selection, and all three -required human AT sessions are UNRUN. This evidence does not authorize exports, -production package code, a public constructor, registry changes, -aggregate-runner changes, docs, changelog, or goldens. +node contract, SelectableText prevents secondary/physical wrapper opens, the +scaffold fails same-gesture touch selection, and all three required human AT +sessions are UNRUN. This evidence does not authorize exports, production +package code, a public constructor, registry changes, aggregate-runner changes, +docs, changelog, or goldens. diff --git a/packages/example/lib/src/testing/context_menu_accessibility_spike.dart b/packages/example/lib/src/testing/context_menu_accessibility_spike.dart index 015dec6f..1e974699 100644 --- a/packages/example/lib/src/testing/context_menu_accessibility_spike.dart +++ b/packages/example/lib/src/testing/context_menu_accessibility_spike.dart @@ -125,6 +125,7 @@ class ContextMenuSpikeCounters extends ChangeNotifier { void observeInitialFocus(String value) { initialFocusObservation = value; events.add('initial-focus:$value'); + notifyListeners(); } void reset() { @@ -199,6 +200,9 @@ class _ContextMenuSpikeTriggerState extends State { final GlobalKey _deleteFocusProbeKey = GlobalKey( debugLabel: 'context-menu-spike.delete.focus-probe', ); + final GlobalKey _menuFocusProbeKey = GlobalKey( + debugLabel: 'context-menu-spike.menu.focus-probe', + ); bool _closeInFlight = false; int _openGeneration = 0; @@ -231,15 +235,19 @@ class _ContextMenuSpikeTriggerState extends State { generation != _openGeneration) { return; } - _measureOrApplyInitialFocus(); + _measureOrApplyInitialFocus(generation); }); }); } - void _measureOrApplyInitialFocus() { + void _measureOrApplyInitialFocus(int generation) { if (widget.initialFocus == ContextMenuSpikeInitialFocus.boundary) { + final menuContext = _menuFocusProbeKey.currentContext; + final boundaryHasPrimaryFocus = + menuContext != null && + Focus.of(menuContext) == FocusManager.instance.primaryFocus; widget.counters.observeInitialFocus( - widget.counters.focusedItem ?? 'boundary', + boundaryHasPrimaryFocus ? 'boundary' : 'none', ); return; } @@ -253,7 +261,11 @@ class _ContextMenuSpikeTriggerState extends State { } Focus.of(targetContext).requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_menuController.isOpen) return; + if (!mounted || + !_menuController.isOpen || + generation != _openGeneration) { + return; + } widget.counters.observeInitialFocus( widget.counters.focusedItem ?? 'none', ); @@ -330,6 +342,7 @@ class _ContextMenuSpikeTriggerState extends State { key: ContextMenuSpikeKeys.menu, constraints: const BoxConstraints.tightFor(width: 184), child: ColoredBox( + key: _menuFocusProbeKey, color: const Color(0xFFF5F5F5), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/packages/example/test/context_menu_accessibility_spike_test.dart b/packages/example/test/context_menu_accessibility_spike_test.dart index 24a52e5a..9520cbea 100644 --- a/packages/example/test/context_menu_accessibility_spike_test.dart +++ b/packages/example/test/context_menu_accessibility_spike_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show Tristate; + import 'package:example/src/testing/context_menu_accessibility_spike.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -6,10 +8,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets( + _semanticsTestWidgets( 'V1 adds exactly one longPress action without changing the Link node', (tester) async { - final semantics = tester.ensureSemantics(); final counters = ContextMenuSpikeCounters(); await tester.pumpWidget( @@ -49,14 +50,12 @@ void main() { expect(counters.openRequests, 1); expect(counters.actualOpens, 1); expect(find.byKey(ContextMenuSpikeKeys.menu), findsOneWidget); - semantics.dispose(); }, ); - testWidgets('V0 to V1 records exact node-level contract failures', ( + _semanticsTestWidgets('V0 to V1 records exact node-level contract failures', ( tester, ) async { - final semantics = tester.ensureSemantics(); for (final childKind in ContextMenuSpikeChildKind.values) { await _pumpTrigger( tester, @@ -64,13 +63,29 @@ void main() { childKind: childKind, variant: ContextMenuSpikeVariant.v0PhysicalAndKeyboard, ); - final v0 = _treeSnapshot(tester); + final v0 = _serializedSemanticsSnapshot(tester); final v0LongPressCount = _semanticNodes(tester) .where( (node) => node.getSemanticsData().hasAction(SemanticsAction.longPress), ) .length; + String? v0TextFieldSnapshot; + if (childKind == ContextMenuSpikeChildKind.selectableText) { + final textFieldNodes = _semanticNodes(tester) + .where( + (node) => node.getSemanticsData().flagsCollection.isTextField, + ) + .toList(); + expect(textFieldNodes, hasLength(1)); + expect( + textFieldNodes.single.getSemanticsData().hasAction( + SemanticsAction.longPress, + ), + isTrue, + ); + v0TextFieldSnapshot = _semanticNodeSnapshot(textFieldNodes.single); + } await _pumpTrigger( tester, @@ -85,7 +100,7 @@ void main() { ) .toList(); - final v1 = _treeSnapshot(tester); + final v1 = _serializedSemanticsSnapshot(tester); if (childKind == ContextMenuSpikeChildKind.link) { expect(v0LongPressCount, 0); expect(actionNodes, hasLength(1)); @@ -109,13 +124,37 @@ void main() { expect(actionData.role, SemanticsRole.none); expect(actionData.flagsCollection.isButton, isFalse); if (childKind == ContextMenuSpikeChildKind.selectableText) { + final textFieldNodes = _semanticNodes(tester) + .where( + (node) => node.getSemanticsData().flagsCollection.isTextField, + ) + .toList(); + expect(textFieldNodes, hasLength(1)); + expect( + textFieldNodes.single.getSemanticsData().hasAction( + SemanticsAction.longPress, + ), + isTrue, + ); + expect( + _semanticNodeSnapshot(textFieldNodes.single), + v0TextFieldSnapshot, + ); expect(v1, hasLength(v0.length + 1)); expect( - _treeSnapshot(tester, omitUnlabeledLongPressNode: true), + _serializedSemanticsSnapshot( + tester, + omitUnlabeledLongPressNode: true, + ), v0, reason: 'SelectableText native node must otherwise stay intact', ); } else { + expect( + actionData.flagsCollection.isFocused, + isNot(Tristate.none), + reason: 'the row action must be on an existing focusable node', + ); expect( v1, v0, @@ -124,97 +163,110 @@ void main() { } } } - semantics.dispose(); }); testWidgets( - 'secondary click opens once and primary Link tap passes through', + 'secondary click records every child and primary Link tap passes through', (tester) async { - final counters = ContextMenuSpikeCounters(); - await _pumpTrigger(tester, counters: counters); - final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); - - await tester.tap(trigger); - await tester.pump(); - expect(counters.childActivations, 1); - expect(counters.openRequests, 0); - - final secondary = await tester.createGesture( - buttons: kSecondaryButton, - kind: PointerDeviceKind.mouse, - ); - final center = tester.getCenter(trigger); - await secondary.addPointer(location: center); - await secondary.down(center); - await tester.pump(); - expect(counters.openRequests, 0); - expect(counters.actualOpens, 0); + final linkCounters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: linkCounters); + final link = find.byKey(ContextMenuSpikeKeys.triggerLink); - await secondary.up(); + await tester.tap(link); await tester.pump(); - await secondary.removePointer(); - expect(counters.openRequests, 1); - expect(counters.actualOpens, 1); - expect(counters.childActivations, 1); - expect( - counters.lastOpenSource, - ContextMenuSpikeOpenSource.secondaryPointer, - ); - expect(counters.lastLocalInvocation, isNotNull); + expect(linkCounters.childActivations, 1); + expect(linkCounters.openRequests, 0); + + for (final childKind in ContextMenuSpikeChildKind.values) { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: counters, childKind: childKind); + final trigger = _triggerFor(childKind); + final secondary = await tester.createGesture( + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + final center = tester.getCenter(trigger); + await secondary.addPointer(location: center); + await secondary.down(center); + await tester.pump(); + expect(counters.openRequests, 0); + expect(counters.actualOpens, 0); + + await secondary.up(); + await tester.pump(); + await secondary.removePointer(); + + final selectable = + childKind == ContextMenuSpikeChildKind.selectableText; + expect(counters.openRequests, selectable ? 0 : 1); + expect(counters.actualOpens, selectable ? 0 : 1); + expect(counters.childActivations, 0); + expect( + counters.lastOpenSource, + selectable ? isNull : ContextMenuSpikeOpenSource.secondaryPointer, + ); + expect(counters.lastLocalInvocation, selectable ? isNull : isNotNull); + if (selectable) { + expect(counters.textSelectionChanges, greaterThan(0)); + expect(counters.lastTextSelection, isNotNull); + expect(counters.lastTextSelection!.isCollapsed, isTrue); + } + } }, ); - testWidgets( - 'physical long press opens, while a pre-threshold scroll cancels', - (tester) async { + testWidgets('physical long press records every child, while scroll cancels', ( + tester, + ) async { + for (final childKind in ContextMenuSpikeChildKind.values) { final counters = ContextMenuSpikeCounters(); - await _pumpTrigger( - tester, - counters: counters, - childKind: ContextMenuSpikeChildKind.row, - ); - await tester.longPress(find.byKey(ContextMenuSpikeKeys.triggerRow)); + await _pumpTrigger(tester, counters: counters, childKind: childKind); + await tester.longPress(_triggerFor(childKind)); await tester.pump(); - expect(counters.openRequests, 1); - expect(counters.actualOpens, 1); + + final selectable = childKind == ContextMenuSpikeChildKind.selectableText; + expect(counters.openRequests, selectable ? 0 : 1); + expect(counters.actualOpens, selectable ? 0 : 1); expect( counters.lastOpenSource, - ContextMenuSpikeOpenSource.physicalLongPress, + selectable ? isNull : ContextMenuSpikeOpenSource.physicalLongPress, ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(); + if (selectable) { + expect(counters.textSelectionChanges, greaterThan(0)); + expect(counters.lastTextSelection, isNotNull); + expect(counters.lastTextSelection!.isCollapsed, isFalse); + } else { + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + } + } - final scrollCounters = ContextMenuSpikeCounters(); - await tester.pumpWidget( - MaterialApp( - home: ListView( - key: ContextMenuSpikeKeys.scroll, - children: [ - ContextMenuSpikeTrigger( - variant: ContextMenuSpikeVariant.v1SemanticLongPress, - childKind: ContextMenuSpikeChildKind.row, - counters: scrollCounters, - ), - const SizedBox(height: 1200), - ], - ), + final scrollCounters = ContextMenuSpikeCounters(); + await tester.pumpWidget( + MaterialApp( + home: ListView( + key: ContextMenuSpikeKeys.scroll, + children: [ + ContextMenuSpikeTrigger( + variant: ContextMenuSpikeVariant.v1SemanticLongPress, + childKind: ContextMenuSpikeChildKind.row, + counters: scrollCounters, + ), + const SizedBox(height: 1200), + ], ), - ); - await tester.pump(); - final row = find.byKey(ContextMenuSpikeKeys.triggerRow); - final scrollable = Scrollable.of(tester.element(row)); - await tester.timedDrag( - row, - const Offset(0, -300), - kLongPressTimeout ~/ 4, - ); - await tester.pump(kLongPressTimeout); + ), + ); + await tester.pump(); + final row = find.byKey(ContextMenuSpikeKeys.triggerRow); + final scrollable = Scrollable.of(tester.element(row)); + await tester.timedDrag(row, const Offset(0, -300), kLongPressTimeout ~/ 4); + await tester.pump(kLongPressTimeout); - expect(scrollable.position.pixels, greaterThan(0)); - expect(scrollCounters.openRequests, 0); - expect(scrollCounters.actualOpens, 0); - }, - ); + expect(scrollable.position.pixels, greaterThan(0)); + expect(scrollCounters.openRequests, 0); + expect(scrollCounters.actualOpens, 0); + }); testWidgets('same long-press gesture cannot activate an inserted item', ( tester, @@ -375,12 +427,21 @@ void main() { kind: PointerDeviceKind.mouse, ); await tester.pump(); - await tester.tap( - trigger, + final rapidReopen = await tester.createGesture( buttons: kSecondaryButton, kind: PointerDeviceKind.mouse, ); + final triggerCenter = tester.getCenter(trigger); + await rapidReopen.down(triggerCenter); await tester.pump(); + expect(counters.openRequests, 2); + expect(counters.actualOpens, 2); + expect(counters.closeRequests, 2); + expect(counters.actualCloses, 2); + + await rapidReopen.up(); + await tester.pump(); + await rapidReopen.removePointer(); expect(counters.openRequests, 3); expect(counters.actualOpens, 3); expect(counters.closeRequests, 2); @@ -418,6 +479,10 @@ void main() { await tester.pump(); await tester.pump(); await tester.pump(); + final boundaryFocus = Focus.of( + tester.element(find.byKey(ContextMenuSpikeKeys.menu)), + ); + expect(FocusManager.instance.primaryFocus, same(boundaryFocus)); expect(boundary.initialFocusObservation, 'boundary'); expect(boundary.focusedItem, isNull); await tester.sendKeyEvent(LogicalKeyboardKey.escape); @@ -445,29 +510,28 @@ void main() { expect(firstEnabled.focusedItem, 'delete'); }); - testWidgets('disabled wrapper has no trigger action but Link stays native', ( - tester, - ) async { - final semantics = tester.ensureSemantics(); - final counters = ContextMenuSpikeCounters(); - await _pumpTrigger(tester, counters: counters, enabled: false); - final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); - final data = tester.getSemantics(trigger).getSemanticsData(); - expect(data.flagsCollection.isLink, isTrue); - expect(data.hasAction(SemanticsAction.longPress), isFalse); + _semanticsTestWidgets( + 'disabled wrapper has no trigger action but Link stays native', + (tester) async { + final counters = ContextMenuSpikeCounters(); + await _pumpTrigger(tester, counters: counters, enabled: false); + final trigger = find.byKey(ContextMenuSpikeKeys.triggerLink); + final data = tester.getSemantics(trigger).getSemanticsData(); + expect(data.flagsCollection.isLink, isTrue); + expect(data.hasAction(SemanticsAction.longPress), isFalse); - await tester.tap( - trigger, - buttons: kSecondaryButton, - kind: PointerDeviceKind.mouse, - ); - await tester.pump(); - expect(counters.openRequests, 0); - await tester.tap(trigger); - await tester.pump(); - expect(counters.childActivations, 1); - semantics.dispose(); - }); + await tester.tap( + trigger, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + expect(counters.openRequests, 0); + await tester.tap(trigger); + await tester.pump(); + expect(counters.childActivations, 1); + }, + ); testWidgets('open trigger can be removed without stale focus or callbacks', ( tester, @@ -596,33 +660,34 @@ void main() { ); }); - testWidgets('geometry survives scroll, translation, RTL, and 200% text', ( - tester, - ) async { - final observations = ContextMenuGeometryObservations(); - final scrollController = ScrollController(initialScrollOffset: 120); - addTearDown(scrollController.dispose); - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: const TextScaler.linear(2)), - child: Directionality( - textDirection: TextDirection.rtl, - child: SingleChildScrollView( - controller: scrollController, - child: SizedBox( - height: 1000, - child: Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.only(top: 240), - child: Transform.translate( - offset: const Offset(36, 24), - child: ContextMenuGeometryProbe( - observations: observations, + testWidgets( + 'point conversion survives scroll/translation with ambient RTL/text scale', + (tester) async { + final observations = ContextMenuGeometryObservations(); + final scrollController = ScrollController(initialScrollOffset: 120); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: Directionality( + textDirection: TextDirection.rtl, + child: SingleChildScrollView( + controller: scrollController, + child: SizedBox( + height: 1000, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 240), + child: Transform.translate( + offset: const Offset(36, 24), + child: ContextMenuGeometryProbe( + observations: observations, + ), ), ), ), @@ -632,24 +697,35 @@ void main() { ), ), ), - ), - ); - await tester.pump(); - final anchor = find.byKey(ContextMenuSpikeKeys.geometryAnchor); - final point = tester.getCenter(anchor); - await tester.tapAt( - point, - buttons: kSecondaryButton, - kind: PointerDeviceKind.mouse, - ); - await tester.pump(); + ); + await tester.pump(); + final anchor = find.byKey(ContextMenuSpikeKeys.geometryAnchor); + final point = tester.getCenter(anchor); + await tester.tapAt( + point, + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); - expect(scrollController.offset, 120); - expect( - tester.getTopLeft(find.byKey(ContextMenuSpikeKeys.geometryOverlay)), - offsetMoreOrLessEquals(point, epsilon: 0.01), - ); - expect(observations.rawMenuPosition, observations.requestedLocalPosition); + expect(scrollController.offset, 120); + expect( + tester.getTopLeft(find.byKey(ContextMenuSpikeKeys.geometryOverlay)), + offsetMoreOrLessEquals(point, epsilon: 0.01), + ); + expect(observations.rawMenuPosition, observations.requestedLocalPosition); + }, + ); +} + +void _semanticsTestWidgets(String description, WidgetTesterCallback callback) { + testWidgets(description, (tester) async { + final semantics = tester.ensureSemantics(); + try { + await callback(tester); + } finally { + semantics.dispose(); + } }); } @@ -693,7 +769,17 @@ Future _sendShiftF10(WidgetTester tester) async { await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); } -List _treeSnapshot( +Finder _triggerFor(ContextMenuSpikeChildKind childKind) => switch (childKind) { + ContextMenuSpikeChildKind.link => find.byKey( + ContextMenuSpikeKeys.triggerLink, + ), + ContextMenuSpikeChildKind.selectableText => find.byKey( + ContextMenuSpikeKeys.triggerSelectable, + ), + ContextMenuSpikeChildKind.row => find.byKey(ContextMenuSpikeKeys.triggerRow), +}; + +List _serializedSemanticsSnapshot( WidgetTester tester, { bool omitUnlabeledLongPressNode = false, }) => _semanticNodes(tester) @@ -704,24 +790,26 @@ List _treeSnapshot( data.value.isNotEmpty || !data.hasAction(SemanticsAction.longPress); }) - .map((node) { - final data = node.getSemanticsData(); - final actionsWithoutLongPress = - data.actions & ~SemanticsAction.longPress.index; - return [ - data.role, - data.flagsCollection.toString(), - actionsWithoutLongPress, - data.label, - data.value, - data.hint, - data.tooltip, - data.textDirection, - node.rect, - ].join('|'); - }) + .map(_semanticNodeSnapshot) .toList(); +String _semanticNodeSnapshot(SemanticsNode node) { + final data = node.getSemanticsData(); + final actionsWithoutLongPress = + data.actions & ~SemanticsAction.longPress.index; + return [ + data.role, + data.flagsCollection.toString(), + actionsWithoutLongPress, + data.label, + data.value, + data.hint, + data.tooltip, + data.textDirection, + node.rect, + ].join('|'); +} + Iterable _semanticNodes(WidgetTester tester) sync* { // Flutter 3.41.0 attaches test semantics to this child PipelineOwner rather // than rootPipelineOwner; the deprecated accessor is required by the spike's From 059183aa4e2e5d1048b6350f27973d10fbe79f3b Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 17:05:41 -0400 Subject: [PATCH 3/6] docs: record context menu target runs --- .../context_menu_accessibility_at_results.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md index f71616c5..7fbdc48a 100644 --- a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md +++ b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md @@ -71,6 +71,20 @@ Additional automated observations: | 3.41.2 workspace pin | 15/15 | 4/4 | 67/67 | PASS; full analyze clean | | 3.44.6 current stable | 15/15 | 4/4 | 67/67 | PASS | +### Automated real-target execution — 2026-07-13 + +These runs used the unchanged committed spike integration runner from the +`packages/example` directory with pinned Flutter 3.41.2. + +| Target | Invocation | Outcome | Exact evidence | +|---|---|---|---| +| macOS 26.5.2 (25F84) | `fvm flutter test integration_test/spikes/context_menu_accessibility_spike_test.dart -d macos` | **PASS — 4/4, exit 0** | The debug app built and all four tests passed. Flutter logged `Failed to foreground app; open returned 1`, but that warning did not prevent the test protocol from completing. | +| Chrome 150.0.7871.115, headless through matching ChromeDriver | `fvm flutter drive --driver=test_driver/integration_test_behavior.dart --target=integration_test/spikes/context_menu_accessibility_spike_test.dart -d chrome` | **INFRASTRUCTURE INCOMPLETE — not a clean target pass** | Chrome connected and the runner printed all four named tests followed by `00:00 +5: All tests passed!`. The host drive process then remained silent and idle for more than six minutes during result/teardown, so it was interrupted with exit 130. No assertion failed. | + +These are automated runner outcomes only. They do not establish spoken output, +assistive-technology discoverability, or the Chrome accessibility tree; every +human AT session below remains UNRUN. + The rapid-reopen assertion records the full ordered sequence. Each secondary pointer interaction contributes one `open-request` then one `actual-open`; the second click while open contributes one `close-request`/`actual-close` before From 6ee81da0ee20971139d0db744c41c03c30d5db4a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 17:10:27 -0400 Subject: [PATCH 4/6] docs: refresh context menu spike baseline --- .../spikes/context_menu_accessibility_at_results.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md index 7fbdc48a..518e4242 100644 --- a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md +++ b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md @@ -3,7 +3,7 @@ Status: **D-03 OPEN; production Context Menu API NOT AUTHORIZED** Spike date: 2026-07-13 Fixture: `packages/example/lib/context_menu_accessibility_spike.dart` -Baseline: Link PR #65 head `8084ecf`, Flutter 3.41.2 workspace pin +Baseline: Link PR #65 head `52d9c97`, Flutter 3.41.2 workspace pin This is a disposable V0/V1 evidence record. Automated semantics can establish serialized node count/order/properties, selected node flags/actions, behavior, From d0918c3276f2346360d225d07ba6134bca206ba5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 17:12:52 -0400 Subject: [PATCH 5/6] test: adapt context menu spike to Link activation --- .../context_menu_accessibility_spike.dart | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/example/lib/src/testing/context_menu_accessibility_spike.dart b/packages/example/lib/src/testing/context_menu_accessibility_spike.dart index 1e974699..15cee0e8 100644 --- a/packages/example/lib/src/testing/context_menu_accessibility_spike.dart +++ b/packages/example/lib/src/testing/context_menu_accessibility_spike.dart @@ -297,15 +297,18 @@ class _ContextMenuSpikeTriggerState extends State { Widget _buildRealChild() { switch (widget.childKind) { case ContextMenuSpikeChildKind.link: - return NakedLink( - key: ContextMenuSpikeKeys.triggerLink, - linkUrl: Uri.parse('https://example.com/naked-ui'), - focusNode: _triggerFocusNode, - semanticLabel: 'Naked UI documentation', - onPressed: widget.counters.recordChildActivation, - child: const Padding( - padding: EdgeInsets.all(12), - child: Text('Naked UI documentation'), + return NakedLinkResolver( + resolve: (_, _) => NakedLinkResolution.handled, + child: NakedLink( + key: ContextMenuSpikeKeys.triggerLink, + linkUrl: Uri.parse('https://example.com/naked-ui'), + focusNode: _triggerFocusNode, + semanticLabel: 'Naked UI documentation', + onActivated: (_) => widget.counters.recordChildActivation(), + child: const Padding( + padding: EdgeInsets.all(12), + child: Text('Naked UI documentation'), + ), ), ); case ContextMenuSpikeChildKind.selectableText: From 2aca8d64f208e3499dc3a6102f95aff872e6116e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 17:16:34 -0400 Subject: [PATCH 6/6] docs: refresh context menu target evidence --- .../spikes/context_menu_accessibility_at_results.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md index 518e4242..6ee1fb37 100644 --- a/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md +++ b/packages/example/integration_test/spikes/context_menu_accessibility_at_results.md @@ -79,7 +79,7 @@ These runs used the unchanged committed spike integration runner from the | Target | Invocation | Outcome | Exact evidence | |---|---|---|---| | macOS 26.5.2 (25F84) | `fvm flutter test integration_test/spikes/context_menu_accessibility_spike_test.dart -d macos` | **PASS — 4/4, exit 0** | The debug app built and all four tests passed. Flutter logged `Failed to foreground app; open returned 1`, but that warning did not prevent the test protocol from completing. | -| Chrome 150.0.7871.115, headless through matching ChromeDriver | `fvm flutter drive --driver=test_driver/integration_test_behavior.dart --target=integration_test/spikes/context_menu_accessibility_spike_test.dart -d chrome` | **INFRASTRUCTURE INCOMPLETE — not a clean target pass** | Chrome connected and the runner printed all four named tests followed by `00:00 +5: All tests passed!`. The host drive process then remained silent and idle for more than six minutes during result/teardown, so it was interrupted with exit 130. No assertion failed. | +| Chrome 150.0.7871.115, headless through matching ChromeDriver | `fvm flutter drive --timeout=90 --driver=test_driver/integration_test_behavior.dart --target=integration_test/spikes/context_menu_accessibility_spike_test.dart -d chrome` | **INFRASTRUCTURE INCOMPLETE — not a clean target pass** | Chrome connected and the runner printed all four named tests followed by `00:00 +5: All tests passed!`. The host drive process then remained silent and idle through the explicit 90-second timeout during result/teardown; Flutter did not terminate it, so it was interrupted with exit 130. No assertion failed. | These are automated runner outcomes only. They do not establish spoken output, assistive-technology discoverability, or the Chrome accessibility tree; every