Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Peritext API improvements and tests #655

Merged
merged 10 commits into from
Jun 29, 2024
Merged
51 changes: 50 additions & 1 deletion src/json-crdt-extensions/peritext/Peritext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class Peritext<T = string> implements Printable {
// TODO: Rename `str` to `rga`.
public readonly str: AbstractRga<T>,
slices: ArrNode,
// TODO: Add test that verifies that SIDs are different across all three models.
extraSlicesModel: SlicesModel = Model.create(EXTRA_SLICES_SCHEMA, model.clock.sid - 1),
localSlicesModel: SlicesModel = Model.create(EXTRA_SLICES_SCHEMA, SESSION.LOCAL),
) {
Expand Down Expand Up @@ -212,7 +213,7 @@ export class Peritext<T = string> implements Printable {
return this.range(start, end);
}

// --------------------------------------------------------------------- text
// ---------------------------------------------------------- text (& slices)

/**
* Insert plain text at a view position in the text.
Expand Down Expand Up @@ -241,6 +242,54 @@ export class Peritext<T = string> implements Printable {
return textId;
}

public delAt(pos: number, len: number): void {
const range = this.rangeAt(pos, len);
this.del(range);
}

public del(range: Range<T>): void {
this.delSlices(range);
this.delStr(range);
}

public delStr(range: Range<T>): boolean {
const isCaret = range.isCollapsed();
if (isCaret) return false;
const {start, end} = range;
const delStartId = start.isAbsStart()
? this.point().refStart().id
: start.anchor === Anchor.Before
? start.id
: start.nextId();
const delEndId = end.isAbsEnd() ? this.point().refEnd().id : end.anchor === Anchor.After ? end.id : end.prevId();
if (!delStartId || !delEndId) throw new Error('INVALID_RANGE');
const rga = this.str;
const spans = rga.findInterval2(delStartId, delEndId);
const api = this.model.api;
api.builder.del(rga.id, spans);
api.apply();
return true;
}

public delSlices(range: Range<T>): boolean {
// TODO: PERF: do we need this refresh?
this.overlay.refresh();
range = range.range();
range.expand();
const slices = this.overlay.findContained(range);
let deleted = false;
if (!slices.size) return deleted;
if (this.savedSlices.delSlices(slices)) deleted = true;
if (this.extraSlices.delSlices(slices)) deleted = true;
if (this.localSlices.delSlices(slices)) deleted = true;
return deleted;
}

// public delSlice(sliceId: ITimestampStruct): void {

// this.savedSlices.del(sliceId);
// }

// ------------------------------------------------------------------ markers

/** @deprecated Use the method in `Editor` and `Cursor` instead. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,3 @@ test('cursor can move across block boundary forwards', () => {
[SliceTypes.Cursor]: [[[CursorAnchor.Start, void 0]], InlineAttrPos.Collapsed],
});
});

test.todo('moving past text end keeps cursor at text end');
101 changes: 101 additions & 0 deletions src/json-crdt-extensions/peritext/__tests__/Peritext.deletions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
setupAlphabetChunkSplitKit,
setupAlphabetKit,
Kit,
setupAlphabetWithDeletesKit,
setupAlphabetWithTwoChunksKit,
} from './setup';

const run = (setup: () => Kit) => {
describe('.delAt()', () => {
test('can delete text', () => {
const {peritext} = setup();
peritext.delAt(0, 1);
peritext.delAt(0, 2);
peritext.delAt(1, 2);
peritext.delAt(3, 15);
peritext.delAt(4, 2);
expect(peritext.str.view()).toBe('dghx');
});

test('deletes slice if it is contained in deletion range', () => {
const {peritext, editor} = setup();
editor.cursor.setAt(2, 2);
editor.saved.insOverwrite('underline');
editor.cursor.setAt(0);
peritext.refresh();
expect(editor.saved.slices.size()).toBe(1);
peritext.delAt(1, 3);
peritext.refresh();
expect(editor.saved.slices.size()).toBe(0);
});

test('does not delete slice if it is only partially contained', () => {
const {peritext, editor} = setup();
editor.cursor.setAt(2, 10);
editor.extra.insOverwrite('underline');
editor.cursor.setAt(0);
peritext.refresh();
expect(peritext.extraSlices.size()).toBe(1);
peritext.delAt(1, 10);
peritext.refresh();
expect(peritext.extraSlices.size()).toBe(1);
peritext.delAt(1, 10);
peritext.refresh();
expect(peritext.extraSlices.size()).toBe(0);
});
});

describe('.delSlices()', () => {
test('does not delete already deleted slice', () => {
const {peritext, editor, model} = setup();
editor.cursor.setAt(15, 2);
editor.saved.insOverwrite('bold');
editor.cursor.setAt(2, 2);
editor.saved.insOverwrite('underline');
editor.cursor.setAt(0);
peritext.refresh();
const tick1 = model.tick;
expect(editor.saved.slices.size()).toBe(2);
const range = peritext.rangeAt(1, 5);
const deleted = peritext.delSlices(range);
const tick2 = model.tick;
expect(deleted).toBe(true);
expect(tick2).not.toBe(tick1);
peritext.refresh();
const deleted2 = peritext.delSlices(range);
const tick3 = model.tick;
expect(editor.saved.slices.size()).toBe(1);
expect(deleted2).toBe(false);
expect(tick3).toBe(tick2);
});

test('does not attempt to delete slices in the wrong models', () => {
const {peritext, editor} = setup();
editor.cursor.setAt(2, 2);
editor.extra.insStack('something...');
editor.cursor.setAt(0);
peritext.refresh();
const tick1 = peritext.savedSlices.set.doc.tick;
const tick2 = peritext.extraSlices.set.doc.tick;
const tick3 = peritext.localSlices.set.doc.tick;
expect(editor.extra.slices.size()).toBe(1);
const range = peritext.rangeAt(1, 5);
const deleted = peritext.delSlices(range);
peritext.refresh();
const tick4 = peritext.savedSlices.set.doc.tick;
const tick5 = peritext.extraSlices.set.doc.tick;
const tick6 = peritext.localSlices.set.doc.tick;
expect(deleted).toBe(true);
expect(editor.extra.slices.size()).toBe(0);
expect(tick1 === tick4).toBe(true);
expect(tick2 < tick5).toBe(true);
expect(tick3 === tick6).toBe(true);
});
});
};

describe('basic alphabet', () => run(setupAlphabetKit));
describe('alphabet with chunk splits', () => run(setupAlphabetChunkSplitKit));
describe('alphabet with deletes', () => run(setupAlphabetWithDeletesKit));
describe('alphabet with two chunks', () => run(setupAlphabetWithTwoChunksKit));
116 changes: 116 additions & 0 deletions src/json-crdt-extensions/peritext/__tests__/Peritext.hashes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {Model} from '../../../json-crdt/model';
import {Peritext} from '../Peritext';
import {Editor} from '../editor/Editor';
import {render} from './render';

const setup = (insertNumbers = (editor: Editor) => editor.insert('Hello world!'), sid?: number) => {
const model = Model.withLogicalClock(sid);
model.api.root({
text: '',
slices: [],
});
const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node);
const editor = peritext.editor;
insertNumbers(editor);
const view = () => {
peritext.refresh();
return render(peritext.blocks.root, '', true);
};
// console.log(peritext.str + '');
return {model, peritext, editor, view};
};

test('updates block hash on text input', () => {
const {editor, peritext} = setup();
// const {editor, peritext} = setup(undefined, 6015966450700167);
let prevHash = 0;
let hash = 0;
editor.cursor.setAt(2);
editor.insert('1');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.cursor.setAt(1);
editor.insert('2');
editor.insert('2');
editor.insert('2');
editor.insert('2');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.cursor.setAt(2);
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
editor.insert('3');
peritext.refresh();
hash = peritext.blocks.root.children[0].hash;
expect(hash).not.toBe(prevHash);
prevHash = hash;
});

test('updates block hash when moving across deleted char', () => {
const {editor, peritext} = setup();
editor.cursor.setAt(5);
editor.insert('1');
peritext.refresh();
const hash1 = peritext.blocks.root.children[0].hash;
editor.cursor.move(-1);
peritext.refresh();
const hash2 = peritext.blocks.root.children[0].hash;
expect(hash1).not.toBe(hash2);
});

test('updates block after moving over new block boundary', () => {
const {editor, peritext} = setup();
editor.cursor.setAt(6);
editor.saved.insMarker(['p'], '\n');
peritext.refresh();
const hash11 = peritext.blocks.root.children[0].hash;
const hash12 = peritext.blocks.root.children[1].hash;
editor.cursor.move(1);
peritext.refresh();
const hash21 = peritext.blocks.root.children[0].hash;
const hash22 = peritext.blocks.root.children[1].hash;
expect(hash11).not.toBe(hash21);
expect(hash12).not.toBe(hash22);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {setupKit} from './setup';

describe('.insAt()', () => {
test('can insert text', () => {
const {peritext} = setupKit();
peritext.insAt(0, 'ac');
peritext.insAt(1, 'b');
peritext.refresh();
expect(peritext.str.view()).toBe('abc');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {setupKit} from './setup';

test('can move back across single char block marker', () => {
const {editor, peritext} = setupKit();
editor.insert('ab');
editor.cursor.setAt(1);
editor.saved.insMarker(['p'], '\n');
peritext.refresh();
expect(peritext.blocks.root.children.length).toBe(2);
editor.cursor.setAt(2);
peritext.refresh();
expect(peritext.blocks.root.children.length).toBe(2);
editor.cursor.move(-1);
peritext.refresh();
expect(peritext.blocks.root.children.length).toBe(2);
});
Loading
Loading