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.
This commit is contained in:
1 parent
bf7778f312
commit
f1cbf64282
28 files changed
+1158
-64
No files matched your search
@@ -0,0 +1,116 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
class BrokenService implements AnalyticsService {
|
||||
BrokenService(this.mode);
|
||||
final int mode;
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) {
|
||||
if (mode == 0) throw StateError('offline');
|
||||
if (mode == 1) return Future.error(StateError('offline'));
|
||||
return Completer<void>().future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('signup through verification shares ID; retries and duplicates do not inflate outcomes', () {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final signup = recorder.begin(JourneyKind.signup, JourneyStep.signup);
|
||||
recorder.finish(signup, simulated: true);
|
||||
final send = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailDelivery,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(send, simulated: true);
|
||||
final failed = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(failed, failure: JourneyFailure.expired);
|
||||
final retry = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailConfirmation,
|
||||
onboarding: true,
|
||||
);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
recorder.finish(retry, simulated: true, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
expect(sink.events.last.signal, JourneySignal.simulated);
|
||||
expect(sink.events.last.operation, 4);
|
||||
expect(
|
||||
sink.events.map((e) => e.sequence),
|
||||
orderedEquals(List.generate(9, (i) => i + 1)),
|
||||
);
|
||||
expect(
|
||||
sink.events.any((e) => e.properties.toString().contains('email_address')),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
test(
|
||||
'explicit exit records once and suppresses late operation completion',
|
||||
() {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.abandon(JourneyExit.modeChange);
|
||||
recorder.abandon(JourneyExit.navigation);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.last.signal, JourneySignal.abandoned);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
final newOp = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(newOp, terminal: true);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'inactivity waits for pending operations and starts after settled failure',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService();
|
||||
final recorder = JourneyRecorder(
|
||||
sink,
|
||||
idleWindow: const Duration(milliseconds: 10),
|
||||
);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.any((e) => e.terminal), isFalse);
|
||||
recorder.finish(op, failure: JourneyFailure.unavailable);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
expect(sink.events.last.exit, JourneyExit.inactivity);
|
||||
},
|
||||
);
|
||||
for (final mode in [0, 1, 2]) {
|
||||
test('analytics failure mode $mode never blocks outcomes', () async {
|
||||
final recorder = JourneyRecorder(BrokenService(mode));
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
recorder.finish(op, terminal: true);
|
||||
recorder.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
});
|
||||
}
|
||||
test(
|
||||
'dummy is bounded, immutable and deduplicates repeated event IDs',
|
||||
() async {
|
||||
final sink = DummyAnalyticsService(capacity: 2);
|
||||
final recorder = JourneyRecorder(sink);
|
||||
addTearDown(recorder.dispose);
|
||||
final op = recorder.begin(JourneyKind.login, JourneyStep.login);
|
||||
final event = sink.events.last;
|
||||
await sink.capture(event);
|
||||
expect(sink.events.length, 2);
|
||||
recorder.finish(op, terminal: true);
|
||||
expect(sink.events.length, 2);
|
||||
expect(() => sink.events.clear(), throwsUnsupportedError);
|
||||
expect(() => event.properties['password'] = 'no', throwsUnsupportedError);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user