A wayfinder inspired map plugin for obisidian
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});