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
5 changed files with 0 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue