Files
sxchnge/frontend/app/test/ux_analytics_test.dart
T
Stephan D f1cbf64282 Fix onboarding UX and add replaceable journey analytics
Make recipient changes explicit, prioritize verification retry, and label simulated outcomes prominently. Add shared typed analytics with bounded dummy capture, correlated service events and documented PostHog adapter contract. Validate privacy, retries, abandonment, keyboard recovery and accessibility. Related to #12.
2026-09-12 08:36:45 +02:00

194 lines
6.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/routing/routes.dart';
Future<void> open(
WidgetTester tester,
String path,
DummyAnalyticsService sink,
) async {
tester.platformDispatcher.defaultRouteNameTestValue = path;
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(
SxchngeApp(
analyticsService: sink,
signupService: const DummySignupService(delay: Duration.zero),
verificationService: const DummyVerificationService(delay: Duration.zero),
),
);
await tester.pumpAndSettle();
}
Future<void> tap(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.tap(finder);
await tester.pumpAndSettle();
}
void main() {
testWidgets(
'recipient change removes sent claim, focuses editor and requires a new send',
(tester) async {
final sink = DummyAnalyticsService();
await open(tester, '/verification-sent', sink);
final email = find.byKey(const ValueKey('verification-email'));
await tester.enterText(email, 'alex@example.com');
await tap(tester, find.byKey(const ValueKey('verification-resend')));
expect(email, findsNothing);
expect(
find.byKey(const ValueKey('verification-recipient')),
findsOneWidget,
);
await tap(
tester,
find.byKey(const ValueKey('verification-change-email')),
);
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
final editable = tester.widget<EditableText>(
find.descendant(of: email, matching: find.byType(EditableText)),
);
expect(editable.focusNode.hasFocus, isTrue);
await tester.enterText(email, 'different@example.com');
expect(
find.byKey(const ValueKey('verification-recipient')),
findsNothing,
);
await tester.testTextInput.receiveAction(TextInputAction.send);
await tester.pumpAndSettle();
expect(find.text('different@example.com'), findsOneWidget);
expect(
sink.events
.where(
(e) =>
e.step == JourneyStep.emailDelivery &&
e.signal == JourneySignal.requested,
)
.length,
2,
);
},
);
testWidgets(
'confirmation error has primary retry and keyboard-accessible new-email recovery',
(tester) async {
final semantics = tester.ensureSemantics();
final sink = DummyAnalyticsService();
await open(tester, '/verify-email?token=demo-internal', sink);
expect(
tester.widget(find.byKey(const ValueKey('verification-retry'))),
isA<FilledButton>(),
);
expect(find.byKey(const ValueKey('verification-email')), findsNothing);
final node = tester.getSemantics(
find.byKey(const ValueKey('verification-error')),
);
expect(node.getSemanticsData().flagsCollection.isHeader, isTrue);
expect(node.getSemanticsData().flagsCollection.isLiveRegion, isTrue);
await tap(tester, find.text('Send a new verification email'));
await tester.enterText(
find.byKey(const ValueKey('verification-email')),
'alex@example.com',
);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
semantics.dispose();
},
);
testWidgets(
'signup to demo verification is correlated and never claims a business success',
(tester) async {
final sink = DummyAnalyticsService();
await open(tester, '/', sink);
await tap(tester, find.text('Sign up'));
await tester.enterText(find.byKey(const ValueKey('name')), 'Alex');
await tester.enterText(
find.byKey(const ValueKey('email')),
'alex@example.com',
);
await tester.enterText(
find.byKey(const ValueKey('password')),
'example-password',
);
await tester.enterText(
find.byKey(const ValueKey('confirmation')),
'example-password',
);
await tap(tester, find.byKey(const ValueKey('submit')));
expect(find.text('Demo email prepared.'), findsOneWidget);
expect(find.textContaining('We’ve sent you'), findsNothing);
await tap(tester, find.byKey(const ValueKey('demo-confirmation')));
expect(find.text('Demo verification\ncomplete.'), findsOneWidget);
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
expect(sink.events.where((e) => e.terminal).length, 1);
expect(
sink.events.where((e) => e.signal == JourneySignal.succeeded),
isEmpty,
);
expect(sink.events.every((e) => e.simulated), isTrue);
final payload = sink.events.map((e) => e.properties).toString();
for (final secret in [
'alex@example.com',
'example-password',
'Alex',
'demo-valid',
]) {
expect(payload, isNot(contains(secret)));
}
final count = sink.events.length;
await tester.pumpAndSettle();
expect(sink.events.length, count);
},
);
testWidgets(
'exit from a deep-linked failed confirmation records abandonment once',
(tester) async {
final sink = DummyAnalyticsService();
await open(tester, '/verify-email?token=demo-expired', sink);
await tap(tester, find.text('Back to login'));
expect(sink.events.last.signal, JourneySignal.abandoned);
expect(sink.events.last.exit, JourneyExit.navigation);
expect(sink.events.where((e) => e.terminal).length, 1);
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
router.goNamed(AppRoutes.login);
await tester.pumpAndSettle();
expect(sink.events.where((e) => e.terminal).length, 1);
},
);
testWidgets(
'verification controls have accessible labels and mobile tap targets',
(tester) async {
final semantics = tester.ensureSemantics();
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await open(
tester,
'/verify-email?token=demo-internal',
DummyAnalyticsService(),
);
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
semantics.dispose();
},
);
}