Open History to see the last commit
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 makeQuitShortcutHandler,
5 QUIT_DOUBLE_PRESS_MS,
6 QUIT_HOLD_DURATION_MS,
7} from "./QuitHold.ts";
8import type { QuitHoldKeyInput } from "./QuitHold.ts";
9import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts";
10
11const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const;
12const UP = { state: "up" } as const;
13
14function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
15 return {
16 type: "keyDown",
17 key: "q",
18 meta: true,
19 control: false,
20 alt: false,
21 shift: false,
22 isAutoRepeat: false,
23 ...overrides,
24 };
25}
26
27function makeHarness(options?: {
28 mode?: QuitConfirmationMode;
29 platform?: NodeJS.Platform;
30 getMode?: () => Promise<QuitConfirmationMode>;
31}) {
32 const notifications: Array<QuitShortcutHintEvent> = [];
33 const concealWindow = vi.fn();
34 const quit = vi.fn();
35 const handler = makeQuitShortcutHandler({
36 platform: options?.platform ?? "darwin",
37 getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")),
38 notify: (event) => notifications.push(event),
39 concealWindow,
40 quit,
41 });
42 const preventDefault = vi.fn();
43 const send = async (input: QuitHoldKeyInput) => {
44 handler({ preventDefault }, input);
45 await Promise.resolve();
46 await Promise.resolve();
47 };
48 const holdFor = async (
49 durationMs: number,
50 repeatOverrides: Partial<QuitHoldKeyInput> = {},
51 intervalMs = 100,
52 ) => {
53 for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) {
54 vi.advanceTimersByTime(intervalMs);
55 await send(makeInput({ isAutoRepeat: true, ...repeatOverrides }));
56 }
57 };
58 return { notifications, concealWindow, quit, preventDefault, send, holdFor };
59}
60
61describe("makeQuitShortcutHandler", () => {
62 beforeEach(() => {
63 vi.useFakeTimers();
64 });
65 afterEach(() => {
66 vi.useRealTimers();
67 });
68
69 it("honors direct mode when the key is released before its mode read settles", async () => {
70 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
71 const harness = makeHarness({
72 getMode: () =>
73 new Promise((resolve) => {
74 resolveMode = resolve;
75 }),
76 });
77 await harness.send(makeInput({}));
78 await harness.send(makeInput({ type: "keyUp" }));
79
80 resolveMode?.("direct");
81 await Promise.resolve();
82 await Promise.resolve();
83
84 expect(harness.quit).toHaveBeenCalledTimes(1);
85 expect(harness.notifications).toEqual([]);
86 });
87
88 it("does not arm hold mode after a released key's mode read settles", async () => {
89 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
90 const harness = makeHarness({
91 getMode: () =>
92 new Promise((resolve) => {
93 resolveMode = resolve;
94 }),
95 });
96 await harness.send(makeInput({}));
97 await harness.send(makeInput({ type: "keyUp" }));
98
99 resolveMode?.("hold");
100 await Promise.resolve();
101 await Promise.resolve();
102
103 expect(harness.quit).not.toHaveBeenCalled();
104 expect(harness.notifications).toEqual([]);
105 });
106
107 it.each(["direct", "hold", "double-click"] as const)(
108 "quits on a quick second press without waiting for a pending %s mode read",
109 async (mode) => {
110 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
111 const harness = makeHarness({
112 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
113 });
114 await harness.send(makeInput({}));
115 await harness.send(makeInput({ type: "keyUp" }));
116 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
117 await harness.send(makeInput({}));
118
119 expect(harness.quit).toHaveBeenCalledTimes(1);
120 expect(harness.notifications).toEqual([]);
121
122 await harness.send(makeInput({ type: "keyUp" }));
123
124 resolvers[0]?.(mode);
125 await Promise.resolve();
126 await Promise.resolve();
127
128 expect(harness.quit).toHaveBeenCalledTimes(1);
129 expect(harness.notifications).toEqual([]);
130 },
131 );
132
133 it("discards a stale mode resolution from a superseded press", async () => {
134 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
135 const harness = makeHarness({
136 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
137 });
138 await harness.send(makeInput({}));
139 await harness.send(makeInput({ type: "keyUp" }));
140 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100);
141 await harness.send(makeInput({}));
142 expect(resolvers).toHaveLength(2);
143
144 resolvers[0]?.("direct");
145 await Promise.resolve();
146 await Promise.resolve();
147 expect(harness.quit).not.toHaveBeenCalled();
148
149 resolvers[1]?.("hold");
150 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
151 await harness.send(makeInput({ type: "keyUp" }));
152 expect(harness.quit).toHaveBeenCalledTimes(1);
153 });
154
155 it("does not count an interrupted press toward a double press", async () => {
156 const harness = makeHarness({ mode: "double-click" });
157 await harness.send(makeInput({}));
158 await harness.send(makeInput({ shift: true }));
159 await harness.send(makeInput({}));
160 expect(harness.quit).not.toHaveBeenCalled();
161 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]);
162 });
163});