snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
This commit is contained in:
236
tests/IdMap.tests.js
Normal file
236
tests/IdMap.tests.js
Normal file
@@ -0,0 +1,236 @@
|
||||
import * as t from 'lib0/testing'
|
||||
import * as ids from '../src/utils/ids.js'
|
||||
import * as prng from 'lib0/prng'
|
||||
import * as math from 'lib0/math'
|
||||
import { compareIdmaps as compareIdMaps, createIdMap, ID, createRandomIdSet, createRandomIdMap, createContentAttribute } from './testHelper.js'
|
||||
import * as YY from '../src/index.js'
|
||||
import * as time from 'lib0/time'
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {Array<[number, number, number, Array<T>]>} ops
|
||||
*/
|
||||
const simpleConstructAttrs = ops => {
|
||||
const attrs = createIdMap()
|
||||
ops.forEach(op => {
|
||||
attrs.add(op[0], op[1], op[2], op[3].map(v => createContentAttribute('', v)))
|
||||
})
|
||||
return attrs
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testAmMerge = _tc => {
|
||||
const attrs = [42]
|
||||
t.group('filter out empty items (1))', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 0, attrs]]),
|
||||
simpleConstructAttrs([])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (2))', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 0, attrs], [0, 2, 0, attrs]]),
|
||||
simpleConstructAttrs([])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (3 - end))', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 1, attrs], [0, 2, 0, attrs]]),
|
||||
simpleConstructAttrs([[0, 1, 1, attrs]])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (4 - middle))', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 1, attrs], [0, 2, 0, attrs], [0, 3, 1, attrs]]),
|
||||
simpleConstructAttrs([[0, 1, 1, attrs], [0, 3, 1, attrs]])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (5 - beginning))', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 0, attrs], [0, 2, 1, attrs], [0, 3, 1, attrs]]),
|
||||
simpleConstructAttrs([[0, 2, 1, attrs], [0, 3, 1, attrs]])
|
||||
)
|
||||
})
|
||||
t.group('merge of overlapping id ranges', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 2, attrs], [0, 0, 2, attrs]]),
|
||||
simpleConstructAttrs([[0, 0, 3, attrs]])
|
||||
)
|
||||
})
|
||||
t.group('construct without hole', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 2, attrs], [0, 3, 1, attrs]]),
|
||||
simpleConstructAttrs([[0, 1, 3, attrs]])
|
||||
)
|
||||
})
|
||||
t.group('no merge of overlapping id ranges with different attributes', () => {
|
||||
compareIdMaps(
|
||||
simpleConstructAttrs([[0, 1, 2, [1]], [0, 0, 2, [2]]]),
|
||||
simpleConstructAttrs([[0, 0, 1, [2]], [0, 1, 1, [1, 2]], [0, 2, 1, [1]]])
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatMergingMultipleIdMaps = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 5
|
||||
/**
|
||||
* @type {Array<IdMap<number>>}
|
||||
*/
|
||||
const sets = []
|
||||
for (let i = 0; i < 3; i++) {
|
||||
sets.push(createRandomIdMap(tc.prng, clients, clockRange, [1, 2, 3]))
|
||||
}
|
||||
const merged = ids.mergeIdMaps(sets)
|
||||
const mergedReverse = ids.mergeIdMaps(sets.reverse())
|
||||
compareIdMaps(merged, mergedReverse)
|
||||
const composed = ids.createIdMap()
|
||||
for (let iclient = 0; iclient < clients; iclient++) {
|
||||
for (let iclock = 0; iclock < clockRange + 42; iclock++) {
|
||||
const mergedHas = merged.hasId(new ID(iclient, iclock))
|
||||
const oneHas = sets.some(ids => ids.hasId(new ID(iclient, iclock)))
|
||||
t.assert(mergedHas === oneHas)
|
||||
const mergedAttrs = merged.sliceId(new ID(iclient, iclock), 1)
|
||||
mergedAttrs.forEach(a => {
|
||||
if (a.attrs != null) {
|
||||
composed.add(iclient, a.clock, a.len, a.attrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
compareIdMaps(merged, composed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomDiffing = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
const attrs = [1, 2, 3]
|
||||
const idset1 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||
const idset2 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||
const merged = ids.mergeIdMaps([idset1, idset2])
|
||||
const e1 = ids.diffIdMap(idset1, idset2)
|
||||
const e2 = ids.diffIdMap(merged, idset2)
|
||||
compareIdMaps(e1, e2)
|
||||
const copy = YY.decodeIdMap(YY.encodeIdMap(e1))
|
||||
compareIdMaps(e1, copy)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomDiffing2 = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
const attrs = [1, 2, 3]
|
||||
const idmap1 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||
const idmap2 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
|
||||
const idsExclude = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const merged = ids.mergeIdMaps([idmap1, idmap2])
|
||||
const mergedExcluded = ids.diffIdMap(merged, idsExclude)
|
||||
const e1 = ids.diffIdMap(idmap1, idsExclude)
|
||||
const e2 = ids.diffIdMap(idmap2, idsExclude)
|
||||
const excludedMerged = ids.mergeIdMaps([e1, e2])
|
||||
compareIdMaps(mergedExcluded, excludedMerged)
|
||||
const copy = YY.decodeIdMap(YY.encodeIdMap(mergedExcluded))
|
||||
compareIdMaps(mergedExcluded, copy)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomDeletes = tc => {
|
||||
const clients = 1
|
||||
const clockRange = 100
|
||||
const idset = createRandomIdMap(tc.prng, clients, clockRange, [])
|
||||
const client = Array.from(idset.clients.keys())[0]
|
||||
const clock = prng.int31(tc.prng, 0, clockRange)
|
||||
const len = prng.int31(tc.prng, 0, math.round((clockRange - clock) * 1.2)) // allow exceeding range to cover more edge cases
|
||||
const idsetOfDeletes = ids.createIdMap()
|
||||
idsetOfDeletes.add(client, clock, len, [])
|
||||
const diffed = ids.diffIdMap(idset, idsetOfDeletes)
|
||||
idset.delete(client, clock, len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
t.assert(!idset.has(client, clock + i))
|
||||
}
|
||||
compareIdMaps(idset, diffed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomIntersects = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
const ids1 = createRandomIdMap(tc.prng, clients, clockRange, [1])
|
||||
const ids2 = createRandomIdMap(tc.prng, clients, clockRange, ['two'])
|
||||
const intersected = ids.intersectMaps(ids1, ids2)
|
||||
for (let client = 0; client < clients; client++) {
|
||||
for (let clock = 0; clock < clockRange; clock++) {
|
||||
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
|
||||
/**
|
||||
* @type {Array<any>?}
|
||||
*/
|
||||
const slice1 = ids1.slice(client, clock, 1)[0].attrs
|
||||
/**
|
||||
* @type {Array<any>?}
|
||||
*/
|
||||
const slice2 = ids2.slice(client, clock, 1)[0].attrs
|
||||
/**
|
||||
* @type {Array<any>?}
|
||||
*/
|
||||
const expectedAttrs = (slice1 != null && slice2 != null) ? slice1.concat(slice2) : null
|
||||
const attrs = intersected.slice(client, clock, 1)[0].attrs
|
||||
t.assert(attrs?.length === expectedAttrs?.length)
|
||||
}
|
||||
}
|
||||
const diffed1 = ids.diffIdMap(ids1, ids2)
|
||||
const altDiffed1 = ids.diffIdMap(ids1, intersected)
|
||||
compareIdMaps(diffed1, altDiffed1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUserAttributionEncodingBenchmark = tc => {
|
||||
/**
|
||||
* @todo debug why this approach needs 30 bytes per item
|
||||
* @todo it should be possible to only use a single idmap and, in each attr entry, encode the diff
|
||||
* to the previous entries (e.g. remove a,b, insert c,d)
|
||||
*/
|
||||
const attributions = createIdMap()
|
||||
const currentTime = time.getUnixTime()
|
||||
const ydoc = new YY.Doc()
|
||||
ydoc.on('afterTransaction', tr => {
|
||||
ids.insertIntoIdMap(attributions, ids.createIdMapFromIdSet(tr.insertSet, [createContentAttribute('insert', 'userX'), createContentAttribute('insertAt', currentTime)]))
|
||||
ids.insertIntoIdMap(attributions, ids.createIdMapFromIdSet(tr.deleteSet, [createContentAttribute('delete', 'userX'), createContentAttribute('deleteAt', currentTime)]))
|
||||
})
|
||||
const ytext = ydoc.get()
|
||||
const N = 10000
|
||||
t.measureTime(`time to attribute ${N / 1000}k changes`, () => {
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (i % 2 > 0 && ytext.length > 0) {
|
||||
const pos = prng.int31(tc.prng, 0, ytext.length)
|
||||
const delLen = prng.int31(tc.prng, 0, ytext.length - pos)
|
||||
ytext.delete(pos, delLen)
|
||||
} else {
|
||||
ytext.insert(prng.int31(tc.prng, 0, ytext.length), prng.word(tc.prng))
|
||||
}
|
||||
}
|
||||
})
|
||||
t.measureTime('time to encode attributions map', () => {
|
||||
/**
|
||||
* @todo I can optimize size by encoding only the differences to the prev item.
|
||||
*/
|
||||
const encAttributions = ids.encodeIdMap(attributions)
|
||||
t.info('encoded size: ' + encAttributions.byteLength)
|
||||
t.info('size per change: ' + math.floor((encAttributions.byteLength / N) * 100) / 100 + ' bytes')
|
||||
})
|
||||
}
|
||||
397
tests/IdSet.tests.js
Normal file
397
tests/IdSet.tests.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import * as t from 'lib0/testing'
|
||||
import * as Y from '../src/index.js'
|
||||
import * as d from '../src/utils/ids.js'
|
||||
import * as math from 'lib0/math'
|
||||
import * as prng from 'lib0/prng'
|
||||
import { compareIdSets, createRandomIdSet, ID } from './testHelper.js'
|
||||
|
||||
/**
|
||||
* @param {Array<[number, number, number]>} ops
|
||||
*/
|
||||
const simpleConstructIdSet = ops => {
|
||||
const idset = d.createIdSet()
|
||||
ops.forEach(op => {
|
||||
idset.add(op[0], op[1], op[2])
|
||||
})
|
||||
return idset
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIdsetMerge = _tc => {
|
||||
t.group('filter out empty items (1))', () => {
|
||||
compareIdSets(
|
||||
simpleConstructIdSet([[0, 1, 0]]),
|
||||
simpleConstructIdSet([])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (2))', () => {
|
||||
compareIdSets(
|
||||
simpleConstructIdSet([[0, 1, 0], [0, 2, 0]]),
|
||||
simpleConstructIdSet([])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (3 - end))', () => {
|
||||
compareIdSets(
|
||||
simpleConstructIdSet([[0, 1, 1], [0, 2, 0]]),
|
||||
simpleConstructIdSet([[0, 1, 1]])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (4 - middle))', () => {
|
||||
compareIdSets(
|
||||
simpleConstructIdSet([[0, 1, 1], [0, 2, 0], [0, 3, 1]]),
|
||||
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]])
|
||||
)
|
||||
})
|
||||
t.group('filter out empty items (5 - beginning))', () => {
|
||||
compareIdSets(
|
||||
simpleConstructIdSet([[0, 1, 0], [0, 2, 1], [0, 3, 1]]),
|
||||
simpleConstructIdSet([[0, 2, 1], [0, 3, 1]])
|
||||
)
|
||||
})
|
||||
t.group('merge of overlapping id ranges', () => {
|
||||
compareIdSets(
|
||||
simpleConstructIdSet([[0, 1, 2], [0, 0, 2]]),
|
||||
simpleConstructIdSet([[0, 0, 3]])
|
||||
)
|
||||
})
|
||||
t.group('construct without hole', () => {
|
||||
compareIdSets(
|
||||
simpleConstructIdSet([[0, 1, 2], [0, 3, 1]]),
|
||||
simpleConstructIdSet([[0, 1, 3]])
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDiffing = _tc => {
|
||||
t.group('simple case (1))', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]]),
|
||||
simpleConstructIdSet([[0, 3, 1]])
|
||||
),
|
||||
simpleConstructIdSet([[0, 1, 1]])
|
||||
)
|
||||
})
|
||||
t.group('subset left', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 3]]),
|
||||
simpleConstructIdSet([[0, 1, 1]])
|
||||
),
|
||||
simpleConstructIdSet([[0, 2, 2]])
|
||||
)
|
||||
})
|
||||
t.group('subset right', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 3]]),
|
||||
simpleConstructIdSet([[0, 3, 1]])
|
||||
),
|
||||
simpleConstructIdSet([[0, 1, 2]])
|
||||
)
|
||||
})
|
||||
t.group('subset middle', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 3]]),
|
||||
simpleConstructIdSet([[0, 2, 1]])
|
||||
),
|
||||
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]])
|
||||
)
|
||||
})
|
||||
t.group('overlapping left', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 3]]),
|
||||
simpleConstructIdSet([[0, 0, 2]])
|
||||
),
|
||||
simpleConstructIdSet([[0, 2, 2]])
|
||||
)
|
||||
})
|
||||
t.group('overlapping right', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 3]]),
|
||||
simpleConstructIdSet([[0, 3, 5]])
|
||||
),
|
||||
simpleConstructIdSet([[0, 1, 2]])
|
||||
)
|
||||
})
|
||||
t.group('overlapping completely', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 3]]),
|
||||
simpleConstructIdSet([[0, 0, 5]])
|
||||
),
|
||||
simpleConstructIdSet([])
|
||||
)
|
||||
})
|
||||
t.group('overlapping into new range', () => {
|
||||
compareIdSets(
|
||||
d.diffIdSet(
|
||||
simpleConstructIdSet([[0, 1, 3], [0, 5, 2]]),
|
||||
simpleConstructIdSet([[0, 0, 6]])
|
||||
),
|
||||
simpleConstructIdSet([[0, 6, 1]])
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomDiffing = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
const ds1 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const ds2 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const merged = d.mergeIdSets([ds1, ds2])
|
||||
const e1 = d.diffIdSet(ds1, ds2)
|
||||
const e2 = d.diffIdSet(merged, ds2)
|
||||
compareIdSets(e1, e2)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomDeletes = tc => {
|
||||
const clients = 1
|
||||
const clockRange = 100
|
||||
const idset = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const client = Array.from(idset.clients.keys())[0]
|
||||
const clock = prng.int31(tc.prng, 0, clockRange)
|
||||
const len = prng.int31(tc.prng, 0, math.round((clockRange - clock) * 1.2)) // allow exceeding range to cover more edge cases
|
||||
const idsetOfDeletes = d.createIdSet()
|
||||
idsetOfDeletes.add(client, clock, len)
|
||||
const diffed = d.diffIdSet(idset, idsetOfDeletes)
|
||||
idset.delete(client, clock, len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
t.assert(!idset.has(client, clock + i))
|
||||
}
|
||||
compareIdSets(idset, diffed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatMergingMultipleIdsets = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
/**
|
||||
* @type {Array<d.IdSet>}
|
||||
*/
|
||||
const idss = []
|
||||
for (let i = 0; i < 3; i++) {
|
||||
idss.push(createRandomIdSet(tc.prng, clients, clockRange))
|
||||
}
|
||||
const merged = d.mergeIdSets(idss)
|
||||
const mergedReverse = d.mergeIdSets(idss.reverse())
|
||||
compareIdSets(merged, mergedReverse)
|
||||
const composed = d.createIdSet()
|
||||
for (let iclient = 0; iclient < clients; iclient++) {
|
||||
for (let iclock = 0; iclock < clockRange + 42; iclock++) {
|
||||
const mergedHas = merged.hasId(new ID(iclient, iclock))
|
||||
const oneHas = idss.some(ids => ids.hasId(new ID(iclient, iclock)))
|
||||
t.assert(mergedHas === oneHas)
|
||||
if (oneHas) {
|
||||
composed.add(iclient, iclock, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
compareIdSets(merged, composed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomDiffing2 = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
const ids1 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const ids2 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const idsExclude = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const merged = d.mergeIdSets([ids1, ids2])
|
||||
const mergedExcluded = d.diffIdSet(merged, idsExclude)
|
||||
const e1 = d.diffIdSet(ids1, idsExclude)
|
||||
const e2 = d.diffIdSet(ids2, idsExclude)
|
||||
const excludedMerged = d.mergeIdSets([e1, e2])
|
||||
compareIdSets(mergedExcluded, excludedMerged)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomIntersects = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
const ids1 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const ids2 = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
const intersected = d.intersectSets(ids1, ids2)
|
||||
for (let client = 0; client < clients; client++) {
|
||||
for (let clock = 0; clock < clockRange; clock++) {
|
||||
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
|
||||
}
|
||||
}
|
||||
const diffed1 = d.diffIdSet(ids1, ids2)
|
||||
const altDiffed1 = d.diffIdSet(ids1, intersected)
|
||||
compareIdSets(diffed1, altDiffed1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The allocation-free `covers` / `coveredLength` must agree with per-id `has` and with the
|
||||
* allocating `slice(...)` reduction.
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatRandomCoversAndCoveredLength = tc => {
|
||||
const clients = 4
|
||||
const clockRange = 100
|
||||
const ids = createRandomIdSet(tc.prng, clients, clockRange)
|
||||
for (let iter = 0; iter < 300; iter++) {
|
||||
const client = prng.int31(tc.prng, 0, clients - 1)
|
||||
const clock = prng.int31(tc.prng, 0, clockRange)
|
||||
const len = prng.int31(tc.prng, 1, clockRange - clock + 5) // may exceed the range to cover edges
|
||||
// reference by scanning every id in the range
|
||||
let refCovered = 0
|
||||
let refCovers = true
|
||||
for (let c = clock; c < clock + len; c++) {
|
||||
if (ids.has(client, c)) refCovered++
|
||||
else refCovers = false
|
||||
}
|
||||
t.assert(ids.covers(client, clock, len) === refCovers)
|
||||
t.assert(ids.coveredLength(client, clock, len) === refCovered)
|
||||
// cross-check against the allocating slice
|
||||
const sliceCovered = ids.slice(client, clock, len).reduce((s, r) => r.exists ? s + r.len : s, 0)
|
||||
t.assert(ids.coveredLength(client, clock, len) === sliceCovered)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The struct that covers `clock` for the document's local client.
|
||||
*
|
||||
* @param {Y.Doc} doc
|
||||
* @param {number} clock
|
||||
* @return {any}
|
||||
*/
|
||||
const structAt = (doc, clock) => {
|
||||
const structs = /** @type {Array<any>} */ (doc.store.clients.get(doc.clientID))
|
||||
return structs[Y.findIndexSS(structs, clock)]
|
||||
}
|
||||
|
||||
/**
|
||||
* `gcIdSet` collects the deleted content referenced by an IdSet on a `gc: false` document,
|
||||
* without changing the observable state, and the result still converges with a peer.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGcIdSetBasic = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const yarr = doc.get('array')
|
||||
yarr.insert(0, ['a', 'b', 'c', 'd'])
|
||||
yarr.delete(1, 2) // delete 'b' (clock 1) and 'c' (clock 2)
|
||||
// gc is disabled, so the deleted content is retained
|
||||
t.assert(structAt(doc, 1).content instanceof Y.ContentAny)
|
||||
|
||||
Y.gcIdSet(doc, Y.createDeleteSetFromStructStore(doc.store))
|
||||
|
||||
// observable state is unchanged
|
||||
t.compare(yarr.toArray(), ['a', 'd'])
|
||||
// every collectible deleted item is now collected
|
||||
doc.store.clients.forEach(structs => {
|
||||
structs.forEach(struct => {
|
||||
if (struct.constructor === Y.Item && struct.deleted && !struct.keep) {
|
||||
t.assert(struct.content instanceof Y.ContentDeleted)
|
||||
}
|
||||
})
|
||||
})
|
||||
// still converges with a fresh peer
|
||||
const peer = new Y.Doc()
|
||||
Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc))
|
||||
t.compare(peer.get('array').toArray(), ['a', 'd'])
|
||||
}
|
||||
|
||||
/**
|
||||
* `gcIdSet` splits structs at the IdSet boundaries, so only the exact referenced content is
|
||||
* collected - the rest of a partially-covered deleted run is preserved.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGcIdSetPartialRange = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const yarr = doc.get('array')
|
||||
yarr.insert(0, ['a', 'b', 'c', 'd', 'e'])
|
||||
yarr.delete(1, 4) // delete 'b','c','d','e' (clocks 1..4), retained because gc is off
|
||||
|
||||
// collect only the middle of the deleted run: clocks 2..3 ('c', 'd')
|
||||
const ids = d.createIdSet()
|
||||
ids.add(doc.clientID, 2, 2)
|
||||
Y.gcIdSet(doc, ids)
|
||||
|
||||
t.compare(yarr.toArray(), ['a'])
|
||||
// 'b' (clock 1) and 'e' (clock 4) are outside the IdSet -> still retained
|
||||
t.assert(structAt(doc, 1).deleted && structAt(doc, 1).content instanceof Y.ContentAny)
|
||||
t.assert(structAt(doc, 4).deleted && structAt(doc, 4).content instanceof Y.ContentAny)
|
||||
// 'c','d' (clocks 2..3) were inside the IdSet -> collected
|
||||
t.assert(structAt(doc, 2).content instanceof Y.ContentDeleted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids that don't reference collectible content (live content) are skipped.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGcIdSetSkipsLiveContent = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const yarr = doc.get('array')
|
||||
yarr.insert(0, ['a', 'b', 'c'])
|
||||
yarr.delete(1, 1) // delete only 'b'
|
||||
|
||||
// pass *all* ids (live + deleted); only the deleted one is collectible
|
||||
Y.gcIdSet(doc, Y.createInsertSetFromStructStore(doc.store, false))
|
||||
|
||||
t.compare(yarr.toArray(), ['a', 'c'])
|
||||
t.assert(structAt(doc, 0).content instanceof Y.ContentAny) // 'a' is live -> untouched
|
||||
t.assert(structAt(doc, 2).content instanceof Y.ContentAny) // 'c' is live -> untouched
|
||||
t.assert(structAt(doc, 1).content instanceof Y.ContentDeleted) // 'b' was deleted -> collected
|
||||
}
|
||||
|
||||
/**
|
||||
* Calling `gcIdSet` twice is a no-op the second time.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGcIdSetIdempotent = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const yarr = doc.get('array')
|
||||
yarr.insert(0, ['a', 'b', 'c'])
|
||||
yarr.delete(0, 2)
|
||||
const ds = Y.createDeleteSetFromStructStore(doc.store)
|
||||
Y.gcIdSet(doc, ds)
|
||||
const encoded = Y.encodeStateAsUpdate(doc)
|
||||
Y.gcIdSet(doc, ds) // second pass changes nothing
|
||||
t.compare(yarr.toArray(), ['c'])
|
||||
t.compare(Y.encodeStateAsUpdate(doc), encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Items flagged with `keep` (e.g. protected by an UndoManager) are not collected.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGcIdSetRespectsKeep = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const yarr = doc.get('array')
|
||||
yarr.insert(0, ['a', 'b', 'c'])
|
||||
yarr.delete(1, 1) // delete 'b' (clock 1)
|
||||
structAt(doc, 1).keep = true // protect it from gc
|
||||
|
||||
Y.gcIdSet(doc, Y.createDeleteSetFromStructStore(doc.store))
|
||||
|
||||
// 'b' was kept -> still retained, not collected
|
||||
t.assert(structAt(doc, 1).deleted && structAt(doc, 1).content instanceof Y.ContentAny)
|
||||
}
|
||||
2348
tests/attribution.tests.js
Normal file
2348
tests/attribution.tests.js
Normal file
File diff suppressed because it is too large
Load Diff
69
tests/compatibility.tests.js
Normal file
69
tests/compatibility.tests.js
Normal file
File diff suppressed because one or more lines are too long
197
tests/delta.tests.js
Normal file
197
tests/delta.tests.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as delta from 'lib0/delta'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as s from 'lib0/schema'
|
||||
|
||||
/**
|
||||
* Delta is a versatile format enabling you to efficiently describe changes. It is part of lib0, so
|
||||
* that non-yjs applications can use it without consuming the full Yjs package. It is well suited
|
||||
* for efficiently describing state & changesets.
|
||||
*
|
||||
* Assume we start with the text "hello world". Now we want to delete " world" and add an
|
||||
* exclamation mark. The final content should be "hello!" ("hello world" => "hello!")
|
||||
*
|
||||
* In most editors, you would describe the necessary changes as replace operations using indexes.
|
||||
* However, this might become ambiguous when many changes are involved.
|
||||
*
|
||||
* - delete range 5-11
|
||||
* - insert "!" at position 11
|
||||
*
|
||||
* Using the delta format, you can describe the changes similar to what you would do in an text editor.
|
||||
* The "|" describes the current cursor position.
|
||||
*
|
||||
* - d.retain(5) - "|hello world" => "hello| world" - jump over the next five characters
|
||||
* - d.delete(6) - "hello| world" => "hello|" - delete the next 6 characres
|
||||
* - d.insert('!') - "hello!|" - insert "!" at the current position
|
||||
* => compact form: d.retain(5).delete(6).insert('!')
|
||||
*
|
||||
* You can also apply the changes in two distinct steps and then rebase the op so that you can apply
|
||||
* them in two distinct steps.
|
||||
* - delete " world": d1 = delta.create().retain(5).delete(6)
|
||||
* - insert "!": d2 = delta.create().retain(11).insert('!')
|
||||
* - rebase d2 on-top of d1: d2.rebase(d1) == delta.create().retain(5).insert('!')
|
||||
* - merge into a single change: d1.apply(d2) == delta.create().retain(5).delete(6).insert(!)
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaBasics = _tc => {
|
||||
// the state of our text document
|
||||
const state = delta.create().insert('hello world')
|
||||
// describe changes: delete " world" & insert "!"
|
||||
const change = delta.create().retain(5).delete(6).insert('!')
|
||||
// apply changes to state
|
||||
state.apply(change)
|
||||
// compare state to expected state
|
||||
t.assert(state.equals(delta.create().insert('hello!').done()))
|
||||
}
|
||||
|
||||
/**
|
||||
* lib0 also ships a schema library that can be used to validate JSON objects and custom data types,
|
||||
* like Yjs types.
|
||||
*
|
||||
* As a convention, schemas are usually prefixed with a $ sign. This clarifies the difference
|
||||
* between a schema, and an instance of a schema.
|
||||
*
|
||||
* const $myobj = s.$object({ key: s.$number })
|
||||
* let inputValue: any
|
||||
* if ($myobj.check(inputValue)) {
|
||||
* inputValue // is validated and of type $myobj
|
||||
* }
|
||||
*
|
||||
* We can also define the expected values on a delta.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaBasicSchema = _tc => {
|
||||
const $d = delta.$delta({ attrs: { key: s.$string }, children: s.$number, text: false })
|
||||
const d = delta.create($d)
|
||||
// @ts-expect-error
|
||||
d.setAttr('key', false) // invalid change: will throw a type error
|
||||
t.fails(() => {
|
||||
// @ts-expect-error
|
||||
d.apply(delta.create().setAttr('key', false)) // invalid delta: will throw a type error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deltas can describe changes on attributes and children. Textual insertions are children. But we
|
||||
* may also insert json-objects and other deltas as children.
|
||||
* Key-value pairs can be represented as attributes. This "convoluted" changeset enables us to
|
||||
* describe many changes in the same breath:
|
||||
*
|
||||
* delta.create().setAttr('a', 42).retain(5).delete(6).insert('!').deleteAttr('b')
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaValues = _tc => {
|
||||
const change = delta.create().setAttr('a', 42).deleteAttr('b').retain(5).delete(6).insert('!').insert([{ my: 'custom object' }])
|
||||
// iterate through attribute changes
|
||||
for (const attrChange of change.attrs) {
|
||||
if (delta.$insertOp.check(attrChange)) {
|
||||
console.log(`set ${attrChange.key} to ${attrChange.value}`)
|
||||
} else if (delta.$deleteOp.check(attrChange)) {
|
||||
console.log(`delete ${attrChange.key}`)
|
||||
}
|
||||
}
|
||||
// iterate through child changes
|
||||
for (const childChange of change.children) {
|
||||
if (delta.$retainOp.check(childChange)) {
|
||||
console.log(`retain ${childChange.retain} child items`)
|
||||
} else if (delta.$deleteOp.check(childChange)) {
|
||||
console.log(`delete ${childChange.delete} child items`)
|
||||
} else if (delta.$insertOp.check(childChange)) {
|
||||
console.log('insert child items:', childChange.insert)
|
||||
} else if (delta.$textOp.check(childChange)) {
|
||||
console.log('insert textual content', childChange.insert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The new delta defines changes on attributes (key-value) and child elements (list & text), but can
|
||||
* also be used to describe the current state of a document.
|
||||
*
|
||||
* 1. apply a delta to change a yjs type
|
||||
* 2. observe deltas to read the differences
|
||||
* 3. merge deltas to reflect multiple changes in a single delta
|
||||
* 4. All Yjs types fully support the delta format. It is no longer necessary to define the type (such as Y.Array)
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testBasics = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ynode = ydoc.get('my data')
|
||||
/**
|
||||
* @type {delta.Delta<{attrs: { a: number }, children: { my: string }, text: true }>}
|
||||
*/
|
||||
let observedDelta = delta.create()
|
||||
ynode.observe(event => {
|
||||
observedDelta = event.deltaDeep
|
||||
console.log('ynode changed:', observedDelta.toJSON())
|
||||
})
|
||||
// define a change: set attribute: a=42
|
||||
const attrChange = delta.create().setAttr('a', 42).done()
|
||||
// define a change: insert textual content and an object
|
||||
const childChange = delta.create().insert('hello').insert([{ my: 'object' }]).done()
|
||||
// merge changes
|
||||
const mergedChanges = delta.create(delta.$deltaAny)
|
||||
mergedChanges.apply(attrChange)
|
||||
mergedChanges.apply(childChange).done()
|
||||
console.log('merged changes: ', mergedChanges.toJSON())
|
||||
ynode.applyDelta(mergedChanges)
|
||||
// the observed change should equal the applied change
|
||||
t.assert(observedDelta.equals(mergedChanges))
|
||||
// read the current state of the yjs types as a delta
|
||||
const currState = ynode.toDeltaDeep()
|
||||
t.assert(currState.equals(mergedChanges)) // equal to the changes that we applied
|
||||
}
|
||||
|
||||
/**
|
||||
* Deltas allow us to describe the differences between two Yjs documents though "Attributions".
|
||||
*
|
||||
* - We can attribute changes to a user, or a group of users
|
||||
* - There are 'insert', 'delete', and 'format' attributions
|
||||
* - When we render attributions, we render inserted & deleted content as an insertions with special
|
||||
* attributes which allow you to..
|
||||
* -- Render deleted content using a strikethrough: I.e. `hello w̶o̶r̶l̶d̶!`
|
||||
* -- Render attributed insertions using a background color.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testAttributions = _tc => {
|
||||
const ydocV1 = new Y.Doc()
|
||||
const ynodeV1 = ydocV1.get('txt')
|
||||
ynodeV1.applyDelta(delta.create().insert('hello world').done())
|
||||
// create a new version with updated content
|
||||
const ydoc = new Y.Doc()
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||
const ynode = ydoc.get('txt')
|
||||
// delete " world" and insert exclamation mark "!".
|
||||
ynode.applyDelta(delta.create().retain(5).delete(6).insert('!').done())
|
||||
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||
// get the attributed differences
|
||||
const attributedContent = ynode.toDelta({ renderer })
|
||||
console.log('attributed content', attributedContent.toJSON())
|
||||
t.assert(attributedContent.equals(delta.create().insert('hello').insert(' world', null, { delete: [] }).insert('!', null, { insert: [] }).done()))
|
||||
// for editor bindings, it is also necessary to observe changes and get the attributed changes
|
||||
ynode.observe(event => {
|
||||
const attributedChange = event.getDelta({ renderer })
|
||||
console.log('the attributed change', attributedChange.toJSON())
|
||||
t.assert(attributedChange.done().equals(delta.create().retain(11).insert('!', null, { insert: [] }).done()))
|
||||
const unattributedChange = event.delta
|
||||
console.log('the UNattributed change', unattributedChange.toJSON())
|
||||
t.assert(unattributedChange.equals(delta.create().retain(5).insert('!').done()))
|
||||
})
|
||||
/**
|
||||
* Content now has different representations.
|
||||
* - The UNattributed representation renders the latest state, without history.
|
||||
* - The attributed representation renders the differences.
|
||||
*
|
||||
* Attributed: 'hello<delete> world</delete><insert>!</insert>'
|
||||
* UNattributed: 'world!'
|
||||
*/
|
||||
// Apply a change to the attributed content
|
||||
ynode.applyDelta(delta.create().retain(11).insert('!').done(), null, { renderer })
|
||||
// // Equivalent to applying a change to the UNattributed content:
|
||||
// ynode.applyDelta(delta.create().retain(5).insert('!'))
|
||||
}
|
||||
354
tests/doc.tests.js
Normal file
354
tests/doc.tests.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testAfterTransactionRecursion = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yxml = ydoc.get('')
|
||||
ydoc.on('afterTransaction', tr => {
|
||||
if (tr.origin === 'test') {
|
||||
yxml.toJSON()
|
||||
}
|
||||
})
|
||||
ydoc.transact(_tr => {
|
||||
for (let i = 0; i < 15000; i++) {
|
||||
yxml.push([new Y.Node('a')])
|
||||
}
|
||||
}, 'test')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testFindTypeInOtherDoc = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ymap = ydoc.get()
|
||||
const ytext = ymap.setAttr('ytext', new Y.Node())
|
||||
const ydocClone = new Y.Doc()
|
||||
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
|
||||
/**
|
||||
* @param {Y.Node} ynode
|
||||
* @param {Y.Doc} otherYdoc
|
||||
* @return {Y.Node}
|
||||
*/
|
||||
const findTypeInOtherYdoc = (ynode, otherYdoc) => {
|
||||
const ydoc = /** @type {Y.Doc} */ (ynode.doc)
|
||||
if (ynode._item === null) {
|
||||
/**
|
||||
* If is a root type, we need to find the root key in the original ydoc
|
||||
* and use it to get the type in the other ydoc.
|
||||
*/
|
||||
const rootKey = Array.from(ydoc.share.keys()).find(
|
||||
(key) => ydoc.share.get(key) === ynode
|
||||
)
|
||||
if (rootKey == null) {
|
||||
throw new Error('type does not exist in other ydoc')
|
||||
}
|
||||
return otherYdoc.get(rootKey)
|
||||
} else {
|
||||
/**
|
||||
* If it is a sub type, we use the item id to find the history type.
|
||||
*/
|
||||
const ynodeItem = ynode._item
|
||||
const otherStructs = otherYdoc.store.clients.get(ynodeItem.id.client) ?? []
|
||||
const itemIndex = Y.findIndexSS(
|
||||
otherStructs,
|
||||
ynodeItem.id.clock
|
||||
)
|
||||
const otherItem = /** @type {Y.Item} */ (otherStructs[itemIndex])
|
||||
const otherContent = /** @type {Y.ContentType} */ (otherItem.content)
|
||||
return /** @type {Y.Node} */ (otherContent.type)
|
||||
}
|
||||
}
|
||||
t.assert(findTypeInOtherYdoc(ymap, ydocClone) != null)
|
||||
t.assert(findTypeInOtherYdoc(ytext, ydocClone) != null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client id should be changed when an instance receives updates from another client using the same client id.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testClientIdDuplicateChange = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.clientID = 0
|
||||
const doc2 = new Y.Doc()
|
||||
doc2.clientID = 0
|
||||
t.assert(doc2.clientID === doc1.clientID)
|
||||
doc1.get('a').insert(0, [1, 2])
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||
t.assert(doc2.clientID !== doc1.clientID)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGetTypeEmptyId = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
doc1.get('').insert(0, 'h')
|
||||
doc1.get().insert(1, 'i')
|
||||
const doc2 = new Y.Doc()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
||||
t.assert(doc2.get().toString() === 'hi')
|
||||
t.assert(doc2.get('').toString() === 'hi')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testToJSON = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
|
||||
|
||||
const arr = doc.get('array')
|
||||
arr.push(['test1'])
|
||||
|
||||
const map = doc.get('map')
|
||||
map.setAttr('k1', 'v1')
|
||||
const map2 = new Y.Node()
|
||||
map.setAttr('k2', map2)
|
||||
map2.setAttr('m2k1', 'm2v1')
|
||||
t.compare(doc.toJSON(), {
|
||||
array: { children: ['test1'] },
|
||||
map: {
|
||||
attrs: {
|
||||
k1: 'v1',
|
||||
k2: {
|
||||
attrs: {
|
||||
m2k1: 'm2v1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'doc.toJSON has array and recursive map')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdoc = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
doc.load() // doesn't do anything
|
||||
{
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
*/
|
||||
let event = /** @type {any} */ (null)
|
||||
doc.on('subdocs', subdocs => {
|
||||
event = [Array.from(subdocs.added).map(x => x.guid), Array.from(subdocs.removed).map(x => x.guid), Array.from(subdocs.loaded).map(x => x.guid)]
|
||||
})
|
||||
const subdocs = doc.get('mysubdocs')
|
||||
const docA = new Y.Doc({ guid: 'a' })
|
||||
docA.load()
|
||||
subdocs.setAttr('a', docA)
|
||||
t.compare(event, [['a'], [], ['a']])
|
||||
|
||||
event = null
|
||||
subdocs.getAttr('a').load()
|
||||
t.assert(event === null)
|
||||
|
||||
event = null
|
||||
subdocs.getAttr('a').destroy()
|
||||
t.compare(event, [['a'], ['a'], []])
|
||||
subdocs.getAttr('a').load()
|
||||
t.compare(event, [[], [], ['a']])
|
||||
|
||||
subdocs.setAttr('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
|
||||
t.compare(event, [['a'], [], []])
|
||||
subdocs.getAttr('b').load()
|
||||
t.compare(event, [[], [], ['a']])
|
||||
|
||||
const docC = new Y.Doc({ guid: 'c' })
|
||||
docC.load()
|
||||
subdocs.setAttr('c', docC)
|
||||
t.compare(event, [['c'], [], ['c']])
|
||||
|
||||
t.compare(Array.from(doc.getSubdocGuids()), ['a', 'c'])
|
||||
}
|
||||
|
||||
const doc2 = new Y.Doc()
|
||||
{
|
||||
t.compare(Array.from(doc2.getSubdocs()), [])
|
||||
/**
|
||||
* @type {Array<any>|null}
|
||||
*/
|
||||
let event = /** @type {any} */ (null)
|
||||
doc2.on('subdocs', subdocs => {
|
||||
event = [Array.from(subdocs.added).map(d => d.guid), Array.from(subdocs.removed).map(d => d.guid), Array.from(subdocs.loaded).map(d => d.guid)]
|
||||
})
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
t.compare(event, [['a', 'a', 'c'], [], []])
|
||||
|
||||
doc2.get('mysubdocs').getAttr('a').load()
|
||||
t.compare(event, [[], [], ['a']])
|
||||
|
||||
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
|
||||
|
||||
doc2.get('mysubdocs').deleteAttr('a')
|
||||
t.compare(event, [[], ['a'], []])
|
||||
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdocLoadEdgeCases = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yarray = ydoc.get()
|
||||
const subdoc1 = new Y.Doc()
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let lastEvent = null
|
||||
ydoc.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
yarray.insert(0, [subdoc1])
|
||||
t.assert(subdoc1.shouldLoad)
|
||||
t.assert(subdoc1.autoLoad === false)
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
|
||||
// destroy and check whether lastEvent adds it again to added (it shouldn't)
|
||||
subdoc1.destroy()
|
||||
const subdoc2 = yarray.get(0)
|
||||
t.assert(subdoc1 !== subdoc2)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
|
||||
// load
|
||||
subdoc2.load()
|
||||
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
|
||||
// apply from remote
|
||||
const ydoc2 = new Y.Doc()
|
||||
ydoc2.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||
const subdoc3 = ydoc2.get().get(0)
|
||||
t.assert(subdoc3.shouldLoad === false)
|
||||
t.assert(subdoc3.autoLoad === false)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
|
||||
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc3))
|
||||
// load
|
||||
subdoc3.load()
|
||||
t.assert(subdoc3.shouldLoad)
|
||||
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc3))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdocLoadEdgeCasesAutoload = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yarray = ydoc.get()
|
||||
const subdoc1 = new Y.Doc({ autoLoad: true })
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let lastEvent = null
|
||||
ydoc.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
yarray.insert(0, [subdoc1])
|
||||
t.assert(subdoc1.shouldLoad)
|
||||
t.assert(subdoc1.autoLoad)
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
|
||||
// destroy and check whether lastEvent adds it again to added (it shouldn't)
|
||||
subdoc1.destroy()
|
||||
const subdoc2 = yarray.get(0)
|
||||
t.assert(subdoc1 !== subdoc2)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
|
||||
// load
|
||||
subdoc2.load()
|
||||
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
|
||||
// apply from remote
|
||||
const ydoc2 = new Y.Doc()
|
||||
ydoc2.on('subdocs', event => {
|
||||
lastEvent = event
|
||||
})
|
||||
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||
const subdoc3 = ydoc2.get().get(0)
|
||||
t.assert(subdoc1.shouldLoad)
|
||||
t.assert(subdoc1.autoLoad)
|
||||
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
|
||||
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSubdocsUndo = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const elems = ydoc.get()
|
||||
const undoManager = new Y.UndoManager(elems)
|
||||
const subdoc = new Y.Doc()
|
||||
// @ts-ignore
|
||||
elems.insert(0, [subdoc])
|
||||
undoManager.undo()
|
||||
undoManager.redo()
|
||||
t.assert(elems.length === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testLoadDocsEvent = async _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
t.assert(ydoc.isLoaded === false)
|
||||
let loadedEvent = false
|
||||
ydoc.on('load', () => {
|
||||
loadedEvent = true
|
||||
})
|
||||
ydoc.emit('load', [ydoc])
|
||||
await ydoc.whenLoaded
|
||||
t.assert(loadedEvent)
|
||||
t.assert(ydoc.isLoaded)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSyncDocsEvent = async _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
t.assert(ydoc.isLoaded === false)
|
||||
t.assert(ydoc.isSynced === false)
|
||||
let loadedEvent = false
|
||||
ydoc.once('load', () => {
|
||||
loadedEvent = true
|
||||
})
|
||||
let syncedEvent = false
|
||||
ydoc.once('sync', /** @param {any} isSynced */ (isSynced) => {
|
||||
syncedEvent = true
|
||||
t.assert(isSynced)
|
||||
})
|
||||
ydoc.emit('sync', [true, ydoc])
|
||||
await ydoc.whenLoaded
|
||||
const oldWhenSynced = ydoc.whenSynced
|
||||
await ydoc.whenSynced
|
||||
t.assert(loadedEvent)
|
||||
t.assert(syncedEvent)
|
||||
t.assert(ydoc.isLoaded)
|
||||
t.assert(ydoc.isSynced)
|
||||
let loadedEvent2 = false
|
||||
ydoc.on('load', () => {
|
||||
loadedEvent2 = true
|
||||
})
|
||||
let syncedEvent2 = false
|
||||
ydoc.on('sync', (isSynced) => {
|
||||
syncedEvent2 = true
|
||||
t.assert(isSynced === false)
|
||||
})
|
||||
ydoc.emit('sync', [false, ydoc])
|
||||
t.assert(!loadedEvent2)
|
||||
t.assert(syncedEvent2)
|
||||
t.assert(ydoc.isLoaded)
|
||||
t.assert(!ydoc.isSynced)
|
||||
t.assert(ydoc.whenSynced !== oldWhenSynced)
|
||||
}
|
||||
75
tests/encoding.tests.js
Normal file
75
tests/encoding.tests.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as t from 'lib0/testing'
|
||||
|
||||
import {
|
||||
contentRefs,
|
||||
readContentDeleted,
|
||||
readContentBinary,
|
||||
readContentString,
|
||||
readContentJSON,
|
||||
readContentEmbed,
|
||||
readContentType,
|
||||
readContentFormat,
|
||||
readContentAny,
|
||||
readContentDoc
|
||||
} from '../src/ynode.js'
|
||||
|
||||
import * as Y from '../src/index.js'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testStructReferences = _tc => {
|
||||
t.assert(contentRefs.length === 11)
|
||||
t.assert(contentRefs[1] === readContentDeleted)
|
||||
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
|
||||
t.assert(contentRefs[3] === readContentBinary)
|
||||
t.assert(contentRefs[4] === readContentString)
|
||||
t.assert(contentRefs[5] === readContentEmbed)
|
||||
t.assert(contentRefs[6] === readContentFormat)
|
||||
t.assert(contentRefs[7] === readContentType)
|
||||
t.assert(contentRefs[8] === readContentAny)
|
||||
t.assert(contentRefs[9] === readContentDoc)
|
||||
// contentRefs[10] is reserved for Skip structs
|
||||
}
|
||||
|
||||
/**
|
||||
* Reported here: https://github.com/yjs/yjs/issues/308
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDiffStateVectorOfUpdateIsEmpty = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let sv = null
|
||||
ydoc.get().insert(0, 'a')
|
||||
ydoc.on('update', update => {
|
||||
sv = Y.encodeStateVectorFromUpdate(update)
|
||||
})
|
||||
// should produce an update with an empty state vector (because previous ops are missing)
|
||||
ydoc.get().insert(0, 'a')
|
||||
t.assert(sv !== null && sv.byteLength === 1 && sv[0] === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reported here: https://github.com/yjs/yjs/issues/308
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDiffStateVectorOfUpdateIgnoresSkips = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
/**
|
||||
* @type {Array<Uint8Array<ArrayBuffer>>}
|
||||
*/
|
||||
const updates = []
|
||||
ydoc.on('update', update => {
|
||||
updates.push(update)
|
||||
})
|
||||
ydoc.get().insert(0, 'a')
|
||||
ydoc.get().insert(0, 'b')
|
||||
ydoc.get().insert(0, 'c')
|
||||
const update13 = Y.mergeUpdates([updates[0], updates[2]])
|
||||
const sv = Y.encodeStateVectorFromUpdate(update13)
|
||||
const state = Y.decodeStateVector(sv)
|
||||
t.assert(state.get(ydoc.clientID) === 1)
|
||||
t.assert(state.size === 1)
|
||||
}
|
||||
39
tests/index.js
Normal file
39
tests/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/* eslint-env node */
|
||||
|
||||
import * as map from './y-map.tests.js'
|
||||
import * as array from './y-array.tests.js'
|
||||
import * as text from './y-text.tests.js'
|
||||
import * as xml from './y-xml.tests.js'
|
||||
import * as encoding from './encoding.tests.js'
|
||||
import * as undoredo from './undo-redo.tests.js'
|
||||
import * as compatibility from './compatibility.tests.js'
|
||||
import * as doc from './doc.tests.js'
|
||||
import * as snapshot from './snapshot.tests.js'
|
||||
import * as updates from './updates.tests.js'
|
||||
import * as relativePositions from './relativePositions.tests.js'
|
||||
import * as idset from './IdSet.tests.js'
|
||||
import * as idmap from './IdMap.tests.js'
|
||||
import * as attribution from './attribution.tests.js'
|
||||
import * as delta from './delta.tests.js'
|
||||
import * as schema from './schema.tests.js'
|
||||
|
||||
import { runTests } from 'lib0/testing'
|
||||
import { isBrowser, isNode } from 'lib0/environment'
|
||||
import * as log from 'lib0/logging'
|
||||
|
||||
if (isBrowser) {
|
||||
log.createVConsole(document.body)
|
||||
}
|
||||
|
||||
const tests = {
|
||||
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions, idset, idmap, attribution, delta, schema
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const success = await runTests(tests)
|
||||
/* istanbul ignore next */
|
||||
if (isNode) {
|
||||
process.exit(success ? 0 : 1)
|
||||
}
|
||||
}
|
||||
run()
|
||||
372
tests/relativePositions.tests.js
Normal file
372
tests/relativePositions.tests.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as dpos from 'lib0/delta/position'
|
||||
|
||||
/**
|
||||
* @param {Y.Node<{text:true}>} ytext
|
||||
*/
|
||||
const checkRelativePositions = ytext => {
|
||||
// test if all positions are encoded and restored correctly
|
||||
for (let i = 0; i < ytext.length; i++) {
|
||||
// for all types of associations..
|
||||
for (let assoc = -1; assoc < 2; assoc++) {
|
||||
const rpos = Y.createRelativePositionFromTypeIndex(ytext, i, assoc)
|
||||
const encodedRpos = Y.encodeRelativePosition(rpos)
|
||||
const decodedRpos = Y.decodeRelativePosition(encodedRpos)
|
||||
const absPos = /** @type {Y.AbsolutePosition} */ (Y.createAbsolutePositionFromRelativePosition(decodedRpos, /** @type {Y.Doc} */ (ytext.doc)))
|
||||
t.assert(absPos.index === i)
|
||||
t.assert(absPos.assoc === assoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase1 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '1')
|
||||
ytext.insert(0, 'abc')
|
||||
ytext.insert(0, 'z')
|
||||
ytext.insert(0, 'y')
|
||||
ytext.insert(0, 'x')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase2 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'abc')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase3 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'abc')
|
||||
ytext.insert(0, '1')
|
||||
ytext.insert(0, 'xyz')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase4 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '1')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase5 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '2')
|
||||
ytext.insert(0, '1')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase6 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Testing https://github.com/yjs/yjs/issues/657
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase7 = _tc => {
|
||||
const docA = new Y.Doc()
|
||||
const textA = docA.get('text')
|
||||
textA.insert(0, 'abcde')
|
||||
// Create a relative position at index 2 in 'textA'
|
||||
const relativePosition = Y.createRelativePositionFromTypeIndex(textA, 2)
|
||||
// Verify that the absolutes positions on 'docA' are the same
|
||||
const absolutePositionWithFollow =
|
||||
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, true)
|
||||
const absolutePositionWithoutFollow =
|
||||
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, false)
|
||||
t.assert(absolutePositionWithFollow?.index === 2)
|
||||
t.assert(absolutePositionWithoutFollow?.index === 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionAssociationDifference = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '2')
|
||||
ytext.insert(0, '1')
|
||||
const rposRight = Y.createRelativePositionFromTypeIndex(ytext, 1, 0)
|
||||
const rposLeft = Y.createRelativePositionFromTypeIndex(ytext, 1, -1)
|
||||
ytext.insert(1, 'x')
|
||||
const posRight = Y.createAbsolutePositionFromRelativePosition(rposRight, ydoc)
|
||||
const posLeft = Y.createAbsolutePositionFromRelativePosition(rposLeft, ydoc)
|
||||
t.assert(posRight != null && posRight.index === 2)
|
||||
t.assert(posLeft != null && posLeft.index === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionWithUndo = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello world')
|
||||
const rpos = Y.createRelativePositionFromTypeIndex(ytext, 1)
|
||||
const um = new Y.UndoManager(ytext)
|
||||
ytext.delete(0, 6)
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 0)
|
||||
um.undo()
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 1)
|
||||
const posWithoutFollow = Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)
|
||||
console.log({ posWithoutFollow })
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)?.index === 6)
|
||||
const ydocClone = new Y.Doc()
|
||||
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone)?.index === 6)
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone, false)?.index === 6)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsFlatTextRoundtrip = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello world')
|
||||
const positions = [dpos.create([0]), dpos.create([5]), dpos.create([5], -1), dpos.create([11])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
|
||||
t.assert(rposs.every(rpos => rpos !== null))
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||
positions.forEach((pos, i) => {
|
||||
const b = back[i]
|
||||
t.assert(b !== null && dpos.equals(b, pos))
|
||||
})
|
||||
// the singular wrappers behave like a one-element batch
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
|
||||
t.assert(Y.compareRelativePositions(rpos, rposs[1]))
|
||||
const single = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
|
||||
t.assert(single !== null && dpos.equals(single, positions[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsNested = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yroot = ydoc.get('arr')
|
||||
yroot.insert(0, ['a', new Y.Node(), 'b'])
|
||||
const child = /** @type {Y.Node<any>} */ (yroot.get(1))
|
||||
child.insert(0, 'xyz')
|
||||
const positions = [dpos.create([1, 2]), dpos.create([0])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions)
|
||||
t.assert(rposs.every(rpos => rpos !== null))
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs)
|
||||
positions.forEach((pos, i) => {
|
||||
const b = back[i]
|
||||
t.assert(b !== null && dpos.equals(b, pos))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsAttrDescent = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ymap = ydoc.get('map')
|
||||
const child = ymap.setAttr('k', new Y.Node())
|
||||
child.insert(0, 'ab')
|
||||
const pos = dpos.create(['k', 1])
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
|
||||
t.assert(rpos !== null)
|
||||
const back = Y.createDeltaPositionFromRelativePosition(ymap, rpos)
|
||||
t.assert(back !== null && dpos.equals(back, pos))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsAttrLeaf = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ymap = ydoc.get('map')
|
||||
ymap.setAttr('k', 42)
|
||||
const pos = dpos.create(['k'])
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
|
||||
t.assert(rpos !== null && rpos.item !== null)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
|
||||
// the attribute-leaf anchor survives overwriting the attribute
|
||||
ymap.setAttr('k', 43)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
|
||||
// .. but not deleting it
|
||||
ymap.deleteAttr('k')
|
||||
t.assert(Y.createDeltaPositionFromRelativePosition(ymap, rpos) === null)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsDocRooted = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
ytext.insert(0, 'hello')
|
||||
// doc-rooted paths start with a root-type name; numeric or terminal-string steps at doc level are unresolvable
|
||||
const positions = [dpos.create(['text', 3]), dpos.create([0]), dpos.create(['text'])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ydoc, positions)
|
||||
t.assert(rposs[0] !== null && rposs[1] === null && rposs[2] === null)
|
||||
const back = Y.createDeltaPositionFromRelativePosition(ydoc, rposs[0])
|
||||
t.assert(back !== null && dpos.equals(back, positions[0]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsAssocThroughEdit = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '2')
|
||||
ytext.insert(0, '1')
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, [dpos.create([1]), dpos.create([1], -1)])
|
||||
ytext.insert(1, 'x')
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||
t.assert(back[0] !== null && dpos.equals(back[0], dpos.create([2])))
|
||||
t.assert(back[1] !== null && dpos.equals(back[1], dpos.create([1], -1)))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsSurviveConcurrentEdit = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
ytext.insert(0, 'hello')
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([2]))
|
||||
t.assert(rpos !== null)
|
||||
const encoded = Y.encodeRelativePosition(/** @type {Y.RelativePosition} */ (rpos))
|
||||
const ydoc2 = new Y.Doc()
|
||||
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||
const ytext2 = ydoc2.get('text')
|
||||
ytext2.insert(0, 'abc')
|
||||
const back = Y.createDeltaPositionFromRelativePosition(ytext2, Y.decodeRelativePosition(encoded))
|
||||
t.assert(back !== null && dpos.equals(back, dpos.create([5])))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsWithRenderer = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello world')
|
||||
const v1 = Y.cloneDoc(ydoc)
|
||||
ytext.delete(1, 6)
|
||||
ytext.insert(1, 'x')
|
||||
const renderer = Y.createDiffRenderer(v1, ydoc)
|
||||
const pos = dpos.create([9]) // pos after "hello wo" in the rendered diff
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, pos, { renderer })
|
||||
t.assert(rpos !== null)
|
||||
const backWith = Y.createDeltaPositionFromRelativePosition(ytext, rpos, { renderer })
|
||||
t.assert(backWith !== null && dpos.equals(backWith, pos))
|
||||
const backWithout = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
|
||||
t.assert(backWithout !== null && dpos.equals(backWithout, dpos.create([3])))
|
||||
// the node's own renderer is the default - matching toDelta / node.delta
|
||||
ytext.useRenderer(renderer)
|
||||
const rpos2 = Y.createRelativePositionFromDeltaPosition(ytext, pos)
|
||||
t.assert(Y.compareRelativePositions(rpos, rpos2))
|
||||
const backDefault = Y.createDeltaPositionFromRelativePosition(ytext, rpos2)
|
||||
t.assert(backDefault !== null && dpos.equals(backDefault, pos))
|
||||
// explicit renderer: null overrides the node's renderer
|
||||
const backNull = Y.createDeltaPositionFromRelativePosition(ytext, rpos2, { renderer: null })
|
||||
t.assert(backNull !== null && dpos.equals(backNull, dpos.create([3])))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsUnresolvable = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'abc')
|
||||
const positions = [
|
||||
dpos.create([0, 0]), // descend into a scalar
|
||||
dpos.create(['missing', 0]), // missing attribute
|
||||
dpos.create([]), // a node reference doesn't round-trip
|
||||
dpos.create([10, 0]), // out-of-range non-terminal index
|
||||
dpos.create([1]) // resolvable - other entries must not affect it
|
||||
]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
|
||||
t.assert(rposs[0] === null && rposs[1] === null && rposs[2] === null && rposs[3] === null)
|
||||
t.assert(rposs[4] !== null)
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||
t.assert(back[0] === null && back[1] === null && back[2] === null && back[3] === null)
|
||||
t.assert(back[4] !== null && dpos.equals(back[4], positions[4]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsEndOfNode = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello')
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
|
||||
t.assert(rpos !== null && rpos.item === null)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([5])))
|
||||
// an end-of-node position sticks to the end
|
||||
ytext.insert(5, ' world')
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([11])))
|
||||
// empty node
|
||||
const yempty = ydoc.get('empty')
|
||||
const rposE = Y.createRelativePositionFromDeltaPosition(yempty, dpos.create([0]))
|
||||
t.assert(rposE !== null && rposE.item === null)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(yempty, rposE)), dpos.create([0])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions inside a nested node that is deleted but still rendered (e.g. a suggestion-mode
|
||||
* subtree delete). Requires gc: false - with gc enabled, the deleted subtree's content is
|
||||
* destroyed and such positions are unresolvable.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsInDeletedRenderedSubtree = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const yroot = ydoc.get('r')
|
||||
yroot.insert(0, [new Y.Node(), 'x'])
|
||||
const mid = /** @type {Y.Node<any>} */ (yroot.get(0))
|
||||
mid.insert(0, ['q', new Y.Node()])
|
||||
const leaf = /** @type {Y.Node<any>} */ (mid.get(1))
|
||||
leaf.insert(0, 'hello')
|
||||
const v1 = Y.cloneDoc(ydoc)
|
||||
yroot.delete(0, 1) // delete the whole `mid` subtree - the diff renderer still renders it
|
||||
const renderer = Y.createDiffRenderer(v1, ydoc)
|
||||
// the underlying primitive resolves indexes inside the deleted-but-rendered type
|
||||
const rposLeaf = Y.createRelativePositionFromTypeIndex(leaf, 3, 0, renderer)
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rposLeaf, ydoc, true, renderer)?.index === 3)
|
||||
// .. and delta positions round-trip through the deleted subtree
|
||||
const positions = [dpos.create([0, 1, 3]), dpos.create([0, 0]), dpos.create([1])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions, { renderer })
|
||||
t.assert(rposs.every(rpos => rpos !== null))
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs, { renderer })
|
||||
positions.forEach((pos, i) => {
|
||||
const b = back[i]
|
||||
t.assert(b !== null && dpos.equals(b, pos))
|
||||
})
|
||||
}
|
||||
327
tests/schema.tests.js
Normal file
327
tests/schema.tests.js
Normal file
@@ -0,0 +1,327 @@
|
||||
import * as t from 'lib0/testing'
|
||||
import * as s from 'lib0/schema'
|
||||
import * as Y from '../src/index.js'
|
||||
|
||||
/**
|
||||
* @param {any} val
|
||||
* @return {ContentAttribute<any>}
|
||||
*/
|
||||
const attr = val => Y.createContentAttribute('u', val)
|
||||
|
||||
/**
|
||||
* An IdMap holding one attribute value per range, all on client 1.
|
||||
*
|
||||
* @param {Array<any>} vals
|
||||
* @return {IdMap<any>}
|
||||
*/
|
||||
const idMapOf = vals => {
|
||||
const m = Y.createIdMap()
|
||||
vals.forEach((v, i) => m.add(1, i * 10, 10, [attr(v)]))
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {IdSet}
|
||||
*/
|
||||
const idSetOf = () => {
|
||||
const set = Y.createIdSet()
|
||||
set.add(1, 0, 10)
|
||||
return set
|
||||
}
|
||||
|
||||
/**
|
||||
* Every schema on the public surface, paired with a value it must accept.
|
||||
*
|
||||
* The explicit return annotation is required: without it the mixed tuples infer a union element
|
||||
* type and destructuring `$schema` loses `.check`.
|
||||
*
|
||||
* @return {Array<[string, s.Schema<any>, any]>}
|
||||
*/
|
||||
const $schemas = () => [
|
||||
['$idSet', Y.$idSet, Y.createIdSet()],
|
||||
['$idMapAny', Y.$idMapAny, Y.createIdMap()],
|
||||
['$idMap($string)', Y.$idMap(s.$string), idMapOf(['a'])],
|
||||
['$contentIds', Y.$contentIds, Y.createContentIds()],
|
||||
['$contentMap', Y.$contentMap, Y.createContentMap()],
|
||||
['$doc', Y.$doc, new Y.Doc()],
|
||||
['$nodeAny', Y.$nodeAny, new Y.Doc().get('n')],
|
||||
['$node({name:"p"})', Y.$node({ name: 'p' }), new Y.Doc().get('n', 'p')]
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSchemasAcceptTheirOwnInstance = _tc => {
|
||||
$schemas().forEach(([name, $schema, instance]) => {
|
||||
t.group(name, () => {
|
||||
t.assert($schema.check(instance))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* No schema accepts a foreign value, and none throws. `s.$object` returns false for null and reads
|
||||
* its shape's keys off the value, so primitives / arrays / Maps fail on the `undefined` fields.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSchemasRejectJunk = _tc => {
|
||||
const junk = [null, undefined, {}, 0, 1, '', 'idSet', [], true, false, new Map(), new Set(), () => {}]
|
||||
$schemas().forEach(([name, $schema]) => {
|
||||
t.group(name, () => {
|
||||
junk.forEach(j => {
|
||||
t.assert(!$schema.check(j), `must reject ${typeof j} ${String(j)}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The nominal tags discriminate IdSet from IdMap in both directions. Structurally an IdSet
|
||||
* (`{clients}`) is a *subset* of an IdMap (`{clients, attrsH, attrs}`), and `s.$object` always
|
||||
* permits excess properties - so only a nominal check can tell them apart.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIdSetIdMapDiscrimination = _tc => {
|
||||
t.assert(!Y.$idSet.check(Y.createIdMap()), '$idSet rejects an IdMap')
|
||||
t.assert(!Y.$idMapAny.check(Y.createIdSet()), '$idMapAny rejects an IdSet')
|
||||
t.assert(!Y.$idMap(s.$string).check(Y.createIdSet()), '$idMap rejects an IdSet')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testContentIdsVsContentMap = _tc => {
|
||||
const cIds = Y.createContentIds()
|
||||
const cMap = Y.createContentMap()
|
||||
t.group('positive', () => {
|
||||
t.assert(Y.$contentIds.check(cIds))
|
||||
t.assert(Y.$contentMap.check(cMap))
|
||||
})
|
||||
t.group('both directions', () => {
|
||||
t.assert(!Y.$contentIds.check(cMap), '$contentIds rejects a ContentMap')
|
||||
t.assert(!Y.$contentMap.check(cIds), '$contentMap rejects a ContentIds')
|
||||
})
|
||||
t.group('mixed shapes', () => {
|
||||
const a = { inserts: Y.createIdSet(), deletes: Y.createIdMap() }
|
||||
const b = { inserts: Y.createIdMap(), deletes: Y.createIdSet() }
|
||||
t.assert(!Y.$contentIds.check(a))
|
||||
t.assert(!Y.$contentIds.check(b))
|
||||
t.assert(!Y.$contentMap.check(a))
|
||||
t.assert(!Y.$contentMap.check(b))
|
||||
})
|
||||
t.group('missing fields', () => {
|
||||
t.assert(!Y.$contentIds.check({ inserts: Y.createIdSet() }), 'deletes missing')
|
||||
t.assert(!Y.$contentIds.check({ deletes: Y.createIdSet() }), 'inserts missing')
|
||||
})
|
||||
t.group('excess properties are allowed - s.$object reads the shape keys', () => {
|
||||
t.assert(Y.$contentIds.check({ ...Y.createContentIds(), extra: 1 }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* $idMap($attrs) checks the mapped values, not merely the nominal type.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIdMapValueSchema = _tc => {
|
||||
t.group('accepts the matching value type, rejects the other', () => {
|
||||
t.assert(Y.$idMap(s.$string).check(idMapOf(['a', 'b'])))
|
||||
t.assert(!Y.$idMap(s.$string).check(idMapOf([1, 2])))
|
||||
t.assert(Y.$idMap(s.$number).check(idMapOf([1, 2])))
|
||||
t.assert(!Y.$idMap(s.$number).check(idMapOf(['a', 'b'])))
|
||||
})
|
||||
t.group('a single mismatching value is enough to reject', () => {
|
||||
t.assert(!Y.$idMap(s.$string).check(idMapOf(['a', 1])))
|
||||
t.assert(!Y.$idMap(s.$number).check(idMapOf([1, 'a'])))
|
||||
})
|
||||
t.group('an empty map holds no values, so it satisfies every $attrs', () => {
|
||||
t.assert(Y.$idMap(s.$string).check(Y.createIdMap()))
|
||||
t.assert(Y.$idMap(s.$number).check(Y.createIdMap()))
|
||||
})
|
||||
t.group('$idMapAny ignores the value type', () => {
|
||||
t.assert(Y.$idMapAny.check(idMapOf(['a'])))
|
||||
t.assert(Y.$idMapAny.check(idMapOf([1])))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `check()` must not mutate its argument. `AttrRanges#getIds` sorts and splices in place, so a check
|
||||
* built on it would silently reorder the caller's map; `everyAttr` iterates the raw ranges instead.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIdMapCheckDoesNotMutate = _tc => {
|
||||
const m = Y.createIdMap()
|
||||
// deliberately out of clock order, so a sort would be observable
|
||||
m.add(1, 10, 5, [attr('b')])
|
||||
m.add(1, 0, 10, [attr('a')])
|
||||
const ranges = /** @type {any} */ (m.clients.get(1))
|
||||
t.assert(ranges.sorted === false, 'precondition: not yet sorted')
|
||||
t.assert(Y.$idMap(s.$string).check(m))
|
||||
t.assert(ranges.sorted === false, 'check() must not have sorted the ranges')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every real IdMap producer yields a map that $idMap validates correctly.
|
||||
*
|
||||
* Every producer fed only string values must be accepted by `$idMap($string)`. The `keepsValues`
|
||||
* flag drives the far more important negative assertion: `$idMap($number)` must *reject* it. Only
|
||||
* the three genuinely-emptying producers are exempt - they hold nothing, so they satisfy every
|
||||
* `$attrs` vacuously and cannot discriminate. Without the negative, `$idMap($string).check` would
|
||||
* pass just as happily on a producer that silently dropped every attribute.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIdMapProducers = _tc => {
|
||||
/**
|
||||
* @type {Array<[string, () => IdMap<any>, boolean]>}
|
||||
*/
|
||||
const producers = [
|
||||
['createIdMap', () => Y.createIdMap(), false],
|
||||
['add', () => idMapOf(['k']), true],
|
||||
['mergeIdMaps', () => Y.mergeIdMaps([idMapOf(['k']), idMapOf(['j'])]), true],
|
||||
['diffIdMap (exclude all)', () => Y.diffIdMap(idMapOf(['k']), idSetOf()), false],
|
||||
['diffIdMap (partial)', () => {
|
||||
const exclude = Y.createIdSet()
|
||||
exclude.add(1, 0, 5)
|
||||
return Y.diffIdMap(idMapOf(['k']), exclude)
|
||||
}, true],
|
||||
['intersectMaps', () => Y.intersectMaps(idMapOf(['k']), idMapOf(['k'])), true],
|
||||
['createIdMapFromIdSet', () => Y.createIdMapFromIdSet(idSetOf(), [attr('k')]), true],
|
||||
['filterIdMap (keep)', () => Y.filterIdMap(idMapOf(['k']), () => true), true],
|
||||
['filterIdMap (drop)', () => Y.filterIdMap(idMapOf(['k']), () => false), false],
|
||||
['decodeIdMap(encodeIdMap)', () => Y.decodeIdMap(Y.encodeIdMap(idMapOf(['k']))), true],
|
||||
['insertIntoIdMap(dest, IdMap)', () => {
|
||||
const dest = Y.createIdMap()
|
||||
Y.insertIntoIdMap(dest, idMapOf(['k']))
|
||||
return dest
|
||||
}, true]
|
||||
]
|
||||
producers.forEach(([name, produce, keepsValues]) => {
|
||||
t.group(name, () => {
|
||||
const m = produce()
|
||||
t.assert(Y.$idMapAny.check(m), 'is an IdMap')
|
||||
t.assert(Y.$idMap(s.$string).check(m), 'holds only string values (vacuously so when empty)')
|
||||
if (keepsValues) {
|
||||
t.assert(!Y.$idMap(s.$number).check(m), 'carries its value through, so it rejects $number')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `insertIntoIdMap`'s `src` may be an IdSet, which puts attribute-less `IdRanges` into an `IdMap`.
|
||||
* `$idMap` must not throw on those - `IdRange#attrs` is `[]` by design, so they are vacuously fine.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIdMapWithIdRanges = _tc => {
|
||||
t.group('an IdSet merged into an IdMap', () => {
|
||||
const dest = Y.createIdMap()
|
||||
Y.insertIntoIdMap(dest, idSetOf())
|
||||
t.assert(Y.$idMap(s.$string).check(dest), 'no attributes present, so nothing can violate $attrs')
|
||||
t.assert(Y.$idMap(s.$number).check(dest))
|
||||
})
|
||||
t.group('a map holding both AttrRanges and IdRanges still checks its values', () => {
|
||||
const dest = idMapOf(['k']) // client 1 -> AttrRanges
|
||||
const other = Y.createIdSet()
|
||||
other.add(2, 0, 10) // client 2 -> IdRanges
|
||||
Y.insertIntoIdMap(dest, other)
|
||||
t.assert(Y.$idMap(s.$string).check(dest))
|
||||
t.assert(!Y.$idMap(s.$number).check(dest), 'client 1 still holds a string')
|
||||
})
|
||||
t.group('an IdRanges container that later receives AttrRanges on the SAME client', () => {
|
||||
// `_insertIntoIdSet` picks the container class from whichever `src` reaches a client first, and
|
||||
// its append branch copies elements without checking their class. So an IdSet-first / IdMap-
|
||||
// second sequence leaves real AttrRanges inside an `IdRanges`. `IdRanges#everyAttr` must
|
||||
// iterate rather than shortcut to `true`, or these attributes become invisible to `$idMap`.
|
||||
const dest = Y.createIdMap()
|
||||
const set = Y.createIdSet()
|
||||
set.add(1, 0, 10)
|
||||
Y.insertIntoIdMap(dest, set) // client 1 -> IdRanges container
|
||||
Y.insertIntoIdMap(dest, idMapOf([42])) // same client -> AttrRange(42) appended into it
|
||||
t.assert(/** @type {any} */ (dest.clients.get(1)).constructor.name === 'IdRanges', 'precondition: still an IdRanges')
|
||||
t.assert(!Y.$idMap(s.$string).check(dest), 'the leaked number 42 must be seen')
|
||||
t.assert(!Y.$idMap(s.$never).check(dest), '$never must reject a map that holds a value')
|
||||
t.assert(Y.$idMap(s.$number).check(dest), 'and accepted for the matching schema')
|
||||
t.group('the verdict agrees with an encode round-trip', () => {
|
||||
const rt = Y.decodeIdMap(Y.encodeIdMap(dest))
|
||||
t.assert(Y.$idMap(s.$string).check(rt) === Y.$idMap(s.$string).check(dest))
|
||||
t.assert(Y.$idMap(s.$number).check(rt) === Y.$idMap(s.$number).check(dest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testNodeSchemas = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const unnamed = ydoc.get('unnamed')
|
||||
const para = ydoc.get('para', 'p')
|
||||
t.group('$nodeAny', () => {
|
||||
t.assert(Y.$nodeAny.check(unnamed))
|
||||
t.assert(Y.$nodeAny.check(para))
|
||||
t.assert(!Y.$nodeAny.check(ydoc), 'a Doc is not a Node')
|
||||
t.assert(!Y.$doc.check(para), 'a Node is not a Doc')
|
||||
})
|
||||
t.group('$node(conf) checks the name', () => {
|
||||
t.assert(Y.$node({ name: 'p' }).check(para))
|
||||
t.assert(!Y.$node({ name: 'h1' }).check(para), 'discriminates between named nodes')
|
||||
})
|
||||
t.group('a conf without a name accepts any node', () => {
|
||||
t.assert(Y.$node({ text: true }).check(para))
|
||||
t.assert(Y.$node({ text: true }).check(unnamed))
|
||||
t.assert(Y.$node({}).check(unnamed))
|
||||
})
|
||||
t.group('every ReadableDeltaConf name form', () => {
|
||||
t.assert(Y.$node({ name: ['p', 'h1'] }).check(para), 'array form')
|
||||
t.assert(!Y.$node({ name: ['h1', 'h2'] }).check(para))
|
||||
t.assert(Y.$node({ name: s.$string }).check(para), 'schema form')
|
||||
})
|
||||
t.group('a named conf rejects a nameless node', () => {
|
||||
// Deliberately stricter than lib0's $Delta.check, which skips the name when name == null: a
|
||||
// nameless *node* is definitively a fragment, whereas a nameless *delta* asserts nothing.
|
||||
t.assert(unnamed.name === null, 'precondition')
|
||||
t.assert(!Y.$node({ name: 'p' }).check(unnamed))
|
||||
t.assert(!Y.$node({ name: s.$string }).check(unnamed))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags are interned in a global cross-install registry, so re-declaring a name returns the very
|
||||
* same schema object. That is what makes `$type` sound across duplicate yjs installations, and what
|
||||
* an `x.$type === $idMapAny` dispatch would rely on.
|
||||
*
|
||||
* Identity (`===`), never `.equals()` - `$Type#equals` compares an unset `.shape`, so any two
|
||||
* `$Type`s compare equal.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testTagInterning = _tc => {
|
||||
t.assert(s.$type('y:idSet', null) === Y.$idSet)
|
||||
t.assert(s.$type('y:idMap', null) === Y.$idMapAny)
|
||||
t.assert(s.$type('y:node', null) === Y.$nodeAny)
|
||||
t.assert(s.$type('y:doc', null) === Y.$doc)
|
||||
t.assert(s.$type('y:renderer', null) === Y.$renderer)
|
||||
}
|
||||
|
||||
/**
|
||||
* The prototype stamps are what `check()` reads.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testPrototypeStamps = _tc => {
|
||||
t.assert(Y.IdSet.prototype.$type === Y.$idSet)
|
||||
t.assert(Y.IdMap.prototype.$type === Y.$idMapAny)
|
||||
t.assert(Y.Node.prototype.$type === Y.$nodeAny)
|
||||
t.assert(Y.Doc.prototype.$type === Y.$doc)
|
||||
t.group('instances carry the tag', () => {
|
||||
t.assert(Y.createIdSet().$type === Y.$idSet)
|
||||
t.assert(Y.createIdMap().$type === Y.$idMapAny)
|
||||
t.assert(new Y.Doc().get('n').$type === Y.$nodeAny)
|
||||
t.assert(new Y.Doc().$type === Y.$doc)
|
||||
})
|
||||
}
|
||||
244
tests/snapshot.tests.js
Normal file
244
tests/snapshot.tests.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import { init } from './testHelper.js'
|
||||
import * as delta from 'lib0/delta'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testBasic = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
ydoc.get().insert(0, 'world!')
|
||||
const snapshot = Y.snapshot(ydoc)
|
||||
ydoc.get().insert(0, 'hello ')
|
||||
const restored = Y.createDocFromSnapshot(ydoc, snapshot)
|
||||
t.assert(restored.get().toDelta().equals(delta.create().insert('world!').done()))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testBasicXmlAttributes = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const yxml = ydoc.get().setAttr('el', new Y.Node('div'))
|
||||
const snapshot1 = Y.snapshot(ydoc)
|
||||
yxml.setAttr('a', '1')
|
||||
const snapshot2 = Y.snapshot(ydoc)
|
||||
yxml.setAttr('a', '2')
|
||||
t.compare(yxml.getAttrs(), { a: '2' })
|
||||
t.compare(yxml.getAttrs(snapshot2), { a: '1' })
|
||||
t.compare(yxml.getAttrs(snapshot1), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testBasicRestoreSnapshot = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.get('array').insert(0, ['hello'])
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.get('array').insert(1, ['world'])
|
||||
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.get('array').toArray(), ['hello'])
|
||||
t.compare(doc.get('array').toJSON().children, ['hello', 'world'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testEmptyRestoreSnapshot = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const snap = Y.snapshot(doc)
|
||||
snap.sv.set(9999, 0)
|
||||
doc.get().insert(0, ['world'])
|
||||
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.get().toArray(), [])
|
||||
t.compare(doc.get().toArray(), ['world'])
|
||||
|
||||
// now this snapshot reflects the latest state. It should still work.
|
||||
const snap2 = Y.snapshot(doc)
|
||||
const docRestored2 = Y.createDocFromSnapshot(doc, snap2)
|
||||
t.compare(docRestored2.get().toArray(), ['world'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRestoreSnapshotWithSubType = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.get('array').insert(0, [new Y.Node()])
|
||||
const subMap = doc.get('array').get(0)
|
||||
subMap.setAttr('key1', 'value1')
|
||||
|
||||
const snap = Y.snapshot(doc)
|
||||
subMap.setAttr('key2', 'value2')
|
||||
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.get('array').toJSON().children, [{
|
||||
attrs: {
|
||||
key1: 'value1'
|
||||
}
|
||||
}])
|
||||
t.compare(doc.get('array').toJSON().children, [{
|
||||
attrs: {
|
||||
key1: 'value1',
|
||||
key2: 'value2'
|
||||
}
|
||||
}])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRestoreDeletedItem1 = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.get('array').insert(0, ['item1', 'item2'])
|
||||
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.get('array').delete(0)
|
||||
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.get('array').toArray(), ['item1', 'item2'])
|
||||
t.compare(doc.get('array').toArray(), ['item2'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRestoreLeftItem = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.get('array').insert(0, ['item1'])
|
||||
doc.get('map').setAttr('test', 1)
|
||||
doc.get('array').insert(0, ['item0'])
|
||||
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.get('array').delete(1)
|
||||
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.get('array').toArray(), ['item0', 'item1'])
|
||||
t.compare(doc.get('array').toArray(), ['item0'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeletedItemsBase = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.get('array').insert(0, ['item1'])
|
||||
doc.get('array').delete(0)
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.get('array').insert(0, ['item0'])
|
||||
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.get('array').toArray(), [])
|
||||
t.compare(doc.get('array').toArray(), ['item0'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeletedItems2 = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
doc.get('array').insert(0, ['item1', 'item2', 'item3'])
|
||||
doc.get('array').delete(1)
|
||||
const snap = Y.snapshot(doc)
|
||||
doc.get('array').insert(0, ['item0'])
|
||||
|
||||
const docRestored = Y.createDocFromSnapshot(doc, snap)
|
||||
|
||||
t.compare(docRestored.get('array').toArray(), ['item1', 'item3'])
|
||||
t.compare(doc.get('array').toArray(), ['item0', 'item1', 'item3'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testDependentChanges = tc => {
|
||||
const { array0, array1, testConnector } = init(tc, { users: 2 })
|
||||
|
||||
if (!array0.doc) {
|
||||
throw new Error('no document 0')
|
||||
}
|
||||
if (!array1.doc) {
|
||||
throw new Error('no document 1')
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Y.Doc}
|
||||
*/
|
||||
const doc0 = array0.doc
|
||||
/**
|
||||
* @type {Y.Doc}
|
||||
*/
|
||||
const doc1 = array1.doc
|
||||
|
||||
doc0.gc = false
|
||||
doc1.gc = false
|
||||
|
||||
array0.insert(0, ['user1item1'])
|
||||
testConnector.syncAll()
|
||||
array1.insert(1, ['user2item1'])
|
||||
testConnector.syncAll()
|
||||
|
||||
const snap = Y.snapshot(array0.doc)
|
||||
|
||||
array0.insert(2, ['user1item2'])
|
||||
testConnector.syncAll()
|
||||
array1.insert(3, ['user2item2'])
|
||||
testConnector.syncAll()
|
||||
|
||||
const docRestored0 = Y.createDocFromSnapshot(array0.doc, snap)
|
||||
t.compare(docRestored0.get('array').toArray(), ['user1item1', 'user2item1'])
|
||||
|
||||
const docRestored1 = Y.createDocFromSnapshot(array1.doc, snap)
|
||||
t.compare(docRestored1.get('array').toArray(), ['user1item1', 'user2item1'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testContainsUpdate = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
/**
|
||||
* @type {Array<Uint8Array>}
|
||||
*/
|
||||
const updates = []
|
||||
ydoc.on('update', update => {
|
||||
updates.push(update)
|
||||
})
|
||||
const yarr = ydoc.get()
|
||||
const snapshot1 = Y.snapshot(ydoc)
|
||||
yarr.insert(0, [1])
|
||||
const snapshot2 = Y.snapshot(ydoc)
|
||||
yarr.delete(0, 1)
|
||||
const snapshotFinal = Y.snapshot(ydoc)
|
||||
t.assert(!Y.snapshotContainsUpdate(snapshot1, updates[0]))
|
||||
t.assert(!Y.snapshotContainsUpdate(snapshot2, updates[1]))
|
||||
t.assert(Y.snapshotContainsUpdate(snapshot2, updates[0]))
|
||||
t.assert(Y.snapshotContainsUpdate(snapshotFinal, updates[0]))
|
||||
t.assert(Y.snapshotContainsUpdate(snapshotFinal, updates[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reported by https://github.com/yjs/yjs/issues/767
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testContainsUpdate2 = _tc => {
|
||||
const local = new Y.Doc()
|
||||
const remote = new Y.Doc()
|
||||
local.get('t').insert(0, 'abcdefghij')
|
||||
local.get('t').delete(0, 3)
|
||||
Y.applyUpdate(remote, Y.encodeStateAsUpdate(local))
|
||||
const snap = Y.snapshot(remote)
|
||||
local.get('t').delete(0, 3)
|
||||
const update = Y.encodeStateAsUpdate(local)
|
||||
t.assert(!Y.snapshotContainsUpdate(snap, update))
|
||||
}
|
||||
595
tests/testHelper.js
Normal file
595
tests/testHelper.js
Normal file
@@ -0,0 +1,595 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as prng from 'lib0/prng'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as syncProtocol from '@y/protocols/sync'
|
||||
import * as object from 'lib0/object'
|
||||
import * as map from 'lib0/map'
|
||||
import * as math from 'lib0/math'
|
||||
import * as list from 'lib0/list'
|
||||
import * as delta from 'lib0/delta'
|
||||
import { createIdSet, createIdMap, encodeIdMap } from '../src/utils/ids.js'
|
||||
|
||||
export * from '../src/index.js'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
window.Y = Y // eslint-disable-line
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TestYInstance} y // publish message created by `y` to all other online clients
|
||||
* @param {Uint8Array} m
|
||||
*/
|
||||
const broadcastMessage = (y, m) => {
|
||||
if (y.tc.onlineConns.has(y)) {
|
||||
y.tc.onlineConns.forEach(remoteYInstance => {
|
||||
if (remoteYInstance !== y) {
|
||||
remoteYInstance._receive(m, y)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export let useV2 = false
|
||||
|
||||
export const encV1 = {
|
||||
encodeStateAsUpdate: Y.encodeStateAsUpdate,
|
||||
mergeUpdates: Y.mergeUpdates,
|
||||
applyUpdate: Y.applyUpdate,
|
||||
logUpdate: Y.logUpdate,
|
||||
updateEventName: /** @type {'update'} */ ('update'),
|
||||
diffUpdate: Y.diffUpdate
|
||||
}
|
||||
|
||||
export const encV2 = {
|
||||
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
|
||||
mergeUpdates: Y.mergeUpdatesV2,
|
||||
applyUpdate: Y.applyUpdateV2,
|
||||
logUpdate: Y.logUpdateV2,
|
||||
updateEventName: /** @type {'updateV2'} */ ('updateV2'),
|
||||
diffUpdate: Y.diffUpdateV2
|
||||
}
|
||||
|
||||
export let enc = encV1
|
||||
|
||||
const useV1Encoding = () => {
|
||||
useV2 = false
|
||||
enc = encV1
|
||||
}
|
||||
|
||||
const useV2Encoding = () => {
|
||||
console.error('sync protocol doesnt support v2 protocol yet, fallback to v1 encoding') // @Todo
|
||||
useV2 = false
|
||||
enc = encV1
|
||||
}
|
||||
|
||||
/**
|
||||
* @extends {Y.Doc}
|
||||
*/
|
||||
export class TestYInstance extends Y.Doc {
|
||||
/**
|
||||
* @param {TestConnector} testConnector
|
||||
* @param {number} clientID
|
||||
*/
|
||||
constructor (testConnector, clientID) {
|
||||
super()
|
||||
this.userID = clientID // overwriting clientID
|
||||
/**
|
||||
* @type {TestConnector}
|
||||
*/
|
||||
this.tc = testConnector
|
||||
/**
|
||||
* @type {Map<TestYInstance, Array<Uint8Array>>}
|
||||
*/
|
||||
this.receiving = new Map()
|
||||
testConnector.allConns.add(this)
|
||||
/**
|
||||
* The list of received updates.
|
||||
* We are going to merge them later using Y.mergeUpdates and check if the resulting document is correct.
|
||||
* @type {Array<Uint8Array<ArrayBuffer>>}
|
||||
*/
|
||||
this.updates = []
|
||||
// set up observe on local model
|
||||
this.on(enc.updateEventName, (update, origin) => {
|
||||
if (origin !== testConnector) {
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeUpdate(encoder, update)
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder))
|
||||
}
|
||||
this.updates.push(update)
|
||||
})
|
||||
this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from TestConnector.
|
||||
*/
|
||||
disconnect () {
|
||||
this.receiving = new Map()
|
||||
this.tc.onlineConns.delete(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append yourself to the list of known Y instances in testconnector.
|
||||
* Also initiate sync with all clients.
|
||||
*/
|
||||
connect () {
|
||||
if (!this.tc.onlineConns.has(this)) {
|
||||
this.tc.onlineConns.add(this)
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeSyncStep1(encoder, /** @type {any} */ (this))
|
||||
// publish SyncStep1
|
||||
broadcastMessage(this, encoding.toUint8Array(encoder))
|
||||
this.tc.onlineConns.forEach(remoteYInstance => {
|
||||
if (remoteYInstance !== this) {
|
||||
// remote instance sends instance to this instance
|
||||
const encoder = encoding.createEncoder()
|
||||
syncProtocol.writeSyncStep1(encoder, /** @type {any} */ (remoteYInstance))
|
||||
this._receive(encoding.toUint8Array(encoder), remoteYInstance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive a message from another client. This message is only appended to the list of receiving messages.
|
||||
* TestConnector decides when this client actually reads this message.
|
||||
*
|
||||
* @param {Uint8Array} message
|
||||
* @param {TestYInstance} remoteClient
|
||||
*/
|
||||
_receive (message, remoteClient) {
|
||||
map.setIfUndefined(this.receiving, remoteClient, () => /** @type {Array<Uint8Array>} */ ([])).push(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps track of TestYInstances.
|
||||
*
|
||||
* The TestYInstances add/remove themselves from the list of connections maiained in this object.
|
||||
* I think it makes sense. Deal with it.
|
||||
*/
|
||||
export class TestConnector {
|
||||
/**
|
||||
* @param {prng.PRNG} gen
|
||||
*/
|
||||
constructor (gen) {
|
||||
/**
|
||||
* @type {Set<TestYInstance>}
|
||||
*/
|
||||
this.allConns = new Set()
|
||||
/**
|
||||
* @type {Set<TestYInstance>}
|
||||
*/
|
||||
this.onlineConns = new Set()
|
||||
/**
|
||||
* @type {prng.PRNG}
|
||||
*/
|
||||
this.prng = gen
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Y instance and add it to the list of connections
|
||||
* @param {number} clientID
|
||||
*/
|
||||
createY (clientID) {
|
||||
return new TestYInstance(this, clientID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose random connection and flush a random message from a random sender.
|
||||
*
|
||||
* If this function was unable to flush a message, because there are no more messages to flush, it returns false. true otherwise.
|
||||
* @return {boolean}
|
||||
*/
|
||||
flushRandomMessage () {
|
||||
const gen = this.prng
|
||||
const conns = Array.from(this.onlineConns).filter(conn => conn.receiving.size > 0)
|
||||
if (conns.length > 0) {
|
||||
const receiver = prng.oneOf(gen, conns)
|
||||
const [sender, messages] = prng.oneOf(gen, Array.from(receiver.receiving))
|
||||
const m = messages.shift()
|
||||
if (messages.length === 0) {
|
||||
receiver.receiving.delete(sender)
|
||||
}
|
||||
if (m === undefined) {
|
||||
return this.flushRandomMessage()
|
||||
}
|
||||
const encoder = encoding.createEncoder()
|
||||
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
|
||||
// do not publish data created when this function is executed (could be ss2 or update message)
|
||||
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, /** @type {any} */ (receiver), receiver.tc)
|
||||
if (encoding.length(encoder) > 0) {
|
||||
// send reply message
|
||||
sender._receive(encoding.toUint8Array(encoder), receiver)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} True iff this function actually flushed something
|
||||
*/
|
||||
flushAllMessages () {
|
||||
let didSomething = false
|
||||
while (this.flushRandomMessage()) {
|
||||
didSomething = true
|
||||
}
|
||||
return didSomething
|
||||
}
|
||||
|
||||
reconnectAll () {
|
||||
this.allConns.forEach(conn => conn.connect())
|
||||
}
|
||||
|
||||
disconnectAll () {
|
||||
this.allConns.forEach(conn => conn.disconnect())
|
||||
}
|
||||
|
||||
syncAll () {
|
||||
this.reconnectAll()
|
||||
this.flushAllMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} Whether it was possible to disconnect a random connection.
|
||||
*/
|
||||
disconnectRandom () {
|
||||
if (this.onlineConns.size === 0) {
|
||||
return false
|
||||
}
|
||||
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} Whether it was possible to reconnect a random connection.
|
||||
*/
|
||||
reconnectRandom () {
|
||||
/**
|
||||
* @type {Array<TestYInstance>}
|
||||
*/
|
||||
const reconnectable = []
|
||||
this.allConns.forEach(conn => {
|
||||
if (!this.onlineConns.has(conn)) {
|
||||
reconnectable.push(conn)
|
||||
}
|
||||
})
|
||||
if (reconnectable.length === 0) {
|
||||
return false
|
||||
}
|
||||
prng.oneOf(this.prng, reconnectable).connect()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {t.TestCase} tc
|
||||
* @param {{users?:number}} conf
|
||||
* @param {InitTestObjectCallback<T>} [initTestObject]
|
||||
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Node<any>,array1:Y.Node<any>,array2:Y.Node<any>,map0:Y.Node<any>,map1:Y.Node<any>,map2:Y.Node<any>,map3:Y.Node<any>,text0:Y.Node,text1:Y.Node,text2:Y.Node,xml0:Y.Node,xml1:Y.Node,xml2:Y.Node}}
|
||||
*/
|
||||
export const init = (tc, { users = 5 } = {}, initTestObject) => {
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
const result = {
|
||||
users: []
|
||||
}
|
||||
const gen = tc.prng
|
||||
// choose an encoding approach at random
|
||||
if (prng.bool(gen)) {
|
||||
useV2Encoding()
|
||||
} else {
|
||||
useV1Encoding()
|
||||
}
|
||||
|
||||
const testConnector = new TestConnector(gen)
|
||||
result.testConnector = testConnector
|
||||
for (let i = 0; i < users; i++) {
|
||||
const y = testConnector.createY(i)
|
||||
y.clientID = i
|
||||
result.users.push(y)
|
||||
result['array' + i] = y.get('array')
|
||||
result['map' + i] = y.get('map')
|
||||
result['xml' + i] = y.get('xml')
|
||||
result['text' + i] = y.get('text')
|
||||
}
|
||||
testConnector.syncAll()
|
||||
result.testObjects = result.users.map(initTestObject || (() => null))
|
||||
useV1Encoding()
|
||||
return /** @type {any} */ (result)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.IdSet} idSet1
|
||||
* @param {Y.IdSet} idSet2
|
||||
*/
|
||||
export const compareIdSets = (idSet1, idSet2) => {
|
||||
t.assert(idSet1.clients.size === idSet2.clients.size)
|
||||
for (const [client, _items1] of idSet1.clients.entries()) {
|
||||
const items1 = _items1.getIds()
|
||||
const items2 = idSet2.clients.get(client)?.getIds()
|
||||
t.assert(items2 !== undefined && items1.length === items2.length)
|
||||
for (let i = 0; i < items1.length; i++) {
|
||||
const di1 = items1[i]
|
||||
const di2 = /** @type {Array<import('../src/utils/ids.js').IdRange>} */ (items2)[i]
|
||||
t.assert(di1.clock === di2.clock && di1.len === di2.len)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* only use for testing
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<Y.ContentAttribute<T>>} attrs
|
||||
* @param {Y.ContentAttribute<T>} attr
|
||||
*
|
||||
*/
|
||||
const _idmapAttrsHas = (attrs, attr) => {
|
||||
const hash = attr.hash()
|
||||
return attrs.find(a => a.hash() === hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* only use for testing
|
||||
*
|
||||
* @template T
|
||||
* @param {Array<Y.ContentAttribute<T>>} a
|
||||
* @param {Array<Y.ContentAttribute<T>>} b
|
||||
*/
|
||||
export const _idmapAttrsEqual = (a, b) => a.length === b.length && a.every(v => _idmapAttrsHas(b, v))
|
||||
|
||||
/**
|
||||
* Ensure that all attributes exist. Also create a copy and compare it to the original.
|
||||
*
|
||||
* @template T
|
||||
* @param {Y.IdMap<T>} idmap
|
||||
*/
|
||||
export const validateIdMap = idmap => {
|
||||
const copy = Y.createIdMap()
|
||||
idmap.clients.forEach((ranges, client) => {
|
||||
ranges.getIds().forEach(range => {
|
||||
range.attrs.forEach(attr => {
|
||||
t.assert(idmap.attrs.has(attr))
|
||||
t.assert(idmap.attrsH.get(attr.hash()) === attr)
|
||||
copy.add(client, range.clock, range.len, range.attrs.slice())
|
||||
})
|
||||
})
|
||||
t.assert(copy.clients.get(client)?.getIds().length === ranges.getIds().length)
|
||||
})
|
||||
t.assert(idmap.attrsH.size === idmap.attrs.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {Y.IdMap<T>} idmap1
|
||||
* @param {Y.IdMap<T>} idmap2
|
||||
*/
|
||||
export const compareIdmaps = (idmap1, idmap2) => {
|
||||
t.assert(idmap1.clients.size === idmap2.clients.size)
|
||||
for (const [client, _items1] of idmap1.clients.entries()) {
|
||||
const items1 = _items1.getIds()
|
||||
const items2 = idmap2.clients.get(client)?.getIds()
|
||||
t.assert(items2 !== undefined && items1.length === items2.length)
|
||||
for (let i = 0; i < items1.length; i++) {
|
||||
const di1 = items1[i]
|
||||
const di2 = /** @type {Array<import('../src/utils/ids.js').AttrRange<T>>} */ (items2)[i]
|
||||
t.assert(di1.clock === di2.clock && di1.len === di2.len && _idmapAttrsEqual(di1.attrs, di2.attrs))
|
||||
}
|
||||
}
|
||||
validateIdMap(idmap1)
|
||||
validateIdMap(idmap2)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {prng.PRNG} gen
|
||||
* @param {number} clients
|
||||
* @param {number} clockRange (max clock - exclusive - by each client)
|
||||
*/
|
||||
export const createRandomIdSet = (gen, clients, clockRange) => {
|
||||
const maxOpLen = 5
|
||||
const numOfOps = math.ceil((clients * clockRange) / maxOpLen)
|
||||
const idset = createIdSet()
|
||||
for (let i = 0; i < numOfOps; i++) {
|
||||
const client = prng.uint32(gen, 0, clients - 1)
|
||||
const clockStart = prng.uint32(gen, 0, clockRange)
|
||||
const len = prng.uint32(gen, 0, clockRange - clockStart)
|
||||
idset.add(client, clockStart, len)
|
||||
}
|
||||
if (idset.clients.size === clients && clients > 1 && prng.bool(gen)) {
|
||||
idset.clients.delete(prng.uint32(gen, 0, clients))
|
||||
}
|
||||
return idset
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {prng.PRNG} gen
|
||||
* @param {number} clients
|
||||
* @param {number} clockRange (max clock - exclusive - by each client)
|
||||
* @param {Array<T>} attrChoices (max clock - exclusive - by each client)
|
||||
* @return {Y.IdMap<T>}
|
||||
*/
|
||||
export const createRandomIdMap = (gen, clients, clockRange, attrChoices) => {
|
||||
const maxOpLen = 5
|
||||
const numOfOps = math.ceil((clients * clockRange) / maxOpLen)
|
||||
const idMap = createIdMap()
|
||||
for (let i = 0; i < numOfOps; i++) {
|
||||
const client = prng.uint32(gen, 0, clients - 1)
|
||||
const clockStart = prng.uint32(gen, 0, clockRange)
|
||||
const len = prng.uint32(gen, 0, clockRange - clockStart)
|
||||
const attrs = [prng.oneOf(gen, attrChoices)]
|
||||
// maybe add another attr
|
||||
if (prng.bool(gen)) {
|
||||
const a = prng.oneOf(gen, attrChoices)
|
||||
if (attrs.find(attr => attr === a) == null) {
|
||||
attrs.push(a)
|
||||
}
|
||||
}
|
||||
idMap.add(client, clockStart, len, attrs.map(v => Y.createContentAttribute('', v)))
|
||||
}
|
||||
t.info(`Created IdMap with ${numOfOps} ranges and ${attrChoices.length} different attributes. Encoded size: ${encodeIdMap(idMap).byteLength}`)
|
||||
return idMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. reconnect and flush all
|
||||
* 2. user 0 gc
|
||||
* 3. get type content
|
||||
* 4. disconnect & reconnect all (so gc is propagated)
|
||||
* 5. compare os, ds, ss
|
||||
*
|
||||
* @param {Array<TestYInstance>} users
|
||||
*/
|
||||
export const compare = users => {
|
||||
users.forEach(u => u.connect())
|
||||
while (users[0].tc.flushAllMessages()) {} // eslint-disable-line
|
||||
// For each document, merge all received document updates with Y.mergeUpdates and create a new document which will be added to the list of "users"
|
||||
// This ensures that mergeUpdates works correctly
|
||||
const mergedDocs = users.map(user => {
|
||||
const ydoc = new Y.Doc()
|
||||
enc.applyUpdate(ydoc, enc.mergeUpdates(user.updates))
|
||||
return ydoc
|
||||
})
|
||||
users.push(.../** @type {any} */(mergedDocs))
|
||||
const userArrayValues = users.map(u => u.get('array').toJSON().children ?? [])
|
||||
const userMapValues = users.map(u => u.get('map').toJSON().attrs ?? {})
|
||||
// @todo fix type error here
|
||||
// @ts-ignore
|
||||
const userXmlValues = users.map(u => u.get('xml').toString())
|
||||
const userTextValues = users.map(u => u.get('text').toDeltaDeep())
|
||||
for (const u of users) {
|
||||
t.assert(u.store.pendingDs === null)
|
||||
t.assert(u.store.pendingStructs === null)
|
||||
}
|
||||
// Test Map iterator
|
||||
const ymapkeys = Array.from(users[0].get('map').attrKeys())
|
||||
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
|
||||
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
|
||||
// Compare all users
|
||||
for (let i = 0; i < users.length - 1; i++) {
|
||||
t.compare(userArrayValues[i].length, users[i].get('array').length)
|
||||
t.compare(userArrayValues[i], userArrayValues[i + 1])
|
||||
t.compare(userMapValues[i], userMapValues[i + 1])
|
||||
t.compare(userXmlValues[i], userXmlValues[i + 1])
|
||||
t.compare(list.toArray(userTextValues[i].children).map(a => (delta.$textOp.check(a) || delta.$insertOp.check(a)) ? a.insert.length : 0).reduce((a, b) => a + b, 0), users[i].get('text').length)
|
||||
t.compare(userTextValues[i], userTextValues[i + 1], '', (_constructor, a, b) => {
|
||||
if (a instanceof Y.Node) {
|
||||
t.compare(a.toJSON(), b.toJSON())
|
||||
} else if (a !== b) {
|
||||
t.fail('Deltas dont match')
|
||||
}
|
||||
return true
|
||||
})
|
||||
t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1]))
|
||||
Y.equalIdSets(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
|
||||
compareStructStores(users[i].store, users[i + 1].store)
|
||||
t.compare(Y.encodeSnapshot(Y.snapshot(users[i])), Y.encodeSnapshot(Y.snapshot(users[i + 1])))
|
||||
}
|
||||
users.forEach(user => {
|
||||
compareIdSets(user.store.ds, Y.createDeleteSetFromStructStore(user.store))
|
||||
})
|
||||
users.map(u => u.destroy())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.Item?} a
|
||||
* @param {Y.Item?} b
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
|
||||
|
||||
/**
|
||||
* @param {StructStore} ss1
|
||||
* @param {StructStore} ss2
|
||||
*/
|
||||
export const compareStructStores = (ss1, ss2) => {
|
||||
t.assert(ss1.clients.size === ss2.clients.size)
|
||||
for (const [client, structs1] of ss1.clients) {
|
||||
const structs2 = /** @type {Array<Y.AbstractStruct>} */ (ss2.clients.get(client))
|
||||
t.assert(structs2 !== undefined && structs1.length === structs2.length)
|
||||
for (let i = 0; i < structs1.length; i++) {
|
||||
const s1 = structs1[i]
|
||||
const s2 = structs2[i]
|
||||
// checks for abstract struct
|
||||
if (
|
||||
s1.constructor !== s2.constructor ||
|
||||
!Y.compareIDs(s1.id, s2.id) ||
|
||||
s1.deleted !== s2.deleted ||
|
||||
// @ts-ignore
|
||||
s1.length !== s2.length
|
||||
) {
|
||||
t.fail('Structs dont match')
|
||||
}
|
||||
if (s1 instanceof Y.Item) {
|
||||
if (
|
||||
!(s2 instanceof Y.Item) ||
|
||||
!((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) ||
|
||||
!compareItemIDs(s1.right, s2.right) ||
|
||||
!Y.compareIDs(s1.origin, s2.origin) ||
|
||||
!Y.compareIDs(s1.rightOrigin, s2.rightOrigin) ||
|
||||
s1.parentSub !== s2.parentSub
|
||||
) {
|
||||
return t.fail('Items dont match')
|
||||
}
|
||||
// make sure that items are connected correctly
|
||||
t.assert(s1.left === null || s1.left.right === s1)
|
||||
t.assert(s1.right === null || s1.right.left === s1)
|
||||
t.assert(s2.left === null || s2.left.right === s2)
|
||||
t.assert(s2.right === null || s2.right.left === s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @callback InitTestObjectCallback
|
||||
* @param {TestYInstance} y
|
||||
* @return {T}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {t.TestCase} tc
|
||||
* @param {Array<function(Y.Doc,prng.PRNG,T):void>} mods
|
||||
* @param {number} iterations
|
||||
* @param {InitTestObjectCallback<T>} [initTestObject]
|
||||
*/
|
||||
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
|
||||
const gen = tc.prng
|
||||
const result = init(tc, { users: 5 }, initTestObject)
|
||||
const { testConnector, users } = result
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (prng.int32(gen, 0, 100) <= 2) {
|
||||
// 2% chance to disconnect/reconnect a random user
|
||||
if (prng.bool(gen)) {
|
||||
testConnector.disconnectRandom()
|
||||
} else {
|
||||
testConnector.reconnectRandom()
|
||||
}
|
||||
} else if (prng.int32(gen, 0, 100) <= 1) {
|
||||
// 1% chance to flush all
|
||||
testConnector.flushAllMessages()
|
||||
} else if (prng.int32(gen, 0, 100) <= 50) {
|
||||
// 50% chance to flush a random message
|
||||
testConnector.flushRandomMessage()
|
||||
}
|
||||
const user = prng.int32(gen, 0, users.length - 1)
|
||||
const test = prng.oneOf(gen, mods)
|
||||
test(users[user], gen, result.testObjects[user])
|
||||
}
|
||||
compare(users)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {ReturnType<typeof applyRandomTests>} ApplyRandomTestsResult
|
||||
*/
|
||||
825
tests/undo-redo.tests.js
Normal file
825
tests/undo-redo.tests.js
Normal file
@@ -0,0 +1,825 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import { init } from './testHelper.js' // eslint-disable-line
|
||||
import * as t from 'lib0/testing'
|
||||
import * as delta from 'lib0/delta'
|
||||
|
||||
export const testInconsistentFormat = () => {
|
||||
/**
|
||||
* @param {Y.Doc} ydoc
|
||||
*/
|
||||
const testYjsMerge = ydoc => {
|
||||
const content = ydoc.get('text')
|
||||
content.format(0, 6, { bold: null })
|
||||
content.format(6, 4, { type: 'text' })
|
||||
t.compare(content.toDelta(), delta.create().insert('Merge Test', { type: 'text' }).insert(' After', { type: 'text', italic: true }).done())
|
||||
}
|
||||
const initializeYDoc = () => {
|
||||
const yDoc = new Y.Doc({ gc: false })
|
||||
const content = yDoc.get('text')
|
||||
content.insert(0, ' After', { type: 'text', italic: true })
|
||||
content.insert(0, 'Test', { type: 'text' })
|
||||
content.insert(0, 'Merge ', { type: 'text', bold: true })
|
||||
return yDoc
|
||||
}
|
||||
{
|
||||
const yDoc = initializeYDoc()
|
||||
testYjsMerge(yDoc)
|
||||
}
|
||||
{
|
||||
const initialYDoc = initializeYDoc()
|
||||
const yDoc = new Y.Doc({ gc: false })
|
||||
Y.applyUpdate(yDoc, Y.encodeStateAsUpdate(initialYDoc))
|
||||
testYjsMerge(yDoc)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInfiniteCaptureTimeout = tc => {
|
||||
const { array0 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(array0, { captureTimeout: Number.MAX_VALUE })
|
||||
array0.push([1, 2, 3])
|
||||
undoManager.stopCapturing()
|
||||
array0.push([4, 5, 6])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toArray(), [1, 2, 3])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoText = tc => {
|
||||
const { testConnector, text0, text1 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
|
||||
// items that are added & deleted in the same transaction won't be undo
|
||||
text0.insert(0, 'test')
|
||||
text0.delete(0, 4)
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
// follow redone items
|
||||
text0.insert(0, 'a')
|
||||
undoManager.stopCapturing()
|
||||
text0.delete(0, 1)
|
||||
undoManager.stopCapturing()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'a')
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
|
||||
text0.insert(0, 'abc')
|
||||
text1.insert(0, 'xyz')
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'xyz')
|
||||
undoManager.redo()
|
||||
t.assert(text0.toString() === 'abcxyz')
|
||||
testConnector.syncAll()
|
||||
text1.delete(0, 1)
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === 'xyz')
|
||||
undoManager.redo()
|
||||
t.assert(text0.toString() === 'bcxyz')
|
||||
// test marks
|
||||
text0.format(1, 3, { bold: true })
|
||||
t.compare(text0.toDelta(), delta.create().insert('b').insert('cxy', { bold: true }).insert('z').done())
|
||||
undoManager.undo()
|
||||
t.compare(text0.toDelta(), delta.create().insert('bcxyz').done())
|
||||
undoManager.redo()
|
||||
t.compare(text0.toDelta(), delta.create().insert('b').insert('cxy', { bold: true }).insert('z').done())
|
||||
}
|
||||
|
||||
/**
|
||||
* Test case to fix #241
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testEmptyTypeScope = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const um = new Y.UndoManager([], { doc: ydoc })
|
||||
const yarray = ydoc.get()
|
||||
um.addToScope(yarray)
|
||||
yarray.insert(0, [1])
|
||||
um.undo()
|
||||
t.assert(yarray.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRejectUpdateExample = _tc => {
|
||||
const tmpydoc1 = new Y.Doc()
|
||||
tmpydoc1.get('restricted').insert(0, [1])
|
||||
tmpydoc1.get('public').insert(0, [1])
|
||||
const update1 = Y.encodeStateAsUpdate(tmpydoc1)
|
||||
const tmpydoc2 = new Y.Doc()
|
||||
tmpydoc2.get('public').insert(0, [2])
|
||||
const update2 = Y.encodeStateAsUpdate(tmpydoc2)
|
||||
|
||||
const ydoc = new Y.Doc()
|
||||
const restrictedType = ydoc.get('restricted')
|
||||
|
||||
/**
|
||||
* Assume this function handles incoming updates via a communication channel like websockets.
|
||||
* Changes to the `ydoc.getMap('restricted')` type should be rejected.
|
||||
*
|
||||
* - set up undo manager on the restricted types
|
||||
* - cache pending* updates from the Ydoc to avoid certain attacks
|
||||
* - apply received update and check whether the restricted type (or any of its children) has been changed.
|
||||
* - catch errors that might try to circumvent the restrictions
|
||||
* - undo changes on restricted types
|
||||
* - reapply pending* updates
|
||||
*
|
||||
* @param {Uint8Array} update
|
||||
*/
|
||||
const updateHandler = (update) => {
|
||||
// don't handle changes of the local undo manager, which is used to undo invalid changes
|
||||
const um = new Y.UndoManager(restrictedType, { trackedOrigins: new Set(['remote change']) })
|
||||
const beforePendingDs = ydoc.store.pendingDs
|
||||
const beforePendingStructs = ydoc.store.pendingStructs?.update
|
||||
try {
|
||||
Y.applyUpdate(ydoc, update, 'remote change')
|
||||
} finally {
|
||||
while (um.undoStack.length) {
|
||||
um.undo()
|
||||
}
|
||||
um.destroy()
|
||||
ydoc.store.pendingDs = beforePendingDs
|
||||
ydoc.store.pendingStructs = null
|
||||
if (beforePendingStructs) {
|
||||
Y.applyUpdateV2(ydoc, beforePendingStructs)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateHandler(update1)
|
||||
updateHandler(update2)
|
||||
t.assert(restrictedType.length === 0)
|
||||
t.assert(ydoc.get('public').length === 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test case to fix #241
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testGlobalScope = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const um = new Y.UndoManager(ydoc)
|
||||
const yarray = ydoc.get()
|
||||
yarray.insert(0, [1])
|
||||
um.undo()
|
||||
t.assert(yarray.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test case to fix #241
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDoubleUndo = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const text = doc.get()
|
||||
text.insert(0, '1221')
|
||||
|
||||
const manager = new Y.UndoManager(text)
|
||||
|
||||
text.insert(2, '3')
|
||||
text.insert(3, '3')
|
||||
|
||||
manager.undo()
|
||||
manager.undo()
|
||||
|
||||
text.insert(2, '3')
|
||||
|
||||
t.compareStrings(text.toString(), '12321')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoMap = tc => {
|
||||
const { testConnector, map0, map1 } = init(tc, { users: 2 })
|
||||
map0.setAttr('a', 0)
|
||||
const undoManager = new Y.UndoManager(map0)
|
||||
map0.setAttr('a', 1)
|
||||
undoManager.undo()
|
||||
t.assert(map0.getAttr('a') === 0)
|
||||
undoManager.redo()
|
||||
t.assert(map0.getAttr('a') === 1)
|
||||
// testing sub-types and if it can restore a whole type
|
||||
const subType = new Y.Node()
|
||||
map0.setAttr('a', subType)
|
||||
subType.setAttr('x', 42)
|
||||
t.compare(map0.toJSON().attrs, /** @type {any} */ ({ a: { attrs: { x: 42 } } }))
|
||||
undoManager.undo()
|
||||
t.assert(map0.getAttr('a') === 1)
|
||||
undoManager.redo()
|
||||
t.compare(map0.toJSON().attrs, /** @type {any} */ ({ a: { attrs: { x: 42 } } }))
|
||||
testConnector.syncAll()
|
||||
// if content is overwritten by another user, undo operations should be skipped
|
||||
map1.setAttr('a', 44)
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.assert(map0.getAttr('a') === 44)
|
||||
undoManager.redo()
|
||||
t.assert(map0.getAttr('a') === 44)
|
||||
|
||||
// test setting value multiple times
|
||||
map0.setAttr('b', 'initial')
|
||||
undoManager.stopCapturing()
|
||||
map0.setAttr('b', 'val1')
|
||||
map0.setAttr('b', 'val2')
|
||||
undoManager.stopCapturing()
|
||||
undoManager.undo()
|
||||
t.assert(map0.getAttr('b') === 'initial')
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: undoing a deletion of an embedded type together with an attribute
|
||||
* change on it must restore the attribute consistently for remote peers. The
|
||||
* redone attribute item used to keep an origin from the original (deleted)
|
||||
* parent, so the value was correct locally but dropped after sync.
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoEmbeddedTypeAttribute = tc => {
|
||||
const { testConnector, text0, text1 } = init(tc, { users: 2 })
|
||||
const button = new Y.Node()
|
||||
button.setAttr('type', 'button')
|
||||
button.setAttr('test', true)
|
||||
button.insert(0, 'Click me')
|
||||
text0.insert(0, [button])
|
||||
testConnector.syncAll()
|
||||
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
undoManager.stopCapturing()
|
||||
// change an attribute, then delete the embedded type
|
||||
button.setAttr('type', 'paragraph')
|
||||
text0.delete(0, 1)
|
||||
// undo both operations at once
|
||||
undoManager.undo()
|
||||
|
||||
const expected = { type: 'button', test: true }
|
||||
t.compare(text0.get(0).getAttrs(), expected)
|
||||
testConnector.syncAll()
|
||||
// remote peer must converge to the same attributes
|
||||
t.compare(text1.get(0).getAttrs(), expected)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoArray = tc => {
|
||||
const { testConnector, array0, array1 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(array0)
|
||||
array0.insert(0, [1, 2, 3])
|
||||
array1.insert(0, [4, 5, 6])
|
||||
testConnector.syncAll()
|
||||
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toArray(), [4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
|
||||
testConnector.syncAll()
|
||||
array1.delete(0, 1) // user1 deletes [1]
|
||||
testConnector.syncAll()
|
||||
undoManager.undo()
|
||||
t.compare(array0.toArray(), [4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toArray(), [2, 3, 4, 5, 6])
|
||||
array0.delete(0, 5)
|
||||
// test nested structure
|
||||
const ymap = new Y.Node()
|
||||
array0.insert(0, [ymap])
|
||||
t.compare(array0.toJSON().children, [{}])
|
||||
undoManager.stopCapturing()
|
||||
ymap.setAttr('a', 1)
|
||||
t.compare(array0.toJSON().children, [{ attrs: { a: 1 } }])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON().children, [{}])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON().children, [2, 3, 4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON().children, [{}])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON().children, [{ attrs: { a: 1 } }])
|
||||
testConnector.syncAll()
|
||||
array1.get(0).setAttr('b', 2)
|
||||
testConnector.syncAll()
|
||||
t.compare(array0.toJSON().children, [{ attrs: { a: 1, b: 2 } }])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON().children, [{ attrs: { b: 2 } }])
|
||||
undoManager.undo()
|
||||
t.compare(array0.toJSON().children, [2, 3, 4, 5, 6])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON().children, [{ attrs: { b: 2 } }])
|
||||
undoManager.redo()
|
||||
t.compare(array0.toJSON().children, [{ attrs: { a: 1, b: 2 } }])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoXml = tc => {
|
||||
const { xml0 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(xml0)
|
||||
const child = new Y.Node('p')
|
||||
xml0.insert(0, [child])
|
||||
const textchild = Y.Node.from(delta.create().insert('content'))
|
||||
child.insert(0, [textchild])
|
||||
// format textchild and revert that change
|
||||
undoManager.stopCapturing()
|
||||
textchild.format(3, 4, { bold: true })
|
||||
const v1 = delta.create().insert([delta.create('p').insert([delta.create().insert('con').insert('tent', { bold: true }).done()]).done()]).done()
|
||||
const v2 = delta.create().insert([delta.create('p').insert([delta.create().insert('content').done()]).done()]).done()
|
||||
t.compare(xml0.toDeltaDeep(), v1)
|
||||
undoManager.undo()
|
||||
t.compare(xml0.toDeltaDeep(), v2)
|
||||
undoManager.redo()
|
||||
t.compare(xml0.toDeltaDeep(), v1)
|
||||
xml0.delete(0, 1)
|
||||
t.compare(xml0.toDeltaDeep(), delta.create().done())
|
||||
undoManager.undo()
|
||||
t.compare(xml0.toDeltaDeep(), v1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoEvents = tc => {
|
||||
const { text0 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
let counter = 0
|
||||
let receivedMetadata = -1
|
||||
undoManager.on('stack-item-added', /** @param {any} event */ event => {
|
||||
t.assert(event.type != null)
|
||||
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
|
||||
event.stackItem.meta.set('test', counter++)
|
||||
})
|
||||
undoManager.on('stack-item-popped', /** @param {any} event */ event => {
|
||||
t.assert(event.type != null)
|
||||
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
|
||||
receivedMetadata = event.stackItem.meta.get('test')
|
||||
})
|
||||
text0.insert(0, 'abc')
|
||||
undoManager.undo()
|
||||
t.assert(receivedMetadata === 0)
|
||||
undoManager.redo()
|
||||
t.assert(receivedMetadata === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testTrackClass = tc => {
|
||||
const { users, text0 } = init(tc, { users: 3 })
|
||||
// only track origins that are numbers
|
||||
const undoManager = new Y.UndoManager(text0, { trackedOrigins: new Set([Number]) })
|
||||
users[0].transact(() => {
|
||||
text0.insert(0, 'abc')
|
||||
}, 42)
|
||||
t.assert(text0.toString() === 'abc')
|
||||
undoManager.undo()
|
||||
t.assert(text0.toString() === '')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testTypeScope = tc => {
|
||||
const { array0 } = init(tc, { users: 3 })
|
||||
// only track origins that are numbers
|
||||
const text0 = new Y.Node()
|
||||
const text1 = new Y.Node()
|
||||
array0.insert(0, [text0, text1])
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
const undoManagerBoth = new Y.UndoManager([text0, text1])
|
||||
text1.insert(0, 'abc')
|
||||
t.assert(undoManager.undoStack.length === 0)
|
||||
t.assert(undoManagerBoth.undoStack.length === 1)
|
||||
t.assert(text1.toString() === 'abc')
|
||||
undoManager.undo()
|
||||
t.assert(text1.toString() === 'abc')
|
||||
undoManagerBoth.undo()
|
||||
t.assert(text1.toString() === '')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoInEmbed = tc => {
|
||||
const { text0 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(text0)
|
||||
const nestedText = Y.Node.from(delta.create().insert('initial text'))
|
||||
undoManager.stopCapturing()
|
||||
text0.insert(0, [nestedText], { bold: true })
|
||||
t.assert(nestedText.toString() === 'initial text')
|
||||
undoManager.stopCapturing()
|
||||
nestedText.delete(0, nestedText.length)
|
||||
nestedText.insert(0, 'other text')
|
||||
t.assert(nestedText.toString() === 'other text')
|
||||
undoManager.undo()
|
||||
t.assert(nestedText.toString() === 'initial text')
|
||||
undoManager.undo()
|
||||
t.assert(text0.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoDeleteFilter = tc => {
|
||||
const array0 = init(tc, { users: 3 }).array0
|
||||
const undoManager = new Y.UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
|
||||
const map0 = new Y.Node()
|
||||
map0.setAttr('hi', 1)
|
||||
const map1 = new Y.Node()
|
||||
array0.insert(0, [map0, map1])
|
||||
undoManager.undo()
|
||||
t.assert(array0.length === 1)
|
||||
array0.get(0)
|
||||
t.assert(Array.from(array0.get(0).attrKeys()).length === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://discuss.yjs.dev/t/undomanager-with-external-updates/454/6
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testUndoUntilChangePerformed = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
doc.on('update', update => Y.applyUpdate(doc2, update))
|
||||
doc2.on('update', update => Y.applyUpdate(doc, update))
|
||||
|
||||
const yArray = doc.get('array')
|
||||
const yArray2 = doc2.get('array')
|
||||
const yMap = new Y.Node()
|
||||
yMap.setAttr('hello', 'world')
|
||||
yArray.push([yMap])
|
||||
const yMap2 = new Y.Node()
|
||||
yMap2.setAttr('key', 'value')
|
||||
yArray.push([yMap2])
|
||||
|
||||
const undoManager = new Y.UndoManager([yArray], { trackedOrigins: new Set([doc.clientID]) })
|
||||
const undoManager2 = new Y.UndoManager([doc2.get('array')], { trackedOrigins: new Set([doc2.clientID]) })
|
||||
|
||||
Y.transact(doc, () => yMap2.setAttr('key', 'value modified'), doc.clientID)
|
||||
undoManager.stopCapturing()
|
||||
Y.transact(doc, () => yMap.setAttr('hello', 'world modified'), doc.clientID)
|
||||
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID)
|
||||
undoManager2.undo()
|
||||
undoManager.undo()
|
||||
t.compareStrings(yMap2.getAttr('key'), 'value')
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://github.com/yjs/yjs/issues/317
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testUndoNestedUndoIssue = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const design = doc.get()
|
||||
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
|
||||
|
||||
const text = new Y.Node()
|
||||
|
||||
const blocks1 = new Y.Node()
|
||||
const blocks1block = new Y.Node()
|
||||
|
||||
doc.transact(() => {
|
||||
blocks1block.setAttr('text', 'Type Something')
|
||||
blocks1.push([blocks1block])
|
||||
text.setAttr('blocks', blocks1block)
|
||||
design.setAttr('text', text)
|
||||
})
|
||||
|
||||
const blocks2 = new Y.Node()
|
||||
const blocks2block = new Y.Node()
|
||||
doc.transact(() => {
|
||||
blocks2block.setAttr('text', 'Something')
|
||||
blocks2.push([blocks2block])
|
||||
text.setAttr('blocks', blocks2block)
|
||||
})
|
||||
|
||||
const blocks3 = new Y.Node()
|
||||
const blocks3block = new Y.Node()
|
||||
doc.transact(() => {
|
||||
blocks3block.setAttr('text', 'Something Else')
|
||||
blocks3.push([blocks3block])
|
||||
text.setAttr('blocks', blocks3block)
|
||||
})
|
||||
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something Else' }) }) }))
|
||||
undoManager.undo()
|
||||
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something' }) }) }))
|
||||
undoManager.undo()
|
||||
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Type Something' }) }) }))
|
||||
undoManager.undo()
|
||||
t.compare(design.toDeltaDeep(), delta.from({}))
|
||||
undoManager.redo()
|
||||
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Type Something' }) }) }))
|
||||
undoManager.redo()
|
||||
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something' }) }) }))
|
||||
undoManager.redo()
|
||||
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something Else' }) }) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://github.com/yjs/yjs/issues/355
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testConsecutiveRedoBug = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const yRoot = doc.get()
|
||||
const undoMgr = new Y.UndoManager(yRoot)
|
||||
|
||||
let yPoint = new Y.Node()
|
||||
yPoint.setAttr('x', 0)
|
||||
yPoint.setAttr('y', 0)
|
||||
yRoot.setAttr('a', yPoint)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
yPoint.setAttr('x', 100)
|
||||
yPoint.setAttr('y', 100)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
yPoint.setAttr('x', 200)
|
||||
yPoint.setAttr('y', 200)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
yPoint.setAttr('x', 300)
|
||||
yPoint.setAttr('y', 300)
|
||||
undoMgr.stopCapturing()
|
||||
|
||||
t.compare(yPoint.toJSON().attrs, { x: 300, y: 300 })
|
||||
|
||||
undoMgr.undo() // x=200, y=200
|
||||
t.compare(yPoint.toJSON().attrs, { x: 200, y: 200 })
|
||||
undoMgr.undo() // x=100, y=100
|
||||
t.compare(yPoint.toJSON().attrs, { x: 100, y: 100 })
|
||||
undoMgr.undo() // x=0, y=0
|
||||
t.compare(yPoint.toJSON().attrs, { x: 0, y: 0 })
|
||||
undoMgr.undo() // nil
|
||||
t.compare(yRoot.getAttr('a'), undefined)
|
||||
|
||||
undoMgr.redo() // x=0, y=0
|
||||
yPoint = yRoot.getAttr('a')
|
||||
|
||||
t.compare(yPoint.toJSON().attrs, { x: 0, y: 0 })
|
||||
undoMgr.redo() // x=100, y=100
|
||||
t.compare(yPoint.toJSON().attrs, { x: 100, y: 100 })
|
||||
undoMgr.redo() // x=200, y=200
|
||||
t.compare(yPoint.toJSON().attrs, { x: 200, y: 200 })
|
||||
undoMgr.redo() // expected x=300, y=300, actually nil
|
||||
t.compare(yPoint.toJSON().attrs, { x: 300, y: 300 })
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://github.com/yjs/yjs/issues/304
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testUndoXmlBug = _tc => {
|
||||
const origin = 'origin'
|
||||
const doc = new Y.Doc()
|
||||
const fragment = doc.get('t')
|
||||
const undoManager = new Y.UndoManager(fragment, {
|
||||
captureTimeout: 0,
|
||||
trackedOrigins: new Set([origin])
|
||||
})
|
||||
|
||||
// create element
|
||||
doc.transact(() => {
|
||||
const e = new Y.Node('test-node')
|
||||
e.setAttr('a', '100')
|
||||
e.setAttr('b', '0')
|
||||
fragment.insert(fragment.length, [e])
|
||||
}, origin)
|
||||
|
||||
// change one attribute
|
||||
doc.transact(() => {
|
||||
const e = fragment.get(0)
|
||||
e.setAttr('a', '200')
|
||||
}, origin)
|
||||
|
||||
// change both attributes
|
||||
doc.transact(() => {
|
||||
const e = fragment.get(0)
|
||||
e.setAttr('a', '180')
|
||||
e.setAttr('b', '50')
|
||||
}, origin)
|
||||
|
||||
undoManager.undo()
|
||||
undoManager.undo()
|
||||
undoManager.undo()
|
||||
|
||||
undoManager.redo()
|
||||
undoManager.redo()
|
||||
undoManager.redo()
|
||||
t.compare(fragment.toString(), '<test-node a="180" b="50" />')
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported in https://github.com/yjs/yjs/issues/343
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testUndoBlockBug = _tc => {
|
||||
const doc = new Y.Doc({ gc: false })
|
||||
const design = doc.get()
|
||||
|
||||
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
|
||||
|
||||
const text = new Y.Node()
|
||||
|
||||
const blocks1 = new Y.Node()
|
||||
const blocks1block = new Y.Node()
|
||||
doc.transact(() => {
|
||||
blocks1block.setAttr('text', '1')
|
||||
blocks1.push([blocks1block])
|
||||
|
||||
text.setAttr('blocks', blocks1block)
|
||||
design.setAttr('text', text)
|
||||
})
|
||||
|
||||
const blocks2 = new Y.Node()
|
||||
const blocks2block = new Y.Node()
|
||||
doc.transact(() => {
|
||||
blocks2block.setAttr('text', '2')
|
||||
blocks2.push([blocks2block])
|
||||
text.setAttr('blocks', blocks2block)
|
||||
})
|
||||
|
||||
const blocks3 = new Y.Node()
|
||||
const blocks3block = new Y.Node()
|
||||
doc.transact(() => {
|
||||
blocks3block.setAttr('text', '3')
|
||||
blocks3.push([blocks3block])
|
||||
text.setAttr('blocks', blocks3block)
|
||||
})
|
||||
|
||||
const blocks4 = new Y.Node()
|
||||
const blocks4block = new Y.Node()
|
||||
doc.transact(() => {
|
||||
blocks4block.setAttr('text', '4')
|
||||
blocks4.push([blocks4block])
|
||||
text.setAttr('blocks', blocks4block)
|
||||
})
|
||||
|
||||
// {"text":{"blocks":{"text":"4"}}}
|
||||
undoManager.undo() // {"text":{"blocks":{"3"}}}
|
||||
undoManager.undo() // {"text":{"blocks":{"text":"2"}}}
|
||||
undoManager.undo() // {"text":{"blocks":{"text":"1"}}}
|
||||
undoManager.undo() // {}
|
||||
undoManager.redo() // {"text":{"blocks":{"text":"1"}}}
|
||||
undoManager.redo() // {"text":{"blocks":{"text":"2"}}}
|
||||
undoManager.redo() // {"text":{"blocks":{"text":"3"}}}
|
||||
undoManager.redo() // {"text":{}}
|
||||
t.compare(design.toJSON().attrs, { text: { attrs: { blocks: { attrs: { text: '4' } } } } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo text formatting delete should not corrupt peer state.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/392
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testUndoDeleteTextFormat = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const text = doc.get()
|
||||
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
|
||||
const doc2 = new Y.Doc()
|
||||
const text2 = doc2.get()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
const undoManager = new Y.UndoManager(text)
|
||||
|
||||
text.format(13, 7, { bold: true })
|
||||
undoManager.stopCapturing()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
|
||||
text.format(16, 4, { bold: null })
|
||||
undoManager.stopCapturing()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
|
||||
undoManager.undo()
|
||||
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
|
||||
|
||||
const expect = delta.create()
|
||||
.insert('Attack ships ')
|
||||
.insert('on fire', { bold: true })
|
||||
.insert(' off the shoulder of Orion.')
|
||||
t.compare(text.toDelta(), expect)
|
||||
t.compare(text2.toDelta(), expect)
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo text formatting delete should not corrupt peer state.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/392
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testBehaviorOfIgnoreRemoteAttributeChangesProperty = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
doc.on('update', update => Y.applyUpdate(doc2, update, doc))
|
||||
doc2.on('update', update => Y.applyUpdate(doc, update, doc2))
|
||||
const map1 = doc.get()
|
||||
const map2 = doc2.get()
|
||||
const um1 = new Y.UndoManager(map1, { ignoreRemoteAttributeChanges: true })
|
||||
map1.setAttr('x', 1)
|
||||
map2.setAttr('x', 2)
|
||||
map1.setAttr('x', 3)
|
||||
map2.setAttr('x', 4)
|
||||
um1.undo()
|
||||
t.assert(map1.getAttr('x') === 2)
|
||||
t.assert(map2.getAttr('x') === 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Special deletion case.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/447
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSpecialDeletionCase = _tc => {
|
||||
const origin = 'undoable'
|
||||
const doc = new Y.Doc()
|
||||
const fragment = doc.get()
|
||||
const undoManager = new Y.UndoManager(fragment, { trackedOrigins: new Set([origin]) })
|
||||
doc.transact(() => {
|
||||
const e = new Y.Node('test')
|
||||
e.setAttr('a', '1')
|
||||
e.setAttr('b', '2')
|
||||
fragment.insert(0, [e])
|
||||
})
|
||||
t.compareStrings(fragment.toString(), '<test a="1" b="2" />')
|
||||
doc.transact(() => {
|
||||
// change attribute "b" and delete test-node
|
||||
const e = fragment.get(0)
|
||||
e.setAttr('b', '3')
|
||||
fragment.delete(0)
|
||||
}, origin)
|
||||
t.compareStrings(fragment.toString(), '')
|
||||
undoManager.undo()
|
||||
t.compareStrings(fragment.toString(), '<test a="1" b="2" />')
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleted entries in a map should be restored on undo.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/500
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testUndoDeleteInMap = (tc) => {
|
||||
const { map0 } = init(tc, { users: 3 })
|
||||
const undoManager = new Y.UndoManager(map0, { captureTimeout: 0 })
|
||||
map0.setAttr('a', 'a')
|
||||
map0.deleteAttr('a')
|
||||
map0.setAttr('a', 'b')
|
||||
map0.deleteAttr('a')
|
||||
map0.setAttr('a', 'c')
|
||||
map0.deleteAttr('a')
|
||||
map0.setAttr('a', 'd')
|
||||
t.compare(map0.toJSON().attrs, { a: 'd' })
|
||||
undoManager.undo()
|
||||
t.compare(map0.toJSON(), {})
|
||||
undoManager.undo()
|
||||
t.compare(map0.toJSON().attrs, { a: 'c' })
|
||||
undoManager.undo()
|
||||
t.compare(map0.toJSON(), {})
|
||||
undoManager.undo()
|
||||
t.compare(map0.toJSON().attrs, { a: 'b' })
|
||||
undoManager.undo()
|
||||
t.compare(map0.toJSON(), {})
|
||||
undoManager.undo()
|
||||
t.compare(map0.toJSON().attrs, { a: 'a' })
|
||||
}
|
||||
|
||||
/**
|
||||
* It should expose the StackItem being processed if undoing
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testUndoDoingStackItem = async (_tc) => {
|
||||
const doc = new Y.Doc()
|
||||
const text = doc.get('text')
|
||||
const undoManager = new Y.UndoManager([text])
|
||||
undoManager.on('stack-item-added', /** @param {any} event */ event => {
|
||||
event.stackItem.meta.set('str', '42')
|
||||
})
|
||||
let metaUndo = /** @type {any} */ (null)
|
||||
let metaRedo = /** @type {any} */ (null)
|
||||
text.observe((event) => {
|
||||
const /** @type {Y.UndoManager} */ origin = event.transaction.origin
|
||||
if (origin === undoManager && origin.undoing) {
|
||||
metaUndo = origin.currStackItem?.meta.get('str')
|
||||
} else if (origin === undoManager && origin.redoing) {
|
||||
metaRedo = origin.currStackItem?.meta.get('str')
|
||||
}
|
||||
})
|
||||
text.insert(0, 'abc')
|
||||
undoManager.undo()
|
||||
undoManager.redo()
|
||||
t.compare(metaUndo, '42', 'currStackItem is accessible while undoing')
|
||||
t.compare(metaRedo, '42', 'currStackItem is accessible while redoing')
|
||||
t.compare(undoManager.currStackItem, null, 'currStackItem is null after observe/transaction')
|
||||
}
|
||||
461
tests/updates.tests.js
Normal file
461
tests/updates.tests.js
Normal file
@@ -0,0 +1,461 @@
|
||||
import * as t from 'lib0/testing'
|
||||
import * as Y from '../src/index.js'
|
||||
import { init, compare } from './testHelper.js' // eslint-disable-line
|
||||
import { readBlockSet } from '../src/utils/BlockSet.js'
|
||||
import { readIdSet, writeIdSet } from '../src/utils/ids.js'
|
||||
import { UpdateDecoderV2 } from '../src/utils/UpdateDecoder.js'
|
||||
import { UpdateEncoderV2 } from '../src/utils/UpdateEncoder.js'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as object from 'lib0/object'
|
||||
import * as delta from 'lib0/delta'
|
||||
import * as array from 'lib0/array'
|
||||
|
||||
/**
|
||||
* @typedef {Object} Enc
|
||||
* @property {function(Array<Uint8Array<ArrayBuffer>>):Uint8Array<ArrayBuffer>} Enc.mergeUpdates
|
||||
* @property {function(Y.Doc):Uint8Array<ArrayBuffer>} Enc.encodeStateAsUpdate
|
||||
* @property {function(Y.Doc, Uint8Array):void} Enc.applyUpdate
|
||||
* @property {function(Uint8Array):void} Enc.logUpdate
|
||||
* @property {function(Uint8Array):{deletes:Y.IdSet,inserts:Y.IdSet}} Enc.readUpdateToContentIds
|
||||
* @property {function(Y.Doc):Uint8Array<ArrayBuffer>} Enc.encodeStateVector
|
||||
* @property {function(Uint8Array):Uint8Array<ArrayBuffer>} Enc.encodeStateVectorFromUpdate
|
||||
* @property {'update'|'updateV2'} Enc.updateEventName
|
||||
* @property {string} Enc.description
|
||||
* @property {function(Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>):Uint8Array<ArrayBuffer>} Enc.diffUpdate
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Enc}
|
||||
*/
|
||||
const encV1 = {
|
||||
mergeUpdates: Y.mergeUpdates,
|
||||
encodeStateAsUpdate: Y.encodeStateAsUpdate,
|
||||
applyUpdate: Y.applyUpdate,
|
||||
logUpdate: Y.logUpdate,
|
||||
readUpdateToContentIds: Y.createContentIdsFromUpdate,
|
||||
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdate,
|
||||
encodeStateVector: Y.encodeStateVector,
|
||||
updateEventName: 'update',
|
||||
description: 'V1',
|
||||
diffUpdate: Y.diffUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Enc}
|
||||
*/
|
||||
const encV2 = {
|
||||
mergeUpdates: Y.mergeUpdatesV2,
|
||||
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
|
||||
applyUpdate: Y.applyUpdateV2,
|
||||
logUpdate: Y.logUpdateV2,
|
||||
readUpdateToContentIds: Y.createContentIdsFromUpdateV2,
|
||||
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
|
||||
encodeStateVector: Y.encodeStateVector,
|
||||
updateEventName: 'updateV2',
|
||||
description: 'V2',
|
||||
diffUpdate: Y.diffUpdateV2
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Enc}
|
||||
*/
|
||||
const encDoc = {
|
||||
mergeUpdates: (updates) => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
updates.forEach(update => {
|
||||
Y.applyUpdateV2(ydoc, update)
|
||||
})
|
||||
return Y.encodeStateAsUpdateV2(ydoc)
|
||||
},
|
||||
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
|
||||
applyUpdate: Y.applyUpdateV2,
|
||||
logUpdate: Y.logUpdateV2,
|
||||
readUpdateToContentIds: Y.createContentIdsFromUpdateV2,
|
||||
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
|
||||
encodeStateVector: Y.encodeStateVector,
|
||||
updateEventName: 'updateV2',
|
||||
description: 'Merge via Y.Doc',
|
||||
/**
|
||||
* @param {Uint8Array} update
|
||||
* @param {Uint8Array} sv
|
||||
*/
|
||||
diffUpdate: (update, sv) => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
Y.applyUpdateV2(ydoc, update)
|
||||
return Y.encodeStateAsUpdateV2(ydoc, sv)
|
||||
}
|
||||
}
|
||||
|
||||
const encoders = [encV1, encV2, encDoc]
|
||||
|
||||
/**
|
||||
* @param {Array<Y.Doc>} users
|
||||
* @param {Enc} enc
|
||||
*/
|
||||
const fromUpdates = (users, enc) => {
|
||||
const updates = users.map(user =>
|
||||
enc.encodeStateAsUpdate(user)
|
||||
)
|
||||
const ydoc = new Y.Doc()
|
||||
enc.applyUpdate(ydoc, enc.mergeUpdates(updates))
|
||||
return ydoc
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testMergeUpdates = tc => {
|
||||
const { users, array0, array1 } = init(tc, { users: 3 })
|
||||
|
||||
array0.insert(0, [1])
|
||||
array1.insert(0, [2])
|
||||
|
||||
compare(users)
|
||||
encoders.forEach(enc => {
|
||||
const merged = fromUpdates(users, enc)
|
||||
t.compareArrays(array0.toArray(), merged.get('array').toArray())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testKeyEncoding = tc => {
|
||||
const { users, text0, text1 } = init(tc, { users: 2 })
|
||||
|
||||
text0.insert(0, 'a', { italic: true })
|
||||
text0.insert(0, 'b')
|
||||
text0.insert(0, 'c', { italic: true })
|
||||
|
||||
const update = Y.encodeStateAsUpdateV2(users[0])
|
||||
Y.applyUpdateV2(users[1], update)
|
||||
|
||||
const c = text1.toDelta()
|
||||
t.compare(
|
||||
c,
|
||||
delta.create()
|
||||
.insert('c', { italic: true })
|
||||
.insert('b')
|
||||
.insert('a', { italic: true })
|
||||
.done()
|
||||
)
|
||||
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.Doc} ydoc
|
||||
* @param {Array<Uint8Array<ArrayBuffer>>} updates - expecting at least 4 updates
|
||||
* @param {Enc} enc
|
||||
* @param {boolean} hasDeletes
|
||||
*/
|
||||
const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => {
|
||||
const cases = []
|
||||
// Case 1: Simple case, simply merge everything
|
||||
cases.push(enc.mergeUpdates(updates))
|
||||
|
||||
// Case 2: Overlapping updates
|
||||
cases.push(enc.mergeUpdates([
|
||||
enc.mergeUpdates(updates.slice(2)),
|
||||
enc.mergeUpdates(updates.slice(0, 2))
|
||||
]))
|
||||
|
||||
// Case 3: Overlapping updates
|
||||
cases.push(enc.mergeUpdates([
|
||||
enc.mergeUpdates(updates.slice(2)),
|
||||
enc.mergeUpdates(updates.slice(1, 3)),
|
||||
updates[0]
|
||||
]))
|
||||
|
||||
// Case 4: Separated updates (containing skips)
|
||||
cases.push(enc.mergeUpdates([
|
||||
enc.mergeUpdates([updates[0], updates[2]]),
|
||||
enc.mergeUpdates([updates[1], updates[3]]),
|
||||
enc.mergeUpdates(updates.slice(4))
|
||||
]))
|
||||
|
||||
// Case 5: overlapping with many duplicates
|
||||
cases.push(enc.mergeUpdates(cases))
|
||||
|
||||
// const targetState = enc.encodeStateAsUpdate(ydoc)
|
||||
// t.info('Target State: ')
|
||||
// enc.logUpdate(targetState)
|
||||
|
||||
cases.forEach((mergedUpdates, i) => {
|
||||
t.info(`State Case $${i} (${enc.description}):`)
|
||||
// enc.logUpdate(updates)
|
||||
const merged = new Y.Doc({ gc: false })
|
||||
enc.applyUpdate(merged, mergedUpdates)
|
||||
t.compareArrays(merged.get().toArray(), ydoc.get().toArray())
|
||||
t.compare(enc.encodeStateVector(merged), enc.encodeStateVectorFromUpdate(mergedUpdates))
|
||||
if (enc.updateEventName !== 'update') { // @todo should this also work on legacy updates?
|
||||
for (let j = 1; j < updates.length; j++) {
|
||||
const partMerged = enc.mergeUpdates(updates.slice(j))
|
||||
const partMeta = enc.readUpdateToContentIds(partMerged)
|
||||
const targetSV = enc.encodeStateVectorFromUpdate(enc.mergeUpdates(updates.slice(0, j)))
|
||||
const diffed = enc.diffUpdate(mergedUpdates, targetSV)
|
||||
const diffedMeta = enc.readUpdateToContentIds(diffed)
|
||||
t.compare(partMeta.inserts, diffedMeta.inserts)
|
||||
{
|
||||
// We can'd do the following
|
||||
// - t.compare(diffed, mergedDeletes)
|
||||
// because diffed contains the set of all deletes.
|
||||
// So we add all deletes from `diffed` to `partDeletes` and compare then
|
||||
const decoder = decoding.createDecoder(diffed)
|
||||
const updateDecoder = new UpdateDecoderV2(decoder)
|
||||
readBlockSet(updateDecoder)
|
||||
const ds = readIdSet(updateDecoder)
|
||||
const updateEncoder = new UpdateEncoderV2()
|
||||
encoding.writeVarUint(updateEncoder.restEncoder, 0) // 0 structs
|
||||
writeIdSet(updateEncoder, ds)
|
||||
const deletesUpdate = updateEncoder.toUint8Array()
|
||||
const mergedDeletes = Y.mergeUpdatesV2([deletesUpdate, partMerged])
|
||||
if (!hasDeletes || enc !== encDoc) {
|
||||
// deletes will almost definitely lead to different encoders because of the mergeStruct feature that is present in encDoc
|
||||
t.compare(diffed, mergedDeletes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const meta = enc.readUpdateToContentIds(mergedUpdates)
|
||||
meta.inserts.clients.forEach(range => { t.assert(range.getIds()[0].clock === 0) })
|
||||
meta.inserts.clients.forEach((range, client) => {
|
||||
const structs = /** @type {Array<Y.Item>} */ (merged.store.clients.get(client))
|
||||
const lastStruct = structs[structs.length - 1]
|
||||
const lastIdRange = array.last(range.getIds())
|
||||
t.assert(lastStruct.id.clock + lastStruct.length === lastIdRange.clock + lastIdRange.len)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testMergeUpdates1 = _tc => {
|
||||
encoders.forEach((enc) => {
|
||||
t.info(`Using encoder: ${enc.description}`)
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const updates = /** @type {Array<Uint8Array<ArrayBuffer>>} */ ([])
|
||||
ydoc.on(enc.updateEventName, update => { updates.push(update) })
|
||||
const array = ydoc.get()
|
||||
array.insert(0, [1])
|
||||
array.insert(0, [2])
|
||||
array.insert(0, [3])
|
||||
array.insert(0, [4])
|
||||
checkUpdateCases(ydoc, updates, enc, false)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testMergeUpdates2 = _tc => {
|
||||
encoders.forEach((enc, _i) => {
|
||||
t.info(`Using encoder: ${enc.description}`)
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const updates = /** @type {Array<Uint8Array<ArrayBuffer>>} */ ([])
|
||||
ydoc.on(enc.updateEventName, update => { updates.push(update) })
|
||||
const array = ydoc.get()
|
||||
array.insert(0, [1, 2])
|
||||
array.delete(1, 1)
|
||||
array.insert(0, [3, 4])
|
||||
array.delete(1, 2)
|
||||
checkUpdateCases(ydoc, updates, enc, true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testMergeUpdatesStressTest = _tc => {
|
||||
const N = 100
|
||||
const M = 100
|
||||
encoders.forEach((enc, _i) => {
|
||||
t.info(`Using encoder: ${enc.description}`)
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const updates = /** @type {Array<Uint8Array<ArrayBuffer>>} */ ([])
|
||||
ydoc.on(enc.updateEventName, update => { updates.push(update) })
|
||||
const array = ydoc.get()
|
||||
for (let clientid = 0; clientid < N; clientid++) {
|
||||
ydoc.clientID = clientid
|
||||
for (let i = 0; i < M; i++) {
|
||||
array.push([i])
|
||||
}
|
||||
}
|
||||
t.measureTime('merge via Y.mergeUpdates', () => {
|
||||
enc.mergeUpdates(updates)
|
||||
})
|
||||
t.measureTime('merge via Y.applyUpdate on Y.Doc', () => {
|
||||
const ydoc = new Y.Doc()
|
||||
updates.forEach(update => {
|
||||
enc.applyUpdate(ydoc, update)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testMergePendingUpdates = _tc => {
|
||||
const yDoc = new Y.Doc()
|
||||
/**
|
||||
* @type {Array<Uint8Array>}
|
||||
*/
|
||||
const serverUpdates = []
|
||||
yDoc.on('update', (update, _origin, _c) => {
|
||||
serverUpdates.splice(serverUpdates.length, 0, update)
|
||||
})
|
||||
const yText = yDoc.get('textBlock')
|
||||
yText.applyDelta(delta.create().insert('r').done())
|
||||
yText.applyDelta(delta.create().insert('o').done())
|
||||
yText.applyDelta(delta.create().insert('n').done())
|
||||
yText.applyDelta(delta.create().insert('e').done())
|
||||
yText.applyDelta(delta.create().insert('n').done())
|
||||
|
||||
const yDoc1 = new Y.Doc()
|
||||
Y.applyUpdate(yDoc1, serverUpdates[0])
|
||||
const update1 = Y.encodeStateAsUpdate(yDoc1)
|
||||
|
||||
const yDoc2 = new Y.Doc()
|
||||
Y.applyUpdate(yDoc2, update1)
|
||||
Y.applyUpdate(yDoc2, serverUpdates[1])
|
||||
const update2 = Y.encodeStateAsUpdate(yDoc2)
|
||||
|
||||
const yDoc3 = new Y.Doc()
|
||||
Y.applyUpdate(yDoc3, update2)
|
||||
Y.applyUpdate(yDoc3, serverUpdates[3])
|
||||
const update3 = Y.encodeStateAsUpdate(yDoc3)
|
||||
|
||||
const yDoc4 = new Y.Doc()
|
||||
Y.applyUpdate(yDoc4, update3)
|
||||
Y.applyUpdate(yDoc4, serverUpdates[2])
|
||||
const update4 = Y.encodeStateAsUpdate(yDoc4)
|
||||
|
||||
const yDoc5 = new Y.Doc()
|
||||
Y.applyUpdate(yDoc5, update4)
|
||||
Y.applyUpdate(yDoc5, serverUpdates[4])
|
||||
Y.encodeStateAsUpdate(yDoc5)
|
||||
|
||||
const yText5 = yDoc5.get('textBlock')
|
||||
t.compareStrings(yText5.toString(), 'nenor')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testObfuscateUpdates = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
const ymap = ydoc.get('map')
|
||||
const yarray = ydoc.get('array')
|
||||
// test ytext
|
||||
ytext.applyDelta(delta.create().insert('text', { bold: true }).insert([{ href: 'supersecreturl' }]).done())
|
||||
// test ymap
|
||||
ymap.setAttr('key', 'secret1')
|
||||
ymap.setAttr('key', 'secret2')
|
||||
// test yarray with subtype & subdoc
|
||||
const subtype = new Y.Node('secretnodename')
|
||||
const subdoc = new Y.Doc({ guid: 'secret' })
|
||||
subtype.setAttr('attr', 'val')
|
||||
yarray.insert(0, ['teststring', 42, subtype, subdoc])
|
||||
// obfuscate the content and put it into a new document
|
||||
const obfuscatedUpdate = Y.obfuscateUpdate(Y.encodeStateAsUpdate(ydoc))
|
||||
const odoc = new Y.Doc()
|
||||
Y.applyUpdate(odoc, obfuscatedUpdate)
|
||||
const otext = odoc.get('text')
|
||||
const omap = odoc.get('map')
|
||||
const oarray = odoc.get('array')
|
||||
// test ytext
|
||||
const d = /** @type {any} */ (otext.toDelta().toJSON().children)
|
||||
t.assert(d.length === 2)
|
||||
t.assert(d[0].insert !== 'text' && d[0].insert.length === 4)
|
||||
t.assert(object.length(d[0].format) === 1)
|
||||
t.assert(!object.hasProperty(d[0].format, 'bold'))
|
||||
t.assert(object.length(d[1].insert) === 1)
|
||||
t.assert(object.hasProperty(d[1], 'insert'))
|
||||
// test ymap
|
||||
t.assert(omap.attrSize === 1)
|
||||
t.assert(!omap.hasAttr('key'))
|
||||
// test yarray with subtype & subdoc
|
||||
const result = oarray.toArray()
|
||||
t.assert(result.length === 4)
|
||||
t.assert(result[0] !== 'teststring')
|
||||
t.assert(result[1] !== 42)
|
||||
const osubtype = /** @type {Y.Node} */ (result[2])
|
||||
const osubdoc = result[3]
|
||||
// test subtype
|
||||
t.assert(osubtype.name !== subtype.name)
|
||||
t.assert(object.length(osubtype.getAttrs()) === 1)
|
||||
t.assert(osubtype.getAttr('attr') === undefined)
|
||||
// test subdoc
|
||||
t.assert(osubdoc.guid !== subdoc.guid)
|
||||
}
|
||||
|
||||
export const testIntersectDoc = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
ydoc.get().setAttr('k', 1)
|
||||
const c1 = Y.createContentIdsFromDoc(ydoc, true)
|
||||
ydoc.get().setAttr('k', 2)
|
||||
|
||||
const v1 = Y.intersectUpdateWithContentIds(Y.encodeStateAsUpdate(ydoc), c1)
|
||||
const y1 = new Y.Doc()
|
||||
Y.applyUpdate(y1, v1)
|
||||
t.assert(ydoc.get().getAttr('k'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Selecting a sparse mid-stream range of content-ids must not drop the selection
|
||||
* just because earlier structs of the same client were not selected.
|
||||
*
|
||||
* @see https://github.com/yjs/yjs/issues/781
|
||||
*/
|
||||
export const testIntersectSparseContentIds = () => {
|
||||
const src = new Y.Doc()
|
||||
src.transact(() => {
|
||||
const m = src.get('m')
|
||||
for (let i = 0; i < 10; i++) m.setAttr(`k${i}`, i) // clocks 0..9, single client
|
||||
})
|
||||
const update = Y.encodeStateAsUpdate(src)
|
||||
const cids = Y.createContentIdsFromUpdate(update)
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
let client = 0
|
||||
cids.inserts.clients.forEach((_r, c) => { client = c })
|
||||
|
||||
// Select ONLY clocks [5, 7) — a mid-stream range.
|
||||
const sel = Y.createIdSet()
|
||||
sel.add(client, 5, 2)
|
||||
const chunk = Y.intersectUpdateWithContentIds(update, {
|
||||
inserts: sel,
|
||||
deletes: Y.createIdSet()
|
||||
})
|
||||
// Each map key is a separate length-1 item, so clocks [5,7) yield two structs.
|
||||
const structs = Y.decodeUpdate(chunk).structs.filter(s => !(s instanceof Y.Skip))
|
||||
t.assert(structs.every(s => s.id.client === client))
|
||||
t.compare(structs.map(s => [s.id.clock, s.length]), [[5, 1], [6, 1]])
|
||||
|
||||
// A selection split across a gap must round-trip both ranges with a Skip in between.
|
||||
const sel2 = Y.createIdSet()
|
||||
sel2.add(client, 1, 2) // clocks 1,2
|
||||
sel2.add(client, 7, 1) // clock 7
|
||||
const chunk2 = Y.intersectUpdateWithContentIds(update, {
|
||||
inserts: sel2,
|
||||
deletes: Y.createIdSet()
|
||||
})
|
||||
const structs2 = Y.decodeUpdate(chunk2).structs.filter(s => !(s instanceof Y.Skip))
|
||||
t.compare(structs2.map(s => [s.id.clock, s.length]), [[1, 1], [2, 1], [7, 1]])
|
||||
|
||||
// A full-coverage selection must round-trip byte-identically to the source update.
|
||||
const selAll = Y.createIdSet()
|
||||
cids.inserts.clients.forEach((ranges, c) => {
|
||||
ranges.getIds().forEach(r => selAll.add(c, r.clock, r.len))
|
||||
})
|
||||
const chunkAll = Y.intersectUpdateWithContentIds(update, {
|
||||
inserts: selAll,
|
||||
deletes: Y.createIdSet()
|
||||
})
|
||||
t.compare(chunkAll, update)
|
||||
}
|
||||
666
tests/y-array.tests.js
Normal file
666
tests/y-array.tests.js
Normal file
@@ -0,0 +1,666 @@
|
||||
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as prng from 'lib0/prng'
|
||||
import * as math from 'lib0/math'
|
||||
import * as env from 'lib0/environment'
|
||||
import * as delta from 'lib0/delta'
|
||||
|
||||
const isDevMode = env.getVariable('node_env') === 'development'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testBasicUpdate = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
doc1.get('array').insert(0, ['hi'])
|
||||
const update = Y.encodeStateAsUpdate(doc1)
|
||||
Y.applyUpdate(doc2, update)
|
||||
t.compare(doc2.get('array').toArray(), ['hi'])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testFailsObjectManipulationInDevMode = _tc => {
|
||||
if (isDevMode) {
|
||||
t.info('running in dev mode')
|
||||
const doc = new Y.Doc()
|
||||
const a = [1, 2, 3]
|
||||
const b = { o: 1 }
|
||||
doc.get('test').insert(0, [a])
|
||||
doc.get('map').setAttr('k', b)
|
||||
t.fails(() => {
|
||||
a[0] = 42
|
||||
})
|
||||
t.fails(() => {
|
||||
b.o = 42
|
||||
})
|
||||
} else {
|
||||
t.info('not in dev mode')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSlice = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const arr = doc1.get('array')
|
||||
arr.insert(0, [1, 2, 3])
|
||||
t.compareArrays(arr.slice(0), [1, 2, 3])
|
||||
t.compareArrays(arr.slice(1), [2, 3])
|
||||
t.compareArrays(arr.slice(0, -1), [1, 2])
|
||||
arr.insert(0, [0])
|
||||
t.compareArrays(arr.slice(0), [0, 1, 2, 3])
|
||||
t.compareArrays(arr.slice(0, 2), [0, 1])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testArrayFrom = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const db1 = doc1.get('root')
|
||||
const nestedArray1 = Y.Node.from(delta.create().insert([0, 1, 2]))
|
||||
db1.setAttr('array', nestedArray1)
|
||||
t.compare(nestedArray1.toArray(), [0, 1, 2])
|
||||
}
|
||||
|
||||
/**
|
||||
* Debugging yjs#297 - a critical bug connected to the search-marker approach
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testLengthIssue = _tc => {
|
||||
const doc1 = new Y.Doc()
|
||||
const arr = doc1.get('array')
|
||||
arr.push([0, 1, 2, 3])
|
||||
arr.delete(0)
|
||||
arr.insert(0, [0])
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
doc1.transact(() => {
|
||||
arr.delete(1)
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
arr.insert(1, [1])
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
arr.delete(2)
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
arr.insert(2, [2])
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
})
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
arr.delete(1)
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
arr.insert(1, [1])
|
||||
t.assert(arr.length === arr.toArray().length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Debugging yjs#314
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testLengthIssue2 = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const next = doc.get()
|
||||
doc.transact(() => {
|
||||
next.insert(0, ['group2'])
|
||||
})
|
||||
doc.transact(() => {
|
||||
next.insert(1, ['rectangle3'])
|
||||
})
|
||||
doc.transact(() => {
|
||||
next.delete(0)
|
||||
next.insert(0, ['rectangle3'])
|
||||
})
|
||||
next.delete(1)
|
||||
doc.transact(() => {
|
||||
next.insert(1, ['ellipse4'])
|
||||
})
|
||||
doc.transact(() => {
|
||||
next.insert(2, ['ellipse3'])
|
||||
})
|
||||
doc.transact(() => {
|
||||
next.insert(3, ['ellipse2'])
|
||||
})
|
||||
doc.transact(() => {
|
||||
doc.transact(() => {
|
||||
t.fails(() => {
|
||||
next.insert(5, ['rectangle2'])
|
||||
})
|
||||
next.insert(4, ['rectangle2'])
|
||||
})
|
||||
doc.transact(() => {
|
||||
// this should not throw an error message
|
||||
next.delete(4)
|
||||
})
|
||||
})
|
||||
console.log(next.toArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testDeleteInsert = tc => {
|
||||
const { users, array0 } = init(tc, { users: 2 })
|
||||
array0.delete(0, 0)
|
||||
t.describe('Does not throw when deleting zero elements with position 0')
|
||||
t.fails(() => {
|
||||
array0.delete(1, 1)
|
||||
})
|
||||
array0.insert(0, ['A'])
|
||||
array0.delete(1, 0)
|
||||
t.describe('Does not throw when deleting zero elements with valid position 1')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertThreeElementsTryRegetProperty = tc => {
|
||||
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
|
||||
array0.insert(0, [1, true, false])
|
||||
t.compare(array0.toDelta(), delta.create().insert([1, true, false]).done(), 'content works')
|
||||
testConnector.flushAllMessages()
|
||||
t.compare(array1.toDelta(), delta.create().insert([1, true, false]).done(), 'comparison works after sync')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testConcurrentInsertWithThreeConflicts = tc => {
|
||||
const { users, array0, array1, array2 } = init(tc, { users: 3 })
|
||||
array0.insert(0, [0])
|
||||
array1.insert(0, [1])
|
||||
array2.insert(0, [2])
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testConcurrentInsertDeleteWithThreeConflicts = tc => {
|
||||
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
|
||||
array0.insert(0, ['x', 'y', 'z'])
|
||||
testConnector.flushAllMessages()
|
||||
array0.insert(1, [0])
|
||||
array1.delete(0)
|
||||
array1.delete(1, 1)
|
||||
array2.insert(1, [2])
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertionsInLateSync = tc => {
|
||||
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
|
||||
array0.insert(0, ['x', 'y'])
|
||||
testConnector.flushAllMessages()
|
||||
users[1].disconnect()
|
||||
users[2].disconnect()
|
||||
array0.insert(1, ['user0'])
|
||||
array1.insert(1, ['user1'])
|
||||
array2.insert(1, ['user2'])
|
||||
users[1].connect()
|
||||
users[2].connect()
|
||||
testConnector.flushAllMessages()
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testDisconnectReallyPreventsSendingMessages = tc => {
|
||||
const { testConnector, users, array0, array1 } = init(tc, { users: 3 })
|
||||
array0.insert(0, ['x', 'y'])
|
||||
testConnector.flushAllMessages()
|
||||
users[1].disconnect()
|
||||
users[2].disconnect()
|
||||
array0.insert(1, ['user0'])
|
||||
array1.insert(1, ['user1'])
|
||||
t.compare(array0.toJSON().children, ['x', 'user0', 'y'])
|
||||
t.compare(array1.toJSON().children, ['x', 'user1', 'y'])
|
||||
users[1].connect()
|
||||
users[2].connect()
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testDeletionsInLateSync = tc => {
|
||||
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
|
||||
array0.insert(0, ['x', 'y'])
|
||||
testConnector.flushAllMessages()
|
||||
users[1].disconnect()
|
||||
array1.delete(1, 1)
|
||||
array0.delete(0, 2)
|
||||
users[1].connect()
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertThenMergeDeleteOnSync = tc => {
|
||||
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
|
||||
array0.insert(0, ['x', 'y', 'z'])
|
||||
testConnector.flushAllMessages()
|
||||
users[0].disconnect()
|
||||
array1.delete(0, 3)
|
||||
users[0].connect()
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertAndDeleteEvents = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {Object<string,any>?}
|
||||
*/
|
||||
let event = null
|
||||
array0.observe(e => {
|
||||
event = e
|
||||
})
|
||||
array0.insert(0, [0, 1, 2])
|
||||
t.assert(event !== null)
|
||||
event = null
|
||||
array0.delete(0)
|
||||
t.assert(event !== null)
|
||||
event = null
|
||||
array0.delete(0, 2)
|
||||
t.assert(event !== null)
|
||||
event = null
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testNestedObserverEvents = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {Array<number>}
|
||||
*/
|
||||
const vals = []
|
||||
array0.observe(() => {
|
||||
if (array0.length === 1) {
|
||||
// inserting, will call this observer again
|
||||
// we expect that this observer is called after this event handler finishedn
|
||||
array0.insert(1, [1])
|
||||
vals.push(0)
|
||||
} else {
|
||||
// this should be called the second time an element is inserted (above case)
|
||||
vals.push(1)
|
||||
}
|
||||
})
|
||||
array0.insert(0, [0])
|
||||
t.compareArrays(vals, [0, 1])
|
||||
t.compareArrays(array0.toArray(), [0, 1])
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertAndDeleteEventsForTypes = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {Object<string,any>|null}
|
||||
*/
|
||||
let event = null
|
||||
array0.observe(e => {
|
||||
event = e
|
||||
})
|
||||
array0.insert(0, [new Y.Node()])
|
||||
t.assert(event !== null)
|
||||
event = null
|
||||
array0.delete(0)
|
||||
t.assert(event !== null)
|
||||
event = null
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testChangeEvent = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {delta.Delta<any>}
|
||||
*/
|
||||
let d = delta.create()
|
||||
array0.observe(e => {
|
||||
d = e.delta
|
||||
})
|
||||
const newArr = new Y.Node()
|
||||
array0.insert(0, [newArr, 4, 'dtrn'])
|
||||
t.assert(d !== null && d.children.len === 1)
|
||||
t.compare(d, delta.create().insert([newArr, 4, 'dtrn']).done())
|
||||
array0.delete(0, 2)
|
||||
t.assert(d !== null && d.children.len === 1)
|
||||
t.compare(d.toJSON().children, [{ delete: 2 }])
|
||||
array0.insert(1, [0.1])
|
||||
t.assert(d !== null && d.children.len === 2)
|
||||
t.compare(d, delta.create().retain(1).insert([0.1]).done())
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testInsertAndDeleteEventsForTypes2 = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {Array<Object<string,any>>}
|
||||
*/
|
||||
const events = []
|
||||
array0.observe(e => {
|
||||
events.push(e)
|
||||
})
|
||||
array0.insert(0, ['hi', new Y.Node()])
|
||||
t.assert(events.length === 1, 'Event is triggered exactly once for insertion of two elements')
|
||||
array0.delete(1)
|
||||
t.assert(events.length === 2, 'Event is triggered exactly once for deletion')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* This issue has been reported here https://github.com/yjs/yjs/issues/155
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testNewChildDoesNotEmitEventInTransaction = tc => {
|
||||
const { array0, users } = init(tc, { users: 2 })
|
||||
let fired = false
|
||||
users[0].transact(() => {
|
||||
const newMap = new Y.Node()
|
||||
newMap.observe(() => {
|
||||
fired = true
|
||||
})
|
||||
array0.insert(0, [newMap])
|
||||
newMap.setAttr('tst', 42)
|
||||
})
|
||||
t.assert(!fired, 'Event does not trigger')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGarbageCollector = tc => {
|
||||
const { testConnector, users, array0 } = init(tc, { users: 3 })
|
||||
array0.insert(0, ['x', 'y', 'z'])
|
||||
testConnector.flushAllMessages()
|
||||
users[0].disconnect()
|
||||
array0.delete(0, 3)
|
||||
users[0].connect()
|
||||
testConnector.flushAllMessages()
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testEventTargetIsSetCorrectlyOnLocal = tc => {
|
||||
const { array0, users } = init(tc, { users: 3 })
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let event
|
||||
array0.observe(e => {
|
||||
event = e
|
||||
})
|
||||
array0.insert(0, ['stuff'])
|
||||
t.assert(event.target === array0, '"target" property is set correctly')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testEventTargetIsSetCorrectlyOnRemote = tc => {
|
||||
const { testConnector, array0, array1, users } = init(tc, { users: 3 })
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let event
|
||||
array0.observe(e => {
|
||||
event = e
|
||||
})
|
||||
array1.insert(0, ['stuff'])
|
||||
testConnector.flushAllMessages()
|
||||
t.assert(event.target === array0, '"target" property is set correctly')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIteratingArrayContainingTypes = _tc => {
|
||||
const y = new Y.Doc()
|
||||
const arr = y.get('arr')
|
||||
const numItems = 10
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
const map = new Y.Node()
|
||||
map.setAttr('value', i)
|
||||
arr.push([map])
|
||||
}
|
||||
let cnt = 0
|
||||
for (const item of arr.toArray()) {
|
||||
t.assert(item.getAttr('value') === cnt++, 'value is correct')
|
||||
}
|
||||
y.destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testAttributedContent = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
/**
|
||||
* @type {Y.Node<{ children: number }>}
|
||||
*/
|
||||
const yarray = ydoc.get()
|
||||
yarray.insert(0, [1, 2])
|
||||
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||
|
||||
ydoc.on('afterTransaction', tr => {
|
||||
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
|
||||
})
|
||||
t.group('insert / delete', () => {
|
||||
ydoc.transact(() => {
|
||||
yarray.delete(0, 1)
|
||||
yarray.insert(1, [42])
|
||||
})
|
||||
const expectedContent = delta.create().insert([1], null, { delete: [] }).insert([2]).insert([42], null, { insert: [] })
|
||||
const attributedContent = yarray.toDelta({ renderer })
|
||||
console.log(attributedContent.toJSON())
|
||||
t.assert(attributedContent.equals(expectedContent))
|
||||
})
|
||||
}
|
||||
|
||||
let _uniqueNumber = 0
|
||||
const getUniqueNumber = () => _uniqueNumber++
|
||||
|
||||
/**
|
||||
* @type {Array<function(Doc,prng.PRNG,any):void>}
|
||||
*/
|
||||
const arrayTransactions = [
|
||||
function insert (user, gen) {
|
||||
const yarray = user.get('array')
|
||||
const uniqueNumber = getUniqueNumber()
|
||||
const content = []
|
||||
const len = prng.int32(gen, 1, 4)
|
||||
for (let i = 0; i < len; i++) {
|
||||
content.push(uniqueNumber)
|
||||
}
|
||||
const pos = prng.int32(gen, 0, yarray.length)
|
||||
const oldContent = yarray.toArray()
|
||||
yarray.insert(pos, content)
|
||||
oldContent.splice(pos, 0, ...content)
|
||||
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
|
||||
},
|
||||
function insertTypeArray (user, gen) {
|
||||
const yarray = user.get('array')
|
||||
const pos = prng.int32(gen, 0, yarray.length)
|
||||
yarray.insert(pos, [new Y.Node()])
|
||||
const array2 = yarray.get(pos)
|
||||
array2.insert(0, [1, 2, 3, 4])
|
||||
},
|
||||
function insertTypeMap (user, gen) {
|
||||
const yarray = user.get('array')
|
||||
const pos = prng.int32(gen, 0, yarray.length)
|
||||
yarray.insert(pos, [new Y.Node()])
|
||||
const map = yarray.get(pos)
|
||||
map.setAttr('someprop', 42)
|
||||
map.setAttr('someprop', 43)
|
||||
map.setAttr('someprop', 44)
|
||||
},
|
||||
function insertTypeNull (user, gen) {
|
||||
const yarray = user.get('array')
|
||||
const pos = prng.int32(gen, 0, yarray.length)
|
||||
yarray.insert(pos, [null])
|
||||
},
|
||||
function _delete (user, gen) {
|
||||
const yarray = user.get('array')
|
||||
const length = yarray.length
|
||||
if (length > 0) {
|
||||
let somePos = prng.int32(gen, 0, length - 1)
|
||||
let delLength = prng.int32(gen, 1, math.min(2, length - somePos))
|
||||
if (prng.bool(gen)) {
|
||||
const type = yarray.get(somePos)
|
||||
if (type instanceof Y.Node && type.length > 0) {
|
||||
somePos = prng.int32(gen, 0, type.length - 1)
|
||||
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))
|
||||
type.delete(somePos, delLength)
|
||||
}
|
||||
} else {
|
||||
const oldContent = yarray.toArray()
|
||||
yarray.delete(somePos, delLength)
|
||||
oldContent.splice(somePos, delLength)
|
||||
t.compareArrays(yarray.toArray(), oldContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests6 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 6)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests40 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 40)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests42 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 42)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests43 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 43)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests44 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 44)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests45 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 45)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests46 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 46)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests300 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests400 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 400)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests500 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests600 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 600)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests1000 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests1800 = tc => {
|
||||
applyRandomTests(tc, arrayTransactions, 1800)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests3000 = tc => {
|
||||
t.skip(!t.production)
|
||||
applyRandomTests(tc, arrayTransactions, 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests5000 = tc => {
|
||||
t.skip(!t.production)
|
||||
applyRandomTests(tc, arrayTransactions, 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYarrayTests30000 = tc => {
|
||||
t.skip(!t.production)
|
||||
applyRandomTests(tc, arrayTransactions, 30000)
|
||||
}
|
||||
727
tests/y-map.tests.js
Normal file
727
tests/y-map.tests.js
Normal file
@@ -0,0 +1,727 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
|
||||
import { AttributionsRenderer } from '../src/utils/Renderer.js'
|
||||
import { createIdMapFromIdSet } from '../src/utils/ids.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as prng from 'lib0/prng'
|
||||
import * as delta from 'lib0/delta'
|
||||
import * as s from 'lib0/schema'
|
||||
import * as object from 'lib0/object'
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testIterators = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
/**
|
||||
* @type {Y.Node<{attrs: { [k:string]: number} }>}
|
||||
*/
|
||||
const ymap = ydoc.get()
|
||||
// we are only checking if the type assumptions are correct
|
||||
/**
|
||||
* @type {Array<number>}
|
||||
*/
|
||||
const vals = Array.from(ymap.attrValues())
|
||||
/**
|
||||
* @type {Array<[string,number]>}
|
||||
*/
|
||||
const entries = Array.from(ymap.attrEntries())
|
||||
/**
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
const keys = Array.from(ymap.attrKeys())
|
||||
console.log(vals, entries, keys)
|
||||
}
|
||||
|
||||
export const testNestedMapEvent = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ymap = ydoc.get()
|
||||
const ymapNested = ymap.setAttr('nested', new Y.Node())
|
||||
let called = 0
|
||||
ymap.observeDeep(event => {
|
||||
const d = event.deltaDeep
|
||||
called++
|
||||
t.compare(d, delta.create().modifyAttr('nested', delta.create().setAttr('k', 'v')).done())
|
||||
})
|
||||
ymapNested.setAttr('k', 'v')
|
||||
t.assert(called === 1)
|
||||
}
|
||||
|
||||
export const testNestedMapEvent2 = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yarr = ydoc.get()
|
||||
const ymapNested = new Y.Node()
|
||||
yarr.insert(0, [ymapNested])
|
||||
let called = 0
|
||||
yarr.observeDeep(event => {
|
||||
const d = event.deltaDeep
|
||||
called++
|
||||
t.compare(d, delta.create().modify(delta.create().setAttr('k', 'v')))
|
||||
})
|
||||
ymapNested.setAttr('k', 'v')
|
||||
t.assert(called === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computing event changes after transaction should result in an error. See yjs#539
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testMapEventError = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const ymap = doc.get()
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let event = null
|
||||
ymap.observe((e) => {
|
||||
event = e
|
||||
})
|
||||
t.fails(() => {
|
||||
t.info(event.keys)
|
||||
})
|
||||
t.fails(() => {
|
||||
t.info(event.keys)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testMapHavingIterableAsConstructorParamTests = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
const m1 = Y.Node.from(delta.create().setAttr('number', 1).setAttr('string', 'hello'))
|
||||
map0.setAttr('m1', m1)
|
||||
t.assert(m1.getAttr('number') === 1)
|
||||
t.assert(m1.getAttr('string') === 'hello')
|
||||
const m2 = Y.Node.from(delta.create(delta.$deltaAny).setAttrs({ object: { x: 1 }, boolean: true }).done())
|
||||
map0.setAttr('m2', m2)
|
||||
t.assert(m2.getAttr('object')?.x === 1)
|
||||
t.assert(m2.getAttr('boolean') === true)
|
||||
const m3 = new Y.Node()
|
||||
m3.applyDelta(m1.toDelta())
|
||||
m3.applyDelta(m2.toDelta())
|
||||
map0.setAttr('m3', m3)
|
||||
t.assert(m3.getAttr('number') === 1)
|
||||
t.assert(m3.getAttr('string') === 'hello')
|
||||
t.assert(m3.getAttr('object')?.x === 1)
|
||||
t.assert(m3.getAttr('boolean') === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testBasicMapTests = tc => {
|
||||
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
|
||||
users[2].disconnect()
|
||||
|
||||
map0.setAttr('null', null)
|
||||
map0.setAttr('number', 1)
|
||||
map0.setAttr('string', 'hello Y')
|
||||
map0.setAttr('object', { key: { key2: 'value' } })
|
||||
map0.setAttr('y-map', new Y.Node())
|
||||
map0.setAttr('boolean1', true)
|
||||
map0.setAttr('boolean0', false)
|
||||
const map = map0.getAttr('y-map')
|
||||
map.setAttr('y-array', new Y.Node())
|
||||
const array = map.getAttr('y-array')
|
||||
array.insert(0, [0])
|
||||
array.insert(0, [-1])
|
||||
|
||||
t.assert(map0.getAttr('null') === null, 'client 0 computed the change (null)')
|
||||
t.assert(map0.getAttr('number') === 1, 'client 0 computed the change (number)')
|
||||
t.assert(map0.getAttr('string') === 'hello Y', 'client 0 computed the change (string)')
|
||||
t.assert(map0.getAttr('boolean0') === false, 'client 0 computed the change (boolean)')
|
||||
t.assert(map0.getAttr('boolean1') === true, 'client 0 computed the change (boolean)')
|
||||
t.compare(map0.getAttr('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
|
||||
t.assert(map0.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 0 computed the change (type)')
|
||||
t.assert(map0.attrSize === 7, 'client 0 map has correct size')
|
||||
|
||||
users[2].connect()
|
||||
testConnector.flushAllMessages()
|
||||
|
||||
t.assert(map1.getAttr('null') === null, 'client 1 received the update (null)')
|
||||
t.assert(map1.getAttr('number') === 1, 'client 1 received the update (number)')
|
||||
t.assert(map1.getAttr('string') === 'hello Y', 'client 1 received the update (string)')
|
||||
t.assert(map1.getAttr('boolean0') === false, 'client 1 computed the change (boolean)')
|
||||
t.assert(map1.getAttr('boolean1') === true, 'client 1 computed the change (boolean)')
|
||||
t.compare(map1.getAttr('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
|
||||
t.assert(map1.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 1 received the update (type)')
|
||||
t.assert(map1.attrSize === 7, 'client 1 map has correct size')
|
||||
|
||||
// compare disconnected user
|
||||
t.assert(map2.getAttr('null') === null, 'client 2 received the update (null) - was disconnected')
|
||||
t.assert(map2.getAttr('number') === 1, 'client 2 received the update (number) - was disconnected')
|
||||
t.assert(map2.getAttr('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
|
||||
t.assert(map2.getAttr('boolean0') === false, 'client 2 computed the change (boolean)')
|
||||
t.assert(map2.getAttr('boolean1') === true, 'client 2 computed the change (boolean)')
|
||||
t.compare(map2.getAttr('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected')
|
||||
t.assert(map2.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 2 received the update (type) - was disconnected')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetAndSetOfMapProperty = tc => {
|
||||
const { testConnector, users, map0 } = init(tc, { users: 2 })
|
||||
map0.setAttr('stuff', 'stuffy')
|
||||
map0.setAttr('undefined', undefined)
|
||||
map0.setAttr('null', null)
|
||||
t.compare(map0.getAttr('stuff'), 'stuffy')
|
||||
|
||||
testConnector.flushAllMessages()
|
||||
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.compare(u.getAttr('stuff'), 'stuffy')
|
||||
t.assert(u.getAttr('undefined') === undefined, 'undefined')
|
||||
t.compare(u.getAttr('null'), null, 'null')
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testYmapSetsYmap = tc => {
|
||||
const { users, map0 } = init(tc, { users: 2 })
|
||||
const map = map0.setAttr('Map', new Y.Node())
|
||||
t.assert(map0.getAttr('Map') === map)
|
||||
map.setAttr('one', 1)
|
||||
t.compare(map.getAttr('one'), 1)
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testYmapSetsYarray = tc => {
|
||||
const { users, map0 } = init(tc, { users: 2 })
|
||||
const array = map0.setAttr('Array', new Y.Node())
|
||||
t.assert(array === map0.getAttr('Array'))
|
||||
array.insert(0, [1, 2, 3])
|
||||
// @ts-ignore
|
||||
t.compare(map0.toJSON().attrs, { Array: { children: [1, 2, 3] } })
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetAndSetOfMapPropertySyncs = tc => {
|
||||
const { testConnector, users, map0 } = init(tc, { users: 2 })
|
||||
map0.setAttr('stuff', 'stuffy')
|
||||
t.compare(map0.getAttr('stuff'), 'stuffy')
|
||||
testConnector.flushAllMessages()
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.compare(u.getAttr('stuff'), 'stuffy')
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetAndSetOfMapPropertyWithConflict = tc => {
|
||||
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
|
||||
map0.setAttr('stuff', 'c0')
|
||||
map1.setAttr('stuff', 'c1')
|
||||
testConnector.flushAllMessages()
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.compare(u.getAttr('stuff'), 'c1')
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSizeAndDeleteOfMapProperty = tc => {
|
||||
const { map0 } = init(tc, { users: 1 })
|
||||
map0.setAttr('stuff', 'c0')
|
||||
map0.setAttr('otherstuff', 'c1')
|
||||
t.assert(map0.attrSize === 2, `map size is ${map0.attrSize} expected 2`)
|
||||
map0.deleteAttr('stuff')
|
||||
t.assert(map0.attrSize === 1, `map size after delete is ${map0.attrSize}, expected 1`)
|
||||
map0.deleteAttr('otherstuff')
|
||||
t.assert(map0.attrSize === 0, `map size after delete is ${map0.attrSize}, expected 0`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetAndSetAndDeleteOfMapProperty = tc => {
|
||||
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
|
||||
map0.setAttr('stuff', 'c0')
|
||||
map1.setAttr('stuff', 'c1')
|
||||
map1.deleteAttr('stuff')
|
||||
testConnector.flushAllMessages()
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.assert(u.getAttr('stuff') === undefined)
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSetAndClearOfMapProperties = tc => {
|
||||
const { testConnector, users, map0 } = init(tc, { users: 1 })
|
||||
map0.setAttr('stuff', 'c0')
|
||||
map0.setAttr('otherstuff', 'c1')
|
||||
map0.clearAttrs()
|
||||
testConnector.flushAllMessages()
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.assert(u.getAttr('stuff') === undefined)
|
||||
t.assert(u.getAttr('otherstuff') === undefined)
|
||||
t.assert(u.attrSize === 0, `map size after clear is ${u.attrSize}, expected 0`)
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSetAndClearOfMapPropertiesWithConflicts = tc => {
|
||||
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
|
||||
map0.setAttr('stuff', 'c0')
|
||||
map1.setAttr('stuff', 'c1')
|
||||
map1.setAttr('stuff', 'c2')
|
||||
map2.setAttr('stuff', 'c3')
|
||||
testConnector.flushAllMessages()
|
||||
map0.setAttr('otherstuff', 'c0')
|
||||
map1.setAttr('otherstuff', 'c1')
|
||||
map2.setAttr('otherstuff', 'c2')
|
||||
map3.setAttr('otherstuff', 'c3')
|
||||
map3.clearAttrs()
|
||||
testConnector.flushAllMessages()
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.assert(u.getAttr('stuff') === undefined)
|
||||
t.assert(u.getAttr('otherstuff') === undefined)
|
||||
t.assert(u.attrSize === 0, `map size after clear is ${u.attrSize}, expected 0`)
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
|
||||
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
|
||||
map0.setAttr('stuff', 'c0')
|
||||
map1.setAttr('stuff', 'c1')
|
||||
map1.setAttr('stuff', 'c2')
|
||||
map2.setAttr('stuff', 'c3')
|
||||
testConnector.flushAllMessages()
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.compare(u.getAttr('stuff'), 'c3')
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
|
||||
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
|
||||
map0.setAttr('stuff', 'c0')
|
||||
map1.setAttr('stuff', 'c1')
|
||||
map1.setAttr('stuff', 'c2')
|
||||
map2.setAttr('stuff', 'c3')
|
||||
testConnector.flushAllMessages()
|
||||
map0.setAttr('stuff', 'deleteme')
|
||||
map1.setAttr('stuff', 'c1')
|
||||
map2.setAttr('stuff', 'c2')
|
||||
map3.setAttr('stuff', 'c3')
|
||||
map3.deleteAttr('stuff')
|
||||
testConnector.flushAllMessages()
|
||||
for (const user of users) {
|
||||
const u = user.get('map')
|
||||
t.assert(u.getAttr('stuff') === undefined)
|
||||
}
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object<string,any>} is
|
||||
* @param {Object<string,any>} should
|
||||
*/
|
||||
const compareEvent = (is, should) => {
|
||||
for (const key in should) {
|
||||
t.compare(should[key], is[key])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
|
||||
const { users, map0 } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
let event = {}
|
||||
map0.observe(e => {
|
||||
event = e // just put it on event, should be thrown synchronously anyway
|
||||
})
|
||||
map0.setAttr('stuff', 4)
|
||||
compareEvent(event, {
|
||||
target: map0,
|
||||
keysChanged: new Set(['stuff'])
|
||||
})
|
||||
// update, oldValue is in contents
|
||||
map0.setAttr('stuff', new Y.Node())
|
||||
compareEvent(event, {
|
||||
target: map0,
|
||||
keysChanged: new Set(['stuff'])
|
||||
})
|
||||
// update, oldValue is in opContents
|
||||
map0.setAttr('stuff', 5)
|
||||
// delete
|
||||
map0.deleteAttr('stuff')
|
||||
compareEvent(event, {
|
||||
keysChanged: new Set(['stuff']),
|
||||
target: map0
|
||||
})
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testThrowsDeleteEventsOnClear = tc => {
|
||||
const { users, map0 } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
let event = {}
|
||||
map0.observe(e => {
|
||||
event = e // just put it on event, should be thrown synchronously anyway
|
||||
})
|
||||
// set values
|
||||
map0.setAttr('stuff', 4)
|
||||
map0.setAttr('otherstuff', new Y.Node())
|
||||
// clear
|
||||
map0.clearAttrs()
|
||||
compareEvent(event, {
|
||||
keysChanged: new Set(['stuff', 'otherstuff']),
|
||||
target: map0
|
||||
})
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testChangeEvent = tc => {
|
||||
const { map0, users } = init(tc, { users: 2 })
|
||||
/**
|
||||
* @type {delta.Delta<any>?}
|
||||
*/
|
||||
let changes = delta.create()
|
||||
map0.observe(e => {
|
||||
changes = e.delta
|
||||
})
|
||||
map0.setAttr('a', 1)
|
||||
let keyChange = changes.attrs.a
|
||||
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 1)
|
||||
map0.setAttr('a', 2)
|
||||
keyChange = changes.attrs.a
|
||||
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
|
||||
users[0].transact(() => {
|
||||
map0.setAttr('a', 3)
|
||||
map0.setAttr('a', 4)
|
||||
})
|
||||
keyChange = changes.attrs.a
|
||||
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 4)
|
||||
users[0].transact(() => {
|
||||
map0.setAttr('b', 1)
|
||||
map0.setAttr('b', 2)
|
||||
})
|
||||
keyChange = changes.attrs.b
|
||||
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
|
||||
users[0].transact(() => {
|
||||
map0.setAttr('c', 1)
|
||||
map0.deleteAttr('c')
|
||||
})
|
||||
t.assert(changes !== null && object.isEmpty(changes.attrs))
|
||||
users[0].transact(() => {
|
||||
map0.setAttr('d', 1)
|
||||
map0.setAttr('d', 2)
|
||||
})
|
||||
keyChange = changes.attrs.d
|
||||
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testYmapEventExceptionsShouldCompleteTransaction = _tc => {
|
||||
const doc = new Y.Doc()
|
||||
const map = doc.get('map')
|
||||
|
||||
let updateCalled = false
|
||||
let throwingObserverCalled = false
|
||||
let throwingDeepObserverCalled = false
|
||||
doc.on('update', () => {
|
||||
updateCalled = true
|
||||
})
|
||||
|
||||
const throwingObserver = () => {
|
||||
throwingObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
const throwingDeepObserver = () => {
|
||||
throwingDeepObserverCalled = true
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
map.observe(throwingObserver)
|
||||
map.observeDeep(throwingDeepObserver)
|
||||
|
||||
t.fails(() => {
|
||||
map.setAttr('y', '2')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
// check if it works again
|
||||
updateCalled = false
|
||||
throwingObserverCalled = false
|
||||
throwingDeepObserverCalled = false
|
||||
t.fails(() => {
|
||||
map.setAttr('z', '3')
|
||||
})
|
||||
|
||||
t.assert(updateCalled)
|
||||
t.assert(throwingObserverCalled)
|
||||
t.assert(throwingDeepObserverCalled)
|
||||
|
||||
t.assert(map.getAttr('z') === '3')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testYmapEventHasCorrectValueWhenSettingAPrimitive = tc => {
|
||||
const { users, map0 } = init(tc, { users: 3 })
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
let event = {}
|
||||
map0.observe(e => {
|
||||
event = e
|
||||
})
|
||||
map0.setAttr('stuff', 2)
|
||||
t.compare(event.value, event.target.get(event.name))
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc => {
|
||||
const { users, map0, map1, testConnector } = init(tc, { users: 3 })
|
||||
/**
|
||||
* @type {Object<string,any>}
|
||||
*/
|
||||
let event = {}
|
||||
map0.observe(e => {
|
||||
event = e
|
||||
})
|
||||
map1.setAttr('stuff', 2)
|
||||
testConnector.flushAllMessages()
|
||||
t.compare(event.value, event.target.get(event.name))
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testAttributedContent = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const ymap = ydoc.get()
|
||||
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||
|
||||
ydoc.on('afterTransaction', tr => {
|
||||
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||
renderer = new AttributionsRenderer(Y.createContentMap(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, [])))
|
||||
})
|
||||
t.group('initial value', () => {
|
||||
ymap.setAttr('test', 42)
|
||||
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 42, attribution: { insert: [] } }) }
|
||||
const attributedContent = ymap.toDelta({ renderer })
|
||||
console.log(attributedContent.toJSON())
|
||||
t.compare(expectedContent, attributedContent.toJSON().attrs)
|
||||
})
|
||||
t.group('overwrite value', () => {
|
||||
ymap.setAttr('test', 'fourtytwo')
|
||||
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 'fourtytwo', attribution: { insert: [] } }) }
|
||||
const attributedContent = ymap.toDelta({ renderer })
|
||||
console.log(attributedContent)
|
||||
t.compare(expectedContent, attributedContent.toJSON().attrs)
|
||||
})
|
||||
t.group('delete value', () => {
|
||||
ymap.deleteAttr('test')
|
||||
// Snapshot-mode `toDelta(renderer)` (no `itemsToRender` opt) must not emit
|
||||
// `DeleteAttrOp`. An attribute deleted under attribution is still
|
||||
// observable in the rendered state with its prior value and a `delete`
|
||||
// attribution marker - symmetric with how soft-deleted content children
|
||||
// surface as `InsertOp` with `{ delete: [] }` rather than `DeleteOp`.
|
||||
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 'fourtytwo', attribution: { delete: [] } }) }
|
||||
const attributedContent = ymap.toDelta({ renderer })
|
||||
console.log(attributedContent.toJSON())
|
||||
t.compare(expectedContent, attributedContent.toJSON().attrs)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {Array<function(Doc,prng.PRNG):void>}
|
||||
*/
|
||||
const mapTransactions = [
|
||||
function set (user, gen) {
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
const value = prng.utf16String(gen)
|
||||
user.get('map').setAttr(key, value)
|
||||
},
|
||||
function setType (user, gen) {
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
const type = new Y.Node()
|
||||
user.get('map').setAttr(key, type)
|
||||
if (prng.bool(gen)) {
|
||||
type.insert(0, [1, 2, 3, 4])
|
||||
} else {
|
||||
type.setAttr('deepkey', 'deepvalue')
|
||||
}
|
||||
},
|
||||
function _delete (user, gen) {
|
||||
const key = prng.oneOf(gen, ['one', 'two'])
|
||||
user.get('map').deleteAttr(key)
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests10 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests40 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 40)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests42 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 42)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests43 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 43)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests44 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 44)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests45 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 45)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests46 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 46)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests300 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests400 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 400)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests500 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests600 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 600)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests1000 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests1800 = tc => {
|
||||
applyRandomTests(tc, mapTransactions, 1800)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests5000 = tc => {
|
||||
t.skip(!t.production)
|
||||
applyRandomTests(tc, mapTransactions, 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests10000 = tc => {
|
||||
t.skip(!t.production)
|
||||
applyRandomTests(tc, mapTransactions, 10000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRepeatGeneratingYmapTests100000 = tc => {
|
||||
t.skip(!t.production)
|
||||
applyRandomTests(tc, mapTransactions, 100000)
|
||||
}
|
||||
2385
tests/y-text.tests.js
Normal file
2385
tests/y-text.tests.js
Normal file
File diff suppressed because it is too large
Load Diff
473
tests/y-xml.tests.js
Normal file
473
tests/y-xml.tests.js
Normal file
@@ -0,0 +1,473 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import { init, compare } from './testHelper.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as delta from 'lib0/delta'
|
||||
|
||||
export const testCustomTypings = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ymap = ydoc.get()
|
||||
/**
|
||||
* @type {Y.Node<{ attrs: { num: number, str: string, [k:string]: number|string } }>}
|
||||
*/
|
||||
const yxml = ymap.setAttr('yxml', new Y.Node('test'))
|
||||
/**
|
||||
* @type {number|undefined}
|
||||
*/
|
||||
const num = yxml.getAttr('num')
|
||||
/**
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
const str = yxml.getAttr('str')
|
||||
/**
|
||||
* @type {object|number|string|undefined}
|
||||
*/
|
||||
const dtrn = yxml.getAttr('dtrn')
|
||||
const attrs = yxml.getAttrs()
|
||||
/**
|
||||
* @type {object|number|string|undefined}
|
||||
*/
|
||||
const any = attrs.shouldBeAny
|
||||
console.log({ num, str, dtrn, attrs, any })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testSetProperty = tc => {
|
||||
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
|
||||
xml0.setAttr('height', '10')
|
||||
t.assert(xml0.getAttr('height') === '10', 'Simple set+get works')
|
||||
testConnector.flushAllMessages()
|
||||
t.assert(xml1.getAttr('height') === '10', 'Simple set+get works (remote)')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testHasProperty = tc => {
|
||||
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
|
||||
xml0.setAttr('height', '10')
|
||||
t.assert(xml0.hasAttr('height'), 'Simple set+has works')
|
||||
testConnector.flushAllMessages()
|
||||
t.assert(xml1.hasAttr('height'), 'Simple set+has works (remote)')
|
||||
xml0.deleteAttr('height')
|
||||
t.assert(!xml0.hasAttr('height'), 'Simple set+remove+has works')
|
||||
testConnector.flushAllMessages()
|
||||
t.assert(!xml1.hasAttr('height'), 'Simple set+remove+has works (remote)')
|
||||
compare(users)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testYtextAttributes = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('')
|
||||
ytext.observe(event => {
|
||||
t.assert(event.delta.attrs.test?.type === 'insert')
|
||||
})
|
||||
ytext.setAttr('test', 42)
|
||||
t.compare(ytext.getAttr('test'), 42)
|
||||
t.compare(ytext.getAttrs(), { test: 42 })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testSiblings = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yxml = ydoc.get()
|
||||
const first = new Y.Node()
|
||||
const second = new Y.Node('p')
|
||||
yxml.insert(0, [first, second])
|
||||
t.assert(first.parent === /** @type {Y.Node<any>} */ (yxml))
|
||||
t.assert(yxml.parent === null)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testClone = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yxml = ydoc.get()
|
||||
const first = new Y.Node('text')
|
||||
const second = new Y.Node('p')
|
||||
const third = new Y.Node('p')
|
||||
yxml.push([first, second, third])
|
||||
t.compareArrays(yxml.toArray(), [first, second, third])
|
||||
const cloneYxml = yxml.clone()
|
||||
ydoc.get('copyarr').insert(0, [cloneYxml])
|
||||
t.assert(cloneYxml.length === 3)
|
||||
t.compare(cloneYxml.toJSON(), yxml.toJSON())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testFormattingBug = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yxml = ydoc.get()
|
||||
const q = delta.create()
|
||||
.insert('A', { em: {}, strong: {} })
|
||||
.insert('B', { em: {} })
|
||||
.insert('C', { em: {}, strong: {} })
|
||||
yxml.applyDelta(q)
|
||||
t.compare(yxml.toDelta(), q)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testElement = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yxmlel = ydoc.get()
|
||||
const text1 = new Y.Node('text1')
|
||||
const text2 = new Y.Node('text2')
|
||||
yxmlel.insert(0, [text1, text2])
|
||||
t.compareArrays(yxmlel.toArray(), [text1, text2])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testFragmentAttributedContent = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const yfragment = new Y.Node()
|
||||
const elem1 = Y.Node.from(delta.create().insert('hello'))
|
||||
const elem2 = new Y.Node()
|
||||
const elem3 = Y.Node.from(delta.create().insert('world'))
|
||||
yfragment.insert(0, [elem1, elem2])
|
||||
ydoc.get().insert(0, [yfragment])
|
||||
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||
ydoc.on('afterTransaction', tr => {
|
||||
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
|
||||
})
|
||||
t.group('insert / delete', () => {
|
||||
ydoc.transact(() => {
|
||||
yfragment.delete(0, 1)
|
||||
yfragment.insert(1, [elem3])
|
||||
})
|
||||
const expectedContent = delta.create().insert([elem1], null, { delete: [] }).insert([elem2]).insert([elem3], null, { insert: [] })
|
||||
const attributedContent = yfragment.toDelta({ renderer })
|
||||
console.log(attributedContent.toJSON())
|
||||
t.assert(attributedContent.equals(expectedContent))
|
||||
t.compare(elem1.toDelta({ renderer }).toJSON(), delta.create().insert('hello', null, { delete: [] }).toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testElementAttributedContent = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const yelement = ydoc.get('p')
|
||||
const elem1 = delta.create().insert('hello').done()
|
||||
const elem2 = delta.create('span').done()
|
||||
const elem3 = delta.create().insert('world').done()
|
||||
yelement.insert(0, [elem1, elem2])
|
||||
let renderer = /** @type {AbstractRenderer?} */ (null)
|
||||
ydoc.on('afterTransaction', tr => {
|
||||
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
|
||||
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
|
||||
})
|
||||
t.group('insert / delete', () => {
|
||||
ydoc.transact(() => {
|
||||
yelement.delete(0, 1)
|
||||
yelement.insert(1, [elem3])
|
||||
yelement.setAttr('key', '42')
|
||||
})
|
||||
const expectedContent = delta.create()
|
||||
.insert([delta.create().insert('hello', null, { delete: [] })], null, { delete: [] })
|
||||
.insert([elem2])
|
||||
.insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] })
|
||||
.setAttr('key', '42', { insert: [] })
|
||||
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||
console.log('retrieved content', attributedContent.toJSON())
|
||||
t.assert(attributedContent.equals(expectedContent))
|
||||
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testElementAttributedContentViaDiffer = _tc => {
|
||||
const ydocV1 = new Y.Doc()
|
||||
ydocV1.get('p').insert(0, [delta.create().insert('hello'), delta.create('span')])
|
||||
const ydoc = new Y.Doc()
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||
const yelement = ydoc.get('p')
|
||||
const elem2 = yelement.get(1) // new Y.XmlElement('span')
|
||||
const elem3 = Y.Node.from(delta.create().insert('world'))
|
||||
ydoc.transact(() => {
|
||||
yelement.delete(0, 1)
|
||||
yelement.insert(1, [elem3])
|
||||
yelement.setAttr('key', '42')
|
||||
})
|
||||
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||
const expectedContent = delta.create().insert([delta.create().insert('hello')], null, { delete: [] }).insert([elem2.toDeltaDeep()]).insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] }).setAttr('key', '42', { insert: [] })
|
||||
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||
console.log('children', attributedContent.toJSON().children)
|
||||
console.log('attributes', attributedContent.toJSON().attrs)
|
||||
t.compare(attributedContent.toJSON(), expectedContent.toJSON())
|
||||
t.assert(attributedContent.equals(expectedContent))
|
||||
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||
t.group('test getContentDeep', () => {
|
||||
const expectedContent = delta.create()
|
||||
.insert(
|
||||
[delta.create().insert('hello')],
|
||||
null,
|
||||
{ delete: [] }
|
||||
)
|
||||
.insert([delta.create('span')])
|
||||
.insert([
|
||||
delta.create().insert('world', null, { insert: [] })
|
||||
], null, { insert: [] })
|
||||
.setAttr('key', '42', { insert: [] })
|
||||
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
|
||||
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
|
||||
console.log('attributes', attributedContent.toJSON().attrs)
|
||||
t.assert(attributedContent.equals(expectedContent))
|
||||
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||
t.assert(attributedContent.name === null)
|
||||
})
|
||||
ydoc.transact(() => {
|
||||
elem3.insert(0, 'big')
|
||||
})
|
||||
t.group('test getContentDeep after some more updates', () => {
|
||||
t.info('expecting DiffRenderer to auto update itself')
|
||||
const expectedContent = delta.create()
|
||||
.insert(
|
||||
[delta.create().insert('hello')],
|
||||
null,
|
||||
{ delete: [] }
|
||||
)
|
||||
.insert([delta.create('span')])
|
||||
.insert([
|
||||
delta.create().insert('bigworld', null, { insert: [] })
|
||||
], null, { insert: [] })
|
||||
.setAttr('key', '42', { insert: [] })
|
||||
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
|
||||
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
|
||||
console.log('attributes', attributedContent.toJSON().attrs)
|
||||
t.assert(attributedContent.equals(expectedContent))
|
||||
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
|
||||
t.assert(attributedContent.name === null)
|
||||
})
|
||||
Y.applyUpdate(ydocV1, Y.encodeStateAsUpdate(ydoc))
|
||||
t.group('test getContentDeep both docs synced', () => {
|
||||
t.info('expecting DiffRenderer to auto update itself')
|
||||
const expectedContent = delta.create().insert([delta.create('span')]).insert([
|
||||
delta.create().insert('bigworld')
|
||||
]).setAttr('key', '42')
|
||||
const attributedContent = yelement.toDeltaDeep({ renderer })
|
||||
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
|
||||
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
|
||||
console.log('attributes', attributedContent.toJSON().attrs)
|
||||
t.assert(attributedContent.equals(expectedContent))
|
||||
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42' } })
|
||||
t.assert(attributedContent.name === null)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRendererSimpleExample = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
ydoc.clientID = 0
|
||||
// create some initial content
|
||||
ydoc.get().insert(0, [delta.create().insert('hello world')])
|
||||
const ydocFork = new Y.Doc()
|
||||
ydocFork.clientID = 1
|
||||
Y.applyUpdate(ydocFork, Y.encodeStateAsUpdate(ydoc))
|
||||
// modify the fork
|
||||
// append a span element
|
||||
ydocFork.get().insert(1, [new Y.Node('span')])
|
||||
const ytext = ydocFork.get().get(0)
|
||||
// make "hello" italic
|
||||
ytext.format(0, 5, { italic: true })
|
||||
ytext.insert(11, 'deleteme')
|
||||
ytext.delete(11, 8)
|
||||
ytext.insert(11, '!')
|
||||
// highlight the changes
|
||||
console.log(JSON.stringify(ydocFork.get().toDeltaDeep({ renderer: Y.createDiffRenderer(ydoc, ydocFork) }), null, 2))
|
||||
/* =>
|
||||
{
|
||||
"children": {
|
||||
"ops": [
|
||||
{
|
||||
"insert": [
|
||||
{
|
||||
"ops": [
|
||||
{
|
||||
"insert": "hello",
|
||||
"attributes": {
|
||||
"italic": true
|
||||
},
|
||||
"attribution": {
|
||||
"attributes": {
|
||||
"italic": [] -- the attribute "italic" was changed
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"insert": " world" -- "world" remains unchanged
|
||||
},
|
||||
{
|
||||
"insert": "!",
|
||||
"attribution": {
|
||||
"insert": [] -- "!" was inserted
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"insert": [
|
||||
{
|
||||
"nodeName": "span",
|
||||
"children": {
|
||||
"ops": []
|
||||
},
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"attribution": {
|
||||
"insert": [] -- A <span/> tag was inserted
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a delta tree and collect every op whose constructor matches one of the
|
||||
* forbidden names. Returns a list of `{ path, opType, op }` for reporting.
|
||||
*
|
||||
* @param {any} d
|
||||
* @param {Array<string>} forbidden - constructor names that should never appear
|
||||
* @param {string} [path]
|
||||
* @param {Array<{path:string, opType:string, op:any}>} [acc]
|
||||
*/
|
||||
const collectForbiddenOps = (d, forbidden, path = '$', acc = []) => {
|
||||
if (d == null || typeof d !== 'object') return acc
|
||||
if (d.attrs != null) {
|
||||
for (const attrOp of d.attrs) {
|
||||
const name = attrOp?.constructor?.name
|
||||
if (name && forbidden.includes(name)) {
|
||||
acc.push({ path: `${path}.attrs[${attrOp.key}]`, opType: name, op: attrOp })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (d.children?.start != null) {
|
||||
let op = d.children.start
|
||||
let i = 0
|
||||
while (op != null) {
|
||||
const name = op.constructor?.name
|
||||
if (name && forbidden.includes(name)) {
|
||||
acc.push({ path: `${path}.children[${i}]`, opType: name, op })
|
||||
}
|
||||
if (op.insert && Array.isArray(op.insert)) {
|
||||
op.insert.forEach((/** @type {any} */ nested, /** @type {number} */ j) => collectForbiddenOps(nested, forbidden, `${path}.children[${i}].insert[${j}]`, acc))
|
||||
}
|
||||
if (op.value != null) {
|
||||
collectForbiddenOps(op.value, forbidden, `${path}.children[${i}].value`, acc)
|
||||
}
|
||||
op = op.next
|
||||
i++
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduces the y-prosemirror issue #247 contract violation at the @y/y
|
||||
* level: `ynode.toDeltaDeep({ renderer })` is supposed to surface soft-deleted content
|
||||
* as positive ops (`SetAttrOp` / `InsertOp`) carrying attribution metadata,
|
||||
* never as `DeleteAttrOp` / `DeleteOp`. Today, when a parent YXmlElement is
|
||||
* itself soft-deleted under attribution, `nodeMapGetDelta` (ynode.js:1928)
|
||||
* and the children traversal in `toDelta` emit raw delete ops for the
|
||||
* cascaded child setAttr / content items - which downstream consumers
|
||||
* (lib0/delta `diff`, y-prosemirror's PM mapper) cannot handle.
|
||||
*
|
||||
* Expected after fix: walking the delta returned by `parent.toDeltaDeep({ renderer })`
|
||||
* finds zero `DeleteAttrOp` entries in any `attrs` map and zero `DeleteOp`
|
||||
* entries in any `children` list, at every nesting level.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testToDeltaDeepEmitsNoDeleteOpsForSoftDeletedParent = _tc => {
|
||||
// Base doc: a fragment containing one YXmlElement that has an `id` attr
|
||||
// and one nested text child.
|
||||
const ydocV1 = new Y.Doc({ gc: false })
|
||||
const parentV1 = ydocV1.get('frag')
|
||||
const childV1 = new Y.Node('item')
|
||||
childV1.setAttr('id', 'C')
|
||||
childV1.insert(0, [delta.create().insert('hello')])
|
||||
parentV1.insert(0, [childV1])
|
||||
|
||||
// Forked doc: copy V1, then suggestion-delete the YXmlElement child by
|
||||
// removing it from the fragment. Under the diff AM this surfaces as a
|
||||
// soft-deleted child whose own `id` setAttr Item is cascade-tombstoned.
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||
const parent = ydoc.get('frag')
|
||||
ydoc.transact(() => {
|
||||
parent.delete(0, 1)
|
||||
})
|
||||
|
||||
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||
const rendered = parent.toDeltaDeep({ renderer })
|
||||
|
||||
// The cascade should surface as positive ops with attribution, not as
|
||||
// delete ops. Find any DeleteAttrOp / DeleteOp anywhere in the tree.
|
||||
const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp'])
|
||||
if (offenders.length > 0) {
|
||||
console.log('Forbidden delete ops in toDeltaDeep output:')
|
||||
for (const o of offenders) {
|
||||
console.log(` ${o.opType} at ${o.path}`)
|
||||
}
|
||||
console.log('Full delta:', JSON.stringify(rendered.toJSON(), null, 2))
|
||||
}
|
||||
t.assert(
|
||||
offenders.length === 0,
|
||||
`toDeltaDeep(renderer) emitted ${offenders.length} forbidden delete op(s) for a soft-deleted parent (issue #247 / y-prosemirror)`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion to the cascade case above: an explicit `deleteAttr` under a diff
|
||||
* AM must also surface as a positive `SetAttrOp` carrying the prior value and
|
||||
* `{ delete: [] }` attribution, never as a `DeleteAttrOp`. The same contract
|
||||
* applies whether the attribute deletion came from a parent-cascade or from
|
||||
* a direct `ynode.deleteAttr(key)` call.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testToDeltaDeepRendersExplicitDeleteAttrAsSetAttrWithAttribution = _tc => {
|
||||
const ydocV1 = new Y.Doc({ gc: false })
|
||||
ydocV1.get('p').setAttr('id', 'C')
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
|
||||
ydoc.transact(() => {
|
||||
ydoc.get('p').deleteAttr('id')
|
||||
})
|
||||
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
|
||||
const rendered = ydoc.get('p').toDeltaDeep({ renderer })
|
||||
|
||||
const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp'])
|
||||
t.assert(offenders.length === 0, 'no DeleteAttrOp / DeleteOp from explicit deleteAttr under diff AM')
|
||||
t.compare(
|
||||
rendered.toJSON().attrs,
|
||||
{ id: { type: 'insert', value: 'C', attribution: { delete: [] } } },
|
||||
'explicit deleteAttr surfaces as SetAttrOp with prior value and delete attribution'
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user