| 1 | import { EnvironmentId, type DeviceDetail, type DeviceSummary } from "@t3tools/contracts"; |
| 2 | import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; |
| 3 | import { act, useEffect } from "react"; |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 6 | import { useDeviceControls, type DeviceControls } from "./useDeviceControls"; |
| 7 | |
| 8 | type Result = { _tag: "Success"; value: DeviceDetail } | { _tag: "Failure"; cause: unknown }; |
| 9 | const { 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 | })); |
| 15 | vi.mock("~/state/device", () => ({ deviceEnvironment: { detail: "detail", action: "action" } })); |
| 16 | vi.mock("~/state/use-atom-command", () => ({ |
| 17 | useAtomCommand: (command: string) => (command === "detail" ? read : action), |
| 18 | })); |
| 19 | vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device action failed" })); |
| 20 | vi.mock("./deviceHubApi", () => ({ subscribeDeviceForeground: subscribe })); |
| 21 | |
| 22 | const device: DeviceSummary = { |
| 23 | hostId: "remote", |
| 24 | id: "phone", |
| 25 | name: "Phone", |
| 26 | platform: "ios", |
| 27 | version: "iOS", |
| 28 | booted: true, |
| 29 | physical: false, |
| 30 | }; |
| 31 | const 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 | }); |
| 41 | function deferred() { |
| 42 | let resolve!: (value: Result) => void; |
| 43 | const promise = new Promise<Result>((done) => { |
| 44 | resolve = done; |
| 45 | }); |
| 46 | return { promise, resolve }; |
| 47 | } |
| 48 | let renderer: ReactTestRenderer | undefined; |
| 49 | let controls: DeviceControls; |
| 50 | function 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 | } |
| 62 | beforeEach(() => { |
| 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 | }); |
| 70 | afterEach(async () => { |
| 71 | await act(async () => renderer?.unmount()); |
| 72 | vi.unstubAllGlobals(); |
| 73 | }); |
| 74 | async function mount() { |
| 75 | await act(async () => { |
| 76 | renderer = create(<Probe visible />); |
| 77 | }); |
| 78 | } |
| 79 | |
| 80 | it("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 | |
| 104 | it("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 | |
| 138 | it("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 | |
| 164 | it("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 | |
| 181 | it("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 | |
| 196 | it.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 | ); |