fix(app): a card's edit builds on what it started from

The composer saved a stored card with the card's current heads as its
parents, so whatever had arrived meanwhile was replaced, not merged:

- another device's change to the side the person left alone was
  overwritten silently;
- an edit made while saving, saved before the card was read back,
  stood beside the first save: a conflict with itself;
- a new card left again before it was read back wasn't written at
  all, while it showed as saved.

A draft now keeps the revisions its content came from (`base`): taken
with the stored content while the person hasn't changed it, and its own
revision after each save. A save replaces exactly those
(`CardRepository.saveEdit`), so what another device wrote meanwhile
stays a head of its own and merges per side (ADR 0003): a side only it
changed comes through, a side both changed is a conflict to settle. A
changed draft takes new conflicts only once it is saved.

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-02 20:57:11 +02:00
commit 3b10922888
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 192 additions and 15 deletions

View file

@ -317,6 +317,116 @@ void main() {
);
expect(state.byId(neko)!.status, CardDraftStatus.clean);
});
test('keeps what another device changed meanwhile in a side the person '
'left alone', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
bloc.add(CardComposerEventChanged(neko, card('ねこ', 'cat')));
await until((s) => s.byId(neko)?.status == CardDraftStatus.dirty);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
await until((s) => s.byId(neko)?.saved == card('猫', 'kitty'));
bloc.add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.content == card('ねこ', 'kitty'));
final merged = (await cards.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'kitty'));
expect(merged.hasConflict, isFalse);
});
test('a side both changed meanwhile becomes a conflict, never '
'overwritten', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
bloc.add(CardComposerEventChanged(neko, card('猫', 'a cat')));
await until((s) => s.byId(neko)?.status == CardDraftStatus.dirty);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
await until((s) => s.byId(neko)?.saved == card('猫', 'kitty'));
bloc.add(CardComposerEventLeft(neko));
final conflicted = await until(
(s) => s.byId(neko)?.conflicts.isNotEmpty ?? false,
);
expect(conflicted.byId(neko)!.conflicts, {
CardField.back: [text('a cat'), text('kitty')],
});
expect(
(await cards.cardsOf('japanese')).single.conflicts[CardField.back],
[text('a cat'), text('kitty')],
);
});
test('builds an edit made while saving on that save, not beside '
'it', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(CardComposerEventChanged(neko, card('猫', 'a cat')))
..add(CardComposerEventLeft(neko))
..add(CardComposerEventChanged(neko, card('猫', 'the cat')));
await until(
(s) =>
s.byId(neko)?.savedHere == true &&
s.byId(neko)?.status == CardDraftStatus.dirty,
);
bloc.add(CardComposerEventLeft(neko));
await until(
(s) =>
s.byId(neko)?.status == CardDraftStatus.saved &&
s.byId(neko)?.saved == card('猫', 'the cat'),
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.hasConflict, isFalse);
expect(stored.heads, hasLength(1));
expect(stored.content, card('猫', 'the cat'));
});
test('builds an edit made while a new card is created on that '
'card', () async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged('new', card('犬', 'dog')))
..add(const CardComposerEventLeft('new'))
..add(CardComposerEventChanged('new', card('犬', 'a dog')));
await until(
(s) =>
s.byId('new')?.savedHere == true &&
s.byId('new')?.status == CardDraftStatus.dirty,
);
bloc.add(const CardComposerEventLeft('new'));
await until(
(s) =>
s.byId('new')?.status == CardDraftStatus.saved &&
s.byId('new')?.saved == card('犬', 'a dog'),
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.hasConflict, isFalse);
expect(stored.heads, hasLength(1));
expect(stored.content, card('犬', 'a dog'));
});
});
test('a duplicate copies content and tags as a new card', () async {

View file

@ -150,6 +150,28 @@ void main() {
expect(gateway.written.last.parents, conflicted.heads);
expect(conflicted.heads, hasLength(2));
});
test('an edit saved on what it started from stands beside what came '
'meanwhile, and merges with it', () async {
remoteEdit(card('猫', 'a cat'));
final id = await repository.saveEdit(
created.id,
card('ねこ', 'cat'),
basedOn: created.heads,
);
expect(gateway.written.last.id, id);
expect(gateway.written.last.parents, created.heads);
final merged = (await repository.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'a cat'));
expect(merged.heads, {id, 'rev-9000'});
expect(await repository.headsOf(created.id), {id, 'rev-9000'});
});
});
test('a card not on this device has no heads', () async {
expect(await repository.headsOf('card-9999'), isEmpty);
});
test(