1import { afterEach, expect, it, vi } from "vite-plus/test";
2import {
3 createDuoControl,
4 createDuoPinch,
5 duoFoldState,
6 duoHoldOrientation,
7 type DuoCommand,
8} from "./duoControl.ts";
9afterEach(() => vi.useRealTimers());
10
11it("keeps a failed send visible, including a disconnect while draining queued motion", () => {
12 const send = vi.fn((_request: { requestId: number; command: DuoCommand }) => false);
13 const onChange = vi.fn();
14 const queue = createDuoControl({ send, onChange });
15 queue.enqueue({ control: "angle", value: 40 });
16 expect(onChange).toHaveBeenLastCalledWith({
17 pending: false,
18 requested: null,
19 error: "Device is disconnected.",
20 });
21 send.mockReturnValueOnce(true);
22 queue.enqueue({ control: "pose", value: "book" });
23 queue.enqueue({ control: "angle", value: 60 });
24 queue.receive({ requestId: 2, ok: true });
25 expect(onChange).toHaveBeenLastCalledWith({
26 pending: false,
27 requested: null,
28 error: "Device is disconnected.",
29 });
30});
31
32it("coalesces hinge edits behind acknowledgements and lets a preset replace queued edits", () => {
33 const send = vi.fn((_request: { requestId: number; command: DuoCommand }) => true);
34 const onChange = vi.fn();
35 const queue = createDuoControl({ send, onChange });
36 queue.enqueue({ control: "angle", value: 40 });
37 queue.enqueue({ control: "angle", value: 50 });
38 queue.enqueue({ control: "angle", value: 60 });
39 expect(send).toHaveBeenCalledTimes(1);
40 queue.receive({ requestId: 1, ok: true });
41 expect(send.mock.calls[1]?.[0]).toEqual({
42 requestId: 2,
43 command: { control: "angle", value: 60 },
44 });
45 queue.enqueue({ control: "angle", value: 100 });
46 queue.enqueue({ control: "pose", value: "tent" });
47 queue.receive({ requestId: 2, ok: true });
48 expect(send.mock.calls[2]?.[0]).toEqual({
49 requestId: 3,
50 command: { control: "pose", value: "tent" },
51 });
52 queue.receive({ requestId: 3, ok: true });
53 expect(onChange).toHaveBeenLastCalledWith({ pending: false, requested: null, error: null });
54 queue.clear();
55});
56
57it("drops queued commands on failure, timeout and disconnect; late replies cannot acknowledge later work", () => {
58 vi.useFakeTimers();
59 const send = vi.fn((_request: { requestId: number; command: DuoCommand }) => true);
60 const onChange = vi.fn();
61 const queue = createDuoControl({ send, onChange, timeoutMs: 100 });
62 queue.enqueue({ control: "angle", value: 90 });
63 queue.enqueue({ control: "angle", value: 100 });
64 vi.advanceTimersByTime(100);
65 expect(onChange.mock.lastCall?.[0].error).toContain("timed out");
66 queue.enqueue({ control: "pose", value: "open" });
67 queue.receive({ requestId: 1, ok: true });
68 expect(onChange.mock.lastCall?.[0].pending).toBe(true);
69 queue.enqueue({ control: "angle", value: 130 });
70 queue.receive({ requestId: 2, ok: false, error: "native refused" });
71 expect(onChange.mock.lastCall?.[0]).toEqual({
72 pending: false,
73 requested: null,
74 error: "native refused",
75 });
76 queue.enqueue({ control: "pose", value: "book" });
77 queue.enqueue({ control: "pose", value: "closed" });
78 queue.clear();
79 queue.receive({ requestId: 3, ok: true });
80 expect(send).toHaveBeenCalledTimes(3);
81 queue.enqueue({ control: "angle", value: Infinity });
82 expect(send).toHaveBeenCalledTimes(3);
83 queue.clear();
84});
85
86it("pinches only a hit device, accumulates independently of native readback, clamps and cancels", () => {
87 let confirmed = 90;
88 const change = vi.fn();
89 const pinch = createDuoPinch({
90 angle: () => confirmed,
91 contains: (x, y) => x > 0.2 && y > 0.2,
92 change,
93 });
94 expect(pinch.begin(0.1, 0.5)).toBe(false);
95 pinch.move(1);
96 expect(change).not.toHaveBeenCalled();
97 expect(pinch.begin(0.5, 0.5)).toBe(true);
98 pinch.move(0.25);
99 expect(change).toHaveBeenLastCalledWith(120);
100 confirmed = 100;
101 pinch.move(0.25);
102 expect(change).toHaveBeenLastCalledWith(150);
103 pinch.move(2);
104 expect(change).toHaveBeenLastCalledWith(180);
105 pinch.move(-3);
106 expect(change).toHaveBeenLastCalledWith(0);
107 pinch.move(NaN);
108 pinch.end();
109 expect(change).toHaveBeenLastCalledWith(null);
110 const count = change.mock.calls.length;
111 pinch.move(1);
112 pinch.end();
113 expect(change).toHaveBeenCalledTimes(count);
114 expect(pinch.active).toBe(false);
115});
116
117// Screen configs as an iPhone Duo simulator reports them for each way of holding the device.
118it.each([
119 [
120 "vertical phone, closed",
121 { screenId: 1, orientation: "portrait", hingeAngle: 0 },
122 "closed",
123 true,
124 ],
125 [
126 "horizontal phone, closed",
127 { screenId: 1, orientation: "landscape_right", hingeAngle: 0 },
128 "closed",
129 false,
130 ],
131 [
132 "vertical phone opened as a book",
133 { screenId: 3, orientation: "landscape_left", hingeAngle: 180 },
134 "open",
135 true,
136 ],
137 [
138 "horizontal phone opened as a laptop",
139 { screenId: 3, orientation: "portrait_upside_down", hingeAngle: 180 },
140 "open",
141 false,
142 ],
143 ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true],
144] as const)("reads a %s", (_name, screen, fold, phoneVertical) => {
145 expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical, settled: true });
146});
147
148it("marks a display handoff unsettled while the cover reports the inner display's orientation", () => {
149 // Recorded from the iPhone Duo simulator opening a closed vertical phone to a book.
150 const handoff = [
151 { screenId: 1, orientation: "portrait", hingeAngle: 90 },
152 { screenId: 1, orientation: "landscape_left", hingeAngle: 90 },
153 { screenId: 3, orientation: "landscape_left", hingeAngle: 90 },
154 ] as const;
155 expect(handoff.map((screen) => duoFoldState(screen).settled)).toEqual([false, false, true]);
156 // Closing hands back to the cover the same way.
157 expect(duoFoldState({ screenId: 3, orientation: "landscape_left", hingeAngle: 0 }).settled).toBe(
158 false,
159 );
160});
161
162it("marks native stands so the fold group does not also claim them", () => {
163 expect(
164 duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }),
165 ).toEqual({ fold: "half", stand: true, phoneVertical: false, settled: true });
166});
167
168it("falls back like the 3D view when hinge fields are missing", () => {
169 expect(duoFoldState({ screenId: 1, orientation: "portrait" })).toMatchObject({
170 fold: "closed",
171 phoneVertical: true,
172 });
173 // Without a display ID the 3D view draws the open inner panel, so the controls do too.
174 expect(duoFoldState({ orientation: "landscape_left" })).toMatchObject({
175 fold: "open",
176 phoneVertical: true,
177 });
178});
179
180it("rotates a stand back to how the phone was held, in the frame of the display receiving it", () => {
181 // Recorded from the iPhone Duo simulator: Tent can rest on either display.
182 // Rotating the inner display to landscape_left, or the cover to portrait,
183 // leaves a phone that opens and closes vertical.
184 expect(duoHoldOrientation(true, 3)).toBe("landscape_left");
185 expect(duoHoldOrientation(true, 1)).toBe("portrait");
186 // A quarter turn apart, these describe the same horizontal phone.
187 expect(duoHoldOrientation(false, 1)).toBe("landscape_left");
188 expect(duoHoldOrientation(false, 3)).toBe("portrait_upside_down");
189 for (const screenId of [1, 3])
190 for (const vertical of [true, false])
191 expect(
192 duoFoldState({ screenId, orientation: duoHoldOrientation(vertical, screenId) })
193 .phoneVertical,
194 ).toBe(vertical);
195});