mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Address review feedback on the concurrent-edit merge change: - When no base snapshot is available (e.g. a metadata-only change bumped the version without creating a snapshot, or the base was pruned), mergeContentUpdate now keeps the incoming edit instead of discarding it in favour of the server copy, and logs a warning. Dropping it silently lost the user's offline edits. - Unify getBaseContent into one shared helper in versions-service and drop sync's "fall back to the latest snapshot" branch: a base newer than what the client actually saw made the 3-way merge treat other users' lines as deletions and silently remove them. - Remove the now-dead mergeContentTwoWay helper. - Add coverage: null-base keeps incoming (unit), a metadata-bump concurrent edit is preserved (integration), and getBaseContent returns null rather than a newer snapshot. Verified the null-base cases fail against the old drop-incoming behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
145 lines
5.1 KiB
TypeScript
145 lines
5.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { mergeContent, mergeContentUpdate } from './content-merge.js';
|
|
|
|
describe('mergeContent (3-way)', () => {
|
|
it('should return remote when local is unchanged', () => {
|
|
const base = '- [ ] Milk\n- [ ] Bread';
|
|
const local = '- [ ] Milk\n- [ ] Bread';
|
|
const remote = '- [x] Milk\n- [ ] Bread';
|
|
|
|
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
|
|
});
|
|
|
|
it('should return local when remote is unchanged', () => {
|
|
const base = '- [ ] Milk\n- [ ] Bread';
|
|
const local = '- [x] Milk\n- [ ] Bread';
|
|
const remote = '- [ ] Milk\n- [ ] Bread';
|
|
|
|
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
|
|
});
|
|
|
|
it('should merge when User A checks an item and User B adds an item', () => {
|
|
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
|
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
|
|
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter'; // User B adds Butter
|
|
|
|
const result = mergeContent(base, local, remote);
|
|
expect(result).toBe('- [x] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter');
|
|
});
|
|
|
|
it('should merge when both users check different items', () => {
|
|
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
|
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
|
|
const remote = '- [ ] Milk\n- [x] Bread\n- [ ] Eggs'; // User B checks Bread
|
|
|
|
const result = mergeContent(base, local, remote);
|
|
expect(result).toBe('- [x] Milk\n- [x] Bread\n- [ ] Eggs');
|
|
});
|
|
|
|
it('should LWW (prefer remote) when both users edit the same line', () => {
|
|
const base = '- [ ] Milk\n- [ ] Bread';
|
|
const local = '- [ ] Whole Milk\n- [ ] Bread'; // User A renames Milk
|
|
const remote = '- [x] Milk\n- [ ] Bread'; // User B checks Milk
|
|
|
|
const result = mergeContent(base, local, remote);
|
|
// Both changed line 1 — conflict resolved by preferring remote
|
|
expect(result).toBe('- [x] Milk\n- [ ] Bread');
|
|
});
|
|
|
|
it('should handle line additions by both sides', () => {
|
|
const base = '- [ ] Milk';
|
|
const local = '- [ ] Milk\n- [ ] Bread'; // User A adds Bread at end
|
|
const remote = '- [ ] Eggs\n- [ ] Milk'; // User B adds Eggs at beginning
|
|
|
|
const result = mergeContent(base, local, remote);
|
|
// Both additions should be preserved
|
|
expect(result).toContain('- [ ] Milk');
|
|
expect(result).toContain('- [ ] Bread');
|
|
expect(result).toContain('- [ ] Eggs');
|
|
});
|
|
|
|
it('should handle line deletion by one side', () => {
|
|
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
|
const local = '- [ ] Milk\n- [ ] Eggs'; // User A removes Bread
|
|
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'; // User B unchanged
|
|
|
|
const result = mergeContent(base, local, remote);
|
|
expect(result).toBe('- [ ] Milk\n- [ ] Eggs');
|
|
});
|
|
|
|
it('should work with non-checklist markdown (prose paragraphs)', () => {
|
|
const base = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nNothing special.';
|
|
const local = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nRemember coupons.'; // User A edits notes
|
|
const remote = '# Shopping List\n\nBuy groceries today.\n\n## Notes\n\nNothing special.'; // User B edits title
|
|
|
|
const result = mergeContent(base, local, remote);
|
|
expect(result).toContain('# Shopping List');
|
|
expect(result).toContain('Remember coupons.');
|
|
});
|
|
|
|
it('should handle empty content', () => {
|
|
expect(mergeContent('', '', '')).toBe('');
|
|
expect(mergeContent('', 'hello', '')).toBe('hello');
|
|
expect(mergeContent('', '', 'hello')).toBe('hello');
|
|
});
|
|
|
|
it('should return remote when local equals remote', () => {
|
|
const content = '- [ ] Milk\n- [ ] Bread';
|
|
expect(mergeContent('old', content, content)).toBe(content);
|
|
});
|
|
});
|
|
|
|
describe('mergeContentUpdate', () => {
|
|
it('should merge an incoming update against current content when a base is available', () => {
|
|
const result = mergeContentUpdate({
|
|
baseContent: '- [ ] Milk\n- [ ] Bread',
|
|
incomingContent: '- [x] Milk\n- [ ] Bread',
|
|
currentContent: '- [ ] Milk\n- [x] Bread'
|
|
});
|
|
|
|
expect(result).toBe('- [x] Milk\n- [x] Bread');
|
|
});
|
|
|
|
it('should keep the incoming edit when no base snapshot is available', () => {
|
|
// Without a common ancestor a 3-way merge is impossible; dropping the
|
|
// incoming edit would silently lose the user's changes.
|
|
const result = mergeContentUpdate({
|
|
baseContent: null,
|
|
incomingContent: '- [x] Milk',
|
|
currentContent: '- [ ] Bread'
|
|
});
|
|
|
|
expect(result).toBe('- [x] Milk');
|
|
});
|
|
|
|
it('should keep current content when the incoming edit equals it', () => {
|
|
const result = mergeContentUpdate({
|
|
baseContent: null,
|
|
incomingContent: 'same',
|
|
currentContent: 'same'
|
|
});
|
|
|
|
expect(result).toBe('same');
|
|
});
|
|
|
|
it('should keep current content when the incoming edit matches the base (no local change)', () => {
|
|
const result = mergeContentUpdate({
|
|
baseContent: 'base',
|
|
incomingContent: 'base',
|
|
currentContent: 'server changed'
|
|
});
|
|
|
|
expect(result).toBe('server changed');
|
|
});
|
|
|
|
it('should take the incoming edit when the server is unchanged from the base', () => {
|
|
const result = mergeContentUpdate({
|
|
baseContent: 'base',
|
|
incomingContent: 'incoming change',
|
|
currentContent: 'base'
|
|
});
|
|
|
|
expect(result).toBe('incoming change');
|
|
});
|
|
});
|