Skip to content
Source
Sign inSign up

useDeviceControls.browser.test.tsx

188 lines7.0 KB

1import { EnvironmentId, type DeviceDetail, type DeviceSummary } from "@t3tools/contracts";
2import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
3import type { AtomCommandConcurrency } from "@t3tools/client-runtime/state/runtime";
4import { RegistryContext } from "@effect/atom-react";
5import * as Cause from "effect/Cause";
6import { AtomRegistry } from "effect/reactivity";
7import { useEffect } from "react";
8import { expect, it, vi } from "vite-plus/test";
9​
10import { act, render } from "../../../test/render";
11​
12type Result = { _tag: "Success"; value: DeviceDetail } | { _tag: "Failure"; cause: unknown };
13const { read, action, subscribe } = vi.hoisted(() => ({
14 read: vi.fn<() => Promise<Result>>(),
15 action: vi.fn<() => Promise<Result>>(),
16 subscribe:
17 vi.fn<(_target: unknown, onChange: (app: { id: string } | null) => void) => () => void>(),
18}));
19// Stubs only the transport; the device atoms' real scheduler and lanes decide ordering.
20vi.mock(import("@t3tools/client-runtime/state/runtime"), async (original) => {
21 const runtime = await original();
22 const createEnvironmentRpcCommand = (
23 _runtime: unknown,
24 options: {
25 readonly label: string;
26 readonly scheduler?: {
27 readonly schedule: (
28 registry: AtomRegistry.AtomRegistry,
29 concurrency: AtomCommandConcurrency<unknown>,
30 input: unknown,
31 execute: () => Promise<Result>,
32 ) => Promise<Result>;
33 };
34 readonly concurrency?: AtomCommandConcurrency<unknown>;
35 },
36 ) => {
37 const send = options.label === "environment-data:device:detail" ? read : action;
38 return {
39 label: options.label,
40 run: (registry: AtomRegistry.AtomRegistry, input: unknown) =>
41 options.scheduler && options.concurrency
42 ? options.scheduler.schedule(registry, options.concurrency, input, () => send())
43 : send(),
44 };
45 };
46 return { ...runtime, createEnvironmentRpcCommand: createEnvironmentRpcCommand as never };
47});
48vi.mock(import("./deviceHubApi"), async (original) => ({
49 ...(await original()),
50 subscribeDeviceForeground: subscribe as never,
51}));
52​
53import { deviceDetailReadAtom } from "~/state/device";
54import { useDeviceControls, type DeviceControls } from "./useDeviceControls";
55​
56const offline = Cause.fail(new Error("Device offline"));
57const device: DeviceSummary = {
58 hostId: "remote",
59 id: "phone",
60 name: "Phone",
61 platform: "ios",
62 version: "iOS",
63 booted: true,
64 physical: false,
65};
66const snapshot = (appearance: "light" | "dark"): Result => ({
67 _tag: "Success",
68 value: {
69 hostId: "remote",
70 deviceId: "phone",
71 settings: { appearance },
72 foregroundApp: null,
73 readAt: "2026-09-20T00:00:00Z",
74 },
75});
76function deferred() {
77 let resolve!: (value: Result) => void;
78 const promise = new Promise<Result>((done) => {
79 resolve = done;
80 });
81 return { promise, resolve };
83let controls: DeviceControls;
84const environmentId = EnvironmentId.make("environment");
85function Probe({ visible, access = null }: { visible: boolean; access?: DeviceHubAccess | null }) {
86 const next = useDeviceControls({ environmentId, device, access, visible });
87 useEffect(() => {
88 controls = next;
89 }, [next]);
90 return null;
92function setup() {
93 read.mockReset();
94 action.mockReset();
95 subscribe.mockReset();
96 subscribe.mockReturnValue(() => {});
97 read.mockResolvedValue(snapshot("light"));
98 // No idle TTL, as in the app: hiding the panel ends its read.
99 const registry = AtomRegistry.make();
100 const view = (visible: boolean) => (
101 <RegistryContext.Provider value={registry}>
102 <Probe visible={visible} />
103 </RegistryContext.Provider>
104 );
105 const readAtom = deviceDetailReadAtom({ environmentId, hostId: "remote", deviceId: "phone" });
106 const hide = async (screen: { rerender: (element: React.ReactElement) => Promise<unknown> }) => {
107 await screen.rerender(view(false));
108 await vi.waitFor(() => expect(registry.getNodes().has(readAtom)).toBe(false));
109 };
110 return { view, hide };
112​
113it("serializes rapid actions and retains confirmed settings while pending or after failure", async () => {
114 const { view } = setup();
115 await using _screen = await render(view(true));
116 await vi.waitFor(() => expect(controls.detail?.settings.appearance).toBe("light"));
117 const response = deferred();
118 action.mockReturnValueOnce(response.promise);
119 let pending!: Promise<boolean>;
120 await act(async () => {
121 pending = controls.act({ type: "setAppearance", value: "dark" });
122 expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false);
123 });
124 expect(action).toHaveBeenCalledOnce();
125 expect(controls.disabled).toBe(true);
126 expect(controls.detail?.settings.appearance).toBe("light");
127 await act(async () => {
128 response.resolve(snapshot("dark"));
129 expect(await pending).toBe(true);
130 });
131 expect(controls.detail?.settings.appearance).toBe("dark");
132 action.mockResolvedValueOnce({ _tag: "Failure", cause: offline });
133 await act(async () => {
134 expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false);
135 });
136 expect(controls.detail?.settings.appearance).toBe("dark");
137 expect(controls.error).toBe("Device offline");
138 expect(controls.disabled).toBe(false);
139});
140​
141it("confirms settings again after a command sent before the panel reopened", async () => {
142 const { view, hide } = setup();
143 await using screen = await render(view(true));
144 await vi.waitFor(() => expect(controls.detail).not.toBeNull());
145 const response = deferred();
146 action.mockReturnValueOnce(response.promise);
147 let pending!: Promise<boolean>;
148 await act(async () => {
149 pending = controls.act({ type: "setAppearance", value: "dark" });
150 });
151 await hide(screen);
152 read.mockResolvedValueOnce({ _tag: "Failure", cause: offline });
153 await screen.rerender(view(true));
154 await act(async () => {
155 response.resolve(snapshot("dark"));
156 await pending;
157 });
158 await vi.waitFor(() => expect(controls.error).toBe("Device offline"));
159 expect(controls.detail).toBeNull();
160 expect(controls.disabled).toBe(true);
161 await act(async () => controls.act({ type: "setAppearance", value: "light" }));
162 expect(action).toHaveBeenCalledOnce();
163 await hide(screen);
164 await screen.rerender(view(true));
165 await vi.waitFor(() => expect(controls.disabled).toBe(false));
166 expect(controls.error).toBeNull();
167});
168​
169it("does not let a read sent before an action overwrite the action's answer", async () => {
170 const { view, hide } = setup();
171 await using screen = await render(view(true));
172 await vi.waitFor(() => expect(controls.detail).not.toBeNull());
173 await hide(screen);
174 const refresh = deferred();
175 read.mockReturnValueOnce(refresh.promise);
176 await screen.rerender(view(true));
177 await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2));
178 action.mockResolvedValueOnce(snapshot("dark"));
179 let pending!: Promise<boolean>;
180 await act(async () => {
181 pending = controls.act({ type: "setAppearance", value: "dark" });
182 });
183 await act(async () => {
184 refresh.resolve(snapshot("light"));
185 await pending;
186 });
187 expect(controls.detail?.settings.appearance).toBe("dark");
188});