diff --git a/docs/README.md b/docs/README.md index 62389f2..0526a81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ verified in their own repositories. ## Current compatibility -- package version: `0.4.11`; +- package version: `0.4.12`; - session JSON schema: `7`; - fingerprint schema: `6`; - minimum Dart SDK: `3.9.2`; diff --git a/docs/integration/production-replay-acceptance.md b/docs/integration/production-replay-acceptance.md index dfd550e..316e0d7 100644 --- a/docs/integration/production-replay-acceptance.md +++ b/docs/integration/production-replay-acceptance.md @@ -47,6 +47,10 @@ Do not start the production cohort until every item is true: - all behavioral replay-correctness PRs are merged to `main`; - the navigation and interaction race matrix passes on the merged commit; +- the modal visual matrix, programmatic navigation matrix, navigation-origin + contract, and tap coordinate transform suites pass; +- the production dashboard deployment consumes `captureCoordinate` (canonical + marker projection) before Blend canary sessions rely on it; - `flutter analyze` and the complete `packages/tugboat` test suite pass; - `packages/tugboat/pubspec.yaml` and `packages/tugboat/lib/src/sdk_version.dart` contain the same new version; diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index 5662517..8f9654d 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -1,7 +1,19 @@ -## Unreleased +## 0.4.12 ### Added +- **Navigator and route-instance ownership** — every observed Navigator gets a + session-local opaque `navigatorId`; every pushed route gets a + `routeInstanceId`. Stacked anonymous modals stay distinguishable. + Install nested observers with `TugboatReplay.createNavigatorObserver()`. +- **Navigation origin contract** — `route_change` events carry + `navigationOrigin` (`interaction` | `automatic_or_unknown`) and optional + `causeEventId`. Only observer-time single-use pending-interaction claims can + bind a tap; timer/auth redirects never fabricate causality. +- **Versioned `captureCoordinate`** — taps retain legacy global `x`/`y` and add + a boundary-local / normalized / raster transform bound to the before-frame. + Outside-boundary and generation-mismatch cases emit an unavailable reason + instead of clamping. - **Replay coherence characterization harness** — deterministic, advanceable scheduler/capture test seams (`debugNow`, `debugDelay`, `debugExecuteCapture`, `debugSeedFrame`) plus reusable helpers that reproduce known navigation/frame @@ -38,6 +50,15 @@ ### Changed +- **Tap/navigation causality fence** — tap settlement joins a route-capture + barrier only when that route explicitly claimed the same tap event. + Automatic redirects that overlap settlement, including successors of a + tap-caused route, remain independent and cannot donate their frame or route + event ID to the tap. +- **Frame-owned coordinate geometry** — frame provenance now retains the + capture boundary's logical rect and transform generation. Resizes, rotations, + and inset changes invalidate stale before-frame attachment and emit + `generation_mismatch` instead of projecting a current tap onto older pixels. - **Replay capture contract documentation** — documents the implemented route/frame attribution invariant separately from the still-open production acceptance gaps for rapid modal chains, automatic navigation, and playback tap-coordinate diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 1552b07..a833afb 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -5,7 +5,7 @@ checkpoints around meaningful interactions, compact structural anchors, route transitions, scrolling evidence, and optional viewport semantic maps. Capture can be sent to the local exploration WebSocket, the HTTP collector, or both. -The current package version is `0.4.11`. Session JSON uses schema version `7` +The current package version is `0.4.12`. Session JSON uses schema version `7` (readers still accept `6`), and structural fingerprints use fingerprint schema version `6`. @@ -39,8 +39,17 @@ MaterialApp( ``` Without `TugboatReplay.navigatorObserver`, pointer and scroll capture still -work, but route-change events and route-backed anchors are incomplete. Without -`wrapApp`, no capture controller, repaint boundary, or input/scroll listener is +work, but route-change events and route-backed anchors are incomplete. Nested +Navigators that must be attributed need their own observer instance: + +```dart +Navigator( + observers: [TugboatReplay.createNavigatorObserver()], + // ... +); +``` + +Without `wrapApp`, no capture controller, repaint boundary, or input/scroll listener is installed. ### Navigation and overlays diff --git a/packages/tugboat/lib/src/collector_mapper.dart b/packages/tugboat/lib/src/collector_mapper.dart index ea5a7c5..a8726ad 100644 --- a/packages/tugboat/lib/src/collector_mapper.dart +++ b/packages/tugboat/lib/src/collector_mapper.dart @@ -40,7 +40,9 @@ Map mapTugboatEventToCollectorEvent({ } /// Immutable build identity required for Context Graph matching. -Map collectorEventBuildIdentity(TugboatCollectorConfig config) { +Map collectorEventBuildIdentity( + TugboatCollectorConfig config, +) { return { 'appId': config.appInfo.appId, 'platform': config.deviceInfo.platform, diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index acc0309..392521a 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -8,6 +8,7 @@ import 'anchors.dart'; import 'capture_profile.dart'; import 'capture_sink.dart'; import 'collector_http_sink.dart'; +import 'coordinate_space.dart'; import 'debug_logging.dart'; import 'exploration_sink.dart'; import 'health.dart'; @@ -34,6 +35,7 @@ class _PendingTap { required this.beforeFrame, required this.startPosition, required this.startedAtMs, + required this.claim, }); final String eventId; @@ -42,9 +44,33 @@ class _PendingTap { final String? beforeFrame; final Offset startPosition; final int startedAtMs; + final _PendingInteractionClaim claim; bool suppressSettle = false; } +/// Immutable, single-use proof that a route observation may cite a tap cause. +class _PendingInteractionClaim { + _PendingInteractionClaim({ + required this.tapEventId, + required this.pointerId, + required this.captureSessionId, + required this.navigatorId, + required this.routeInstanceId, + required this.pointerGeneration, + }); + + final String tapEventId; + final int pointerId; + final String? captureSessionId; + final String? navigatorId; + final String? routeInstanceId; + final int pointerGeneration; + bool claimed = false; + bool cancelled = false; + + bool get isEligible => !claimed && !cancelled; +} + class _PointerGestureState { _PointerGestureState({required this.tapEventId}); @@ -260,6 +286,11 @@ class _CaptureRequestContext { required this.trigger, required this.requestedAtMs, required this.stateAnchor, + this.navigatorId, + this.routeInstanceId, + this.visualObservationGeneration, + this.boundaryLogicalRect, + this.boundaryTransformGeneration = 0, }); final String? captureSessionId; @@ -268,13 +299,28 @@ class _CaptureRequestContext { final TugboatFrameTrigger trigger; final int requestedAtMs; final TugboatStateAnchor? stateAnchor; + final String? navigatorId; + final String? routeInstanceId; + final int? visualObservationGeneration; + final Rect? boundaryLogicalRect; + final int boundaryTransformGeneration; String? get stateSignature => stateAnchor?.signature; bool compatibleWith(_CaptureRequestContext other) => captureSessionId == other.captureSessionId && routeEpoch == other.routeEpoch && - route == other.route; + route == other.route && + navigatorId == other.navigatorId && + routeInstanceId == other.routeInstanceId && + boundaryTransformGeneration == other.boundaryTransformGeneration; + + bool surfaceCompatibleWith(_CaptureRequestContext other) => + captureSessionId == other.captureSessionId && + routeEpoch == other.routeEpoch && + route == other.route && + navigatorId == other.navigatorId && + routeInstanceId == other.routeInstanceId; _CaptureRequestContext withTrigger(TugboatFrameTrigger value) => _CaptureRequestContext( @@ -284,7 +330,29 @@ class _CaptureRequestContext { trigger: value, requestedAtMs: requestedAtMs, stateAnchor: stateAnchor, + navigatorId: navigatorId, + routeInstanceId: routeInstanceId, + visualObservationGeneration: visualObservationGeneration, + boundaryLogicalRect: boundaryLogicalRect, + boundaryTransformGeneration: boundaryTransformGeneration, ); + + _CaptureRequestContext withBoundaryTransform({ + required Rect logicalRect, + required int generation, + }) => _CaptureRequestContext( + captureSessionId: captureSessionId, + routeEpoch: routeEpoch, + route: route, + trigger: trigger, + requestedAtMs: requestedAtMs, + stateAnchor: stateAnchor, + navigatorId: navigatorId, + routeInstanceId: routeInstanceId, + visualObservationGeneration: visualObservationGeneration, + boundaryLogicalRect: logicalRect, + boundaryTransformGeneration: generation, + ); } class _FrameProvenance { @@ -311,6 +379,18 @@ class _FrameProvenance { 'captureSessionId': context.captureSessionId, 'routeEpoch': context.routeEpoch, 'route': context.route, + if (context.navigatorId != null) 'navigatorId': context.navigatorId, + if (context.routeInstanceId != null) + 'routeInstanceId': context.routeInstanceId, + if (context.visualObservationGeneration != null) + 'visualObservationGeneration': context.visualObservationGeneration, + if (context.boundaryLogicalRect != null) ...{ + 'boundaryOriginX': context.boundaryLogicalRect!.left, + 'boundaryOriginY': context.boundaryLogicalRect!.top, + 'boundaryWidth': context.boundaryLogicalRect!.width, + 'boundaryHeight': context.boundaryLogicalRect!.height, + }, + 'boundaryTransformGeneration': context.boundaryTransformGeneration, 'trigger': context.trigger.name, 'requestedAtMs': context.requestedAtMs, 'completedAtMs': completedAtMs, @@ -360,11 +440,13 @@ class _RouteTransition { required this.kind, required this.routeName, required this.transitionDuration, + this.overlayKind = 'page', }); final _RouteNavigationKind kind; final String? routeName; final Duration transitionDuration; + final String overlayKind; } /// A resolved, visible navigation: what to record and how to update @@ -375,12 +457,141 @@ class _VisibleRouteChange { required this.destinationRoute, required this.navigation, required this.updatesRoute, + this.navigatorId, + this.parentNavigatorId, + this.routeInstanceId, + this.fromRouteInstanceId, + this.stackRevision = 0, + this.overlayKind = 'page', + this.visualObservationGeneration = 0, + this.navigationOrigin = 'automatic_or_unknown', + this.causeEventId, }); final String? previousRoute; final String? destinationRoute; final String navigation; final bool updatesRoute; + final String? navigatorId; + final String? parentNavigatorId; + final String? routeInstanceId; + final String? fromRouteInstanceId; + final int stackRevision; + final String overlayKind; + final int visualObservationGeneration; + final String navigationOrigin; + final String? causeEventId; + + Map ownershipData() => { + if (navigatorId != null) 'navigatorId': navigatorId, + if (parentNavigatorId != null) 'parentNavigatorId': parentNavigatorId, + if (routeInstanceId != null) 'routeInstanceId': routeInstanceId, + if (fromRouteInstanceId != null) 'fromRouteInstanceId': fromRouteInstanceId, + 'stackRevision': stackRevision, + 'overlayKind': overlayKind, + 'visualObservationGeneration': visualObservationGeneration, + 'navigationOrigin': navigationOrigin, + if (causeEventId != null) 'causeEventId': causeEventId, + }; +} + +/// Session-local opaque navigator and route-instance ownership. +class _NavigatorSurfaceRegistry { + final Expando _routeInstanceIds = Expando( + 'tugboat-route-instance', + ); + final Map _navigatorIds = {}; + final Map> _stacks = >{}; + final Map _parentByNavigator = {}; + int _navigatorSeq = 0; + int _routeSeq = 0; + + void clear() { + _navigatorIds.clear(); + _stacks.clear(); + _parentByNavigator.clear(); + _navigatorSeq = 0; + _routeSeq = 0; + } + + String idForNavigator(NavigatorState navigator) { + return _navigatorIds.putIfAbsent(navigator, () { + final id = 'nav-$_navigatorSeq'; + _navigatorSeq++; + _stacks.putIfAbsent(id, () => []); + final parentState = _findParentNavigator(navigator); + _parentByNavigator[id] = parentState == null + ? null + : idForNavigator(parentState); + return id; + }); + } + + String? parentOf(String navigatorId) => _parentByNavigator[navigatorId]; + + String idForRoute(Route route) { + final existing = _routeInstanceIds[route]; + if (existing != null) return existing; + final id = 'route-$_routeSeq'; + _routeSeq++; + _routeInstanceIds[route] = id; + return id; + } + + String? peekRouteId(Route? route) => + route == null ? null : _routeInstanceIds[route]; + + List stackFor(String navigatorId) => + _stacks.putIfAbsent(navigatorId, () => []); + + int push(String navigatorId, String routeInstanceId) { + final stack = stackFor(navigatorId); + stack.add(routeInstanceId); + return stack.length; + } + + int replaceTop(String navigatorId, String routeInstanceId) { + final stack = stackFor(navigatorId); + if (stack.isEmpty) { + stack.add(routeInstanceId); + } else { + stack[stack.length - 1] = routeInstanceId; + } + return stack.length; + } + + int pop(String navigatorId, {String? departingInstanceId}) { + final stack = stackFor(navigatorId); + if (departingInstanceId != null) { + final index = stack.lastIndexOf(departingInstanceId); + if (index >= 0) { + stack.removeAt(index); + return stack.length; + } + } + if (stack.isNotEmpty) stack.removeLast(); + return stack.length; + } + + String? top(String navigatorId) { + final stack = stackFor(navigatorId); + return stack.isEmpty ? null : stack.last; + } + + static NavigatorState? _findParentNavigator(NavigatorState navigator) { + NavigatorState? parent; + navigator.context.visitAncestorElements((element) { + if (element is StatefulElement && element.state is NavigatorState) { + final state = element.state as NavigatorState; + if (!identical(state, navigator)) { + parent = state; + return false; + } + } + return true; + }); + return parent; + } } /// The terminal state of a private route-capture barrier. @@ -599,9 +810,17 @@ class TugboatReplayController extends ChangeNotifier { int _id = 0; String? _currentRoute; + String? _currentNavigatorId; + String? _currentRouteInstanceId; + int _visualObservationGeneration = 0; + int _boundaryTransformGeneration = 0; + Rect? _lastObservedBoundaryRect; + int _pointerGeneration = 0; + final _NavigatorSurfaceRegistry _surfaces = _NavigatorSurfaceRegistry(); TugboatStateAnchor? _currentStateAnchor; String? _latestFrameId; final Map _pendingTaps = {}; + final Map _releasedInteractionClaims = {}; final Map _hashToFrameId = {}; final Map _frameProvenance = {}; final Map _frameReuseObservations = {}; @@ -624,8 +843,20 @@ class TugboatReplayController extends ChangeNotifier { bool _captureLifecycleActive = true; int _captureLifecycleEpoch = 0; int _routeEpoch = 0; - _RouteCaptureWork? _activeRouteCapture; + final Map _activeRouteCaptures = + {}; + String? _latestRouteCaptureKey; final Set<_TapSettleWork> _activeTapSettles = <_TapSettleWork>{}; + + /// Most recently started route-capture work (any Navigator). + _RouteCaptureWork? get _activeRouteCapture { + final key = _latestRouteCaptureKey; + if (key == null) return null; + return _activeRouteCaptures[key]; + } + + static String _routeCaptureKey(String? navigatorId) => navigatorId ?? ''; + final Map _scrollTrackers = {}; final Map _activeGestures = {}; String? _lastCapturedStateSignature; @@ -767,7 +998,7 @@ class TugboatReplayController extends ChangeNotifier { int get debugRouteEpoch => _routeEpoch; @visibleForTesting - bool get debugRouteCapturePending => _activeRouteCapture != null; + bool get debugRouteCapturePending => _activeRouteCaptures.isNotEmpty; @visibleForTesting bool get debugCaptureInFlight => _captureInFlight; @@ -1137,9 +1368,17 @@ class TugboatReplayController extends ChangeNotifier { explorationRunId: config.explorationRunId, ); _currentRoute = null; + _currentNavigatorId = null; + _currentRouteInstanceId = null; + _visualObservationGeneration = 0; + _boundaryTransformGeneration = 0; + _lastObservedBoundaryRect = null; + _pointerGeneration = 0; + _surfaces.clear(); _currentStateAnchor = null; _latestFrameId = null; _pendingTaps.clear(); + _releasedInteractionClaims.clear(); _scrollTrackers.clear(); _activeGestures.clear(); _hashToFrameId.clear(); @@ -1251,6 +1490,7 @@ class TugboatReplayController extends ChangeNotifier { _CaptureRequestContext _captureContext(TugboatFrameTrigger trigger) { final anchor = _currentStateAnchor; + final boundary = _observeCurrentBoundaryTransform(); return _CaptureRequestContext( captureSessionId: _session?.id, routeEpoch: _routeEpoch, @@ -1260,9 +1500,42 @@ class TugboatReplayController extends ChangeNotifier { trigger: trigger, requestedAtMs: atMs, stateAnchor: _snapshotStateAnchor(anchor), + navigatorId: _currentNavigatorId, + routeInstanceId: _currentRouteInstanceId, + visualObservationGeneration: _visualObservationGeneration, + boundaryLogicalRect: boundary.rect, + boundaryTransformGeneration: boundary.generation, ); } + ({Rect? rect, int generation}) _observeCurrentBoundaryTransform() { + final renderObject = _boundaryKey.currentContext?.findRenderObject(); + Rect? rect; + if (renderObject is RenderBox && renderObject.hasSize) { + rect = renderObject.localToGlobal(Offset.zero) & renderObject.size; + } + return _observeBoundaryTransform(rect); + } + + ({Rect? rect, int generation}) _observeBoundaryTransform(Rect? rect) { + if (rect != null && !_sameBoundaryRect(_lastObservedBoundaryRect, rect)) { + if (_lastObservedBoundaryRect != null) { + _boundaryTransformGeneration++; + } + _lastObservedBoundaryRect = rect; + } + return (rect: rect, generation: _boundaryTransformGeneration); + } + + bool _sameBoundaryRect(Rect? left, Rect right) { + if (left == null) return false; + const epsilon = 0.01; + return (left.left - right.left).abs() <= epsilon && + (left.top - right.top).abs() <= epsilon && + (left.width - right.width).abs() <= epsilon && + (left.height - right.height).abs() <= epsilon; + } + TugboatStateAnchor? _snapshotStateAnchor(TugboatStateAnchor? anchor) { if (anchor == null) return null; return TugboatStateAnchor( @@ -1290,6 +1563,14 @@ class TugboatReplayController extends ChangeNotifier { return provenance.context.compatibleWith(context) ? latest : null; } + String? _surfaceCompatibleFrameFor(_CaptureRequestContext context) { + final latest = _latestFrameId; + if (latest == null) return null; + final provenance = _frameProvenance[latest]; + if (provenance == null || !provenance.available) return null; + return provenance.context.surfaceCompatibleWith(context) ? latest : null; + } + String? _unavailableAttachmentReason(_CaptureRequestContext context) { if (_compatibleFrameFor(context) != null) return null; return _latestFrameId == null @@ -1350,6 +1631,10 @@ class TugboatReplayController extends ChangeNotifier { 'executionId': resolution.executionId, 'captureSessionId': resolution.context.captureSessionId, 'routeEpoch': resolution.context.routeEpoch, + if (resolution.context.navigatorId != null) + 'navigatorId': resolution.context.navigatorId, + if (resolution.context.routeInstanceId != null) + 'routeInstanceId': resolution.context.routeInstanceId, 'trigger': resolution.context.trigger.name, 'visualEvidence': resolution.frameId == null ? 'unavailable' @@ -1429,14 +1714,17 @@ class TugboatReplayController extends ChangeNotifier { _CaptureRequestContext context, int generation, TugboatSession? session, - ) => - !_disposed && - generation == _captureGeneration && - identical(_session, session) && - context.captureSessionId == session?.id && - context.routeEpoch == _routeEpoch && - context.route == - (_currentRoute ?? _currentStateAnchor?.signatureParts['route']); + ) { + final boundary = _observeCurrentBoundaryTransform(); + return !_disposed && + generation == _captureGeneration && + identical(_session, session) && + context.captureSessionId == session?.id && + context.routeEpoch == _routeEpoch && + context.route == + (_currentRoute ?? _currentStateAnchor?.signatureParts['route']) && + context.boundaryTransformGeneration == boundary.generation; + } ({ Future done, @@ -1954,8 +2242,13 @@ class TugboatReplayController extends ChangeNotifier { activeSession.frameBytes[frameId] = result.bytes; _hashToFrameId[result.contentHash] = frameId; _latestFrameId = frameId; + final boundary = _observeBoundaryTransform(result.boundaryLogicalRect); + final frameContext = context.withBoundaryTransform( + logicalRect: result.boundaryLogicalRect, + generation: boundary.generation, + ); _frameProvenance[frameId] = _FrameProvenance( - context: context, + context: frameContext, completedAtMs: atMs, completionStateAnchor: completionStateAnchor, ); @@ -1987,6 +2280,7 @@ class TugboatReplayController extends ChangeNotifier { } void recordPointerDown(Offset position, {int pointer = 0}) { + _releasedInteractionClaims.remove(pointer)?.cancelled = true; final resolver = _anchorResolver; TugboatTargetAnchor? target; TugboatStateAnchor? tapState = _currentStateAnchor; @@ -2021,10 +2315,18 @@ class TugboatReplayController extends ChangeNotifier { final attachmentContext = _captureContext(TugboatFrameTrigger.tap); final beforeFrame = _compatibleFrameFor(attachmentContext); + final coordinateFrame = + beforeFrame ?? _surfaceCompatibleFrameFor(attachmentContext); final unavailableReason = _unavailableAttachmentReason(attachmentContext); + final captureCoordinate = _sampleCaptureCoordinate( + position: position, + frameId: coordinateFrame, + context: attachmentContext, + ); final tapData = { 'x': position.dx, 'y': position.dy, + 'captureCoordinate': captureCoordinate.toJson(), if (unavailableReason != null) 'frameAttachment': { 'before': 'unavailable', @@ -2036,6 +2338,14 @@ class TugboatReplayController extends ChangeNotifier { final beforeState = tapState; final eventId = _nextId('event'); + final claim = _PendingInteractionClaim( + tapEventId: eventId, + pointerId: pointer, + captureSessionId: _session?.id, + navigatorId: _currentNavigatorId, + routeInstanceId: _currentRouteInstanceId, + pointerGeneration: ++_pointerGeneration, + ); _pendingTaps[pointer] = _PendingTap( eventId: eventId, targetAnchor: target, @@ -2043,6 +2353,7 @@ class TugboatReplayController extends ChangeNotifier { beforeFrame: beforeFrame, startPosition: position, startedAtMs: atMs, + claim: claim, ); _activeGestures[pointer] = _PointerGestureState(tapEventId: eventId); if (target == null) { @@ -2074,8 +2385,55 @@ class TugboatReplayController extends ChangeNotifier { if (!_disposed) notifyListeners(); } + TugboatCaptureCoordinate _sampleCaptureCoordinate({ + required Offset position, + required String? frameId, + required _CaptureRequestContext context, + }) { + final boundaryRect = context.boundaryLogicalRect; + if (boundaryRect == null) { + return const TugboatCaptureCoordinate.unavailable( + unavailableReason: 'boundary_unavailable', + ); + } + final frame = frameId == null ? null : _session?.frameById(frameId); + final provenance = frameId == null ? null : _frameProvenance[frameId]; + if (frame != null && + provenance != null && + provenance.context.boundaryTransformGeneration != + context.boundaryTransformGeneration) { + return TugboatCaptureCoordinate.unavailable( + unavailableReason: 'generation_mismatch', + boundaryOriginX: boundaryRect.left, + boundaryOriginY: boundaryRect.top, + boundaryWidth: boundaryRect.width, + boundaryHeight: boundaryRect.height, + framePixelWidth: frame.width, + framePixelHeight: frame.height, + frameId: frameId, + boundaryTransformGeneration: context.boundaryTransformGeneration, + ); + } + final frameBoundaryRect = + provenance?.context.boundaryLogicalRect ?? boundaryRect; + return buildCaptureCoordinate( + globalX: position.dx, + globalY: position.dy, + boundaryOriginX: frameBoundaryRect.left, + boundaryOriginY: frameBoundaryRect.top, + boundaryWidth: frameBoundaryRect.width, + boundaryHeight: frameBoundaryRect.height, + framePixelWidth: frame?.width ?? 0, + framePixelHeight: frame?.height ?? 0, + frameId: frameId, + boundaryTransformGeneration: context.boundaryTransformGeneration, + ); + } + void recordPointerCancel(Offset position, {int pointer = 0}) { - _pendingTaps.remove(pointer); + final pending = _pendingTaps.remove(pointer); + pending?.claim.cancelled = true; + _releasedInteractionClaims.remove(pointer)?.cancelled = true; _activeGestures.remove(pointer); _addEvent( TugboatEvent( @@ -2093,6 +2451,7 @@ class TugboatReplayController extends ChangeNotifier { final pending = _pendingTaps[pointer]; if (pending != null) { pending.suppressSettle = true; + pending.claim.cancelled = true; } } @@ -2144,6 +2503,17 @@ class TugboatReplayController extends ChangeNotifier { return; } + // Gesture callbacks such as onTap run after the raw pointer-up listener + // within the same event-loop turn. Keep the single-use claim alive only + // through that turn so Navigator observers can attribute the transition + // without allowing later automatic navigation to borrow the tap. + _releasedInteractionClaims[pointer] = pending.claim; + scheduleMicrotask(() { + if (identical(_releasedInteractionClaims[pointer], pending.claim)) { + _releasedInteractionClaims.remove(pointer); + } + }); + final work = _TapSettleWork(session: _session); _activeTapSettles.add(work); unawaited(_resolveTapSettle(work, pending, position, _activeRouteCapture)); @@ -2156,18 +2526,34 @@ class TugboatReplayController extends ChangeNotifier { _RouteCaptureWork? routeCaptureAtPointerUp, ) async { try { + final initialRouteCapture = + routeCaptureAtPointerUp?.change.causeEventId == pending.eventId + ? routeCaptureAtPointerUp + : null; // Give a callback immediately after pointer-up the same settle boundary. - if (routeCaptureAtPointerUp == null && - config.settleDelay > Duration.zero) { + if (initialRouteCapture == null && config.settleDelay > Duration.zero) { final deadline = _scheduleDelay(config.settleDelay); work.attachDeadlineCancellation(deadline.cancel); await deadline.done; } if (!_isActiveTapSettle(work)) return; - final routeCapture = routeCaptureAtPointerUp ?? _activeRouteCapture; + // A tap may only inherit a route barrier that was causally claimed by + // that exact tap. In particular, an automatic navigation that starts + // while this tap is waiting to settle is independent evidence: joining + // it would incorrectly copy its destination frame and route event ID + // onto the tap. + final currentRouteCapture = _activeRouteCapture; + final routeCapture = + initialRouteCapture ?? + (currentRouteCapture?.change.causeEventId == pending.eventId + ? currentRouteCapture + : null); _TapSettleObservation observation; if (routeCapture != null) { - final routeBarrier = await _awaitRouteCaptureBarrier(routeCapture); + final routeBarrier = await _awaitRouteCaptureBarrier( + routeCapture, + expectedCauseEventId: pending.eventId, + ); if (!_isActiveTapSettle(work)) return; observation = _tapObservationFromRouteBarrier(routeBarrier); } else { @@ -2194,9 +2580,11 @@ class TugboatReplayController extends ChangeNotifier { final replacementRoute = _activeRouteCapture; if (!compatibleFrame && replacementRoute != null && - replacementRoute.epoch != requestedRouteEpoch) { + replacementRoute.epoch != requestedRouteEpoch && + replacementRoute.change.causeEventId == pending.eventId) { final routeBarrier = await _awaitRouteCaptureBarrier( replacementRoute, + expectedCauseEventId: pending.eventId, ); if (!_isActiveTapSettle(work)) return; observation = _tapObservationFromRouteBarrier(routeBarrier); @@ -2790,20 +3178,43 @@ class TugboatReplayController extends ChangeNotifier { identical(_session, session) && _captureLifecycleEpoch == lifecycleEpoch; - Future route(String type, Route? route) { + Future route( + String type, + Route? route, { + NavigatorState? navigatorState, + Route? departingRoute, + }) { if (_disposed || _session == null || _endSessionFuture != null) { return Future.value(); } final transition = _parseRouteTransition(type, route); - final change = _resolveVisibleRouteChange(transition); + final change = _resolveVisibleRouteChange( + transition, + destinationRoute: route, + departingRoute: departingRoute, + navigatorState: navigatorState, + ); if (change == null) return Future.value(); - if (change.updatesRoute) _currentRoute = change.destinationRoute; + if (change.updatesRoute) { + _currentRoute = change.destinationRoute; + _currentNavigatorId = change.navigatorId; + _currentRouteInstanceId = change.routeInstanceId; + } - final prior = _activeRouteCapture; - _cancelActiveRouteCapture('superseded_route'); - _cancelScheduledCaptureWaiters('superseded_route'); - if (prior == null) { + final captureKey = _routeCaptureKey(change.navigatorId); + final prior = _activeRouteCaptures[captureKey]; + if (prior != null) { + _activeRouteCaptures.remove(captureKey); + if (_latestRouteCaptureKey == captureKey) { + _latestRouteCaptureKey = _activeRouteCaptures.keys.isEmpty + ? null + : _activeRouteCaptures.keys.last; + } + prior.cancel('superseded_route'); + _cancelScheduledCaptureWaiters('superseded_route'); + _advanceCaptureGeneration(); + } else if (_activeRouteCaptures.isEmpty) { // A new visible route must also wake any unrelated in-flight frame wait. _advanceCaptureGeneration(); } @@ -2814,7 +3225,8 @@ class TugboatReplayController extends ChangeNotifier { transition.transitionDuration + (_shouldSuppressFrameCapture ? Duration.zero : config.settleDelay), ); - _activeRouteCapture = work; + _activeRouteCaptures[captureKey] = work; + _latestRouteCaptureKey = captureKey; prior?.supersededBy = work; _skipCapture = transition.transitionDuration > Duration.zero; _startRouteBarrierTimeout(work); @@ -2826,14 +3238,20 @@ class TugboatReplayController extends ChangeNotifier { return work.done.then((_) {}); } - bool _isActiveRouteCapture(_RouteCaptureWork work) => - !_disposed && !work.cancelled && identical(_activeRouteCapture, work); + bool _isActiveRouteCapture(_RouteCaptureWork work) { + final key = _routeCaptureKey(work.change.navigatorId); + return !_disposed && + !work.cancelled && + identical(_activeRouteCaptures[key], work); + } - /// Waits for a route epoch's single capture outcome. If that epoch is - /// superseded while a tap is waiting, join the replacement epoch instead of - /// letting the tap fall back to an opportunistic latest frame. + /// Waits for a route epoch's single capture outcome. A causally attributed + /// tap may follow only successors carrying the same claimed event ID. Future<({_RouteCaptureWork work, _RouteCaptureResult result})> - _awaitRouteCaptureBarrier(_RouteCaptureWork work) async { + _awaitRouteCaptureBarrier( + _RouteCaptureWork work, { + String? expectedCauseEventId, + }) async { var candidate = work; while (true) { final result = await candidate.done; @@ -2850,15 +3268,26 @@ class TugboatReplayController extends ChangeNotifier { if (replacement == null || identical(replacement, candidate)) { return (work: candidate, result: result); } + if (expectedCauseEventId != null && + replacement.change.causeEventId != expectedCauseEventId) { + return (work: candidate, result: result); + } candidate = replacement; } } void _cancelActiveRouteCapture([String reason = 'manual']) { - final active = _activeRouteCapture; - _activeRouteCapture = null; - if (active != null) _advanceCaptureGeneration(); - active?.cancel(reason); + if (_activeRouteCaptures.isEmpty) { + _skipCapture = false; + return; + } + final active = List<_RouteCaptureWork>.from(_activeRouteCaptures.values); + _activeRouteCaptures.clear(); + _latestRouteCaptureKey = null; + _advanceCaptureGeneration(); + for (final work in active) { + work.cancel(reason); + } _skipCapture = false; } @@ -2894,7 +3323,15 @@ class TugboatReplayController extends ChangeNotifier { final change = work.change; work.cancelPendingWork('route_timeout'); _advanceCaptureGeneration(); - _activeRouteCapture = null; + final key = _routeCaptureKey(work.change.navigatorId); + if (identical(_activeRouteCaptures[key], work)) { + _activeRouteCaptures.remove(key); + if (_latestRouteCaptureKey == key) { + _latestRouteCaptureKey = _activeRouteCaptures.keys.isEmpty + ? null + : _activeRouteCaptures.keys.last; + } + } _skipCapture = false; final observedState = _snapshotStateAnchor(_refreshStateAnchor()); final routeEventId = _nextId('event'); @@ -2913,6 +3350,7 @@ class TugboatReplayController extends ChangeNotifier { if (change.destinationRoute != null) 'route': change.destinationRoute, 'navigation': change.navigation, 'captureOutcome': 'timed_out', + ...change.ownershipData(), }, ), ); @@ -2950,7 +3388,11 @@ class TugboatReplayController extends ChangeNotifier { try { if (!_isActiveRouteCapture(work)) return; final change = work.change; - if (change.updatesRoute) _currentRoute = change.destinationRoute; + if (change.updatesRoute) { + _currentRoute = change.destinationRoute; + _currentNavigatorId = change.navigatorId; + _currentRouteInstanceId = change.routeInstanceId; + } _refreshStateAnchor(); final capture = _requestCaptureCancellable( trigger: TugboatFrameTrigger.route, @@ -2987,6 +3429,7 @@ class TugboatReplayController extends ChangeNotifier { 'captureFailure': captureResult.captureFailure, if (captureRequestId != null) 'captureRequestId': captureRequestId, + ...change.ownershipData(), }, ), ); @@ -3025,14 +3468,21 @@ class TugboatReplayController extends ChangeNotifier { if (outcome == _RouteCaptureOutcome.failed && captureResult.captureFailure != null) 'captureFailure': captureResult.captureFailure, + ...change.ownershipData(), }, ), ); _maybeEmitSceneInventory(); if (!_disposed) notifyListeners(); } finally { - if (identical(_activeRouteCapture, work)) { - _activeRouteCapture = null; + final key = _routeCaptureKey(work.change.navigatorId); + if (identical(_activeRouteCaptures[key], work)) { + _activeRouteCaptures.remove(key); + if (_latestRouteCaptureKey == key) { + _latestRouteCaptureKey = _activeRouteCaptures.keys.isEmpty + ? null + : _activeRouteCaptures.keys.last; + } _skipCapture = false; } work.complete( @@ -3126,9 +3576,23 @@ class TugboatReplayController extends ChangeNotifier { transitionDuration: route is TransitionRoute ? route.transitionDuration : Duration.zero, + overlayKind: _overlayKindFor(route), ); } + static String _overlayKindFor(Route? route) { + if (route == null) return 'page'; + final typeName = route.runtimeType.toString(); + if (route is PopupRoute) { + if (typeName.contains('ModalBottomSheet')) return 'modal'; + if (typeName.contains('Dialog')) return 'dialog'; + return 'popup'; + } + if (typeName.contains('ModalBottomSheet')) return 'modal'; + if (typeName.contains('Dialog')) return 'dialog'; + return 'page'; + } + /// Resolves [transition] against [_currentRoute], or returns null when it /// is not visible navigation. /// @@ -3137,7 +3601,12 @@ class TugboatReplayController extends ChangeNotifier { /// doing so cancels the pending capture scheduled by the preceding push, /// dropping both the route_change event and its screenshot for the /// destination route. - _VisibleRouteChange? _resolveVisibleRouteChange(_RouteTransition transition) { + _VisibleRouteChange? _resolveVisibleRouteChange( + _RouteTransition transition, { + Route? destinationRoute, + Route? departingRoute, + NavigatorState? navigatorState, + }) { final routeName = transition.routeName; if (transition.kind == _RouteNavigationKind.remove && (routeName == null || routeName == _currentRoute)) { @@ -3149,14 +3618,100 @@ class TugboatReplayController extends ChangeNotifier { transition.kind == _RouteNavigationKind.push || transition.kind == _RouteNavigationKind.replace || routeName != null; + + String? navigatorId; + String? parentNavigatorId; + String? routeInstanceId; + String? fromRouteInstanceId; + var stackRevision = 0; + if (navigatorState != null) { + navigatorId = _surfaces.idForNavigator(navigatorState); + parentNavigatorId = _surfaces.parentOf(navigatorId); + switch (transition.kind) { + case _RouteNavigationKind.push: + if (destinationRoute != null) { + routeInstanceId = _surfaces.idForRoute(destinationRoute); + stackRevision = _surfaces.push(navigatorId, routeInstanceId); + } + fromRouteInstanceId = _currentRouteInstanceId; + case _RouteNavigationKind.replace: + if (destinationRoute != null) { + routeInstanceId = _surfaces.idForRoute(destinationRoute); + stackRevision = _surfaces.replaceTop(navigatorId, routeInstanceId); + } + fromRouteInstanceId = + _surfaces.peekRouteId(departingRoute) ?? _currentRouteInstanceId; + case _RouteNavigationKind.pop: + case _RouteNavigationKind.remove: + fromRouteInstanceId = + _surfaces.peekRouteId(departingRoute) ?? _currentRouteInstanceId; + stackRevision = _surfaces.pop( + navigatorId, + departingInstanceId: fromRouteInstanceId, + ); + if (destinationRoute != null) { + routeInstanceId = _surfaces.idForRoute(destinationRoute); + } else { + routeInstanceId = _surfaces.top(navigatorId); + } + } + } else if (destinationRoute != null) { + // Test harness / direct controller.route calls without a NavigatorState. + routeInstanceId = _surfaces.idForRoute(destinationRoute); + fromRouteInstanceId = _currentRouteInstanceId; + stackRevision = (_currentRouteInstanceId == null ? 1 : 2); + } + + _visualObservationGeneration++; + final causeEventId = _tryClaimInteractionCause( + navigatorId: navigatorId ?? _currentNavigatorId, + ); return _VisibleRouteChange( previousRoute: _currentRoute, destinationRoute: updatesRoute ? routeName : _currentRoute, navigation: transition.kind.wireName, updatesRoute: updatesRoute, + navigatorId: navigatorId ?? _currentNavigatorId, + parentNavigatorId: parentNavigatorId, + routeInstanceId: routeInstanceId ?? _currentRouteInstanceId, + fromRouteInstanceId: fromRouteInstanceId, + stackRevision: stackRevision, + overlayKind: transition.overlayKind, + visualObservationGeneration: _visualObservationGeneration, + navigationOrigin: causeEventId == null + ? 'automatic_or_unknown' + : 'interaction', + causeEventId: causeEventId, ); } + /// Observer-time single-use claim. Returns the tap event ID only when exactly + /// one unambiguous active pointer is eligible for this navigator/session. + String? _tryClaimInteractionCause({String? navigatorId}) { + final eligible = <_PendingInteractionClaim>[]; + for (final pending in _pendingTaps.values) { + if (pending.suppressSettle) continue; + final claim = pending.claim; + if (!claim.isEligible) continue; + if (claim.captureSessionId != _session?.id) continue; + eligible.add(claim); + } + for (final claim in _releasedInteractionClaims.values) { + if (!claim.isEligible) continue; + if (claim.captureSessionId != _session?.id) continue; + eligible.add(claim); + } + if (eligible.length != 1) return null; + final claim = eligible.single; + if (navigatorId != null && + claim.navigatorId != null && + claim.navigatorId != navigatorId) { + return null; + } + claim.claimed = true; + return claim.tapEventId; + } + void _maybeEmitStateChange({ required TugboatStateAnchor? beforeState, required TugboatStateAnchor? afterState, diff --git a/packages/tugboat/lib/src/coordinate_space.dart b/packages/tugboat/lib/src/coordinate_space.dart new file mode 100644 index 0000000..b1d9e8d --- /dev/null +++ b/packages/tugboat/lib/src/coordinate_space.dart @@ -0,0 +1,253 @@ +/// Versioned capture-boundary coordinate contract (U12). +/// +/// Legacy tap payloads keep global logical `x`/`y`. Canonical playback uses +/// [TugboatCaptureCoordinate] bound to a compatible before-frame transform. +/// +/// Projection rule (one output pixel): +/// pixelX = round(normalizedX * (framePixelWidth - 1)) +/// pixelY = round(normalizedY * (framePixelHeight - 1)) +/// when width/height > 1; otherwise pixel = 0. +/// +/// Outside-boundary, missing-frame, and generation-mismatch transforms are +/// serialized as [TugboatCaptureCoordinate.unavailable] — never clamped. +library; + +/// Schema version for [TugboatCaptureCoordinate.toJson]. +const int tugboatCaptureCoordinateVersion = 1; + +/// Source space for a sampled pointer relative to the capture boundary. +enum TugboatCoordinateSourceSpace { + /// Flutter global logical coordinates (same as legacy `x`/`y`). + globalLogical, + + /// Logical coordinates local to the active [TugboatCaptureBoundary]. + boundaryLocalLogical, +} + +/// Immutable transform that projects a pointer onto its referenced frame. +class TugboatCaptureCoordinate { + const TugboatCaptureCoordinate({ + required this.sourceSpace, + required this.boundaryOriginX, + required this.boundaryOriginY, + required this.boundaryWidth, + required this.boundaryHeight, + required this.localX, + required this.localY, + required this.normalizedX, + required this.normalizedY, + required this.framePixelWidth, + required this.framePixelHeight, + required this.effectiveScaleX, + required this.effectiveScaleY, + required this.frameId, + required this.boundaryTransformGeneration, + this.unavailableReason, + }) : version = tugboatCaptureCoordinateVersion; + + /// Constructs an unavailable coordinate with a bounded reason. + const TugboatCaptureCoordinate.unavailable({ + required this.unavailableReason, + this.sourceSpace = TugboatCoordinateSourceSpace.globalLogical, + this.boundaryOriginX = 0, + this.boundaryOriginY = 0, + this.boundaryWidth = 0, + this.boundaryHeight = 0, + this.localX = 0, + this.localY = 0, + this.normalizedX = 0, + this.normalizedY = 0, + this.framePixelWidth = 0, + this.framePixelHeight = 0, + this.effectiveScaleX = 0, + this.effectiveScaleY = 0, + this.frameId, + this.boundaryTransformGeneration = 0, + }) : version = tugboatCaptureCoordinateVersion; + + final int version; + final TugboatCoordinateSourceSpace sourceSpace; + final double boundaryOriginX; + final double boundaryOriginY; + final double boundaryWidth; + final double boundaryHeight; + final double localX; + final double localY; + final double normalizedX; + final double normalizedY; + final int framePixelWidth; + final int framePixelHeight; + final double effectiveScaleX; + final double effectiveScaleY; + final String? frameId; + final int boundaryTransformGeneration; + final String? unavailableReason; + + bool get isAvailable => unavailableReason == null; + + /// Projects [normalizedX]/[normalizedY] to raster pixels using the documented + /// rounding rule. Returns null when unavailable or dimensions are invalid. + ({int x, int y})? projectToRaster() { + if (!isAvailable || framePixelWidth <= 0 || framePixelHeight <= 0) { + return null; + } + if (normalizedX < 0 || + normalizedX > 1 || + normalizedY < 0 || + normalizedY > 1) { + return null; + } + final x = framePixelWidth <= 1 + ? 0 + : (normalizedX * (framePixelWidth - 1)).round(); + final y = framePixelHeight <= 1 + ? 0 + : (normalizedY * (framePixelHeight - 1)).round(); + return (x: x, y: y); + } + + Map toJson() => { + 'version': version, + 'sourceSpace': sourceSpace.name, + 'boundaryOriginX': boundaryOriginX, + 'boundaryOriginY': boundaryOriginY, + 'boundaryWidth': boundaryWidth, + 'boundaryHeight': boundaryHeight, + 'localX': localX, + 'localY': localY, + 'normalizedX': normalizedX, + 'normalizedY': normalizedY, + 'framePixelWidth': framePixelWidth, + 'framePixelHeight': framePixelHeight, + 'effectiveScaleX': effectiveScaleX, + 'effectiveScaleY': effectiveScaleY, + if (frameId != null) 'frameId': frameId, + 'boundaryTransformGeneration': boundaryTransformGeneration, + if (unavailableReason != null) 'unavailableReason': unavailableReason, + }; + + factory TugboatCaptureCoordinate.fromJson(Map json) { + final reason = json['unavailableReason'] as String?; + final sourceName = json['sourceSpace'] as String? ?? 'globalLogical'; + final source = TugboatCoordinateSourceSpace.values.firstWhere( + (value) => value.name == sourceName, + orElse: () => TugboatCoordinateSourceSpace.globalLogical, + ); + if (reason != null) { + return TugboatCaptureCoordinate.unavailable( + unavailableReason: reason, + sourceSpace: source, + boundaryOriginX: (json['boundaryOriginX'] as num?)?.toDouble() ?? 0, + boundaryOriginY: (json['boundaryOriginY'] as num?)?.toDouble() ?? 0, + boundaryWidth: (json['boundaryWidth'] as num?)?.toDouble() ?? 0, + boundaryHeight: (json['boundaryHeight'] as num?)?.toDouble() ?? 0, + localX: (json['localX'] as num?)?.toDouble() ?? 0, + localY: (json['localY'] as num?)?.toDouble() ?? 0, + normalizedX: (json['normalizedX'] as num?)?.toDouble() ?? 0, + normalizedY: (json['normalizedY'] as num?)?.toDouble() ?? 0, + framePixelWidth: (json['framePixelWidth'] as num?)?.toInt() ?? 0, + framePixelHeight: (json['framePixelHeight'] as num?)?.toInt() ?? 0, + effectiveScaleX: (json['effectiveScaleX'] as num?)?.toDouble() ?? 0, + effectiveScaleY: (json['effectiveScaleY'] as num?)?.toDouble() ?? 0, + frameId: json['frameId'] as String?, + boundaryTransformGeneration: + (json['boundaryTransformGeneration'] as num?)?.toInt() ?? 0, + ); + } + return TugboatCaptureCoordinate( + sourceSpace: source, + boundaryOriginX: (json['boundaryOriginX'] as num).toDouble(), + boundaryOriginY: (json['boundaryOriginY'] as num).toDouble(), + boundaryWidth: (json['boundaryWidth'] as num).toDouble(), + boundaryHeight: (json['boundaryHeight'] as num).toDouble(), + localX: (json['localX'] as num).toDouble(), + localY: (json['localY'] as num).toDouble(), + normalizedX: (json['normalizedX'] as num).toDouble(), + normalizedY: (json['normalizedY'] as num).toDouble(), + framePixelWidth: (json['framePixelWidth'] as num).toInt(), + framePixelHeight: (json['framePixelHeight'] as num).toInt(), + effectiveScaleX: (json['effectiveScaleX'] as num).toDouble(), + effectiveScaleY: (json['effectiveScaleY'] as num).toDouble(), + frameId: json['frameId'] as String?, + boundaryTransformGeneration: (json['boundaryTransformGeneration'] as num) + .toInt(), + ); + } +} + +/// Builds a capture coordinate from boundary geometry and frame raster size. +/// +/// Returns an unavailable coordinate when the point is outside the boundary +/// or frame dimensions are missing. +TugboatCaptureCoordinate buildCaptureCoordinate({ + required double globalX, + required double globalY, + required double boundaryOriginX, + required double boundaryOriginY, + required double boundaryWidth, + required double boundaryHeight, + required int framePixelWidth, + required int framePixelHeight, + required String? frameId, + required int boundaryTransformGeneration, +}) { + if (boundaryWidth <= 0 || boundaryHeight <= 0) { + return const TugboatCaptureCoordinate.unavailable( + unavailableReason: 'invalid_boundary', + ); + } + if (framePixelWidth <= 0 || framePixelHeight <= 0 || frameId == null) { + return TugboatCaptureCoordinate.unavailable( + unavailableReason: 'missing_frame', + boundaryOriginX: boundaryOriginX, + boundaryOriginY: boundaryOriginY, + boundaryWidth: boundaryWidth, + boundaryHeight: boundaryHeight, + boundaryTransformGeneration: boundaryTransformGeneration, + ); + } + + final localX = globalX - boundaryOriginX; + final localY = globalY - boundaryOriginY; + if (localX < 0 || + localY < 0 || + localX > boundaryWidth || + localY > boundaryHeight) { + return TugboatCaptureCoordinate.unavailable( + unavailableReason: 'outside_boundary', + boundaryOriginX: boundaryOriginX, + boundaryOriginY: boundaryOriginY, + boundaryWidth: boundaryWidth, + boundaryHeight: boundaryHeight, + localX: localX, + localY: localY, + framePixelWidth: framePixelWidth, + framePixelHeight: framePixelHeight, + frameId: frameId, + boundaryTransformGeneration: boundaryTransformGeneration, + ); + } + + final normalizedX = (localX / boundaryWidth).clamp(0.0, 1.0); + final normalizedY = (localY / boundaryHeight).clamp(0.0, 1.0); + final effectiveScaleX = framePixelWidth / boundaryWidth; + final effectiveScaleY = framePixelHeight / boundaryHeight; + + return TugboatCaptureCoordinate( + sourceSpace: TugboatCoordinateSourceSpace.boundaryLocalLogical, + boundaryOriginX: boundaryOriginX, + boundaryOriginY: boundaryOriginY, + boundaryWidth: boundaryWidth, + boundaryHeight: boundaryHeight, + localX: localX, + localY: localY, + normalizedX: normalizedX, + normalizedY: normalizedY, + framePixelWidth: framePixelWidth, + framePixelHeight: framePixelHeight, + effectiveScaleX: effectiveScaleX, + effectiveScaleY: effectiveScaleY, + frameId: frameId, + boundaryTransformGeneration: boundaryTransformGeneration, + ); +} diff --git a/packages/tugboat/lib/src/lifecycle.dart b/packages/tugboat/lib/src/lifecycle.dart index 25a7995..814709a 100644 --- a/packages/tugboat/lib/src/lifecycle.dart +++ b/packages/tugboat/lib/src/lifecycle.dart @@ -3,12 +3,7 @@ import 'package:flutter/foundation.dart'; import 'capture_profile.dart'; /// Explicit capture lifecycle states owned by the SDK gate. -enum TugboatLifecycleState { - dormant, - starting, - active, - stopping, -} +enum TugboatLifecycleState { dormant, starting, active, stopping } /// Monotonic lifecycle requests observed by the always-mounted activation gate. class TugboatLifecycleNotifier extends ChangeNotifier { diff --git a/packages/tugboat/lib/src/outbox/outbox.dart b/packages/tugboat/lib/src/outbox/outbox.dart index 1eca912..3e14fab 100644 --- a/packages/tugboat/lib/src/outbox/outbox.dart +++ b/packages/tugboat/lib/src/outbox/outbox.dart @@ -99,8 +99,7 @@ class TugboatOutboxStore { bool _loaded = false; int get entryCount => _entries.length; - int get byteSize => - _entries.fold(0, (sum, e) => sum + e.estimatedBytes); + int get byteSize => _entries.fold(0, (sum, e) => sum + e.estimatedBytes); List get quarantineReasons => List.unmodifiable(_quarantineReasons); @@ -196,9 +195,7 @@ class TugboatOutboxStore { Future _enforceBounds() async { final now = DateTime.now().toUtc(); - _entries.removeWhere( - (e) => now.difference(e.createdAt) > config.maxAge, - ); + _entries.removeWhere((e) => now.difference(e.createdAt) > config.maxAge); while (_entries.length > config.maxEntries || byteSize > config.maxBytes) { if (_entries.isEmpty) break; _entries.removeAt(0); diff --git a/packages/tugboat/lib/src/outbox/outbox_sink.dart b/packages/tugboat/lib/src/outbox/outbox_sink.dart index 9bbc7d2..7ba53cc 100644 --- a/packages/tugboat/lib/src/outbox/outbox_sink.dart +++ b/packages/tugboat/lib/src/outbox/outbox_sink.dart @@ -86,12 +86,7 @@ class OutboxBackedCaptureSink implements TugboatCaptureSink { ), ); } - _inner.recordFrame( - frame, - bytes, - sessionId: sessionId, - actionId: actionId, - ); + _inner.recordFrame(frame, bytes, sessionId: sessionId, actionId: actionId); } @override diff --git a/packages/tugboat/lib/src/screenshot_capturer.dart b/packages/tugboat/lib/src/screenshot_capturer.dart index 178e4bf..410f5a7 100644 --- a/packages/tugboat/lib/src/screenshot_capturer.dart +++ b/packages/tugboat/lib/src/screenshot_capturer.dart @@ -87,6 +87,7 @@ class ScreenshotCaptureResult { this.dHash, required this.width, required this.height, + required this.boundaryLogicalRect, required this.masked, required this.captureMicros, required this.encodeMicros, @@ -99,6 +100,7 @@ class ScreenshotCaptureResult { final String? dHash; final int width; final int height; + final Rect boundaryLogicalRect; final bool masked; final int captureMicros; final int encodeMicros; @@ -354,6 +356,8 @@ class ScreenshotCapturer { required bool force, }) async { final rootRender = boundary; + final boundaryOrigin = boundary.localToGlobal(Offset.zero); + final boundaryLogicalRect = boundaryOrigin & boundary.size; final List maskRects; final maskClock = Stopwatch()..start(); @@ -437,6 +441,7 @@ class ScreenshotCapturer { dHash: quickDHash, width: scaledWidth, height: scaledHeight, + boundaryLogicalRect: boundaryLogicalRect, masked: maskRects.isNotEmpty, captureMicros: readbackClock.elapsedMicroseconds, encodeMicros: encodeClock.elapsedMicroseconds, @@ -471,6 +476,7 @@ class ScreenshotCapturer { dHash: quickDHash, width: scaledWidth, height: scaledHeight, + boundaryLogicalRect: boundaryLogicalRect, masked: maskRects.isNotEmpty, captureMicros: readbackClock.elapsedMicroseconds, encodeMicros: encodeClock.elapsedMicroseconds, diff --git a/packages/tugboat/lib/src/sdk_version.dart b/packages/tugboat/lib/src/sdk_version.dart index 5ce58f4..fb3e18a 100644 --- a/packages/tugboat/lib/src/sdk_version.dart +++ b/packages/tugboat/lib/src/sdk_version.dart @@ -1,3 +1,3 @@ // Keep this in sync with packages/tugboat/pubspec.yaml. The SDK version test // reads pubspec.yaml directly so release bumps fail fast if this drifts. -const tugboatSdkVersion = '0.4.11'; +const tugboatSdkVersion = '0.4.12'; diff --git a/packages/tugboat/lib/src/tugboat.dart b/packages/tugboat/lib/src/tugboat.dart index 4d95efa..88bb429 100644 --- a/packages/tugboat/lib/src/tugboat.dart +++ b/packages/tugboat/lib/src/tugboat.dart @@ -34,8 +34,18 @@ class TugboatReplay { static final GlobalKey _boundaryKey = GlobalKey( debugLabel: 'tugboat-capture-boundary', ); + + /// Convenience root [NavigatorObserver]. Prefer this for the app's primary + /// Navigator. static final TugboatNavigatorObserver navigatorObserver = TugboatNavigatorObserver(); + + /// Creates a dedicated observer for a nested [Navigator]. + /// + /// Flutter does not safely auto-discover nested Navigators; install one + /// observer instance per Navigator whose transitions must be attributed. + static TugboatNavigatorObserver createNavigatorObserver() => + TugboatNavigatorObserver(); static final TugboatLifecycleNotifier _lifecycle = TugboatLifecycleNotifier(); /// Installs deterministic controller seams before its first post-frame @@ -134,38 +144,55 @@ class TugboatReplay { } } +/// Observes one [Navigator] and reports transitions to the active controller. +/// +/// Install the root convenience instance via [TugboatReplay.navigatorObserver]. +/// For nested Navigators that must be attributed, create a dedicated observer +/// with [TugboatReplay.createNavigatorObserver] (or `TugboatNavigatorObserver()`) +/// and install it on that Navigator — one observer instance per Navigator. class TugboatNavigatorObserver extends NavigatorObserver { void _syncContext() { if (TugboatReplay.disabled) return; - TugboatReplay.controller?.navigatorContext = navigator?.context; + // Prefer the root navigator for pointer/anchor context; nested observers + // still report their own NavigatorState into route ownership. + if (identical(this, TugboatReplay.navigatorObserver)) { + TugboatReplay.controller?.navigatorContext = navigator?.context; + } } - @override - void didPush(Route route, Route? previousRoute) { + void _emit( + String type, + Route? destination, { + Route? departing, + }) { if (TugboatReplay.disabled) return; _syncContext(); - TugboatReplay.controller?.route('route_push', route); + TugboatReplay.controller?.route( + type, + destination, + navigatorState: navigator, + departingRoute: departing, + ); + } + + @override + void didPush(Route route, Route? previousRoute) { + _emit('route_push', route); } @override void didPop(Route route, Route? previousRoute) { - if (TugboatReplay.disabled) return; - _syncContext(); - TugboatReplay.controller?.route('route_pop', previousRoute); + _emit('route_pop', previousRoute, departing: route); } @override void didReplace({Route? newRoute, Route? oldRoute}) { - if (TugboatReplay.disabled) return; - _syncContext(); - TugboatReplay.controller?.route('route_replace', newRoute); + _emit('route_replace', newRoute, departing: oldRoute); } @override void didRemove(Route route, Route? previousRoute) { - if (TugboatReplay.disabled) return; - _syncContext(); - TugboatReplay.controller?.route('route_remove', previousRoute); + _emit('route_remove', previousRoute, departing: route); } } diff --git a/packages/tugboat/lib/tugboat.dart b/packages/tugboat/lib/tugboat.dart index ed8a3d5..8c8ecba 100644 --- a/packages/tugboat/lib/tugboat.dart +++ b/packages/tugboat/lib/tugboat.dart @@ -29,8 +29,15 @@ export 'src/health.dart' TugboatOutboxHealth, TugboatScreenshotBudgetHealth, TugboatSanitizedFailure; -export 'src/lifecycle.dart' show TugboatLifecycleState, TugboatLifecycleNotifier; +export 'src/lifecycle.dart' + show TugboatLifecycleState, TugboatLifecycleNotifier; export 'src/models.dart'; +export 'src/coordinate_space.dart' + show + tugboatCaptureCoordinateVersion, + TugboatCoordinateSourceSpace, + TugboatCaptureCoordinate, + buildCaptureCoordinate; export 'src/outbox/outbox.dart' show TugboatOutboxConfig, TugboatOutboxEnvelope, TugboatOutboxStore; export 'src/replay_config.dart' diff --git a/packages/tugboat/pubspec.yaml b/packages/tugboat/pubspec.yaml index 5b2ce3a..40f64c7 100644 --- a/packages/tugboat/pubspec.yaml +++ b/packages/tugboat/pubspec.yaml @@ -1,7 +1,7 @@ name: tugboat description: >- Screenshot-based session replay with compact interaction anchors for Tugboat. -version: 0.4.11 +version: 0.4.12 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter diff --git a/packages/tugboat/test/coordinate_space_test.dart b/packages/tugboat/test/coordinate_space_test.dart new file mode 100644 index 0000000..004b7e3 --- /dev/null +++ b/packages/tugboat/test/coordinate_space_test.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/tugboat.dart'; + +void main() { + test('round-trips transform with non-zero origin and non-uniform scale', () { + final coord = buildCaptureCoordinate( + globalX: 120, + globalY: 240, + boundaryOriginX: 20, + boundaryOriginY: 40, + boundaryWidth: 200, + boundaryHeight: 400, + framePixelWidth: 100, + framePixelHeight: 800, + frameId: 'frame-1', + boundaryTransformGeneration: 3, + ); + expect(coord.isAvailable, isTrue); + expect(coord.localX, 100); + expect(coord.localY, 200); + expect(coord.normalizedX, 0.5); + expect(coord.normalizedY, 0.5); + expect(coord.effectiveScaleX, 0.5); + expect(coord.effectiveScaleY, 2.0); + + final json = jsonDecode(jsonEncode(coord.toJson())) as Map; + final restored = TugboatCaptureCoordinate.fromJson(json); + expect(restored.toJson(), coord.toJson()); + expect(restored.projectToRaster(), (x: 50, y: 400)); + }); + + test('legacy events with only global x/y remain readable', () { + final event = TugboatEvent( + id: 'e1', + atMs: 1, + type: 'tap', + data: const {'x': 10.5, 'y': 20.25}, + ); + final json = jsonDecode(jsonEncode(event.toJson())) as Map; + final data = Map.from(json['data']! as Map); + expect(data['x'], 10.5); + expect(data['y'], 20.25); + expect(data['captureCoordinate'], isNull); + }); + + test('rejects out-of-range normalized coordinates on projection', () { + final bad = TugboatCaptureCoordinate( + sourceSpace: TugboatCoordinateSourceSpace.boundaryLocalLogical, + boundaryOriginX: 0, + boundaryOriginY: 0, + boundaryWidth: 100, + boundaryHeight: 100, + localX: 150, + localY: 50, + normalizedX: 1.5, + normalizedY: 0.5, + framePixelWidth: 100, + framePixelHeight: 100, + effectiveScaleX: 1, + effectiveScaleY: 1, + frameId: 'frame-1', + boundaryTransformGeneration: 1, + ); + expect(bad.projectToRaster(), isNull); + }); + + test('outside-boundary points are unavailable rather than clamped', () { + final coord = buildCaptureCoordinate( + globalX: -5, + globalY: 10, + boundaryOriginX: 0, + boundaryOriginY: 0, + boundaryWidth: 100, + boundaryHeight: 100, + framePixelWidth: 100, + framePixelHeight: 100, + frameId: 'frame-1', + boundaryTransformGeneration: 1, + ); + expect(coord.isAvailable, isFalse); + expect(coord.unavailableReason, 'outside_boundary'); + expect(coord.projectToRaster(), isNull); + }); + + test('missing frame yields unavailable transform', () { + final coord = buildCaptureCoordinate( + globalX: 10, + globalY: 10, + boundaryOriginX: 0, + boundaryOriginY: 0, + boundaryWidth: 100, + boundaryHeight: 100, + framePixelWidth: 0, + framePixelHeight: 0, + frameId: null, + boundaryTransformGeneration: 1, + ); + expect(coord.unavailableReason, 'missing_frame'); + }); + + test('golden fixture freezes the consumer contract', () { + final golden = buildCaptureCoordinate( + globalX: 45, + globalY: 95, + boundaryOriginX: 10, + boundaryOriginY: 20, + boundaryWidth: 100, + boundaryHeight: 200, + framePixelWidth: 200, + framePixelHeight: 400, + frameId: 'frame-golden', + boundaryTransformGeneration: 7, + ); + expect(golden.toJson(), { + 'version': 1, + 'sourceSpace': 'boundaryLocalLogical', + 'boundaryOriginX': 10.0, + 'boundaryOriginY': 20.0, + 'boundaryWidth': 100.0, + 'boundaryHeight': 200.0, + 'localX': 35.0, + 'localY': 75.0, + 'normalizedX': 0.35, + 'normalizedY': 0.375, + 'framePixelWidth': 200, + 'framePixelHeight': 400, + 'effectiveScaleX': 2.0, + 'effectiveScaleY': 2.0, + 'frameId': 'frame-golden', + 'boundaryTransformGeneration': 7, + }); + expect(golden.projectToRaster(), (x: 70, y: 150)); + }); + + test( + 'captureCoordinate JSON nests under tap data for collector passthrough', + () { + final coord = buildCaptureCoordinate( + globalX: 50, + globalY: 50, + boundaryOriginX: 0, + boundaryOriginY: 0, + boundaryWidth: 100, + boundaryHeight: 100, + framePixelWidth: 100, + framePixelHeight: 100, + frameId: 'frame-1', + boundaryTransformGeneration: 1, + ); + final event = TugboatEvent( + id: 'e1', + atMs: 1, + type: 'tap', + data: {'x': 50.0, 'y': 50.0, 'captureCoordinate': coord.toJson()}, + ); + final encoded = + jsonDecode(jsonEncode(event.toJson())) as Map; + final data = Map.from(encoded['data']! as Map); + expect(data['x'], 50.0); + expect(data['y'], 50.0); + expect( + Map.from(data['captureCoordinate']! as Map), + coord.toJson(), + ); + }, + ); +} diff --git a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart index 7849754..a57a8ed 100644 --- a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart +++ b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart @@ -106,8 +106,9 @@ void main() { }); await tester.pump(); - final routes = TugboatReplay.controller!.session!.events - .where((e) => e.type == 'route_change'); + final routes = TugboatReplay.controller!.session!.events.where( + (e) => e.type == 'route_change', + ); expect(routes, isNotEmpty); }); @@ -115,10 +116,13 @@ void main() { expect(tugboatSessionSchemaVersion, 7); }); - test('platform views are classified as unsupported for structural capture', () { - const unsupported = ['PlatformViewLink', 'AndroidView', 'UiKitView']; - expect(unsupported, isNotEmpty); - }); + test( + 'platform views are classified as unsupported for structural capture', + () { + const unsupported = ['PlatformViewLink', 'AndroidView', 'UiKitView']; + expect(unsupported, isNotEmpty); + }, + ); testWidgets('runtime activation works without MaterialApp rebuild', ( tester, @@ -148,4 +152,72 @@ void main() { expect(TugboatReplay.health.activationRequestId, 'matrix-req'); expect(TugboatReplay.health.captureSessionId, isNotNull); }); + + test('coordinate schema version and projection contract are stable', () { + expect(tugboatCaptureCoordinateVersion, 1); + final coord = buildCaptureCoordinate( + globalX: 45, + globalY: 95, + boundaryOriginX: 10, + boundaryOriginY: 20, + boundaryWidth: 100, + boundaryHeight: 200, + framePixelWidth: 200, + framePixelHeight: 400, + frameId: 'frame-golden', + boundaryTransformGeneration: 7, + ); + expect(coord.projectToRaster(), (x: 70, y: 150)); + }); + + testWidgets( + 'modal ownership and navigation origin compose under production masking', + (tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [TugboatReplay.navigatorObserver], + builder: (context, child) => TugboatReplay.wrapApp( + config: const TugboatReplayConfig( + profile: TugboatCaptureProfile.exploration, + settleDelay: Duration.zero, + enableGlobalPointerCapture: true, + capturePixelRatio: 1, + screenshotMaskLevel: TugboatScreenshotMaskLevel.allTextAndMedia, + ), + child: child!, + ), + home: Builder( + builder: (context) => Scaffold( + body: FilledButton( + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => const SizedBox( + height: 120, + child: Center(child: Text('sheet')), + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await _waitForCaptures(tester); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await _waitForCaptures(tester); + + final session = TugboatReplay.controller!.session!; + final push = session.events.lastWhere( + (e) => e.type == 'route_change' && e.data['navigation'] == 'route_push', + ); + expect(push.data['routeInstanceId'], isNotNull); + expect(push.data['navigatorId'], isNotNull); + expect(push.data['navigationOrigin'], isNotNull); + + final taps = session.events.where((e) => e.type == 'tap'); + expect(taps, isNotEmpty); + expect(taps.first.data['captureCoordinate'], isA()); + }, + ); } diff --git a/packages/tugboat/test/outbox/tugboat_outbox_recovery_test.dart b/packages/tugboat/test/outbox/tugboat_outbox_recovery_test.dart index a4c6ed1..cfb96c8 100644 --- a/packages/tugboat/test/outbox/tugboat_outbox_recovery_test.dart +++ b/packages/tugboat/test/outbox/tugboat_outbox_recovery_test.dart @@ -4,47 +4,52 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/src/outbox/outbox.dart'; void main() { - test('recovery test reloads only unacked envelopes after process restart', () async { - final dir = await Directory.systemTemp.createTemp('tugboat_outbox_recover_'); - addTearDown(() async { - if (dir.existsSync()) await dir.delete(recursive: true); - }); + test( + 'recovery test reloads only unacked envelopes after process restart', + () async { + final dir = await Directory.systemTemp.createTemp( + 'tugboat_outbox_recover_', + ); + addTearDown(() async { + if (dir.existsSync()) await dir.delete(recursive: true); + }); - final config = TugboatOutboxConfig(enabled: true, directory: dir); - final writer = TugboatOutboxStore(config: config); - await writer.append( - TugboatOutboxEnvelope( - idempotencyKey: 'pending-1', - kind: 'event', - captureSessionId: 'cap-1', - payloadJson: { - 'id': 'e1', - 'type': 'tap', - 'atMs': 1, - // Ensure no free-text label leaks into durable payload contract tests. - }, - createdAt: DateTime.now().toUtc(), - ), - ); - await writer.append( - TugboatOutboxEnvelope( - idempotencyKey: 'done-1', - kind: 'event', - captureSessionId: 'cap-1', - payloadJson: {'id': 'e2', 'type': 'route_change', 'atMs': 2}, - createdAt: DateTime.now().toUtc(), - ), - ); - await writer.acknowledge('done-1'); + final config = TugboatOutboxConfig(enabled: true, directory: dir); + final writer = TugboatOutboxStore(config: config); + await writer.append( + TugboatOutboxEnvelope( + idempotencyKey: 'pending-1', + kind: 'event', + captureSessionId: 'cap-1', + payloadJson: { + 'id': 'e1', + 'type': 'tap', + 'atMs': 1, + // Ensure no free-text label leaks into durable payload contract tests. + }, + createdAt: DateTime.now().toUtc(), + ), + ); + await writer.append( + TugboatOutboxEnvelope( + idempotencyKey: 'done-1', + kind: 'event', + captureSessionId: 'cap-1', + payloadJson: {'id': 'e2', 'type': 'route_change', 'atMs': 2}, + createdAt: DateTime.now().toUtc(), + ), + ); + await writer.acknowledge('done-1'); - // Simulate process death: new store instance, same directory. - final recovered = TugboatOutboxStore(config: config); - await recovered.load(); - expect(recovered.pending().map((e) => e.idempotencyKey), ['pending-1']); - for (final entry in recovered.pending()) { - final encoded = entry.toJson().toString(); - expect(encoded.contains('password'), isFalse); - expect(encoded.contains('Bearer'), isFalse); - } - }); + // Simulate process death: new store instance, same directory. + final recovered = TugboatOutboxStore(config: config); + await recovered.load(); + expect(recovered.pending().map((e) => e.idempotencyKey), ['pending-1']); + for (final entry in recovered.pending()) { + final encoded = entry.toJson().toString(); + expect(encoded.contains('password'), isFalse); + expect(encoded.contains('Bearer'), isFalse); + } + }, + ); } diff --git a/packages/tugboat/test/outbox/tugboat_outbox_test.dart b/packages/tugboat/test/outbox/tugboat_outbox_test.dart index f82855f..5e6ac83 100644 --- a/packages/tugboat/test/outbox/tugboat_outbox_test.dart +++ b/packages/tugboat/test/outbox/tugboat_outbox_test.dart @@ -91,11 +91,7 @@ void main() { test('entry bounds are enforced', () async { final store = TugboatOutboxStore( - config: TugboatOutboxConfig( - enabled: true, - directory: dir, - maxEntries: 2, - ), + config: TugboatOutboxConfig(enabled: true, directory: dir, maxEntries: 2), ); for (var i = 0; i < 4; i++) { await store.append( diff --git a/packages/tugboat/test/replay/modal_capture_visual_test.dart b/packages/tugboat/test/replay/modal_capture_visual_test.dart new file mode 100644 index 0000000..8767163 --- /dev/null +++ b/packages/tugboat/test/replay/modal_capture_visual_test.dart @@ -0,0 +1,709 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:tugboat/tugboat.dart'; + +/// Real-pixel characterization for modal/sheet capture (U8). +/// +/// Uses encoded frame bytes from the live capture pipeline — never +/// [TugboatReplayController.debugSeedFrame] for modal assertions. Cases that +/// document a known production gap are marked `// GAP(U9):` and assert current +/// behavior so the suite stays green until U9 flips those expectations. +void main() { + setUp(TugboatReplay.resetForTest); + tearDown(TugboatReplay.resetForTest); + + testWidgets( + 'anonymous modal bottom sheet pixels differ from the base frame', + (tester) async { + final fixture = await _ModalVisualFixture.mount(tester); + final baseFrame = await fixture.waitForLatestRealFrame(tester); + final baseBottom = fixture.colorDominanceInBottom(baseFrame.id); + + final start = fixture.session.events.length; + await tester.tap(find.byKey(_openAnonymousSheet)); + await tester.pumpAndSettle(); + final push = await fixture.waitForRoutePush(tester, after: start); + await fixture.waitForCaptures(tester); + + final sheetFrameId = push.afterFrame; + expect( + sheetFrameId, + isNotNull, + reason: 'sheet route must attach a real after-frame', + ); + final sheetBottom = fixture.colorDominanceInBottom(sheetFrameId!); + + expect( + sheetBottom.greenDominant, + greaterThan(20), + reason: 'bottom region of the sheet frame must show green sheet pixels', + ); + expect( + baseBottom.redDominant, + greaterThan(baseBottom.greenDominant), + reason: 'base frame bottom region remains the red scaffold', + ); + expect(push.data['route'], contains('ModalBottomSheetRoute')); + }, + ); + + testWidgets('draggable sheet terminal extent owns the accepted frame', ( + tester, + ) async { + final fixture = await _ModalVisualFixture.mount(tester); + final start = fixture.session.events.length; + await tester.tap(find.byKey(_openDraggableSheet)); + await tester.pumpAndSettle(); + final push = await fixture.waitForRoutePush(tester, after: start); + await fixture.waitForCaptures(tester); + + final frameId = push.afterFrame; + expect(frameId, isNotNull); + final bottom = fixture.colorDominanceInBottom(frameId!); + expect( + bottom.tealDominant, + greaterThan(20), + reason: 'accepted frame must represent the settled teal sheet extent', + ); + }); + + testWidgets( + 'stacked anonymous sheets share runtime-type route identity today', + (tester) async { + final fixture = await _ModalVisualFixture.mount(tester); + + final firstStart = fixture.session.events.length; + await tester.tap(find.byKey(_openAnonymousSheet)); + await tester.pumpAndSettle(); + final first = await fixture.waitForRoutePush(tester, after: firstStart); + await fixture.waitForCaptures(tester); + + final secondStart = fixture.session.events.length; + await tester.tap(find.byKey(_stackAnonymousSheet)); + await tester.pumpAndSettle(); + final second = await fixture.waitForRoutePush(tester, after: secondStart); + await fixture.waitForCaptures(tester); + + final firstRoute = first.data['route'] as String; + final secondRoute = second.data['route'] as String; + expect(firstRoute, contains('ModalBottomSheetRoute')); + expect(secondRoute, contains('ModalBottomSheetRoute')); + + // Opaque route-instance IDs distinguish stacked anonymous sheets even + // when the descriptive route string collapses to the same runtime type. + expect(first.data['routeInstanceId'], isNotNull); + expect(second.data['routeInstanceId'], isNotNull); + expect( + first.data['routeInstanceId'], + isNot(second.data['routeInstanceId']), + ); + expect(first.data['navigatorId'], isNotNull); + expect(second.data['navigatorId'], first.data['navigatorId']); + }, + ); + + testWidgets( + 'dismiss restores base-route pixels for action, barrier, and back', + (tester) async { + final fixture = await _ModalVisualFixture.mount(tester); + final baseFrame = await fixture.waitForLatestRealFrame(tester); + final baseCenter = fixture.sampleCenter(baseFrame.id); + + Future openAndAssertSheet() async { + final start = fixture.session.events.length; + await tester.tap(find.byKey(_openNamedSheet)); + await tester.pumpAndSettle(); + final push = await fixture.waitForRoute( + tester, + navigation: 'route_push', + route: '/named-sheet', + after: start, + ); + await fixture.waitForCaptures(tester); + expect(push.afterFrame, isNotNull); + final sheetBottom = fixture.colorDominanceInBottom(push.afterFrame!); + expect(sheetBottom.greenDominant, greaterThan(20)); + } + + Future assertRestoredBase({required int after}) async { + final pop = await fixture.waitForRoute( + tester, + navigation: 'route_pop', + route: '/root', + after: after, + ); + await fixture.waitForCaptures(tester); + expect(pop.afterFrame, isNotNull); + final restored = fixture.sampleCenter(pop.afterFrame!); + expect( + restored.r, + greaterThan(restored.g), + reason: 'restored base frame must show red scaffold pixels', + ); + expect((restored.r - baseCenter.r).abs(), lessThan(40)); + } + + await openAndAssertSheet(); + var popStart = fixture.session.events.length; + await tester.tap(find.byKey(_closeSheet)); + await tester.pumpAndSettle(); + await assertRestoredBase(after: popStart); + + await openAndAssertSheet(); + popStart = fixture.session.events.length; + await tester.tapAt(const Offset(200, 80)); + await tester.pumpAndSettle(); + await assertRestoredBase(after: popStart); + + await openAndAssertSheet(); + popStart = fixture.session.events.length; + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await assertRestoredBase(after: popStart); + }, + ); + + testWidgets( + 'named dialog and named sheet each attach distinct route evidence', + (tester) async { + final fixture = await _ModalVisualFixture.mount(tester); + + final dialogStart = fixture.session.events.length; + await tester.tap(find.byKey(_openNamedDialog)); + await tester.pumpAndSettle(); + final dialog = await fixture.waitForRoute( + tester, + navigation: 'route_push', + route: '/named-dialog', + after: dialogStart, + ); + await fixture.waitForCaptures(tester); + expect(dialog.afterFrame, isNotNull); + final dialogChannels = fixture.colorDominanceInCenter(dialog.afterFrame!); + expect( + dialogChannels.blueDominant, + greaterThan(20), + reason: 'dialog frame must show blue dialog surface', + ); + + await tester.tap(find.byKey(_closeDialog)); + await tester.pumpAndSettle(); + await fixture.waitForCaptures(tester); + + final sheetStart = fixture.session.events.length; + await tester.tap(find.byKey(_openNamedSheet)); + await tester.pumpAndSettle(); + final sheet = await fixture.waitForRoute( + tester, + navigation: 'route_push', + route: '/named-sheet', + after: sheetStart, + ); + await fixture.waitForCaptures(tester); + expect(sheet.afterFrame, isNotNull); + final sheetBottom = fixture.colorDominanceInBottom(sheet.afterFrame!); + expect(sheetBottom.greenDominant, greaterThan(20)); + }, + ); + + testWidgets('nested Navigator with observer records nested transition', ( + tester, + ) async { + final fixture = await _ModalVisualFixture.mount(tester); + final hostStart = fixture.session.events.length; + await tester.tap(find.byKey(_openNestedHost)); + await tester.pumpAndSettle(); + await fixture.waitForCaptures(tester); + // Opening the nested host also bootstraps the nested Navigator's initial + // route; pick the root host push by name. + final hostPush = await fixture.waitForRoute( + tester, + navigation: 'route_push', + route: '/nested-host', + after: hostStart, + ); + + final start = fixture.session.events.length; + await tester.tap(find.byKey(_openNestedSheet)); + await tester.pumpAndSettle(); + final push = await fixture.waitForRoutePush(tester, after: start); + await fixture.waitForCaptures(tester); + + expect(push.afterFrame, isNotNull); + expect(push.data['navigatorId'], isNotNull); + expect(hostPush.data['navigatorId'], isNotNull); + expect( + push.data['navigatorId'], + isNot(hostPush.data['navigatorId']), + reason: 'nested Navigator must own a distinct navigatorId', + ); + }); + + testWidgets( + 'unobserved nested Navigator does not fabricate a root transition', + (tester) async { + final fixture = await _ModalVisualFixture.mount( + tester, + installNestedObserver: false, + ); + await tester.tap(find.byKey(_openNestedHost)); + await tester.pumpAndSettle(); + await fixture.waitForCaptures(tester); + + final before = fixture.session.events + .where((e) => e.type == 'route_change') + .length; + await tester.tap(find.byKey(_openNestedSheet)); + await tester.pumpAndSettle(); + await fixture.waitForCaptures(tester); + + final after = fixture.session.events + .where((e) => e.type == 'route_change') + .length; + expect( + after, + before, + reason: + 'unobserved nested navigation must not fabricate a root transition', + ); + }, + ); + + testWidgets('out-of-boundary surface must not borrow a prior Flutter frame', ( + tester, + ) async { + final fixture = await _ModalVisualFixture.mount(tester); + final realFrames = fixture.session.frames + .where((f) { + final bytes = fixture.session.frameBytes[f.id]; + return bytes != null && bytes.isNotEmpty; + }) + .map((f) => f.id) + .toSet(); + expect(realFrames, isNotEmpty); + + // Unsupported / out-of-boundary surfaces must never re-label a prior + // Flutter raster as proof. Real frames keep their own JPG identity. + for (final frameId in realFrames) { + final bytes = fixture.session.frameBytes[frameId]!; + expect(img.decodeJpg(Uint8List.fromList(bytes)), isNotNull); + expect( + fixture.session.frameById(frameId)?.contentHash, + isNot(equals('out_of_capture_boundary')), + ); + } + }); + + testWidgets( + 'repeated named modal does not collapse ownership by name alone', + (tester) async { + final fixture = await _ModalVisualFixture.mount(tester); + + Future openNamed() async { + final start = fixture.session.events.length; + await tester.tap(find.byKey(_openNamedSheet)); + await tester.pumpAndSettle(); + final push = await fixture.waitForRoute( + tester, + navigation: 'route_push', + route: '/named-sheet', + after: start, + ); + await fixture.waitForCaptures(tester); + return push; + } + + final first = await openNamed(); + await tester.tap(find.byKey(_closeSheet)); + await tester.pumpAndSettle(); + await fixture.waitForCaptures(tester); + final second = await openNamed(); + + expect(first.data['route'], '/named-sheet'); + expect(second.data['route'], '/named-sheet'); + expect(first.data['routeInstanceId'], isNotNull); + expect(second.data['routeInstanceId'], isNotNull); + expect( + first.data['routeInstanceId'], + isNot(second.data['routeInstanceId']), + reason: 'repeated named modals keep distinct routeInstanceId values', + ); + expect(first.afterFrame, isNot(second.afterFrame)); + expect( + fixture.colorDominanceInBottom(first.afterFrame!).greenDominant, + greaterThan(20), + ); + expect( + fixture.colorDominanceInBottom(second.afterFrame!).greenDominant, + greaterThan(20), + ); + }, + ); +} + +const _openAnonymousSheet = Key('modal-open-anonymous-sheet'); +const _stackAnonymousSheet = Key('modal-stack-anonymous-sheet'); +const _openNamedSheet = Key('modal-open-named-sheet'); +const _openNamedDialog = Key('modal-open-named-dialog'); +const _openDraggableSheet = Key('modal-open-draggable-sheet'); +const _closeSheet = Key('modal-close-sheet'); +const _closeDialog = Key('modal-close-dialog'); +const _openNestedHost = Key('modal-open-nested-host'); +const _openNestedSheet = Key('modal-open-nested-sheet'); + +const _config = TugboatReplayConfig( + profile: TugboatCaptureProfile.exploration, + settleDelay: Duration.zero, + enableGlobalPointerCapture: true, + capturePixelRatio: 1, + screenshotMaskLevel: TugboatScreenshotMaskLevel.explicitOnly, +); + +class _ColorDominance { + const _ColorDominance({ + required this.redDominant, + required this.greenDominant, + required this.blueDominant, + required this.tealDominant, + }); + + final int redDominant; + final int greenDominant; + final int blueDominant; + final int tealDominant; +} + +class _ModalVisualFixture { + _ModalVisualFixture(this.controller); + + final TugboatReplayController controller; + + TugboatSession get session => controller.session!; + + static Future<_ModalVisualFixture> mount( + WidgetTester tester, { + bool installNestedObserver = true, + }) async { + final nestedObserver = TugboatReplay.createNavigatorObserver(); + await tester.pumpWidget( + MaterialApp( + initialRoute: '/root', + navigatorObservers: [ + TugboatReplay.navigatorObserver, + ], + routes: { + '/root': (_) => _RootPage( + nestedObserver: installNestedObserver ? nestedObserver : null, + ), + }, + // Mirror Blend: SDK boundary outside the Navigator child via builder. + builder: (context, child) => + TugboatReplay.wrapApp(config: _config, child: child!), + ), + ); + final controller = await _pumpUntil( + tester, + () => TugboatReplay.controller, + description: 'mounted replay controller', + ); + await _pumpUntil( + tester, + () => controller.session, + description: 'active replay session', + ); + final fixture = _ModalVisualFixture(controller); + await fixture.waitForCaptures(tester); + return fixture; + } + + Future waitForCaptures(WidgetTester tester) async { + await tester.pump(); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 350)); + }); + await tester.pump(); + for (var i = 0; i < 16; i++) { + if (!controller.debugRouteCapturePending && + !controller.debugCaptureInFlight) { + break; + } + await tester.pump(); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pump(); + } + } + + Future waitForLatestRealFrame(WidgetTester tester) async { + return _pumpUntil(tester, () { + for (final frame in session.frames.reversed) { + final bytes = session.frameBytes[frame.id]; + if (bytes != null && bytes.isNotEmpty) return frame; + } + return null; + }, description: 'real encoded frame'); + } + + Future waitForRoute( + WidgetTester tester, { + required String navigation, + required String route, + required int after, + }) => _pumpUntil(tester, () { + for (final event in session.events.skip(after)) { + if (event.type == 'route_change' && + event.data['navigation'] == navigation && + event.data['route'] == route) { + return event; + } + } + return null; + }, description: '$navigation $route'); + + Future waitForRoutePush( + WidgetTester tester, { + required int after, + }) => _pumpUntil(tester, () { + for (final event in session.events.skip(after)) { + if (event.type == 'route_change' && + event.data['navigation'] == 'route_push') { + return event; + } + } + return null; + }, description: 'route_push'); + + img.Pixel sampleCenter(String frameId) { + final image = _decode(frameId); + return image.getPixel(image.width ~/ 2, image.height ~/ 2); + } + + _ColorDominance colorDominanceInBottom(String frameId) { + final image = _decode(frameId); + return _scan(image, startY: image.height ~/ 2, endY: image.height); + } + + _ColorDominance colorDominanceInCenter(String frameId) { + final image = _decode(frameId); + final top = (image.height * 0.25).floor(); + final bottom = (image.height * 0.75).floor(); + return _scan(image, startY: top, endY: bottom); + } + + _ColorDominance _scan( + img.Image image, { + required int startY, + required int endY, + }) { + var red = 0; + var green = 0; + var blue = 0; + var teal = 0; + final x0 = image.width ~/ 4; + final x1 = (image.width * 3) ~/ 4; + for (var y = startY; y < endY; y += 2) { + for (var x = x0; x < x1; x += 4) { + final p = image.getPixel(x, y); + final r = p.r.toInt(); + final g = p.g.toInt(); + final b = p.b.toInt(); + if (r > g + 40 && r > b + 40) red++; + if (g > r + 40 && g > b + 40) green++; + if (b > r + 40 && b > g + 40) blue++; + if (g > r + 40 && b > r + 40) teal++; + } + } + return _ColorDominance( + redDominant: red, + greenDominant: green, + blueDominant: blue, + tealDominant: teal, + ); + } + + img.Image _decode(String frameId) { + final bytes = session.frameBytes[frameId]; + expect(bytes, isNotNull, reason: 'frame $frameId must have bytes'); + expect(bytes!, isNotEmpty, reason: 'frame $frameId must be a real raster'); + final decoded = img.decodeJpg(Uint8List.fromList(bytes)); + expect(decoded, isNotNull, reason: 'frame $frameId must decode as JPEG'); + return decoded!; + } +} + +class _RootPage extends StatelessWidget { + const _RootPage({this.nestedObserver}); + + final NavigatorObserver? nestedObserver; + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFFCC0000), + body: ListView( + children: [ + FilledButton( + key: _openAnonymousSheet, + onPressed: () => _openSheet(context, named: false, stackable: true), + child: const Text('anonymous sheet'), + ), + FilledButton( + key: _openNamedSheet, + onPressed: () => _openSheet(context, named: true), + child: const Text('named sheet'), + ), + FilledButton( + key: _openNamedDialog, + onPressed: () => showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'dismiss', + routeSettings: const RouteSettings(name: '/named-dialog'), + pageBuilder: (context, _, _) => Center( + child: Material( + color: const Color(0xFF0033CC), + child: SizedBox( + width: 220, + height: 160, + child: Center( + child: FilledButton( + key: _closeDialog, + onPressed: () => Navigator.of(context).pop(), + child: const Text('close dialog'), + ), + ), + ), + ), + ), + ), + child: const Text('named dialog'), + ), + FilledButton( + key: _openDraggableSheet, + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.55, + minChildSize: 0.3, + maxChildSize: 0.9, + builder: (context, controller) => ColoredBox( + color: const Color(0xFF008888), + child: ListView( + controller: controller, + children: const [ + SizedBox(height: 24), + Center(child: Text('draggable')), + SizedBox(height: 400), + ], + ), + ), + ), + ), + child: const Text('draggable sheet'), + ), + FilledButton( + key: _openNestedHost, + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: '/nested-host'), + builder: (_) => _NestedHost(observer: nestedObserver), + ), + ), + child: const Text('nested host'), + ), + ], + ), + ); + + void _openSheet( + BuildContext context, { + required bool named, + bool stackable = false, + }) { + showModalBottomSheet( + context: context, + routeSettings: named ? const RouteSettings(name: '/named-sheet') : null, + backgroundColor: const Color(0xFF00AA00), + builder: (context) => ColoredBox( + color: const Color(0xFF00AA00), + child: SizedBox( + height: 180, + child: Column( + children: [ + FilledButton( + key: _closeSheet, + onPressed: () => Navigator.of(context).pop(), + child: const Text('close sheet'), + ), + if (stackable) + FilledButton( + key: _stackAnonymousSheet, + onPressed: () => + _openSheet(context, named: false, stackable: false), + child: const Text('stack sheet'), + ), + ], + ), + ), + ), + ); + } +} + +class _NestedHost extends StatelessWidget { + const _NestedHost({this.observer}); + + final NavigatorObserver? observer; + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFF884400), + body: Navigator( + observers: [if (observer != null) observer!], + onGenerateRoute: (settings) => MaterialPageRoute( + settings: settings, + builder: (context) => Center( + child: FilledButton( + key: _openNestedSheet, + onPressed: () => showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF00AA00), + builder: (context) => const ColoredBox( + color: Color(0xFF00AA00), + child: SizedBox( + height: 120, + child: Center(child: Text('nested sheet')), + ), + ), + ), + child: const Text('nested sheet'), + ), + ), + ), + ), + ); +} + +Future _pumpUntil( + WidgetTester tester, + T? Function() read, { + required String description, +}) async { + for (var attempt = 0; attempt < 100; attempt++) { + final value = read(); + if (value != null) return value; + await tester.pump(); + if (attempt > 0 && attempt % 10 == 0) { + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pump(); + } + } + fail('Timed out waiting for $description'); +} diff --git a/packages/tugboat/test/replay/navigation_origin_contract_test.dart b/packages/tugboat/test/replay/navigation_origin_contract_test.dart new file mode 100644 index 0000000..f3bdc9b --- /dev/null +++ b/packages/tugboat/test/replay/navigation_origin_contract_test.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/tugboat.dart'; + +import '../helpers/replay_coherence_harness.dart'; + +Map _roundTrip(Map json) => + Map.from(jsonDecode(jsonEncode(json)) as Map); + +/// Navigation-origin and causal-link contract (U10). +void main() { + test('route_change serializes automatic_or_unknown without a cause', () { + final event = TugboatEvent( + id: 'event-1', + atMs: 10, + type: 'route_change', + data: const { + 'route': '/dest', + 'navigation': 'route_push', + 'navigationOrigin': 'automatic_or_unknown', + }, + ); + final json = _roundTrip(event.toJson()); + final data = Map.from(json['data']! as Map); + expect(data['navigationOrigin'], 'automatic_or_unknown'); + expect(data.containsKey('causeEventId'), isFalse); + }); + + test('legacy route_change without origin remains readable as unknown', () { + final event = TugboatEvent( + id: 'event-legacy', + atMs: 1, + type: 'route_change', + data: const {'route': '/a', 'navigation': 'route_push'}, + ); + final json = _roundTrip(event.toJson()); + final data = Map.from(json['data']! as Map); + expect(data['navigationOrigin'], isNull); + final origin = + data['navigationOrigin'] as String? ?? 'automatic_or_unknown'; + expect(origin, 'automatic_or_unknown'); + }); + + test('interaction-caused route preserves the original tap id', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(12, 34)); + final tap = harness.controller.session!.ofType('tap').single; + + await harness.controller.route('route_push', harness.route('/dest')); + await harness.flushScheduler(); + + final change = harness.controller.session! + .ofType('route_change') + .lastWhere((e) => e.data['route'] == '/dest'); + expect(change.data['navigationOrigin'], 'interaction'); + expect(change.data['causeEventId'], tap.id); + }); + + test('timer redirect after tap settle has no causal event id', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(12, 34)); + harness.controller.recordPointerUp(const Offset(12, 34)); + await harness.flushScheduler(); + + await harness.controller.route('route_push', harness.route('/redirect')); + await harness.flushScheduler(); + + final change = harness.controller.session! + .ofType('route_change') + .lastWhere((e) => e.data['route'] == '/redirect'); + expect(change.data['navigationOrigin'], 'automatic_or_unknown'); + expect(change.data['causeEventId'], isNull); + }); + + test('cancelled pointer cannot claim a route', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(12, 34)); + harness.controller.recordPointerCancel(const Offset(12, 34)); + await harness.controller.route('route_push', harness.route('/x')); + await harness.flushScheduler(); + + final change = harness.controller.session!.ofType('route_change').last; + expect(change.data['navigationOrigin'], 'automatic_or_unknown'); + expect(change.data['causeEventId'], isNull); + }); + + test('swipe classification cannot claim a route', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(12, 80)); + harness.controller.markPendingTapAsSwipe(0); + await harness.controller.route('route_push', harness.route('/swipe')); + await harness.flushScheduler(); + + final change = harness.controller.session!.ofType('route_change').last; + expect(change.data['navigationOrigin'], 'automatic_or_unknown'); + expect(change.data['causeEventId'], isNull); + }); + + test('ambiguous multi-touch cannot claim a route', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(10, 10), pointer: 1); + harness.controller.recordPointerDown(const Offset(20, 20), pointer: 2); + await harness.controller.route('route_push', harness.route('/multi')); + await harness.flushScheduler(); + + final change = harness.controller.session!.ofType('route_change').last; + expect(change.data['navigationOrigin'], 'automatic_or_unknown'); + expect(change.data['causeEventId'], isNull); + }); + + test('superseded successor does not inherit the verified cause', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(12, 34)); + final tap = harness.controller.session!.ofType('tap').single; + + await harness.controller.route('route_push', harness.route('/first')); + await harness.pumpMicrotasks(); + + // Second navigation has no eligible unclaimed tap — cause was consumed. + await harness.controller.route('route_push', harness.route('/second')); + await harness.pumpQueueWork(); + + final changes = harness.controller.session!.ofType('route_change'); + final first = changes.where((e) => e.data['route'] == '/first').toList(); + final second = changes.where((e) => e.data['route'] == '/second').toList(); + + expect(first, isNotEmpty); + expect(first.first.data['navigationOrigin'], 'interaction'); + expect(first.first.data['causeEventId'], tap.id); + + expect(second, isNotEmpty); + expect(second.last.data['navigationOrigin'], 'automatic_or_unknown'); + expect(second.last.data['causeEventId'], isNull); + }); +} diff --git a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart index 6b12e34..5c40d07 100644 --- a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart @@ -121,7 +121,7 @@ Future _tearDownObservedApp(WidgetTester tester) async { void main() { testWidgets( - 'rapid Navigator successors retain evidence only for visible epoch', + 'automatic Navigator successor stays independent of the claimed tap', (tester) async { final navigatorKey = GlobalKey(); final controller = await _mountObservedApp( @@ -162,14 +162,12 @@ void main() { final settle = _ofType(session, 'tap_settled').single; expect(changes.map((event) => event.data['route']), ['/b']); expect(settle.relatedEventId, tap.id); - expect(settle.afterFrame, changes.single.afterFrame); - expect( - CoherenceInvariants.hasChronologicalChain( - events: session.events, - orderedEventIds: [tap.id, changes.single.id, settle.id], - ), - isTrue, + final observation = Map.from( + settle.data['settleObservation']! as Map, ); + expect(settle.afterFrame, isNull); + expect(observation['navigationOutcome'], 'same_route'); + expect(observation['routeEventId'], isNull); final routeFrame = changes.single.afterFrame; expect( routeFrame, @@ -250,14 +248,13 @@ void main() { expect(change.data['route'], '/home'); expect(settle.relatedEventId, tap.id); expect(settle.beforeFrame, isNull); - expect(settle.afterFrame, change.afterFrame); - expect( - CoherenceInvariants.hasChronologicalChain( - events: session.events, - orderedEventIds: [tap.id, change.id, settle.id], - ), - isTrue, + expect(settle.afterFrame, isNull); + final observation = Map.from( + settle.data['settleObservation']! as Map, ); + expect(observation['navigationOutcome'], 'same_route'); + expect(observation['routeEventId'], isNull); + expect(change.afterFrame, isNotNull); _expectEveryDiagnosticRequestIsResolvedOnce(session); expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); await harness.tearDownWidgetBacked(tester); diff --git a/packages/tugboat/test/replay/replay_programmatic_navigation_matrix_test.dart b/packages/tugboat/test/replay/replay_programmatic_navigation_matrix_test.dart new file mode 100644 index 0000000..edcddb4 --- /dev/null +++ b/packages/tugboat/test/replay/replay_programmatic_navigation_matrix_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/tugboat.dart'; + +import '../helpers/replay_coherence_harness.dart'; + +/// Programmatic / automatic navigation matrix (U11). +void main() { + void expectAutomatic(TugboatEvent change) { + expect(change.data['navigationOrigin'], 'automatic_or_unknown'); + expect(change.data['causeEventId'], isNull); + } + + test('direct push/replace/pop emit automatic_or_unknown', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + await harness.controller.route('route_push', harness.route('/a')); + await harness.controller.route('route_replace', harness.route('/b')); + await harness.controller.route('route_pop', harness.route('/a')); + await harness.pumpQueueWork(); + + final changes = harness.controller.session!.ofType('route_change'); + expect(changes.length, greaterThanOrEqualTo(3)); + for (final change in changes) { + expectAutomatic(change); + } + expect(harness.controller.session!.ofType('tap'), isEmpty); + expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); + }); + + test('service-style push without pointer stays automatic', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + await harness.controller.route('route_push', harness.route('/login')); + await harness.controller.route('route_replace', harness.route('/home')); + await harness.pumpQueueWork(); + + final home = harness.controller.session! + .ofType('route_change') + .where((e) => e.data['route'] == '/home') + .last; + expectAutomatic(home); + expect(home.afterFrame, isNotNull); + expect(harness.controller.session!.ofType('tap'), isEmpty); + }); + + test('auth redirect after settle does not claim prior tap', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(8, 8)); + harness.controller.recordPointerUp(const Offset(8, 8)); + await harness.pumpQueueWork(); + + await harness.controller.route('route_push', harness.route('/login')); + await harness.controller.route('route_replace', harness.route('/home')); + await harness.pumpQueueWork(); + + final home = harness.controller.session! + .ofType('route_change') + .where((e) => e.data['route'] == '/home') + .last; + expectAutomatic(home); + }); + + test( + 'automatic navigation overlapping tap settle stays independent', + () async { + final harness = ReplayCoherenceHarness( + settleDelay: const Duration(milliseconds: 100), + ); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.seedRouteState(route: '/source', signature: 'source'); + harness.controller.recordPointerDown(const Offset(8, 8)); + final tap = harness.controller.session!.ofType('tap').single; + harness.controller.recordPointerUp(const Offset(8, 8)); + await harness.pumpMicrotasks(); + + // The pointer event turn has ended, so this navigation must remain + // automatic even though the tap's settle delay is still active. + final automaticRoute = harness.controller.route( + 'route_push', + harness.route('/redirect'), + ); + harness.scheduler.advance(const Duration(milliseconds: 100)); + await harness.pumpQueueWork(); + await automaticRoute; + await harness.flushScheduler(); + + final redirect = harness.controller.session! + .ofType('route_change') + .lastWhere((event) => event.data['route'] == '/redirect'); + final settled = harness.controller.session! + .ofType('tap_settled') + .singleWhere((event) => event.relatedEventId == tap.id); + final observation = Map.from( + settled.data['settleObservation']! as Map, + ); + + expectAutomatic(redirect); + expect(observation['navigationOutcome'], 'same_route'); + expect(observation['routeEventId'], isNull); + expect( + observation['captureRequestId'], + isNot(redirect.data['captureRequestId']), + ); + }, + ); + + test( + 'automatic successor cannot replace a tap-caused route barrier', + () async { + final harness = ReplayCoherenceHarness( + settleDelay: const Duration(milliseconds: 100), + ); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(8, 8)); + final tap = harness.controller.session!.ofType('tap').single; + final tappedRoute = harness.controller.route( + 'route_push', + harness.route('/tapped'), + ); + harness.controller.recordPointerUp(const Offset(8, 8)); + + // Supersede the causally claimed route before its terminal frame is + // available. The redirect has no pointer cause and must not become the + // tap's route barrier through successor transfer. + final automaticRoute = harness.controller.route( + 'route_replace', + harness.route('/redirect'), + ); + await harness.pumpMicrotasks(); + harness.scheduler.advance(const Duration(milliseconds: 100)); + await harness.pumpQueueWork(); + await Future.wait([tappedRoute, automaticRoute]); + await harness.flushScheduler(); + + final redirect = harness.controller.session! + .ofType('route_change') + .lastWhere((event) => event.data['route'] == '/redirect'); + final settled = harness.controller.session! + .ofType('tap_settled') + .singleWhere((event) => event.relatedEventId == tap.id); + final observation = Map.from( + settled.data['settleObservation']! as Map, + ); + + expectAutomatic(redirect); + expect(observation['navigationOutcome'], 'navigation_unavailable'); + expect(observation['routeEventId'], isNull); + expect(settled.afterFrame, isNull); + }, + ); + + test('verified tap then automatic redirect keep distinct origins', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(3, 3)); + final tap = harness.controller.session!.ofType('tap').single; + await harness.controller.route('route_push', harness.route('/tapped')); + harness.controller.recordPointerUp(const Offset(3, 3)); + await harness.pumpQueueWork(); + + await harness.controller.route('route_push', harness.route('/redirect')); + await harness.pumpQueueWork(); + + final tapped = harness.controller.session! + .ofType('route_change') + .where((e) => e.data['route'] == '/tapped') + .last; + final redirect = harness.controller.session! + .ofType('route_change') + .where((e) => e.data['route'] == '/redirect') + .last; + + expect(tapped.data['navigationOrigin'], 'interaction'); + expect(tapped.data['causeEventId'], tap.id); + expectAutomatic(redirect); + }); + + test( + 'stack cleanup remove stays automatic without fabricated taps', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + await harness.controller.route('route_push', harness.route('/')); + await harness.controller.route('route_push', harness.route('/intro')); + await harness.controller.route('route_push', harness.route('/cleanup')); + await harness.controller.route('route_remove', harness.route('/intro')); + await harness.pumpQueueWork(); + + for (final change in harness.controller.session!.ofType('route_change')) { + expectAutomatic(change); + } + expect(harness.controller.session!.ofType('tap'), isEmpty); + expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); + }, + ); +} diff --git a/packages/tugboat/test/replay/tap_coordinate_transform_test.dart b/packages/tugboat/test/replay/tap_coordinate_transform_test.dart new file mode 100644 index 0000000..2315885 --- /dev/null +++ b/packages/tugboat/test/replay/tap_coordinate_transform_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/tugboat.dart'; + +/// Runtime pointer-to-frame transform capture (U13). +void main() { + setUp(TugboatReplay.resetForTest); + tearDown(TugboatReplay.resetForTest); + + Future mount( + WidgetTester tester, { + EdgeInsets padding = EdgeInsets.zero, + double capturePixelRatio = 1, + }) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => TugboatReplay.wrapApp( + config: TugboatReplayConfig( + profile: TugboatCaptureProfile.exploration, + settleDelay: Duration.zero, + enableGlobalPointerCapture: true, + capturePixelRatio: capturePixelRatio, + screenshotMaskLevel: TugboatScreenshotMaskLevel.explicitOnly, + ), + child: child!, + ), + home: MediaQuery( + data: MediaQueryData(padding: padding), + child: Scaffold( + body: SafeArea( + child: Center( + child: ColoredBox( + color: const Color(0xFF222222), + child: SizedBox( + width: 200, + height: 200, + child: FilledButton( + key: const Key('target'), + onPressed: () {}, + child: const Text('tap'), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 350)), + ); + await tester.pump(); + final controller = TugboatReplay.controller; + expect(controller, isNotNull); + expect(controller!.session, isNotNull); + return controller; + } + + testWidgets('edge taps emit available captureCoordinate inside boundary', ( + tester, + ) async { + final controller = await mount( + tester, + padding: const EdgeInsets.only(top: 48, bottom: 24), + ); + final center = tester.getCenter(find.byKey(const Key('target'))); + controller.recordPointerDown(center); + final tap = controller.session!.events.where((e) => e.type == 'tap').last; + expect(tap.data['x'], center.dx); + expect(tap.data['y'], center.dy); + final coord = Map.from( + tap.data['captureCoordinate']! as Map, + ); + expect(coord['version'], 1); + expect(coord['unavailableReason'], isNull); + expect(coord['normalizedX'], inInclusiveRange(0.0, 1.0)); + expect(coord['normalizedY'], inInclusiveRange(0.0, 1.0)); + expect(coord['frameId'], isNotNull); + + final restored = TugboatCaptureCoordinate.fromJson(coord); + final raster = restored.projectToRaster(); + expect(raster, isNotNull); + expect(raster!.x, inInclusiveRange(0, restored.framePixelWidth - 1)); + expect(raster.y, inInclusiveRange(0, restored.framePixelHeight - 1)); + }); + + testWidgets('outside-boundary tap is unavailable without clamping', ( + tester, + ) async { + final controller = await mount(tester); + // Far outside the capture boundary / screen. + controller.recordPointerDown(const Offset(-80, -80)); + final tap = controller.session!.events.where((e) => e.type == 'tap').last; + final coord = Map.from( + tap.data['captureCoordinate']! as Map, + ); + expect(coord['unavailableReason'], 'outside_boundary'); + expect(tap.data['x'], -80); + expect(tap.data['y'], -80); + }); + + testWidgets('capture ratio below 1.0 still projects within one pixel', ( + tester, + ) async { + final controller = await mount(tester, capturePixelRatio: 0.5); + // Ensure a real before-frame exists at the reduced ratio. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 350)), + ); + await tester.pump(); + final center = tester.getCenter(find.byKey(const Key('target'))); + controller.recordPointerDown(center); + final tap = controller.session!.events.where((e) => e.type == 'tap').last; + final coord = TugboatCaptureCoordinate.fromJson( + Map.from(tap.data['captureCoordinate']! as Map), + ); + if (!coord.isAvailable) { + // No compatible before-frame yet — still emit explicit unavailability. + expect(coord.unavailableReason, isNotNull); + return; + } + final raster = coord.projectToRaster()!; + final backX = raster.x / (coord.framePixelWidth - 1); + final backY = raster.y / (coord.framePixelHeight - 1); + expect((backX - coord.normalizedX).abs(), lessThan(0.02)); + expect((backY - coord.normalizedY).abs(), lessThan(0.02)); + }); + + testWidgets('resized boundary suppresses coordinates for the older frame', ( + tester, + ) async { + tester.view.physicalSize = const Size(600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = await mount(tester); + final priorFrame = controller.session!.frames.last.id; + + tester.view.physicalSize = const Size(900, 600); + await tester.pump(); + + final center = tester.getCenter(find.byKey(const Key('target'))); + controller.recordPointerDown(center); + final tap = controller.session!.events.where((e) => e.type == 'tap').last; + final coord = TugboatCaptureCoordinate.fromJson( + Map.from(tap.data['captureCoordinate']! as Map), + ); + + expect(tap.beforeFrame, isNull); + expect(coord.isAvailable, isFalse); + expect(coord.unavailableReason, 'generation_mismatch'); + expect(coord.frameId, priorFrame); + expect(coord.framePixelWidth, greaterThan(0)); + expect(coord.framePixelHeight, greaterThan(0)); + }); +} diff --git a/packages/tugboat/test/replay_coherence_characterization_test.dart b/packages/tugboat/test/replay_coherence_characterization_test.dart index c6c28d8..9019c74 100644 --- a/packages/tugboat/test/replay_coherence_characterization_test.dart +++ b/packages/tugboat/test/replay_coherence_characterization_test.dart @@ -83,12 +83,9 @@ void main() { frameContentHash: 'scan-pixels', ); - // Pointer-up enqueues tap_settled first. + // The observer callback runs while the pointer claim is active, proving + // that this route was caused by the tap. harness.controller.recordPointerDown(const Offset(20, 20)); - harness.controller.recordPointerUp(const Offset(20, 20)); - - // Navigation callback arrives while settle is queued. The settle must - // join this route epoch's capture instead of consuming the old frame. final routeFuture = harness.controller.route( 'route_push', harness.route( @@ -96,6 +93,7 @@ void main() { transitionDuration: const Duration(milliseconds: 200), ), ); + harness.controller.recordPointerUp(const Offset(20, 20)); // Pump queue work without advancing the route deadline: settle is now // waiting on the route barrier, not publishing stale evidence. @@ -165,7 +163,7 @@ void main() { ); test( - 'navigation before pointer-up shares one route capture across settles', + 'automatic route active before taps stays independent of both settles', () async { final harness = ReplayCoherenceHarness( settleDelay: const Duration(milliseconds: 20), @@ -200,10 +198,13 @@ void main() { final routeFrame = session.ofType('route_change').single.afterFrame; final settles = session.ofType('tap_settled'); expect(settles, hasLength(2)); - expect( - settles.map((event) => event.afterFrame), - everyElement(routeFrame), - ); + for (final settle in settles) { + final observation = Map.from( + settle.data['settleObservation']! as Map, + ); + expect(observation['navigationOutcome'], 'same_route'); + expect(observation['routeEventId'], isNull); + } expect( harness.capturer.triggers.where( (trigger) => trigger == TugboatFrameTrigger.route, @@ -214,7 +215,7 @@ void main() { }, ); - test('route just before tap settle boundary wins the capture slot', () async { + test('automatic route before tap settle stays independent', () async { final harness = ReplayCoherenceHarness( settleDelay: const Duration(milliseconds: 20), ); @@ -236,74 +237,86 @@ void main() { final session = harness.controller.session!; final routeFrame = session.ofType('route_change').single.afterFrame; - expect(session.ofType('tap_settled').single.afterFrame, routeFrame); + final settle = session.ofType('tap_settled').single; + final observation = Map.from( + settle.data['settleObservation']! as Map, + ); + expect(settle.afterFrame, isNot(routeFrame)); + expect(observation['navigationOutcome'], 'same_route'); + expect(observation['routeEventId'], isNull); expect( harness.capturer.triggers.where( (trigger) => trigger == TugboatFrameTrigger.route, ), hasLength(1), ); - expect(harness.capturer.triggers, isNot(contains(TugboatFrameTrigger.tap))); + expect( + harness.capturer.triggers.where( + (trigger) => trigger == TugboatFrameTrigger.tap, + ), + hasLength(1), + ); + }); + + test('automatic route during tap readback stays independent', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.seedRouteState(route: '/scan', signature: 'sig-scan'); + harness.capturer.blockNext = true; + harness.controller.recordPointerDown(const Offset(10, 10)); + harness.controller.recordPointerUp(const Offset(10, 10)); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + final routeFuture = harness.controller.route( + 'route_push', + harness.route('/home'), + ); + harness.capturer.completeBlocked('stale-tap-frame'); + await harness.flushScheduler(); + await routeFuture; + + final session = harness.controller.session!; + final routeChange = session.ofType('route_change').single; + final settle = session.ofType('tap_settled').single; + final observation = Map.from( + settle.data['settleObservation']! as Map, + ); + expect(settle.afterFrame, isNull); + expect(settle.result, isNot(TugboatInteractionResult.navigated)); + expect(observation['navigationOutcome'], 'same_route'); + expect(observation['routeEventId'], isNull); + expect(routeChange.afterFrame, isNotNull); }); test( - 'route starting during tap readback replaces the same-route observation', + 'automatic successors cannot replace a tap-caused route barrier', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - - harness.seedRouteState(route: '/scan', signature: 'sig-scan'); - harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - final routeFuture = harness.controller.route( - 'route_push', - harness.route('/home'), - ); - harness.capturer.completeBlocked('stale-tap-frame'); + harness.seedRouteState(route: '/root', signature: 'root'); + harness.controller.recordPointerDown(const Offset(1, 1)); + final a = harness.controller.route('route_push', harness.route('/a')); + harness.controller.recordPointerUp(const Offset(1, 1)); + final b = harness.controller.route('route_push', harness.route('/b')); + final c = harness.controller.route('route_push', harness.route('/c')); await harness.flushScheduler(); - await routeFuture; - - final session = harness.controller.session!; - final routeChange = session.ofType('route_change').single; - final settle = session.ofType('tap_settled').single; - expect(settle.afterFrame, routeChange.afterFrame); - expect(settle.stateAnchor, routeChange.stateAnchor); - expect(settle.result, TugboatInteractionResult.navigated); - expect( - settle.data['settleObservation'], - allOf( - containsPair('route', '/home'), - containsPair('routeEventId', routeChange.id), - ), + await Future.wait([a, b, c]); + final changes = harness.controller.session!.ofType('route_change'); + expect(changes.map((event) => event.data['route']), ['/c']); + final settle = harness.controller.session!.ofType('tap_settled').single; + final observation = Map.from( + settle.data['settleObservation']! as Map, ); + expect(settle.afterFrame, isNull); + expect(observation['navigationOutcome'], 'navigation_unavailable'); + expect(observation['routeEventId'], isNull); }, ); - test('successor chain resolves a waiting settle only to C', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - harness.seedRouteState(route: '/root', signature: 'root'); - harness.controller.recordPointerDown(const Offset(1, 1)); - final a = harness.controller.route('route_push', harness.route('/a')); - harness.controller.recordPointerUp(const Offset(1, 1)); - final b = harness.controller.route('route_push', harness.route('/b')); - final c = harness.controller.route('route_push', harness.route('/c')); - await harness.flushScheduler(); - await Future.wait([a, b, c]); - final changes = harness.controller.session!.ofType('route_change'); - expect(changes.map((event) => event.data['route']), ['/c']); - expect( - harness.controller.session!.ofType('tap_settled').single.afterFrame, - changes.single.afterFrame, - ); - }); - test('cancelling a settle deadline removes its scheduler entry', () async { final harness = ReplayCoherenceHarness( settleDelay: const Duration(milliseconds: 20), diff --git a/packages/tugboat/test/semantics_flags_compat_test.dart b/packages/tugboat/test/semantics_flags_compat_test.dart index 61c493e..6a83ac0 100644 --- a/packages/tugboat/test/semantics_flags_compat_test.dart +++ b/packages/tugboat/test/semantics_flags_compat_test.dart @@ -4,29 +4,23 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/src/semantics_flags_compat.dart'; void main() { - test('semanticsEnabledFromFlags returns null when enabled state is unknown', () { - expect( - semanticsEnabledFromFlags(SemanticsFlags.none), - isNull, - ); - }); + test( + 'semanticsEnabledFromFlags returns null when enabled state is unknown', + () { + expect(semanticsEnabledFromFlags(SemanticsFlags.none), isNull); + }, + ); test('semanticsEnabledFromFlags reads explicit enabled state', () { expect( semanticsEnabledFromFlags( - SemanticsFlags.none.copyWith( - hasEnabledState: true, - isEnabled: true, - ), + SemanticsFlags.none.copyWith(hasEnabledState: true, isEnabled: true), ), isTrue, ); expect( semanticsEnabledFromFlags( - SemanticsFlags.none.copyWith( - hasEnabledState: true, - isEnabled: false, - ), + SemanticsFlags.none.copyWith(hasEnabledState: true, isEnabled: false), ), isFalse, );