| |
| |
| |
| |
| |
| |
| |
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
| import { |
| createRefocusSync, |
| type RefocusProviderLike, |
| type RefocusEditorLike, |
| } from "../src/editor/refocus-sync"; |
|
|
| interface Fakes { |
| provider: RefocusProviderLike & { emit: (event: string) => void }; |
| editor: RefocusEditorLike & { setEditable: ReturnType<typeof vi.fn> }; |
| socket: { status: string; webSocket: { readyState: number }; lastMessageReceived: number }; |
| } |
|
|
| function makeFakes(opts: { |
| synced?: boolean; |
| status?: string; |
| readyState?: number; |
| /** Simulate a healthy socket: forceSync draws a server reply that advances lastMessageReceived. */ |
| replyOnForceSync?: boolean; |
| } = {}): Fakes { |
| const listeners: Record<string, Array<(...a: any[]) => void>> = {}; |
| const socket = { |
| status: opts.status ?? "connected", |
| webSocket: { readyState: opts.readyState ?? 1 }, |
| lastMessageReceived: 0, |
| }; |
|
|
| const provider: Fakes["provider"] = { |
| synced: opts.synced ?? true, |
| configuration: { websocketProvider: socket }, |
| connect: vi.fn(() => Promise.resolve()), |
| forceSync: vi.fn(() => { |
| if (opts.replyOnForceSync) socket.lastMessageReceived += 1; |
| }), |
| on: (event, cb) => { |
| (listeners[event] ??= []).push(cb); |
| }, |
| off: (event, cb) => { |
| listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb); |
| }, |
| emit: (event) => { |
| (listeners[event] ?? []).forEach((f) => f()); |
| }, |
| }; |
|
|
| const editor: Fakes["editor"] = { |
| isEditable: true, |
| isDestroyed: false, |
| setEditable: vi.fn(function (this: unknown, editable: boolean) { |
| editor.isEditable = editable; |
| }), |
| }; |
|
|
| return { provider, editor, socket }; |
| } |
|
|
| function lastEditableArg(setEditable: ReturnType<typeof vi.fn>): boolean | undefined { |
| const call = setEditable.mock.calls.at(-1); |
| return call ? (call[0] as boolean) : undefined; |
| } |
|
|
| describe("createRefocusSync", () => { |
| beforeEach(() => vi.useFakeTimers()); |
| afterEach(() => vi.useRealTimers()); |
|
|
| it("healthy + synced socket: forces a sync but never locks", () => { |
| const { provider, editor } = makeFakes({ synced: true, replyOnForceSync: true }); |
| const { onVisibility } = createRefocusSync({ provider, editor }); |
|
|
| onVisibility(); |
|
|
| expect(provider.connect).toHaveBeenCalled(); |
| expect(provider.forceSync).toHaveBeenCalled(); |
| expect(editor.setEditable).not.toHaveBeenCalled(); |
|
|
| |
| vi.advanceTimersByTime(2000); |
| expect(editor.setEditable).not.toHaveBeenCalledWith(false); |
| }); |
|
|
| it("disconnected socket: locks immediately, unlocks on synced", () => { |
| const { provider, editor } = makeFakes({ synced: false, status: "disconnected" }); |
| const { onVisibility } = createRefocusSync({ provider, editor }); |
|
|
| onVisibility(); |
| expect(editor.setEditable).toHaveBeenCalledWith(false); |
| expect(editor.isEditable).toBe(false); |
|
|
| provider.emit("synced"); |
| expect(lastEditableArg(editor.setEditable)).toBe(true); |
| expect(editor.isEditable).toBe(true); |
| }); |
|
|
| it("closed readyState while status says connected: locks", () => { |
| const { provider, editor } = makeFakes({ synced: true, status: "connected", readyState: 3 }); |
| const { onVisibility } = createRefocusSync({ provider, editor }); |
|
|
| onVisibility(); |
| expect(editor.setEditable).toHaveBeenCalledWith(false); |
| }); |
|
|
| it("offline: never traps the user, releases after the grace window", () => { |
| const { provider, editor } = makeFakes({ synced: false }); |
| createRefocusSync({ provider, editor }).onVisibility(); |
|
|
| expect(editor.isEditable).toBe(false); |
| vi.advanceTimersByTime(4000); |
| expect(lastEditableArg(editor.setEditable)).toBe(true); |
| expect(editor.isEditable).toBe(true); |
| }); |
|
|
| it("half-open socket: looks alive but no reply -> locks after probe", () => { |
| const { provider, editor } = makeFakes({ |
| synced: true, |
| status: "connected", |
| readyState: 1, |
| replyOnForceSync: false, |
| }); |
| const { onVisibility } = createRefocusSync({ provider, editor }); |
|
|
| onVisibility(); |
| |
| expect(editor.setEditable).not.toHaveBeenCalledWith(false); |
|
|
| |
| vi.advanceTimersByTime(2000); |
| expect(editor.setEditable).toHaveBeenCalledWith(false); |
| expect(provider.connect).toHaveBeenCalledTimes(2); |
| }); |
|
|
| it("dispose detaches the synced listener and releases the lock", () => { |
| const { provider, editor } = makeFakes({ synced: false }); |
| const { onVisibility, dispose } = createRefocusSync({ provider, editor }); |
|
|
| onVisibility(); |
| expect(editor.isEditable).toBe(false); |
|
|
| dispose(); |
| expect(editor.isEditable).toBe(true); |
|
|
| |
| editor.setEditable.mockClear(); |
| provider.emit("synced"); |
| expect(editor.setEditable).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|