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

View file

@ -0,0 +1,83 @@
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/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
void main() {
test('a search finds words of any language as typed on any keyboard: '
'without accents, tone marks or umlauts, ß as ss, kana by romaji', () {
bool finds(String text, String query) =>
matchesQuery(searchableText(text), query);
expect(finds('Straße', 'strasse'), isTrue);
expect(finds('Mädchen', 'madchen'), isTrue);
expect(finds('Mädchen', 'maedchen'), isTrue);
expect(finds('το σπίτι', 'σπιτι'), isTrue);
expect(finds('ο δρόμος', 'δρομος'), isTrue);
expect(finds('xué xí', 'xue xi'), isTrue);
expect(finds('nǚ ér', 'nu er'), isTrue);
expect(finds('café', 'CAFE'), isTrue);
expect(finds('たべる', 'taberu'), isTrue);
expect(finds('テレビ', 'てれび'), isTrue);
expect(finds('Haus', 'maus'), isFalse);
// Only a card's umlauts are written out, never a query's ue, oe or ae.
expect(finds('Müller', 'mueller'), isTrue);
expect(finds('blunt', 'blue'), isFalse);
expect(finds('aquí', 'que'), isFalse);
expect(finds('god', 'goed'), isFalse);
});
test('a blank search needs no search text: every card matches', () {
expect(queryMatcher(' '), isNull);
expect(queryMatcher('neko')?.call('neko\nねこ'), isTrue);
});
test("A–Z works each card's key out once, not at every comparison", () {
BrowsedCard browsed(String front) => (
card: Card(
id: 'card-$front',
position: 'a0',
content: CardContent(
front: RichContent.plain(front),
back: RichContent.empty(),
),
heads: const {},
),
studysetId: 'japanese',
tags: const [],
schedule: null,
);
var worked = 0;
final sorted = sortedCards(
[
for (final front in ['ね', 'a', 'Ärger', 'c', 'b']) browsed(front),
],
CardBrowserSort.alphabetical,
alphabeticKey: (card) {
worked++;
return alphabeticKeyOf(card);
},
);
expect(
[for (final each in sorted) each.card.content.front.plainText],
[
'a',
'Ärger',
'b',
'c',
'ね',
],
);
expect(worked, 5);
});
test('A–Z compares letters as their bare ones: Ärger among the a, xué '
'beside xue', () {
expect(searchFolded('Ärger'), 'arger');
expect(searchFolded('xué'), 'xue');
expect(searchFolded('ὁδός'), 'οδοσ');
});
}

View file

@ -0,0 +1,281 @@
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/model/card_content.dart';
void main() {
RichContent text(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
final none = RichContent.empty();
group('A card is complete', () {
test('front/back: with both sides', () {
expect(CardContent(front: text('猫'), back: text('cat')).isComplete, true);
expect(CardContent(front: text('猫'), back: none).isComplete, false);
});
test('multiple choice: with two answers or more, every one written, '
'one of them right', () {
CardContent question(List<CardAnswer> answers) => CardContent(
type: CardType.multipleChoice,
front: text('Capital of Japan?'),
back: none,
answers: answers,
);
final tokyo = CardAnswer(content: text('Tokyo'), correct: true);
final kyoto = CardAnswer(content: text('Kyoto'));
expect(question([tokyo, kyoto]).isComplete, true);
expect(question([tokyo]).isComplete, false);
expect(question([kyoto, kyoto]).isComplete, false);
expect(question([tokyo, CardAnswer(content: none)]).isComplete, false);
});
test('on a map: with a prompt and the region to point at', () {
final where = CardContent(
type: CardType.multipleChoice,
front: text('Where is Tokyo?'),
back: none,
answers: [
CardAnswer(content: text('Tokyo'), correct: true, region: 'JP-13'),
],
map: 'japan',
);
expect(where.isComplete, true);
expect(
where.withField(CardField.answers, <CardAnswer>[]).isComplete,
false,
);
});
test('vocabulary: with the expression and a meaning', () {
final word = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: none,
meanings: const ['to eat'],
);
expect(word.isComplete, true);
expect(word.withField(CardField.meanings, const [' ']).isComplete, false);
});
test('character: with the character and a meaning or a reading; a '
"kana's romaji is its meaning", () {
final kanji = CardContent(
type: CardType.character,
front: text('食'),
back: none,
onReadings: const ['ショク'],
);
final kana = CardContent(
type: CardType.character,
front: text('あ'),
back: none,
meanings: const ['a'],
);
expect(kanji.isComplete, true);
expect(kana.isComplete, true);
expect(
kanji.withField(CardField.onReadings, const <String>[]).isComplete,
false,
);
expect(kana.withField(CardField.front, none).isComplete, false);
expect(
kanji
.withField(CardField.onReadings, const <String>[])
.withField(CardField.front, none)
.isBlank,
true,
);
});
test('matching: with two targets and two items at least, every item in '
'a target and no target empty', () {
CardContent sorting(List<int> targets) => CardContent(
type: CardType.matching,
front: none,
back: none,
matching: Matching(
targets: const ['mammal', 'bird'],
items: [
for (final (i, target) in targets.indexed)
MatchingItem(text: 'animal $i', target: target),
],
),
);
expect(sorting([0, 1]).isComplete, true);
expect(sorting([0, 0]).isComplete, false);
expect(sorting([0, 2]).isComplete, false);
expect(sorting([0, -1, 1]).isComplete, false);
expect(sorting([0]).isComplete, false);
});
});
test("a map question's region is its right answer's; other cards have "
'none', () {
final where = CardContent(
type: CardType.multipleChoice,
front: text('Where is Tokyo?'),
back: none,
answers: [
CardAnswer(content: text('Tokyo'), correct: true, region: 'JP-13'),
],
map: 'japan',
);
expect(where.mapRegion, 'JP-13');
expect(where.withField(CardField.map, null).mapRegion, isNull);
expect(CardContent(front: text('猫'), back: none).mapRegion, isNull);
});
test('previews the answer as its type has it', () {
expect(
CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: none,
reading: 'たべる',
meanings: const ['to eat', 'to live on'],
).answerPreview,
'たべる · to eat; to live on',
);
expect(
CardContent(
type: CardType.multipleChoice,
front: text('Capital?'),
back: none,
answers: [
CardAnswer(content: text('Tokyo'), correct: true),
CardAnswer(content: text('Kyoto')),
],
).answerPreview,
'Tokyo',
);
expect(
CardContent(
type: CardType.character,
front: text('食'),
back: none,
meanings: const ['eat', 'food'],
onReadings: const ['ショク', 'ジキ'],
kunReadings: const ['た.べる'],
).answerPreview,
'eat; food · ショク、ジキ · た.べる',
);
});
test("a character's readings, typed on one line, split at commas, "
'Japanese commas and spaces', () {
expect(splitReadings('ショク、ジキ'), ['ショク', 'ジキ']);
expect(splitReadings(' く.う, た.べる は.む '), ['く.う', 'た.べる', 'は.む']);
expect(splitReadings(''), isEmpty);
});
test('uses only the fields of its type', () {
expect(
CardContent(front: none, back: none).fields,
{CardField.front, CardField.back},
);
expect(
CardContent(
type: CardType.multipleChoice,
front: none,
back: none,
map: 'japan',
).fields,
{CardField.front, CardField.answers, CardField.map},
);
expect(
CardContent(type: CardType.character, front: none, back: none).fields,
{
CardField.front,
CardField.meanings,
CardField.onReadings,
CardField.kunReadings,
},
);
});
test('normalized drops blank hints and a blank explanation, and takes a '
"word's meanings from its senses", () {
final content = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
meanings: const ['stale'],
senses: const [
VocabularySense(glosses: ['to eat', ' ']),
VocabularySense(glosses: ['to live on']),
],
hints: [text('a verb'), RichContent.empty()],
explanation: RichContent.empty(),
).normalized;
expect(content.hints, [text('a verb')]);
expect(content.explanation, isNull);
expect(content.meanings, ['to eat', 'to live on']);
expect(content.senses.first.glosses, ['to eat']);
});
test('a word is usually written in kana when its first sense says so', () {
CardContent word(List<VocabularySense> senses) => CardContent(
type: CardType.vocabulary,
front: text('蛙'),
back: RichContent.empty(),
senses: senses,
);
expect(
word(const [
VocabularySense(glosses: ['frog'], misc: ['uk']),
]).usuallyKana,
isTrue,
);
expect(
word(const [
VocabularySense(glosses: ['frog']),
VocabularySense(glosses: ['x'], misc: ['uk']),
]).usuallyKana,
isFalse,
);
});
test('normalized keeps only the fields of its type', () {
final switched = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: text('to eat'),
answers: [CardAnswer(content: text('Tokyo'), correct: true)],
reading: 'たべる',
meanings: const ['to eat'],
);
expect(
switched.normalized,
CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: none,
reading: 'たべる',
meanings: const ['to eat'],
),
);
});
}

View file

@ -0,0 +1,209 @@
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/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart';
/// Rich content holding just [text], enough to tell versions apart.
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
/// A revision with the id [id] (ids sort by creation, like UUID v7).
CardRevision revision(
String id,
CardContent content, {
Set<String> parents = const {},
}) => CardRevision(id: id, parents: parents, content: content);
void main() {
test('a card with one revision shows it', () {
final merged = mergeRevisions([revision('r1', card('big', 'groß'))])!;
expect(merged.content, card('big', 'groß'));
expect(merged.heads, {'r1'});
expect(merged.conflicts, isEmpty);
});
test('the newest revision of a chain is the current one', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('large', 'gross'), parents: {'r2'}),
])!;
expect(merged.content, card('large', 'gross'));
expect(merged.heads, {'r3'});
});
test('changes to different sides on two devices both survive', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('big', 'gross'), parents: {'r1'}),
])!;
expect(merged.content, card('large', 'gross'));
expect(merged.heads, {'r2', 'r3'});
expect(merged.conflicts, isEmpty);
});
test('the same change on two devices is no conflict', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('large', 'groß'), parents: {'r1'}),
])!;
expect(merged.content, card('large', 'groß'));
expect(merged.conflicts, isEmpty);
});
test('different changes to the same side are a conflict with every '
'version; the other side still merges', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('huge', 'gross'), parents: {'r1'}),
])!;
expect(merged.conflicts, {
CardField.front: [text('large'), text('huge')],
});
// Until the person chooses, the newest version shows.
expect(merged.content, card('huge', 'gross'));
expect(merged.heads, {'r2', 'r3'});
});
test('a revision with both as parents resolves the conflict', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('huge', 'gross'), parents: {'r1'}),
revision('r4', card('large', 'gross'), parents: {'r2', 'r3'}),
])!;
expect(merged.content, card('large', 'gross'));
expect(merged.heads, {'r4'});
expect(merged.conflicts, isEmpty);
});
test('three devices merge the same way', () {
final merged = mergeRevisions([
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('big', 'gross'), parents: {'r1'}),
revision('r4', card('huge', 'groß'), parents: {'r1'}),
])!;
expect(merged.heads, {'r2', 'r3', 'r4'});
expect(merged.conflicts, {
CardField.front: [text('large'), text('huge')],
});
expect(merged.content.back, text('gross'));
});
test('counts a revision whose parent has not arrived yet as it is', () {
final merged = mergeRevisions([
revision('r2', card('large', 'groß'), parents: {'r1'}),
])!;
expect(merged.content, card('large', 'groß'));
expect(merged.heads, {'r2'});
});
test('comes out the same whatever order the revisions arrive in', () {
final revisions = [
revision('r1', card('big', 'groß')),
revision('r2', card('large', 'groß'), parents: {'r1'}),
revision('r3', card('huge', 'gross'), parents: {'r1'}),
];
expect(
mergeRevisions(revisions.reversed),
mergeRevisions(revisions),
);
});
test('a card without revisions has no content', () {
expect(mergeRevisions([]), isNull);
});
group('of the other card types', () {
CardContent word({
String reading = 'たべる',
List<String> meanings = const ['to eat'],
}) => CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: reading,
meanings: meanings,
);
test('a word whose reading one device changed and whose meanings '
'another changed keeps both', () {
final merged = mergeRevisions([
revision('r1', word()),
revision('r2', word(reading: 'タベル'), parents: {'r1'}),
revision(
'r3',
word(meanings: ['to eat', 'to live on']),
parents: {'r1'},
),
])!;
expect(merged.conflicts, isEmpty);
expect(
merged.content,
word(reading: 'タベル', meanings: ['to eat', 'to live on']),
);
});
test('the same list of meanings on two devices is no conflict, '
'different ones are', () {
final same = mergeRevisions([
revision('r1', word()),
revision('r2', word(meanings: ['to eat', 'to dine']), parents: {'r1'}),
revision('r3', word(meanings: ['to eat', 'to dine']), parents: {'r1'}),
])!;
final different = mergeRevisions([
revision('r1', word()),
revision('r2', word(meanings: ['to dine']), parents: {'r1'}),
revision('r3', word(meanings: ['to feed']), parents: {'r1'}),
])!;
expect(same.conflicts, isEmpty);
expect(different.conflicts, {
CardField.meanings: [
['to dine'],
['to feed'],
],
});
});
test('answers changed differently are a conflict as a whole', () {
CardContent question(List<String> answers) => CardContent(
type: CardType.multipleChoice,
front: text('Capital of Japan?'),
back: RichContent.empty(),
answers: [
for (final (i, answer) in answers.indexed)
CardAnswer(content: text(answer), correct: i == 0),
],
);
final merged = mergeRevisions([
revision('r1', question(['Tokyo', 'Kyoto'])),
revision('r2', question(['Tokyo', 'Osaka']), parents: {'r1'}),
revision('r3', question(['Tokyo', 'Nara']), parents: {'r1'}),
])!;
expect(merged.conflicts.keys, [CardField.answers]);
expect(merged.conflicts[CardField.answers], hasLength(2));
});
});
}

View file

@ -0,0 +1,121 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_text_import.dart';
void main() {
test('one card per line, split at its first separator, so a back may '
'hold one too', () {
expect(parseCardLines('hola\thello\nadiós\tgood\tbye', CardSeparator.tab), [
(front: 'hola', back: 'hello'),
(front: 'adiós', back: 'good\tbye'),
]);
});
test('leaves out blank lines, lines without a separator and lines with '
'an empty side, such as a header without a back', () {
expect(
parseCardLines(
'Spanish vocab\n\nhola;hello\n;nothing in front\nnothing behind;\n',
CardSeparator.semicolon,
),
[(front: 'hola', back: 'hello')],
);
});
test('trims both sides, and reads Windows line endings', () {
expect(
parseCardLines(' hola , hello \r\nsí,yes\r\n', CardSeparator.comma),
[
(front: 'hola', back: 'hello'),
(front: 'sí', back: 'yes'),
],
);
});
test('reads quoted fields as spreadsheets write them: separators inside, '
'a doubled quote for one', () {
expect(
parseCardLines(
'"hello, ""friend""",hola amigo\nthank you,"gracias, señor"',
CardSeparator.comma,
),
[
(front: 'hello, "friend"', back: 'hola amigo'),
(front: 'thank you', back: 'gracias, señor'),
],
);
});
test('leaves out a line whose quoted front never closes', () {
expect(parseCardLines('"hello,hola', CardSeparator.comma), isEmpty);
});
test('ignores the byte order mark spreadsheets put before UTF-8 text', () {
expect(parseCardLines('hola,hello', CardSeparator.comma), [
(front: 'hola', back: 'hello'),
]);
});
test('a file says its separator by its name: commas for .csv, tabs for '
'the rest', () {
expect(CardSeparator.forFile('words.CSV'), CardSeparator.comma);
expect(CardSeparator.forFile('words.tsv'), CardSeparator.tab);
expect(CardSeparator.forFile('words.txt'), CardSeparator.tab);
});
test('a quoted field may run over several lines, and a back over '
'several fields keeps none of their quotes', () {
expect(
parseCardLines(
'"word","line one\nline two"\n"a","b","c"\nnext,one',
CardSeparator.comma,
),
[
(front: 'word', back: 'line one\nline two'),
(front: 'a', back: 'b,c'),
(front: 'next', back: 'one'),
],
);
});
test('a quote that never closes spoils only its own line', () {
expect(
parseCardLines('"hello,hola\nsí,yes', CardSeparator.comma),
[(front: 'sí', back: 'yes')],
);
});
test('the separator is guessed from what the lines hold: semicolons as '
'German spreadsheets write them, whatever the name', () {
expect(
guessSeparator(
'Wort;Bedeutung, mit Komma\nHaus;house',
fileName: 'w.csv',
),
CardSeparator.semicolon,
);
expect(
guessSeparator('hola\thello, hi\nsí\tyes', fileName: 'w.csv'),
CardSeparator.tab,
);
expect(
guessSeparator('nothing here', fileName: 'w.csv'),
CardSeparator.comma,
);
});
test('a file is read in whichever encoding it was saved: UTF-8, UTF-16 '
'with its mark, or Windows-1252', () {
expect(decodeCardText(utf8.encode('Straße;street')), 'Straße;street');
expect(
decodeCardText([0xFF, 0xFE, 0x68, 0, 0x3B, 0, 0x42, 0x30]),
'h;あ',
);
// "Café – €" as Windows-1252.
expect(
decodeCardText([0x43, 0x61, 0x66, 0xE9, 0x20, 0x96, 0x20, 0x80]),
'Café – €',
);
});
}