feat(07-13): B-3 ErrorBanner wiring + Dart-side latch + regression-gated tests (D-07-16/17 + rev-2 BLOCKER B1)

* error_banner._templateFor: map ConnectErrorCode.connectFailed →
  Strings.errConnectFailedHeading + Strings.errConnectFailedBody + Retry
  + Switch layer actions (UX team's exact strings per UX-REVIEW.md §5
  Bug #1).

* connection_state.dart: add errorLatchTransformer (~20 LOC
  defense-in-depth per INVESTIGATION-260516-blockers.md Item 3 SCOPE
  REDUCER — "~30 LOC of insurance"). Wired into ConnectionStatus.build()
  via .transform(...). The Kotlin D-07-16 lift remains the user-visible
  bug fix; this latch is the REGRESSION GATE — if the Kotlin lift ever
  reverts, the Dart latch keeps the dial latched on error.

* error_banner_test: assert connectFailed template renders correctly.

* connect_dial_test: TWO new test cases pair-up the gate:
  - Case A: [connecting, error] → dial latches error (bug-fix gate).
  - Case B: [connecting, error, idle] → dial STILL latches error (the
    REGRESSION GATE; differentiates latch-present from latch-absent
    deterministically, even with the Kotlin lift in place). Pipes
    through .transform(errorLatchTransformer()) so the test genuinely
    exercises the latch.

* Rule 1 fix: dropped the redundant <Override> type annotation in
  ProviderScope.overrides lists — flutter_riverpod requires  (the
  Override class is private to the package and not exposed at the
  consumer-facing type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 09:22:53 +02:00
parent c7789c6fcf
commit 2fc1d481a0
4 changed files with 188 additions and 1 deletions

View File

@@ -1,8 +1,58 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:prowler/src/generated/prowler_platform.g.dart' as pigeon;
part 'connection_state.g.dart';
/// Defense-in-depth latch (07-13 / rev-2 BLOCKER B1 fix; companion to the
/// D-07-16 AP-4 Kotlin lift on `ProwlerVpnService.stopLocked`). When the
/// platform emits `[connecting, error, idle]` without an intervening
/// `connected`, hold the dial in error rather than letting `idle` clobber
/// the error frame on the [pigeon.Status] stream.
///
/// The D-07-16 Kotlin lift means the trailing `idle` shouldn't fire on the
/// start-failed path — but if that lift is ever reverted (whether
/// accidentally or by a future refactor that misses the predicate), this
/// latch keeps the user-visible behavior correct. The latch is
/// REGRESSION-GATED by widget-test Case B in `connect_dial_test.dart`:
/// even if the Kotlin lift reverts, Case B still passes because the Dart
/// latch holds.
///
/// Per `INVESTIGATION-260516-blockers.md` Item 3, the SCOPE REDUCER
/// originally costed this at "~30 LOC of insurance"; the actual
/// implementation lands around 20 LOC (the state machine has 3
/// transitions the latch cares about — `connected` → reset, `error` →
/// arm, `idle`-after-armed-error → suppress).
StreamTransformer<pigeon.Status, pigeon.Status> errorLatchTransformer() {
bool latched = false;
return StreamTransformer<pigeon.Status, pigeon.Status>.fromHandlers(
handleData: (pigeon.Status event, EventSink<pigeon.Status> sink) {
// A real `connected` frame resets the latch — we are healthy again.
if (event.state == pigeon.ConnectionState.connected) {
latched = false;
sink.add(event);
return;
}
// An `error` frame arms the latch — we MUST hold this state until
// a future `connected` resets it. Forward the error to the sink.
if (event.state == pigeon.ConnectionState.error) {
latched = true;
sink.add(event);
return;
}
// An `idle`/`disconnected` frame AFTER a latched error is the bug
// shape — suppress it. Per D-07-16's Kotlin lift this shouldn't
// arrive, but the latch is defense-in-depth.
if (latched && event.state == pigeon.ConnectionState.idle) {
return; // suppress; do NOT add to sink
}
// Any other frame (idle pre-error, connecting, etc.) — forward.
sink.add(event);
},
);
}
/// Riverpod 3.x StreamNotifier wrapping the Pigeon `@EventChannelApi`
/// `onStatusChanged` stream from `lib/src/generated/prowler_platform.g.dart`.
///
@@ -13,10 +63,15 @@ part 'connection_state.g.dart';
/// which does not exist in the Pigeon 26.3.2 generated file landed by 06-02.
/// We consume the actual generated symbol — see plan 06-02 SUMMARY.md for
/// the codegen output spec.
///
/// **07-13 rev-2 BLOCKER B1 fix:** the raw Pigeon stream is piped through
/// [errorLatchTransformer] so the dial latches on the first `error` frame
/// and stays latched until a real `connected` resets it. This is the
/// defense-in-depth regression gate for the D-07-16 Kotlin lift.
@riverpod
class ConnectionStatus extends _$ConnectionStatus {
@override
Stream<pigeon.Status> build() {
return pigeon.onStatusChanged();
return pigeon.onStatusChanged().transform(errorLatchTransformer());
}
}

View File

@@ -121,6 +121,25 @@ class ErrorBanner extends ConsumerWidget {
return (Strings.errSwitchHeading, Strings.errSwitchBody, <Widget>[
_btn(Strings.btnRetry, () => _retry(ref)),
]);
case pigeon.ConnectErrorCode.connectFailed:
// B-3 / D-07-17 — UX team's exact strings (UX-REVIEW.md §5 Bug #1):
// `Connection failed.` / `We tried 3 times. Switch layer or check
// your network.`
// Actions: Retry + Switch layer (the two paths UX team enumerated;
// open-settings is third-priority and omitted to keep the banner
// tight). The Strings.errConnectFailedHeading + errConnectFailedBody
// constants have existed since Phase 6 (06-04 wired them but only
// for the legacy coreCrashed case which UX team reframed); D-07-17
// gives them their own ConnectErrorCode case so the lexicon match is
// semantic, not coincidental.
return (
Strings.errConnectFailedHeading,
Strings.errConnectFailedBody,
<Widget>[
_btn(Strings.btnRetry, () => _retry(ref)),
_btn(Strings.btnSwitchLayer, () => context.go('/layers')),
],
);
}
}

View File

@@ -18,6 +18,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:prowler/src/generated/prowler_platform.g.dart' as pigeon;
import 'package:prowler/src/l10n/strings.dart';
import 'package:prowler/src/screens/home/home_screen.dart';
import 'package:prowler/src/state/connection_state.dart';
import 'package:prowler/src/state/error_state.dart';
@@ -31,6 +32,28 @@ class _FakeConnStatus extends ConnectionStatus {
Stream<pigeon.Status> build() => Stream<pigeon.Status>.value(_fakeStatus);
}
/// Test double for [ConnectionStatus] that emits a SEQUENCE of statuses
/// (one per frame) then halts. Used for the B-3 regression test pair.
///
/// **rev-3 INFO#1 fix:** the build() override pipes the raw frame sequence
/// through `.transform(errorLatchTransformer())` so test case B genuinely
/// exercises the latch. Without this pipe, case B would pass for the wrong
/// reason (the dial widget snapshots the latest async frame; the harness
/// would emit `[connecting, error, idle]` and the latest is idle — but
/// Flutter's `AsyncValue<Status>` to `DialState` mapping would still
/// resolve the dial to idle, so a naïve test wouldn't catch the latch
/// regression). The pipe is what makes case B a TRUE regression gate: if
/// `errorLatchTransformer()` is removed/broken, the trailing `idle` reaches
/// the dial unfiltered and the case B `findsOneWidget` on dialError fires.
class _FakeConnStatusSeq extends ConnectionStatus {
_FakeConnStatusSeq(this._fakeStatuses);
final List<pigeon.Status> _fakeStatuses;
@override
Stream<pigeon.Status> build() =>
Stream<pigeon.Status>.fromIterable(_fakeStatuses)
.transform(errorLatchTransformer());
}
/// Test double for [ErrorEvents] that emits no events (for HomeScreen mounts
/// that exercise sibling widgets — the ErrorBanner stays empty).
class _NoErrEvents extends ErrorEvents {
@@ -186,5 +209,79 @@ void main() {
reason: 'pulse controller must not run when disableAnimations=true',
);
});
testWidgets('Case A — connecting → error latches DialState.error '
'(B-3 / D-07-16 — bug-fix gate, asserts the post-lift Kotlin '
'behaviour: no trailing idle frame)',
(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
connectionStatusProvider.overrideWith(
() => _FakeConnStatusSeq(<pigeon.Status>[
pigeon.Status(
state: pigeon.ConnectionState.connecting,
activeMode: pigeon.Mode.direct,
),
pigeon.Status(
state: pigeon.ConnectionState.error,
activeMode: pigeon.Mode.direct,
errorMessage: 'xray-core exhausted retry budget',
),
// NO trailing idle/disconnected frame — D-07-16 Kotlin lift
// suppresses it. This is the post-lift platform output shape.
]),
),
],
child: const MaterialApp(home: Scaffold(body: ConnectDial())),
),
);
await tester.pumpAndSettle();
expect(find.text(Strings.dialError), findsOneWidget);
expect(find.text(Strings.dialIdle), findsNothing);
});
testWidgets('Case B — connecting → error → idle STILL latches '
'DialState.error (B-3 / rev-2 BLOCKER B1 REGRESSION GATE — asserts '
'the Dart-side latch holds even if the D-07-16 Kotlin lift reverts '
'to the pre-lift behaviour)',
(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
connectionStatusProvider.overrideWith(
() => _FakeConnStatusSeq(<pigeon.Status>[
pigeon.Status(
state: pigeon.ConnectionState.connecting,
activeMode: pigeon.Mode.direct,
),
pigeon.Status(
state: pigeon.ConnectionState.error,
activeMode: pigeon.Mode.direct,
errorMessage: 'xray-core exhausted retry budget',
),
// SIMULATES the pre-lift Kotlin behaviour: a trailing `idle`
// arrives after `error`. The Dart-side errorLatchTransformer
// MUST suppress this `idle` so the dial stays latched on
// error. If a future regression reverts the D-07-16 lift,
// the platform stream looks like THIS sequence; this case
// asserts the Dart latch holds the line.
pigeon.Status(
state: pigeon.ConnectionState.idle,
activeMode: null,
),
]),
),
],
child: const MaterialApp(home: Scaffold(body: ConnectDial())),
),
);
await tester.pumpAndSettle();
// After the stream drains, dial MUST still show error label.
// If this assertion ever fires, either the Dart latch regressed OR
// the test wiring no longer goes through ConnectionStatus.build().
expect(find.text(Strings.dialError), findsOneWidget);
expect(find.text(Strings.dialIdle), findsNothing);
});
});
}

View File

@@ -106,6 +106,22 @@ void main() {
await _pumpAt(tester, pigeon.ConnectErrorCode.switchFailed);
expect(find.text("Couldn't switch layers."), findsOneWidget);
});
testWidgets('connectFailed code renders Connection failed. heading + body '
'+ Retry & Switch layer actions (B-3 / D-07-17)',
(WidgetTester tester) async {
await _pumpAt(tester, pigeon.ConnectErrorCode.connectFailed);
await tester.pump();
// UX-REVIEW.md §5 Bug #1 verbatim strings:
expect(find.text('Connection failed.'), findsOneWidget);
expect(
find.text('We tried 3 times. Switch layer or check your network.'),
findsOneWidget,
);
// Action labels from Strings.btnRetry / Strings.btnSwitchLayer:
expect(find.text('Retry'), findsOneWidget);
expect(find.text('Switch layer'), findsOneWidget);
});
});
group('ErrorBanner lexicon discipline (D-06-23 / iter-3 BLOCKER #1)', () {