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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)', () {
|
||||
|
||||
Reference in New Issue
Block a user