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:
parent
11abd9afce
commit
649499be23
2 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/statistics/model/study_statistics.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
|
||||
void main() {
|
||||
// Monday, 12 October 2026, in the afternoon, local time.
|
||||
final now = DateTime(2026, 10, 12, 15);
|
||||
var ids = 0;
|
||||
|
||||
Review review(
|
||||
DateTime at, {
|
||||
Duration duration = const Duration(seconds: 20),
|
||||
Rating rating = Rating.good,
|
||||
StudyMode mode = StudyMode.review,
|
||||
}) => Review(
|
||||
id: 'review-${ids++}',
|
||||
cardId: 'card',
|
||||
rating: rating,
|
||||
reviewedAt: at.toUtc(),
|
||||
duration: duration,
|
||||
mode: mode,
|
||||
);
|
||||
|
||||
test('counts at most a minute a card, cramming too, a fresh start '
|
||||
'not', () {
|
||||
final statistics = studyStatistics(
|
||||
[
|
||||
review(now, duration: const Duration(minutes: 45)),
|
||||
review(now, duration: const Duration(seconds: 30)),
|
||||
review(now, mode: StudyMode.cram),
|
||||
review(
|
||||
now,
|
||||
duration: const Duration(minutes: 5),
|
||||
mode: StudyMode.reset,
|
||||
),
|
||||
],
|
||||
now: now,
|
||||
span: StatisticsSpan.days,
|
||||
);
|
||||
|
||||
expect(statistics.total, const Duration(seconds: 110));
|
||||
expect(statistics.periods.last.time, const Duration(seconds: 110));
|
||||
expect(statistics.periods.last.reviews, 3);
|
||||
expect(statistics.studied, true);
|
||||
});
|
||||
|
||||
test('per day counts every day from the first studied to today; the '
|
||||
'record is the most in one day', () {
|
||||
final statistics = studyStatistics(
|
||||
[
|
||||
// Three days ago, 2 minutes; today, 1.
|
||||
for (var i = 0; i < 2; i++)
|
||||
review(
|
||||
now.subtract(const Duration(days: 3)),
|
||||
duration: const Duration(minutes: 1),
|
||||
),
|
||||
review(now, duration: const Duration(minutes: 1)),
|
||||
],
|
||||
now: now,
|
||||
span: StatisticsSpan.days,
|
||||
);
|
||||
|
||||
expect(statistics.total, const Duration(minutes: 3));
|
||||
// Four days: three ago, two, one, today.
|
||||
expect(statistics.perDay, const Duration(seconds: 45));
|
||||
expect(statistics.record, const Duration(minutes: 2));
|
||||
});
|
||||
|
||||
test('ten days, today last; ten weeks from Monday; twelve months', () {
|
||||
final days = studyStatistics([], now: now, span: StatisticsSpan.days);
|
||||
expect(days.periods, hasLength(10));
|
||||
expect(days.periods.first.start, DateTime(2026, 10, 3));
|
||||
expect(days.periods.last.start, DateTime(2026, 10, 12));
|
||||
expect(days.studied, false);
|
||||
|
||||
final weeks = studyStatistics([], now: now, span: StatisticsSpan.weeks);
|
||||
expect(weeks.periods, hasLength(10));
|
||||
expect(weeks.periods.last.start, DateTime(2026, 10, 12));
|
||||
expect(weeks.periods.first.start, DateTime(2026, 8, 10));
|
||||
|
||||
final months = studyStatistics([], now: now, span: StatisticsSpan.months);
|
||||
expect(months.periods, hasLength(12));
|
||||
expect(months.periods.first.start, DateTime(2025, 11));
|
||||
expect(months.periods.last.start, DateTime(2026, 10));
|
||||
});
|
||||
|
||||
test("each period holds its reviews' time and ratings; older ones count "
|
||||
'in the totals only', () {
|
||||
final statistics = studyStatistics(
|
||||
[
|
||||
review(DateTime(2026, 10, 6, 9), rating: Rating.again),
|
||||
review(DateTime(2026, 10, 11, 23), rating: Rating.easy),
|
||||
review(DateTime(2026, 10, 12, 8), rating: Rating.easy),
|
||||
review(DateTime(2026, 7, 1, 8)),
|
||||
],
|
||||
now: now,
|
||||
span: StatisticsSpan.weeks,
|
||||
);
|
||||
|
||||
final [...earlier, lastWeek, thisWeek] = statistics.periods;
|
||||
expect(thisWeek.ratings, {Rating.easy: 1});
|
||||
expect(lastWeek.ratings, {Rating.again: 1, Rating.easy: 1});
|
||||
expect(lastWeek.time, const Duration(seconds: 40));
|
||||
expect(earlier.every((period) => period.reviews == 0), true);
|
||||
expect(statistics.total, const Duration(seconds: 80));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue