mirror of
https://github.com/yjs/yjs.git
synced 2026-08-29 10:09:05 +02:00
YType is an RDT
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import * as t from 'lib0/testing'
|
||||
import * as delta from 'lib0/delta'
|
||||
import * as prng from 'lib0/prng'
|
||||
import * as math from 'lib0/math'
|
||||
import { bind, $rdt } from 'lib0/delta/rdt'
|
||||
import { init } from './testHelper.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
@@ -207,10 +210,346 @@ export const testAttributionChange = () => {
|
||||
renderer.on('change', changes => {
|
||||
calledHandler = true
|
||||
const changeUpdate = ytype.toDelta({ renderer, deep: true, itemsToRender: changes, retainInserts: true, retainDeletes: true })
|
||||
const expectedUpdate = delta.create().retain(2).retain(1, null, {})
|
||||
// the '!' lost its `{ insert: [] }` suggestion attribution → the change clears it (tri-state `null`)
|
||||
const expectedUpdate = delta.create().retain(2).retain(1, undefined, null)
|
||||
t.compare(changeUpdate, expectedUpdate)
|
||||
console.log(changeUpdate.toJSON())
|
||||
})
|
||||
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
|
||||
t.assert(calledHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* A YType implements the lib0 `RDT` interface, so two types can be kept in sync with `bind`.
|
||||
*/
|
||||
export const testRdtBinding = () => {
|
||||
const docA = new Y.Doc()
|
||||
const docB = new Y.Doc()
|
||||
const a = docA.get('text')
|
||||
const b = docB.get('text')
|
||||
const binding = bind(a, b)
|
||||
// edit A -> propagates to B
|
||||
a.insert(0, 'hello')
|
||||
t.assert(b.toString() === 'hello')
|
||||
// edit B -> propagates back to A (no echo loop)
|
||||
b.insert(5, ' world')
|
||||
t.assert(a.toString() === 'hello world')
|
||||
t.assert(b.toString() === 'hello world')
|
||||
// after the binding is destroyed, changes no longer propagate
|
||||
binding.destroy()
|
||||
a.insert(0, 'x')
|
||||
t.assert(a.toString() === 'xhello world')
|
||||
t.assert(b.toString() === 'hello world')
|
||||
}
|
||||
|
||||
/**
|
||||
* Local changes are emitted on the `'delta'` channel as the deep delta.
|
||||
*/
|
||||
export const testRdtDeltaEvent = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let captured = null
|
||||
ytext.on('delta', d => { captured = d })
|
||||
ytext.insert(0, 'hello')
|
||||
t.compare(captured, delta.create().insert('hello').done())
|
||||
}
|
||||
|
||||
/**
|
||||
* `useRenderer` changes the default renderer used by `toDelta` (and friends). Calling `toDelta()`
|
||||
* with no argument afterwards is equivalent to passing the renderer explicitly.
|
||||
*/
|
||||
export const testUseRenderer = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get()
|
||||
ytext.insert(0, 'hello world')
|
||||
const v1 = Y.cloneDoc(ydoc)
|
||||
ydoc.transact(() => {
|
||||
ytext.delete(6, 5)
|
||||
})
|
||||
const renderer = Y.createDiffRenderer(v1, ydoc)
|
||||
const explicit = ytext.toDelta({ renderer })
|
||||
// change the default renderer; toDelta() with no arg now matches the explicit form
|
||||
ytext.useRenderer(renderer)
|
||||
const viaDefault = ytext.toDelta()
|
||||
t.compare(viaDefault, explicit)
|
||||
t.compare(viaDefault, delta.create().insert('hello ').insert('world', null, { delete: [] }).done())
|
||||
}
|
||||
|
||||
/**
|
||||
* `destroy()` emits the RDT `'destroy'` event, and top-level types are destroyed with their Doc.
|
||||
*/
|
||||
export const testRdtDestroy = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
let destroyed = 0
|
||||
ytext.on('destroy', () => { destroyed++ })
|
||||
ytext.destroy()
|
||||
t.assert(destroyed === 1)
|
||||
// a top-level type is torn down when its Doc is destroyed
|
||||
const ydoc2 = new Y.Doc()
|
||||
const ytext2 = ydoc2.get('text')
|
||||
let destroyed2 = 0
|
||||
ytext2.on('destroy', () => { destroyed2++ })
|
||||
ydoc2.destroy()
|
||||
t.assert(destroyed2 === 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `'delta'` event bubbles to ancestors on nested changes, like `observeDeep`. A listener on a
|
||||
* container fires (with the container-rooted delta) when a nested child is edited.
|
||||
*/
|
||||
export const testRdtDeltaBubblesLikeObserveDeep = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const yarray = ydoc.get('arr')
|
||||
const child = new Y.Type()
|
||||
yarray.insert(0, [child])
|
||||
let containerFired = 0
|
||||
let childFired = 0
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let captured = null
|
||||
yarray.on('delta', d => { containerFired++; captured = d })
|
||||
child.on('delta', () => { childFired++ })
|
||||
child.insert(0, 'hi')
|
||||
// both the edited child and its ancestor container received a 'delta'
|
||||
t.assert(childFired === 1)
|
||||
t.assert(containerFired === 1)
|
||||
// the container-rooted delta is a non-empty (nested modify) change
|
||||
t.assert(captured !== null && !captured.isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* `get delta()` returns the deep delta and keeps it current on every event of this type, including
|
||||
* nested-child edits (which apply as a nested `modify`). The returned value is the live cache.
|
||||
*/
|
||||
export const testRdtDeltaCacheMaintenance = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
ytext.insert(0, 'hello')
|
||||
// first access materializes the cache
|
||||
t.assert(ytext.delta.equals(delta.create().insert('hello').done()))
|
||||
// a later edit updates the live cache in place
|
||||
const live = ytext.delta
|
||||
ytext.insert(5, ' world')
|
||||
t.assert(live === ytext.delta) // same maintained object
|
||||
t.assert(ytext.delta.equals(delta.create().insert('hello world').done()))
|
||||
t.assert(ytext.delta.equals(ytext.toDeltaDeep())) // matches a fresh deep render
|
||||
|
||||
// nested: editing a child updates the container's cached deep delta via a nested modify apply
|
||||
const yarray = ydoc.get('arr')
|
||||
const child = new Y.Type()
|
||||
yarray.insert(0, [child])
|
||||
child.insert(0, 'a')
|
||||
const before = yarray.delta // materialize under base renderer
|
||||
child.insert(1, 'b') // nested edit after materialization
|
||||
t.assert(before === yarray.delta)
|
||||
t.assert(yarray.delta.equals(yarray.toDeltaDeep()))
|
||||
}
|
||||
|
||||
/**
|
||||
* `clearCache()` drops the maintained deep delta; the next `delta` access re-materializes it.
|
||||
*/
|
||||
export const testRdtClearCache = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
ytext.insert(0, 'hello')
|
||||
const d1 = ytext.delta
|
||||
t.assert(ytext._delta !== null)
|
||||
ytext.clearCache()
|
||||
t.assert(ytext._delta === null)
|
||||
const d2 = ytext.delta // re-materialized, a fresh builder
|
||||
t.assert(d2 !== d1)
|
||||
t.assert(d2.equals(delta.create().insert('hello').done()))
|
||||
}
|
||||
|
||||
/**
|
||||
* `useRenderer` re-renders the maintained delta with the new renderer, emits the difference on the
|
||||
* `'delta'` channel, and updates the cache.
|
||||
*/
|
||||
export const testRdtUseRendererEmitsDiff = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const ytext = ydoc.get('text')
|
||||
ytext.insert(0, 'hello world')
|
||||
const v1 = Y.cloneDoc(ydoc)
|
||||
ydoc.transact(() => { ytext.delete(6, 5) })
|
||||
// materialize the cache under the base renderer
|
||||
t.assert(ytext.delta.equals(delta.create().insert('hello ').done()))
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let captured = null
|
||||
ytext.on('delta', d => { captured = d })
|
||||
ytext.useRenderer(Y.createDiffRenderer(v1, ydoc))
|
||||
// a non-empty rendering diff was emitted only on the 'delta' channel
|
||||
t.assert(captured !== null && !captured.isEmpty())
|
||||
// and the cache now reflects the diff-rendered state
|
||||
t.assert(ytext.delta.equals(delta.create().insert('hello ').insert('world', null, { delete: [] }).done()))
|
||||
}
|
||||
|
||||
/**
|
||||
* `YType` conforms to the lib0 `RDT` interface — verified at runtime with `$rdt.check` (replaces the
|
||||
* old compile-time `_assertYTypeIsRdt`).
|
||||
*/
|
||||
export const testRdtConformsToRdtSchema = () => {
|
||||
t.assert($rdt.check(new Y.Doc().get()))
|
||||
t.assert($rdt.check(new Y.Type()))
|
||||
t.assert(!$rdt.check({}))
|
||||
t.assert(!$rdt.check(null))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a type and all of its (non-deleted) nested `YType` descendants.
|
||||
*
|
||||
* @param {Y.Type<any>} root
|
||||
* @return {Array<Y.Type<any>>}
|
||||
*/
|
||||
const collectTypes = root => {
|
||||
/**
|
||||
* @type {Array<Y.Type<any>>}
|
||||
*/
|
||||
const out = [root]
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
out[i].forEach(c => { if (c instanceof Y.Type) out.push(c) })
|
||||
out[i].forEachAttr(v => { if (v instanceof Y.Type) out.push(v) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one random mutation to a random type in the tree rooted at `root`.
|
||||
*
|
||||
* @param {prng.PRNG} gen
|
||||
* @param {Y.Type<any>} root
|
||||
* @param {boolean} [includeFormat] - include `format` ops. Excluded under a diffing renderer because
|
||||
* *removing* a format from attributed content still desyncs the maintained diff-attributed `delta`.
|
||||
* lib0's `apply` is correct (a valid change exists: `apply(prev, delta.diff(prev, next)) === next`);
|
||||
* the bug is in YJS's `toDelta` change-computation (the ContentFormat formatting-attribution block,
|
||||
* src/ytype.js ~L1076-1166): un-formatting emits an invalid deep change — wrong range, spurious
|
||||
* `{attribution:{format:[…]}}` re-asserts, and no `attribution:{format:null}` clear — instead of the
|
||||
* correct `retain(n, { format: null, attribution: { format: null } })`. Format under the
|
||||
* (unattributed) base renderer is exercised separately (`testRdtDeltaFuzz`).
|
||||
*/
|
||||
const applyRandomYTypeOp = (gen, root, includeFormat = true) => {
|
||||
const target = prng.oneOf(gen, collectTypes(root))
|
||||
switch (prng.int32(gen, 0, 5)) {
|
||||
case 0: // insert text
|
||||
target.insert(prng.int32(gen, 0, target.length), prng.word(gen))
|
||||
break
|
||||
case 1: // insert a nested type
|
||||
target.insert(prng.int32(gen, 0, target.length), [new Y.Type()])
|
||||
break
|
||||
case 2: // delete a range
|
||||
if (target.length > 0) {
|
||||
const p = prng.int32(gen, 0, target.length - 1)
|
||||
target.delete(p, prng.int32(gen, 1, math.min(3, target.length - p)))
|
||||
}
|
||||
break
|
||||
case 3: // format a range (skipped when format is excluded — see @param includeFormat)
|
||||
if (includeFormat && target.length > 0) {
|
||||
const p = prng.int32(gen, 0, target.length - 1)
|
||||
target.format(p, prng.int32(gen, 1, math.min(3, target.length - p)), { bold: prng.bool(gen) ? true : null })
|
||||
}
|
||||
break
|
||||
case 4: // set / delete a map attribute
|
||||
if (prng.bool(gen)) {
|
||||
target.setAttr(prng.oneOf(gen, ['a', 'b', 'c']), prng.word(gen))
|
||||
} else {
|
||||
target.deleteAttr(prng.oneOf(gen, ['a', 'b', 'c']))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzz: after each random mutation, every type's maintained `delta` cache (at every nesting level)
|
||||
* must equal a fresh deep render `toDelta({ deep: true })`.
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRdtDeltaFuzz = tc => {
|
||||
const ydoc = new Y.Doc()
|
||||
const root = ydoc.get('root')
|
||||
for (let i = 0; i < 300; i++) {
|
||||
applyRandomYTypeOp(tc.prng, root)
|
||||
collectTypes(root).forEach(type =>
|
||||
t.assert(type.delta.equals(type.toDelta({ deep: true })), `iter ${i}`))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzz under a diffing renderer, across two synced replicas. Each replica is a "suggestion doc" that
|
||||
* diffs against its own fixed baseline clone (taken after some shared initial content). With the plain
|
||||
* diff renderer (no `attrs`), suggestion inserts render `{ insert: [] }` and deletes render
|
||||
* `{ delete: [] }` — identical on every replica — so the maintained, diff-attributed `delta` must
|
||||
* converge across replicas (and match a fresh deep render). The cache is kept current purely by the
|
||||
* `'delta'` event (no recompute).
|
||||
*
|
||||
* @param {t.TestCase} tc
|
||||
*/
|
||||
export const testRdtDeltaSuggestionConvergence = tc => {
|
||||
const { testConnector, users } = init(tc, { users: 2 })
|
||||
const [d0, d1] = users
|
||||
d0.get('root').insert(0, 'shared baseline content')
|
||||
testConnector.flushAllMessages()
|
||||
// each replica diffs against its own fixed baseline clone (plain diff renderer => {insert:[]}/{delete:[]})
|
||||
d0.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d0), d0))
|
||||
d1.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d1), d1))
|
||||
for (let i = 0; i < 300; i++) {
|
||||
applyRandomYTypeOp(tc.prng, prng.oneOf(tc.prng, users).get('root'), false) // exclude format (lib0 apply bug)
|
||||
testConnector.flushAllMessages()
|
||||
const a = d0.get('root').delta
|
||||
const b = d1.get('root').delta
|
||||
t.assert(a.equals(b), `converge iter ${i}`) // the suggestion view is replica-independent
|
||||
t.assert(a.equals(d0.get('root').toDelta({ deep: true })), `canonical iter ${i}`) // and matches a fresh render
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity: the maintained `delta` equals both an explicit expected delta and a fresh deep render —
|
||||
* for flat content, nested children, and ongoing edits.
|
||||
*/
|
||||
export const testRdtDeltaSanity = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const root = ydoc.get('root')
|
||||
root.insert(0, 'hello')
|
||||
root.setAttr('k', 'v')
|
||||
t.assert(root.delta.equals(delta.create().insert('hello').setAttr('k', 'v').done()))
|
||||
t.assert(root.delta.equals(root.toDelta({ deep: true })))
|
||||
// nested child + ongoing edits keep delta == fresh deep render
|
||||
const child = new Y.Type()
|
||||
root.insert(5, [child])
|
||||
child.insert(0, 'world')
|
||||
t.assert(root.delta.equals(root.toDelta({ deep: true })))
|
||||
child.insert(5, '!')
|
||||
root.delete(0, 1)
|
||||
t.assert(root.delta.equals(root.toDelta({ deep: true })))
|
||||
// the nested child's own cache is consistent too
|
||||
t.assert(child.delta.equals(child.toDelta({ deep: true })))
|
||||
t.assert(child.delta.equals(delta.create().insert('world!').done()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity: under a diffing-attribution renderer the maintained `delta` carries the expected
|
||||
* attribution markers and equals a fresh attributed deep render.
|
||||
*/
|
||||
export const testRdtDeltaAttributionSanity = () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const root = ydoc.get('root')
|
||||
const v1 = Y.cloneDoc(ydoc)
|
||||
const attrs = new Y.Attributions()
|
||||
ydoc.on('update', (update, _origin, doc, tr) => {
|
||||
if (!tr.local) return
|
||||
const uid = doc.clientID.toString()
|
||||
const cids = Y.createContentIdsFromUpdate(update)
|
||||
Y.insertIntoIdMap(attrs.inserts, Y.createIdMapFromIdSet(cids.inserts, [Y.createContentAttribute('insert', uid)]))
|
||||
Y.insertIntoIdMap(attrs.deletes, Y.createIdMapFromIdSet(cids.deletes, [Y.createContentAttribute('delete', uid)]))
|
||||
})
|
||||
root.insert(0, 'hello') // a suggestion relative to v1
|
||||
const uid = ydoc.clientID.toString()
|
||||
root.useRenderer(Y.createDiffRenderer(v1, ydoc, { attrs }))
|
||||
t.assert(root.delta.equals(delta.create().insert('hello', null, { insert: [uid] }).done()))
|
||||
t.assert(root.delta.equals(root.toDelta({ deep: true })))
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ export const testMapHavingIterableAsConstructorParamTests = tc => {
|
||||
map0.setAttr('m2', m2)
|
||||
t.assert(m2.getAttr('object')?.x === 1)
|
||||
t.assert(m2.getAttr('boolean') === true)
|
||||
const m3 = new Y.Type().applyDelta(m1.toDelta()).applyDelta(m2.toDelta())
|
||||
const m3 = new Y.Type()
|
||||
m3.applyDelta(m1.toDelta())
|
||||
m3.applyDelta(m2.toDelta())
|
||||
map0.setAttr('m3', m3)
|
||||
t.assert(m3.getAttr('number') === 1)
|
||||
t.assert(m3.getAttr('string') === 'hello')
|
||||
|
||||
Reference in New Issue
Block a user