feat: 共享记事本前端 zWhiteBoard
textarea + WebSocket 实时同步、心跳、断线重连的便携式 Vue 组件。 复用 zTools2 的 /api/wb/* 与 /api/ws/wb/* 接口,与 zPDF_package 集成范式一致。 可独立运行或经 mainPage 构建期组件 import 集成(:locale + :boardId props)。
This commit is contained in:
299
test/useWhiteboard.test.js
Normal file
299
test/useWhiteboard.test.js
Normal file
@@ -0,0 +1,299 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user