@appthrust/kest 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/ts/matchers/to-match-unordered.ts +177 -45
package/package.json
CHANGED
|
@@ -1,40 +1,65 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { expect, type MatcherResult } from "bun:test";
|
|
2
|
+
import { stripAnsi } from "../reporter/markdown/strip-ansi";
|
|
3
|
+
|
|
4
|
+
type EqualsFunction = (a: unknown, b: unknown) => boolean;
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Deep partial match: checks that every key in `expected` exists in `actual`
|
|
5
8
|
* with a matching value. For nested objects, recurses. For nested arrays,
|
|
6
9
|
* checks index-by-index with ordered semantics.
|
|
10
|
+
*
|
|
11
|
+
* When an `equals` function is provided (from the matcher context), it is
|
|
12
|
+
* called first for every comparison. This lets Bun's native equality handle
|
|
13
|
+
* asymmetric matchers (`expect.stringMatching`, `expect.any`, etc.)
|
|
14
|
+
* transparently — no special detection needed.
|
|
7
15
|
*/
|
|
8
|
-
export function deepPartialMatch(
|
|
16
|
+
export function deepPartialMatch(
|
|
17
|
+
actual: unknown,
|
|
18
|
+
expected: unknown,
|
|
19
|
+
equals?: EqualsFunction
|
|
20
|
+
): boolean {
|
|
9
21
|
if (Object.is(actual, expected)) {
|
|
10
22
|
return true;
|
|
11
23
|
}
|
|
12
24
|
|
|
25
|
+
// Delegate to Bun's equals — handles asymmetric matchers and exact matches
|
|
26
|
+
if (equals?.(actual, expected)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
if (expected === null || actual === null) {
|
|
14
|
-
return
|
|
31
|
+
return false;
|
|
15
32
|
}
|
|
16
33
|
|
|
17
34
|
if (typeof expected !== "object" || typeof actual !== "object") {
|
|
18
|
-
return
|
|
35
|
+
return false;
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
if (Array.isArray(expected)) {
|
|
22
|
-
return deepPartialMatchArrays(actual, expected);
|
|
39
|
+
return deepPartialMatchArrays(actual, expected, equals);
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
if (Array.isArray(actual)) {
|
|
26
43
|
return false;
|
|
27
44
|
}
|
|
28
45
|
|
|
46
|
+
// Only partial-match plain objects; for non-plain objects (Bun asymmetric
|
|
47
|
+
// matchers, Date, RegExp, etc.) the equals() check above is authoritative.
|
|
48
|
+
if (Object.getPrototypeOf(expected) !== Object.prototype) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
return deepPartialMatchObjects(
|
|
30
53
|
actual as Record<string, unknown>,
|
|
31
|
-
expected as Record<string, unknown
|
|
54
|
+
expected as Record<string, unknown>,
|
|
55
|
+
equals
|
|
32
56
|
);
|
|
33
57
|
}
|
|
34
58
|
|
|
35
59
|
function deepPartialMatchArrays(
|
|
36
60
|
actual: unknown,
|
|
37
|
-
expected: Array<unknown
|
|
61
|
+
expected: Array<unknown>,
|
|
62
|
+
equals?: EqualsFunction
|
|
38
63
|
): boolean {
|
|
39
64
|
if (!Array.isArray(actual)) {
|
|
40
65
|
return false;
|
|
@@ -42,51 +67,58 @@ function deepPartialMatchArrays(
|
|
|
42
67
|
if (actual.length !== expected.length) {
|
|
43
68
|
return false;
|
|
44
69
|
}
|
|
45
|
-
return expected.every((item, i) => deepPartialMatch(actual[i], item));
|
|
70
|
+
return expected.every((item, i) => deepPartialMatch(actual[i], item, equals));
|
|
46
71
|
}
|
|
47
72
|
|
|
48
73
|
function deepPartialMatchObjects(
|
|
49
74
|
actual: Record<string, unknown>,
|
|
50
|
-
expected: Record<string, unknown
|
|
75
|
+
expected: Record<string, unknown>,
|
|
76
|
+
equals?: EqualsFunction
|
|
51
77
|
): boolean {
|
|
52
78
|
for (const key of Object.keys(expected)) {
|
|
53
79
|
if (!(key in actual)) {
|
|
54
80
|
return false;
|
|
55
81
|
}
|
|
56
|
-
if (!deepPartialMatch(actual[key], expected[key])) {
|
|
82
|
+
if (!deepPartialMatch(actual[key], expected[key], equals)) {
|
|
57
83
|
return false;
|
|
58
84
|
}
|
|
59
85
|
}
|
|
60
86
|
return true;
|
|
61
87
|
}
|
|
62
88
|
|
|
89
|
+
interface MatchResult {
|
|
90
|
+
pass: boolean;
|
|
91
|
+
/** Maps each expected index to its matched actual index (or -1 if unmatched). */
|
|
92
|
+
pairing: Array<number>;
|
|
93
|
+
unmatchedActual: Array<number>;
|
|
94
|
+
}
|
|
95
|
+
|
|
63
96
|
/**
|
|
64
97
|
* Find a one-to-one matching between expected and actual items using
|
|
65
|
-
* deep partial match semantics. Returns
|
|
66
|
-
*
|
|
98
|
+
* deep partial match semantics. Returns a pairing array and unmatched
|
|
99
|
+
* actual indices.
|
|
67
100
|
*/
|
|
68
|
-
function
|
|
101
|
+
function findMatching(
|
|
69
102
|
actual: ReadonlyArray<unknown>,
|
|
70
|
-
expected: ReadonlyArray<unknown
|
|
71
|
-
|
|
103
|
+
expected: ReadonlyArray<unknown>,
|
|
104
|
+
equals?: EqualsFunction
|
|
105
|
+
): MatchResult {
|
|
72
106
|
const usedActual = new Set<number>();
|
|
73
|
-
const
|
|
107
|
+
const pairing: Array<number> = [];
|
|
74
108
|
|
|
75
|
-
for (
|
|
76
|
-
let
|
|
109
|
+
for (const [, expectedItem] of expected.entries()) {
|
|
110
|
+
let matched = -1;
|
|
77
111
|
for (let ai = 0; ai < actual.length; ai++) {
|
|
78
112
|
if (usedActual.has(ai)) {
|
|
79
113
|
continue;
|
|
80
114
|
}
|
|
81
|
-
if (deepPartialMatch(actual[ai],
|
|
115
|
+
if (deepPartialMatch(actual[ai], expectedItem, equals)) {
|
|
82
116
|
usedActual.add(ai);
|
|
83
|
-
|
|
117
|
+
matched = ai;
|
|
84
118
|
break;
|
|
85
119
|
}
|
|
86
120
|
}
|
|
87
|
-
|
|
88
|
-
unmatchedExpected.push(ei);
|
|
89
|
-
}
|
|
121
|
+
pairing.push(matched);
|
|
90
122
|
}
|
|
91
123
|
|
|
92
124
|
const unmatchedActual: Array<number> = [];
|
|
@@ -96,45 +128,145 @@ function findUnmatched(
|
|
|
96
128
|
}
|
|
97
129
|
}
|
|
98
130
|
|
|
99
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
pass: pairing.every((ai) => ai !== -1),
|
|
133
|
+
pairing,
|
|
134
|
+
unmatchedActual,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Score how well an actual item matches an expected item by counting
|
|
140
|
+
* top-level keys in expected that have a matching value in actual.
|
|
141
|
+
*/
|
|
142
|
+
function matchScore(
|
|
143
|
+
actual: unknown,
|
|
144
|
+
expected: unknown,
|
|
145
|
+
equals?: EqualsFunction
|
|
146
|
+
): number {
|
|
147
|
+
if (
|
|
148
|
+
typeof expected !== "object" ||
|
|
149
|
+
expected === null ||
|
|
150
|
+
typeof actual !== "object" ||
|
|
151
|
+
actual === null ||
|
|
152
|
+
Array.isArray(expected) ||
|
|
153
|
+
Array.isArray(actual)
|
|
154
|
+
) {
|
|
155
|
+
return deepPartialMatch(actual, expected, equals) ? 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
const expectedObj = expected as Record<string, unknown>;
|
|
158
|
+
const actualObj = actual as Record<string, unknown>;
|
|
159
|
+
let score = 0;
|
|
160
|
+
for (const key of Object.keys(expectedObj)) {
|
|
161
|
+
if (
|
|
162
|
+
key in actualObj &&
|
|
163
|
+
deepPartialMatch(actualObj[key], expectedObj[key], equals)
|
|
164
|
+
) {
|
|
165
|
+
score++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return score;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Find the closest actual item for an expected item by scoring all
|
|
173
|
+
* candidates and returning the one with the highest match score.
|
|
174
|
+
*/
|
|
175
|
+
function findClosestActual(
|
|
176
|
+
expectedItem: unknown,
|
|
177
|
+
candidates: ReadonlyArray<unknown>,
|
|
178
|
+
equals?: EqualsFunction
|
|
179
|
+
): unknown {
|
|
180
|
+
let bestScore = -1;
|
|
181
|
+
let bestCandidate: unknown = candidates[0];
|
|
182
|
+
for (const candidate of candidates) {
|
|
183
|
+
const score = matchScore(candidate, expectedItem, equals);
|
|
184
|
+
if (score > bestScore) {
|
|
185
|
+
bestScore = score;
|
|
186
|
+
bestCandidate = candidate;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return bestCandidate;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a reordered actual array aligned with expected. Matched items
|
|
194
|
+
* keep their paired actual; unmatched expected items get their closest
|
|
195
|
+
* candidate from the unmatched actuals.
|
|
196
|
+
*/
|
|
197
|
+
function buildReorderedActual(
|
|
198
|
+
actual: ReadonlyArray<unknown>,
|
|
199
|
+
expected: ReadonlyArray<unknown>,
|
|
200
|
+
pairing: ReadonlyArray<number>,
|
|
201
|
+
unmatchedActual: ReadonlyArray<number>,
|
|
202
|
+
equals?: EqualsFunction
|
|
203
|
+
): Array<unknown> {
|
|
204
|
+
const unmatchedActualItems = unmatchedActual.map((i) => actual[i]);
|
|
205
|
+
const reordered: Array<unknown> = [];
|
|
206
|
+
|
|
207
|
+
for (let ei = 0; ei < expected.length; ei++) {
|
|
208
|
+
const paired = pairing[ei] ?? -1;
|
|
209
|
+
if (paired !== -1) {
|
|
210
|
+
reordered.push(actual[paired]);
|
|
211
|
+
} else if (unmatchedActualItems.length > 0) {
|
|
212
|
+
reordered.push(
|
|
213
|
+
findClosestActual(expected[ei], unmatchedActualItems, equals)
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
reordered.push(undefined);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return reordered;
|
|
100
221
|
}
|
|
101
222
|
|
|
102
223
|
export function toMatchUnordered(
|
|
103
|
-
this: {
|
|
224
|
+
this: {
|
|
225
|
+
isNot: boolean;
|
|
226
|
+
equals: EqualsFunction;
|
|
227
|
+
utils: { stringify(v: unknown): string };
|
|
228
|
+
},
|
|
104
229
|
actual: unknown,
|
|
105
|
-
expected: unknown
|
|
230
|
+
expected: ReadonlyArray<unknown>
|
|
106
231
|
): MatcherResult {
|
|
107
232
|
if (!Array.isArray(actual)) {
|
|
108
|
-
return {
|
|
109
|
-
pass: false,
|
|
110
|
-
message: () => `expected value to be an array, but got ${typeof actual}`,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (!Array.isArray(expected)) {
|
|
115
233
|
return {
|
|
116
234
|
pass: false,
|
|
117
235
|
message: () =>
|
|
118
|
-
`expected
|
|
236
|
+
`expect(received).toMatchUnordered(expected)\n\nReceived value must be an array, but got ${typeof actual}`,
|
|
119
237
|
};
|
|
120
238
|
}
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const pass
|
|
239
|
+
const actualArray: ReadonlyArray<unknown> = actual;
|
|
240
|
+
const equals = this.equals.bind(this);
|
|
241
|
+
const { pass, pairing, unmatchedActual } = findMatching(
|
|
242
|
+
actualArray,
|
|
243
|
+
expected,
|
|
244
|
+
equals
|
|
245
|
+
);
|
|
124
246
|
|
|
125
247
|
const message = (): string => {
|
|
126
248
|
if (this.isNot) {
|
|
127
249
|
return "expected arrays not to match (in any order), but every expected item had a match";
|
|
128
250
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
251
|
+
|
|
252
|
+
const reordered = buildReorderedActual(
|
|
253
|
+
actualArray,
|
|
254
|
+
expected,
|
|
255
|
+
pairing,
|
|
256
|
+
unmatchedActual,
|
|
257
|
+
equals
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// biome-ignore lint: expect is used to generate diff output, not as a test assertion
|
|
262
|
+
expect(reordered).toMatchObject(expected as Array<unknown>);
|
|
263
|
+
return "expected arrays to match (in any order), but some items did not match";
|
|
264
|
+
} catch (e: unknown) {
|
|
265
|
+
return stripAnsi((e as Error).message).replace(
|
|
266
|
+
"expect(received).toMatchObject(expected)",
|
|
267
|
+
"expect(received).toMatchUnordered(expected)"
|
|
268
|
+
);
|
|
136
269
|
}
|
|
137
|
-
return lines.join("\n");
|
|
138
270
|
};
|
|
139
271
|
|
|
140
272
|
return { pass, message };
|