Skip to content

Commit cec825f

Browse files
committed
chore: add unit test for debounced onChange
1 parent 2f05259 commit cec825f

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import "@testing-library/jest-dom";
2+
import { render } from "@testing-library/react";
3+
import userEvent, { UserEvent } from "@testing-library/user-event";
4+
import { ColorPicker, ColorPickerProps } from "../ColorPicker";
5+
6+
/**
7+
* Mock all react-color pickers to expose controllable onChange / onChangeComplete
8+
* buttons, while still rendering with the expected CSS class names so the
9+
* existing picker-type tests continue to pass.
10+
*/
11+
jest.mock("react-color", () => {
12+
const colorState = {
13+
hex: "#FF0000",
14+
rgb: { r: 255, g: 0, b: 0, a: 1 },
15+
hsl: { h: 0, s: 1, l: 0.5, a: 1 }
16+
};
17+
18+
type MockPickerProps = {
19+
onChange: (c: typeof colorState) => void;
20+
onChangeComplete: (c: typeof colorState) => void;
21+
};
22+
23+
const createMockPicker = (cssClass: string) =>
24+
function MockPicker({ onChange, onChangeComplete }: MockPickerProps) {
25+
return (
26+
<div className={cssClass}>
27+
<button data-testid="trigger-change" onClick={() => onChange(colorState)}>
28+
change
29+
</button>
30+
<button data-testid="trigger-change-complete" onClick={() => onChangeComplete(colorState)}>
31+
complete
32+
</button>
33+
</div>
34+
);
35+
};
36+
37+
return {
38+
SketchPicker: createMockPicker("sketch-picker"),
39+
ChromePicker: createMockPicker("chrome-picker"),
40+
BlockPicker: createMockPicker("block-picker"),
41+
GithubPicker: createMockPicker("github-picker"),
42+
TwitterPicker: createMockPicker("twitter-picker"),
43+
CirclePicker: createMockPicker("circle-picker"),
44+
HuePicker: createMockPicker("hue-picker"),
45+
SliderPicker: createMockPicker("slider-picker"),
46+
CompactPicker: createMockPicker("compact-picker"),
47+
MaterialPicker: createMockPicker("material-picker"),
48+
SwatchesPicker: createMockPicker("swatches-picker")
49+
};
50+
});
51+
52+
describe("ColorPicker – debounced onChange behavior", () => {
53+
/**
54+
* color="#FF0000" matches the hex value emitted by the mock picker so that
55+
* the guard in onChangeComplete (`currentColor.current === parseColor(color, format)`)
56+
* passes and completeColorChange is actually scheduled.
57+
*/
58+
const baseProps: ColorPickerProps = {
59+
id: "color-picker",
60+
name: "color picker",
61+
color: "#FF0000",
62+
disabled: false,
63+
defaultColors: [],
64+
format: "hex",
65+
mode: "inline",
66+
type: "sketch",
67+
onChange: jest.fn(),
68+
onColorChange: jest.fn()
69+
};
70+
71+
let user: UserEvent;
72+
73+
beforeEach(() => {
74+
jest.useFakeTimers();
75+
// advanceTimers keeps userEvent in sync with fake timers
76+
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
77+
baseProps.onChange = jest.fn();
78+
baseProps.onColorChange = jest.fn();
79+
});
80+
81+
afterEach(() => {
82+
jest.useRealTimers();
83+
});
84+
85+
it("does not call onChange immediately after onChangeComplete fires", async () => {
86+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
87+
88+
// onChange sets currentColor.current so the guard in onChangeComplete passes
89+
await user.click(getByTestId("trigger-change"));
90+
await user.click(getByTestId("trigger-change-complete"));
91+
92+
// Debounce delay has not elapsed yet
93+
expect(baseProps.onChange).not.toHaveBeenCalled();
94+
});
95+
96+
it("calls onChange once after the 500 ms debounce delay", async () => {
97+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
98+
99+
await user.click(getByTestId("trigger-change"));
100+
await user.click(getByTestId("trigger-change-complete"));
101+
102+
expect(baseProps.onChange).not.toHaveBeenCalled();
103+
104+
jest.advanceTimersByTime(500);
105+
106+
expect(baseProps.onChange).toHaveBeenCalledTimes(1);
107+
});
108+
109+
it("calls onChange only once when multiple rapid color changes occur", async () => {
110+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
111+
112+
// Each iteration: onChange aborts the previous pending debounce and
113+
// onChangeComplete schedules a new one. Only the last scheduled call
114+
// survives.
115+
for (let i = 0; i < 5; i++) {
116+
await user.click(getByTestId("trigger-change"));
117+
await user.click(getByTestId("trigger-change-complete"));
118+
// Advance less than the debounce window so the timer never fires mid-loop
119+
jest.advanceTimersByTime(100);
120+
}
121+
122+
// Let the last pending debounce fire
123+
jest.advanceTimersByTime(500);
124+
125+
expect(baseProps.onChange).toHaveBeenCalledTimes(1);
126+
});
127+
128+
it("does not call onChange if abortCompleteColorChange is called before the delay elapses", async () => {
129+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
130+
131+
// Schedule a completeColorChange via onChangeComplete
132+
await user.click(getByTestId("trigger-change"));
133+
await user.click(getByTestId("trigger-change-complete"));
134+
135+
// Advance partway through the debounce window
136+
jest.advanceTimersByTime(200);
137+
138+
// Triggering onChange calls submitColor → abortCompleteColorChange,
139+
// cancelling the previously scheduled debounce
140+
await user.click(getByTestId("trigger-change"));
141+
142+
// Advance past the original debounce deadline
143+
jest.advanceTimersByTime(500);
144+
145+
expect(baseProps.onChange).not.toHaveBeenCalled();
146+
});
147+
});

0 commit comments

Comments
 (0)