| 1 | import { afterEach, expect, it, vi } from "vite-plus/test"; |
| 2 | import { |
| 3 | createDuoControl, |
| 4 | createDuoPinch, |
| 5 | duoFoldState, |
| 6 | duoHoldOrientation, |
| 7 | type DuoCommand, |
| 8 | } from "./duoControl.ts"; |
| 9 | afterEach(() => vi.useRealTimers()); |
| 10 | |
| 11 | it("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 | |
| 32 | it("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 | |
| 57 | it("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 | |
| 86 | it("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. |
| 118 | it.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 | |
| 148 | it("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 | |
| 162 | it("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 | |
| 168 | it("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 | |
| 180 | it("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 | }); |