snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
This commit is contained in:
372
tests/relativePositions.tests.js
Normal file
372
tests/relativePositions.tests.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as dpos from 'lib0/delta/position'
|
||||
|
||||
/**
|
||||
* @param {Y.Node<{text:true}>} ytext
|
||||
*/
|
||||
const checkRelativePositions = ytext => {
|
||||
// test if all positions are encoded and restored correctly
|
||||
for (let i = 0; i < ytext.length; i++) {
|
||||
// for all types of associations..
|
||||
for (let assoc = -1; assoc < 2; assoc++) {
|
||||
const rpos = Y.createRelativePositionFromTypeIndex(ytext, i, assoc)
|
||||
const encodedRpos = Y.encodeRelativePosition(rpos)
|
||||
const decodedRpos = Y.decodeRelativePosition(encodedRpos)
|
||||
const absPos = /** @type {Y.AbsolutePosition} */ (Y.createAbsolutePositionFromRelativePosition(decodedRpos, /** @type {Y.Doc} */ (ytext.doc)))
|
||||
t.assert(absPos.index === i)
|
||||
t.assert(absPos.assoc === assoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase1 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '1')
|
||||
ytext.insert(0, 'abc')
|
||||
ytext.insert(0, 'z')
|
||||
ytext.insert(0, 'y')
|
||||
ytext.insert(0, 'x')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase2 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'abc')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase3 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'abc')
|
||||
ytext.insert(0, '1')
|
||||
ytext.insert(0, 'xyz')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase4 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '1')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase5 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '2')
|
||||
ytext.insert(0, '1')
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase6 = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
checkRelativePositions(ytext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Testing https://github.com/yjs/yjs/issues/657
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionCase7 = _tc => {
|
||||
const docA = new Y.Doc()
|
||||
const textA = docA.get('text')
|
||||
textA.insert(0, 'abcde')
|
||||
// Create a relative position at index 2 in 'textA'
|
||||
const relativePosition = Y.createRelativePositionFromTypeIndex(textA, 2)
|
||||
// Verify that the absolutes positions on 'docA' are the same
|
||||
const absolutePositionWithFollow =
|
||||
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, true)
|
||||
const absolutePositionWithoutFollow =
|
||||
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, false)
|
||||
t.assert(absolutePositionWithFollow?.index === 2)
|
||||
t.assert(absolutePositionWithoutFollow?.index === 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionAssociationDifference = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '2')
|
||||
ytext.insert(0, '1')
|
||||
const rposRight = Y.createRelativePositionFromTypeIndex(ytext, 1, 0)
|
||||
const rposLeft = Y.createRelativePositionFromTypeIndex(ytext, 1, -1)
|
||||
ytext.insert(1, 'x')
|
||||
const posRight = Y.createAbsolutePositionFromRelativePosition(rposRight, ydoc)
|
||||
const posLeft = Y.createAbsolutePositionFromRelativePosition(rposLeft, ydoc)
|
||||
t.assert(posRight != null && posRight.index === 2)
|
||||
t.assert(posLeft != null && posLeft.index === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testRelativePositionWithUndo = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello world')
|
||||
const rpos = Y.createRelativePositionFromTypeIndex(ytext, 1)
|
||||
const um = new Y.UndoManager(ytext)
|
||||
ytext.delete(0, 6)
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 0)
|
||||
um.undo()
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 1)
|
||||
const posWithoutFollow = Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)
|
||||
console.log({ posWithoutFollow })
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)?.index === 6)
|
||||
const ydocClone = new Y.Doc()
|
||||
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone)?.index === 6)
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone, false)?.index === 6)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsFlatTextRoundtrip = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello world')
|
||||
const positions = [dpos.create([0]), dpos.create([5]), dpos.create([5], -1), dpos.create([11])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
|
||||
t.assert(rposs.every(rpos => rpos !== null))
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||
positions.forEach((pos, i) => {
|
||||
const b = back[i]
|
||||
t.assert(b !== null && dpos.equals(b, pos))
|
||||
})
|
||||
// the singular wrappers behave like a one-element batch
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
|
||||
t.assert(Y.compareRelativePositions(rpos, rposs[1]))
|
||||
const single = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
|
||||
t.assert(single !== null && dpos.equals(single, positions[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsNested = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yroot = ydoc.get('arr')
|
||||
yroot.insert(0, ['a', new Y.Node(), 'b'])
|
||||
const child = /** @type {Y.Node<any>} */ (yroot.get(1))
|
||||
child.insert(0, 'xyz')
|
||||
const positions = [dpos.create([1, 2]), dpos.create([0])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions)
|
||||
t.assert(rposs.every(rpos => rpos !== null))
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs)
|
||||
positions.forEach((pos, i) => {
|
||||
const b = back[i]
|
||||
t.assert(b !== null && dpos.equals(b, pos))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsAttrDescent = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ymap = ydoc.get('map')
|
||||
const child = ymap.setAttr('k', new Y.Node())
|
||||
child.insert(0, 'ab')
|
||||
const pos = dpos.create(['k', 1])
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
|
||||
t.assert(rpos !== null)
|
||||
const back = Y.createDeltaPositionFromRelativePosition(ymap, rpos)
|
||||
t.assert(back !== null && dpos.equals(back, pos))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsAttrLeaf = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ymap = ydoc.get('map')
|
||||
ymap.setAttr('k', 42)
|
||||
const pos = dpos.create(['k'])
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
|
||||
t.assert(rpos !== null && rpos.item !== null)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
|
||||
// the attribute-leaf anchor survives overwriting the attribute
|
||||
ymap.setAttr('k', 43)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
|
||||
// .. but not deleting it
|
||||
ymap.deleteAttr('k')
|
||||
t.assert(Y.createDeltaPositionFromRelativePosition(ymap, rpos) === null)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsDocRooted = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
ytext.insert(0, 'hello')
|
||||
// doc-rooted paths start with a root-type name; numeric or terminal-string steps at doc level are unresolvable
|
||||
const positions = [dpos.create(['text', 3]), dpos.create([0]), dpos.create(['text'])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ydoc, positions)
|
||||
t.assert(rposs[0] !== null && rposs[1] === null && rposs[2] === null)
|
||||
const back = Y.createDeltaPositionFromRelativePosition(ydoc, rposs[0])
|
||||
t.assert(back !== null && dpos.equals(back, positions[0]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsAssocThroughEdit = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, '2')
|
||||
ytext.insert(0, '1')
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, [dpos.create([1]), dpos.create([1], -1)])
|
||||
ytext.insert(1, 'x')
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||
t.assert(back[0] !== null && dpos.equals(back[0], dpos.create([2])))
|
||||
t.assert(back[1] !== null && dpos.equals(back[1], dpos.create([1], -1)))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsSurviveConcurrentEdit = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
ytext.insert(0, 'hello')
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([2]))
|
||||
t.assert(rpos !== null)
|
||||
const encoded = Y.encodeRelativePosition(/** @type {Y.RelativePosition} */ (rpos))
|
||||
const ydoc2 = new Y.Doc()
|
||||
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
|
||||
const ytext2 = ydoc2.get('text')
|
||||
ytext2.insert(0, 'abc')
|
||||
const back = Y.createDeltaPositionFromRelativePosition(ytext2, Y.decodeRelativePosition(encoded))
|
||||
t.assert(back !== null && dpos.equals(back, dpos.create([5])))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsWithRenderer = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello world')
|
||||
const v1 = Y.cloneDoc(ydoc)
|
||||
ytext.delete(1, 6)
|
||||
ytext.insert(1, 'x')
|
||||
const renderer = Y.createDiffRenderer(v1, ydoc)
|
||||
const pos = dpos.create([9]) // pos after "hello wo" in the rendered diff
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, pos, { renderer })
|
||||
t.assert(rpos !== null)
|
||||
const backWith = Y.createDeltaPositionFromRelativePosition(ytext, rpos, { renderer })
|
||||
t.assert(backWith !== null && dpos.equals(backWith, pos))
|
||||
const backWithout = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
|
||||
t.assert(backWithout !== null && dpos.equals(backWithout, dpos.create([3])))
|
||||
// the node's own renderer is the default - matching toDelta / node.delta
|
||||
ytext.useRenderer(renderer)
|
||||
const rpos2 = Y.createRelativePositionFromDeltaPosition(ytext, pos)
|
||||
t.assert(Y.compareRelativePositions(rpos, rpos2))
|
||||
const backDefault = Y.createDeltaPositionFromRelativePosition(ytext, rpos2)
|
||||
t.assert(backDefault !== null && dpos.equals(backDefault, pos))
|
||||
// explicit renderer: null overrides the node's renderer
|
||||
const backNull = Y.createDeltaPositionFromRelativePosition(ytext, rpos2, { renderer: null })
|
||||
t.assert(backNull !== null && dpos.equals(backNull, dpos.create([3])))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsUnresolvable = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'abc')
|
||||
const positions = [
|
||||
dpos.create([0, 0]), // descend into a scalar
|
||||
dpos.create(['missing', 0]), // missing attribute
|
||||
dpos.create([]), // a node reference doesn't round-trip
|
||||
dpos.create([10, 0]), // out-of-range non-terminal index
|
||||
dpos.create([1]) // resolvable - other entries must not affect it
|
||||
]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
|
||||
t.assert(rposs[0] === null && rposs[1] === null && rposs[2] === null && rposs[3] === null)
|
||||
t.assert(rposs[4] !== null)
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
|
||||
t.assert(back[0] === null && back[1] === null && back[2] === null && back[3] === null)
|
||||
t.assert(back[4] !== null && dpos.equals(back[4], positions[4]))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsEndOfNode = _tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello')
|
||||
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
|
||||
t.assert(rpos !== null && rpos.item === null)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([5])))
|
||||
// an end-of-node position sticks to the end
|
||||
ytext.insert(5, ' world')
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([11])))
|
||||
// empty node
|
||||
const yempty = ydoc.get('empty')
|
||||
const rposE = Y.createRelativePositionFromDeltaPosition(yempty, dpos.create([0]))
|
||||
t.assert(rposE !== null && rposE.item === null)
|
||||
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(yempty, rposE)), dpos.create([0])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions inside a nested node that is deleted but still rendered (e.g. a suggestion-mode
|
||||
* subtree delete). Requires gc: false - with gc enabled, the deleted subtree's content is
|
||||
* destroyed and such positions are unresolvable.
|
||||
*
|
||||
* @param {t.TestCase} _tc
|
||||
*/
|
||||
export const testDeltaPositionsInDeletedRenderedSubtree = _tc => {
|
||||
const ydoc = new Y.Doc({ gc: false })
|
||||
const yroot = ydoc.get('r')
|
||||
yroot.insert(0, [new Y.Node(), 'x'])
|
||||
const mid = /** @type {Y.Node<any>} */ (yroot.get(0))
|
||||
mid.insert(0, ['q', new Y.Node()])
|
||||
const leaf = /** @type {Y.Node<any>} */ (mid.get(1))
|
||||
leaf.insert(0, 'hello')
|
||||
const v1 = Y.cloneDoc(ydoc)
|
||||
yroot.delete(0, 1) // delete the whole `mid` subtree - the diff renderer still renders it
|
||||
const renderer = Y.createDiffRenderer(v1, ydoc)
|
||||
// the underlying primitive resolves indexes inside the deleted-but-rendered type
|
||||
const rposLeaf = Y.createRelativePositionFromTypeIndex(leaf, 3, 0, renderer)
|
||||
t.assert(Y.createAbsolutePositionFromRelativePosition(rposLeaf, ydoc, true, renderer)?.index === 3)
|
||||
// .. and delta positions round-trip through the deleted subtree
|
||||
const positions = [dpos.create([0, 1, 3]), dpos.create([0, 0]), dpos.create([1])]
|
||||
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions, { renderer })
|
||||
t.assert(rposs.every(rpos => rpos !== null))
|
||||
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs, { renderer })
|
||||
positions.forEach((pos, i) => {
|
||||
const b = back[i]
|
||||
t.assert(b !== null && dpos.equals(b, pos))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user