👁️
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

add and handle more edge cases

+141 -32
+2
src/lib/__tests__/test-cards.json
··· 22 22 "Cormela, Glamour Thief": "3cd9bac9-0abc-43d8-9f84-94f965f7a2e0", 23 23 "Cryptolith Rite": "043f869d-b11c-4c0d-9591-2bf0df7bde55", 24 24 "Dark Ritual": "53f7c868-b03e-4fc2-8dcf-a75bbfa3272b", 25 + "Delver of Secrets": "edd531b9-f615-4399-8c8c-1c5e18c4acbf", 25 26 "Dryad Arbor": "e996cd67-739c-40f4-b276-0042acf26c71", 26 27 "Elvish Mystic": "3f3b2c10-21f8-4e13-be83-4ef3fa36e123", 27 28 "Elvish Spirit Guide": "6b0e23cf-7d68-4329-86db-7adc26abd86b", ··· 47 48 "Mana Vault": "736892cb-a34b-4bb9-b56c-e26e3db207a2", 48 49 "Misty Rainforest": "09dd85aa-47bc-4713-a9b9-8b52ff2285ed", 49 50 "Mox Diamond": "f3c5978a-70fa-431f-933b-b954bd0db0ea", 51 + "Nicol Bolas, Planeswalker": "05e9b55e-6329-48ec-b7d7-24c6b9692244", 50 52 "Ornithopter": "a3a98bc9-caa0-49b7-951c-fe4e4f54e4ba", 51 53 "Phyrexian Tower": "1861e642-21d5-4232-89f3-b5557f2946c1", 52 54 "Priest of Gix": "d93f82ce-0eed-45cc-a7b1-50fd4cbb6152",
+102
src/lib/search/__tests__/edge-cases.test.ts
··· 242 242 } 243 243 }); 244 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 + }); 245 347 });
+35 -31
src/lib/search/colors.ts
··· 61 61 /** 62 62 * Compare card colors against search colors using the given operator 63 63 * 64 - * Special handling for colorless (C): 64 + * Special handling for colorless (C) in colors/color_identity: 65 65 * - "C" in search means "colorless" = empty color set 66 66 * - Colorless cards have [] for colors/color_identity, not ["C"] 67 + * 68 + * For produced_mana, C is literal - set literalColorless=true 67 69 */ 68 70 export function compareColors( 69 71 cardColors: string[] | undefined, 70 72 searchColors: Set<string>, 71 73 operator: ComparisonOp, 74 + literalColorless = false, 72 75 ): boolean { 73 76 const cardSet = new Set(cardColors ?? []); 74 77 75 - // Searching for exactly colorless (only C in search) 76 - const isColorlessSearch = searchColors.size === 1 && searchColors.has("C"); 77 - const cardIsColorless = cardSet.size === 0; 78 + // For colors/color_identity, colorless means empty array 79 + // For produced_mana, C is literal in the array 80 + if (!literalColorless) { 81 + const isColorlessSearch = searchColors.size === 1 && searchColors.has("C"); 82 + const cardIsColorless = cardSet.size === 0; 78 83 79 - if (isColorlessSearch) { 80 - switch (operator) { 81 - case ":": 82 - case ">=": 83 - case "=": 84 - return cardIsColorless; 85 - case "!=": 86 - return !cardIsColorless; 87 - case "<=": 88 - // id<=c means "can go in colorless deck" = only colorless cards 89 - return cardIsColorless; 90 - case "<": 91 - // Strict subset of empty = impossible 92 - return false; 93 - case ">": 94 - // Strict superset of empty = has any colors 95 - return !cardIsColorless; 84 + if (isColorlessSearch) { 85 + switch (operator) { 86 + case ":": 87 + case ">=": 88 + case "=": 89 + return cardIsColorless; 90 + case "!=": 91 + return !cardIsColorless; 92 + case "<=": 93 + return cardIsColorless; 94 + case "<": 95 + return false; 96 + case ">": 97 + return !cardIsColorless; 98 + } 96 99 } 100 + 101 + // Remove C from search - it doesn't appear in colors/color_identity 102 + const normalizedSearch = new Set(searchColors); 103 + normalizedSearch.delete("C"); 104 + searchColors = normalizedSearch; 97 105 } 98 106 99 - // Remove C from search - it doesn't appear in actual card data 100 - const normalizedSearch = new Set(searchColors); 101 - normalizedSearch.delete("C"); 102 - 103 107 switch (operator) { 104 108 case ":": 105 109 case ">=": 106 - return isSuperset(cardSet, normalizedSearch); 110 + return isSuperset(cardSet, searchColors); 107 111 108 112 case "=": 109 - return setsEqual(cardSet, normalizedSearch); 113 + return setsEqual(cardSet, searchColors); 110 114 111 115 case "!=": 112 - return !setsEqual(cardSet, normalizedSearch); 116 + return !setsEqual(cardSet, searchColors); 113 117 114 118 case "<=": 115 - return isSubset(cardSet, normalizedSearch); 119 + return isSubset(cardSet, searchColors); 116 120 117 121 case "<": 118 - return isStrictSubset(cardSet, normalizedSearch); 122 + return isStrictSubset(cardSet, searchColors); 119 123 120 124 case ">": 121 - return isStrictSuperset(cardSet, normalizedSearch); 125 + return isStrictSuperset(cardSet, searchColors); 122 126 } 123 127 } 124 128
+2 -1
src/lib/search/fields.ts
··· 451 451 452 452 return (card) => { 453 453 if (!card.produced_mana) return false; 454 - return compareColors(card.produced_mana, searchColors, operator); 454 + // produced_mana has literal "C" for colorless, unlike colors/color_identity 455 + return compareColors(card.produced_mana, searchColors, operator, true); 455 456 }; 456 457 } 457 458