Skip to content
Source
Sign inSign up

duoControl.test.ts

162 lines5.7 KB

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("pinches only a hit device, accumulates independently of native readback, clamps and cancels", () => {
58 let confirmed = 90;
59 const change = vi.fn();
60 const pinch = createDuoPinch({
61 angle: () => confirmed,
62 contains: (x, y) => x > 0.2 && y > 0.2,
63 change,
64 });
65 expect(pinch.begin(0.1, 0.5)).toBe(false);
66 pinch.move(1);
67 expect(change).not.toHaveBeenCalled();
68 expect(pinch.begin(0.5, 0.5)).toBe(true);
69 pinch.move(0.25);
70 expect(change).toHaveBeenLastCalledWith(120);
71 confirmed = 100;
72 pinch.move(0.25);
73 expect(change).toHaveBeenLastCalledWith(150);
74 pinch.move(2);
75 expect(change).toHaveBeenLastCalledWith(180);
76 pinch.move(-3);
77 expect(change).toHaveBeenLastCalledWith(0);
78 pinch.move(NaN);
79 pinch.end();
80 expect(change).toHaveBeenLastCalledWith(null);
81 const count = change.mock.calls.length;
82 pinch.move(1);
83 pinch.end();
84 expect(change).toHaveBeenCalledTimes(count);
85 expect(pinch.active).toBe(false);
86});
87​
88// regression: #16630 fold controls follow the phone's orientation on each screen
89it.each([
90 [
91 "vertical phone, closed",
92 { screenId: 1, orientation: "portrait", hingeAngle: 0 },
93 "closed",
94 true,
95 ],
96 [
97 "horizontal phone, closed",
98 { screenId: 1, orientation: "landscape_right", hingeAngle: 0 },
99 "closed",
100 false,
101 ],
102 [
103 "vertical phone opened as a book",
104 { screenId: 3, orientation: "landscape_left", hingeAngle: 180 },
105 "open",
106 true,
107 ],
108 [
109 "horizontal phone opened as a laptop",
110 { screenId: 3, orientation: "portrait_upside_down", hingeAngle: 180 },
111 "open",
112 false,
113 ],
114 ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true],
115] as const)("reads a %s", (_name, screen, fold, phoneVertical) => {
116 expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical, settled: true });
117});
118​
119it("marks a display handoff unsettled while the cover reports the inner display's orientation", () => {
120 const handoff = [
121 { screenId: 1, orientation: "portrait", hingeAngle: 90 },
122 { screenId: 1, orientation: "landscape_left", hingeAngle: 90 },
123 { screenId: 3, orientation: "landscape_left", hingeAngle: 90 },
124 ] as const;
125 expect(handoff.map((screen) => duoFoldState(screen).settled)).toEqual([false, false, true]);
126 expect(duoFoldState({ screenId: 3, orientation: "landscape_left", hingeAngle: 0 }).settled).toBe(
127 false,
128 );
129});
130​
131it("marks native stands so the fold group does not also claim them", () => {
132 expect(
133 duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }),
134 ).toEqual({ fold: "half", stand: true, phoneVertical: false, settled: true });
135});
136​
137it("falls back like the 3D view when hinge fields are missing", () => {
138 expect(duoFoldState({ screenId: 1, orientation: "portrait" })).toMatchObject({
139 fold: "closed",
140 phoneVertical: true,
141 });
142 expect(duoFoldState({ orientation: "landscape_left" })).toMatchObject({
143 fold: "open",
144 phoneVertical: true,
145 });
146});
147​
148it("rotates a stand back to how the phone was held, in the frame of the display receiving it", () => {
149 // Recorded from the iPhone Duo simulator: Tent can rest on either display.
150 // Rotating the inner display to landscape_left, or the cover to portrait,
151 // leaves a phone that opens and closes vertical.
152 expect(duoHoldOrientation(true, 3)).toBe("landscape_left");
153 expect(duoHoldOrientation(true, 1)).toBe("portrait");
154 expect(duoHoldOrientation(false, 1)).toBe("landscape_left");
155 expect(duoHoldOrientation(false, 3)).toBe("portrait_upside_down");
156 for (const screenId of [1, 3])
157 for (const vertical of [true, false])
158 expect(
159 duoFoldState({ screenId, orientation: duoHoldOrientation(vertical, screenId) })
160 .phoneVertical,
161 ).toBe(vertical);
162});