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:
parent
079179d2ed
commit
3b10922888
2 changed files with 192 additions and 15 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue