chore: Halkyon Learn moves into apps/learn

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

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

View file

@ -0,0 +1,169 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/widget/bar_chart.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeReviewGateway reviewGateway;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
cardGateway = FakeCardGateway();
reviewGateway = FakeReviewGateway();
});
/// A card of Japanese, rated with each of [ratings]: when, how, and how
/// long it took.
Future<void> studied(
List<({Duration ago, Rating rating, Duration took})> ratings,
) async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
final reviews = ReviewRepository(reviewGateway);
addTearDown(reviews.dispose);
final card = await cards.create(
'japanese',
CardContent(front: RichContent.plain('猫'), back: RichContent.empty()),
);
for (final (:ago, :rating, :took) in ratings) {
await reviews.record(
cardId: card,
rating: rating,
reviewedAt: clock.now().toUtc().subtract(ago),
duration: took,
);
}
}
Future<void> showStatistics(WidgetTester tester, {double width = 400}) async {
tester.view
..physicalSize = Size(width, 1600)
..devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
reviewGateway: reviewGateway,
),
);
await openTab(tester, 'Profile');
await tester.tap(find.widgetWithText(Tab, 'Statistics'));
await tester.pumpAndSettle();
// Worked out once the reviews stop changing for a moment.
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets('the Profile tab has the statistics beside the profile: the '
'time in all, per day and the most in a day, each card counted for '
'at most a minute, and the ratings', (tester) async {
// Today 2 min (one card left on the screen for ten), yesterday 30 s.
await studied([
(
ago: Duration.zero,
rating: Rating.easy,
took: const Duration(minutes: 10),
),
(
ago: Duration.zero,
rating: Rating.good,
took: const Duration(seconds: 60),
),
(
ago: const Duration(days: 1),
rating: Rating.again,
took: const Duration(seconds: 30),
),
]);
await showStatistics(tester);
expect(find.text('In total'), findsOneWidget);
// 2 min 30 s; 1 min 15 s a day over two days; 2 min today.
expect(find.text('3 min'), findsOneWidget);
expect(find.text('1 min'), findsOneWidget);
expect(find.text('Today · 2 min'), findsOneWidget);
expect(find.text('Today · 2 cards rated'), findsOneWidget);
expect(find.text('Perfect 1'), findsOneWidget);
expect(find.text('Bad 0'), findsOneWidget);
expect(find.byType(BarChart), findsNWidgets(2));
});
testWidgets('a tap on a bar says what it holds; Weeks shows weeks', (
tester,
) async {
await studied([
(
ago: const Duration(days: 1),
rating: Rating.again,
took: const Duration(seconds: 30),
),
]);
await showStatistics(tester);
expect(find.text('Today · No cards rated'), findsOneWidget);
// Yesterday's bar: the second from the end.
final chart = tester.getRect(find.byType(BarChart).last);
await tester.tapAt(
Offset(chart.right - (chart.width - 24) / 10 * 1.5, chart.center.dy),
);
await tester.pumpAndSettle();
expect(find.textContaining(' · 1 card rated'), findsOneWidget);
expect(find.text('Bad 1'), findsOneWidget);
await tester.tap(find.text('Weeks'));
await tester.pumpAndSettle();
expect(find.textContaining('Week of '), findsNWidgets(2));
});
testWidgets('from expanded windows on, the two charts stand side by side, '
'as tall as each other', (tester) async {
await studied([
(
ago: Duration.zero,
rating: Rating.good,
took: const Duration(seconds: 20),
),
]);
await showStatistics(tester, width: 1200);
final cards = find.ancestor(
of: find.byType(BarChart),
matching: find.byType(Card),
);
final [first, second] = [
for (final element in cards.evaluate())
tester.getRect(find.byElementPredicate((e) => e == element)),
];
expect(first.top, second.top);
expect(first.height, second.height);
expect(first.right, lessThan(second.left));
});
testWidgets('says so while nothing was studied', (tester) async {
await showStatistics(tester);
expect(find.textContaining('Nothing studied yet'), findsOneWidget);
expect(find.byType(BarChart), findsNothing);
});
}