1import { EnvironmentId, type DeviceDetail, type DeviceSummary } from "@t3tools/contracts";
2import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
3import { act, useEffect } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6import { useDeviceControls, type DeviceControls } from "./useDeviceControls";
7
8type Result = { _tag: "Success"; value: DeviceDetail } | { _tag: "Failure"; cause: unknown };
9const { read, action, subscribe } = vi.hoisted(() => ({
10 read: vi.fn<() => Promise<Result>>(),
11 action: vi.fn<() => Promise<Result>>(),
12 subscribe:
13 vi.fn<(_target: unknown, onChange: (app: { id: string } | null) => void) => () => void>(),
14}));
15vi.mock("~/state/device", () => ({ deviceEnvironment: { detail: "detail", action: "action" } }));
16vi.mock("~/state/use-atom-command", () => ({
17 useAtomCommand: (command: string) => (command === "detail" ? read : action),
18}));
19vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device action failed" }));
20vi.mock("./deviceHubApi", () => ({ subscribeDeviceForeground: subscribe }));
21
22const device: DeviceSummary = {
23 hostId: "remote",
24 id: "phone",
25 name: "Phone",
26 platform: "ios",
27 version: "iOS",
28 booted: true,
29 physical: false,
30};
31const snapshot = (appearance: "light" | "dark"): Result => ({
32 _tag: "Success",
33 value: {
34 hostId: "remote",
35 deviceId: "phone",
36 settings: { appearance },
37 foregroundApp: null,
38 readAt: "2026-09-20T00:00:00Z",
39 },
40});
41function deferred() {
42 let resolve!: (value: Result) => void;
43 const promise = new Promise<Result>((done) => {
44 resolve = done;
45 });
46 return { promise, resolve };
47}
48let renderer: ReactTestRenderer | undefined;
49let controls: DeviceControls;
50function Probe({ visible, access = null }: { visible: boolean; access?: DeviceHubAccess | null }) {
51 const next = useDeviceControls({
52 environmentId: EnvironmentId.make("environment"),
53 device,
54 access,
55 visible,
56 });
57 useEffect(() => {
58 controls = next;
59 }, [next]);
60 return null;
61}
62beforeEach(() => {
63 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64 read.mockReset();
65 action.mockReset();
66 subscribe.mockReset();
67 subscribe.mockReturnValue(() => {});
68 read.mockResolvedValue(snapshot("light"));
69});
70afterEach(async () => {
71 await act(async () => renderer?.unmount());
72 vi.unstubAllGlobals();
73});
74async function mount() {
75 await act(async () => {
76 renderer = create(<Probe visible />);
77 });
78}
79
80it("serializes rapid actions and retains confirmed settings while pending or after failure", async () => {
81 await mount();
82 const response = deferred();
83 action.mockReturnValueOnce(response.promise);
84 let pending!: Promise<void>;
85 await act(async () => {
86 pending = controls.act({ type: "setAppearance", value: "dark" });
87 await controls.act({ type: "setAppearance", value: "light" });
88 });
89 expect(action).toHaveBeenCalledOnce();
90 expect(controls.disabled).toBe(true);
91 expect(controls.detail?.settings.appearance).toBe("light");
92 await act(async () => {
93 response.resolve(snapshot("dark"));
94 await pending;
95 });
96 expect(controls.detail?.settings.appearance).toBe("dark");
97 action.mockResolvedValueOnce({ _tag: "Failure", cause: "offline" });
98 await act(async () => controls.act({ type: "setAppearance", value: "light" }));
99 expect(controls.detail?.settings.appearance).toBe("dark");
100 expect(controls.error).toBe("Device action failed");
101 expect(controls.disabled).toBe(false);
102});
103
104it("serializes host actions across hide/reopen and discards detached results", async () => {
105 await mount();
106 const response = deferred();
107 action.mockReturnValueOnce(response.promise);
108 let pending!: Promise<void>;
109 await act(async () => {
110 pending = controls.act({ type: "setAppearance", value: "dark" });
111 });
112 await act(async () => renderer!.update(<Probe visible={false} />));
113 expect(controls.disabled).toBe(true);
114 await act(async () => renderer!.update(<Probe visible />));
115 await act(async () => controls.act({ type: "setAppearance", value: "light" }));
116 expect(action).toHaveBeenCalledOnce();
117 expect(controls.disabled).toBe(true);
118 const confirmed = deferred();
119 read.mockReturnValueOnce(confirmed.promise);
120 await act(async () => {
121 response.resolve(snapshot("dark"));
122 await response.promise;
123 });
124 expect(controls.detail?.settings.appearance).toBe("light");
125 expect(controls.disabled).toBe(true);
126 await act(async () => {
127 confirmed.resolve(snapshot("dark"));
128 await pending;
129 });
130 expect(controls.detail?.settings.appearance).toBe("dark");
131 expect(controls.disabled).toBe(false);
132 action.mockResolvedValueOnce(snapshot("dark"));
133 await act(async () => controls.act({ type: "setAppearance", value: "dark" }));
134 expect(action).toHaveBeenCalledTimes(2);
135 expect(controls.detail?.settings.appearance).toBe("dark");
136});
137
138it("keeps actions disabled when settings cannot be confirmed after a detached command", async () => {
139 await mount();
140 const response = deferred();
141 action.mockReturnValueOnce(response.promise);
142 let pending!: Promise<void>;
143 await act(async () => {
144 pending = controls.act({ type: "setAppearance", value: "dark" });
145 });
146 await act(async () => renderer!.update(<Probe visible={false} />));
147 await act(async () => renderer!.update(<Probe visible />));
148 read.mockResolvedValueOnce({ _tag: "Failure", cause: "offline" });
149 await act(async () => {
150 response.resolve(snapshot("dark"));
151 await pending;
152 });
153 expect(controls.detail).toBeNull();
154 expect(controls.disabled).toBe(true);
155 expect(controls.error).toBe("Device action failed");
156 await act(async () => controls.act({ type: "setAppearance", value: "light" }));
157 expect(action).toHaveBeenCalledOnce();
158 await act(async () => renderer!.update(<Probe visible={false} />));
159 await act(async () => renderer!.update(<Probe visible />));
160 expect(controls.disabled).toBe(false);
161 expect(controls.error).toBeNull();
162});
163
164it("disables stale settings after a failed reopen read and recovers on a successful read", async () => {
165 await mount();
166 await act(async () => renderer!.update(<Probe visible={false} />));
167 read.mockResolvedValueOnce({ _tag: "Failure", cause: "offline" });
168 await act(async () => renderer!.update(<Probe visible />));
169 expect(controls.detail).toBeNull();
170 expect(controls.disabled).toBe(true);
171 expect(controls.error).toBe("Device action failed");
172 await act(async () => controls.act({ type: "setAppearance", value: "dark" }));
173 expect(action).not.toHaveBeenCalled();
174 await act(async () => renderer!.update(<Probe visible={false} />));
175 await act(async () => renderer!.update(<Probe visible />));
176 expect(controls.detail?.settings.appearance).toBe("light");
177 expect(controls.disabled).toBe(false);
178 expect(controls.error).toBeNull();
179});
180
181it("does not let an older refresh overwrite a newer confirmed action", async () => {
182 await mount();
183 await act(async () => renderer!.update(<Probe visible={false} />));
184 const refresh = deferred();
185 read.mockReturnValueOnce(refresh.promise);
186 await act(async () => renderer!.update(<Probe visible />));
187 action.mockResolvedValueOnce(snapshot("dark"));
188 await act(async () => controls.act({ type: "setAppearance", value: "dark" }));
189 await act(async () => {
190 refresh.resolve(snapshot("light"));
191 await refresh.promise;
192 });
193 expect(controls.detail?.settings.appearance).toBe("dark");
194});
195
196it.each(["hidden", "access renewed"] as const)(
197 "uses the detail snapshot until a new foreground event after the subscription is %s",
198 async (restart) => {
199 const access: DeviceHubAccess = {
200 httpBase: "https://remote.test/api/device-hub",
201 wsBase: "wss://remote.test/api/device-hub",
202 query: {},
203 credentials: false,
204 };
205 const detail = snapshot("light");
206 if (detail._tag !== "Success") throw new Error("Expected a successful snapshot");
207 read.mockResolvedValue({
208 ...detail,
209 value: { ...detail.value, foregroundApp: { id: "snapshot.app" } },
210 });
211 await act(async () => {
212 renderer = create(<Probe visible access={access} />);
213 });
214 const oldEvent = subscribe.mock.calls[0]![1];
215 await act(async () => oldEvent({ id: "previous.live.app" }));
216 expect(controls.foregroundApp?.id).toBe("previous.live.app");
217 if (restart === "hidden") {
218 await act(async () => renderer!.update(<Probe visible={false} access={access} />));
219 await act(async () => renderer!.update(<Probe visible access={access} />));
220 } else {
221 await act(async () =>
222 renderer!.update(<Probe visible access={{ ...access, query: { ticket: "renewed" } }} />),
223 );
224 }
225 expect(controls.foregroundApp?.id).toBe("snapshot.app");
226 await act(async () => oldEvent({ id: "late.previous.app" }));
227 expect(controls.foregroundApp?.id).toBe("snapshot.app");
228 const newEvent = subscribe.mock.calls[1]![1];
229 await act(async () => newEvent({ id: "current.live.app" }));
230 expect(controls.foregroundApp?.id).toBe("current.live.app");
231 },
232);