feat(app): card types, whose fields merge separately

A card has a type: front/back, multiple choice (drawn on a map when it
names one), vocabulary or matching, and the fields of that type. The card
payload's schema 2 holds the type and the fields in use; schema 1 reads
as front/back. Revisions merge per field rather than per side (ADR 0003,
amended): a field one device changed takes the change, a field changed
differently is a conflict, lists included as a whole.

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-01 18:17:00 +02:00
commit 927017bf15
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 695 additions and 71 deletions

View file

@ -1,6 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -56,8 +55,8 @@ class CardEditBloc(
on<CardEditEventDeleteRequested>(_onDeleteRequested);
}
CardEditState _withSide(CardSide side, RichContent content) => state.copyWith(
content: state.content.withSide(side, content),
CardEditState _withSide(CardField side, Object? content) => state.copyWith(
content: state.content.withField(side, content),
resolvedSides: {...state.resolvedSides, side},
);
@ -115,10 +114,10 @@ final class const CardEditEventTagsChanged(
/// [side] was edited to [content].
final class const CardEditEventSideChanged(
/// The side edited.
final CardSide side,
final CardField side,
/// Its new content.
final RichContent content,
final Object? content,
) extends CardEditEvent {
@override
List<Object?> get props => [side, content];
@ -127,10 +126,10 @@ final class const CardEditEventSideChanged(
/// For a side changed differently on two devices, the person keeps [version].
final class const CardEditEventVersionChosen(
/// The conflicted side.
final CardSide side,
final CardField side,
/// The version to keep.
final RichContent version,
final Object? version,
) extends CardEditEvent {
@override
List<Object?> get props => [side, version];
@ -151,7 +150,7 @@ final class const CardEditState({
final Card? card,
/// The conflicted sides the person has settled.
final Set<CardSide> resolvedSides = const {},
final Set<CardField> resolvedSides = const {},
/// Saved or deleted; the screen closes then.
final bool isDone = false,
@ -166,7 +165,7 @@ final class const CardEditState({
final Set<String>? tagIds,
}) with Equatable {
/// The sides devices changed differently that still need a choice.
Set<CardSide> get unresolvedSides => {
Set<CardField> get unresolvedSides => {
...?card?.conflicts.keys,
}.difference(resolvedSides);
@ -197,7 +196,7 @@ final class const CardEditState({
/// This state with some fields replaced.
CardEditState copyWith({
CardContent? content,
Set<CardSide>? resolvedSides,
Set<CardField>? resolvedSides,
bool? isDone,
Tagging? tagging,
Set<String>? savedTagIds,

View file

@ -6,21 +6,48 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
// A card revision's content as its synced row stores it (see
// core/content/payload).
//
// Schema 1 held the front and the back. Schema 2 added the card's type and
// the fields of the other types (answers; reading and meanings; matching;
// map), each left out while empty; schema 1 reads as a front/back card.
/// The schema this app writes.
const cardSchemaVersion = 1;
const cardSchemaVersion = 2;
/// Encodes a card's content into a revision's payload.
ByteData encodeCardPayload(CardContent content) =>
encodePayload(cardSchemaVersion, {
'type': content.type.name,
'front': content.front.toJson(),
'back': content.back.toJson(),
if (content.answers.isNotEmpty)
'answers': [
for (final answer in content.answers)
{
'content': answer.content.toJson(),
'correct': answer.correct,
'region': ?answer.region,
},
],
if (content.reading.isNotEmpty) 'reading': content.reading,
if (content.meanings.isNotEmpty) 'meanings': content.meanings,
if (content.matching.targets.isNotEmpty ||
content.matching.items.isNotEmpty)
'matching': {
'targets': content.matching.targets,
'items': [
for (final item in content.matching.items)
{'text': item.text, 'target': item.target},
],
},
'map': ?content.map,
});
/// Decodes a revision's payload.
///
/// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know.
/// by a newer app version in a schema this one doesn't know, or of a type it
/// doesn't know.
CardContent decodeCardPayload(ByteData payload) {
final json = decodePayload(payload, newestSchema: cardSchemaVersion);
if (json case {
@ -28,9 +55,88 @@ CardContent decodeCardPayload(ByteData payload) {
'back': final Map<String, Object?> back,
}) {
return CardContent(
type: _type(json['type']),
front: RichContent.fromJson(front),
back: RichContent.fromJson(back),
answers: _answers(json['answers']),
reading: _optional<String>(json, 'reading') ?? '',
meanings: _strings(json['meanings'], 'meanings'),
matching: _matching(json['matching']),
map: _optional<String>(json, 'map'),
);
}
throw const FormatException('Not a card payload.');
}
CardType _type(Object? json) {
// Schema 1 knew only front/back cards.
if (json == null) {
return CardType.frontBack;
}
for (final type in CardType.values) {
if (type.name == json) {
return type;
}
}
throw FormatException('A card of a type this app does not know: $json.');
}
List<CardAnswer> _answers(Object? json) => switch (json) {
null => const [],
final List<Object?> answers => [
for (final answer in answers)
switch (answer) {
{
'content': final Map<String, Object?> content,
'correct': final bool correct,
} =>
CardAnswer(
content: RichContent.fromJson(content),
correct: correct,
region: switch (answer['region']) {
null => null,
final String region => region,
_ => throw const FormatException('A card has a bad region.'),
},
),
_ => throw const FormatException('A card has a bad answer.'),
},
],
_ => throw const FormatException('A card has bad answers.'),
};
Matching _matching(Object? json) => switch (json) {
null => const Matching(),
{
'targets': final List<Object?> targets,
'items': final List<Object?> items,
} =>
Matching(
targets: _strings(targets, 'targets'),
items: [
for (final item in items)
switch (item) {
{'text': final String text, 'target': final int target} =>
MatchingItem(text: text, target: target),
_ => throw const FormatException('A card has a bad item.'),
},
],
),
_ => throw const FormatException('A card has a bad matching.'),
};
List<String> _strings(Object? json, String key) => switch (json) {
null => const [],
final List<Object?> list when list.every((each) => each is String) =>
list.cast<String>(),
_ => throw FormatException('A card has bad $key.'),
};
/// The optional field [key] of [json]: null when absent, and a
/// [FormatException] when it holds something other than a [T].
T? _optional<T extends Object>(Map<String, Object?> json, String key) =>
switch (json[key]) {
null => null,
final T value => value,
_ => throw FormatException('A card payload has a malformed $key.'),
};

View file

@ -1,5 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
/// A flashcard as the app shows it: its revisions merged (`mergeRevisions`).
@ -10,18 +9,19 @@ final class const Card({
/// Where the card stands in its studyset: a fractional index.
required final String position,
/// The current content. For a side in [conflicts], the newest version shows
/// until the person chooses.
/// The current content. For a field in [conflicts], the newest version
/// shows until the person chooses.
required final CardContent content,
/// The revisions the next save replaces: all current heads.
required final Set<String> heads,
/// The sides changed differently on different devices, with every version,
/// oldest first.
final Map<CardSide, List<RichContent>> conflicts = const {},
/// The fields changed differently on different devices, with every
/// version, oldest first.
final Map<CardField, List<Object?>> conflicts = const {},
}) with Equatable {
/// Whether devices changed a side differently and the person has to choose.
/// Whether devices changed a field differently and the person has to
/// choose.
bool get hasConflict => conflicts.isNotEmpty;
@override

View file

@ -1,39 +1,231 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// The sides of a card, which merge separately (decided 2026-09-30).
enum CardSide() {
/// The prompt.
front,
/// What kind of card it is: how it is edited and studied. A closed set the
/// app knows; what a card is about goes into its tags.
enum CardType() {
/// A prompt on the front and the answer to recall on the back.
frontBack,
/// The answer to recall.
back,
/// A question and the answers to choose from, at least one of them
/// correct; with a map, the region to point at on it.
multipleChoice,
/// A word: its expression on the front, its reading and its meanings.
vocabulary,
/// Items to sort into targets, each into exactly one.
matching,
}
/// What a card shows: its [front], and the [back] to recall.
/// The parts of a card, which merge separately when devices changed a card
/// differently (ADR 0003, amended 2026-10-01): a part one device changed
/// takes that change, a part changed differently is a conflict.
enum CardField() {
/// What kind of card it is.
type,
/// The prompt: the front, the question, the expression or what to sort.
front,
/// The answer of a front/back card.
back,
/// The answers of a multiple-choice card.
answers,
/// A word's reading.
reading,
/// A word's meanings.
meanings,
/// The targets and items of a matching card.
matching,
/// The map a multiple-choice card is drawn on.
map,
}
/// One answer of a multiple-choice card.
final class const CardAnswer({
/// The answer.
required final RichContent content,
/// Whether it is right.
final bool correct = false,
/// On a map, the code of the region it names, e.g. `JP-13`.
final String? region,
}) with Equatable {
@override
List<Object?> get props => [content, correct, region];
}
/// An item of a matching card and the target it belongs to.
final class const MatchingItem({
/// The item.
required final String text,
/// The index of its target.
required final int target,
}) with Equatable {
@override
List<Object?> get props => [text, target];
}
/// What a matching card sorts: [items] into [targets].
final class const Matching({
/// The targets, e.g. categories.
final List<String> targets = const [],
/// The items, each belonging to one target.
final List<MatchingItem> items = const [],
}) with Equatable {
@override
List<Object?> get props => [targets, items];
}
/// What a card holds: its [type] and the fields of that type. Fields of
/// other types stay empty.
final class const CardContent({
/// The prompt.
/// The prompt: the front, the question, the expression or what to sort.
required final RichContent front,
/// The answer to recall.
/// The answer of a front/back card.
required final RichContent back,
/// What kind of card it is.
final CardType type = CardType.frontBack,
/// The answers of a multiple-choice card.
final List<CardAnswer> answers = const [],
/// A word's reading, e.g. たべる; empty for a word written in kana.
final String reading = '',
/// A word's meanings, the main one first.
final List<String> meanings = const [],
/// The targets and items of a matching card.
final Matching matching = const Matching(),
/// The id of the map a multiple-choice card is drawn on.
final String? map,
}) with Equatable {
/// A card with nothing on either side.
/// A front/back card with nothing on either side.
factory empty() =>
CardContent(front: RichContent.empty(), back: RichContent.empty());
/// The content of [side].
RichContent operator [](CardSide side) => switch (side) {
CardSide.front => front,
CardSide.back => back,
/// The value of [field].
Object? operator [](CardField field) => switch (field) {
CardField.type => type,
CardField.front => front,
CardField.back => back,
CardField.answers => answers,
CardField.reading => reading,
CardField.meanings => meanings,
CardField.matching => matching,
CardField.map => map,
};
/// This content with [side] replaced by [content].
CardContent withSide(CardSide side, RichContent content) => switch (side) {
CardSide.front => CardContent(front: content, back: back),
CardSide.back => CardContent(front: front, back: content),
/// This content with [field] set to [value], which must be of the field's
/// kind.
CardContent withField(CardField field, Object? value) => CardContent(
type: field == CardField.type ? value! as CardType : type,
front: field == CardField.front ? value! as RichContent : front,
back: field == CardField.back ? value! as RichContent : back,
answers: field == CardField.answers ? value! as List<CardAnswer> : answers,
reading: field == CardField.reading ? value! as String : reading,
meanings: field == CardField.meanings ? value! as List<String> : meanings,
matching: field == CardField.matching ? value! as Matching : matching,
map: field == CardField.map ? value as String? : map,
);
/// The fields [type] uses; the others stay empty.
Set<CardField> get fields => switch (type) {
CardType.frontBack => const {CardField.front, CardField.back},
CardType.multipleChoice when map != null => const {
CardField.front,
CardField.answers,
CardField.map,
},
CardType.multipleChoice => const {CardField.front, CardField.answers},
CardType.vocabulary => const {
CardField.front,
CardField.reading,
CardField.meanings,
},
CardType.matching => const {CardField.front, CardField.matching},
};
/// This content with only the fields of its [type]; the others empty, as
/// they are stored: a card switched to another type while being written
/// keeps the other type's fields only until it is saved.
CardContent get normalized {
final kept = fields;
var content = CardContent(
type: type,
front: RichContent.empty(),
back: RichContent.empty(),
);
for (final field in kept) {
content = content.withField(field, this[field]);
}
return content;
}
/// Whether the card can be studied: everything its type asks for is there.
bool get isComplete => switch (type) {
CardType.frontBack => !_blank(front) && !_blank(back),
CardType.multipleChoice when map != null =>
!_blank(front) && answers.any((answer) => answer.region != null),
CardType.multipleChoice =>
!_blank(front) &&
answers.length >= 2 &&
answers.every((answer) => !_blank(answer.content)) &&
answers.any((answer) => answer.correct),
CardType.vocabulary =>
!_blank(front) && meanings.any((meaning) => meaning.trim().isNotEmpty),
CardType.matching =>
matching.targets.isNotEmpty &&
matching.items.isNotEmpty &&
matching.items.every(
(item) =>
item.text.trim().isNotEmpty &&
item.target >= 0 &&
item.target < matching.targets.length,
),
};
/// What a list shows beside the front: the back, the right answers, the
/// reading and meanings, or the targets; one line.
String get answerPreview => switch (type) {
CardType.frontBack => _firstLine(back.plainText),
CardType.multipleChoice => [
for (final answer in answers)
if (answer.correct) _firstLine(answer.content.plainText),
].join(' · '),
CardType.vocabulary => [
if (reading.trim().isNotEmpty) reading.trim(),
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
].where((part) => part.isNotEmpty).join(' · '),
CardType.matching => matching.targets.join(' · '),
};
@override
List<Object?> get props => [front, back];
List<Object?> get props => [
type,
front,
back,
answers,
reading,
meanings,
matching,
map,
];
}
bool _blank(RichContent content) => content.plainText.trim().isEmpty;
String _firstLine(String text) => text.split('\n').first;

View file

@ -1,20 +1,19 @@
import 'package:equatable/equatable.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_revision.dart';
/// A card's content, merged from its revisions by [mergeRevisions].
final class const MergedCard({
/// The current content. For a side in [conflicts], the newest version shows
/// until the person chooses.
/// The current content. For a field in [conflicts], the newest version
/// shows until the person chooses.
required final CardContent content,
/// The revisions the next edit replaces: all current heads.
required final Set<String> heads,
/// The sides changed differently on different devices, with every version,
/// oldest first.
final Map<CardSide, List<RichContent>> conflicts = const {},
/// The fields changed differently on different devices, with every
/// version, oldest first.
final Map<CardField, List<Object?>> conflicts = const {},
}) with Equatable {
@override
List<Object?> get props => [content, heads, conflicts];
@ -24,9 +23,9 @@ final class const MergedCard({
///
/// The heads are the revisions no other revision replaces. One head is the
/// current version. Several heads, from edits on different devices while
/// offline, merge side by side against their newest common ancestor: a side
/// only one of them changed takes that change, a side they changed
/// differently is a conflict. Nothing is merged silently inside a side.
/// offline, merge field by field against their newest common ancestor: a
/// field only one of them changed takes that change, a field they changed
/// differently is a conflict. Nothing is merged silently inside a field.
/// Revisions whose parents haven't arrived yet count as they are.
MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
final byId = {for (final revision in revisions) revision.id: revision};
@ -45,13 +44,14 @@ MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
final base = _commonAncestor(heads, byId)?.content;
var content = heads.last.content;
final conflicts = <CardSide, List<RichContent>>{};
for (final side in CardSide.values) {
// What the heads changed this side to, oldest first, without repeats.
final changed = <RichContent>[];
final conflicts = <CardField, List<Object?>>{};
for (final field in CardField.values) {
// What the heads changed this field to, oldest first, without repeats.
final changed = <Object?>[];
for (final head in heads) {
final version = head.content[side];
if (version != base?[side] && !changed.contains(version)) {
final version = head.content[field];
if (!_same(version, base?[field]) &&
!changed.any((each) => _same(each, version))) {
changed.add(version);
}
}
@ -60,10 +60,10 @@ MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
// Unchanged everywhere; the newest head holds it already.
break;
case [final only]:
content = content.withSide(side, only);
content = content.withField(field, only);
default:
conflicts[side] = changed;
content = content.withSide(side, changed.last);
conflicts[field] = changed;
content = content.withField(field, changed.last);
}
}
return MergedCard(
@ -105,3 +105,11 @@ CardRevision? _commonAncestor(
]..sort();
return nearest.isEmpty ? null : byId[nearest.last];
}
/// Whether two values of a field are the same: lists by their elements.
bool _same(Object? a, Object? b) => switch ((a, b)) {
(final List<Object?> a, final List<Object?> b) =>
a.length == b.length &&
[for (var i = 0; i < a.length; i++) a[i] == b[i]].every((same) => same),
_ => a == b,
};

View file

@ -67,23 +67,20 @@ class const CardScreen({
),
const SizedBox(height: 16),
],
for (final side in CardSide.values) ...[
for (final side in const [CardField.front, CardField.back]) ...[
Text(
switch (side) {
CardSide.front => 'Front',
CardSide.back => 'Back',
},
side == CardField.front ? 'Front' : 'Back',
style: Theme.of(context).textTheme.titleSmall,
),
if (state.unresolvedSides.contains(side))
for (final version in state.card!.conflicts[side]!)
_Version(side: side, version: version)
_Version(side: side, version: version! as RichContent)
else
SizedBox(
height: 200,
child: RichContentEditor(
key: ValueKey(side),
initialContent: state.content[side],
initialContent: state.content[side]! as RichContent,
onChanged: (content) => context
.read<CardEditBloc>()
.add(CardEditEventSideChanged(side, content)),
@ -144,7 +141,7 @@ class const _Tags({required final CardEditState state})
/// One version of a side devices changed differently, and keeping it.
class const _Version({
required final CardSide side,
required final CardField side,
required final RichContent version,
}) extends StatelessWidget {
@override

View file

@ -159,7 +159,7 @@ void main() {
addTearDown(bloc.close);
expect(bloc.state.canSave, isFalse);
bloc.add(CardEditEventSideChanged(CardSide.front, text('猫')));
bloc.add(CardEditEventSideChanged(CardField.front, text('猫')));
await expectLater(
bloc.stream,
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
@ -187,7 +187,7 @@ void main() {
addTearDown(bloc.close);
expect(bloc.state.canSave, isFalse);
bloc.add(CardEditEventSideChanged(CardSide.back, text('a cat')));
bloc.add(CardEditEventSideChanged(CardField.back, text('a cat')));
await expectLater(
bloc.stream,
@ -215,13 +215,13 @@ void main() {
tearDown(() => bloc.close());
test('waits for a version of every conflicted side', () {
expect(bloc.state.unresolvedSides, {CardSide.back});
expect(bloc.state.unresolvedSides, {CardField.back});
expect(bloc.state.canSave, isFalse);
});
test('choosing a version resolves the side; saving resolves the '
'card', () async {
bloc.add(CardEditEventVersionChosen(CardSide.back, text('a cat')));
bloc.add(CardEditEventVersionChosen(CardField.back, text('a cat')));
await expectLater(
bloc.stream,
emits(
@ -282,7 +282,7 @@ void main() {
bloc
..add(CardEditEventTagsChanged({noun}))
..add(CardEditEventSideChanged(CardSide.front, text('猫')))
..add(CardEditEventSideChanged(CardField.front, text('猫')))
..add(const CardEditEventSaveRequested());
await bloc.stream.firstWhere((s) => s.isDone);

View file

@ -43,4 +43,89 @@ void main() {
throwsFormatException,
);
});
test('every type survives a round trip with its fields', () {
final contents = [
CardContent(
type: CardType.multipleChoice,
front: text('Capital of Japan?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: text('Tokyo'), correct: true),
CardAnswer(content: text('Kyoto')),
],
),
CardContent(
type: CardType.multipleChoice,
front: text('Where is Tokyo?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: text('Tokyo'), correct: true, region: 'JP-13'),
],
map: 'japan',
),
CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat', 'to live on'],
),
CardContent(
type: CardType.matching,
front: text('Sort the animals'),
back: RichContent.empty(),
matching: const Matching(
targets: ['mammal', 'bird'],
items: [
MatchingItem(text: 'cat', target: 0),
MatchingItem(text: 'crow', target: 1),
],
),
),
];
for (final content in contents) {
expect(decodeCardPayload(encodeCardPayload(content)), content);
}
});
test('leaves out the fields a card leaves empty', () {
final payload = encodeCardPayload(
CardContent(front: text('猫'), back: text('cat')),
);
expect(decodePayload(payload, newestSchema: cardSchemaVersion).keys, {
'schemaVersion',
'type',
'front',
'back',
});
});
test('reads a card of the first schema as a front/back card', () {
final content = decodeCardPayload(
bytes({
'schemaVersion': 1,
'front': text('猫').toJson(),
'back': text('cat').toJson(),
}),
);
expect(content, CardContent(front: text('猫'), back: text('cat')));
});
test('refuses a type this app does not know', () {
expect(
() => decodeCardPayload(
bytes({
'schemaVersion': cardSchemaVersion,
'type': 'hologram',
'front': RichContent.empty().toJson(),
'back': RichContent.empty().toJson(),
}),
),
throwsFormatException,
);
});
}

View file

@ -109,7 +109,7 @@ void main() {
await repository.save(created, card('猫', 'a cat'));
remoteEdit(card('猫', 'the cat'));
final conflicted = (await repository.cardsOf('japanese')).single;
expect(conflicted.conflicts.keys, [CardSide.back]);
expect(conflicted.conflicts.keys, [CardField.back]);
await repository.save(conflicted, card('猫', 'cat'));

View file

@ -0,0 +1,155 @@
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('matching: with targets, and items each in one of them', () {
CardContent sorting(int target) => CardContent(
type: CardType.matching,
front: none,
back: none,
matching: Matching(
targets: const ['mammal', 'bird'],
items: [MatchingItem(text: 'cat', target: target)],
),
);
expect(sorting(0).isComplete, true);
expect(sorting(2).isComplete, false);
});
});
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',
);
});
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},
);
});
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

@ -71,7 +71,7 @@ void main() {
])!;
expect(merged.conflicts, {
CardSide.front: [text('large'), text('huge')],
CardField.front: [text('large'), text('huge')],
});
// Until the person chooses, the newest version shows.
expect(merged.content, card('huge', 'gross'));
@ -101,7 +101,7 @@ void main() {
expect(merged.heads, {'r2', 'r3', 'r4'});
expect(merged.conflicts, {
CardSide.front: [text('large'), text('huge')],
CardField.front: [text('large'), text('huge')],
});
expect(merged.content.back, text('gross'));
});
@ -131,4 +131,79 @@ void main() {
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

@ -115,7 +115,7 @@ void main() {
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
expect((await device.card(studyset, id))!.conflicts.keys, [
CardSide.back,
CardField.back,
]);
}