import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart'; import 'package:material_ui/material_ui.dart'; import '../../support/fake_auth_gateway.dart'; import '../../support/fake_file_picker.dart'; import '../../support/fake_profile_gateway.dart'; import '../../support/pump_app.dart'; import '../../support/test_dependencies.dart'; void main() { /// Opens the Profile tab of a person with an identity, whose profile the /// instance answers through [profiles]. Future showProfile( WidgetTester tester, FakeProfileGateway profiles, { bool identified = true, FakeFilePicker? files, }) async { tester.view.physicalSize = const Size(1000, 1800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await pumpApp( tester, testDependencies( authGateway: FakeAuthGateway( status: SignedIn( authUserId: 'github-person', isIdentified: identified, ), ), profileGateway: profiles, files: files, ), location: RoutePaths.profile, ); await tester.pumpAndSettle(); } Finder field(String label) => find.widgetWithText(TextField, label); /// The dialog's field for the handle. Finder handleInDialog() => find.descendant( of: find.byType(AlertDialog), matching: find.byType(TextField), ); /// Types [handle] into the handle's dialog and waits for the instance to /// say whether it is free. Future typeHandle(WidgetTester tester, String handle) async { await tester.enterText(handleInDialog(), handle); await tester.pump(const Duration(milliseconds: 400)); await tester.pumpAndSettle(); } testWidgets('a person with an identity writes first, then chooses a ' 'handle in a dialog saying whether it is free; that makes the profile ' 'with what was written', (tester) async { final profiles = FakeProfileGateway(taken: {'Tizian'}); await showProfile(tester, profiles); expect(find.text('Public profile'), findsOneWidget); expect(find.text('Save'), findsNothing); await tester.enterText(field('Display name'), 'Tizian'); await tester.enterText(field('Bio'), 'Learning Japanese.'); await tester.tap(find.text('Display name')); await tester.pumpAndSettle(); // Nothing to save without a handle. expect(profiles.saves, isEmpty); await tester.tap(find.text('Choose a handle')); await tester.pumpAndSettle(); expect(find.text('Choose your handle'), findsOneWidget); await typeHandle(tester, 'tizian'); expect(find.text('Someone else has this handle.'), findsOneWidget); expect( tester .widget(find.widgetWithText(FilledButton, 'Choose')) .onPressed, isNull, ); await typeHandle(tester, 'raisondetredev'); expect(find.text('@raisondetredev is free'), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, 'Choose')); await tester.pumpAndSettle(); expect( find.text('Others find you as @raisondetredev@halkyon.eu'), findsOneWidget, ); expect(find.text('@raisondetredev'), findsOneWidget); expect(profiles.profile!.displayName, 'Tizian'); expect(profiles.profile!.bio, 'Learning Japanese.'); expect(find.text('Saved'), findsOneWidget); }); testWidgets('a profile saves a field when it is left or Enter is pressed, ' 'only what changed, without losing what is being written', ( tester, ) async { final profiles = FakeProfileGateway( profile: const PublicProfile( handle: 'raisondetredev', address: 'raisondetredev@halkyon.eu', displayName: 'Tizian', ), ); await showProfile(tester, profiles); await tester.enterText(field('Display name'), 'Tizian K.'); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(profiles.saves.single.displayName, 'Tizian K.'); await tester.enterText(field('Bio'), 'Learning Japanese.'); // Into the next field: the bio is left, and saves. await tester.tap(find.text('Add link')); await tester.pumpAndSettle(); expect(profiles.saves.last.bio, 'Learning Japanese.'); expect(profiles.saves, hasLength(2)); await tester.enterText( find.widgetWithText(TextField, 'https://…'), 'https://halkyon.app', ); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(profiles.profile!.links, ['https://halkyon.app']); // What was written stays as it was written. expect(find.text('Tizian K.'), findsOneWidget); await tester.tap(find.byTooltip('Remove link')); await tester.pumpAndSettle(); expect(profiles.profile!.links, isEmpty); expect(profiles.saves, hasLength(4)); }); testWidgets('the handle changes through the dialog, which says one is ' 'yours', (tester) async { final profiles = FakeProfileGateway( profile: const PublicProfile( handle: 'raisondetredev', address: 'raisondetredev@halkyon.eu', displayName: 'Tizian', ), ); await showProfile(tester, profiles); await tester.tap(find.text('@raisondetredev')); await tester.pumpAndSettle(); expect(find.text('Change your handle'), findsOneWidget); expect(find.text('This is your handle'), findsOneWidget); await typeHandle(tester, 'tizian_k'); await tester.tap(find.widgetWithText(FilledButton, 'Change')); await tester.pumpAndSettle(); expect(profiles.profile!.handle, 'tizian_k'); expect(profiles.profile!.displayName, 'Tizian'); expect(find.text('@tizian_k'), findsOneWidget); }); testWidgets('once its person has published, the handle is fixed and the ' 'profile stays', (tester) async { await showProfile( tester, FakeProfileGateway( profile: const PublicProfile( handle: 'raisondetredev', address: 'raisondetredev@halkyon.eu', handleFixed: true, ), ), ); expect( find.text('Fixed since you published: others refer to it.'), findsOneWidget, ); expect( tester.widget(find.byType(ChevronField)).onTap, isNull, ); expect(find.text('Remove profile'), findsNothing); }); testWidgets('removing a profile asks first, then frees its handle', ( tester, ) async { final profiles = FakeProfileGateway( profile: const PublicProfile( handle: 'raisondetredev', address: 'raisondetredev@halkyon.eu', ), ); await showProfile(tester, profiles); await tester.tap(find.text('Remove profile')); await tester.pumpAndSettle(); expect(find.text('Remove your public profile?'), findsOneWidget); await tester.tap(find.text('Remove profile').last); await tester.pumpAndSettle(); expect(find.text('Profile removed.'), findsOneWidget); expect(profiles.profile, isNull); }); testWidgets('a saved profile takes a picture as its avatar, and gives it ' 'up', (tester) async { final profiles = FakeProfileGateway( profile: const PublicProfile( handle: 'raisondetredev', address: 'raisondetredev@halkyon.eu', ), ); final files = FakeFilePicker() ..next = (name: 'me.jpg', bytes: Uint8List.fromList([1, 2, 3])); await showProfile(tester, profiles, files: files); await tester.tap(find.text('Choose picture')); await tester.pumpAndSettle(); expect(profiles.avatars.single, [1, 2, 3]); expect(files.asked.single, contains('jpg')); expect(find.text('Saved'), findsOneWidget); await tester.tap(find.text('Remove picture')); await tester.pumpAndSettle(); expect(profiles.profile!.avatarUrl, isNull); expect(find.text('Remove picture'), findsNothing); }); testWidgets('a profile not yet saved has no picture to choose', ( tester, ) async { await showProfile(tester, FakeProfileGateway()); expect(find.text('Choose your handle to add a picture.'), findsOneWidget); expect(find.text('Choose picture'), findsNothing); }); testWidgets('an anonymous person is told how to have one', (tester) async { await showProfile(tester, FakeProfileGateway(), identified: false); expect( find.text('Sign in to have a public profile and publish.'), findsOneWidget, ); expect(find.text('Public profile'), findsNothing); }); testWidgets('without the instance, it says so and tries again', ( tester, ) async { final profiles = FakeProfileGateway()..fail = Exception('offline'); await showProfile(tester, profiles); expect( find.text("Your public profile can't be reached right now."), findsOneWidget, ); profiles.fail = null; await tester.tap(find.text('Retry')); await tester.pumpAndSettle(); expect(find.byType(ChevronField), findsOneWidget); }); test('a save asked for while another is on its way follows it, the ' 'latest of those waiting; a handle chosen holds for the saves after ' 'it', () async { final gateway = _HeldProfileGateway( profile: const PublicProfile( handle: 'raisondetredev', address: 'raisondetredev@halkyon.eu', ), ); final bloc = PublicProfileBloc(ProfileRepository(gateway)) ..add(const PublicProfileEventStarted()); addTearDown(bloc.close); await bloc.stream.firstWhere((state) => state is PublicProfileStateReady); PublicProfileEventSaved saved(String name, {String? handle}) => PublicProfileEventSaved( displayName: name, bio: '', links: const [], handle: handle, ); bloc ..add(saved('A')) ..add(saved('A', handle: 'tizian')) ..add(saved('B')); await pumpEventQueue(); gateway.release(); await pumpEventQueue(); gateway.release(); await pumpEventQueue(); // The second gave way to the third, which kept its handle. expect( [for (final s in gateway.saves) (s.handle, s.displayName)], [ ('raisondetredev', 'A'), ('tizian', 'B'), ], ); }); test( 'the repository leaves blank links out and trims what it saves', () async { final profiles = FakeProfileGateway(); await ProfileRepository(profiles).save( handle: ' tizian ', displayName: ' Tizian ', bio: '', links: ['', ' https://halkyon.app '], ); expect(profiles.profile!.handle, 'tizian'); expect(profiles.profile!.links, ['https://halkyon.app']); }, ); group('verified links', () { PublicProfile withLinks(List links) => PublicProfile( handle: 'raisondetredev', address: 'raisondetredev@halkyon.eu', links: links, pageUrl: 'https://halkyon.eu/@raisondetredev', ); testWidgets('the profile page to link back to shows, and its address ' 'copies', (tester) async { String? copied; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, (call) async { if (call.method == 'Clipboard.setData') { copied = (call.arguments as Map)['text'] as String?; } return null; }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, null, ), ); await showProfile(tester, FakeProfileGateway(profile: withLinks([]))); expect( find.text('Your profile page: halkyon.eu/@raisondetredev'), findsOneWidget, ); await tester.tap(find.byTooltip('Copy the address')); await tester.pumpAndSettle(); expect(copied, 'https://halkyon.eu/@raisondetredev'); expect(find.text('Address copied.'), findsOneWidget); }); testWidgets('a link saved is checked by itself, and gets a checkmark once ' 'its page links back, without a message', (tester) async { final profiles = FakeProfileGateway(profile: withLinks([])) ..linkingBack.add('https://halkyon.app'); await showProfile(tester, profiles); await tester.tap(find.text('Add link')); await tester.pumpAndSettle(); await tester.enterText( find.widgetWithText(TextField, 'https://…'), 'https://halkyon.app', ); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(profiles.linkChecks, 1); expect(find.byTooltip('Links back to your profile'), findsOneWidget); expect(find.textContaining('Checked:'), findsNothing); }); testWidgets('Check links checks again, and says how many link back', ( tester, ) async { final profiles = FakeProfileGateway( profile: withLinks(['https://halkyon.app', 'https://example.org']), )..linkingBack.add('https://example.org'); await showProfile(tester, profiles); expect(find.byTooltip('Links back to your profile'), findsNothing); await tester.tap(find.text('Check links')); await tester.pumpAndSettle(); expect( find.text('Checked: 1 of 2 links link back to your profile.'), findsOneWidget, ); expect(find.byTooltip('Links back to your profile'), findsOneWidget); }); testWidgets('checking too often says so', (tester) async { final profiles = FakeProfileGateway( profile: withLinks(['https://halkyon.app']), )..refuseLinkChecks = true; await showProfile(tester, profiles); await tester.tap(find.text('Check links')); await tester.pumpAndSettle(); expect( find.text('The links were checked too often. Try again in an hour.'), findsOneWidget, ); }); test('a check that ends after a save keeps what was saved, and adds the ' "check's answer to it", () async { final profiles = FakeProfileGateway( profile: withLinks(['https://a.example']), ) ..linkingBack.add('https://a.example') ..checkWaits = Completer(); final bloc = PublicProfileBloc(ProfileRepository(profiles)) ..add(const PublicProfileEventStarted()); addTearDown(bloc.close); await expectLater( bloc.stream, emitsThrough(isA()), ); bloc.add(const PublicProfileEventLinksChecked()); await expectLater( bloc.stream, emits( isA().having( (state) => state.checkingLinks, 'checking', isTrue, ), ), ); bloc.add( const PublicProfileEventSaved( displayName: 'Tizian', bio: '', links: ['https://a.example'], ), ); await expectLater( bloc.stream, emitsThrough( isA().having( (state) => state.outcome, 'outcome', PublicProfileOutcome.saved, ), ), ); profiles.checkWaits!.complete(); await expectLater( bloc.stream, emits( isA() .having((state) => state.profile?.displayName, 'name', 'Tizian') .having( (state) => state.profile?.verifiedLinks, 'verified', ['https://a.example'], ), ), ); }); }); } /// Saves only once [release] lets each through. class _HeldProfileGateway({super.profile}) extends FakeProfileGateway { final _held = >[]; /// Lets the save waiting longest through. void release() => _held.removeAt(0).complete(); @override Future save({ required String handle, required String displayName, required String bio, required List links, }) async { final held = Completer(); _held.add(held); await held.future; return await super.save( handle: handle, displayName: displayName, bio: bio, links: links, ); } }