textarea + WebSocket 实时同步、心跳、断线重连的便携式 Vue 组件。 复用 zTools2 的 /api/wb/* 与 /api/ws/wb/* 接口,与 zPDF_package 集成范式一致。 可独立运行或经 mainPage 构建期组件 import 集成(:locale + :boardId props)。
300 lines
9.9 KiB
JavaScript
300 lines
9.9 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||
import { computeDiff, createWhiteboardClient } from '../src/composables/useWhiteboard.js'
|
||
|
||
// ---------- computeDiff 纯函数 ----------
|
||
describe('computeDiff', () => {
|
||
it('相同文本返回全长前后缀', () => {
|
||
expect(computeDiff('abc', 'abc')).toEqual({ prefix: 3, suffixOld: 3, suffixNew: 3 })
|
||
})
|
||
|
||
it('前插:新文本在头部插入', () => {
|
||
// old="bc", new="abc" -> prefix=0, suffixOld=0(newtext 无公共前缀... 实际 old[0]='b'!=new[0]='a')
|
||
// 公共后缀: old="bc" new="abc" 从尾比: 'c'='c', 'b'='b' -> suffixOld=0, suffixNew=1
|
||
const d = computeDiff('bc', 'abc')
|
||
expect(d.prefix).toBe(0)
|
||
// 后缀比较到 prefix=0 停止:suffixOld 从 2 递减,suffixNew 从 3 递减
|
||
// old[1]='c'==new[2]='c' -> suffixOld=1,suffixNew=2; old[0]='b'==new[1]='b' -> suffixOld=0,suffixNew=1; 停
|
||
expect(d.suffixOld).toBe(0)
|
||
expect(d.suffixNew).toBe(1)
|
||
})
|
||
|
||
it('后插:新文本在尾部追加', () => {
|
||
// old="ab", new="abc" -> prefix=2, suffixOld=2, suffixNew=3(后缀循环不进入因 suffixOld>prefix 即 2>2 false)
|
||
const d = computeDiff('ab', 'abc')
|
||
expect(d.prefix).toBe(2)
|
||
expect(d.suffixOld).toBe(2)
|
||
expect(d.suffixNew).toBe(3)
|
||
})
|
||
|
||
it('中间改:替换中间字符', () => {
|
||
// old="abc", new="aXc" -> prefix=1('a'), 后缀 old[2]='c'==new[2]='c' -> suffixOld=2,suffixNew=2
|
||
const d = computeDiff('abc', 'aXc')
|
||
expect(d.prefix).toBe(1)
|
||
expect(d.suffixOld).toBe(2)
|
||
expect(d.suffixNew).toBe(2)
|
||
})
|
||
|
||
it('全替换:无公共前后缀', () => {
|
||
const d = computeDiff('abc', 'xyz')
|
||
expect(d.prefix).toBe(0)
|
||
expect(d.suffixOld).toBe(3)
|
||
expect(d.suffixNew).toBe(3)
|
||
})
|
||
|
||
it('空旧文本', () => {
|
||
const d = computeDiff('', 'abc')
|
||
expect(d.prefix).toBe(0)
|
||
expect(d.suffixOld).toBe(0)
|
||
expect(d.suffixNew).toBe(3)
|
||
})
|
||
|
||
it('空新文本(清空)', () => {
|
||
const d = computeDiff('abc', '')
|
||
expect(d.prefix).toBe(0)
|
||
expect(d.suffixOld).toBe(3)
|
||
expect(d.suffixNew).toBe(0)
|
||
})
|
||
})
|
||
|
||
// ---------- createWhiteboardClient ----------
|
||
// 用 mock WebSocket + mock fetch + mock sessionStorage/location/document 测同步状态机
|
||
|
||
function makeMockWs() {
|
||
const instances = []
|
||
class MockWebSocket {
|
||
constructor(url) {
|
||
this.url = url
|
||
this.readyState = 0 // CONNECTING
|
||
this.OPEN = 1
|
||
this.onopen = null
|
||
this.onmessage = null
|
||
this.onclose = null
|
||
this.onerror = null
|
||
this.sent = []
|
||
instances.push(this)
|
||
}
|
||
_open() {
|
||
this.readyState = 1
|
||
if (this.onopen) this.onopen()
|
||
}
|
||
_receive(data) {
|
||
if (this.onmessage) this.onmessage({ data })
|
||
}
|
||
_close() {
|
||
this.readyState = 3
|
||
if (this.onclose) this.onclose()
|
||
}
|
||
send(data) {
|
||
this.sent.push(data)
|
||
}
|
||
close() {}
|
||
}
|
||
MockWebSocket.OPEN = 1
|
||
return { MockWebSocket, instances }
|
||
}
|
||
|
||
function makeMockStorage() {
|
||
const store = {}
|
||
return {
|
||
getItem: (k) => (k in store ? store[k] : null),
|
||
setItem: (k, v) => {
|
||
store[k] = String(v)
|
||
},
|
||
removeItem: (k) => {
|
||
delete store[k]
|
||
},
|
||
_store: store,
|
||
}
|
||
}
|
||
|
||
function makeClient(overrides = {}) {
|
||
const { MockWebSocket, instances } = makeMockWs()
|
||
const ss = makeMockStorage()
|
||
const fetchMock = vi.fn(async () => ({
|
||
ok: true,
|
||
json: async () => ({ content: '' }),
|
||
}))
|
||
const loc = { protocol: 'http:', host: 'localhost' }
|
||
const doc = {
|
||
visibilityState: 'visible',
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
activeElement: null,
|
||
}
|
||
const client = createWhiteboardClient({
|
||
fetch: fetchMock,
|
||
WebSocket: MockWebSocket,
|
||
sessionStorage: ss,
|
||
location: loc,
|
||
document: doc,
|
||
...overrides,
|
||
})
|
||
return { client, instances, ss, fetchMock, loc, doc, MockWebSocket }
|
||
}
|
||
|
||
describe('createWhiteboardClient', () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers()
|
||
})
|
||
afterEach(() => {
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
it('clientId 用 sessionStorage 持久化(c_ 前缀)', () => {
|
||
const { ss } = makeClient()
|
||
const cid = ss._store['wb_cid']
|
||
expect(cid).toMatch(/^c_/)
|
||
})
|
||
|
||
it('connectBoard 先 GET /api/wb/{id} 预取,再连 WS 并发 hello', async () => {
|
||
const { client, instances, fetchMock } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
// 预取
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'/api/wb/share',
|
||
expect.objectContaining({ headers: { accept: 'application/json' } })
|
||
)
|
||
// WS 已创建
|
||
expect(instances.length).toBe(1)
|
||
const ws = instances[0]
|
||
expect(ws.url).toBe('ws://localhost/api/ws/wb/share')
|
||
// 模拟 onopen -> 应发 hello
|
||
ws._open()
|
||
expect(ws.sent).toContainEqual(JSON.stringify({ type: 'hello', client_id: client.clientId }))
|
||
})
|
||
|
||
it('收到 init 填充 content(无编辑器时)', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
ws._receive(JSON.stringify({ type: 'init', content: 'hello world', version: 1, edit_count: 1 }))
|
||
expect(client.content.value).toBe('hello world')
|
||
expect(client.connected.value).toBe(true)
|
||
})
|
||
|
||
it('sendEdit 400ms debounce 后发 edit,且 lastSentText 去重', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
// 初始化 lastSentText 为空
|
||
ws._receive(JSON.stringify({ type: 'init', content: '', version: 0, edit_count: 0 }))
|
||
// 编辑
|
||
client.sendEdit('abc')
|
||
// 立即未发
|
||
expect(ws.sent.filter((s) => s.includes('"edit"'))).toHaveLength(0)
|
||
// 推进 400ms
|
||
vi.advanceTimersByTime(400)
|
||
const edits = ws.sent.filter((s) => s.includes('"edit"'))
|
||
expect(edits).toHaveLength(1)
|
||
expect(JSON.parse(edits[0])).toEqual({ type: 'edit', content: 'abc' })
|
||
expect(client._getLastSentText()).toBe('abc')
|
||
|
||
// 再次发相同内容 -> 去重
|
||
client.sendEdit('abc')
|
||
vi.advanceTimersByTime(400)
|
||
const edits2 = ws.sent.filter((s) => s.includes('"edit"'))
|
||
expect(edits2).toHaveLength(1) // 仍只有 1 条
|
||
})
|
||
|
||
it('心跳每 3s 发 ping', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
const pingsBefore = ws.sent.filter((s) => s.includes('"ping"')).length
|
||
vi.advanceTimersByTime(3000)
|
||
expect(ws.sent.filter((s) => s.includes('"ping"')).length).toBe(pingsBefore + 1)
|
||
vi.advanceTimersByTime(3000)
|
||
expect(ws.sent.filter((s) => s.includes('"ping"')).length).toBe(pingsBefore + 2)
|
||
})
|
||
|
||
it('sendClear 发 clear 帧', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
client.sendClear()
|
||
expect(ws.sent).toContainEqual(JSON.stringify({ type: 'clear' }))
|
||
})
|
||
|
||
it('收到 update 应用 applyRemoteUpdate(无编辑器时更新 content)', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
ws._receive(JSON.stringify({ type: 'init', content: 'abc', version: 1, edit_count: 1 }))
|
||
ws._receive(JSON.stringify({ type: 'update', content: 'abcd', version: 2, client_id: 'other' }))
|
||
expect(client.content.value).toBe('abcd')
|
||
})
|
||
|
||
it('收到 cleared 清空 content', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
ws._receive(JSON.stringify({ type: 'init', content: 'abc', version: 1, edit_count: 1 }))
|
||
ws._receive(JSON.stringify({ type: 'cleared', client_id: 'other' }))
|
||
expect(client.content.value).toBe('')
|
||
})
|
||
|
||
it('断线 2s 后重连(创建新 WS)', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
expect(instances.length).toBe(1)
|
||
// 模拟断线
|
||
ws._close()
|
||
expect(client.connected.value).toBe(false)
|
||
// 2s 内未重连
|
||
vi.advanceTimersByTime(1999)
|
||
expect(instances.length).toBe(1)
|
||
// 满 2s 重连
|
||
vi.advanceTimersByTime(1)
|
||
expect(instances.length).toBe(2)
|
||
})
|
||
|
||
it('flushSend 立即补发未发送编辑', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
ws._receive(JSON.stringify({ type: 'init', content: '', version: 0, edit_count: 0 }))
|
||
client.sendEdit('pending')
|
||
// 未到 400ms,立即 flush
|
||
client.flushSend()
|
||
const edits = ws.sent.filter((s) => s.includes('"edit"'))
|
||
expect(edits).toHaveLength(1)
|
||
expect(JSON.parse(edits[0])).toEqual({ type: 'edit', content: 'pending' })
|
||
})
|
||
|
||
it('init 时本地有未发送编辑 -> 发本地编辑(last-writer-wins)', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
// 先 open(此时 lastSentText='')
|
||
ws._open()
|
||
// 模拟本地有未发送编辑:直接调 sendEdit 但不推进定时器,再手动设 lastSentText 为旧值模拟 pending
|
||
client.sendEdit('local-edit')
|
||
// 不推进 debounce,直接收到 init(带服务端旧内容)
|
||
ws._receive(JSON.stringify({ type: 'init', content: 'server-old', version: 5, edit_count: 5 }))
|
||
// 应发本地编辑覆盖
|
||
const edits = ws.sent.filter((s) => s.includes('"edit"'))
|
||
expect(edits.some((s) => s.includes('local-edit'))).toBe(true)
|
||
})
|
||
|
||
it('disconnect 停心跳/重连并关闭', async () => {
|
||
const { client, instances } = makeClient()
|
||
await client.connectBoard('share', null)
|
||
const ws = instances[0]
|
||
ws._open()
|
||
client.disconnect()
|
||
// 断线重连定时器不应再触发
|
||
vi.advanceTimersByTime(5000)
|
||
expect(instances.length).toBe(1) // 无新 WS
|
||
expect(client.connected.value).toBe(false)
|
||
})
|
||
})
|