File size: 5,259 Bytes
45d6656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
/**
 * Refocus re-sync guard tests.
 *
 * Exercises createRefocusSync across the four liveness cases it must
 * distinguish on tab refocus, plus the offline grace release. Uses fake
 * timers and a fake provider/editor - no network, no browser.
 */
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();

    // Probe window passes; reply advanced lastMessageReceived -> still no lock.
    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();
    // Looks alive, so no immediate lock.
    expect(editor.setEditable).not.toHaveBeenCalledWith(false);

    // No message landed within the probe window -> declare it stale.
    vi.advanceTimersByTime(2000);
    expect(editor.setEditable).toHaveBeenCalledWith(false);
    expect(provider.connect).toHaveBeenCalledTimes(2); // initial + probe reconnect
  });

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

    // A late synced event must not touch a disposed editor.
    editor.setEditable.mockClear();
    provider.emit("synced");
    expect(editor.setEditable).not.toHaveBeenCalled();
  });
});