feat(train): Halkyon Train's shell, Todo's copied and renamed (ADR 0030, D12)
apps/train with halkyon_train_server, _client and _flutter, as Todo's: halkyon_base configured by trainBase(), its own register alone and a member of the instance's where the operator names one, the app on halkyon_app's gates with Train's frame, account sheet and About, texts in British English and German that speak of workouts, app.halkyon.train on Android, iOS, Linux, macOS and Windows. Where it differs from Todo: development ports of its own (8180 to 8190), so it runs beside Learn's server; one initial migration with the schema Todo's migrations end at (the same tables, compared line by line), not Todo's seventeen; and Todo's icon as a placeholder until the icon tool of ADR 0031 draws Train's bike. The workspace, the licences, the READMEs and AGENTS.md name it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2fd39352fc
commit
7b5fbee913
3 changed files with 22363 additions and 7 deletions
256
apps/train/halkyon_train_flutter/test/app/train_app_test.dart
Normal file
256
apps/train/halkyon_train_flutter/test/app/train_app_test.dart
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
final builtIn = Instance.parse('train.example.org')!;
|
||||
late MemoryInstanceStore store;
|
||||
late FakeAuthGateway gateway;
|
||||
late List<SignedIn> opened;
|
||||
late MemorySignInAddresses addresses;
|
||||
|
||||
setUp(() {
|
||||
store = MemoryInstanceStore();
|
||||
gateway = FakeAuthGateway();
|
||||
addresses = MemorySignInAddresses();
|
||||
opened = [];
|
||||
});
|
||||
|
||||
/// Starts Train on a new install ([usedBefore] false), where nobody is
|
||||
/// signed in, or on one that chose its instance before, signed in unless
|
||||
/// [signedOut].
|
||||
Future<void> start(
|
||||
WidgetTester tester, {
|
||||
bool usedBefore = false,
|
||||
bool signedOut = false,
|
||||
bool passwords = true,
|
||||
FakeSimpleLoginGateway? simpleLogin,
|
||||
}) 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 {},
|
||||
sharesStorage: (a, b) => false,
|
||||
);
|
||||
await choice.load(usedBefore: usedBefore);
|
||||
if (!usedBefore || signedOut) {
|
||||
gateway.revokeSession();
|
||||
}
|
||||
await tester.pumpWidget(
|
||||
TrainApp(
|
||||
choice: choice,
|
||||
checker: FakeInstanceChecker({builtIn}),
|
||||
countryNames: MemoryCountryNames(const {}),
|
||||
open: (instance) async => TrainDependencies(
|
||||
serverName: instance.name,
|
||||
auth: AuthRepository(gateway, addresses),
|
||||
passwords: PasswordRepository(
|
||||
FakePasswordGateway(gateway)..offered = passwords,
|
||||
FakeAccountCrypto(),
|
||||
MemoryAccountKeyStore(),
|
||||
gateway,
|
||||
addresses,
|
||||
),
|
||||
// Offered by no instance here but where a test gives one:
|
||||
// halkyon_app's tests test its sheet.
|
||||
simpleLogin: SimpleLoginRepository(
|
||||
simpleLogin ?? (FakeSimpleLoginGateway(gateway)..isOffered = false),
|
||||
FakeOidcBrowser(),
|
||||
FakeAccountCrypto(),
|
||||
MemoryAccountKeyStore(),
|
||||
gateway,
|
||||
callbackScheme: 'app.halkyon.train',
|
||||
loopbackPort: 43824,
|
||||
),
|
||||
instanceOperators: InstanceOperatorRepository(
|
||||
FakeInstanceOperatorGateway(),
|
||||
),
|
||||
assistants: AssistantRepository(
|
||||
FakeAssistantGateway(),
|
||||
llm: FakeLlmClient(),
|
||||
),
|
||||
assistantOperators: AssistantOperatorRepository(
|
||||
FakeAssistantOperatorGateway(),
|
||||
FakeInstanceOperatorGateway(),
|
||||
),
|
||||
fileSaver: MemoryFileSaver(),
|
||||
sourceCodeUrl: () async =>
|
||||
'https://code.halkyon.app/project-halkyon/halkyon',
|
||||
openPerson: (person) async {
|
||||
opened.add(person);
|
||||
return TrainPerson(person);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens the account sheet from home.
|
||||
Future<void> openAccount(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Account'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("a new install chooses its instance first, in Train's words and "
|
||||
'look', (tester) async {
|
||||
await start(tester);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is BrandWordmark && widget.product == 'Train',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(
|
||||
'Choose the instance to keep your workouts on. Someone runs each one; '
|
||||
'you can add others later.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('train.example.org'), findsOneWidget);
|
||||
expect(find.text('Another instance'), findsOneWidget);
|
||||
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
|
||||
});
|
||||
|
||||
testWidgets("the build's instance in one tap, then how to start on it, "
|
||||
'making no account by itself (ADR 0020, D25)', (tester) async {
|
||||
await start(tester);
|
||||
|
||||
await tester.tap(find.text('train.example.org'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Start on train.example.org:'), findsOneWidget);
|
||||
expect(find.text('Create an account'), findsOneWidget);
|
||||
expect(opened, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("an account's home is empty until workouts come", (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
|
||||
expect(opened.single, FakeAuthGateway.person);
|
||||
expect(find.text('Nothing here yet'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Workouts come with a later version of Halkyon Train.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('signed out, it asks how to start, with an account to create '
|
||||
'where passwords are offered (ADR 0017), and no GitHub (ADR 0020, '
|
||||
'D36)', (tester) async {
|
||||
await start(tester, usedBefore: true, signedOut: true);
|
||||
|
||||
expect(find.text('Start on train.example.org:'), findsOneWidget);
|
||||
expect(find.text('Create an account'), findsOneWidget);
|
||||
expect(find.textContaining('GitHub'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('where no account can be made, it says so', (tester) async {
|
||||
await start(tester, usedBefore: true, signedOut: true, passwords: false);
|
||||
|
||||
expect(
|
||||
find.text('train.example.org offers no way to create an account.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the account says on which instance it is signed in; signing '
|
||||
'out deletes nothing and asks how to start', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openAccount(tester);
|
||||
|
||||
expect(find.text('Signed in on train.example.org'), findsOneWidget);
|
||||
expect(find.text('Switch account'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.signOuts, 1);
|
||||
expect(gateway.accountDeletions, 0);
|
||||
expect(gateway.status, const SignedOut());
|
||||
expect(find.text('Start on train.example.org:'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('where the instance offers SimpleLogin, the account sheet '
|
||||
'adds it (ADR 0020, D21)', (tester) async {
|
||||
await start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
simpleLogin: FakeSimpleLoginGateway(gateway),
|
||||
);
|
||||
|
||||
await openAccount(tester);
|
||||
expect(find.widgetWithText(ListTile, 'Add SimpleLogin'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deleting the account asks first; Cancel keeps it, Delete '
|
||||
'deletes it and asks how to start', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openAccount(tester);
|
||||
|
||||
await tester.tap(find.text('Delete account'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete your account?'), 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('Start on train.example.org:'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a deletion the instance never got says so on the account '
|
||||
'sheet, the person still signed in', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openAccount(tester);
|
||||
gateway.deletionError = Exception('No connection');
|
||||
|
||||
await tester.tap(find.text('Delete account'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete account').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(SheetScaffold),
|
||||
matching: find.widgetWithText(
|
||||
SnackBar,
|
||||
"Couldn't delete your account: the instance wasn't reached. "
|
||||
'Try again.',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: 'on the sheet, not behind its barrier',
|
||||
);
|
||||
expect(gateway.status, isA<SignedIn>());
|
||||
});
|
||||
|
||||
testWidgets('About names the source of what the instance runs, which both '
|
||||
'licences ask to offer', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openAccount(tester);
|
||||
|
||||
await tester.tap(find.text('About'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('The source of what this instance runs:'), findsOneWidget);
|
||||
expect(
|
||||
find.text('https://code.halkyon.app/project-halkyon/halkyon'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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('app_en.arb');
|
||||
|
||||
test('German has every message, with the same placeholders', () {
|
||||
final german = _messages('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 ['app_en_GB.arb', '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',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a placeholder is found also inside a plural', () {
|
||||
expect(
|
||||
_placeholders('{count, plural, =1{1 card} other{{count} cards in {t}}}'),
|
||||
{'count', 't'},
|
||||
);
|
||||
expect(_placeholders("Couldn't open {name}"), {'name'});
|
||||
});
|
||||
}
|
||||
26
apps/train/halkyon_train_flutter/test/licences_test.dart
Normal file
26
apps/train/halkyon_train_flutter/test/licences_test.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/licences.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
"Lucide's notice, whose icon is Train's, joins the licence list",
|
||||
(
|
||||
tester,
|
||||
) async {
|
||||
registerLucideLicense();
|
||||
|
||||
final entries = await tester.runAsync(
|
||||
() => LicenseRegistry.licenses.toList(),
|
||||
);
|
||||
final lucide = entries!.where(
|
||||
(entry) => entry.packages.contains('Lucide (the app icon)'),
|
||||
);
|
||||
expect(lucide, isNotEmpty);
|
||||
expect(
|
||||
lucide.first.paragraphs.map((paragraph) => paragraph.text).join('\n'),
|
||||
contains('ISC License'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue