Skip to content

Commit 36574f6

Browse files
authored
[ArrayAssertion] Starter (#29)
feat(arrays): Array assertion starter
1 parent 0504bee commit 36574f6

File tree

9 files changed

+755
-28
lines changed

9 files changed

+755
-28
lines changed

src/lib/ArrayAssertion.ts

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { AssertionError } from "assert";
2+
import { isDeepStrictEqual } from "util";
3+
4+
import { Assertion } from "./Assertion";
5+
import { UnsupportedOperationError } from "./errors/UnsupportedOperationError";
6+
import { expect } from "./expect";
7+
import { TypeFactory } from "./helpers/TypeFactories";
8+
9+
export class ArrayAssertion<T> extends Assertion<T[]> {
10+
11+
public constructor(actual: T[]) {
12+
super(actual);
13+
}
14+
15+
/**
16+
* Check if all the array values match the predicate
17+
*
18+
* @param matcher a generic matcher predicate
19+
* @returns the assertion instance
20+
*/
21+
public toMatchAll(matcher: (value: T) => boolean): this {
22+
const error = new AssertionError({
23+
actual: this.actual,
24+
message: "Expected all values of the array to return true on the matcher predicate"
25+
});
26+
const invertedError = new AssertionError({
27+
actual: this.actual,
28+
message: "Expected not every value of the array to return true on the matcher predicate"
29+
});
30+
31+
return this.execute({
32+
assertWhen: this.actual.every(matcher),
33+
error,
34+
invertedError
35+
});
36+
}
37+
38+
/**
39+
* Check if any of the array values match the predicate
40+
* @param matcher a matcher predicate
41+
* @returns the assertion instance
42+
*/
43+
public toMatchAny(matcher: (value: T) => boolean): this {
44+
const error = new AssertionError({
45+
actual: this.actual,
46+
message: "Expected any value of the array to return true on the matcher predicate"
47+
});
48+
const invertedError = new AssertionError({
49+
actual: this.actual,
50+
message: "Expected no value of the array to return true on the matcher predicate"
51+
});
52+
53+
return this.execute({
54+
assertWhen: this.actual.some(matcher),
55+
error,
56+
invertedError
57+
});
58+
}
59+
60+
/**
61+
* Check if all the values of the array satisfies a given assertion.
62+
*
63+
* @param consumer a consumer of the array to assert over each value of its values
64+
* @returns the assertion instance
65+
*/
66+
public toSatisfyAll(consumer: (value: T) => void): this {
67+
const tryAllValues = (): AssertionError | undefined => {
68+
try {
69+
this.actual.forEach(consumer);
70+
return undefined;
71+
} catch (error) {
72+
if (error instanceof AssertionError) {
73+
return error;
74+
}
75+
76+
throw error;
77+
}
78+
};
79+
const firstError = tryAllValues();
80+
81+
return this.execute({
82+
assertWhen: firstError === undefined,
83+
error: firstError!,
84+
invertedError: new AssertionError({
85+
actual: this.actual,
86+
message: "Expected not all values of the array to satisfy the given assertion"
87+
})
88+
});
89+
}
90+
91+
/**
92+
* Check if any value of the array satisfies the give assertion.
93+
*
94+
* @param consumer a consumer of the array to assert over each value of its values
95+
* @returns the assertion instance
96+
*/
97+
public toSatisfyAny(consumer: (value: T) => void): this {
98+
const error = new AssertionError({
99+
actual: this.actual,
100+
message: "Expected any value of the array to satisfy the given assertion"
101+
});
102+
const invertedError = new AssertionError({
103+
actual: this.actual,
104+
message: "Expected no value of the array to satisfy the given assertion"
105+
});
106+
107+
return this.execute({
108+
assertWhen: this.actual.some(value => {
109+
try {
110+
consumer(value);
111+
return true;
112+
} catch (err) {
113+
return false;
114+
}
115+
}),
116+
error,
117+
invertedError
118+
});
119+
}
120+
121+
/**
122+
* Check if the array is empty. That is, when its `length` property is zero.
123+
*
124+
* @returns the assertion instance
125+
*/
126+
public toBeEmpty(): this {
127+
const error = new AssertionError({
128+
actual: this.actual,
129+
message: "Expected array to be empty"
130+
});
131+
const invertedError = new AssertionError({
132+
actual: this.actual,
133+
message: "Expected array NOT to be empty"
134+
});
135+
136+
return this.execute({
137+
assertWhen: this.actual.length === 0,
138+
error,
139+
invertedError
140+
});
141+
}
142+
143+
/**
144+
* Check if the array has some specific number of elements.
145+
*
146+
* @param size the expected number of elements in the array
147+
* @returns the assertion instance
148+
*/
149+
public toHaveSize(size: number): this {
150+
const error = new AssertionError({
151+
actual: this.actual.length,
152+
expected: size,
153+
message: `Expected array to contain ${size} elements, but it has ${this.actual.length}`
154+
});
155+
const invertedError = new AssertionError({
156+
actual: this.actual.length,
157+
message: `Expected array NOT to contain ${size} elements, but it does`
158+
});
159+
160+
return this.execute({
161+
assertWhen: this.actual.length === size,
162+
error,
163+
invertedError
164+
});
165+
}
166+
167+
public toHaveSameMembers(expected: T[]): this {
168+
const prettyValues = `[${expected.map(value => JSON.stringify(value)).join(", ")}]`;
169+
const error = new AssertionError({
170+
actual: this.actual,
171+
expected,
172+
message: `Expected array to have the same members as: ${prettyValues}`
173+
});
174+
const invertedError = new AssertionError({
175+
actual: this.actual,
176+
message: `Expected array NOT to have the same members as: ${prettyValues}`
177+
});
178+
179+
return this.execute({
180+
assertWhen:
181+
this.actual.length === expected.length
182+
&& this.actual.every(value => expected.includes(value)),
183+
error,
184+
invertedError
185+
});
186+
}
187+
188+
/**
189+
* Check if the array contains all the passed values.
190+
*
191+
* @param values the values the array should contain
192+
* @returns the assertion instance
193+
*/
194+
public toContainAll(...values: T[]): this {
195+
const prettyValues = `[${values.map(value => JSON.stringify(value)).join(", ")}]`;
196+
const error = new AssertionError({
197+
actual: this.actual,
198+
message: `Expected array to contain the following values: ${prettyValues}`
199+
});
200+
const invertedError = new AssertionError({
201+
actual: this.actual,
202+
message: `Expected array NOT to contain the following values, but it does: ${prettyValues}`
203+
});
204+
205+
return this.execute({
206+
assertWhen: values.every(value => this.actual.includes(value)),
207+
error,
208+
invertedError
209+
});
210+
}
211+
212+
/**
213+
* Check if the array contains any of the passed values.
214+
*
215+
* @param values the value the array should include (at least one)
216+
* @returns the assertion instance
217+
*/
218+
public toContainAny(...values: T[]): this {
219+
const prettyValues = `[${values.map(value => JSON.stringify(value)).join(", ")}]`;
220+
const error = new AssertionError({
221+
actual: this.actual,
222+
message: `Expected array to contain at least one of the following values: ${prettyValues}`
223+
});
224+
const invertedError = new AssertionError({
225+
actual: this.actual,
226+
message: `Expected array NOT to contain one of the following values, but it does: ${prettyValues}`
227+
});
228+
229+
return this.execute({
230+
assertWhen: values.some(value => this.actual.includes(value)),
231+
error,
232+
invertedError
233+
});
234+
}
235+
236+
/**
237+
* Check if the array contains an specific value at an exact index.
238+
*
239+
* @param index the index of the array to find the value
240+
* @param value the expected value of the index in the array
241+
* @returns the assertion instance
242+
*/
243+
public toContainAt(index: number, value: T): this {
244+
const error = new AssertionError({
245+
actual: this.actual[index],
246+
expected: value,
247+
message: `Expected value at index ${index} of the array to be <${value}>`
248+
});
249+
const invertedError = new AssertionError({
250+
actual: this.actual[index],
251+
message: `Expected value at index ${index} of the array NOT to be <${value}>`
252+
});
253+
254+
return this.execute({
255+
assertWhen: isDeepStrictEqual(this.actual[index], value),
256+
error,
257+
invertedError
258+
});
259+
}
260+
261+
/**
262+
* Extract the value on a specific index of the array and create an assertion
263+
* instance of that specific type. This method uses {@link Assertion.asType}
264+
* internally to create the new assertion instance.
265+
*
266+
* @example
267+
* ```
268+
* expect(["foo", 2, true])
269+
* .extracting(1, TypeFactories.Number)
270+
* .toBePositive();
271+
* ```
272+
*
273+
* @param index the index of the array to extract the value
274+
* @param typeFactory a factory to assert the extracted value type and create
275+
* an assertion for it
276+
* @returns a more specific assertion based on the factory type for the value
277+
*/
278+
public extracting<S extends T, A extends Assertion<S>>(index: number, typeFactory: TypeFactory<S, A>): A {
279+
if (this.inverted) {
280+
throw new UnsupportedOperationError("The `.not` modifier is not allowed on `.extracting(..)` method");
281+
}
282+
283+
if (index >= this.actual.length) {
284+
throw new AssertionError({
285+
actual: this.actual,
286+
message: `Out of bounds! Cannot extract index ${index} from an array of ${this.actual.length} elements`
287+
});
288+
}
289+
290+
return expect(this.actual[index]).asType(typeFactory);
291+
}
292+
}

src/lib/Assertion.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AssertionError } from "assert";
22
import { isDeepStrictEqual } from "util";
33

4+
import { UnsupportedOperationError } from "./errors/UnsupportedOperationError";
45
import { isJSObject, isKeyOf } from "./helpers/guards";
56
import { TypeFactory } from "./helpers/TypeFactories";
67

@@ -73,29 +74,23 @@ export class Assertion<T> {
7374
}
7475

7576
/**
76-
* Check if the assertion passes using a generic matcher function. That is,
77-
* if the matcher function returns true, the assertion passes, otherwise it
78-
* fails.
77+
* Check if the value matches the given predicate.
7978
*
80-
* As a convenience, the matcher function recieves the actual value in its
81-
* first argument, and a boolean in its second which indicates it the logic
82-
* was inverted by the `.not` operator
83-
*
84-
* @param matcher a generic matcher function
79+
* @param matcher a matcher predicate
8580
* @returns the assertion instance
8681
*/
87-
public toMatch(matcher: (actual: T, inverted: boolean) => boolean): this {
82+
public toMatch(matcher: (actual: T) => boolean): this {
8883
const error = new AssertionError({
8984
actual: this.actual,
90-
message: "Expected matcher function to return true"
85+
message: "Expected matcher predicate to return true"
9186
});
9287
const invertedError = new AssertionError({
9388
actual: this.actual,
94-
message: "Expected matcher function NOT to return true"
89+
message: "Expected matcher predicate NOT to return true"
9590
});
9691

9792
return this.execute({
98-
assertWhen: matcher(this.actual, this.inverted),
93+
assertWhen: matcher(this.actual),
9994
error,
10095
invertedError
10196
});
@@ -341,7 +336,7 @@ export class Assertion<T> {
341336
const { Factory, predicate, typeName } = typeFactory;
342337

343338
if (this.inverted) {
344-
throw Error("Unsupported operation. The `.not` modifier is not allowed on `.asType(..)` method");
339+
throw new UnsupportedOperationError("The `.not` modifier is not allowed on `.asType(..)` method");
345340
}
346341

347342
if (predicate(this.actual)) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class UnsupportedOperationError extends Error {
2+
3+
public constructor(message?: string) {
4+
super(`Unsupported operation. ${message}`);
5+
6+
this.name = this.constructor.name;
7+
Error.captureStackTrace(this, this.constructor);
8+
}
9+
}

src/lib/expect.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ArrayAssertion } from "./ArrayAssertion";
12
import { Assertion } from "./Assertion";
23
import { BooleanAssertion } from "./BooleanAssertion";
34
import { DateAssertion } from "./DateAssertion";
@@ -10,12 +11,15 @@ import { StringAssertion } from "./StringAssertion";
1011

1112
type PromiseType<T> = T extends Promise<infer X> ? X : never;
1213

14+
type ArrayType<T> = T extends Array<infer X> ? X : never;
15+
1316
export function expect<T extends boolean>(actual: T): BooleanAssertion;
1417
export function expect<T extends number>(actual: T): NumberAssertion;
1518
export function expect<T extends string>(actual: T): StringAssertion;
1619
export function expect<T extends Date>(actual: T): DateAssertion;
1720
export function expect<T extends Promise<any>>(actual: T): PromiseAssertion<PromiseType<T>>;
1821
export function expect<T extends AnyFunction>(actual: T): FunctionAssertion<T>;
22+
export function expect<T extends any[]>(actual: T): ArrayAssertion<ArrayType<T>>;
1923
export function expect<T extends JSObject>(actual: T): ObjectAssertion<T>;
2024
export function expect<T>(actual: T): Assertion<T>;
2125
export function expect<T>(actual: T) {
@@ -25,6 +29,10 @@ export function expect<T>(actual: T) {
2529
case "string": return new StringAssertion(actual);
2630
}
2731

32+
if (Array.isArray(actual)) {
33+
return new ArrayAssertion(actual);
34+
}
35+
2836
if (actual instanceof Date) {
2937
return new DateAssertion(actual);
3038
}
@@ -37,6 +45,10 @@ export function expect<T>(actual: T) {
3745
return new FunctionAssertion(actual);
3846
}
3947

48+
if (Array.isArray(actual)) {
49+
return new ArrayAssertion(actual);
50+
}
51+
4052
if (isJSObject(actual)) {
4153
return new ObjectAssertion(actual);
4254
}

0 commit comments

Comments
 (0)