Skip to content

Commit 09f28e1

Browse files
authored
feat(dom): DOM - toHaveStyle (#154)
1 parent da792b3 commit 09f28e1

3 files changed

Lines changed: 192 additions & 5 deletions

File tree

packages/dom/src/lib/ElementAssertion.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { Assertion, AssertionError } from "@assertive-ts/core";
2+
import equal from "fast-deep-equal";
3+
4+
import { getExpectedAndReceivedStyles } from "./helpers/helpers";
25

36
export class ElementAssertion<T extends Element> extends Assertion<T> {
47

@@ -176,9 +179,42 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
176179
});
177180
}
178181

179-
private getClassList(): string[] {
180-
return this.actual.className.split(/\s+/).filter(Boolean);
181-
}
182+
/**
183+
* Asserts that the element has the specified CSS styles.
184+
*
185+
* @example
186+
* ```
187+
* expect(component).toHaveStyle({ color: 'green', display: 'block' });
188+
* ```
189+
*
190+
* @param expected the expected CSS styles.
191+
* @returns the assertion instance.
192+
*/
193+
194+
public toHaveStyle(expected: Partial<CSSStyleDeclaration>): this {
195+
196+
const [expectedStyle, receivedStyle] = getExpectedAndReceivedStyles(this.actual, expected);
197+
198+
if (!expectedStyle || !receivedStyle) {
199+
throw new Error("Currently there are no available styles.");
200+
}
201+
202+
const error = new AssertionError({
203+
actual: this.actual,
204+
expected: expectedStyle,
205+
message: `Expected the element to match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`,
206+
});
207+
const invertedError = new AssertionError({
208+
actual: this.actual,
209+
message: `Expected the element to NOT match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`,
210+
});
211+
212+
return this.execute({
213+
assertWhen: equal(expectedStyle, receivedStyle),
214+
error,
215+
invertedError,
216+
});
217+
}
182218

183219
/**
184220
* Helper method to assert the presence or absence of class names.
@@ -215,4 +251,8 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
215251
invertedError,
216252
});
217253
}
254+
255+
private getClassList(): string[] {
256+
return this.actual.className.split(/\s+/).filter(Boolean);
257+
}
218258
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
interface StyleDeclaration extends Record<string, string> {
2+
property: string;
3+
value: string;
4+
}
5+
6+
function normalizeStyles(css: Partial<CSSStyleDeclaration>): StyleDeclaration {
7+
const normalizer = document.createElement("div");
8+
document.body.appendChild(normalizer);
9+
10+
const { expectedStyle } = Object.entries(css).reduce(
11+
(acc, [property, value]) => {
12+
13+
if (typeof value !== "string") {
14+
return acc;
15+
}
16+
17+
normalizer.style.setProperty(property, value);
18+
19+
const normalizedValue = window
20+
.getComputedStyle(normalizer)
21+
.getPropertyValue(property)
22+
.trim();
23+
24+
return {
25+
expectedStyle: {
26+
...acc.expectedStyle,
27+
[property]: normalizedValue,
28+
},
29+
};
30+
},
31+
{ expectedStyle: {} as StyleDeclaration },
32+
);
33+
34+
document.body.removeChild(normalizer);
35+
36+
return expectedStyle;
37+
}
38+
39+
function getReceivedStyle (props: string[], received: CSSStyleDeclaration): StyleDeclaration {
40+
41+
return props.reduce((acc, prop) => {
42+
43+
const actualStyle = received.getPropertyValue(prop).trim();
44+
45+
return actualStyle
46+
? { ...acc, [prop]: actualStyle }
47+
: acc;
48+
49+
}, {} as StyleDeclaration);
50+
}
51+
52+
export const getExpectedAndReceivedStyles =
53+
(actual: Element, expected: Partial<CSSStyleDeclaration>): StyleDeclaration[] => {
54+
if (!actual.ownerDocument.defaultView) {
55+
throw new Error("The element is not attached to a document with a default view.");
56+
}
57+
if (!(actual instanceof HTMLElement)) {
58+
throw new Error("The element is not an HTMLElement.");
59+
}
60+
61+
const window = actual.ownerDocument.defaultView;
62+
63+
const rawElementStyles = window.getComputedStyle(actual);
64+
65+
const expectedStyle = normalizeStyles(expected);
66+
67+
const styleKeys = Object.keys(expectedStyle);
68+
69+
const elementProcessedStyle = getReceivedStyle(styleKeys, rawElementStyles);
70+
71+
return [
72+
expectedStyle,
73+
elementProcessedStyle,
74+
];
75+
};

packages/dom/test/unit/lib/ElementAssertion.test.tsx

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,8 @@ describe("[Unit] ElementAssertion.test.ts", () => {
258258
const test = new ElementAssertion(divTest);
259259

260260
expect(() => test.toHaveAllClasses("foo", "bar", "baz"))
261-
.toThrowError(AssertionError)
262-
.toHaveMessage('Expected the element to have all of these classes: "foo bar baz"');
261+
.toThrowError(AssertionError)
262+
.toHaveMessage('Expected the element to have all of these classes: "foo bar baz"');
263263

264264
expect(test.not.toHaveAllClasses("foo", "bar", "baz")).toBeEqual(test);
265265
});
@@ -296,4 +296,76 @@ describe("[Unit] ElementAssertion.test.ts", () => {
296296
});
297297
});
298298
});
299+
describe(".toHaveStyle", () => {
300+
context("when the element has the expected style", () => {
301+
it("returns the assertion instance", () => {
302+
const { getByTestId } = render(
303+
<div
304+
className="foo bar test"
305+
style={{ border: "1px solid black", color: "red", display: "flex" }}
306+
data-testid="test-div"
307+
/>);
308+
const divTest = getByTestId("test-div");
309+
const test = new ElementAssertion(divTest);
310+
311+
expect(test.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);
312+
313+
expect(() => test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" }))
314+
.toThrowError(AssertionError)
315+
.toHaveMessage(
316+
// eslint-disable-next-line max-len
317+
'Expected the element to NOT match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
318+
);
319+
});
320+
});
321+
322+
context("when the element does not have the expected style", () => {
323+
it("throws an assertion error", () => {
324+
const { getByTestId } = render(
325+
<div
326+
className="foo bar test"
327+
style={{ color: "blue", display: "block" }}
328+
data-testid="test-div"
329+
/>,
330+
);
331+
332+
const divTest = getByTestId("test-div");
333+
const test = new ElementAssertion(divTest);
334+
335+
expect(() => test.toHaveStyle(({ border: "1px solid black", color: "red", display: "flex" })))
336+
.toThrowError(AssertionError)
337+
.toHaveMessage(
338+
// eslint-disable-next-line max-len
339+
'Expected the element to match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
340+
);
341+
342+
expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);
343+
344+
});
345+
});
346+
context("when the element partially match the style", () => {
347+
it("throws an assertion error", () => {
348+
const { getByTestId } = render(
349+
<div
350+
className="foo bar test"
351+
style={{ border: "1px solid black", color: "blue", display: "block" }}
352+
data-testid="test-div"
353+
/>,
354+
);
355+
356+
const divTest = getByTestId("test-div");
357+
const test = new ElementAssertion(divTest);
358+
359+
expect(() => test.toHaveStyle(({ color: "red", display: "flex" })))
360+
.toThrowError(AssertionError)
361+
.toHaveMessage(
362+
// eslint-disable-next-line max-len
363+
'Expected the element to match the following style:\n{\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
364+
);
365+
366+
expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);
367+
368+
});
369+
});
370+
});
299371
});

0 commit comments

Comments
 (0)