Files
zWhiteBoard/test/useWhiteboard.test.js
zikai d7d7450296 feat: 共享记事本前端 zWhiteBoard
textarea + WebSocket 实时同步、心跳、断线重连的便携式 Vue 组件。
复用 zTools2 的 /api/wb/* 与 /api/ws/wb/* 接口,与 zPDF_package 集成范式一致。
可独立运行或经 mainPage 构建期组件 import 集成(:locale + :boardId props)。
2026-07-28 10:32:41 +08:00

300 lines
9.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
})
})