MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
Phase 3 of the collaboration plan:
- Blocks held by someone else show a lock with the name and are read-only;
Enter in such a block adds a new block below. Delete, merge, indent and
drag are not started when a involved block (or one in its branch) is
locked; a short notice explains why (fixes FLAW-25).
- Presence: the editor header lists who else has the note open.
- Conflict copies are highlighted ("Konflikt – Version von …") with
Übernehmen (replaces the original or the title) and Verwerfen. Both check
the locks of the original and of the copy first.
- Undo/redo of own changes (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y, beforeinput
history events): typing is grouped per block until a 1 s pause, up to 200
steps per tab; a step is skipped with a notice if someone else changed or
holds the block meanwhile. New clearConflict props patch on the server.
- New blocks get focus synchronously on mount (fixes FLAW-26).
- Fix: after reopening a note from local storage the engine polled every
30 s instead of 3 s until the next change, because the delay was decided
before the first response told it the note is shared.
- Tests: undo/conflict engine tests, lock/undo/conflict UI tests and
e2e/lock.e2e.mjs (two browsers); all three browser tests pass against a
freshly built image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
import React from 'react';
|
||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||
import { render, fireEvent } from '@testing-library/react';
|
||
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
|
||
import { DocumentBlock } from '../../../lib/types';
|
||
|
||
describe('mapRenderedOffsetToRaw', () => {
|
||
it('is the identity for plain text', () => {
|
||
expect(mapRenderedOffsetToRaw('Hallo Welt', 'Hallo Welt', 4)).toBe(4);
|
||
});
|
||
it('skips bold markers', () => {
|
||
expect(mapRenderedOffsetToRaw('Hallo **Welt** du', 'Hallo Welt du', 8)).toBe(10);
|
||
});
|
||
it('maps behind closing markers', () => {
|
||
expect(mapRenderedOffsetToRaw('a **b** c', 'a b c', 4)).toBe(8);
|
||
});
|
||
it('maps into a link label', () => {
|
||
expect(mapRenderedOffsetToRaw('x [Seite](https://a.de) y', 'x Seite y', 4)).toBe(5);
|
||
});
|
||
it('handles the empty placeholder', () => {
|
||
expect(mapRenderedOffsetToRaw('', '', 1)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('clicking into an unfocused bullet', () => {
|
||
afterEach(() => {
|
||
delete (document as any).caretRangeFromPoint;
|
||
});
|
||
|
||
it('places the caret at the click position instead of the end', () => {
|
||
const block = { id: 'b1', type: 'bullet', content: 'Hallo **Welt** du', level: 0, position: 1 } as DocumentBlock;
|
||
const { container } = render(
|
||
<OutlinerBlock
|
||
docId="d"
|
||
block={block}
|
||
index={0}
|
||
isLast
|
||
maxAllowedLevel={1}
|
||
hasChildren={false}
|
||
isFocused={false}
|
||
canEdit
|
||
onUpdateBlock={() => {}}
|
||
onCreateBlockBelow={() => {}}
|
||
onDeleteBlock={() => {}}
|
||
onIndentBlock={() => {}}
|
||
onNavigateFocus={() => {}}
|
||
registerTextareaRef={() => {}}
|
||
/>
|
||
);
|
||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||
const strong = container.querySelector('strong') as HTMLElement; // rendered "Welt"
|
||
// jsdom has no layout: pretend the click hit between "We" and "lt"
|
||
(document as any).caretRangeFromPoint = () => {
|
||
const r = document.createRange();
|
||
r.setStart(strong.firstChild!, 2);
|
||
return r;
|
||
};
|
||
|
||
fireEvent.click(strong);
|
||
|
||
expect(document.activeElement).toBe(ta);
|
||
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
|
||
});
|
||
});
|
||
|
||
function renderBlock(block: DocumentBlock, extra: Record<string, unknown> = {}) {
|
||
return render(
|
||
<OutlinerBlock
|
||
docId="d"
|
||
block={block}
|
||
index={0}
|
||
isLast
|
||
maxAllowedLevel={1}
|
||
hasChildren={false}
|
||
isFocused={false}
|
||
canEdit
|
||
onUpdateBlock={() => {}}
|
||
onCreateBlockBelow={() => {}}
|
||
onDeleteBlock={() => {}}
|
||
onIndentBlock={() => {}}
|
||
onNavigateFocus={() => {}}
|
||
registerTextareaRef={() => {}}
|
||
{...extra}
|
||
/>
|
||
);
|
||
}
|
||
|
||
describe('undo shortcut', () => {
|
||
it('saves pending typing first, then undoes', () => {
|
||
const calls: string[] = [];
|
||
const block = { id: 'b1', type: 'bullet', content: 'Alt', level: 0 } as DocumentBlock;
|
||
const { container } = renderBlock(block, {
|
||
onUpdateBlock: (b: DocumentBlock) => calls.push(`update:${b.content}`),
|
||
onUndo: () => calls.push('undo'),
|
||
onRedo: () => calls.push('redo'),
|
||
});
|
||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||
ta.focus();
|
||
fireEvent.change(ta, { target: { value: 'Alt neu' } });
|
||
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true });
|
||
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true, shiftKey: true });
|
||
fireEvent.keyDown(ta, { key: 'y', metaKey: true });
|
||
expect(calls).toEqual(['update:Alt neu', 'undo', 'redo', 'redo']);
|
||
});
|
||
});
|
||
|
||
describe('conflict copies', () => {
|
||
it('show who lost and offer accept/discard', () => {
|
||
const onResolve = vi.fn();
|
||
const block = { id: 'cf', type: 'bullet', content: 'Meine Version', level: 0, conflictOf: 'b1', conflictAuthor: 'Ben' } as DocumentBlock;
|
||
const { getByText } = renderBlock(block, { onResolveConflict: onResolve });
|
||
expect(getByText(/Version von Ben/)).toBeTruthy();
|
||
fireEvent.click(getByText('Übernehmen'));
|
||
fireEvent.click(getByText('Verwerfen'));
|
||
expect(onResolve.mock.calls).toEqual([
|
||
['cf', true],
|
||
['cf', false],
|
||
]);
|
||
});
|
||
});
|