The Watch screen's test tapped the middle of an unfolded entry's senses, which lay beyond the card's scrolling clip, so the tap reached the list behind it; it now taps the card's first line. The public profile's test tapped the display name's floated label, outside the field, to leave the bio; it now taps the field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
511 lines
16 KiB
Dart
511 lines
16 KiB
Dart
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<void> 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<void> 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.');
|
|
// Leaving the bio for the name.
|
|
await tester.tap(field('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<FilledButton>(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<ChevronField>(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<String> 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<PublicProfileStateReady>()),
|
|
);
|
|
|
|
bloc.add(const PublicProfileEventLinksChecked());
|
|
await expectLater(
|
|
bloc.stream,
|
|
emits(
|
|
isA<PublicProfileStateReady>().having(
|
|
(state) => state.checkingLinks,
|
|
'checking',
|
|
isTrue,
|
|
),
|
|
),
|
|
);
|
|
bloc.add(
|
|
const PublicProfileEventSaved(
|
|
displayName: 'Tizian',
|
|
bio: '',
|
|
links: ['https://a.example'],
|
|
),
|
|
);
|
|
await expectLater(
|
|
bloc.stream,
|
|
emitsThrough(
|
|
isA<PublicProfileStateReady>().having(
|
|
(state) => state.outcome,
|
|
'outcome',
|
|
PublicProfileOutcome.saved,
|
|
),
|
|
),
|
|
);
|
|
profiles.checkWaits!.complete();
|
|
|
|
await expectLater(
|
|
bloc.stream,
|
|
emits(
|
|
isA<PublicProfileStateReady>()
|
|
.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 = <Completer<void>>[];
|
|
|
|
/// Lets the save waiting longest through.
|
|
void release() => _held.removeAt(0).complete();
|
|
|
|
@override
|
|
Future<PublicProfile> save({
|
|
required String handle,
|
|
required String displayName,
|
|
required String bio,
|
|
required List<String> links,
|
|
}) async {
|
|
final held = Completer<void>();
|
|
_held.add(held);
|
|
await held.future;
|
|
return await super.save(
|
|
handle: handle,
|
|
displayName: displayName,
|
|
bio: bio,
|
|
links: links,
|
|
);
|
|
}
|
|
}
|