Skip to content
Source
Sign inSign up
Open History to see the last commit

DeviceStreamView.browser.test.tsx

208 lines7.2 KB

1import { RegistryContext } from "@effect/atom-react";
2import { AtomRegistry } from "effect/reactivity";
3import "~/index.css";
4import { EnvironmentId } from "@t3tools/contracts";
5import { expect, it, vi } from "vite-plus/test";
6​
7import { act, render, stubGlobal } from "../../../test/render";
8​
9vi.mock(import("~/state/device"), async (original) => ({
10 ...(await original()),
11 useDeviceHubAccess: () => access,
12}));
13vi.mock(import("./DevicePhoneViewport"), () => ({
14 DevicePhoneViewport: () => <></>,
15}));
16import { DeviceStreamView } from "./DeviceStreamView";
17​
18const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true };
19function setup() {
20 const registry = AtomRegistry.make();
21 const touches: unknown[] = [];
22 const image = vi.spyOn(HTMLImageElement.prototype, "src", "set").mockImplementation(() => {});
23 // Select MJPEG without starting a WebCodecs decoder. Input uses the real stream client.
24 const decoder = stubGlobal("VideoDecoder", undefined);
25 Reflect.deleteProperty(globalThis, "VideoDecoder");
26 const fetch = stubGlobal("fetch", () => Promise.resolve(new Response("prime")));
27 // The client opens its input socket only after the MJPEG prime resolves.
28 type InputSocket = {
29 onmessage?: (event: { data: ArrayBuffer }) => void;
30 };
31 let open!: (socket: InputSocket) => void;
32 const opened = new Promise<InputSocket>((resolve) => {
33 open = resolve;
34 });
35 const socket = stubGlobal(
36 "WebSocket",
37 class {
38 static OPEN = 1;
39 readyState = 1;
40 constructor() {
41 open(this);
42 }
43 onmessage?: (event: { data: ArrayBuffer }) => void;
44 send(message: string | Uint8Array) {
45 if (typeof message !== "string" && message[0] === 0x03) {
46 touches.push(JSON.parse(new TextDecoder().decode(message.subarray(1))));
47 }
48 }
49 close() {}
50 },
51 );
52 return {
53 view: () => (
54 <RegistryContext.Provider value={registry}>
55 <div style={{ width: 400, height: 800 }}>{view()}</div>
56 </RegistryContext.Provider>
57 ),
58 [Symbol.dispose]() {
59 registry.dispose();
60 image.mockRestore();
61 socket[Symbol.dispose]();
62 fetch[Symbol.dispose]();
63 decoder[Symbol.dispose]();
64 },
65 touches,
66 async configure(inputUnavailable = false) {
67 const json = new TextEncoder().encode(
68 JSON.stringify({ width: 400, height: 800, orientation: "portrait", inputUnavailable }),
69 );
70 const packet = new Uint8Array(1 + json.length);
71 packet[0] = 0x82;
72 packet.set(json, 1);
73 const input = await opened;
74 input.onmessage?.({ data: Uint8Array.of(0x83).buffer });
75 input.onmessage?.({ data: packet.buffer });
76 },
77 };
79​
80function view() {
81 return (
82 <DeviceStreamView
83 hostId="local"
84 environmentId={EnvironmentId.make("test")}
85 deviceId="test"
86 platform="ios"
87 visible
88 allowPhoneView={false}
89 renderControls={(controls) => (
90 <output>
91 {controls.handle?.inputConnected ? "Input connected" : "Input disconnected"}
92 </output>
93 )}
94 />
95 );
97​
98// router: false because the device view renders no links and never navigates.
99const options = { router: false } as const;
100​
101function flatPointer(container: HTMLElement) {
102 const surface = container.querySelector("canvas")!.parentElement!.parentElement!;
103 // Synthetic IDs exercise concurrent touches independently of the browser's capture state.
104 surface.setPointerCapture = () => {};
105 const rect = new DOMRect(0, 0, 400, 800);
106 surface.getBoundingClientRect = () => rect;
107 return {
108 send(type: string, pointerId: number, x = 0.25, y = 0.25) {
109 surface.dispatchEvent(
110 new PointerEvent(type, {
111 bubbles: true,
112 pointerId,
113 pointerType: "touch",
114 clientX: rect.left + rect.width * x,
115 clientY: rect.top + rect.height * y,
116 }),
117 );
118 },
119 };
121​
122it("keeps the first flat pointer active when a second finger moves and lifts", async () => {
123 using input = setup();
124 const { configure, touches } = input;
125 await using screen = await render(input.view(), options);
126 await act(async () => configure());
127 await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible();
128 const { send } = flatPointer(screen.container);
129 send("pointerdown", 1);
130 send("pointerdown", 2, 0.75, 0.75);
131 send("pointermove", 2, 0.875, 0.875);
132 send("pointerup", 2);
133 send("pointercancel", 2);
134 send("pointermove", 1, 0.5, 0.5);
135 send("pointerup", 1, 0.75, 0.75);
136 send("pointermove", 1);
137 expect(touches).toEqual([
138 { type: "begin", x: 0.25, y: 0.25 },
139 { type: "move", x: 0.5, y: 0.5 },
140 { type: "end", x: 0.75, y: 0.75 },
141 ]);
142});
143​
144it("ignores a flat pointer that goes down before input connects", async () => {
145 using input = setup();
146 const { configure, touches } = input;
147 await using screen = await render(input.view(), options);
148 const { send } = flatPointer(screen.container);
149 send("pointerdown", 1);
150 await act(async () => configure());
151 await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible();
152 send("pointerdown", 2, 0.5, 0.5);
153 send("pointerup", 2, 0.75, 0.75);
154 expect(touches).toEqual([
155 { type: "begin", x: 0.5, y: 0.5 },
156 { type: "end", x: 0.75, y: 0.75 },
157 ]);
158});
159​
160it.each(["pointerup", "pointercancel", "lostpointercapture"])(
161 "ends the owned flat pointer exactly once on %s, then accepts a new pointer",
162 async (ending) => {
163 using input = setup();
164 const { configure, touches } = input;
165 await using screen = await render(input.view(), options);
166 await act(async () => configure());
167 await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible();
168 const { send } = flatPointer(screen.container);
169 send("pointerdown", 7);
170 send("pointermove", 7, 0.5, 0.5);
171 send("lostpointercapture", 8, 0, 0);
172 // Capture loss reports its own point; the end must still use the last move.
173 const at = ending === "lostpointercapture" ? 0 : 0.5;
174 send(ending, 7, at, at);
175 send("lostpointercapture", 7, 0, 0);
176 send("pointerup", 7);
177 send("pointermove", 7);
178 send("pointerdown", 9);
179 send("pointerup", 9);
180 expect(touches).toEqual([
181 { type: "begin", x: 0.25, y: 0.25 },
182 { type: "move", x: 0.5, y: 0.5 },
183 { type: "end", x: 0.5, y: 0.5 },
184 { type: "begin", x: 0.25, y: 0.25 },
185 { type: "end", x: 0.25, y: 0.25 },
186 ]);
187 },
188);
189​
190it("releases flat pointer ownership when input disconnects", async () => {
191 using input = setup();
192 await using screen = await render(input.view(), options);
193 await act(() => input.configure());
194 await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible();
195 const { send } = flatPointer(screen.container);
196 send("pointerdown", 1);
197 await act(() => input.configure(true));
198 await expect.element(screen.getByText("Input disconnected", { exact: true })).toBeVisible();
199 await act(() => input.configure());
200 await expect.element(screen.getByText("Input connected", { exact: true })).toBeVisible();
201 send("pointerdown", 2, 0.5, 0.5);
202 send("pointerup", 2, 0.75, 0.75);
203 expect(input.touches).toEqual([
204 { type: "begin", x: 0.25, y: 0.25 },
205 { type: "begin", x: 0.5, y: 0.5 },
206 { type: "end", x: 0.75, y: 0.75 },
207 ]);
208});