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,480 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.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/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/model/pomodoro_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
void main() {
const verb = (id: 'tag-1', name: 'verb');
const noun = (id: 'tag-2', name: 'noun');
test('a studyset studied by no tags is left out', () {
final settings = const PersonSettings()
.withStudyTags('japanese', [verb])
.withStudyTags('japanese', []);
expect(settings.studyTags, isEmpty);
expect(settings.studyTagsOf('japanese'), isEmpty);
});
test('folding keeps these settings and adds what only the other has', () {
const mine = PersonSettings(
studyTags: {
'japanese': [verb],
},
);
const theirs = PersonSettings(
studyTags: {
'japanese': [noun],
'german': [noun],
},
);
expect(
mine.foldedWith(theirs),
const PersonSettings(
studyTags: {
'japanese': [verb],
'german': [noun],
},
),
);
});
test('survive a round trip through the payload', () {
const settings = PersonSettings(
studyTags: {
'japanese': [verb, noun],
'日本語': [noun],
},
);
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
});
test('study options survive a round trip, and a payload of the first '
'schema reads with them as they come', () {
const settings = PersonSettings(
study: StudyOptions(
mode: LearnMode.cram,
order: StudyOrder.random,
cardsPerRound: 10,
newPerDay: 0,
backFirst: true,
),
);
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodeSettingsPayload(encodePayload(1, {'studyTags': {}})).study,
const StudyOptions(),
);
});
test('quizzing vocabulary as multiple choice survives a round trip, in the '
'fifth schema; an aspect of a newer app reads as the meaning', () {
const settings = PersonSettings(
study: StudyOptions(
quizVocabulary: true,
quizAspect: VocabularyQuizAspect.mixed,
),
);
expect(settingsSchemaVersion, greaterThanOrEqualTo(5));
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodeSettingsPayload(
encodePayload(5, {
'studyTags': {},
'study': {'quizVocabulary': true, 'quizAspect': 'kanji'},
}),
).study,
const StudyOptions(quizVocabulary: true),
);
});
test('typing readings survives a round trip, in the sixth schema; a '
'payload from before has it off', () {
const settings = PersonSettings(study: StudyOptions(typeReadings: true));
expect(settingsSchemaVersion, greaterThanOrEqualTo(6));
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodeSettingsPayload(
encodePayload(5, {'studyTags': {}, 'study': {}}),
).study.typeReadings,
isFalse,
);
});
test('writing by hand survives a round trip, in the seventh schema; a '
'payload from before has it off', () {
const settings = PersonSettings(study: StudyOptions(writeByHand: true));
expect(settingsSchemaVersion, greaterThanOrEqualTo(7));
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodeSettingsPayload(
encodePayload(6, {'studyTags': {}, 'study': {}}),
).study.writeByHand,
isFalse,
);
});
test('study options keep their numbers in range', () {
final options = const StudyOptions().copyWith(
cardsPerRound: 50,
newPerDay: -5,
);
expect(options.cardsPerRound, StudyOptions.maxCardsPerRound);
expect(options.newPerDay, StudyOptions.unlimitedNewPerDay);
});
test('newest first is for cramming; spaced repetition goes oldest '
'first', () {
expect(
const StudyOptions(order: StudyOrder.newestFirst).effectiveOrder,
StudyOrder.oldestFirst,
);
expect(
const StudyOptions(
mode: LearnMode.cram,
order: StudyOrder.newestFirst,
).effectiveOrder,
StudyOrder.newestFirst,
);
});
test('the goal, rest days and home layout survive a round trip', () {
final settings = PersonSettings(
goal: const StudyGoal(period: GoalPeriod.week, target: 150),
restDays: {DateTime(2026, 10, 5), DateTime(2026, 12, 24)},
home: const HomeLayout(
order: [
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.pomodoro,
HomeWidget.deadlines,
HomeWidget.week,
],
hidden: {HomeWidget.week},
),
);
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
});
test('a home layout from another version keeps the widgets this one '
'knows, and adds those it misses', () {
final settings = decodeSettingsPayload(
encodePayload(3, {
'studyTags': {},
'home': {
'order': ['activity', 'clock'],
'hidden': ['clock'],
},
}),
);
expect(settings.home.order, [
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.pomodoro,
HomeWidget.deadlines,
HomeWidget.week,
]);
expect(settings.home.hidden, isEmpty);
});
test('folding keeps both rest days, and the goal one of them has', () {
final mine = PersonSettings(restDays: {DateTime(2026, 10, 5)});
final theirs = PersonSettings(
goal: const StudyGoal(period: GoalPeriod.day, target: 20),
restDays: {DateTime(2026, 10, 6)},
);
final folded = mine.foldedWith(theirs);
expect(folded.restDays, {DateTime(2026, 10, 5), DateTime(2026, 10, 6)});
expect(folded.goal, theirs.goal);
});
group('Pomodoro profiles', () {
const mine = PomodoroProfile(
id: 'mine',
name: 'Exam weeks',
focusMinutes: 40,
shortBreakMinutes: 8,
longBreakMinutes: 20,
longBreakAfter: 3,
autoStartFocus: true,
autoStartBreaks: true,
doNotDisturb: true,
);
test('survive a round trip, in the newest schema', () {
const settings = PersonSettings(
pomodoro: PomodoroSettings(
profiles: [PomodoroProfile.classic, mine],
selectedId: 'mine',
),
);
final payload = encodeSettingsPayload(settings);
expect(
decodePayload(
payload,
newestSchema: settingsSchemaVersion,
)['schemaVersion'],
settingsSchemaVersion,
);
expect(decodeSettingsPayload(payload), settings);
});
test('as they come, are left out, in the newest schema all the same', () {
final payload = decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
);
expect(payload['schemaVersion'], settingsSchemaVersion);
expect(payload.keys, isNot(contains('pomodoro')));
});
test('that are malformed read as they come, keeping the other '
'settings', () {
final settings = decodeSettingsPayload(
encodePayload(4, {
'studyTags': {},
'study': {'newPerDay': 10},
'pomodoro': {
'profiles': [
{'id': 'x', 'focus': 'long'},
],
},
}),
);
expect(settings.pomodoro, const PomodoroSettings());
expect(settings.study.newPerDay, 10);
});
test('folding keeps the profiles of the settings that have any', () {
const theirs = PersonSettings(
pomodoro: PomodoroSettings(profiles: [mine], selectedId: 'mine'),
);
expect(
const PersonSettings().foldedWith(theirs).pomodoro,
theirs.pomodoro,
);
});
});
test('counting learning in Explore survives a round trip, in the eighth '
'schema; a payload from before has it off', () {
const counted = PersonSettings(countLearning: true);
expect(settingsSchemaVersion, greaterThanOrEqualTo(8));
expect(decodeSettingsPayload(encodeSettingsPayload(counted)), counted);
expect(
decodeSettingsPayload(
encodePayload(7, {'studyTags': <String, Object?>{}}),
).countLearning,
isFalse,
);
});
test('hiding the tags from Jellyfin while studying survives a round '
'trip, in the ninth schema, left out while off; on, on either '
'device, it stays on as they fold', () {
const hiding = PersonSettings(hideJellyfinTags: true);
expect(decodeSettingsPayload(encodeSettingsPayload(hiding)), hiding);
expect(
decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
),
isNot(contains('hideJellyfinTags')),
);
expect(
const PersonSettings().foldedWith(hiding).hideJellyfinTags,
isTrue,
);
});
group('where words added from a studyset go', () {
test('is the studyset itself until another is chosen; choosing the '
'studyset itself forgets the choice', () {
final settings = const PersonSettings().withAddDestination(
'vocabulary',
'n3',
);
expect(const PersonSettings().addDestinationOf('vocabulary'), isNull);
expect(settings.addDestinationOf('vocabulary'), 'n3');
expect(settings.addDestinationOf('japanese'), isNull);
expect(
settings.withAddDestination('vocabulary', 'vocabulary').addDestinations,
isEmpty,
);
});
test('survives a round trip, in the tenth schema, left out while none '
'is chosen; a payload from before has none', () {
final settings = const PersonSettings().withAddDestination(
'vocabulary',
'n3',
);
expect(settingsSchemaVersion, 10);
expect(
decodePayload(
encodeSettingsPayload(settings),
newestSchema: settingsSchemaVersion,
)['addDestinations'],
{'vocabulary': 'n3'},
);
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
).keys,
isNot(contains('addDestinations')),
);
expect(
decodeSettingsPayload(
encodePayload(9, {
'studyTags': <String, Object?>{},
'addDestinations': 'n3',
}),
).addDestinations,
isEmpty,
);
});
test('fold as ties do: for a studyset these settings win, the choices '
'only the other has are added', () {
final here = const PersonSettings().withAddDestination(
'vocabulary',
'n3',
);
final there = const PersonSettings()
.withAddDestination('vocabulary', 'n5')
.withAddDestination('kanji', 'grade-1');
expect(here.foldedWith(there).addDestinations, {
'vocabulary': 'n3',
'kanji': 'grade-1',
});
});
});
group('the Jellyfin shows tied to a studyset', () {
const show = JellyfinTie(serverId: 'server-1', itemId: 'show-1');
const film = JellyfinTie(serverId: 'server-1', itemId: 'film-1');
const elsewhere = JellyfinTie(serverId: 'server-2', itemId: 'show-9');
test('are tied once each, in the order tied, and untied', () {
final settings = const PersonSettings()
.withJellyfinTie('japanese', show)
.withJellyfinTie('japanese', film)
.withJellyfinTie('japanese', show);
expect(settings.jellyfinTiesOf('japanese'), [show, film]);
expect(
settings
.withoutJellyfinTie('japanese', show)
.jellyfinTiesOf('japanese'),
[film],
);
expect(
settings
.withoutJellyfinTie('japanese', show)
.withoutJellyfinTie('japanese', film)
.jellyfinTies,
isEmpty,
);
});
test('survive a round trip as ids only, in the ninth schema', () {
final settings = const PersonSettings()
.withJellyfinTie('japanese', show)
.withJellyfinTie('japanese', elsewhere);
final payload = decodePayload(
encodeSettingsPayload(settings),
newestSchema: settingsSchemaVersion,
);
expect(settingsSchemaVersion, greaterThanOrEqualTo(9));
expect(payload['jellyfinTies'], {
'japanese': [
{'server': 'server-1', 'item': 'show-1'},
{'server': 'server-2', 'item': 'show-9'},
],
});
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
});
test('are left out while there are none; a payload from before has '
'none', () {
expect(
decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
).keys,
isNot(contains('jellyfinTies')),
);
expect(
decodeSettingsPayload(
encodePayload(8, {'studyTags': <String, Object?>{}}),
).jellyfinTies,
isEmpty,
);
});
test('that are malformed are skipped, keeping the rest', () {
final settings = decodeSettingsPayload(
encodePayload(9, {
'studyTags': <String, Object?>{},
'study': {'newPerDay': 10},
'jellyfinTies': {
'japanese': [
{'server': 'server-1', 'item': 'show-1'},
{'server': 'server-1'},
'show-2',
],
'korean': 'show-3',
},
}),
);
expect(settings.jellyfinTies, {
'japanese': [show],
});
expect(settings.study.newPerDay, 10);
});
test('fold as tags do: for a studyset these settings win, ties of '
'studysets only the other has are added', () {
final mine = const PersonSettings().withJellyfinTie('japanese', show);
final theirs = const PersonSettings()
.withJellyfinTie('japanese', film)
.withJellyfinTie('korean', elsewhere);
expect(mine.foldedWith(theirs).jellyfinTies, {
'japanese': [show],
'korean': [elsewhere],
});
});
});
}