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>
2026-10-19 15:18:49 +02:00
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 { } ,
halkyon_app: data locations as precise as a country or a state
An operator's DATA_LOCATION and BACKUP_LOCATION may name a subdivision
(ISO 3166-2, US-CA, DE-BY) besides the EU or a country. The app adds
"(EU)" to a member country and the country to a subdivision ("Germany
(EU)", "California, United States", "Bavaria, Germany (EU)"), in
English and German, and shows the most specific flag there is: a
subdivision's own where the flag package has one, else its country's.
Subdivision names come from Unicode CLDR 48.2 (tool/subdivision_names.py),
bundled with their licence, which the app registers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 18:47:25 +02:00
SubdivisionNames subdivisionNames = const MemorySubdivisionNames ( { } ) ,
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>
2026-10-19 15:18:49 +02:00
} ) 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 ' } ,
halkyon_app: data locations as precise as a country or a state
An operator's DATA_LOCATION and BACKUP_LOCATION may name a subdivision
(ISO 3166-2, US-CA, DE-BY) besides the EU or a country. The app adds
"(EU)" to a member country and the country to a subdivision ("Germany
(EU)", "California, United States", "Bavaria, Germany (EU)"), in
English and German, and shows the most specific flag there is: a
subdivision's own where the flag package has one, else its country's.
Subdivision names come from Unicode CLDR 48.2 (tool/subdivision_names.py),
bundled with their licence, which the app registers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 18:47:25 +02:00
' DE ' : { ' en ' : ' Germany ' } ,
' US ' : { ' en ' : ' United States ' } ,
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>
2026-10-19 15:18:49 +02:00
} ) ,
halkyon_app: data locations as precise as a country or a state
An operator's DATA_LOCATION and BACKUP_LOCATION may name a subdivision
(ISO 3166-2, US-CA, DE-BY) besides the EU or a country. The app adds
"(EU)" to a member country and the country to a subdivision ("Germany
(EU)", "California, United States", "Bavaria, Germany (EU)"), in
English and German, and shows the most specific flag there is: a
subdivision's own where the flag package has one, else its country's.
Subdivision names come from Unicode CLDR 48.2 (tool/subdivision_names.py),
bundled with their licence, which the app registers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 18:47:25 +02:00
subdivisionNames: subdivisionNames ,
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>
2026-10-19 15:18:49 +02:00
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 \n Backups: Switzerland ' ) , findsOne ) ;
expect ( find . byType ( Flag ) , findsOne ) ;
} ) ;
halkyon_app: data locations as precise as a country or a state
An operator's DATA_LOCATION and BACKUP_LOCATION may name a subdivision
(ISO 3166-2, US-CA, DE-BY) besides the EU or a country. The app adds
"(EU)" to a member country and the country to a subdivision ("Germany
(EU)", "California, United States", "Bavaria, Germany (EU)"), in
English and German, and shows the most specific flag there is: a
subdivision's own where the flag package has one, else its country's.
Subdivision names come from Unicode CLDR 48.2 (tool/subdivision_names.py),
bundled with their licence, which the app registers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 18:47:25 +02:00
testWidgets ( ' an instance may say where more precisely: a state, a region ' , (
tester ,
) async {
await start (
tester ,
answering: { builtIn } ,
locations: {
builtIn: const InstanceLocations ( data: ' US-CA ' , backups: ' DE-BY ' ) ,
} ,
subdivisionNames: const MemorySubdivisionNames ( {
' DE-BY ' : { ' en ' : ' Bavaria ' } ,
' US-CA ' : { ' en ' : ' California ' } ,
} ) ,
) ;
expect (
find . text (
' Data: California, United States \n Backups: Bavaria, Germany (EU) ' ,
) ,
findsOne ,
) ;
expect ( find . byType ( Flag ) , findsOne ) ;
} ) ;
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>
2026-10-19 15:18:49 +02:00
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 \n No 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 ) ;
} ) ;
}