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
4 changed files with 0 additions and 0 deletions
|
|
@ -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('ὁδός'), 'οδοσ');
|
||||
});
|
||||
}
|
||||
|
|
@ -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'],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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é – €',
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue