at dev 321 lines 8.6 kB view raw
1import fc from "fast-check"; 2import { describe, expect, it } from "vitest"; 3import { parse } from "../parser"; 4import type { SearchNode } from "../types"; 5 6describe("parse", () => { 7 function expectParse(input: string): SearchNode { 8 const result = parse(input); 9 expect(result.ok).toBe(true); 10 if (!result.ok) throw new Error(result.error.message); 11 return result.value; 12 } 13 14 function expectParseError(input: string): string { 15 const result = parse(input); 16 expect(result.ok).toBe(false); 17 if (result.ok) throw new Error("Expected parse error"); 18 return result.error.message; 19 } 20 21 describe("name expressions", () => { 22 it("parses bare word as name", () => { 23 const node = expectParse("bolt"); 24 expect(node.type).toBe("NAME"); 25 if (node.type === "NAME") { 26 expect(node.value).toBe("bolt"); 27 expect(node.pattern).toBeNull(); 28 } 29 }); 30 31 it("parses quoted string as name", () => { 32 const node = expectParse('"Lightning Bolt"'); 33 expect(node.type).toBe("NAME"); 34 if (node.type === "NAME") { 35 expect(node.value).toBe("Lightning Bolt"); 36 } 37 }); 38 39 it("parses exact name with !", () => { 40 const node = expectParse("!Lightning"); 41 expect(node.type).toBe("EXACT_NAME"); 42 if (node.type === "EXACT_NAME") { 43 expect(node.value).toBe("Lightning"); 44 } 45 }); 46 47 it("parses regex as name", () => { 48 const node = expectParse("/bolt$/i"); 49 expect(node.type).toBe("NAME"); 50 if (node.type === "NAME") { 51 expect(node.pattern).toBeInstanceOf(RegExp); 52 expect(node.pattern?.test("Lightning Bolt")).toBe(true); 53 } 54 }); 55 }); 56 57 describe("field expressions", () => { 58 it("parses type field", () => { 59 const node = expectParse("t:creature"); 60 expect(node.type).toBe("FIELD"); 61 if (node.type === "FIELD") { 62 expect(node.field).toBe("type"); 63 expect(node.operator).toBe(":"); 64 expect(node.value).toEqual({ kind: "string", value: "creature" }); 65 } 66 }); 67 68 it("parses oracle field", () => { 69 const node = expectParse("o:flying"); 70 expect(node.type).toBe("FIELD"); 71 if (node.type === "FIELD") { 72 expect(node.field).toBe("oracle"); 73 } 74 }); 75 76 it("parses color field as colors", () => { 77 const node = expectParse("c:urg"); 78 expect(node.type).toBe("FIELD"); 79 if (node.type === "FIELD") { 80 expect(node.field).toBe("color"); 81 expect(node.value.kind).toBe("colors"); 82 if (node.value.kind === "colors") { 83 expect(node.value.colors).toEqual(new Set(["U", "R", "G"])); 84 } 85 } 86 }); 87 88 it("parses identity field", () => { 89 const node = expectParse("id<=bg"); 90 expect(node.type).toBe("FIELD"); 91 if (node.type === "FIELD") { 92 expect(node.field).toBe("identity"); 93 expect(node.operator).toBe("<="); 94 if (node.value.kind === "colors") { 95 expect(node.value.colors).toEqual(new Set(["B", "G"])); 96 } 97 } 98 }); 99 100 it("parses ci: as identity alias", () => { 101 const node = expectParse("ci:wubrg"); 102 expect(node.type).toBe("FIELD"); 103 if (node.type === "FIELD") { 104 expect(node.field).toBe("identity"); 105 } 106 }); 107 108 it("parses numeric fields", () => { 109 const node = expectParse("cmc>=3"); 110 expect(node.type).toBe("FIELD"); 111 if (node.type === "FIELD") { 112 expect(node.field).toBe("manavalue"); 113 expect(node.operator).toBe(">="); 114 expect(node.value).toEqual({ kind: "number", value: 3 }); 115 } 116 }); 117 118 it("parses power with star", () => { 119 const node = expectParse("pow=*"); 120 expect(node.type).toBe("FIELD"); 121 if (node.type === "FIELD") { 122 expect(node.value).toEqual({ kind: "string", value: "*" }); 123 } 124 }); 125 126 it("parses regex in field", () => { 127 const node = expectParse("o:/draw.*card/"); 128 expect(node.type).toBe("FIELD"); 129 if (node.type === "FIELD") { 130 expect(node.value.kind).toBe("regex"); 131 } 132 }); 133 134 it("parses format field", () => { 135 const node = expectParse("f:commander"); 136 expect(node.type).toBe("FIELD"); 137 if (node.type === "FIELD") { 138 expect(node.field).toBe("format"); 139 expect(node.value).toEqual({ kind: "string", value: "commander" }); 140 } 141 }); 142 }); 143 144 describe("boolean operators", () => { 145 it("parses implicit AND", () => { 146 const node = expectParse("t:creature c:g"); 147 expect(node.type).toBe("AND"); 148 if (node.type === "AND") { 149 expect(node.children).toHaveLength(2); 150 } 151 }); 152 153 it("parses explicit OR", () => { 154 const node = expectParse("t:creature or t:artifact"); 155 expect(node.type).toBe("OR"); 156 if (node.type === "OR") { 157 expect(node.children).toHaveLength(2); 158 } 159 }); 160 161 it("parses NOT", () => { 162 const node = expectParse("-t:creature"); 163 expect(node.type).toBe("NOT"); 164 if (node.type === "NOT") { 165 expect(node.child.type).toBe("FIELD"); 166 } 167 }); 168 169 it("parses parentheses", () => { 170 const node = expectParse("(t:creature or t:artifact) c:r"); 171 expect(node.type).toBe("AND"); 172 if (node.type === "AND") { 173 expect(node.children[0].type).toBe("OR"); 174 } 175 }); 176 177 it("NOT binds tighter than AND", () => { 178 const node = expectParse("-t:creature c:g"); 179 expect(node.type).toBe("AND"); 180 if (node.type === "AND") { 181 expect(node.children[0].type).toBe("NOT"); 182 } 183 }); 184 185 it("AND binds tighter than OR", () => { 186 const node = expectParse("a b or c d"); 187 expect(node.type).toBe("OR"); 188 if (node.type === "OR") { 189 expect(node.children).toHaveLength(2); 190 expect(node.children[0].type).toBe("AND"); 191 expect(node.children[1].type).toBe("AND"); 192 } 193 }); 194 }); 195 196 describe("complex queries", () => { 197 it("parses commander deckbuilding query", () => { 198 const node = expectParse("id<=bg t:creature cmc<=3"); 199 expect(node.type).toBe("AND"); 200 if (node.type === "AND") { 201 expect(node.children).toHaveLength(3); 202 } 203 }); 204 205 it("parses nested groups", () => { 206 const node = expectParse("((a or b) (c or d))"); 207 expect(node.type).toBe("AND"); 208 }); 209 210 it("parses word that looks like field but isnt", () => { 211 // "is" without : should be treated as name 212 const node = expectParse("is cool"); 213 expect(node.type).toBe("AND"); 214 if (node.type === "AND") { 215 expect(node.children[0].type).toBe("NAME"); 216 expect(node.children[1].type).toBe("NAME"); 217 } 218 }); 219 }); 220 221 describe("error handling", () => { 222 it("errors on empty query", () => { 223 const msg = expectParseError(""); 224 expect(msg).toContain("Empty"); 225 }); 226 227 it("errors on unmatched paren", () => { 228 const msg = expectParseError("(foo"); 229 expect(msg).toContain("parenthesis"); 230 }); 231 232 it("errors on trailing garbage", () => { 233 const msg = expectParseError("foo )"); 234 expect(msg).toContain("Unexpected"); 235 }); 236 }); 237 238 describe("span tracking", () => { 239 it("tracks span for simple term", () => { 240 const node = expectParse("bolt"); 241 expect(node.span).toEqual({ start: 0, end: 4 }); 242 }); 243 244 it("tracks span for field expression", () => { 245 const node = expectParse("t:creature"); 246 expect(node.span).toEqual({ start: 0, end: 10 }); 247 }); 248 249 it("tracks span for AND expression", () => { 250 const node = expectParse("foo bar"); 251 expect(node.span).toEqual({ start: 0, end: 7 }); 252 }); 253 }); 254 255 describe("property tests", () => { 256 const wordArb = fc 257 .stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,8}$/) 258 .filter((w) => w.toLowerCase() !== "or"); 259 const fieldArb = fc.constantFrom("t", "o", "c", "cmc", "pow"); 260 const opArb = fc.constantFrom(":", "=", ">=", "<=", ">", "<"); 261 262 it("parses any valid field expression", () => { 263 fc.assert( 264 fc.property(fieldArb, opArb, wordArb, (field, op, value) => { 265 const result = parse(`${field}${op}${value}`); 266 expect(result.ok).toBe(true); 267 }), 268 { numRuns: 500 }, 269 ); 270 }); 271 272 it("parses any sequence of words", () => { 273 fc.assert( 274 fc.property( 275 fc.array(wordArb, { minLength: 1, maxLength: 5 }), 276 (words) => { 277 const result = parse(words.join(" ")); 278 expect(result.ok).toBe(true); 279 }, 280 ), 281 { numRuns: 500 }, 282 ); 283 }); 284 285 it("parses OR combinations", () => { 286 fc.assert( 287 fc.property(wordArb, wordArb, (a, b) => { 288 const result = parse(`${a} or ${b}`); 289 expect(result.ok).toBe(true); 290 if (result.ok) { 291 expect(result.value.type).toBe("OR"); 292 } 293 }), 294 { numRuns: 500 }, 295 ); 296 }); 297 298 it("parses NOT expressions", () => { 299 fc.assert( 300 fc.property(wordArb, (word) => { 301 const result = parse(`-${word}`); 302 expect(result.ok).toBe(true); 303 if (result.ok) { 304 expect(result.value.type).toBe("NOT"); 305 } 306 }), 307 { numRuns: 500 }, 308 ); 309 }); 310 311 it("parses grouped expressions", () => { 312 fc.assert( 313 fc.property(wordArb, wordArb, (a, b) => { 314 const result = parse(`(${a} or ${b})`); 315 expect(result.ok).toBe(true); 316 }), 317 { numRuns: 500 }, 318 ); 319 }); 320 }); 321});