Files
upstream-yjs/tests/relativePositions.tests.js
upstream-mirror 1ff79b5b83
Some checks failed
Node.js CI / build (26.x) (push) Successful in 2m53s
Scorecard supply-chain security / Scorecard analysis (push) Failing after 1m5s
snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
2026-09-15 17:21:43 +05:00

373 lines
14 KiB
JavaScript

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))
})
}