snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
Some checks failed
Node.js CI / build (26.x) (push) Successful in 2m53s
Scorecard supply-chain security / Scorecard analysis (push) Failing after 1m5s

This commit is contained in:
upstream-mirror
2026-09-15 17:21:43 +05:00
commit 1ff79b5b83
75 changed files with 32547 additions and 0 deletions

236
tests/IdMap.tests.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

197
tests/delta.tests.js Normal file
View 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
View 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
View 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
View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

473
tests/y-xml.tests.js Normal file
View 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'
)
}