1import { describe, expect, it, vi } from "vite-plus/test";
2import {
3 EnvironmentId,
4 type UiSettingsProfile,
5 type UiSettingsValues,
6 type UiSettingsPatchResult,
7} from "@t3tools/contracts";
8import { UiSettingsSync, type UiSettingsSyncCache } from "./uiSettingsSync.ts";
9
10const host = EnvironmentId.make("personal");
11const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} };
12const profile: UiSettingsProfile = {
13 revision: 2,
14 values: { fontSizeCode: 16, chatWidth: "wide" },
15 fieldRevisions: { fontSizeCode: 1, chatWidth: 2 },
16};
17
18function 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
33function 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
43describe("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});