A wayfinder inspired map plugin for obisidian
at main 743 lines 27 kB view raw
1import { describe, it, expect } from "vitest"; 2import { parsePlaces, Place } from "../src/parser"; 3import fc from "fast-check"; 4 5// ─── Contract 1: Top-level bullets start new Place blocks ────────────── 6 7describe("Contract 1: Top-level bullets", () => { 8 it("parses a single asterisk bullet", () => { 9 const result = parsePlaces("* Sagrada Familia"); 10 expect(result).toHaveLength(1); 11 expect(result[0].name).toBe("Sagrada Familia"); 12 }); 13 14 it("parses a single dash bullet", () => { 15 const result = parsePlaces("- Sagrada Familia"); 16 expect(result).toHaveLength(1); 17 expect(result[0].name).toBe("Sagrada Familia"); 18 }); 19 20 it("parses multiple top-level bullets", () => { 21 const result = parsePlaces("* Place A\n* Place B\n* Place C"); 22 expect(result).toHaveLength(3); 23 expect(result[0].name).toBe("Place A"); 24 expect(result[1].name).toBe("Place B"); 25 expect(result[2].name).toBe("Place C"); 26 }); 27 28 it("supports mixed * and - bullet styles", () => { 29 const result = parsePlaces("* Place A\n- Place B"); 30 expect(result).toHaveLength(2); 31 expect(result[0].name).toBe("Place A"); 32 expect(result[1].name).toBe("Place B"); 33 }); 34 35 it("does not treat + as a bullet marker", () => { 36 const result = parsePlaces("+ Not a place"); 37 expect(result).toHaveLength(0); 38 }); 39 40 it("does not treat ordered lists as bullets", () => { 41 const result = parsePlaces("1. Not a place\n2. Also not"); 42 expect(result).toHaveLength(0); 43 }); 44 45 it("requires bullet at column 0", () => { 46 const result = parsePlaces(" * Indented bullet"); 47 // An indented bullet at the start (no preceding top-level bullet) is ignored 48 expect(result).toHaveLength(0); 49 }); 50}); 51 52// ─── Contract 2: Sub-bullets belong to preceding top-level bullet ────── 53 54describe("Contract 2: Sub-bullets", () => { 55 it("assigns tab-indented sub-bullets to the preceding top-level", () => { 56 const result = parsePlaces("* Place A\n\t* Sub note"); 57 expect(result).toHaveLength(1); 58 expect(result[0].notes).toContain("Sub note"); 59 }); 60 61 it("assigns space-indented sub-bullets to the preceding top-level", () => { 62 const result = parsePlaces("* Place A\n * Sub note"); 63 expect(result).toHaveLength(1); 64 expect(result[0].notes).toContain("Sub note"); 65 }); 66 67 it("assigns 4-space-indented sub-bullets", () => { 68 const result = parsePlaces("* Place A\n * Sub note"); 69 expect(result).toHaveLength(1); 70 expect(result[0].notes).toContain("Sub note"); 71 }); 72 73 it("treats deeply nested bullets as sub-bullets of current block", () => { 74 const result = parsePlaces("* Place A\n\t* Level 1\n\t\t* Level 2"); 75 expect(result).toHaveLength(1); 76 expect(result[0].notes).toContain("Level 1"); 77 expect(result[0].notes).toContain("Level 2"); 78 }); 79 80 it("supports dash sub-bullets under asterisk top-level", () => { 81 const result = parsePlaces("* Place A\n\t- Sub note"); 82 expect(result).toHaveLength(1); 83 expect(result[0].notes).toContain("Sub note"); 84 }); 85 86 it("ignores sub-bullets with no preceding top-level bullet", () => { 87 const result = parsePlaces("\t* Orphan sub-bullet\n* Place A"); 88 expect(result).toHaveLength(1); 89 expect(result[0].name).toBe("Place A"); 90 expect(result[0].notes).toHaveLength(0); 91 }); 92}); 93 94// ─── Contract 3: Structured field parsing ────────────────────────────── 95 96describe("Contract 3: Structured fields", () => { 97 it("parses key: value sub-bullets into fields", () => { 98 const result = parsePlaces("* Place A\n\t* category: Architecture"); 99 expect(result[0].fields).toEqual({ category: "Architecture" }); 100 }); 101 102 it("stores keys as lowercase-trimmed", () => { 103 const result = parsePlaces("* Place A\n\t* Category: Art"); 104 expect(result[0].fields).toEqual({ category: "Art" }); 105 }); 106 107 it("trims values", () => { 108 const result = parsePlaces("* Place A\n\t* category: Art "); 109 expect(result[0].fields).toEqual({ category: "Art" }); 110 }); 111 112 it("parses multiple fields", () => { 113 const result = parsePlaces( 114 "* Place A\n\t* category: Art\n\t* rating: 5" 115 ); 116 expect(result[0].fields).toEqual({ category: "Art", rating: "5" }); 117 }); 118 119 it("last field wins on duplicate keys", () => { 120 const result = parsePlaces( 121 "* Place A\n\t* category: Art\n\t* category: Architecture" 122 ); 123 expect(result[0].fields).toEqual({ category: "Architecture" }); 124 }); 125 126 it("does not treat key with spaces as a field", () => { 127 const result = parsePlaces("* Place A\n\t* some key: value"); 128 expect(result[0].fields).toEqual({}); 129 expect(result[0].notes).toContain("some key: value"); 130 }); 131 132 it("does not treat key:value (no space after colon) as a field", () => { 133 const result = parsePlaces("* Place A\n\t* category:Art"); 134 expect(result[0].fields).toEqual({}); 135 expect(result[0].notes).toContain("category:Art"); 136 }); 137 138 it("does not treat : value (no key) as a field", () => { 139 const result = parsePlaces("* Place A\n\t* : value"); 140 expect(result[0].fields).toEqual({}); 141 expect(result[0].notes).toContain(": value"); 142 }); 143}); 144 145// ─── Contract 4: Geo field parsing ───────────────────────────────────── 146 147describe("Contract 4: Geo field", () => { 148 it("sets lat/lng for valid geo coordinates", () => { 149 const result = parsePlaces("* Place A\n\t* geo: 41.403600,2.174400"); 150 expect(result[0].lat).toBe(41.4036); 151 expect(result[0].lng).toBe(2.1744); 152 expect(result[0].fields.geo).toBe("41.403600,2.174400"); 153 }); 154 155 it("handles negative coordinates (southern/western hemispheres)", () => { 156 const result = parsePlaces("* Place A\n\t* geo: -33.8688,151.2093"); 157 expect(result[0].lat).toBe(-33.8688); 158 expect(result[0].lng).toBe(151.2093); 159 }); 160 161 it("handles space after comma in geo", () => { 162 const result = parsePlaces("* Place A\n\t* geo: 41.4036, 2.1744"); 163 expect(result[0].lat).toBe(41.4036); 164 expect(result[0].lng).toBe(2.1744); 165 }); 166 167 it("handles integer coordinates", () => { 168 const result = parsePlaces("* Place A\n\t* geo: 41,2"); 169 expect(result[0].lat).toBe(41); 170 expect(result[0].lng).toBe(2); 171 }); 172 173 it("stores malformed geo in fields but lat/lng remain undefined", () => { 174 const result = parsePlaces("* Place A\n\t* geo: abc,def"); 175 expect(result[0].fields.geo).toBe("abc,def"); 176 expect(result[0].lat).toBeUndefined(); 177 expect(result[0].lng).toBeUndefined(); 178 }); 179 180 it("rejects out-of-range lat (>90)", () => { 181 const result = parsePlaces("* Place A\n\t* geo: 999,999"); 182 expect(result[0].fields.geo).toBe("999,999"); 183 expect(result[0].lat).toBeUndefined(); 184 expect(result[0].lng).toBeUndefined(); 185 }); 186 187 it("rejects out-of-range lat (<-90)", () => { 188 const result = parsePlaces("* Place A\n\t* geo: -91,0"); 189 expect(result[0].lat).toBeUndefined(); 190 expect(result[0].lng).toBeUndefined(); 191 }); 192 193 it("rejects out-of-range lng (>180)", () => { 194 const result = parsePlaces("* Place A\n\t* geo: 0,181"); 195 expect(result[0].lat).toBeUndefined(); 196 expect(result[0].lng).toBeUndefined(); 197 }); 198 199 it("rejects out-of-range lng (<-180)", () => { 200 const result = parsePlaces("* Place A\n\t* geo: 0,-181"); 201 expect(result[0].lat).toBeUndefined(); 202 expect(result[0].lng).toBeUndefined(); 203 }); 204 205 it("rejects trailing dot (e.g., 41.,2.)", () => { 206 const result = parsePlaces("* Place A\n\t* geo: 41.,2."); 207 expect(result[0].lat).toBeUndefined(); 208 expect(result[0].lng).toBeUndefined(); 209 }); 210 211 it("accepts boundary values (90, 180)", () => { 212 const result = parsePlaces("* Place A\n\t* geo: 90,180"); 213 expect(result[0].lat).toBe(90); 214 expect(result[0].lng).toBe(180); 215 }); 216 217 it("accepts boundary values (-90, -180)", () => { 218 const result = parsePlaces("* Place A\n\t* geo: -90,-180"); 219 expect(result[0].lat).toBe(-90); 220 expect(result[0].lng).toBe(-180); 221 }); 222 223 it("last geo field wins when multiple geo sub-bullets", () => { 224 const result = parsePlaces( 225 "* Place A\n\t* geo: 41.4036,2.1744\n\t* geo: 48.8606,2.3376" 226 ); 227 expect(result[0].lat).toBe(48.8606); 228 expect(result[0].lng).toBe(2.3376); 229 }); 230}); 231 232// ─── Contract 5: Freeform notes ──────────────────────────────────────── 233 234describe("Contract 5: Freeform notes", () => { 235 it("stores non-field sub-bullets as notes", () => { 236 const result = parsePlaces("* Place A\n\t* Amazing architecture"); 237 expect(result[0].notes).toEqual(["Amazing architecture"]); 238 }); 239 240 it("strips bullet prefix from notes", () => { 241 const result = parsePlaces("* Place A\n\t* A note\n\t- Another note"); 242 expect(result[0].notes).toEqual(["A note", "Another note"]); 243 }); 244 245 it("trims note text", () => { 246 const result = parsePlaces("* Place A\n\t* A note "); 247 expect(result[0].notes).toEqual(["A note"]); 248 }); 249 250 it("preserves order of notes", () => { 251 const result = parsePlaces("* Place A\n\t* Note 1\n\t* Note 2\n\t* Note 3"); 252 expect(result[0].notes).toEqual(["Note 1", "Note 2", "Note 3"]); 253 }); 254 255 it("keeps notes separate from fields", () => { 256 const result = parsePlaces( 257 "* Place A\n\t* A note\n\t* category: Art\n\t* Another note" 258 ); 259 expect(result[0].notes).toEqual(["A note", "Another note"]); 260 expect(result[0].fields).toEqual({ category: "Art" }); 261 }); 262}); 263 264// ─── Contract 6: Markdown links ──────────────────────────────────────── 265 266describe("Contract 6: Markdown links", () => { 267 it("extracts name and url from markdown link", () => { 268 const result = parsePlaces( 269 "* [The Louvre](https://en.wikipedia.org/wiki/Louvre)" 270 ); 271 expect(result[0].name).toBe("The Louvre"); 272 expect(result[0].url).toBe("https://en.wikipedia.org/wiki/Louvre"); 273 }); 274 275 it("ignores title attribute in markdown link", () => { 276 const result = parsePlaces( 277 '* [The Louvre](https://example.com "A museum")' 278 ); 279 expect(result[0].name).toBe("The Louvre"); 280 expect(result[0].url).toBe("https://example.com"); 281 }); 282 283 it("excludes empty text markdown links", () => { 284 const result = parsePlaces("* [](https://example.com)"); 285 expect(result).toHaveLength(0); 286 }); 287 288 it("excludes whitespace-only text markdown links", () => { 289 const result = parsePlaces("* [ ](https://example.com)"); 290 expect(result).toHaveLength(0); 291 }); 292 293 it("treats markdown link with trailing text as plain text", () => { 294 const result = parsePlaces("* [Name](https://example.com) extra text"); 295 expect(result[0].name).toBe("[Name](https://example.com) extra text"); 296 expect(result[0].url).toBeUndefined(); 297 }); 298 299 it("sets url to undefined for empty URL", () => { 300 const result = parsePlaces("* [Some Place]()"); 301 expect(result[0].name).toBe("Some Place"); 302 expect(result[0].url).toBeUndefined(); 303 }); 304 305 it("falls back to plain text for URLs containing literal quotes (known limitation)", () => { 306 // URLs containing literal double-quote characters break MD_LINK_RE 307 // and fall through to plain text parsing. 308 const result = parsePlaces( 309 '* [Place](https://example.com/q="test")' 310 ); 311 expect(result).toHaveLength(1); 312 // The regex can't distinguish URL-with-quotes from title syntax, 313 // so it falls back to plain text 314 expect(result[0].url).toBeUndefined(); 315 }); 316 317 it("falls back to plain text for URLs with parentheses (known limitation)", () => { 318 // URLs with parentheses (e.g., Wikipedia disambiguation) break MD_LINK_RE 319 // and fall through to plain text parsing. This is a known limitation. 320 const result = parsePlaces( 321 "* [Place](https://en.wikipedia.org/wiki/Place_(disambiguation))" 322 ); 323 expect(result).toHaveLength(1); 324 // Falls back to plain text since the regex can't handle parens in URL 325 expect(result[0].name).toBe( 326 "[Place](https://en.wikipedia.org/wiki/Place_(disambiguation))" 327 ); 328 expect(result[0].url).toBeUndefined(); 329 }); 330}); 331 332// ─── Contract 7: Wiki-links ─────────────────────────────────────────── 333 334describe("Contract 7: Wiki-links", () => { 335 it("extracts name from wiki-link", () => { 336 const result = parsePlaces("* [[Page Name]]"); 337 expect(result[0].name).toBe("Page Name"); 338 expect(result[0].url).toBeUndefined(); 339 }); 340 341 it("uses display name from piped wiki-link", () => { 342 const result = parsePlaces("* [[Target|Display Name]]"); 343 expect(result[0].name).toBe("Display Name"); 344 expect(result[0].url).toBeUndefined(); 345 }); 346 347 it("excludes empty wiki-links", () => { 348 const result = parsePlaces("* [[]]"); 349 expect(result).toHaveLength(0); 350 }); 351 352 it("excludes wiki-link with pipe but empty display name", () => { 353 const result = parsePlaces("* [[Target|]]"); 354 expect(result).toHaveLength(0); 355 }); 356 357 it("handles multiple pipes in wiki-link (everything after first pipe is display)", () => { 358 const result = parsePlaces("* [[Target|Display|Extra]]"); 359 expect(result[0].name).toBe("Display|Extra"); 360 }); 361 362 it("treats wiki-link with trailing text as plain text", () => { 363 const result = parsePlaces("* [[Page Name]] extra text"); 364 expect(result[0].name).toBe("[[Page Name]] extra text"); 365 expect(result[0].url).toBeUndefined(); 366 }); 367}); 368 369// ─── Contract 8: Plain text names ────────────────────────────────────── 370 371describe("Contract 8: Plain text names", () => { 372 it("sets name to trimmed bullet content", () => { 373 const result = parsePlaces("* Blue Bottle Coffee, Tokyo "); 374 expect(result[0].name).toBe("Blue Bottle Coffee, Tokyo"); 375 }); 376 377 it("preserves inline markdown in names", () => { 378 const result = parsePlaces("* **Bold Place**"); 379 expect(result[0].name).toBe("**Bold Place**"); 380 }); 381 382 it("preserves strikethrough in names", () => { 383 const result = parsePlaces("* ~~Closed Restaurant~~"); 384 expect(result[0].name).toBe("~~Closed Restaurant~~"); 385 }); 386}); 387 388// ─── Contract 9: Line numbers ────────────────────────────────────────── 389 390describe("Contract 9: Line numbers (0-based)", () => { 391 it("sets startLine and endLine for a single bullet", () => { 392 const result = parsePlaces("* Place A"); 393 expect(result[0].startLine).toBe(0); 394 expect(result[0].endLine).toBe(0); 395 }); 396 397 it("sets endLine to last sub-bullet line", () => { 398 const result = parsePlaces("* Place A\n\t* Note 1\n\t* Note 2"); 399 expect(result[0].startLine).toBe(0); 400 expect(result[0].endLine).toBe(2); 401 }); 402 403 it("handles multiple places with correct line ranges", () => { 404 const content = "* Place A\n\t* Note A\n* Place B\n\t* Note B1\n\t* Note B2"; 405 const result = parsePlaces(content); 406 expect(result[0].startLine).toBe(0); 407 expect(result[0].endLine).toBe(1); 408 expect(result[1].startLine).toBe(2); 409 expect(result[1].endLine).toBe(4); 410 }); 411 412 it("handles blank lines between places (dead zones)", () => { 413 const content = "* Place A\n\t* Note A\n\n* Place B"; 414 const result = parsePlaces(content); 415 expect(result[0].startLine).toBe(0); 416 expect(result[0].endLine).toBe(1); // dead zone line 2 not included 417 expect(result[1].startLine).toBe(3); 418 expect(result[1].endLine).toBe(3); 419 }); 420 421 it("endLine includes deeply nested descendants", () => { 422 const content = "* Place A\n\t* Level 1\n\t\t* Level 2\n\t\t\t* Level 3"; 423 const result = parsePlaces(content); 424 expect(result[0].endLine).toBe(3); 425 }); 426}); 427 428// ─── Contract 10: Empty/whitespace names excluded ────────────────────── 429 430describe("Contract 10: Empty names excluded", () => { 431 it("excludes bullet with no text", () => { 432 const result = parsePlaces("* "); 433 expect(result).toHaveLength(0); 434 }); 435 436 it("excludes bullet with only whitespace", () => { 437 const result = parsePlaces("* "); 438 expect(result).toHaveLength(0); 439 }); 440 441 it("excludes dash bullet with no text", () => { 442 const result = parsePlaces("- "); 443 expect(result).toHaveLength(0); 444 }); 445}); 446 447// ─── Contract 11: Non-bullet lines ignored ───────────────────────────── 448 449describe("Contract 11: Non-bullet lines ignored", () => { 450 it("ignores headings", () => { 451 const result = parsePlaces("# Heading\n* Place A"); 452 expect(result).toHaveLength(1); 453 expect(result[0].name).toBe("Place A"); 454 }); 455 456 it("ignores paragraphs", () => { 457 const result = parsePlaces("Some text\n* Place A"); 458 expect(result).toHaveLength(1); 459 }); 460 461 it("ignores blank lines", () => { 462 const result = parsePlaces("\n\n* Place A\n\n"); 463 expect(result).toHaveLength(1); 464 }); 465 466 it("dead zone lines don't affect endLine of preceding place", () => { 467 const content = "* Place A\n\t* Note\nSome paragraph\n\n* Place B"; 468 const result = parsePlaces(content); 469 expect(result[0].endLine).toBe(1); // Not 2 or 3 470 }); 471}); 472 473// ─── Contract 13: Duplicate field keys ───────────────────────────────── 474 475describe("Contract 13: Duplicate field keys — last wins", () => { 476 it("last value wins for duplicate keys", () => { 477 const result = parsePlaces( 478 "* Place A\n\t* rating: 3\n\t* rating: 5" 479 ); 480 expect(result[0].fields.rating).toBe("5"); 481 }); 482}); 483 484// ─── Edge Cases ──────────────────────────────────────────────────────── 485 486describe("Edge cases", () => { 487 it("returns empty array for empty string", () => { 488 expect(parsePlaces("")).toEqual([]); 489 }); 490 491 it("returns empty array for string with no bullets", () => { 492 expect(parsePlaces("Just some text\nAnother line")).toEqual([]); 493 }); 494 495 it("handles Windows line endings (\\r\\n)", () => { 496 const result = parsePlaces("* Place A\r\n\t* Note\r\n* Place B"); 497 expect(result).toHaveLength(2); 498 expect(result[0].name).toBe("Place A"); 499 expect(result[0].notes).toEqual(["Note"]); 500 expect(result[1].name).toBe("Place B"); 501 }); 502 503 it("handles tab-indented sub-bullets", () => { 504 const result = parsePlaces("* Place A\n\t* category: Art"); 505 expect(result[0].fields.category).toBe("Art"); 506 }); 507 508 it("handles space-indented sub-bullets", () => { 509 const result = parsePlaces("* Place A\n * category: Art"); 510 expect(result[0].fields.category).toBe("Art"); 511 }); 512 513 it("handles the full example from the spec", () => { 514 const content = [ 515 "* Sagrada Familia", 516 "\t* Amazing architecture, book tickets in advance", 517 "\t* category: Architecture", 518 "\t* geo: 41.403600,2.174400", 519 "* [The Louvre](https://en.wikipedia.org/wiki/Louvre)", 520 "\t* Must see the Mona Lisa", 521 "\t* category: Art", 522 "\t* geo: 48.860600,2.337600", 523 "* Blue Bottle Coffee, Tokyo", 524 ].join("\n"); 525 const result = parsePlaces(content); 526 527 expect(result).toHaveLength(3); 528 529 // Sagrada Familia 530 expect(result[0].name).toBe("Sagrada Familia"); 531 expect(result[0].url).toBeUndefined(); 532 expect(result[0].notes).toEqual([ 533 "Amazing architecture, book tickets in advance", 534 ]); 535 expect(result[0].fields.category).toBe("Architecture"); 536 expect(result[0].lat).toBe(41.4036); 537 expect(result[0].lng).toBe(2.1744); 538 expect(result[0].startLine).toBe(0); 539 expect(result[0].endLine).toBe(3); 540 541 // The Louvre 542 expect(result[1].name).toBe("The Louvre"); 543 expect(result[1].url).toBe("https://en.wikipedia.org/wiki/Louvre"); 544 expect(result[1].notes).toEqual(["Must see the Mona Lisa"]); 545 expect(result[1].fields.category).toBe("Art"); 546 expect(result[1].lat).toBe(48.8606); 547 expect(result[1].lng).toBe(2.3376); 548 expect(result[1].startLine).toBe(4); 549 expect(result[1].endLine).toBe(7); 550 551 // Blue Bottle Coffee 552 expect(result[2].name).toBe("Blue Bottle Coffee, Tokyo"); 553 expect(result[2].url).toBeUndefined(); 554 expect(result[2].fields).toEqual({}); 555 expect(result[2].notes).toEqual([]); 556 expect(result[2].lat).toBeUndefined(); 557 expect(result[2].lng).toBeUndefined(); 558 expect(result[2].startLine).toBe(8); 559 expect(result[2].endLine).toBe(8); 560 }); 561 562 it("accepts geo: 0,0 (Null Island)", () => { 563 const result = parsePlaces("* Place A\n\t* geo: 0,0"); 564 expect(result[0].lat).toBe(0); 565 expect(result[0].lng).toBe(0); 566 }); 567 568 it("ignores single-space-indented bullet (dead zone)", () => { 569 const result = parsePlaces("* Place A\n * Not a sub-bullet"); 570 expect(result).toHaveLength(1); 571 expect(result[0].notes).toHaveLength(0); 572 expect(result[0].endLine).toBe(0); 573 }); 574 575 it("rejects geo with space before comma", () => { 576 const result = parsePlaces("* Place A\n\t* geo: 41.4036 ,2.1744"); 577 expect(result[0].lat).toBeUndefined(); 578 expect(result[0].lng).toBeUndefined(); 579 }); 580 581 it("rejects geo with leading dot and no digit (.5,.5)", () => { 582 const result = parsePlaces("* Place A\n\t* geo: .5,.5"); 583 expect(result[0].lat).toBeUndefined(); 584 expect(result[0].lng).toBeUndefined(); 585 }); 586 587 it("does not add empty strings to notes from blank sub-bullets", () => { 588 const result = parsePlaces("* Place A\n\t* "); 589 expect(result[0].notes).toEqual([]); 590 }); 591 592 it("accepts digit-only field keys (spec: word characters include digits)", () => { 593 const result = parsePlaces("* Place A\n\t* 2024: A great year"); 594 expect(result[0].fields["2024"]).toBe("A great year"); 595 expect(result[0].notes).toHaveLength(0); 596 }); 597 598 it("handles bare CR line endings (old Mac)", () => { 599 const result = parsePlaces("* Place A\r\t* Note\r* Place B"); 600 expect(result).toHaveLength(2); 601 expect(result[0].notes).toEqual(["Note"]); 602 expect(result[1].name).toBe("Place B"); 603 }); 604 605 it("field key 'constructor' doesn't collide with Object prototype", () => { 606 const result = parsePlaces("* Place A\n\t* constructor: value"); 607 expect(result[0].fields.constructor).toBe("value"); 608 expect(typeof result[0].fields.constructor).toBe("string"); 609 }); 610 611 it("field key 'toString' doesn't collide with Object prototype", () => { 612 const result = parsePlaces("* Place A\n\t* tostring: value"); 613 expect(result[0].fields.tostring).toBe("value"); 614 expect(typeof result[0].fields.tostring).toBe("string"); 615 }); 616 617 it("accepts underscore in field keys", () => { 618 const result = parsePlaces("* Place A\n\t* my_field: value"); 619 expect(result[0].fields.my_field).toBe("value"); 620 }); 621 622 it("default Place has empty fields and notes", () => { 623 const result = parsePlaces("* Simple Place"); 624 expect(result[0].fields).toEqual({}); 625 expect(result[0].notes).toEqual([]); 626 expect(result[0].lat).toBeUndefined(); 627 expect(result[0].lng).toBeUndefined(); 628 expect(result[0].url).toBeUndefined(); 629 }); 630}); 631 632// ─── Property-based tests (fast-check) ──────────────────────────────── 633 634describe("Property-based tests", () => { 635 // Generate valid place names: non-empty, no newlines, no link-like syntax 636 // that could produce empty names after parsing (e.g., [[]], [](url)) 637 const placeNameArb = fc 638 .stringOf( 639 fc.oneof( 640 fc.char().filter( 641 (c) => c !== "\n" && c !== "\r" && c !== "[" && c !== "]" 642 ), 643 fc.constant(" ") 644 ), 645 { minLength: 1, maxLength: 50 } 646 ) 647 .filter((s) => s.trim().length > 0); 648 649 it("number of top-level bullets equals number of places", () => { 650 fc.assert( 651 fc.property( 652 fc.array(placeNameArb, { minLength: 1, maxLength: 20 }), 653 (names) => { 654 const content = names.map((n) => `* ${n}`).join("\n"); 655 const result = parsePlaces(content); 656 // placeNameArb guarantees non-empty trimmed names with no link syntax 657 expect(result.length).toBe(names.length); 658 } 659 ) 660 ); 661 }); 662 663 it("startLine is always <= endLine", () => { 664 fc.assert( 665 fc.property( 666 fc.array(placeNameArb, { minLength: 1, maxLength: 10 }), 667 (names) => { 668 const content = names.map((n) => `* ${n}\n\t* A note`).join("\n"); 669 const result = parsePlaces(content); 670 for (const place of result) { 671 expect(place.startLine).toBeLessThanOrEqual(place.endLine); 672 } 673 } 674 ) 675 ); 676 }); 677 678 it("line ranges never overlap between places", () => { 679 fc.assert( 680 fc.property( 681 fc.array(placeNameArb, { minLength: 2, maxLength: 10 }), 682 (names) => { 683 const content = names.map((n) => `* ${n}\n\t* note`).join("\n"); 684 const result = parsePlaces(content); 685 for (let i = 1; i < result.length; i++) { 686 expect(result[i].startLine).toBeGreaterThan(result[i - 1].endLine); 687 } 688 } 689 ) 690 ); 691 }); 692 693 it("valid geo coordinates always produce defined lat/lng within bounds", () => { 694 fc.assert( 695 fc.property( 696 fc.double({ min: -90, max: 90, noNaN: true, noDefaultInfinity: true }), 697 fc.double({ min: -180, max: 180, noNaN: true, noDefaultInfinity: true }), 698 (lat, lng) => { 699 // Format with fixed decimals to avoid trailing dot issues 700 const latStr = lat.toFixed(6); 701 const lngStr = lng.toFixed(6); 702 const content = `* Place\n\t* geo: ${latStr},${lngStr}`; 703 const result = parsePlaces(content); 704 expect(result).toHaveLength(1); 705 expect(result[0].lat).toBeDefined(); 706 expect(result[0].lng).toBeDefined(); 707 expect(result[0].lat!).toBeGreaterThanOrEqual(-90); 708 expect(result[0].lat!).toBeLessThanOrEqual(90); 709 expect(result[0].lng!).toBeGreaterThanOrEqual(-180); 710 expect(result[0].lng!).toBeLessThanOrEqual(180); 711 } 712 ) 713 ); 714 }); 715 716 it("fields record keys are always lowercase", () => { 717 const keyArb = fc.stringOf(fc.char().filter((c) => /\w/.test(c) && c !== ":"), { 718 minLength: 1, 719 maxLength: 10, 720 }).filter((s) => /^\w+$/.test(s)); 721 722 fc.assert( 723 fc.property(keyArb, fc.string({ minLength: 1, maxLength: 20 }), (key, value) => { 724 const safeValue = value.replace(/\n/g, " ").replace(/\r/g, " "); 725 const content = `* Place\n\t* ${key}: ${safeValue}`; 726 const result = parsePlaces(content); 727 if (result.length > 0) { 728 for (const k of Object.keys(result[0].fields)) { 729 expect(k).toBe(k.toLowerCase()); 730 } 731 } 732 }) 733 ); 734 }); 735 736 it("parsePlaces never throws on arbitrary string input", () => { 737 fc.assert( 738 fc.property(fc.string({ maxLength: 500 }), (input) => { 739 expect(() => parsePlaces(input)).not.toThrow(); 740 }) 741 ); 742 }); 743});