From 8b7da528c86896cd2a3ab36c28bc005bf2179887 Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 7 May 2024 15:36:44 +0200 Subject: [PATCH 01/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20add=20.chunkSlices0()=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/Overlay.chunkSlices0.spec.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts 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..df669da7ca --- /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 {setupNumbersWithTombstones} 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} = setupNumbersWithTombstones(); + testAllPossibleChunkPointCombinations(peritext); + }); +}); From 64d5ca2c6251ca9c4092ef9f77af46dd9c0792df Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 7 May 2024 20:59:01 +0200 Subject: [PATCH 02/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20add=20Overlay=20points=20iteration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/overlay/Overlay.ts | 151 +++++-------- .../overlay/__tests__/Overlay.pointsx.spec.ts | 212 ++++++++++++++++++ 2 files changed, 274 insertions(+), 89 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index e56f4c5d6f..f1ece0a8b4 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -7,7 +7,7 @@ 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'; @@ -28,11 +28,8 @@ import type {Slices} from '../slice/Slices'; */ export class Overlay implements Printable, Stateful { public root: OverlayPoint | undefined = undefined; - public readonly start: OverlayPoint; - constructor(protected readonly txt: Peritext) { - this.start = this.point(this.txt.str.id, Anchor.After); - } + constructor(protected readonly txt: Peritext) {} private point(id: ITimestampStruct, anchor: Anchor): OverlayPoint { return new OverlayPoint(this.txt.str, id, anchor); @@ -51,21 +48,16 @@ export class Overlay implements Printable, Stateful { } public iterator(): () => OverlayPoint | undefined { - let curr = this.first(); - return () => { - const ret = curr; - if (curr) curr = next(curr); - return ret; - }; + return this.points(undefined); } public entries(): IterableIterator> { return new UndefEndIter(this.iterator()); } - [Symbol.iterator]() { - return this.entries(); - } + // [Symbol.iterator]() { + // return this.entries(); + // } public markerIterator(): () => MarkerOverlayPoint | undefined { let curr = this.first(); @@ -191,49 +183,30 @@ export class Overlay implements Printable, Stateful { }) as Chunk; } + public points(start: undefined | OverlayPoint): () => OverlayPoint | undefined { + let curr = start || this.first(); + return () => { + const ret = curr; + if (curr) curr = next(curr); + return ret; + }; + } + + /** + * @todo Unify this with `.entries()`. + */ 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); - } + const i = this.points(start); + let point = i(); 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; + point = i(); } - 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 points1( @@ -290,47 +263,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 +409,47 @@ 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; + 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; + } + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts new file mode 100644 index 0000000000..71448a0bc8 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts @@ -0,0 +1,212 @@ +import {last, next} from 'sonic-forest/lib/util'; +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import {Point} from '../../rga/Point'; +import {Anchor} from '../../rga/constants'; +import {SliceBehavior} from '../../slice/constants'; +import type {Overlay} from '../Overlay'; +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('.points0()', () => { + const getPoints = (overlay: Overlay, start?: OverlayPoint, end?: (next: OverlayPoint) => boolean) => { + const points: OverlayPoint[] = []; + overlay.points0(start, end, (point) => { + points.push(point); + }); + return points; + }; + + describe('with overlay', () => { + test('iterates through all points', () => { + const {peritext} = setupWithOverlay(); + const overlay = peritext.overlay; + console.log(overlay + ''); + // const points = getPoints(overlay); + // expect(overlay.first()).not.toBe(undefined); + // expect(points.length).toBe(peritext.strApi().length()); + }); + + test('iterates through all points, when points anchored to the same anchor', () => { + const {peritext, overlay} = setupWithOverlay(); + peritext.editor.cursor.setAt(2, 1); + peritext.editor.saved.insStack(''); + peritext.refresh(); + const points = getPoints(overlay); + expect(overlay.first()).not.toBe(undefined); + expect(points.length).toBe(6); + }); + + 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 = getPoints(overlay); + 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 = getPoints(overlay); + 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(); + const points1 = getPoints(overlay); + expect(points1.length).toBe(5); + const first = overlay.first()!; + const points2 = getPoints(overlay, first); + expect(points2.length).toBe(4); + const second = next(first)!; + const points3 = getPoints(overlay, second); + expect(points3.length).toBe(3); + const third = next(second); + const points4 = getPoints(overlay, third); + expect(points4.length).toBe(2); + }); + + test('can skip last virtual point', () => { + const {overlay} = setupWithOverlay(); + const points1 = getPoints(overlay); + expect(points1.length).toBe(5); + const points2 = getPoints(overlay, undefined, (point) => !point.refs.length); + expect(points2.length).toBe(4); + }); + + test('can skip last real point', () => { + const {overlay} = setupWithOverlay(); + const lastPoint = last(overlay.root!); + const points1 = getPoints(overlay, undefined, (point) => point === lastPoint); + expect(points1.length).toBe(3); + }); + }); +}); + +describe('.points1()', () => { + const getSlices = (overlay: Overlay, start?: OverlayPoint, end?: (next: OverlayPoint) => boolean) => { + const slices: [OverlayPoint, OverlayPoint][] = []; + overlay.points1(start, end, (p1, p2) => { + slices.push([p1, p2]); + }); + return slices; + }; + + describe('when overlay is empty', () => { + test('returns one interval that contains the whole string', () => { + const {peritext, overlay} = setup(); + const slices = getSlices(overlay); + expect(overlay.first()).toBe(undefined); + expect(slices.length).toBe(1); + expect(slices[0][0].id.time).toBe(peritext.str.first()!.id.time); + expect(slices[0][1].id.time).toBe(peritext.str.last()!.id.time + peritext.str.last()!.span - 1); + expect(slices[0][0].anchor).toBe(Anchor.Before); + expect(slices[0][1].anchor).toBe(Anchor.After); + }); + }); + + describe('with overlay', () => { + test('iterates through all slices', () => { + const {overlay} = setupWithOverlay(); + const slices = getSlices(overlay); + expect(overlay.first()).not.toBe(undefined); + expect(slices.length).toBe(4); + }); + + test('iterates through all slices, when points anchored to the same anchor', () => { + const {peritext, overlay} = setupWithOverlay(); + peritext.editor.cursor.setAt(2, 1); + peritext.editor.saved.insStack(''); + peritext.refresh(); + const slices = getSlices(overlay); + expect(overlay.first()).not.toBe(undefined); + expect(slices.length).toBe(5); + }); + + test('can skip the first slice', () => { + const {overlay} = setupWithOverlay(); + const slices = getSlices(overlay, overlay.first()); + expect(slices.length).toBe(3); + }); + + test('can skip the last slice', () => { + const {overlay} = setupWithOverlay(); + const slices = getSlices(overlay, overlay.first(), (point) => !point.refs.length); + expect(slices.length).toBe(2); + }); + + test('can skip last two slices', () => { + const {overlay} = setupWithOverlay(); + const lastPoint = last(overlay.root!); + const slices = getSlices(overlay, overlay.first(), (point) => point === lastPoint); + expect(slices.length).toBe(1); + }); + + test('handles case when cursor is at start of document', () => { + const model = Model.withLogicalClock(); + const api = model.api; + api.root({ + text: '', + slices: [], + }); + const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node); + peritext.overlay.refresh(true); + peritext.insAt(0, '\n'); + peritext.overlay.refresh(true); + peritext.insAt(0, 'abc xyz'); + peritext.overlay.refresh(true); + peritext.savedSlices.ins(peritext.rangeAt(4, 3), SliceBehavior.Overwrite, 'bold'); + peritext.overlay.refresh(true); + const points: [Point, Point][] = []; + peritext.overlay.points1(undefined, undefined, (p1, p2) => { + points.push([p1, p2]); + }); + const first = peritext.overlay.first(); + const second = next(first!); + const third = next(second!); + const fourth = next(third!); + expect(fourth).toBe(undefined); + expect(points.length).toBe(3); + expect(points[0][0]).toBe(first); + expect(points[0][1]).toBe(second); + expect(points[1][0]).toBe(second); + expect(points[1][1]).toBe(third); + expect(points[2][0]).toBe(third); + expect(points[2][1].pos()).toBe(7); + expect(points[2][1].anchor).toBe(Anchor.After); + }); + }); +}); From 2b2070bc3a184ada298df2eb0e36ba47a4200d78 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 7 May 2024 23:40:50 +0200 Subject: [PATCH 03/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20fix=20.points0()=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/overlay/Overlay.ts | 5 ++- .../overlay/__tests__/Overlay.pointsx.spec.ts | 37 ++++++++----------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index f1ece0a8b4..d996c13ca8 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -183,8 +183,8 @@ export class Overlay implements Printable, Stateful { }) as Chunk; } - public points(start: undefined | OverlayPoint): () => OverlayPoint | undefined { - let curr = start || this.first(); + public points(after: undefined | OverlayPoint): () => OverlayPoint | undefined { + let curr = after ? next(after) : this.first(); return () => { const ret = curr; if (curr) curr = next(curr); @@ -194,6 +194,7 @@ export class Overlay implements Printable, Stateful { /** * @todo Unify this with `.entries()`. + * @deprecated */ public points0( start: undefined | OverlayPoint, diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts index 71448a0bc8..07c0fb969f 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts @@ -47,20 +47,20 @@ describe('.points0()', () => { test('iterates through all points', () => { const {peritext} = setupWithOverlay(); const overlay = peritext.overlay; - console.log(overlay + ''); - // const points = getPoints(overlay); - // expect(overlay.first()).not.toBe(undefined); - // expect(points.length).toBe(peritext.strApi().length()); + const points = getPoints(overlay); + 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(getPoints(overlay).length).toBe(3); peritext.editor.cursor.setAt(2, 1); peritext.editor.saved.insStack(''); peritext.refresh(); - const points = getPoints(overlay); + expect(getPoints(overlay).length).toBe(4); expect(overlay.first()).not.toBe(undefined); - expect(points.length).toBe(6); }); test('should not return virtual start point, if real start point exists', () => { @@ -86,32 +86,27 @@ describe('.points0()', () => { test('can skip points from beginning', () => { const {overlay} = setupWithOverlay(); + overlay.refresh(); const points1 = getPoints(overlay); - expect(points1.length).toBe(5); + expect(points1.length).toBe(3); const first = overlay.first()!; const points2 = getPoints(overlay, first); - expect(points2.length).toBe(4); + expect(points2.length).toBe(2); const second = next(first)!; const points3 = getPoints(overlay, second); - expect(points3.length).toBe(3); + expect(points3.length).toBe(1); const third = next(second); const points4 = getPoints(overlay, third); - expect(points4.length).toBe(2); - }); - - test('can skip last virtual point', () => { - const {overlay} = setupWithOverlay(); - const points1 = getPoints(overlay); - expect(points1.length).toBe(5); - const points2 = getPoints(overlay, undefined, (point) => !point.refs.length); - expect(points2.length).toBe(4); + expect(points4.length).toBe(0); }); - test('can skip last real point', () => { + test('can skip the last real point', () => { const {overlay} = setupWithOverlay(); + overlay.refresh(); + expect(getPoints(overlay).length).toBe(3); const lastPoint = last(overlay.root!); const points1 = getPoints(overlay, undefined, (point) => point === lastPoint); - expect(points1.length).toBe(3); + expect(points1.length).toBe(2); }); }); }); From 6b07e3ad46d123214039ec62adf4551b5c2f7163 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 8 May 2024 00:45:07 +0200 Subject: [PATCH 04/12] =?UTF-8?q?chore(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=A4=96=20introduce=20more=20iteration=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/overlay/Overlay.ts | 41 ++++ .../overlay/__tests__/Overlay.pointsx.spec.ts | 202 ++++++++++-------- 2 files changed, 152 insertions(+), 91 deletions(-) diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index d996c13ca8..9608cd4fed 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -210,6 +210,7 @@ export class Overlay implements Printable, Stateful { } } + /** @deprecated */ public points1( start: undefined | OverlayPoint, end: undefined | ((next: OverlayPoint) => boolean), @@ -228,6 +229,46 @@ export class Overlay implements Printable, Stateful { }); } + public pairs0(after: undefined | OverlayPoint): () => undefined | [p1: OverlayPoint | undefined, p2: OverlayPoint | undefined] { + let p1: OverlayPoint | undefined; + let p2: OverlayPoint | undefined; + const iterator = this.points(after); + return () => { + p1 = p2; + p2 = iterator(); + return (p1 || p2) ? [p1, p2] : undefined; + }; + } + + public pairs(after?: undefined | OverlayPoint): IterableIterator<[p1: OverlayPoint | undefined, p2: OverlayPoint | undefined]> { + return new UndefEndIter(this.pairs0(after)); + } + + public tuples0(after: undefined | OverlayPoint): () => undefined | [p1: OverlayPoint, p2: OverlayPoint] { + const iterator = this.pairs0(after); + return () => { + const pair = iterator(); + if (!pair) return; + if (pair[0]) pair[0] = this.point(this.txt.str.id, Anchor.After); + if (pair[1]) pair[1] = this.point(this.txt.str.id, Anchor.Before); + }; + } + + // public tuples2(after: undefined | OverlayPoint): () => undefined | [p1: OverlayPoint, p2: OverlayPoint] { + // let p1: OverlayPoint | undefined; + // let p2: OverlayPoint | undefined; + // const iterator = this.points(after); + // return () => { + // const isFirst = !p1; + // if (isFirst) { + // p1 = iterator(); + // } + // // p1 = p2; + // // p2 = iterator(); + // // return p2 ? [p1, p2] : undefined; + // }; + // } + public findContained(range: Range): Set> { const result = new Set>(); let point = this.getOrNextLower(range.start); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts index 07c0fb969f..5f74c043ae 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts @@ -111,97 +111,117 @@ describe('.points0()', () => { }); }); -describe('.points1()', () => { - const getSlices = (overlay: Overlay, start?: OverlayPoint, end?: (next: OverlayPoint) => boolean) => { - const slices: [OverlayPoint, OverlayPoint][] = []; - overlay.points1(start, end, (p1, p2) => { - slices.push([p1, p2]); - }); - return slices; - }; - describe('when overlay is empty', () => { - test('returns one interval that contains the whole string', () => { - const {peritext, overlay} = setup(); - const slices = getSlices(overlay); - expect(overlay.first()).toBe(undefined); - expect(slices.length).toBe(1); - expect(slices[0][0].id.time).toBe(peritext.str.first()!.id.time); - expect(slices[0][1].id.time).toBe(peritext.str.last()!.id.time + peritext.str.last()!.span - 1); - expect(slices[0][0].anchor).toBe(Anchor.Before); - expect(slices[0][1].anchor).toBe(Anchor.After); - }); - }); - - describe('with overlay', () => { - test('iterates through all slices', () => { - const {overlay} = setupWithOverlay(); - const slices = getSlices(overlay); - expect(overlay.first()).not.toBe(undefined); - expect(slices.length).toBe(4); - }); - - test('iterates through all slices, when points anchored to the same anchor', () => { - const {peritext, overlay} = setupWithOverlay(); - peritext.editor.cursor.setAt(2, 1); - peritext.editor.saved.insStack(''); - peritext.refresh(); - const slices = getSlices(overlay); - expect(overlay.first()).not.toBe(undefined); - expect(slices.length).toBe(5); - }); - - test('can skip the first slice', () => { - const {overlay} = setupWithOverlay(); - const slices = getSlices(overlay, overlay.first()); - expect(slices.length).toBe(3); - }); - - test('can skip the last slice', () => { - const {overlay} = setupWithOverlay(); - const slices = getSlices(overlay, overlay.first(), (point) => !point.refs.length); - expect(slices.length).toBe(2); - }); - - test('can skip last two slices', () => { - const {overlay} = setupWithOverlay(); - const lastPoint = last(overlay.root!); - const slices = getSlices(overlay, overlay.first(), (point) => point === lastPoint); - expect(slices.length).toBe(1); - }); - - test('handles case when cursor is at start of document', () => { - const model = Model.withLogicalClock(); - const api = model.api; - api.root({ - text: '', - slices: [], - }); - const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node); - peritext.overlay.refresh(true); - peritext.insAt(0, '\n'); - peritext.overlay.refresh(true); - peritext.insAt(0, 'abc xyz'); - peritext.overlay.refresh(true); - peritext.savedSlices.ins(peritext.rangeAt(4, 3), SliceBehavior.Overwrite, 'bold'); - peritext.overlay.refresh(true); - const points: [Point, Point][] = []; - peritext.overlay.points1(undefined, undefined, (p1, p2) => { - points.push([p1, p2]); - }); - const first = peritext.overlay.first(); - const second = next(first!); - const third = next(second!); - const fourth = next(third!); - expect(fourth).toBe(undefined); - expect(points.length).toBe(3); - expect(points[0][0]).toBe(first); - expect(points[0][1]).toBe(second); - expect(points[1][0]).toBe(second); - expect(points[1][1]).toBe(third); - expect(points[2][0]).toBe(third); - expect(points[2][1].pos()).toBe(7); - expect(points[2][1].anchor).toBe(Anchor.After); - }); +describe('.pairs()', () => { + test('all adjacent pairs', () => { + const {peritext} = setupWithOverlay(); + const overlay = peritext.overlay; + overlay.refresh(); + const pairs = [...overlay.pairs()]; + const p1 = overlay.first()!; + const p2 = next(p1)!; + const p3 = next(p2)!; + expect(pairs.length).toBe(4); + expect(pairs).toEqual([ + [undefined, p1], + [p1, p2], + [p2, p3], + [p3, undefined], + ]); }); }); + +// describe('.points1()', () => { +// const getSlices = (overlay: Overlay, start?: OverlayPoint, end?: (next: OverlayPoint) => boolean) => { +// const slices: [OverlayPoint, OverlayPoint][] = []; +// overlay.points1(start, end, (p1, p2) => { +// slices.push([p1, p2]); +// }); +// return slices; +// }; + +// describe('when overlay is empty', () => { +// test('returns one interval that contains the whole string', () => { +// const {peritext, overlay} = setup(); +// const slices = getSlices(overlay); +// expect(overlay.first()).toBe(undefined); +// expect(slices.length).toBe(1); +// expect(slices[0][0].id.time).toBe(peritext.str.first()!.id.time); +// expect(slices[0][1].id.time).toBe(peritext.str.last()!.id.time + peritext.str.last()!.span - 1); +// expect(slices[0][0].anchor).toBe(Anchor.Before); +// expect(slices[0][1].anchor).toBe(Anchor.After); +// }); +// }); + +// describe('with overlay', () => { +// test('iterates through all slices', () => { +// const {overlay} = setupWithOverlay(); +// const slices = getSlices(overlay); +// expect(overlay.first()).not.toBe(undefined); +// expect(slices.length).toBe(4); +// }); + +// test('iterates through all slices, when points anchored to the same anchor', () => { +// const {peritext, overlay} = setupWithOverlay(); +// peritext.editor.cursor.setAt(2, 1); +// peritext.editor.saved.insStack(''); +// peritext.refresh(); +// const slices = getSlices(overlay); +// expect(overlay.first()).not.toBe(undefined); +// expect(slices.length).toBe(5); +// }); + +// test('can skip the first slice', () => { +// const {overlay} = setupWithOverlay(); +// const slices = getSlices(overlay, overlay.first()); +// expect(slices.length).toBe(3); +// }); + +// test('can skip the last slice', () => { +// const {overlay} = setupWithOverlay(); +// const slices = getSlices(overlay, overlay.first(), (point) => !point.refs.length); +// expect(slices.length).toBe(2); +// }); + +// test('can skip last two slices', () => { +// const {overlay} = setupWithOverlay(); +// const lastPoint = last(overlay.root!); +// const slices = getSlices(overlay, overlay.first(), (point) => point === lastPoint); +// expect(slices.length).toBe(1); +// }); + +// test('handles case when cursor is at start of document', () => { +// const model = Model.withLogicalClock(); +// const api = model.api; +// api.root({ +// text: '', +// slices: [], +// }); +// const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node); +// peritext.overlay.refresh(true); +// peritext.insAt(0, '\n'); +// peritext.overlay.refresh(true); +// peritext.insAt(0, 'abc xyz'); +// peritext.overlay.refresh(true); +// peritext.savedSlices.ins(peritext.rangeAt(4, 3), SliceBehavior.Overwrite, 'bold'); +// peritext.overlay.refresh(true); +// const points: [Point, Point][] = []; +// peritext.overlay.points1(undefined, undefined, (p1, p2) => { +// points.push([p1, p2]); +// }); +// const first = peritext.overlay.first(); +// const second = next(first!); +// const third = next(second!); +// const fourth = next(third!); +// expect(fourth).toBe(undefined); +// expect(points.length).toBe(3); +// expect(points[0][0]).toBe(first); +// expect(points[0][1]).toBe(second); +// expect(points[1][0]).toBe(second); +// expect(points[1][1]).toBe(third); +// expect(points[2][0]).toBe(third); +// expect(points[2][1].pos()).toBe(7); +// expect(points[2][1].anchor).toBe(Anchor.After); +// }); +// }); +// }); From 557d0b2420d380f9c488d9afb1bc72f79a8cc0f6 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 May 2024 10:04:06 +0200 Subject: [PATCH 05/12] =?UTF-8?q?refactor(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=A1=20unify=20all=20iteration=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/overlay/Overlay.ts | 128 ++++++------------ ....pointsx.spec.ts => Overlay.pairs.spec.ts} | 85 +----------- .../overlay/__tests__/Overlay.points.spec.ts | 104 ++++++++++++++ .../overlay/__tests__/Overlay.refresh.spec.ts | 4 +- .../overlay/__tests__/Overlay.spec.ts | 27 +--- .../peritext/overlay/types.ts | 18 +++ src/util/iterator.ts | 6 +- 7 files changed, 179 insertions(+), 193 deletions(-) rename src/json-crdt-extensions/peritext/overlay/__tests__/{Overlay.pointsx.spec.ts => Overlay.pairs.spec.ts} (62%) create mode 100644 src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index 9608cd4fed..ed4fd9b6d5 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -11,13 +11,14 @@ 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 @@ -29,7 +30,17 @@ import type {Slices} from '../slice/Slices'; export class Overlay implements Printable, Stateful { public root: OverlayPoint | undefined = undefined; - constructor(protected readonly txt: Peritext) {} + /** A virtual absolute start point, used when the absolute start is missing. */ + private readonly START: OverlayPoint; + + /** A virtual absolute end point, used when the absolute end is missing. */ + private readonly END: OverlayPoint; + + constructor(protected readonly txt: Peritext) { + 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 { return new OverlayPoint(this.txt.str, id, anchor); @@ -47,34 +58,6 @@ export class Overlay implements Printable, Stateful { return this.root ? last(this.root) : undefined; } - public iterator(): () => OverlayPoint | undefined { - return this.points(undefined); - } - - 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. */ @@ -183,7 +166,7 @@ export class Overlay implements Printable, Stateful { }) as Chunk; } - public points(after: undefined | OverlayPoint): () => OverlayPoint | undefined { + public points0(after: undefined | OverlayPoint): UndefIterator> { let curr = after ? next(after) : this.first(); return () => { const ret = curr; @@ -192,47 +175,30 @@ export class Overlay implements Printable, Stateful { }; } - /** - * @todo Unify this with `.entries()`. - * @deprecated - */ - public points0( - start: undefined | OverlayPoint, - end: undefined | ((next: OverlayPoint) => boolean), - callback: (point: OverlayPoint) => void, - ): void { - const i = this.points(start); - let point = i(); - while (point) { - if (end && end(point)) return; - callback(point); - point = i(); - } + public points(after?: undefined | OverlayPoint): IterableIterator> { + return new UndefEndIter(this.points0(after)); } - /** @deprecated */ - public points1( - start: undefined | OverlayPoint, - end: undefined | ((next: OverlayPoint) => boolean), - callback: (p1: OverlayPoint, p2: OverlayPoint) => void, - ): void { - let p1: OverlayPoint | undefined; - let p2: OverlayPoint | undefined; - this.points0(start, end, (point) => { - if (p1) { - p2 = point; - callback(p1, p2); - p1 = p2; - } else { - p1 = point; + 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): () => undefined | [p1: OverlayPoint | undefined, p2: OverlayPoint | undefined] { + public pairs0(after: undefined | OverlayPoint): UndefIterator> { let p1: OverlayPoint | undefined; let p2: OverlayPoint | undefined; - const iterator = this.points(after); + const iterator = this.points0(after); return () => { p1 = p2; p2 = iterator(); @@ -240,34 +206,24 @@ export class Overlay implements Printable, Stateful { }; } - public pairs(after?: undefined | OverlayPoint): IterableIterator<[p1: OverlayPoint | undefined, p2: OverlayPoint | undefined]> { + public pairs(after?: undefined | OverlayPoint): IterableIterator> { return new UndefEndIter(this.pairs0(after)); } - public tuples0(after: undefined | OverlayPoint): () => undefined | [p1: OverlayPoint, p2: OverlayPoint] { + public tuples0(after: undefined | OverlayPoint): UndefIterator> { const iterator = this.pairs0(after); return () => { const pair = iterator(); if (!pair) return; - if (pair[0]) pair[0] = this.point(this.txt.str.id, Anchor.After); - if (pair[1]) pair[1] = this.point(this.txt.str.id, Anchor.Before); + if (pair[0] === undefined) pair[0] = this.START; + if (pair[1] === undefined) pair[1] = this.END; + return pair as OverlayTuple; }; } - // public tuples2(after: undefined | OverlayPoint): () => undefined | [p1: OverlayPoint, p2: OverlayPoint] { - // let p1: OverlayPoint | undefined; - // let p2: OverlayPoint | undefined; - // const iterator = this.points(after); - // return () => { - // const isFirst = !p1; - // if (isFirst) { - // p1 = iterator(); - // } - // // p1 = p2; - // // p2 = iterator(); - // // return p2 ? [p1, p2] : undefined; - // }; - // } + public tuples(after?: undefined | OverlayPoint): IterableIterator> { + return new UndefEndIter(this.tuples0(after)); + } public findContained(range: Range): Set> { const result = new Set>(); @@ -461,7 +417,9 @@ export class Overlay implements Printable, Stateful { let chunk: Chunk | undefined = firstChunk; let marker: MarkerOverlayPoint | undefined = undefined; let state: number = CONST.START_STATE; - this.points1(undefined, undefined, (p1, p2) => { + 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; @@ -484,7 +442,7 @@ export class Overlay implements Printable, Stateful { state = CONST.START_STATE; marker = p2; } - }); + } if ((marker as any) instanceof MarkerOverlayPoint) { (marker as any as MarkerOverlayPoint).textHash = state; } else { diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts similarity index 62% rename from src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts rename to src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts index 5f74c043ae..7d3dee9996 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pointsx.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts @@ -1,11 +1,6 @@ -import {last, next} from 'sonic-forest/lib/util'; +import {next} from 'sonic-forest/lib/util'; import {Model} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; -import {Point} from '../../rga/Point'; -import {Anchor} from '../../rga/constants'; -import {SliceBehavior} from '../../slice/constants'; -import type {Overlay} from '../Overlay'; -import type {OverlayPoint} from '../OverlayPoint'; const setup = () => { const model = Model.withLogicalClock(); @@ -34,84 +29,6 @@ const setupWithOverlay = () => { return res; }; -describe('.points0()', () => { - const getPoints = (overlay: Overlay, start?: OverlayPoint, end?: (next: OverlayPoint) => boolean) => { - const points: OverlayPoint[] = []; - overlay.points0(start, end, (point) => { - points.push(point); - }); - return points; - }; - - describe('with overlay', () => { - test('iterates through all points', () => { - const {peritext} = setupWithOverlay(); - const overlay = peritext.overlay; - const points = getPoints(overlay); - 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(getPoints(overlay).length).toBe(3); - peritext.editor.cursor.setAt(2, 1); - peritext.editor.saved.insStack(''); - peritext.refresh(); - expect(getPoints(overlay).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 = getPoints(overlay); - 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 = getPoints(overlay); - 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 = getPoints(overlay); - expect(points1.length).toBe(3); - const first = overlay.first()!; - const points2 = getPoints(overlay, first); - expect(points2.length).toBe(2); - const second = next(first)!; - const points3 = getPoints(overlay, second); - expect(points3.length).toBe(1); - const third = next(second); - const points4 = getPoints(overlay, third); - expect(points4.length).toBe(0); - }); - - test('can skip the last real point', () => { - const {overlay} = setupWithOverlay(); - overlay.refresh(); - expect(getPoints(overlay).length).toBe(3); - const lastPoint = last(overlay.root!); - const points1 = getPoints(overlay, undefined, (point) => point === lastPoint); - expect(points1.length).toBe(2); - }); - }); -}); - - describe('.pairs()', () => { test('all adjacent pairs', () => { const {peritext} = setupWithOverlay(); 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..981b576552 --- /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..90ac3bb7c2 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 @@ -30,7 +30,7 @@ describe('Overlay.refresh()', () => { 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); @@ -43,7 +43,7 @@ describe('Overlay.refresh()', () => { 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/types.ts b/src/json-crdt-extensions/peritext/overlay/types.ts index 09bccc97bd..7d0bb6ca38 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); From b095301aa285ff49ecedc7790911a71071522fb2 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 May 2024 13:14:22 +0200 Subject: [PATCH 06/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20Overlay.pairs()=20iterator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/__tests__/setup.ts | 114 +++++--- .../peritext/editor/Editor.ts | 4 + .../peritext/overlay/Overlay.ts | 25 +- .../overlay/__tests__/Overlay.pairs.spec.ts | 266 +++++++++--------- 4 files changed, 240 insertions(+), 169 deletions(-) diff --git a/src/json-crdt-extensions/peritext/__tests__/setup.ts b/src/json-crdt-extensions/peritext/__tests__/setup.ts index 81f4a09713..fc1bb6c74f 100644 --- a/src/json-crdt-extensions/peritext/__tests__/setup.ts +++ b/src/json-crdt-extensions/peritext/__tests__/setup.ts @@ -1,41 +1,18 @@ 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'), - }); - 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 type Schema = ReturnType; +export type Kit = ReturnType; + +const schema = (text: string) => s.obj({ + text: ext.peritext.new(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 +26,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 ed4fd9b6d5..1188bed61b 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -196,12 +196,35 @@ export class Overlay implements Printable, Stateful { } public pairs0(after: undefined | OverlayPoint): UndefIterator> { + const isEmpty = !this.root; + if (isEmpty) { + let closed = false; + return () => { + if (closed) return; + closed = true; + return [undefined, undefined]; + } + } let p1: OverlayPoint | undefined; let p2: OverlayPoint | undefined; const iterator = this.points0(after); return () => { + const next = iterator(); + const isEnd = !next; + if (isEnd) { + if (!p2 || p2.isAbsEnd()) return; + p1 = p2; + p2 = undefined; + return [p1, p2]; + } p1 = p2; - p2 = iterator(); + p2 = next; + if (!p1) { + if (p2 && p2.isAbsStart()) { + p1 = p2; + p2 = iterator(); + } + } return (p1 || p2) ? [p1, p2] : undefined; }; } 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 index 7d3dee9996..eb4d2aaa80 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts @@ -1,144 +1,144 @@ import {next} from 'sonic-forest/lib/util'; -import {Model} from '../../../../json-crdt/model'; -import {Peritext} from '../../Peritext'; +import {Kit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +import {Anchor} from '../../rga/constants'; +import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; +import {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 runPairsTests = (setup: () => Kit) => { + describe('.pairs()', () => { + 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] + ]); + }); -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('.pairs()', () => { - test('all adjacent pairs', () => { - const {peritext} = setupWithOverlay(); - const overlay = peritext.overlay; - overlay.refresh(); - const pairs = [...overlay.pairs()]; - const p1 = overlay.first()!; - const p2 = next(p1)!; - const p3 = next(p2)!; - expect(pairs.length).toBe(4); - expect(pairs).toEqual([ - [undefined, p1], - [p1, p2], - [p2, p3], - [p3, 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] + ]); + }); -// describe('.points1()', () => { -// const getSlices = (overlay: Overlay, start?: OverlayPoint, end?: (next: OverlayPoint) => boolean) => { -// const slices: [OverlayPoint, OverlayPoint][] = []; -// overlay.points1(start, end, (p1, p2) => { -// slices.push([p1, p2]); -// }); -// return slices; -// }; + 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] + ]); + }); -// describe('when overlay is empty', () => { -// test('returns one interval that contains the whole string', () => { -// const {peritext, overlay} = setup(); -// const slices = getSlices(overlay); -// expect(overlay.first()).toBe(undefined); -// expect(slices.length).toBe(1); -// expect(slices[0][0].id.time).toBe(peritext.str.first()!.id.time); -// expect(slices[0][1].id.time).toBe(peritext.str.last()!.id.time + peritext.str.last()!.span - 1); -// expect(slices[0][0].anchor).toBe(Anchor.Before); -// expect(slices[0][1].anchor).toBe(Anchor.After); -// }); -// }); + 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], + ]); + }); -// describe('with overlay', () => { -// test('iterates through all slices', () => { -// const {overlay} = setupWithOverlay(); -// const slices = getSlices(overlay); -// expect(overlay.first()).not.toBe(undefined); -// expect(slices.length).toBe(4); -// }); + 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('iterates through all slices, when points anchored to the same anchor', () => { -// const {peritext, overlay} = setupWithOverlay(); -// peritext.editor.cursor.setAt(2, 1); -// peritext.editor.saved.insStack(''); -// peritext.refresh(); -// const slices = getSlices(overlay); -// expect(overlay.first()).not.toBe(undefined); -// expect(slices.length).toBe(5); -// }); + 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('can skip the first slice', () => { -// const {overlay} = setupWithOverlay(); -// const slices = getSlices(overlay, overlay.first()); -// expect(slices.length).toBe(3); -// }); + 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('can skip the last slice', () => { -// const {overlay} = setupWithOverlay(); -// const slices = getSlices(overlay, overlay.first(), (point) => !point.refs.length); -// expect(slices.length).toBe(2); -// }); + 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); + }); + }); +}; -// test('can skip last two slices', () => { -// const {overlay} = setupWithOverlay(); -// const lastPoint = last(overlay.root!); -// const slices = getSlices(overlay, overlay.first(), (point) => point === lastPoint); -// expect(slices.length).toBe(1); -// }); +describe('numbers "0123456789", no edits', () => { + runPairsTests(setupNumbersKit); +}); -// test('handles case when cursor is at start of document', () => { -// const model = Model.withLogicalClock(); -// const api = model.api; -// api.root({ -// text: '', -// slices: [], -// }); -// const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node); -// peritext.overlay.refresh(true); -// peritext.insAt(0, '\n'); -// peritext.overlay.refresh(true); -// peritext.insAt(0, 'abc xyz'); -// peritext.overlay.refresh(true); -// peritext.savedSlices.ins(peritext.rangeAt(4, 3), SliceBehavior.Overwrite, 'bold'); -// peritext.overlay.refresh(true); -// const points: [Point, Point][] = []; -// peritext.overlay.points1(undefined, undefined, (p1, p2) => { -// points.push([p1, p2]); -// }); -// const first = peritext.overlay.first(); -// const second = next(first!); -// const third = next(second!); -// const fourth = next(third!); -// expect(fourth).toBe(undefined); -// expect(points.length).toBe(3); -// expect(points[0][0]).toBe(first); -// expect(points[0][1]).toBe(second); -// expect(points[1][0]).toBe(second); -// expect(points[1][1]).toBe(third); -// expect(points[2][0]).toBe(third); -// expect(points[2][1].pos()).toBe(7); -// expect(points[2][1].anchor).toBe(Anchor.After); -// }); -// }); -// }); +describe('numbers "0123456789", with default schema and tombstones', () => { + runPairsTests(setupNumbersWithTombstonesKit); +}); From 5cff8396de4b90f25fda6abc5365e68bb87bc361 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 May 2024 13:22:54 +0200 Subject: [PATCH 07/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20make=20all=20Peritext=20tests=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/Peritext.overlay.spec.ts | 14 +++++++------- .../__tests__/Overlay.chunkSlices0.spec.ts | 4 ++-- .../__tests__/Overlay.getOrNextLH.spec.ts | 18 +++++++++--------- .../overlay/__tests__/Overlay.refresh.spec.ts | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) 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/overlay/__tests__/Overlay.chunkSlices0.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts index df669da7ca..c1b498d474 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts @@ -3,7 +3,7 @@ import {Model} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; import {Point} from '../../rga/Point'; import {Anchor} from '../../rga/constants'; -import {setupNumbersWithTombstones} from '../../__tests__/setup'; +import {setupNumbersWithTombstonesKit} from '../../__tests__/setup'; import type {Chunk} from '../../../../json-crdt/nodes/rga'; const setup = () => { @@ -226,7 +226,7 @@ describe('.chunkSlices0()', () => { }); describe('with "integer list" text', () => { - const {peritext} = setupNumbersWithTombstones(); + 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.refresh.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts index 90ac3bb7c2..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,7 +24,7 @@ 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); @@ -37,7 +37,7 @@ describe('Overlay.refresh()', () => { }); 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); From 7eecc01a94d1b689a23a5f0ddfb3006090d60f4c Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 May 2024 13:35:31 +0200 Subject: [PATCH 08/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20add=20Overlay.tuples()=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/overlay/Overlay.ts | 4 +- .../overlay/__tests__/Overlay.tuples.spec.ts | 141 ++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index 1188bed61b..a1ffdf4cd5 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -31,10 +31,10 @@ export class Overlay implements Printable, Stateful { public root: OverlayPoint | undefined = undefined; /** A virtual absolute start point, used when the absolute start is missing. */ - private readonly START: OverlayPoint; + public readonly START: OverlayPoint; /** A virtual absolute end point, used when the absolute end is missing. */ - private readonly END: OverlayPoint; + public readonly END: OverlayPoint; constructor(protected readonly txt: Peritext) { const id = txt.str.id; 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..832b182fb5 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts @@ -0,0 +1,141 @@ +import {next} from 'sonic-forest/lib/util'; +import {Kit, setupHelloWorldKit, setupHelloWorldWithFewEditsKit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +import {Anchor} from '../../rga/constants'; +import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; +import {OverlayPoint} from '../OverlayPoint'; + +const runPairsTests = (setup: () => Kit) => { + describe('.tuples()', () => { + 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('numbers "hello world", no edits', () => { + runPairsTests(setupHelloWorldKit); +}); + +describe('numbers "hello world", with default schema and tombstones', () => { + runPairsTests(setupHelloWorldWithFewEditsKit); +}); From 47676976e31113244966f0de80df0bfafe28a305 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 May 2024 14:26:18 +0200 Subject: [PATCH 09/12] =?UTF-8?q?refactor(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=A1=20simplify=20iterator=20edge=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/overlay/Overlay.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index a1ffdf4cd5..bba76f8ea1 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -198,12 +198,9 @@ export class Overlay implements Printable, Stateful { public pairs0(after: undefined | OverlayPoint): UndefIterator> { const isEmpty = !this.root; if (isEmpty) { + const u = undefined; let closed = false; - return () => { - if (closed) return; - closed = true; - return [undefined, undefined]; - } + return () => (closed ? u : (closed = true, [u, u])); } let p1: OverlayPoint | undefined; let p2: OverlayPoint | undefined; From 8de86766519c5e3dfbf28e0fd4943c00d2f0b277 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 8 May 2024 16:01:20 +0200 Subject: [PATCH 10/12] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20.points()=20iteration=20at=20offset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/overlay/Overlay.ts | 2 +- .../overlay/__tests__/Overlay.pairs.spec.ts | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index bba76f8ea1..fecf05aa71 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -203,7 +203,7 @@ export class Overlay implements Printable, Stateful { return () => (closed ? u : (closed = true, [u, u])); } let p1: OverlayPoint | undefined; - let p2: OverlayPoint | undefined; + let p2: OverlayPoint | undefined = after; const iterator = this.points0(after); return () => { const next = iterator(); 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 index eb4d2aaa80..f93622a1da 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts @@ -5,7 +5,7 @@ import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; import {OverlayPoint} from '../OverlayPoint'; const runPairsTests = (setup: () => Kit) => { - describe('.pairs()', () => { + describe('.pairs() full range', () => { test('returns [undef, undef] single pair for an empty overlay', () => { const {peritext} = setup(); const overlay = peritext.overlay; @@ -133,6 +133,34 @@ const runPairsTests = (setup: () => Kit) => { 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', () => { From 5e7fa893b9981c1a74b95661294439320d15458c Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 May 2024 17:38:44 +0200 Subject: [PATCH 11/12] =?UTF-8?q?test(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=8D=20add=20Overlay.tuples()=20tests=20at=20offset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/__tests__/Overlay.tuples.spec.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) 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 index 832b182fb5..2edae7e1a5 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts @@ -1,11 +1,11 @@ import {next} from 'sonic-forest/lib/util'; -import {Kit, setupHelloWorldKit, setupHelloWorldWithFewEditsKit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +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()', () => { + describe('.tuples() full range', () => { test('returns [START, END] single tuple for an empty overlay', () => { const {peritext} = setup(); const overlay = peritext.overlay; @@ -130,6 +130,34 @@ const runPairsTests = (setup: () => Kit) => { 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', () => { From 91e6a92b6448ce609fff467dafbea5470e2ad182 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 May 2024 17:39:27 +0200 Subject: [PATCH 12/12] =?UTF-8?q?style(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=84=20run=20Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/__tests__/setup.ts | 12 ++++++++---- .../peritext/overlay/Overlay.ts | 8 ++++---- .../overlay/__tests__/Overlay.pairs.spec.ts | 16 ++++------------ .../overlay/__tests__/Overlay.points.spec.ts | 2 +- .../overlay/__tests__/Overlay.tuples.spec.ts | 16 ++++------------ .../peritext/overlay/types.ts | 2 +- 6 files changed, 22 insertions(+), 34 deletions(-) diff --git a/src/json-crdt-extensions/peritext/__tests__/setup.ts b/src/json-crdt-extensions/peritext/__tests__/setup.ts index fc1bb6c74f..267e8f91a2 100644 --- a/src/json-crdt-extensions/peritext/__tests__/setup.ts +++ b/src/json-crdt-extensions/peritext/__tests__/setup.ts @@ -6,11 +6,15 @@ import {ModelWithExt, ext} from '../../ModelWithExt'; export type Schema = ReturnType; export type Kit = ReturnType; -const schema = (text: string) => s.obj({ - text: ext.peritext.new(text), -}); +const schema = (text: string) => + s.obj({ + text: ext.peritext.new(text), + }); -export const setupKit = (initialText: string = '', edits: (model: Model>) => void = () => {}) => { +export const setupKit = ( + initialText: string = '', + edits: (model: Model>) => void = () => {}, +) => { const model = ModelWithExt.create(schema(initialText)); edits(model); const api = model.api; diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index fecf05aa71..016c6e7896 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -38,7 +38,7 @@ export class Overlay implements Printable, Stateful { constructor(protected readonly txt: Peritext) { const id = txt.str.id; - this.START = this.point(id, Anchor.After) + this.START = this.point(id, Anchor.After); this.END = this.point(id, Anchor.Before); } @@ -200,7 +200,7 @@ export class Overlay implements Printable, Stateful { if (isEmpty) { const u = undefined; let closed = false; - return () => (closed ? u : (closed = true, [u, u])); + return () => (closed ? u : ((closed = true), [u, u])); } let p1: OverlayPoint | undefined; let p2: OverlayPoint | undefined = after; @@ -222,7 +222,7 @@ export class Overlay implements Printable, Stateful { p2 = iterator(); } } - return (p1 || p2) ? [p1, p2] : undefined; + return p1 || p2 ? [p1, p2] : undefined; }; } @@ -230,7 +230,7 @@ export class Overlay implements Printable, Stateful { return new UndefEndIter(this.pairs0(after)); } - public tuples0(after: undefined | OverlayPoint): UndefIterator> { + public tuples0(after: undefined | OverlayPoint): UndefIterator> { const iterator = this.pairs0(after); return () => { const pair = iterator(); 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 index f93622a1da..5d5addca61 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts @@ -11,9 +11,7 @@ const runPairsTests = (setup: () => Kit) => { const overlay = peritext.overlay; overlay.refresh(); const pairs = [...overlay.pairs()]; - expect(pairs).toEqual([ - [undefined, undefined] - ]); + expect(pairs).toEqual([[undefined, undefined]]); }); test('when caret at abs start, returns one pair', () => { @@ -24,9 +22,7 @@ const runPairsTests = (setup: () => Kit) => { const pairs = [...overlay.pairs()]; const p1 = overlay.first()!; expect(peritext.editor.cursor.start.rightChar()?.view()).toBe('0'); - expect(pairs).toEqual([ - [p1, undefined] - ]); + expect(pairs).toEqual([[p1, undefined]]); }); test('when caret at abs end, returns one pair', () => { @@ -37,9 +33,7 @@ const runPairsTests = (setup: () => Kit) => { const pairs = [...overlay.pairs()]; const p1 = overlay.first()!; expect(peritext.editor.cursor.start.leftChar()?.view()).toBe('9'); - expect(pairs).toEqual([ - [undefined, p1] - ]); + expect(pairs).toEqual([[undefined, p1]]); }); test('for only caret in overlay, returns two edge pairs', () => { @@ -142,9 +136,7 @@ const runPairsTests = (setup: () => Kit) => { overlay.refresh(); const first = overlay.first()!; const pairs = [...overlay.pairs(first)]; - expect(pairs).toEqual([ - [first, undefined], - ]); + expect(pairs).toEqual([[first, undefined]]); }); test('in empty overlay, after selection start returns the selection and the edge', () => { 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 index 981b576552..d341897e5a 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts @@ -39,7 +39,7 @@ describe('.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(); 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 index 2edae7e1a5..6174e7a26a 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts @@ -11,9 +11,7 @@ const runPairsTests = (setup: () => Kit) => { const overlay = peritext.overlay; overlay.refresh(); const list = [...overlay.tuples()]; - expect(list).toEqual([ - [overlay.START, overlay.END], - ]); + expect(list).toEqual([[overlay.START, overlay.END]]); }); test('when caret at abs start, returns one [p, END] tuple', () => { @@ -24,9 +22,7 @@ const runPairsTests = (setup: () => Kit) => { 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], - ]); + expect(list).toEqual([[p1, overlay.END]]); }); test('when caret at abs end, returns one [START, p] tuple', () => { @@ -37,9 +33,7 @@ const runPairsTests = (setup: () => Kit) => { 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] - ]); + expect(list).toEqual([[overlay.START, p1]]); }); test('for only caret in overlay, returns two edge tuples', () => { @@ -139,9 +133,7 @@ const runPairsTests = (setup: () => Kit) => { overlay.refresh(); const first = overlay.first()!; const pairs = [...overlay.tuples(first)]; - expect(pairs).toEqual([ - [first, overlay.END], - ]); + expect(pairs).toEqual([[first, overlay.END]]); }); test('in empty overlay, after selection start returns the selection and the edge', () => { diff --git a/src/json-crdt-extensions/peritext/overlay/types.ts b/src/json-crdt-extensions/peritext/overlay/types.ts index 7d0bb6ca38..47696f1310 100644 --- a/src/json-crdt-extensions/peritext/overlay/types.ts +++ b/src/json-crdt-extensions/peritext/overlay/types.ts @@ -1,4 +1,4 @@ -import type {OverlayPoint} from "./OverlayPoint"; +import type {OverlayPoint} from './OverlayPoint'; export type BlockTag = [ /**