refactor(halkyon_app): the account section, About, the language and countries' names, shared
What Learn and Todo each held now comes from halkyon_app:
- AccountTiles: whose account on which instance, switching, signing
out (asking first for an anonymous account, as it deletes it) and
deleting it (asking first), the app's own words passed in.
- showAppAbout: the app's name, version, icon and legalese as it gives
them, the source of what the instance runs (AppDependencies gains
sourceCodeUrl), the app's own parts, the licence list.
- resolveAppLocale: the first preferred language the app speaks, never
by country alone, British English otherwise.
- AssetCountryNames, the default of InstanceGate and InstanceScope:
Unicode CLDR 48.2's territory names in English and German
(tool/country_names.py, 7.6 KB), Hong Kong, Macao, Myanmar and
Palestine by their short names as Learn's world map has them, so
Todo says "Germany (EU)" where it said "DE (EU)".
Learn's texts read as before in every language; its About bloc and
gateway, its country names from the world map and the keys only the
moved code used are gone, as are Todo's copies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 23:51:52 +02:00
|
|
|
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, and the
|
|
|
|
|
/// account's tiles in the app's own words.
|
|
|
|
|
class const _PersonApp(final SignedIn person) extends StatelessWidget {
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) => MaterialApp(
|
|
|
|
|
localizationsDelegates: testLocalizationsDelegates,
|
|
|
|
|
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
|
|
|
|
home: Scaffold(
|
|
|
|
|
body: ListView(
|
|
|
|
|
children: [
|
|
|
|
|
Text('App of ${person.authUserId}'),
|
|
|
|
|
const AccountTiles(
|
|
|
|
|
anonymousNote: 'Your notes stay on this device.',
|
|
|
|
|
signOutAnonymousMessage:
|
|
|
|
|
"This account can't be signed into again, so signing out "
|
|
|
|
|
'deletes it with its notes.',
|
|
|
|
|
deleteMessage: 'Your notes are deleted everywhere.',
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late FakeAuthGateway gateway;
|
|
|
|
|
late TestAppDependencies dependencies;
|
|
|
|
|
|
|
|
|
|
Future<void> start(WidgetTester tester, {InstanceChoice? choice}) async {
|
|
|
|
|
dependencies = TestAppDependencies(gateway: gateway);
|
|
|
|
|
final gate = AppScope(
|
|
|
|
|
dependencies: dependencies,
|
|
|
|
|
child: SessionGate(
|
|
|
|
|
look: (context) => (themeMode: ThemeMode.light, locale: null),
|
|
|
|
|
app: _PersonApp.new,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
AppFrameScope(
|
|
|
|
|
frame: testFrame(),
|
|
|
|
|
child: switch (choice) {
|
|
|
|
|
final choice? => InstanceScope(
|
|
|
|
|
notifier: choice,
|
|
|
|
|
checker: FakeInstanceChecker(),
|
|
|
|
|
countryNames: MemoryCountryNames(const {}),
|
|
|
|
|
subdivisionNames: const MemorySubdivisionNames({}),
|
|
|
|
|
child: gate,
|
|
|
|
|
),
|
|
|
|
|
null => gate,
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setUp(() => gateway = FakeAuthGateway());
|
|
|
|
|
|
|
|
|
|
testWidgets('says whose account it is, anonymous on which instance, with '
|
|
|
|
|
"the app's note", (tester) async {
|
|
|
|
|
await start(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.text('Anonymous on test.example.org'), findsOneWidget);
|
|
|
|
|
expect(find.text('Your notes stay on this device.'), findsOneWidget);
|
|
|
|
|
expect(
|
|
|
|
|
find.text('Switch account'),
|
|
|
|
|
findsNothing,
|
|
|
|
|
reason: 'no instances to switch among here',
|
|
|
|
|
);
|
|
|
|
|
expect(find.text('Sign out'), findsOneWidget);
|
|
|
|
|
expect(find.text('Delete account'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('says the account is signed in, without the note', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
gateway = FakeAuthGateway(
|
|
|
|
|
status: const SignedIn(authUserId: 'github-person', isIdentified: true),
|
|
|
|
|
);
|
|
|
|
|
await start(tester);
|
|
|
|
|
|
|
|
|
|
expect(find.text('Signed in on test.example.org'), findsOneWidget);
|
|
|
|
|
expect(find.text('Your notes stay on this device.'), findsNothing);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('switching account lists the accounts on the device, the '
|
|
|
|
|
'instance in use named', (tester) async {
|
|
|
|
|
final instance = Instance.parse('learn.example.org')!;
|
|
|
|
|
final choice = InstanceChoice(
|
|
|
|
|
MemoryInstanceStore((current: instance, known: [instance])),
|
|
|
|
|
builtIn: instance,
|
|
|
|
|
forget: (instance) async {},
|
|
|
|
|
);
|
|
|
|
|
await choice.load(usedBefore: false);
|
|
|
|
|
await start(tester, choice: choice);
|
|
|
|
|
|
|
|
|
|
expect(find.text('Switch account'), findsOneWidget);
|
|
|
|
|
expect(find.text('learn.example.org'), findsWidgets);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Switch account'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text('Accounts'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('signing out of an anonymous account asks first, in the '
|
|
|
|
|
"app's words, as it deletes it; Cancel keeps it", (tester) async {
|
|
|
|
|
await start(tester);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Sign out'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Sign out?'), findsOneWidget);
|
|
|
|
|
expect(
|
|
|
|
|
find.text(
|
|
|
|
|
"This account can't be signed into again, so signing out deletes "
|
|
|
|
|
'it with its notes.',
|
|
|
|
|
),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Cancel'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('App of anonymous-1'), findsOneWidget);
|
|
|
|
|
expect(gateway.signOuts, 0);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Sign out'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('Sign out').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(gateway.status, const SignedOut());
|
|
|
|
|
expect(find.text('Signed out'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets('signing out of an identified account asks nothing', (
|
|
|
|
|
tester,
|
|
|
|
|
) async {
|
|
|
|
|
gateway = FakeAuthGateway(
|
|
|
|
|
status: const SignedIn(authUserId: 'github-person', isIdentified: true),
|
|
|
|
|
);
|
|
|
|
|
await start(tester);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Sign out'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(find.text('Sign out?'), findsNothing);
|
|
|
|
|
expect(gateway.status, const SignedOut());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
testWidgets("deleting the account asks first, in the app's words; Cancel "
|
|
|
|
|
'keeps it', (tester) async {
|
|
|
|
|
await start(tester);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(find.text('Delete your account?'), findsOneWidget);
|
|
|
|
|
expect(find.text('Your notes are deleted everywhere.'), findsOneWidget);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Cancel'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(gateway.accountDeletions, 0);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('Delete account').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(gateway.accountDeletions, 1);
|
|
|
|
|
expect(find.text('Signed out'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-20 21:59:42 +02:00
|
|
|
testWidgets('deleting an account with a password asks for it and a '
|
|
|
|
|
'code, which a wrong one fails, deleting nothing', (tester) async {
|
|
|
|
|
// The first person the fake password gateway makes.
|
|
|
|
|
const person = '00000000-0000-4000-8000-000000000001';
|
|
|
|
|
gateway = FakeAuthGateway(
|
|
|
|
|
status: const SignedIn(authUserId: person, isIdentified: true),
|
|
|
|
|
);
|
|
|
|
|
await start(tester);
|
|
|
|
|
dependencies.passwordGateway.addAccount(
|
|
|
|
|
'anna@example.org',
|
|
|
|
|
password: 'correct horse',
|
|
|
|
|
crypto: dependencies.crypto,
|
|
|
|
|
);
|
fix(auth): every call with a code after the password takes the address
Whoever guessed codes decided whether the owner was mailed (the re-review's
fourth finding): the address a sign-in sent was optional, so a guesser left
it out and no mail went, and a wait begun without a mail used up the one
mail there was. Every call carrying a code after the password or the phrase
proved (signing in, the phrase's reset, a change of password, a new phrase,
deletion) now takes the account's address and the language of its mails,
and is refused (wrongCredentials, as anything wrong after the password)
before any code is checked when the address isn't the account's; every
wait mails the owner, not only the first. The app sends the address the
person typed, or for changes while signed in the one this device signed in
with, and says so where it doesn't know it (addressUnknown).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 23:08:25 +02:00
|
|
|
// The address this device signed in with, which deleting sends.
|
|
|
|
|
dependencies.addresses.addresses[person] = 'anna@example.org';
|
2026-10-20 21:59:42 +02:00
|
|
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
|
|
|
|
|
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
await tester.tap(find.text('Delete account').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(gateway.accountDeletions, 0);
|
|
|
|
|
expect(field('Current password'), findsOneWidget);
|
|
|
|
|
await tester.enterText(field('Current password'), 'wrong horse');
|
|
|
|
|
await tester.enterText(
|
|
|
|
|
field('Code from your authenticator app'),
|
|
|
|
|
fakeTotpCode,
|
|
|
|
|
);
|
|
|
|
|
await tester.tap(find.text('Delete account').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
expect(
|
|
|
|
|
find.text("The address, the password or the code isn't right."),
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
);
|
|
|
|
|
expect(gateway.accountDeletions, 0);
|
|
|
|
|
expect(dependencies.passwordGateway.accounts, contains('anna@example.org'));
|
|
|
|
|
|
|
|
|
|
await tester.enterText(field('Current password'), 'correct horse');
|
|
|
|
|
await tester.tap(find.text('Delete account').last);
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
expect(gateway.accountDeletions, 1);
|
|
|
|
|
expect(
|
|
|
|
|
dependencies.passwordGateway.accounts,
|
|
|
|
|
isNot(contains('anna@example.org')),
|
|
|
|
|
);
|
|
|
|
|
expect(find.text('Signed out'), findsOneWidget);
|
|
|
|
|
});
|
|
|
|
|
|
refactor(halkyon_app): the account section, About, the language and countries' names, shared
What Learn and Todo each held now comes from halkyon_app:
- AccountTiles: whose account on which instance, switching, signing
out (asking first for an anonymous account, as it deletes it) and
deleting it (asking first), the app's own words passed in.
- showAppAbout: the app's name, version, icon and legalese as it gives
them, the source of what the instance runs (AppDependencies gains
sourceCodeUrl), the app's own parts, the licence list.
- resolveAppLocale: the first preferred language the app speaks, never
by country alone, British English otherwise.
- AssetCountryNames, the default of InstanceGate and InstanceScope:
Unicode CLDR 48.2's territory names in English and German
(tool/country_names.py, 7.6 KB), Hong Kong, Macao, Myanmar and
Palestine by their short names as Learn's world map has them, so
Todo says "Germany (EU)" where it said "DE (EU)".
Learn's texts read as before in every language; its About bloc and
gateway, its country names from the world map and the keys only the
moved code used are gone, as are Todo's copies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 23:51:52 +02:00
|
|
|
testWidgets('speaks German', (tester) async {
|
|
|
|
|
await start(tester);
|
|
|
|
|
final german = lookupHalkyonAppLocalizations(const Locale('de'));
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
german.accountAnonymousOn('test.example.org'),
|
|
|
|
|
'Anonym auf test.example.org',
|
|
|
|
|
);
|
|
|
|
|
expect(german.accountSignedInOn('x'), 'Angemeldet auf x');
|
|
|
|
|
expect(german.accountSwitch, 'Konto wechseln');
|
|
|
|
|
expect(german.accountSignOut, 'Abmelden');
|
|
|
|
|
expect(german.accountSignOutTitle, 'Abmelden?');
|
|
|
|
|
expect(german.accountDelete, 'Konto löschen');
|
|
|
|
|
expect(german.accountDeleteTitle, 'Dein Konto löschen?');
|
|
|
|
|
});
|
|
|
|
|
}
|