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,226 @@
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/home/bloc/home_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.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_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
void main() {
late StudysetRepository studysets;
late CardRepository cards;
late ReviewRepository reviews;
late SettingsRepository settings;
late PathProgressRepository pathProgress;
late HomeBloc bloc;
// A Wednesday afternoon, local time.
final now = DateTime(2026, 10, 7, 15);
final today = DateTime(2026, 10, 7);
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
reviews = ReviewRepository(FakeReviewGateway());
settings = SettingsRepository(FakeSettingsGateway());
pathProgress = PathProgressRepository(FakePathAnswerGateway());
});
tearDown(() async {
await bloc.close();
await studysets.dispose();
await cards.dispose();
await reviews.dispose();
await settings.dispose();
await pathProgress.dispose();
});
CardContent card() =>
CardContent(front: RichContent.empty(), back: RichContent.empty());
void start() =>
bloc = HomeBloc(studysets, cards, reviews, settings, pathProgress)
..add(const HomeEventStarted());
Future<HomeStateLoaded> showing(bool Function(HomeStateLoaded) test) async =>
await bloc.stream.firstWhere((s) => s is HomeStateLoaded && test(s))
as HomeStateLoaded;
test('a step of a path done counts as activity, and keeps the '
'streak', () async {
await withClock(Clock.fixed(now), () async {
await withClock(
Clock.fixed(now.subtract(const Duration(days: 1))),
() => pathProgress.record('step-1', AnswerOutcome.continued),
);
start();
final yesterday = await showing((s) => s.streak == 1);
expect(yesterday.activity.on(today), 0);
await pathProgress.record('step-2', AnswerOutcome.continued);
await pathProgress.record('step-3', AnswerOutcome.wrong);
final both = await showing((s) => s.streak == 2);
expect(both.activity.on(today), 1);
expect(both.goalProgress, 0);
});
});
test('counts ratings and cards written, keeps the streak and the goal, '
'and follows new ratings', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await cards.create(japanese, card());
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 1)),
duration: Duration.zero,
);
await settings.update(
(s) => s.withGoal(const StudyGoal(period: GoalPeriod.day, target: 5)),
);
start();
// Written today, rated yesterday: two days in a row.
final first = await showing((s) => s.goal != null);
expect(first.streak, 2);
expect(first.activity.on(today), 1);
expect(first.goalProgress, 0);
await reviews.record(
cardId: neko,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
final rated = await showing((s) => s.goalProgress == 1);
expect(rated.activity.on(today), 2);
});
});
test('imported cards are no writing of the day', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
await cards.createAll(japanese, [card(), card()], origin: 'anki');
await cards.create(japanese, card());
start();
final state = await showing((s) => true);
expect(state.activity.on(today), 1);
});
});
test('an import alone starts the activity, without studying', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
await cards.createAll(japanese, [card()], origin: 'anki');
start();
final state = await showing((s) => true);
expect(state.activity.start, today);
expect(state.activity.on(today), 0);
});
});
test('cards fall due on their day, but those resting in an archived '
'studyset', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final german = await studysets.create(StudysetContent.titled('German'));
for (final studyset in [japanese, german]) {
final id = await cards.create(studyset, card());
await reviews.record(
cardId: id,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
}
await studysets.archive(german);
start();
final state = await showing((s) => s.due.isNotEmpty);
expect(state.due.values.fold(0, (a, b) => a + b), 1);
});
});
test('a rest day keeps the streak, and toggles off again', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await cards.create(japanese, card());
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 2)),
duration: Duration.zero,
);
start();
// Written today, rated two days ago: yesterday breaks it.
expect((await showing((s) => true)).streak, 1);
final yesterday = DateTime(2026, 10, 6);
bloc.add(HomeEventRestDayToggled(yesterday));
expect((await showing((s) => s.restDays.isNotEmpty)).streak, 3);
bloc.add(HomeEventRestDayToggled(yesterday));
expect((await showing((s) => s.restDays.isEmpty)).streak, 1);
});
});
test('saves the goal and the layout in the settings', () async {
start();
await showing((s) => true);
bloc
..add(
const HomeEventGoalSet(StudyGoal(period: GoalPeriod.week, target: 50)),
)
..add(
const HomeEventLayoutChanged(
HomeLayout(
order: [
HomeWidget.week,
HomeWidget.goal,
HomeWidget.pomodoro,
HomeWidget.activity,
HomeWidget.deadlines,
],
hidden: {
HomeWidget.pomodoro,
HomeWidget.activity,
HomeWidget.deadlines,
},
),
),
);
final state = await showing(
(s) => s.goal != null && s.layout.hidden.isNotEmpty,
);
expect(state.layout.shown, [HomeWidget.week, HomeWidget.goal]);
expect((await settings.read()).goal?.target, 50);
});
}