diff --git a/src/document/document.ts b/src/document/document.ts index 8e85accf1..3000afc62 100644 --- a/src/document/document.ts +++ b/src/document/document.ts @@ -140,7 +140,7 @@ export type ChangeInfo = { message: string; updates: Array; }; -type UpdateDelta = +export type UpdateDelta = | ModifiedWithPath | ModifiedWithPath | ModifiedWithPath diff --git a/test/helper/helper.ts b/test/helper/helper.ts index d14f11a2b..cf36b1d33 100644 --- a/test/helper/helper.ts +++ b/test/helper/helper.ts @@ -19,7 +19,22 @@ import { EventEmitter } from 'events'; import { NextFn } from '@yorkie-js-sdk/src/util/observable'; import { ClientEvent } from '@yorkie-js-sdk/src/client/client'; -import { DocEvent } from '@yorkie-js-sdk/src/document/document'; +import { + DocEvent, + UpdateDelta, + ModifiedWithPath, +} from '@yorkie-js-sdk/src/document/document'; +import { + AddOpModified, + IncreaseOpModified, + RemoveOpModified, + SetOpModified, + MoveOpModified, + EditOpModified, + StyleOpModified, + SelectOpModified, +} from '@yorkie-js-sdk/src/document/operation/operation'; + import { TextChange, TextChangeType, @@ -33,6 +48,59 @@ export function range(from: number, to: number): Array { return list; } +export type TestDocEvent = { + type: string; + path: string; +} & { [key: string]: any }; +export function getUpdateDeltaForTest( + updateDelta: UpdateDelta, +): TestDocEvent | null { + switch (updateDelta.type) { + case 'set': { + const { type, path, key } = + updateDelta as ModifiedWithPath; + return { type, path, key }; + } + case 'add': { + const { type, path, index } = + updateDelta as ModifiedWithPath; + return { type, path, index }; + } + case 'move': { + const { type, path, index, previousIndex } = + updateDelta as ModifiedWithPath; + return { type, path, index, previousIndex }; + } + case 'remove': { + const { type, path, key, index } = + updateDelta as ModifiedWithPath; + return key !== undefined ? { type, path, key } : { type, path, index }; + } + case 'increase': { + const { type, path, value } = + updateDelta as ModifiedWithPath; + return { type, path, value }; + } + case 'edit': { + const { type, path, actor, from, to, value } = + updateDelta as ModifiedWithPath; + return { type, path, actor, from, to, value }; + } + case 'style': { + const { type, path, actor, from, to, value } = + updateDelta as ModifiedWithPath; + return { type, path, actor, from, to, value }; + } + case 'select': { + const { type, path, actor, from, to } = + updateDelta as ModifiedWithPath; + return { type, path, actor, from, to }; + } + default: + return null; + } +} + export type Indexable = Record; export function waitFor( diff --git a/test/integration/object_test.ts b/test/integration/object_test.ts index c7f7d704c..74ddf1fff 100644 --- a/test/integration/object_test.ts +++ b/test/integration/object_test.ts @@ -1,6 +1,10 @@ import { assert } from 'chai'; +import { + getUpdateDeltaForTest, + TestDocEvent, +} from '@yorkie-js-sdk/test/helper/helper'; import { JSONObject } from '@yorkie-js-sdk/src/yorkie'; -import { DocEventType, Document } from '@yorkie-js-sdk/src/document/document'; +import { Document, DocEventType } from '@yorkie-js-sdk/src/document/document'; import { withTwoClientsAndDocuments } from '@yorkie-js-sdk/test/integration/integration_helper'; describe('Object', function () { @@ -180,20 +184,31 @@ describe('Object', function () { }; k2: number; }>(async (c1, d1, c2, d2) => { + const expectedEvents: Array = []; + const expectedEvents2: Array = []; // TODO(hackerwins): consider replacing the below code with `createEmitterAndSpy`. d2.subscribe((event) => { - if (event.type === DocEventType.RemoteChange) { - assert.deepEqual(event.value[0].paths.sort(), ['$.k1', '$.k2']); - } + if (event.type !== DocEventType.RemoteChange) return; + + event.value.forEach(({ updates }) => { + updates.forEach((updateDelta, i) => { + assert.deepEqual( + getUpdateDeltaForTest(updateDelta)!, + expectedEvents[i], + ); + }); + }); }); d1.subscribe((event) => { - if (event.type === DocEventType.RemoteChange) { - assert.deepEqual(event.value[0].paths, [ - '$.k1.selected', - '$.k1.layers.0.a', - '$.k2', - ]); - } + if (event.type !== DocEventType.RemoteChange) return; + event.value.forEach(({ updates }) => { + updates.forEach((updateDelta, i) => { + assert.deepEqual( + getUpdateDeltaForTest(updateDelta)!, + expectedEvents2[i], + ); + }); + }); }); d1.update((root) => { root['k1'] = { @@ -205,6 +220,17 @@ describe('Object', function () { root['k1']['selected'] = true; root['k1']['test'] = 'change'; root['k2'] = 5; + expectedEvents.push({ type: 'set', path: '$', key: 'k1' }); + expectedEvents.push({ type: 'set', path: '$.k1', key: 'id' }); + expectedEvents.push({ type: 'set', path: '$.k1', key: 'selected' }); + expectedEvents.push({ type: 'set', path: '$.k1', key: 'test' }); + expectedEvents.push({ type: 'set', path: '$.k1', key: 'layers' }); + expectedEvents.push({ type: 'add', path: '$.k1.layers', index: 0 }); + expectedEvents.push({ type: 'set', path: '$.k1.layers.0', key: 'a' }); + expectedEvents.push({ type: 'set', path: '$.k1.layers.0', key: 'b' }); + expectedEvents.push({ type: 'set', path: '$.k1', key: 'selected' }); + expectedEvents.push({ type: 'set', path: '$.k1', key: 'test' }); + expectedEvents.push({ type: 'set', path: '$', key: 'k2' }); }); await c1.sync(); @@ -214,6 +240,9 @@ describe('Object', function () { root['k1']['selected'] = false; root['k1']['layers'][0]['a'] = 'hi2'; root['k2']++; + expectedEvents2.push({ type: 'set', path: '$.k1', key: 'selected' }); + expectedEvents2.push({ type: 'set', path: '$.k1.layers.0', key: 'a' }); + expectedEvents2.push({ type: 'set', path: '$', key: 'k2' }); }); await c1.sync(); await c2.sync(); diff --git a/test/unit/document/document_test.ts b/test/unit/document/document_test.ts index 7af6bea04..ea2e9e2da 100644 --- a/test/unit/document/document_test.ts +++ b/test/unit/document/document_test.ts @@ -15,6 +15,11 @@ */ import { assert } from 'chai'; +import { + getUpdateDeltaForTest, + TestDocEvent, +} from '@yorkie-js-sdk/test/helper/helper'; + import { MaxTimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; import { Document, DocEventType } from '@yorkie-js-sdk/src/document/document'; import { JSONArray, Text, Counter } from '@yorkie-js-sdk/src/yorkie'; @@ -737,7 +742,7 @@ describe('Document', function () { ); }); - it('should allow mutation of objects returned from readonly list methods', () => { + it('should allow mutation of objects returned from readqonly list methods', () => { const doc = Document.create('test-doc'); doc.update((root) => { root.objects = [{ id: 'first' }, { id: 'second' }]; @@ -932,85 +937,105 @@ describe('Document', function () { assert.equal(4, doc.getRoot().data.length); }); - it('change paths test for object', async function () { + it('detect events for object', async function () { const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; - + const expectedEvents: Array = []; doc.subscribe((event) => { assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); - } + if (event.type !== DocEventType.LocalChange) return; + + event.value.forEach(({ updates }) => { + updates.forEach((updateDelta, i) => { + assert.deepEqual( + getUpdateDeltaForTest(updateDelta)!, + expectedEvents[i], + ); + }); + }); }); - // NOTE(hackerwins): We skip nested paths after introducing the trie. doc.update((root) => { root[''] = {}; - paths.push('$.'); - + expectedEvents.push({ type: 'set', path: '$', key: '' }); root.obj = {}; - paths.push('$.obj'); + expectedEvents.push({ type: 'set', path: '$', key: 'obj' }); root.obj.a = 1; - // paths.push('$.obj.a'); + expectedEvents.push({ type: 'set', path: '$.obj', key: 'a' }); delete root.obj.a; - // paths.push('$.obj'); + expectedEvents.push({ type: 'remove', path: '$.obj', key: 'a' }); root.obj['$.hello'] = 1; - // paths.push('$.obj.\\$\\.hello'); + expectedEvents.push({ type: 'set', path: '$.obj', key: '$.hello' }); delete root.obj['$.hello']; - // paths.push('$.obj'); + expectedEvents.push({ type: 'remove', path: '$.obj', key: '$.hello' }); delete root.obj; - // paths.push('$'); + expectedEvents.push({ type: 'remove', path: '$', key: 'obj' }); }); }); - it('change paths test for array', async function () { + it('detect events for array', async function () { const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; - + const expectedEvents: Array = []; doc.subscribe((event) => { assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); - } + if (event.type !== DocEventType.LocalChange) return; + + event.value.forEach(({ updates }) => { + updates.forEach((updateDelta, i) => { + assert.deepEqual( + getUpdateDeltaForTest(updateDelta)!, + expectedEvents[i], + ); + }); + }); }); - // NOTE(hackerwins): We skip nested paths after introducing the trie. doc.update((root) => { root.arr = []; - paths.push('$.arr'); + expectedEvents.push({ type: 'set', path: '$', key: 'arr' }); root.arr.push(0); - // paths.push('$.arr.0'); + expectedEvents.push({ type: 'add', path: '$.arr', index: 0 }); root.arr.push(1); - // paths.push('$.arr.1'); + expectedEvents.push({ type: 'add', path: '$.arr', index: 1 }); delete root.arr[1]; - // paths.push('$.arr'); + expectedEvents.push({ type: 'remove', path: '$.arr', index: 1 }); root['$$...hello'] = []; - paths.push('$.\\$\\$\\.\\.\\.hello'); + expectedEvents.push({ type: 'set', path: '$', key: '$$...hello' }); root['$$...hello'].push(0); - // paths.push('$.\\$\\$\\.\\.\\.hello.0'); + expectedEvents.push({ type: 'add', path: '$.$$...hello', index: 0 }); }); }); - it('change paths test for counter', async function () { + it('detect events for counter', async function () { type TestDoc = { cnt: Counter }; const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; + const expectedEvents: Array = []; doc.subscribe((event) => { assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); - } + if (event.type !== DocEventType.LocalChange) return; + + event.value.forEach(({ updates }) => { + updates.forEach((updateDelta, i) => { + assert.deepEqual( + getUpdateDeltaForTest(updateDelta)!, + expectedEvents[i], + ); + }); + }); }); doc.update((root) => { root.cnt = new Counter(CounterType.IntegerCnt, 0); - paths.push('$.cnt'); + expectedEvents.push({ type: 'set', path: '$', key: 'cnt' }); root.cnt.increase(1); - paths.push('$.cnt'); + expectedEvents.push({ type: 'increase', path: '$.cnt', value: 1 }); + root.cnt.increase(10); + expectedEvents.push({ type: 'increase', path: '$.cnt', value: 10 }); + root.cnt.increase(-3); + expectedEvents.push({ type: 'increase', path: '$.cnt', value: -3 }); }); }); @@ -1028,50 +1053,91 @@ describe('Document', function () { }); }); - it('change paths test for text', async function () { + it('detect events for text', async function () { type TestDoc = { text: Text }; const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; + const expectedEvents: Array = []; doc.subscribe((event) => { assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); - } + if (event.type !== DocEventType.LocalChange) return; + + event.value.forEach(({ updates }) => { + updates.forEach((updateDelta, i) => { + assert.deepEqual( + getUpdateDeltaForTest(updateDelta)!, + expectedEvents[i], + ); + }); + }); }); doc.update((root) => { root.text = new Text(); - paths.push('$.text'); + expectedEvents.push({ type: 'set', path: '$', key: 'text' }); root.text.edit(0, 0, 'hello world'); - paths.push('$.text'); + expectedEvents.push({ + type: 'edit', + path: '$.text', + actor: '000000000000000000000000', + from: 0, + to: 0, + value: { attributes: {}, content: 'hello world' }, + }); root.text.select(0, 2); - paths.push('$.text'); + expectedEvents.push({ + type: 'select', + path: '$.text', + actor: '000000000000000000000000', + from: 0, + to: 2, + }); }); }); - it('change paths test for text with attributes', async function () { + it('detect events for text with attributes', async function () { type TestDoc = { textWithAttr: Text }; const doc = Document.create('test-doc'); await new Promise((resolve) => setTimeout(resolve, 0)); - const paths: Array = []; + const expectedEvents: Array = []; doc.subscribe((event) => { assert.equal(event.type, DocEventType.LocalChange); - if (event.type === DocEventType.LocalChange) { - assert.deepEqual(event.value[0].paths, paths); - } + if (event.type !== DocEventType.LocalChange) return; + + event.value.forEach(({ updates }) => { + updates.forEach((updateDelta, i) => { + assert.deepEqual( + getUpdateDeltaForTest(updateDelta)!, + expectedEvents[i], + ); + }); + }); }); doc.update((root) => { root.textWithAttr = new Text(); - paths.push('$.textWithAttr'); + expectedEvents.push({ type: 'set', path: '$', key: 'textWithAttr' }); root.textWithAttr.edit(0, 0, 'hello world'); - paths.push('$.textWithAttr'); + expectedEvents.push({ + type: 'edit', + path: '$.textWithAttr', + actor: '000000000000000000000000', + from: 0, + to: 0, + value: { attributes: {}, content: 'hello world' }, + }); root.textWithAttr.setStyle(0, 1, { bold: 'true' }); - paths.push('$.textWithAttr'); + expectedEvents.push({ + type: 'style', + path: '$.textWithAttr', + actor: '000000000000000000000000', + from: 0, + to: 1, + value: { attributes: { bold: 'true' } }, + }); }); });