diff --git a/src/json-crdt-extensions/mval/__tests__/extension.spec.ts b/src/json-crdt-extensions/mval/__tests__/extension.spec.ts deleted file mode 100644 index 054f003820..0000000000 --- a/src/json-crdt-extensions/mval/__tests__/extension.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import {ValueMvExt} from '..'; -import {konst} from '../../../json-crdt-patch/builder/Konst'; -import {Model} from '../../../json-crdt/model'; - -test('can specify extension name', () => { - expect(ValueMvExt.name).toBe('mval'); -}); - -test('can create a new multi-value register', () => { - const model = Model.withLogicalClock(); - model.ext.register(ValueMvExt); - model.api.root({ - mv: ValueMvExt.new(), - }); - expect(model.view()).toEqual({ - mv: [], - }); -}); - -test('can provide initial value', () => { - const model = Model.withLogicalClock(); - model.ext.register(ValueMvExt); - model.api.root({ - mv: ValueMvExt.new({foo: 'bar'}), - }); - expect(model.view()).toEqual({ - mv: [{foo: 'bar'}], - }); -}); - -test('does not treat ArrNode as extension if header is too long', () => { - const model = Model.withLogicalClock(); - model.ext.register(ValueMvExt); - model.api.root({ - mv: ValueMvExt.new(), - }); - const buf = new Uint8Array(4); - buf.set(model.api.const(['mv', 0]).node.view() as Uint8Array, 0); - model.api.vec(['mv']).set([[0, buf]]); - expect(model.view()).toEqual({ - mv: [buf, []], - }); -}); - -test('does not treat ArrNode as extension if header sid is wrong', () => { - const model = Model.withLogicalClock(); - model.ext.register(ValueMvExt); - model.api.root({ - mv: ValueMvExt.new(), - }); - const buf = model.api.const(['mv', 0]).node.view() as Uint8Array; - buf[1] += 1; - expect(model.view()).toEqual({ - mv: [buf, []], - }); -}); - -test('does not treat ArrNode as extension if header time is wrong', () => { - const model = Model.withLogicalClock(); - model.ext.register(ValueMvExt); - model.api.root({ - mv: ValueMvExt.new(), - }); - const buf = model.api.const(['mv', 0]).node.view() as Uint8Array; - buf[2] += 1; - expect(model.view()).toEqual({ - mv: [buf, []], - }); -}); - -test('exposes API to edit extension data', () => { - const model = Model.withLogicalClock(); - model.ext.register(ValueMvExt); - model.api.root({ - mv: ValueMvExt.new(), - }); - const nodeApi = model.api.in('mv').asExt(ValueMvExt); - nodeApi.set(konst('lol')); - expect(model.view()).toEqual({ - mv: ['lol'], - }); -}); diff --git a/src/json-crdt/nodes/vec/VecNode.ts b/src/json-crdt/nodes/vec/VecNode.ts index a374081da4..1c79e0e34f 100644 --- a/src/json-crdt/nodes/vec/VecNode.ts +++ b/src/json-crdt/nodes/vec/VecNode.ts @@ -71,6 +71,8 @@ export class VecNode implements JsonNode< /** * @ignore + * @returns Returns the extension data node if this is an extension node, + * otherwise `undefined`. The node is cached after the first access. */ public ext(): JsonNode | undefined { if (this.__extNode) return this.__extNode; @@ -92,6 +94,7 @@ export class VecNode implements JsonNode< /** * @ignore + * @returns Returns extension ID if this is an extension node, otherwise -1. */ public getExtId(): number { if (this.elements.length !== 2) return -1; diff --git a/src/json-crdt/nodes/vec/__tests__/VecNode-extension.spec.ts b/src/json-crdt/nodes/vec/__tests__/VecNode-extension.spec.ts new file mode 100644 index 0000000000..ac2c032f63 --- /dev/null +++ b/src/json-crdt/nodes/vec/__tests__/VecNode-extension.spec.ts @@ -0,0 +1,119 @@ +import {ITimestampStruct, delayed, s} from '../../../../json-crdt-patch'; +import {printTree} from '../../../../util/print/printTree'; +import {Printable} from '../../../../util/print/types'; +import {ext} from '../../../extensions'; +import {ExtensionApi, ExtensionDefinition, ExtensionJsonNode} from '../../../extensions/types'; +import {Model, NodeApi} from '../../../model'; +import {StrNode} from '../../nodes'; +import {JsonNode} from '../../types'; + +test('treats a simple "vec" node as a vector', () => { + const model = Model.withLogicalClock(); + model.api.root({ + vec: s.vec(), + }); + expect(model.view()).toEqual({ + vec: [], + }); + expect(model.api.vec(['vec']).node.isExt()).toBe(false); +}); + +test('does not treat "vec" node as extension, if extension is not registered in registry', () => { + const model = Model.withLogicalClock(); + model.api.root({ + vec: s.vec(), + }); + const vec = model.api.vec(['vec']); + const buf = new Uint8Array(3); + buf[0] = 42; + buf[1] = vec.node.id.sid % 256; + buf[2] = vec.node.id.time % 256; + vec.push(buf, 123); + expect(vec.node.isExt()).toBe(false); +}); + +describe('sample extension', () => { + const DoubleConcatExt: ExtensionDefinition = { + id: 123, + name: 'double-concat', + new: (value: string = '') => + ext( + 123, + delayed((builder) => builder.json(value)), + ), + Node: class CntNode implements ExtensionJsonNode, Printable { + public readonly id: ITimestampStruct; + + constructor(public readonly data: StrNode) { + this.id = data.id; + } + + public name(): string { + return 'double-concat'; + } + + public view(): string { + const str = String(this.data.view()); + return str + str; + } + + public children(callback: (node: JsonNode) => void): void {} + + public child?(): JsonNode | undefined { + return this.data; + } + + public container(): JsonNode | undefined { + return this.data.container(); + } + + public api: undefined | unknown = undefined; + + public toString(tab?: string): string { + return `${this.name()} (${this.view()})` + printTree(tab, [(tab) => this.data.toString(tab)]); + } + }, + Api: class CntApi extends NodeApi implements ExtensionApi { + public ins(index: number, text: string): this { + const {api, node} = this; + const dataApi = api.wrap(node.data as StrNode); + dataApi.ins(index, text); + return this; + } + }, + }; + + test('can run an extension', () => { + const model = Model.withLogicalClock(); + model.ext.register(DoubleConcatExt); + model.api.root({ + str: DoubleConcatExt.new('foo'), + }); + expect(model.view()).toEqual({ + str: 'foofoo', + }); + }); + + test('can use extension node', () => { + const model = Model.withLogicalClock(); + model.ext.register(DoubleConcatExt); + model.api.root({ + str: DoubleConcatExt.new('abc'), + }); + const extNode = model.api.in(['str']).asExt(DoubleConcatExt).node; + expect(extNode.view()).toBe('abcabc'); + }); + + test('can use extension API node', () => { + const model = Model.withLogicalClock(); + model.ext.register(DoubleConcatExt); + model.api.root({ + str: DoubleConcatExt.new('abc'), + }); + const extStr = model.api.in(['str']).asExt(DoubleConcatExt); + extStr.ins(1, '.'); + expect(model.view()).toEqual({ + str: 'a.bca.bc', + }); + }); +}); diff --git a/src/json-crdt/nodes/vec/__tests__/extension.spec.ts b/src/json-crdt/nodes/vec/__tests__/extension.spec.ts new file mode 100644 index 0000000000..8e6ae482cc --- /dev/null +++ b/src/json-crdt/nodes/vec/__tests__/extension.spec.ts @@ -0,0 +1,98 @@ +import {ValueMvExt} from '../../../../json-crdt-extensions/mval'; +import {konst} from '../../../../json-crdt-patch/builder/Konst'; +import {Model} from '../../../../json-crdt/model'; + +test('can specify extension name', () => { + expect(ValueMvExt.name).toBe('mval'); +}); + +test('can create a new multi-value register', () => { + const model = Model.withLogicalClock(); + model.ext.register(ValueMvExt); + model.api.root({ + mv: ValueMvExt.new(), + }); + expect(model.view()).toEqual({ + mv: [], + }); +}); + +test('can provide initial value', () => { + const model = Model.withLogicalClock(); + model.ext.register(ValueMvExt); + model.api.root({ + mv: ValueMvExt.new({foo: 'bar'}), + }); + expect(model.view()).toEqual({ + mv: [{foo: 'bar'}], + }); +}); + +test('can read view from node or API node', () => { + const model = Model.withLogicalClock(); + model.ext.register(ValueMvExt); + model.api.root({ + mv: ValueMvExt.new('foo'), + }); + const api = model.api.in('mv').asExt(ValueMvExt); + expect(api.view()).toEqual(['foo']); + expect(api.node.view()).toEqual(['foo']); +}); + +test('exposes API to edit extension data', () => { + const model = Model.withLogicalClock(); + model.ext.register(ValueMvExt); + model.api.root({ + mv: ValueMvExt.new(), + }); + const nodeApi = model.api.in('mv').asExt(ValueMvExt); + nodeApi.set(konst('lol')); + expect(model.view()).toEqual({ + mv: ['lol'], + }); +}); + +describe('extension validity checks', () => { + test('does not treat ArrNode as extension if header is too long', () => { + const model = Model.withLogicalClock(); + model.ext.register(ValueMvExt); + model.api.root({ + mv: ValueMvExt.new(), + }); + const buf = new Uint8Array(4); + buf.set(model.api.const(['mv', 0]).node.view() as Uint8Array, 0); + model.api.vec(['mv']).set([[0, buf]]); + expect(model.view()).toEqual({ + mv: [buf, []], + }); + expect(model.api.vec(['mv']).node.isExt()).toBe(false); + }); + + test('does not treat ArrNode as extension if header sid is wrong', () => { + const model = Model.withLogicalClock(); + model.ext.register(ValueMvExt); + model.api.root({ + mv: ValueMvExt.new(), + }); + const buf = model.api.const(['mv', 0]).node.view() as Uint8Array; + buf[1] += 1; + expect(model.view()).toEqual({ + mv: [buf, []], + }); + expect(model.api.vec(['mv']).node.isExt()).toBe(false); + }); + + test('does not treat ArrNode as extension if header time is wrong', () => { + const model = Model.withLogicalClock(); + model.ext.register(ValueMvExt); + model.api.root({ + mv: ValueMvExt.new(), + }); + const buf = model.api.const(['mv', 0]).node.view() as Uint8Array; + buf[2] += 1; + expect(model.view()).toEqual({ + mv: [buf, []], + }); + expect(model.api.vec(['mv']).node.isExt()).toBe(false); + }); +});