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
5 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,171 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
void main() {
group('Plans offered from level tags', () {
test('offer every level up to the highest present, each taking the '
'levels below it, whatever the case of the tags', () {
final plans = automaticExamPlans(['JLPT-N5', 'jlpt-n3', 'noun']);
expect(plans, const [
ExamPlan(id: 'auto:jlpt:1', name: 'JLPT N5', tags: ['JLPT-N5']),
ExamPlan(id: 'auto:jlpt:2', name: 'JLPT N4', tags: ['JLPT-N5']),
ExamPlan(
id: 'auto:jlpt:3',
name: 'JLPT N3',
tags: ['JLPT-N5', 'JLPT-N3'],
),
]);
expect(plans.every((plan) => plan.automatic), isTrue);
});
test('come for HSK and Korean levels too, and none without level '
'tags', () {
expect(
[
for (final plan in automaticExamPlans(['HSK-7-9', '중급'])) plan.name,
],
['HSK 7–9', 'Korean 중급'],
);
expect(automaticExamPlans(['noun', 'verb']), isEmpty);
});
test('leave out the levels below the lowest present, which would cover '
'nothing', () {
expect(automaticExamPlans(['HSK-3', 'HSK-5']), const [
ExamPlan(id: 'auto:hsk:3', name: 'HSK 3', tags: ['HSK-3']),
ExamPlan(id: 'auto:hsk:4', name: 'HSK 4', tags: ['HSK-3']),
ExamPlan(id: 'auto:hsk:5', name: 'HSK 5', tags: ['HSK-3', 'HSK-5']),
]);
});
});
group('An exam plan', () {
test('survives a round trip, and is its author’s', () {
const plan = ExamPlan(
id: 'final',
name: 'Final exam',
topics: ['kana'],
tags: ['verb'],
);
expect(ExamPlan.fromJson(plan.toJson()), plan);
expect(plan.automatic, isFalse);
expect(const ExamPlan(id: 'a', name: 'A').toJson(), {
'id': 'a',
'name': 'A',
});
});
test('is null for what is not one', () {
expect(ExamPlan.fromJson({'name': 'No id'}), isNull);
expect(ExamPlan.fromJson('final'), isNull);
});
});
group('The exam chosen', () {
test('keeps its day as the calendar has it', () {
final choice = ExamChoice(plan: 'final', date: DateTime(2026, 3, 9));
expect(choice.toJson(), {'plan': 'final', 'date': '2026-03-09'});
expect(ExamChoice.fromJson(choice.toJson()), choice);
});
test('is null without a day it can read', () {
expect(ExamChoice.fromJson({'plan': 'final', 'date': 'soon'}), isNull);
expect(ExamChoice.fromJson({'plan': 'final'}), isNull);
});
test('counts the days left by the calendar, and is past from the day '
'after it on', () {
final choice = ExamChoice(plan: 'final', date: DateTime(2026, 12, 6));
expect(choice.daysLeftAt(DateTime(2026, 10, 7, 23)), 60);
expect(choice.daysLeftAt(DateTime(2026, 12, 6, 8)), 0);
expect(choice.isPastAt(DateTime(2026, 12, 6, 23, 59)), isFalse);
expect(choice.isPastAt(DateTime(2026, 12, 7)), isTrue);
expect(choice.daysLeftAt(DateTime(2026, 12, 8)), 0);
});
});
group('The pace to an exam', () {
test('spreads what is new so that it is done a week before the exam', () {
final pace = examPace(
today: DateTime(2026, 10, 14, 21),
examDate: DateTime(2026, 12, 6),
newCards: 460,
stepsLeft: 13,
pagesLeft: 0,
);
// 53 days left, 46 of them to learn in: 10 cards a day, and 13 steps
// over six and a half weeks.
expect(pace, (
daysLeft: 53,
newPerDay: 10,
stepsPerWeek: 2,
pagesPerDay: 0,
));
});
test('leaves half the days left for reviews when the exam is closer '
'than two weeks', () {
final pace = examPace(
today: DateTime(2026, 10, 14),
examDate: DateTime(2026, 10, 24),
newCards: 25,
stepsLeft: 3,
pagesLeft: 101,
);
// 10 days left, 5 of them to learn in.
expect(pace, (
daysLeft: 10,
newPerDay: 5,
stepsPerWeek: 3,
pagesPerDay: 21,
));
});
test('keeps its rate when followed until two weeks are left', () {
int newPerDay(DateTime today, int newCards) => examPace(
today: today,
examDate: DateTime(2026, 12, 6),
newCards: newCards,
stepsLeft: 0,
pagesLeft: 0,
).newPerDay;
// 30 days out, 230 cards over the 23 before the last week.
expect(newPerDay(DateTime(2026, 11, 6), 230), 10);
// 16 days on, 70 are left, with 14 days to go: not half the rate.
expect(newPerDay(DateTime(2026, 11, 22), 70), 10);
});
test('counts calendar days across a change of summer time', () {
expect(
examPace(
today: DateTime(2026, 10, 20),
examDate: DateTime(2026, 10, 30),
newCards: 0,
stepsLeft: 0,
pagesLeft: 0,
).daysLeft,
10,
);
});
test('asks for everything at once on the day or after it', () {
final pace = examPace(
today: DateTime(2026, 12, 8),
examDate: DateTime(2026, 12, 6),
newCards: 40,
stepsLeft: 0,
pagesLeft: 0,
);
expect(pace.daysLeft, 0);
expect(pace.newPerDay, 40);
});
});
}

View file

@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_progress.dart';
void main() {
ExamProgress progress({
int newCards = 0,
int startedToday = 0,
int due = 0,
}) => (
cards: 100,
newCards: newCards,
startedToday: startedToday,
due: due,
steps: 0,
stepsDone: 0,
pages: 0,
pagesRead: 0,
);
const ExamPace pace = (
daysLeft: 30,
newPerDay: 5,
stepsPerWeek: 0,
pagesPerDay: 0,
);
test('studying for the exam takes every review due and the new cards '
'the pace leaves for the day', () {
expect(examCardsToStudy(progress(newCards: 40, due: 12), pace), 17);
expect(
examCardsToStudy(progress(newCards: 40, startedToday: 3, due: 2), pace),
4,
);
expect(
examCardsToStudy(progress(newCards: 40, startedToday: 7), pace),
0,
reason: 'more started today than the pace asks',
);
expect(
examCardsToStudy(progress(newCards: 2, due: 1), pace),
3,
reason: 'no more new cards than there are',
);
});
}

View file

@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
void main() {
test('links a Creative Commons licence to its deed', () {
expect(
licenseUrl('CC-BY-SA-4.0'),
Uri.parse('https://creativecommons.org/licenses/by-sa/4.0/'),
);
expect(
licenseUrl('CC-BY-NC-SA-3.0-US'),
Uri.parse('https://creativecommons.org/licenses/by-nc-sa/3.0/us/'),
);
expect(
licenseUrl('CC0-1.0'),
Uri.parse('https://creativecommons.org/publicdomain/zero/1.0/'),
);
});
test("links other well-known licences to SPDX's page", () {
expect(
licenseUrl('MIT'),
Uri.parse('https://spdx.org/licenses/MIT.html'),
);
expect(
licenseUrl('AGPL-3.0-or-later'),
Uri.parse('https://spdx.org/licenses/AGPL-3.0-or-later.html'),
);
});
test('links nothing it does not know', () {
expect(licenseUrl('All Rights Reserved'), isNull);
expect(licenseUrl('My own licence'), isNull);
});
}

View file

@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license.dart';
void main() {
test('a licence is kept in one spelling: known ones as known, Creative '
'Commons by its identifier, anything else as typed', () {
expect(canonicalLicense('Cc By 4.0'), 'CC-BY-4.0');
expect(canonicalLicense('cc_by_sa'), 'CC-BY-SA-4.0');
expect(canonicalLicense('mit'), 'MIT');
expect(canonicalLicense('arr'), 'All Rights Reserved');
expect(canonicalLicense('cc by-nc-sa 3.0 us'), 'CC-BY-NC-SA-3.0-US');
expect(canonicalLicense(' My own terms '), 'My own terms');
expect(canonicalLicense(' '), isNull);
expect(canonicalLicense(null), isNull);
});
test("words around a Creative Commons licence don't change it, and a "
'name written out is read', () {
expect(canonicalLicense('CC BY-SA 4.0 International'), 'CC-BY-SA-4.0');
expect(canonicalLicense('CC BY 3.0 Unported'), 'CC-BY-3.0');
expect(
canonicalLicense(
'Creative Commons Attribution-NonCommercial-ShareAlike 4.0 '
'International Public License',
),
'CC-BY-NC-SA-4.0',
);
expect(canonicalLicense('CC BY 3.0 IGO'), 'CC-BY-3.0-IGO');
// 4.0 was never ported to a country.
expect(canonicalLicense('CC BY 4.0 DE'), 'CC BY 4.0 DE');
});
test("a licence's meaning is never widened: GPL-3.0 is \"only\", and "
'what says neither, or is no licence, stays as typed', () {
expect(canonicalLicense('GPL-3.0'), 'GPL-3.0-only');
expect(canonicalLicense('agpl 3.0'), 'AGPL-3.0-only');
expect(canonicalLicense('GPL-3.0-or-later'), 'GPL-3.0-or-later');
expect(canonicalLicense('GPLv3'), 'GPLv3');
expect(canonicalLicense('public domain'), 'public domain');
});
test('an older or ported Creative Commons licence has a current version; '
'a current one, or another licence, none', () {
expect(newerCreativeCommons('CC-BY-NC-SA-3.0-US'), 'CC-BY-NC-SA-4.0');
expect(newerCreativeCommons('CC-BY-2.0'), 'CC-BY-4.0');
expect(newerCreativeCommons('CC-BY-SA-4.0'), isNull);
expect(newerCreativeCommons('CC0-1.0'), isNull);
expect(newerCreativeCommons('MIT'), isNull);
});
}

View file

@ -0,0 +1,207 @@
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/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
Studyset studyset(
String id, {
String? parent,
String position = 'a0',
DateTime? archivedAt,
}) => Studyset(
id: id,
content: StudysetContent.titled(id),
parentId: parent,
position: position,
archivedAt: archivedAt,
);
Iterable<String> ids(List<Studyset> studysets) =>
studysets.map((studyset) => studyset.id);
void main() {
test('the top level holds the studysets without a parent, in their '
'order', () {
final all = [
studyset('japanese'),
studyset('kana', parent: 'japanese'),
studyset('german'),
];
expect(ids(topLevel(all)), ['japanese', 'german']);
});
test('a studyset whose parent is gone shows at the top level, with what '
'is inside it', () {
final all = [
studyset('kana', parent: 'deleted'),
studyset('hiragana', parent: 'kana'),
];
expect(ids(topLevel(all)), ['kana']);
});
test('studysets moved into each other show at the top level; what is '
'inside them stays inside', () {
// Two devices moved a into b and b into a at once.
final all = [
studyset('a', parent: 'b'),
studyset('b', parent: 'a'),
studyset('c', parent: 'a'),
];
expect(ids(topLevel(all)), ['a', 'b']);
});
group('the children of a studyset', () {
test('come in their order; ties go by id', () {
final all = [
studyset('japanese'),
studyset('kanji', parent: 'japanese', position: 'a2'),
studyset('kana', parent: 'japanese', position: 'a1'),
studyset('grammar', parent: 'japanese', position: 'a1'),
studyset('german'),
];
expect(ids(childrenOf('japanese', all)), ['grammar', 'kana', 'kanji']);
});
});
group('the subtree of a studyset', () {
test('is the studyset, then each child with its own subtree, in '
'order', () {
final all = [
studyset('japanese'),
studyset('kana', parent: 'japanese', position: 'a1'),
studyset('hiragana', parent: 'kana', position: 'a1'),
studyset('katakana', parent: 'kana', position: 'a2'),
studyset('kanji', parent: 'japanese', position: 'a2'),
studyset('german'),
];
expect(subtreeOf('japanese', all), [
'japanese',
'kana',
'hiragana',
'katakana',
'kanji',
]);
});
test('ends where studysets were moved into each other', () {
final all = [
studyset('a', parent: 'b'),
studyset('b', parent: 'a'),
studyset('c', parent: 'a'),
];
expect(subtreeOf('a', all), ['a', 'b', 'c']);
});
});
group('the ancestors of a studyset', () {
test('lead from the top level down to its parent', () {
final all = [
studyset('japanese'),
studyset('kana', parent: 'japanese'),
studyset('hiragana', parent: 'kana'),
];
expect(ids(ancestorsOf('hiragana', all)), ['japanese', 'kana']);
expect(ancestorsOf('japanese', all), isEmpty);
});
test('start at a studyset that shows at the top level for a missing '
'parent or a cycle', () {
final all = [
studyset('kana', parent: 'deleted'),
studyset('hiragana', parent: 'kana'),
studyset('a', parent: 'b'),
studyset('b', parent: 'a'),
studyset('c', parent: 'a'),
];
expect(ids(ancestorsOf('hiragana', all)), ['kana']);
expect(ids(ancestorsOf('c', all)), ['a']);
expect(ancestorsOf('a', all), isEmpty);
});
});
group('what a session on a studyset studies', () {
test('is its subtree in tree order, without archived studysets and '
'what is inside them', () {
final archived = DateTime.utc(2026, 10);
final all = [
studyset('japanese'),
studyset('kana', parent: 'japanese', position: 'a1'),
studyset('hiragana', parent: 'kana'),
studyset(
'kanji',
parent: 'japanese',
position: 'a2',
archivedAt: archived,
),
studyset('jouyou', parent: 'kanji'),
];
expect(studiedIn('japanese', all), ['japanese', 'kana', 'hiragana']);
expect(studiedIn('kanji', all), isEmpty);
});
});
test('a studyset without a colour of its own takes the nearest one above '
'it', () {
Studyset coloured(String id, String? color, {String? parent}) => Studyset(
id: id,
content: StudysetContent(
title: id,
description: RichContent.empty(),
colorName: color,
),
parentId: parent,
);
final all = [
coloured('japanese', 'fern'),
coloured('kana', null, parent: 'japanese'),
coloured('hiragana', null, parent: 'kana'),
coloured('kanji', 'plum', parent: 'japanese'),
coloured('german', null),
];
expect(colorNameOf('hiragana', all), 'fern');
expect(colorNameOf('kanji', all), 'plum');
expect(colorNameOf('german', all), isNull);
});
test('a studyset without languages of its own takes those of the nearest '
'studyset above it that gives any, both together', () {
Studyset speaking(
String id, {
String? front,
String? back,
String? parent,
}) => Studyset(
id: id,
content: StudysetContent(
title: id,
description: RichContent.empty(),
frontLanguage: front,
backLanguage: back,
),
parentId: parent,
);
final all = [
speaking('japanese', front: 'ja', back: 'en'),
speaking('kana', parent: 'japanese'),
speaking('hiragana', parent: 'kana'),
// Its own front, and no back: the inherited back doesn't join it.
speaking('kanji', front: 'ja', parent: 'japanese'),
speaking('german'),
];
expect(languagesOf('hiragana', all), (front: 'ja', back: 'en'));
expect(languagesOf('kanji', all), (front: 'ja', back: null));
expect(languagesOf('german', all), (front: null, back: null));
});
}