Files
MindSift/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx
T
sandrodachandClaude Opus 5.5 aa397c18cd
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
feat(notes): show block locks and presence, conflict copies with accept/discard, undo/redo
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>
2026-10-28 13:44:27 +01:00

121 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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],
]);
});
});