| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { |
| 3 | EnvironmentId, |
| 4 | type UiSettingsProfile, |
| 5 | type UiSettingsValues, |
| 6 | type UiSettingsPatchResult, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { UiSettingsSync, type UiSettingsSyncCache } from "./uiSettingsSync.ts"; |
| 9 | |
| 10 | const host = EnvironmentId.make("personal"); |
| 11 | const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} }; |
| 12 | const profile: UiSettingsProfile = { |
| 13 | revision: 2, |
| 14 | values: { fontSizeCode: 16, chatWidth: "wide" }, |
| 15 | fieldRevisions: { fontSizeCode: 1, chatWidth: 2 }, |
| 16 | }; |
| 17 | |
| 18 | function setup(saved = profile) { |
| 19 | let cache: UiSettingsSyncCache = { |
| 20 | environmentId: host, |
| 21 | profile: saved, |
| 22 | pending: { patch: {}, baseRevisions: {} }, |
| 23 | }; |
| 24 | const apply = vi.fn<(values: UiSettingsValues) => Promise<void>>().mockResolvedValue(undefined); |
| 25 | const patch = vi.fn<(input: UiSettingsSyncCache["pending"]) => Promise<UiSettingsPatchResult>>(); |
| 26 | const save = vi.fn(async (value: UiSettingsSyncCache) => { |
| 27 | cache = value; |
| 28 | }); |
| 29 | const controller = new UiSettingsSync(cache, { save, apply, patch }); |
| 30 | return { controller, patch, save, apply, cache: () => cache }; |
| 31 | } |
| 32 | |
| 33 | function deferred<A>() { |
| 34 | let resolve!: (value: A) => void; |
| 35 | let reject!: (reason: unknown) => void; |
| 36 | const promise = new Promise<A>((done, fail) => { |
| 37 | resolve = done; |
| 38 | reject = fail; |
| 39 | }); |
| 40 | return { promise, resolve, reject }; |
| 41 | } |
| 42 | |
| 43 | describe("UI preference sync", () => { |
| 44 | it("adopts the saved profile without publishing bootstrap defaults or echoing", async () => { |
| 45 | const { controller, apply, patch } = setup(); |
| 46 | await controller.receive(profile); |
| 47 | expect(apply).toHaveBeenLastCalledWith(profile.values); |
| 48 | controller.edit({ fontSizeCode: 16 }); |
| 49 | expect(patch).not.toHaveBeenCalled(); |
| 50 | expect(controller.snapshot.status).toBe("synced"); |
| 51 | }); |
| 52 | |
| 53 | it("restores cached preferences while offline without a bootstrap write", async () => { |
| 54 | const { controller, apply, patch } = setup(); |
| 55 | controller.edit({ fontSizeCode: 18 }); |
| 56 | await controller.restore(); |
| 57 | expect(apply).toHaveBeenLastCalledWith({ fontSizeCode: 18, chatWidth: "wide" }); |
| 58 | expect(patch).not.toHaveBeenCalled(); |
| 59 | expect(controller.snapshot.status).toBe("offline"); |
| 60 | }); |
| 61 | |
| 62 | it("keeps deliberate offline edits across a restart and reads before sending", async () => { |
| 63 | const first = setup(); |
| 64 | first.controller.edit({ fontSizeCode: 18 }); |
| 65 | await first.controller.stop(); |
| 66 | expect(first.patch).not.toHaveBeenCalled(); |
| 67 | const applied: UiSettingsValues[] = []; |
| 68 | const send = vi.fn( |
| 69 | async () => |
| 70 | ({ |
| 71 | profile: { |
| 72 | revision: 4, |
| 73 | values: { fontSizeCode: 18, chatWidth: "full" }, |
| 74 | fieldRevisions: { fontSizeCode: 4, chatWidth: 3 }, |
| 75 | }, |
| 76 | conflicts: [], |
| 77 | }) satisfies UiSettingsPatchResult, |
| 78 | ); |
| 79 | const restarted = new UiSettingsSync(first.cache(), { |
| 80 | save: async () => {}, |
| 81 | apply: async (value) => { |
| 82 | applied.push(value); |
| 83 | }, |
| 84 | patch: send, |
| 85 | }); |
| 86 | expect(send).not.toHaveBeenCalled(); |
| 87 | await restarted.receive({ |
| 88 | ...profile, |
| 89 | revision: 3, |
| 90 | values: { ...profile.values, chatWidth: "full" }, |
| 91 | fieldRevisions: { ...profile.fieldRevisions, chatWidth: 3 }, |
| 92 | }); |
| 93 | expect(send).toHaveBeenCalledWith({ |
| 94 | patch: { fontSizeCode: 18 }, |
| 95 | baseRevisions: { fontSizeCode: 1 }, |
| 96 | }); |
| 97 | expect(applied.at(-1)).toEqual({ fontSizeCode: 18, chatWidth: "full" }); |
| 98 | }); |
| 99 | |
| 100 | it("preserves an edit made while an earlier edit is in flight", async () => { |
| 101 | const { controller, patch, cache } = setup(empty); |
| 102 | const started = deferred<void>(); |
| 103 | const response = deferred<UiSettingsPatchResult>(); |
| 104 | patch.mockImplementationOnce(() => { |
| 105 | started.resolve(); |
| 106 | return response.promise; |
| 107 | }); |
| 108 | patch.mockResolvedValueOnce({ |
| 109 | profile: { revision: 2, values: { fontSizeCode: 18 }, fieldRevisions: { fontSizeCode: 2 } }, |
| 110 | conflicts: [], |
| 111 | }); |
| 112 | controller.edit({ fontSizeCode: 16 }); |
| 113 | const connected = controller.receive(empty); |
| 114 | await started.promise; |
| 115 | controller.edit({ fontSizeCode: 18 }); |
| 116 | response.resolve({ |
| 117 | profile: { revision: 1, values: { fontSizeCode: 16 }, fieldRevisions: { fontSizeCode: 1 } }, |
| 118 | conflicts: [], |
| 119 | }); |
| 120 | await connected; |
| 121 | expect(patch.mock.calls[1]?.[0]).toEqual({ |
| 122 | patch: { fontSizeCode: 18 }, |
| 123 | baseRevisions: { fontSizeCode: 1 }, |
| 124 | }); |
| 125 | expect(cache().pending.patch).toEqual({}); |
| 126 | expect(cache().profile.values.fontSizeCode).toBe(18); |
| 127 | }); |
| 128 | |
| 129 | it("adopts newer saved values on a same-field conflict while retaining independent edits", async () => { |
| 130 | const { controller, patch, apply } = setup(); |
| 131 | controller.edit({ fontSizeCode: 18, timestampFormat: "12-hour" }); |
| 132 | patch.mockResolvedValue({ |
| 133 | profile: { |
| 134 | revision: 4, |
| 135 | values: { fontSizeCode: 20, timestampFormat: "12-hour" }, |
| 136 | fieldRevisions: { fontSizeCode: 3, timestampFormat: 4 }, |
| 137 | }, |
| 138 | conflicts: ["fontSizeCode"], |
| 139 | }); |
| 140 | await controller.receive(profile); |
| 141 | expect(apply).toHaveBeenLastCalledWith({ fontSizeCode: 20, timestampFormat: "12-hour" }); |
| 142 | expect(controller.snapshot.conflicts).toEqual(["fontSizeCode"]); |
| 143 | }); |
| 144 | |
| 145 | it("does not revive a disconnected client while hydration is applying", async () => { |
| 146 | const { controller, patch, apply } = setup(); |
| 147 | const applying = deferred<void>(); |
| 148 | const ready = deferred<void>(); |
| 149 | apply.mockImplementation(() => { |
| 150 | ready.resolve(); |
| 151 | return applying.promise; |
| 152 | }); |
| 153 | controller.edit({ fontSizeCode: 18 }); |
| 154 | const receiving = controller.receive(profile); |
| 155 | await ready.promise; |
| 156 | controller.disconnect(); |
| 157 | applying.resolve(); |
| 158 | await receiving; |
| 159 | expect(patch).not.toHaveBeenCalled(); |
| 160 | expect(controller.snapshot.status).toBe("offline"); |
| 161 | }); |
| 162 | |
| 163 | it("keeps pending edits when sending fails and retries them after a fresh profile", async () => { |
| 164 | const { controller, patch, cache } = setup(); |
| 165 | controller.edit({ fontSizeCode: 18 }); |
| 166 | patch.mockRejectedValueOnce(new Error("offline")); |
| 167 | await controller.receive(profile); |
| 168 | expect(controller.snapshot.status).toBe("error"); |
| 169 | expect(cache().pending.patch).toEqual({ fontSizeCode: 18 }); |
| 170 | patch.mockResolvedValue({ |
| 171 | profile: { |
| 172 | ...profile, |
| 173 | revision: 3, |
| 174 | values: { ...profile.values, fontSizeCode: 18 }, |
| 175 | fieldRevisions: { ...profile.fieldRevisions, fontSizeCode: 3 }, |
| 176 | }, |
| 177 | conflicts: [], |
| 178 | }); |
| 179 | await controller.receive(profile); |
| 180 | expect(patch).toHaveBeenCalledTimes(2); |
| 181 | expect(controller.snapshot.status).toBe("synced"); |
| 182 | expect(cache().pending.patch).toEqual({}); |
| 183 | }); |
| 184 | |
| 185 | it("flushes a reconnected client after the old connection's pending request fails", async () => { |
| 186 | const { controller, patch, cache } = setup(); |
| 187 | const started = deferred<void>(); |
| 188 | const oldResponse = deferred<UiSettingsPatchResult>(); |
| 189 | patch.mockImplementationOnce(() => { |
| 190 | started.resolve(); |
| 191 | return oldResponse.promise; |
| 192 | }); |
| 193 | patch.mockResolvedValueOnce({ |
| 194 | profile: { |
| 195 | ...profile, |
| 196 | revision: 3, |
| 197 | values: { ...profile.values, fontSizeCode: 18 }, |
| 198 | fieldRevisions: { ...profile.fieldRevisions, fontSizeCode: 3 }, |
| 199 | }, |
| 200 | conflicts: [], |
| 201 | }); |
| 202 | |
| 203 | controller.edit({ fontSizeCode: 18 }); |
| 204 | const firstConnection = controller.receive(profile); |
| 205 | await started.promise; |
| 206 | controller.disconnect(); |
| 207 | await controller.receive(profile); |
| 208 | oldResponse.reject(new Error("old connection closed")); |
| 209 | await firstConnection; |
| 210 | |
| 211 | expect(patch).toHaveBeenCalledTimes(2); |
| 212 | expect(cache().pending.patch).toEqual({}); |
| 213 | expect(cache().profile.values.fontSizeCode).toBe(18); |
| 214 | expect(controller.snapshot.status).toBe("synced"); |
| 215 | }); |
| 216 | |
| 217 | it("stops applying and sending after disabling even when an old request finishes", async () => { |
| 218 | const { controller, patch, apply } = setup(); |
| 219 | const started = deferred<void>(); |
| 220 | const response = deferred<UiSettingsPatchResult>(); |
| 221 | patch.mockImplementation(() => { |
| 222 | started.resolve(); |
| 223 | return response.promise; |
| 224 | }); |
| 225 | controller.edit({ fontSizeCode: 18 }); |
| 226 | const connecting = controller.receive(profile); |
| 227 | await started.promise; |
| 228 | await controller.stop(); |
| 229 | const calls = apply.mock.calls.length; |
| 230 | response.resolve({ profile, conflicts: [] }); |
| 231 | await connecting; |
| 232 | await controller.receive(profile); |
| 233 | controller.edit({ chatWidth: "full" }); |
| 234 | expect(apply).toHaveBeenCalledTimes(calls); |
| 235 | expect(patch).toHaveBeenCalledTimes(1); |
| 236 | }); |
| 237 | }); |