👁️
1import { beforeAll, describe, expect, it } from "vitest";
2import {
3 setupTestCards,
4 type TestCardLookup,
5} from "../../__tests__/test-card-lookup";
6import { search } from "../index";
7
8describe("Scryfall search edge cases", () => {
9 let cards: TestCardLookup;
10
11 beforeAll(async () => {
12 cards = await setupTestCards();
13 }, 30_000);
14
15 describe("power/toughness edge cases", () => {
16 it("matches * power exactly", async () => {
17 const tarmogoyf = await cards.get("Tarmogoyf");
18 expect(tarmogoyf.power).toBe("*");
19
20 const result = search("pow=*");
21 expect(result.ok).toBe(true);
22 if (result.ok) {
23 expect(result.value.match(tarmogoyf)).toBe(true);
24 }
25 });
26
27 it("matches 1+* toughness (contains *)", async () => {
28 const tarmogoyf = await cards.get("Tarmogoyf");
29 expect(tarmogoyf.toughness).toBe("1+*");
30
31 const result = search("tou=*");
32 expect(result.ok).toBe(true);
33 if (result.ok) {
34 // Should match because toughness contains *
35 expect(result.value.match(tarmogoyf)).toBe(true);
36 }
37 });
38
39 it("treats * as 0 for numeric comparisons", async () => {
40 const tarmogoyf = await cards.get("Tarmogoyf");
41
42 const result = search("pow<=0");
43 expect(result.ok).toBe(true);
44 if (result.ok) {
45 // * is treated as 0
46 expect(result.value.match(tarmogoyf)).toBe(true);
47 }
48
49 const gtZero = search("pow>0");
50 expect(gtZero.ok).toBe(true);
51 if (gtZero.ok) {
52 expect(gtZero.value.match(tarmogoyf)).toBe(false);
53 }
54 });
55
56 it("handles fractional power/toughness", async () => {
57 const littleGirl = await cards.get("Little Girl");
58 // Scryfall stores ".5" not "0.5"
59 expect(littleGirl.power).toBe(".5");
60 expect(littleGirl.toughness).toBe(".5");
61
62 // parseFloat handles ".5" correctly as 0.5
63 const exactHalf = search("pow=0.5");
64 expect(exactHalf.ok).toBe(true);
65 if (exactHalf.ok) {
66 expect(exactHalf.value.match(littleGirl)).toBe(true);
67 }
68
69 // Comparison with decimal
70 const ltOne = search("pow<1");
71 expect(ltOne.ok).toBe(true);
72 if (ltOne.ok) {
73 expect(ltOne.value.match(littleGirl)).toBe(true);
74 }
75
76 const gtZero = search("pow>0");
77 expect(gtZero.ok).toBe(true);
78 if (gtZero.ok) {
79 expect(gtZero.value.match(littleGirl)).toBe(true);
80 }
81 });
82 });
83
84 describe("mana value edge cases", () => {
85 it("handles fractional CMC", async () => {
86 const littleGirl = await cards.get("Little Girl");
87 expect(littleGirl.cmc).toBe(0.5);
88
89 const exactHalf = search("cmc=0.5");
90 expect(exactHalf.ok).toBe(true);
91 if (exactHalf.ok) {
92 expect(exactHalf.value.match(littleGirl)).toBe(true);
93 }
94
95 const ltOne = search("cmc<1");
96 expect(ltOne.ok).toBe(true);
97 if (ltOne.ok) {
98 expect(ltOne.value.match(littleGirl)).toBe(true);
99 }
100 });
101
102 it("handles X spells (X doesn't add to CMC)", async () => {
103 const fireball = await cards.get("Fireball");
104 expect(fireball.mana_cost).toContain("X");
105
106 // Fireball is {X}{R}, CMC = 1
107 const cmcOne = search("cmc=1");
108 expect(cmcOne.ok).toBe(true);
109 if (cmcOne.ok) {
110 expect(cmcOne.value.match(fireball)).toBe(true);
111 }
112 });
113 });
114
115 describe("mana cost matching", () => {
116 it("m: matches mana cost substring", async () => {
117 const apostle = await cards.get("Apostle's Blessing");
118 expect(apostle.mana_cost).toBe("{1}{W/P}");
119
120 // Phyrexian mana symbol matching - braces included like Scryfall
121 const result = search("m:{W/P}");
122 expect(result.ok).toBe(true);
123 if (result.ok) {
124 expect(result.value.match(apostle)).toBe(true);
125 }
126 });
127
128 it("m: matches snow mana", async () => {
129 const astrolabe = await cards.get("Arcum's Astrolabe");
130 expect(astrolabe.mana_cost).toBe("{S}");
131
132 const result = search("m:S");
133 expect(result.ok).toBe(true);
134 if (result.ok) {
135 expect(result.value.match(astrolabe)).toBe(true);
136 }
137 });
138
139 it("m: matches X in cost", async () => {
140 const fireball = await cards.get("Fireball");
141
142 const result = search("m:X");
143 expect(result.ok).toBe(true);
144 if (result.ok) {
145 expect(result.value.match(fireball)).toBe(true);
146 }
147 });
148 });
149
150 describe("is:snow predicate", () => {
151 it("matches snow permanents", async () => {
152 const snowForest = await cards.get("Snow-Covered Forest");
153 expect(snowForest.type_line).toContain("Snow");
154
155 const result = search("is:snow");
156 expect(result.ok).toBe(true);
157 if (result.ok) {
158 expect(result.value.match(snowForest)).toBe(true);
159 }
160 });
161
162 it("does not match non-snow cards", async () => {
163 const bolt = await cards.get("Lightning Bolt");
164
165 const result = search("is:snow");
166 expect(result.ok).toBe(true);
167 if (result.ok) {
168 expect(result.value.match(bolt)).toBe(false);
169 }
170 });
171 });
172
173 describe("colorless identity", () => {
174 it("id:c matches colorless identity cards", async () => {
175 const ornithopter = await cards.get("Ornithopter");
176 expect(ornithopter.color_identity).toEqual([]);
177
178 const result = search("id:c");
179 expect(result.ok).toBe(true);
180 if (result.ok) {
181 expect(result.value.match(ornithopter)).toBe(true);
182 }
183 });
184
185 it("id<=c matches only colorless cards", async () => {
186 const ornithopter = await cards.get("Ornithopter");
187 const bolt = await cards.get("Lightning Bolt");
188
189 const result = search("id<=c");
190 expect(result.ok).toBe(true);
191 if (result.ok) {
192 expect(result.value.match(ornithopter)).toBe(true);
193 expect(result.value.match(bolt)).toBe(false);
194 }
195 });
196 });
197
198 describe("is:historic predicate", () => {
199 it("matches legendary permanents", async () => {
200 const bosh = await cards.get("Bosh, Iron Golem");
201 expect(bosh.type_line).toContain("Legendary");
202
203 const result = search("is:historic");
204 expect(result.ok).toBe(true);
205 if (result.ok) {
206 expect(result.value.match(bosh)).toBe(true);
207 }
208 });
209
210 it("matches artifacts", async () => {
211 const solRing = await cards.get("Sol Ring");
212 expect(solRing.type_line).toContain("Artifact");
213
214 const result = search("is:historic");
215 expect(result.ok).toBe(true);
216 if (result.ok) {
217 expect(result.value.match(solRing)).toBe(true);
218 }
219 });
220
221 it("does not match non-historic cards", async () => {
222 const bolt = await cards.get("Lightning Bolt");
223
224 const result = search("is:historic");
225 expect(result.ok).toBe(true);
226 if (result.ok) {
227 expect(result.value.match(bolt)).toBe(false);
228 }
229 });
230 });
231
232 describe("complex mana and color queries", () => {
233 it("Phyrexian mana cards are in the color's identity", async () => {
234 const apostle = await cards.get("Apostle's Blessing");
235 // W/P adds W to color identity even though it can be paid with life
236 expect(apostle.color_identity).toContain("W");
237
238 const whiteId = search("id:w");
239 expect(whiteId.ok).toBe(true);
240 if (whiteId.ok) {
241 expect(whiteId.value.match(apostle)).toBe(true);
242 }
243 });
244 });
245
246 describe("loyalty matching", () => {
247 it("matches planeswalker loyalty", async () => {
248 const bolas = await cards.get("Nicol Bolas, Planeswalker");
249 expect(bolas.loyalty).toBeDefined();
250
251 const result = search("loy>=5");
252 expect(result.ok).toBe(true);
253 if (result.ok) {
254 expect(result.value.match(bolas)).toBe(true);
255 }
256 });
257
258 it("non-planeswalkers have no loyalty", async () => {
259 const bolt = await cards.get("Lightning Bolt");
260
261 const result = search("loy>0");
262 expect(result.ok).toBe(true);
263 if (result.ok) {
264 expect(result.value.match(bolt)).toBe(false);
265 }
266 });
267 });
268
269 describe("multi-face cards", () => {
270 it("searches oracle text across faces", async () => {
271 const delver = await cards.get("Delver of Secrets");
272 // Delver transforms into Insectile Aberration
273
274 // Should match front face
275 const front = search('o:"Look at the top card"');
276 expect(front.ok).toBe(true);
277 if (front.ok) {
278 expect(front.value.match(delver)).toBe(true);
279 }
280 });
281
282 it("is:transform matches transform cards", async () => {
283 const delver = await cards.get("Delver of Secrets");
284 const bolt = await cards.get("Lightning Bolt");
285
286 const result = search("is:transform");
287 expect(result.ok).toBe(true);
288 if (result.ok) {
289 expect(result.value.match(delver)).toBe(true);
290 expect(result.value.match(bolt)).toBe(false);
291 }
292 });
293 });
294
295 describe("regex edge cases", () => {
296 it("regex at start of query works", async () => {
297 const bolt = await cards.get("Lightning Bolt");
298
299 const result = search("/^lightning/i");
300 expect(result.ok).toBe(true);
301 if (result.ok) {
302 expect(result.value.match(bolt)).toBe(true);
303 }
304 });
305
306 it("regex after field works", async () => {
307 const bolt = await cards.get("Lightning Bolt");
308
309 const result = search("o:/\\d+ damage/");
310 expect(result.ok).toBe(true);
311 if (result.ok) {
312 expect(result.value.match(bolt)).toBe(true);
313 }
314 });
315
316 it("invalid regex returns error", () => {
317 const result = search("/[invalid/");
318 expect(result.ok).toBe(false);
319 if (!result.ok) {
320 expect(result.error.message).toContain("Invalid regex");
321 }
322 });
323 });
324
325 describe("produces: mana production", () => {
326 it("matches cards that produce mana", async () => {
327 const solRing = await cards.get("Sol Ring");
328 expect(solRing.produced_mana).toContain("C");
329
330 const result = search("produces:c");
331 expect(result.ok).toBe(true);
332 if (result.ok) {
333 expect(result.value.match(solRing)).toBe(true);
334 }
335 });
336
337 it("non-mana-producers don't match", async () => {
338 const bolt = await cards.get("Lightning Bolt");
339
340 const result = search("produces:r");
341 expect(result.ok).toBe(true);
342 if (result.ok) {
343 expect(result.value.match(bolt)).toBe(false);
344 }
345 });
346 });
347});