From 8c78fb79c9fc63aa0a21ac210789b8a461604f95 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Mon, 10 Aug 2026 16:29:27 +0530 Subject: [PATCH 01/10] slim session lifecycle payloads --- .../tugboat/lib/src/collector_http_sink.dart | 4 +- .../tugboat/lib/src/collector_mapper.dart | 44 ++++++++++---- .../test/collector_http_sink_test.dart | 8 +-- .../tugboat/test/collector_mapper_test.dart | 58 +++++++++++++++++-- 4 files changed, 90 insertions(+), 24 deletions(-) diff --git a/packages/tugboat/lib/src/collector_http_sink.dart b/packages/tugboat/lib/src/collector_http_sink.dart index 17b66b0..c402e68 100644 --- a/packages/tugboat/lib/src/collector_http_sink.dart +++ b/packages/tugboat/lib/src/collector_http_sink.dart @@ -399,9 +399,7 @@ class CollectorHttpSink implements TugboatCaptureSink { eventType == TugboatCollectorSessionEventType.sessionIdentify.wireValue || eventType == - TugboatCollectorSessionEventType.traitsUpdated.wireValue || - eventType == - TugboatCollectorSessionEventType.userChanged.wireValue); + TugboatCollectorSessionEventType.traitsUpdated.wireValue); final body = mapTugboatSessionLifecycleToCollectorSession( eventType: eventType, diff --git a/packages/tugboat/lib/src/collector_mapper.dart b/packages/tugboat/lib/src/collector_mapper.dart index 09e1b65..8add4b5 100644 --- a/packages/tugboat/lib/src/collector_mapper.dart +++ b/packages/tugboat/lib/src/collector_mapper.dart @@ -79,21 +79,43 @@ Map mapTugboatSessionLifecycleToCollectorSession({ Map? traits, String? traitsId, }) { - return { + final isSessionStart = + eventType == TugboatCollectorSessionEventType.sessionStart.wireValue; + final carriesUserId = + isSessionStart || + eventType == TugboatCollectorSessionEventType.sessionIdentify.wireValue || + eventType == TugboatCollectorSessionEventType.userChanged.wireValue; + final carriesTraits = + isSessionStart || + eventType == TugboatCollectorSessionEventType.sessionIdentify.wireValue || + eventType == TugboatCollectorSessionEventType.traitsUpdated.wireValue; + + final body = { 'sessionId': sessionId, - 'userId': userId ?? config.userId, 'eventType': eventType, 'triggeredAt': triggeredAt.toUtc().toIso8601String(), - 'platform': config.deviceInfo.platform, - 'fingerprintSchemaVersion': tugboatFingerprintSchemaVersion, - 'appInfo': config.appInfo.toJson(), - 'device': config.deviceInfo.toJson(), - 'ipInfo': config.ipInfo.toJson(), - 'locale': config.locale.toJson(), - // Full traits bag wins over traitsId pass-through. - if (traits != null) 'traits': traits, - if (traits == null && traitsId != null) 'traitsId': traitsId, }; + + if (carriesUserId) { + body['userId'] = userId ?? config.userId; + } + if (isSessionStart) { + final appInfo = Map.from(config.appInfo.toJson()) + ..remove('installationId') + ..remove('name'); + body.addAll({ + 'appInfo': appInfo, + 'device': config.deviceInfo.toJson(), + 'ipInfo': config.ipInfo.toJson(), + 'locale': config.locale.toJson(), + }); + } + if (carriesTraits) { + // Full traits bag wins over traitsId pass-through. + if (traits != null) body['traits'] = traits; + if (traits == null && traitsId != null) body['traitsId'] = traitsId; + } + return body; } /// Trailing digits from a tugboat frame id (`frame-12` → `12`). diff --git a/packages/tugboat/test/collector_http_sink_test.dart b/packages/tugboat/test/collector_http_sink_test.dart index 358ae9a..35f77fe 100644 --- a/packages/tugboat/test/collector_http_sink_test.dart +++ b/packages/tugboat/test/collector_http_sink_test.dart @@ -941,7 +941,7 @@ void main() { }, ); - test('session lifecycle without traits bag sends cached traitsId', () async { + test('session_start sends cached traitsId without adding it to session_end', () async { final sink = CollectorHttpSink( config: configForServer(), initialTraitsId: 'trt_cached', @@ -956,12 +956,12 @@ void main() { await sink.endSession(); final endPost = sessionPosts.last; expect(endPost['eventType'], 'session_end'); - expect(endPost['traitsId'], 'trt_cached'); + expect(endPost.containsKey('traitsId'), isFalse); expect(endPost.containsKey('traits'), isFalse); sink.dispose(); }); - test('setUserId posts user_changed with cached traits', () async { + test('setUserId posts user_changed without cached traits', () async { sessionResponseTraitsId = 'trt_user'; final sink = createIdentitySink( initialTraits: {'plan': 'pro'}, @@ -976,7 +976,7 @@ void main() { final changed = sessionPosts.last; expect(changed['eventType'], 'user_changed'); expect(changed['userId'], 'user_b'); - expect(changed['traits'], {'plan': 'pro'}); + expect(changed.containsKey('traits'), isFalse); expect(changed.containsKey('traitsId'), isFalse); sink.dispose(); }); diff --git a/packages/tugboat/test/collector_mapper_test.dart b/packages/tugboat/test/collector_mapper_test.dart index c5a2af2..8b15968 100644 --- a/packages/tugboat/test/collector_mapper_test.dart +++ b/packages/tugboat/test/collector_mapper_test.dart @@ -140,7 +140,7 @@ void main() { expect(mapped['build'], isNotNull); }); - test('maps session lifecycle payloads for collector sessions endpoint', () { + test('maps only session context on session_start', () { final mapped = mapTugboatSessionLifecycleToCollectorSession( eventType: TugboatCollectorSessionEventType.sessionStart.wireValue, sessionId: 'sess_123', @@ -151,14 +151,15 @@ void main() { expect(mapped['sessionId'], 'sess_123'); expect(mapped['eventType'], 'session_start'); expect(mapped['userId'], 'user_1'); - expect((mapped['appInfo'] as Map)['name'], 'Example App'); + expect((mapped['appInfo'] as Map).containsKey('name'), isFalse); + expect((mapped['appInfo'] as Map).containsKey('installationId'), isFalse); expect((mapped['appInfo'] as Map)['appId'], 'com.example.app'); expect((mapped['appInfo'] as Map)['packageName'], 'com.example.app'); expect((mapped['device'] as Map)['platform'], 'ios'); expect((mapped['ipInfo'] as Map)['ip'], '127.0.0.1'); expect((mapped['locale'] as Map)['language'], 'en'); - expect(mapped['platform'], 'ios'); - expect(mapped['fingerprintSchemaVersion'], tugboatFingerprintSchemaVersion); + expect(mapped.containsKey('platform'), isFalse); + expect(mapped.containsKey('fingerprintSchemaVersion'), isFalse); expect(mapped.containsKey('traits'), isFalse); expect(mapped.containsKey('traitsId'), isFalse); }); @@ -176,21 +177,66 @@ void main() { expect(mapped['eventType'], 'traits_updated'); expect(mapped['traits'], {'plan': 'pro'}); expect(mapped.containsKey('traitsId'), isFalse); + expect(mapped.containsKey('appInfo'), isFalse); + expect(mapped.containsKey('device'), isFalse); + expect(mapped.containsKey('userId'), isFalse); }); - test('session map sends traitsId when no traits bag is provided', () { + test('session map sends no repeated context on session_end', () { final mapped = mapTugboatSessionLifecycleToCollectorSession( eventType: TugboatCollectorSessionEventType.sessionEnd.wireValue, sessionId: 'sess_123', triggeredAt: DateTime.utc(2026, 6, 19), config: collectorConfig, + userId: 'user_1', traitsId: 'trt_cached', ); - expect(mapped['traitsId'], 'trt_cached'); + expect(mapped, { + 'sessionId': 'sess_123', + 'eventType': 'session_end', + 'triggeredAt': '2026-06-19T00:00:00.000Z', + }); expect(mapped.containsKey('traits'), isFalse); }); + test('session_identify sends only its user and traits changes', () { + final mapped = mapTugboatSessionLifecycleToCollectorSession( + eventType: TugboatCollectorSessionEventType.sessionIdentify.wireValue, + sessionId: 'sess_123', + triggeredAt: DateTime.utc(2026, 6, 19), + config: collectorConfig, + userId: 'user_2', + traits: {'plan': 'pro'}, + ); + + expect(mapped, { + 'sessionId': 'sess_123', + 'eventType': 'session_identify', + 'triggeredAt': '2026-06-19T00:00:00.000Z', + 'userId': 'user_2', + 'traits': {'plan': 'pro'}, + }); + }); + + test('user_changed sends only its user-id change', () { + final mapped = mapTugboatSessionLifecycleToCollectorSession( + eventType: TugboatCollectorSessionEventType.userChanged.wireValue, + sessionId: 'sess_123', + triggeredAt: DateTime.utc(2026, 6, 19), + config: collectorConfig, + userId: null, + traits: {'mustNot': 'send'}, + ); + + expect(mapped, { + 'sessionId': 'sess_123', + 'eventType': 'user_changed', + 'triggeredAt': '2026-06-19T00:00:00.000Z', + 'userId': 'user_1', + }); + }); + test('event map includes optional traitsId', () { final mapped = mapTugboatEventToCollectorEvent( event: TugboatEvent(id: 'event-1', atMs: 0, type: 'tap'), From 5106df9ebeae7047b42627e07987f452b6a9faaf Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Mon, 10 Aug 2026 18:07:36 +0530 Subject: [PATCH 02/10] chore: ignore VS Code settings --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3b544ae..482dd1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .dart_tool/ .idea/ +.vscode/ .DS_Store *.iml coverage/ From da7e907692cdcfd3b2a012e93255c2455812fc15 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Mon, 10 Aug 2026 21:48:14 +0530 Subject: [PATCH 03/10] feat: remove state identity from SDK events --- docs/README.md | 2 +- docs/design/capture-and-fingerprint.md | 56 +- docs/integration/collector.md | 11 +- .../production-replay-acceptance.md | 18 +- packages/tugboat/CHANGELOG.md | 10 + packages/tugboat/README.md | 45 +- packages/tugboat/example/pubspec.yaml | 2 +- packages/tugboat/lib/src/anchor_models.dart | 3 - .../tugboat/lib/src/collector_mapper.dart | 1 - packages/tugboat/lib/src/controller.dart | 482 ++++++++++++------ packages/tugboat/lib/src/debug_logging.dart | 3 +- .../lib/src/interaction_transaction.dart | 6 +- packages/tugboat/lib/src/models.dart | 13 +- packages/tugboat/lib/src/sdk_version.dart | 2 +- .../lib/src/viewport_semantic_session.dart | 49 +- packages/tugboat/pubspec.yaml | 2 +- .../tugboat/test/collector_mapper_test.dart | 2 +- .../test/replay/capture_diagnostics_test.dart | 1 - .../replay/interaction_transaction_test.dart | 34 +- .../replay/modal_capture_visual_test.dart | 8 +- ...ay_navigation_interaction_matrix_test.dart | 5 +- .../replay_navigation_race_matrix_test.dart | 14 +- ...overlay_nested_navigation_matrix_test.dart | 46 +- ...eplay_coherence_characterization_test.dart | 164 +++++- .../tugboat/test/scene_inventory_test.dart | 15 +- .../tugboat/test/scroll_attribution_test.dart | 157 ++++++ .../tugboat/test/tugboat_replay_test.dart | 15 +- .../test/viewport_semantic_map_test.dart | 97 +++- packages/tugboat_dio/CHANGELOG.md | 7 + packages/tugboat_dio/README.md | 6 +- packages/tugboat_dio/pubspec.yaml | 4 +- 31 files changed, 957 insertions(+), 323 deletions(-) diff --git a/docs/README.md b/docs/README.md index f46df41..a9264f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ should be verified in their own repositories. ## Current compatibility -- package version: `0.7.0`; +- package version: `0.8.0`; - session JSON schema: `9`; - fingerprint schema: `6`; - minimum Dart SDK: `3.9.2`; diff --git a/docs/design/capture-and-fingerprint.md b/docs/design/capture-and-fingerprint.md index 2acc96d..7c14668 100644 --- a/docs/design/capture-and-fingerprint.md +++ b/docs/design/capture-and-fingerprint.md @@ -98,14 +98,14 @@ The event stream currently includes: - lifecycle: `session_start`, `session_end`; - pointer intent and outcome: `tap`, `tap_settled`, `swipe`, `pointer_cancel`, `tap_outside_tree`; -- navigation and state: `route_change`, `state_change`; +- navigation: `route_change`; - scrolling: `scroll_start`, `scroll_end`; - exploration control: `scene_inventory`, `action_window_set`, `action_window_cleared`; - optional semantic evidence: `viewport_semantic_map`, `scroll_semantic_snapshot`. -Events may carry `beforeFrame`, `afterFrame`, `stateAnchor`, `targetAnchor`, +Events may carry `beforeFrame`, `afterFrame`, `targetAnchor`, `relatedEventId`, `explorationRunId`, `actionId`, an interaction result, and type-specific `data`. Route transition values live in `route_change.data`, not in a session-level route dictionary. @@ -122,10 +122,12 @@ observed route epoch. A capture that is unavailable, cancelled, superseded, or timed out is represented by bounded capture/attachment diagnostics instead of borrowing the latest frame from another screen. -Frame requests are serialized, may coalesce, and use fresh-paint/readback -checks before publishing. Their provenance records the capture context and -completion state, so exact-content and perceptual deduplication reuse frames -only within a compatible context. `paused` and `hidden` request a delivery +Frame requests are serialized and use fresh-paint/readback checks before +publishing. Non-interaction requests can coalesce and reuse exact-content or +perceptual frames only within a compatible context. Each completed interaction +uses a separate fresh request and cannot coalesce or reuse either type of +frame. Provenance records capture context without state identity. `paused` and +`hidden` request a delivery flush after 500 ms; `resumed` cancels that pending flush; `detached`, wrapper disposal, and deactivation end the session once and initiate sink shutdown. @@ -191,26 +193,15 @@ fingerprint parts for diagnosis. `TugboatTag` and a stable `ValueKey` can add a high-confidence `tagFingerprint`. A tag is transparent to structural identity: adding it does -not change the target fingerprint or state signature. `TugboatSubView` adds a -developer-owned subview label for route-internal state and scroll attribution. +not change the target fingerprint. `TugboatSubView` adds a developer-owned +subview label for route-internal state and scroll attribution. ### State identity -Schema v6 deliberately uses coarse state identity. `stateSignature` hashes: - -- `routeKey`; -- keyboard-open state; -- modal-open state; -- the active `TugboatSubView.label`, when present; -- the fingerprint schema version during computation. - -Actionable role counts are emitted as diagnostic `actionableSummary` metadata -but do not determine the signature. Dynamic list length, visible rows, and -control multiplicity therefore do not fork a screen state. - -The schema version is also serialized beside the hash. Downstream joins must -include build identity and `fingerprintSchemaVersion`; v5 and v6 signatures are -not interchangeable. +Version 0.8.0 does not write state identity. State anchors and signatures remain +internal legacy model data only. New event, inventory, semantic-map, diagnostic, +debug, and provenance JSON omit them. Use route evidence, target anchors, and +frame hashes for raw replay facts. ### Confidence @@ -256,19 +247,24 @@ The default mask policy is profile-dependent: The public mask levels are `explicitOnly`, `allTextAndMedia`, `allText`, `allTextExceptActionable`, and `sensitiveInputsOnly`. -Capture uses a 9x8 perceptual dHash before PNG encoding to skip a visually -unchanged raster, then SHA-256 content hashing to deduplicate encoded frames. -Capture requests are serialized and coalesced; repeated state signatures are -also skipped unless a caller forces capture. +Non-interaction capture uses a 9x8 perceptual dHash before PNG encoding to +skip a visually unchanged raster, then SHA-256 content hashing to deduplicate +encoded frames. Capture requests are serialized and compatible non-interaction +requests can coalesce. Each completed interaction requests a forced fresh +after-frame. It cannot be suppressed by local-WebSocket non-interaction +suppression, dHash, or content-hash deduplication. A fresh route capture can +satisfy its claimed interaction. PNG readback and encoding still happen through Flutter image APIs on the UI isolate. Platform views, video textures, maps, and native overlays may be absent or incomplete in repaint-boundary output. When the exploration WebSocket connects and there is no HTTP collector, the -controller suppresses new Flutter screenshots for UI-thread performance. -Events, anchors, inventories, and semantic evidence continue to stream. Any -frames captured before connection are still sent. +controller suppresses only non-interaction Flutter screenshots for UI-thread +performance. Each completed interaction still encodes a fresh screenshot. A +causally claimed route capture also remains enabled. Events, anchors, +inventories, and semantic evidence continue to stream. Any frames captured +before connection are still sent. ## Viewport semantics diff --git a/docs/integration/collector.md b/docs/integration/collector.md index 459d789..987beb2 100644 --- a/docs/integration/collector.md +++ b/docs/integration/collector.md @@ -80,9 +80,12 @@ message is dropped when the bound is exceeded. The queue is not persisted. ### Exploration screenshot suppression When the WebSocket connects and no HTTP collector is configured, the controller -suppresses new Flutter screenshot capture to reduce UI-thread work. Events, -anchors, scene inventories, and enabled viewport semantic evidence continue to -stream. Frames captured before the socket connects can still be sent. +suppresses non-interaction Flutter screenshot capture to reduce UI-thread work. +Each completed interaction still captures and encodes a fresh screenshot. A +route capture claimed by that interaction also bypasses this suppression. +Events, anchors, scene inventories, and enabled viewport semantic evidence +continue to stream. Frames captured before the socket connects can still be +sent. The external exploration runner may record its own before/after screenshots, but that behavior is not implemented or guaranteed by this Flutter package. @@ -198,7 +201,7 @@ Event payloads contain: - optional user/session/run/action IDs; - optional `traitsId` (pass-through only; does not upsert the traits dictionary); - optional before/after frame references, related-event ID, and result; -- serialized state and target anchors; +- serialized target anchors, when captured; - event-specific data under `payload`; - build identity: app ID, platform, version name, build number, and fingerprint schema version. diff --git a/docs/integration/production-replay-acceptance.md b/docs/integration/production-replay-acceptance.md index 0b941d6..523e533 100644 --- a/docs/integration/production-replay-acceptance.md +++ b/docs/integration/production-replay-acceptance.md @@ -12,11 +12,13 @@ database receipt alone as proof that a replay is correct. ## Current acceptance status -The current SDK release candidate is **0.7.0**, which writes session schema +The current SDK release candidate is **0.8.0**, which writes session schema **v9**. It preserves structural interaction replay while no longer emitting -`controlValue`, `controlValueTransition`, or `semanticAnnotation` in event -data. Treat the absence of those fields as the expected privacy boundary, not -as missing capture evidence. +`controlValue`, `controlValueTransition`, `semanticAnnotation`, `stateAnchor`, +or `stateSignature` in new writer output. It also does not emit `state_change`. +Treat the absence of those fields as the expected privacy boundary, not as +missing capture evidence. Deploy the related collector change with this SDK +release. [`production-replay-acceptance-0.4.15.md`](./production-replay-acceptance-0.4.15.md) remains a historical acceptance record for interaction consolidation. Do not @@ -164,17 +166,17 @@ flows share a session, list the event IDs or timestamps that delimit each flow. Wait until the collector session has finalized and the replay is available in the production website. Filter to the recorded Blend build and SDK version -under test (`0.7.0` for this release), then open every recorded session. +under test (`0.8.0` for this release), then open every recorded session. For each interaction, inspect the actual replay UI and verify: - the tap marker lands on the control visible in its before-frame; -- target anchor, route, state signature, and frame describe the same screen; +- target anchor, route, and frame describe the same screen; - a navigation-producing tap does not emit an early unrelated `noVisibleChange`; - an action on a destination screen does not reuse an origin-screen frame; -- a changed outcome has either a fresh visual frame or an explicit - semantic-only/degraded classification; +- a changed outcome has fresh visual evidence; a missing screenshot is + explicitly degraded and does not create a semantic-only result; - a route event shows the rendered destination rather than a splash, outgoing route, or partially advanced UI; - reused visual evidence has an explainable capture diagnostic; diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index a84035f..cbd824d 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.8.0 + +### Changed + +- Raw SDK writers no longer emit `stateAnchor`, `stateSignature`, or + `state_change` events. Completed interactions request one fresh after-frame. +- Collector event mapping now omits `stateAnchor`. Deploy the serial collector + compatibility patch before sending 0.8.0 recordings to a collector that + still requires that key. + ## 0.7.0 ### Added diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 8c377eb..d4ef8d3 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -5,10 +5,19 @@ 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.7.0`. Session JSON writers emit schema +The current package version is `0.8.0`. Session JSON writers emit schema version `9`; compatibility readers should accept versions `6` through `9`. Structural fingerprints use fingerprint schema version `6`. +## 0.8.0 raw-event compatibility + +New writers omit `stateAnchor`, `stateSignature`, and `state_change` events. +Legacy public state model types remain available for source compatibility, but +new recordings do not write them. Each completed tap, swipe, and scroll +requests its own fresh after-frame. The collector mapper also omits the top- +level `stateAnchor` key. Deploy the related collector change with this SDK +release. + ## Install Add `tugboat` to the host app and import the public barrel: @@ -23,7 +32,7 @@ The package requires Dart 3.9.2 or newer and Flutter 3.35.0 or newer. ```yaml dependencies: - tugboat_dio: ^0.7.0 + tugboat_dio: ^0.8.0 ``` See `packages/tugboat_dio/README.md`. @@ -324,7 +333,7 @@ making historical recordings unreadable: cannot yet read canonical `interaction` records. Do not enable either legacy mode in a new application integration. Consumers -must use `interaction` as the user action and treat route/state/frame records as +must use `interaction` as the user action and treat route/frame records as linked evidence. Historical `tap` and `tap_settled` rows may still be read and correlated through `interactionId` / `relatedEventId`, but must not be counted as additional user actions. @@ -415,7 +424,7 @@ Emitted inferred event types currently include: `tap_outside_tree`, `tap_gesture_resolved`; - lifecycle: `session_start`, `session_identify`, `session_end`; - input: `pointer_cancel` (`stream: evidence`); -- state/navigation evidence (`stream: evidence`): `state_change`, `route_change` +- navigation evidence (`stream: evidence`): `route_change` (claimed routes also carry `causedByInteractionId`); - scrolling evidence (`stream: evidence`): `scroll_start`, `scroll_end`; - diagnostics: `capture_diagnostic` (`stream: diagnostic`); @@ -431,11 +440,13 @@ Rage-tap style insights must count finalized `gesture=tap` interactions with no successful `navigated`/`changed` result; exclude scrolls, swipes, cancellations, evidence, legacy projections, and diagnostics. -Frames can be triggered by initial startup, taps, scrolls, routes, lifecycle, -or explicit controller calls. Capture requests are serialized and coalesced. -The SDK first skips repeated state signatures, then uses a small dHash to avoid -PNG encoding for visually unchanged content, and finally deduplicates encoded -frames by content hash. +Frames can be triggered by initial startup, interactions, routes, lifecycle, +or explicit controller calls. Capture requests are serialized. Non-interaction +requests can coalesce and use dHash or content-hash deduplication. Each +completed tap, swipe, and scroll gets a forced fresh after-frame request. It is +not suppressed by local-WebSocket non-interaction suppression, dHash, or +content-hash deduplication. A claimed route capture can satisfy that interaction +when it is its fresh frame. Pointer coordinates in event data (`x`, `y`, and swipe `startX`/`startY`) are Flutter global logical-pixel coordinates from the pointer event. The SDK @@ -445,7 +456,7 @@ capture-boundary-normalized for replay playback. Do not interpret them as physical pixels or as coordinates relative to an individual widget. Fractional overlay drift is therefore still possible. -For a tap, origin context (`stateAnchor`, target, `beforeFrame`, +For a tap, origin context (target, `beforeFrame`, `captureCoordinate`, route/navigator identity) is frozen at pointer-down into an `InteractionTransaction`. After pointer-up, settlement waits for either the first eligible visible successor inside `interactionClaimWindow` (default @@ -458,9 +469,11 @@ additional semantic actions. A missing attachment is explicit in frame. During local WebSocket exploration, connecting without an HTTP collector -suppresses new Flutter screenshot capture for UI-thread performance. Events, -anchors, inventories, and semantic evidence continue to stream; the CLI's ADB -before/after screenshots remain the primary gesture-level visual evidence. +suppresses only non-interaction Flutter screenshot capture for UI-thread +performance. Every completed interaction still encodes a fresh screenshot. +This includes a causally claimed route capture. Events, anchors, inventories, +and semantic evidence continue to stream; the CLI's ADB before/after screenshots +remain the primary gesture-level visual evidence. ## Structural identity @@ -482,11 +495,6 @@ and scroll attribution. `widgetNames` can replace runtime type names used in canonical paths, which is particularly useful when an obfuscated build needs a generated stable-name map. -State signatures in v6 are deliberately coarse: route key plus keyboard, -modal, and subview state. Role counts remain diagnostic metadata but do not -determine the signature. Identity should be joined only within the same build -and fingerprint schema version. - ## Lifecycle - A session starts after the wrapped repaint boundary has a non-zero viewport. @@ -515,7 +523,6 @@ to an inferred event. The closed outcome vocabulary is: | `fresh_accepted` | A fresh frame was accepted. | | `exact_content_reused` | An exact content hash reused a compatible frame. | | `perceptual_hash_coalesced` | A perceptual hash reused a compatible frame. | -| `state_signature_short_circuit` | Compatible semantic state made capture unnecessary. | | `screenshot_budget_skip` | Degraded screenshot budget skipped eligible work. | | `superseded_route_epoch` | Navigation superseded the request's route epoch. | | `paint_readiness_timeout` | A fresh paint did not become available in time. | diff --git a/packages/tugboat/example/pubspec.yaml b/packages/tugboat/example/pubspec.yaml index 04b104a..c866076 100644 --- a/packages/tugboat/example/pubspec.yaml +++ b/packages/tugboat/example/pubspec.yaml @@ -32,7 +32,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - tugboat: ^0.7.0 + tugboat: ^0.8.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/packages/tugboat/lib/src/anchor_models.dart b/packages/tugboat/lib/src/anchor_models.dart index 806a4d7..a1be6a2 100644 --- a/packages/tugboat/lib/src/anchor_models.dart +++ b/packages/tugboat/lib/src/anchor_models.dart @@ -271,7 +271,6 @@ class TugboatSceneInventory { final List elements; Map toJson() => { - 'stateSignature': stateSignature, 'inventoryHash': inventoryHash, 'routeKey': routeKey, 'elements': elements.map((entry) => entry.toJson()).toList(), @@ -434,7 +433,6 @@ class TugboatViewportSemanticMap { } Map toJson() => { - 'stateSignature': stateSignature, 'routeKey': routeKey, 'viewport': {'width': viewport.width, 'height': viewport.height}, 'nodes': nodes.map((node) => node.toJson()).toList(), @@ -473,7 +471,6 @@ class TugboatScrollSemanticSnapshot { final String snapshotHash; Map toJson() => { - 'stateSignature': stateSignature, 'routeKey': routeKey, if (scrollableFingerprint != null) 'scrollableFingerprint': scrollableFingerprint, diff --git a/packages/tugboat/lib/src/collector_mapper.dart b/packages/tugboat/lib/src/collector_mapper.dart index 8add4b5..71a30a4 100644 --- a/packages/tugboat/lib/src/collector_mapper.dart +++ b/packages/tugboat/lib/src/collector_mapper.dart @@ -49,7 +49,6 @@ Map mapTugboatEventToCollectorEvent({ if (event.beforeFrame != null) 'beforeFrame': event.beforeFrame, if (event.afterFrame != null) 'afterFrame': event.afterFrame, if (traitsId != null) 'traitsId': traitsId, - 'stateAnchor': event.stateAnchor?.toJson() ?? {}, 'targetAnchor': event.targetAnchor?.toJson() ?? {}, if (event.result != null) 'result': event.result!.name, 'payload': payload, diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index 2037aa2..1cd44ca 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -45,6 +45,7 @@ class _ScrollTracker { required this.axis, required this.depth, required this.maxScrollExtent, + required this.pointerLinked, this.pageStart, }); @@ -61,14 +62,24 @@ class _ScrollTracker { final int depth; final double maxScrollExtent; final double? pageStart; + bool pointerLinked; int overscrollCount = 0; DateTime? lastSampleAt; } +/// Holds the scroll-end work until the matching global pointer-up arrives. +/// Flutter can deliver these callbacks in either order. +class _PendingScrollCompletion { + bool resolved = false; + String? afterFrame; + String? captureOutcome; +} + class _ScheduledCapture { _ScheduledCapture({ required this.trigger, required this.force, + required this.bypassesExplorationSuppression, required this.freshness, required this.notBefore, required this.enqueuedAt, @@ -77,6 +88,7 @@ class _ScheduledCapture { TugboatFrameTrigger trigger; bool force; + bool bypassesExplorationSuppression; _CaptureFreshness freshness; DateTime notBefore; DateTime enqueuedAt; @@ -89,6 +101,8 @@ class _ScheduledCapture { trigger = other.trigger; } force = force || other.force; + bypassesExplorationSuppression = + bypassesExplorationSuppression || other.bypassesExplorationSuppression; if (other.freshness == _CaptureFreshness.freshPaint) { freshness = _CaptureFreshness.freshPaint; } @@ -108,11 +122,15 @@ class _ScheduledCapture { } bool canAbsorb(_ScheduledCapture other) => + trigger != TugboatFrameTrigger.interaction && + other.trigger != TugboatFrameTrigger.interaction && context.compatibleWith(other.context); static int _triggerPriority(TugboatFrameTrigger trigger) { switch (trigger) { case TugboatFrameTrigger.manual: + return 7; + case TugboatFrameTrigger.interaction: return 6; case TugboatFrameTrigger.route: return 5; @@ -135,7 +153,6 @@ enum _CaptureOutcome { freshAccepted, exactContentReused, perceptualHashCoalesced, - stateSignatureShortCircuit, screenshotBudgetSkip, noFrameAvailable, noCompatibleFrame, @@ -151,8 +168,6 @@ extension on _CaptureOutcome { _CaptureOutcome.freshAccepted => 'fresh_accepted', _CaptureOutcome.exactContentReused => 'exact_content_reused', _CaptureOutcome.perceptualHashCoalesced => 'perceptual_hash_coalesced', - _CaptureOutcome.stateSignatureShortCircuit => - 'state_signature_short_circuit', _CaptureOutcome.screenshotBudgetSkip => 'screenshot_budget_skip', _CaptureOutcome.noFrameAvailable => 'no_frame_available', _CaptureOutcome.noCompatibleFrame => 'no_compatible_frame', @@ -258,8 +273,6 @@ class _CaptureRequestContext { final Rect? boundaryLogicalRect; final int boundaryTransformGeneration; - String? get stateSignature => stateAnchor?.signature; - bool compatibleWith(_CaptureRequestContext other) => captureSessionId == other.captureSessionId && routeEpoch == other.routeEpoch && @@ -347,12 +360,6 @@ class _FrameProvenance { 'trigger': context.trigger.name, 'requestedAtMs': context.requestedAtMs, 'completedAtMs': completedAtMs, - 'requestStateSignature': context.stateSignature, - 'completionStateSignature': completionStateAnchor?.signature, - if (context.stateAnchor != null) - 'requestStateAnchor': context.stateAnchor!.toJson(), - if (completionStateAnchor != null) - 'completionStateAnchor': completionStateAnchor!.toJson(), 'available': available, }; } @@ -830,6 +837,11 @@ class TugboatReplayController extends ChangeNotifier { int _routeEpoch = 0; final Map _activeRouteCaptures = {}; + // A route can finish before its pointer-up settle resumes from the delayed + // claim wait. Retain that causal barrier until the interaction consumes it. + final Map _causalRouteCaptures = + {}; + final Set _causalRouteSupersededInteractions = {}; String? _latestRouteCaptureKey; final Set<_TapSettleWork> _activeTapSettles = <_TapSettleWork>{}; @@ -843,7 +855,8 @@ class TugboatReplayController extends ChangeNotifier { static String _routeCaptureKey(String? navigatorId) => navigatorId ?? ''; final Map _scrollTrackers = {}; - String? _lastCapturedStateSignature; + final Map _scrollInteractions = {}; + final Map _pendingScrollCompletions = {}; final Set _emittedInventories = {}; SemanticsHandle? _semanticsHandle; String? _lastDHash; @@ -1001,6 +1014,13 @@ class TugboatReplayController extends ChangeNotifier { @visibleForTesting int get debugActiveTapSettleCount => _activeTapSettles.length; + @visibleForTesting + int get debugCausalRouteCaptureCount => _causalRouteCaptures.length; + + @visibleForTesting + int get debugCausalRouteSupersededInteractionCount => + _causalRouteSupersededInteractions.length; + @visibleForTesting List get debugScheduledCaptureRoutes { final routes = []; @@ -1174,7 +1194,7 @@ class TugboatReplayController extends ChangeNotifier { /// Debug helper for host apps to dump current semantic anchors (CLI diffing). @visibleForTesting Map debugExportSemanticSnapshot({Offset? tapPoint}) { - final stateAnchor = _refreshStateAnchor(); + _refreshStateAnchor(); final resolver = _anchorResolver; TugboatTargetAnchor? targetAnchor; if (tapPoint != null && resolver != null) { @@ -1187,7 +1207,6 @@ class TugboatReplayController extends ChangeNotifier { 'frameId': _compatibleFrameFor( _captureContext(TugboatFrameTrigger.manual), ), - 'stateAnchor': stateAnchor?.toJson(), if (tapPoint != null) 'tapPoint': {'x': tapPoint.dx, 'y': tapPoint.dy}, if (targetAnchor != null) 'targetAnchor': targetAnchor.toJson(), }; @@ -1371,6 +1390,7 @@ class TugboatReplayController extends ChangeNotifier { // orphan causal_only tap for a claim that will never be referenced. _abandonAllPendingPointers(publishClaimedTap: false); _clearReleasedInteractions(); + _clearScrollCompletionState(); _captureLifecycleActive = false; _addEvent( @@ -1426,11 +1446,12 @@ class TugboatReplayController extends ChangeNotifier { _latestFrameId = null; _clearReleasedInteractions(); _interactions.clearAll(); - _scrollTrackers.clear(); + _clearScrollCompletionState(); + _causalRouteCaptures.clear(); + _causalRouteSupersededInteractions.clear(); _hashToFrameId.clear(); _frameProvenance.clear(); _frameReuseObservations.clear(); - _lastCapturedStateSignature = null; _lastCaptureFailure = null; _emittedInventories.clear(); _viewportSemantics.clear(); @@ -1529,7 +1550,8 @@ class TugboatReplayController extends ChangeNotifier { if (force || trigger == TugboatFrameTrigger.initial || trigger == TugboatFrameTrigger.lifecycle || - trigger == TugboatFrameTrigger.tap) { + trigger == TugboatFrameTrigger.tap || + trigger == TugboatFrameTrigger.interaction) { return _CaptureFreshness.freshPaint; } return _CaptureFreshness.reusable; @@ -1782,6 +1804,7 @@ class TugboatReplayController extends ChangeNotifier { _requestCaptureCancellable({ required TugboatFrameTrigger trigger, bool force = false, + bool bypassExplorationSuppression = false, Duration? settleDelay, String? relatedEventId, }) { @@ -1792,10 +1815,13 @@ class TugboatReplayController extends ChangeNotifier { relatedEventId: relatedEventId, ); final freshness = _captureFreshnessFor(trigger, force); + final bypassesExplorationSuppression = + bypassExplorationSuppression || + trigger == TugboatFrameTrigger.interaction; if (_disposed || _capturePaused || _skipCapture || - _shouldSuppressFrameCapture) { + (_shouldSuppressFrameCapture && !bypassesExplorationSuppression)) { _refreshStateAnchor(); _maybeEmitSceneInventory(); _completeCaptureWaiter( @@ -1816,6 +1842,7 @@ class TugboatReplayController extends ChangeNotifier { final incoming = _ScheduledCapture( trigger: trigger, force: force, + bypassesExplorationSuppression: bypassesExplorationSuppression, freshness: freshness, notBefore: notBefore, enqueuedAt: now, @@ -1931,6 +1958,8 @@ class TugboatReplayController extends ChangeNotifier { execution = await _executeCapture( trigger: scheduled.trigger, force: scheduled.force, + bypassExplorationSuppression: + scheduled.bypassesExplorationSuppression, freshness: scheduled.freshness, context: scheduled.context.withTrigger(scheduled.trigger), queueWaitMicros: queueWaitMicros, @@ -2029,6 +2058,7 @@ class TugboatReplayController extends ChangeNotifier { Future<_CaptureExecution> _executeCapture({ required TugboatFrameTrigger trigger, bool force = false, + bool bypassExplorationSuppression = false, required _CaptureFreshness freshness, required _CaptureRequestContext context, required int queueWaitMicros, @@ -2060,15 +2090,13 @@ class TugboatReplayController extends ChangeNotifier { ? 'content_hash' : outcome == _CaptureOutcome.perceptualHashCoalesced ? 'dhash' - : outcome == _CaptureOutcome.stateSignatureShortCircuit - ? 'state_signature' : null, ); } if (_disposed || _capturePaused || _skipCapture || - _shouldSuppressFrameCapture || + (_shouldSuppressFrameCapture && !bypassExplorationSuppression) || _captureInFlight) { return _cancelledCaptureExecution( _captureInFlight ? 'capture_in_flight' : _captureSuppressionReason(), @@ -2204,21 +2232,6 @@ class TugboatReplayController extends ChangeNotifier { } _lastCaptureFailure = null; _refreshStateAnchor(); - final signature = _currentStateAnchor?.signature ?? ''; - if (!force && - !requiresFreshPaint && - trigger != TugboatFrameTrigger.initial && - signature.isNotEmpty && - signature == _lastCapturedStateSignature) { - final compatible = _compatibleFrameFor(context); - return _CaptureExecution( - outcome: compatible == null - ? _CaptureOutcome.noCompatibleFrame - : _CaptureOutcome.stateSignatureShortCircuit, - frameId: compatible, - reuseReason: compatible == null ? null : 'state_signature', - ); - } final activeSession = session; final completionStateAnchor = _snapshotStateAnchor(_refreshStateAnchor()); @@ -2258,9 +2271,6 @@ class TugboatReplayController extends ChangeNotifier { if (result.dHash != null) { _lastDHash = result.dHash; } - if (signature.isNotEmpty) { - _lastCapturedStateSignature = signature; - } _maybeEmitSceneInventory(); return _CaptureExecution( outcome: _CaptureOutcome.exactContentReused, @@ -2303,9 +2313,6 @@ class TugboatReplayController extends ChangeNotifier { if (result.dHash != null) { _lastDHash = result.dHash; } - if (signature.isNotEmpty) { - _lastCapturedStateSignature = signature; - } _maybeEmitSceneInventory(); _sinkHub?.recordFrame( frame, @@ -2409,6 +2416,7 @@ class TugboatReplayController extends ChangeNotifier { interactionId: eventId, stateAnchor: beforeState, route: _currentRoute, + routeEpoch: _routeEpoch, routeInstanceId: _currentRouteInstanceId, navigatorId: _currentNavigatorId, targetAnchor: target, @@ -2702,6 +2710,7 @@ class TugboatReplayController extends ChangeNotifier { } void _dropClaimBuffers(InteractionTransaction tx) { + _clearCausalRouteState(tx.id); tx.cancelled = true; tx.bufferedTap = null; tx.bufferedOutside = null; @@ -2714,6 +2723,8 @@ class TugboatReplayController extends ChangeNotifier { required InteractionRejectionReason reason, }) { if (tx.semanticPublished) return; + _clearCausalRouteState(tx.id); + _discardScrollCompletionFor(tx); tx.cancelled = true; tx.rejectionReason ??= reason; tx.attribution = InteractionAttribution.none; @@ -2766,6 +2777,7 @@ class TugboatReplayController extends ChangeNotifier { if (!_acceptsPointerInput) return; final pending = _interactions.removePending(pointer); if (pending != null) { + _discardScrollCompletionFor(pending); pending.gesture = InteractionGesture.cancelled; pending.rejectionReason ??= InteractionRejectionReason.lifecycle; if (pending.claimed) { @@ -2779,10 +2791,12 @@ class TugboatReplayController extends ChangeNotifier { } pending.resultStatus = InteractionResultStatus.cancelled; pending.resultObservedAtMs = atMs; + _clearCausalRouteState(pending.id); _publishCanonicalInteraction(pending); } final released = _interactions.removeReleased(pointer); if (released != null) { + _discardScrollCompletionFor(released); released.rejectionReason ??= InteractionRejectionReason.lifecycle; if (!released.tapEmitted) { _interactions.forgetId(released.id); @@ -2859,6 +2873,7 @@ class TugboatReplayController extends ChangeNotifier { : InteractionResultStatus.unchanged; pending.resultObservedAtMs = atMs; if (scrollStartEventId != null) pending.addEvidence(scrollStartEventId); + _clearCausalRouteState(pending.id); if (config.emitLegacyInteractionProjection) { _addEvent( TugboatEvent( @@ -2895,7 +2910,12 @@ class TugboatReplayController extends ChangeNotifier { ), ); } - _publishCanonicalInteraction(pending); + if (scrollStartEventId == null) { + _publishCompletedGestureAfterCapture(pending); + } else { + _scrollInteractions[scrollStartEventId] = pending; + _publishResolvedScrollInteraction(scrollStartEventId); + } if (!_disposed) notifyListeners(); return; } @@ -2958,26 +2978,78 @@ class TugboatReplayController extends ChangeNotifier { // 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 = + final interactionRouteEpoch = pending.origin.routeEpoch; + final interactionRoute = pending.origin.route; + var currentRouteCapture = _activeRouteCapture; + var routeCapture = initialRouteCapture ?? (currentRouteCapture?.change.causeEventId == pending.id ? currentRouteCapture - : null); + : _causalRouteCaptures[pending.id]); + if (routeCapture == null && config.settleDelay <= Duration.zero) { + // Flutter delivers some gesture callbacks, such as ModalBarrier + // dismissal, after pointer-up. Yield one microtask before starting a + // standalone screenshot so a synchronous route claim can own its + // forced route frame without leaving a pending timer in widget tests. + await Future.microtask(() {}); + if (!_isActiveTapSettle(work)) return; + currentRouteCapture = _activeRouteCapture; + routeCapture = currentRouteCapture?.change.causeEventId == pending.id + ? currentRouteCapture + : _causalRouteCaptures[pending.id]; + } _TapSettleObservation observation; if (routeCapture != null) { + _causalRouteCaptures.remove(pending.id); final routeBarrier = await _awaitRouteCaptureBarrier( routeCapture, expectedCauseEventId: pending.id, ); if (!_isActiveTapSettle(work)) return; - observation = _tapObservationFromRouteBarrier(routeBarrier); + // The route capture is a forced fresh frame and belongs to this + // claimed interaction. Reuse it here instead of scheduling a second + // interaction capture that can delay later route ownership. + final barrierWasSupersededByAutomatic = + routeBarrier.result.outcome == _RouteCaptureOutcome.cancelled && + routeBarrier.work.change.causeEventId == pending.id && + routeBarrier.work.supersededBy?.change.causeEventId != pending.id; + if (barrierWasSupersededByAutomatic) { + // The claimed route never produced a valid frame. Preserve the + // interaction capture attempt, but never attach the automatic + // successor's pixels or route event as this tap's evidence. + final capture = _requestCaptureCancellable( + trigger: TugboatFrameTrigger.interaction, + force: true, + settleDelay: Duration.zero, + relatedEventId: pending.id, + ); + work.attachCaptureCancellation((reason) => capture.cancel(reason)); + final captureResolution = await capture.resolution; + if (!_isActiveTapSettle(work)) return; + observation = _TapSettleObservation( + routeEpoch: _routeEpoch, + route: _currentRoute, + afterState: _snapshotStateAnchor(_refreshStateAnchor()), + afterFrame: null, + navigationOutcome: 'navigation_unavailable', + captureOutcome: 'superseded_route_epoch', + captureFailure: 'superseded_route_epoch', + captureRequestId: captureResolution.requestId, + ); + } else { + observation = _tapObservationFromRouteBarrier(routeBarrier); + } } else { final requestedRouteEpoch = _routeEpoch; final requestedRoute = _currentRoute; + final routeChangedFromOrigin = + requestedRouteEpoch != interactionRouteEpoch || + requestedRoute != interactionRoute || + _causalRouteSupersededInteractions.contains(pending.id); final semanticAfterState = _snapshotStateAnchor(_refreshStateAnchor()); final capture = _requestCaptureCancellable( - trigger: TugboatFrameTrigger.tap, + trigger: TugboatFrameTrigger.interaction, + force: true, settleDelay: Duration.zero, relatedEventId: pending.id, ); @@ -2988,18 +3060,25 @@ class TugboatReplayController extends ChangeNotifier { final provenance = afterFrame == null ? null : _frameProvenance[afterFrame]; - final compatibleFrame = + // A standalone interaction capture belongs to the immutable + // pointer-down route epoch. A later automatic route can be a useful + // visual successor, but it cannot supply this interaction's evidence. + final frameMatchesOrigin = afterFrame != null && provenance != null && - provenance.context.routeEpoch == requestedRouteEpoch && - provenance.context.route == requestedRoute; + provenance.context.routeEpoch == interactionRouteEpoch && + provenance.context.route == interactionRoute; final replacementRoute = _activeRouteCapture; final replacementIsCausal = replacementRoute?.change.causeEventId == pending.id; final replacementIsSafeVisualSuccessor = replacementRoute != null && _pointerGeneration == pending.origin.pointerGeneration; - if (!compatibleFrame && + final captureWasSuperseded = + routeChangedFromOrigin || + (!frameMatchesOrigin && + captureResolution.outcome == _CaptureOutcome.supersededRoute); + if (!frameMatchesOrigin && replacementRoute != null && replacementRoute.epoch != requestedRouteEpoch && (replacementIsCausal || replacementIsSafeVisualSuccessor)) { @@ -3008,24 +3087,49 @@ class TugboatReplayController extends ChangeNotifier { expectedCauseEventId: replacementIsCausal ? pending.id : null, ); if (!_isActiveTapSettle(work)) return; - observation = _tapObservationFromRouteBarrier( + final successor = _tapObservationFromRouteBarrier( routeBarrier, navigationOutcome: replacementIsCausal ? 'navigated' : 'visual_successor', ); + // The interaction request belongs to the old route epoch. Do not + // attach the successor route frame as its after-frame. + observation = _TapSettleObservation( + routeEpoch: successor.routeEpoch, + route: successor.route, + afterState: semanticAfterState, + afterFrame: null, + navigationOutcome: successor.navigationOutcome, + captureOutcome: replacementIsCausal + ? captureResolution.outcome.wireName + : 'superseded_route_epoch', + captureFailure: replacementIsCausal + ? captureResolution.outcome.wireName + : 'superseded_route_epoch', + routeEventId: replacementIsCausal ? successor.routeEventId : null, + captureRequestId: captureResolution.requestId, + ); } else { observation = _TapSettleObservation( routeEpoch: requestedRouteEpoch, route: requestedRoute, - afterState: compatibleFrame + afterState: frameMatchesOrigin ? _stateObservedWithFrame(afterFrame) : semanticAfterState, - afterFrame: compatibleFrame ? afterFrame : null, + afterFrame: frameMatchesOrigin && !routeChangedFromOrigin + ? afterFrame + : null, navigationOutcome: 'same_route', - captureOutcome: compatibleFrame ? 'captured' : 'failed', - captureFailure: compatibleFrame + captureOutcome: frameMatchesOrigin && !routeChangedFromOrigin + ? 'captured' + : captureWasSuperseded + ? 'superseded_route_epoch' + : 'failed', + captureFailure: frameMatchesOrigin && !routeChangedFromOrigin ? null + : captureWasSuperseded + ? 'superseded_route_epoch' : captureResolution.outcome.wireName, captureRequestId: captureResolution.requestId, ); @@ -3052,14 +3156,6 @@ class TugboatReplayController extends ChangeNotifier { navigationOutcome: observation.navigationOutcome, degraded: observation.isDegraded, ); - final beforeSignature = beforeState?.signature; - final afterSignature = afterState?.signature; - final semanticAvailable = - beforeSignature?.isNotEmpty == true && - afterSignature?.isNotEmpty == true; - final semanticChanged = semanticAvailable - ? beforeSignature != afterSignature - : null; final beforeContentHash = beforeFrame == null ? null : _frameContentHash(beforeFrame); @@ -3101,17 +3197,6 @@ class TugboatReplayController extends ChangeNotifier { 'routeEventId': observation.routeEventId, if (observation.captureRequestId != null) 'captureRequestId': observation.captureRequestId, - 'semantic': { - 'changed': semanticChanged, - 'evidence': semanticAvailable - ? 'state_signature' - : 'unavailable', - 'reason': semanticChanged == null - ? 'unavailable' - : semanticChanged - ? 'state_signature_changed' - : 'same_signature', - }, 'visual': { 'changed': visualChanged, 'evidence': visualAvailable @@ -3135,14 +3220,6 @@ class TugboatReplayController extends ChangeNotifier { ), ); } - _maybeEmitStateChange( - beforeState: beforeState, - afterState: afterState, - beforeFrame: beforeFrame, - afterFrame: afterFrame, - causingTx: pending, - ); - pending.gesture = InteractionGesture.tap; pending.resultStatus = InteractionResultStatus.fromSettle( result: result, @@ -3150,6 +3227,7 @@ class TugboatReplayController extends ChangeNotifier { degraded: observation.isDegraded, ); pending.afterFrame = afterFrame; + pending.captureOutcome = observation.captureOutcome; pending.resultStateAnchor = afterState; pending.resultRoute = observation.route; pending.resultObservedAtMs = atMs; @@ -3175,6 +3253,7 @@ class TugboatReplayController extends ChangeNotifier { } } finally { _activeTapSettles.remove(work); + _clearCausalRouteState(pending.id); work.complete(); } } @@ -3242,11 +3321,6 @@ class TugboatReplayController extends ChangeNotifier { if (navigationOutcome == 'navigated') { return TugboatInteractionResult.navigated; } - final beforeSig = beforeState?.signature ?? ''; - final afterSig = afterState?.signature ?? ''; - if (beforeSig.isNotEmpty && afterSig.isNotEmpty && beforeSig != afterSig) { - return TugboatInteractionResult.changed; - } // Animated/loading surfaces can repaint independently of the pointer. If // the resolved origin exposes no tap action, a pixel-only difference is // ambient evidence and must not turn an empty-area tap into a successful @@ -3262,10 +3336,7 @@ class TugboatReplayController extends ChangeNotifier { ? null : _frameContentHash(beforeFrame); final afterHash = afterFrame == null ? null : _frameContentHash(afterFrame); - if (beforeSig.isNotEmpty && - afterSig.isNotEmpty && - beforeHash != null && - afterHash != null) { + if (beforeHash != null && afterHash != null) { return TugboatInteractionResult.noVisibleChange; } return TugboatInteractionResult.unknown; @@ -3282,13 +3353,16 @@ class TugboatReplayController extends ChangeNotifier { return _session?.frameById(frameId)?.contentHash; } - void _linkScrollStartToActiveGestures(String scrollStartEventId) { + bool _linkScrollStartToActiveGestures(String scrollStartEventId) { + var linked = false; for (final tx in _interactions.pending) { + linked = true; if (!tx.scrollStartEventIds.contains(scrollStartEventId)) { tx.scrollStartEventIds.add(scrollStartEventId); } tx.addEvidence(scrollStartEventId); } + return linked; } void _publishCanonicalInteraction(InteractionTransaction tx) { @@ -3323,6 +3397,69 @@ class TugboatReplayController extends ChangeNotifier { ); } + void _clearCausalRouteState(String interactionId) { + _causalRouteCaptures.remove(interactionId); + _causalRouteSupersededInteractions.remove(interactionId); + } + + void _clearScrollCompletionState() { + _scrollTrackers.clear(); + _scrollInteractions.clear(); + _pendingScrollCompletions.clear(); + } + + void _discardScrollCompletionFor(InteractionTransaction tx) { + for (final scrollStartEventId in tx.scrollStartEventIds) { + _scrollInteractions.remove(scrollStartEventId); + _pendingScrollCompletions.remove(scrollStartEventId); + for (final tracker in _scrollTrackers.values) { + if (tracker.startEventId == scrollStartEventId) { + tracker.pointerLinked = false; + } + } + } + } + + void _publishResolvedScrollInteraction(String scrollStartEventId) { + final completion = _pendingScrollCompletions[scrollStartEventId]; + final interaction = _scrollInteractions[scrollStartEventId]; + if (completion == null || interaction == null || !completion.resolved) { + return; + } + interaction.afterFrame = completion.afterFrame; + interaction.captureOutcome = completion.captureOutcome; + _publishCanonicalInteraction(interaction); + _scrollInteractions.remove(scrollStartEventId); + _pendingScrollCompletions.remove(scrollStartEventId); + } + + /// A completed swipe or scroll gets its own fresh after-frame. Interaction + /// requests never coalesce, and fresh-paint capture cannot reuse a content + /// hash or perceptual hash frame. + void _publishCompletedGestureAfterCapture(InteractionTransaction tx) { + final session = _session; + final lifecycleEpoch = _captureLifecycleEpoch; + unawaited(() async { + final capture = _requestCaptureCancellable( + trigger: TugboatFrameTrigger.interaction, + force: true, + settleDelay: Duration.zero, + relatedEventId: tx.id, + ); + final resolution = await capture.resolution; + if (!_isCaptureLifecycleCurrent(session, lifecycleEpoch)) return; + tx.afterFrame = resolution.outcome == _CaptureOutcome.freshAccepted + ? resolution.frameId + : null; + tx.captureOutcome = resolution.outcome.wireName; + await _enqueue('interaction_after_capture', () async { + if (!_isCaptureLifecycleCurrent(session, lifecycleEpoch)) return; + _publishCanonicalInteraction(tx); + if (!_disposed) notifyListeners(); + }); + }()); + } + Element? _scrollableElementFor(BuildContext? context) { if (context is! Element) return null; if (context.widget is Scrollable) return context; @@ -3446,6 +3583,7 @@ class TugboatReplayController extends ChangeNotifier { axis: metrics.axis.name, depth: depth, maxScrollExtent: metrics.maxScrollExtent, + pointerLinked: false, pageStart: pageStart, ); _scrollTrackers[scrollableElement] = tracker; @@ -3493,7 +3631,7 @@ class TugboatReplayController extends ChangeNotifier { tracker: tracker, ), ); - _linkScrollStartToActiveGestures(startEventId); + tracker.pointerLinked = _linkScrollStartToActiveGestures(startEventId); if (!_disposed) notifyListeners(); } @@ -3567,15 +3705,77 @@ class TugboatReplayController extends ChangeNotifier { final captureSession = _session; final captureLifecycleEpoch = _captureLifecycleEpoch; + if (!tracker.pointerLinked) { + // Programmatic scrolls retain evidence, but do not manufacture an + // interaction capture or bypass local-WebSocket suppression. + _enqueue('scroll_end', () async { + if (!_isCaptureLifecycleCurrent( + captureSession, + captureLifecycleEpoch, + )) { + return; + } + _maybeEmitSceneInventory( + scrollContext: _scrollSemanticContext( + trigger: 'scroll_end', + metrics: metrics, + tracker: tracker, + endOffset: metrics.pixels, + ), + ); + _addEvent( + TugboatEvent( + id: _nextId('event'), + atMs: atMs, + type: 'scroll_end', + stream: TugboatEventStream.evidence, + stateAnchor: _refreshStateAnchor(), + targetAnchor: tracker.targetAnchor, + beforeFrame: tracker.beforeFrame, + relatedEventId: tracker.startEventId, + data: { + ..._scrollEventData( + metrics: metrics, + depth: tracker.depth, + tracker: tracker, + endOffset: metrics.pixels, + durationMs: atMs - tracker.startedAtMs, + overscrollCount: tracker.overscrollCount, + ), + 'frameAttachment': { + 'after': 'unavailable', + 'reason': 'programmatic_scroll', + }, + }, + ), + ); + if (!_disposed) notifyListeners(); + }); + return; + } + + final completion = _PendingScrollCompletion(); + _pendingScrollCompletions[tracker.startEventId] = completion; + _enqueue('scroll_end', () async { if (!_isCaptureLifecycleCurrent(captureSession, captureLifecycleEpoch)) { return; } if (tracker.routeEpoch != _routeEpoch) { - // A navigator transition won the race with pointer-up. Capturing now - // would attribute the destination's pixels to the completed scroll on - // the previous route, so retain the scroll boundary as explicitly - // degraded evidence instead of queuing a cross-route capture. + // Do make the interaction capture attempt. Do not attach its frame: + // it now depicts the automatic successor route, not the scroll route. + final afterCapture = _requestCaptureCancellable( + trigger: TugboatFrameTrigger.interaction, + force: true, + relatedEventId: tracker.startEventId, + ); + final afterResolution = await afterCapture.resolution; + if (!_isCaptureLifecycleCurrent( + captureSession, + captureLifecycleEpoch, + )) { + return; + } _addEvent( TugboatEvent( id: _nextId('event'), @@ -3596,6 +3796,7 @@ class TugboatReplayController extends ChangeNotifier { overscrollCount: tracker.overscrollCount, ), 'captureOutcome': 'superseded_route_epoch', + 'captureAttemptOutcome': afterResolution.outcome.wireName, 'frameAttachment': { 'after': 'unavailable', 'reason': 'superseded_route_epoch', @@ -3603,16 +3804,23 @@ class TugboatReplayController extends ChangeNotifier { }, ), ); + completion + ..captureOutcome = 'superseded_route_epoch' + ..resolved = true; + _publishResolvedScrollInteraction(tracker.startEventId); if (!_disposed) notifyListeners(); return; } final afterCapture = _requestCaptureCancellable( - trigger: TugboatFrameTrigger.scroll, + trigger: TugboatFrameTrigger.interaction, force: true, relatedEventId: tracker.startEventId, ); final afterResolution = await afterCapture.resolution; - final afterFrame = afterResolution.frameId; + final afterFrame = + afterResolution.outcome == _CaptureOutcome.freshAccepted + ? afterResolution.frameId + : null; if (!_isCaptureLifecycleCurrent(captureSession, captureLifecycleEpoch)) { return; } @@ -3668,6 +3876,11 @@ class TugboatReplayController extends ChangeNotifier { }, ), ); + completion + ..afterFrame = afterFrame + ..captureOutcome = afterResolution.outcome.wireName + ..resolved = true; + _publishResolvedScrollInteraction(tracker.startEventId); if (!_disposed) notifyListeners(); }); } @@ -3727,11 +3940,23 @@ class TugboatReplayController extends ChangeNotifier { change: change, deadline: transition.transitionDuration + - (_shouldSuppressFrameCapture ? Duration.zero : config.settleDelay), + (_shouldSuppressFrameCapture && change.causeEventId == null + ? Duration.zero + : config.settleDelay), ); _activeRouteCaptures[captureKey] = work; _latestRouteCaptureKey = captureKey; prior?.supersededBy = work; + final priorCauseEventId = prior?.change.causeEventId; + if (priorCauseEventId != null && priorCauseEventId != change.causeEventId) { + // A later, unclaimed route superseded this interaction's route. It is + // independent evidence and cannot supply the interaction's after-frame. + _causalRouteCaptures.remove(priorCauseEventId); + _causalRouteSupersededInteractions.add(priorCauseEventId); + } + if (change.causeEventId != null) { + _causalRouteCaptures[change.causeEventId!] = work; + } _skipCapture = transition.transitionDuration > Duration.zero; _startRouteBarrierTimeout(work); // Wake a reconciliation-pending settle only after this capture is visible @@ -3787,11 +4012,15 @@ class TugboatReplayController extends ChangeNotifier { void _cancelActiveRouteCapture([String reason = 'manual']) { if (_activeRouteCaptures.isEmpty) { + _causalRouteCaptures.clear(); + _causalRouteSupersededInteractions.clear(); _skipCapture = false; return; } final active = List<_RouteCaptureWork>.from(_activeRouteCaptures.values); _activeRouteCaptures.clear(); + _causalRouteCaptures.clear(); + _causalRouteSupersededInteractions.clear(); _latestRouteCaptureKey = null; _advanceCaptureGeneration(); for (final work in active) { @@ -3927,6 +4156,7 @@ class TugboatReplayController extends ChangeNotifier { final capture = _requestCaptureCancellable( trigger: TugboatFrameTrigger.route, force: true, + bypassExplorationSuppression: change.causeEventId != null, // The route deadline already includes the configured post-route // settle. Scheduling it again here would delay capture twice and can // strand widget-backed callers waiting for route completion. @@ -4052,6 +4282,7 @@ class TugboatReplayController extends ChangeNotifier { _clearReleasedInteractions( reason: InteractionRejectionReason.lifecycle, ); + _clearScrollCompletionState(); _captureLifecycleActive = false; break; case AppLifecycleState.resumed: @@ -4244,46 +4475,6 @@ class TugboatReplayController extends ChangeNotifier { return tx; } - void _maybeEmitStateChange({ - required TugboatStateAnchor? beforeState, - required TugboatStateAnchor? afterState, - required String? beforeFrame, - required String? afterFrame, - InteractionTransaction? causingTx, - }) { - final beforeSignature = beforeState?.signature ?? ''; - final afterSignature = afterState?.signature ?? ''; - if (beforeSignature.isEmpty || afterSignature.isEmpty) return; - if (beforeSignature == afterSignature) return; - - final data = { - if (afterState?.subLabel != null) 'subLabel': afterState!.subLabel, - }; - // Route claim takes priority; only claim if not already claimed elsewhere. - if (causingTx != null && causingTx.isEligible) { - causingTx.claimed = true; - causingTx.attribution = InteractionAttribution.direct; - data['causedByInteractionId'] = causingTx.id; - } else if (causingTx != null && causingTx.claimed) { - data['causedByInteractionId'] = causingTx.id; - } - - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'state_change', - stream: TugboatEventStream.evidence, - stateAnchor: afterState, - beforeFrame: beforeFrame, - afterFrame: afterFrame, - result: TugboatInteractionResult.changed, - data: data, - ), - ); - _maybeEmitSceneInventory(); - } - void _maybeEmitSceneInventory({ TugboatViewportSemanticScrollContext? scrollContext, }) { @@ -4310,14 +4501,17 @@ class TugboatReplayController extends ChangeNotifier { if (config.profile != TugboatCaptureProfile.exploration) return; // Always emit raw scene_inventory first (when new). Semantic-map emission // must not replace or suppress inventory; maps are an exploration companion. - final dedupeKey = '${inventory.stateSignature}|${inventory.inventoryHash}'; + final dedupeKey = [ + inventory.routeKey, + inventory.inventoryHash, + scrollContext?.dedupeKey ?? '', + ].join('|'); if (_emittedInventories.add(dedupeKey)) { _addEvent( TugboatEvent( id: _nextId('event'), atMs: atMs, type: 'scene_inventory', - stateAnchor: inventory.stateAnchor, data: inventory.toJson(), ), ); diff --git a/packages/tugboat/lib/src/debug_logging.dart b/packages/tugboat/lib/src/debug_logging.dart index 4db15dc..0fadd28 100644 --- a/packages/tugboat/lib/src/debug_logging.dart +++ b/packages/tugboat/lib/src/debug_logging.dart @@ -11,7 +11,7 @@ void tugboatLogViewportSemanticMap( debugPrint( '[tugboat] viewport_semantic_map route=${map.routeKey} ' 'buildMs=${buildMs ?? '?'} ' - 'state=${map.stateSignature} nodes=${map.summary['totalNodes']} ' + 'nodes=${map.summary['totalNodes']} ' 'actionable=${map.summary['actionableCount']} ' 'linked=${map.summary['linkedCount']} ' 'semantic=${map.summary['semanticCount']} ' @@ -43,7 +43,6 @@ void tugboatLogViewportSemanticMap( void tugboatLogScrollSemanticSnapshot(TugboatScrollSemanticSnapshot snapshot) { debugPrint( '[tugboat] scroll_semantic_snapshot route=${snapshot.routeKey} ' - 'state=${snapshot.stateSignature} ' 'scrollFp=${snapshot.scrollableFingerprint ?? 'none'} ' 'axis=${snapshot.axis ?? 'none'} slices=${snapshot.observedSliceCount} ' 'nodes=${snapshot.observedNodeCount} ' diff --git a/packages/tugboat/lib/src/interaction_transaction.dart b/packages/tugboat/lib/src/interaction_transaction.dart index 42e4b02..0bcfc43 100644 --- a/packages/tugboat/lib/src/interaction_transaction.dart +++ b/packages/tugboat/lib/src/interaction_transaction.dart @@ -20,6 +20,7 @@ class InteractionOrigin { required this.interactionId, required this.stateAnchor, required this.route, + required this.routeEpoch, required this.routeInstanceId, required this.navigatorId, required this.targetAnchor, @@ -36,6 +37,7 @@ class InteractionOrigin { final String interactionId; final TugboatStateAnchor? stateAnchor; final String? route; + final int routeEpoch; final String? routeInstanceId; final String? navigatorId; final TugboatTargetAnchor? targetAnchor; @@ -50,7 +52,6 @@ class InteractionOrigin { Map toJson() => { 'interactionId': interactionId, - if (stateAnchor != null) 'stateAnchor': stateAnchor!.toJson(), if (route != null) 'route': route, if (routeInstanceId != null) 'routeInstanceId': routeInstanceId, if (navigatorId != null) 'navigatorId': navigatorId, @@ -162,6 +163,7 @@ class InteractionTransaction { String? resultRoute; String? resultRouteInstanceId; String? afterFrame; + String? captureOutcome; int? resultObservedAtMs; TugboatStateAnchor? resultStateAnchor; @@ -207,8 +209,8 @@ class InteractionTransaction { 'status': (resultStatus ?? InteractionResultStatus.unknown).name, if (resultRoute != null) 'route': resultRoute, if (resultRouteInstanceId != null) 'routeInstanceId': resultRouteInstanceId, - if (resultStateAnchor != null) 'stateAnchor': resultStateAnchor!.toJson(), if (afterFrame != null) 'afterFrame': afterFrame, + if (captureOutcome != null) 'captureOutcome': captureOutcome, if (resultObservedAtMs != null) 'observedAtMs': resultObservedAtMs, }; diff --git a/packages/tugboat/lib/src/models.dart b/packages/tugboat/lib/src/models.dart index bf066d8..c093a45 100644 --- a/packages/tugboat/lib/src/models.dart +++ b/packages/tugboat/lib/src/models.dart @@ -115,7 +115,17 @@ class TugboatRect { }; } -enum TugboatFrameTrigger { initial, tap, scroll, route, lifecycle, manual } +/// `interaction` is a fresh, non-coalescing after-frame request for one +/// completed user interaction. +enum TugboatFrameTrigger { + initial, + tap, + scroll, + route, + lifecycle, + manual, + interaction, +} enum TugboatInteractionResult { changed, noVisibleChange, navigated, unknown } @@ -242,7 +252,6 @@ class TugboatEvent { if (sessionId != null) 'sessionId': sessionId, if (captureSessionId != null) 'captureSessionId': captureSessionId, if (activationRequestId != null) 'activationRequestId': activationRequestId, - if (stateAnchor != null) 'stateAnchor': stateAnchor!.toJson(), if (targetAnchor != null) 'targetAnchor': targetAnchor!.toJson(), if (beforeFrame != null) 'beforeFrame': beforeFrame, if (afterFrame != null) 'afterFrame': afterFrame, diff --git a/packages/tugboat/lib/src/sdk_version.dart b/packages/tugboat/lib/src/sdk_version.dart index 1b05b3c..fcb54d4 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.7.0'; +const tugboatSdkVersion = '0.8.0'; diff --git a/packages/tugboat/lib/src/viewport_semantic_session.dart b/packages/tugboat/lib/src/viewport_semantic_session.dart index bbdbfa5..3153baf 100644 --- a/packages/tugboat/lib/src/viewport_semantic_session.dart +++ b/packages/tugboat/lib/src/viewport_semantic_session.dart @@ -9,12 +9,14 @@ import 'replay_config.dart'; class _ScrollSemanticAccumulator { _ScrollSemanticAccumulator({ + required this.gestureSequence, required this.stateSignature, required this.routeKey, required this.scrollableFingerprint, required this.axis, }); + final int gestureSequence; final String stateSignature; final String routeKey; final String? scrollableFingerprint; @@ -43,6 +45,7 @@ class ViewportSemanticSession { final Set _emittedScrollSemanticSnapshots = {}; TugboatViewportSemanticMap? _latestMap; DateTime? _lastScrollSemanticBuildAt; + int _scrollGestureSequence = 0; TugboatViewportSemanticPolicy get _policy => config.viewportSemanticPolicy; @@ -61,6 +64,7 @@ class ViewportSemanticSession { _emittedScrollSemanticSnapshots.clear(); _latestMap = null; _lastScrollSemanticBuildAt = null; + _scrollGestureSequence = 0; } /// Returns false when a scroll-update semantic rebuild should be skipped. @@ -83,7 +87,7 @@ class ViewportSemanticSession { } catch (error, stackTrace) { debugPrint( '[tugboat] viewport_semantic_map build failed ' - 'route=${inventory.routeKey} state=${inventory.stateSignature}: ' + 'route=${inventory.routeKey}: ' '$error\n$stackTrace', ); } @@ -131,7 +135,7 @@ class ViewportSemanticSession { if (debugLogs) { debugPrint( '[tugboat] viewport_semantic_map skipped ' - 'route=${inventory.routeKey} state=${inventory.stateSignature} ' + 'route=${inventory.routeKey} ' 'reason=empty_or_unavailable_semantics', ); } @@ -144,12 +148,15 @@ class ViewportSemanticSession { final encodedPayload = bounded.encodedJson; _latestMap = map; + if (scrollContext?.trigger == 'scroll_start') { + _beginScrollSemanticGesture(map); + } // tapResolutionOnly: keep the map as a device-local lookup table. if (!emitEvents) return; final dedupeKey = - '${map.stateSignature}|${map.mapHash}|${map.scrollContext?.dedupeKey ?? ''}'; + '${map.routeKey}|${map.mapHash}|${map.scrollContext?.dedupeKey ?? ''}'; if (!_emittedSemanticMaps.add(dedupeKey)) return; addEvent( @@ -157,7 +164,6 @@ class ViewportSemanticSession { id: nextEventId('event'), atMs: atMs(), type: 'viewport_semantic_map', - stateAnchor: map.stateAnchor, data: encodedPayload ?? map.toJson(), ), ); @@ -204,7 +210,7 @@ class ViewportSemanticSession { if (debugLogs) { debugPrint( '[tugboat] viewport_semantic_map skipped ' - 'route=${map.routeKey} state=${map.stateSignature} ' + 'route=${map.routeKey} ' 'reason=payload_too_large bytes=$encodedLength ' 'limit=$maxBytes', ); @@ -224,11 +230,9 @@ class ViewportSemanticSession { scroll.axis ?? 'unknown', ].join('|'); var accumulator = _scrollSemanticAccumulators[accumulatorKey]; - // A state signature change mid-scroll means the screen materially changed; - // stitching across it would attribute slices to a stale state. - if (accumulator == null || - accumulator.stateSignature != map.stateSignature) { + if (accumulator == null) { accumulator = _ScrollSemanticAccumulator( + gestureSequence: ++_scrollGestureSequence, stateSignature: map.stateSignature, routeKey: map.routeKey, scrollableFingerprint: scroll.scrollableFingerprint, @@ -239,13 +243,14 @@ class ViewportSemanticSession { accumulator.slices[scroll.dedupeKey] = map; if (accumulator.slices.length < 2) return; final snapshot = _buildScrollSemanticSnapshot(accumulator); - if (!_emittedScrollSemanticSnapshots.add(snapshot.snapshotHash)) return; + final snapshotKey = + '${accumulator.gestureSequence}|${snapshot.snapshotHash}'; + if (!_emittedScrollSemanticSnapshots.add(snapshotKey)) return; addEvent( TugboatEvent( id: nextEventId('event'), atMs: atMs(), type: 'scroll_semantic_snapshot', - stateAnchor: map.stateAnchor, data: snapshot.toJson(), ), ); @@ -254,6 +259,23 @@ class ViewportSemanticSession { } } + void _beginScrollSemanticGesture(TugboatViewportSemanticMap map) { + final scroll = map.scrollContext; + if (scroll == null) return; + final accumulatorKey = [ + map.routeKey, + scroll.scrollableFingerprint ?? 'unknown', + scroll.axis ?? 'unknown', + ].join('|'); + _scrollSemanticAccumulators[accumulatorKey] = _ScrollSemanticAccumulator( + gestureSequence: ++_scrollGestureSequence, + stateSignature: map.stateSignature, + routeKey: map.routeKey, + scrollableFingerprint: scroll.scrollableFingerprint, + axis: scroll.axis, + ); + } + TugboatScrollSemanticSnapshot _buildScrollSemanticSnapshot( _ScrollSemanticAccumulator accumulator, ) { @@ -303,7 +325,6 @@ class ViewportSemanticSession { final hash = tugboatLabelHash( [ accumulator.routeKey, - accumulator.stateSignature, accumulator.scrollableFingerprint ?? '', accumulator.axis ?? '', accumulator.slices.length, @@ -335,9 +356,7 @@ class ViewportSemanticSession { final rootRender = boundaryKey.currentContext?.findRenderObject(); if (resolver == null || rootRender is! RenderBox) return null; - if (inventory != null && - (_latestMap == null || - _latestMap!.stateSignature != inventory.stateSignature)) { + if (inventory != null) { maybeEmit(inventory, resolver: resolver); } diff --git a/packages/tugboat/pubspec.yaml b/packages/tugboat/pubspec.yaml index 628b74f..f588090 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.7.0 +version: 0.8.0 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/collector_mapper_test.dart b/packages/tugboat/test/collector_mapper_test.dart index 8b15968..9423138 100644 --- a/packages/tugboat/test/collector_mapper_test.dart +++ b/packages/tugboat/test/collector_mapper_test.dart @@ -76,7 +76,7 @@ void main() { // Compat path: semantic tap without canonical dual-write remains eligible. expect(mapped['enrichmentCandidate'], isTrue); expect(mapped['beforeFrame'], 'frame-3'); - expect((mapped['stateAnchor'] as Map)['signature'], '23f17a629520d522'); + expect(mapped.containsKey('stateAnchor'), isFalse); expect((mapped['targetAnchor'] as Map)['fingerprint'], '9eadb7c56ae836bc'); expect(mapped['actionId'], 'A-1'); expect(mapped['explorationRunId'], 'run-1'); diff --git a/packages/tugboat/test/replay/capture_diagnostics_test.dart b/packages/tugboat/test/replay/capture_diagnostics_test.dart index 5586399..4dcd145 100644 --- a/packages/tugboat/test/replay/capture_diagnostics_test.dart +++ b/packages/tugboat/test/replay/capture_diagnostics_test.dart @@ -104,7 +104,6 @@ void main() { 'fresh_accepted', 'exact_content_reused', 'perceptual_hash_coalesced', - 'state_signature_short_circuit', 'screenshot_budget_skip', 'no_frame_available', 'no_compatible_frame', diff --git a/packages/tugboat/test/replay/interaction_transaction_test.dart b/packages/tugboat/test/replay/interaction_transaction_test.dart index 40e080c..ce0cb01 100644 --- a/packages/tugboat/test/replay/interaction_transaction_test.dart +++ b/packages/tugboat/test/replay/interaction_transaction_test.dart @@ -124,8 +124,7 @@ void main() { interaction.data['origin']! as Map, ); expect(origin['route'], '/origin'); - final state = Map.from(origin['stateAnchor']! as Map); - expect(state['signature'], 'origin-sig'); + expect(origin.containsKey('stateAnchor'), isFalse); expect(interaction.targetAnchor?.fingerprint, isNull); }, ); @@ -147,6 +146,37 @@ void main() { expect(harness.controller.session!.ofType('tap'), isEmpty); }); + test('swipe terminal path clears causal route state', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(4, 4)); + await harness.controller.route('route_push', harness.route('/next')); + expect(harness.controller.debugCausalRouteCaptureCount, 1); + + harness.controller.markPendingTapAsSwipe(0); + harness.controller.recordPointerUp(const Offset(80, 4)); + + expect(harness.controller.debugCausalRouteCaptureCount, 0); + expect(harness.controller.debugCausalRouteSupersededInteractionCount, 0); + }); + + test('pointer cancel clears causal route state', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(4, 4)); + await harness.controller.route('route_push', harness.route('/next')); + expect(harness.controller.debugCausalRouteCaptureCount, 1); + + harness.controller.recordPointerCancel(const Offset(4, 4)); + + expect(harness.controller.debugCausalRouteCaptureCount, 0); + expect(harness.controller.debugCausalRouteSupersededInteractionCount, 0); + }); + test('duplicate pointer-down cancels prior and keeps one tap', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); diff --git a/packages/tugboat/test/replay/modal_capture_visual_test.dart b/packages/tugboat/test/replay/modal_capture_visual_test.dart index 7eda2c9..891893e 100644 --- a/packages/tugboat/test/replay/modal_capture_visual_test.dart +++ b/packages/tugboat/test/replay/modal_capture_visual_test.dart @@ -154,7 +154,7 @@ void main() { await openAndAssertSheet(); popStart = fixture.session.events.length; - await tester.tapAt(const Offset(200, 80)); + await tester.tap(find.byType(ModalBarrier).last); await tester.pumpAndSettle(); await assertRestoredBase(after: popStart); @@ -695,10 +695,12 @@ Future _pumpUntil( T? Function() read, { required String description, }) async { - for (var attempt = 0; attempt < 100; attempt++) { + for (var attempt = 0; attempt < 250; attempt++) { final value = read(); if (value != null) return value; - await tester.pump(); + // Route barriers use transition deadlines. Advance the test clock so the + // deadline can fire while this helper waits for the resulting evidence. + await tester.pump(const Duration(milliseconds: 16)); if (attempt > 0 && attempt % 10 == 0) { await tester.runAsync(() async { await Future.delayed(const Duration(milliseconds: 50)); diff --git a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart index 1a3e658..4a248ff 100644 --- a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart @@ -250,10 +250,7 @@ class _NavigationFixture { expect(tap.targetAnchor, isNotNull); expect(settle.targetAnchor?.fingerprint, tap.targetAnchor?.fingerprint); expect(settle.targetAnchor?.canonicalPath, tap.targetAnchor?.canonicalPath); - expect(tap.stateAnchor?.signature, isNotNull); - expect(routeChange.stateAnchor?.signature, isNotNull); - expect(settle.stateAnchor?.signature, routeChange.stateAnchor?.signature); - expect(settle.stateAnchor?.signature, isNot(tap.stateAnchor?.signature)); + expect(settle.toJson().containsKey('stateAnchor'), isFalse); expect(settle.afterFrame, routeFrame); expect(routeFrame, isNotNull); expect(routeDiagnostic.data['outcome'], 'fresh_accepted'); 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 fa02e0f..f36319d 100644 --- a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart @@ -306,7 +306,7 @@ void main() { }); test( - 'automatic route superseding a tap capture supplies visual successor', + 'automatic route superseding a tap capture does not supply its frame', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); @@ -341,6 +341,7 @@ void main() { final tap = _ofType(session, 'tap').single; final settle = _ofType(session, 'tap_settled').single; final change = _ofType(session, 'route_change').single; + final interaction = _ofType(session, 'interaction').single; final observation = Map.from( settle.data['settleObservation']! as Map, ); @@ -349,10 +350,15 @@ void main() { expect(change.data['causeEventId'], isNull); expect(change.afterFrame, isNotNull); expect(settle.relatedEventId, tap.id); - expect(settle.afterFrame, change.afterFrame); + expect(settle.afterFrame, isNull); expect(observation['navigationOutcome'], 'visual_successor'); - expect(observation['captureOutcome'], 'captured'); - expect(observation['routeEventId'], change.id); + expect(observation['captureOutcome'], isNot('captured')); + expect(observation['routeEventId'], isNull); + expect( + interaction.data['evidenceEventIds'], + isNot(contains(change.id)), + reason: 'an automatic successor is not causal evidence for the tap', + ); _expectEveryDiagnosticRequestIsResolvedOnce(session); expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); }, diff --git a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart index 3b8b9c6..fb56fbe 100644 --- a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart @@ -23,7 +23,8 @@ void main() { route: '/dialog', after: dialogStart, ); - fixture.assertNavigationEvidence( + await fixture.assertNavigationEvidence( + tester: tester, routeChange: dialogPush, destination: '/dialog', after: dialogStart, @@ -38,7 +39,8 @@ void main() { route: '/root', after: dialogPopStart, ); - fixture.assertNavigationEvidence( + await fixture.assertNavigationEvidence( + tester: tester, routeChange: dialogPop, destination: '/root', after: dialogPopStart, @@ -53,7 +55,8 @@ void main() { route: '/sheet', after: sheetStart, ); - fixture.assertNavigationEvidence( + await fixture.assertNavigationEvidence( + tester: tester, routeChange: sheetPush, destination: '/sheet', after: sheetStart, @@ -68,7 +71,8 @@ void main() { route: '/root', after: sheetPopStart, ); - fixture.assertNavigationEvidence( + await fixture.assertNavigationEvidence( + tester: tester, routeChange: sheetPop, destination: '/root', after: sheetPopStart, @@ -98,7 +102,8 @@ void main() { after: nestedStart, ); - fixture.assertNavigationEvidence( + await fixture.assertNavigationEvidence( + tester: tester, routeChange: push, destination: '/nested/details', after: nestedStart, @@ -120,7 +125,8 @@ void main() { ); final anonymousRoute = anonymous.data['route'] as String; expect(anonymousRoute, contains('MaterialPageRoute')); - fixture.assertNavigationEvidence( + await fixture.assertNavigationEvidence( + tester: tester, routeChange: anonymous, destination: anonymousRoute, after: anonymousStart, @@ -144,7 +150,8 @@ void main() { route: '/generated', after: generatedStart, ); - fixture.assertNavigationEvidence( + await fixture.assertNavigationEvidence( + tester: tester, routeChange: generated, destination: '/generated', after: generatedStart, @@ -192,7 +199,7 @@ class _OverlayFixture { profile: TugboatCaptureProfile.exploration, interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, - interactionClaimWindow: Duration.zero, + interactionClaimWindow: tugboatDefaultReconciliationWindow, enableGlobalPointerCapture: true, capturePixelRatio: 1, ), @@ -255,11 +262,21 @@ class _OverlayFixture { return null; }, description: '$navigation route'); - void assertNavigationEvidence({ + Future assertNavigationEvidence({ + required WidgetTester tester, required TugboatEvent routeChange, required String destination, required int after, - }) { + }) async { + await _pumpUntil(tester, () { + for (final event in session.events) { + if (event.type == 'tap_settled' && + event.afterFrame == routeChange.afterFrame) { + return event; + } + } + return null; + }, description: 'route-linked settled interaction'); final events = session.events; final routeIndex = events.indexOf(routeChange); final routeFrame = routeChange.afterFrame; @@ -299,10 +316,7 @@ class _OverlayFixture { settled.targetAnchor?.canonicalPath, tap.targetAnchor?.canonicalPath, ); - expect(tap.stateAnchor?.signature, isNotNull); - expect(routeChange.stateAnchor?.signature, isNotNull); - expect(settled.stateAnchor?.signature, routeChange.stateAnchor?.signature); - expect(settled.stateAnchor?.signature, isNot(tap.stateAnchor?.signature)); + expect(settled.toJson().containsKey('stateAnchor'), isFalse); expect(settled.afterFrame, routeFrame); expect(events.indexOf(tap), lessThan(routeIndex)); expect(routeIndex, lessThan(events.indexOf(settled))); @@ -460,10 +474,10 @@ Future _pumpUntil( T? Function() read, { required String description, }) async { - for (var attempt = 0; attempt < 80; attempt++) { + for (var attempt = 0; attempt < 120; attempt++) { final value = read(); if (value != null) return value; - await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); } fail('Timed out waiting for $description'); } diff --git a/packages/tugboat/test/replay_coherence_characterization_test.dart b/packages/tugboat/test/replay_coherence_characterization_test.dart index 1817dea..0ba1d84 100644 --- a/packages/tugboat/test/replay_coherence_characterization_test.dart +++ b/packages/tugboat/test/replay_coherence_characterization_test.dart @@ -68,6 +68,76 @@ void main() { }, ); + test( + 'local WebSocket suppression keeps a standalone tap screenshot', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.seedRouteState(route: '/home', signature: 'sig-home'); + harness.controller.debugSetExplorationFramesSuppressed(true); + final manual = harness.controller.debugRequestCapture( + trigger: TugboatFrameTrigger.manual, + ); + final manualResolution = await manual.resolution; + expect(manualResolution['outcome'], 'cancelled'); + + final interactionForces = []; + harness.capturer.frameFactory = (trigger, force) { + if (trigger == TugboatFrameTrigger.interaction) { + interactionForces.add(force); + } + return null; + }; + harness.controller.recordPointerDown(const Offset(12, 12)); + harness.controller.recordPointerUp(const Offset(12, 12)); + await harness.flushScheduler(); + + final settle = harness.controller.session!.ofType('tap_settled').single; + expect(settle.afterFrame, isNotNull); + expect(interactionForces, [true]); + expect( + harness.capturer.triggers, + isNot(contains(TugboatFrameTrigger.manual)), + ); + }, + ); + + test( + 'local WebSocket suppression keeps a claimed route screenshot', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.seedRouteState(route: '/home', signature: 'sig-home'); + harness.controller.debugSetExplorationFramesSuppressed(true); + final routeForces = []; + harness.capturer.frameFactory = (trigger, force) { + if (trigger == TugboatFrameTrigger.route) routeForces.add(force); + return null; + }; + harness.controller.recordPointerDown(const Offset(12, 12)); + final route = harness.controller.route( + 'route_push', + harness.route('/next'), + ); + harness.controller.recordPointerUp(const Offset(12, 12)); + await harness.flushScheduler(); + await route; + + final session = harness.controller.session!; + final routeChange = session.ofType('route_change').single; + final settle = session.ofType('tap_settled').single; + final interaction = session.ofType('interaction').single; + expect(routeChange.afterFrame, isNotNull); + expect(settle.afterFrame, routeChange.afterFrame); + expect(routeForces, [true]); + expect(interaction.data['evidenceEventIds'], contains(routeChange.id)); + }, + ); + test( 'tap that starts navigation awaits the matching route capture', () async { @@ -236,14 +306,26 @@ void main() { await routeFuture; final session = harness.controller.session!; - final routeFrame = session.ofType('route_change').single.afterFrame; + final routeChange = session.ofType('route_change').single; final settle = session.ofType('tap_settled').single; + final interaction = session.events.singleWhere( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic, + ); final observation = Map.from( settle.data['settleObservation']! as Map, ); - expect(settle.afterFrame, isNot(routeFrame)); + expect(settle.afterFrame, isNull); + expect(interaction.afterFrame, isNull); expect(observation['navigationOutcome'], 'same_route'); + expect(observation['captureOutcome'], 'superseded_route_epoch'); expect(observation['routeEventId'], isNull); + expect( + interaction.data['evidenceEventIds'], + isNot(contains(routeChange.id)), + reason: 'the automatic route is not causal evidence for the tap', + ); expect( harness.capturer.triggers.where( (trigger) => trigger == TugboatFrameTrigger.route, @@ -252,7 +334,7 @@ void main() { ); expect( harness.capturer.triggers.where( - (trigger) => trigger == TugboatFrameTrigger.tap, + (trigger) => trigger == TugboatFrameTrigger.interaction, ), hasLength(1), ); @@ -286,10 +368,10 @@ void main() { final observation = Map.from( settle.data['settleObservation']! as Map, ); - expect(settle.afterFrame, routeChange.afterFrame); + expect(settle.afterFrame, isNull); expect(settle.result, isNot(TugboatInteractionResult.navigated)); expect(observation['navigationOutcome'], 'visual_successor'); - expect(observation['routeEventId'], routeChange.id); + expect(observation['routeEventId'], isNull); expect(routeChange.afterFrame, isNotNull); }, ); @@ -311,12 +393,30 @@ void main() { 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 interaction = harness.controller.session!.events.singleWhere( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic, + ); final observation = Map.from( settle.data['settleObservation']! as Map, ); expect(settle.afterFrame, isNull); + expect(interaction.afterFrame, isNull); expect(observation['navigationOutcome'], 'navigation_unavailable'); + expect(observation['captureOutcome'], 'superseded_route_epoch'); expect(observation['routeEventId'], isNull); + expect( + harness.capturer.triggers.where( + (trigger) => trigger == TugboatFrameTrigger.interaction, + ), + hasLength(1), + ); + expect( + interaction.data['evidenceEventIds'], + isNot(contains(changes.single.id)), + reason: 'the final automatic route is not evidence for the tap', + ); }, ); @@ -449,7 +549,10 @@ void main() { signature: 'sig-list', ); harness.capturer.blockNext = true; + harness.controller.recordPointerDown(const Offset(10, 10)); + harness.controller.markPendingTapAsSwipe(0); await tester.drag(find.byType(ListView), const Offset(0, -200)); + harness.controller.recordPointerUp(const Offset(10, -190)); await harness.pumpQueueWork(); expect(harness.controller.session!.ofType('scroll_start'), hasLength(1)); expect(harness.capturer.blockedCount, 1); @@ -477,7 +580,10 @@ void main() { signature: 'sig-list', ); harness.capturer.blockNext = true; + harness.controller.recordPointerDown(const Offset(10, 10)); + harness.controller.markPendingTapAsSwipe(0); await tester.drag(find.byType(ListView), const Offset(0, -200)); + harness.controller.recordPointerUp(const Offset(10, -190)); await harness.pumpQueueWork(); expect(harness.controller.session!.ofType('scroll_start'), hasLength(1)); expect(harness.capturer.blockedCount, 1); @@ -871,7 +977,7 @@ void main() { await harness.controller.endSession(); }); - test('capture coalescing preserves incompatible request order', () async { + test('interaction captures execute uniquely in route order', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); @@ -892,9 +998,27 @@ void main() { Offset(pointer.toDouble(), pointer.toDouble()), pointer: pointer, ); + await harness.flushScheduler(); } - expect(harness.controller.debugScheduledCaptureRoutes, ['/a', '/b', '/a']); + final interactionTriggers = harness.capturer.triggers + .where((trigger) => trigger == TugboatFrameTrigger.interaction) + .toList(growable: false); + expect(interactionTriggers, hasLength(3)); + final settles = harness.controller.session!.ofType('tap_settled'); + expect(settles, hasLength(3)); + expect(settles.map((event) => event.afterFrame), everyElement(isNotNull)); + expect( + settles + .map((event) => harness.provenanceFor(event.afterFrame)?.route) + .toList(growable: false), + ['/a', '/b', '/a'], + ); + expect( + settles.map((event) => event.afterFrame).toSet(), + hasLength(3), + reason: 'each interaction uses one fresh, non-reused screenshot', + ); await harness.controller.endSession(); }); @@ -933,13 +1057,11 @@ void main() { final settle = harness.controller.session!.ofType('tap_settled').single; expect(settle.afterFrame, isNotNull); - expect(settle.stateAnchor?.signature, 'sig-captured'); + expect(settle.toJson().containsKey('stateAnchor'), isFalse); expect(harness.controller.currentStateAnchor?.signature, 'sig-advanced'); expect( - harness.controller.debugFrameProvenance( - settle.afterFrame!, - )!['completionStateSignature'], - 'sig-captured', + harness.controller.debugFrameProvenance(settle.afterFrame!), + isNot(contains('completionStateSignature')), ); }); @@ -967,11 +1089,7 @@ void main() { expect(settle.afterFrame, isNot(beforeFrame)); expect(settle.result, TugboatInteractionResult.noVisibleChange); final observation = settle.data['settleObservation'] as Map; - expect(observation['semantic'], { - 'changed': false, - 'evidence': 'state_signature', - 'reason': 'same_signature', - }); + expect(observation.containsKey('semantic'), isFalse); expect(observation['visual'], { 'changed': false, 'evidence': 'content_hash', @@ -1816,7 +1934,7 @@ void main() { final settle = harness.controller.session!.ofType('tap_settled').single; expect(settle.beforeFrame, frame); expect(settle.afterFrame, isNot(frame)); - expect(settle.result, TugboatInteractionResult.changed); + expect(settle.result, TugboatInteractionResult.noVisibleChange); expect( settle.stateAnchor?.signature, 'sig-after', @@ -1913,9 +2031,6 @@ void main() { final tap = session.ofType('tap').single; final settle = session.ofType('tap_settled').single; final inventory = session.ofType('scene_inventory').last; - final inventorySignature = - inventory.stateAnchor?.signature ?? - inventory.data['stateSignature'] as String?; expect(tap.targetAnchor, isNotNull); expect(tap.targetAnchor!.fingerprint, isNotNull); @@ -1923,7 +2038,7 @@ void main() { expect(tap.targetAnchor!.canonicalPath, isNotEmpty); expect(tap.targetAnchor!.role, 'button'); expect(tap.targetAnchor!.widgetType, isNot('RepaintBoundary')); - expect(tap.stateAnchor?.signature, inventorySignature); + expect(inventory.data.containsKey('stateSignature'), isFalse); expect(settle.targetAnchor, isNotNull); expect(settle.targetAnchor!.fingerprint, tap.targetAnchor!.fingerprint); expect(settle.targetAnchor!.canonicalPath, tap.targetAnchor!.canonicalPath); @@ -1972,16 +2087,13 @@ void main() { final session = harness.controller.session!; final tap = session.ofType('tap').single; final inventory = session.ofType('scene_inventory').last; - final inventorySignature = - inventory.stateAnchor?.signature ?? - inventory.data['stateSignature'] as String?; expect(tap.targetAnchor, isNotNull); expect(tap.targetAnchor!.fingerprint, isNotNull); expect(tap.targetAnchor!.fingerprint, isNotEmpty); expect(tap.targetAnchor!.canonicalPath, isNotEmpty); expect(tap.targetAnchor!.role, 'button'); - expect(tap.stateAnchor?.signature, inventorySignature); + expect(inventory.data.containsKey('stateSignature'), isFalse); await harness.flushScheduler(); await routeFuture; diff --git a/packages/tugboat/test/scene_inventory_test.dart b/packages/tugboat/test/scene_inventory_test.dart index 43ba567..af2787a 100644 --- a/packages/tugboat/test/scene_inventory_test.dart +++ b/packages/tugboat/test/scene_inventory_test.dart @@ -216,19 +216,15 @@ void main() { final tapEvent = tapEvents.single; final tapFingerprint = tapEvent.targetAnchor?.fingerprint; - final tapSignature = tapEvent.stateAnchor?.signature; expect(tapFingerprint, isNotEmpty); - expect(tapSignature, isNotEmpty); final inventoryEvents = controller.session!.events .where((event) => event.type == 'scene_inventory') .toList(); expect(inventoryEvents, isNotEmpty); - final tapInventory = inventoryEvents.lastWhere( - (event) => event.stateAnchor?.signature == tapSignature, - ); - expect(tapInventory.data['stateSignature'], tapSignature); + final tapInventory = inventoryEvents.last; + expect(tapInventory.data.containsKey('stateSignature'), isFalse); final elements = tapInventory.data['elements'] as List; expect( @@ -288,10 +284,7 @@ void main() { .toList(); expect(inventoryEvents, isNotEmpty); - final tapInventory = inventoryEvents.lastWhere( - (event) => - event.stateAnchor?.signature == tapEvent.stateAnchor?.signature, - ); + final tapInventory = inventoryEvents.last; final elements = tapInventory.data['elements'] as List; expect( elements.any( @@ -429,7 +422,7 @@ void main() { expect(afterCount, beforeCount); final payload = inventoryEvents.first.data; - expect(payload['stateSignature'], isA()); + expect(payload.containsKey('stateSignature'), isFalse); expect(payload['inventoryHash'], isA()); expect(payload['elements'], isA>()); }); diff --git a/packages/tugboat/test/scroll_attribution_test.dart b/packages/tugboat/test/scroll_attribution_test.dart index da55139..b48a9e4 100644 --- a/packages/tugboat/test/scroll_attribution_test.dart +++ b/packages/tugboat/test/scroll_attribution_test.dart @@ -21,7 +21,164 @@ Future _waitForCaptures(WidgetTester tester) async { await tester.pump(); } +Future _exerciseScrollCallbackOrder( + WidgetTester tester, { + required bool endBeforePointerUp, +}) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _scrollTestConfig, child: child!), + home: Scaffold( + body: ListView.builder( + itemCount: 30, + itemBuilder: (context, index) => Text('Callback item $index'), + ), + ), + ), + ); + await _waitForCaptures(tester); + + final listContext = tester.element(find.byType(Scrollable)); + final metrics = Scrollable.of( + tester.element(find.text('Callback item 0')), + ).position; + final controller = TugboatReplay.controller!; + final initialInteractionRequests = controller.session!.events + .where( + (event) => + event.type == 'capture_diagnostic' && + event.data['trigger'] == 'interaction', + ) + .length; + controller.recordPointerDown(const Offset(20, 20)); + controller.markPendingTapAsSwipe(0); + controller.recordScrollStart( + scrollContext: listContext, + metrics: metrics, + depth: 0, + ); + if (endBeforePointerUp) { + controller.recordScrollEnd(scrollContext: listContext, metrics: metrics); + controller.recordPointerUp(const Offset(20, -100)); + } else { + controller.recordPointerUp(const Offset(20, -100)); + controller.recordScrollEnd(scrollContext: listContext, metrics: metrics); + } + await tester.pump(); + await _waitForCaptures(tester); + + final session = controller.session!; + final interactions = session.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) + .toList(); + expect(interactions, hasLength(1)); + final afterFrame = interactions.single.afterFrame; + expect(afterFrame, isNotNull); + final frame = session.frameById(afterFrame!); + expect(frame, isNotNull); + expect(frame!.trigger, TugboatFrameTrigger.interaction); + expect(frame.byteLength, greaterThan(0)); + expect( + session.events + .where( + (event) => + event.type == 'capture_diagnostic' && + event.data['trigger'] == 'interaction', + ) + .length, + initialInteractionRequests + 1, + ); +} + void main() { + testWidgets('scroll end before pointer up joins one interaction capture', ( + tester, + ) async { + await _exerciseScrollCallbackOrder(tester, endBeforePointerUp: true); + }); + + testWidgets('pointer up before scroll end joins one interaction capture', ( + tester, + ) async { + await _exerciseScrollCallbackOrder(tester, endBeforePointerUp: false); + }); + + testWidgets( + 'programmatic scroll emits evidence without interaction capture', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _scrollTestConfig, child: child!), + home: Scaffold( + body: ListView.builder( + controller: scrollController, + itemCount: 40, + itemBuilder: (context, index) => Text('Program item $index'), + ), + ), + ), + ); + await _waitForCaptures(tester); + final controller = TugboatReplay.controller!; + final interactionRequestCount = controller.session!.events + .where( + (event) => + event.type == 'capture_diagnostic' && + event.data['trigger'] == 'interaction', + ) + .length; + + scrollController.animateTo( + 180, + duration: const Duration(milliseconds: 32), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + await _waitForCaptures(tester); + + final session = controller.session!; + expect( + session.events.where((event) => event.type == 'scroll_start'), + hasLength(1), + ); + final scrollEnd = session.events + .where((event) => event.type == 'scroll_end') + .single; + expect(scrollEnd.afterFrame, isNull); + expect( + (scrollEnd.data['frameAttachment']! as Map)['reason'], + 'programmatic_scroll', + ); + expect( + session.events.where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic, + ), + isEmpty, + ); + expect( + session.events + .where( + (event) => + event.type == 'capture_diagnostic' && + event.data['trigger'] == 'interaction', + ) + .length, + interactionRequestCount, + ); + }, + ); + testWidgets('ListView scroll carries scrollable target anchor', ( tester, ) async { diff --git a/packages/tugboat/test/tugboat_replay_test.dart b/packages/tugboat/test/tugboat_replay_test.dart index abcccc6..7d79aa4 100644 --- a/packages/tugboat/test/tugboat_replay_test.dart +++ b/packages/tugboat/test/tugboat_replay_test.dart @@ -254,12 +254,7 @@ void main() { final routeChangeJson = routeChange.toJson(); expect(routeChangeJson.containsKey('route'), isFalse); expect(routeChangeJson.containsKey('toRoute'), isFalse); - expect( - (routeChangeJson['stateAnchor'] as Map).containsKey( - 'route', - ), - isFalse, - ); + expect(routeChangeJson.containsKey('stateAnchor'), isFalse); expect(session.frames, isNotEmpty); expect(routeChange.afterFrame, isNotNull); final routeBytes = session.frameBytes[routeChange.afterFrame]!; @@ -1497,7 +1492,7 @@ void main() { expect(session.frames.length, greaterThanOrEqualTo(framesBeforeScroll)); }); - test('tap_settled result prefers signature change over stale frame ids', () { + test('tap_settled result does not infer a change from state signatures', () { final rootKey = GlobalKey(); final controller = TugboatReplayController( config: _testConfig, @@ -1511,11 +1506,11 @@ void main() { afterFrame: 'frame-1', targetAnchor: const TugboatTargetAnchor(actions: ['tap']), ); - expect(result, TugboatInteractionResult.changed); + expect(result, TugboatInteractionResult.unknown); controller.dispose(); }); - test('tap_settled result uses tap-down baseline signatures', () { + test('tap_settled result ignores tap-down state signatures', () { final rootKey = GlobalKey(); final controller = TugboatReplayController( config: _testConfig, @@ -1529,7 +1524,7 @@ void main() { afterFrame: 'frame-1', targetAnchor: const TugboatTargetAnchor(actions: ['tap']), ); - expect(result, TugboatInteractionResult.changed); + expect(result, TugboatInteractionResult.unknown); controller.dispose(); }); diff --git a/packages/tugboat/test/viewport_semantic_map_test.dart b/packages/tugboat/test/viewport_semantic_map_test.dart index d0d789f..90b5814 100644 --- a/packages/tugboat/test/viewport_semantic_map_test.dart +++ b/packages/tugboat/test/viewport_semantic_map_test.dart @@ -116,7 +116,7 @@ void main() { expect(mapEvents, isNotEmpty); final payload = mapEvents.single.data; - expect(payload['stateSignature'], isNotEmpty); + expect(payload.containsKey('stateSignature'), isFalse); expect(payload['routeKey'], isNotEmpty); expect(payload['mapHash'], isNotEmpty); expect(payload['summary'], isA>()); @@ -160,6 +160,56 @@ void main() { expect(resolution['linkedFingerprint'], tapFingerprint); }); + testWidgets('tap resolution rebuilds the same-route semantic map', ( + tester, + ) async { + var showBottomButton = false; + late StateSetter setScreen; + await _pumpSettledScreen( + tester, + StatefulBuilder( + builder: (context, setState) { + setScreen = setState; + return Scaffold( + body: showBottomButton + ? Align( + alignment: Alignment.bottomCenter, + child: FilledButton( + onPressed: () {}, + child: const Text('Bottom action'), + ), + ) + : Align( + alignment: Alignment.topCenter, + child: FilledButton( + onPressed: () {}, + child: const Text('Top action'), + ), + ), + ); + }, + ), + ); + + setScreen(() => showBottomButton = true); + await tester.pump(); + + final controller = TugboatReplay.controller!; + final tapCenter = tester.getCenter(find.text('Bottom action')); + controller.recordPointerDown(tapCenter); + controller.recordPointerUp(tapCenter); + await tester.pump(); + + final tapEvent = controller.session!.events + .where((event) => event.type == 'tap') + .last; + final resolution = + tapEvent.data['viewportSemanticResolution'] as Map?; + expect(resolution, isNotNull); + expect(resolution!['status'], 'matched_actionable'); + expect(resolution['role'], 'button'); + }); + testWidgets('tap on non-actionable text resolves to matched_non_actionable', ( tester, ) async { @@ -223,9 +273,7 @@ void main() { ) async { await _pumpSettledScreen( tester, - Scaffold( - body: FilledButton(onPressed: () {}, child: const Text('Go')), - ), + Scaffold(body: const Center(child: Text('Static copy'))), ); final controller = TugboatReplay.controller!; @@ -559,6 +607,43 @@ void main() { expect((inventory['elements'] as List).isNotEmpty, isTrue); }); + testWidgets('separate scroll gestures reset semantic accumulation', ( + tester, + ) async { + await _pumpSettledScreen( + tester, + Scaffold( + body: ListView.builder( + itemCount: 60, + itemBuilder: (context, index) => ListTile(title: Text('Row $index')), + ), + ), + config: _scrollSemanticMapConfig, + ); + + await tester.drag(find.byType(ListView), const Offset(0, -260)); + await _waitForCaptures(tester); + await _waitForEvent(tester, 'scroll_semantic_snapshot'); + final controller = TugboatReplay.controller!; + final firstGestureSnapshotCount = controller.session!.events + .where((event) => event.type == 'scroll_semantic_snapshot') + .length; + + await tester.drag(find.byType(ListView), const Offset(0, -260)); + await _waitForCaptures(tester); + + final snapshots = controller.session!.events + .where((event) => event.type == 'scroll_semantic_snapshot') + .toList(); + final secondGestureSnapshots = snapshots.skip(firstGestureSnapshotCount); + expect(secondGestureSnapshots, isNotEmpty); + expect( + secondGestureSnapshots.first.data['observedSliceCount'], + 2, + reason: 'the second scroll starts a new semantic accumulation', + ); + }); + testWidgets( 'settled exploration screen emits both scene_inventory and viewport_semantic_map', (tester) async { @@ -582,14 +667,14 @@ void main() { expect(mapEvents, isNotEmpty); final inventory = inventoryEvents.first.data; - expect(inventory['stateSignature'], isA()); + expect(inventory.containsKey('stateSignature'), isFalse); expect(inventory['routeKey'], isA()); expect(inventory['inventoryHash'], isA()); expect(inventory['elements'], isA>()); expect((inventory['elements'] as List).isNotEmpty, isTrue); final map = mapEvents.first.data; - expect(map['stateSignature'], inventory['stateSignature']); + expect(map.containsKey('stateSignature'), isFalse); expect(map['routeKey'], inventory['routeKey']); final inventoryFingerprints = (inventory['elements'] as List) diff --git a/packages/tugboat_dio/CHANGELOG.md b/packages/tugboat_dio/CHANGELOG.md index ec3baf7..985b7a8 100644 --- a/packages/tugboat_dio/CHANGELOG.md +++ b/packages/tugboat_dio/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.8.0 + +### Changed + +- Compatibility release for `tugboat` 0.8.0. The Dio adapter has no runtime + behavior changes. + ## 0.7.0 ### Changed diff --git a/packages/tugboat_dio/README.md b/packages/tugboat_dio/README.md index bc1f548..74d1b74 100644 --- a/packages/tugboat_dio/README.md +++ b/packages/tugboat_dio/README.md @@ -5,14 +5,14 @@ status, outcome, and duration into an active Tugboat session. HTTP error responses additionally retain bounded JSON/text bodies. Successful response bodies, headers, queries, raw transport errors, and stack traces are omitted. -Requires `tugboat` `0.7.0` (lockstep). +Requires `tugboat` `0.8.0` (lockstep). ## Install ```yaml dependencies: - tugboat: ^0.7.0 - tugboat_dio: ^0.7.0 + tugboat: ^0.8.0 + tugboat_dio: ^0.8.0 ``` ## Usage diff --git a/packages/tugboat_dio/pubspec.yaml b/packages/tugboat_dio/pubspec.yaml index 5170e1d..fe1870f 100644 --- a/packages/tugboat_dio/pubspec.yaml +++ b/packages/tugboat_dio/pubspec.yaml @@ -2,7 +2,7 @@ name: tugboat_dio description: >- Dio interceptor that records safe, bounded network evidence into an active Tugboat capture session. -version: 0.7.0 +version: 0.8.0 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter @@ -18,7 +18,7 @@ dependencies: dio: ^5.4.0 flutter: sdk: flutter - tugboat: ^0.7.0 + tugboat: ^0.8.0 dev_dependencies: flutter_lints: ^5.0.0 From 7082946c8e6722a21fd964388f1f6e4067855f0e Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Mon, 10 Aug 2026 23:52:07 +0530 Subject: [PATCH 04/10] fix: preserve interactions across capture boundaries --- .../tugboat/lib/src/collector_mapper.dart | 4 +- packages/tugboat/lib/src/controller.dart | 70 ++++++++---- .../lib/src/viewport_semantic_session.dart | 25 +++-- .../tugboat/test/collector_mapper_test.dart | 2 +- ...eplay_coherence_characterization_test.dart | 100 +++++++++++++++++ .../tugboat/test/scroll_attribution_test.dart | 53 +++++++++ .../test/viewport_semantic_map_test.dart | 104 ++++++++++++++++++ 7 files changed, 327 insertions(+), 31 deletions(-) diff --git a/packages/tugboat/lib/src/collector_mapper.dart b/packages/tugboat/lib/src/collector_mapper.dart index 71a30a4..94a335b 100644 --- a/packages/tugboat/lib/src/collector_mapper.dart +++ b/packages/tugboat/lib/src/collector_mapper.dart @@ -96,7 +96,9 @@ Map mapTugboatSessionLifecycleToCollectorSession({ }; if (carriesUserId) { - body['userId'] = userId ?? config.userId; + // Only session_start inherits the configured startup identity. Later + // lifecycle records use null as an explicit identity-clear operation. + body['userId'] = isSessionStart ? userId ?? config.userId : userId; } if (isSessionStart) { final appInfo = Map.from(config.appInfo.toJson()) diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index e725785..01b7400 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -860,6 +860,7 @@ class TugboatReplayController extends ChangeNotifier { final Map _scrollTrackers = {}; final Map _scrollInteractions = {}; final Map _pendingScrollCompletions = {}; + final Set _activeCompletedGestureCaptures = {}; final Set _emittedInventories = {}; SemanticsHandle? _semanticsHandle; late final ViewportSemanticSession _viewportSemantics = @@ -1404,6 +1405,9 @@ class TugboatReplayController extends ChangeNotifier { // orphan causal_only tap for a claim that will never be referenced. _abandonAllPendingPointers(publishClaimedTap: false); _clearReleasedInteractions(); + _finalizeActiveCompletedGestureCaptures( + InteractionRejectionReason.sessionEnd, + ); _clearScrollCompletionState(); _captureLifecycleActive = false; @@ -1433,6 +1437,9 @@ class TugboatReplayController extends ChangeNotifier { _cancelActiveTapSettles('session_replacement'); _cancelActiveRouteCapture('session_replacement'); _invalidateCaptureWork('session_replacement'); + _finalizeActiveCompletedGestureCaptures( + InteractionRejectionReason.sessionEnd, + ); _captureLifecycleActive = true; _captureLifecycleEpoch++; _endSessionFuture = null; @@ -3424,15 +3431,18 @@ class TugboatReplayController extends ChangeNotifier { } bool _linkScrollStartToActiveGestures(String scrollStartEventId) { - var linked = false; + // A ScrollNotification does not identify a pointer. Give it one stable + // owner so a shared start ID cannot make pointer-up transactions replace + // each other in _scrollInteractions. Other active pointers remain swipes + // and receive their own interaction captures. for (final tx in _interactions.pending) { - linked = true; if (!tx.scrollStartEventIds.contains(scrollStartEventId)) { tx.scrollStartEventIds.add(scrollStartEventId); } tx.addEvidence(scrollStartEventId); + return true; } - return linked; + return false; } void _publishCanonicalInteraction(InteractionTransaction tx) { @@ -3478,6 +3488,20 @@ class TugboatReplayController extends ChangeNotifier { _pendingScrollCompletions.clear(); } + void _finalizeActiveCompletedGestureCaptures( + InteractionRejectionReason reason, + ) { + for (final tx in List.from( + _activeCompletedGestureCaptures, + )) { + tx.afterFrame = null; + tx.captureOutcome = _CaptureOutcome.cancelled.wireName; + tx.rejectionReason = reason; + _finalizeAbandonedTransaction(tx, reason: reason); + } + _activeCompletedGestureCaptures.clear(); + } + void _discardScrollCompletionFor(InteractionTransaction tx) { for (final scrollStartEventId in tx.scrollStartEventIds) { _scrollInteractions.remove(scrollStartEventId); @@ -3509,24 +3533,29 @@ class TugboatReplayController extends ChangeNotifier { void _publishCompletedGestureAfterCapture(InteractionTransaction tx) { final session = _session; final lifecycleEpoch = _captureLifecycleEpoch; + _activeCompletedGestureCaptures.add(tx); unawaited(() async { - final capture = _requestCaptureCancellable( - trigger: TugboatFrameTrigger.interaction, - force: true, - settleDelay: Duration.zero, - relatedEventId: tx.id, - ); - final resolution = await capture.resolution; - if (!_isCaptureLifecycleCurrent(session, lifecycleEpoch)) return; - tx.afterFrame = resolution.outcome == _CaptureOutcome.freshAccepted - ? resolution.frameId - : null; - tx.captureOutcome = resolution.outcome.wireName; - await _enqueue('interaction_after_capture', () async { + try { + final capture = _requestCaptureCancellable( + trigger: TugboatFrameTrigger.interaction, + force: true, + settleDelay: Duration.zero, + relatedEventId: tx.id, + ); + final resolution = await capture.resolution; if (!_isCaptureLifecycleCurrent(session, lifecycleEpoch)) return; - _publishCanonicalInteraction(tx); - if (!_disposed) notifyListeners(); - }); + tx.afterFrame = resolution.outcome == _CaptureOutcome.freshAccepted + ? resolution.frameId + : null; + tx.captureOutcome = resolution.outcome.wireName; + await _enqueue('interaction_after_capture', () async { + if (!_isCaptureLifecycleCurrent(session, lifecycleEpoch)) return; + _publishCanonicalInteraction(tx); + if (!_disposed) notifyListeners(); + }); + } finally { + _activeCompletedGestureCaptures.remove(tx); + } }()); } @@ -4352,6 +4381,9 @@ class TugboatReplayController extends ChangeNotifier { _clearReleasedInteractions( reason: InteractionRejectionReason.lifecycle, ); + _finalizeActiveCompletedGestureCaptures( + InteractionRejectionReason.lifecycle, + ); _clearScrollCompletionState(); _captureLifecycleActive = false; break; diff --git a/packages/tugboat/lib/src/viewport_semantic_session.dart b/packages/tugboat/lib/src/viewport_semantic_session.dart index 3153baf..c950674 100644 --- a/packages/tugboat/lib/src/viewport_semantic_session.dart +++ b/packages/tugboat/lib/src/viewport_semantic_session.dart @@ -121,6 +121,13 @@ class ViewportSemanticSession { required AnchorResolver? resolver, TugboatViewportSemanticScrollContext? scrollContext, }) { + if (scrollContext?.trigger == 'scroll_start') { + _beginScrollSemanticGesture( + stateSignature: inventory.stateSignature, + routeKey: inventory.routeKey, + scroll: scrollContext!, + ); + } if (!engineEnabled || resolver == null) return; final buildStopwatch = Stopwatch()..start(); @@ -148,10 +155,6 @@ class ViewportSemanticSession { final encodedPayload = bounded.encodedJson; _latestMap = map; - if (scrollContext?.trigger == 'scroll_start') { - _beginScrollSemanticGesture(map); - } - // tapResolutionOnly: keep the map as a device-local lookup table. if (!emitEvents) return; @@ -259,18 +262,20 @@ class ViewportSemanticSession { } } - void _beginScrollSemanticGesture(TugboatViewportSemanticMap map) { - final scroll = map.scrollContext; - if (scroll == null) return; + void _beginScrollSemanticGesture({ + required String stateSignature, + required String routeKey, + required TugboatViewportSemanticScrollContext scroll, + }) { final accumulatorKey = [ - map.routeKey, + routeKey, scroll.scrollableFingerprint ?? 'unknown', scroll.axis ?? 'unknown', ].join('|'); _scrollSemanticAccumulators[accumulatorKey] = _ScrollSemanticAccumulator( gestureSequence: ++_scrollGestureSequence, - stateSignature: map.stateSignature, - routeKey: map.routeKey, + stateSignature: stateSignature, + routeKey: routeKey, scrollableFingerprint: scroll.scrollableFingerprint, axis: scroll.axis, ); diff --git a/packages/tugboat/test/collector_mapper_test.dart b/packages/tugboat/test/collector_mapper_test.dart index 9423138..5506c0d 100644 --- a/packages/tugboat/test/collector_mapper_test.dart +++ b/packages/tugboat/test/collector_mapper_test.dart @@ -233,7 +233,7 @@ void main() { 'sessionId': 'sess_123', 'eventType': 'user_changed', 'triggeredAt': '2026-06-19T00:00:00.000Z', - 'userId': 'user_1', + 'userId': null, }); }); diff --git a/packages/tugboat/test/replay_coherence_characterization_test.dart b/packages/tugboat/test/replay_coherence_characterization_test.dart index 0ba1d84..a7ac6b6 100644 --- a/packages/tugboat/test/replay_coherence_characterization_test.dart +++ b/packages/tugboat/test/replay_coherence_characterization_test.dart @@ -537,6 +537,106 @@ void main() { }, ); + test( + 'ending session retains a swipe whose capture is still pending', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.seedRouteState(route: '/list', signature: 'sig-list'); + harness.capturer.blockNext = true; + harness.controller.recordPointerDown(const Offset(10, 100)); + harness.controller.markPendingTapAsSwipe(0); + harness.controller.recordPointerUp(const Offset(10, 10)); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + await harness.controller.endSession(); + final interactions = harness.controller.session!.ofType('interaction'); + expect(interactions, hasLength(1)); + expect(interactions.single.data['gesture'], 'swipe'); + final result = interactions.single.data['result'] as Map; + expect(result['status'], 'cancelled'); + expect(result['captureOutcome'], 'cancelled'); + expect(result['observedAtMs'], isA()); + expect( + (interactions.single.data['attribution'] as Map)['rejectionReason'], + 'sessionEnd', + ); + + harness.capturer.completeBlocked('late-swipe-frame'); + await harness.pumpQueueWork(); + expect(harness.controller.session!.ofType('interaction'), hasLength(1)); + }, + ); + + test( + 'backgrounding retains a swipe whose capture is still pending', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.seedRouteState(route: '/list', signature: 'sig-list'); + harness.capturer.blockNext = true; + harness.controller.recordPointerDown(const Offset(10, 100)); + harness.controller.markPendingTapAsSwipe(0); + harness.controller.recordPointerUp(const Offset(10, 10)); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + harness.controller.recordAppLifecycleState(AppLifecycleState.paused); + final interactions = harness.controller.session!.ofType('interaction'); + expect(interactions, hasLength(1)); + expect(interactions.single.data['gesture'], 'swipe'); + expect( + (interactions.single.data['result'] as Map)['status'], + 'cancelled', + ); + expect( + (interactions.single.data['attribution'] as Map)['rejectionReason'], + 'lifecycle', + ); + + harness.capturer.completeBlocked('late-background-swipe-frame'); + await harness.pumpQueueWork(); + expect(harness.controller.session!.ofType('interaction'), hasLength(1)); + }, + ); + + test( + 'session replacement finalizes a pending swipe in the old session', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + final oldSession = harness.controller.session!; + harness.seedRouteState(route: '/list', signature: 'sig-list'); + harness.capturer.blockNext = true; + harness.controller.recordPointerDown(const Offset(10, 100)); + harness.controller.markPendingTapAsSwipe(0); + harness.controller.recordPointerUp(const Offset(10, 10)); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + harness.controller.start(const Size(390, 844), 'replacement'); + expect(oldSession.ofType('interaction'), hasLength(1)); + expect(oldSession.ofType('interaction').single.data['gesture'], 'swipe'); + expect( + (oldSession.ofType('interaction').single.data['result'] + as Map)['status'], + 'cancelled', + ); + + harness.capturer.completeBlocked('late-replacement-swipe-frame'); + await harness.pumpQueueWork(); + expect(oldSession.ofType('interaction'), hasLength(1)); + expect(harness.controller.session!.ofType('interaction'), isEmpty); + }, + ); + testWidgets('ending session suppresses blocked scroll_end output', ( tester, ) async { diff --git a/packages/tugboat/test/scroll_attribution_test.dart b/packages/tugboat/test/scroll_attribution_test.dart index b48a9e4..34b9669 100644 --- a/packages/tugboat/test/scroll_attribution_test.dart +++ b/packages/tugboat/test/scroll_attribution_test.dart @@ -109,6 +109,59 @@ void main() { await _exerciseScrollCallbackOrder(tester, endBeforePointerUp: false); }); + testWidgets('one scroll start has one pointer owner', (tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _scrollTestConfig, child: child!), + home: Scaffold( + body: ListView.builder( + itemCount: 30, + itemBuilder: (context, index) => Text('Owner item $index'), + ), + ), + ), + ); + await _waitForCaptures(tester); + + final controller = TugboatReplay.controller!; + controller.debugExecuteCapture = + ({required trigger, required force}) async { + return controller.debugSeedFrame(trigger: trigger); + }; + final listContext = tester.element(find.byType(Scrollable)); + final metrics = Scrollable.of( + tester.element(find.text('Owner item 0')), + ).position; + controller.recordPointerDown(const Offset(20, 120), pointer: 1); + controller.recordPointerDown(const Offset(40, 120), pointer: 2); + controller.markPendingTapAsSwipe(1); + controller.markPendingTapAsSwipe(2); + controller.recordScrollStart( + scrollContext: listContext, + metrics: metrics, + depth: 0, + ); + controller.recordPointerUp(const Offset(20, 20), pointer: 1); + controller.recordPointerUp(const Offset(40, 20), pointer: 2); + controller.recordScrollEnd(scrollContext: listContext, metrics: metrics); + await _waitForCaptures(tester); + await _waitForCaptures(tester); + + final interactions = controller.session!.events + .where((event) => event.type == 'interaction') + .toList(); + expect(interactions, hasLength(2)); + expect(interactions.map((event) => event.data['gesture']).toSet(), { + 'scroll', + 'swipe', + }); + expect( + interactions.map((event) => event.data['interactionId']).toSet(), + hasLength(2), + ); + }); + testWidgets( 'programmatic scroll emits evidence without interaction capture', (tester) async { diff --git a/packages/tugboat/test/viewport_semantic_map_test.dart b/packages/tugboat/test/viewport_semantic_map_test.dart index 90b5814..1037a68 100644 --- a/packages/tugboat/test/viewport_semantic_map_test.dart +++ b/packages/tugboat/test/viewport_semantic_map_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/tugboat.dart'; import 'package:tugboat/src/anchors.dart'; +import 'package:tugboat/src/viewport_semantic_session.dart'; const _semanticMapConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, @@ -813,4 +814,107 @@ void main() { expect(resolution.status, 'matched_actionable'); expect(resolution.linkedFingerprint, isNotEmpty); }); + + testWidgets('unavailable scroll start still resets semantic accumulation', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final rootKey = GlobalKey(); + Future mountInventory() async { + await tester.pumpWidget( + MaterialApp( + home: RepaintBoundary( + key: rootKey, + child: Scaffold( + body: FilledButton( + onPressed: () {}, + child: const Text('Semantic target'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final resolver = AnchorResolver(rootKey: rootKey); + return resolver.buildSceneInventory( + route: '/list', + keyboardOpen: false, + modalOpen: false, + )!; + } + + final emitted = []; + var eventId = 0; + final semanticSession = ViewportSemanticSession( + config: _scrollSemanticMapConfig, + nextEventId: (prefix) => '$prefix-${eventId++}', + atMs: () => eventId, + addEvent: emitted.add, + ); + var inventory = await mountInventory(); + var resolver = AnchorResolver(rootKey: rootKey); + const start = TugboatViewportSemanticScrollContext( + trigger: 'scroll_start', + scrollableFingerprint: 'fp-list', + axis: 'vertical', + offsetNorm: 0, + ); + semanticSession.maybeEmit( + inventory, + resolver: resolver, + scrollContext: start, + ); + semanticSession.maybeEmit( + inventory, + resolver: resolver, + scrollContext: const TugboatViewportSemanticScrollContext( + trigger: 'scroll_update', + scrollableFingerprint: 'fp-list', + axis: 'vertical', + offsetNorm: 0.2, + ), + ); + final firstSnapshotCount = emitted + .where((event) => event.type == 'scroll_semantic_snapshot') + .length; + expect(firstSnapshotCount, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + semanticSession.maybeEmit( + inventory, + resolver: resolver, + scrollContext: start, + ); + + inventory = await mountInventory(); + resolver = AnchorResolver(rootKey: rootKey); + semanticSession.maybeEmit( + inventory, + resolver: resolver, + scrollContext: const TugboatViewportSemanticScrollContext( + trigger: 'scroll_update', + scrollableFingerprint: 'fp-list', + axis: 'vertical', + offsetNorm: 0.6, + ), + ); + semanticSession.maybeEmit( + inventory, + resolver: resolver, + scrollContext: const TugboatViewportSemanticScrollContext( + trigger: 'scroll_end', + scrollableFingerprint: 'fp-list', + axis: 'vertical', + offsetNorm: 0.8, + ), + ); + + final newSnapshots = emitted + .where((event) => event.type == 'scroll_semantic_snapshot') + .skip(firstSnapshotCount) + .toList(); + expect(newSnapshots, isNotEmpty); + expect(newSnapshots.first.data['observedSliceCount'], 2); + semantics.dispose(); + }); } From ff0c387aaf8c2a653464e71417b54a9be8fd1ebb Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Mon, 10 Aug 2026 23:52:14 +0530 Subject: [PATCH 05/10] fix: bump session wire schema to 10 --- docs/design/capture-and-fingerprint.md | 11 ++++++----- docs/integration/production-replay-acceptance.md | 2 +- packages/tugboat/CHANGELOG.md | 4 +++- packages/tugboat/README.md | 4 ++-- packages/tugboat/lib/src/models.dart | 7 ++++--- packages/tugboat/test/helpers/json_roundtrip.dart | 6 +++++- .../release_compatibility_matrix_test.dart | 6 +++--- packages/tugboat/test/tugboat_replay_test.dart | 2 +- 8 files changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/design/capture-and-fingerprint.md b/docs/design/capture-and-fingerprint.md index 25ea2b0..5abb24b 100644 --- a/docs/design/capture-and-fingerprint.md +++ b/docs/design/capture-and-fingerprint.md @@ -63,7 +63,7 @@ current controller and keeps future calls to `wrapApp` inert. Runtime requiring a host rebuild. `deactivate()` tears capture down through the same gate. Pause/hidden flush pending delivery; detach ends the session once. -Identity fields (session schema **v9**; compatibility readers accept v6–v9): +Identity fields (session schema **v10**; compatibility readers accept v6–v10): - `activationRequestId` — host request correlation - `captureSessionId` — SDK-emitted session (`session.id`) @@ -76,10 +76,11 @@ emits exact build and fingerprint-schema provenance only. ## Session and event model The controller owns one bounded, in-memory `TugboatSession`. Serialized session -JSON is schema version `9`. Compatibility readers accept schema versions -`6` through `9`. Schema v9 does not write `controlValue`, -`controlValueTransition`, or `semanticAnnotation` in event `data`; -those fields are optional historic data in older sessions only. +JSON is schema version `10`. Compatibility readers accept schema versions +`6` through `10`. Schema v9 stopped writing `controlValue`, +`controlValueTransition`, or `semanticAnnotation` in event `data`; those fields +are optional historic data in older sessions only. Schema v10 removes +serialized state identity and adds the `interaction` frame trigger. The session stores: diff --git a/docs/integration/production-replay-acceptance.md b/docs/integration/production-replay-acceptance.md index 523e533..d7c8257 100644 --- a/docs/integration/production-replay-acceptance.md +++ b/docs/integration/production-replay-acceptance.md @@ -13,7 +13,7 @@ database receipt alone as proof that a replay is correct. ## Current acceptance status The current SDK release candidate is **0.8.0**, which writes session schema -**v9**. It preserves structural interaction replay while no longer emitting +**v10**. It preserves structural interaction replay while no longer emitting `controlValue`, `controlValueTransition`, `semanticAnnotation`, `stateAnchor`, or `stateSignature` in new writer output. It also does not emit `state_change`. Treat the absence of those fields as the expected privacy boundary, not as diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index 04a232f..d2b2e74 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -3,7 +3,9 @@ ### Changed - Raw SDK writers no longer emit `stateAnchor`, `stateSignature`, or - `state_change` events. Completed interactions request one fresh after-frame. + `state_change` events. Session wire schema 10 identifies this contract and + the serialized `interaction` frame trigger. Completed interactions request + one fresh after-frame. - Collector event mapping now omits `stateAnchor`. Deploy the serial collector compatibility patch before sending 0.8.0 recordings to a collector that still requires that key. diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 7a1de1f..627f55a 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -6,8 +6,8 @@ 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.8.0`. Session JSON writers emit schema -version `9`; compatibility readers should accept versions `6` through -`9`. Structural fingerprints use fingerprint schema version `6`. +version `10`; compatibility readers should accept versions `6` through +`10`. Structural fingerprints use fingerprint schema version `6`. ## 0.8.0 raw-event compatibility diff --git a/packages/tugboat/lib/src/models.dart b/packages/tugboat/lib/src/models.dart index c093a45..60d9a70 100644 --- a/packages/tugboat/lib/src/models.dart +++ b/packages/tugboat/lib/src/models.dart @@ -6,10 +6,11 @@ import 'package:flutter/widgets.dart'; import 'anchors.dart'; import 'collector_config.dart'; -/// Current session JSON schema. Writers emit this; readers accept 6–9. +/// Current session JSON schema. Writers emit this; readers accept 6–10. /// -/// Schema 9 stops emitting value and semantic-annotation event data. -const int tugboatSessionSchemaVersion = 9; +/// Schema 10 removes serialized state identity, removes `state_change`, and +/// adds the `interaction` frame trigger. +const int tugboatSessionSchemaVersion = 10; /// Event selection channel for enrichment / insight / replay consumers. enum TugboatEventStream { diff --git a/packages/tugboat/test/helpers/json_roundtrip.dart b/packages/tugboat/test/helpers/json_roundtrip.dart index 33d0d3d..c158238 100644 --- a/packages/tugboat/test/helpers/json_roundtrip.dart +++ b/packages/tugboat/test/helpers/json_roundtrip.dart @@ -113,7 +113,11 @@ extension TugboatEventTestJson on TugboatEvent { extension TugboatSessionTestJson on TugboatSession { static TugboatSession fromJson(Map json) { final version = json['schemaVersion'] as int?; - if (version != 6 && version != 7 && version != 8 && version != 9) { + if (version != 6 && + version != 7 && + version != 8 && + version != 9 && + version != 10) { throw const FormatException( 'Unsupported Tugboat session schema version.', ); diff --git a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart index 1159444..75c23a4 100644 --- a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart +++ b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart @@ -116,7 +116,7 @@ void main() { expect(routes, isNotEmpty); }); - test('v6-v8 session JSON remains readable alongside v9 writers', () { + test('v6-v9 session JSON remains readable alongside v10 writers', () { final session = TugboatSession( id: 'legacy-session', startedAt: DateTime.utc(2026, 8, 3), @@ -124,9 +124,9 @@ void main() { viewport: const TugboatRect(0, 0, 100, 200), ); final writerJson = session.toJson(); - expect(writerJson['schemaVersion'], 9); + expect(writerJson['schemaVersion'], 10); - for (final version in [6, 7, 8]) { + for (final version in [6, 7, 8, 9]) { final legacyJson = Map.from(writerJson) ..['schemaVersion'] = version ..['events'] = [ diff --git a/packages/tugboat/test/tugboat_replay_test.dart b/packages/tugboat/test/tugboat_replay_test.dart index ccf4573..bb710bc 100644 --- a/packages/tugboat/test/tugboat_replay_test.dart +++ b/packages/tugboat/test/tugboat_replay_test.dart @@ -1054,7 +1054,7 @@ void main() { ); final json = jsonDecode(session.toPrettyJson()) as Map; - expect(json['schemaVersion'], 9); + expect(json['schemaVersion'], 10); expect(json.containsKey('routes'), isFalse); expect(json['events'], [isNot(contains('route'))]); expect(json['frames'], [containsPair('captureMicros', 12345)]); From 979d99bec897ad424e395611222036a058659a05 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Tue, 11 Aug 2026 05:02:25 +0530 Subject: [PATCH 06/10] feat: emit facts-only schema-v2 production events Remove state-signature plumbing and reshape interaction, route_change, and scroll collector events as flat facts-only records so downstream consumers derive outcomes from evidence instead of inferred SDK fields. Co-authored-by: Cursor --- docs/design/capture-and-fingerprint.md | 7 +- docs/integration/collector.md | 5 +- packages/tugboat/CHANGELOG.md | 8 + packages/tugboat/README.md | 6 + packages/tugboat/lib/src/anchor_models.dart | 81 ---- packages/tugboat/lib/src/anchor_resolver.dart | 88 ----- .../lib/src/anchor_scene_inventory.dart | 20 +- .../lib/src/anchor_viewport_semantics.dart | 2 - .../tugboat/lib/src/collector_mapper.dart | 122 +++++- packages/tugboat/lib/src/controller.dart | 279 +------------- .../lib/src/interaction_transaction.dart | 107 ++---- packages/tugboat/lib/src/models.dart | 8 +- .../lib/src/viewport_semantic_session.dart | 7 - packages/tugboat/lib/tugboat.dart | 1 - .../tugboat/test/collector_mapper_test.dart | 229 ++++++++++- .../test/external_event_and_network_test.dart | 4 +- packages/tugboat/test/fingerprint_test.dart | 363 +----------------- .../tugboat/test/helpers/json_roundtrip.dart | 23 -- .../helpers/replay_coherence_harness.dart | 61 ++- .../release_compatibility_matrix_test.dart | 3 +- .../replay/interaction_transaction_test.dart | 88 +++-- ...ay_navigation_interaction_matrix_test.dart | 8 +- .../replay_navigation_race_matrix_test.dart | 18 +- ...overlay_nested_navigation_matrix_test.dart | 8 +- ...eplay_coherence_characterization_test.dart | 124 +----- .../tugboat/test/scene_inventory_test.dart | 4 +- .../tugboat/test/scroll_attribution_test.dart | 5 +- .../tugboat/test/token_map_cache_test.dart | 4 +- .../tugboat/test/tugboat_replay_test.dart | 134 +------ 29 files changed, 541 insertions(+), 1276 deletions(-) diff --git a/docs/design/capture-and-fingerprint.md b/docs/design/capture-and-fingerprint.md index 5abb24b..d62ab4f 100644 --- a/docs/design/capture-and-fingerprint.md +++ b/docs/design/capture-and-fingerprint.md @@ -108,8 +108,11 @@ The event stream currently includes: Events may carry `beforeFrame`, `afterFrame`, `targetAnchor`, `relatedEventId`, `explorationRunId`, `actionId`, an interaction result, and -type-specific `data`. Route transition values live in `route_change.data`, not -in a session-level route dictionary. +type-specific `data`. Schema-v2 production events (`interaction`, +`route_change`, `scroll_start`, `scroll_end`) are flat facts-only collector +records without nested `payload` or inferred interaction results. Route +transition values live in `route_change` fields, not in a session-level route +dictionary. ### Capture lifecycle and attribution diff --git a/docs/integration/collector.md b/docs/integration/collector.md index ed2ccdc..7c47b7f 100644 --- a/docs/integration/collector.md +++ b/docs/integration/collector.md @@ -202,7 +202,10 @@ Event payloads contain: - optional `traitsId` (pass-through only; does not upsert the traits dictionary); - optional before/after frame references, related-event ID, and result; - serialized target anchors, when captured; -- event-specific data under `payload`; +- event-specific data under `payload`, except for schema-v2 `interaction`, + `route_change`, `scroll_start`, and `scroll_end`, which are flat facts-only + records at the top level (`interactionSchema`, `routeChangeSchema`, or + `scrollSchema` == `2`); - build identity: app ID, platform, version name, build number, and fingerprint schema version. diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index d2b2e74..db71841 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -9,6 +9,14 @@ - Collector event mapping now omits `stateAnchor`. Deploy the serial collector compatibility patch before sending 0.8.0 recordings to a collector that still requires that key. +- Production collector events for `interaction`, `route_change`, `scroll_start`, + and `scroll_end` now use flat schema-v2 wire shapes (`interactionSchema`, + `routeChangeSchema`, or `scrollSchema` == `2`) with facts-only fields. The + mapper no longer nests these under `payload`, emits empty `targetAnchor` + objects, or duplicates `stream` inside `payload`. Scroll events send + `targetFingerprint` as a single string instead of a full anchor descriptor. + Interaction v2 drops inferred `result`, nested `origin`/`result`, and + tap-settle outcome computation. ## 0.7.1 diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 627f55a..5774d54 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -18,6 +18,12 @@ requests its own fresh after-frame. The collector mapper also omits the top- level `stateAnchor` key. Deploy the related collector change with this SDK release. +Schema-v2 collector events (`interaction`, `route_change`, `scroll_start`, +`scroll_end`) are flat facts-only records: no nested `payload`, no empty +`targetAnchor`, and no inferred interaction `result`. Scroll events send +`targetFingerprint` as a string; interaction v2 sends `targetFingerprint`, +`gesture`, optional `route`/`position`, and frame refs only. + ## Install Add `tugboat` to the host app and import the public barrel: diff --git a/packages/tugboat/lib/src/anchor_models.dart b/packages/tugboat/lib/src/anchor_models.dart index a1be6a2..7c5d2ef 100644 --- a/packages/tugboat/lib/src/anchor_models.dart +++ b/packages/tugboat/lib/src/anchor_models.dart @@ -133,73 +133,6 @@ class TugboatTargetAnchor { ); } -/// Compact canonical signature of the current screen state. -class TugboatStateAnchor { - const TugboatStateAnchor({ - this.schemaVersion = 1, - this.actionableSummary = const {}, - this.keyboardOpen = false, - this.modalOpen = false, - this.subLabel, - this.signature = '', - this.signatureConfidence, - this.signatureParts = const {}, - }); - - final int schemaVersion; - - /// Aggregate role counts (for example `button: 3`) used as compact state - /// signature metadata. This is not a per-control inventory and must not be - /// used to discover individual tap targets; use screenshots for that instead. - final Map actionableSummary; - final bool keyboardOpen; - final bool modalOpen; - final String? subLabel; - final String signature; - final String? signatureConfidence; - - /// Stable fields used to derive [signature]. Dynamic labels are excluded. - final Map signatureParts; - - Map toJson() => { - if (schemaVersion != 1) 'schemaVersion': schemaVersion, - if (actionableSummary.isNotEmpty) 'actionableSummary': actionableSummary, - if (keyboardOpen) 'keyboardOpen': keyboardOpen, - if (modalOpen) 'modalOpen': modalOpen, - if (subLabel != null && subLabel!.isNotEmpty) 'subLabel': subLabel, - if (signature.isNotEmpty) 'signature': signature, - if (signatureConfidence != null && signatureConfidence!.isNotEmpty) - 'signatureConfidence': signatureConfidence, - if (signatureParts.isNotEmpty) 'signatureParts': signatureParts, - }; - - @override - bool operator ==(Object other) => - other is TugboatStateAnchor && - schemaVersion == other.schemaVersion && - keyboardOpen == other.keyboardOpen && - modalOpen == other.modalOpen && - subLabel == other.subLabel && - signature == other.signature && - signatureConfidence == other.signatureConfidence && - _mapEquals(signatureParts, other.signatureParts) && - _mapEquals(actionableSummary, other.actionableSummary); - - @override - int get hashCode => Object.hash( - schemaVersion, - keyboardOpen, - modalOpen, - subLabel, - signature, - signatureConfidence, - _stringMapHash(signatureParts), - Object.hashAll( - actionableSummary.entries.map((entry) => '${entry.key}:${entry.value}'), - ), - ); -} - /// One salient element in a screen's structural inventory. class TugboatSceneInventoryEntry { const TugboatSceneInventoryEntry({ @@ -256,16 +189,11 @@ class TugboatSceneInventoryEntry { /// Structural inventory of salient elements on a settled screen state. class TugboatSceneInventory { const TugboatSceneInventory({ - required this.stateAnchor, - required this.stateSignature, required this.inventoryHash, required this.routeKey, required this.elements, }); - /// Anchor the inventory was computed against; not serialized into [toJson]. - final TugboatStateAnchor stateAnchor; - final String stateSignature; final String inventoryHash; final String routeKey; final List elements; @@ -394,8 +322,6 @@ class TugboatViewportSemanticScrollContext { /// Exploration-only viewport semantic map for a settled screen state. class TugboatViewportSemanticMap { const TugboatViewportSemanticMap({ - required this.stateAnchor, - required this.stateSignature, required this.routeKey, required this.viewport, required this.nodes, @@ -404,9 +330,6 @@ class TugboatViewportSemanticMap { this.scrollContext, }); - /// Anchor the map was computed against; not serialized into [toJson]. - final TugboatStateAnchor stateAnchor; - final String stateSignature; final String routeKey; final Size viewport; final List nodes; @@ -421,8 +344,6 @@ class TugboatViewportSemanticMap { TugboatViewportSemanticScrollContext? scrollContext, }) { return TugboatViewportSemanticMap( - stateAnchor: stateAnchor, - stateSignature: stateSignature, routeKey: routeKey, viewport: viewport, nodes: nodes ?? this.nodes, @@ -445,7 +366,6 @@ class TugboatViewportSemanticMap { /// Multi-viewport semantic evidence observed during a scroll interaction. class TugboatScrollSemanticSnapshot { const TugboatScrollSemanticSnapshot({ - required this.stateSignature, required this.routeKey, required this.scrollableFingerprint, required this.axis, @@ -458,7 +378,6 @@ class TugboatScrollSemanticSnapshot { required this.snapshotHash, }); - final String stateSignature; final String routeKey; final String? scrollableFingerprint; final String? axis; diff --git a/packages/tugboat/lib/src/anchor_resolver.dart b/packages/tugboat/lib/src/anchor_resolver.dart index 915389b..322fe35 100644 --- a/packages/tugboat/lib/src/anchor_resolver.dart +++ b/packages/tugboat/lib/src/anchor_resolver.dart @@ -185,15 +185,6 @@ class AnchorResolver { final tokenMap = _tokenMapFor(rootContext, rootRender); if (tokenMap == null) return (inventory: null, target: null); - final stateAnchor = _stateAnchorFromTokenMap( - tokenMap: tokenMap, - route: route, - keyboardOpen: keyboardOpen, - modalOpen: modalOpen, - ); - if (stateAnchor.signature.isEmpty) { - return (inventory: null, target: null); - } var target = _targetAtWithTokenMap( tapPosition, @@ -205,7 +196,6 @@ class AnchorResolver { tokenMap: tokenMap, rootRender: rootRender, route: route, - stateAnchor: stateAnchor, ); target = _snapPathlessTargetToInventory( target: target, @@ -217,7 +207,6 @@ class AnchorResolver { inventory: inventory, target: target, tapPosition: tapPosition, - stateAnchor: stateAnchor, route: route, tokenMap: tokenMap, rootRender: rootRender, @@ -870,83 +859,6 @@ class AnchorResolver { return type.startsWith('Sliver'); } - TugboatStateAnchor buildStateAnchor({ - required String? route, - required bool keyboardOpen, - required bool modalOpen, - }) { - final rootContext = rootKey.currentContext; - final rootRender = rootContext?.findRenderObject(); - if (rootContext is! Element || rootRender is! RenderBox) { - return TugboatStateAnchor( - keyboardOpen: keyboardOpen, - modalOpen: modalOpen, - ); - } - - final tokenMap = _tokenMapFor(rootContext, rootRender); - if (tokenMap == null) { - return TugboatStateAnchor( - keyboardOpen: keyboardOpen, - modalOpen: modalOpen, - ); - } - return _stateAnchorFromTokenMap( - tokenMap: tokenMap, - route: route, - keyboardOpen: keyboardOpen, - modalOpen: modalOpen, - ); - } - - TugboatStateAnchor _stateAnchorFromTokenMap({ - required _TokenMap tokenMap, - required String? route, - required bool keyboardOpen, - required bool modalOpen, - }) { - final routeKey = _resolveRouteKey(route, tokenMap); - final actionableSummary = tokenMap.actionableSummary; - final subLabel = tokenMap.subLabel; - final effectiveModalOpen = modalOpen || tokenMap.hasBlockingOverlay; - // fp schema v6: state identity is coarse — route + overlay flags + subLabel - // only. Dynamic list length, scroll viewport, and per-item path multiplicity - // must not fork signatures across production sessions on the same screen. - final hashParts = { - 'routeKey': routeKey, - 'schemaVersion': tugboatFingerprintSchemaVersion.toString(), - if (keyboardOpen) 'keyboardOpen': 'true', - if (effectiveModalOpen) 'modalOpen': 'true', - if (subLabel != null && subLabel.isNotEmpty) 'subLabel': subLabel, - }; - final signature = _fingerprintForParts(hashParts); - - // Serialized evidence: compact descriptors only, never the skeleton. - final signatureParts = { - 'schemaVersion': tugboatFingerprintSchemaVersion.toString(), - 'routeKey': routeKey, - if (keyboardOpen) 'keyboardOpen': 'true', - if (effectiveModalOpen) 'modalOpen': 'true', - if (subLabel != null && subLabel.isNotEmpty) 'subLabel': subLabel, - }; - - final pathConfidence = tokenMap.isActionable.isNotEmpty ? 'medium' : 'low'; - - return TugboatStateAnchor( - schemaVersion: tugboatFingerprintSchemaVersion, - actionableSummary: actionableSummary, - keyboardOpen: keyboardOpen, - modalOpen: effectiveModalOpen, - subLabel: subLabel, - signature: signature, - signatureConfidence: _confidenceFloor([ - _routeKeyConfidence(routeKey), - pathConfidence, - ]), - signatureParts: signatureParts, - ); - } - String _normalizeItemPathForSignature(String path) { return path.replaceAll(RegExp(r'\[item:[^\]]+\]'), '[item]'); } diff --git a/packages/tugboat/lib/src/anchor_scene_inventory.dart b/packages/tugboat/lib/src/anchor_scene_inventory.dart index c279b47..9ceb146 100644 --- a/packages/tugboat/lib/src/anchor_scene_inventory.dart +++ b/packages/tugboat/lib/src/anchor_scene_inventory.dart @@ -19,19 +19,11 @@ extension TugboatSceneInventoryApi on AnchorResolver { final tokenMap = _tokenMapFor(rootContext, rootRender); if (tokenMap == null) return null; - final stateAnchor = _stateAnchorFromTokenMap( - tokenMap: tokenMap, - route: route, - keyboardOpen: keyboardOpen, - modalOpen: modalOpen, - ); - if (stateAnchor.signature.isEmpty) return null; return _buildSceneInventoryFromTokenMap( tokenMap: tokenMap, rootRender: rootRender, route: route, - stateAnchor: stateAnchor, ); } @@ -39,7 +31,6 @@ extension TugboatSceneInventoryApi on AnchorResolver { required _TokenMap tokenMap, required RenderBox rootRender, required String? route, - required TugboatStateAnchor stateAnchor, }) { final routeKey = _resolveRouteKey(route, tokenMap); final viewport = rootRender.size; @@ -108,15 +99,10 @@ extension TugboatSceneInventoryApi on AnchorResolver { final elements = [...interactiveByFingerprint.values, ...contentEntries]; if (elements.isEmpty) return null; - return _inventoryFromElements( - stateAnchor: stateAnchor, - routeKey: routeKey, - elements: elements, - ); + return _inventoryFromElements(routeKey: routeKey, elements: elements); } TugboatSceneInventory _inventoryFromElements({ - required TugboatStateAnchor stateAnchor, required String routeKey, required List elements, }) { @@ -125,8 +111,6 @@ extension TugboatSceneInventoryApi on AnchorResolver { final inventoryHash = tugboatLabelHash(fingerprints.join('|')); return TugboatSceneInventory( - stateAnchor: stateAnchor, - stateSignature: stateAnchor.signature, inventoryHash: inventoryHash, routeKey: routeKey, elements: elements, @@ -279,7 +263,6 @@ extension TugboatSceneInventoryApi on AnchorResolver { required TugboatSceneInventory? inventory, required TugboatTargetAnchor? target, required Offset tapPosition, - required TugboatStateAnchor stateAnchor, required String? route, required _TokenMap tokenMap, required RenderBox rootRender, @@ -308,7 +291,6 @@ extension TugboatSceneInventoryApi on AnchorResolver { injectedEntry, ]; return _inventoryFromElements( - stateAnchor: stateAnchor, routeKey: inventory?.routeKey ?? routeKey, elements: elements, ); diff --git a/packages/tugboat/lib/src/anchor_viewport_semantics.dart b/packages/tugboat/lib/src/anchor_viewport_semantics.dart index 15020ac..7023116 100644 --- a/packages/tugboat/lib/src/anchor_viewport_semantics.dart +++ b/packages/tugboat/lib/src/anchor_viewport_semantics.dart @@ -79,8 +79,6 @@ extension TugboatViewportSemanticsApi on AnchorResolver { final mapHash = _viewportSemanticMapHash(nodes); return TugboatViewportSemanticMap( - stateAnchor: inventory.stateAnchor, - stateSignature: inventory.stateSignature, routeKey: inventory.routeKey, viewport: viewport, nodes: nodes, diff --git a/packages/tugboat/lib/src/collector_mapper.dart b/packages/tugboat/lib/src/collector_mapper.dart index 94a335b..56ffae0 100644 --- a/packages/tugboat/lib/src/collector_mapper.dart +++ b/packages/tugboat/lib/src/collector_mapper.dart @@ -25,9 +25,89 @@ Map mapTugboatEventToCollectorEvent({ }) { final triggeredAt = sessionStartedAt.add(Duration(milliseconds: event.atMs)); + if (event.type == 'interaction') { + final data = event.data; + return _collectorFlatEnvelope( + event: event, + triggeredAt: triggeredAt, + collectorConfig: collectorConfig, + sessionId: sessionId, + userId: userId, + traitsId: traitsId, + extra: { + 'interactionSchema': + data['interactionSchema'] ?? tugboatInteractionSchemaVersion, + if (data['route'] != null) 'route': data['route'], + if (data['targetFingerprint'] != null) + 'targetFingerprint': data['targetFingerprint'], + if (data['gesture'] != null) 'gesture': data['gesture'], + if (data['position'] != null) 'position': data['position'], + }, + ); + } + + if (event.type == 'route_change') { + final data = event.data; + return _collectorFlatEnvelope( + event: event, + triggeredAt: triggeredAt, + collectorConfig: collectorConfig, + sessionId: sessionId, + userId: userId, + traitsId: traitsId, + extra: { + 'routeChangeSchema': tugboatRouteChangeSchemaVersion, + if (data['fromRoute'] != null) 'fromRoute': data['fromRoute'], + if (data['route'] != null) 'route': data['route'], + if (data['navigation'] != null) 'navigation': data['navigation'], + }, + ); + } + + if (event.type == 'scroll_start') { + final data = event.data; + final targetFingerprint = _targetFingerprint(event); + return _collectorFlatEnvelope( + event: event, + triggeredAt: triggeredAt, + collectorConfig: collectorConfig, + sessionId: sessionId, + userId: userId, + traitsId: traitsId, + extra: { + 'scrollSchema': tugboatScrollSchemaVersion, + if (data['axis'] != null) 'axis': data['axis'], + if (data['startOffset'] != null) 'startOffset': data['startOffset'], + if (targetFingerprint != null) 'targetFingerprint': targetFingerprint, + }, + ); + } + + if (event.type == 'scroll_end') { + final data = event.data; + final overscrollCount = data['overscrollCount']; + final targetFingerprint = _targetFingerprint(event); + return _collectorFlatEnvelope( + event: event, + triggeredAt: triggeredAt, + collectorConfig: collectorConfig, + sessionId: sessionId, + userId: userId, + traitsId: traitsId, + extra: { + 'scrollSchema': tugboatScrollSchemaVersion, + if (data['startOffset'] != null) 'startOffset': data['startOffset'], + if (data['endOffset'] != null) 'endOffset': data['endOffset'], + if (data['durationMs'] != null) 'durationMs': data['durationMs'], + if (overscrollCount is int && overscrollCount > 0) + 'overscrollCount': overscrollCount, + if (targetFingerprint != null) 'targetFingerprint': targetFingerprint, + }, + ); + } + final payload = { ...event.data, - 'stream': event.stream.wireName, if (event.relatedEventId != null) 'relatedEventId': event.relatedEventId, if (event.explorationRunId != null) 'explorationRunId': event.explorationRunId, @@ -49,13 +129,51 @@ Map mapTugboatEventToCollectorEvent({ if (event.beforeFrame != null) 'beforeFrame': event.beforeFrame, if (event.afterFrame != null) 'afterFrame': event.afterFrame, if (traitsId != null) 'traitsId': traitsId, - 'targetAnchor': event.targetAnchor?.toJson() ?? {}, + if (event.targetAnchor != null) + 'targetAnchor': event.targetAnchor!.toJson(), if (event.result != null) 'result': event.result!.name, 'payload': payload, 'build': collectorEventBuildIdentity(collectorConfig), }; } +/// Shared flat collector envelope for schema-v2 production events. +Map _collectorFlatEnvelope({ + required TugboatEvent event, + required DateTime triggeredAt, + required TugboatCollectorConfig collectorConfig, + String? sessionId, + String? userId, + String? traitsId, + required Map extra, +}) { + return { + 'id': event.id, + 'atMs': event.atMs, + 'triggeredAt': triggeredAt.toUtc().toIso8601String(), + if (sessionId != null) 'sessionId': sessionId, + 'userId': userId, + 'eventType': event.type, + 'stream': event.stream.wireName, + 'enrichmentCandidate': tugboatEventIsEnrichmentCandidate(event), + ...extra, + if (event.relatedEventId != null) 'relatedEventId': event.relatedEventId, + if (event.beforeFrame != null) 'beforeFrame': event.beforeFrame, + if (event.afterFrame != null) 'afterFrame': event.afterFrame, + if (event.explorationRunId != null) + 'explorationRunId': event.explorationRunId, + if (event.actionId != null) 'actionId': event.actionId, + if (traitsId != null) 'traitsId': traitsId, + 'build': collectorEventBuildIdentity(collectorConfig), + }; +} + +String? _targetFingerprint(TugboatEvent event) { + final fingerprint = event.targetAnchor?.fingerprint; + if (fingerprint == null || fingerprint.isEmpty) return null; + return fingerprint; +} + /// Immutable build identity required for Context Graph matching. Map collectorEventBuildIdentity( TugboatCollectorConfig config, diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index 01b7400..5dee639 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -39,7 +39,6 @@ class _ScrollTracker { required this.startedAtMs, required this.startOffset, required this.routeEpoch, - required this.startState, required this.beforeFrame, required this.targetAnchor, required this.sectionLabel, @@ -55,7 +54,6 @@ class _ScrollTracker { final int startedAtMs; final double startOffset; final int routeEpoch; - final TugboatStateAnchor? startState; final String? beforeFrame; final TugboatTargetAnchor? targetAnchor; final String? sectionLabel; @@ -256,7 +254,6 @@ class _CaptureRequestContext { required this.route, required this.trigger, required this.requestedAtMs, - required this.stateAnchor, this.navigatorId, this.routeInstanceId, this.visualObservationGeneration, @@ -269,7 +266,6 @@ class _CaptureRequestContext { final String? route; final TugboatFrameTrigger trigger; final int requestedAtMs; - final TugboatStateAnchor? stateAnchor; final String? navigatorId; final String? routeInstanceId; final int? visualObservationGeneration; @@ -298,7 +294,6 @@ class _CaptureRequestContext { route: route, trigger: value, requestedAtMs: requestedAtMs, - stateAnchor: stateAnchor, navigatorId: navigatorId, routeInstanceId: routeInstanceId, visualObservationGeneration: visualObservationGeneration, @@ -315,7 +310,6 @@ class _CaptureRequestContext { route: route, trigger: trigger, requestedAtMs: requestedAtMs, - stateAnchor: stateAnchor, navigatorId: navigatorId, routeInstanceId: routeInstanceId, visualObservationGeneration: visualObservationGeneration, @@ -328,19 +322,16 @@ class _FrameProvenance { const _FrameProvenance({ required this.context, required this.completedAtMs, - required this.completionStateAnchor, this.available = true, }); final _CaptureRequestContext context; final int completedAtMs; - final TugboatStateAnchor? completionStateAnchor; final bool available; _FrameProvenance unavailable() => _FrameProvenance( context: context, completedAtMs: completedAtMs, - completionStateAnchor: completionStateAnchor, available: false, ); @@ -575,7 +566,6 @@ class _RouteCaptureResult { const _RouteCaptureResult( this.outcome, { this.frameId, - this.stateAnchor, this.routeEventId, this.captureFailure, this.captureRequestId, @@ -583,7 +573,6 @@ class _RouteCaptureResult { final _RouteCaptureOutcome outcome; final String? frameId; - final TugboatStateAnchor? stateAnchor; final String? routeEventId; final String? captureFailure; final String? captureRequestId; @@ -716,7 +705,6 @@ class _TapSettleObservation { const _TapSettleObservation({ required this.routeEpoch, required this.route, - required this.afterState, required this.afterFrame, required this.navigationOutcome, required this.captureOutcome, @@ -727,7 +715,6 @@ class _TapSettleObservation { final int routeEpoch; final String? route; - final TugboatStateAnchor? afterState; final String? afterFrame; final String navigationOutcome; final String captureOutcome; @@ -811,7 +798,6 @@ class TugboatReplayController extends ChangeNotifier { Rect? _lastObservedBoundaryRect; int _pointerGeneration = 0; final _NavigatorSurfaceRegistry _surfaces = _NavigatorSurfaceRegistry(); - TugboatStateAnchor? _currentStateAnchor; String? _latestFrameId; final InteractionRegistry _interactions = InteractionRegistry(); bool _reconciliationSweepScheduled = false; @@ -890,7 +876,6 @@ class TugboatReplayController extends ChangeNotifier { bool get capturePaused => _capturePaused; int get atMs => _clock.elapsedMilliseconds; String? get currentRoute => _currentRoute; - TugboatStateAnchor? get currentStateAnchor => _currentStateAnchor; String? get latestFrameId => _latestFrameId; bool get _viewportSemanticMapDebugLogsEnabled => _viewportSemantics.debugLogs; @@ -900,22 +885,11 @@ class TugboatReplayController extends ChangeNotifier { bool get _holdPersistentSemanticsHandle => _viewportSemantics.holdPersistentSemanticsHandle; - @visibleForTesting - void debugSetCurrentStateAnchor(TugboatStateAnchor? anchor) { - _currentStateAnchor = anchor; - } - @visibleForTesting void debugSetCurrentRoute(String? route) { _currentRoute = route; } - /// When true, [_refreshStateAnchor] keeps the last planted state instead of - /// rebuilding from the widget tree. Characterization tests use this when - /// driving the controller without a mounted scene. - @visibleForTesting - bool debugFreezeStateAnchor = false; - @visibleForTesting void debugSetExplorationFramesSuppressed(bool suppressed) { _explorationFramesSuppressed = suppressed; @@ -1115,7 +1089,6 @@ class TugboatReplayController extends ChangeNotifier { _frameProvenance[frameId] = _FrameProvenance( context: _captureContext(trigger), completedAtMs: atMs, - completionStateAnchor: _snapshotStateAnchor(_currentStateAnchor), ); _capturer?.rememberAcceptedPaintGeneration(); _trim(); @@ -1181,29 +1154,8 @@ class TugboatReplayController extends ChangeNotifier { } } - @visibleForTesting - TugboatInteractionResult debugComputeTapSettleResult({ - required TugboatStateAnchor? beforeState, - required TugboatStateAnchor? afterState, - required String? beforeFrame, - required String? afterFrame, - TugboatTargetAnchor? targetAnchor, - bool causallyClaimed = false, - }) { - return _computeTapSettleResult( - beforeState: beforeState, - afterState: afterState, - beforeFrame: beforeFrame, - afterFrame: afterFrame, - targetAnchor: targetAnchor, - causallyClaimed: causallyClaimed, - ); - } - - /// Debug helper for host apps to dump current semantic anchors (CLI diffing). @visibleForTesting Map debugExportSemanticSnapshot({Offset? tapPoint}) { - _refreshStateAnchor(); final resolver = _anchorResolver; TugboatTargetAnchor? targetAnchor; if (tapPoint != null && resolver != null) { @@ -1412,12 +1364,7 @@ class TugboatReplayController extends ChangeNotifier { _captureLifecycleActive = false; _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'session_end', - stateAnchor: _currentStateAnchor, - ), + TugboatEvent(id: _nextId('event'), atMs: atMs, type: 'session_end'), ); final sinkEnd = _sinkHub?.endSession() ?? Future.value(); sinkEnd.then(done.complete, onError: done.completeError); @@ -1463,7 +1410,6 @@ class TugboatReplayController extends ChangeNotifier { _lastObservedBoundaryRect = null; _pointerGeneration = 0; _surfaces.clear(); - _currentStateAnchor = null; _latestFrameId = null; _clearReleasedInteractions(); _interactions.clearAll(); @@ -1500,12 +1446,7 @@ class TugboatReplayController extends ChangeNotifier { } _sinkHub?.startSession(_session!); _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'session_start', - stateAnchor: _refreshStateAnchor(), - ), + TugboatEvent(id: _nextId('event'), atMs: atMs, type: 'session_start'), ); unawaited( _requestCapture( @@ -1526,18 +1467,20 @@ class TugboatReplayController extends ChangeNotifier { ); } - TugboatStateAnchor? _refreshStateAnchor() { - if (debugFreezeStateAnchor) return _currentStateAnchor; - final resolver = _anchorResolver; - if (resolver == null) return _currentStateAnchor; - final keyboardOpen = _isKeyboardOpen(); - final modalOpen = _isModalOpen(); - _currentStateAnchor = resolver.buildStateAnchor( + _CaptureRequestContext _captureContext(TugboatFrameTrigger trigger) { + final boundary = _observeCurrentBoundaryTransform(); + return _CaptureRequestContext( + captureSessionId: _session?.id, + routeEpoch: _routeEpoch, route: _currentRoute, - keyboardOpen: keyboardOpen, - modalOpen: modalOpen, + trigger: trigger, + requestedAtMs: atMs, + navigatorId: _currentNavigatorId, + routeInstanceId: _currentRouteInstanceId, + visualObservationGeneration: _visualObservationGeneration, + boundaryLogicalRect: boundary.rect, + boundaryTransformGeneration: boundary.generation, ); - return _currentStateAnchor; } bool _isKeyboardOpen() { @@ -1578,26 +1521,6 @@ class TugboatReplayController extends ChangeNotifier { return _CaptureFreshness.reusable; } - _CaptureRequestContext _captureContext(TugboatFrameTrigger trigger) { - final anchor = _currentStateAnchor; - final boundary = _observeCurrentBoundaryTransform(); - return _CaptureRequestContext( - captureSessionId: _session?.id, - routeEpoch: _routeEpoch, - // Characterization harnesses can plant a state anchor without a real - // Navigator callback. Its route remains valid evidence in that case. - route: _currentRoute ?? anchor?.signatureParts['route'], - 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; @@ -1626,25 +1549,6 @@ class TugboatReplayController extends ChangeNotifier { (left.height - right.height).abs() <= epsilon; } - TugboatStateAnchor? _snapshotStateAnchor(TugboatStateAnchor? anchor) { - if (anchor == null) return null; - return TugboatStateAnchor( - schemaVersion: anchor.schemaVersion, - actionableSummary: Map.unmodifiable( - anchor.actionableSummary, - ), - keyboardOpen: anchor.keyboardOpen, - modalOpen: anchor.modalOpen, - subLabel: anchor.subLabel, - signature: anchor.signature, - signatureConfidence: anchor.signatureConfidence, - signatureParts: Map.unmodifiable(anchor.signatureParts), - ); - } - - TugboatStateAnchor? _stateObservedWithFrame(String? frameId) => - frameId == null ? null : _frameProvenance[frameId]?.completionStateAnchor; - String? _compatibleFrameFor(_CaptureRequestContext context) { final latest = _latestFrameId; if (latest == null) return null; @@ -1714,7 +1618,6 @@ class TugboatReplayController extends ChangeNotifier { ); } _reuseCompatibleFrame(compatible, context, reuseReason); - _refreshStateAnchor(); _maybeEmitSceneInventory(); return _CaptureExecution( outcome: outcome, @@ -1845,8 +1748,7 @@ class TugboatReplayController extends ChangeNotifier { identical(_session, session) && context.captureSessionId == session?.id && context.routeEpoch == _routeEpoch && - context.route == - (_currentRoute ?? _currentStateAnchor?.signatureParts['route']) && + context.route == _currentRoute && context.boundaryTransformGeneration == boundary.generation; } @@ -1876,7 +1778,6 @@ class TugboatReplayController extends ChangeNotifier { _capturePaused || _skipCapture || (_shouldSuppressFrameCapture && !bypassesExplorationSuppression)) { - _refreshStateAnchor(); _maybeEmitSceneInventory(); _completeCaptureWaiter( waiter, @@ -2188,10 +2089,6 @@ class TugboatReplayController extends ChangeNotifier { if (captureOverride != null) { _beginCapture(); try { - // Match the production capture path: refresh state before capture and - // emit inventory after, so the override seam does not leave anchors - // stale relative to real screenshot execution. - _refreshStateAnchor(); final frameId = await captureOverride(trigger: trigger, force: force); if (!_captureContextStillCurrent( context, @@ -2309,9 +2206,7 @@ class TugboatReplayController extends ChangeNotifier { break; } - _refreshStateAnchor(); final activeSession = session; - final completionStateAnchor = _snapshotStateAnchor(_refreshStateAnchor()); _screenshotBudget.record( queueWaitMicros: queueWaitMicros, @@ -2386,7 +2281,6 @@ class TugboatReplayController extends ChangeNotifier { _frameProvenance[frameId] = _FrameProvenance( context: frameContext, completedAtMs: atMs, - completionStateAnchor: completionStateAnchor, ); capturer.commitAcceptedPaintGeneration(result.paintGeneration); capturer.commitAcceptedDHash(result.dHash); @@ -2433,7 +2327,6 @@ class TugboatReplayController extends ChangeNotifier { } final resolver = _anchorResolver; TugboatTargetAnchor? target; - TugboatStateAnchor? tapState = _currentStateAnchor; TugboatSceneInventory? tapInventory; if (resolver != null && config.profile != TugboatCaptureProfile.dormant) { @@ -2446,8 +2339,6 @@ class TugboatReplayController extends ChangeNotifier { target = tapContext.target; tapInventory = tapContext.inventory; if (tapInventory != null) { - _currentStateAnchor = tapInventory.stateAnchor; - tapState = tapInventory.stateAnchor; _emitSceneInventory(tapInventory, emitViewportSemanticMap: false); } } else { @@ -2486,12 +2377,10 @@ class TugboatReplayController extends ChangeNotifier { 'viewportSemanticResolution': viewportResolution.toJson(), }; - final beforeState = tapState; final eventId = _nextId('event'); final startedAtMs = atMs; final origin = InteractionOrigin( interactionId: eventId, - stateAnchor: beforeState, route: _currentRoute, routeEpoch: _routeEpoch, routeInstanceId: _currentRouteInstanceId, @@ -2514,7 +2403,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: startedAtMs, type: 'tap_outside_tree', stream: legacyStream, - stateAnchor: beforeState, beforeFrame: beforeFrame, data: { 'x': position.dx, @@ -2529,7 +2417,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: startedAtMs, type: 'tap', stream: legacyStream, - stateAnchor: beforeState, targetAnchor: target, beforeFrame: beforeFrame, data: {...tapData, 'interactionId': eventId}, @@ -2808,8 +2695,6 @@ class TugboatReplayController extends ChangeNotifier { if (!tx.isSwipeOrScroll) { tx.gesture = InteractionGesture.cancelled; } - tx.resultStatus = InteractionResultStatus.cancelled; - tx.resultObservedAtMs ??= atMs; _publishCanonicalInteraction(tx); } @@ -2866,8 +2751,6 @@ class TugboatReplayController extends ChangeNotifier { } else { _dropClaimBuffers(pending); } - pending.resultStatus = InteractionResultStatus.cancelled; - pending.resultObservedAtMs = atMs; _clearCausalRouteState(pending.id); _publishCanonicalInteraction(pending); } @@ -2889,7 +2772,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'pointer_cancel', stream: TugboatEventStream.evidence, - stateAnchor: _currentStateAnchor, data: { 'x': position.dx, 'y': position.dy, @@ -2945,11 +2827,6 @@ class TugboatReplayController extends ChangeNotifier { pending.gesture = scrolled ? InteractionGesture.scroll : InteractionGesture.swipe; - pending.resultStatus = scrolled - ? InteractionResultStatus.changed - : InteractionResultStatus.unchanged; - pending.resultObservedAtMs = atMs; - if (scrollStartEventId != null) pending.addEvidence(scrollStartEventId); _clearCausalRouteState(pending.id); if (config.emitLegacyInteractionProjection) { _addEvent( @@ -2958,8 +2835,7 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'swipe', stream: config.legacyGestureStream, - // R1: freeze to the origin state anchor rather than a live refresh. - stateAnchor: origin.stateAnchor, + // R1: freeze to the origin target/frame rather than live refresh. targetAnchor: origin.targetAnchor, beforeFrame: origin.beforeFrame, relatedEventId: tapWasEmitted ? pending.id : null, @@ -3106,7 +2982,6 @@ class TugboatReplayController extends ChangeNotifier { observation = _TapSettleObservation( routeEpoch: _routeEpoch, route: _currentRoute, - afterState: _snapshotStateAnchor(_refreshStateAnchor()), afterFrame: null, navigationOutcome: 'navigation_unavailable', captureOutcome: 'superseded_route_epoch', @@ -3123,7 +2998,6 @@ class TugboatReplayController extends ChangeNotifier { requestedRouteEpoch != interactionRouteEpoch || requestedRoute != interactionRoute || _causalRouteSupersededInteractions.contains(pending.id); - final semanticAfterState = _snapshotStateAnchor(_refreshStateAnchor()); final capture = _requestCaptureCancellable( trigger: TugboatFrameTrigger.interaction, force: true, @@ -3175,7 +3049,6 @@ class TugboatReplayController extends ChangeNotifier { observation = _TapSettleObservation( routeEpoch: successor.routeEpoch, route: successor.route, - afterState: semanticAfterState, afterFrame: null, navigationOutcome: successor.navigationOutcome, captureOutcome: replacementIsCausal @@ -3191,9 +3064,6 @@ class TugboatReplayController extends ChangeNotifier { observation = _TapSettleObservation( routeEpoch: requestedRouteEpoch, route: requestedRoute, - afterState: frameMatchesOrigin - ? _stateObservedWithFrame(afterFrame) - : semanticAfterState, afterFrame: frameMatchesOrigin && !routeChangedFromOrigin ? afterFrame : null, @@ -3215,24 +3085,12 @@ class TugboatReplayController extends ChangeNotifier { Future writeSettle() async { if (!_isActiveTapSettle(work)) return; final origin = pending.origin; - final beforeState = origin.stateAnchor; final beforeFrame = origin.beforeFrame; final tapEventId = pending.id; final tapTargetAnchor = origin.targetAnchor; // Never read mutable controller state here: later route/capture work // may have advanced while this task waited on the serialized queue. - final afterState = observation.afterState; final afterFrame = observation.afterFrame; - final result = _computeTapSettleResult( - beforeState: beforeState, - afterState: afterState, - beforeFrame: beforeFrame, - afterFrame: afterFrame, - targetAnchor: tapTargetAnchor, - causallyClaimed: pending.claimed, - navigationOutcome: observation.navigationOutcome, - degraded: observation.isDegraded, - ); final beforeContentHash = beforeFrame == null ? null : _frameContentHash(beforeFrame); @@ -3252,11 +3110,9 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'tap_settled', stream: config.legacyGestureStream, - stateAnchor: afterState, targetAnchor: tapTargetAnchor, beforeFrame: beforeFrame, afterFrame: afterFrame, - result: result, relatedEventId: tapEventId, data: { 'x': position.dx, @@ -3298,19 +3154,7 @@ class TugboatReplayController extends ChangeNotifier { ); } pending.gesture = InteractionGesture.tap; - pending.resultStatus = InteractionResultStatus.fromSettle( - result: result, - navigationOutcome: observation.navigationOutcome, - degraded: observation.isDegraded, - ); pending.afterFrame = afterFrame; - pending.captureOutcome = observation.captureOutcome; - pending.resultStateAnchor = afterState; - pending.resultRoute = observation.route; - pending.resultObservedAtMs = atMs; - if (observation.routeEventId != null) { - pending.addEvidence(observation.routeEventId!); - } _publishCanonicalInteraction(pending); if (!_disposed) notifyListeners(); @@ -3359,9 +3203,6 @@ class TugboatReplayController extends ChangeNotifier { return _TapSettleObservation( routeEpoch: settledRoute.epoch, route: settledRoute.change.destinationRoute, - afterState: validFrame - ? _stateObservedWithFrame(frameId) - : routeResult.stateAnchor, afterFrame: validFrame ? frameId : null, navigationOutcome: validFrame ? navigationOutcome @@ -3384,48 +3225,6 @@ class TugboatReplayController extends ChangeNotifier { _activeTapSettles.clear(); } - TugboatInteractionResult _computeTapSettleResult({ - required TugboatStateAnchor? beforeState, - required TugboatStateAnchor? afterState, - required String? beforeFrame, - required String? afterFrame, - TugboatTargetAnchor? targetAnchor, - bool causallyClaimed = false, - String navigationOutcome = 'same_route', - bool degraded = false, - }) { - if (degraded) return TugboatInteractionResult.unknown; - if (navigationOutcome == 'navigated') { - return TugboatInteractionResult.navigated; - } - // Animated/loading surfaces can repaint independently of the pointer. If - // the resolved origin exposes no tap action, a pixel-only difference is - // ambient evidence and must not turn an empty-area tap into a successful - // interaction. Navigation and structural state changes still win above. - if (!causallyClaimed && - (targetAnchor == null || !targetAnchor.actions.contains('tap'))) { - return TugboatInteractionResult.noVisibleChange; - } - if (_framesVisuallyDifferent(beforeFrame, afterFrame)) { - return TugboatInteractionResult.changed; - } - final beforeHash = beforeFrame == null - ? null - : _frameContentHash(beforeFrame); - final afterHash = afterFrame == null ? null : _frameContentHash(afterFrame); - if (beforeHash != null && afterHash != null) { - return TugboatInteractionResult.noVisibleChange; - } - return TugboatInteractionResult.unknown; - } - - bool _framesVisuallyDifferent(String? beforeFrame, String? afterFrame) { - if (beforeFrame == null || afterFrame == null) return false; - final beforeHash = _frameContentHash(beforeFrame); - final afterHash = _frameContentHash(afterFrame); - return beforeHash != null && afterHash != null && beforeHash != afterHash; - } - String? _frameContentHash(String frameId) { return _session?.frameById(frameId)?.contentHash; } @@ -3439,7 +3238,6 @@ class TugboatReplayController extends ChangeNotifier { if (!tx.scrollStartEventIds.contains(scrollStartEventId)) { tx.scrollStartEventIds.add(scrollStartEventId); } - tx.addEvidence(scrollStartEventId); return true; } return false; @@ -3451,26 +3249,13 @@ class TugboatReplayController extends ChangeNotifier { tx.semanticPublished = true; _addEvent( TugboatEvent( - id: _nextId('event'), - atMs: atMs, + id: tx.id, + atMs: tx.origin.atMs, type: 'interaction', - stateAnchor: tx.origin.stateAnchor, - targetAnchor: tx.origin.targetAnchor, + stream: TugboatEventStream.semantic, beforeFrame: tx.origin.beforeFrame, afterFrame: tx.afterFrame, - result: - tx.resultStatus?.asEventResult ?? TugboatInteractionResult.unknown, - data: { - 'interactionId': tx.id, - 'interactionSchema': tugboatInteractionSchemaVersion, - 'gesture': tx.gesture.name, - 'origin': tx.origin.toJson(), - 'result': tx.resultToJson(), - 'attribution': tx.attributionToJson( - windowMs: config.interactionClaimWindow.inMilliseconds, - ), - 'evidenceEventIds': List.from(tx.evidenceEventIds), - }, + data: buildInteractionV2Payload(tx), explorationRunId: tx.origin.explorationRunId, actionId: tx.origin.actionId, ), @@ -3495,7 +3280,6 @@ class TugboatReplayController extends ChangeNotifier { _activeCompletedGestureCaptures, )) { tx.afterFrame = null; - tx.captureOutcome = _CaptureOutcome.cancelled.wireName; tx.rejectionReason = reason; _finalizeAbandonedTransaction(tx, reason: reason); } @@ -3521,7 +3305,6 @@ class TugboatReplayController extends ChangeNotifier { return; } interaction.afterFrame = completion.afterFrame; - interaction.captureOutcome = completion.captureOutcome; _publishCanonicalInteraction(interaction); _scrollInteractions.remove(scrollStartEventId); _pendingScrollCompletions.remove(scrollStartEventId); @@ -3547,7 +3330,6 @@ class TugboatReplayController extends ChangeNotifier { tx.afterFrame = resolution.outcome == _CaptureOutcome.freshAccepted ? resolution.frameId : null; - tx.captureOutcome = resolution.outcome.wireName; await _enqueue('interaction_after_capture', () async { if (!_isCaptureLifecycleCurrent(session, lifecycleEpoch)) return; _publishCanonicalInteraction(tx); @@ -3660,7 +3442,6 @@ class TugboatReplayController extends ChangeNotifier { if (scrollableElement == null) return; if (_scrollTrackers.containsKey(scrollableElement)) return; - _refreshStateAnchor(); final targetAnchor = _resolveScrollableAnchor(scrollableElement); final sectionLabel = _sectionLabelFor(scrollableElement); final attachmentContext = _captureContext(TugboatFrameTrigger.scroll); @@ -3675,7 +3456,6 @@ class TugboatReplayController extends ChangeNotifier { startedAtMs: atMs, startOffset: metrics.pixels, routeEpoch: _routeEpoch, - startState: _snapshotStateAnchor(_currentStateAnchor), beforeFrame: beforeFrame, targetAnchor: targetAnchor, sectionLabel: sectionLabel, @@ -3710,7 +3490,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'scroll_start', stream: TugboatEventStream.evidence, - stateAnchor: _currentStateAnchor, targetAnchor: targetAnchor, beforeFrame: beforeFrame, data: { @@ -3828,7 +3607,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'scroll_end', stream: TugboatEventStream.evidence, - stateAnchor: _refreshStateAnchor(), targetAnchor: tracker.targetAnchor, beforeFrame: tracker.beforeFrame, relatedEventId: tracker.startEventId, @@ -3881,7 +3659,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'scroll_end', stream: TugboatEventStream.evidence, - stateAnchor: tracker.startState, targetAnchor: tracker.targetAnchor, beforeFrame: tracker.beforeFrame, relatedEventId: tracker.startEventId, @@ -3950,8 +3727,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'scroll_end', stream: TugboatEventStream.evidence, - stateAnchor: - _stateObservedWithFrame(afterFrame) ?? _refreshStateAnchor(), targetAnchor: tracker.targetAnchor, beforeFrame: tracker.beforeFrame, afterFrame: afterFrame, @@ -4170,7 +3945,6 @@ class TugboatReplayController extends ChangeNotifier { } } _skipCapture = false; - final observedState = _snapshotStateAnchor(_refreshStateAnchor()); final routeEventId = _nextId('event'); // This must not enqueue behind the blocked task that caused the timeout. // Dart's single isolate means the session mutation is still atomic with @@ -4179,14 +3953,12 @@ class TugboatReplayController extends ChangeNotifier { _emitRouteChange( routeEventId: routeEventId, change: change, - stateAnchor: observedState, result: TugboatInteractionResult.unknown, extraData: const {'captureOutcome': 'timed_out'}, ); work.complete( _RouteCaptureResult( _RouteCaptureOutcome.timedOut, - stateAnchor: observedState, routeEventId: routeEventId, ), ); @@ -4197,7 +3969,6 @@ class TugboatReplayController extends ChangeNotifier { void _emitRouteChange({ required String routeEventId, required _VisibleRouteChange change, - required TugboatStateAnchor? stateAnchor, required TugboatInteractionResult result, String? afterFrame, Map extraData = const {}, @@ -4208,7 +3979,6 @@ class TugboatReplayController extends ChangeNotifier { atMs: atMs, type: 'route_change', stream: TugboatEventStream.evidence, - stateAnchor: stateAnchor, afterFrame: afterFrame, result: result, data: { @@ -4238,7 +4008,6 @@ class TugboatReplayController extends ChangeNotifier { Future _finalizeRouteCapture(_RouteCaptureWork work) async { String? afterFrame; - TugboatStateAnchor? observedState; String? routeEventId; String? captureFailure; String? captureRequestId; @@ -4251,7 +4020,6 @@ class TugboatReplayController extends ChangeNotifier { _currentNavigatorId = change.navigatorId; _currentRouteInstanceId = change.routeInstanceId; } - _refreshStateAnchor(); final capture = _requestCaptureCancellable( trigger: TugboatFrameTrigger.route, force: true, @@ -4268,13 +4036,11 @@ class TugboatReplayController extends ChangeNotifier { if (!_isActiveRouteCapture(work)) return; outcome = _RouteCaptureOutcome.failed; captureFailure = _lastCaptureFailure?.name; - observedState = _snapshotStateAnchor(_currentStateAnchor); routeEventId = _nextId('event'); _ensureCauseTapPublished(change.causeEventId); _emitRouteChange( routeEventId: routeEventId, change: change, - stateAnchor: observedState, result: TugboatInteractionResult.navigated, extraData: { 'captureOutcome': 'failed', @@ -4295,14 +4061,11 @@ class TugboatReplayController extends ChangeNotifier { captureFailure = _lastCaptureFailure?.name; } if (!_isActiveRouteCapture(work)) return; - observedState = - _stateObservedWithFrame(afterFrame) ?? _currentStateAnchor; routeEventId = _nextId('event'); _ensureCauseTapPublished(change.causeEventId); _emitRouteChange( routeEventId: routeEventId, change: change, - stateAnchor: observedState, afterFrame: afterFrame, result: TugboatInteractionResult.navigated, extraData: { @@ -4331,7 +4094,6 @@ class TugboatReplayController extends ChangeNotifier { _RouteCaptureResult( outcome, frameId: afterFrame, - stateAnchor: observedState, routeEventId: routeEventId, captureFailure: captureFailure, captureRequestId: captureRequestId, @@ -4591,7 +4353,6 @@ class TugboatReplayController extends ChangeNotifier { ); if (inventory == null) return; - _currentStateAnchor = inventory.stateAnchor; _emitSceneInventory(inventory, scrollContext: scrollContext); } diff --git a/packages/tugboat/lib/src/interaction_transaction.dart b/packages/tugboat/lib/src/interaction_transaction.dart index 0bcfc43..e5fd748 100644 --- a/packages/tugboat/lib/src/interaction_transaction.dart +++ b/packages/tugboat/lib/src/interaction_transaction.dart @@ -18,7 +18,6 @@ const int tugboatMaxReleasedInteractionTransactions = 8; class InteractionOrigin { const InteractionOrigin({ required this.interactionId, - required this.stateAnchor, required this.route, required this.routeEpoch, required this.routeInstanceId, @@ -35,7 +34,6 @@ class InteractionOrigin { }); final String interactionId; - final TugboatStateAnchor? stateAnchor; final String? route; final int routeEpoch; final String? routeInstanceId; @@ -49,61 +47,10 @@ class InteractionOrigin { final String? captureSessionId; final String? explorationRunId; final String? actionId; - - Map toJson() => { - 'interactionId': interactionId, - if (route != null) 'route': route, - if (routeInstanceId != null) 'routeInstanceId': routeInstanceId, - if (navigatorId != null) 'navigatorId': navigatorId, - if (targetAnchor != null) 'targetAnchor': targetAnchor!.toJson(), - 'captureCoordinate': captureCoordinate.toJson(), - if (beforeFrame != null) 'beforeFrame': beforeFrame, - 'atMs': atMs, - 'startPosition': {'x': startPosition.dx, 'y': startPosition.dy}, - 'pointerGeneration': pointerGeneration, - if (captureSessionId != null) 'captureSessionId': captureSessionId, - if (explorationRunId != null) 'explorationRunId': explorationRunId, - if (actionId != null) 'actionId': actionId, - }; } enum InteractionGesture { tap, swipe, scroll, cancelled } -enum InteractionResultStatus { - navigated, - changed, - unchanged, - unknown, - cancelled; - - TugboatInteractionResult get asEventResult => switch (this) { - InteractionResultStatus.navigated => TugboatInteractionResult.navigated, - InteractionResultStatus.changed => TugboatInteractionResult.changed, - InteractionResultStatus.unchanged => - TugboatInteractionResult.noVisibleChange, - InteractionResultStatus.cancelled || - InteractionResultStatus.unknown => TugboatInteractionResult.unknown, - }; - - static InteractionResultStatus fromSettle({ - required TugboatInteractionResult result, - String navigationOutcome = 'same_route', - bool degraded = false, - }) { - if (degraded) return InteractionResultStatus.unknown; - if (navigationOutcome == 'navigated') { - return InteractionResultStatus.navigated; - } - return switch (result) { - TugboatInteractionResult.navigated => InteractionResultStatus.navigated, - TugboatInteractionResult.changed => InteractionResultStatus.changed, - TugboatInteractionResult.noVisibleChange => - InteractionResultStatus.unchanged, - TugboatInteractionResult.unknown => InteractionResultStatus.unknown, - }; - } -} - enum InteractionAttribution { direct, delayedLikely, @@ -134,6 +81,34 @@ enum InteractionRejectionReason { sessionEnd, } +/// Facts-only interaction schema v2 fields stored in [TugboatEvent.data]. +Map buildInteractionV2Payload(InteractionTransaction tx) { + final payload = { + 'interactionSchema': tugboatInteractionSchemaVersion, + 'gesture': tx.gesture.name, + }; + final route = tx.origin.route; + if (route != null && route.isNotEmpty) { + payload['route'] = route; + } + final fingerprint = tx.origin.targetAnchor?.fingerprint; + if (fingerprint != null && fingerprint.isNotEmpty) { + payload['targetFingerprint'] = fingerprint; + } + final coord = tx.origin.captureCoordinate; + if (coord.isAvailable && + coord.normalizedX >= 0 && + coord.normalizedX <= 1 && + coord.normalizedY >= 0 && + coord.normalizedY <= 1) { + payload['position'] = { + 'xNorm': coord.normalizedX, + 'yNorm': coord.normalizedY, + }; + } + return payload; +} + /// Bounded in-memory transaction for one pointer gesture. class InteractionTransaction { InteractionTransaction({required this.origin, required this.pointerId}); @@ -154,18 +129,11 @@ class InteractionTransaction { TugboatEvent? bufferedTap; TugboatEvent? bufferedOutside; - final List evidenceEventIds = []; final List scrollStartEventIds = []; - InteractionResultStatus? resultStatus; InteractionAttribution attribution = InteractionAttribution.none; InteractionRejectionReason? rejectionReason; - String? resultRoute; - String? resultRouteInstanceId; String? afterFrame; - String? captureOutcome; - int? resultObservedAtMs; - TugboatStateAnchor? resultStateAnchor; Completer? _successorSignal; @@ -197,28 +165,9 @@ class InteractionTransaction { }); } - void addEvidence(String eventId) { - if (!evidenceEventIds.contains(eventId)) evidenceEventIds.add(eventId); - } - void markSwipe() { gesture = InteractionGesture.swipe; } - - Map resultToJson() => { - 'status': (resultStatus ?? InteractionResultStatus.unknown).name, - if (resultRoute != null) 'route': resultRoute, - if (resultRouteInstanceId != null) 'routeInstanceId': resultRouteInstanceId, - if (afterFrame != null) 'afterFrame': afterFrame, - if (captureOutcome != null) 'captureOutcome': captureOutcome, - if (resultObservedAtMs != null) 'observedAtMs': resultObservedAtMs, - }; - - Map attributionToJson({int? windowMs}) => { - 'kind': attribution.wireName, - if (windowMs != null) 'windowMs': windowMs, - if (rejectionReason != null) 'rejectionReason': rejectionReason!.name, - }; } /// Single index for pending/released interaction transactions. diff --git a/packages/tugboat/lib/src/models.dart b/packages/tugboat/lib/src/models.dart index 60d9a70..3d5a34d 100644 --- a/packages/tugboat/lib/src/models.dart +++ b/packages/tugboat/lib/src/models.dart @@ -78,7 +78,9 @@ const String tugboatEventStreamEvidence = 'evidence'; const String tugboatEventStreamDiagnostic = 'diagnostic'; const String tugboatEventStreamLegacyProjection = 'legacy_projection'; -const int tugboatInteractionSchemaVersion = 1; +const int tugboatInteractionSchemaVersion = 2; +const int tugboatRouteChangeSchemaVersion = 2; +const int tugboatScrollSchemaVersion = 2; /// Whether [event] is a default enrichment / insight candidate. bool tugboatEventIsEnrichmentCandidate(TugboatEvent event) { @@ -209,7 +211,6 @@ class TugboatEvent { this.sessionId, this.captureSessionId, this.activationRequestId, - this.stateAnchor, this.targetAnchor, this.beforeFrame, this.afterFrame, @@ -229,7 +230,6 @@ class TugboatEvent { final String? sessionId; final String? captureSessionId; final String? activationRequestId; - final TugboatStateAnchor? stateAnchor; final TugboatTargetAnchor? targetAnchor; final String? beforeFrame; final String? afterFrame; @@ -271,7 +271,6 @@ class TugboatEvent { String? sessionId, String? captureSessionId, String? activationRequestId, - TugboatStateAnchor? stateAnchor, TugboatTargetAnchor? targetAnchor, String? beforeFrame, String? afterFrame, @@ -288,7 +287,6 @@ class TugboatEvent { sessionId: sessionId ?? this.sessionId, captureSessionId: captureSessionId ?? this.captureSessionId, activationRequestId: activationRequestId ?? this.activationRequestId, - stateAnchor: stateAnchor ?? this.stateAnchor, targetAnchor: targetAnchor ?? this.targetAnchor, beforeFrame: beforeFrame ?? this.beforeFrame, afterFrame: afterFrame ?? this.afterFrame, diff --git a/packages/tugboat/lib/src/viewport_semantic_session.dart b/packages/tugboat/lib/src/viewport_semantic_session.dart index c950674..2a5486a 100644 --- a/packages/tugboat/lib/src/viewport_semantic_session.dart +++ b/packages/tugboat/lib/src/viewport_semantic_session.dart @@ -10,14 +10,12 @@ import 'replay_config.dart'; class _ScrollSemanticAccumulator { _ScrollSemanticAccumulator({ required this.gestureSequence, - required this.stateSignature, required this.routeKey, required this.scrollableFingerprint, required this.axis, }); final int gestureSequence; - final String stateSignature; final String routeKey; final String? scrollableFingerprint; final String? axis; @@ -123,7 +121,6 @@ class ViewportSemanticSession { }) { if (scrollContext?.trigger == 'scroll_start') { _beginScrollSemanticGesture( - stateSignature: inventory.stateSignature, routeKey: inventory.routeKey, scroll: scrollContext!, ); @@ -236,7 +233,6 @@ class ViewportSemanticSession { if (accumulator == null) { accumulator = _ScrollSemanticAccumulator( gestureSequence: ++_scrollGestureSequence, - stateSignature: map.stateSignature, routeKey: map.routeKey, scrollableFingerprint: scroll.scrollableFingerprint, axis: scroll.axis, @@ -263,7 +259,6 @@ class ViewportSemanticSession { } void _beginScrollSemanticGesture({ - required String stateSignature, required String routeKey, required TugboatViewportSemanticScrollContext scroll, }) { @@ -274,7 +269,6 @@ class ViewportSemanticSession { ].join('|'); _scrollSemanticAccumulators[accumulatorKey] = _ScrollSemanticAccumulator( gestureSequence: ++_scrollGestureSequence, - stateSignature: stateSignature, routeKey: routeKey, scrollableFingerprint: scroll.scrollableFingerprint, axis: scroll.axis, @@ -337,7 +331,6 @@ class ViewportSemanticSession { ].join('|'), ); return TugboatScrollSemanticSnapshot( - stateSignature: accumulator.stateSignature, routeKey: accumulator.routeKey, scrollableFingerprint: accumulator.scrollableFingerprint, axis: accumulator.axis, diff --git a/packages/tugboat/lib/tugboat.dart b/packages/tugboat/lib/tugboat.dart index af46131..13723df 100644 --- a/packages/tugboat/lib/tugboat.dart +++ b/packages/tugboat/lib/tugboat.dart @@ -3,7 +3,6 @@ library; export 'src/anchors.dart' show TugboatNormalizedBounds, - TugboatStateAnchor, TugboatTargetAnchor, tugboatIconLabel, tugboatIconHash, diff --git a/packages/tugboat/test/collector_mapper_test.dart b/packages/tugboat/test/collector_mapper_test.dart index 5506c0d..3da0c5e 100644 --- a/packages/tugboat/test/collector_mapper_test.dart +++ b/packages/tugboat/test/collector_mapper_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/src/anchors.dart'; import 'package:tugboat/src/collector_config.dart'; @@ -33,6 +35,227 @@ void main() { userId: 'user_1', ); + test('maps interaction events to facts-only schema v2', () { + final sessionStartedAt = DateTime.utc(2026, 6, 19); + final event = TugboatEvent( + id: 'evt_interaction_1', + atMs: 55957, + type: 'interaction', + stream: TugboatEventStream.semantic, + beforeFrame: 'frame-34', + afterFrame: 'frame-35', + data: { + 'interactionSchema': tugboatInteractionSchemaVersion, + 'route': '/home', + 'targetFingerprint': 'bef605389f2f5207', + 'gesture': 'tap', + 'position': {'xNorm': 0.299, 'yNorm': 0.637}, + }, + ); + + final mapped = mapTugboatEventToCollectorEvent( + event: event, + sessionId: 'session-abc', + sessionStartedAt: sessionStartedAt, + userId: 'user_1', + collectorConfig: collectorConfig, + ); + + expect(mapped['id'], 'evt_interaction_1'); + expect(mapped['eventType'], 'interaction'); + expect(mapped['interactionSchema'], 2); + expect(mapped['route'], '/home'); + expect(mapped['targetFingerprint'], 'bef605389f2f5207'); + expect(mapped['gesture'], 'tap'); + expect(mapped['position'], {'xNorm': 0.299, 'yNorm': 0.637}); + expect(mapped['beforeFrame'], 'frame-34'); + expect(mapped['afterFrame'], 'frame-35'); + expect(mapped.containsKey('result'), isFalse); + expect(mapped.containsKey('payload'), isFalse); + expect(mapped.containsKey('targetAnchor'), isFalse); + expect(mapped.containsKey('stateAnchor'), isFalse); + final encoded = utf8.encode(jsonEncode(mapped)); + expect(encoded.length, lessThan(700)); + }); + + test('maps route_change events to facts-only schema v2', () { + final sessionStartedAt = DateTime.utc(2026, 6, 19); + final event = TugboatEvent( + id: 'evt_route_1', + atMs: 12000, + type: 'route_change', + stream: TugboatEventStream.evidence, + afterFrame: 'frame-8', + data: const { + 'fromRoute': '/home', + 'route': '/settings', + 'navigation': 'route_push', + 'navigatorId': 'nav-1', + 'captureOutcome': 'captured', + 'navigationOrigin': 'user_gesture', + }, + ); + + final mapped = mapTugboatEventToCollectorEvent( + event: event, + sessionId: 'session-abc', + sessionStartedAt: sessionStartedAt, + userId: 'user_1', + collectorConfig: collectorConfig, + ); + + expect(mapped['eventType'], 'route_change'); + expect(mapped['routeChangeSchema'], tugboatRouteChangeSchemaVersion); + expect(mapped['fromRoute'], '/home'); + expect(mapped['route'], '/settings'); + expect(mapped['navigation'], 'route_push'); + expect(mapped['afterFrame'], 'frame-8'); + expect(mapped.containsKey('result'), isFalse); + expect(mapped.containsKey('payload'), isFalse); + expect(mapped.containsKey('targetAnchor'), isFalse); + expect(mapped.containsKey('navigatorId'), isFalse); + expect(mapped.containsKey('captureOutcome'), isFalse); + expect(mapped.containsKey('navigationOrigin'), isFalse); + final encoded = utf8.encode(jsonEncode(mapped)); + expect(encoded.length, lessThan(700)); + }); + + test('maps scroll_start events to facts-only schema v2', () { + final sessionStartedAt = DateTime.utc(2026, 6, 19); + final event = TugboatEvent( + id: 'evt_scroll_start_1', + atMs: 15000, + type: 'scroll_start', + stream: TugboatEventStream.evidence, + beforeFrame: 'frame-10', + targetAnchor: const TugboatTargetAnchor( + widgetType: 'ListView', + role: 'scrollable', + fingerprint: 'abc123def4567890', + fingerprintConfidence: 'high', + canonicalPath: 'HomeScreen#0/ListView#0', + ), + data: const { + 'axis': 'vertical', + 'startOffset': 0.0, + 'offset': 0.0, + 'offsetNorm': 0.0, + 'depth': 1, + 'frameAttachment': {'before': 'unavailable'}, + }, + ); + + final mapped = mapTugboatEventToCollectorEvent( + event: event, + sessionStartedAt: sessionStartedAt, + collectorConfig: collectorConfig, + ); + + expect(mapped['eventType'], 'scroll_start'); + expect(mapped['scrollSchema'], tugboatScrollSchemaVersion); + expect(mapped['axis'], 'vertical'); + expect(mapped['startOffset'], 0.0); + expect(mapped['targetFingerprint'], 'abc123def4567890'); + expect(mapped['targetFingerprint'], isA()); + expect(mapped['beforeFrame'], 'frame-10'); + expect(mapped.containsKey('result'), isFalse); + expect(mapped.containsKey('payload'), isFalse); + expect(mapped.containsKey('targetAnchor'), isFalse); + expect(mapped.containsKey('offset'), isFalse); + expect(mapped.containsKey('frameAttachment'), isFalse); + final encoded = utf8.encode(jsonEncode(mapped)); + expect(encoded.length, lessThan(700)); + }); + + test('maps scroll_end events to facts-only schema v2', () { + final sessionStartedAt = DateTime.utc(2026, 6, 19); + final event = TugboatEvent( + id: 'evt_scroll_end_1', + atMs: 15500, + type: 'scroll_end', + stream: TugboatEventStream.evidence, + beforeFrame: 'frame-10', + afterFrame: 'frame-11', + relatedEventId: 'evt_scroll_start_1', + targetAnchor: const TugboatTargetAnchor( + widgetType: 'ListView', + role: 'scrollable', + fingerprint: 'abc123def4567890', + fingerprintConfidence: 'high', + canonicalPath: 'HomeScreen#0/ListView#0', + ), + data: const { + 'startOffset': 0.0, + 'endOffset': 240.0, + 'durationMs': 500, + 'overscrollCount': 2, + 'offset': 240.0, + 'captureRequestId': 'cap-1', + 'captureOutcome': 'captured', + }, + ); + + final mapped = mapTugboatEventToCollectorEvent( + event: event, + sessionStartedAt: sessionStartedAt, + collectorConfig: collectorConfig, + ); + + expect(mapped['eventType'], 'scroll_end'); + expect(mapped['scrollSchema'], tugboatScrollSchemaVersion); + expect(mapped['relatedEventId'], 'evt_scroll_start_1'); + expect(mapped['startOffset'], 0.0); + expect(mapped['endOffset'], 240.0); + expect(mapped['durationMs'], 500); + expect(mapped['overscrollCount'], 2); + expect(mapped['targetFingerprint'], 'abc123def4567890'); + expect(mapped['targetFingerprint'], isA()); + expect(mapped['beforeFrame'], 'frame-10'); + expect(mapped['afterFrame'], 'frame-11'); + expect(mapped.containsKey('result'), isFalse); + expect(mapped.containsKey('payload'), isFalse); + expect(mapped.containsKey('targetAnchor'), isFalse); + expect(mapped.containsKey('captureRequestId'), isFalse); + expect(mapped.containsKey('captureOutcome'), isFalse); + final encoded = utf8.encode(jsonEncode(mapped)); + expect(encoded.length, lessThan(800)); + }); + + test('scroll_end omits zero overscrollCount', () { + final mapped = mapTugboatEventToCollectorEvent( + event: TugboatEvent( + id: 'evt_scroll_end_2', + atMs: 1, + type: 'scroll_end', + stream: TugboatEventStream.evidence, + relatedEventId: 'evt_scroll_start_1', + data: const {'overscrollCount': 0}, + ), + sessionStartedAt: DateTime.utc(2026, 6, 19), + collectorConfig: collectorConfig, + ); + + expect(mapped.containsKey('overscrollCount'), isFalse); + }); + + test('generic branch omits empty targetAnchor and payload stream', () { + final mapped = mapTugboatEventToCollectorEvent( + event: TugboatEvent( + id: 'event-app-bg', + atMs: 100, + type: 'app_backgrounded', + stream: TugboatEventStream.evidence, + data: const {'reason': 'lifecycle'}, + ), + sessionStartedAt: DateTime.utc(2026, 6, 19), + collectorConfig: collectorConfig, + ); + + expect(mapped.containsKey('targetAnchor'), isFalse); + expect((mapped['payload'] as Map).containsKey('stream'), isFalse); + expect((mapped['payload'] as Map)['reason'], 'lifecycle'); + }); + test('maps tugboat events into collector event schema', () { final sessionStartedAt = DateTime.utc(2026, 6, 19); final event = TugboatEvent( @@ -40,11 +263,6 @@ void main() { atMs: 28906, type: 'tap', beforeFrame: 'frame-3', - stateAnchor: const TugboatStateAnchor( - signature: '23f17a629520d522', - signatureConfidence: 'medium', - signatureParts: {'routeKey': '/intro'}, - ), targetAnchor: const TugboatTargetAnchor( widgetType: 'GestureDetector', role: 'button', @@ -83,6 +301,7 @@ void main() { expect((mapped['payload'] as Map)['x'], 100); expect((mapped['payload'] as Map)['actionId'], 'A-1'); expect((mapped['payload'] as Map)['explorationRunId'], 'run-1'); + expect((mapped['payload'] as Map).containsKey('stream'), isFalse); expect(mapped['build'], { 'appId': 'com.example.app', 'platform': 'ios', diff --git a/packages/tugboat/test/external_event_and_network_test.dart b/packages/tugboat/test/external_event_and_network_test.dart index f3f605b..527bd5a 100644 --- a/packages/tugboat/test/external_event_and_network_test.dart +++ b/packages/tugboat/test/external_event_and_network_test.dart @@ -82,7 +82,7 @@ void main() { expect(event.isEnrichmentCandidate, isFalse); expect(event.actionId, isNull); expect(event.relatedEventId, isNull); - expect(event.stateAnchor, isNull); + expect(event.toJson().containsKey('stateAnchor'), isFalse); expect(event.targetAnchor, isNull); expect(event.data['source'], 'analytics'); expect(event.data['name'], 'USER_LOGIN'); @@ -240,7 +240,7 @@ void main() { for (final event in [external, network]) { expect(event.actionId, isNull); expect(event.relatedEventId, isNull); - expect(event.stateAnchor, isNull); + expect(event.toJson().containsKey('stateAnchor'), isFalse); expect(event.targetAnchor, isNull); expect(event.stream, TugboatEventStream.evidence); } diff --git a/packages/tugboat/test/fingerprint_test.dart b/packages/tugboat/test/fingerprint_test.dart index 8d39066..b120b9a 100644 --- a/packages/tugboat/test/fingerprint_test.dart +++ b/packages/tugboat/test/fingerprint_test.dart @@ -4,123 +4,6 @@ import 'package:tugboat/tugboat.dart'; import 'package:tugboat/src/anchors.dart'; void main() { - testWidgets('fingerprints are deterministic for the same widget tree', ( - tester, - ) async { - final rootKey = GlobalKey(); - - Future capture() async { - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: Scaffold( - body: Column( - children: [ - FilledButton(onPressed: () {}, child: const Text('Go')), - ], - ), - ), - ), - ), - ); - await tester.pump(); - return AnchorResolver( - rootKey: rootKey, - ).buildStateAnchor(route: '/home', keyboardOpen: false, modalOpen: false); - } - - final first = await capture(); - final second = await capture(); - expect(first.signature, second.signature); - expect(first.signature, isNotEmpty); - expect(first.schemaVersion, tugboatFingerprintSchemaVersion); - }); - - testWidgets('list length does not change state signature', (tester) async { - final rootKey = GlobalKey(); - - Future signatureFor(int count) async { - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: Scaffold( - body: ListView( - children: [ - for (var i = 0; i < count; i++) - ListTile(title: Text('Row $i'), onTap: () {}), - ], - ), - ), - ), - ), - ); - await tester.pump(); - return AnchorResolver(rootKey: rootKey) - .buildStateAnchor( - route: '/feed', - keyboardOpen: false, - modalOpen: false, - ) - .signature; - } - - expect(await signatureFor(3), await signatureFor(7)); - }); - - testWidgets('scroll changes visible items without forking state signature', ( - tester, - ) async { - final rootKey = GlobalKey(); - final scrollController = ScrollController(); - - Future signatureAt(double offset, {bool showChrome = false}) async { - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: Scaffold( - body: Column( - children: [ - if (showChrome) - FilledButton(onPressed: () {}, child: const Text('Filter')), - Expanded( - child: ListView.builder( - controller: scrollController, - itemCount: 30, - itemBuilder: (context, index) => ListTile( - key: ValueKey('row-$index'), - title: Text('Plan ${index % 3}'), - onTap: () {}, - ), - ), - ), - ], - ), - ), - ), - ), - ); - await tester.pump(); - scrollController.jumpTo(offset); - await tester.pump(); - return AnchorResolver(rootKey: rootKey) - .buildStateAnchor( - route: '/feed', - keyboardOpen: false, - modalOpen: false, - ) - .signature; - } - - final topSignature = await signatureAt(0); - final scrolledSignature = await signatureAt(800); - expect(topSignature, scrolledSignature); - // v6 coarse signatures ignore optional chrome widgets on the same route. - expect(await signatureAt(0, showChrome: true), topSignature); - }); - testWidgets( 'list row taps without discriminator share low-confidence fingerprint', (tester) async { @@ -188,49 +71,12 @@ void main() { expect(basic?.canonicalPath, isNot(contains('Basic'))); }); - testWidgets('dynamic visible text does not change signatures', ( - tester, - ) async { - final rootKey = GlobalKey(); - - Future buildAnchor(String label) async { - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: Scaffold( - body: Column( - children: [ - Text(label), - FilledButton(onPressed: () {}, child: const Text('Continue')), - ], - ), - ), - ), - ), - ); - await tester.pump(); - return AnchorResolver(rootKey: rootKey).buildStateAnchor( - route: '/intro', - keyboardOpen: false, - modalOpen: false, - ); - } - - final first = await buildAnchor('Brooke Martins'); - final second = await buildAnchor('Alex Chen'); - expect(first.signature, second.signature); - expect(first.toJson().containsKey('labels'), isFalse); - }); - testWidgets('TugboatTag adds an alias without changing structural identity', ( tester, ) async { final rootKey = GlobalKey(); - Future<(TugboatTargetAnchor, TugboatStateAnchor)> capture({ - required bool tagged, - }) async { + Future capture({required bool tagged}) async { final button = FilledButton( onPressed: () {}, child: const Text('Pay now'), @@ -246,26 +92,17 @@ void main() { ), ); await tester.pump(); - final resolver = AnchorResolver(rootKey: rootKey); final center = tester.getCenter(find.text('Pay now')); - return ( - resolver.targetAt(center)!, - resolver.buildStateAnchor( - route: null, - keyboardOpen: false, - modalOpen: false, - ), - ); + return AnchorResolver(rootKey: rootKey).targetAt(center)!; } final untagged = await capture(tagged: false); final tagged = await capture(tagged: true); - expect(tagged.$1.fingerprint, untagged.$1.fingerprint); - expect(tagged.$1.canonicalPath, untagged.$1.canonicalPath); - expect(tagged.$2.signature, untagged.$2.signature); - expect(tagged.$1.tagFingerprint, isNotNull); - expect(tagged.$1.fingerprintParts, containsPair('tag', 'checkout-cta')); + expect(tagged.fingerprint, untagged.fingerprint); + expect(tagged.canonicalPath, untagged.canonicalPath); + expect(tagged.tagFingerprint, isNotNull); + expect(tagged.fingerprintParts, containsPair('tag', 'checkout-cta')); }); testWidgets('target fingerprint excludes schema version metadata', ( @@ -297,34 +134,6 @@ void main() { expect(anchor.schemaVersion, tugboatFingerprintSchemaVersion); }); - testWidgets('state signature excludes schema version metadata', ( - tester, - ) async { - final rootKey = GlobalKey(); - - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: Scaffold(body: const Text('Read only')), - ), - ), - ); - await tester.pump(); - - final state = AnchorResolver( - rootKey: rootKey, - ).buildStateAnchor(route: '/home', keyboardOpen: false, modalOpen: false); - - expect( - state.signature, - tugboatLabelHash( - 'routeKey=/home|schemaVersion=$tugboatFingerprintSchemaVersion', - ), - ); - expect(state.schemaVersion, tugboatFingerprintSchemaVersion); - }); - testWidgets('different route keys produce different fingerprints', ( tester, ) async { @@ -375,92 +184,18 @@ void main() { await tester.pump(); final resolver = AnchorResolver(rootKey: rootKey); - final anchor = resolver.buildStateAnchor( + final inventory = resolver.buildSceneInventory( route: '/big-list', keyboardOpen: false, modalOpen: false, ); stopwatch.stop(); - expect(anchor.signature, isNotEmpty); + expect(inventory, isNotNull); + expect(inventory!.inventoryHash, isNotEmpty); expect(stopwatch.elapsedMilliseconds, lessThan(5000)); }); - testWidgets('same resolver refreshes signatures after an in-route rebuild', ( - tester, - ) async { - final rootKey = GlobalKey(); - final contentKey = GlobalKey<_MutableContentState>(); - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: _MutableContent(key: contentKey), - ), - ), - ); - final resolver = AnchorResolver(rootKey: rootKey); - final first = resolver.buildStateAnchor( - route: '/mutable', - keyboardOpen: false, - modalOpen: false, - ); - - contentKey.currentState!.showSecondControl(); - await tester.pump(); - final second = resolver.buildStateAnchor( - route: '/mutable', - keyboardOpen: false, - modalOpen: false, - ); - - expect(first.actionableSummary['button'], 1); - expect(second.actionableSummary['button'], 2); - // v6 coarse state signatures ignore actionable-count drift on the same route. - expect(second.signature, first.signature); - }); - - testWidgets('blocking modal excludes controls on the obscured route', ( - tester, - ) async { - final rootKey = GlobalKey(); - await tester.pumpWidget( - RepaintBoundary( - key: rootKey, - child: MaterialApp( - home: Builder( - builder: (context) => Scaffold( - body: FilledButton( - onPressed: () => showDialog( - context: context, - builder: (_) => AlertDialog( - content: const Text('Confirm'), - actions: [ - TextButton(onPressed: () {}, child: const Text('Cancel')), - TextButton(onPressed: () {}, child: const Text('Delete')), - ], - ), - ), - child: const Text('Open'), - ), - ), - ), - ), - ), - ); - final resolver = AnchorResolver(rootKey: rootKey); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - final modal = resolver.buildStateAnchor( - route: '/home', - keyboardOpen: false, - modalOpen: false, - ); - expect(modal.modalOpen, isTrue); - expect(modal.actionableSummary['button'], 2); - }); - testWidgets('generated names replace runtime names in canonical paths', ( tester, ) async { @@ -482,54 +217,6 @@ void main() { expect(anchor.canonicalPath, contains('CheckoutButton')); }); - testWidgets('hidden and off-viewport controls do not affect state identity', ( - tester, - ) async { - final rootKey = GlobalKey(); - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: Scaffold( - body: Stack( - children: [ - FilledButton(onPressed: () {}, child: const Text('Visible')), - Offstage( - offstage: true, - child: FilledButton( - onPressed: () {}, - child: const Text('Offstage'), - ), - ), - Opacity( - opacity: 0, - child: FilledButton( - onPressed: () {}, - child: const Text('Transparent'), - ), - ), - Positioned( - top: 2000, - child: FilledButton( - onPressed: () {}, - child: const Text('Outside'), - ), - ), - ], - ), - ), - ), - ), - ); - - final state = AnchorResolver(rootKey: rootKey).buildStateAnchor( - route: '/visibility', - keyboardOpen: false, - modalOpen: false, - ); - expect(state.actionableSummary['button'], 1); - }); - testWidgets('disabled controls retain target role but are not actionable', ( tester, ) async { @@ -550,15 +237,9 @@ void main() { tester.getCenter(find.text('Disabled')), route: '/disabled', ); - final state = resolver.buildStateAnchor( - route: '/disabled', - keyboardOpen: false, - modalOpen: false, - ); expect(target?.role, 'button'); expect(target?.enabled, isFalse); expect(target?.actions, isEmpty); - expect(state.actionableSummary['button'], isNull); }); testWidgets('typed async dropdown callbacks do not crash role inspection', ( @@ -772,32 +453,6 @@ void main() { }); } -class _MutableContent extends StatefulWidget { - const _MutableContent({super.key}); - - @override - State<_MutableContent> createState() => _MutableContentState(); -} - -class _MutableContentState extends State<_MutableContent> { - bool showSecond = false; - - void showSecondControl() => setState(() => showSecond = true); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Column( - children: [ - FilledButton(onPressed: () {}, child: const Text('First')), - if (showSecond) - FilledButton(onPressed: () {}, child: const Text('Second')), - ], - ), - ); - } -} - class _CatalogButton extends StatelessWidget { const _CatalogButton(); diff --git a/packages/tugboat/test/helpers/json_roundtrip.dart b/packages/tugboat/test/helpers/json_roundtrip.dart index c158238..73d047f 100644 --- a/packages/tugboat/test/helpers/json_roundtrip.dart +++ b/packages/tugboat/test/helpers/json_roundtrip.dart @@ -32,24 +32,6 @@ extension TugboatTargetAnchorTestJson on TugboatTargetAnchor { ); } -extension TugboatStateAnchorTestJson on TugboatStateAnchor { - static TugboatStateAnchor fromJson(Map json) => - TugboatStateAnchor( - schemaVersion: json['schemaVersion'] as int? ?? 1, - actionableSummary: json['actionableSummary'] == null - ? const {} - : Map.from(json['actionableSummary'] as Map), - keyboardOpen: json['keyboardOpen'] as bool? ?? false, - modalOpen: json['modalOpen'] as bool? ?? false, - subLabel: json['subLabel'] as String?, - signature: json['signature'] as String? ?? '', - signatureConfidence: json['signatureConfidence'] as String?, - signatureParts: json['signatureParts'] == null - ? const {} - : Map.from(json['signatureParts'] as Map), - ); -} - extension TugboatFrameTestJson on TugboatFrame { static TugboatFrame fromJson(Map json) => TugboatFrame( id: json['id'] as String, @@ -88,11 +70,6 @@ extension TugboatEventTestJson on TugboatEvent { sessionId: json['sessionId'] as String?, captureSessionId: json['captureSessionId'] as String?, activationRequestId: json['activationRequestId'] as String?, - stateAnchor: json['stateAnchor'] == null - ? null - : TugboatStateAnchorTestJson.fromJson( - Map.from(json['stateAnchor'] as Map), - ), targetAnchor: json['targetAnchor'] == null ? null : TugboatTargetAnchorTestJson.fromJson( diff --git a/packages/tugboat/test/helpers/replay_coherence_harness.dart b/packages/tugboat/test/helpers/replay_coherence_harness.dart index 745504a..4ac7502 100644 --- a/packages/tugboat/test/helpers/replay_coherence_harness.dart +++ b/packages/tugboat/test/helpers/replay_coherence_harness.dart @@ -281,20 +281,9 @@ class ReplayCoherenceHarness { capturer = ControllableCaptureExecutor(controller); capturer.registerFrame = (frameId, {route, routeEpoch}) { final currentRoute = controller.currentRoute; - final anchorRouteRaw = - controller.currentStateAnchor?.signatureParts['route']; - final anchorRoute = anchorRouteRaw is String && anchorRouteRaw.isNotEmpty - ? anchorRouteRaw - : null; final resolvedRoute = route ?? - (currentRoute != null && currentRoute.isNotEmpty - ? currentRoute - : null) ?? - (anchorRoute != null && anchorRoute.isNotEmpty - ? anchorRoute - : null) ?? - ''; + (currentRoute != null && currentRoute.isNotEmpty ? currentRoute : ''); registerFrameProvenance( frameId, route: resolvedRoute, @@ -305,7 +294,6 @@ class ReplayCoherenceHarness { controller.debugDelay = scheduler.delay; controller.debugScheduleDelay = scheduler.schedule; controller.debugExecuteCapture = capturer.call; - controller.debugFreezeStateAnchor = true; await controller.initialize(); controller.start(const Size(390, 844), 'test'); await pumpMicrotasks(); @@ -328,16 +316,10 @@ class ReplayCoherenceHarness { String seedRouteState({ required String route, - required String signature, + String? signature, String? frameContentHash, }) { controller.debugSetCurrentRoute(route); - controller.debugSetCurrentStateAnchor( - TugboatStateAnchor( - signature: signature, - signatureParts: {'route': route}, - ), - ); final frameId = controller.debugSeedFrame( contentHash: frameContentHash ?? 'frame-$route', trigger: TugboatFrameTrigger.route, @@ -441,8 +423,12 @@ class EventCoherenceView { final TugboatEvent event; String get type => event.type; - String? get route => event.stateAnchor?.signatureParts['route']; - String? get signature => event.stateAnchor?.signature; + String? get route { + final direct = event.data['route']; + if (direct is String && direct.isNotEmpty) return direct; + return null; + } + String? get beforeFrame => event.beforeFrame; String? get afterFrame => event.afterFrame; String? get relatedEventId => event.relatedEventId; @@ -482,11 +468,18 @@ class CoherenceInvariants { final indexesById = {}; for (var index = 0; index < events.length; index++) { final event = events[index]; - if (event.atMs < previousAtMs) return false; - previousAtMs = event.atMs; - if (indexesById.putIfAbsent(event.id, () => index) != index) { + // Canonical interactions publish after settle but keep pointer-down atMs. + if (event.type != 'interaction') { + if (event.atMs < previousAtMs) return false; + previousAtMs = event.atMs; + } + final identity = '${event.id}:${event.type}'; + if (indexesById.putIfAbsent(identity, () => index) != index) { return false; } + if (event.type != 'interaction') { + indexesById.putIfAbsent(event.id, () => index); + } } var previousIndex = -1; @@ -565,8 +558,7 @@ class CoherenceInvariants { if (outcome is! String || outcome.isEmpty || outcome == 'captured') { return false; } - if (event.type == 'tap_settled' && - event.result != TugboatInteractionResult.unknown) { + if (event.type == 'tap_settled' && event.result != null) { return false; } return true; @@ -601,10 +593,6 @@ class CoherenceInvariants { if (tap.type != 'tap' || settle.type != 'tap_settled') return false; if (settle.relatedEventId != tap.id) return false; if (settle.beforeFrame != tap.beforeFrame) return false; - if (expectedRouteSignature != null && - settle.stateAnchor?.signature != expectedRouteSignature) { - return false; - } if (settle.afterFrame == null) return false; final frameIds = [ @@ -709,6 +697,15 @@ class CoherenceInvariants { if (routeEvent.data['route'] != expectedDestinationRoute) return false; if (routeEvent.atMs < tap.atMs) return false; - return settle.result != TugboatInteractionResult.noVisibleChange; + final observation = settle.data['settleObservation']; + if (observation is Map) { + final outcome = observation['navigationOutcome']; + if (outcome == 'navigated') return true; + } + if (settle.result != null && + settle.result != TugboatInteractionResult.noVisibleChange) { + return true; + } + return settle.afterFrame != null; } } diff --git a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart index 75c23a4..e64ceca 100644 --- a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart +++ b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart @@ -258,7 +258,8 @@ void main() { final interactions = session.events.where((e) => e.type == 'interaction'); expect(interactions, isNotEmpty); - expect(interactions.first.data['origin'], isA()); + expect(interactions.first.data.containsKey('origin'), isFalse); + expect(interactions.first.data['interactionSchema'], 2); }, ); } diff --git a/packages/tugboat/test/replay/interaction_transaction_test.dart b/packages/tugboat/test/replay/interaction_transaction_test.dart index ce0cb01..8912e43 100644 --- a/packages/tugboat/test/replay/interaction_transaction_test.dart +++ b/packages/tugboat/test/replay/interaction_transaction_test.dart @@ -9,6 +9,33 @@ import '../helpers/replay_coherence_harness.dart'; Map _roundTrip(Map json) => Map.from(jsonDecode(jsonEncode(json)) as Map); +void expectInteractionV2Contract(TugboatEvent event) { + expect(event.type, 'interaction'); + expect(event.stream, TugboatEventStream.semantic); + expect(event.result, isNull); + expect(event.targetAnchor, isNull); + final data = event.data; + expect(data['interactionSchema'], tugboatInteractionSchemaVersion); + expect(data.containsKey('origin'), isFalse); + expect(data.containsKey('result'), isFalse); + expect(data.containsKey('attribution'), isFalse); + expect(data.containsKey('evidenceEventIds'), isFalse); + expect(data.containsKey('interactionId'), isFalse); + expect(data.containsKey('stateAnchor'), isFalse); + expect(data.containsKey('targetAnchor'), isFalse); + if (data.containsKey('targetFingerprint')) { + expect(data['targetFingerprint'], isA()); + } + if (data.containsKey('position')) { + final position = Map.from(data['position']! as Map); + expect(position['xNorm'], isA()); + expect(position['yNorm'], isA()); + expect(position.containsKey('normalizedX'), isFalse); + } + final encoded = utf8.encode(jsonEncode(event.toJson())); + expect(encoded.length, lessThan(600)); +} + extension on TugboatSession { List semanticOfType(String type) => events .where((e) => e.type == type && e.stream == TugboatEventStream.semantic) @@ -102,14 +129,6 @@ void main() { addTearDown(harness.dispose); harness.controller.debugSetCurrentRoute('/origin'); - harness.controller.debugFreezeStateAnchor = true; - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'origin-sig', - signatureConfidence: 'high', - signatureParts: {'route': '/origin'}, - ), - ); harness.controller.recordPointerDown(const Offset(12, 34)); await harness.controller.route('route_push', harness.route('/dest')); @@ -120,12 +139,9 @@ void main() { final interaction = harness.controller.session! .semanticOfType('interaction') .single; - final origin = Map.from( - interaction.data['origin']! as Map, - ); - expect(origin['route'], '/origin'); - expect(origin.containsKey('stateAnchor'), isFalse); - expect(interaction.targetAnchor?.fingerprint, isNull); + expectInteractionV2Contract(interaction); + expect(interaction.data['route'], '/origin'); + expect(interaction.data.containsKey('targetFingerprint'), isFalse); }, ); @@ -143,7 +159,7 @@ void main() { ); expect(interactions, hasLength(1)); expect(interactions.single.data['gesture'], 'cancelled'); - expect(harness.controller.session!.ofType('tap'), isEmpty); + expectInteractionV2Contract(interactions.single); }); test('swipe terminal path clears causal route state', () async { @@ -196,11 +212,13 @@ void main() { containsAll(['cancelled', 'tap']), ); final tap = interactions.singleWhere((e) => e.data['gesture'] == 'tap'); - final origin = Map.from(tap.data['origin']! as Map); - expect( - Map.from(origin['startPosition']! as Map)['x'], - 20.0, - ); + expectInteractionV2Contract(tap); + if (tap.data.containsKey('position')) { + final position = Map.from( + tap.data['position']! as Map, + ); + expect(position['xNorm'], isA()); + } }); test( @@ -222,10 +240,8 @@ void main() { .semanticOfType('interaction') .where((e) => e.data['gesture'] == 'cancelled'); expect(cancelled, isNotEmpty); - final attribution = Map.from( - cancelled.last.data['attribution']! as Map, - ); - expect(attribution['rejectionReason'], 'lifecycle'); + expectInteractionV2Contract(cancelled.last); + expect(cancelled.last.data['gesture'], 'cancelled'); }, ); @@ -284,14 +300,7 @@ void main() { final interaction = harness.controller.session! .semanticOfType('interaction') .single; - final attribution = Map.from( - interaction.data['attribution']! as Map, - ); - expect(attribution['kind'], anyOf('direct', 'delayed_likely')); - final result = Map.from( - interaction.data['result']! as Map, - ); - expect(result['status'], anyOf('navigated', 'changed', 'unknown')); + expectInteractionV2Contract(interaction); }, ); @@ -360,10 +369,7 @@ void main() { ); expect(interactions, hasLength(1)); expect(interactions.single.data['gesture'], anyOf('swipe', 'scroll')); - final origin = Map.from( - interactions.single.data['origin']! as Map, - ); - expect(origin['captureCoordinate'], isA()); + expectInteractionV2Contract(interactions.single); }); test('sub-slop movement remains one tap interaction', () async { @@ -384,7 +390,7 @@ void main() { }); group('Canonical publish and diagnostic isolation (U4)', () { - test('serialization round-trip preserves origin and result', () async { + test('serialization round-trip preserves facts-only v2 payload', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); @@ -401,9 +407,11 @@ void main() { expect(json['stream'], tugboatEventStreamSemantic); final data = Map.from(json['data']! as Map); expect(data['interactionSchema'], tugboatInteractionSchemaVersion); - expect(data['origin'], isA()); - expect(data['result'], isA()); - expect(data['attribution'], isA()); + expect(data.containsKey('origin'), isFalse); + expect(data.containsKey('result'), isFalse); + expect(data.containsKey('attribution'), isFalse); + expect(data.containsKey('evidenceEventIds'), isFalse); + expect(data['gesture'], 'tap'); }); test('legacy peers are dual-written on legacy_projection stream', () async { diff --git a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart index 4a248ff..b118d7f 100644 --- a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart @@ -361,9 +361,11 @@ void _assertChronological(List events) { var previousAt = -1; final ids = {}; for (final event in events) { - expect(event.atMs, greaterThanOrEqualTo(previousAt)); - expect(ids.add(event.id), isTrue); - previousAt = event.atMs; + if (event.type != 'interaction') { + expect(event.atMs, greaterThanOrEqualTo(previousAt)); + previousAt = event.atMs; + } + expect(ids.add('${event.id}:${event.type}'), isTrue); } } 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 f36319d..175698a 100644 --- a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart @@ -225,12 +225,6 @@ void main() { transitionDuration: const Duration(milliseconds: 150), ), ); - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'home', - signatureParts: {'route': '/home'}, - ), - ); final position = harness.targetTapPosition(tester); harness.controller.recordPointerDown(position); harness.controller.recordPointerUp(position); @@ -326,12 +320,6 @@ void main() { 'route_push', harness.route('/automatic'), ); - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'automatic', - signatureParts: {'route': '/automatic'}, - ), - ); harness.capturer.completeBlocked(); await harness.flushScheduler(); @@ -354,11 +342,7 @@ void main() { expect(observation['navigationOutcome'], 'visual_successor'); expect(observation['captureOutcome'], isNot('captured')); expect(observation['routeEventId'], isNull); - expect( - interaction.data['evidenceEventIds'], - isNot(contains(change.id)), - reason: 'an automatic successor is not causal evidence for the tap', - ); + expect(interaction.data.containsKey('evidenceEventIds'), isFalse); _expectEveryDiagnosticRequestIsResolvedOnce(session); expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); }, diff --git a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart index fb56fbe..be19119 100644 --- a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart @@ -486,8 +486,10 @@ void _assertChronological(List events) { var previousAt = -1; final ids = {}; for (final event in events) { - expect(event.atMs, greaterThanOrEqualTo(previousAt)); - expect(ids.add(event.id), isTrue); - previousAt = event.atMs; + if (event.type != 'interaction') { + expect(event.atMs, greaterThanOrEqualTo(previousAt)); + previousAt = event.atMs; + } + expect(ids.add('${event.id}:${event.type}'), isTrue); } } diff --git a/packages/tugboat/test/replay_coherence_characterization_test.dart b/packages/tugboat/test/replay_coherence_characterization_test.dart index a7ac6b6..b397a15 100644 --- a/packages/tugboat/test/replay_coherence_characterization_test.dart +++ b/packages/tugboat/test/replay_coherence_characterization_test.dart @@ -38,7 +38,6 @@ void main() { expect(tap.beforeFrame, originFrame); expect(settle.beforeFrame, originFrame); expect(settle.afterFrame, isNotNull); - expect(settle.stateAnchor?.signature, 'sig-home'); expect( CoherenceInvariants.tapSettleIsLinked( events: session.events, @@ -134,7 +133,7 @@ void main() { expect(routeChange.afterFrame, isNotNull); expect(settle.afterFrame, routeChange.afterFrame); expect(routeForces, [true]); - expect(interaction.data['evidenceEventIds'], contains(routeChange.id)); + expect(interaction.data.containsKey('evidenceEventIds'), isFalse); }, ); @@ -188,7 +187,7 @@ void main() { expect(settle.relatedEventId, tap.id); expect(settle.afterFrame, routeChange.afterFrame); expect(settle.afterFrame, isNot(originFrame)); - expect(settle.result, TugboatInteractionResult.navigated); + expect(settle.result, isNull); expect(routeChange.data['route'], '/home'); expect(routeChange.afterFrame, isNot(originFrame)); expect( @@ -306,7 +305,7 @@ void main() { await routeFuture; final session = harness.controller.session!; - final routeChange = session.ofType('route_change').single; + expect(session.ofType('route_change'), hasLength(1)); final settle = session.ofType('tap_settled').single; final interaction = session.events.singleWhere( (event) => @@ -321,11 +320,7 @@ void main() { expect(observation['navigationOutcome'], 'same_route'); expect(observation['captureOutcome'], 'superseded_route_epoch'); expect(observation['routeEventId'], isNull); - expect( - interaction.data['evidenceEventIds'], - isNot(contains(routeChange.id)), - reason: 'the automatic route is not causal evidence for the tap', - ); + expect(interaction.data.containsKey('evidenceEventIds'), isFalse); expect( harness.capturer.triggers.where( (trigger) => trigger == TugboatFrameTrigger.route, @@ -369,7 +364,7 @@ void main() { settle.data['settleObservation']! as Map, ); expect(settle.afterFrame, isNull); - expect(settle.result, isNot(TugboatInteractionResult.navigated)); + expect(settle.result, isNull); expect(observation['navigationOutcome'], 'visual_successor'); expect(observation['routeEventId'], isNull); expect(routeChange.afterFrame, isNotNull); @@ -412,11 +407,7 @@ void main() { ), hasLength(1), ); - expect( - interaction.data['evidenceEventIds'], - isNot(contains(changes.single.id)), - reason: 'the final automatic route is not evidence for the tap', - ); + expect(interaction.data.containsKey('evidenceEventIds'), isFalse); }, ); @@ -556,14 +547,8 @@ void main() { final interactions = harness.controller.session!.ofType('interaction'); expect(interactions, hasLength(1)); expect(interactions.single.data['gesture'], 'swipe'); - final result = interactions.single.data['result'] as Map; - expect(result['status'], 'cancelled'); - expect(result['captureOutcome'], 'cancelled'); - expect(result['observedAtMs'], isA()); - expect( - (interactions.single.data['attribution'] as Map)['rejectionReason'], - 'sessionEnd', - ); + expect(interactions.single.result, isNull); + expect(interactions.single.data.containsKey('result'), isFalse); harness.capturer.completeBlocked('late-swipe-frame'); await harness.pumpQueueWork(); @@ -590,14 +575,7 @@ void main() { final interactions = harness.controller.session!.ofType('interaction'); expect(interactions, hasLength(1)); expect(interactions.single.data['gesture'], 'swipe'); - expect( - (interactions.single.data['result'] as Map)['status'], - 'cancelled', - ); - expect( - (interactions.single.data['attribution'] as Map)['rejectionReason'], - 'lifecycle', - ); + expect(interactions.single.data.containsKey('result'), isFalse); harness.capturer.completeBlocked('late-background-swipe-frame'); await harness.pumpQueueWork(); @@ -625,9 +603,8 @@ void main() { expect(oldSession.ofType('interaction'), hasLength(1)); expect(oldSession.ofType('interaction').single.data['gesture'], 'swipe'); expect( - (oldSession.ofType('interaction').single.data['result'] - as Map)['status'], - 'cancelled', + oldSession.ofType('interaction').single.data.containsKey('result'), + isFalse, ); harness.capturer.completeBlocked('late-replacement-swipe-frame'); @@ -965,12 +942,6 @@ void main() { // Destination UI semantics are already visible, but the route capture has // not published a destination frame yet. - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'sig-home', - signatureParts: {'route': '/home'}, - ), - ); harness.controller.recordPointerDown(const Offset(30, 30)); harness.controller.recordPointerUp(const Offset(30, 30)); @@ -984,7 +955,6 @@ void main() { 'before': 'unavailable', 'reason': 'no_compatible_frame', }); - expect(destinationTap.stateAnchor?.signature, 'sig-home'); expect(harness.controller.debugRouteCapturePending, isTrue); expect( CoherenceInvariants.actionFrameMatchesRoute( @@ -1058,12 +1028,6 @@ void main() { harness.capturer.blockNext = true; harness.controller.start(const Size(390, 844), 'test'); harness.controller.debugSetCurrentRoute('/home'); - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'sig-home', - signatureParts: {'route': '/home'}, - ), - ); harness.controller.recordPointerDown(const Offset(4, 4)); harness.controller.recordPointerUp(const Offset(4, 4)); @@ -1084,12 +1048,6 @@ void main() { for (final (pointer, route) in [(1, '/a'), (2, '/b'), (3, '/a')]) { harness.controller.debugSetCurrentRoute(route); - harness.controller.debugSetCurrentStateAnchor( - TugboatStateAnchor( - signature: 'sig-$route', - signatureParts: {'route': route}, - ), - ); harness.controller.recordPointerDown( Offset(pointer.toDouble(), pointer.toDouble()), pointer: pointer, @@ -1130,24 +1088,12 @@ void main() { harness.seedRouteState(route: '/home', signature: 'sig-before'); harness.capturer.frameFactory = (trigger, force) { - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'sig-captured', - signatureParts: {'route': '/home'}, - ), - ); final frame = harness.controller.debugSeedFrame( contentHash: 'captured-pixels', trigger: trigger, ); // Simulate controller state advancing after readback but before the // settle event is admitted to the serialized mutation queue. - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'sig-advanced', - signatureParts: {'route': '/home'}, - ), - ); return frame; }; @@ -1157,8 +1103,6 @@ void main() { final settle = harness.controller.session!.ofType('tap_settled').single; expect(settle.afterFrame, isNotNull); - expect(settle.toJson().containsKey('stateAnchor'), isFalse); - expect(harness.controller.currentStateAnchor?.signature, 'sig-advanced'); expect( harness.controller.debugFrameProvenance(settle.afterFrame!), isNot(contains('completionStateSignature')), @@ -1187,7 +1131,7 @@ void main() { final settle = harness.controller.session!.ofType('tap_settled').single; expect(settle.beforeFrame, beforeFrame); expect(settle.afterFrame, isNot(beforeFrame)); - expect(settle.result, TugboatInteractionResult.noVisibleChange); + expect(settle.result, isNull); final observation = settle.data['settleObservation'] as Map; expect(observation.containsKey('semantic'), isFalse); expect(observation['visual'], { @@ -1219,12 +1163,6 @@ void main() { transitionDuration: const Duration(milliseconds: 20), ), ); - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'sig-destination', - signatureParts: {'route': '/destination'}, - ), - ); final destination = harness.controller.debugSeedFrame( contentHash: 'same-pixels', trigger: TugboatFrameTrigger.route, @@ -1270,12 +1208,6 @@ void main() { transitionDuration: const Duration(milliseconds: 20), ), ); - harness.controller.debugSetCurrentStateAnchor( - TugboatStateAnchor( - signature: 'sig-${transition.$2}', - signatureParts: {'route': transition.$2}, - ), - ); harness.controller.recordPointerDown(const Offset(8, 8)); harness.controller.recordPointerUp(const Offset(8, 8)); @@ -1399,13 +1331,6 @@ void main() { routeEpoch: 2, ); - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'sig-home', - signatureParts: {'route': '/home'}, - ), - ); - harness.controller.recordPointerDown(const Offset(30, 30)); harness.controller.recordPointerUp(const Offset(30, 30)); await harness.flushScheduler(); @@ -1416,7 +1341,6 @@ void main() { id: tap.id, atMs: tap.atMs, type: tap.type, - stateAnchor: tap.stateAnchor, targetAnchor: tap.targetAnchor, beforeFrame: unrelatedFrame, data: tap.data, @@ -1476,12 +1400,6 @@ void main() { ), ); expect(harness.controller.currentRoute, '/home'); - expect( - harness.controller.currentStateAnchor?.signatureParts['route'], - '/scan', - reason: - 'debugFreezeStateAnchor retains origin semantics during capture', - ); await harness.flushScheduler(); await routeFuture; @@ -1775,9 +1693,8 @@ void main() { final timedOutSettle = harness.controller.session! .ofType('tap_settled') .single; - expect(timedOutSettle.result, TugboatInteractionResult.unknown); + expect(timedOutSettle.result, isNull); expect(timedOutSettle.afterFrame, isNull); - expect(timedOutSettle.stateAnchor, change.stateAnchor); expect( timedOutSettle.data['settleObservation'], allOf( @@ -2020,12 +1937,6 @@ void main() { ); harness.controller.recordPointerDown(const Offset(8, 8)); - harness.controller.debugSetCurrentStateAnchor( - const TugboatStateAnchor( - signature: 'sig-after', - signatureParts: {'route': '/home'}, - ), - ); harness.capturer.frameFactory = (trigger, force) => harness.controller .debugSeedFrame(contentHash: 'same-pixels', trigger: trigger); harness.controller.recordPointerUp(const Offset(8, 8)); @@ -2034,12 +1945,7 @@ void main() { final settle = harness.controller.session!.ofType('tap_settled').single; expect(settle.beforeFrame, frame); expect(settle.afterFrame, isNot(frame)); - expect(settle.result, TugboatInteractionResult.noVisibleChange); - expect( - settle.stateAnchor?.signature, - 'sig-after', - reason: 'same-route semantic evidence is captured with the settle', - ); + expect(settle.result, isNull); expect((settle.data['settleObservation'] as Map)['visual'], { 'changed': false, 'evidence': 'content_hash', @@ -2065,7 +1971,7 @@ void main() { final settles = harness.controller.session!.ofType('tap_settled'); expect(settles, hasLength(1)); expect(settles.single.afterFrame, isNull); - expect(settles.single.result, TugboatInteractionResult.unknown); + expect(settles.single.result, isNull); expect(settles.single.data['frameAttachment'], { 'after': 'unavailable', 'reason': 'capture_processing_failed', diff --git a/packages/tugboat/test/scene_inventory_test.dart b/packages/tugboat/test/scene_inventory_test.dart index af2787a..1d79fb2 100644 --- a/packages/tugboat/test/scene_inventory_test.dart +++ b/packages/tugboat/test/scene_inventory_test.dart @@ -62,10 +62,8 @@ void main() { ); expect(inventory, isNotNull); - expect(inventory!.stateAnchor.signature, inventory.stateSignature); - expect(inventory.elements.length, greaterThanOrEqualTo(2)); + expect(inventory!.elements.length, greaterThanOrEqualTo(2)); expect(inventory.inventoryHash, isNotEmpty); - expect(inventory.stateSignature, isNotEmpty); final buttonCenter = tester.getCenter(find.text('Go')); final tapAnchor = resolver.targetAt(buttonCenter, route: '/home'); diff --git a/packages/tugboat/test/scroll_attribution_test.dart b/packages/tugboat/test/scroll_attribution_test.dart index 34b9669..f6f78b5 100644 --- a/packages/tugboat/test/scroll_attribution_test.dart +++ b/packages/tugboat/test/scroll_attribution_test.dart @@ -156,10 +156,7 @@ void main() { 'scroll', 'swipe', }); - expect( - interactions.map((event) => event.data['interactionId']).toSet(), - hasLength(2), - ); + expect(interactions.map((event) => event.id).toSet(), hasLength(2)); }); testWidgets( diff --git a/packages/tugboat/test/token_map_cache_test.dart b/packages/tugboat/test/token_map_cache_test.dart index 9c95fce..1005e6b 100644 --- a/packages/tugboat/test/token_map_cache_test.dart +++ b/packages/tugboat/test/token_map_cache_test.dart @@ -35,7 +35,7 @@ void main() { final tap = tester.getCenter(find.text('Continue')); final before = resolver.debugTokenMapBuildCount; - resolver.buildStateAnchor( + resolver.buildSceneInventory( route: '/home', keyboardOpen: false, modalOpen: false, @@ -59,7 +59,7 @@ void main() { // A bare pump may not fire post-frame callbacks that idle apps schedule; // invalidate explicitly and confirm the next call rebuilds. resolver.invalidateTokenMapCache(); - resolver.buildStateAnchor( + resolver.buildSceneInventory( route: '/home', keyboardOpen: false, modalOpen: false, diff --git a/packages/tugboat/test/tugboat_replay_test.dart b/packages/tugboat/test/tugboat_replay_test.dart index bb710bc..97f6ee9 100644 --- a/packages/tugboat/test/tugboat_replay_test.dart +++ b/packages/tugboat/test/tugboat_replay_test.dart @@ -621,7 +621,7 @@ void main() { final scrollStart = session.events.firstWhere( (event) => event.type == 'scroll_start', ); - expect(scrollStart.stateAnchor?.actionableSummary['scrollable'], 1); + expect(scrollStart.targetAnchor?.role, 'scrollable'); expect(session.scrollSamples, isNotEmpty); expect(session.frames, isNotEmpty); }); @@ -922,7 +922,7 @@ void main() { ); expect(interaction.actionId, 'A-origin'); expect(interaction.explorationRunId, 'run-1'); - expect((interaction.data['origin'] as Map)['actionId'], 'A-origin'); + expect(interaction.data.containsKey('origin'), isFalse); }); testWidgets('does not record icon or tooltip labels on icon button taps', ( @@ -1132,43 +1132,6 @@ void main() { expect(anchor.toJson().containsKey('itemIndex'), isFalse); }); - testWidgets('state signatures ignore dynamic visible labels', (tester) async { - final rootKey = GlobalKey(); - - Future buildAnchor(String label) async { - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: rootKey, - child: Scaffold( - body: Column( - children: [ - Text(label), - FilledButton(onPressed: () {}, child: const Text('Continue')), - ], - ), - ), - ), - ), - ); - await tester.pump(); - return AnchorResolver(rootKey: rootKey).buildStateAnchor( - route: '/intro', - keyboardOpen: false, - modalOpen: false, - ); - } - - final first = await buildAnchor('Brooke Martins'); - final second = await buildAnchor('Alex Chen'); - - expect(first.signature, second.signature); - expect(first.signatureConfidence, isNotNull); - expect(first.signatureParts, containsPair('routeKey', '/intro')); - expect(first.signatureParts.containsKey('labels'), isFalse); - expect(_containsLabelTelemetry(first.toJson()), isFalse); - }); - testWidgets('target fingerprints ignore dynamic button labels', ( tester, ) async { @@ -1641,99 +1604,6 @@ void main() { expect(session.frames.length, greaterThanOrEqualTo(framesBeforeScroll)); }); - test('tap_settled result does not infer a change from state signatures', () { - final rootKey = GlobalKey(); - final controller = TugboatReplayController( - config: _testConfig, - boundaryKey: rootKey, - ); - - final result = controller.debugComputeTapSettleResult( - beforeState: const TugboatStateAnchor(signature: 'sig-before'), - afterState: const TugboatStateAnchor(signature: 'sig-after'), - beforeFrame: 'frame-1', - afterFrame: 'frame-1', - targetAnchor: const TugboatTargetAnchor(actions: ['tap']), - ); - expect(result, TugboatInteractionResult.unknown); - controller.dispose(); - }); - - test('tap_settled result ignores tap-down state signatures', () { - final rootKey = GlobalKey(); - final controller = TugboatReplayController( - config: _testConfig, - boundaryKey: rootKey, - ); - - final result = controller.debugComputeTapSettleResult( - beforeState: const TugboatStateAnchor(signature: 'home-sig'), - afterState: const TugboatStateAnchor(signature: 'route-sig'), - beforeFrame: 'frame-1', - afterFrame: 'frame-1', - targetAnchor: const TugboatTargetAnchor(actions: ['tap']), - ); - expect(result, TugboatInteractionResult.unknown); - controller.dispose(); - }); - - test('tap_settled does not claim a no-op without visual evidence', () { - final rootKey = GlobalKey(); - final controller = TugboatReplayController( - config: _testConfig, - boundaryKey: rootKey, - ); - - final result = controller.debugComputeTapSettleResult( - beforeState: const TugboatStateAnchor(signature: 'same-sig'), - afterState: const TugboatStateAnchor(signature: 'same-sig'), - beforeFrame: null, - afterFrame: 'frame-without-evidence', - targetAnchor: const TugboatTargetAnchor(actions: ['tap']), - ); - expect(result, TugboatInteractionResult.unknown); - controller.dispose(); - }); - - test('tap_settled ignores ambient frame changes on non-tappable targets', () { - final rootKey = GlobalKey(); - final controller = TugboatReplayController( - config: _testConfig, - boundaryKey: rootKey, - ); - controller.start(const Size(100, 100), 'test'); - controller.session!.frames.addAll(const [ - TugboatFrame( - id: 'frame-before', - atMs: 1, - width: 100, - height: 100, - contentHash: 'before-hash', - ), - TugboatFrame( - id: 'frame-after', - atMs: 2, - width: 100, - height: 100, - contentHash: 'after-hash', - ), - ]); - - final result = controller.debugComputeTapSettleResult( - beforeState: const TugboatStateAnchor(signature: 'same-sig'), - afterState: const TugboatStateAnchor(signature: 'same-sig'), - beforeFrame: 'frame-before', - afterFrame: 'frame-after', - targetAnchor: const TugboatTargetAnchor( - role: 'scrollable', - actions: ['scroll'], - ), - ); - - expect(result, TugboatInteractionResult.noVisibleChange); - controller.dispose(); - }); - test('a throwing queued task does not poison later tap settles', () async { final rootKey = GlobalKey(); final controller = TugboatReplayController( From 22aeab159fe39cf7cbd73af15621cb570193d1b8 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Tue, 11 Aug 2026 05:56:32 +0530 Subject: [PATCH 07/10] feat: consolidate gestures into interaction payload Fold tap, swipe, scroll, and cancel facts into interaction schema v2 with a nested gesture payload, and stop emitting scroll_start, scroll_end, and pointer_cancel as separate production events. Co-authored-by: Cursor --- packages/tugboat/CHANGELOG.md | 6 + packages/tugboat/README.md | 11 +- .../tugboat/lib/src/collector_mapper.dart | 50 +---- packages/tugboat/lib/src/controller.dart | 166 ++-------------- .../lib/src/interaction_transaction.dart | 110 +++++++++-- packages/tugboat/lib/src/models.dart | 1 - .../tugboat/test/collector_mapper_test.dart | 183 ++++++------------ .../replay/deferred_tap_emission_test.dart | 11 +- .../replay/interaction_transaction_test.dart | 47 +++-- .../replay_navigation_race_matrix_test.dart | 26 ++- ...eplay_coherence_characterization_test.dart | 46 ++++- .../tugboat/test/scroll_attribution_test.dart | 122 ++++++------ .../test/scroll_playground_live_test.dart | 46 +++-- .../tugboat/test/tugboat_replay_test.dart | 102 ++++++---- 14 files changed, 439 insertions(+), 488 deletions(-) diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index db71841..8d2dded 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -17,6 +17,12 @@ `targetFingerprint` as a single string instead of a full anchor descriptor. Interaction v2 drops inferred `result`, nested `origin`/`result`, and tap-settle outcome computation. +- `interaction` schema v2 now carries gesture-specific facts under a nested + `payload` (`position` for tap; `position`/`endPosition`/`delta` for swipe; + `position`/`startOffset`/`endOffset`/`overscrollCount` for scroll). Cancelled + interactions omit `payload`. The SDK no longer emits `scroll_start`, + `scroll_end`, or `pointer_cancel` — scroll and cancel semantics live on + `interaction` only. ## 0.7.1 diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 5774d54..f47a62c 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -18,11 +18,12 @@ requests its own fresh after-frame. The collector mapper also omits the top- level `stateAnchor` key. Deploy the related collector change with this SDK release. -Schema-v2 collector events (`interaction`, `route_change`, `scroll_start`, -`scroll_end`) are flat facts-only records: no nested `payload`, no empty -`targetAnchor`, and no inferred interaction `result`. Scroll events send -`targetFingerprint` as a string; interaction v2 sends `targetFingerprint`, -`gesture`, optional `route`/`position`, and frame refs only. +Schema-v2 collector events (`interaction`, `route_change`) are flat facts-only +records: no nested top-level `payload` on route changes, no empty +`targetAnchor`, and no inferred interaction `result`. Interaction v2 uses a +nested `payload` for gesture facts (`tap`/`swipe`/`scroll`/`cancelled`) and +no longer emits separate `scroll_start`, `scroll_end`, or `pointer_cancel` +events. ## Install diff --git a/packages/tugboat/lib/src/collector_mapper.dart b/packages/tugboat/lib/src/collector_mapper.dart index 56ffae0..96fe11c 100644 --- a/packages/tugboat/lib/src/collector_mapper.dart +++ b/packages/tugboat/lib/src/collector_mapper.dart @@ -41,7 +41,7 @@ Map mapTugboatEventToCollectorEvent({ if (data['targetFingerprint'] != null) 'targetFingerprint': data['targetFingerprint'], if (data['gesture'] != null) 'gesture': data['gesture'], - if (data['position'] != null) 'position': data['position'], + if (data['payload'] != null) 'payload': data['payload'], }, ); } @@ -64,48 +64,6 @@ Map mapTugboatEventToCollectorEvent({ ); } - if (event.type == 'scroll_start') { - final data = event.data; - final targetFingerprint = _targetFingerprint(event); - return _collectorFlatEnvelope( - event: event, - triggeredAt: triggeredAt, - collectorConfig: collectorConfig, - sessionId: sessionId, - userId: userId, - traitsId: traitsId, - extra: { - 'scrollSchema': tugboatScrollSchemaVersion, - if (data['axis'] != null) 'axis': data['axis'], - if (data['startOffset'] != null) 'startOffset': data['startOffset'], - if (targetFingerprint != null) 'targetFingerprint': targetFingerprint, - }, - ); - } - - if (event.type == 'scroll_end') { - final data = event.data; - final overscrollCount = data['overscrollCount']; - final targetFingerprint = _targetFingerprint(event); - return _collectorFlatEnvelope( - event: event, - triggeredAt: triggeredAt, - collectorConfig: collectorConfig, - sessionId: sessionId, - userId: userId, - traitsId: traitsId, - extra: { - 'scrollSchema': tugboatScrollSchemaVersion, - if (data['startOffset'] != null) 'startOffset': data['startOffset'], - if (data['endOffset'] != null) 'endOffset': data['endOffset'], - if (data['durationMs'] != null) 'durationMs': data['durationMs'], - if (overscrollCount is int && overscrollCount > 0) - 'overscrollCount': overscrollCount, - if (targetFingerprint != null) 'targetFingerprint': targetFingerprint, - }, - ); - } - final payload = { ...event.data, if (event.relatedEventId != null) 'relatedEventId': event.relatedEventId, @@ -168,12 +126,6 @@ Map _collectorFlatEnvelope({ }; } -String? _targetFingerprint(TugboatEvent event) { - final fingerprint = event.targetAnchor?.fingerprint; - if (fingerprint == null || fingerprint.isEmpty) return null; - return fingerprint; -} - /// Immutable build identity required for Context Graph matching. Map collectorEventBuildIdentity( TugboatCollectorConfig config, diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index 5dee639..c1c3aff 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -2766,22 +2766,6 @@ class TugboatReplayController extends ChangeNotifier { reason: InteractionRejectionReason.lifecycle, ); } - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'pointer_cancel', - stream: TugboatEventStream.evidence, - data: { - 'x': position.dx, - 'y': position.dy, - 'pointer': pointer, - if (pending?.claimed == true) 'invalidatesRelatedTap': true, - if (pending != null) 'interactionId': pending.id, - }, - relatedEventId: pending?.claimed == true ? pending!.id : null, - ), - ); if (!_disposed) notifyListeners(); } @@ -2827,6 +2811,7 @@ class TugboatReplayController extends ChangeNotifier { pending.gesture = scrolled ? InteractionGesture.scroll : InteractionGesture.swipe; + pending.endPosition = position; _clearCausalRouteState(pending.id); if (config.emitLegacyInteractionProjection) { _addEvent( @@ -3298,6 +3283,18 @@ class TugboatReplayController extends ChangeNotifier { } } + void _applyScrollMetricsToInteraction( + _ScrollTracker tracker, + ScrollMetrics metrics, + ) { + final interaction = _scrollInteractions[tracker.startEventId]; + if (interaction == null) return; + interaction.scrollStartOffset = tracker.startOffset; + interaction.scrollEndOffset = metrics.pixels; + interaction.overscrollCount = tracker.overscrollCount; + interaction.scrollTargetAnchor = tracker.targetAnchor; + } + void _publishResolvedScrollInteraction(String scrollStartEventId) { final completion = _pendingScrollCompletions[scrollStartEventId]; final interaction = _scrollInteractions[scrollStartEventId]; @@ -3370,39 +3367,6 @@ class TugboatReplayController extends ChangeNotifier { return _anchorResolver?.subViewLabelFor(scrollableElement); } - Map _scrollEventData({ - required ScrollMetrics metrics, - required int depth, - required _ScrollTracker tracker, - double? endOffset, - int? durationMs, - int? overscrollCount, - }) { - final data = tugboatScrollMetricsData(metrics) - ..['depth'] = depth - ..['startOffset'] = tracker.startOffset; - if (endOffset != null) { - data['endOffset'] = endOffset; - } - if (durationMs != null) { - data['durationMs'] = durationMs; - } - if (tracker.pageStart != null) { - data['pageStart'] = tracker.pageStart; - if (metrics is PageMetrics) { - data['pageEnd'] = metrics.page; - } - } - if (tracker.sectionLabel != null) { - data['sectionLabel'] = tracker.sectionLabel; - } - if (overscrollCount != null && overscrollCount > 0) { - data['overscrollCount'] = overscrollCount; - } - data.addAll(tugboatScrollEdgeData(metrics)); - return data; - } - TugboatViewportSemanticScrollContext _scrollSemanticContext({ required String trigger, required ScrollMetrics metrics, @@ -3446,7 +3410,6 @@ class TugboatReplayController extends ChangeNotifier { final sectionLabel = _sectionLabelFor(scrollableElement); final attachmentContext = _captureContext(TugboatFrameTrigger.scroll); final beforeFrame = _compatibleFrameFor(attachmentContext); - final unavailableReason = _unavailableAttachmentReason(attachmentContext); final startEventId = _nextId('event'); final pageStart = metrics is PageMetrics ? metrics.page : null; @@ -3484,24 +3447,6 @@ class TugboatReplayController extends ChangeNotifier { _trimScrollSamples(); } - _addEvent( - TugboatEvent( - id: startEventId, - atMs: atMs, - type: 'scroll_start', - stream: TugboatEventStream.evidence, - targetAnchor: targetAnchor, - beforeFrame: beforeFrame, - data: { - ..._scrollEventData(metrics: metrics, depth: depth, tracker: tracker), - if (unavailableReason != null) - 'frameAttachment': { - 'before': 'unavailable', - 'reason': unavailableReason, - }, - }, - ), - ); _maybeEmitSceneInventory( scrollContext: _scrollSemanticContext( trigger: 'scroll_start', @@ -3601,31 +3546,6 @@ class TugboatReplayController extends ChangeNotifier { endOffset: metrics.pixels, ), ); - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'scroll_end', - stream: TugboatEventStream.evidence, - targetAnchor: tracker.targetAnchor, - beforeFrame: tracker.beforeFrame, - relatedEventId: tracker.startEventId, - data: { - ..._scrollEventData( - metrics: metrics, - depth: tracker.depth, - tracker: tracker, - endOffset: metrics.pixels, - durationMs: atMs - tracker.startedAtMs, - overscrollCount: tracker.overscrollCount, - ), - 'frameAttachment': { - 'after': 'unavailable', - 'reason': 'programmatic_scroll', - }, - }, - ), - ); if (!_disposed) notifyListeners(); }); return; @@ -3646,40 +3566,14 @@ class TugboatReplayController extends ChangeNotifier { force: true, relatedEventId: tracker.startEventId, ); - final afterResolution = await afterCapture.resolution; + await afterCapture.resolution; if (!_isCaptureLifecycleCurrent( captureSession, captureLifecycleEpoch, )) { return; } - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'scroll_end', - stream: TugboatEventStream.evidence, - targetAnchor: tracker.targetAnchor, - beforeFrame: tracker.beforeFrame, - relatedEventId: tracker.startEventId, - data: { - ..._scrollEventData( - metrics: metrics, - depth: tracker.depth, - tracker: tracker, - endOffset: metrics.pixels, - durationMs: atMs - tracker.startedAtMs, - overscrollCount: tracker.overscrollCount, - ), - 'captureOutcome': 'superseded_route_epoch', - 'captureAttemptOutcome': afterResolution.outcome.wireName, - 'frameAttachment': { - 'after': 'unavailable', - 'reason': 'superseded_route_epoch', - }, - }, - ), - ); + _applyScrollMetricsToInteraction(tracker, metrics); completion ..captureOutcome = 'superseded_route_epoch' ..resolved = true; @@ -3721,35 +3615,7 @@ class TugboatReplayController extends ChangeNotifier { endOffset: metrics.pixels, ), ); - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'scroll_end', - stream: TugboatEventStream.evidence, - targetAnchor: tracker.targetAnchor, - beforeFrame: tracker.beforeFrame, - afterFrame: afterFrame, - relatedEventId: tracker.startEventId, - data: { - ..._scrollEventData( - metrics: metrics, - depth: tracker.depth, - tracker: tracker, - endOffset: metrics.pixels, - durationMs: atMs - tracker.startedAtMs, - overscrollCount: tracker.overscrollCount, - ), - 'captureRequestId': afterResolution.requestId, - 'captureOutcome': afterResolution.outcome.wireName, - if (afterFrame == null) - 'frameAttachment': { - 'after': 'unavailable', - 'reason': afterResolution.outcome.wireName, - }, - }, - ), - ); + _applyScrollMetricsToInteraction(tracker, metrics); completion ..afterFrame = afterFrame ..captureOutcome = afterResolution.outcome.wireName diff --git a/packages/tugboat/lib/src/interaction_transaction.dart b/packages/tugboat/lib/src/interaction_transaction.dart index e5fd748..80cef09 100644 --- a/packages/tugboat/lib/src/interaction_transaction.dart +++ b/packages/tugboat/lib/src/interaction_transaction.dart @@ -81,32 +81,103 @@ enum InteractionRejectionReason { sessionEnd, } +Map? interactionNormalizedPosition( + TugboatCaptureCoordinate coordinate, +) { + if (!coordinate.isAvailable) return null; + if (coordinate.normalizedX < 0 || + coordinate.normalizedX > 1 || + coordinate.normalizedY < 0 || + coordinate.normalizedY > 1) { + return null; + } + return {'xNorm': coordinate.normalizedX, 'yNorm': coordinate.normalizedY}; +} + +Map? interactionNormalizedPoint( + Offset global, + TugboatCaptureCoordinate reference, +) { + if (!reference.isAvailable || + reference.boundaryWidth <= 0 || + reference.boundaryHeight <= 0) { + return null; + } + final localX = global.dx - reference.boundaryOriginX; + final localY = global.dy - reference.boundaryOriginY; + return { + 'xNorm': (localX / reference.boundaryWidth).clamp(0.0, 1.0), + 'yNorm': (localY / reference.boundaryHeight).clamp(0.0, 1.0), + }; +} + /// Facts-only interaction schema v2 fields stored in [TugboatEvent.data]. Map buildInteractionV2Payload(InteractionTransaction tx) { - final payload = { + final envelope = { 'interactionSchema': tugboatInteractionSchemaVersion, 'gesture': tx.gesture.name, }; final route = tx.origin.route; if (route != null && route.isNotEmpty) { - payload['route'] = route; + envelope['route'] = route; } - final fingerprint = tx.origin.targetAnchor?.fingerprint; + final fingerprint = tx.gesture == InteractionGesture.scroll + ? (tx.scrollTargetAnchor?.fingerprint ?? + tx.origin.targetAnchor?.fingerprint) + : tx.origin.targetAnchor?.fingerprint; if (fingerprint != null && fingerprint.isNotEmpty) { - payload['targetFingerprint'] = fingerprint; - } - final coord = tx.origin.captureCoordinate; - if (coord.isAvailable && - coord.normalizedX >= 0 && - coord.normalizedX <= 1 && - coord.normalizedY >= 0 && - coord.normalizedY <= 1) { - payload['position'] = { - 'xNorm': coord.normalizedX, - 'yNorm': coord.normalizedY, - }; - } - return payload; + envelope['targetFingerprint'] = fingerprint; + } + if (tx.gesture == InteractionGesture.cancelled) { + return envelope; + } + + final gesturePayload = {}; + final position = interactionNormalizedPosition(tx.origin.captureCoordinate); + if (position != null) { + gesturePayload['position'] = position; + } + + switch (tx.gesture) { + case InteractionGesture.tap: + break; + case InteractionGesture.swipe: + final endPosition = tx.endPosition; + if (endPosition != null) { + final end = interactionNormalizedPoint( + endPosition, + tx.origin.captureCoordinate, + ); + if (end != null) { + gesturePayload['endPosition'] = end; + if (position != null) { + gesturePayload['delta'] = { + 'xNorm': end['xNorm']! - position['xNorm']!, + 'yNorm': end['yNorm']! - position['yNorm']!, + }; + } + } + } + break; + case InteractionGesture.scroll: + if (tx.scrollStartOffset != null) { + gesturePayload['startOffset'] = tx.scrollStartOffset; + } + if (tx.scrollEndOffset != null) { + gesturePayload['endOffset'] = tx.scrollEndOffset; + } + if (tx.overscrollCount > 0) { + gesturePayload['overscrollCount'] = tx.overscrollCount; + } + break; + case InteractionGesture.cancelled: + break; + } + + if (gesturePayload.isNotEmpty) { + envelope['payload'] = gesturePayload; + } + return envelope; } /// Bounded in-memory transaction for one pointer gesture. @@ -134,6 +205,11 @@ class InteractionTransaction { InteractionAttribution attribution = InteractionAttribution.none; InteractionRejectionReason? rejectionReason; String? afterFrame; + Offset? endPosition; + double? scrollStartOffset; + double? scrollEndOffset; + int overscrollCount = 0; + TugboatTargetAnchor? scrollTargetAnchor; Completer? _successorSignal; diff --git a/packages/tugboat/lib/src/models.dart b/packages/tugboat/lib/src/models.dart index 3d5a34d..d0416ba 100644 --- a/packages/tugboat/lib/src/models.dart +++ b/packages/tugboat/lib/src/models.dart @@ -80,7 +80,6 @@ const String tugboatEventStreamLegacyProjection = 'legacy_projection'; const int tugboatInteractionSchemaVersion = 2; const int tugboatRouteChangeSchemaVersion = 2; -const int tugboatScrollSchemaVersion = 2; /// Whether [event] is a default enrichment / insight candidate. bool tugboatEventIsEnrichmentCandidate(TugboatEvent event) { diff --git a/packages/tugboat/test/collector_mapper_test.dart b/packages/tugboat/test/collector_mapper_test.dart index 3da0c5e..de95422 100644 --- a/packages/tugboat/test/collector_mapper_test.dart +++ b/packages/tugboat/test/collector_mapper_test.dart @@ -49,7 +49,9 @@ void main() { 'route': '/home', 'targetFingerprint': 'bef605389f2f5207', 'gesture': 'tap', - 'position': {'xNorm': 0.299, 'yNorm': 0.637}, + 'payload': { + 'position': {'xNorm': 0.299, 'yNorm': 0.637}, + }, }, ); @@ -67,15 +69,70 @@ void main() { expect(mapped['route'], '/home'); expect(mapped['targetFingerprint'], 'bef605389f2f5207'); expect(mapped['gesture'], 'tap'); - expect(mapped['position'], {'xNorm': 0.299, 'yNorm': 0.637}); + expect(mapped['payload'], { + 'position': {'xNorm': 0.299, 'yNorm': 0.637}, + }); + expect(mapped.containsKey('position'), isFalse); expect(mapped['beforeFrame'], 'frame-34'); expect(mapped['afterFrame'], 'frame-35'); expect(mapped.containsKey('result'), isFalse); - expect(mapped.containsKey('payload'), isFalse); expect(mapped.containsKey('targetAnchor'), isFalse); expect(mapped.containsKey('stateAnchor'), isFalse); final encoded = utf8.encode(jsonEncode(mapped)); - expect(encoded.length, lessThan(700)); + expect(encoded.length, lessThan(750)); + }); + + test('maps cancelled interactions without payload', () { + final mapped = mapTugboatEventToCollectorEvent( + event: TugboatEvent( + id: 'evt_cancelled_1', + atMs: 1000, + type: 'interaction', + stream: TugboatEventStream.semantic, + data: const { + 'interactionSchema': tugboatInteractionSchemaVersion, + 'gesture': 'cancelled', + }, + ), + sessionStartedAt: DateTime.utc(2026, 6, 19), + collectorConfig: collectorConfig, + ); + + expect(mapped['gesture'], 'cancelled'); + expect(mapped.containsKey('payload'), isFalse); + }); + + test('maps scroll interactions with nested payload', () { + final mapped = mapTugboatEventToCollectorEvent( + event: TugboatEvent( + id: 'evt_scroll_interaction_1', + atMs: 15000, + type: 'interaction', + stream: TugboatEventStream.semantic, + beforeFrame: 'frame-10', + afterFrame: 'frame-11', + data: const { + 'interactionSchema': tugboatInteractionSchemaVersion, + 'gesture': 'scroll', + 'targetFingerprint': 'abc123def4567890', + 'payload': { + 'position': {'xNorm': 0.30, 'yNorm': 0.64}, + 'startOffset': 0.0, + 'endOffset': 240.0, + 'overscrollCount': 2, + }, + }, + ), + sessionStartedAt: DateTime.utc(2026, 6, 19), + collectorConfig: collectorConfig, + ); + + expect(mapped['gesture'], 'scroll'); + expect(mapped['targetFingerprint'], 'abc123def4567890'); + expect((mapped['payload'] as Map)['startOffset'], 0.0); + expect((mapped['payload'] as Map)['endOffset'], 240.0); + expect((mapped['payload'] as Map)['overscrollCount'], 2); + expect(mapped.containsKey('scrollSchema'), isFalse); }); test('maps route_change events to facts-only schema v2', () { @@ -120,124 +177,6 @@ void main() { expect(encoded.length, lessThan(700)); }); - test('maps scroll_start events to facts-only schema v2', () { - final sessionStartedAt = DateTime.utc(2026, 6, 19); - final event = TugboatEvent( - id: 'evt_scroll_start_1', - atMs: 15000, - type: 'scroll_start', - stream: TugboatEventStream.evidence, - beforeFrame: 'frame-10', - targetAnchor: const TugboatTargetAnchor( - widgetType: 'ListView', - role: 'scrollable', - fingerprint: 'abc123def4567890', - fingerprintConfidence: 'high', - canonicalPath: 'HomeScreen#0/ListView#0', - ), - data: const { - 'axis': 'vertical', - 'startOffset': 0.0, - 'offset': 0.0, - 'offsetNorm': 0.0, - 'depth': 1, - 'frameAttachment': {'before': 'unavailable'}, - }, - ); - - final mapped = mapTugboatEventToCollectorEvent( - event: event, - sessionStartedAt: sessionStartedAt, - collectorConfig: collectorConfig, - ); - - expect(mapped['eventType'], 'scroll_start'); - expect(mapped['scrollSchema'], tugboatScrollSchemaVersion); - expect(mapped['axis'], 'vertical'); - expect(mapped['startOffset'], 0.0); - expect(mapped['targetFingerprint'], 'abc123def4567890'); - expect(mapped['targetFingerprint'], isA()); - expect(mapped['beforeFrame'], 'frame-10'); - expect(mapped.containsKey('result'), isFalse); - expect(mapped.containsKey('payload'), isFalse); - expect(mapped.containsKey('targetAnchor'), isFalse); - expect(mapped.containsKey('offset'), isFalse); - expect(mapped.containsKey('frameAttachment'), isFalse); - final encoded = utf8.encode(jsonEncode(mapped)); - expect(encoded.length, lessThan(700)); - }); - - test('maps scroll_end events to facts-only schema v2', () { - final sessionStartedAt = DateTime.utc(2026, 6, 19); - final event = TugboatEvent( - id: 'evt_scroll_end_1', - atMs: 15500, - type: 'scroll_end', - stream: TugboatEventStream.evidence, - beforeFrame: 'frame-10', - afterFrame: 'frame-11', - relatedEventId: 'evt_scroll_start_1', - targetAnchor: const TugboatTargetAnchor( - widgetType: 'ListView', - role: 'scrollable', - fingerprint: 'abc123def4567890', - fingerprintConfidence: 'high', - canonicalPath: 'HomeScreen#0/ListView#0', - ), - data: const { - 'startOffset': 0.0, - 'endOffset': 240.0, - 'durationMs': 500, - 'overscrollCount': 2, - 'offset': 240.0, - 'captureRequestId': 'cap-1', - 'captureOutcome': 'captured', - }, - ); - - final mapped = mapTugboatEventToCollectorEvent( - event: event, - sessionStartedAt: sessionStartedAt, - collectorConfig: collectorConfig, - ); - - expect(mapped['eventType'], 'scroll_end'); - expect(mapped['scrollSchema'], tugboatScrollSchemaVersion); - expect(mapped['relatedEventId'], 'evt_scroll_start_1'); - expect(mapped['startOffset'], 0.0); - expect(mapped['endOffset'], 240.0); - expect(mapped['durationMs'], 500); - expect(mapped['overscrollCount'], 2); - expect(mapped['targetFingerprint'], 'abc123def4567890'); - expect(mapped['targetFingerprint'], isA()); - expect(mapped['beforeFrame'], 'frame-10'); - expect(mapped['afterFrame'], 'frame-11'); - expect(mapped.containsKey('result'), isFalse); - expect(mapped.containsKey('payload'), isFalse); - expect(mapped.containsKey('targetAnchor'), isFalse); - expect(mapped.containsKey('captureRequestId'), isFalse); - expect(mapped.containsKey('captureOutcome'), isFalse); - final encoded = utf8.encode(jsonEncode(mapped)); - expect(encoded.length, lessThan(800)); - }); - - test('scroll_end omits zero overscrollCount', () { - final mapped = mapTugboatEventToCollectorEvent( - event: TugboatEvent( - id: 'evt_scroll_end_2', - atMs: 1, - type: 'scroll_end', - stream: TugboatEventStream.evidence, - relatedEventId: 'evt_scroll_start_1', - data: const {'overscrollCount': 0}, - ), - sessionStartedAt: DateTime.utc(2026, 6, 19), - collectorConfig: collectorConfig, - ); - - expect(mapped.containsKey('overscrollCount'), isFalse); - }); - test('generic branch omits empty targetAnchor and payload stream', () { final mapped = mapTugboatEventToCollectorEvent( event: TugboatEvent( diff --git a/packages/tugboat/test/replay/deferred_tap_emission_test.dart b/packages/tugboat/test/replay/deferred_tap_emission_test.dart index 07f4486..f519c9b 100644 --- a/packages/tugboat/test/replay/deferred_tap_emission_test.dart +++ b/packages/tugboat/test/replay/deferred_tap_emission_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/tugboat.dart'; import '../helpers/replay_coherence_harness.dart'; @@ -52,7 +53,15 @@ void main() { harness.controller.recordPointerCancel(const Offset(4, 4)); expect(harness.controller.session!.ofType('tap'), isEmpty); - expect(harness.controller.session!.ofType('pointer_cancel'), hasLength(1)); + expect( + harness.controller.session!.events.where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'cancelled', + ), + hasLength(1), + ); }); test( diff --git a/packages/tugboat/test/replay/interaction_transaction_test.dart b/packages/tugboat/test/replay/interaction_transaction_test.dart index 8912e43..9cce921 100644 --- a/packages/tugboat/test/replay/interaction_transaction_test.dart +++ b/packages/tugboat/test/replay/interaction_transaction_test.dart @@ -23,17 +23,39 @@ void expectInteractionV2Contract(TugboatEvent event) { expect(data.containsKey('interactionId'), isFalse); expect(data.containsKey('stateAnchor'), isFalse); expect(data.containsKey('targetAnchor'), isFalse); + expect(data.containsKey('position'), isFalse); if (data.containsKey('targetFingerprint')) { expect(data['targetFingerprint'], isA()); } - if (data.containsKey('position')) { - final position = Map.from(data['position']! as Map); - expect(position['xNorm'], isA()); - expect(position['yNorm'], isA()); - expect(position.containsKey('normalizedX'), isFalse); + final gesture = data['gesture']; + if (gesture == 'cancelled') { + expect(data.containsKey('payload'), isFalse); + } else if (data.containsKey('payload')) { + final payload = Map.from(data['payload']! as Map); + if (payload.containsKey('position')) { + final position = Map.from(payload['position']! as Map); + expect(position['xNorm'], isA()); + expect(position['yNorm'], isA()); + expect(position.containsKey('normalizedX'), isFalse); + } + if (gesture == 'swipe') { + if (payload.containsKey('delta')) { + final delta = Map.from(payload['delta']! as Map); + expect(delta['xNorm'], isA()); + expect(delta['yNorm'], isA()); + } + } + if (gesture == 'scroll') { + if (payload.containsKey('startOffset')) { + expect(payload['startOffset'], isA()); + } + if (payload.containsKey('endOffset')) { + expect(payload['endOffset'], isA()); + } + } } final encoded = utf8.encode(jsonEncode(event.toJson())); - expect(encoded.length, lessThan(600)); + expect(encoded.length, lessThan(700)); } extension on TugboatSession { @@ -213,11 +235,14 @@ void main() { ); final tap = interactions.singleWhere((e) => e.data['gesture'] == 'tap'); expectInteractionV2Contract(tap); - if (tap.data.containsKey('position')) { - final position = Map.from( - tap.data['position']! as Map, - ); - expect(position['xNorm'], isA()); + if (tap.data.containsKey('payload')) { + final payload = Map.from(tap.data['payload']! as Map); + if (payload.containsKey('position')) { + final position = Map.from( + payload['position']! as Map, + ); + expect(position['xNorm'], isA()); + } } }); 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 175698a..cfc874c 100644 --- a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart @@ -379,34 +379,30 @@ void main() { final session = controller.session!; expect(_ofType(session, 'tap'), isEmpty); - final scrollStart = _ofType(session, 'scroll_start').single; final swipe = _ofType(session, 'swipe').single; - final scrollEnd = _ofType(session, 'scroll_end').single; + final scrollInteraction = session.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) + .single; final change = _ofType(session, 'route_change').single; expect(_ofType(session, 'tap_settled'), isEmpty); expect(swipe.relatedEventId, isNull); expect(swipe.data['startCaptureCoordinate'], isA()); expect(swipe.data['scrolled'], isTrue); - expect(scrollEnd.relatedEventId, scrollStart.id); - expect(scrollEnd.afterFrame, isNull); - expect(scrollEnd.data['captureOutcome'], 'superseded_route_epoch'); - expect(scrollEnd.data['frameAttachment'], { - 'after': 'unavailable', - 'reason': 'superseded_route_epoch', - }); + expect(scrollInteraction.afterFrame, isNull); expect(change.data['route'], '/details'); expect( CoherenceInvariants.hasChronologicalChain( events: session.events, - orderedEventIds: [ - scrollStart.id, - swipe.id, - scrollEnd.id, - change.id, - ], + orderedEventIds: [swipe.id, change.id], ), isTrue, ); + expect(_ofType(session, 'interaction'), hasLength(1)); _expectEveryDiagnosticRequestIsResolvedOnce(session); expect(controller.debugRouteCapturePending, isFalse); expect(controller.debugActiveTapSettleCount, 0); diff --git a/packages/tugboat/test/replay_coherence_characterization_test.dart b/packages/tugboat/test/replay_coherence_characterization_test.dart index b397a15..8277a41 100644 --- a/packages/tugboat/test/replay_coherence_characterization_test.dart +++ b/packages/tugboat/test/replay_coherence_characterization_test.dart @@ -614,7 +614,7 @@ void main() { }, ); - testWidgets('ending session suppresses blocked scroll_end output', ( + testWidgets('ending session suppresses blocked scroll interaction output', ( tester, ) async { final harness = ReplayCoherenceHarness(); @@ -631,21 +631,38 @@ void main() { await tester.drag(find.byType(ListView), const Offset(0, -200)); harness.controller.recordPointerUp(const Offset(10, -190)); await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('scroll_start'), hasLength(1)); + expect(harness.controller.session!.ofType('swipe'), isNotEmpty); expect(harness.capturer.blockedCount, 1); + expect( + harness.controller.session!.events.where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ), + isEmpty, + ); await harness.controller.endSession(); harness.capturer.completeBlocked('late-scroll-frame'); await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('scroll_end'), isEmpty); + expect( + harness.controller.session!.events.where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ), + isEmpty, + ); expect(harness.controller.latestFrameId, originFrame); harness.dispose(); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); }); - testWidgets('backgrounding suppresses blocked scroll_end output', ( + testWidgets('backgrounding suppresses blocked scroll interaction output', ( tester, ) async { final harness = ReplayCoherenceHarness(); @@ -662,14 +679,31 @@ void main() { await tester.drag(find.byType(ListView), const Offset(0, -200)); harness.controller.recordPointerUp(const Offset(10, -190)); await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('scroll_start'), hasLength(1)); + expect(harness.controller.session!.ofType('swipe'), isNotEmpty); expect(harness.capturer.blockedCount, 1); + expect( + harness.controller.session!.events.where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ), + isEmpty, + ); harness.controller.recordAppLifecycleState(AppLifecycleState.paused); harness.capturer.completeBlocked('late-scroll-frame'); await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('scroll_end'), isEmpty); + expect( + harness.controller.session!.events.where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ), + isEmpty, + ); expect(harness.controller.latestFrameId, originFrame); harness.dispose(); await tester.pumpWidget(const SizedBox.shrink()); diff --git a/packages/tugboat/test/scroll_attribution_test.dart b/packages/tugboat/test/scroll_attribution_test.dart index f6f78b5..b9c030a 100644 --- a/packages/tugboat/test/scroll_attribution_test.dart +++ b/packages/tugboat/test/scroll_attribution_test.dart @@ -7,13 +7,20 @@ const _scrollTestConfig = TugboatReplayConfig( interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, - enableGlobalPointerCapture: false, + enableGlobalPointerCapture: true, scrollCaptureInterval: Duration(milliseconds: 50), captureScrollSamples: true, capturePixelRatio: 1.0, ); Future _waitForCaptures(WidgetTester tester) async { + final controller = TugboatReplay.controller; + if (controller != null) { + controller.debugExecuteCapture = + ({required trigger, required force}) async { + return controller.debugSeedFrame(trigger: trigger); + }; + } await tester.pump(); await tester.runAsync(() async { await Future.delayed(const Duration(milliseconds: 400)); @@ -83,7 +90,6 @@ Future _exerciseScrollCallbackOrder( final frame = session.frameById(afterFrame!); expect(frame, isNotNull); expect(frame!.trigger, TugboatFrameTrigger.interaction); - expect(frame.byteLength, greaterThan(0)); expect( session.events .where( @@ -196,18 +202,7 @@ void main() { await _waitForCaptures(tester); final session = controller.session!; - expect( - session.events.where((event) => event.type == 'scroll_start'), - hasLength(1), - ); - final scrollEnd = session.events - .where((event) => event.type == 'scroll_end') - .single; - expect(scrollEnd.afterFrame, isNull); - expect( - (scrollEnd.data['frameAttachment']! as Map)['reason'], - 'programmatic_scroll', - ); + expect(session.scrollSamples, isNotEmpty); expect( session.events.where( (event) => @@ -251,25 +246,24 @@ void main() { await _waitForCaptures(tester); final session = TugboatReplay.controller!.session!; - final scrollStarts = session.events - .where((event) => event.type == 'scroll_start') - .toList(); - final scrollEnds = session.events - .where((event) => event.type == 'scroll_end') + final scrollInteractions = session.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) .toList(); - expect(scrollStarts, isNotEmpty); - expect(scrollEnds, isNotEmpty); - expect(scrollStarts.first.targetAnchor, isNotNull); - expect(scrollStarts.first.targetAnchor!.role, 'scrollable'); - expect(scrollStarts.first.targetAnchor!.canonicalPath, isNotEmpty); - expect(scrollStarts.first.data['axis'], isNotNull); - expect(scrollStarts.first.data['depth'], isNotNull); - expect(scrollEnds.first.relatedEventId, scrollStarts.first.id); - expect( - scrollEnds.first.targetAnchor?.fingerprint, - scrollStarts.first.targetAnchor?.fingerprint, + expect(scrollInteractions, isNotEmpty); + expect(scrollInteractions.first.data['targetFingerprint'], isNotNull); + expect(scrollInteractions.first.data['targetFingerprint'], isNotEmpty); + final payload = Map.from( + scrollInteractions.first.data['payload']! as Map, ); + expect(payload['startOffset'], isNotNull); + expect(payload['endOffset'], isNotNull); + expect(payload['endOffset'], isNot(equals(payload['startOffset']))); }); testWidgets('dead swipe on static widget emits swipe without tap_settled', ( @@ -306,8 +300,13 @@ void main() { final settled = session.events .where((event) => event.type == 'tap_settled') .toList(); - final scrollStarts = session.events - .where((event) => event.type == 'scroll_start') + final scrollInteractions = session.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) .toList(); expect(swipes, isNotEmpty); @@ -316,10 +315,10 @@ void main() { expect(swipes.first.relatedEventId, isNull); expect(swipes.first.data['startCaptureCoordinate'], isA()); expect(settled, isEmpty); - expect(scrollStarts, isEmpty); + expect(scrollInteractions, isEmpty); }); - testWidgets('scroll swipe links tap to scroll_start via swipe event', ( + testWidgets('scroll swipe links legacy swipe to internal scroll tracker', ( tester, ) async { await tester.pumpWidget( @@ -344,14 +343,10 @@ void main() { final swipes = session.events .where((event) => event.type == 'swipe') .toList(); - final scrollStarts = session.events - .where((event) => event.type == 'scroll_start') - .toList(); expect(swipes, isNotEmpty); - expect(scrollStarts, isNotEmpty); expect(swipes.first.data['scrolled'], isTrue); - expect(swipes.first.data['scrollStartEventId'], scrollStarts.first.id); + expect(swipes.first.data['scrollStartEventId'], isNotNull); }); testWidgets('sub-slop tap still emits tap_settled', (tester) async { @@ -378,9 +373,7 @@ void main() { expect(session.events.where((event) => event.type == 'swipe'), isEmpty); }); - testWidgets('TugboatSubView label appears on scroll_start data', ( - tester, - ) async { + testWidgets('TugboatSubView scroll emits scroll interaction', (tester) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => @@ -402,10 +395,15 @@ void main() { await tester.drag(find.byType(ListView), const Offset(0, -180)); await _waitForCaptures(tester); - final scrollStart = TugboatReplay.controller!.session!.events.firstWhere( - (event) => event.type == 'scroll_start', - ); - expect(scrollStart.data['sectionLabel'], 'feed-section'); + final scrollInteractions = TugboatReplay.controller!.session!.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) + .toList(); + expect(scrollInteractions, isNotEmpty); }); testWidgets('nested scrollables produce independent scroll pairs', ( @@ -452,20 +450,23 @@ void main() { await _waitForCaptures(tester); final session = TugboatReplay.controller!.session!; - final scrollStarts = session.events - .where((event) => event.type == 'scroll_start') - .toList(); - final scrollEnds = session.events - .where((event) => event.type == 'scroll_end') + final scrollInteractions = session.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) .toList(); - expect(scrollStarts.length, greaterThanOrEqualTo(2)); - expect(scrollEnds.length, greaterThanOrEqualTo(2)); - final axes = scrollStarts.map((event) => event.data['axis']).toSet(); + expect(scrollInteractions.length, greaterThanOrEqualTo(2)); + final axes = session.scrollSamples.map((sample) => sample.axis).toSet(); expect(axes, containsAll(['horizontal', 'vertical'])); }); - testWidgets('PageView scroll emits page metrics', (tester) async { + testWidgets('PageView scroll updates horizontal scroll samples', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => @@ -486,10 +487,11 @@ void main() { await tester.drag(find.byType(PageView), const Offset(-300, 0)); await _waitForCaptures(tester); - final scrollStart = TugboatReplay.controller!.session!.events - .where((event) => event.type == 'scroll_start') - .toList(); - expect(scrollStart, isNotEmpty); - expect(scrollStart.first.data.containsKey('page'), isTrue); + final session = TugboatReplay.controller!.session!; + expect( + session.scrollSamples.any((sample) => sample.axis == 'horizontal'), + isTrue, + ); + expect(session.scrollSamples.length, greaterThan(1)); }); } diff --git a/packages/tugboat/test/scroll_playground_live_test.dart b/packages/tugboat/test/scroll_playground_live_test.dart index a7f9f8e..a7bd1c4 100644 --- a/packages/tugboat/test/scroll_playground_live_test.dart +++ b/packages/tugboat/test/scroll_playground_live_test.dart @@ -11,13 +11,20 @@ void main() { interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, - enableGlobalPointerCapture: false, + enableGlobalPointerCapture: true, scrollCaptureInterval: Duration(milliseconds: 50), captureScrollSamples: true, capturePixelRatio: 1.0, ); Future settle(WidgetTester tester) async { + final controller = TugboatReplay.controller; + if (controller != null) { + controller.debugExecuteCapture = + ({required trigger, required force}) async { + return controller.debugSeedFrame(trigger: trigger); + }; + } await tester.pump(); await tester.runAsync(() async { await Future.delayed(const Duration(milliseconds: 450)); @@ -120,13 +127,17 @@ void main() { final interesting = session.events .where( (e) => - e.type == 'scroll_start' || - e.type == 'scroll_end' || + (e.type == 'interaction' && + e.stream == TugboatEventStream.semantic && + (e.data['gesture'] == 'scroll' || + e.data['gesture'] == 'swipe' || + e.data['gesture'] == 'cancelled')) || e.type == 'swipe', ) .map( (e) => { 'type': e.type, + if (e.type == 'interaction') 'gesture': e.data['gesture'], 'id': e.id, if (e.relatedEventId != null) 'relatedEventId': e.relatedEventId, if (e.targetAnchor?.role != null) 'role': e.targetAnchor!.role, @@ -143,26 +154,33 @@ void main() { print(const JsonEncoder.withIndent(' ').convert(interesting)); expect( - interesting.where((e) => e['type'] == 'scroll_start').length, + interesting + .where((e) => e['type'] == 'interaction' && e['gesture'] == 'scroll') + .length, greaterThanOrEqualTo(2), ); expect(interesting.where((e) => e['type'] == 'swipe'), isNotEmpty); // Hero-image drag is inside the outer ListView: parent scroll fires with // overscroll but no offset change — failed scroll intent on static content. - final overscrollAtStatic = interesting.where( - (e) => - e['type'] == 'scroll_end' && - (e['data'] as Map)['overscrollCount'] != null && - ((e['data'] as Map)['overscrollCount'] as num) > 0, - ); + final overscrollAtStatic = interesting.where((e) { + if (e['type'] != 'interaction' || e['gesture'] != 'scroll') { + return false; + } + final payload = (e['data'] as Map)['payload'] as Map?; + if (payload == null) return false; + final overscrollCount = payload['overscrollCount']; + return overscrollCount != null && (overscrollCount as num) > 0; + }); expect(overscrollAtStatic, isNotEmpty); final verticalScroll = interesting.firstWhere( - (e) => - e['type'] == 'scroll_start' && - (e['data'] as Map)['sectionLabel'] == 'vertical-feed', + (e) => e['type'] == 'interaction' && e['gesture'] == 'scroll', + ); + final payload = Map.from( + (verticalScroll['data'] as Map)['payload']! as Map, ); - expect(verticalScroll['role'], 'scrollable'); + expect(payload['endOffset'], isNotNull); + expect((payload['endOffset'] as num), greaterThan(0)); }); } diff --git a/packages/tugboat/test/tugboat_replay_test.dart b/packages/tugboat/test/tugboat_replay_test.dart index 97f6ee9..03c55cc 100644 --- a/packages/tugboat/test/tugboat_replay_test.dart +++ b/packages/tugboat/test/tugboat_replay_test.dart @@ -595,10 +595,18 @@ void main() { }); testWidgets('captures scroll checkpoints and samples', (tester) async { + TugboatReplay.debugConfigureControllerForTest = (controller) { + controller.debugExecuteCapture = + ({required trigger, required force}) async { + return controller.debugSeedFrame(trigger: trigger); + }; + }; await tester.pumpWidget( MaterialApp( - builder: (context, child) => - TugboatReplay.wrapApp(config: _testConfig, child: child!), + builder: (context, child) => TugboatReplay.wrapApp( + config: _testConfig.copyWith(enableGlobalPointerCapture: true), + child: child!, + ), home: Scaffold( body: ListView( children: [ @@ -616,14 +624,18 @@ void main() { await _waitForCaptures(tester); final session = TugboatReplay.controller!.session!; - final types = session.events.map((event) => event.type).toList(); - expect(types, containsAll(['scroll_start', 'scroll_end'])); - final scrollStart = session.events.firstWhere( - (event) => event.type == 'scroll_start', - ); - expect(scrollStart.targetAnchor?.role, 'scrollable'); + final scrollInteractions = session.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) + .toList(); + expect(scrollInteractions, isNotEmpty); expect(session.scrollSamples, isNotEmpty); expect(session.frames, isNotEmpty); + TugboatReplay.debugConfigureControllerForTest = null; }); testWidgets('masks only TugboatSensitive subtrees in screenshots', ( @@ -1569,40 +1581,56 @@ void main() { controller.dispose(); }); - testWidgets('scroll_end forces after-frame capture when samples disabled', ( - tester, - ) async { - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => TugboatReplay.wrapApp( - config: _testConfig.copyWith(captureScrollSamples: false), - child: child!, - ), - home: Scaffold( - body: ListView( - children: const [ - SizedBox(height: 80, child: Text('Row 0')), - SizedBox(height: 80, child: Text('Row 1')), - ], + testWidgets( + 'scroll interaction forces after-frame capture when samples disabled', + (tester) async { + TugboatReplay.debugConfigureControllerForTest = (controller) { + controller.debugExecuteCapture = + ({required trigger, required force}) async { + return controller.debugSeedFrame(trigger: trigger); + }; + }; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => TugboatReplay.wrapApp( + config: _testConfig.copyWith( + captureScrollSamples: false, + enableGlobalPointerCapture: true, + ), + child: child!, + ), + home: Scaffold( + body: ListView( + children: const [ + SizedBox(height: 80, child: Text('Row 0')), + SizedBox(height: 80, child: Text('Row 1')), + ], + ), ), ), - ), - ); + ); - await _waitForCaptures(tester); - final session = TugboatReplay.controller!.session!; - final framesBeforeScroll = session.frames.length; + await _waitForCaptures(tester); + final session = TugboatReplay.controller!.session!; + final framesBeforeScroll = session.frames.length; - await tester.drag(find.byType(ListView), const Offset(0, -40)); - await _waitForCaptures(tester); + await tester.drag(find.byType(ListView), const Offset(0, -40)); + await _waitForCaptures(tester); - final scrollEnds = session.events - .where((event) => event.type == 'scroll_end') - .toList(); - expect(scrollEnds, isNotEmpty); - expect(scrollEnds.last.afterFrame, isNotNull); - expect(session.frames.length, greaterThanOrEqualTo(framesBeforeScroll)); - }); + final scrollInteractions = session.events + .where( + (event) => + event.type == 'interaction' && + event.stream == TugboatEventStream.semantic && + event.data['gesture'] == 'scroll', + ) + .toList(); + expect(scrollInteractions, isNotEmpty); + expect(scrollInteractions.last.afterFrame, isNotNull); + expect(session.frames.length, greaterThanOrEqualTo(framesBeforeScroll)); + TugboatReplay.debugConfigureControllerForTest = null; + }, + ); test('a throwing queued task does not poison later tap settles', () async { final rootKey = GlobalKey(); From 48b498827985a8c57879317277234a1e61cf99a1 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Tue, 11 Aug 2026 06:10:39 +0530 Subject: [PATCH 08/10] docs: align changelog and event catalogs with interaction v2 Update core docs to describe nested interaction payload gestures and the removal of scroll_start, scroll_end, and pointer_cancel from production wire. Co-authored-by: Cursor --- docs/design/capture-and-fingerprint.md | 19 ++++++++++--------- docs/integration/collector.md | 8 ++++---- packages/tugboat/CHANGELOG.md | 15 ++++++--------- packages/tugboat/README.md | 18 ++++++++---------- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/docs/design/capture-and-fingerprint.md b/docs/design/capture-and-fingerprint.md index d62ab4f..1b6d30e 100644 --- a/docs/design/capture-and-fingerprint.md +++ b/docs/design/capture-and-fingerprint.md @@ -97,22 +97,23 @@ delivery envelopes across process restarts within byte/age bounds. The event stream currently includes: - lifecycle: `session_start`, `session_end`; -- pointer intent and outcome: `tap`, `tap_settled`, `swipe`, - `pointer_cancel`, `tap_outside_tree`; +- canonical gestures: `interaction` (`gesture`: `tap`, `swipe`, `scroll`, + `cancelled`) with nested `payload` facts; +- deprecated legacy gesture peers (`dualWrite` / `legacyOnly` only): `tap`, + `tap_settled`, `swipe`, `tap_outside_tree`; - navigation: `route_change`; -- scrolling: `scroll_start`, `scroll_end`; - exploration control: `scene_inventory`, `action_window_set`, `action_window_cleared`; - optional semantic evidence: `viewport_semantic_map`, `scroll_semantic_snapshot`. Events may carry `beforeFrame`, `afterFrame`, `targetAnchor`, -`relatedEventId`, `explorationRunId`, `actionId`, an interaction result, and -type-specific `data`. Schema-v2 production events (`interaction`, -`route_change`, `scroll_start`, `scroll_end`) are flat facts-only collector -records without nested `payload` or inferred interaction results. Route -transition values live in `route_change` fields, not in a session-level route -dictionary. +`relatedEventId`, `explorationRunId`, `actionId`, and type-specific `data`. +Schema-v2 production `interaction` and `route_change` collector records are +flat facts-only shapes without inferred interaction results. `interaction` +carries gesture facts under nested `payload` (omitted for `cancelled`). +Route transition values live in `route_change` fields, not in a session-level +route dictionary. ### Capture lifecycle and attribution diff --git a/docs/integration/collector.md b/docs/integration/collector.md index 7c47b7f..4368f60 100644 --- a/docs/integration/collector.md +++ b/docs/integration/collector.md @@ -202,10 +202,10 @@ Event payloads contain: - optional `traitsId` (pass-through only; does not upsert the traits dictionary); - optional before/after frame references, related-event ID, and result; - serialized target anchors, when captured; -- event-specific data under `payload`, except for schema-v2 `interaction`, - `route_change`, `scroll_start`, and `scroll_end`, which are flat facts-only - records at the top level (`interactionSchema`, `routeChangeSchema`, or - `scrollSchema` == `2`); +- event-specific data under `payload`, except for schema-v2 `interaction` and + `route_change`, which are flat facts-only records at the top level + (`interactionSchema` or `routeChangeSchema` == `2`). `interaction` carries + gesture facts under nested `payload` (omitted for `cancelled`); - build identity: app ID, platform, version name, build number, and fingerprint schema version. diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index 8d2dded..5a119e5 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -9,15 +9,12 @@ - Collector event mapping now omits `stateAnchor`. Deploy the serial collector compatibility patch before sending 0.8.0 recordings to a collector that still requires that key. -- Production collector events for `interaction`, `route_change`, `scroll_start`, - and `scroll_end` now use flat schema-v2 wire shapes (`interactionSchema`, - `routeChangeSchema`, or `scrollSchema` == `2`) with facts-only fields. The - mapper no longer nests these under `payload`, emits empty `targetAnchor` - objects, or duplicates `stream` inside `payload`. Scroll events send - `targetFingerprint` as a single string instead of a full anchor descriptor. - Interaction v2 drops inferred `result`, nested `origin`/`result`, and - tap-settle outcome computation. -- `interaction` schema v2 now carries gesture-specific facts under a nested +- Production collector events for `interaction` and `route_change` use flat + schema-v2 wire shapes (`interactionSchema` or `routeChangeSchema` == `2`) with + facts-only fields. The mapper no longer emits empty `targetAnchor` objects or + duplicates `stream` inside generic `payload`. Interaction v2 drops inferred + `result`, nested `origin`/`result`, and tap-settle outcome computation. +- `interaction` schema v2 carries gesture-specific facts under a nested `payload` (`position` for tap; `position`/`endPosition`/`delta` for swipe; `position`/`startOffset`/`endOffset`/`overscrollCount` for scroll). Cancelled interactions omit `payload`. The SDK no longer emits `scroll_start`, diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index f47a62c..40a9555 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -205,7 +205,7 @@ those cases as an SDK capture gap, not as coherent replay evidence. | `route_change.data.navigationOrigin` | `interaction` or `automatic_or_unknown` | | `route_change.data.causeEventId` | Claimed tap id when origin is `interaction` | | `route_change.data.interactionAttribution` | Always `same_turn` when claimed | -| `swipe`/`pointer_cancel.data.invalidatesRelatedTap` | Boolean `true` when a related causal tap should be ignored for playback | +| `swipe` (legacy dual-write) `.data.invalidatesRelatedTap` | Boolean `true` when a related causal tap should be ignored for playback | | `tap_gesture_resolved` | Promotes `relatedEventId` from `causal_only` → interaction (`promotesRelatedTap`) | Released pointer-up claims attribute a route only through the pointer-up turn @@ -424,16 +424,14 @@ host-supplied analytics records via `TugboatReplay.eventHook` (see Emitted inferred event types currently include: -- canonical: `interaction` (`stream: semantic`) — one finalized gesture with - immutable `origin`, `result`, `attribution`, and `evidenceEventIds`; +- canonical: `interaction` (`stream: semantic`) — one finalized gesture + (`tap`, `swipe`, `scroll`, or `cancelled`) with gesture-specific facts under + `payload` (omitted for `cancelled`); - deprecated legacy gesture peers (emitted only when an integration explicitly selects `dualWrite` or `legacyOnly`): `tap`, `tap_settled`, `swipe`, `tap_outside_tree`, `tap_gesture_resolved`; - lifecycle: `session_start`, `session_identify`, `session_end`; -- input: `pointer_cancel` (`stream: evidence`); -- navigation evidence (`stream: evidence`): `route_change` - (claimed routes also carry `causedByInteractionId`); -- scrolling evidence (`stream: evidence`): `scroll_start`, `scroll_end`; +- navigation evidence (`stream: evidence`): `route_change`; - diagnostics: `capture_diagnostic` (`stream: diagnostic`); - exploration: `scene_inventory`, `action_window_set`, `action_window_cleared`; @@ -443,9 +441,9 @@ Emitted inferred event types currently include: Default enrichment and insight selection should use inferred events: `stream: semantic` `interaction` records (`enrichmentCandidate: true` on collector payloads). -Rage-tap style insights must count finalized `gesture=tap` interactions with -no successful `navigated`/`changed` result; exclude scrolls, swipes, -cancellations, evidence, legacy projections, and diagnostics. +Rage-tap style insights must count finalized `gesture=tap` interactions; +exclude scrolls, swipes, cancellations, evidence, legacy projections, and +diagnostics. Frames can be triggered by initial startup, interactions, routes, lifecycle, or explicit controller calls. Capture requests are serialized. Non-interaction From 53fda606e0d36aae0516b8cf8260f5e540a9c611 Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Tue, 11 Aug 2026 13:32:25 +0530 Subject: [PATCH 09/10] Remove legacy interaction formats --- docs/README.md | 2 +- docs/design/capture-and-fingerprint.md | 21 +- packages/tugboat/CHANGELOG.md | 8 + packages/tugboat/README.md | 107 +- .../tugboat/lib/src/collector_config.dart | 9 - packages/tugboat/lib/src/controller.dart | 412 +-- .../lib/src/exploration_transport.dart | 2 +- .../lib/src/interaction_transaction.dart | 4 - packages/tugboat/lib/src/models.dart | 59 +- .../tugboat/lib/src/outbox/outbox_sink.dart | 1 - packages/tugboat/lib/src/replay_config.dart | 24 - packages/tugboat/lib/src/tugboat.dart | 17 +- packages/tugboat/test/capture_sink_test.dart | 6 +- .../test/collector_http_sink_test.dart | 6 +- .../tugboat/test/collector_mapper_test.dart | 92 +- .../tugboat/test/coordinate_space_test.dart | 47 - .../test/exploration_transport_test.dart | 6 +- .../tugboat/test/helpers/json_roundtrip.dart | 13 +- .../helpers/replay_coherence_harness.dart | 130 +- .../release_compatibility_matrix_test.dart | 41 - .../replay/deferred_tap_emission_test.dart | 88 - .../replay/interaction_transaction_test.dart | 125 +- .../navigation_origin_contract_test.dart | 339 +-- ...ay_navigation_interaction_matrix_test.dart | 395 +-- .../replay_navigation_race_matrix_test.dart | 469 +--- ...overlay_nested_navigation_matrix_test.dart | 388 +-- ...y_programmatic_navigation_matrix_test.dart | 224 +- .../replay/tap_coordinate_transform_test.dart | 101 +- ...eplay_coherence_characterization_test.dart | 2279 ++--------------- .../tugboat/test/scene_inventory_test.dart | 36 +- .../tugboat/test/scroll_attribution_test.dart | 53 +- .../test/scroll_playground_live_test.dart | 5 +- .../test/sinks/tugboat_capture_sink_test.dart | 16 +- .../tugboat/test/tugboat_replay_test.dart | 155 +- .../test/viewport_semantic_map_test.dart | 120 +- 35 files changed, 824 insertions(+), 4976 deletions(-) delete mode 100644 packages/tugboat/test/replay/deferred_tap_emission_test.dart diff --git a/docs/README.md b/docs/README.md index a9264f0..5f6d360 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,7 @@ should be verified in their own repositories. ## Current compatibility - package version: `0.8.0`; -- session JSON schema: `9`; +- session JSON schema: `10`; - fingerprint schema: `6`; - minimum Dart SDK: `3.9.2`; - minimum Flutter SDK: `3.35.0`. diff --git a/docs/design/capture-and-fingerprint.md b/docs/design/capture-and-fingerprint.md index 1b6d30e..4249afd 100644 --- a/docs/design/capture-and-fingerprint.md +++ b/docs/design/capture-and-fingerprint.md @@ -63,7 +63,7 @@ current controller and keeps future calls to `wrapApp` inert. Runtime requiring a host rebuild. `deactivate()` tears capture down through the same gate. Pause/hidden flush pending delivery; detach ends the session once. -Identity fields (session schema **v10**; compatibility readers accept v6–v10): +Identity fields (session schema **v10** only): - `activationRequestId` — host request correlation - `captureSessionId` — SDK-emitted session (`session.id`) @@ -76,8 +76,7 @@ emits exact build and fingerprint-schema provenance only. ## Session and event model The controller owns one bounded, in-memory `TugboatSession`. Serialized session -JSON is schema version `10`. Compatibility readers accept schema versions -`6` through `10`. Schema v9 stopped writing `controlValue`, +JSON is schema version `10` only. Schema v9 stopped writing `controlValue`, `controlValueTransition`, or `semanticAnnotation` in event `data`; those fields are optional historic data in older sessions only. Schema v10 removes serialized state identity and adds the `interaction` frame trigger. @@ -99,8 +98,6 @@ The event stream currently includes: - lifecycle: `session_start`, `session_end`; - canonical gestures: `interaction` (`gesture`: `tap`, `swipe`, `scroll`, `cancelled`) with nested `payload` facts; -- deprecated legacy gesture peers (`dualWrite` / `legacyOnly` only): `tap`, - `tap_settled`, `swipe`, `tap_outside_tree`; - navigation: `route_change`; - exploration control: `scene_inventory`, `action_window_set`, `action_window_cleared`; @@ -119,11 +116,10 @@ route dictionary. `wrapApp` starts a session only after its repaint boundary has a non-zero viewport. The session begins with `session_start` and an initial capture -request. Pointer-down records `tap` plus a compatible pre-interaction frame, -then pointer-up either records a swipe or creates one `tap_settled` outcome. -The settled event refers to the initial tap through `relatedEventId` and is -intended to attach an after-frame only when that frame's provenance matches the -observed route epoch. A capture that is unavailable, cancelled, superseded, or +request. Pointer-down freezes a compatible pre-interaction frame. Pointer-up +publishes one canonical `interaction` after gesture classification. Its +after-frame attaches only when the frame provenance matches the observed route +epoch. A capture that is unavailable, cancelled, superseded, or timed out is represented by bounded capture/attachment diagnostics instead of borrowing the latest frame from another screen. @@ -338,8 +334,9 @@ The package test suite covers deterministic fingerprints, list-length and scroll stability, dynamic-label exclusion, static list discriminators, tag transparency, route separation, modal/visibility filtering, generated widget names, actionable `InkWell` paths, dormant activation without rebuild, -screenshot mask defaults, route payloads, scroll/swipe attribution, schema-v7 -JSON with v6 read compatibility, semantic modes, sink factories/mailboxes, +screenshot mask defaults, route payloads, scroll/swipe attribution, schema-v10 +JSON round trips and rejection of unsupported schema versions, semantic modes, +sink factories/mailboxes, outbox restart recovery, health diagnostics, lifecycle ordering, retry bounds, and stale session/frame protection. diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index 5a119e5..b4f0109 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased + +### Breaking changes + +- The SDK now publishes only schema-v2 canonical `interaction` gesture events. + Removed legacy gesture projections, publication modes, session aliases, and + compatibility constructors. + ## 0.8.0 ### Changed diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 40a9555..bc03f7c 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -5,9 +5,9 @@ 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.8.0`. Session JSON writers emit schema -version `10`; compatibility readers should accept versions `6` through -`10`. Structural fingerprints use fingerprint schema version `6`. +The current package version is `0.8.0`. Session JSON writers and readers use +schema version `10` only. Structural fingerprints use fingerprint schema +version `6`. ## 0.8.0 raw-event compatibility @@ -182,7 +182,7 @@ Navigator/repaint-boundary contract. Each visible route change creates a route epoch and waits for the transition plus the configured settle delay before taking the destination capture. A newer visible route supersedes an older pending capture. Consequently, a route event -and a related `tap_settled` event are intended to reference a frame compatible +and a related canonical `interaction` are intended to reference a frame compatible with that route epoch, or report bounded degraded/capture diagnostics rather than attach an origin-route frame merely because it was the latest frame. @@ -191,30 +191,12 @@ Production acceptance for #13/#14 remains open: rapid or nested modal chains and programmatic/automatic navigation can still be absent or degraded. Treat those cases as an SDK capture gap, not as coherent replay evidence. -### Deferred taps and interaction claims +### Interaction claims -`tap` is sampled at pointer-down but emitted only after gesture classification -(or when a claimed `route_change` must publish a cause). Consumers must read: - -| Field | Meaning | -| --- | --- | -| `data.replayRole` | `interaction` (real user tap) or `causal_only` (cause id for a route; not a replayable action) | -| `data.gestureFinal` | `tap`, `swipe`, `cancelled`, `superseded`, `session_end`, or `unresolved` | -| `data.sampledAtMs` | Pointer-down sample time when the event was published later | -| `data.captureCoordinate` | Boundary-local / normalized / raster transform for the before-frame | -| `route_change.data.navigationOrigin` | `interaction` or `automatic_or_unknown` | -| `route_change.data.causeEventId` | Claimed tap id when origin is `interaction` | -| `route_change.data.interactionAttribution` | Always `same_turn` when claimed | -| `swipe` (legacy dual-write) `.data.invalidatesRelatedTap` | Boolean `true` when a related causal tap should be ignored for playback | -| `tap_gesture_resolved` | Promotes `relatedEventId` from `causal_only` → interaction (`promotesRelatedTap`) | - -Released pointer-up claims attribute a route only through the pointer-up turn -(sync `onTap` → `Navigator.push`). Timer/auth redirects after that turn stay -`automatic_or_unknown`. Backgrounding and `session_end` drop pending claims -without minting orphan `causal_only` taps for cancelled routes. - -**Downstream contract:** Context Graph and PMKit CLI must not treat -`replayRole: causal_only` taps as real actions or step openers. +Pointer-down freezes interaction origin data. Pointer-up classifies the gesture +and publishes one canonical `interaction`. A claimed `route_change` uses that +interaction ID as `causeEventId`. Released pointer-up claims apply only through +the pointer-up turn. Timer or auth redirects stay `automatic_or_unknown`. ## Capture profiles and runtime state @@ -250,9 +232,8 @@ Identity contract: - `explorationRunId` — exploration control-plane ID from config - `traitsId` — collector-issued traits dictionary id after `setTraits` / session responses -`TugboatReplay.activeSessionId` remains as a deprecated alias for -`activationRequestId`. Inspect `TugboatReplay.health` for sink/outbox/screenshot -budget pressure without reading protected content. +Inspect `TugboatReplay.health` for sink, outbox, and screenshot-budget pressure +without reading protected content. ### User traits and user id @@ -300,7 +281,6 @@ Call `TugboatReplay.clearDurableOutbox()` on logout/consent revocation. | `profile` | `dormant` | capture cost and exploration-only behavior | | `settleDelay` | 1 second | delay before post-interaction and post-route capture | | `interactionClaimWindow` | 1,250 ms | released-tap window for delayed route/modal attribution; `Duration.zero` keeps microtask-only same-turn claims | -| `interactionPublishMode` | `canonicalOnly` | how finalized gestures are published; new recordings emit one canonical `interaction`. `legacyOnly` and `dualWrite` are deprecated compatibility modes | | `maxFrames` | 500 | in-memory frame bound | | `maxEvents` | 5000 | in-memory event bound | | `scrollCaptureInterval` | 2 seconds | interval for scroll checkpoint capture | @@ -321,46 +301,6 @@ Call `TugboatReplay.clearDurableOutbox()` on logout/consent revocation. | `outbox` | disabled | durable HTTP outbox configuration | | `screenshotBudget` | 60ms / 5s window | degraded-capture skip window / budget | -### Legacy gesture projection deprecation - -New recordings default to `TugboatInteractionPublishMode.canonicalOnly`. One -completed physical gesture produces one semantic `interaction` event containing -its immutable origin, finalized gesture, result, attribution, and evidence IDs. -The SDK no longer emits separate `tap`, `tap_settled`, or `swipe` rows unless an -integration explicitly opts into a legacy mode. - -`dualWrite` and `legacyOnly` remain available temporarily so older collectors, -Context Graph revisions, dashboards, and replay fixtures can be migrated without -making historical recordings unreadable: - -- `canonicalOnly` — supported default for all new recordings; -- `dualWrite` — deprecated migration override that adds legacy peers on - `stream: legacy_projection` with `enrichmentCandidate: false`; -- `legacyOnly` — deprecated emergency compatibility override for consumers that - cannot yet read canonical `interaction` records. - -Do not enable either legacy mode in a new application integration. Consumers -must use `interaction` as the user action and treat route/frame records as -linked evidence. Historical `tap` and `tap_settled` rows may still be read and -correlated through `interactionId` / `relatedEventId`, but must not be counted as -additional user actions. - -The code marker `TODO(tugboat-legacy-projection-removal)` tracks final removal. -Remove the legacy enum values and emission branches in a future breaking SDK -release only after all of the following are true: - -1. Supported Collector and Context Graph versions consume canonical - `interaction` records and ignore legacy projections by default. -2. Dashboard, insight, rage-tap, and replay queries no longer depend on - `tap_settled` or legacy `swipe` rows. -3. Production telemetry confirms that current SDK versions are recording - canonical interactions successfully across representative tap, navigation, - scroll, cancellation, and lifecycle cases. -4. Retained dual-write fixtures remain available to test historical replay - compatibility after the emitters are deleted. -5. Release notes announce the removal and the SDK schema/breaking version is - advanced deliberately. - ### Resolver and exploration events When exploration is active, the controller may emit: @@ -371,10 +311,6 @@ When exploration is active, the controller may emit: | `viewport_semantic_map` | Bounded semantic node map (mode-dependent) | | `scroll_semantic_snapshot` | Semantic snapshot tied to scroll checkpoints | | `action_window_set` / `action_window_cleared` | CLI exploration action-window fencing | -| `tap_outside_tree` | Pointer resolved no target; carries the same `replayRole` / `gestureFinal` as the paired tap when deferred | -| `tap_gesture_resolved` | Promotes a prior `causal_only` tap (`promotesRelatedTap: true`, `relatedEventId`) after the gesture finalizes as a real tap | - -Consumers that filter `replayRole: causal_only` must honor `tap_gesture_resolved` (or the patched in-memory tap) before suppressing genuine navigations. ## Privacy and payload boundary @@ -427,9 +363,6 @@ Emitted inferred event types currently include: - canonical: `interaction` (`stream: semantic`) — one finalized gesture (`tap`, `swipe`, `scroll`, or `cancelled`) with gesture-specific facts under `payload` (omitted for `cancelled`); -- deprecated legacy gesture peers (emitted only when an integration explicitly - selects `dualWrite` or `legacyOnly`): `tap`, `tap_settled`, `swipe`, - `tap_outside_tree`, `tap_gesture_resolved`; - lifecycle: `session_start`, `session_identify`, `session_end`; - navigation evidence (`stream: evidence`): `route_change`; - diagnostics: `capture_diagnostic` (`stream: diagnostic`); @@ -442,7 +375,7 @@ Default enrichment and insight selection should use inferred events: `stream: semantic` `interaction` records (`enrichmentCandidate: true` on collector payloads). Rage-tap style insights must count finalized `gesture=tap` interactions; -exclude scrolls, swipes, cancellations, evidence, legacy projections, and +exclude scrolls, swipes, cancellations, evidence, and diagnostics. Frames can be triggered by initial startup, interactions, routes, lifecycle, @@ -457,13 +390,8 @@ local-WebSocket suppression, paint-generation reuse, dHash reuse, or content-hash reuse. A fresh route capture can satisfy only the interaction that causally claimed it. -Pointer coordinates in event data (`x`, `y`, and swipe `startX`/`startY`) are -Flutter global logical-pixel coordinates from the pointer event. The SDK -converts a copy into its capture boundary's local space only for hit-testing and -normalizing target/viewport-semantic bounds; stored event coordinates are not -capture-boundary-normalized for replay playback. Do not interpret them as -physical pixels or as coordinates relative to an individual widget. Fractional -overlay drift is therefore still possible. +Interaction payload coordinates use normalized capture-boundary space. Do not +interpret them as physical pixels or as coordinates relative to a widget. For a tap, origin context (target, `beforeFrame`, `captureCoordinate`, route/navigator identity) is frozen at pointer-down into @@ -471,11 +399,8 @@ an `InteractionTransaction`. After pointer-up, settlement waits for either the first eligible visible successor inside `interactionClaimWindow` (default 1,250 ms) or the deadline. The canonical `interaction` event retains that frozen origin and attaches destination/result fields when a successor claims. -When deprecated dual-write compatibility is explicitly enabled, legacy `tap` + -`tap_settled` records link via `relatedEventId` / `interactionId`. They are not -additional semantic actions. A missing attachment is explicit in -`frameAttachment`/settle diagnostics rather than a fallback to an unrelated -frame. +A missing attachment remains unavailable. The SDK does not attach an unrelated +frame as a fallback. During local WebSocket exploration, connecting without an HTTP collector suppresses only non-interaction Flutter screenshot capture for UI-thread diff --git a/packages/tugboat/lib/src/collector_config.dart b/packages/tugboat/lib/src/collector_config.dart index 9b48982..4c257bf 100644 --- a/packages/tugboat/lib/src/collector_config.dart +++ b/packages/tugboat/lib/src/collector_config.dart @@ -8,15 +8,6 @@ class TugboatCollectorAppInfo { required this.appId, }); - @Deprecated('Use TugboatCollectorAppInfo(appId: ...) instead.') - const TugboatCollectorAppInfo.legacyPackageName({ - required this.name, - required this.version, - required this.buildNumber, - required this.installationId, - required String packageName, - }) : appId = packageName; - final String name; final String version; final String buildNumber; diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index c1c3aff..ea6054f 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -22,7 +22,6 @@ import 'outbox/outbox_sink.dart'; import 'replay_config.dart'; import 'screenshot_capturer.dart'; import 'screenshot_encode.dart'; -import 'scroll_capture.dart'; import 'viewport_semantic_session.dart'; export 'replay_config.dart' @@ -696,7 +695,7 @@ class _TapSettleWork { } } -/// The one immutable observation used to write a `tap_settled` event. +/// The one immutable observation used to write an `interaction` event. /// /// A tap can outlive both a Navigator callback and another capture request. /// Do not derive event fields from controller state after this is constructed: @@ -1355,11 +1354,14 @@ class TugboatReplayController extends ChangeNotifier { _invalidateCaptureWork(cancellationReason); // Routes cancelled above never publish causeEventId — do not mint an // orphan causal_only tap for a claim that will never be referenced. - _abandonAllPendingPointers(publishClaimedTap: false); + _abandonAllPendingPointers(); _clearReleasedInteractions(); _finalizeActiveCompletedGestureCaptures( InteractionRejectionReason.sessionEnd, ); + _finalizeScrollCompletionInteractions( + InteractionRejectionReason.sessionEnd, + ); _clearScrollCompletionState(); _captureLifecycleActive = false; @@ -1387,6 +1389,11 @@ class TugboatReplayController extends ChangeNotifier { _finalizeActiveCompletedGestureCaptures( InteractionRejectionReason.sessionEnd, ); + _finalizeScrollCompletionInteractions( + InteractionRejectionReason.sessionEnd, + ); + _abandonAllPendingPointers(gestureFinal: 'session_end'); + _clearReleasedInteractions(reason: InteractionRejectionReason.sessionEnd); _captureLifecycleActive = true; _captureLifecycleEpoch++; _endSessionFuture = null; @@ -1411,7 +1418,6 @@ class TugboatReplayController extends ChangeNotifier { _pointerGeneration = 0; _surfaces.clear(); _latestFrameId = null; - _clearReleasedInteractions(); _interactions.clearAll(); _clearScrollCompletionState(); _causalRouteCaptures.clear(); @@ -2315,12 +2321,10 @@ class TugboatReplayController extends ChangeNotifier { if (!_acceptsPointerInput) return; final previousClaim = _interactions.removeReleased(pointer); if (previousClaim != null) { - previousClaim.cancelled = true; - previousClaim.rejectionReason ??= - InteractionRejectionReason.claimConsumed; - if (!previousClaim.tapEmitted) { - _interactions.forgetId(previousClaim.id); - } + _finalizeAbandonedTransaction( + previousClaim, + reason: InteractionRejectionReason.claimConsumed, + ); } if (_interactions.pendingAt(pointer) != null) { _abandonPendingPointer(pointer, gestureFinal: 'superseded'); @@ -2358,25 +2362,11 @@ class TugboatReplayController extends ChangeNotifier { 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', - 'reason': unavailableReason, - }, - if (viewportResolution != null) - 'viewportSemanticResolution': viewportResolution.toJson(), - }; - final eventId = _nextId('event'); final startedAtMs = atMs; final origin = InteractionOrigin( @@ -2396,31 +2386,6 @@ class TugboatReplayController extends ChangeNotifier { actionId: _activeActionId, ); final tx = InteractionTransaction(origin: origin, pointerId: pointer); - final legacyStream = config.legacyGestureStream; - tx.bufferedOutside = target == null - ? TugboatEvent( - id: _nextId('event'), - atMs: startedAtMs, - type: 'tap_outside_tree', - stream: legacyStream, - beforeFrame: beforeFrame, - data: { - 'x': position.dx, - 'y': position.dy, - 'pointer': pointer, - 'interactionId': eventId, - }, - ) - : null; - tx.bufferedTap = TugboatEvent( - id: eventId, - atMs: startedAtMs, - type: 'tap', - stream: legacyStream, - targetAnchor: target, - beforeFrame: beforeFrame, - data: {...tapData, 'interactionId': eventId}, - ); _interactions.register(tx); if (viewportResolution != null && _viewportSemanticMapDebugLogsEnabled) { tugboatLogViewportSemanticTapResolution(position, viewportResolution); @@ -2473,110 +2438,16 @@ class TugboatReplayController extends ChangeNotifier { ); } - void _emitBufferedTapFromClaim( - InteractionTransaction tx, { - required String gestureFinal, - required String replayRole, + /// Retains route evidence until the claimed interaction reaches terminal + /// gesture classification. + void _attachCauseInteractionEvidence( + String? causeEventId, { + String? afterFrame, }) { - if (tx.tapEmitted) return; - tx.tapEmitted = true; - final emittedAtMs = atMs; - final emitLegacy = config.emitLegacyInteractionProjection; - final outside = tx.bufferedOutside; - if (outside != null) { - if (emitLegacy) { - _addEvent( - outside.copyWith( - atMs: emittedAtMs, - data: { - ...outside.data, - 'gestureFinal': gestureFinal, - 'replayRole': replayRole, - 'sampledAtMs': outside.atMs, - }, - ), - ); - } - tx.bufferedOutside = null; - } - final tap = tx.bufferedTap; - if (tap != null) { - if (emitLegacy) { - _addEvent( - tap.copyWith( - atMs: emittedAtMs, - data: { - ...tap.data, - 'gestureFinal': gestureFinal, - 'replayRole': replayRole, - 'sampledAtMs': tap.atMs, - }, - ), - ); - } - tx.bufferedTap = null; - } - _interactions.forgetId(tx.id); - } - - /// Promotes a previously published `causal_only` tap once the gesture finalizes - /// as a real tap. Patches the in-memory session (and sibling `tap_outside_tree`) - /// and emits `tap_gesture_resolved` so already-flushed sinks can promote too. - void _promoteCausalTapToInteraction(String tapEventId) { - if (!config.emitLegacyInteractionProjection) return; - const promotion = { - 'gestureFinal': 'tap', - 'replayRole': 'interaction', - 'promotedFrom': 'causal_only', - }; - final session = _session; - if (session != null) { - Object? sampledAtMs; - for (var i = 0; i < session.events.length; i++) { - final event = session.events[i]; - if (event.id != tapEventId || event.type != 'tap') continue; - sampledAtMs = event.data['sampledAtMs']; - session.events[i] = event.withData(promotion); - break; - } - if (sampledAtMs != null) { - for (var i = 0; i < session.events.length; i++) { - final event = session.events[i]; - if (event.type != 'tap_outside_tree') continue; - if (event.data['sampledAtMs'] != sampledAtMs) continue; - if (event.data['replayRole'] != 'causal_only') continue; - session.events[i] = event.withData(promotion); - } - } - } - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'tap_gesture_resolved', - stream: config.legacyGestureStream, - relatedEventId: tapEventId, - data: { - 'gestureFinal': 'tap', - 'replayRole': 'interaction', - 'promotesRelatedTap': true, - 'interactionId': tapEventId, - }, - ), - ); - } - - /// Ensures a route_change causeEventId names a live tap before it is written. - void _ensureCauseTapPublished(String? causeEventId) { if (causeEventId == null) return; final tx = _interactions.byId(causeEventId); - if (tx == null || tx.tapEmitted) return; - // Still unresolved at route publish time — causal only until gesture ends. - _emitBufferedTapFromClaim( - tx, - gestureFinal: 'unresolved', - replayRole: 'causal_only', - ); + if (tx == null) return; + if (afterFrame != null) tx.afterFrame = afterFrame; } void _releaseInteractionClaim(InteractionTransaction tx) { @@ -2591,9 +2462,8 @@ class TugboatReplayController extends ChangeNotifier { if (!identical(_interactions.byPointer(pointer), tx)) return; tx.sameTurnEligible = false; _interactions.removeReleased(pointer); - if (!tx.claimed && !tx.tapEmitted) { + if (!tx.claimed && !tx.semanticPublished) { tx.rejectionReason ??= InteractionRejectionReason.expired; - _interactions.forgetId(tx.id); } }); return; @@ -2652,9 +2522,6 @@ class TugboatReplayController extends ChangeNotifier { tx.rejectionReason ??= InteractionRejectionReason.expired; } _interactions.removeReleased(tx.pointerId); - if (!tx.claimed && !tx.tapEmitted) { - _interactions.forgetId(tx.id); - } } void _clearReleasedInteractions({ @@ -2665,22 +2532,9 @@ class TugboatReplayController extends ChangeNotifier { _reconciliationSweepScheduled = false; for (final tx in _interactions.takeAllReleased()) { _finalizeAbandonedTransaction(tx, reason: reason); - if (!tx.tapEmitted) { - tx.bufferedTap = null; - tx.bufferedOutside = null; - _interactions.forgetId(tx.id); - } } } - void _dropClaimBuffers(InteractionTransaction tx) { - _clearCausalRouteState(tx.id); - tx.cancelled = true; - tx.bufferedTap = null; - tx.bufferedOutside = null; - _interactions.forgetId(tx.id); - } - /// Ensures every transaction reaches exactly one terminal canonical state. void _finalizeAbandonedTransaction( InteractionTransaction tx, { @@ -2698,22 +2552,9 @@ class TugboatReplayController extends ChangeNotifier { _publishCanonicalInteraction(tx); } - void _abandonPendingPointer( - int pointer, { - required String gestureFinal, - bool publishClaimedTap = true, - }) { + void _abandonPendingPointer(int pointer, {required String gestureFinal}) { final pending = _interactions.removePending(pointer); if (pending == null) return; - if (pending.claimed && !pending.tapEmitted && publishClaimedTap) { - _emitBufferedTapFromClaim( - pending, - gestureFinal: gestureFinal, - replayRole: 'causal_only', - ); - } else if (!pending.tapEmitted) { - _dropClaimBuffers(pending); - } final reason = switch (gestureFinal) { 'superseded' => InteractionRejectionReason.claimConsumed, 'session_end' => InteractionRejectionReason.sessionEnd, @@ -2722,16 +2563,9 @@ class TugboatReplayController extends ChangeNotifier { _finalizeAbandonedTransaction(pending, reason: reason); } - void _abandonAllPendingPointers({ - bool publishClaimedTap = true, - String gestureFinal = 'session_end', - }) { + void _abandonAllPendingPointers({String gestureFinal = 'session_end'}) { for (final pointer in _interactions.takePendingPointers()) { - _abandonPendingPointer( - pointer, - gestureFinal: gestureFinal, - publishClaimedTap: publishClaimedTap, - ); + _abandonPendingPointer(pointer, gestureFinal: gestureFinal); } } @@ -2742,15 +2576,6 @@ class TugboatReplayController extends ChangeNotifier { _discardScrollCompletionFor(pending); pending.gesture = InteractionGesture.cancelled; pending.rejectionReason ??= InteractionRejectionReason.lifecycle; - if (pending.claimed) { - _emitBufferedTapFromClaim( - pending, - gestureFinal: 'cancelled', - replayRole: 'causal_only', - ); - } else { - _dropClaimBuffers(pending); - } _clearCausalRouteState(pending.id); _publishCanonicalInteraction(pending); } @@ -2758,9 +2583,6 @@ class TugboatReplayController extends ChangeNotifier { if (released != null) { _discardScrollCompletionFor(released); released.rejectionReason ??= InteractionRejectionReason.lifecycle; - if (!released.tapEmitted) { - _interactions.forgetId(released.id); - } _finalizeAbandonedTransaction( released, reason: InteractionRejectionReason.lifecycle, @@ -2773,12 +2595,8 @@ class TugboatReplayController extends ChangeNotifier { final pending = _interactions.pendingAt(pointer); if (pending != null) { pending.markSwipe(); - // Keep a claimed cause intact so route_change causeEventId stays valid. - if (!pending.claimed) { - pending.rejectionReason ??= - InteractionRejectionReason.gestureReclassified; - _dropClaimBuffers(pending); - } + pending.rejectionReason ??= + InteractionRejectionReason.gestureReclassified; } } @@ -2788,66 +2606,15 @@ class TugboatReplayController extends ChangeNotifier { if (pending == null) return; if (pending.isSwipeOrScroll) { - if (pending.claimed) { - _emitBufferedTapFromClaim( - pending, - gestureFinal: 'swipe', - replayRole: 'causal_only', - ); - } else { - _dropClaimBuffers(pending); - } - final origin = pending.origin; - final delta = position - origin.startPosition; - final durationMs = atMs - origin.atMs; - final velocity = durationMs > 0 - ? delta.distance / (durationMs / 1000) - : 0.0; final scrollStartEventId = pending.scrollStartEventIds.isNotEmpty ? pending.scrollStartEventIds.first : null; final scrolled = scrollStartEventId != null; - final tapWasEmitted = pending.tapEmitted; pending.gesture = scrolled ? InteractionGesture.scroll : InteractionGesture.swipe; pending.endPosition = position; _clearCausalRouteState(pending.id); - if (config.emitLegacyInteractionProjection) { - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'swipe', - stream: config.legacyGestureStream, - // R1: freeze to the origin target/frame rather than live refresh. - targetAnchor: origin.targetAnchor, - beforeFrame: origin.beforeFrame, - relatedEventId: tapWasEmitted ? pending.id : null, - result: scrolled - ? TugboatInteractionResult.changed - : TugboatInteractionResult.noVisibleChange, - data: { - 'x': position.dx, - 'y': position.dy, - 'startX': origin.startPosition.dx, - 'startY': origin.startPosition.dy, - 'deltaX': delta.dx, - 'deltaY': delta.dy, - 'direction': tugboatSwipeDirection(delta), - 'distance': delta.distance, - 'velocity': velocity, - 'durationMs': durationMs, - 'scrolled': scrolled, - 'startCaptureCoordinate': origin.captureCoordinate.toJson(), - if (tapWasEmitted) 'invalidatesRelatedTap': true, - if (scrollStartEventId != null) - 'scrollStartEventId': scrollStartEventId, - 'interactionId': pending.id, - }, - ), - ); - } if (scrollStartEventId == null) { _publishCompletedGestureAfterCapture(pending); } else { @@ -2858,18 +2625,6 @@ class TugboatReplayController extends ChangeNotifier { return; } - if (!pending.tapEmitted) { - _emitBufferedTapFromClaim( - pending, - gestureFinal: 'tap', - replayRole: 'interaction', - ); - } else if (pending.claimed) { - // Route already published a causal-only tap; promote it for consumers that - // filter causal_only, and patch the in-memory event when still present. - _promoteCausalTapToInteraction(pending.id); - } - // Keep the single-use claim alive through the pointer-up turn so sync // onTap → Navigator can attribute without letting later redirects borrow. _releaseInteractionClaim(pending); @@ -3069,75 +2824,7 @@ class TugboatReplayController extends ChangeNotifier { } Future writeSettle() async { if (!_isActiveTapSettle(work)) return; - final origin = pending.origin; - final beforeFrame = origin.beforeFrame; - final tapEventId = pending.id; - final tapTargetAnchor = origin.targetAnchor; - // Never read mutable controller state here: later route/capture work - // may have advanced while this task waited on the serialized queue. final afterFrame = observation.afterFrame; - final beforeContentHash = beforeFrame == null - ? null - : _frameContentHash(beforeFrame); - final afterContentHash = afterFrame == null - ? null - : _frameContentHash(afterFrame); - final visualAvailable = - beforeContentHash != null && afterContentHash != null; - final visualChanged = visualAvailable - ? beforeContentHash != afterContentHash - : null; - - if (config.emitLegacyInteractionProjection) { - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'tap_settled', - stream: config.legacyGestureStream, - targetAnchor: tapTargetAnchor, - beforeFrame: beforeFrame, - afterFrame: afterFrame, - relatedEventId: tapEventId, - data: { - 'x': position.dx, - 'y': position.dy, - 'interactionId': tapEventId, - 'settleObservation': { - 'version': 1, - 'routeEpoch': observation.routeEpoch, - if (observation.route != null) 'route': observation.route, - 'navigationOutcome': observation.navigationOutcome, - 'captureOutcome': observation.captureOutcome, - if (observation.captureFailure != null) - 'captureFailure': observation.captureFailure, - if (observation.routeEventId != null) - 'routeEventId': observation.routeEventId, - if (observation.captureRequestId != null) - 'captureRequestId': observation.captureRequestId, - 'visual': { - 'changed': visualChanged, - 'evidence': visualAvailable - ? 'content_hash' - : 'unavailable', - 'reason': visualChanged == null - ? 'unavailable' - : visualChanged - ? 'frame_changed' - : 'same_frame', - }, - }, - if (afterFrame == null) - 'frameAttachment': { - 'after': 'unavailable', - 'reason': - observation.captureFailure ?? - observation.captureOutcome, - }, - }, - ), - ); - } pending.gesture = InteractionGesture.tap; pending.afterFrame = afterFrame; _publishCanonicalInteraction(pending); @@ -3152,10 +2839,12 @@ class TugboatReplayController extends ChangeNotifier { try { await writeSettle(); } catch (error, stackTrace) { - debugPrint('[tugboat] tap_settled failed: $error\n$stackTrace'); + debugPrint( + '[tugboat] interaction settle failed: $error\n$stackTrace', + ); } } else { - await _enqueue('tap_settled', writeSettle); + await _enqueue('interaction_settle', writeSettle); } } finally { _activeTapSettles.remove(work); @@ -3210,10 +2899,6 @@ class TugboatReplayController extends ChangeNotifier { _activeTapSettles.clear(); } - String? _frameContentHash(String frameId) { - return _session?.frameById(frameId)?.contentHash; - } - bool _linkScrollStartToActiveGestures(String scrollStartEventId) { // A ScrollNotification does not identify a pointer. Give it one stable // owner so a shared start ID cannot make pointer-up transactions replace @@ -3230,7 +2915,6 @@ class TugboatReplayController extends ChangeNotifier { void _publishCanonicalInteraction(InteractionTransaction tx) { if (tx.semanticPublished) return; - if (!config.emitCanonicalInteractions) return; tx.semanticPublished = true; _addEvent( TugboatEvent( @@ -3245,6 +2929,7 @@ class TugboatReplayController extends ChangeNotifier { actionId: tx.origin.actionId, ), ); + _interactions.forgetId(tx.id); } void _clearCausalRouteState(String interactionId) { @@ -3258,6 +2943,15 @@ class TugboatReplayController extends ChangeNotifier { _pendingScrollCompletions.clear(); } + void _finalizeScrollCompletionInteractions( + InteractionRejectionReason reason, + ) { + final transactions = _scrollInteractions.values.toSet(); + for (final tx in transactions) { + _finalizeAbandonedTransaction(tx, reason: reason); + } + } + void _finalizeActiveCompletedGestureCaptures( InteractionRejectionReason reason, ) { @@ -3815,7 +3509,7 @@ class TugboatReplayController extends ChangeNotifier { // This must not enqueue behind the blocked task that caused the timeout. // Dart's single isolate means the session mutation is still atomic with // respect to the next event-loop turn. - _ensureCauseTapPublished(change.causeEventId); + _attachCauseInteractionEvidence(change.causeEventId); _emitRouteChange( routeEventId: routeEventId, change: change, @@ -3903,7 +3597,7 @@ class TugboatReplayController extends ChangeNotifier { outcome = _RouteCaptureOutcome.failed; captureFailure = _lastCaptureFailure?.name; routeEventId = _nextId('event'); - _ensureCauseTapPublished(change.causeEventId); + _attachCauseInteractionEvidence(change.causeEventId); _emitRouteChange( routeEventId: routeEventId, change: change, @@ -3928,7 +3622,10 @@ class TugboatReplayController extends ChangeNotifier { } if (!_isActiveRouteCapture(work)) return; routeEventId = _nextId('event'); - _ensureCauseTapPublished(change.causeEventId); + _attachCauseInteractionEvidence( + change.causeEventId, + afterFrame: afterFrame, + ); _emitRouteChange( routeEventId: routeEventId, change: change, @@ -4002,16 +3699,16 @@ class TugboatReplayController extends ChangeNotifier { _invalidateCaptureWork('lifecycle_deactivate'); // Drop in-flight pointer claims so a later resume/navigation cannot // attribute itself to a pre-background gesture. - _abandonAllPendingPointers( - publishClaimedTap: false, - gestureFinal: 'lifecycle', - ); + _abandonAllPendingPointers(gestureFinal: 'lifecycle'); _clearReleasedInteractions( reason: InteractionRejectionReason.lifecycle, ); _finalizeActiveCompletedGestureCaptures( InteractionRejectionReason.lifecycle, ); + _finalizeScrollCompletionInteractions( + InteractionRejectionReason.lifecycle, + ); _clearScrollCompletionState(); _captureLifecycleActive = false; break; @@ -4172,9 +3869,8 @@ class TugboatReplayController extends ChangeNotifier { /// Observer-time single-use claim. Returns the transaction only when exactly /// one unambiguous active pointer is eligible for this navigator/session. /// - /// Does not flush the buffered tap — that happens at route_change publish via - /// [_ensureCauseTapPublished], or earlier at pointer-up / swipe / cancel, so - /// a pre-up claim that later becomes a swipe stays `causal_only`. + /// The route writer retains the claim until terminal publication, so a + /// causeEventId remains stable across route and gesture settlement. InteractionTransaction? _tryClaimInteractionCause({String? navigatorId}) { if (!_captureLifecycleActive || _endSessionFuture != null) return null; final eligible = _interactions.eligibleForClaim( @@ -4264,7 +3960,6 @@ class TugboatReplayController extends ChangeNotifier { if (session == null) return; final enriched = attachActionContext ? event.withExplorationContext( - sessionId: session.id, captureSessionId: session.id, activationRequestId: session.activationRequestId ?? activationRequestId, @@ -4275,7 +3970,6 @@ class TugboatReplayController extends ChangeNotifier { actionId: event.actionId ?? _activeActionId, ) : event.copyWith( - sessionId: event.sessionId ?? session.id, captureSessionId: event.captureSessionId ?? session.id, activationRequestId: event.activationRequestId ?? diff --git a/packages/tugboat/lib/src/exploration_transport.dart b/packages/tugboat/lib/src/exploration_transport.dart index b70e512..0ee68c8 100644 --- a/packages/tugboat/lib/src/exploration_transport.dart +++ b/packages/tugboat/lib/src/exploration_transport.dart @@ -74,7 +74,7 @@ class TugboatExplorationTransport { void sendEvent(TugboatEvent event) { _sendJson({ 'type': 'event', - if (event.sessionId != null) 'sessionId': event.sessionId, + if (event.captureSessionId != null) 'sessionId': event.captureSessionId, if (event.explorationRunId != null) 'explorationRunId': event.explorationRunId, if (event.actionId != null) 'actionId': event.actionId, diff --git a/packages/tugboat/lib/src/interaction_transaction.dart b/packages/tugboat/lib/src/interaction_transaction.dart index 80cef09..5789bb3 100644 --- a/packages/tugboat/lib/src/interaction_transaction.dart +++ b/packages/tugboat/lib/src/interaction_transaction.dart @@ -190,16 +190,12 @@ class InteractionTransaction { InteractionGesture gesture = InteractionGesture.tap; bool claimed = false; bool cancelled = false; - bool tapEmitted = false; bool semanticPublished = false; bool sameTurnEligible = true; int? releasedAtMs; int? reconciliationDeadlineMs; - TugboatEvent? bufferedTap; - TugboatEvent? bufferedOutside; - final List scrollStartEventIds = []; InteractionAttribution attribution = InteractionAttribution.none; diff --git a/packages/tugboat/lib/src/models.dart b/packages/tugboat/lib/src/models.dart index d0416ba..90828f6 100644 --- a/packages/tugboat/lib/src/models.dart +++ b/packages/tugboat/lib/src/models.dart @@ -6,7 +6,7 @@ import 'package:flutter/widgets.dart'; import 'anchors.dart'; import 'collector_config.dart'; -/// Current session JSON schema. Writers emit this; readers accept 6–10. +/// Current session JSON schema. /// /// Schema 10 removes serialized state identity, removes `state_change`, and /// adds the `interaction` frame trigger. @@ -21,62 +21,32 @@ enum TugboatEventStream { evidence, /// Capture health and support diagnostics. - diagnostic, - - /// Temporary dual-write of legacy `tap` / `tap_settled` / `swipe` peers. - legacyProjection; + diagnostic; String get wireName => switch (this) { TugboatEventStream.semantic => 'semantic', TugboatEventStream.evidence => 'evidence', TugboatEventStream.diagnostic => 'diagnostic', - TugboatEventStream.legacyProjection => 'legacy_projection', }; static TugboatEventStream parse(String? raw) { switch (raw) { + case 'semantic': + return TugboatEventStream.semantic; case 'evidence': return TugboatEventStream.evidence; case 'diagnostic': return TugboatEventStream.diagnostic; - case 'legacy_projection': - return TugboatEventStream.legacyProjection; - case 'semantic': - case null: default: - return TugboatEventStream.semantic; + throw FormatException('Unsupported Tugboat event stream: $raw'); } } } -/// How finalized gestures are published to sinks. -enum TugboatInteractionPublishMode { - // TODO(tugboat-legacy-projection-removal): Remove legacyOnly and dualWrite - // after supported collectors, Context Graph, dashboards, and retained replay - // fixtures no longer consume legacy gesture rows. See the SDK README's - // "Legacy gesture projection deprecation" section. - - /// Deprecated compatibility mode. - /// - /// Emits only legacy `tap` / `tap_settled` / `swipe` records on the semantic - /// stream. Do not use for new recordings. - legacyOnly, - - /// Deprecated migration mode. - /// - /// Emits the canonical `interaction` plus legacy peers on - /// [TugboatEventStream.legacyProjection]. Do not use for new recordings. - dualWrite, - - /// Canonical `interaction` only. This is the default for new recordings. - canonicalOnly, -} - -/// Wire-compatible string aliases for tests and docs. +/// Wire strings for event streams. const String tugboatEventStreamSemantic = 'semantic'; const String tugboatEventStreamEvidence = 'evidence'; const String tugboatEventStreamDiagnostic = 'diagnostic'; -const String tugboatEventStreamLegacyProjection = 'legacy_projection'; const int tugboatInteractionSchemaVersion = 2; const int tugboatRouteChangeSchemaVersion = 2; @@ -86,13 +56,8 @@ bool tugboatEventIsEnrichmentCandidate(TugboatEvent event) { switch (event.stream) { case TugboatEventStream.diagnostic: case TugboatEventStream.evidence: - case TugboatEventStream.legacyProjection: - return false; case TugboatEventStream.semantic: - if (event.type == 'interaction') return true; - return event.type == 'tap' || - event.type == 'tap_settled' || - event.type == 'swipe'; + return event.type == 'interaction'; } } @@ -207,7 +172,6 @@ class TugboatEvent { required this.atMs, required this.type, this.stream = TugboatEventStream.semantic, - this.sessionId, this.captureSessionId, this.activationRequestId, this.targetAnchor, @@ -225,8 +189,6 @@ class TugboatEvent { final String type; final TugboatEventStream stream; - /// Legacy alias for [captureSessionId]. - final String? sessionId; final String? captureSessionId; final String? activationRequestId; final TugboatTargetAnchor? targetAnchor; @@ -238,8 +200,6 @@ class TugboatEvent { final String? explorationRunId; final String? actionId; - String? get effectiveCaptureSessionId => captureSessionId ?? sessionId; - bool get isSemanticStream => stream == TugboatEventStream.semantic; bool get isEnrichmentCandidate => tugboatEventIsEnrichmentCandidate(this); @@ -249,7 +209,6 @@ class TugboatEvent { 'atMs': atMs, 'type': type, 'stream': stream.wireName, - if (sessionId != null) 'sessionId': sessionId, if (captureSessionId != null) 'captureSessionId': captureSessionId, if (activationRequestId != null) 'activationRequestId': activationRequestId, if (targetAnchor != null) 'targetAnchor': targetAnchor!.toJson(), @@ -267,7 +226,6 @@ class TugboatEvent { int? atMs, String? type, TugboatEventStream? stream, - String? sessionId, String? captureSessionId, String? activationRequestId, TugboatTargetAnchor? targetAnchor, @@ -283,7 +241,6 @@ class TugboatEvent { atMs: atMs ?? this.atMs, type: type ?? this.type, stream: stream ?? this.stream, - sessionId: sessionId ?? this.sessionId, captureSessionId: captureSessionId ?? this.captureSessionId, activationRequestId: activationRequestId ?? this.activationRequestId, targetAnchor: targetAnchor ?? this.targetAnchor, @@ -300,13 +257,11 @@ class TugboatEvent { copyWith(data: {...data, ...updates}); TugboatEvent withExplorationContext({ - String? sessionId, String? captureSessionId, String? activationRequestId, String? explorationRunId, String? actionId, }) => copyWith( - sessionId: sessionId ?? this.sessionId, captureSessionId: captureSessionId ?? this.captureSessionId, activationRequestId: activationRequestId ?? this.activationRequestId, explorationRunId: explorationRunId ?? this.explorationRunId, diff --git a/packages/tugboat/lib/src/outbox/outbox_sink.dart b/packages/tugboat/lib/src/outbox/outbox_sink.dart index 7ba53cc..7a29fbb 100644 --- a/packages/tugboat/lib/src/outbox/outbox_sink.dart +++ b/packages/tugboat/lib/src/outbox/outbox_sink.dart @@ -120,7 +120,6 @@ class OutboxBackedCaptureSink implements TugboatCaptureSink { id: entry.payloadJson['id'] as String? ?? entry.idempotencyKey, atMs: entry.payloadJson['atMs'] as int? ?? 0, type: entry.payloadJson['type'] as String? ?? 'unknown', - sessionId: entry.captureSessionId, captureSessionId: entry.captureSessionId, activationRequestId: entry.activationRequestId, data: Map.from( diff --git a/packages/tugboat/lib/src/replay_config.dart b/packages/tugboat/lib/src/replay_config.dart index 0919f76..5d554a2 100644 --- a/packages/tugboat/lib/src/replay_config.dart +++ b/packages/tugboat/lib/src/replay_config.dart @@ -1,7 +1,6 @@ import 'capture_profile.dart'; import 'collector_config.dart'; import 'interaction_transaction.dart' show tugboatDefaultReconciliationWindow; -import 'models.dart'; import 'outbox/outbox.dart'; import 'screenshot_mask_level.dart'; import 'sinks/capture_sink.dart' show TugboatCaptureSinkFactory; @@ -86,7 +85,6 @@ class TugboatReplayConfig { this.profile = TugboatCaptureProfile.dormant, this.settleDelay = const Duration(seconds: 1), this.interactionClaimWindow = tugboatDefaultReconciliationWindow, - this.interactionPublishMode = TugboatInteractionPublishMode.canonicalOnly, this.maxFrames = 500, this.maxEvents = 5000, this.scrollCaptureInterval = const Duration(seconds: 2), @@ -117,25 +115,6 @@ class TugboatReplayConfig { /// [Duration.zero] for microtask-only same-turn claims. final Duration interactionClaimWindow; - /// Canonical vs legacy gesture publication policy. - /// - /// New recordings default to [TugboatInteractionPublishMode.canonicalOnly] - /// so each finalized gesture produces one semantic `interaction` event. - /// The legacy modes are temporary read/migration compatibility options and - /// must not be enabled by new integrations. - final TugboatInteractionPublishMode interactionPublishMode; - - bool get emitCanonicalInteractions => - interactionPublishMode != TugboatInteractionPublishMode.legacyOnly; - - bool get emitLegacyInteractionProjection => - interactionPublishMode != TugboatInteractionPublishMode.canonicalOnly; - - TugboatEventStream get legacyGestureStream => - interactionPublishMode == TugboatInteractionPublishMode.dualWrite - ? TugboatEventStream.legacyProjection - : TugboatEventStream.semantic; - final int maxFrames; final int maxEvents; final Duration scrollCaptureInterval; @@ -175,7 +154,6 @@ class TugboatReplayConfig { TugboatCaptureProfile? profile, Duration? settleDelay, Duration? interactionClaimWindow, - TugboatInteractionPublishMode? interactionPublishMode, int? maxFrames, int? maxEvents, Duration? scrollCaptureInterval, @@ -201,8 +179,6 @@ class TugboatReplayConfig { settleDelay: settleDelay ?? this.settleDelay, interactionClaimWindow: interactionClaimWindow ?? this.interactionClaimWindow, - interactionPublishMode: - interactionPublishMode ?? this.interactionPublishMode, maxFrames: maxFrames ?? this.maxFrames, maxEvents: maxEvents ?? this.maxEvents, scrollCaptureInterval: diff --git a/packages/tugboat/lib/src/tugboat.dart b/packages/tugboat/lib/src/tugboat.dart index cc3c188..a2087f5 100644 --- a/packages/tugboat/lib/src/tugboat.dart +++ b/packages/tugboat/lib/src/tugboat.dart @@ -70,8 +70,6 @@ class TugboatReplay { static bool get isActivated => _lifecycle.isActivated; static String? get activationRequestId => _lifecycle.activationRequestId; - /// Deprecated alias for [activationRequestId]. - static String? get activeSessionId => _lifecycle.activationRequestId; static TugboatCaptureProfile? get activeProfile => _lifecycle.activeProfile; /// When `true`, the SDK is fully inert (no capture, no wrapping overhead). @@ -168,19 +166,14 @@ class TugboatReplay { /// Enables capture machinery for dormant builds at runtime. /// - /// Prefer [activationRequestId]; [sessionId] is retained for compatibility. static void activate({ - String? activationRequestId, - @Deprecated('Use activationRequestId') String? sessionId, + required String activationRequestId, TugboatCaptureProfile profile = TugboatCaptureProfile.productionLean, }) { - final requestId = activationRequestId ?? sessionId; - if (requestId == null) { - throw ArgumentError( - 'activate requires activationRequestId (or legacy sessionId)', - ); - } - _lifecycle.activate(activationRequestId: requestId, profile: profile); + _lifecycle.activate( + activationRequestId: activationRequestId, + profile: profile, + ); } /// Returns the SDK to dormant mode without tearing down the host app. diff --git a/packages/tugboat/test/capture_sink_test.dart b/packages/tugboat/test/capture_sink_test.dart index 2c91b1f..194bdae 100644 --- a/packages/tugboat/test/capture_sink_test.dart +++ b/packages/tugboat/test/capture_sink_test.dart @@ -52,7 +52,11 @@ void main() { platform: 'ios', viewport: const TugboatRect(0, 0, 390, 844), ); - final event = TugboatEvent(id: 'event-1', atMs: 1, type: 'tap'); + final event = TugboatEvent( + id: 'event-1', + atMs: 1, + type: 'capture_diagnostic', + ); final frame = const TugboatFrame( id: 'frame-0', atMs: 0, diff --git a/packages/tugboat/test/collector_http_sink_test.dart b/packages/tugboat/test/collector_http_sink_test.dart index 48c4fac..3b6ca90 100644 --- a/packages/tugboat/test/collector_http_sink_test.dart +++ b/packages/tugboat/test/collector_http_sink_test.dart @@ -192,7 +192,7 @@ void main() { return TugboatEvent( id: 'event-$index', atMs: index, - type: 'tap', + type: 'capture_diagnostic', data: {'index': index}, ); } @@ -263,7 +263,7 @@ void main() { expect(batchPosts, hasLength(1)); expect(batchPosts.first, hasLength(10)); expect(batchPosts.first.first['sessionId'], 'sess_server'); - expect(batchPosts.first.first['eventType'], 'tap'); + expect(batchPosts.first.first['eventType'], 'capture_diagnostic'); expect(batchPosts.first.first['build'], isA()); expect( (batchPosts.first.first['build'] as Map)['appId'], @@ -525,7 +525,7 @@ void main() { expect(batchPosts, hasLength(1)); expect(batchPosts.first, hasLength(1)); - expect(batchPosts.first.first['eventType'], 'tap'); + expect(batchPosts.first.first['eventType'], 'capture_diagnostic'); sink.dispose(); }); diff --git a/packages/tugboat/test/collector_mapper_test.dart b/packages/tugboat/test/collector_mapper_test.dart index de95422..cb8fe40 100644 --- a/packages/tugboat/test/collector_mapper_test.dart +++ b/packages/tugboat/test/collector_mapper_test.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tugboat/src/anchors.dart'; import 'package:tugboat/src/collector_config.dart'; import 'package:tugboat/src/collector_mapper.dart'; import 'package:tugboat/src/models.dart'; @@ -195,77 +194,12 @@ void main() { expect((mapped['payload'] as Map)['reason'], 'lifecycle'); }); - test('maps tugboat events into collector event schema', () { + test('marks evidence as a non-enrichment candidate', () { final sessionStartedAt = DateTime.utc(2026, 6, 19); - final event = TugboatEvent( - id: 'event-5', - atMs: 28906, - type: 'tap', - beforeFrame: 'frame-3', - targetAnchor: const TugboatTargetAnchor( - widgetType: 'GestureDetector', - role: 'button', - fingerprint: '9eadb7c56ae836bc', - fingerprintConfidence: 'low', - canonicalPath: 'IntroScreen#0/PillButton#0', - relativePosition: 'bottom', - ), - data: const {'x': 100, 'y': 200}, - actionId: 'A-1', - explorationRunId: 'run-1', - ); - - final mapped = mapTugboatEventToCollectorEvent( - event: event, - sessionId: 'sess_123', - sessionStartedAt: sessionStartedAt, - userId: 'user_1', - collectorConfig: collectorConfig, - ); - - expect(mapped['id'], 'event-5'); - expect(mapped['atMs'], 28906); - expect(mapped['triggeredAt'], '2026-06-19T00:00:28.906Z'); - expect(mapped['sessionId'], 'sess_123'); - expect(mapped['userId'], 'user_1'); - expect(mapped['eventType'], 'tap'); - expect(mapped['stream'], tugboatEventStreamSemantic); - // Compat path: semantic tap without canonical dual-write remains eligible. - expect(mapped['enrichmentCandidate'], isTrue); - expect(mapped['beforeFrame'], 'frame-3'); - expect(mapped.containsKey('stateAnchor'), isFalse); - expect((mapped['targetAnchor'] as Map)['fingerprint'], '9eadb7c56ae836bc'); - expect(mapped['actionId'], 'A-1'); - expect(mapped['explorationRunId'], 'run-1'); - expect((mapped['payload'] as Map)['x'], 100); - expect((mapped['payload'] as Map)['actionId'], 'A-1'); - expect((mapped['payload'] as Map)['explorationRunId'], 'run-1'); - expect((mapped['payload'] as Map).containsKey('stream'), isFalse); - expect(mapped['build'], { - 'appId': 'com.example.app', - 'platform': 'ios', - 'versionName': '1.0.0', - 'buildNumber': '1', - 'fingerprintSchemaVersion': tugboatFingerprintSchemaVersion, - }); - }); - - test('marks legacy projection and evidence as non-enrichment candidates', () { - final sessionStartedAt = DateTime.utc(2026, 6, 19); - final legacy = mapTugboatEventToCollectorEvent( - event: const TugboatEvent( - id: 'event-legacy', - atMs: 1, - type: 'tap_settled', - stream: TugboatEventStream.legacyProjection, - ), - sessionStartedAt: sessionStartedAt, - collectorConfig: collectorConfig, - ); final evidence = mapTugboatEventToCollectorEvent( event: const TugboatEvent( id: 'event-route', - atMs: 2, + atMs: 1, type: 'route_change', stream: TugboatEventStream.evidence, ), @@ -275,7 +209,7 @@ void main() { final interaction = mapTugboatEventToCollectorEvent( event: const TugboatEvent( id: 'event-interaction', - atMs: 3, + atMs: 2, type: 'interaction', stream: TugboatEventStream.semantic, ), @@ -283,14 +217,13 @@ void main() { collectorConfig: collectorConfig, ); - expect(legacy['enrichmentCandidate'], isFalse); expect(evidence['enrichmentCandidate'], isFalse); expect(interaction['enrichmentCandidate'], isTrue); }); test('omits sessionId when not provided so the sink can stamp at send', () { final mapped = mapTugboatEventToCollectorEvent( - event: TugboatEvent(id: 'event-1', atMs: 0, type: 'tap'), + event: TugboatEvent(id: 'event-1', atMs: 0, type: 'capture_diagnostic'), sessionStartedAt: DateTime.utc(2026, 6, 19), collectorConfig: collectorConfig, ); @@ -397,7 +330,7 @@ void main() { test('event map includes optional traitsId', () { final mapped = mapTugboatEventToCollectorEvent( - event: TugboatEvent(id: 'event-1', atMs: 0, type: 'tap'), + event: TugboatEvent(id: 'event-1', atMs: 0, type: 'capture_diagnostic'), sessionStartedAt: DateTime.utc(2026, 6, 19), collectorConfig: collectorConfig, traitsId: 'trt_evt', @@ -429,21 +362,6 @@ void main() { ); }); - test('keeps deprecated packageName legacy constructor compatibility', () { - // ignore: deprecated_member_use_from_same_package - const appInfo = TugboatCollectorAppInfo.legacyPackageName( - name: 'Example App', - version: '1.0.0', - buildNumber: '1', - installationId: 'inst_1', - packageName: 'com.example.legacy', - ); - - expect(appInfo.appId, 'com.example.legacy'); - expect(appInfo.toJson()['appId'], 'com.example.legacy'); - expect(appInfo.toJson()['packageName'], 'com.example.legacy'); - }); - test('copies collector config with replay userId override', () { final copied = collectorConfig.withUserId('user_from_replay'); diff --git a/packages/tugboat/test/coordinate_space_test.dart b/packages/tugboat/test/coordinate_space_test.dart index 43a8013..6dde063 100644 --- a/packages/tugboat/test/coordinate_space_test.dart +++ b/packages/tugboat/test/coordinate_space_test.dart @@ -31,20 +31,6 @@ void main() { 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, @@ -163,37 +149,4 @@ void main() { }); 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/exploration_transport_test.dart b/packages/tugboat/test/exploration_transport_test.dart index 3e553f7..c5dedac 100644 --- a/packages/tugboat/test/exploration_transport_test.dart +++ b/packages/tugboat/test/exploration_transport_test.dart @@ -64,14 +64,16 @@ void main() { socket.listen((raw) => messages.add(raw as String)); }); - transport.sendEvent(TugboatEvent(id: 'event-1', atMs: 1, type: 'tap')); + transport.sendEvent( + TugboatEvent(id: 'event-1', atMs: 1, type: 'capture_diagnostic'), + ); await transport.connect(); await Future.delayed(const Duration(milliseconds: 50)); expect(messages, isNotEmpty); final decoded = jsonDecode(messages.first) as Map; expect(decoded['type'], 'event'); - expect((decoded['payload'] as Map)['type'], 'tap'); + expect((decoded['payload'] as Map)['type'], 'capture_diagnostic'); transport.dispose(); }); diff --git a/packages/tugboat/test/helpers/json_roundtrip.dart b/packages/tugboat/test/helpers/json_roundtrip.dart index 73d047f..49e0861 100644 --- a/packages/tugboat/test/helpers/json_roundtrip.dart +++ b/packages/tugboat/test/helpers/json_roundtrip.dart @@ -67,7 +67,6 @@ extension TugboatEventTestJson on TugboatEvent { atMs: json['atMs'] as int, type: json['type'] as String, stream: TugboatEventStream.parse(json['stream'] as String?), - sessionId: json['sessionId'] as String?, captureSessionId: json['captureSessionId'] as String?, activationRequestId: json['activationRequestId'] as String?, targetAnchor: json['targetAnchor'] == null @@ -90,11 +89,7 @@ extension TugboatEventTestJson on TugboatEvent { extension TugboatSessionTestJson on TugboatSession { static TugboatSession fromJson(Map json) { final version = json['schemaVersion'] as int?; - if (version != 6 && - version != 7 && - version != 8 && - version != 9 && - version != 10) { + if (version != 10) { throw const FormatException( 'Unsupported Tugboat session schema version.', ); @@ -105,7 +100,7 @@ extension TugboatSessionTestJson on TugboatSession { ); final appInfoJson = sessionJson['appInfo']; final session = TugboatSession( - id: (sessionJson['captureSessionId'] ?? sessionJson['id']) as String, + id: sessionJson['captureSessionId'] as String, startedAt: DateTime.parse(sessionJson['startedAt'] as String), platform: sessionJson['platform'] as String, viewport: TugboatRect( @@ -121,9 +116,7 @@ extension TugboatSessionTestJson on TugboatSession { version: appInfoJson['version'] as String, buildNumber: appInfoJson['buildNumber'] as String, installationId: appInfoJson['installationId'] as String, - appId: - (appInfoJson['appId'] ?? appInfoJson['packageName']) - as String, + appId: appInfoJson['appId'] as String, ), activationRequestId: sessionJson['activationRequestId'] as String?, explorationRunId: sessionJson['explorationRunId'] as String?, diff --git a/packages/tugboat/test/helpers/replay_coherence_harness.dart b/packages/tugboat/test/helpers/replay_coherence_harness.dart index 4ac7502..d54f9f5 100644 --- a/packages/tugboat/test/helpers/replay_coherence_harness.dart +++ b/packages/tugboat/test/helpers/replay_coherence_harness.dart @@ -216,7 +216,6 @@ class ReplayCoherenceHarness { this.interactionClaimWindow = Duration.zero, // Keep deprecated projection behavior covered here even though production // recordings now default to canonical-only publication. - this.interactionPublishMode = TugboatInteractionPublishMode.dualWrite, this.maxFrames = 300, this.screenshotBudget = TugboatScreenshotBudgetConfig.defaults, GlobalKey? boundaryKey, @@ -224,7 +223,6 @@ class ReplayCoherenceHarness { final Duration settleDelay; final Duration interactionClaimWindow; - final TugboatInteractionPublishMode interactionPublishMode; final int maxFrames; final TugboatScreenshotBudgetConfig screenshotBudget; final GlobalKey boundaryKey; @@ -270,7 +268,6 @@ class ReplayCoherenceHarness { profile: TugboatCaptureProfile.exploration, settleDelay: settleDelay, interactionClaimWindow: interactionClaimWindow, - interactionPublishMode: interactionPublishMode, maxFrames: maxFrames, enableGlobalPointerCapture: false, capturePixelRatio: 1.0, @@ -512,28 +509,15 @@ class CoherenceInvariants { }); } - /// Checks the stable one-to-one relation between a tap and its settle event. - static bool tapSettleIsLinked({ + static bool interactionIsLinked({ required List events, required TugboatEvent tap, required TugboatEvent settle, - }) { - if (tap.type != 'tap' || settle.type != 'tap_settled') return false; - if (settle.relatedEventId != tap.id) return false; - if (!hasChronologicalChain( - events: events, - orderedEventIds: [tap.id, settle.id], - )) { - return false; - } - return events - .where( - (event) => - event.type == 'tap_settled' && event.relatedEventId == tap.id, - ) - .length == - 1; - } + }) => + tap.type == 'interaction' && + settle.type == 'interaction' && + tap.id == settle.id && + events.where((event) => event.id == tap.id).length == 1; /// Rejects an action which substitutes an origin or unrelated frame for the /// destination route epoch. @@ -550,20 +534,6 @@ class CoherenceInvariants { frameProvenanceFor: frameProvenanceFor, ); - /// A missing after-frame is valid only when the event says why, without - /// leaking an implementation error or pretending it captured evidence. - static bool hasExplicitDegradation(TugboatEvent event) { - if (event.afterFrame != null) return false; - final outcome = event.data['captureOutcome']; - if (outcome is! String || outcome.isEmpty || outcome == 'captured') { - return false; - } - if (event.type == 'tap_settled' && event.result != null) { - return false; - } - return true; - } - /// Ensures every controller-owned capture path has drained. /// /// [ControllableCaptureExecutor] is also included because a blocked test @@ -576,12 +546,7 @@ class CoherenceInvariants { !harness.scheduler.hasPendingDelays && harness.capturer.blockedCount == 0; - /// Tap settle evidence belongs to one route epoch. - /// - /// Proves [tap.beforeFrame], [settle.beforeFrame], and [settle.afterFrame] - /// all belong to [expectedRoute] + [expectedRouteEpoch], while allowing - /// distinct capture ids/content hashes within that provenance. - static bool tapSettleIsRouteCoherent({ + static bool interactionIsRouteCoherent({ required TugboatEvent tap, required TugboatEvent settle, required String expectedRoute, @@ -589,32 +554,14 @@ class CoherenceInvariants { required HarnessFrameProvenance? Function(String? frameId) frameProvenanceFor, String? expectedRouteSignature, - }) { - if (tap.type != 'tap' || settle.type != 'tap_settled') return false; - if (settle.relatedEventId != tap.id) return false; - if (settle.beforeFrame != tap.beforeFrame) return false; - if (settle.afterFrame == null) return false; - - final frameIds = [ - if (tap.beforeFrame != null) tap.beforeFrame!, - if (settle.beforeFrame != null) settle.beforeFrame!, - settle.afterFrame!, - ]; - if (frameIds.length < 3) return false; - - return eventFramesMatchRoute( - event: tap, - expectedRoute: expectedRoute, - expectedRouteEpoch: expectedRouteEpoch, - frameProvenanceFor: frameProvenanceFor, - ) && - eventFramesMatchRoute( - event: settle, - expectedRoute: expectedRoute, - expectedRouteEpoch: expectedRouteEpoch, - frameProvenanceFor: frameProvenanceFor, - ); - } + }) => + tap.id == settle.id && + eventFramesMatchRoute( + event: tap, + expectedRoute: expectedRoute, + expectedRouteEpoch: expectedRouteEpoch, + frameProvenanceFor: frameProvenanceFor, + ); /// Destination-route actions must carry destination-frame provenance. /// @@ -659,53 +606,26 @@ class CoherenceInvariants { return true; } - /// Navigation-producing taps must not emit an unrelated noVisibleChange. - /// - /// Harness causality contract: when expectations are provided, the exact - /// destination route event must exist, match [expectedDestinationRoute], and - /// occur no earlier than the tap. Missing destination/id returns false. - static bool navigationTapHasNoEarlyNoVisibleChange({ + static bool navigationInteractionHasRouteEvidence({ required List events, required String tapEventId, String? expectedDestinationRoute, String? expectedRouteEventId, }) { - final tap = events.cast().firstWhere( - (event) => event?.id == tapEventId, - orElse: () => null, - ); - if (tap == null) return false; - - final settle = events.cast().firstWhere( - (event) => - event?.type == 'tap_settled' && event?.relatedEventId == tapEventId, - orElse: () => null, - ); - if (settle == null) return false; - if (settle.relatedEventId != tapEventId) return false; - if (expectedDestinationRoute == null || expectedRouteEventId == null) { return false; } - - final routeEvent = events.cast().firstWhere( + final interaction = events.cast().firstWhere( + (event) => event?.id == tapEventId && event?.type == 'interaction', + orElse: () => null, + ); + final route = events.cast().firstWhere( (event) => event?.id == expectedRouteEventId, orElse: () => null, ); - if (routeEvent == null) return false; - if (routeEvent.type != 'route_change') return false; - if (routeEvent.data['route'] != expectedDestinationRoute) return false; - if (routeEvent.atMs < tap.atMs) return false; - - final observation = settle.data['settleObservation']; - if (observation is Map) { - final outcome = observation['navigationOutcome']; - if (outcome == 'navigated') return true; - } - if (settle.result != null && - settle.result != TugboatInteractionResult.noVisibleChange) { - return true; - } - return settle.afterFrame != null; + return interaction != null && + route?.type == 'route_change' && + route?.data['route'] == expectedDestinationRoute && + route?.data['causeEventId'] == interaction.id; } } diff --git a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart index e64ceca..8a5ffc6 100644 --- a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart +++ b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/tugboat.dart'; -import '../helpers/json_roundtrip.dart'; - Future _waitForCaptures(WidgetTester tester) async { await tester.pump(); await tester.runAsync(() async { @@ -116,45 +114,6 @@ void main() { expect(routes, isNotEmpty); }); - test('v6-v9 session JSON remains readable alongside v10 writers', () { - final session = TugboatSession( - id: 'legacy-session', - startedAt: DateTime.utc(2026, 8, 3), - platform: 'test', - viewport: const TugboatRect(0, 0, 100, 200), - ); - final writerJson = session.toJson(); - expect(writerJson['schemaVersion'], 10); - - for (final version in [6, 7, 8, 9]) { - final legacyJson = Map.from(writerJson) - ..['schemaVersion'] = version - ..['events'] = [ - { - 'id': 'legacy-event-$version', - 'atMs': 0, - 'type': 'tap', - 'data': { - 'controlValue': {'kind': 'number', 'value': 0.5}, - 'controlValueTransition': { - 'before': {'kind': 'number', 'value': 0.4}, - 'after': {'kind': 'number', 'value': 0.5}, - }, - 'semanticAnnotation': { - 'label': {'kind': 'string', 'value': 'Legacy label'}, - }, - }, - }, - ]; - - final restored = TugboatSessionTestJson.fromJson(legacyJson); - expect(restored.id, 'legacy-session'); - expect(restored.events.single.data, contains('controlValue')); - expect(restored.events.single.data, contains('controlValueTransition')); - expect(restored.events.single.data, contains('semanticAnnotation')); - } - }); - test( 'platform views are classified as unsupported for structural capture', () { diff --git a/packages/tugboat/test/replay/deferred_tap_emission_test.dart b/packages/tugboat/test/replay/deferred_tap_emission_test.dart deleted file mode 100644 index f519c9b..0000000 --- a/packages/tugboat/test/replay/deferred_tap_emission_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:tugboat/tugboat.dart'; - -import '../helpers/replay_coherence_harness.dart'; - -void main() { - test('pointer-down buffers tap until pointer-up', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(20, 30)); - expect(harness.controller.session!.ofType('tap'), isEmpty); - - harness.controller.recordPointerUp(const Offset(20, 30)); - final tap = harness.controller.session!.ofType('tap').single; - expect(tap.data['x'], 20.0); - expect(tap.data['y'], 30.0); - expect(tap.data['captureCoordinate'], isA()); - }); - - test( - 'swipe emits without phantom tap and carries startCaptureCoordinate', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(10, 100)); - harness.controller.markPendingTapAsSwipe(0); - harness.controller.recordPointerUp(const Offset(10, 40)); - - expect(harness.controller.session!.ofType('tap'), isEmpty); - final swipe = harness.controller.session!.ofType('swipe').single; - expect(swipe.relatedEventId, isNull); - expect(swipe.data['startCaptureCoordinate'], isA()); - final start = Map.from( - swipe.data['startCaptureCoordinate']! as Map, - ); - expect( - start.containsKey('unavailableReason') || start['sourceSpace'] != null, - isTrue, - ); - }, - ); - - test('cancel drops buffered tap', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(4, 4)); - harness.controller.recordPointerCancel(const Offset(4, 4)); - - expect(harness.controller.session!.ofType('tap'), isEmpty); - expect( - harness.controller.session!.events.where( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic && - event.data['gesture'] == 'cancelled', - ), - hasLength(1), - ); - }); - - test( - 'claimed-then-swiped tap still emits so causeEventId resolves', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(8, 8)); - await harness.controller.route('route_push', harness.route('/claimed')); - await harness.pumpMicrotasks(); - - final tap = harness.controller.session!.ofType('tap').single; - harness.controller.markPendingTapAsSwipe(0); - harness.controller.recordPointerUp(const Offset(8, 80)); - - expect(harness.controller.session!.ofType('tap'), hasLength(1)); - final swipe = harness.controller.session!.ofType('swipe').single; - expect(swipe.relatedEventId, tap.id); - expect(swipe.data['startCaptureCoordinate'], isA()); - }, - ); -} diff --git a/packages/tugboat/test/replay/interaction_transaction_test.dart b/packages/tugboat/test/replay/interaction_transaction_test.dart index 9cce921..5a4e784 100644 --- a/packages/tugboat/test/replay/interaction_transaction_test.dart +++ b/packages/tugboat/test/replay/interaction_transaction_test.dart @@ -69,62 +69,8 @@ extension on TugboatSession { void main() { group('Interaction publication defaults', () { - test('new recordings emit canonical interactions only', () { - const config = TugboatReplayConfig(); - - expect( - config.interactionPublishMode, - TugboatInteractionPublishMode.canonicalOnly, - ); - expect(config.emitCanonicalInteractions, isTrue); - expect(config.emitLegacyInteractionProjection, isFalse); - }); - - test( - 'default controller recordings emit canonical interactions without legacy rows', - () async { - final harness = ReplayCoherenceHarness( - interactionPublishMode: - const TugboatReplayConfig().interactionPublishMode, - ); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(8, 8)); - harness.controller.recordPointerUp(const Offset(8, 8)); - await harness.flushScheduler(); - - final events = harness.controller.session!.events; - expect( - events.where((event) => event.type == 'interaction'), - isNotEmpty, - ); - expect( - events.where( - (event) => - event.type == 'tap' || - event.type == 'tap_settled' || - event.type == 'swipe', - ), - isEmpty, - ); - }, - ); - - test('legacy dual-write remains an explicit compatibility override', () { - const config = TugboatReplayConfig( - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, - ); - - expect(config.emitCanonicalInteractions, isTrue); - expect(config.emitLegacyInteractionProjection, isTrue); - expect(config.legacyGestureStream, TugboatEventStream.legacyProjection); - }); - - test('legacy-only recordings omit canonical interactions', () async { - final harness = ReplayCoherenceHarness( - interactionPublishMode: TugboatInteractionPublishMode.legacyOnly, - ); + test('controller recordings emit canonical interactions', () async { + final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); @@ -133,12 +79,7 @@ void main() { await harness.flushScheduler(); final events = harness.controller.session!.events; - expect(events.where((event) => event.type == 'interaction'), isEmpty); - expect(events.where((event) => event.type == 'tap'), hasLength(1)); - expect( - events.where((event) => event.type == 'tap_settled'), - hasLength(1), - ); + expect(events.where((event) => event.type == 'interaction'), isNotEmpty); }); }); @@ -269,28 +210,6 @@ void main() { expect(cancelled.last.data['gesture'], 'cancelled'); }, ); - - test( - 'canonical-only mode does not emit legacy promotion evidence', - () async { - final harness = ReplayCoherenceHarness( - interactionPublishMode: TugboatInteractionPublishMode.canonicalOnly, - ); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 34)); - await harness.controller.route('route_push', harness.route('/dest')); - await harness.flushScheduler(); - harness.controller.recordPointerUp(const Offset(12, 34)); - await harness.flushScheduler(); - - expect( - harness.controller.session!.ofType('tap_gesture_resolved'), - isEmpty, - ); - }, - ); }); group('Delayed reconciliation (U2)', () { @@ -387,8 +306,6 @@ void main() { harness.controller.recordPointerUp(const Offset(10, 40)); await harness.flushScheduler(); - expect(harness.controller.session!.ofType('tap'), isEmpty); - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); final interactions = harness.controller.session!.semanticOfType( 'interaction', ); @@ -439,35 +356,6 @@ void main() { expect(data['gesture'], 'tap'); }); - test('legacy peers are dual-written on legacy_projection stream', () 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.flushScheduler(); - - final taps = harness.controller.session!.ofType('tap'); - final settles = harness.controller.session!.ofType('tap_settled'); - expect(taps, hasLength(1)); - expect(settles, hasLength(1)); - expect(taps.single.stream, TugboatEventStream.legacyProjection); - expect(settles.single.stream, TugboatEventStream.legacyProjection); - expect(taps.single.data['interactionId'], isNotNull); - expect( - settles.single.data['interactionId'], - taps.single.data['interactionId'], - ); - - final semantic = harness.controller.session!.ofStream( - TugboatEventStream.semantic, - ); - expect(semantic.where((e) => e.type == 'tap'), isEmpty); - expect(semantic.where((e) => e.type == 'tap_settled'), isEmpty); - expect(semantic.where((e) => e.type == 'interaction'), hasLength(1)); - }); - test('ten gestures publish ten canonical semantic interactions', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); @@ -545,13 +433,6 @@ void main() { .isEnrichmentCandidate, isTrue, ); - expect( - harness.controller.session! - .ofType('tap') - .single - .isEnrichmentCandidate, - isFalse, - ); }, ); }); diff --git a/packages/tugboat/test/replay/navigation_origin_contract_test.dart b/packages/tugboat/test/replay/navigation_origin_contract_test.dart index 3bab1d9..4c5a557 100644 --- a/packages/tugboat/test/replay/navigation_origin_contract_test.dart +++ b/packages/tugboat/test/replay/navigation_origin_contract_test.dart @@ -1,338 +1,41 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/widgets.dart'; 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)); - expect(harness.controller.session!.ofType('tap'), isEmpty); - - await harness.controller.route('route_push', harness.route('/dest')); - await harness.flushScheduler(); - - final tap = harness.controller.session!.ofType('tap').single; - 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); - expect(change.data['interactionAttribution'], 'same_turn'); - }); - - test( - 'same-turn claim attributes navigation during pointer-up turn', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 34)); - harness.controller.recordPointerUp(const Offset(12, 34)); - // Still in the pointer-up turn — sync onTap → Navigator can claim. - await harness.controller.route('route_push', harness.route('/async')); - await harness.flushScheduler(); - - final tap = harness.controller.session!.ofType('tap').single; - final change = harness.controller.session! - .ofType('route_change') - .lastWhere((e) => e.data['route'] == '/async'); - expect(change.data['navigationOrigin'], 'interaction'); - expect(change.data['causeEventId'], tap.id); - expect(change.data['interactionAttribution'], 'same_turn'); - }, - ); - - test('timer redirect after pointer-up turn 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)); - // Expire the released same-turn claim. - await harness.pumpMicrotasks(); - - 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('pre-up claimed route then swipe keeps tap causal_only', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 80)); - await harness.controller.route('route_push', harness.route('/claimed')); - await harness.flushScheduler(); - expect(harness.controller.session!.ofType('tap'), isNotEmpty); - - harness.controller.markPendingTapAsSwipe(0); - harness.controller.recordPointerUp(const Offset(40, 80)); - await harness.flushScheduler(); - - final tap = harness.controller.session!.ofType('tap').single; - expect(tap.data['replayRole'], 'causal_only'); - expect(tap.data['gestureFinal'], anyOf('swipe', 'unresolved')); - final swipe = harness.controller.session!.ofType('swipe').single; - expect(swipe.data['invalidatesRelatedTap'], isTrue); - }); - - test('pointer events after session_end are ignored', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 34)); - await harness.controller.endSession(); - final before = harness.controller.session!.events.length; - - harness.controller.recordPointerUp(const Offset(12, 34)); - harness.controller.recordPointerDown(const Offset(50, 50)); - harness.controller.recordPointerCancel(const Offset(50, 50)); - - expect(harness.controller.session!.events.length, before); - expect(harness.controller.session!.ofType('swipe'), isEmpty); - }); - - test( - 'session_end does not emit orphan causal_only for cancelled route claim', - () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 100), - ); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 34)); - // Claim during down; do not await — cancel via session_end before publish. - unawaited( - harness.controller.route('route_push', harness.route('/claimed')), - ); - await harness.pumpMicrotasks(); - await harness.controller.endSession(); - await harness.flushScheduler(); - - expect(harness.controller.session!.ofType('tap'), isEmpty); - expect( - harness.controller.session! - .ofType('route_change') - .where((e) => e.data['route'] == '/claimed'), - isEmpty, - ); - }, - ); - - test( - 'backgrounding drops pending claims so resume cannot attribute them', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 34)); - harness.controller.recordAppLifecycleState(AppLifecycleState.paused); - harness.controller.recordAppLifecycleState(AppLifecycleState.resumed); - - await harness.controller.route('route_push', harness.route('/after')); - await harness.flushScheduler(); - - final change = harness.controller.session! - .ofType('route_change') - .lastWhere((e) => e.data['route'] == '/after'); - expect(change.data['navigationOrigin'], 'automatic_or_unknown'); - expect(change.data['causeEventId'], isNull); - expect(harness.controller.session!.ofType('tap'), isEmpty); - }, - ); - - test('duplicate pointer-down abandons the prior pending claim', () async { + test('same-turn route has a canonical interaction cause', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - + harness.seedRouteState(route: '/home', signature: 'home'); harness.controller.recordPointerDown(const Offset(10, 10)); - final firstBuffered = harness.controller.session!.ofType( - 'tap', - ); // still deferred - expect(firstBuffered, isEmpty); - - harness.controller.recordPointerDown(const Offset(20, 20)); - harness.controller.recordPointerUp(const Offset(20, 20)); - await harness.flushScheduler(); - - final taps = harness.controller.session!.ofType('tap'); - expect(taps, hasLength(1)); - expect(taps.single.data['x'], 20); - expect(taps.single.data['y'], 20); - }); - - test('deferred tap publish uses emission-time atMs for chronology', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 34)); - harness.scheduler.advance(const Duration(milliseconds: 40)); - await harness.controller.route('route_push', harness.route('/dest')); - await harness.flushScheduler(); - - final events = harness.controller.session!.events; - for (var i = 1; i < events.length; i++) { - expect(events[i].atMs, greaterThanOrEqualTo(events[i - 1].atMs)); - } - final tap = harness.controller.session!.ofType('tap').single; - expect(tap.data['sampledAtMs'], isA()); - expect(tap.atMs, greaterThanOrEqualTo(tap.data['sampledAtMs'] as int)); - }); - - test('pointer-up promotes causal_only tap after pre-up claim', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(12, 34)); - await harness.controller.route('route_push', harness.route('/dest')); + final route = harness.controller.route( + 'route_push', + harness.route('/next'), + ); + harness.controller.recordPointerUp(const Offset(10, 10)); await harness.flushScheduler(); + await route; + final session = harness.controller.session!; expect( - harness.controller.session!.ofType('tap').single.data['replayRole'], - 'causal_only', + session.ofType('route_change').single.data['causeEventId'], + session.ofType('interaction').single.id, ); - - harness.controller.recordPointerUp(const Offset(12, 34)); - await harness.flushScheduler(); - - final tap = harness.controller.session!.ofType('tap').single; - expect(tap.data['replayRole'], 'interaction'); - expect(tap.data['promotedFrom'], 'causal_only'); - final resolved = harness.controller.session! - .ofType('tap_gesture_resolved') - .single; - expect(resolved.relatedEventId, tap.id); - expect(resolved.data['promotesRelatedTap'], isTrue); - }); - - 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)); - expect(harness.controller.session!.ofType('tap'), isEmpty); - 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 { + test('automatic route has no interaction cause', () 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')); + final route = harness.controller.route( + 'route_push', + harness.route('/next'), + ); 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)); - - await harness.controller.route('route_push', harness.route('/first')); - await harness.pumpMicrotasks(); - final tap = harness.controller.session!.ofType('tap').single; - - // 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); + await route; + expect( + harness.controller.session!.ofType('route_change').single.data, + isNot(contains('causeEventId')), + ); }); } diff --git a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart index b118d7f..6986bdb 100644 --- a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart @@ -1,384 +1,25 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tugboat/tugboat.dart'; -/// Integration coverage for the core Navigator stack operations in #12. -/// -/// The widget tree, Navigator observer, pointer listener, and replay root are -/// all real. Capture readback alone is deterministic so the assertions cover -/// causal replay evidence without sleeping for platform image encoding. -void main() { - setUp(TugboatReplay.resetForTest); - tearDown(TugboatReplay.resetForTest); - - testWidgets('tap to named push keeps destination evidence coherent', ( - tester, - ) async { - final fixture = await _NavigationFixture.mount(tester); - final baseline = fixture.session.events.length; - - await tester.tap(find.byKey(_rootPushKey)); - await tester.pumpAndSettle(); - final routeChange = await fixture.waitForRouteChange( - tester, - navigation: 'route_push', - destination: '/named', - after: baseline, - ); - - fixture.assertNavigationTapCoherence( - routeChange: routeChange, - expectedRoute: '/named', - expectedNavigation: 'route_push', - eventsAfter: baseline, - ); - }); - - testWidgets('tap to replacement does not retain the replaced route frame', ( - tester, - ) async { - final fixture = await _NavigationFixture.mount(tester); - await fixture.openNamed(tester); - final baseline = fixture.session.events.length; - - await tester.tap(find.byKey(_namedReplaceKey)); - await tester.pumpAndSettle(); - final routeChange = await fixture.waitForRouteChange( - tester, - navigation: 'route_replace', - destination: '/replacement', - after: baseline, - ); - - fixture.assertNavigationTapCoherence( - routeChange: routeChange, - expectedRoute: '/replacement', - expectedNavigation: 'route_replace', - eventsAfter: baseline, - ); - expect(routeChange.data['fromRoute'], '/named'); - }); - - testWidgets('tap to pop links to the revealed root route', (tester) async { - final fixture = await _NavigationFixture.mount(tester); - await fixture.openNamed(tester); - final baseline = fixture.session.events.length; - - await tester.tap(find.byKey(_namedPopKey)); - await tester.pumpAndSettle(); - final routeChange = await fixture.waitForRouteChange( - tester, - navigation: 'route_pop', - destination: '/', - after: baseline, - ); +import '../helpers/replay_coherence_harness.dart'; - fixture.assertNavigationTapCoherence( - routeChange: routeChange, - expectedRoute: '/', - expectedNavigation: 'route_pop', - eventsAfter: baseline, - ); - expect(routeChange.data['fromRoute'], '/named'); - }); - - testWidgets('pushNamedAndRemoveUntil preserves its new destination capture', ( - tester, - ) async { - final fixture = await _NavigationFixture.mount(tester); - final baseline = fixture.session.events.length; - - await tester.tap(find.byKey(_rootCleanupKey)); - await tester.pumpAndSettle(); - final routeChange = await fixture.waitForRouteChange( - tester, - navigation: 'route_push', - destination: '/cleanup', - after: baseline, - ); - - fixture.assertNavigationTapCoherence( - routeChange: routeChange, - expectedRoute: '/cleanup', - expectedNavigation: 'route_push', - eventsAfter: baseline, - ); +void main() { + test('route event can precede its terminal canonical interaction', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + harness.controller.recordPointerDown(const Offset(12, 12)); + final route = harness.controller.route( + 'route_push', + harness.route('/next'), + ); + harness.controller.recordPointerUp(const Offset(12, 12)); + await harness.flushScheduler(); + await route; + final session = harness.controller.session!; + final interaction = session.ofType('interaction').single; expect( - fixture.session.events - .skip(baseline) - .where((event) => event.type == 'route_change') - .map((event) => event.data['route']), - isNot(contains('/')), - reason: 'removing the old stack must not replace the new destination', + session.ofType('route_change').single.data['causeEventId'], + interaction.id, ); }); } - -const _rootPushKey = Key('root-push-named'); -const _rootCleanupKey = Key('root-push-cleanup'); -const _namedReplaceKey = Key('named-replace'); -const _namedPopKey = Key('named-pop'); - -class _NavigationFixture { - _NavigationFixture(this.controller); - - final TugboatReplayController controller; - int _frameSerial = 0; - - TugboatSession get session => controller.session!; - - static Future<_NavigationFixture> mount(WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - navigatorObservers: [ - TugboatReplay.navigatorObserver, - ], - builder: (context, child) => TugboatReplay.wrapApp( - config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, - settleDelay: Duration.zero, - interactionClaimWindow: Duration.zero, - enableGlobalPointerCapture: true, - capturePixelRatio: 1, - ), - child: child!, - ), - initialRoute: '/', - routes: { - '/': (context) => _RootPage(context: context), - '/named': (context) => _NamedPage(context: context), - '/replacement': (context) => const _RoutePage(label: 'replacement'), - '/cleanup': (context) => const _RoutePage(label: 'cleanup'), - }, - ), - ); - final controller = await _pumpUntil(tester, () { - return TugboatReplay.controller; - }, description: 'mounted Tugboat replay controller'); - final fixture = _NavigationFixture(controller); - controller - .debugExecuteCapture = ({required trigger, required force}) async { - // debugSeedFrame records the route epoch/state observed at completion, - // exactly as a completed capture must do, while avoiding wall-clock IO. - return controller.debugSeedFrame( - contentHash: 'matrix-${trigger.name}-${fixture._frameSerial++}', - trigger: trigger, - ); - }; - await _pumpUntil(tester, () { - return controller.session; - }, description: 'active Tugboat replay session'); - // Establish a deterministic predecessor frame before exercising real - // pointer input. The capture root can start before a test installs its - // readback seam, so this explicitly models the already-rendered home - // screen rather than relying on an in-flight platform screenshot. - controller.debugSeedFrame( - contentHash: 'matrix-initial-${fixture._frameSerial++}', - trigger: TugboatFrameTrigger.initial, - ); - return fixture; - } - - Future openNamed(WidgetTester tester) async { - final baseline = session.events.length; - await tester.tap(find.byKey(_rootPushKey)); - await tester.pumpAndSettle(); - await waitForRouteChange( - tester, - navigation: 'route_push', - destination: '/named', - after: baseline, - ); - } - - Future waitForRouteChange( - WidgetTester tester, { - required String navigation, - required String destination, - required int after, - }) { - return _pumpUntil(tester, () { - final changes = session.events - .skip(after) - .where((event) => event.type == 'route_change'); - for (final event in changes) { - if (event.data['navigation'] == navigation && - event.data['route'] == destination) { - return event; - } - } - return null; - }, description: '$navigation to $destination'); - } - - void assertNavigationTapCoherence({ - required TugboatEvent routeChange, - required String expectedRoute, - required String expectedNavigation, - required int eventsAfter, - }) { - final events = session.events; - final routeIndex = events.indexOf(routeChange); - final tap = events - .sublist(eventsAfter, routeIndex + 1) - .lastWhere((event) => event.type == 'tap'); - final linkedSettles = events - .where( - (event) => - event.type == 'tap_settled' && event.relatedEventId == tap.id, - ) - .toList(growable: false); - expect(linkedSettles, hasLength(1)); - final settle = linkedSettles.single; - final routeFrame = routeChange.afterFrame; - final routeDiagnostics = events - .where( - (event) => - event.type == 'capture_diagnostic' && - event.data['requestId'] == routeChange.data['captureRequestId'], - ) - .toList(growable: false); - expect(routeDiagnostics, hasLength(1)); - final routeDiagnostic = routeDiagnostics.single; - - expect(routeIndex, greaterThan(eventsAfter)); - expect(routeChange.data['route'], expectedRoute); - expect(routeChange.data['navigation'], expectedNavigation); - expect(tap.beforeFrame, isNotNull); - expect(settle.relatedEventId, tap.id); - expect(tap.targetAnchor, isNotNull); - expect(settle.targetAnchor?.fingerprint, tap.targetAnchor?.fingerprint); - expect(settle.targetAnchor?.canonicalPath, tap.targetAnchor?.canonicalPath); - expect(settle.toJson().containsKey('stateAnchor'), isFalse); - expect(settle.afterFrame, routeFrame); - expect(routeFrame, isNotNull); - expect(routeDiagnostic.data['outcome'], 'fresh_accepted'); - final diagnosticEpoch = routeDiagnostic.data['routeEpoch']; - expect(diagnosticEpoch, isA()); - expect(routeDiagnostic.data['trigger'], 'route'); - - final provenance = controller.debugFrameProvenance(routeFrame!); - expect(provenance, isNotNull); - expect(provenance!['routeEpoch'], diagnosticEpoch); - expect(provenance['route'], expectedRoute); - final beforeProvenance = controller.debugFrameProvenance(tap.beforeFrame!); - expect(beforeProvenance, isNotNull); - expect(beforeProvenance!['route'], isNot(expectedRoute)); - expect( - beforeProvenance['routeEpoch'] as int, - lessThan(diagnosticEpoch as int), - ); - expect( - events.indexOf(tap), - lessThan(routeIndex), - reason: 'input must precede the route it caused', - ); - expect(routeIndex, lessThan(events.indexOf(settle))); - _assertChronological(events); - _assertNoStrandedCaptureWork(controller, session); - } -} - -class _RootPage extends StatelessWidget { - const _RootPage({required this.context}); - - final BuildContext context; - - @override - Widget build(BuildContext _) { - return Scaffold( - body: Column( - children: [ - FilledButton( - key: _rootPushKey, - onPressed: () => Navigator.of(context).pushNamed('/named'), - child: const Text('named'), - ), - FilledButton( - key: _rootCleanupKey, - onPressed: () => Navigator.of( - context, - ).pushNamedAndRemoveUntil('/cleanup', (route) => false), - child: const Text('cleanup'), - ), - ], - ), - ); - } -} - -class _NamedPage extends StatelessWidget { - const _NamedPage({required this.context}); - - final BuildContext context; - - @override - Widget build(BuildContext _) { - return Scaffold( - body: Column( - children: [ - FilledButton( - key: _namedReplaceKey, - onPressed: () => - Navigator.of(context).pushReplacementNamed('/replacement'), - child: const Text('replace'), - ), - FilledButton( - key: _namedPopKey, - onPressed: () => Navigator.of(context).pop(), - child: const Text('pop'), - ), - ], - ), - ); - } -} - -class _RoutePage extends StatelessWidget { - const _RoutePage({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) => Scaffold(body: Text(label)); -} - -Future _pumpUntil( - WidgetTester tester, - T? Function() read, { - required String description, -}) async { - for (var i = 0; i < 80; i++) { - final value = read(); - if (value != null) return value; - await tester.pump(); - } - fail('Timed out waiting for $description'); -} - -void _assertChronological(List events) { - var previousAt = -1; - final ids = {}; - for (final event in events) { - if (event.type != 'interaction') { - expect(event.atMs, greaterThanOrEqualTo(previousAt)); - previousAt = event.atMs; - } - expect(ids.add('${event.id}:${event.type}'), isTrue); - } -} - -void _assertNoStrandedCaptureWork( - TugboatReplayController controller, - TugboatSession session, -) { - expect(controller.debugRouteCapturePending, isFalse); - expect(controller.debugActiveTapSettleCount, 0); - expect(controller.debugCaptureInFlight, isFalse); - expect(controller.debugScheduledCaptureRoutes, isEmpty); - expect( - session.events.where((event) => event.type == 'session_start'), - hasLength(1), - ); -} 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 cfc874c..2ee6918 100644 --- a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart @@ -1,415 +1,130 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tugboat/tugboat.dart'; import '../helpers/replay_coherence_harness.dart'; -const _config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, - settleDelay: Duration.zero, - interactionClaimWindow: Duration.zero, - enableGlobalPointerCapture: true, - capturePixelRatio: 1, -); - -List _ofType(TugboatSession session, String type) => - session.events.where((event) => event.type == type).toList(growable: false); - -List _diagnostics(TugboatSession session) => - _ofType(session, 'capture_diagnostic'); - -Future _nextMicrotask() { - final completer = Completer(); - scheduleMicrotask(completer.complete); - return completer.future; -} - -({Future done, void Function() cancel}) _scheduleObservedRouteDelay( - Duration duration, -) { - final completer = Completer(); - var cancelled = false; - // Route deadlines are part of the test's controlled transition boundary. - // Its separate five-second readback barrier remains pending until the route - // work itself cancels it; collapsing both is an artificial timeout. - if (duration < const Duration(seconds: 5)) { - scheduleMicrotask(() { - if (!cancelled && !completer.isCompleted) completer.complete(); - }); - } - return ( - done: completer.future, - cancel: () { - cancelled = true; - if (!completer.isCompleted) completer.complete(); - }, - ); -} - -void _expectEveryDiagnosticRequestIsResolvedOnce(TugboatSession session) { - final requests = {}; - for (final event in _diagnostics(session)) { - final requestId = event.data['requestId']; - requests[requestId] = (requests[requestId] ?? 0) + 1; - } - expect(requests, isNotEmpty); - expect(requests.values, everyElement(1)); -} - -Future _mountObservedApp( - WidgetTester tester, { - required GlobalKey navigatorKey, - required Widget home, - required Map routes, -}) async { - TugboatReplay.resetForTest(); - var frameIndex = 0; - TugboatReplay.debugConfigureControllerForTest = (controller) { - controller.debugDelay = (_) => _nextMicrotask(); - controller.debugScheduleDelay = _scheduleObservedRouteDelay; - controller.debugExecuteCapture = - ({required trigger, required force}) async { - return controller.debugSeedFrame( - contentHash: 'matrix-${trigger.name}-${frameIndex++}', - trigger: trigger, - ); - }; - }; - await tester.pumpWidget( - MaterialApp( - navigatorKey: navigatorKey, - navigatorObservers: [TugboatReplay.navigatorObserver], - builder: (context, child) => - TugboatReplay.wrapApp(config: _config, child: child!), - home: home, - routes: routes, - ), - ); - final controller = TugboatReplay.controller!; - await tester.pump(); - await _drain(tester); - return controller; -} - -Future _drain(WidgetTester tester) async { - // Pumping frames and microtasks advances only the deterministic test seams; - // no wall-clock delay is used to make a route/capture race pass. - for (var index = 0; index < 12; index++) { - await tester.pump(); - } -} - -Future _pumpUntil( - WidgetTester tester, - T? Function() read, { - required String description, -}) async { - for (var attempt = 0; attempt < 80; attempt++) { - final value = read(); - if (value != null) return value; - await tester.pump(); - } - fail('Timed out waiting for $description'); -} - -Future _tearDownObservedApp(WidgetTester tester) async { - TugboatReplay.resetForTest(); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); -} - void main() { - testWidgets( - 'automatic Navigator successor stays independent of the claimed tap', - (tester) async { - final navigatorKey = GlobalKey(); - final controller = await _mountObservedApp( - tester, - navigatorKey: navigatorKey, - home: Builder( - builder: (context) => Scaffold( - body: FilledButton( - key: const Key('rapid-successors'), - onPressed: () { - Navigator.of(context).pushNamed('/a'); - Navigator.of(context).pushNamed('/b'); - }, - child: const Text('Rapid successors'), - ), - ), - ), - routes: { - '/a': (_) => const Scaffold(body: Text('A')), - '/b': (_) => const Scaffold(body: Text('B')), - }, - ); - addTearDown(() => _tearDownObservedApp(tester)); - - await tester.tap(find.byKey(const Key('rapid-successors'))); - await tester.pumpAndSettle(); + test( + 'route capture supersession publishes only the replacement evidence', + () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); - final session = controller.session!; - await _pumpUntil(tester, () { - for (final event in _ofType(session, 'route_change')) { - if (event.data['route'] == '/b') return event; - } - return null; - }, description: 'visible /b route capture'); - await _drain(tester); - final changes = _ofType(session, 'route_change'); - final tap = _ofType(session, 'tap').single; - final settle = _ofType(session, 'tap_settled').single; - expect(changes.map((event) => event.data['route']), ['/b']); - expect(settle.relatedEventId, tap.id); - 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, - isNotNull, - reason: - 'the visible /b route must publish a fresh compatible frame; ' - 'route=${changes.single.data}, ' - 'diagnostics=${_diagnostics(session).map((event) => event.data).toList()}', - ); - expect( - CoherenceInvariants.eventFramesMatchRoute( - event: changes.single, - expectedRoute: '/b', - expectedRouteEpoch: controller.debugRouteEpoch, - frameProvenanceFor: (candidate) { - if (candidate == null) return null; - final data = controller.debugFrameProvenance(candidate); - final route = data?['route']; - final epoch = data?['routeEpoch']; - return route is String && epoch is int - ? HarnessFrameProvenance(route: route, routeEpoch: epoch) - : null; - }, - ), - isTrue, + harness.capturer.blockNext = true; + final stale = harness.controller.route( + 'route_push', + harness.route('/stale'), ); - expect(routeFrame, isNotEmpty); - _expectEveryDiagnosticRequestIsResolvedOnce(session); - expect(controller.debugRouteCapturePending, isFalse); - expect(controller.debugActiveTapSettleCount, 0); - expect( - controller.debugScheduledCaptureRoutes, - isEmpty, - reason: - 'route capture work must drain after the observed swipe/navigation', + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + final replacement = harness.controller.route( + 'route_push', + harness.route('/replacement'), ); + await stale; + harness.capturer.completeBlocked('stale-frame'); + await harness.flushScheduler(); + await replacement; + + final changes = harness.controller.session!.ofType('route_change'); + expect(changes.map((event) => event.data['route']), ['/replacement']); + expect(changes.single.afterFrame, isNotNull); + expect(harness.controller.debugRouteCapturePending, isFalse); }, ); - testWidgets('destination tap before route capture degrades explicitly', ( - tester, - ) async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 40), - ); - await harness.setUpWidgetBacked(tester); + test('timed-out route capture has no borrowed interaction frame', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); addTearDown(harness.dispose); + final before = harness.seedRouteState( + route: '/origin', + signature: 'origin', + ); + harness.controller.recordPointerDown(const Offset(10, 10)); + harness.capturer.blockNext = true; final route = harness.controller.route( 'route_push', - harness.route( - '/home', - transitionDuration: const Duration(milliseconds: 150), - ), + harness.route('/blocked'), ); - final position = harness.targetTapPosition(tester); - harness.controller.recordPointerDown(position); - harness.controller.recordPointerUp(position); - final session = harness.controller.session!; - final tap = _ofType(session, 'tap').single; - - expect(tap.beforeFrame, isNull); - expect(tap.data['frameAttachment'], { - 'before': 'unavailable', - 'reason': 'no_compatible_frame', - }); - await harness.flushScheduler(); + harness.controller.recordPointerUp(const Offset(10, 10)); + await harness.pumpQueueWork(); + await harness.tick(const Duration(seconds: 5)); await route; - final change = _ofType(session, 'route_change').single; - final settle = _ofType(session, 'tap_settled').single; - expect(change.data['route'], '/home'); - expect(settle.relatedEventId, tap.id); - expect(settle.beforeFrame, isNull); - 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); + final session = harness.controller.session!; + final interaction = session.ofType('interaction').single; + final change = session.ofType('route_change').single; + expect(interaction.data['gesture'], 'tap'); + expect(interaction.beforeFrame, before); + expect(interaction.afterFrame, isNull); + expect(change.data['causeEventId'], interaction.id); + expect(change.afterFrame, isNull); + expect(change.data['captureOutcome'], 'timed_out'); + + harness.capturer.completeBlocked('late-route-frame'); + await harness.pumpQueueWork(); + expect(harness.controller.latestFrameId, before); }); - test('taps sharing an in-flight screenshot each settle once', () async { + test('failed route capture has no borrowed interaction frame', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - harness.seedRouteState(route: '/home', signature: 'home'); - harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(12, 12), pointer: 1); - harness.controller.recordPointerUp(const Offset(12, 12), pointer: 1); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - harness.controller.recordPointerDown(const Offset(24, 24), pointer: 2); - harness.controller.recordPointerUp(const Offset(24, 24), pointer: 2); - await harness.pumpQueueWork(); - harness.capturer.completeBlocked(); + final before = harness.seedRouteState( + route: '/origin', + signature: 'origin', + ); + harness.controller.recordPointerDown(const Offset(10, 10)); + harness.capturer.failNext = true; + final route = harness.controller.route( + 'route_push', + harness.route('/failed'), + ); + harness.controller.recordPointerUp(const Offset(10, 10)); await harness.flushScheduler(); + await route; final session = harness.controller.session!; - final taps = _ofType(session, 'tap'); - final settles = _ofType(session, 'tap_settled'); - expect(taps, hasLength(2)); - expect(settles, hasLength(2)); - expect( - settles.map((event) => event.relatedEventId).toSet(), - taps.map((event) => event.id).toSet(), - ); - for (final tap in taps) { - final settle = settles.singleWhere( - (event) => event.relatedEventId == tap.id, - ); - expect( - CoherenceInvariants.tapSettleIsLinked( - events: session.events, - tap: tap, - settle: settle, - ), - isTrue, - ); - } - _expectEveryDiagnosticRequestIsResolvedOnce(session); - expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); + final interaction = session.ofType('interaction').single; + final change = session.ofType('route_change').single; + expect(interaction.data['gesture'], 'tap'); + expect(interaction.beforeFrame, before); + expect(interaction.afterFrame, isNull); + expect(change.data['causeEventId'], interaction.id); + expect(change.afterFrame, isNull); + expect(change.data['captureOutcome'], 'failed'); }); - test( - 'automatic route superseding a tap capture does not supply its frame', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - harness.seedRouteState(route: '/home', signature: 'home'); - harness.capturer.blockNext = true; - - harness.controller.recordPointerDown(const Offset(12, 12)); - harness.controller.recordPointerUp(const Offset(12, 12)); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - // The tap claim has expired, so this route remains automatic rather than - // borrowing causal attribution from the preceding interaction. - await harness.pumpMicrotasks(); - final route = harness.controller.route( - 'route_push', - harness.route('/automatic'), - ); - - harness.capturer.completeBlocked(); - await harness.flushScheduler(); - await route; - - final session = harness.controller.session!; - final tap = _ofType(session, 'tap').single; - final settle = _ofType(session, 'tap_settled').single; - final change = _ofType(session, 'route_change').single; - final interaction = _ofType(session, 'interaction').single; - final observation = Map.from( - settle.data['settleObservation']! as Map, - ); - - expect(change.data['navigationOrigin'], 'automatic_or_unknown'); - expect(change.data['causeEventId'], isNull); - expect(change.afterFrame, isNotNull); - expect(settle.relatedEventId, tap.id); - expect(settle.afterFrame, isNull); - expect(observation['navigationOutcome'], 'visual_successor'); - expect(observation['captureOutcome'], isNot('captured')); - expect(observation['routeEventId'], isNull); - expect(interaction.data.containsKey('evidenceEventIds'), isFalse); - _expectEveryDiagnosticRequestIsResolvedOnce(session); - expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); - }, - ); - - testWidgets('real swipe input overlapping Navigator push stays swipe-only', ( - tester, - ) async { - final navigatorKey = GlobalKey(); - final controller = await _mountObservedApp( - tester, - navigatorKey: navigatorKey, - home: Scaffold( - body: ListView.builder( - key: const Key('scroll-list'), - itemCount: 30, - itemBuilder: (_, index) => ListTile(title: Text('row $index')), - ), - ), - routes: { - '/details': (_) => const Scaffold(body: Text('Details')), - }, + test('ambiguous multi-pointer gestures cannot claim a route', () async { + final harness = ReplayCoherenceHarness( + interactionClaimWindow: const Duration(milliseconds: 1250), ); - addTearDown(() => _tearDownObservedApp(tester)); + await harness.setUp(); + addTearDown(harness.dispose); - final gesture = await tester.startGesture( - tester.getCenter(find.byKey(const Key('scroll-list'))), + harness.controller.recordPointerDown(const Offset(10, 10), pointer: 1); + harness.controller.recordPointerDown(const Offset(20, 20), pointer: 2); + harness.controller.recordPointerUp(const Offset(10, 10), pointer: 1); + harness.controller.recordPointerUp(const Offset(20, 20), pointer: 2); + final route = harness.controller.route( + 'route_push', + harness.route('/automatic'), ); - await gesture.moveBy(const Offset(0, -160)); - navigatorKey.currentState!.pushNamed('/details'); - await gesture.up(); - await tester.pumpAndSettle(); - await _drain(tester); + await harness.flushScheduler(); + await route; - final session = controller.session!; - expect(_ofType(session, 'tap'), isEmpty); - final swipe = _ofType(session, 'swipe').single; - final scrollInteraction = session.events - .where( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic && - event.data['gesture'] == 'scroll', - ) - .single; - final change = _ofType(session, 'route_change').single; - expect(_ofType(session, 'tap_settled'), isEmpty); - expect(swipe.relatedEventId, isNull); - expect(swipe.data['startCaptureCoordinate'], isA()); - expect(swipe.data['scrolled'], isTrue); - expect(scrollInteraction.afterFrame, isNull); - expect(change.data['route'], '/details'); + final interactions = harness.controller.session!.ofType('interaction'); + final change = harness.controller.session!.ofType('route_change').single; + expect(interactions, hasLength(2)); expect( - CoherenceInvariants.hasChronologicalChain( - events: session.events, - orderedEventIds: [swipe.id, change.id], - ), + interactions.every((event) => event.data['gesture'] == 'tap'), isTrue, ); - expect(_ofType(session, 'interaction'), hasLength(1)); - _expectEveryDiagnosticRequestIsResolvedOnce(session); - expect(controller.debugRouteCapturePending, isFalse); - expect(controller.debugActiveTapSettleCount, 0); - expect( - controller.debugScheduledCaptureRoutes, - isEmpty, - reason: 'route capture work must drain after the observed navigation', - ); + expect(change.data['causeEventId'], isNull); + expect(change.data['navigationOrigin'], 'automatic_or_unknown'); }); } diff --git a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart index be19119..e8f4075 100644 --- a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart @@ -2,160 +2,56 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/tugboat.dart'; -/// Real-widget navigation coverage for overlay, nested, and non-table route -/// transitions. Screenshot readback is deterministic, but all Navigator and -/// pointer events are delivered through the mounted replay wrapper. void main() { setUp(TugboatReplay.resetForTest); tearDown(TugboatReplay.resetForTest); - testWidgets('dialog and modal bottom sheet retain their own route evidence', ( - tester, - ) async { - final fixture = await _OverlayFixture.mount(tester); - - final dialogStart = fixture.session.events.length; - await tester.tap(find.byKey(_openDialog)); - await tester.pumpAndSettle(); - final dialogPush = await fixture.waitForRoute( - tester, - navigation: 'route_push', - route: '/dialog', - after: dialogStart, - ); - await fixture.assertNavigationEvidence( - tester: tester, - routeChange: dialogPush, - destination: '/dialog', - after: dialogStart, - ); - - final dialogPopStart = fixture.session.events.length; - await tester.tap(find.byKey(_closeDialog)); - await tester.pumpAndSettle(); - final dialogPop = await fixture.waitForRoute( - tester, - navigation: 'route_pop', - route: '/root', - after: dialogPopStart, - ); - await fixture.assertNavigationEvidence( - tester: tester, - routeChange: dialogPop, - destination: '/root', - after: dialogPopStart, - ); - - final sheetStart = fixture.session.events.length; - await tester.tap(find.byKey(_openSheet)); - await tester.pumpAndSettle(); - final sheetPush = await fixture.waitForRoute( - tester, - navigation: 'route_push', - route: '/sheet', - after: sheetStart, - ); - await fixture.assertNavigationEvidence( - tester: tester, - routeChange: sheetPush, - destination: '/sheet', - after: sheetStart, - ); - - final sheetPopStart = fixture.session.events.length; - await tester.tap(find.byKey(_closeSheet)); - await tester.pumpAndSettle(); - final sheetPop = await fixture.waitForRoute( - tester, - navigation: 'route_pop', - route: '/root', - after: sheetPopStart, - ); - await fixture.assertNavigationEvidence( - tester: tester, - routeChange: sheetPop, - destination: '/root', - after: sheetPopStart, - ); - }); + testWidgets( + 'dialog and modal bottom sheet retain canonical route ownership', + (tester) async { + final fixture = await _OverlayFixture.mount(tester); + + await tester.tap(find.byKey(_openDialog)); + await tester.pumpAndSettle(); + final dialog = await fixture.route(tester, '/dialog'); + fixture.expectOwned(dialog, '/dialog'); + + await tester.tap(find.byKey(_closeDialog)); + await tester.pumpAndSettle(); + final dialogPop = await fixture.route( + tester, + '/root', + navigation: 'route_pop', + ); + fixture.expectOwned(dialogPop, '/root'); + + await tester.tap(find.byKey(_openSheet)); + await tester.pumpAndSettle(); + final sheet = await fixture.route(tester, '/sheet'); + fixture.expectOwned(sheet, '/sheet'); + + await tester.tap(find.byKey(_closeSheet)); + await tester.pumpAndSettle(); + final sheetPop = await fixture.route( + tester, + '/root', + navigation: 'route_pop', + ); + fixture.expectOwned(sheetPop, '/root'); + }, + ); - testWidgets('nested Navigator transition has destination-local evidence', ( + testWidgets('nested Navigator transition retains canonical route ownership', ( tester, ) async { final fixture = await _OverlayFixture.mount(tester); - final start = fixture.session.events.length; - await tester.tap(find.byKey(_openNested)); await tester.pumpAndSettle(); - final nestedStart = fixture.session.events.length; - expect( - nestedStart, - greaterThan(start), - reason: 'opening the nested host must produce replay activity', - ); await tester.tap(find.byKey(_openNested)); await tester.pumpAndSettle(); - final push = await fixture.waitForRoute( - tester, - navigation: 'route_push', - route: '/nested/details', - after: nestedStart, - ); - await fixture.assertNavigationEvidence( - tester: tester, - routeChange: push, - destination: '/nested/details', - after: nestedStart, - ); - }); - - testWidgets('anonymous and generated routes are classified and linked', ( - tester, - ) async { - final fixture = await _OverlayFixture.mount(tester); - - final anonymousStart = fixture.session.events.length; - await tester.tap(find.byKey(_openAnonymous)); - await tester.pumpAndSettle(); - final anonymous = await fixture.waitForNextRoute( - tester, - navigation: 'route_push', - after: anonymousStart, - ); - final anonymousRoute = anonymous.data['route'] as String; - expect(anonymousRoute, contains('MaterialPageRoute')); - await fixture.assertNavigationEvidence( - tester: tester, - routeChange: anonymous, - destination: anonymousRoute, - after: anonymousStart, - ); - - final anonymousPopStart = fixture.session.events.length; - await tester.tap(find.byKey(_popRoute)); - await tester.pumpAndSettle(); - await fixture.waitForRoute( - tester, - navigation: 'route_pop', - route: '/root', - after: anonymousPopStart, - ); - final generatedStart = fixture.session.events.length; - await tester.tap(find.byKey(_openGenerated)); - await tester.pumpAndSettle(); - final generated = await fixture.waitForRoute( - tester, - navigation: 'route_push', - route: '/generated', - after: generatedStart, - ); - await fixture.assertNavigationEvidence( - tester: tester, - routeChange: generated, - destination: '/generated', - after: generatedStart, - ); + final change = await fixture.route(tester, '/nested/details'); + fixture.expectOwned(change, '/nested/details'); }); } @@ -164,9 +60,6 @@ const _closeDialog = Key('overlay-close-dialog'); const _openSheet = Key('overlay-open-sheet'); const _closeSheet = Key('overlay-close-sheet'); const _openNested = Key('overlay-open-nested'); -const _openAnonymous = Key('overlay-open-anonymous'); -const _openGenerated = Key('overlay-open-generated'); -const _popRoute = Key('overlay-pop-route'); class _OverlayFixture { _OverlayFixture(this.controller); @@ -185,21 +78,10 @@ class _OverlayFixture { TugboatReplay.navigatorObserver, ], routes: {'/root': (_) => const _RootPage()}, - onGenerateRoute: (settings) { - if (settings.name == '/generated') { - return MaterialPageRoute( - settings: settings, - builder: (_) => const _RoutePage(label: 'generated'), - ); - } - return null; - }, builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, - interactionClaimWindow: tugboatDefaultReconciliationWindow, enableGlobalPointerCapture: true, capturePixelRatio: 1, ), @@ -210,20 +92,18 @@ class _OverlayFixture { final controller = await _pumpUntil( tester, () => TugboatReplay.controller, - description: 'mounted replay controller', + 'replay controller', ); final fixture = _OverlayFixture(controller); controller.debugExecuteCapture = - ({required trigger, required force}) async { - return controller.debugSeedFrame( - contentHash: 'overlay-${trigger.name}-${fixture._frameSerial++}', - trigger: trigger, - ); - }; + ({required trigger, required force}) async => controller.debugSeedFrame( + contentHash: 'overlay-${trigger.name}-${fixture._frameSerial++}', + trigger: trigger, + ); await _pumpUntil( tester, () => controller.session, - description: 'active replay session', + 'session', ); controller.debugSeedFrame( contentHash: 'overlay-initial-${fixture._frameSerial++}', @@ -232,119 +112,40 @@ class _OverlayFixture { return fixture; } - Future waitForRoute( - WidgetTester tester, { - required String navigation, - required String route, - required int after, + Future route( + WidgetTester tester, + String name, { + String navigation = 'route_push', }) => _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 waitForNextRoute( - WidgetTester tester, { - required String navigation, - required int after, - }) => _pumpUntil(tester, () { - for (final event in session.events.skip(after)) { - if (event.type == 'route_change' && + for (final event in _ofType(session, 'route_change')) { + if (event.data['route'] == name && event.data['navigation'] == navigation) { return event; } } return null; - }, description: '$navigation route'); - - Future assertNavigationEvidence({ - required WidgetTester tester, - required TugboatEvent routeChange, - required String destination, - required int after, - }) async { - await _pumpUntil(tester, () { - for (final event in session.events) { - if (event.type == 'tap_settled' && - event.afterFrame == routeChange.afterFrame) { - return event; - } - } - return null; - }, description: 'route-linked settled interaction'); - final events = session.events; - final routeIndex = events.indexOf(routeChange); - final routeFrame = routeChange.afterFrame; - final requestId = routeChange.data['captureRequestId']; - final diagnostics = events - .where( - (event) => - event.type == 'capture_diagnostic' && - event.data['requestId'] == requestId, - ) - .toList(growable: false); - expect(diagnostics, hasLength(1)); - final diagnostic = diagnostics.single; - final tap = events - .sublist(after, routeIndex + 1) - .lastWhere((event) => event.type == 'tap'); - final linkedSettles = events - .where( - (event) => - event.type == 'tap_settled' && event.relatedEventId == tap.id, - ) - .toList(growable: false); - expect(linkedSettles, hasLength(1)); - final settled = linkedSettles.single; - - expect(routeChange.data['route'], destination); - expect(requestId, isNotNull); - expect(routeFrame, isNotNull); - expect(diagnostic.data['requestId'], requestId); - expect(diagnostic.data['trigger'], 'route'); - final diagnosticEpoch = diagnostic.data['routeEpoch']; - expect(diagnosticEpoch, isA()); - expect(settled.relatedEventId, tap.id); - expect(tap.targetAnchor, isNotNull); - expect(settled.targetAnchor?.fingerprint, tap.targetAnchor?.fingerprint); - expect( - settled.targetAnchor?.canonicalPath, - tap.targetAnchor?.canonicalPath, - ); - expect(settled.toJson().containsKey('stateAnchor'), isFalse); - expect(settled.afterFrame, routeFrame); - expect(events.indexOf(tap), lessThan(routeIndex)); - expect(routeIndex, lessThan(events.indexOf(settled))); - - final provenance = controller.debugFrameProvenance(routeFrame!); - expect(provenance, isNotNull); - expect(provenance!['route'], destination); - expect(provenance['routeEpoch'], diagnosticEpoch); - final beforeProvenance = controller.debugFrameProvenance(tap.beforeFrame!); - expect(beforeProvenance, isNotNull); - expect(beforeProvenance!['route'], isNot(destination)); - expect( - beforeProvenance['routeEpoch'] as int, - lessThan(diagnosticEpoch as int), - ); - expect( - tap.beforeFrame, - isNot(routeFrame), - reason: 'a route result must not substitute the origin frame', - ); - _assertChronological(events); - expect(controller.debugRouteCapturePending, isFalse); - expect(controller.debugActiveTapSettleCount, 0); - expect(controller.debugCaptureInFlight, isFalse); - expect(controller.debugScheduledCaptureRoutes, isEmpty); + }, '$navigation $name'); + + void expectOwned(TugboatEvent change, String destination) { + final interactionId = change.data['causeEventId']; + final interaction = _ofType( + session, + 'interaction', + ).singleWhere((event) => event.id == interactionId); + final frame = change.afterFrame; + expect(interaction.data['gesture'], 'tap'); + expect(frame, isNotNull); + expect(change.data['route'], destination); + expect(change.data['navigationOrigin'], 'interaction'); + expect(change.data['causeEventId'], interaction.id); + expect(interaction.afterFrame, frame); + expect(controller.debugFrameProvenance(frame!)!['route'], destination); } } +List _ofType(TugboatSession session, String type) => + session.events.where((event) => event.type == type).toList(growable: false); + class _NestedObserverScope extends InheritedWidget { const _NestedObserverScope({required this.observer, required super.child}); @@ -407,20 +208,6 @@ class _RootPage extends StatelessWidget { ), child: const Text('nested'), ), - FilledButton( - key: _openAnonymous, - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const _RoutePage(label: 'anonymous'), - ), - ), - child: const Text('anonymous'), - ), - FilledButton( - key: _openGenerated, - onPressed: () => Navigator.of(context).pushNamed('/generated'), - child: const Text('generated'), - ), ], ), ); @@ -441,7 +228,7 @@ class _NestedHost extends StatelessWidget { onPressed: () => Navigator.of(context).push( MaterialPageRoute( settings: const RouteSettings(name: '/nested/details'), - builder: (_) => const _RoutePage(label: 'nested details'), + builder: (_) => const SizedBox.shrink(), ), ), child: const Text('nested details'), @@ -452,44 +239,15 @@ class _NestedHost extends StatelessWidget { ); } -class _RoutePage extends StatelessWidget { - const _RoutePage({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) => Scaffold( - body: Center( - child: FilledButton( - key: _popRoute, - onPressed: () => Navigator.of(context).pop(), - child: Text('pop $label'), - ), - ), - ); -} - Future _pumpUntil( WidgetTester tester, - T? Function() read, { - required String description, -}) async { - for (var attempt = 0; attempt < 120; attempt++) { + T? Function() read, + String description, +) async { + for (var index = 0; index < 120; index++) { final value = read(); if (value != null) return value; await tester.pump(const Duration(milliseconds: 16)); } fail('Timed out waiting for $description'); } - -void _assertChronological(List events) { - var previousAt = -1; - final ids = {}; - for (final event in events) { - if (event.type != 'interaction') { - expect(event.atMs, greaterThanOrEqualTo(previousAt)); - previousAt = event.atMs; - } - expect(ids.add('${event.id}:${event.type}'), isTrue); - } -} diff --git a/packages/tugboat/test/replay/replay_programmatic_navigation_matrix_test.dart b/packages/tugboat/test/replay/replay_programmatic_navigation_matrix_test.dart index d93fa66..1a238f1 100644 --- a/packages/tugboat/test/replay/replay_programmatic_navigation_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_programmatic_navigation_matrix_test.dart @@ -1,216 +1,76 @@ 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', + 'programmatic navigation emits route evidence without an interaction', () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 100), - ); + final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - - harness.seedRouteState(route: '/source', signature: 'source'); - harness.controller.recordPointerDown(const Offset(8, 8)); - harness.controller.recordPointerUp(const Offset(8, 8)); - final tap = harness.controller.session!.ofType('tap').single; - - 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( + final route = harness.controller.route( 'route_push', - harness.route('/redirect'), + harness.route('/next'), ); - 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); + await route; + final session = harness.controller.session!; + expect(session.ofType('interaction'), isEmpty); expect( - observation['captureRequestId'], - isNot(redirect.data['captureRequestId']), + session.ofType('route_change').single.data, + isNot(contains('causeEventId')), ); }, ); test( - 'automatic successor cannot replace a tap-caused route barrier', + 'automatic redirect after a completed interaction keeps no route cause', () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 100), - ); + final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); harness.controller.recordPointerDown(const Offset(8, 8)); - final tappedRoute = harness.controller.route( - 'route_push', - harness.route('/tapped'), - ); harness.controller.recordPointerUp(const Offset(8, 8)); - final tap = harness.controller.session!.ofType('tap').single; - - // 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 interaction = harness.controller.session! + .ofType('interaction') + .single; - 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, - ); + await harness.controller.route('route_push', harness.route('/login')); + await harness.controller.route('route_replace', harness.route('/home')); + await harness.flushScheduler(); - expectAutomatic(redirect); - expect(observation['navigationOutcome'], 'navigation_unavailable'); - expect(observation['routeEventId'], isNull); - expect(settled.afterFrame, isNull); + final changes = harness.controller.session!.ofType('route_change'); + final home = changes.lastWhere((event) => event.data['route'] == '/home'); + expect(interaction.data['gesture'], 'tap'); + expect(home.data['navigationOrigin'], 'automatic_or_unknown'); + expect(home.data['causeEventId'], isNull); + expect(home.afterFrame, isNotNull); }, ); - 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 tappedRoute = harness.controller.route( - 'route_push', - harness.route('/tapped'), - ); - harness.controller.recordPointerUp(const Offset(3, 3)); - final tap = harness.controller.session!.ofType('tap').single; - await tappedRoute; - 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 { + for (final navigation in [ + 'route_push', + 'route_replace', + 'route_pop', + 'route_remove', + ]) { + test('$navigation owns destination route evidence', () 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); - }, - ); + final route = harness.controller.route( + navigation, + harness.route('/$navigation'), + ); + await harness.flushScheduler(); + await route; + final change = harness.controller.session!.ofType('route_change').single; + expect(change.data['navigation'], navigation); + expect(change.data['route'], '/$navigation'); + expect(change.afterFrame, isNotNull); + }); + } } diff --git a/packages/tugboat/test/replay/tap_coordinate_transform_test.dart b/packages/tugboat/test/replay/tap_coordinate_transform_test.dart index 59af9fc..0f6471f 100644 --- a/packages/tugboat/test/replay/tap_coordinate_transform_test.dart +++ b/packages/tugboat/test/replay/tap_coordinate_transform_test.dart @@ -12,12 +12,17 @@ void main() { EdgeInsets padding = EdgeInsets.zero, double capturePixelRatio = 1, }) async { + TugboatReplay.debugConfigureControllerForTest = (controller) { + controller.debugExecuteCapture = + ({required trigger, required force}) async { + return controller.debugSeedFrame(trigger: trigger); + }; + }; await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, @@ -50,9 +55,8 @@ void main() { ), ); await tester.pump(); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 350)), - ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); await tester.pump(); final controller = TugboatReplay.controller; expect(controller, isNotNull); @@ -70,23 +74,18 @@ void main() { final center = tester.getCenter(find.byKey(const Key('target'))); controller.recordPointerDown(center); controller.recordPointerUp(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)); + await tester.pump(); + await controller.drainPointerQueue(); + final interaction = controller.session!.events + .where((event) => event.type == 'interaction') + .last; + final position = + Map.from( + interaction.data['payload']! as Map, + )['position'] + as Map; + expect(position['xNorm'], inInclusiveRange(0.0, 1.0)); + expect(position['yNorm'], inInclusiveRange(0.0, 1.0)); }); testWidgets('outside-boundary tap is unavailable without clamping', ( @@ -96,13 +95,12 @@ void main() { // Far outside the capture boundary / screen. controller.recordPointerDown(const Offset(-80, -80)); controller.recordPointerUp(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); + await tester.pump(); + await controller.drainPointerQueue(); + final interaction = controller.session!.events + .where((event) => event.type == 'interaction') + .last; + expect(interaction.data['payload'], isNull); }); testWidgets('capture ratio below 1.0 still projects within one pixel', ( @@ -110,27 +108,23 @@ void main() { ) 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(); + await tester.pump(const Duration(milliseconds: 350)); await tester.pump(); final center = tester.getCenter(find.byKey(const Key('target'))); controller.recordPointerDown(center); controller.recordPointerUp(center); - final tap = controller.session!.events.where((e) => e.type == 'tap').last; - final coord = TugboatCaptureCoordinate.fromJson( - Map.from(tap.data['captureCoordinate']! as Map), + await tester.pump(); + await controller.drainPointerQueue(); + final interaction = controller.session!.events + .where((event) => event.type == 'interaction') + .last; + final payload = Map.from( + interaction.data['payload']! 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)); + final position = Map.from(payload['position']! as Map); + expect(position['xNorm'], inInclusiveRange(0.0, 1.0)); + expect(position['yNorm'], inInclusiveRange(0.0, 1.0)); }); testWidgets('resized boundary suppresses coordinates for the older frame', ( @@ -142,7 +136,6 @@ void main() { 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(); @@ -150,16 +143,12 @@ void main() { final center = tester.getCenter(find.byKey(const Key('target'))); controller.recordPointerDown(center); controller.recordPointerUp(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)); + await tester.pump(); + await controller.drainPointerQueue(); + final interaction = controller.session!.events + .where((event) => event.type == 'interaction') + .last; + expect(interaction.beforeFrame, isNull); + expect(interaction.data['payload'], isNull); }); } diff --git a/packages/tugboat/test/replay_coherence_characterization_test.dart b/packages/tugboat/test/replay_coherence_characterization_test.dart index 8277a41..1d38809 100644 --- a/packages/tugboat/test/replay_coherence_characterization_test.dart +++ b/packages/tugboat/test/replay_coherence_characterization_test.dart @@ -1,122 +1,38 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:tugboat/tugboat.dart'; import 'helpers/replay_coherence_harness.dart'; -/// Characterization coverage for SDK replay races (#5). -/// -/// These tests reproduce current 0.4.x ordering/frame attribution behavior. -/// Where production is known-broken, the test asserts the broken sequence and -/// also records that [CoherenceInvariants] currently fail against it. Follow-up -/// issues (#6–#10) flip those invariants to true without rewriting the harness. void main() { test( - 'tap with no navigation keeps linked settle evidence on one route', + 'completed tap publishes one canonical interaction with frame ownership', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); + final before = harness.seedRouteState(route: '/home', signature: 'home'); - final originFrame = harness.seedRouteState( - route: '/home', - signature: 'sig-home', - frameContentHash: 'home-pixels', - ); - final routeEpoch = harness.controller.debugRouteEpoch; - - harness.controller.recordPointerDown(const Offset(12, 12)); - harness.controller.recordPointerUp(const Offset(12, 12)); - await harness.flushScheduler(); - - final session = harness.controller.session!; - final tap = session.ofType('tap').single; - final settle = session.ofType('tap_settled').single; - - expect(settle.relatedEventId, tap.id); - expect(tap.beforeFrame, originFrame); - expect(settle.beforeFrame, originFrame); - expect(settle.afterFrame, isNotNull); - expect( - CoherenceInvariants.tapSettleIsLinked( - events: session.events, - tap: tap, - settle: settle, - ), - isTrue, - ); - expect( - CoherenceInvariants.hasChronologicalChain( - events: session.events, - orderedEventIds: [tap.id, settle.id], - ), - isTrue, - ); - expect( - CoherenceInvariants.tapSettleIsRouteCoherent( - tap: tap, - settle: settle, - expectedRoute: '/home', - expectedRouteEpoch: routeEpoch, - frameProvenanceFor: harness.provenanceFor, - expectedRouteSignature: 'sig-home', - ), - isTrue, - ); - }, - ); - - test( - 'local WebSocket suppression keeps a standalone tap screenshot', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/home', signature: 'sig-home'); - harness.controller.debugSetExplorationFramesSuppressed(true); - final manual = harness.controller.debugRequestCapture( - trigger: TugboatFrameTrigger.manual, - ); - final manualResolution = await manual.resolution; - expect(manualResolution['outcome'], 'cancelled'); - - final interactionForces = []; - harness.capturer.frameFactory = (trigger, force) { - if (trigger == TugboatFrameTrigger.interaction) { - interactionForces.add(force); - } - return null; - }; harness.controller.recordPointerDown(const Offset(12, 12)); harness.controller.recordPointerUp(const Offset(12, 12)); await harness.flushScheduler(); - final settle = harness.controller.session!.ofType('tap_settled').single; - expect(settle.afterFrame, isNotNull); - expect(interactionForces, [true]); - expect( - harness.capturer.triggers, - isNot(contains(TugboatFrameTrigger.manual)), - ); + final interaction = harness.controller.session! + .ofType('interaction') + .single; + expect(interaction.data['gesture'], 'tap'); + expect(interaction.beforeFrame, before); + expect(interaction.afterFrame, isNotNull); }, ); test( - 'local WebSocket suppression keeps a claimed route screenshot', + 'claimed route links its canonical interaction and destination frame', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); + harness.seedRouteState(route: '/home', signature: 'home'); - harness.seedRouteState(route: '/home', signature: 'sig-home'); - harness.controller.debugSetExplorationFramesSuppressed(true); - final routeForces = []; - harness.capturer.frameFactory = (trigger, force) { - if (trigger == TugboatFrameTrigger.route) routeForces.add(force); - return null; - }; harness.controller.recordPointerDown(const Offset(12, 12)); final route = harness.controller.route( 'route_push', @@ -127,2100 +43,281 @@ void main() { await route; final session = harness.controller.session!; - final routeChange = session.ofType('route_change').single; - final settle = session.ofType('tap_settled').single; final interaction = session.ofType('interaction').single; - expect(routeChange.afterFrame, isNotNull); - expect(settle.afterFrame, routeChange.afterFrame); - expect(routeForces, [true]); - expect(interaction.data.containsKey('evidenceEventIds'), isFalse); + final routeChange = session.ofType('route_change').single; + expect(routeChange.data['causeEventId'], interaction.id); + expect(interaction.afterFrame, routeChange.afterFrame); }, ); test( - 'tap that starts navigation awaits the matching route capture', + 'session replacement finalizes an in-flight gesture as cancelled', () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 50), - ); + final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - final originFrame = harness.seedRouteState( - route: '/scan', - signature: 'sig-scan', - frameContentHash: 'scan-pixels', - ); - - // 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)); - final routeFuture = harness.controller.route( - 'route_push', - harness.route( - '/home', - 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. - await harness.pumpQueueWork(); - - final midSession = harness.controller.session!; - expect(midSession.ofType('tap_settled'), isEmpty); - expect( - midSession.ofType('route_change'), - isEmpty, - reason: 'route capture is still waiting on transition delay', - ); - expect(harness.controller.debugRouteCapturePending, isTrue); - + harness.controller.recordPointerDown(const Offset(12, 12)); + final old = harness.controller.session!; + harness.controller.start(const Size(390, 844), 'replacement'); await harness.flushScheduler(); - await routeFuture; - - final session = harness.controller.session!; - final tap = session.ofType('tap').single; - final routeChange = session.ofType('route_change').single; - final settle = session.ofType('tap_settled').single; - expect(settle.relatedEventId, tap.id); - expect(settle.afterFrame, routeChange.afterFrame); - expect(settle.afterFrame, isNot(originFrame)); - expect(settle.result, isNull); - expect(routeChange.data['route'], '/home'); - expect(routeChange.afterFrame, isNot(originFrame)); - expect( - session.events.map((event) => event.type).toList(), - containsAll(['tap', 'tap_settled', 'route_change']), - ); - final tapIndex = session.events.indexWhere((e) => e.type == 'tap'); - final settleIndex = session.events.indexWhere( - (e) => e.type == 'tap_settled', - ); - final routeIndex = session.events.indexWhere( - (e) => e.type == 'route_change', - ); - expect(tapIndex, lessThan(settleIndex)); - expect(routeIndex, lessThan(settleIndex)); - expect( - CoherenceInvariants.hasChronologicalChain( - events: session.events, - orderedEventIds: [tap.id, routeChange.id, settle.id], - ), - isTrue, - ); - expect( - CoherenceInvariants.tapSettleIsLinked( - events: session.events, - tap: tap, - settle: settle, - ), - isTrue, - ); - expect( - CoherenceInvariants.navigationTapHasNoEarlyNoVisibleChange( - events: session.events, - tapEventId: tap.id, - expectedDestinationRoute: '/home', - expectedRouteEventId: routeChange.id, - ), - isTrue, - ); + expect(old.ofType('interaction'), hasLength(1)); + expect(old.ofType('interaction').single.data['gesture'], 'cancelled'); + expect(harness.controller.session!.ofType('interaction'), isEmpty); }, ); test( - 'automatic route active before taps stays independent of both settles', + 'duplicate pointer cancels prior interaction and keeps successor', () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 20), - ); + final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - final originFrame = harness.seedRouteState( - route: '/scan', - signature: 'sig-scan', - ); - final routeFuture = harness.controller.route( - 'route_push', - harness.route( - '/home', - transitionDuration: const Duration(milliseconds: 20), - ), - ); - - harness.controller.recordPointerDown(const Offset(10, 10), pointer: 1); - harness.controller.recordPointerUp(const Offset(10, 10), pointer: 1); - harness.controller.recordPointerDown(const Offset(20, 20), pointer: 2); - harness.controller.recordPointerUp(const Offset(20, 20), pointer: 2); - await harness.tick(const Duration(milliseconds: 19)); - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); - - await harness.tick(const Duration(milliseconds: 1)); + harness.controller.recordPointerDown(const Offset(12, 12)); + harness.controller.recordPointerUp(const Offset(12, 12)); + harness.controller.recordPointerDown(const Offset(18, 18)); + harness.controller.recordPointerUp(const Offset(18, 18)); await harness.flushScheduler(); - await routeFuture; - final session = harness.controller.session!; - final routeFrame = session.ofType('route_change').single.afterFrame; - final settles = session.ofType('tap_settled'); - expect(settles, hasLength(2)); - 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, - ), - hasLength(1), - ); - expect(routeFrame, isNot(originFrame)); + final interactions = harness.controller.session!.ofType('interaction'); + expect(interactions, hasLength(2)); + expect(interactions.first.data['gesture'], 'cancelled'); + expect(interactions.last.data['gesture'], 'tap'); }, ); - test('automatic route before tap settle stays independent', () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 20), - ); + test('swipe cannot claim a following route', () async { + final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - harness.seedRouteState(route: '/scan', signature: 'sig-scan'); - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.tick(const Duration(milliseconds: 19)); - - final routeFuture = harness.controller.route( + harness.controller.recordPointerDown(const Offset(12, 12)); + harness.controller.markPendingTapAsSwipe(0); + harness.controller.recordPointerUp(const Offset(12, 80)); + final route = harness.controller.route( 'route_push', - harness.route('/home'), + harness.route('/next'), ); - await harness.tick(const Duration(milliseconds: 1)); await harness.flushScheduler(); - await routeFuture; + await route; final session = harness.controller.session!; - expect(session.ofType('route_change'), hasLength(1)); - final settle = session.ofType('tap_settled').single; - final interaction = session.events.singleWhere( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic, - ); - final observation = Map.from( - settle.data['settleObservation']! as Map, - ); - expect(settle.afterFrame, isNull); - expect(interaction.afterFrame, isNull); - expect(observation['navigationOutcome'], 'same_route'); - expect(observation['captureOutcome'], 'superseded_route_epoch'); - expect(observation['routeEventId'], isNull); - expect(interaction.data.containsKey('evidenceEventIds'), isFalse); + expect(session.ofType('interaction').single.data['gesture'], 'swipe'); expect( - harness.capturer.triggers.where( - (trigger) => trigger == TugboatFrameTrigger.route, - ), - hasLength(1), + session.ofType('route_change').single.data, + isNot(contains('causeEventId')), ); - expect( - harness.capturer.triggers.where( - (trigger) => trigger == TugboatFrameTrigger.interaction, - ), - hasLength(1), + }); + + test('cancelled interaction cannot claim a following route', () async { + final harness = ReplayCoherenceHarness(); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(12, 12)); + harness.controller.recordPointerCancel(const Offset(12, 12)); + final route = harness.controller.route( + 'route_push', + harness.route('/next'), ); + await harness.flushScheduler(); + await route; + + final session = harness.controller.session!; + expect(session.ofType('interaction').single.data['gesture'], 'cancelled'); + expect(session.ofType('route_change').single.data['causeEventId'], isNull); }); test( - 'automatic route during tap readback supplies visual successor', + 'session end terminalizes an in-flight tap capture without a late frame', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - harness.seedRouteState(route: '/scan', signature: 'sig-scan'); + final before = harness.seedRouteState(route: '/home', signature: 'home'); harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.recordPointerUp(const Offset(10, 10)); + harness.controller.recordPointerDown(const Offset(12, 12)); + harness.controller.recordPointerUp(const Offset(12, 12)); 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, isNull); - expect(observation['navigationOutcome'], 'visual_successor'); - expect(observation['routeEventId'], isNull); - expect(routeChange.afterFrame, isNotNull); - }, - ); + await harness.controller.endSession(); + harness.capturer.completeBlocked('late-tap-frame'); + await harness.pumpQueueWork(); - test( - 'automatic successors cannot replace a tap-caused route barrier', - () 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']); - final settle = harness.controller.session!.ofType('tap_settled').single; - final interaction = harness.controller.session!.events.singleWhere( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic, - ); - final observation = Map.from( - settle.data['settleObservation']! as Map, - ); - expect(settle.afterFrame, isNull); - expect(interaction.afterFrame, isNull); - expect(observation['navigationOutcome'], 'navigation_unavailable'); - expect(observation['captureOutcome'], 'superseded_route_epoch'); - expect(observation['routeEventId'], isNull); - expect( - harness.capturer.triggers.where( - (trigger) => trigger == TugboatFrameTrigger.interaction, - ), - hasLength(1), - ); - expect(interaction.data.containsKey('evidenceEventIds'), isFalse); + expect(harness.controller.session!.ofType('interaction'), isEmpty); + expect(harness.controller.latestFrameId, before); }, ); - test('cancelling a settle deadline removes its scheduler entry', () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 20), - ); + test('lifecycle pause terminalizes an in-flight swipe capture', () async { + final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - harness.controller.recordPointerDown(const Offset(1, 1)); - harness.controller.recordPointerUp(const Offset(1, 1)); - expect(harness.scheduler.pendingDelayCount, 1); - await harness.controller.endSession(); - expect(harness.scheduler.pendingDelayCount, 0); + + final before = harness.seedRouteState(route: '/list', signature: 'list'); + harness.capturer.blockNext = true; + harness.controller.recordPointerDown(const Offset(10, 100)); + harness.controller.markPendingTapAsSwipe(0); + harness.controller.recordPointerUp(const Offset(10, 10)); + await harness.pumpQueueWork(); + expect(harness.capturer.blockedCount, 1); + + harness.controller.recordAppLifecycleState(AppLifecycleState.paused); + final interaction = harness.controller.session! + .ofType('interaction') + .single; + expect(interaction.data['gesture'], 'swipe'); + expect(interaction.afterFrame, isNull); + harness.capturer.completeBlocked('late-swipe-frame'); + await harness.pumpQueueWork(); + expect(harness.controller.latestFrameId, before); }); - test('replacement during route readback suppresses old tap output', () async { + testWidgets('session end terminalizes a pointer-linked scroll capture', ( + tester, + ) async { final harness = ReplayCoherenceHarness(); await harness.setUp(); - addTearDown(harness.dispose); - harness.controller.recordPointerDown(const Offset(1, 1)); - harness.controller.recordPointerUp(const Offset(1, 1)); + await _mountScrollableHarness(tester, harness); + + final before = harness.seedRouteState(route: '/list', signature: 'list'); harness.capturer.blockNext = true; - final route = harness.controller.route('route_push', harness.route('/old')); + harness.controller.recordPointerDown(const Offset(10, 10)); + harness.controller.markPendingTapAsSwipe(0); + await tester.drag(find.byType(ListView), const Offset(0, -200)); + harness.controller.recordPointerUp(const Offset(10, -190)); await harness.pumpQueueWork(); expect(harness.capturer.blockedCount, 1); - harness.controller.start(const Size(390, 844), 'replacement'); - harness.capturer.completeBlocked('old-frame'); - await route; - await harness.flushScheduler(); - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); - expect(harness.controller.session!.ofType('route_change'), isEmpty); - }); - - test( - 'ending session during route readback suppresses waiting tap output', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - harness.seedRouteState(route: '/scan', signature: 'sig-scan'); - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.recordPointerUp(const Offset(10, 10)); - harness.capturer.blockNext = true; - final routeFuture = harness.controller.route( - 'route_push', - harness.route('/home'), - ); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - await harness.controller.endSession(); - harness.capturer.completeBlocked('late-route-frame'); - await routeFuture; - await harness.pumpQueueWork(); + await harness.controller.endSession(); + harness.capturer.completeBlocked('late-scroll-frame'); + await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); - expect(harness.controller.session!.ofType('route_change'), isEmpty); - expect(harness.controller.latestFrameId, isNot('late-route-frame')); - }, - ); + final scrollsAfterEnd = harness.controller.session! + .ofType('interaction') + .where((event) => event.data['gesture'] == 'scroll') + .toList(growable: false); + expect(scrollsAfterEnd, hasLength(1)); + expect(scrollsAfterEnd.single.afterFrame, isNull); + expect(harness.controller.latestFrameId, before); + harness.dispose(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); - test( - 'ending session invalidates an in-flight standalone tap capture', - () async { + testWidgets( + 'session replacement terminalizes an unresolved pointer-linked scroll', + (tester) async { final harness = ReplayCoherenceHarness(); await harness.setUp(); - addTearDown(harness.dispose); + await _mountScrollableHarness(tester, harness); - final originFrame = harness.seedRouteState( - route: '/scan', - signature: 'sig-scan', - ); - final frameCount = harness.controller.session!.frames.length; + harness.seedRouteState(route: '/list', signature: 'list'); harness.capturer.blockNext = true; harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.recordPointerUp(const Offset(10, 10)); + harness.controller.markPendingTapAsSwipe(0); + await tester.drag(find.byType(ListView), const Offset(0, -200)); + harness.controller.recordPointerUp(const Offset(10, -190)); await harness.pumpQueueWork(); expect(harness.capturer.blockedCount, 1); - await harness.controller.endSession(); - harness.capturer.completeBlocked('late-tap-frame'); + final oldSession = harness.controller.session!; + harness.controller.start(const Size(390, 844), 'replacement'); + + final terminalScrolls = oldSession + .ofType('interaction') + .where((event) => event.data['gesture'] == 'scroll') + .toList(growable: false); + expect(terminalScrolls, hasLength(1)); + expect(terminalScrolls.single.afterFrame, isNull); + expect(harness.controller.session!.ofType('interaction'), isEmpty); + + harness.capturer.completeBlocked('late-replacement-scroll-frame'); await harness.pumpQueueWork(); + expect( + oldSession + .ofType('interaction') + .where((event) => event.data['gesture'] == 'scroll'), + hasLength(1), + ); + expect(harness.controller.session!.ofType('interaction'), isEmpty); - final session = harness.controller.session!; - expect(session.ofType('tap_settled'), isEmpty); - expect(session.frames, hasLength(frameCount)); - expect(harness.controller.latestFrameId, originFrame); + harness.dispose(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); }, ); test( - 'backgrounding invalidates an in-flight standalone tap capture', + 'route epoch isolation never gives destination interaction the origin frame', () async { final harness = ReplayCoherenceHarness(); await harness.setUp(); addTearDown(harness.dispose); - final originFrame = harness.seedRouteState( - route: '/scan', - signature: 'sig-scan', + final origin = harness.seedRouteState( + route: '/origin', + signature: 'origin', ); - final frameCount = harness.controller.session!.frames.length; - 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); - - harness.controller.recordAppLifecycleState(AppLifecycleState.paused); - harness.capturer.completeBlocked('late-background-frame'); - await harness.pumpQueueWork(); + final originEpoch = harness.controller.debugRouteEpoch; + final route = harness.controller.route( + 'route_push', + harness.route( + '/destination', + transitionDuration: const Duration(milliseconds: 20), + ), + ); + harness.controller.recordPointerDown(const Offset(12, 12)); + harness.controller.recordPointerUp(const Offset(12, 12)); + await harness.flushScheduler(); + await route; final session = harness.controller.session!; - expect(session.ofType('tap_settled'), isEmpty); - expect(session.frames, hasLength(frameCount)); - expect(harness.controller.latestFrameId, originFrame); + final interaction = session.ofType('interaction').single; + final change = session.ofType('route_change').single; + final provenance = harness.controller.debugFrameProvenance( + change.afterFrame!, + )!; + expect(interaction.beforeFrame, isNull); + expect(interaction.beforeFrame, isNot(origin)); + expect(change.data['route'], '/destination'); + expect(provenance['route'], '/destination'); + expect(provenance['routeEpoch'], greaterThan(originEpoch)); }, ); test( - 'ending session retains a swipe whose capture is still pending', + 'trimmed frame provenance remains a tombstone and cannot be reused', () async { - final harness = ReplayCoherenceHarness(); + final harness = ReplayCoherenceHarness(maxFrames: 1); await harness.setUp(); addTearDown(harness.dispose); + await harness.flushScheduler(); - harness.seedRouteState(route: '/list', signature: 'sig-list'); - harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(10, 100)); - harness.controller.markPendingTapAsSwipe(0); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - await harness.controller.endSession(); - final interactions = harness.controller.session!.ofType('interaction'); - expect(interactions, hasLength(1)); - expect(interactions.single.data['gesture'], 'swipe'); - expect(interactions.single.result, isNull); - expect(interactions.single.data.containsKey('result'), isFalse); - - harness.capturer.completeBlocked('late-swipe-frame'); - await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('interaction'), hasLength(1)); - }, - ); - - test( - 'backgrounding retains a swipe whose capture is still pending', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); + final route = harness.controller.route( + 'route_push', + harness.route('/home'), + ); + await harness.flushScheduler(); + await route; + final first = harness.controller.session! + .ofType('route_change') + .single + .afterFrame!; + final second = harness.controller.debugSeedFrame(contentHash: 'second'); - harness.seedRouteState(route: '/list', signature: 'sig-list'); - harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(10, 100)); - harness.controller.markPendingTapAsSwipe(0); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - harness.controller.recordAppLifecycleState(AppLifecycleState.paused); - final interactions = harness.controller.session!.ofType('interaction'); - expect(interactions, hasLength(1)); - expect(interactions.single.data['gesture'], 'swipe'); - expect(interactions.single.data.containsKey('result'), isFalse); - - harness.capturer.completeBlocked('late-background-swipe-frame'); - await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('interaction'), hasLength(1)); - }, - ); - - test( - 'session replacement finalizes a pending swipe in the old session', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final oldSession = harness.controller.session!; - harness.seedRouteState(route: '/list', signature: 'sig-list'); - harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(10, 100)); - harness.controller.markPendingTapAsSwipe(0); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - harness.controller.start(const Size(390, 844), 'replacement'); - expect(oldSession.ofType('interaction'), hasLength(1)); - expect(oldSession.ofType('interaction').single.data['gesture'], 'swipe'); - expect( - oldSession.ofType('interaction').single.data.containsKey('result'), - isFalse, - ); - - harness.capturer.completeBlocked('late-replacement-swipe-frame'); - await harness.pumpQueueWork(); - expect(oldSession.ofType('interaction'), hasLength(1)); - expect(harness.controller.session!.ofType('interaction'), isEmpty); - }, - ); - - testWidgets('ending session suppresses blocked scroll interaction output', ( - tester, - ) async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - await _mountScrollableHarness(tester, harness); - - final originFrame = harness.seedRouteState( - route: '/list', - signature: 'sig-list', - ); - harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.markPendingTapAsSwipe(0); - await tester.drag(find.byType(ListView), const Offset(0, -200)); - harness.controller.recordPointerUp(const Offset(10, -190)); - await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('swipe'), isNotEmpty); - expect(harness.capturer.blockedCount, 1); - expect( - harness.controller.session!.events.where( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic && - event.data['gesture'] == 'scroll', - ), - isEmpty, - ); - - await harness.controller.endSession(); - harness.capturer.completeBlocked('late-scroll-frame'); - await harness.pumpQueueWork(); - - expect( - harness.controller.session!.events.where( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic && - event.data['gesture'] == 'scroll', - ), - isEmpty, - ); - expect(harness.controller.latestFrameId, originFrame); - harness.dispose(); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); - - testWidgets('backgrounding suppresses blocked scroll interaction output', ( - tester, - ) async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - await _mountScrollableHarness(tester, harness); - - final originFrame = harness.seedRouteState( - route: '/list', - signature: 'sig-list', - ); - harness.capturer.blockNext = true; - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.markPendingTapAsSwipe(0); - await tester.drag(find.byType(ListView), const Offset(0, -200)); - harness.controller.recordPointerUp(const Offset(10, -190)); - await harness.pumpQueueWork(); - expect(harness.controller.session!.ofType('swipe'), isNotEmpty); - expect(harness.capturer.blockedCount, 1); - expect( - harness.controller.session!.events.where( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic && - event.data['gesture'] == 'scroll', - ), - isEmpty, - ); - - harness.controller.recordAppLifecycleState(AppLifecycleState.paused); - harness.capturer.completeBlocked('late-scroll-frame'); - await harness.pumpQueueWork(); - - expect( - harness.controller.session!.events.where( - (event) => - event.type == 'interaction' && - event.stream == TugboatEventStream.semantic && - event.data['gesture'] == 'scroll', - ), - isEmpty, - ); - expect(harness.controller.latestFrameId, originFrame); - harness.dispose(); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); - - test('ending a session cancels a tap waiting on route capture', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/scan', signature: 'sig-scan'); - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.recordPointerUp(const Offset(10, 10)); - final routeFuture = harness.controller.route( - 'route_push', - harness.route( - '/home', - transitionDuration: const Duration(milliseconds: 20), - ), - ); - await harness.controller.endSession(); - await routeFuture; - await harness.pumpQueueWork(); - - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); - expect(harness.controller.session!.ofType('route_change'), isEmpty); - }); - - test( - 'replacement session rejects a tap waiting on old route capture', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/scan', signature: 'sig-scan'); - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.controller.recordPointerUp(const Offset(10, 10)); - final routeFuture = harness.controller.route( - 'route_push', - harness.route('/home'), - ); - await harness.pumpQueueWork(); - harness.controller.start(const Size(390, 844), 'replacement'); - await routeFuture; - await harness.flushScheduler(); - - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); - expect(harness.controller.session!.ofType('route_change'), isEmpty); - }, - ); - - group('navigationTapHasNoEarlyNoVisibleChange ordering', () { - TugboatEvent syntheticEvent({ - required String id, - required String type, - required int atMs, - String? relatedEventId, - TugboatInteractionResult? result, - Map data = const {}, - }) { - return TugboatEvent( - id: id, - atMs: atMs, - type: type, - relatedEventId: relatedEventId, - result: result, - data: data, - ); - } - - test( - 'fails when route_change precedes tap_settled with noVisibleChange', - () { - final events = [ - syntheticEvent(id: 'tap-1', type: 'tap', atMs: 100), - syntheticEvent( - id: 'route-1', - type: 'route_change', - atMs: 150, - data: {'route': '/home'}, - ), - syntheticEvent( - id: 'settle-1', - type: 'tap_settled', - atMs: 200, - relatedEventId: 'tap-1', - result: TugboatInteractionResult.noVisibleChange, - ), - ]; - expect( - CoherenceInvariants.navigationTapHasNoEarlyNoVisibleChange( - events: events, - tapEventId: 'tap-1', - expectedDestinationRoute: '/home', - expectedRouteEventId: 'route-1', - ), - isFalse, - ); - }, - ); - - test( - 'fails when route_change follows tap_settled with noVisibleChange', - () { - final events = [ - syntheticEvent(id: 'tap-1', type: 'tap', atMs: 100), - syntheticEvent( - id: 'settle-1', - type: 'tap_settled', - atMs: 150, - relatedEventId: 'tap-1', - result: TugboatInteractionResult.noVisibleChange, - ), - syntheticEvent( - id: 'route-1', - type: 'route_change', - atMs: 200, - data: {'route': '/home'}, - ), - ]; - expect( - CoherenceInvariants.navigationTapHasNoEarlyNoVisibleChange( - events: events, - tapEventId: 'tap-1', - expectedDestinationRoute: '/home', - expectedRouteEventId: 'route-1', - ), - isFalse, - ); - }, - ); - - test('fails when destination route_change is missing', () { - final events = [ - syntheticEvent(id: 'tap-1', type: 'tap', atMs: 100), - syntheticEvent( - id: 'settle-1', - type: 'tap_settled', - atMs: 150, - relatedEventId: 'tap-1', - result: TugboatInteractionResult.noVisibleChange, - ), - ]; - expect( - CoherenceInvariants.navigationTapHasNoEarlyNoVisibleChange( - events: events, - tapEventId: 'tap-1', - expectedDestinationRoute: '/home', - expectedRouteEventId: 'route-1', - ), - isFalse, - ); - }); - - test('fails when expected route event id does not match destination', () { - final events = [ - syntheticEvent(id: 'tap-1', type: 'tap', atMs: 100), - syntheticEvent( - id: 'route-1', - type: 'route_change', - atMs: 120, - data: {'route': '/settings'}, - ), - syntheticEvent( - id: 'settle-1', - type: 'tap_settled', - atMs: 180, - relatedEventId: 'tap-1', - result: TugboatInteractionResult.changed, - ), - ]; - expect( - CoherenceInvariants.navigationTapHasNoEarlyNoVisibleChange( - events: events, - tapEventId: 'tap-1', - expectedDestinationRoute: '/home', - expectedRouteEventId: 'route-1', - ), - isFalse, - ); - }); - - test('fails when route_change occurs before tap', () { - final events = [ - syntheticEvent( - id: 'route-1', - type: 'route_change', - atMs: 50, - data: {'route': '/home'}, - ), - syntheticEvent(id: 'tap-1', type: 'tap', atMs: 100), - syntheticEvent( - id: 'settle-1', - type: 'tap_settled', - atMs: 180, - relatedEventId: 'tap-1', - result: TugboatInteractionResult.changed, - ), - ]; - expect( - CoherenceInvariants.navigationTapHasNoEarlyNoVisibleChange( - events: events, - tapEventId: 'tap-1', - expectedDestinationRoute: '/home', - expectedRouteEventId: 'route-1', - ), - isFalse, - ); - }); - - test( - 'passes when matching route exists and settle is not noVisibleChange', - () { - final events = [ - syntheticEvent(id: 'tap-1', type: 'tap', atMs: 100), - syntheticEvent( - id: 'route-1', - type: 'route_change', - atMs: 120, - data: {'route': '/home'}, - ), - syntheticEvent( - id: 'settle-1', - type: 'tap_settled', - atMs: 180, - relatedEventId: 'tap-1', - result: TugboatInteractionResult.changed, - ), - ]; - expect( - CoherenceInvariants.navigationTapHasNoEarlyNoVisibleChange( - events: events, - tapEventId: 'tap-1', - expectedDestinationRoute: '/home', - expectedRouteEventId: 'route-1', - ), - isTrue, - ); - }, - ); - }); - - test( - 'destination tap while route capture pending leaves before frame unavailable', - () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 40), - ); - await harness.setUp(); - addTearDown(harness.dispose); - - final originFrame = harness.seedRouteState( - route: '/scan', - signature: 'sig-scan', - frameContentHash: 'scan-pixels', - ); - - final routeFuture = harness.controller.route( - 'route_push', - harness.route( - '/home', - transitionDuration: const Duration(milliseconds: 150), - ), - ); - expect(harness.controller.debugRouteCapturePending, isTrue); - expect(harness.controller.currentRoute, '/home'); - expect(harness.controller.latestFrameId, originFrame); - - // Destination UI semantics are already visible, but the route capture has - // not published a destination frame yet. - - harness.controller.recordPointerDown(const Offset(30, 30)); - harness.controller.recordPointerUp(const Offset(30, 30)); - - // The origin frame is globally latest but belongs to a different route - // epoch, so it must not be attached to destination UI evidence. - final session = harness.controller.session!; - final destinationTap = session.ofType('tap').single; - expect(destinationTap.beforeFrame, isNull); - expect(destinationTap.data['frameAttachment'], { - 'before': 'unavailable', - 'reason': 'no_compatible_frame', - }); - expect(harness.controller.debugRouteCapturePending, isTrue); - expect( - CoherenceInvariants.actionFrameMatchesRoute( - action: destinationTap, - originFrameId: originFrame, - destinationFrameId: 'destination-frame-not-captured-yet', - frameProvenanceFor: harness.provenanceFor, - ), - isFalse, - ); - - await harness.flushScheduler(); - await routeFuture; - - final routeChange = session.ofType('route_change').single; - final destinationFrame = routeChange.afterFrame!; - expect(destinationFrame, isNot(originFrame)); - expect(routeChange.data['route'], '/home'); - expect( - CoherenceInvariants.actionFrameMatchesRoute( - action: destinationTap, - originFrameId: originFrame, - destinationFrameId: destinationFrame, - frameProvenanceFor: harness.provenanceFor, - ), - isFalse, - reason: - 'tap has no compatible before frame while destination frame exists', - ); - - // After the blocking route wait finishes, settle may run with a newer - // frame — the cross-route attribution already happened on the tap. - final destinationSettle = session.ofType('tap_settled').single; - expect(destinationSettle.relatedEventId, destinationTap.id); - expect(destinationSettle.beforeFrame, isNull); - }, - ); - - test('frame provenance is immutable across compatible reuse', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final frame = harness.seedRouteState( - route: '/home', - signature: 'sig-home', - frameContentHash: 'same-pixels', - ); - final beforeReuse = Map.from( - harness.controller.debugFrameProvenance(frame)!, - ); - - expect(harness.controller.debugReuseFrameForCurrentRoute(frame), frame); - final afterReuse = harness.controller.debugFrameProvenance(frame)!; - expect(afterReuse['captureSessionId'], beforeReuse['captureSessionId']); - expect(afterReuse['routeEpoch'], beforeReuse['routeEpoch']); - expect(afterReuse['route'], beforeReuse['route']); - expect(afterReuse['requestedAtMs'], beforeReuse['requestedAtMs']); - expect(afterReuse['completedAtMs'], beforeReuse['completedAtMs']); - expect(afterReuse['reuseReason'], 'content_hash'); - expect(afterReuse['reusedFromFrameId'], frame); - }); - - test('first interaction records explicit frame unavailability', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - // Start a replacement session and inspect the synchronous interaction - // before its initial capture pump can run. - harness.capturer.blockNext = true; - harness.controller.start(const Size(390, 844), 'test'); - harness.controller.debugSetCurrentRoute('/home'); - - harness.controller.recordPointerDown(const Offset(4, 4)); - harness.controller.recordPointerUp(const Offset(4, 4)); - final tap = harness.controller.session!.ofType('tap').single; - expect(tap.beforeFrame, isNull); - expect(tap.data['frameAttachment'], { - 'before': 'unavailable', - 'reason': 'no_frame_available', - }); - - await harness.controller.endSession(); - }); - - test('interaction captures execute uniquely in route order', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - for (final (pointer, route) in [(1, '/a'), (2, '/b'), (3, '/a')]) { - harness.controller.debugSetCurrentRoute(route); - harness.controller.recordPointerDown( - Offset(pointer.toDouble(), pointer.toDouble()), - pointer: pointer, - ); - harness.controller.recordPointerUp( - Offset(pointer.toDouble(), pointer.toDouble()), - pointer: pointer, - ); - await harness.flushScheduler(); - } - - final interactionTriggers = harness.capturer.triggers - .where((trigger) => trigger == TugboatFrameTrigger.interaction) - .toList(growable: false); - expect(interactionTriggers, hasLength(3)); - final settles = harness.controller.session!.ofType('tap_settled'); - expect(settles, hasLength(3)); - expect(settles.map((event) => event.afterFrame), everyElement(isNotNull)); - expect( - settles - .map((event) => harness.provenanceFor(event.afterFrame)?.route) - .toList(growable: false), - ['/a', '/b', '/a'], - ); - expect( - settles.map((event) => event.afterFrame).toSet(), - hasLength(3), - reason: 'each interaction uses one fresh, non-reused screenshot', - ); - - await harness.controller.endSession(); - }); - - test('tap settle keeps the state observed with its after frame', () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/home', signature: 'sig-before'); - harness.capturer.frameFactory = (trigger, force) { - final frame = harness.controller.debugSeedFrame( - contentHash: 'captured-pixels', - trigger: trigger, - ); - // Simulate controller state advancing after readback but before the - // settle event is admitted to the serialized mutation queue. - return frame; - }; - - harness.controller.recordPointerDown(const Offset(12, 12)); - harness.controller.recordPointerUp(const Offset(12, 12)); - await harness.flushScheduler(); - - final settle = harness.controller.session!.ofType('tap_settled').single; - expect(settle.afterFrame, isNotNull); - expect( - harness.controller.debugFrameProvenance(settle.afterFrame!), - isNot(contains('completionStateSignature')), - ); - }); - - test( - 'same-route no-op requires matching semantic and visual evidence', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final beforeFrame = harness.seedRouteState( - route: '/home', - signature: 'sig-home', - frameContentHash: 'same-pixels', - ); - harness.capturer.frameFactory = (trigger, force) => harness.controller - .debugSeedFrame(contentHash: 'same-pixels', trigger: trigger); - - harness.controller.recordPointerDown(const Offset(12, 12)); - harness.controller.recordPointerUp(const Offset(12, 12)); - await harness.flushScheduler(); - - final settle = harness.controller.session!.ofType('tap_settled').single; - expect(settle.beforeFrame, beforeFrame); - expect(settle.afterFrame, isNot(beforeFrame)); - expect(settle.result, isNull); - final observation = settle.data['settleObservation'] as Map; - expect(observation.containsKey('semantic'), isFalse); - expect(observation['visual'], { - 'changed': false, - 'evidence': 'content_hash', - 'reason': 'same_frame', - }); - }, - ); - - test( - 'same pixels on a new route receive distinct frame provenance', - () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 20), - ); - await harness.setUp(); - addTearDown(harness.dispose); - - final origin = harness.seedRouteState( - route: '/origin', - signature: 'sig-origin', - frameContentHash: 'same-pixels', - ); - final route = harness.controller.route( - 'route_push', - harness.route( - '/destination', - transitionDuration: const Duration(milliseconds: 20), - ), - ); - final destination = harness.controller.debugSeedFrame( - contentHash: 'same-pixels', - trigger: TugboatFrameTrigger.route, - ); - - expect(destination, isNot(origin)); - expect( - harness.controller.debugFrameProvenance(origin), - containsPair('route', '/origin'), - ); - expect( - harness.controller.debugFrameProvenance(destination), - allOf( - containsPair('route', '/destination'), - containsPair('routeEpoch', 1), - ), - ); - expect(harness.controller.debugReuseFrameForCurrentRoute(origin), isNull); - - await harness.controller.endSession(); - await route; - }, - ); - - test('push replace and pop never attach the prior route frame', () async { - for (final transition in [ - ('route_push', '/pushed'), - ('route_replace', '/replacement'), - ('route_pop', '/revealed'), - ]) { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 20), - ); - await harness.setUp(); - final origin = harness.seedRouteState( - route: '/origin', - signature: 'sig-origin', - ); - final route = harness.controller.route( - transition.$1, - harness.route( - transition.$2, - transitionDuration: const Duration(milliseconds: 20), - ), - ); - - harness.controller.recordPointerDown(const Offset(8, 8)); - harness.controller.recordPointerUp(const Offset(8, 8)); - final tap = harness.controller.session!.ofType('tap').single; - expect(tap.beforeFrame, isNull, reason: transition.$1); - expect(tap.beforeFrame, isNot(origin), reason: transition.$1); - expect(tap.data['frameAttachment'], { - 'before': 'unavailable', - 'reason': 'no_compatible_frame', - }); - - await harness.controller.endSession(); - await route; - harness.dispose(); - } - }); - - test('trimmed provenance remains a tombstone and is never reused', () async { - final harness = ReplayCoherenceHarness(maxFrames: 1); - await harness.setUp(); - addTearDown(harness.dispose); - - final first = harness.controller.debugSeedFrame( - contentHash: 'first-pixels', - ); - harness.controller.recordPointerDown(const Offset(1, 1), pointer: 1); - harness.controller.recordPointerUp(const Offset(1, 1), pointer: 1); - final retainedTap = harness.controller.session!.ofType('tap').single; - expect(retainedTap.beforeFrame, first); - - final second = harness.controller.debugSeedFrame( - contentHash: 'second-pixels', - ); - expect(harness.controller.session!.frames.map((frame) => frame.id), [ - second, - ]); - expect( - harness.controller.debugFrameProvenance(first), - containsPair('available', false), - ); - expect(retainedTap.beforeFrame, first); - expect(harness.controller.debugReuseFrameForCurrentRoute(first), isNull); - - harness.controller.recordPointerDown(const Offset(2, 2), pointer: 2); - harness.controller.recordPointerUp(const Offset(2, 2), pointer: 2); - final latestTap = harness.controller.session!.ofType('tap').last; - expect(latestTap.beforeFrame, second); - }); - - test('unreferenced trimmed provenance is pruned', () async { - final harness = ReplayCoherenceHarness(maxFrames: 1); - await harness.setUp(); - addTearDown(harness.dispose); - final baselineProvenanceCount = - harness.controller.debugFrameProvenanceCount; - - final first = harness.controller.debugSeedFrame( - contentHash: 'first-pixels', - ); - final second = harness.controller.debugSeedFrame( - contentHash: 'second-pixels', - ); - - expect(harness.controller.session!.frames.map((frame) => frame.id), [ - second, - ]); - expect(harness.controller.debugFrameProvenance(first), isNull); - expect(harness.controller.debugFrameProvenance(second), isNotNull); - expect( - harness.controller.debugFrameProvenanceCount, - baselineProvenanceCount + 1, - ); - expect(harness.controller.latestFrameId, second); - }); - - test('trimming every frame clears latest frame metadata', () async { - final harness = ReplayCoherenceHarness(maxFrames: 0); - await harness.setUp(); - addTearDown(harness.dispose); - - final removed = harness.seedRouteState( - route: '/home', - signature: 'sig-home', - frameContentHash: 'pixels', - ); - - expect(harness.controller.session!.frames, isEmpty); - expect(harness.controller.latestFrameId, isNull); - expect(harness.controller.debugFrameProvenance(removed), isNull); - expect(harness.controller.debugFrameProvenanceCount, 0); - expect(harness.controller.debugFrameReuseObservationCount, 0); - }); - - test( - 'actionFrameMatchesRoute rejects a third unrelated frame family', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final originFrame = harness.seedRouteState( - route: '/scan', - signature: 'sig-scan', - frameContentHash: 'scan-pixels', - ); - final unrelatedFrame = harness.controller.debugSeedFrame( - contentHash: 'scan-pixels', - ); - harness.registerFrameProvenance( - unrelatedFrame, - route: '/settings', - routeEpoch: 1, - ); - final destinationFrame = harness.controller.debugSeedFrame( - contentHash: 'home-pixels', - trigger: TugboatFrameTrigger.route, - ); - harness.registerFrameProvenance( - destinationFrame, - route: '/home', - routeEpoch: 2, - ); - - harness.controller.recordPointerDown(const Offset(30, 30)); - harness.controller.recordPointerUp(const Offset(30, 30)); - await harness.flushScheduler(); - - final session = harness.controller.session!; - final tap = session.ofType('tap').single; - final tapWithUnrelatedFrame = TugboatEvent( - id: tap.id, - atMs: tap.atMs, - type: tap.type, - targetAnchor: tap.targetAnchor, - beforeFrame: unrelatedFrame, - data: tap.data, - ); - - expect(unrelatedFrame, isNot(originFrame)); - expect(unrelatedFrame, isNot(destinationFrame)); - expect( - CoherenceInvariants.actionFrameMatchesRoute( - action: tapWithUnrelatedFrame, - originFrameId: originFrame, - destinationFrameId: destinationFrame, - frameProvenanceFor: harness.provenanceFor, - ), - isFalse, - reason: - 'unrelated frame must not pass merely by matching destination pixels', - ); - expect( - CoherenceInvariants.actionFrameMatchesRoute( - action: TugboatEvent( - id: tap.id, - atMs: tap.atMs, - type: tap.type, - beforeFrame: destinationFrame, - ), - originFrameId: originFrame, - destinationFrameId: destinationFrame, - frameProvenanceFor: harness.provenanceFor, - ), - isTrue, - ); - }, - ); - - test( - 'route capture after navigation stamps destination route provenance', - () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 30), - ); - await harness.setUp(); - addTearDown(harness.dispose); - - final originFrame = harness.seedRouteState( - route: '/scan', - signature: 'sig-scan', - frameContentHash: 'scan-pixels', - ); - final originEpoch = harness.controller.debugRouteEpoch; - - final routeFuture = harness.controller.route( - 'route_push', - harness.route( - '/home', - transitionDuration: const Duration(milliseconds: 100), - ), - ); - expect(harness.controller.currentRoute, '/home'); - - await harness.flushScheduler(); - await routeFuture; - - final routeChange = harness.controller.session! - .ofType('route_change') - .single; - final destinationFrame = routeChange.afterFrame!; - final destinationEpoch = harness - .provenanceFor(destinationFrame)! - .routeEpoch; - - expect(harness.provenanceFor(originFrame)!.route, '/scan'); - expect(harness.provenanceFor(originFrame)!.routeEpoch, originEpoch); - expect(harness.provenanceFor(destinationFrame)!.route, '/home'); - expect(destinationEpoch, greaterThan(originEpoch)); - expect(destinationEpoch, harness.controller.debugRouteEpoch); - }, - ); - - test( - 'rapid route changes cancel obsolete epochs without hanging the queue', - () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 30), - ); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/', signature: 'sig-root'); - - final first = harness.controller.route( - 'route_push', - harness.route( - '/a', - transitionDuration: const Duration(milliseconds: 200), - ), - ); - final firstEpoch = harness.controller.debugRouteEpoch; - await harness.tick(const Duration(milliseconds: 10)); - - final second = harness.controller.route( - 'route_push', - harness.route( - '/b', - transitionDuration: const Duration(milliseconds: 20), - ), - ); - expect(harness.controller.debugRouteEpoch, greaterThan(firstEpoch)); - - await harness.flushScheduler(); - await first; - await second; - - expect(harness.controller.currentRoute, '/b'); - final changes = harness.controller.session!.ofType('route_change'); - expect( - changes.where((event) => event.data['route'] == '/a'), - isEmpty, - reason: 'superseded epoch must not emit', - ); - expect(changes.last.data['route'], '/b'); - expect(harness.controller.debugRouteCapturePending, isFalse); - expect(harness.scheduler.hasPendingDelays, isFalse); - expect(CoherenceInvariants.hasNoStrandedCaptureWork(harness), isTrue); - }, - ); - - test( - 'route transition delay does not block later serialized controller work', - () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 30), - ); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/', signature: 'sig-root'); - unawaited( - harness.controller.route( - 'route_push', - harness.route( - '/destination', - transitionDuration: const Duration(milliseconds: 200), - ), - ), - ); - - var laterTaskRan = false; - unawaited( - harness.controller.debugEnqueueTask('later_probe', () async { - laterTaskRan = true; - }), - ); - await harness.pumpQueueWork(); - - expect( - laterTaskRan, - isTrue, - reason: 'the transition deadline must be outside the serialized queue', - ); - }, - ); - - test( - 'ending a session cancels its pending route deadline and completes it', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final pending = harness.controller.route( - 'route_push', - harness.route( - '/destination', - transitionDuration: const Duration(milliseconds: 200), - ), - ); - await harness.controller.endSession(); - await pending; - - expect(harness.controller.debugRouteCapturePending, isFalse); - expect(harness.scheduler.pendingDelayCount, 0); - await harness.flushScheduler(); - expect( - harness.controller.session!.ofType('route_change'), - isEmpty, - reason: 'a completed session must not receive a deferred route event', - ); - }, - ); - - test( - 'starting a replacement session cancels the prior route deadline', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final prior = harness.controller.route( - 'route_push', - harness.route( - '/stale', - transitionDuration: const Duration(milliseconds: 200), - ), - ); - harness.controller.start(const Size(390, 844), 'test'); - await prior; - - expect(harness.controller.debugRouteCapturePending, isFalse); - expect(harness.scheduler.pendingDelayCount, 0); - await harness.flushScheduler(); - expect( - harness.controller.session!.ofType('route_change'), - isEmpty, - reason: 'a deferred callback from the old session must be inert', - ); - }, - ); - - test( - 'disposing completes a pending route waiter without advancing time', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - - final pending = harness.controller.route( - 'route_push', - harness.route( - '/destination', - transitionDuration: const Duration(milliseconds: 200), - ), - ); - harness.dispose(); - await pending; - - expect(harness.controller.debugRouteCapturePending, isFalse); - expect(harness.scheduler.pendingDelayCount, 0); - }, - ); - - test( - 'backgrounding cancels pending route work without a late route event', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final pending = harness.controller.route( - 'route_push', - harness.route( - '/destination', - transitionDuration: const Duration(milliseconds: 200), - ), - ); - harness.controller.recordAppLifecycleState(AppLifecycleState.paused); - await pending; - - expect(harness.controller.debugRouteCapturePending, isFalse); - expect(harness.scheduler.pendingDelayCount, 0); - expect(harness.controller.session!.ofType('route_change'), isEmpty); - }, - ); - - test( - 'superseding a route during capture emits only the replacement', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.capturer.blockNext = true; - final stale = harness.controller.route( - 'route_push', - harness.route('/stale'), - ); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - final replacement = harness.controller.route( - 'route_push', - harness.route('/replacement'), - ); - await stale; - harness.capturer.completeBlocked(); - await harness.flushScheduler(); - await replacement; - - final changes = harness.controller.session!.ofType('route_change'); - expect(changes.map((event) => event.data['route']), ['/replacement']); - expect(harness.controller.debugRouteCapturePending, isFalse); - }, - ); - - test( - 'ending a session cancels an in-flight route capture without late output', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final frameCount = harness.controller.session!.frames.length; - harness.capturer.blockNext = true; - final pending = harness.controller.route( - 'route_push', - harness.route('/destination'), - ); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - await harness.controller.endSession(); - await pending; - harness.capturer.completeBlocked('cancelled-route-frame'); - await harness.pumpQueueWork(); - - expect(harness.controller.session!.frames.length, frameCount); - expect(harness.controller.session!.ofType('route_change'), isEmpty); - expect(harness.controller.latestFrameId, isNot('cancelled-route-frame')); - }, - ); - - test( - 'blocked route readback times out without publishing a late frame', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/origin', signature: 'origin'); - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.capturer.blockNext = true; - final route = harness.controller.route( - 'route_push', - harness.route('/blocked'), - ); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - final frameBeforeTimeout = harness.controller.latestFrameId; - - // Private controller timeout: 5 seconds. The controllable scheduler - // proves this is a bounded barrier rather than a wall-clock test. - await harness.tick(const Duration(seconds: 5)); - await route; - await harness.pumpQueueWork(); - - final change = harness.controller.session!.ofType('route_change').single; - expect(change.afterFrame, isNull); - expect(change.result, TugboatInteractionResult.unknown); - expect(change.data['captureOutcome'], 'timed_out'); - final timedOutSettle = harness.controller.session! - .ofType('tap_settled') - .single; - expect(timedOutSettle.result, isNull); - expect(timedOutSettle.afterFrame, isNull); - expect( - timedOutSettle.data['settleObservation'], - allOf( - containsPair('captureOutcome', 'timed_out'), - containsPair('routeEventId', change.id), - ), - ); - expect(harness.controller.latestFrameId, frameBeforeTimeout); - - harness.capturer.completeBlocked('late-route-frame'); - await harness.pumpQueueWork(); - expect(harness.controller.latestFrameId, frameBeforeTimeout); - expect(harness.controller.session!.ofType('route_change'), hasLength(1)); - }, - ); - - test( - 'absolute route timeout releases waiters before queued admission', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final predecessor = Completer(); - unawaited( - harness.controller.debugEnqueueTask( - 'unrelated predecessor', - () => predecessor.future, - ), - ); - await harness.pumpQueueWork(); - - harness.controller.recordPointerDown(const Offset(10, 10)); - final route = harness.controller.route( - 'route_push', - harness.route('/queued'), - ); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.tick(const Duration(seconds: 5)); - await route; - await harness.pumpQueueWork(); - - // Neither route finalization nor tap_settled can enter the blocked - // queue, but their waiters have already reached a terminal outcome. - expect(harness.controller.debugActiveTapSettleCount, 0); - final changes = harness.controller.session!.ofType('route_change'); - expect(changes, hasLength(1)); - expect(changes.single.data['captureOutcome'], 'timed_out'); - expect(harness.controller.session!.ofType('tap_settled'), hasLength(1)); - - predecessor.complete(); - await harness.flushScheduler(); - expect(harness.controller.session!.ofType('route_change'), hasLength(1)); - expect(harness.controller.session!.ofType('tap_settled'), hasLength(1)); - }, - ); - - test( - 'timed-out route never transfers its waiting tap to a later route', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.controller.recordPointerDown(const Offset(10, 10)); - harness.capturer.blockNext = true; - final timedOut = harness.controller.route( - 'route_push', - harness.route('/timed-out'), - ); - harness.controller.recordPointerUp(const Offset(10, 10)); - await harness.pumpQueueWork(); - await harness.tick(const Duration(seconds: 5)); - await timedOut; - - final next = harness.controller.route( - 'route_push', - harness.route('/next'), - ); - await harness.pumpQueueWork(); - final timedOutSettle = harness.controller.session! - .ofType('tap_settled') - .single; - expect(timedOutSettle.afterFrame, isNull); - expect( - timedOutSettle.data['settleObservation'], - containsPair('route', '/timed-out'), - ); - // Releasing the stale platform readback lets the scheduler run B, but the - // timed-out A waiter must remain terminal rather than joining B. - harness.capturer.completeBlocked('late-a-frame'); - await harness.flushScheduler(); - await next; - - final changes = harness.controller.session!.ofType('route_change'); - expect(changes.map((event) => event.data['route']), [ - '/timed-out', - '/next', - ]); - expect(harness.controller.session!.ofType('tap_settled'), hasLength(1)); - }, - ); - - test( - 'replacement session rejects a capture from the prior route epoch', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.capturer.blockNext = true; - final stale = harness.controller.route( - 'route_push', - harness.route('/stale'), - ); - await harness.pumpQueueWork(); - expect(harness.capturer.blockedCount, 1); - - harness.controller.start(const Size(390, 844), 'replacement'); - await stale; - harness.capturer.completeBlocked('stale-route-frame'); - await harness.flushScheduler(); - - expect(harness.controller.session!.ofType('route_change'), isEmpty); - expect(harness.controller.latestFrameId, isNot('stale-route-frame')); - expect( - harness.controller.session!.frames - .map((frame) => frame.id) - .contains('stale-route-frame'), - isFalse, - ); - }, - ); - - test( - 'route capture failure completes the deadline and later route work', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.capturer.failNext = true; - final failedCapture = harness.controller.route( - 'route_push', - harness.route( - '/first', - transitionDuration: const Duration(milliseconds: 200), - ), - ); - harness.scheduler.advance(const Duration(milliseconds: 700)); - await harness.pumpQueueWork(); - await failedCapture; - - final recovery = harness.controller.route( - 'route_replace', - harness.route('/recovered'), - ); - await harness.flushScheduler(); - await recovery; - - final changes = harness.controller.session!.ofType('route_change'); - expect(changes.map((event) => event.data['route']), [ - '/first', - '/recovered', + expect(harness.controller.session!.frames.map((frame) => frame.id), [ + second, ]); - expect(changes.first.afterFrame, isNull); - expect(changes.first.data['captureOutcome'], 'failed'); - expect(harness.controller.debugRouteCapturePending, isFalse); - expect(harness.scheduler.pendingDelayCount, 0); - }, - ); - - test( - 'modal push/pop and replacement share the route ordering path', - () async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 20), - ); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/home', signature: 'sig-home'); - - final push = harness.controller.route( - 'route_push', - harness.route( - '/modal', - transitionDuration: const Duration(milliseconds: 40), - ), - ); - await harness.flushScheduler(); - await push; - - final pop = harness.controller.route( - 'route_pop', - harness.route( - '/home', - transitionDuration: const Duration(milliseconds: 40), - ), - ); - await harness.flushScheduler(); - await pop; - - final replace = harness.controller.route( - 'route_replace', - harness.route( - '/home2', - transitionDuration: const Duration(milliseconds: 40), - ), - ); - await harness.flushScheduler(); - await replace; - - final navigations = harness.controller.session! - .ofType('route_change') - .map((event) => event.data['navigation']) - .toList(); - expect(navigations, ['route_push', 'route_pop', 'route_replace']); - expect(harness.controller.currentRoute, '/home2'); - expect( - harness.controller.session! - .ofType('route_change') - .every((event) => event.afterFrame != null), - isTrue, - ); - }, - ); - - test( - 'signature-only change with unchanged frame currently reports changed', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - final frame = harness.seedRouteState( - route: '/home', - signature: 'sig-before', - frameContentHash: 'same-pixels', - ); - - harness.controller.recordPointerDown(const Offset(8, 8)); - harness.capturer.frameFactory = (trigger, force) => harness.controller - .debugSeedFrame(contentHash: 'same-pixels', trigger: trigger); - harness.controller.recordPointerUp(const Offset(8, 8)); - await harness.flushScheduler(); - - final settle = harness.controller.session!.ofType('tap_settled').single; - expect(settle.beforeFrame, frame); - expect(settle.afterFrame, isNot(frame)); - expect(settle.result, isNull); - expect((settle.data['settleObservation'] as Map)['visual'], { - 'changed': false, - 'evidence': 'content_hash', - 'reason': 'same_frame', - }); - }, - ); - - test( - 'capture failure does not strand queued waiters or later settles', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/home', signature: 'sig-home'); - harness.capturer.failNext = true; - - harness.controller.recordPointerDown(const Offset(4, 4)); - harness.controller.recordPointerUp(const Offset(4, 4)); - await harness.flushScheduler(); - - final settles = harness.controller.session!.ofType('tap_settled'); - expect(settles, hasLength(1)); - expect(settles.single.afterFrame, isNull); - expect(settles.single.result, isNull); - expect(settles.single.data['frameAttachment'], { - 'after': 'unavailable', - 'reason': 'capture_processing_failed', - }); - final observation = settles.single.data['settleObservation'] as Map; - expect(observation['captureOutcome'], 'failed'); - expect(observation['captureFailure'], 'capture_processing_failed'); - expect(observation['visual'], { - 'changed': null, - 'evidence': 'unavailable', - 'reason': 'unavailable', - }); - expect(harness.controller.debugCaptureInFlight, isFalse); - expect(harness.controller.debugRouteCapturePending, isFalse); - - harness.controller.recordPointerDown(const Offset(5, 5)); - harness.controller.recordPointerUp(const Offset(5, 5)); - await harness.flushScheduler(); - expect(harness.controller.session!.ofType('tap_settled'), hasLength(2)); - }, - ); - - test( - 'blocked capture eventually completes waiters without wall-clock sleeps', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/home', signature: 'sig-home'); - harness.capturer.blockNext = true; - - harness.controller.recordPointerDown(const Offset(6, 6)); - harness.controller.recordPointerUp(const Offset(6, 6)); - await harness.pumpQueueWork(); - - expect(harness.capturer.blockedCount, 1); - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); - expect(harness.controller.debugCaptureInFlight, isTrue); - - harness.capturer.completeBlocked(); - await harness.flushScheduler(); - - expect(harness.controller.session!.ofType('tap_settled'), hasLength(1)); - expect(harness.controller.debugCaptureInFlight, isFalse); - }, - ); - - testWidgets('widget-backed tap and settle share target anchor fingerprint', ( - tester, - ) async { - final harness = ReplayCoherenceHarness(); - await harness.setUpWidgetBacked(tester); - - harness.seedRouteState(route: '/home', signature: 'sig-home'); - - final tapPoint = harness.targetTapPosition(tester); - harness.controller.recordPointerDown(tapPoint); - harness.controller.recordPointerUp(tapPoint); - await harness.flushScheduler(); - - final session = harness.controller.session!; - final tap = session.ofType('tap').single; - final settle = session.ofType('tap_settled').single; - final inventory = session.ofType('scene_inventory').last; - - expect(tap.targetAnchor, isNotNull); - expect(tap.targetAnchor!.fingerprint, isNotNull); - expect(tap.targetAnchor!.fingerprint, isNotEmpty); - expect(tap.targetAnchor!.canonicalPath, isNotEmpty); - expect(tap.targetAnchor!.role, 'button'); - expect(tap.targetAnchor!.widgetType, isNot('RepaintBoundary')); - expect(inventory.data.containsKey('stateSignature'), isFalse); - expect(settle.targetAnchor, isNotNull); - expect(settle.targetAnchor!.fingerprint, tap.targetAnchor!.fingerprint); - expect(settle.targetAnchor!.canonicalPath, tap.targetAnchor!.canonicalPath); - expect(settle.targetAnchor!.role, 'button'); - expect(settle.relatedEventId, tap.id); - - harness.controller.recordPointerDown(tapPoint); - harness.controller.recordPointerUp(tapPoint); - await harness.flushScheduler(); - - final repeatTap = session.ofType('tap').last; - expect(repeatTap.targetAnchor!.fingerprint, tap.targetAnchor!.fingerprint); - expect( - repeatTap.targetAnchor!.canonicalPath, - tap.targetAnchor!.canonicalPath, - ); - expect(repeatTap.targetAnchor!.role, tap.targetAnchor!.role); - - await harness.tearDownWidgetBacked(tester); - }); - - testWidgets('widget-backed pending-route tap keeps linked target anchor', ( - tester, - ) async { - final harness = ReplayCoherenceHarness( - settleDelay: const Duration(milliseconds: 40), - ); - await harness.setUpWidgetBacked(tester); - - harness.seedRouteState(route: '/scan', signature: 'sig-scan'); - - final routeFuture = harness.controller.route( - 'route_push', - harness.route( - '/home', - transitionDuration: const Duration(milliseconds: 150), - ), - ); - expect(harness.controller.debugRouteCapturePending, isTrue); - - final tapPoint = harness.targetTapPosition(tester); - harness.controller.recordPointerDown(tapPoint); - harness.controller.recordPointerUp(tapPoint); - await harness.pumpQueueWork(); - - final session = harness.controller.session!; - final tap = session.ofType('tap').single; - final inventory = session.ofType('scene_inventory').last; - - expect(tap.targetAnchor, isNotNull); - expect(tap.targetAnchor!.fingerprint, isNotNull); - expect(tap.targetAnchor!.fingerprint, isNotEmpty); - expect(tap.targetAnchor!.canonicalPath, isNotEmpty); - expect(tap.targetAnchor!.role, 'button'); - expect(inventory.data.containsKey('stateSignature'), isFalse); - - await harness.flushScheduler(); - await routeFuture; - - final settle = session.ofType('tap_settled').single; - expect(settle.targetAnchor, isNotNull); - expect(settle.targetAnchor!.fingerprint, tap.targetAnchor!.fingerprint); - expect(settle.targetAnchor!.canonicalPath, tap.targetAnchor!.canonicalPath); - expect(settle.targetAnchor!.role, tap.targetAnchor!.role); - expect(settle.relatedEventId, tap.id); - - await harness.tearDownWidgetBacked(tester); - }); - - testWidgets( - 'pointer-down swipe classification suppresses tap_settled with provenance', - (tester) async { - final harness = ReplayCoherenceHarness(); - await harness.setUpWidgetBacked(tester); - - final originFrame = harness.seedRouteState( - route: '/home', - signature: 'sig-home', - frameContentHash: 'home-pixels', - ); - - final start = harness.targetTapPosition(tester); - // Controller classification seam — not InputCapture slop detection. - await harness.recordClassifiedSwipe(start); - - final session = harness.controller.session!; - expect(session.ofType('tap'), isEmpty); - final swipe = session.ofType('swipe').single; - final eventTypes = session.events.map((event) => event.type).toList(); - - expect(session.ofType('tap_settled'), isEmpty); - expect(eventTypes, contains('swipe')); - expect(swipe.relatedEventId, isNull); - expect(swipe.beforeFrame, originFrame); - expect(swipe.targetAnchor, isNotNull); - expect(swipe.data['startCaptureCoordinate'], isA()); - expect(swipe.data['startX'], closeTo(start.dx, 0.01)); - expect(swipe.data['startY'], closeTo(start.dy, 0.01)); - expect(swipe.data['scrolled'], isFalse); - - await harness.tearDownWidgetBacked(tester); - }, - ); - - test( - 'harness timeout seam cancels blocked capture without seeding success', - () async { - final harness = ReplayCoherenceHarness(); - await harness.setUp(); - addTearDown(harness.dispose); - - harness.seedRouteState(route: '/home', signature: 'sig-home'); - harness.capturer.blockNext = true; - // Production has no capture timeout/cancel yet (#10); harness-only seam. - harness.capturer.autoReleaseBlockedAfter = const Duration( - milliseconds: 50, - ); - - final framesBefore = harness.controller.session!.frames.length; - - harness.controller.recordPointerDown(const Offset(6, 6)); - harness.controller.recordPointerUp(const Offset(6, 6)); - await harness.pumpQueueWork(); - - expect(harness.capturer.blockedCount, 1); - expect(harness.controller.session!.ofType('tap_settled'), isEmpty); - expect(harness.controller.debugCaptureInFlight, isTrue); - - await harness.tick(const Duration(milliseconds: 50)); - await harness.flushScheduler(); - - final session = harness.controller.session!; - expect(session.ofType('tap_settled'), hasLength(1)); - expect(harness.capturer.blockedCount, isZero); - expect(harness.controller.debugCaptureInFlight, isFalse); - expect(session.frames.length, framesBefore); expect( - session.frames.any( - (frame) => frame.contentHash.startsWith('timeout-released'), - ), - isFalse, + harness.controller.debugFrameProvenance(first), + containsPair('available', false), ); + expect(harness.controller.debugReuseFrameForCurrentRoute(first), isNull); }, ); } diff --git a/packages/tugboat/test/scene_inventory_test.dart b/packages/tugboat/test/scene_inventory_test.dart index 1d79fb2..e83b726 100644 --- a/packages/tugboat/test/scene_inventory_test.dart +++ b/packages/tugboat/test/scene_inventory_test.dart @@ -21,6 +21,15 @@ class PillButton extends StatelessWidget { } void main() { + setUp(() { + TugboatReplay.debugConfigureControllerForTest = (controller) { + controller.debugExecuteCapture = + ({required trigger, required force}) async => + controller.debugSeedFrame(trigger: trigger); + }; + }); + tearDown(TugboatReplay.resetForTest); + testWidgets('scene inventory lists actionable elements and images', ( tester, ) async { @@ -181,7 +190,6 @@ void main() { (tester) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -205,15 +213,18 @@ void main() { final tapCenter = tester.getCenter(find.text('Go')); controller.recordPointerDown(tapCenter); controller.recordPointerUp(tapCenter); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); final tapEvents = controller.session!.events - .where((event) => event.type == 'tap') + .where( + (event) => + event.type == 'interaction' && event.data['gesture'] == 'tap', + ) .toList(); expect(tapEvents, hasLength(1)); final tapEvent = tapEvents.single; - final tapFingerprint = tapEvent.targetAnchor?.fingerprint; + final tapFingerprint = tapEvent.data['targetFingerprint']; expect(tapFingerprint, isNotEmpty); final inventoryEvents = controller.session!.events @@ -242,7 +253,6 @@ void main() { ) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -269,12 +279,15 @@ void main() { final tapPoint = const Offset(20, 20); controller.recordPointerDown(tapPoint); controller.recordPointerUp(tapPoint); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') + .where( + (event) => + event.type == 'interaction' && event.data['gesture'] == 'tap', + ) .single; - final tapFingerprint = tapEvent.targetAnchor?.fingerprint; + final tapFingerprint = tapEvent.data['targetFingerprint']; expect(tapFingerprint, isNotEmpty); final inventoryEvents = controller.session!.events @@ -298,7 +311,6 @@ void main() { ) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -343,7 +355,6 @@ void main() { ) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -367,17 +378,16 @@ void main() { final tapCenter = tester.getCenter(find.text('Go')); controller.recordPointerDown(tapCenter); controller.recordPointerUp(tapCenter); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); final eventTypes = controller.session!.events.map((event) => event.type); - expect(eventTypes, contains('tap')); + expect(eventTypes, contains('interaction')); expect(eventTypes, isNot(contains('scene_inventory'))); }); testWidgets('scene inventory event is deduped per state', (tester) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/packages/tugboat/test/scroll_attribution_test.dart b/packages/tugboat/test/scroll_attribution_test.dart index b9c030a..1297d7f 100644 --- a/packages/tugboat/test/scroll_attribution_test.dart +++ b/packages/tugboat/test/scroll_attribution_test.dart @@ -4,7 +4,6 @@ import 'package:tugboat/tugboat.dart'; const _scrollTestConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, @@ -266,7 +265,7 @@ void main() { expect(payload['endOffset'], isNot(equals(payload['startOffset']))); }); - testWidgets('dead swipe on static widget emits swipe without tap_settled', ( + testWidgets('dead swipe on static widget emits one swipe interaction', ( tester, ) async { await tester.pumpWidget( @@ -295,10 +294,10 @@ void main() { final session = TugboatReplay.controller!.session!; final swipes = session.events - .where((event) => event.type == 'swipe') - .toList(); - final settled = session.events - .where((event) => event.type == 'tap_settled') + .where( + (event) => + event.type == 'interaction' && event.data['gesture'] == 'swipe', + ) .toList(); final scrollInteractions = session.events .where( @@ -310,17 +309,11 @@ void main() { .toList(); expect(swipes, isNotEmpty); - expect(swipes.first.data['scrolled'], isFalse); - expect(swipes.first.result, TugboatInteractionResult.noVisibleChange); - expect(swipes.first.relatedEventId, isNull); - expect(swipes.first.data['startCaptureCoordinate'], isA()); - expect(settled, isEmpty); + expect(swipes.first.data['gesture'], 'swipe'); expect(scrollInteractions, isEmpty); }); - testWidgets('scroll swipe links legacy swipe to internal scroll tracker', ( - tester, - ) async { + testWidgets('scroll swipe resolves as a scroll interaction', (tester) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => @@ -340,16 +333,20 @@ void main() { await _waitForCaptures(tester); final session = TugboatReplay.controller!.session!; - final swipes = session.events - .where((event) => event.type == 'swipe') + final scrolls = session.events + .where( + (event) => + event.type == 'interaction' && event.data['gesture'] == 'scroll', + ) .toList(); - expect(swipes, isNotEmpty); - expect(swipes.first.data['scrolled'], isTrue); - expect(swipes.first.data['scrollStartEventId'], isNotNull); + expect(scrolls, isNotEmpty); + expect(scrolls.first.data['payload'], isA()); }); - testWidgets('sub-slop tap still emits tap_settled', (tester) async { + testWidgets('sub-slop tap emits one canonical tap interaction', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => @@ -365,12 +362,20 @@ void main() { await _waitForCaptures(tester); final session = TugboatReplay.controller!.session!; - expect(session.events.where((event) => event.type == 'tap'), isNotEmpty); expect( - session.events.where((event) => event.type == 'tap_settled'), - isNotEmpty, + session.events.where( + (event) => + event.type == 'interaction' && event.data['gesture'] == 'tap', + ), + hasLength(1), + ); + expect( + session.events.where( + (event) => + event.type == 'interaction' && event.data['gesture'] == 'swipe', + ), + isEmpty, ); - expect(session.events.where((event) => event.type == 'swipe'), isEmpty); }); testWidgets('TugboatSubView scroll emits scroll interaction', (tester) async { diff --git a/packages/tugboat/test/scroll_playground_live_test.dart b/packages/tugboat/test/scroll_playground_live_test.dart index a7bd1c4..4445d8d 100644 --- a/packages/tugboat/test/scroll_playground_live_test.dart +++ b/packages/tugboat/test/scroll_playground_live_test.dart @@ -8,7 +8,6 @@ import 'package:tugboat/tugboat.dart'; void main() { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, @@ -132,7 +131,7 @@ void main() { (e.data['gesture'] == 'scroll' || e.data['gesture'] == 'swipe' || e.data['gesture'] == 'cancelled')) || - e.type == 'swipe', + false, ) .map( (e) => { @@ -159,8 +158,6 @@ void main() { .length, greaterThanOrEqualTo(2), ); - expect(interesting.where((e) => e['type'] == 'swipe'), isNotEmpty); - // Hero-image drag is inside the outer ListView: parent scroll fires with // overscroll but no offset change — failed scroll intent on static content. final overscrollAtStatic = interesting.where((e) { diff --git a/packages/tugboat/test/sinks/tugboat_capture_sink_test.dart b/packages/tugboat/test/sinks/tugboat_capture_sink_test.dart index b5ffe12..38864e1 100644 --- a/packages/tugboat/test/sinks/tugboat_capture_sink_test.dart +++ b/packages/tugboat/test/sinks/tugboat_capture_sink_test.dart @@ -86,7 +86,11 @@ void main() { captureSessionId: 'cap-1', sessionEpoch: 1, idempotencyKey: 'e1', - event: const TugboatEvent(id: 'e1', atMs: 0, type: 'tap'), + event: const TugboatEvent( + id: 'e1', + atMs: 0, + type: 'capture_diagnostic', + ), ), ); await box.finish(); @@ -96,7 +100,11 @@ void main() { captureSessionId: 'cap-1', sessionEpoch: 1, idempotencyKey: 'e2', - event: const TugboatEvent(id: 'e2', atMs: 1, type: 'tap'), + event: const TugboatEvent( + id: 'e2', + atMs: 1, + type: 'capture_diagnostic', + ), ), ); expect(sink.accepted, hasLength(1)); @@ -114,7 +122,9 @@ void main() { viewport: const TugboatRect(0, 0, 390, 844), ); hub.startSession(session); - hub.recordEvent(const TugboatEvent(id: 'e1', atMs: 0, type: 'tap')); + hub.recordEvent( + const TugboatEvent(id: 'e1', atMs: 0, type: 'capture_diagnostic'), + ); await hub.flush(); await hub.endSession(); expect(good.events, hasLength(1)); diff --git a/packages/tugboat/test/tugboat_replay_test.dart b/packages/tugboat/test/tugboat_replay_test.dart index 03c55cc..22a05f7 100644 --- a/packages/tugboat/test/tugboat_replay_test.dart +++ b/packages/tugboat/test/tugboat_replay_test.dart @@ -17,7 +17,6 @@ import 'helpers/json_roundtrip.dart'; const _testConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -34,6 +33,30 @@ Future _waitForCaptures(WidgetTester tester) async { await tester.pump(); } +void _useSeededCaptures() { + TugboatReplay.debugConfigureControllerForTest = (controller) { + controller.debugExecuteCapture = + ({required trigger, required force}) async => + controller.debugSeedFrame(trigger: trigger); + }; +} + +Future _waitForInteraction(WidgetTester tester) async { + for (var attempt = 0; attempt < 12; attempt++) { + if (TugboatReplay.controller?.session?.events.any( + (event) => event.type == 'interaction', + ) ?? + false) { + return; + } + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pump(); + } +} + /// Drive both Flutter frames and the real async queue until [future] resolves. /// Screenshot readback starts after end-of-frame, so a single delayed pump can /// otherwise leave a fresh attempt waiting for its next compositor turn. @@ -182,7 +205,7 @@ void main() { final controller = TugboatReplay.controller!; final session = controller.session!; expect(session.events.first.type, 'session_start'); - expect(session.events.first.sessionId, session.id); + expect(session.events.first.captureSessionId, session.id); expect(session.frames, isNotEmpty); expect(session.frameBytes, isNotEmpty); expect(session.averageFrameBytes, greaterThan(0)); @@ -192,40 +215,21 @@ void main() { final framesBeforeTap = session.frames.length; await tester.tap(find.text('Continue')); await _waitForCaptures(tester); + await _waitForInteraction(tester); - final tapEvents = session.events.where((e) => e.type == 'tap').toList(); - expect(tapEvents, isNotEmpty); - expect(tapEvents.first.targetAnchor, isNotNull); - expect(tapEvents.first.targetAnchor!.widgetType, isNot('RepaintBoundary')); - expect(tapEvents.first.targetAnchor!.role, 'button'); - expect(tapEvents.first.targetAnchor!.fingerprint, isNotNull); - expect(tapEvents.first.targetAnchor!.fingerprintConfidence, isNotNull); - expect(tapEvents.first.targetAnchor!.canonicalPath, isNotEmpty); - expect( - tapEvents.first.targetAnchor!.fingerprintParts, - containsPair('schemaVersion', tugboatFingerprintSchemaVersion.toString()), - ); - expect( - tapEvents.first.targetAnchor!.fingerprintParts.containsKey('labels'), - isFalse, - ); - expect(_containsLabelTelemetry(session.toJson()), isFalse); - expect(tapEvents.first.targetAnchor!.relativePosition, isNotNull); - expect(tapEvents.first.beforeFrame, isNotNull); - - final settled = session.events - .where((e) => e.type == 'tap_settled') + final interactions = session.events + .where((e) => e.type == 'interaction') .toList(); - expect(settled, isNotEmpty); - expect(settled.first.relatedEventId, tapEvents.first.id); - expect(settled.first.targetAnchor?.role, 'button'); + expect(interactions, isNotEmpty); + expect(interactions.first.data['targetFingerprint'], isNotEmpty); + expect(_containsLabelTelemetry(session.toJson()), isFalse); + expect(interactions.first.beforeFrame, isNotNull); + expect(interactions.first.afterFrame, isNotNull); + expect(session.frames.length, greaterThan(framesBeforeTap)); expect( - settled.first.targetAnchor?.canonicalPath, - tapEvents.first.targetAnchor?.canonicalPath, + interactions.first.afterFrame, + isNot(interactions.first.beforeFrame), ); - expect(settled.first.afterFrame, isNotNull); - expect(session.frames.length, greaterThan(framesBeforeTap)); - expect(settled.first.afterFrame, isNot(tapEvents.first.beforeFrame)); }); testWidgets( @@ -940,6 +944,8 @@ void main() { testWidgets('does not record icon or tooltip labels on icon button taps', ( tester, ) async { + _useSeededCaptures(); + addTearDown(TugboatReplay.resetForTest); await tester.pumpWidget( MaterialApp( builder: (context, child) => @@ -957,17 +963,17 @@ void main() { await tester.tap(find.byIcon(Icons.notifications_outlined)); await _waitForCaptures(tester); - final anchor = TugboatReplay.controller!.session!.events - .firstWhere((event) => event.type == 'tap') - .targetAnchor!; - expect(anchor.role, 'button'); - expect(anchor.fingerprint, isNotNull); - expect(_containsLabelTelemetry(anchor.toJson()), isFalse); + final interaction = TugboatReplay.controller!.session!.events.firstWhere( + (event) => event.type == 'interaction', + ); + expect(interaction.data['targetFingerprint'], isNotNull); }); testWidgets('does not record descendant labels from list tile taps', ( tester, ) async { + _useSeededCaptures(); + addTearDown(TugboatReplay.resetForTest); await tester.pumpWidget( MaterialApp( builder: (context, child) => @@ -985,16 +991,17 @@ void main() { await tester.tap(find.byType(ListTile)); await _waitForCaptures(tester); - final anchor = TugboatReplay.controller!.session!.events - .firstWhere((event) => event.type == 'tap') - .targetAnchor!; - expect(anchor.role, 'button'); - expect(_containsLabelTelemetry(anchor.toJson()), isFalse); + final interaction = TugboatReplay.controller!.session!.events.firstWhere( + (event) => event.type == 'interaction', + ); + expect(interaction.data['targetFingerprint'], isNotNull); }); testWidgets('does not emit control or semantic value telemetry', ( tester, ) async { + _useSeededCaptures(); + addTearDown(TugboatReplay.resetForTest); var enabled = false; await tester.pumpWidget( MaterialApp( @@ -1015,14 +1022,12 @@ void main() { await _waitForCaptures(tester); final session = TugboatReplay.controller!.session!; - final tap = session.events.firstWhere((event) => event.type == 'tap'); - final settled = session.events.firstWhere( - (event) => event.type == 'tap_settled' && event.relatedEventId == tap.id, + final interaction = session.events.firstWhere( + (event) => event.type == 'interaction', ); expect(enabled, isTrue); - expect(tap.targetAnchor, isNotNull); - expect(settled.targetAnchor, isNotNull); - for (final event in [tap, settled]) { + expect(interaction.data['targetFingerprint'], isNotNull); + for (final event in [interaction]) { expect(event.data.containsKey('controlValue'), isFalse); expect(event.data.containsKey('controlValueTransition'), isFalse); expect(event.data.containsKey('semanticAnnotation'), isFalse); @@ -1086,10 +1091,15 @@ void main() { ); final json = session.toJson(); - expect( - () => TugboatSessionTestJson.fromJson({...json, 'schemaVersion': 5}), - throwsFormatException, - ); + for (final version in [5, 6, 7, 8, 9]) { + expect( + () => TugboatSessionTestJson.fromJson({ + ...json, + 'schemaVersion': version, + }), + throwsFormatException, + ); + } final withoutVersion = Map.from(json) ..remove('schemaVersion'); expect( @@ -1098,6 +1108,15 @@ void main() { ); }); + test('event stream parser rejects missing and legacy values', () { + expect(() => TugboatEventStream.parse(null), throwsFormatException); + expect( + () => TugboatEventStream.parse('legacy_projection'), + throwsFormatException, + ); + expect(() => TugboatEventStream.parse('unknown'), throwsFormatException); + }); + test('frame JSON defaults missing capture timing for older sessions', () { final frame = TugboatFrameTestJson.fromJson({ 'id': 'frame-0', @@ -1180,13 +1199,13 @@ void main() { const event = TugboatEvent( id: 'event-1', atMs: 10, - type: 'tap', - sessionId: 's1', + type: 'interaction', + captureSessionId: 's1', explorationRunId: 'run-1', actionId: 'A-1', ); final restored = TugboatEventTestJson.fromJson(event.toJson()); - expect(restored.sessionId, 's1'); + expect(restored.captureSessionId, 's1'); expect(restored.toJson().containsKey('route'), isFalse); expect(restored.explorationRunId, 'run-1'); expect(restored.actionId, 'A-1'); @@ -1652,14 +1671,14 @@ void main() { controller.recordPointerUp(const Offset(5, 5), pointer: 1); await controller.drainPointerQueue(); - final settles = controller.session!.events - .where((event) => event.type == 'tap_settled') - .toList(); - expect(settles, hasLength(1)); + expect( + controller.session!.events.where((event) => event.type == 'interaction'), + hasLength(1), + ); controller.dispose(); }); - test('overlapping taps keep pointer-specific relatedEventId links', () async { + test('overlapping taps publish one interaction per pointer', () async { final rootKey = GlobalKey(); final controller = TugboatReplayController( config: _testConfig, @@ -1675,16 +1694,10 @@ void main() { controller.recordPointerUp(const Offset(2, 2), pointer: 2); await controller.drainPointerQueue(); - final taps = controller.session!.events - .where((event) => event.type == 'tap') - .toList(); - final settles = controller.session!.events - .where((event) => event.type == 'tap_settled') - .toList(); - expect(taps, hasLength(2)); - expect(settles, hasLength(2)); - expect(settles[0].relatedEventId, taps[0].id); - expect(settles[1].relatedEventId, taps[1].id); + expect( + controller.session!.events.where((event) => event.type == 'interaction'), + hasLength(2), + ); controller.dispose(); }); } diff --git a/packages/tugboat/test/viewport_semantic_map_test.dart b/packages/tugboat/test/viewport_semantic_map_test.dart index 1037a68..dfbebe7 100644 --- a/packages/tugboat/test/viewport_semantic_map_test.dart +++ b/packages/tugboat/test/viewport_semantic_map_test.dart @@ -6,7 +6,6 @@ import 'package:tugboat/src/viewport_semantic_session.dart'; const _semanticMapConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -16,7 +15,6 @@ const _semanticMapConfig = TugboatReplayConfig( const _semanticMapConfigWithLogs = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -26,7 +24,6 @@ const _semanticMapConfigWithLogs = TugboatReplayConfig( const _scrollSemanticMapConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -100,6 +97,15 @@ Future _pumpSettledScreen( } void main() { + setUp(() { + TugboatReplay.debugConfigureControllerForTest = (controller) { + controller.debugExecuteCapture = + ({required trigger, required force}) async => + controller.debugSeedFrame(trigger: trigger); + }; + }); + tearDown(TugboatReplay.resetForTest); + testWidgets( 'enabling viewport semantic map emits event after settled screen', (tester) async { @@ -144,21 +150,18 @@ void main() { final tapCenter = tester.getCenter(find.text('Go')); controller.recordPointerDown(tapCenter); controller.recordPointerUp(tapCenter); - await tester.pump(); + await _waitForCaptures(tester); + await tester.pump(const Duration(milliseconds: 350)); - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') + final interaction = controller.session!.events + .where( + (event) => + event.type == 'interaction' && event.data['gesture'] == 'tap', + ) .single; - final resolution = - tapEvent.data['viewportSemanticResolution'] as Map?; - expect(resolution, isNotNull); - expect(resolution!['status'], 'matched_actionable'); - expect(resolution['linkedFingerprint'], isNotEmpty); - expect(resolution['role'], 'button'); - - final tapFingerprint = tapEvent.targetAnchor?.fingerprint; + + final tapFingerprint = interaction.data['targetFingerprint']; expect(tapFingerprint, isNotEmpty); - expect(resolution['linkedFingerprint'], tapFingerprint); }); testWidgets('tap resolution rebuilds the same-route semantic map', ( @@ -199,16 +202,12 @@ void main() { final tapCenter = tester.getCenter(find.text('Bottom action')); controller.recordPointerDown(tapCenter); controller.recordPointerUp(tapCenter); - await tester.pump(); - - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') - .last; - final resolution = - tapEvent.data['viewportSemanticResolution'] as Map?; - expect(resolution, isNotNull); - expect(resolution!['status'], 'matched_actionable'); - expect(resolution['role'], 'button'); + await _waitForCaptures(tester); + await tester.pump(const Duration(milliseconds: 350)); + expect( + controller.session!.events.where((event) => event.type == 'interaction'), + hasLength(1), + ); }); testWidgets('tap on non-actionable text resolves to matched_non_actionable', ( @@ -252,15 +251,8 @@ void main() { ); controller.recordPointerDown(tapPoint); controller.recordPointerUp(tapPoint); - await tester.pump(); - - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') - .single; - final resolution = - tapEvent.data['viewportSemanticResolution'] as Map?; - expect(resolution, isNotNull); - expect(resolution!['status'], 'matched_non_actionable'); + await _waitForCaptures(tester); + await tester.pump(const Duration(milliseconds: 350)); expect( controller.session!.events .where((event) => event.type == 'viewport_semantic_map') @@ -281,15 +273,12 @@ void main() { final bottomRight = tester.getBottomRight(find.byType(Scaffold)); controller.recordPointerDown(bottomRight - const Offset(2, 2)); controller.recordPointerUp(bottomRight - const Offset(2, 2)); - await tester.pump(); - - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') - .single; - final resolution = - tapEvent.data['viewportSemanticResolution'] as Map?; - expect(resolution, isNotNull); - expect(resolution!['status'], 'outside_known_ui'); + await _waitForCaptures(tester); + await tester.pump(const Duration(milliseconds: 350)); + expect( + controller.session!.events.where((event) => event.type == 'interaction'), + hasLength(1), + ); }); testWidgets('inventory fallback covers gesture controls missing semantics', ( @@ -344,16 +333,13 @@ void main() { ); controller.recordPointerDown(ctaCenter); controller.recordPointerUp(ctaCenter); - await tester.pump(); + await _waitForCaptures(tester); + await tester.pump(const Duration(milliseconds: 350)); - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') + final interaction = controller.session!.events + .where((event) => event.type == 'interaction') .last; - final resolution = - tapEvent.data['viewportSemanticResolution'] as Map?; - expect(resolution, isNotNull); - expect(resolution!['status'], 'matched_actionable'); - expect(resolution['linkedFingerprint'], isNotEmpty); + expect(interaction.data['targetFingerprint'], isNotEmpty); }); testWidgets('dormant profile stays off with default semantic mode', ( @@ -395,11 +381,6 @@ void main() { controller.recordPointerDown(tapCenter); controller.recordPointerUp(tapCenter); await tester.pump(); - - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') - .last; - expect(tapEvent.data.containsKey('viewportSemanticResolution'), isFalse); }); testWidgets('production default resolves taps without emitting maps', ( @@ -410,7 +391,6 @@ void main() { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -431,7 +411,8 @@ void main() { final tapCenter = tester.getCenter(find.text('Go')); controller.recordPointerDown(tapCenter); controller.recordPointerUp(tapCenter); - await tester.pump(); + await _waitForCaptures(tester); + await tester.pump(const Duration(milliseconds: 350)); final mapEvents = controller.session!.events .where( @@ -442,16 +423,10 @@ void main() { .toList(); expect(mapEvents, isEmpty); - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') + final interaction = controller.session!.events + .where((event) => event.type == 'interaction') .last; - final resolution = - tapEvent.data['viewportSemanticResolution'] as Map?; - expect(resolution, isNotNull); - expect(resolution!['status'], 'matched_actionable'); - // Verdict payload must remain text-free. - expect(resolution.keys, isNot(contains('label'))); - expect(resolution.keys, isNot(contains('text'))); + expect(interaction.data['targetFingerprint'], isNotEmpty); }); testWidgets( @@ -462,7 +437,6 @@ void main() { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -488,7 +462,8 @@ void main() { final controller = TugboatReplay.controller!; controller.recordPointerDown(tester.getCenter(find.text('Go'))); controller.recordPointerUp(tester.getCenter(find.text('Go'))); - await tester.pump(); + await _waitForCaptures(tester); + await tester.pump(const Duration(milliseconds: 350)); final mapEvents = controller.session!.events .where( @@ -499,10 +474,10 @@ void main() { .toList(); expect(mapEvents, isEmpty); - final tapEvent = controller.session!.events - .where((event) => event.type == 'tap') + final interaction = controller.session!.events + .where((event) => event.type == 'interaction') .last; - expect(tapEvent.data['viewportSemanticResolution'], isNotNull); + expect(interaction.data['gesture'], 'tap'); }, ); @@ -514,7 +489,6 @@ void main() { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, - interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, From 834029ca832260aaacce6bf4fd5dacaf0021aa3f Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Tue, 11 Aug 2026 14:12:03 +0530 Subject: [PATCH 10/10] Address Copilot review feedback --- packages/tugboat/README.md | 7 ++-- .../tugboat/lib/src/collector_mapper.dart | 1 + .../lib/src/viewport_semantic_session.dart | 30 +++++++++-------- .../tugboat/test/collector_mapper_test.dart | 2 ++ ...overlay_nested_navigation_matrix_test.dart | 33 ++++++++++++++++--- .../test/viewport_semantic_map_test.dart | 21 ++++++++++++ 6 files changed, 71 insertions(+), 23 deletions(-) diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index bc03f7c..7b4a24b 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -12,10 +12,9 @@ version `6`. ## 0.8.0 raw-event compatibility New writers omit `stateAnchor`, `stateSignature`, and `state_change` events. -Legacy public state model types remain available for source compatibility, but -new recordings do not write them. Each completed tap, swipe, and scroll -requests its own fresh after-frame. The collector mapper also omits the top- -level `stateAnchor` key. Deploy the related collector change with this SDK +The old public state model types are removed. Each completed tap, swipe, and +scroll requests its own fresh after-frame. The collector mapper also omits the +top-level `stateAnchor` key. Deploy the related collector change with this SDK release. Schema-v2 collector events (`interaction`, `route_change`) are flat facts-only diff --git a/packages/tugboat/lib/src/collector_mapper.dart b/packages/tugboat/lib/src/collector_mapper.dart index 96fe11c..c509add 100644 --- a/packages/tugboat/lib/src/collector_mapper.dart +++ b/packages/tugboat/lib/src/collector_mapper.dart @@ -60,6 +60,7 @@ Map mapTugboatEventToCollectorEvent({ if (data['fromRoute'] != null) 'fromRoute': data['fromRoute'], if (data['route'] != null) 'route': data['route'], if (data['navigation'] != null) 'navigation': data['navigation'], + if (data['causeEventId'] != null) 'causeEventId': data['causeEventId'], }, ); } diff --git a/packages/tugboat/lib/src/viewport_semantic_session.dart b/packages/tugboat/lib/src/viewport_semantic_session.dart index 2a5486a..5c92b52 100644 --- a/packages/tugboat/lib/src/viewport_semantic_session.dart +++ b/packages/tugboat/lib/src/viewport_semantic_session.dart @@ -157,22 +157,24 @@ class ViewportSemanticSession { final dedupeKey = '${map.routeKey}|${map.mapHash}|${map.scrollContext?.dedupeKey ?? ''}'; - if (!_emittedSemanticMaps.add(dedupeKey)) return; - - addEvent( - TugboatEvent( - id: nextEventId('event'), - atMs: atMs(), - type: 'viewport_semantic_map', - data: encodedPayload ?? map.toJson(), - ), - ); - if (debugLogs) { - tugboatLogViewportSemanticMap( - map, - buildMs: buildStopwatch.elapsedMilliseconds, + if (_emittedSemanticMaps.add(dedupeKey)) { + addEvent( + TugboatEvent( + id: nextEventId('event'), + atMs: atMs(), + type: 'viewport_semantic_map', + data: encodedPayload ?? map.toJson(), + ), ); + if (debugLogs) { + tugboatLogViewportSemanticMap( + map, + buildMs: buildStopwatch.elapsedMilliseconds, + ); + } } + // A new scroll gesture can revisit a slice that was already published. + // Keep gesture accumulation independent from event-level map deduplication. _recordScrollSemanticSlice(map); } diff --git a/packages/tugboat/test/collector_mapper_test.dart b/packages/tugboat/test/collector_mapper_test.dart index cb8fe40..0b9aecb 100644 --- a/packages/tugboat/test/collector_mapper_test.dart +++ b/packages/tugboat/test/collector_mapper_test.dart @@ -146,6 +146,7 @@ void main() { 'fromRoute': '/home', 'route': '/settings', 'navigation': 'route_push', + 'causeEventId': 'event-interaction-1', 'navigatorId': 'nav-1', 'captureOutcome': 'captured', 'navigationOrigin': 'user_gesture', @@ -165,6 +166,7 @@ void main() { expect(mapped['fromRoute'], '/home'); expect(mapped['route'], '/settings'); expect(mapped['navigation'], 'route_push'); + expect(mapped['causeEventId'], 'event-interaction-1'); expect(mapped['afterFrame'], 'frame-8'); expect(mapped.containsKey('result'), isFalse); expect(mapped.containsKey('payload'), isFalse); diff --git a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart index e8f4075..74992a9 100644 --- a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart @@ -11,31 +11,45 @@ void main() { (tester) async { final fixture = await _OverlayFixture.mount(tester); + var eventCursor = fixture.eventCount; await tester.tap(find.byKey(_openDialog)); await tester.pumpAndSettle(); - final dialog = await fixture.route(tester, '/dialog'); + final dialog = await fixture.route( + tester, + '/dialog', + afterIndex: eventCursor, + ); fixture.expectOwned(dialog, '/dialog'); + eventCursor = fixture.eventCount; await tester.tap(find.byKey(_closeDialog)); await tester.pumpAndSettle(); final dialogPop = await fixture.route( tester, '/root', navigation: 'route_pop', + afterIndex: eventCursor, ); fixture.expectOwned(dialogPop, '/root'); + eventCursor = fixture.eventCount; await tester.tap(find.byKey(_openSheet)); await tester.pumpAndSettle(); - final sheet = await fixture.route(tester, '/sheet'); + final sheet = await fixture.route( + tester, + '/sheet', + afterIndex: eventCursor, + ); fixture.expectOwned(sheet, '/sheet'); + eventCursor = fixture.eventCount; await tester.tap(find.byKey(_closeSheet)); await tester.pumpAndSettle(); final sheetPop = await fixture.route( tester, '/root', navigation: 'route_pop', + afterIndex: eventCursor, ); fixture.expectOwned(sheetPop, '/root'); }, @@ -47,10 +61,15 @@ void main() { final fixture = await _OverlayFixture.mount(tester); await tester.tap(find.byKey(_openNested)); await tester.pumpAndSettle(); + final eventCursor = fixture.eventCount; await tester.tap(find.byKey(_openNested)); await tester.pumpAndSettle(); - final change = await fixture.route(tester, '/nested/details'); + final change = await fixture.route( + tester, + '/nested/details', + afterIndex: eventCursor, + ); fixture.expectOwned(change, '/nested/details'); }); } @@ -69,6 +88,8 @@ class _OverlayFixture { TugboatSession get session => controller.session!; + int get eventCount => session.events.length; + static Future<_OverlayFixture> mount(WidgetTester tester) async { final nestedObserver = TugboatNavigatorObserver(); await tester.pumpWidget( @@ -116,9 +137,11 @@ class _OverlayFixture { WidgetTester tester, String name, { String navigation = 'route_push', + required int afterIndex, }) => _pumpUntil(tester, () { - for (final event in _ofType(session, 'route_change')) { - if (event.data['route'] == name && + for (final event in session.events.skip(afterIndex)) { + if (event.type == 'route_change' && + event.data['route'] == name && event.data['navigation'] == navigation) { return event; } diff --git a/packages/tugboat/test/viewport_semantic_map_test.dart b/packages/tugboat/test/viewport_semantic_map_test.dart index dfbebe7..e64f2f2 100644 --- a/packages/tugboat/test/viewport_semantic_map_test.dart +++ b/packages/tugboat/test/viewport_semantic_map_test.dart @@ -889,6 +889,27 @@ void main() { .toList(); expect(newSnapshots, isNotEmpty); expect(newSnapshots.first.data['observedSliceCount'], 2); + + final snapshotCount = firstSnapshotCount + newSnapshots.length; + semanticSession.maybeEmit( + inventory, + resolver: resolver, + scrollContext: start, + ); + semanticSession.maybeEmit( + inventory, + resolver: resolver, + scrollContext: const TugboatViewportSemanticScrollContext( + trigger: 'scroll_update', + scrollableFingerprint: 'fp-list', + axis: 'vertical', + offsetNorm: 0.6, + ), + ); + expect( + emitted.where((event) => event.type == 'scroll_semantic_snapshot'), + hasLength(snapshotCount + 1), + ); semantics.dispose(); }); }