diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts index eaa22f49bd..4c0e8018d3 100644 --- a/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts +++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts @@ -19,15 +19,15 @@ const setup = () => { test('can insert markers', () => { const {peritext} = setup(); const {editor} = peritext; - expect([...peritext.overlay].length).toBe(0); + expect([...peritext.overlay.points()].length).toBe(0); editor.cursor.setAt(0); peritext.refresh(); - expect([...peritext.overlay].length).toBe(1); - editor.insMarker(['p'], '

'); + expect([...peritext.overlay.points()].length).toBe(1); + editor.saved.insMarker(['p'], '

'); peritext.refresh(); expect(size(peritext.overlay.root)).toBe(2); editor.cursor.setAt(9); - editor.insMarker(['p'], '

'); + editor.saved.insMarker(['p'], '

'); peritext.refresh(); expect(size(peritext.overlay.root)).toBe(3); }); @@ -37,15 +37,15 @@ test('can insert slices', () => { const {editor} = peritext; expect(size(peritext.overlay.root)).toBe(0); editor.cursor.setAt(2, 2); - editor.insStackSlice('bold'); + editor.saved.insStack('bold'); peritext.refresh(); expect(size(peritext.overlay.root)).toBe(2); editor.cursor.setAt(6, 5); - editor.insStackSlice('italic'); + editor.extra.insStack('italic'); peritext.refresh(); expect(size(peritext.overlay.root)).toBe(4); editor.cursor.setAt(0, 5); - editor.insStackSlice('underline'); + editor.local.insStack('underline'); peritext.refresh(); expect(size(peritext.overlay.root)).toBe(6); }); diff --git a/src/json-crdt-extensions/peritext/__tests__/setup.ts b/src/json-crdt-extensions/peritext/__tests__/setup.ts index 81f4a09713..267e8f91a2 100644 --- a/src/json-crdt-extensions/peritext/__tests__/setup.ts +++ b/src/json-crdt-extensions/peritext/__tests__/setup.ts @@ -1,41 +1,22 @@ import {s} from '../../../json-crdt-patch'; +import {Model} from '../../../json-crdt/model'; +import {SchemaToJsonNode} from '../../../json-crdt/schema/types'; import {ModelWithExt, ext} from '../../ModelWithExt'; -/** - * Creates a Peritext instance with text "0123456789", with single-char and - * block-wise chunks, as well as with plenty of tombstones. - */ -export const setupNumbersWithTombstones = () => { - const schema = s.obj({ - text: ext.peritext.new('1234'), +export type Schema = ReturnType; +export type Kit = ReturnType; + +const schema = (text: string) => + s.obj({ + text: ext.peritext.new(text), }); - const model = ModelWithExt.create(schema); - const str = model.s.text.toExt().text(); - str.ins(1, '234'); - str.ins(2, '345'); - str.ins(3, '456'); - str.ins(4, '567'); - str.ins(5, '678'); - str.ins(6, '789'); - str.del(7, 1); - str.del(8, 1); - str.ins(0, '0'); - str.del(1, 4); - str.del(2, 1); - str.ins(1, '1'); - str.del(0, 1); - str.ins(0, '0'); - str.ins(2, '234'); - str.del(4, 7); - str.del(4, 2); - str.del(7, 3); - str.ins(6, '6789'); - str.del(7, 2); - str.ins(7, '78'); - str.del(10, 2); - str.del(2, 3); - str.ins(2, '234'); - if (str.view() !== '0123456789') throw new Error('Invalid text'); + +export const setupKit = ( + initialText: string = '', + edits: (model: Model>) => void = () => {}, +) => { + const model = ModelWithExt.create(schema(initialText)); + edits(model); const api = model.api; const peritextApi = model.s.text.toExt(); const peritext = peritextApi.txt; @@ -49,3 +30,70 @@ export const setupNumbersWithTombstones = () => { editor, }; }; + +export const setupHelloWorldKit = (): Kit => { + return setupKit('', (model) => { + const str = model.s.text.toExt().text(); + str.ins(0, 'hello world'); + if (str.view() !== 'hello world') throw new Error('Invalid text'); + }); +}; + +export const setupHelloWorldWithFewEditsKit = (): Kit => { + return setupKit('', (model) => { + const str = model.s.text.toExt().text(); + str.ins(0, 'wworld'); + str.ins(0, 'helo '); + str.ins(2, 'l'); + str.del(7, 1); + if (str.view() !== 'hello world') throw new Error('Invalid text'); + }); +}; + +/** + * Creates a Peritext instance with text "0123456789", no edits. + */ +export const setupNumbersKit = (): Kit => { + return setupKit('', (model) => { + const str = model.s.text.toExt().text(); + str.ins(0, '0123456789'); + if (str.view() !== '0123456789') throw new Error('Invalid text'); + }); +}; + +/** + * Creates a Peritext instance with text "0123456789", with single-char and + * block-wise chunks, as well as with plenty of tombstones. + */ +export const setupNumbersWithTombstonesKit = (): Kit => { + return setupKit('1234', (model) => { + const str = model.s.text.toExt().text(); + str.ins(0, '234'); + str.ins(1, '234'); + str.ins(2, '345'); + str.ins(3, '456'); + str.ins(4, '567'); + str.ins(5, '678'); + str.ins(6, '789'); + str.del(7, 1); + str.del(8, 1); + str.ins(0, '0'); + str.del(1, 4); + str.del(2, 1); + str.ins(1, '1'); + str.del(0, 1); + str.ins(0, '0'); + str.ins(2, '234'); + str.del(4, 7); + str.del(4, 2); + str.del(7, 3); + str.ins(6, '6789'); + str.del(7, 2); + str.ins(7, '78'); + str.del(10, 2); + str.del(2, 3); + str.ins(2, '234'); + str.del(10, 3); + if (str.view() !== '0123456789') throw new Error('Invalid text'); + }); +}; diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index c66d6ac473..46da44f653 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -10,9 +10,13 @@ import type {MarkerSlice} from '../slice/MarkerSlice'; export class Editor { public readonly saved: EditorSlices; + public readonly extra: EditorSlices; + public readonly local: EditorSlices; constructor(public readonly txt: Peritext) { this.saved = new EditorSlices(txt, txt.savedSlices); + this.extra = new EditorSlices(txt, txt.extraSlices); + this.local = new EditorSlices(txt, txt.localSlices); } public firstCursor(): Cursor | undefined { diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index e56f4c5d6f..016c6e7896 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -7,17 +7,18 @@ import {Point} from '../rga/Point'; import {OverlayPoint} from './OverlayPoint'; import {MarkerOverlayPoint} from './MarkerOverlayPoint'; import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; -import {compare, ITimestampStruct, tick} from '../../../json-crdt-patch/clock'; +import {compare, ITimestampStruct} from '../../../json-crdt-patch/clock'; import {CONST, updateNum} from '../../../json-hash'; import {MarkerSlice} from '../slice/MarkerSlice'; import {Range} from '../rga/Range'; -import {UndefEndIter} from '../../../util/iterator'; +import {UndefEndIter, UndefIterator} from '../../../util/iterator'; import type {Chunk} from '../../../json-crdt/nodes/rga'; import type {Peritext} from '../Peritext'; import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; import type {MutableSlice, Slice} from '../slice/types'; import type {Slices} from '../slice/Slices'; +import type {OverlayPair, OverlayTuple} from './types'; /** * Overlay is a tree structure that represents all the intersections of slices @@ -28,10 +29,17 @@ import type {Slices} from '../slice/Slices'; */ export class Overlay implements Printable, Stateful { public root: OverlayPoint | undefined = undefined; - public readonly start: OverlayPoint; + + /** A virtual absolute start point, used when the absolute start is missing. */ + public readonly START: OverlayPoint; + + /** A virtual absolute end point, used when the absolute end is missing. */ + public readonly END: OverlayPoint; constructor(protected readonly txt: Peritext) { - this.start = this.point(this.txt.str.id, Anchor.After); + const id = txt.str.id; + this.START = this.point(id, Anchor.After); + this.END = this.point(id, Anchor.Before); } private point(id: ITimestampStruct, anchor: Anchor): OverlayPoint { @@ -50,39 +58,6 @@ export class Overlay implements Printable, Stateful { return this.root ? last(this.root) : undefined; } - public iterator(): () => OverlayPoint | undefined { - let curr = this.first(); - return () => { - const ret = curr; - if (curr) curr = next(curr); - return ret; - }; - } - - public entries(): IterableIterator> { - return new UndefEndIter(this.iterator()); - } - - [Symbol.iterator]() { - return this.entries(); - } - - public markerIterator(): () => MarkerOverlayPoint | undefined { - let curr = this.first(); - return () => { - while (curr) { - const ret = curr; - if (curr) curr = next(curr); - if (ret instanceof MarkerOverlayPoint) return ret; - } - return; - }; - } - - public markers(): IterableIterator> { - return new UndefEndIter(this.iterator()); - } - /** * Retrieve overlay point or the previous one, measured in spacial dimension. */ @@ -191,67 +166,83 @@ export class Overlay implements Printable, Stateful { }) as Chunk; } - public points0( - start: undefined | OverlayPoint, - end: undefined | ((next: OverlayPoint) => boolean), - callback: (point: OverlayPoint) => void, - ): void { - const txt = this.txt; - const str = txt.str; - const strFirstChunk = str.first(); - if (!strFirstChunk) return; - let point = start || this.first(); - let prev: typeof point; - const pointIsStart = - point && - ((!compare(point.id, str.id) && point.anchor === Anchor.After) || - (!compare(strFirstChunk.id, point.id) && point.anchor === Anchor.Before)); - if (!start && !pointIsStart) { - const startPoint = this.start; - startPoint.id = strFirstChunk.id; - startPoint.anchor = Anchor.Before; - callback(startPoint); - } - while (point) { - if (end && end(point)) return; - callback(point); - prev = point; - point = next(point); - } - const strLastChunk = str.last()!; - const strLastChunkId = strLastChunk.id; - if (prev) { - const prevId = prev.id; - if ( - prev.anchor === Anchor.After && - prevId.time === strLastChunkId.time + strLastChunk.span - 1 && - prevId.sid === strLastChunkId.sid && - prevId.sid === strLastChunkId.sid - ) - return; - } - const endId = strLastChunk.span > 1 ? tick(strLastChunkId, strLastChunk.span - 1) : strLastChunkId; - const ending = this.point(endId!, Anchor.After); - if (end && end(ending)) return; - callback(ending); + public points0(after: undefined | OverlayPoint): UndefIterator> { + let curr = after ? next(after) : this.first(); + return () => { + const ret = curr; + if (curr) curr = next(curr); + return ret; + }; } - public points1( - start: undefined | OverlayPoint, - end: undefined | ((next: OverlayPoint) => boolean), - callback: (p1: OverlayPoint, p2: OverlayPoint) => void, - ): void { + public points(after?: undefined | OverlayPoint): IterableIterator> { + return new UndefEndIter(this.points0(after)); + } + + public markers0(): UndefIterator> { + let curr = this.first(); + return () => { + while (curr) { + const ret = curr; + if (curr) curr = next(curr); + if (ret instanceof MarkerOverlayPoint) return ret; + } + return; + }; + } + + public markers(): IterableIterator> { + return new UndefEndIter(this.markers0()); + } + + public pairs0(after: undefined | OverlayPoint): UndefIterator> { + const isEmpty = !this.root; + if (isEmpty) { + const u = undefined; + let closed = false; + return () => (closed ? u : ((closed = true), [u, u])); + } let p1: OverlayPoint | undefined; - let p2: OverlayPoint | undefined; - this.points0(start, end, (point) => { - if (p1) { - p2 = point; - callback(p1, p2); + let p2: OverlayPoint | undefined = after; + const iterator = this.points0(after); + return () => { + const next = iterator(); + const isEnd = !next; + if (isEnd) { + if (!p2 || p2.isAbsEnd()) return; p1 = p2; - } else { - p1 = point; + p2 = undefined; + return [p1, p2]; + } + p1 = p2; + p2 = next; + if (!p1) { + if (p2 && p2.isAbsStart()) { + p1 = p2; + p2 = iterator(); + } } - }); + return p1 || p2 ? [p1, p2] : undefined; + }; + } + + public pairs(after?: undefined | OverlayPoint): IterableIterator> { + return new UndefEndIter(this.pairs0(after)); + } + + public tuples0(after: undefined | OverlayPoint): UndefIterator> { + const iterator = this.pairs0(after); + return () => { + const pair = iterator(); + if (!pair) return; + if (pair[0] === undefined) pair[0] = this.START; + if (pair[1] === undefined) pair[1] = this.END; + return pair as OverlayTuple; + }; + } + + public tuples(after?: undefined | OverlayPoint): IterableIterator> { + return new UndefEndIter(this.tuples0(after)); } public findContained(range: Range): Set> { @@ -290,47 +281,6 @@ export class Overlay implements Printable, Stateful { return result; } - public leadingTextHash: number = 0; - - protected computeSplitTextHashes(): void { - const txt = this.txt; - const str = txt.str; - const firstChunk = str.first(); - if (!firstChunk) return; - let chunk: Chunk | undefined = firstChunk; - let marker: MarkerOverlayPoint | undefined = undefined; - let state: number = CONST.START_STATE; - this.points1(undefined, undefined, (p1, p2) => { - // TODO: need to incorporate slice attribute hash here? - const id1 = p1.id; - state = (state << 5) + state + (id1.sid >>> 0) + id1.time; - let overlayPointHash = CONST.START_STATE; - chunk = this.chunkSlices0(chunk || firstChunk, p1, p2, (chunk, off, len) => { - const id = chunk.id; - overlayPointHash = - (overlayPointHash << 5) + overlayPointHash + ((((id.sid >>> 0) + id.time) << 8) + (off << 4) + len); - }); - state = updateNum(state, overlayPointHash); - if (p1) { - p1.hash = overlayPointHash; - } - if (p2 instanceof MarkerOverlayPoint) { - if (marker) { - marker.textHash = state; - } else { - this.leadingTextHash = state; - } - state = CONST.START_STATE; - marker = p2; - } - }); - if ((marker as any) instanceof MarkerOverlayPoint) { - (marker as any as MarkerOverlayPoint).textHash = state; - } else { - this.leadingTextHash = state; - } - } - public isBlockSplit(id: ITimestampStruct): boolean { const point = this.txt.point(id, Anchor.Before); const overlayPoint = this.getOrNextLower(point); @@ -477,6 +427,49 @@ export class Overlay implements Printable, Stateful { this.root = remove(this.root, point); } + public leadingTextHash: number = 0; + + protected computeSplitTextHashes(): void { + const txt = this.txt; + const str = txt.str; + const firstChunk = str.first(); + if (!firstChunk) return; + let chunk: Chunk | undefined = firstChunk; + let marker: MarkerOverlayPoint | undefined = undefined; + let state: number = CONST.START_STATE; + const i = this.tuples0(undefined); + for (let pair = i(); pair; pair = i()) { + const [p1, p2] = pair; + // TODO: need to incorporate slice attribute hash here? + const id1 = p1.id; + state = (state << 5) + state + (id1.sid >>> 0) + id1.time; + let overlayPointHash = CONST.START_STATE; + chunk = this.chunkSlices0(chunk || firstChunk, p1, p2, (chunk, off, len) => { + const id = chunk.id; + overlayPointHash = + (overlayPointHash << 5) + overlayPointHash + ((((id.sid >>> 0) + id.time) << 8) + (off << 4) + len); + }); + state = updateNum(state, overlayPointHash); + if (p1) { + p1.hash = overlayPointHash; + } + if (p2 instanceof MarkerOverlayPoint) { + if (marker) { + marker.textHash = state; + } else { + this.leadingTextHash = state; + } + state = CONST.START_STATE; + marker = p2; + } + } + if ((marker as any) instanceof MarkerOverlayPoint) { + (marker as any as MarkerOverlayPoint).textHash = state; + } else { + this.leadingTextHash = state; + } + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts new file mode 100644 index 0000000000..c1b498d474 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts @@ -0,0 +1,232 @@ +import {tick} from '../../../../json-crdt-patch/clock'; +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import {Point} from '../../rga/Point'; +import {Anchor} from '../../rga/constants'; +import {setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +import type {Chunk} from '../../../../json-crdt/nodes/rga'; + +const setup = () => { + const model = Model.withLogicalClock(); + const api = model.api; + api.root({ + text: '', + slices: [], + }); + api.str(['text']).ins(0, 'wworld'); + api.str(['text']).ins(0, 'helo '); + api.str(['text']).ins(2, 'l'); + api.str(['text']).del(7, 1); + const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node); + const {overlay} = peritext; + return {model, peritext, overlay}; +}; + +describe('.chunkSlices0()', () => { + test('can iterate through all chunk chunks', () => { + const {peritext, overlay} = setup(); + const chunk1 = peritext.str.first(); + const chunk2 = peritext.str.last(); + let str = ''; + const point1 = peritext.point(chunk1!.id, Anchor.Before); + const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.After); + overlay.chunkSlices0(undefined, point1, point2, (chunk, off, len) => { + str += (chunk.data as string).slice(off, off + len); + }); + expect(str).toBe('hello world'); + }); + + test('can skip first character using "After" anchor attachment', () => { + const {peritext, overlay} = setup(); + const chunk1 = peritext.str.first(); + const chunk2 = peritext.str.last(); + let str = ''; + const point1 = peritext.point(chunk1!.id, Anchor.After); + const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.After); + overlay.chunkSlices0(undefined, point1, point2, (chunk, off, len) => { + str += (chunk.data as string).slice(off, off + len); + }); + expect(str).toBe('ello world'); + }); + + test('can skip last character using "Before" anchor attachment', () => { + const {peritext, overlay} = setup(); + const chunk1 = peritext.str.first(); + const chunk2 = peritext.str.last(); + let str = ''; + const point1 = peritext.point(chunk1!.id, Anchor.After); + const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.Before); + overlay.chunkSlices0(undefined, point1, point2, (chunk, off, len) => { + str += (chunk.data as string).slice(off, off + len); + }); + expect(str).toBe('ello worl'); + }); + + test('can skip first chunk, by anchoring to the end of it', () => { + const {peritext, overlay} = setup(); + const chunk1 = peritext.str.first(); + const chunk2 = peritext.str.last(); + let str = ''; + const endOfFirstChunk = peritext.point(tick(chunk1!.id, chunk1!.span - 1), Anchor.After); + const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.After); + overlay.chunkSlices0(undefined, endOfFirstChunk, point2, (chunk, off, len) => { + str += (chunk.data as string).slice(off, off + len); + }); + expect(str).toBe(peritext.strApi().view().slice(chunk1!.span)); + }); + + test('can skip first chunk, by anchoring to the beginning of second chunk', () => { + const {peritext, overlay} = setup(); + const firstChunk = peritext.str.first()!; + const secondChunk = peritext.str.next(firstChunk)!; + const lastChunk = peritext.str.last()!; + let str = ''; + const startOfChunkTwo = peritext.point(secondChunk.id, Anchor.Before); + const point2 = peritext.point(tick(lastChunk!.id, lastChunk!.span - 1), Anchor.After); + overlay.chunkSlices0(undefined, startOfChunkTwo, point2, (chunk, off, len) => { + str += (chunk.data as string).slice(off, off + len); + }); + expect(str).toBe(peritext.strApi().view().slice(firstChunk.span)); + }); + + test('can skip one character of the second chunk', () => { + const {peritext, overlay} = setup(); + const firstChunk = peritext.str.first()!; + const secondChunk = peritext.str.next(firstChunk)!; + const lastChunk = peritext.str.last()!; + let str = ''; + const startOfChunkTwo = peritext.point(secondChunk.id, Anchor.After); + const point2 = peritext.point(tick(lastChunk!.id, lastChunk!.span - 1), Anchor.After); + overlay.chunkSlices0(undefined, startOfChunkTwo, point2, (chunk, off, len) => { + str += (chunk.data as string).slice(off, off + len); + }); + expect(str).toBe( + peritext + .strApi() + .view() + .slice(firstChunk.span + 1), + ); + }); + + const testAllPossibleChunkPointCombinations = (peritext: Peritext) => { + test('can generate slices for all possible chunk point combinations', () => { + const overlay = peritext.overlay; + let chunk1 = peritext.str.first(); + const getText = (p1: Point, p2: Point) => { + let str = ''; + overlay.chunkSlices0(undefined, p1, p2, (chunk, off, len) => { + str += (chunk.data as string).slice(off, off + len); + }); + return str; + }; + while (chunk1) { + if (chunk1.del) { + chunk1 = peritext.str.next(chunk1); + continue; + } + let chunk2: Chunk | undefined = chunk1; + while (chunk2) { + if (chunk2.del) { + chunk2 = peritext.str.next(chunk2); + continue; + } + if (chunk1 === chunk2) { + for (let i = 0; i < chunk1.span; i++) { + for (let j = i; j < chunk1.span; j++) { + let point1 = peritext.point(tick(chunk1.id, i), Anchor.Before); + let point2 = peritext.point(tick(chunk1.id, j), Anchor.After); + let str1 = getText(point1, point2); + let str2 = (chunk1.data as string).slice(i, j + 1); + expect(str1).toBe(str2); + point1 = peritext.point(tick(chunk1.id, i), Anchor.Before); + point2 = peritext.point(tick(chunk1.id, j), Anchor.Before); + str1 = getText(point1, point2); + str2 = (chunk1.data as string).slice(i, j); + expect(str1).toBe(str2); + point1 = peritext.point(tick(chunk1.id, i), Anchor.After); + point2 = peritext.point(tick(chunk1.id, j), Anchor.After); + str1 = getText(point1, point2); + str2 = (chunk1.data as string).slice(i + 1, j + 1); + expect(str1).toBe(str2); + point1 = peritext.point(tick(chunk1.id, i), Anchor.After); + point2 = peritext.point(tick(chunk1.id, j), Anchor.Before); + str1 = getText(point1, point2); + str2 = i >= j ? '' : (chunk1.data as string).slice(i + 1, j); + expect(str1).toBe(str2); + } + } + } else { + for (let i = 0; i < chunk1.span; i++) { + for (let j = 0; j < chunk2.span; j++) { + let point1 = peritext.point(tick(chunk1.id, i), Anchor.Before); + let point2 = peritext.point(tick(chunk2.id, j), Anchor.After); + let str1 = getText(point1, point2); + let str2 = chunk1.data!.slice(i); + let chunk3 = peritext.str.next(chunk1); + while (chunk3 && chunk3 !== chunk2) { + if (!chunk3.del) { + str2 += chunk3.data!; + } + chunk3 = peritext.str.next(chunk3); + } + str2 += chunk2.data!.slice(0, j + 1); + expect(str1).toBe(str2); + point1 = peritext.point(tick(chunk1.id, i), Anchor.Before); + point2 = peritext.point(tick(chunk2.id, j), Anchor.Before); + str1 = getText(point1, point2); + str2 = chunk1.data!.slice(i); + chunk3 = peritext.str.next(chunk1); + while (chunk3 && chunk3 !== chunk2) { + if (!chunk3.del) { + str2 += chunk3.data!; + } + chunk3 = peritext.str.next(chunk3); + } + str2 += chunk2.data!.slice(0, j); + expect(str1).toBe(str2); + point1 = peritext.point(tick(chunk1.id, i), Anchor.After); + point2 = peritext.point(tick(chunk2.id, j), Anchor.Before); + str1 = getText(point1, point2); + str2 = chunk1.data!.slice(i + 1); + chunk3 = peritext.str.next(chunk1); + while (chunk3 && chunk3 !== chunk2) { + if (!chunk3.del) { + str2 += chunk3.data!; + } + chunk3 = peritext.str.next(chunk3); + } + str2 += chunk2.data!.slice(0, j); + expect(str1).toBe(str2); + point1 = peritext.point(tick(chunk1.id, i), Anchor.After); + point2 = peritext.point(tick(chunk2.id, j), Anchor.After); + str1 = getText(point1, point2); + str2 = chunk1.data!.slice(i + 1); + chunk3 = peritext.str.next(chunk1); + while (chunk3 && chunk3 !== chunk2) { + if (!chunk3.del) { + str2 += chunk3.data!; + } + chunk3 = peritext.str.next(chunk3); + } + str2 += chunk2.data!.slice(0, j + 1); + expect(str1).toBe(str2); + } + } + } + chunk2 = peritext.str.next(chunk2); + } + chunk1 = peritext.str.next(chunk1); + } + }); + }; + + describe('with hello world text', () => { + const {peritext} = setup(); + testAllPossibleChunkPointCombinations(peritext); + }); + + describe('with "integer list" text', () => { + const {peritext} = setupNumbersWithTombstonesKit(); + testAllPossibleChunkPointCombinations(peritext); + }); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts index 6e779ecf56..4300f963da 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts @@ -2,7 +2,7 @@ import {Model} from '../../../../json-crdt/model'; import {size} from 'sonic-forest/lib/util'; import {Peritext} from '../../Peritext'; import {Anchor} from '../../rga/constants'; -import {setupNumbersWithTombstones} from '../../__tests__/setup'; +import {setupNumbersWithTombstonesKit} from '../../__tests__/setup'; import {OverlayPoint} from '../OverlayPoint'; import {OverlayRefSliceEnd, OverlayRefSliceStart} from '../refs'; @@ -94,7 +94,7 @@ describe('.getOrNextLower()', () => { describe('when all text selected, using relative range', () => { test('can select the starting point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!); editor.cursor.setRange(range); peritext.refresh(); @@ -107,7 +107,7 @@ describe('.getOrNextLower()', () => { }); test('can select the ending point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!); editor.cursor.setRange(range); peritext.refresh(); @@ -120,7 +120,7 @@ describe('.getOrNextLower()', () => { describe('when all text selected, using absolute range', () => { test('can select the starting point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd()); editor.cursor.setRange(range); peritext.refresh(); @@ -133,7 +133,7 @@ describe('.getOrNextLower()', () => { }); test('can select the end point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd()); editor.cursor.setRange(range); peritext.refresh(); @@ -189,7 +189,7 @@ describe('.getOrNextHigher()', () => { describe('when all text selected, using relative range', () => { test('can select the ending point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!); editor.cursor.setRange(range); peritext.refresh(); @@ -200,7 +200,7 @@ describe('.getOrNextHigher()', () => { }); test('can select the start point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!); editor.cursor.setRange(range); peritext.refresh(); @@ -215,7 +215,7 @@ describe('.getOrNextHigher()', () => { describe('when all text selected, using absolute range', () => { test('can select the ending point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd()); editor.cursor.setRange(range); peritext.refresh(); @@ -226,7 +226,7 @@ describe('.getOrNextHigher()', () => { }); test('can select the start point', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd()); editor.cursor.setRange(range); peritext.refresh(); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts new file mode 100644 index 0000000000..5d5addca61 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts @@ -0,0 +1,164 @@ +import {next} from 'sonic-forest/lib/util'; +import {Kit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +import {Anchor} from '../../rga/constants'; +import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; +import {OverlayPoint} from '../OverlayPoint'; + +const runPairsTests = (setup: () => Kit) => { + describe('.pairs() full range', () => { + test('returns [undef, undef] single pair for an empty overlay', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + overlay.refresh(); + const pairs = [...overlay.pairs()]; + expect(pairs).toEqual([[undefined, undefined]]); + }); + + test('when caret at abs start, returns one pair', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.set(peritext.pointAbsStart()); + overlay.refresh(); + const pairs = [...overlay.pairs()]; + const p1 = overlay.first()!; + expect(peritext.editor.cursor.start.rightChar()?.view()).toBe('0'); + expect(pairs).toEqual([[p1, undefined]]); + }); + + test('when caret at abs end, returns one pair', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.set(peritext.pointAbsEnd()); + overlay.refresh(); + const pairs = [...overlay.pairs()]; + const p1 = overlay.first()!; + expect(peritext.editor.cursor.start.leftChar()?.view()).toBe('9'); + expect(pairs).toEqual([[undefined, p1]]); + }); + + test('for only caret in overlay, returns two edge pairs', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(5); + overlay.refresh(); + const pairs = [...overlay.pairs()]; + const p1 = overlay.first()!; + expect(peritext.editor.cursor.start.leftChar()?.view()).toBe('4'); + expect(pairs).toEqual([ + [undefined, p1], + [p1, undefined], + ]); + }); + + test('for a cursor selection, returns three pairs', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(3, 3); + overlay.refresh(); + const pairs = [...overlay.pairs()]; + const p1 = overlay.first()!; + const p2 = next(p1)!; + expect(peritext.editor.cursor.text()).toBe('345'); + expect(pairs).toEqual([ + [undefined, p1], + [p1, p2], + [p2, undefined], + ]); + }); + + test('for a cursor selection at abs start, returns two pairs', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + const range = peritext.range(peritext.pointAbsStart(), peritext.pointAt(5)); + peritext.editor.cursor.setRange(range); + overlay.refresh(); + const pairs = [...overlay.pairs()]; + const p1 = overlay.first()!; + const p2 = next(p1)!; + expect(peritext.editor.cursor.text()).toBe('01234'); + expect(pairs).toEqual([ + [p1, p2], + [p2, undefined], + ]); + }); + + test('for a cursor selection at abs end, returns two pairs', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + const range = peritext.range(peritext.pointAt(5, Anchor.Before), peritext.pointAbsEnd()); + peritext.editor.cursor.setRange(range); + overlay.refresh(); + const pairs = [...overlay.pairs()]; + const p1 = overlay.first()!; + const p2 = next(p1)!; + expect(peritext.editor.cursor.text()).toBe('56789'); + expect(pairs).toEqual([ + [undefined, p1], + [p1, p2], + ]); + }); + + test('for a marker and a slice after the marker, returns 4 pairs', () => { + const {peritext} = setup(); + const {editor, overlay} = peritext; + editor.cursor.setAt(3); + const [marker] = editor.saved.insMarker(''); + editor.cursor.setAt(6, 2); + editor.extra.insStack(''); + overlay.refresh(); + const p1 = overlay.first()!; + const p2 = next(p1)!; + const p3 = next(p2)!; + const pairs = [...overlay.pairs()]; + expect(peritext.editor.cursor.text()).toBe('56'); + expect(pairs).toEqual([ + [undefined, p1], + [p1, p2], + [p2, p3], + [p3, undefined], + ]); + expect(p1 instanceof MarkerOverlayPoint).toBe(true); + expect(p2 instanceof OverlayPoint).toBe(true); + expect(p3 instanceof OverlayPoint).toBe(true); + expect((p1 as MarkerOverlayPoint).marker).toBe(marker); + expect(p2.layers.length).toBe(2); + expect(p3.layers.length).toBe(0); + expect(p2.refs.length).toBe(2); + expect(p3.refs.length).toBe(2); + }); + }); + + describe('.pairs() at offset', () => { + test('in empty overlay, after caret returns the last edge', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(5); + overlay.refresh(); + const first = overlay.first()!; + const pairs = [...overlay.pairs(first)]; + expect(pairs).toEqual([[first, undefined]]); + }); + + test('in empty overlay, after selection start returns the selection and the edge', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(2, 4); + overlay.refresh(); + const p1 = overlay.first()!; + const p2 = next(p1)!; + const list = [...overlay.pairs(p1)]; + expect(list).toEqual([ + [p1, p2], + [p2, undefined], + ]); + }); + }); +}; + +describe('numbers "0123456789", no edits', () => { + runPairsTests(setupNumbersKit); +}); + +describe('numbers "0123456789", with default schema and tombstones', () => { + runPairsTests(setupNumbersWithTombstonesKit); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts new file mode 100644 index 0000000000..d341897e5a --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts @@ -0,0 +1,104 @@ +import {last, next} from 'sonic-forest/lib/util'; +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import type {OverlayPoint} from '../OverlayPoint'; + +const setup = () => { + const model = Model.withLogicalClock(); + const api = model.api; + api.root({ + text: '', + slices: [], + }); + api.str(['text']).ins(0, 'wworld'); + api.str(['text']).ins(0, 'helo '); + api.str(['text']).ins(2, 'l'); + api.str(['text']).del(7, 1); + const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node); + const {overlay} = peritext; + return {model, peritext, overlay}; +}; + +const setupWithOverlay = () => { + const res = setup(); + const peritext = res.peritext; + peritext.editor.cursor.setAt(6); + peritext.editor.saved.insMarker(['p'], '¶'); + peritext.editor.cursor.setAt(2, 2); + peritext.editor.saved.insStack(''); + peritext.refresh(); + return res; +}; + +describe('.points()', () => { + describe('with overlay', () => { + test('iterates through all points', () => { + const {peritext} = setupWithOverlay(); + const overlay = peritext.overlay; + const points = [...overlay.points()]; + expect(overlay.first()).not.toBe(undefined); + expect(points.length).toBe(3); + }); + + test('iterates through all points, when points anchored to the same anchor', () => { + const {peritext, overlay} = setupWithOverlay(); + peritext.refresh(); + expect([...overlay.points()].length).toBe(3); + peritext.editor.cursor.setAt(2, 1); + peritext.editor.saved.insStack(''); + peritext.refresh(); + expect([...overlay.points()].length).toBe(4); + expect(overlay.first()).not.toBe(undefined); + }); + + test('should not return virtual start point, if real start point exists', () => { + const {peritext, overlay} = setup(); + peritext.editor.cursor.setAt(0); + peritext.editor.saved.insMarker(['p'], '¶'); + peritext.refresh(); + const points = [...overlay.points()]; + expect(points.length).toBe(2); + expect(overlay.first()).toBe(points[0]); + }); + + test('should not return virtual end point, if real end point exists', () => { + const {peritext, overlay} = setup(); + peritext.editor.cursor.setAt(0, peritext.strApi().view().length); + peritext.editor.saved.insStack('bold'); + peritext.refresh(); + const points = [...overlay.points()]; + expect(points.length).toBe(2); + expect(overlay.first()).toBe(points[0]); + expect(last(overlay.root)).toBe(points[1]); + }); + + test('can skip points from beginning', () => { + const {overlay} = setupWithOverlay(); + overlay.refresh(); + const points1 = [...overlay.points()]; + expect(points1.length).toBe(3); + const first = overlay.first()!; + const points2 = [...overlay.points(first)]; + expect(points2.length).toBe(2); + const second = next(first)!; + const points3 = [...overlay.points(second)]; + expect(points3.length).toBe(1); + const third = next(second); + const points4 = [...overlay.points(third)]; + expect(points4.length).toBe(0); + }); + + test('can skip the last real point', () => { + const {overlay} = setupWithOverlay(); + overlay.refresh(); + expect([...overlay.points()].length).toBe(3); + const lastPoint = last(overlay.root!); + const points: OverlayPoint[] = []; + for (const point of overlay.points()) { + if (point === lastPoint) break; + points.push(point); + } + expect(points.length).toBe(2); + }); + }); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts index 94d8454aae..8b5fcbb122 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts @@ -1,6 +1,6 @@ import {Model, ObjApi} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; -import {setupNumbersWithTombstones} from '../../__tests__/setup'; +import {setupNumbersWithTombstonesKit} from '../../__tests__/setup'; import {Anchor} from '../../rga/constants'; import {SliceBehavior} from '../../slice/constants'; @@ -24,26 +24,26 @@ type Kit = ReturnType; describe('Overlay.refresh()', () => { test('can select all text using relative range', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const overlay = peritext.overlay; const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!); editor.cursor.setRange(range); peritext.refresh(); expect(editor.cursor.text()).toBe('0123456789'); - const overlayPoints = [...overlay]; + const overlayPoints = [...overlay.points()]; expect(overlayPoints.length).toBe(2); expect(overlayPoints[0].id.time).toBe(editor.cursor.start.id.time); expect(overlayPoints[1].id.time).toBe(editor.cursor.end.id.time); }); test('can select all text using absolute range', () => { - const {peritext, editor} = setupNumbersWithTombstones(); + const {peritext, editor} = setupNumbersWithTombstonesKit(); const overlay = peritext.overlay; const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd()); editor.cursor.setRange(range); peritext.refresh(); expect(editor.cursor.text()).toBe('0123456789'); - const overlayPoints = [...overlay]; + const overlayPoints = [...overlay.points()]; expect(overlayPoints.length).toBe(2); expect(overlayPoints[0].id.time).toBe(editor.cursor.start.id.time); expect(overlayPoints[1].id.time).toBe(editor.cursor.end.id.time); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts index 32d53ea071..c6d4fe8d9b 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts @@ -21,7 +21,7 @@ const setup = () => { const markerCount = (peritext: Peritext): number => { const overlay = peritext.overlay; - const iterator = overlay.markerIterator(); + const iterator = overlay.markers0(); let count = 0; for (let split = iterator(); split; split = iterator()) { count++; @@ -43,12 +43,9 @@ describe('markers', () => { expect(markerCount(peritext)).toBe(0); peritext.overlay.refresh(); expect(markerCount(peritext)).toBe(1); - const points = []; - let point; - for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + const points = [...peritext.overlay.points()]; expect(points.length).toBe(2); - point = points[0]; - expect(point.pos()).toBe(5); + expect(points[0].pos()).toBe(5); }); test('can insert two markers', () => { @@ -77,10 +74,6 @@ describe('markers', () => { const slice = peritext.editor.insMarker(['p'], '¶'); peritext.refresh(); expect(markerCount(peritext)).toBe(1); - const points = []; - let point; - for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); - point = points[0]; peritext.delMarker(slice); peritext.refresh(); expect(markerCount(peritext)).toBe(0); @@ -94,10 +87,6 @@ describe('markers', () => { const slice = peritext.editor.insMarker(['p'], '¶'); peritext.refresh(); expect(markerCount(peritext)).toBe(2); - const points = []; - let point; - for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); - point = points[0]; peritext.delMarker(slice); peritext.refresh(); expect(markerCount(peritext)).toBe(1); @@ -117,7 +106,7 @@ describe('markers', () => { expect(markerCount(peritext)).toBe(2); const points = []; let point; - for (const iterator = peritext.overlay.markerIterator(); (point = iterator()); ) points.push(point); + for (const iterator = peritext.overlay.markers0(); (point = iterator()); ) points.push(point); expect(points.length).toBe(2); expect(points[0].pos()).toBe(2); expect(points[1].pos()).toBe(11); @@ -139,9 +128,7 @@ describe('slices', () => { expect(peritext.overlay.slices.size).toBe(0); peritext.overlay.refresh(); expect(peritext.overlay.slices.size).toBe(2); - const points = []; - let point; - for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + const points = [...peritext.overlay.points()]; expect(points.length).toBe(2); expect(points[0].pos()).toBe(6); expect(points[0].anchor).toBe(Anchor.Before); @@ -158,9 +145,7 @@ describe('slices', () => { expect(peritext.overlay.slices.size).toBe(0); peritext.overlay.refresh(); expect(peritext.overlay.slices.size).toBe(3); - const points = []; - let point; - for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + const points = [...peritext.overlay.points()]; expect(points.length).toBe(4); }); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts new file mode 100644 index 0000000000..6174e7a26a --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts @@ -0,0 +1,161 @@ +import {next} from 'sonic-forest/lib/util'; +import {Kit, setupHelloWorldKit, setupHelloWorldWithFewEditsKit} from '../../__tests__/setup'; +import {Anchor} from '../../rga/constants'; +import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; +import {OverlayPoint} from '../OverlayPoint'; + +const runPairsTests = (setup: () => Kit) => { + describe('.tuples() full range', () => { + test('returns [START, END] single tuple for an empty overlay', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + overlay.refresh(); + const list = [...overlay.tuples()]; + expect(list).toEqual([[overlay.START, overlay.END]]); + }); + + test('when caret at abs start, returns one [p, END] tuple', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.set(peritext.pointAbsStart()); + overlay.refresh(); + const list = [...overlay.tuples()]; + const p1 = overlay.first()!; + expect(peritext.editor.cursor.start.rightChar()?.view()).toBe(peritext.strApi().view()[0]); + expect(list).toEqual([[p1, overlay.END]]); + }); + + test('when caret at abs end, returns one [START, p] tuple', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.set(peritext.pointAbsEnd()); + overlay.refresh(); + const list = [...overlay.tuples()]; + const p1 = overlay.first()!; + expect(peritext.editor.cursor.start.leftChar()?.view()).toBe(peritext.strApi().view().slice(-1)); + expect(list).toEqual([[overlay.START, p1]]); + }); + + test('for only caret in overlay, returns two edge tuples', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(5); + overlay.refresh(); + const list = [...overlay.tuples()]; + const p1 = overlay.first()!; + expect(peritext.editor.cursor.start.leftChar()?.view()).toBe(peritext.strApi().view()[4]); + expect(list).toEqual([ + [overlay.START, p1], + [p1, overlay.END], + ]); + }); + + test('for a cursor selection, returns three tuples', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(3, 3); + overlay.refresh(); + const list = [...overlay.tuples()]; + const p1 = overlay.first()!; + const p2 = next(p1)!; + expect(peritext.editor.cursor.text()).toBe(peritext.strApi().view().slice(3, 6)); + expect(list).toEqual([ + [overlay.START, p1], + [p1, p2], + [p2, overlay.END], + ]); + }); + + test('for a cursor selection at abs start, returns two tuples', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + const range = peritext.range(peritext.pointAbsStart(), peritext.pointAt(5)); + peritext.editor.cursor.setRange(range); + overlay.refresh(); + const list = [...overlay.tuples()]; + const p1 = overlay.first()!; + const p2 = next(p1)!; + expect(list).toEqual([ + [p1, p2], + [p2, overlay.END], + ]); + }); + + test('for a cursor selection at abs end, returns two tuples', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + const range = peritext.range(peritext.pointAt(5, Anchor.Before), peritext.pointAbsEnd()); + peritext.editor.cursor.setRange(range); + overlay.refresh(); + const list = [...overlay.tuples()]; + const p1 = overlay.first()!; + const p2 = next(p1)!; + expect(list).toEqual([ + [overlay.START, p1], + [p1, p2], + ]); + }); + + test('for a marker and a slice after the marker, returns 4 tuples', () => { + const {peritext} = setup(); + const {editor, overlay} = peritext; + editor.cursor.setAt(3); + const [marker] = editor.saved.insMarker(''); + editor.cursor.setAt(6, 2); + editor.extra.insStack(''); + overlay.refresh(); + const p1 = overlay.first()!; + const p2 = next(p1)!; + const p3 = next(p2)!; + const list = [...overlay.tuples()]; + expect(list).toEqual([ + [overlay.START, p1], + [p1, p2], + [p2, p3], + [p3, overlay.END], + ]); + expect(p1 instanceof MarkerOverlayPoint).toBe(true); + expect(p2 instanceof OverlayPoint).toBe(true); + expect(p3 instanceof OverlayPoint).toBe(true); + expect((p1 as MarkerOverlayPoint).marker).toBe(marker); + expect(p2.layers.length).toBe(2); + expect(p3.layers.length).toBe(0); + expect(p2.refs.length).toBe(2); + expect(p3.refs.length).toBe(2); + }); + }); + + describe('.tuples() at offset', () => { + test('in empty overlay, after caret returns the last edge', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(5); + overlay.refresh(); + const first = overlay.first()!; + const pairs = [...overlay.tuples(first)]; + expect(pairs).toEqual([[first, overlay.END]]); + }); + + test('in empty overlay, after selection start returns the selection and the edge', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(2, 4); + overlay.refresh(); + const p1 = overlay.first()!; + const p2 = next(p1)!; + const list = [...overlay.tuples(p1)]; + expect(list).toEqual([ + [p1, p2], + [p2, overlay.END], + ]); + }); + }); +}; + +describe('numbers "hello world", no edits', () => { + runPairsTests(setupHelloWorldKit); +}); + +describe('numbers "hello world", with default schema and tombstones', () => { + runPairsTests(setupHelloWorldWithFewEditsKit); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/types.ts b/src/json-crdt-extensions/peritext/overlay/types.ts index 09bccc97bd..47696f1310 100644 --- a/src/json-crdt-extensions/peritext/overlay/types.ts +++ b/src/json-crdt-extensions/peritext/overlay/types.ts @@ -1,3 +1,5 @@ +import type {OverlayPoint} from './OverlayPoint'; + export type BlockTag = [ /** * Developer specified type of the block. For example, 'title', 'paragraph', @@ -11,3 +13,19 @@ export type BlockTag = [ */ attr?: undefined | unknown, ]; + +/** + * Represents a two adjacent overlay points. The first point is the point + * that is closer to the start of the document, and the second point is the + * point that is closer to the end of the document. When an absolute start is + * missing, the `p1` will be `undefined`. When an absolute end is missing, the + * `p2` will be `undefined`. + */ +export type OverlayPair = [p1: OverlayPoint | undefined, p2: OverlayPoint | undefined]; + +/** + * The *overlay tuple* is similar to the {@link OverlayPair}, but ensures that + * both points are defined. The leasing and trailing `undefined` are substituted + * by virtual points. + */ +export type OverlayTuple = [p1: OverlayPoint, p2: OverlayPoint]; diff --git a/src/util/iterator.ts b/src/util/iterator.ts index 143506549d..f56305ab92 100644 --- a/src/util/iterator.ts +++ b/src/util/iterator.ts @@ -1,5 +1,7 @@ +export type UndefIterator = () => undefined | T; + export class UndefEndIter implements IterableIterator { - constructor(private readonly i: () => T | undefined) {} + constructor(private readonly i: UndefIterator) {} public next(): IteratorResult { const value = this.i(); @@ -17,3 +19,5 @@ export class IterRes { public readonly done: boolean, ) {} } + +export const iter = (i: UndefIterator) => new UndefEndIter(i);