chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,66 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/avatar_image.dart';
/// A PNG [width] × [height], red on its left half, blue on its right.
Future<Uint8List> picture(int width, int height) async {
final recorder = ui.PictureRecorder();
ui.Canvas(recorder)
..drawRect(
ui.Rect.fromLTWH(0, 0, width / 2, height.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFF0000),
)
..drawRect(
ui.Rect.fromLTWH(width / 2, 0, width / 2, height.toDouble()),
ui.Paint()..color = const ui.Color(0xFF0000FF),
);
final image = await recorder.endRecording().toImage(width, height);
final png = await image.toByteData(format: ui.ImageByteFormat.png);
return png!.buffer.asUint8List();
}
Future<ui.Image> decoded(Uint8List png) async {
final codec = await ui.instantiateImageCodec(png);
return (await codec.getNextFrame()).image;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('a picture becomes its middle square, at most 400 pixels a side, as '
'a PNG', () async {
final wide = await avatarPng(await picture(1200, 600));
final image = await decoded(wide);
expect((image.width, image.height), (400, 400));
expect(wide.sublist(0, 8), [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
]);
// The middle square of a picture red and blue side by side holds both.
final pixels = (await image.toByteData())!;
expect(pixels.getUint8(0), greaterThan(200));
expect(pixels.getUint8(399 * 4 + 2), greaterThan(200));
});
test('a small picture keeps its size', () async {
final image = await decoded(await avatarPng(await picture(100, 160)));
expect((image.width, image.height), (100, 100));
});
test('what is no picture is refused', () async {
await expectLater(
avatarPng(Uint8List.fromList([1, 2, 3])),
throwsA(anything),
);
});
}

View file

@ -0,0 +1,511 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.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,
);
}
}