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