mirror of
https://github.com/yjs/yjs.git
synced 2026-08-29 10:09:05 +02:00
474 lines
17 KiB
JavaScript
474 lines
17 KiB
JavaScript
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.Type<{ attrs: { num: number, str: string, [k:string]: number|string } }>}
|
|
*/
|
|
const yxml = ymap.setAttr('yxml', new Y.Type('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.Type()
|
|
const second = new Y.Type('p')
|
|
yxml.insert(0, [first, second])
|
|
t.assert(first.parent === /** @type {Y.Type<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.Type('text')
|
|
const second = new Y.Type('p')
|
|
const third = new Y.Type('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.Type('text1')
|
|
const text2 = new Y.Type('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.Type()
|
|
const elem1 = Y.Type.from(delta.create().insert('hello'))
|
|
const elem2 = new Y.Type()
|
|
const elem3 = Y.Type.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.Type.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.Type('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: `ytype.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, `typeMapGetDelta` (ytype.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.Type('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 `ytype.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'
|
|
)
|
|
}
|