@@ -259,18 +259,95 @@ class TugboatReplayController extends ChangeNotifier {
259259 _currentStateAnchor = anchor;
260260 }
261261
262+ /// When true, [_refreshStateAnchor] keeps the last planted state instead of
263+ /// rebuilding from the widget tree. Characterization tests use this when
264+ /// driving the controller without a mounted scene.
265+ @visibleForTesting
266+ bool debugFreezeStateAnchor = false ;
267+
262268 @visibleForTesting
263269 void debugSetExplorationFramesSuppressed (bool suppressed) {
264270 _explorationFramesSuppressed = suppressed;
265271 }
266272
273+ /// Test-only clock for capture scheduling. Defaults to [DateTime.now] .
274+ @visibleForTesting
275+ DateTime Function ()? debugNow;
276+
277+ /// Test-only delay primitive used by route and capture waits.
278+ ///
279+ /// Defaults to [Future.delayed] . Harnesses should make each delay
280+ /// explicitly advanceable so tests never rely on wall-clock sleeps.
281+ @visibleForTesting
282+ Future <void > Function (Duration duration)? debugDelay;
283+
284+ /// Test-only capture executor. When set, replaces screenshot readback while
285+ /// preserving the production request/queue/pending-route control flow.
286+ @visibleForTesting
287+ Future <String ?> Function ({
288+ required TugboatFrameTrigger trigger,
289+ required bool force,
290+ })?
291+ debugExecuteCapture;
292+
293+ @visibleForTesting
294+ int get debugRouteEpoch => _routeEpoch;
295+
296+ @visibleForTesting
297+ bool get debugRouteCapturePending => _routeCapturePending;
298+
299+ @visibleForTesting
300+ bool get debugCaptureInFlight => _captureInFlight;
301+
267302 @visibleForTesting
268303 Future <void > drainPointerQueue () => _queue;
269304
270305 @visibleForTesting
271306 Future <void > debugEnqueueTask (String label, Future <void > Function () task) =>
272307 _enqueue (label, task);
273308
309+ /// Plants a synthetic frame for characterization tests that drive the
310+ /// controller without real screenshot readback.
311+ @visibleForTesting
312+ String debugSeedFrame ({
313+ String ? contentHash,
314+ TugboatFrameTrigger trigger = TugboatFrameTrigger .manual,
315+ int width = 10 ,
316+ int height = 10 ,
317+ }) {
318+ final session = _session;
319+ if (session == null ) {
320+ throw StateError ('debugSeedFrame requires an active session' );
321+ }
322+ final frameId = _nextId ('frame' );
323+ final hash = contentHash ?? 'hash-$frameId ' ;
324+ final frame = TugboatFrame (
325+ id: frameId,
326+ atMs: atMs,
327+ width: width,
328+ height: height,
329+ contentHash: hash,
330+ trigger: trigger,
331+ byteLength: 0 ,
332+ captureSessionId: session.id,
333+ );
334+ session.frames.add (frame);
335+ session.frameBytes[frameId] = Uint8List (0 );
336+ _hashToFrameId[hash] = frameId;
337+ _latestFrameId = frameId;
338+ return frameId;
339+ }
340+
341+ DateTime _now () => debugNow? .call () ?? DateTime .now ();
342+
343+ Future <void > _delay (Duration duration) {
344+ // Preserve Future.delayed(Duration.zero) yielding semantics; never sync-complete.
345+ final effective = duration < Duration .zero ? Duration .zero : duration;
346+ final override = debugDelay;
347+ if (override != null ) return override (effective);
348+ return Future <void >.delayed (effective);
349+ }
350+
274351 /// Serializes [task] on the controller queue while guaranteeing that a
275352 /// failure in one task never poisons the chain: an uncaught error in a
276353 /// plain `_queue.then(...)` would turn `_queue` into an errored future and
@@ -530,6 +607,7 @@ class TugboatReplayController extends ChangeNotifier {
530607 }
531608
532609 TugboatStateAnchor ? _refreshStateAnchor () {
610+ if (debugFreezeStateAnchor) return _currentStateAnchor;
533611 final resolver = _anchorResolver;
534612 if (resolver == null ) return _currentStateAnchor;
535613 final keyboardOpen = _isKeyboardOpen ();
@@ -569,7 +647,7 @@ class TugboatReplayController extends ChangeNotifier {
569647 }
570648
571649 final delay = settleDelay ?? config.settleDelay;
572- final notBefore = DateTime . now ().add (delay);
650+ final notBefore = _now ().add (delay);
573651 final completer = Completer <String ?>();
574652 final incoming = _ScheduledCapture (
575653 trigger: trigger,
@@ -598,9 +676,9 @@ class TugboatReplayController extends ChangeNotifier {
598676 _capturePumpScheduled = false ;
599677 while (! _disposed && _scheduledCapture != null ) {
600678 final scheduled = _scheduledCapture! ;
601- final wait = scheduled.notBefore.difference (DateTime . now ());
679+ final wait = scheduled.notBefore.difference (_now ());
602680 if (wait > Duration .zero) {
603- await Future < void >. delayed (wait);
681+ await _delay (wait);
604682 }
605683 if (_disposed) break ;
606684
@@ -636,14 +714,43 @@ class TugboatReplayController extends ChangeNotifier {
636714
637715 Future <void > _waitForCaptureIdle () async {
638716 while (_captureInFlight && ! _disposed) {
639- await Future < void >. delayed (const Duration (milliseconds: 16 ));
717+ await _delay (const Duration (milliseconds: 16 ));
640718 }
641719 }
642720
643721 Future <String ?> _executeCapture ({
644722 required TugboatFrameTrigger trigger,
645723 bool force = false ,
646724 }) async {
725+ final captureOverride = debugExecuteCapture;
726+ if (captureOverride != null ) {
727+ if (_disposed ||
728+ _capturePaused ||
729+ _skipCapture ||
730+ _shouldSuppressFrameCapture ||
731+ _captureInFlight) {
732+ return _latestFrameId;
733+ }
734+ _captureInFlight = true ;
735+ try {
736+ // Match the production capture path: refresh state before capture and
737+ // emit inventory after, so the override seam does not leave anchors
738+ // stale relative to real screenshot execution.
739+ _refreshStateAnchor ();
740+ final frameId = await captureOverride (trigger: trigger, force: force);
741+ if (frameId != null ) {
742+ _latestFrameId = frameId;
743+ }
744+ _maybeEmitSceneInventory ();
745+ return frameId ?? _latestFrameId;
746+ } finally {
747+ _captureInFlight = false ;
748+ if (_scheduledCapture != null ) {
749+ _ensureCapturePumpScheduled ();
750+ }
751+ }
752+ }
753+
647754 if (_disposed ||
648755 _capturePaused ||
649756 _skipCapture ||
@@ -676,8 +783,9 @@ class TugboatReplayController extends ChangeNotifier {
676783
677784 final queueStarted = DateTime .now ();
678785 await capturer.waitForFrameBudget ();
679- final queueWaitMicros =
680- DateTime .now ().difference (queueStarted).inMicroseconds;
786+ final queueWaitMicros = DateTime .now ()
787+ .difference (queueStarted)
788+ .inMicroseconds;
681789 if (_disposed || _capturePaused || _skipCapture) return _latestFrameId;
682790 _refreshStateAnchor ();
683791 final signature = _currentStateAnchor? .signature ?? '' ;
@@ -1292,9 +1400,7 @@ class TugboatReplayController extends ChangeNotifier {
12921400 : config.settleDelay;
12931401 return _enqueue ('route_change' , () async {
12941402 try {
1295- await Future <void >.delayed (
1296- transition.transitionDuration + postRouteSettle,
1297- );
1403+ await _delay (transition.transitionDuration + postRouteSettle);
12981404 _skipCapture = false ;
12991405 if (_disposed) return ;
13001406 if (epoch != _routeEpoch) return ;
0 commit comments