virt2/api/soft/CodeMirror/collab/test/test-collab.ts

297 lines
8.0 KiB
TypeScript
Executable File

import {EditorState, Transaction, StateField, StateEffect, Extension, Mapping} from "@codemirror/next/state"
import {history, undo, redo, isolateHistory} from "@codemirror/next/history"
import ist from "ist"
import {collab, CollabConfig, receiveUpdates, sendableUpdates, Update, getClientID, getSyncedVersion} from "@codemirror/next/collab"
class DummyServer {
states: EditorState[] = []
updates: Update[] = []
clientIDs: string[] = []
delayed: number[] = []
constructor(doc: string = "", config: {n?: number, extensions?: Extension[], collabConf?: CollabConfig} = {}) {
let {n = 2, extensions = [], collabConf = {}} = config
for (let i = 0; i < n; i++)
this.states.push(EditorState.create({doc, extensions: [history(), collab(collabConf), ...extensions]}))
}
sync(n: number) {
let state = this.states[n], version = getSyncedVersion(state)
if (version != this.updates.length) {
let count = 0
for (let i = version; i < this.clientIDs.length; i++) {
if (this.clientIDs[i] == getClientID(this.states[n])) count++
else break
}
this.states[n] = receiveUpdates(state, this.updates.slice(version), count).apply()
}
}
send(n: number) {
let state = this.states[n], sendable = sendableUpdates(state)
if (sendable.length) {
this.updates = this.updates.concat(sendable)
for (let i = 0; i < sendable.length; i++) this.clientIDs.push(getClientID(state))
}
}
broadcast(n: number) {
if (this.delayed.indexOf(n) > -1) return
this.sync(n)
this.send(n)
for (let i = 0; i < this.states.length; i++) if (i != n) this.sync(i)
}
update(n: number, f: (state: EditorState) => Transaction) {
this.states[n] = f(this.states[n]).apply()
this.broadcast(n)
}
type(n: number, text: string, pos: number = this.states[n].selection.primary.head) {
this.update(n, s => s.t().setSelection(pos).replaceSelection(text))
}
undo(n: number) {
undo({state: this.states[n], dispatch: tr => this.update(n, () => tr)})
}
redo(n: number) {
redo({state: this.states[n], dispatch: tr => this.update(n, () => tr)})
}
conv(doc: string) {
this.states.forEach(state => ist(state.doc.toString(), doc))
}
delay(n: number, f: () => void) {
this.delayed.push(n)
f()
this.delayed.pop()
this.broadcast(n)
}
}
describe("collab", () => {
it("converges for simple changes", () => {
let s = new DummyServer
s.type(0, "hi")
s.type(1, "ok", 2)
s.type(0, "!", 4)
s.type(1, "...", 0)
s.conv("...hiok!")
})
it("converges for multiple local changes", () => {
let s = new DummyServer
s.type(0, "hi")
s.delay(0, () => {
s.type(0, "A")
s.type(1, "X", 2)
s.type(0, "B")
s.type(1, "Y")
})
s.conv("hiXYAB")
})
it("converges with three peers", () => {
let s = new DummyServer(undefined, {n: 3})
s.type(0, "A")
s.type(1, "U")
s.type(2, "X")
s.type(0, "B")
s.type(1, "V")
s.type(2, "Y")
s.conv("XYUVAB")
})
it("converges with three peers with multiple steps", () => {
let s = new DummyServer(undefined, {n: 3})
s.type(0, "A")
s.delay(1, () => {
s.type(1, "U")
s.type(2, "X")
s.type(0, "B")
s.type(1, "V")
s.type(2, "Y")
})
s.conv("XYUVAB")
})
it("supports undo", () => {
let s = new DummyServer
s.type(0, "A")
s.type(1, "a")
s.type(0, "B")
s.undo(1)
s.conv("AB")
s.type(1, "b")
s.type(0, "C")
s.conv("bABC")
})
it("supports redo", () => {
let s = new DummyServer
s.type(0, "A")
s.type(1, "a")
s.type(0, "B")
s.undo(1)
s.redo(1)
s.type(1, "b")
s.type(0, "C")
s.conv("abABC")
})
it("supports deep undo", () => {
let s = new DummyServer("hello bye")
s.update(0, s => s.t().setSelection(5))
s.update(1, s => s.t().setSelection(9))
s.type(0, "!")
s.type(1, "!")
s.update(0, s => s.t().annotate(isolateHistory, "full"))
s.delay(0, () => {
s.type(0, " ...")
s.type(1, " ,,,")
})
s.update(0, s => s.t().annotate(isolateHistory, "full"))
s.type(0, "*")
s.type(1, "*")
s.undo(0)
s.conv("hello! ... bye! ,,,*")
s.undo(0)
s.undo(0)
s.conv("hello bye! ,,,*")
s.redo(0)
s.redo(0)
s.redo(0)
s.conv("hello! ...* bye! ,,,*")
s.undo(0)
s.undo(0)
s.conv("hello! bye! ,,,*")
s.undo(1)
s.conv("hello! bye")
})
it("support undo with clashing events", () => {
let s = new DummyServer("okay!")
s.type(0, "A", 5)
s.delay(0, () => {
s.type(0, "B", 3)
s.type(0, "C", 4)
s.type(0, "D", 0)
s.update(1, s => s.t().replace(1, 4, ""))
})
s.conv("Do!A")
s.undo(0)
s.undo(0)
s.conv("o!")
ist(s.states[0].selection.primary.head, 0)
})
it("handles conflicting steps", () => {
let s = new DummyServer("abcde")
s.delay(0, () => {
s.update(0, s => s.t().replace(2, 3, ""))
s.type(0, "x")
s.update(1, s => s.t().replace(1, 4, ""))
})
s.undo(0)
s.undo(0)
s.conv("ae")
})
it("can undo simultaneous typing", () => {
let s = new DummyServer("A B")
s.delay(0, () => {
s.type(0, "1", 1)
s.type(0, "2")
s.type(1, "x", 3)
s.type(1, "y")
})
s.conv("A12 Bxy")
s.undo(0)
s.conv("A Bxy")
s.undo(1)
s.conv("A B")
})
it("includes rebased changes when necessary", () => {
let counter = StateField.define<number>({
create() { return 0 },
update(val, tr) { return val + tr.changes.length }
})
let s = new DummyServer("___ ___", {extensions: [counter]})
s.delay(0, () => {
s.type(0, "a", 1)
s.type(1, "b", 5)
})
ist(s.states[0].field(counter), 2)
ist(s.states[1].field(counter), 2)
s.delay(0, () => {
s.type(0, "x", 3)
s.type(1, "y", 3)
})
ist(s.states[0].field(counter), 6)
ist(s.states[1].field(counter), 4)
s.conv("_a_yx_ _b__")
})
it("allows you to set your client id", () => {
ist(getClientID(EditorState.create({extensions: [collab({clientID: "my id"})]})), "my id")
})
it("client ids survive reconfiguration", () => {
let ext = collab()
let state = EditorState.create({extensions: [ext]})
let state2 = state.t().reconfigure([ext]).apply()
ist(getClientID(state), getClientID(state2))
})
it("associates transaction info with local changes", () => {
let state = EditorState.create({extensions: [collab()]})
let tr = state.t().replace(0, 0, "hi")
ist(sendableUpdates(tr.apply())[0].origin, tr)
})
it("supports shared effects", () => {
class Mark {
constructor(readonly from: number,
readonly to: number,
readonly id: string) {}
map(mapping: Mapping) {
let from = mapping.mapPos(this.from, 1), to = mapping.mapPos(this.to, -1)
return from >= to ? undefined : new Mark(from, to, this.id)
}
toString() { return `${this.from}-${this.to}=${this.id}` }
}
let addMark = StateEffect.define<Mark>({map: (v, m) => v.map(m)})
let marks = StateField.define<Mark[]>({
create: () => [],
update(value, tr) {
value = value.map(m => m.map(tr.changes)).filter(x => x) as any
for (let effect of tr.effects) if (effect.is(addMark)) value = value.concat(effect.value)
return value.sort((a, b) => a.id < b.id ? -1 : 1)
}
})
let s = new DummyServer("hello", {
extensions: [marks],
collabConf: {sharedEffects(tr) { return tr.effects.filter(e => e.is(addMark)) }}
})
s.delay(0, () => {
s.delay(1, () => {
s.update(0, s => s.t().effect(addMark.of(new Mark(1, 3, "a"))))
s.update(1, s => s.t().effect(addMark.of(new Mark(3, 5, "b"))))
s.type(0, "A", 4)
s.type(1, "B", 0)
ist(s.states[0].field(marks).join(), "1-3=a")
ist(s.states[1].field(marks).join(), "4-6=b")
})
})
s.conv("BhellAo")
ist(s.states[0].field(marks).join(), "2-4=a,4-7=b")
ist(s.states[1].field(marks).join(), "2-4=a,4-7=b")
})
})