halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps

What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.

An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.

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 15:18:49 +02:00
commit e23cc77f7c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
26 changed files with 10761 additions and 10 deletions

View file

@ -0,0 +1,275 @@
import 'package:flag/flag.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:material_ui/material_ui.dart';
/// A signed-in person's app as a test makes one: who it is for, and the
/// way to Switch account.
class const _PersonApp(final SignedIn person) extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
localizationsDelegates: testLocalizationsDelegates,
supportedLocales: HalkyonAppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Column(
children: [
Text('Home of ${person.authUserId}'),
TextButton(
onPressed: () => showAccountSwitcher(context),
child: const Text('Switch account'),
),
],
),
),
),
);
}
void main() {
final builtIn = Instance.parse('learn.halkyon.eu')!;
final other = Instance.parse('learn.example.org')!;
late MemoryInstanceStore store;
late List<Instance> opened;
late List<TestAppDependencies> dependencies;
late List<Instance> forgotten;
setUp(() {
store = MemoryInstanceStore();
opened = [];
dependencies = [];
forgotten = [];
});
/// Starts the app on a device that [usedBefore] the choice existed, with
/// [answering] instances answering a check, saying where they keep data
/// as [locations] has it, and the account on each [signedOut].
Future<InstanceChoice> start(
WidgetTester tester, {
bool usedBefore = false,
bool signedOut = false,
Set<Instance> answering = const {},
Map<Instance, InstanceLocations> locations = const {},
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final choice = InstanceChoice(
store,
builtIn: builtIn,
forget: (instance) async => forgotten.add(instance),
);
await choice.load(usedBefore: usedBefore);
await tester.pumpWidget(
InstanceGate(
frame: testFrame(),
choice: choice,
checker: FakeInstanceChecker(answering, locations),
countryNames: MemoryCountryNames(const {
'CH': {'en': 'Switzerland'},
}),
readLook: () async => (themeMode: ThemeMode.light, locale: null),
open: (instance) async {
opened.add(instance);
final opening = TestAppDependencies(serverName: instance.name);
await opening.signedOut.write(signedOut: signedOut);
dependencies.add(opening);
return opening;
},
app: SessionGate(
look: (context) => (themeMode: ThemeMode.light, locale: null),
app: _PersonApp.new,
),
),
);
await tester.pumpAndSettle();
return choice;
}
Future<void> openSwitcher(WidgetTester tester) async {
await tester.tap(find.text('Switch account'));
await tester.pumpAndSettle();
}
testWidgets('a new install asks for the instance first, and opens the '
"build's in one tap", (tester) async {
await start(tester);
// The wordmark says it to screen readers, naming the app.
expect(find.bySemanticsLabel('Welcome to Halkyon Test'), findsOneWidget);
expect(
find.text('Choose the instance to keep your notes on.'),
findsOneWidget,
reason: "the app's own words",
);
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
expect(find.byType(TextField), findsNothing, reason: 'no address asked');
await tester.tap(find.text('learn.halkyon.eu'));
await tester.pumpAndSettle();
expect(opened, [builtIn]);
expect(find.text('Home of anonymous-1'), findsOneWidget);
});
testWidgets("the build's instance shows where it keeps people's data, "
'once it says', (tester) async {
await start(
tester,
answering: {builtIn},
locations: {
builtIn: const InstanceLocations(data: 'EU', backups: 'CH'),
},
);
expect(find.text('Data: European Union\nBackups: Switzerland'), findsOne);
expect(find.byType(Flag), findsOne);
});
testWidgets('an instance saying nothing, or not answering, comes with the '
'app, without a flag', (tester) async {
await start(tester);
expect(find.text('Comes with the app'), findsOne);
expect(find.byType(Flag), findsNothing);
});
testWidgets('another instance is checked before the app moves there', (
tester,
) async {
await start(tester, answering: {other});
await tester.tap(find.text('Another instance'));
await tester.pumpAndSettle();
expect(
FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<TextField>(),
isNotNull,
reason: 'the address is typed at once',
);
expect(
find.widgetWithText(TextField, 'test.example.org'),
findsOneWidget,
reason: "the app's example address as the hint",
);
await tester.enterText(find.byType(TextField), 'nowhere.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(
find.text('No instance of Halkyon Test answers at nowhere.example.org.'),
findsOneWidget,
);
expect(opened, isEmpty);
await tester.enterText(find.byType(TextField), 'learn.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(opened, [other]);
});
testWidgets('an install from before goes on without asking', (tester) async {
await start(tester, usedBefore: true);
expect(opened, [builtIn]);
expect(find.text('Home of anonymous-1'), findsOneWidget);
});
testWidgets('Switch account moves between the accounts on the device, '
'closing the app on the one before, and removes one not in '
'use', (tester) async {
store = MemoryInstanceStore((current: builtIn, known: [builtIn, other]));
await start(
tester,
answering: {builtIn, other},
locations: {
other: const InstanceLocations(
data: 'CH',
backups: InstanceLocations.noBackups,
),
},
);
await openSwitcher(tester);
expect(find.text('learn.halkyon.eu'), findsWidgets);
// Each account with where its instance says it keeps people's data.
expect(find.text('Data: Switzerland\nNo backups'), findsOne);
expect(find.byType(Flag), findsOne);
expect(find.text('Anonymous'), findsOne, reason: 'the account in use');
await tester.tap(find.text('learn.example.org'));
await tester.pumpAndSettle();
expect(opened, [builtIn, other]);
expect(dependencies.first.disposed, isTrue);
expect(find.text('Home of anonymous-1'), findsOneWidget);
await openSwitcher(tester);
await tester.tap(find.byTooltip('Remove account'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
await tester.pumpAndSettle();
expect(forgotten, [builtIn]);
expect(store.kept?.known, [other]);
});
testWidgets('signed out, the start screen names the instance and offers '
'another, through Switch account', (tester) async {
store = MemoryInstanceStore((current: builtIn, known: [builtIn]));
await start(tester, answering: {builtIn, other}, signedOut: true);
expect(find.text('Start on learn.halkyon.eu:'), findsOneWidget);
await tester.tap(find.text('Use another instance'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add account'));
await tester.pumpAndSettle();
expect(
find.text('Choose the instance for the account to add.'),
findsOneWidget,
);
await tester.enterText(find.byType(TextField), 'learn.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(opened, [builtIn, other]);
expect(find.text('Start on learn.example.org:'), findsOneWidget);
});
testWidgets("an instance whose app doesn't open says so, and tries "
'again', (tester) async {
store = MemoryInstanceStore((current: builtIn, known: [builtIn]));
final choice = InstanceChoice(
store,
builtIn: builtIn,
forget: (instance) async {},
);
await choice.load(usedBefore: false);
var attempts = 0;
await tester.pumpWidget(
InstanceGate(
frame: testFrame(),
choice: choice,
checker: FakeInstanceChecker(),
countryNames: MemoryCountryNames(const {}),
readLook: () async => (themeMode: ThemeMode.light, locale: null),
open: (instance) async {
if (++attempts == 1) {
throw StateError('disk unavailable');
}
return TestAppDependencies();
},
app: const Directionality(
textDirection: TextDirection.ltr,
child: Text('the app'),
),
),
);
await tester.pumpAndSettle();
expect(find.text("Couldn't open the app on learn.halkyon.eu."), findsOne);
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(find.text('the app'), findsOne);
});
}

View file

@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:material_ui/material_ui.dart';
/// What the person sees: whose dependencies are open above it.
class const _Person() extends StatelessWidget {
@override
Widget build(BuildContext context) =>
Text('Data of ${PersonScope.of<TestPerson>(context).person.authUserId}');
}
void main() {
const ada = SignedIn(authUserId: 'ada', isIdentified: true);
late TestAppDependencies dependencies;
setUp(() => dependencies = TestAppDependencies());
Future<void> show(WidgetTester tester, Widget scope) async {
await tester.pumpWidget(testApp(scope, dependencies: dependencies));
await tester.pumpAndSettle();
}
testWidgets("shows the person's app once their data is open", (
tester,
) async {
await show(tester, const PersonScope(person: ada, child: _Person()));
expect(find.text('Data of ada'), findsOneWidget);
expect(dependencies.opened.single.person, ada);
});
testWidgets("says so when the person's data can't be opened, and offers "
'to try again; an attempt that works shows the app', (tester) async {
dependencies.openingError = Exception('database locked');
await show(tester, const PersonScope(person: ada, child: _Person()));
expect(find.text("Couldn't open this device's data."), findsOneWidget);
expect(find.text('Data of ada'), findsNothing);
dependencies.openingError = null;
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(find.text('Data of ada'), findsOneWidget);
});
testWidgets("closes the person's data when it goes away", (tester) async {
await show(tester, const PersonScope(person: ada, child: _Person()));
await show(tester, const Text('Someone else'));
expect(dependencies.opened.single.disposed, isTrue);
});
}

View file

@ -0,0 +1,242 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
late FakeAuthGateway gateway;
late MemorySignInAddresses addresses;
/// Whether this device holds content a switch would leave behind.
late bool holdsContent;
setUp(() {
gateway = FakeAuthGateway(status: anonymous);
addresses = MemorySignInAddresses();
holdsContent = false;
});
EmailSignInBloc newBloc({String language = 'en'}) {
final bloc = EmailSignInBloc(
AuthRepository(gateway, addresses),
deviceHoldsContent: () async => holdsContent,
language: language,
);
addTearDown(bloc.close);
return bloc;
}
/// A bloc waiting for the code mailed to [address].
Future<EmailSignInBloc> codeMailed(String address) async {
final bloc = newBloc()..add(EmailSignInEventCodeRequested(address));
await expectLater(
bloc.stream,
emitsThrough(isA<EmailSignInStateCode>()),
);
return bloc;
}
test('starts at the address', () {
expect(newBloc().state, const EmailSignInStateAddress());
});
test("mails a code in the app's language, and the code makes the address "
"the person's: they keep everything and become identified", () async {
final bloc = newBloc(language: 'de')
..add(const EmailSignInEventCodeRequested(' Anna@Example.org '));
await expectLater(
bloc.stream,
emitsInOrder([
const EmailSignInStateAddress(sending: true),
const EmailSignInStateCode(
address: 'anna@example.org',
request: 'request-1',
),
]),
);
expect(gateway.mailed.single.language, 'de');
bloc.add(const EmailSignInEventCodeEntered('code-1'));
await expectLater(
bloc.stream,
emitsInOrder([
const EmailSignInStateCode(
address: 'anna@example.org',
request: 'request-1',
checking: true,
),
const EmailSignInStateLinked(address: 'anna@example.org'),
]),
);
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
);
expect(addresses.addresses, {'anonymous-1': 'anna@example.org'});
});
test('says what is wrong with an address, and with a code, and takes the '
'next try', () async {
final bloc = newBloc()..add(const EmailSignInEventCodeRequested('anna'));
await expectLater(
bloc.stream,
emitsInOrder([
const EmailSignInStateAddress(sending: true),
const EmailSignInStateAddress(
problem: EmailSignInProblem.notAnAddress,
),
]),
);
bloc.add(const EmailSignInEventCodeRequested('anna@example.org'));
await expectLater(bloc.stream, emitsThrough(isA<EmailSignInStateCode>()));
bloc.add(const EmailSignInEventCodeEntered('CODE-9'));
await expectLater(
bloc.stream,
emitsThrough(
const EmailSignInStateCode(
address: 'anna@example.org',
request: 'request-1',
problem: EmailSignInProblem.wrongCode,
),
),
);
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
await expectLater(
bloc.stream,
emitsThrough(const EmailSignInStateLinked(address: 'anna@example.org')),
);
});
test('mails a new code on request, and goes back to the address', () async {
final bloc = await codeMailed('anna@example.org');
bloc.add(const EmailSignInEventCodeResent());
await expectLater(
bloc.stream,
emitsInOrder([
const EmailSignInStateCode(
address: 'anna@example.org',
request: 'request-1',
checking: true,
),
const EmailSignInStateCode(
address: 'anna@example.org',
request: 'request-2',
resent: true,
),
]),
);
bloc.add(const EmailSignInEventAddressReopened());
await expectLater(
bloc.stream,
emits(const EmailSignInStateAddress()),
);
});
test("another person's address: a device holding nothing switches to "
'them straight away', () async {
gateway.addressOwners['ben@example.org'] = 'ben';
final bloc = await codeMailed('ben@example.org');
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
await expectLater(
bloc.stream,
emitsThrough(const EmailSignInStateSwitching()),
);
await pumpEventQueue();
expect(gateway.emailSwitches, 1);
expect(
gateway.status,
const SignedIn(authUserId: 'ben', isIdentified: true),
);
expect(addresses.addresses, {'ben': 'ben@example.org'});
});
test('a device holding content asks first: declining changes nothing, '
'confirming switches', () async {
gateway.addressOwners['ben@example.org'] = 'ben';
holdsContent = true;
final bloc = await codeMailed('ben@example.org');
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
await expectLater(
bloc.stream,
emitsThrough(
const EmailSignInStateConfirmSwitch(
address: 'ben@example.org',
switchToken: 'switch:ben@example.org',
),
),
);
bloc.add(const EmailSignInEventSwitchDeclined());
await expectLater(bloc.stream, emits(const EmailSignInStateAddress()));
expect(gateway.emailSwitches, 0);
expect(gateway.status, anonymous);
bloc.add(const EmailSignInEventCodeRequested('ben@example.org'));
await expectLater(bloc.stream, emitsThrough(isA<EmailSignInStateCode>()));
bloc.add(const EmailSignInEventCodeEntered('CODE-2'));
await expectLater(
bloc.stream,
emitsThrough(isA<EmailSignInStateConfirmSwitch>()),
);
bloc.add(const EmailSignInEventSwitchConfirmed());
await expectLater(bloc.stream, emits(const EmailSignInStateSwitching()));
await pumpEventQueue();
expect(gateway.emailSwitches, 1);
});
test("someone identified whose address is another person's is told so, "
'never switched: their own account stays theirs', () async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'carla', isIdentified: true),
)..addressOwners['ben@example.org'] = 'ben';
final bloc = await codeMailed('ben@example.org');
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
await expectLater(
bloc.stream,
emitsThrough(const EmailSignInStateTaken(address: 'ben@example.org')),
);
expect(gateway.emailSwitches, 0);
expect(
gateway.status,
const SignedIn(authUserId: 'carla', isIdentified: true),
);
});
test("says when the instance couldn't send the mail", () async {
gateway.emailError = const EmailSignInFailed(
EmailSignInProblem.mailFailed,
);
final bloc = newBloc()
..add(const EmailSignInEventCodeRequested('anna@example.org'));
await expectLater(
bloc.stream,
emitsInOrder([
const EmailSignInStateAddress(sending: true),
const EmailSignInStateAddress(problem: EmailSignInProblem.mailFailed),
]),
);
});
test("says when the instance can't be reached", () async {
gateway.emailError = Exception('offline');
final bloc = newBloc()
..add(const EmailSignInEventCodeRequested('anna@example.org'));
await expectLater(
bloc.stream,
emitsInOrder([
const EmailSignInStateAddress(sending: true),
const EmailSignInStateAddress(problem: EmailSignInProblem.failed),
]),
);
});
}

View file

@ -0,0 +1,246 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
late FakeAuthGateway gateway;
/// Whether this device holds content a switch would leave behind.
late bool holdsContent;
setUp(() {
gateway = FakeAuthGateway(status: anonymous);
holdsContent = false;
});
/// A bloc as the account dialog starts it, once it knows whether this
/// instance offers GitHub sign-in.
Future<GitHubSignInBloc> ready() async {
final bloc = GitHubSignInBloc(
AuthRepository(gateway, MemorySignInAddresses()),
deviceHoldsContent: () async => holdsContent,
)..add(const GitHubSignInEventStarted());
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
return bloc;
}
test('offers nothing where the instance has no GitHub sign-in', () async {
gateway.offersGitHub = false;
final bloc = GitHubSignInBloc(
AuthRepository(gateway, MemorySignInAddresses()),
deviceHoldsContent: () async => holdsContent,
)..add(const GitHubSignInEventStarted());
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable()));
});
test('links the GitHub account to the person', () async {
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const GitHubSignInStateBusy(),
const GitHubSignInStateLinked(),
]),
);
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
);
});
test('changes nothing when the person cancels on GitHub', () async {
gateway.gitHubLinkOutcome = GitHubLinkOutcome.cancelled;
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const GitHubSignInStateBusy(),
const GitHubSignInStateReady(),
]),
);
expect(gateway.status, anonymous);
});
test(
"the person can cancel while GitHub's page is open; nothing changes",
() async {
gateway
..canCancelGitHub = true
..gitHubPageOpens = true;
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emits(const GitHubSignInStateBusy(cancellable: true)),
);
bloc.add(const GitHubSignInEventCancelled());
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
expect(gateway.gitHubCancels, 1);
expect(gateway.status, anonymous);
},
);
test(
"offers no cancel where the system's sign-in screen has its own",
() async {
gateway.gitHubPageOpens = true;
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emits(
isA<GitHubSignInStateBusy>().having(
(state) => state.cancellable,
'cancellable',
isFalse,
),
),
);
},
);
test(
"closing the dialog while GitHub's page is open cancels the sign-in",
() async {
gateway
..canCancelGitHub = true
..gitHubPageOpens = true;
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emits(const GitHubSignInStateBusy(cancellable: true)),
);
await bloc.close();
expect(gateway.gitHubCancels, 1);
expect(gateway.status, anonymous);
},
);
test('closing the dialog otherwise cancels nothing', () async {
final bloc = await ready();
await bloc.close();
expect(gateway.gitHubCancels, 0);
});
test("switches right away when the GitHub account is someone else's and "
'this device holds nothing', () async {
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
await pumpEventQueue();
expect(gateway.gitHubSwitches, 1);
});
test("someone identified whose GitHub account is another person's is told "
'so, never switched: their own account stays theirs', () async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'carla', isIdentified: true),
)..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
final bloc = await ready();
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const GitHubSignInStateBusy(),
const GitHubSignInStateTaken(),
]),
);
expect(gateway.gitHubSwitches, 0);
expect(
gateway.status,
const SignedIn(authUserId: 'carla', isIdentified: true),
);
});
group("when the GitHub account is someone else's and this device holds "
'content', () {
late GitHubSignInBloc bloc;
setUp(() async {
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
holdsContent = true;
bloc = (await ready())..add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const GitHubSignInStateBusy(),
const GitHubSignInStateConfirmSwitch(),
]),
);
});
test('asks first; staying anonymous changes nothing', () async {
bloc.add(const GitHubSignInEventSwitchDeclined());
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
expect(gateway.gitHubSwitches, 0);
expect(gateway.status, anonymous);
});
test('switches once confirmed', () async {
bloc.add(const GitHubSignInEventSwitchConfirmed());
await expectLater(bloc.stream, emits(const GitHubSignInStateBusy()));
await pumpEventQueue();
expect(gateway.gitHubSwitches, 1);
});
test("cancelling GitHub's page for the switch stays anonymous", () async {
gateway
..canCancelGitHub = true
..gitHubPageOpens = true;
bloc.add(const GitHubSignInEventSwitchConfirmed());
await expectLater(
bloc.stream,
emits(const GitHubSignInStateBusy(cancellable: true)),
);
bloc.add(const GitHubSignInEventCancelled());
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
expect(gateway.gitHubSwitches, 0);
expect(gateway.status, anonymous);
});
});
test('says so when signing in fails', () async {
final bloc = await ready();
gateway.gitHubError = Exception('No connection');
bloc.add(const GitHubSignInEventRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const GitHubSignInStateBusy(),
const GitHubSignInStateReady(failed: true),
]),
);
expect(gateway.status, anonymous);
});
}

View file

@ -0,0 +1,206 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
late FakeAuthGateway gateway;
late MemorySignedOutMark mark;
late SessionBloc bloc;
SessionStateActive anonymous(int number) => SessionStateActive(
SignedIn(authUserId: 'anonymous-$number', isIdentified: false),
);
void start({AuthStatus status = const SignedOut(), bool signedOut = false}) {
gateway = FakeAuthGateway(status: status);
mark = MemorySignedOutMark(signedOut);
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark);
}
tearDown(() => bloc.close());
test('signs in anonymously at a first start, nobody having signed out '
'here', () async {
start();
bloc.add(const SessionEventStarted());
await expectLater(
bloc.stream,
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
);
expect(gateway.signIns, 1);
});
test('keeps a stored session instead of creating a second account', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
expect(gateway.signIns, 0);
});
test('reports a failed sign-in and signs in when asked again', () async {
start();
gateway.signInError = Exception('No connection');
bloc.add(const SessionEventStarted());
await expectLater(
bloc.stream,
emitsInOrder([const SessionStateLoading(), const SessionStateFailed()]),
);
gateway.signInError = null;
bloc.add(const SessionEventSignInRequested());
await expectLater(
bloc.stream,
emitsInOrder([const SessionStateLoading(), anonymous(2)]),
);
});
test('after signing out, asks how to start, making no account; the next '
'start of the app asks again', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
bloc.add(const SessionEventSignOutRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateLoading(),
const SessionStateSignedOut(),
]),
);
// Signing out of an anonymous account deletes it.
expect(gateway.accountDeletions, 1);
expect(gateway.signIns, 0);
expect(mark.signedOut, isTrue);
await bloc.close();
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark)
..add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.signIns, 0);
});
test('starting without an account from there signs in anonymously, and '
'the device is no longer signed out', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
bloc.add(const SessionEventStartedAnonymously());
await expectLater(
bloc.stream,
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
);
expect(mark.signedOut, isFalse);
});
test('signing in with GitHub from there switches to its person; '
'cancelled, it asks again', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsThrough(
const SessionStateActive(
SignedIn(authUserId: 'github-person', isIdentified: true),
),
),
);
expect(gateway.signIns, 0);
expect(mark.signedOut, isFalse);
});
test('a GitHub account nobody here signs in with yet is said so; any '
'other refusal fails, and nothing is made either way', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.noGitHubAccount),
]),
);
gateway.gitHubError = const GitHubSignInFailed(GitHubSignInProblem.refused);
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateSignedOut(busy: true),
const SessionStateSignedOut(problem: SignedOutProblem.failed),
]),
);
expect(gateway.signIns, 0);
expect(gateway.gitHubSwitches, 0);
});
test('with an email address from there, it starts without an account and '
'asks for the address', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
bloc.add(const SessionEventEmailRequested());
await expectLater(
bloc.stream,
emitsThrough(
const SessionStateActive(
SignedIn(authUserId: 'anonymous-1', isIdentified: false),
signsInWithEmail: true,
),
),
);
});
test('a session the server ended asks how to start too', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
gateway.revokeSession();
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.signIns, 0);
});
test('deleting the account asks how to start', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
bloc.add(const SessionEventDeleteRequested());
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateLoading(),
const SessionStateSignedOut(),
]),
);
expect(gateway.accountDeletions, 1);
expect(gateway.signIns, 0);
});
}

View file

@ -0,0 +1,153 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
group('AuthRepository.ensureSignedIn', () {
test('signs in anonymously when nobody is signed in', () async {
final gateway = FakeAuthGateway();
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
expect(gateway.signIns, 1);
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
);
});
test('keeps the person already signed in', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
final gateway = FakeAuthGateway(status: stored);
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
expect(gateway.signIns, 0);
expect(gateway.status, stored);
});
});
group('AuthRepository.signOut', () {
test(
'deletes an anonymous account, which nobody could sign into again',
() async {
final gateway = FakeAuthGateway(
status: const SignedIn(
authUserId: 'anonymous-1',
isIdentified: false,
),
);
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 0);
expect(gateway.status, const SignedOut());
},
);
test('only signs an identified person out on this device', () async {
final gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.signOuts, 1);
expect(gateway.accountDeletions, 0);
});
});
test('deletes the account of whoever is signed in', () async {
final gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
await AuthRepository(gateway, MemorySignInAddresses()).deleteAccount();
expect(gateway.accountDeletions, 1);
expect(gateway.status, const SignedOut());
});
group('signing in with an email address', () {
late FakeAuthGateway gateway;
late MemorySignInAddresses addresses;
late AuthRepository auth;
setUp(() {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
);
addresses = MemorySignInAddresses();
auth = AuthRepository(gateway, addresses);
});
test("a code for a new address makes it the person's, and this device "
'remembers it for them, to show', () async {
final request = await auth.sendEmailCode(
'anna@example.org',
language: 'en',
);
final outcome = await auth.verifyEmailCode(
request,
'CODE-1',
address: 'anna@example.org',
);
expect(outcome, const EmailLinked());
expect(auth.isIdentified, isTrue);
expect(await auth.emailAddress(), 'anna@example.org');
});
test("a code for another person's address changes nothing here; "
'switching remembers the address for the person switched to, not '
'the one left', () async {
gateway.addressOwners['ben@example.org'] = 'ben';
final request = await auth.sendEmailCode(
'ben@example.org',
language: 'en',
);
final outcome = await auth.verifyEmailCode(
request,
'CODE-1',
address: 'ben@example.org',
);
expect(outcome, isA<EmailTakenByAnotherPerson>());
expect(auth.isIdentified, isFalse);
expect(addresses.addresses, isEmpty);
await auth.switchToEmail(
(outcome as EmailTakenByAnotherPerson).switchToken,
address: 'ben@example.org',
);
expect(
gateway.status,
const SignedIn(authUserId: 'ben', isIdentified: true),
);
expect(addresses.addresses, {'ben': 'ben@example.org'});
expect(await auth.emailAddress(), 'ben@example.org');
});
test('a wrong code remembers nothing', () async {
final request = await auth.sendEmailCode(
'anna@example.org',
language: 'en',
);
await expectLater(
auth.verifyEmailCode(request, 'CODE-9', address: 'anna@example.org'),
throwsA(
isA<EmailSignInFailed>().having(
(failure) => failure.problem,
'problem',
EmailSignInProblem.wrongCode,
),
),
);
expect(addresses.addresses, isEmpty);
});
});
}

View file

@ -0,0 +1,259 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show AppLifecycleState;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/halkyon_app.dart';
void main() {
const gitHubPage = 'https://github.com/login/oauth/authorize?client_id=app';
late int port;
late String callback;
late Completer<Uri> pageOpened;
late int broughtToFront;
late LoopbackWebAuth webAuth;
/// The loopback part with a fake browser that only records the page it
/// opens; [opens] is whether opening it works.
LoopbackWebAuth loopback({
bool opens = true,
Future<void> Function()? bringToFront,
Duration timeout = const Duration(minutes: 1),
}) => LoopbackWebAuth(
appName: 'Halkyon Learn',
launch: (url) {
pageOpened.complete(url);
return Future.value(opens);
},
bringToFront:
bringToFront ??
() {
broughtToFront++;
return Future.value();
},
timeout: timeout,
);
setUp(() async {
// A port that is free right now, so the tests never meet a running app.
final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
port = probe.port;
await probe.close();
callback = 'http://127.0.0.1:$port/github-callback';
pageOpened = Completer();
broughtToFront = 0;
webAuth = loopback();
});
tearDown(() => webAuth.cancel());
/// Starts a sign-in as flutter_web_auth_2 hands it over.
Future<String> signIn() => webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
options: const {},
);
/// The browser asking this machine's [address] for [pathAndQuery], as when
/// GitHub sends it back. Over a raw socket, so that the fake HTTP of
/// Flutter's test binding can't get in between.
Future<({int status, String body})> browserAsks(
String pathAndQuery, {
InternetAddress? address,
}) async {
final socket = await Socket.connect(
address ?? InternetAddress.loopbackIPv4,
port,
timeout: const Duration(seconds: 2),
);
socket.write(
'GET $pathAndQuery HTTP/1.1\r\n'
'Host: 127.0.0.1:$port\r\n'
'Connection: close\r\n\r\n',
);
final response = await utf8.decoder.bind(socket).join();
socket.destroy();
return (
status: int.parse(response.split(' ')[1]),
body: response.substring(response.indexOf('\r\n\r\n') + 4),
);
}
test(
"opens GitHub's page and returns where the browser comes back to",
() async {
final result = signIn();
expect(await pageOpened.future, Uri.parse(gitHubPage));
final response = await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(response.body, contains('return to Halkyon Learn'));
expect(broughtToFront, 1);
},
);
test(
"keeps waiting when the app's window regains focus",
() async {
// The way the bug went: flutter_web_auth_2's front ends every pending
// sign-in when the app is resumed, and on the desktop the window
// regaining focus resumes it.
TestWidgetsFlutterBinding.ensureInitialized();
FlutterWebAuth2Platform.instance = webAuth;
final result = FlutterWebAuth2.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
);
await pageOpened.future;
// The browser takes the focus; then the person looks back at the app.
await _embedderReports(AppLifecycleState.inactive);
await _embedderReports(AppLifecycleState.resumed);
final response = await browserAsks('/github-callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
},
// Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback.
skip: !(Platform.isLinux || Platform.isWindows),
);
test('a cancel ends the wait as cancelled and stops listening', () async {
final result = signIn();
await pageOpened.future;
await webAuth.cancel();
await expectLater(result, throwsA(_cancelled));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('gives up after the timeout and stops listening', () async {
webAuth = loopback(timeout: const Duration(milliseconds: 50));
await expectLater(signIn(), throwsA(isA<TimeoutException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('a second sign-in ends the first as cancelled', () async {
final first = signIn();
await pageOpened.future;
pageOpened = Completer();
final second = signIn();
await expectLater(first, throwsA(_cancelled));
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await second, '$callback?code=c&state=s');
});
test('answers other paths with "not found" and keeps waiting', () async {
final result = signIn();
await pageOpened.future;
final stray = await browserAsks('/favicon.ico');
final response = await browserAsks('/github-callback?code=c&state=s');
expect(stray.status, HttpStatus.notFound);
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
});
test('listens on the loopback address only', () async {
final otherAddresses = [
for (final interface in await NetworkInterface.list())
for (final address in interface.addresses)
if (!address.isLoopback) address,
];
final result = signIn();
await pageOpened.future;
for (final address in otherAddresses) {
await expectLater(
browserAsks('/github-callback?code=c&state=s', address: address),
throwsA(isA<SocketException>()),
reason: '$address',
);
}
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test("stops listening when the browser can't be opened", () async {
webAuth = loopback(opens: false);
await expectLater(signIn(), throwsA(isA<PlatformException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test("signs in even when the window can't come to the front", () async {
webAuth = loopback(
bringToFront: () => Future.error(MissingPluginException()),
);
final result = signIn();
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test(
'refuses a callback other than a path on 127.0.0.1 with a port',
() async {
for (final address in [
'http://0.0.0.0:$port/github-callback',
'http://localhost:$port/github-callback',
'http://example.com:$port/github-callback',
'https://127.0.0.1:$port/github-callback',
'http://127.0.0.1/github-callback',
'http://127.0.0.1:$port',
]) {
await expectLater(
webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: address,
options: const {},
),
throwsArgumentError,
reason: address,
);
}
expect(pageOpened.isCompleted, isFalse);
},
);
}
/// How flutter_web_auth_2 reports a cancelled sign-in, which is how the
/// gateway's callers learn of it.
final _cancelled = isA<PlatformException>().having(
(error) => error.code,
'code',
'CANCELED',
);
/// The embedder reporting the app's lifecycle, as it does when the window
/// gains or loses focus.
Future<void> _embedderReports(AppLifecycleState state) =>
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
SystemChannels.lifecycle.name,
SystemChannels.lifecycle.codec.encodeMessage(state.toString()),
(_) {},
);

View file

@ -0,0 +1,186 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// A session kept in memory, as no test reaches the system's storage.
final class _MemoryStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async => _session = data;
}
/// An instance's halkyon_base as the network shows it: anonymous sign-ins
/// get `anonymous-token`, its basics offer GitHub and email, and an address
/// belongs to Ben, whom a switch signs in as with `ben-token`.
final class _Instance() {
static final anonymous = UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001',
);
static final ben = UuidValue.withValidation(
'0191f0c4-2222-7000-8000-000000000002',
);
/// Each call, by its endpoint and method, with the session it carried.
final calls = <({String call, String? session})>[];
/// What mailing a code is refused for, while set.
EmailSignInRefusal? sendRefused;
http.Client client() => MockClient((request) async {
final call = request.url.path.substring(1);
calls.add((call: call, session: request.headers['authorization']));
String sent(Object? body) => jsonEncode(body);
return switch (call) {
'halkyon_base.anonymousIdp/login' => http.Response(
sent(_session(anonymous, 'anonymous-token', identified: false)),
200,
),
'halkyon_base.instance/basics' => http.Response(
sent(
InstanceBasics(
app: 'learn',
gitHubClientId: 'github-client',
emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/learn',
minimumAppBuild: 1,
).toJson(),
),
200,
),
'halkyon_base.emailCode/sendCode' => switch (sendRefused) {
final reason? => http.Response(
sent({
'className': 'halkyon_base.EmailSignInRefusedException',
'data': EmailSignInRefusedException(reason: reason).toJson(),
}),
400,
),
null => http.Response(
sent('0191f0c4-3333-7000-8000-000000000003'),
200,
),
},
'halkyon_base.emailCode/verifyCode' => http.Response(
sent(EmailCodeAccepted(switchToken: 'switch-to-ben').toJson()),
200,
),
'halkyon_base.emailSignIn/signIn' => http.Response(
sent(_session(ben, 'ben-token', identified: true)),
200,
),
'halkyon_base.account/delete' => http.Response('null', 200),
_ => http.Response('', 404),
};
});
static Map<String, dynamic> _session(
UuidValue person,
String token, {
required bool identified,
}) => AuthSuccess(
authStrategy: 'session',
token: token,
authUserId: person,
scopeNames: {if (identified) 'identified'},
).toJson();
}
void main() {
late _Instance instance;
late BaseClient app;
late ServerpodAuthGateway gateway;
setUp(() async {
instance = _Instance();
app =
BaseClient(
'https://learn.example.org/',
httpClientOverride: instance.client(),
)
..authSessionManager = FlutterAuthSessionManager(
storage: _MemoryStorage(),
);
await app.auth.initialize();
gateway = ServerpodAuthGateway(
app,
base: app.modules,
callbackScheme: 'app.halkyon.test',
appName: 'Halkyon Test',
httpClient: instance.client,
);
addTearDown(() async {
await gateway.dispose();
app.close();
});
});
test(
'signs in anonymously through halkyon_base, nobody identified',
() async {
await gateway.signInAnonymously();
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.anonymous}', isIdentified: false),
);
},
);
test('tells what the instance offers from its basics', () async {
expect(await gateway.gitHubClientId(), 'github-client');
expect(await gateway.offersEmailSignIn(), isTrue);
});
test("the instance's refusals to mail a code arrive in the package's own "
'words', () async {
await gateway.signInAnonymously();
instance.sendRefused = EmailSignInRefusal.tooManyCodes;
await expectLater(
gateway.sendEmailCode('ben@example.org', language: 'en'),
throwsA(
isA<EmailSignInFailed>().having(
(failure) => failure.problem,
'problem',
EmailSignInProblem.tooManyCodes,
),
),
);
});
test("switching to an address's person signs in without this device's "
'session, then deletes the anonymous account left behind as '
'itself', () async {
await gateway.signInAnonymously();
final request = await gateway.sendEmailCode(
'ben@example.org',
language: 'en',
);
final outcome = await gateway.verifyEmailCode(request, 'K7QXM2PD');
expect(outcome, const EmailTakenByAnotherPerson('switch-to-ben'));
expect(await gateway.switchToEmail('switch-to-ben'), '${_Instance.ben}');
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.ben}', isIdentified: true),
);
final signIn = instance.calls.singleWhere(
(call) => call.call == 'halkyon_base.emailSignIn/signIn',
);
expect(signIn.session, isNull, reason: 'no session goes with it');
final deletion = instance.calls.singleWhere(
(call) => call.call == 'halkyon_base.account/delete',
);
expect(deletion.session, contains('anonymous-token'));
});
}

View file

@ -0,0 +1,204 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
late FakeAuthGateway gateway;
late TestAppDependencies dependencies;
/// Whether this device holds content a switch would leave behind.
late bool holdsContent;
/// What the sheet returned once it closed.
late List<String?> returned;
setUp(() {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
);
dependencies = TestAppDependencies(gateway: gateway);
holdsContent = false;
returned = [];
});
/// Opens the sheet, in [locale].
Future<void> open(
WidgetTester tester, {
Locale locale = const Locale('en'),
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () async => returned.add(
await showEmailSignInSheet(
context,
deviceHoldsContent: () async => holdsContent,
),
),
child: const Text('Open'),
),
),
),
dependencies: dependencies,
locale: locale,
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
/// The sheet's text field.
final inSheet = find.descendant(
of: find.byType(SheetScaffold),
matching: find.byType(TextField),
);
/// Types [address] and sends a code to it, then types [code]. A switch to
/// another person shows a spinner until their app replaces the whole
/// subtree, which a test never settles: [switches] pumps frames instead.
Future<void> signInWith(
WidgetTester tester,
String address, {
String code = 'CODE-1',
bool switches = false,
}) async {
await tester.enterText(inSheet, address);
await tester.tap(find.widgetWithText(FilledButton, 'Send code'));
await tester.pumpAndSettle();
expect(
find.text("We mailed a code to $address. It's valid for 15 minutes."),
findsOneWidget,
);
await tester.enterText(inSheet, code);
await tester.tap(find.widgetWithText(FilledButton, 'Continue'));
if (switches) {
await tester.pump();
await tester.pump();
} else {
await tester.pumpAndSettle();
}
}
testWidgets('an anonymous person continues with email: the address, then '
'the code mailed to it, after which the address is theirs', (
tester,
) async {
await open(tester);
expect(
find.textContaining('keeps only a fingerprint of your address'),
findsOneWidget,
);
await signInWith(tester, 'anna@example.org');
expect(returned, ['anna@example.org']);
expect(gateway.mailed.single.language, 'en');
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
);
expect(dependencies.addresses.addresses, {
'anonymous-1': 'anna@example.org',
});
});
testWidgets('the mail is worded in German for someone who reads the app in '
'German', (tester) async {
await open(tester, locale: const Locale('de'));
await tester.enterText(inSheet, 'anna@example.org');
await tester.tap(find.widgetWithText(FilledButton, 'Code senden'));
await tester.pumpAndSettle();
expect(gateway.mailed.single.language, 'de');
});
testWidgets('a wrong code says so, and a new one can be asked for', (
tester,
) async {
await open(tester);
await signInWith(tester, 'anna@example.org', code: 'CODE-9');
expect(find.text("That code isn't right."), findsOneWidget);
await tester.tap(find.text('Send a new code'));
await tester.pumpAndSettle();
expect(find.text('A new code is on its way.'), findsOneWidget);
expect(gateway.mailed, hasLength(2));
});
testWidgets("another person's address switches at once where the device "
'holds nothing', (tester) async {
gateway.addressOwners['ben@example.org'] = 'ben';
await open(tester);
await signInWith(tester, 'ben@example.org', switches: true);
expect(gateway.emailSwitches, 1);
expect(
gateway.status,
const SignedIn(authUserId: 'ben', isIdentified: true),
);
});
testWidgets("another person's address asks first where the device holds "
"content, in the app's own words: staying changes nothing, switching "
'switches', (tester) async {
gateway.addressOwners['ben@example.org'] = 'ben';
holdsContent = true;
await open(tester);
await signInWith(tester, 'ben@example.org');
expect(
find.text('Switch to the account of ben@example.org?'),
findsOneWidget,
);
expect(
find.text(
"The notes on this device can't be combined with it yet: if you "
'switch, they are deleted.',
),
findsOneWidget,
);
await tester.tap(find.text('Stay anonymous'));
await tester.pumpAndSettle();
expect(gateway.emailSwitches, 0);
await signInWith(tester, 'ben@example.org', code: 'CODE-2');
await tester.tap(find.text('Switch'));
await tester.pump();
await tester.pump();
expect(gateway.emailSwitches, 1);
});
testWidgets("someone identified whose address is another account's is "
'told so, and stays who they are', (tester) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-person', isIdentified: true),
)..addressOwners['ben@example.org'] = 'ben';
dependencies = TestAppDependencies(gateway: gateway);
await open(tester);
await signInWith(tester, 'ben@example.org');
expect(
find.text(
'ben@example.org already signs in to another account. To switch to '
'it, sign out first.',
),
findsOneWidget,
);
expect(gateway.emailSwitches, 0);
expect(
gateway.status,
const SignedIn(authUserId: 'github-person', isIdentified: true),
);
});
}

View file

@ -0,0 +1,173 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:material_ui/material_ui.dart';
/// A signed-in person's app as a test makes one: whose it is, whether
/// they asked to continue with email, and a way to sign out.
class const _PersonApp(final SignedIn person) extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
localizationsDelegates: testLocalizationsDelegates,
supportedLocales: HalkyonAppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Column(
children: [
Text('App of ${person.authUserId}'),
if (context.watch<SessionBloc>().state case SessionStateActive(
signsInWithEmail: true,
))
const Text('Asks for the address'),
TextButton(
onPressed: () => context.read<SessionBloc>().add(
const SessionEventSignOutRequested(),
),
child: const Text('Sign out'),
),
],
),
),
),
);
}
void main() {
late FakeAuthGateway gateway;
late TestAppDependencies dependencies;
setUp(() {
gateway = FakeAuthGateway();
dependencies = TestAppDependencies(gateway: gateway);
});
Future<void> start(WidgetTester tester) async {
await tester.pumpWidget(
AppFrameScope(
frame: testFrame(),
child: AppScope(
dependencies: dependencies,
child: SessionGate(
look: (context) => (themeMode: ThemeMode.light, locale: null),
app: _PersonApp.new,
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('shows the app once signed in anonymously at a first start', (
tester,
) async {
await start(tester);
expect(find.text('App of anonymous-1'), findsOneWidget);
});
testWidgets('offers to try again when signing in fails', (tester) async {
gateway.signInError = Exception('No connection');
await start(tester);
expect(find.text("Couldn't reach the server."), findsOneWidget);
gateway.signInError = null;
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(find.text('App of anonymous-2'), findsOneWidget);
});
testWidgets('signed out, it asks how to start on the instance, making no '
'account; starting without one makes one', (tester) async {
await dependencies.signedOut.write(signedOut: true);
await start(tester);
expect(find.text('Signed out'), findsOneWidget);
expect(find.text('Start on test.example.org:'), findsOneWidget);
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.text('Continue with email'), findsOneWidget);
expect(
find.text('Use another instance'),
findsNothing,
reason: 'no instances to choose among here',
);
expect(gateway.signIns, 0);
await tester.tap(find.text('Start without an account'));
await tester.pumpAndSettle();
expect(find.text('App of anonymous-1'), findsOneWidget);
expect(dependencies.signedOut.signedOut, isFalse);
});
testWidgets('from there, GitHub signs in its person; a GitHub account '
'nobody here has yet is said so', (tester) async {
await dependencies.signedOut.write(signedOut: true);
await start(tester);
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text(
'No account here signs in with this GitHub account yet. Start '
'without an account, then add GitHub in Profile.',
),
findsOneWidget,
);
gateway.gitHubError = null;
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(find.text('App of github-person'), findsOneWidget);
expect(gateway.signIns, 0);
});
testWidgets('from there, an email address starts without an account and '
'asks for the address in the app', (tester) async {
await dependencies.signedOut.write(signedOut: true);
await start(tester);
await tester.tap(find.text('Continue with email'));
await tester.pumpAndSettle();
expect(find.text('App of anonymous-1'), findsOneWidget);
expect(find.text('Asks for the address'), findsOneWidget);
});
testWidgets('offers only the ways the instance has', (tester) async {
gateway
..offersGitHub = false
..offersEmail = false;
await dependencies.signedOut.write(signedOut: true);
await start(tester);
expect(find.text('Sign in with GitHub'), findsNothing);
expect(find.text('Continue with email'), findsNothing);
expect(find.text('Start without an account'), findsOneWidget);
});
testWidgets('signing out, or the instance ending the session, asks how to '
'start', (tester) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'ada', isIdentified: true),
);
dependencies = TestAppDependencies(gateway: gateway);
await start(tester);
expect(find.text('App of ada'), findsOneWidget);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(find.text('Signed out'), findsOneWidget);
expect(dependencies.signedOut.signedOut, isTrue);
await tester.tap(find.text('Start without an account'));
await tester.pumpAndSettle();
gateway.revokeSession();
await tester.pumpAndSettle();
expect(find.text('Signed out'), findsOneWidget);
});
}

View file

@ -0,0 +1,106 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// [refusal] as an instance sends it: named by its module.
String sent(SerializableException refusal) => jsonEncode({
'className':
'halkyon_base.${switch (refusal) {
EmailSignInRefusedException() => 'EmailSignInRefusedException',
GitHubSignInRefusedException() => 'GitHubSignInRefusedException',
_ => throw ArgumentError.value(refusal),
}}',
'data': (refusal as SerializableModel).toJson(),
});
void main() {
test("reads the refusals an instance sends under halkyon_base's name, as "
"an app's own generated protocol does", () {
final protocol = BaseProtocol();
expect(
protocol.decodeWithType(
sent(
EmailSignInRefusedException(reason: EmailSignInRefusal.wrongCode),
),
),
isA<EmailSignInRefusedException>().having(
(refusal) => refusal.reason,
'reason',
EmailSignInRefusal.wrongCode,
),
);
expect(
protocol.decodeWithType(
sent(
GitHubSignInRefusedException(
reason: GitHubSignInRefusal.unknownAccount,
),
),
),
isA<GitHubSignInRefusedException>().having(
(refusal) => refusal.reason,
'reason',
GitHubSignInRefusal.unknownAccount,
),
);
});
test("calls halkyon_base's endpoints at the instance, and passes its "
'refusals on as themselves', () async {
final asked = <String>[];
final client = BaseClient(
'https://learn.example.org/',
httpClientOverride: MockClient((request) async {
asked.add(request.url.toString());
return switch (request.url.path) {
'/halkyon_base.instance/basics' => http.Response(
jsonEncode(
InstanceBasics(
app: 'learn',
emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/learn',
minimumAppBuild: 21,
dataLocation: 'EU',
).toJson(),
),
200,
),
'/halkyon_base.emailSignIn/signIn' => http.Response(
sent(
EmailSignInRefusedException(
reason: EmailSignInRefusal.codeExpired,
),
),
400,
),
_ => http.Response('', 404),
};
}),
);
addTearDown(client.close);
final basics = await client.modules.instance.basics();
expect(basics.emailSignIn, isTrue);
expect(basics.minimumAppBuild, 21);
expect(basics.dataLocation, 'EU');
await expectLater(
client.modules.emailSignIn.signIn(switchToken: 'token'),
throwsA(
isA<EmailSignInRefusedException>().having(
(refusal) => refusal.reason,
'reason',
EmailSignInRefusal.codeExpired,
),
),
);
expect(asked, [
'https://learn.example.org/halkyon_base.instance/basics',
'https://learn.example.org/halkyon_base.emailSignIn/signIn',
]);
});
}

View file

@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
void main() {
test('a country is named in the language asked for, else in English, and '
'one without a name not at all', () async {
final names = MemoryCountryNames({
'CH': {'de': 'Schweiz', 'en': 'Switzerland'},
});
expect(await names.nameOf('CH', 'de'), 'Schweiz');
expect(await names.nameOf('ch', 'de'), 'Schweiz');
expect(await names.nameOf('CH', 'ja'), 'Switzerland');
expect(await names.nameOf('XX', 'en'), isNull);
});
}

View file

@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test("the flags' MIT notice joins the licence list, as their licence "
'asks', () async {
registerFlagIconsLicense();
final notices = [
await for (final entry in LicenseRegistry.licenses)
if (entry.packages.contains('flag-icons'))
entry.paragraphs.map((paragraph) => paragraph.text).join('\n'),
];
expect(notices, hasLength(1));
expect(notices.single, contains('Copyright (c) 2013 Panayiotis Lipiridis'));
expect(
notices.single,
contains(
'The above copyright notice and this permission notice shall be '
'included',
),
);
});
}

View file

@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
final halkyon = Instance.parse('learn.example.org')!;
final todo = Instance.parse('todo.example.org')!;
final elsewhere = Instance.parse('www.example.org')!;
/// Basics as an instance of [app] tells them.
http.Response basicsOf(String app) => http.Response(
jsonEncode(
InstanceBasics(
app: app,
emailSignIn: false,
sourceCodeUrl: 'https://code.example.org/$app',
minimumAppBuild: 1,
dataLocation: 'EU',
backupLocation: 'CH',
).toJson(),
),
200,
);
/// A checker of the app `learn` reaching instances through a fake network,
/// where learn.example.org answers as an instance of it, todo.example.org
/// as an instance of another app, and nothing else answers.
ServerpodInstanceChecker checker() => ServerpodInstanceChecker(
app: 'learn',
httpClient: () => MockClient(
(request) async => switch ((request.url.host, request.url.path)) {
('learn.example.org', '/halkyon_base.instance/basics') => basicsOf(
'learn',
),
('todo.example.org', '/halkyon_base.instance/basics') => basicsOf(
'todo',
),
_ => http.Response('<html>Not found</html>', 404),
},
),
);
test('an instance answers when its halkyon_base tells its basics; any '
'other site does not', () async {
expect(await checker().answers(halkyon), isTrue);
expect(await checker().answers(elsewhere), isFalse);
});
test("another app's instance does not answer, so its address is refused "
'before the app moves there', () async {
expect(await checker().answers(todo), isFalse);
expect(await checker().locationsOf(todo), isNull);
});
test("says where an instance keeps people's data and its backups, and "
'nothing for a site that is no instance', () async {
expect(
await checker().locationsOf(halkyon),
const InstanceLocations(data: 'EU', backups: 'CH'),
);
expect(await checker().locationsOf(elsewhere), isNull);
});
}

View file

@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
final builtIn = Instance.parse('learn.halkyon.eu')!;
final other = Instance.parse('learn.example.org')!;
late MemoryInstanceStore store;
late List<Instance> forgotten;
InstanceChoice choice() => InstanceChoice(
store,
builtIn: builtIn,
forget: (instance) async {
forgotten.add(instance);
},
);
setUp(() {
store = MemoryInstanceStore();
forgotten = [];
});
test('a new install has no instance until one is chosen', () async {
final instances = choice();
await instances.load(usedBefore: false);
expect(instances.current, isNull);
expect(instances.known, isEmpty);
await instances.use(other);
expect(instances.current, other);
expect(store.kept?.current, other);
expect(store.kept?.known, [other]);
});
test('an install from before there was a choice goes on with the '
"build's instance", () async {
final instances = choice();
await instances.load(usedBefore: true);
expect(instances.current, builtIn);
expect(store.kept?.current, builtIn);
expect(store.kept?.known, [builtIn]);
});
test('what was chosen is read back', () async {
store = MemoryInstanceStore((current: other, known: [builtIn, other]));
final instances = choice();
await instances.load(usedBefore: true);
expect(instances.current, other);
expect(instances.known, [builtIn, other]);
});
test('switching keeps every account; removing one forgets what the '
'device holds of it, never of the one in use', () async {
final instances = choice();
await instances.load(usedBefore: true);
await instances.use(other);
await instances.use(builtIn);
expect(instances.known, [builtIn, other]);
await instances.remove(builtIn);
expect(forgotten, isEmpty, reason: 'in use');
await instances.remove(other);
expect(forgotten, [other]);
expect(instances.known, [builtIn]);
expect(store.kept?.current, builtIn);
expect(store.kept?.known, [builtIn]);
});
test('tells its listeners of every change', () async {
final instances = choice();
var changes = 0;
instances.addListener(() => changes++);
await instances.load(usedBefore: false);
await instances.use(other);
await instances.use(builtIn);
await instances.remove(other);
expect(changes, 4);
});
}

View file

@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
void main() {
test('reads an address as people type it, HTTPS unless they say not', () {
for (final address in [
'learn.example.org',
'https://learn.example.org',
' HTTPS://Learn.Example.org/ ',
]) {
expect(
Instance.parse(address)?.url,
Uri.parse('https://learn.example.org/'),
reason: address,
);
}
expect(
Instance.parse('http://localhost:8080')?.url,
Uri.parse('http://localhost:8080/'),
);
expect(
Instance.parse('example.org/learn?x=1#top')?.url,
Uri.parse('https://example.org/learn/'),
);
});
test('refuses what is no address', () {
for (final address in [
'',
' ',
'learn example org',
'ftp://example.org',
'https://',
'https://someone@example.org',
]) {
expect(Instance.parse(address), isNull, reason: address);
}
});
test('is named by its host, and its port if it has one', () {
expect(Instance.parse('learn.halkyon.eu')!.name, 'learn.halkyon.eu');
expect(Instance.parse('localhost:8080')!.name, 'localhost:8080');
});
test('is the same instance however its address was written', () {
expect(
Instance.parse('learn.example.org'),
Instance.parse('https://LEARN.example.org/'),
);
});
}

View file

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations_de.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations_en.dart';
void main() {
final en = HalkyonAppLocalizationsEn();
final de = HalkyonAppLocalizationsDe();
const names = {'CH': 'Switzerland', 'DE': 'Germany'};
String? words(InstanceLocations locations) =>
instanceLocationWords(en, locations, names);
test('data and backups in one place are named once', () {
expect(
words(const InstanceLocations(data: 'EU', backups: 'EU')),
'Data and backups: European Union',
);
});
test('data and backups apart are named a line each', () {
expect(
words(const InstanceLocations(data: 'EU', backups: 'CH')),
'Data: European Union\nBackups: Switzerland',
);
});
test('an instance keeping no backups says so', () {
expect(
words(
const InstanceLocations(
data: 'CH',
backups: InstanceLocations.noBackups,
),
),
'Data: Switzerland\nNo backups',
);
});
test('what the instance leaves unsaid is left out', () {
expect(words(const InstanceLocations(data: 'DE')), 'Data: Germany');
expect(
words(const InstanceLocations(backups: 'CH')),
'Backups: Switzerland',
);
expect(words(const InstanceLocations()), isNull);
});
test('a country without a name here goes by its code', () {
expect(words(const InstanceLocations(data: 'XK')), 'Data: XK');
});
test('in German too, without the articles some countries take', () {
expect(
instanceLocationWords(
de,
const InstanceLocations(data: 'EU', backups: 'CH'),
const {'CH': 'Schweiz'},
),
'Daten: Europäische Union\nSicherungen: Schweiz',
);
});
}

View file

@ -0,0 +1,163 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
void main() {
late List<String> tapped;
setUp(() => tapped = []);
/// Shows a tile beside something else to move the pointer or focus to.
Future<void> show(WidgetTester tester, {bool? expanded}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
TextButton(onPressed: () {}, child: const Text('Elsewhere')),
InstanceTile(
icon: const Icon(LucideIcons.globe),
label: 'learn.halkyon.eu',
tagline: 'Comes with the app',
expanded: expanded,
onTap: () => tapped.add('tile'),
),
],
),
),
),
);
}
double ring(WidgetTester tester) =>
tester.widget<Opacity>(find.byKey(InstanceTile.ringKey)).opacity;
double tagline(WidgetTester tester) => tester
.widget<Opacity>(
find.ancestor(
of: find.text('Comes with the app'),
matching: find.byType(Opacity),
),
)
.opacity;
testWidgets('without a mouse, the tagline shows from the start', (
tester,
) async {
await show(tester);
expect(tagline(tester), 1);
expect(ring(tester), 0);
});
testWidgets('with a mouse, the ring and the tagline come in under the '
'pointer and go once it leaves', (tester) async {
await show(tester);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: tester.getCenter(find.text('Elsewhere')));
await tester.pumpAndSettle();
expect(tagline(tester), 0);
expect(ring(tester), 0);
await mouse.moveTo(tester.getCenter(find.byType(Icon)));
// The frame that starts it, then halfway.
await tester.pump();
await tester.pump(InstanceTile.duration ~/ 2);
expect(ring(tester), inExclusiveRange(0, 1), reason: 'it fades in');
await tester.pumpAndSettle();
expect(tagline(tester), 1);
expect(ring(tester), 1);
await mouse.moveTo(tester.getCenter(find.text('Elsewhere')));
await tester.pumpAndSettle();
expect(tagline(tester), 0);
expect(ring(tester), 0);
});
testWidgets("the keyboard's focus rings it, and Enter chooses it", (
tester,
) async {
await show(tester);
// From Elsewhere to the tile.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(ring(tester), 1);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(tapped, ['tile']);
});
testWidgets('it rings while pressed', (tester) async {
await show(tester);
final finger = await tester.startGesture(
tester.getCenter(find.byType(Icon)),
);
await tester.pumpAndSettle();
expect(ring(tester), 1);
await finger.up();
await tester.pumpAndSettle();
expect(ring(tester), 0);
expect(tapped, ['tile']);
});
testWidgets('it stays ringed while what it opens is open', (tester) async {
await show(tester, expanded: true);
expect(ring(tester), 1);
await show(tester, expanded: false);
await tester.pumpAndSettle();
expect(ring(tester), 0);
});
testWidgets('a screen reader hears one button, its tagline included', (
tester,
) async {
await show(tester, expanded: false);
expect(
// The label's node is merged into the tile's.
tester.getSemantics(find.text('learn.halkyon.eu')),
matchesSemantics(
label: 'learn.halkyon.eu\nComes with the app',
isButton: true,
hasTapAction: true,
hasFocusAction: true,
isFocusable: true,
hasExpandedState: true,
),
);
});
testWidgets('with animations off, it rings at once', (tester) async {
await tester.pumpWidget(
MaterialApp(
// The app's own MediaQuery would hide one above it.
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: child!,
),
home: Scaffold(
body: InstanceTile(
icon: const Icon(LucideIcons.globe),
label: 'learn.halkyon.eu',
tagline: 'Comes with the app',
expanded: false,
onTap: () {},
),
),
),
);
await tester.startGesture(tester.getCenter(find.byType(Icon)));
await tester.pump();
expect(ring(tester), 1);
});
}

View file

@ -0,0 +1,70 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// The messages of the `.arb` file [name] in `lib/l10n/`, without metadata.
Map<String, String> _messages(String name) => {
for (final MapEntry(:key, :value) in (jsonDecode(
File('lib/l10n/$name').readAsStringSync(),
) as Map<String, Object?>).entries)
if (!key.startsWith('@')) key: value! as String,
};
/// The names of the placeholders in [message], with plural and select
/// arguments; the words of their branches don't count. (gen-l10n reads
/// quotes as text here: `use-escaping` is off.)
Set<String> _placeholders(String message) {
final names = <String>{};
// Even depths hold text and placeholders, odd ones a plural's branches.
var depth = 0;
for (var i = 0; i < message.length; i++) {
switch (message[i]) {
case '{':
final name = RegExp(r'\{\s*(\w+)\s*[,}]').matchAsPrefix(message, i);
if (name != null && depth.isEven) {
names.add(name.group(1)!);
}
depth++;
case '}':
depth--;
}
}
return names;
}
void main() {
final template = _messages('halkyon_app_en.arb');
test('German has every message, with the same placeholders', () {
final german = _messages('halkyon_app_de.arb');
expect(
template.keys.where((key) => !german.containsKey(key)),
isEmpty,
reason: 'untranslated',
);
expect(
german.keys.where((key) => !template.containsKey(key)),
isEmpty,
reason: 'not in the template',
);
for (final MapEntry(:key, :value) in german.entries) {
expect(_placeholders(value), _placeholders(template[key]!), reason: key);
}
});
test("the English variants override the template's messages only, with "
'the same placeholders', () {
for (final variant in ['halkyon_app_en_GB.arb', 'halkyon_app_en_US.arb']) {
for (final MapEntry(:key, :value) in _messages(variant).entries) {
expect(template, contains(key), reason: '$variant: $key');
expect(
_placeholders(value),
_placeholders(template[key]!),
reason: '$variant: $key',
);
}
}
});
}

View file

@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:material_ui/material_ui.dart';
void main() {
test('speaks British English, American English and German', () {
expect(
HalkyonAppLocalizations.supportedLocales,
containsAll(const [
Locale('en'),
Locale('en', 'GB'),
Locale('en', 'US'),
Locale('de'),
]),
);
});
test("names the app it is part of where its texts do, as Halkyon Learn's "
'own texts did', () {
final english = lookupHalkyonAppLocalizations(const Locale('en'));
final german = lookupHalkyonAppLocalizations(const Locale('de'));
expect(
english.instanceWelcome('Halkyon Learn'),
'Welcome to Halkyon Learn',
);
expect(
german.instanceWelcome('Halkyon Learn'),
'Willkommen bei Halkyon Learn',
);
expect(
english.instanceUnreachable('Halkyon Learn', 'learn.example.org'),
'No instance of Halkyon Learn answers at learn.example.org.',
);
expect(
german.instanceUnreachable('Halkyon Learn', 'learn.example.org'),
'Unter learn.example.org antwortet keine Instanz von Halkyon Learn.',
);
});
}

Some files were not shown because too many files have changed in this diff Show more