A wayfinder inspired map plugin for obisidian
0
fork

Configure Feed

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

misc

+5795 -44
.chainlink/issues.db

This is a binary file and will not be displayed.

+1
AGENTS.md
··· 162 162 6. **Mark issues done** via `chainlink close <id>` when complete 163 163 7. **Use `chainlink start <id>`** to track time on issues 164 164 8. **The spec in the issue is the source of truth** — if the code contradicts the issue description, the code is wrong 165 + 9. **Fix adversary findings immediately** — don't defer non-blocking issues to later phases. Fix them while you have context. Less work later, fewer things to remember, and the codebase stays clean as you go.
+17
CHANGELOG.md
··· 11 11 ### Fixed 12 12 13 13 ### Changed 14 + - styles.css (#7) 15 + - Adversarial review: main.ts (#27) 16 + - main.ts — TDD (#6) 17 + - Refactor main.ts (#22) 18 + - Implement main.ts (#21) 19 + - Write main.ts tests (#20) 20 + - Adversarial review: mapView.ts (#26) 21 + - mapView.ts — TDD (#5) 22 + - Refactor mapView (#19) 23 + - Implement mapView (#18) 24 + - Write mapView tests (#17) 25 + - Adversarial review: mapRenderer.ts (#25) 26 + - mapRenderer.ts — TDD (#4) 27 + - Refactor mapRenderer (#16) 28 + - Implement mapRenderer (#15) 29 + - Write mapRenderer tests (#14) 30 + - Adversarial review: geocoder.ts (#24) 14 31 - geocoder.ts — TDD (#3) 15 32 - Refactor geocoder (#13) 16 33 - Implement geocoder (#12)
+2 -1
esbuild.config.mjs
··· 5 5 const prod = process.argv[2] === "production"; 6 6 7 7 const context = await esbuild.context({ 8 - entryPoints: ["main.ts"], 8 + entryPoints: ["src/main.ts"], 9 9 bundle: true, 10 10 external: [ 11 11 "obsidian", ··· 28 28 logLevel: "info", 29 29 sourcemap: prod ? false : "inline", 30 30 treeShaking: true, 31 + minify: prod, 31 32 outfile: "main.js", 32 33 }); 33 34
+593
package-lock.json
··· 18 18 "builtin-modules": "^3.3.0", 19 19 "esbuild": "^0.20.0", 20 20 "fast-check": "^3.0.0", 21 + "jsdom": "^28.1.0", 21 22 "obsidian": "latest", 22 23 "tslib": "^2.6.0", 23 24 "typescript": "^5.3.0", 24 25 "vitest": "^3.0.0" 25 26 } 27 + }, 28 + "node_modules/@acemir/cssom": { 29 + "version": "0.9.31", 30 + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", 31 + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", 32 + "dev": true, 33 + "license": "MIT" 26 34 }, 27 35 "node_modules/@ampproject/remapping": { 28 36 "version": "2.3.0", ··· 38 46 "node": ">=6.0.0" 39 47 } 40 48 }, 49 + "node_modules/@asamuzakjp/css-color": { 50 + "version": "5.0.1", 51 + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", 52 + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", 53 + "dev": true, 54 + "license": "MIT", 55 + "dependencies": { 56 + "@csstools/css-calc": "^3.1.1", 57 + "@csstools/css-color-parser": "^4.0.2", 58 + "@csstools/css-parser-algorithms": "^4.0.0", 59 + "@csstools/css-tokenizer": "^4.0.0", 60 + "lru-cache": "^11.2.6" 61 + }, 62 + "engines": { 63 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 64 + } 65 + }, 66 + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { 67 + "version": "11.2.6", 68 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", 69 + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", 70 + "dev": true, 71 + "license": "BlueOak-1.0.0", 72 + "engines": { 73 + "node": "20 || >=22" 74 + } 75 + }, 76 + "node_modules/@asamuzakjp/dom-selector": { 77 + "version": "6.8.1", 78 + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", 79 + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", 80 + "dev": true, 81 + "license": "MIT", 82 + "dependencies": { 83 + "@asamuzakjp/nwsapi": "^2.3.9", 84 + "bidi-js": "^1.0.3", 85 + "css-tree": "^3.1.0", 86 + "is-potential-custom-element-name": "^1.0.1", 87 + "lru-cache": "^11.2.6" 88 + } 89 + }, 90 + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { 91 + "version": "11.2.6", 92 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", 93 + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", 94 + "dev": true, 95 + "license": "BlueOak-1.0.0", 96 + "engines": { 97 + "node": "20 || >=22" 98 + } 99 + }, 100 + "node_modules/@asamuzakjp/nwsapi": { 101 + "version": "2.3.9", 102 + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", 103 + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", 104 + "dev": true, 105 + "license": "MIT" 106 + }, 41 107 "node_modules/@babel/helper-string-parser": { 42 108 "version": "7.27.1", 43 109 "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", ··· 98 164 "node": ">=18" 99 165 } 100 166 }, 167 + "node_modules/@bramus/specificity": { 168 + "version": "2.4.2", 169 + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", 170 + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", 171 + "dev": true, 172 + "license": "MIT", 173 + "dependencies": { 174 + "css-tree": "^3.0.0" 175 + }, 176 + "bin": { 177 + "specificity": "bin/cli.js" 178 + } 179 + }, 101 180 "node_modules/@codemirror/state": { 102 181 "version": "6.5.0", 103 182 "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", ··· 123 202 "w3c-keyname": "^2.2.4" 124 203 } 125 204 }, 205 + "node_modules/@csstools/color-helpers": { 206 + "version": "6.0.2", 207 + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", 208 + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", 209 + "dev": true, 210 + "funding": [ 211 + { 212 + "type": "github", 213 + "url": "https://github.com/sponsors/csstools" 214 + }, 215 + { 216 + "type": "opencollective", 217 + "url": "https://opencollective.com/csstools" 218 + } 219 + ], 220 + "license": "MIT-0", 221 + "engines": { 222 + "node": ">=20.19.0" 223 + } 224 + }, 225 + "node_modules/@csstools/css-calc": { 226 + "version": "3.1.1", 227 + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", 228 + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", 229 + "dev": true, 230 + "funding": [ 231 + { 232 + "type": "github", 233 + "url": "https://github.com/sponsors/csstools" 234 + }, 235 + { 236 + "type": "opencollective", 237 + "url": "https://opencollective.com/csstools" 238 + } 239 + ], 240 + "license": "MIT", 241 + "engines": { 242 + "node": ">=20.19.0" 243 + }, 244 + "peerDependencies": { 245 + "@csstools/css-parser-algorithms": "^4.0.0", 246 + "@csstools/css-tokenizer": "^4.0.0" 247 + } 248 + }, 249 + "node_modules/@csstools/css-color-parser": { 250 + "version": "4.0.2", 251 + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", 252 + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", 253 + "dev": true, 254 + "funding": [ 255 + { 256 + "type": "github", 257 + "url": "https://github.com/sponsors/csstools" 258 + }, 259 + { 260 + "type": "opencollective", 261 + "url": "https://opencollective.com/csstools" 262 + } 263 + ], 264 + "license": "MIT", 265 + "dependencies": { 266 + "@csstools/color-helpers": "^6.0.2", 267 + "@csstools/css-calc": "^3.1.1" 268 + }, 269 + "engines": { 270 + "node": ">=20.19.0" 271 + }, 272 + "peerDependencies": { 273 + "@csstools/css-parser-algorithms": "^4.0.0", 274 + "@csstools/css-tokenizer": "^4.0.0" 275 + } 276 + }, 277 + "node_modules/@csstools/css-parser-algorithms": { 278 + "version": "4.0.0", 279 + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", 280 + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", 281 + "dev": true, 282 + "funding": [ 283 + { 284 + "type": "github", 285 + "url": "https://github.com/sponsors/csstools" 286 + }, 287 + { 288 + "type": "opencollective", 289 + "url": "https://opencollective.com/csstools" 290 + } 291 + ], 292 + "license": "MIT", 293 + "engines": { 294 + "node": ">=20.19.0" 295 + }, 296 + "peerDependencies": { 297 + "@csstools/css-tokenizer": "^4.0.0" 298 + } 299 + }, 300 + "node_modules/@csstools/css-syntax-patches-for-csstree": { 301 + "version": "1.1.0", 302 + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", 303 + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", 304 + "dev": true, 305 + "funding": [ 306 + { 307 + "type": "github", 308 + "url": "https://github.com/sponsors/csstools" 309 + }, 310 + { 311 + "type": "opencollective", 312 + "url": "https://opencollective.com/csstools" 313 + } 314 + ], 315 + "license": "MIT-0" 316 + }, 317 + "node_modules/@csstools/css-tokenizer": { 318 + "version": "4.0.0", 319 + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", 320 + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", 321 + "dev": true, 322 + "funding": [ 323 + { 324 + "type": "github", 325 + "url": "https://github.com/sponsors/csstools" 326 + }, 327 + { 328 + "type": "opencollective", 329 + "url": "https://opencollective.com/csstools" 330 + } 331 + ], 332 + "license": "MIT", 333 + "engines": { 334 + "node": ">=20.19.0" 335 + } 336 + }, 126 337 "node_modules/@esbuild/aix-ppc64": { 127 338 "version": "0.20.2", 128 339 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", ··· 565 776 "node": ">=12" 566 777 } 567 778 }, 779 + "node_modules/@exodus/bytes": { 780 + "version": "1.15.0", 781 + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", 782 + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", 783 + "dev": true, 784 + "license": "MIT", 785 + "engines": { 786 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 787 + }, 788 + "peerDependencies": { 789 + "@noble/hashes": "^1.8.0 || ^2.0.0" 790 + }, 791 + "peerDependenciesMeta": { 792 + "@noble/hashes": { 793 + "optional": true 794 + } 795 + } 796 + }, 568 797 "node_modules/@isaacs/cliui": { 569 798 "version": "8.0.2", 570 799 "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", ··· 1222 1451 "url": "https://opencollective.com/vitest" 1223 1452 } 1224 1453 }, 1454 + "node_modules/agent-base": { 1455 + "version": "7.1.4", 1456 + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", 1457 + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", 1458 + "dev": true, 1459 + "license": "MIT", 1460 + "engines": { 1461 + "node": ">= 14" 1462 + } 1463 + }, 1225 1464 "node_modules/ansi-regex": { 1226 1465 "version": "6.2.2", 1227 1466 "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", ··· 1278 1517 "license": "MIT", 1279 1518 "engines": { 1280 1519 "node": "18 || 20 || >=22" 1520 + } 1521 + }, 1522 + "node_modules/bidi-js": { 1523 + "version": "1.0.3", 1524 + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", 1525 + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", 1526 + "dev": true, 1527 + "license": "MIT", 1528 + "dependencies": { 1529 + "require-from-string": "^2.0.2" 1281 1530 } 1282 1531 }, 1283 1532 "node_modules/brace-expansion": { ··· 1386 1635 "node": ">= 8" 1387 1636 } 1388 1637 }, 1638 + "node_modules/css-tree": { 1639 + "version": "3.2.1", 1640 + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", 1641 + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", 1642 + "dev": true, 1643 + "license": "MIT", 1644 + "dependencies": { 1645 + "mdn-data": "2.27.1", 1646 + "source-map-js": "^1.2.1" 1647 + }, 1648 + "engines": { 1649 + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" 1650 + } 1651 + }, 1652 + "node_modules/cssstyle": { 1653 + "version": "6.2.0", 1654 + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", 1655 + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", 1656 + "dev": true, 1657 + "license": "MIT", 1658 + "dependencies": { 1659 + "@asamuzakjp/css-color": "^5.0.1", 1660 + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", 1661 + "css-tree": "^3.1.0", 1662 + "lru-cache": "^11.2.6" 1663 + }, 1664 + "engines": { 1665 + "node": ">=20" 1666 + } 1667 + }, 1668 + "node_modules/cssstyle/node_modules/lru-cache": { 1669 + "version": "11.2.6", 1670 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", 1671 + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", 1672 + "dev": true, 1673 + "license": "BlueOak-1.0.0", 1674 + "engines": { 1675 + "node": "20 || >=22" 1676 + } 1677 + }, 1678 + "node_modules/data-urls": { 1679 + "version": "7.0.0", 1680 + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", 1681 + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", 1682 + "dev": true, 1683 + "license": "MIT", 1684 + "dependencies": { 1685 + "whatwg-mimetype": "^5.0.0", 1686 + "whatwg-url": "^16.0.0" 1687 + }, 1688 + "engines": { 1689 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 1690 + } 1691 + }, 1389 1692 "node_modules/debug": { 1390 1693 "version": "4.4.3", 1391 1694 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", ··· 1403 1706 "optional": true 1404 1707 } 1405 1708 } 1709 + }, 1710 + "node_modules/decimal.js": { 1711 + "version": "10.6.0", 1712 + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", 1713 + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", 1714 + "dev": true, 1715 + "license": "MIT" 1406 1716 }, 1407 1717 "node_modules/deep-eql": { 1408 1718 "version": "5.0.2", ··· 1427 1737 "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", 1428 1738 "dev": true, 1429 1739 "license": "MIT" 1740 + }, 1741 + "node_modules/entities": { 1742 + "version": "6.0.1", 1743 + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", 1744 + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", 1745 + "dev": true, 1746 + "license": "BSD-2-Clause", 1747 + "engines": { 1748 + "node": ">=0.12" 1749 + }, 1750 + "funding": { 1751 + "url": "https://github.com/fb55/entities?sponsor=1" 1752 + } 1430 1753 }, 1431 1754 "node_modules/es-module-lexer": { 1432 1755 "version": "1.7.0", ··· 1632 1955 "node": ">=8" 1633 1956 } 1634 1957 }, 1958 + "node_modules/html-encoding-sniffer": { 1959 + "version": "6.0.0", 1960 + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", 1961 + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", 1962 + "dev": true, 1963 + "license": "MIT", 1964 + "dependencies": { 1965 + "@exodus/bytes": "^1.6.0" 1966 + }, 1967 + "engines": { 1968 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 1969 + } 1970 + }, 1635 1971 "node_modules/html-escaper": { 1636 1972 "version": "2.0.2", 1637 1973 "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", ··· 1639 1975 "dev": true, 1640 1976 "license": "MIT" 1641 1977 }, 1978 + "node_modules/http-proxy-agent": { 1979 + "version": "7.0.2", 1980 + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", 1981 + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", 1982 + "dev": true, 1983 + "license": "MIT", 1984 + "dependencies": { 1985 + "agent-base": "^7.1.0", 1986 + "debug": "^4.3.4" 1987 + }, 1988 + "engines": { 1989 + "node": ">= 14" 1990 + } 1991 + }, 1992 + "node_modules/https-proxy-agent": { 1993 + "version": "7.0.6", 1994 + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", 1995 + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", 1996 + "dev": true, 1997 + "license": "MIT", 1998 + "dependencies": { 1999 + "agent-base": "^7.1.2", 2000 + "debug": "4" 2001 + }, 2002 + "engines": { 2003 + "node": ">= 14" 2004 + } 2005 + }, 1642 2006 "node_modules/is-fullwidth-code-point": { 1643 2007 "version": "3.0.0", 1644 2008 "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", ··· 1648 2012 "engines": { 1649 2013 "node": ">=8" 1650 2014 } 2015 + }, 2016 + "node_modules/is-potential-custom-element-name": { 2017 + "version": "1.0.1", 2018 + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", 2019 + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", 2020 + "dev": true, 2021 + "license": "MIT" 1651 2022 }, 1652 2023 "node_modules/isexe": { 1653 2024 "version": "2.0.0", ··· 1733 2104 "dev": true, 1734 2105 "license": "MIT" 1735 2106 }, 2107 + "node_modules/jsdom": { 2108 + "version": "28.1.0", 2109 + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", 2110 + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", 2111 + "dev": true, 2112 + "license": "MIT", 2113 + "dependencies": { 2114 + "@acemir/cssom": "^0.9.31", 2115 + "@asamuzakjp/dom-selector": "^6.8.1", 2116 + "@bramus/specificity": "^2.4.2", 2117 + "@exodus/bytes": "^1.11.0", 2118 + "cssstyle": "^6.0.1", 2119 + "data-urls": "^7.0.0", 2120 + "decimal.js": "^10.6.0", 2121 + "html-encoding-sniffer": "^6.0.0", 2122 + "http-proxy-agent": "^7.0.2", 2123 + "https-proxy-agent": "^7.0.6", 2124 + "is-potential-custom-element-name": "^1.0.1", 2125 + "parse5": "^8.0.0", 2126 + "saxes": "^6.0.0", 2127 + "symbol-tree": "^3.2.4", 2128 + "tough-cookie": "^6.0.0", 2129 + "undici": "^7.21.0", 2130 + "w3c-xmlserializer": "^5.0.0", 2131 + "webidl-conversions": "^8.0.1", 2132 + "whatwg-mimetype": "^5.0.0", 2133 + "whatwg-url": "^16.0.0", 2134 + "xml-name-validator": "^5.0.0" 2135 + }, 2136 + "engines": { 2137 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 2138 + }, 2139 + "peerDependencies": { 2140 + "canvas": "^3.0.0" 2141 + }, 2142 + "peerDependenciesMeta": { 2143 + "canvas": { 2144 + "optional": true 2145 + } 2146 + } 2147 + }, 1736 2148 "node_modules/leaflet": { 1737 2149 "version": "1.9.4", 1738 2150 "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", ··· 1790 2202 "funding": { 1791 2203 "url": "https://github.com/sponsors/sindresorhus" 1792 2204 } 2205 + }, 2206 + "node_modules/mdn-data": { 2207 + "version": "2.27.1", 2208 + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", 2209 + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", 2210 + "dev": true, 2211 + "license": "CC0-1.0" 1793 2212 }, 1794 2213 "node_modules/minimatch": { 1795 2214 "version": "10.2.4", ··· 1875 2294 "dev": true, 1876 2295 "license": "BlueOak-1.0.0" 1877 2296 }, 2297 + "node_modules/parse5": { 2298 + "version": "8.0.0", 2299 + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", 2300 + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", 2301 + "dev": true, 2302 + "license": "MIT", 2303 + "dependencies": { 2304 + "entities": "^6.0.0" 2305 + }, 2306 + "funding": { 2307 + "url": "https://github.com/inikulin/parse5?sponsor=1" 2308 + } 2309 + }, 1878 2310 "node_modules/path-key": { 1879 2311 "version": "3.1.1", 1880 2312 "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", ··· 1968 2400 "node": "^10 || ^12 || >=14" 1969 2401 } 1970 2402 }, 2403 + "node_modules/punycode": { 2404 + "version": "2.3.1", 2405 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 2406 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 2407 + "dev": true, 2408 + "license": "MIT", 2409 + "engines": { 2410 + "node": ">=6" 2411 + } 2412 + }, 1971 2413 "node_modules/pure-rand": { 1972 2414 "version": "6.1.0", 1973 2415 "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", ··· 1985 2427 ], 1986 2428 "license": "MIT" 1987 2429 }, 2430 + "node_modules/require-from-string": { 2431 + "version": "2.0.2", 2432 + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 2433 + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", 2434 + "dev": true, 2435 + "license": "MIT", 2436 + "engines": { 2437 + "node": ">=0.10.0" 2438 + } 2439 + }, 1988 2440 "node_modules/rollup": { 1989 2441 "version": "4.59.0", 1990 2442 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", ··· 2028 2480 "@rollup/rollup-win32-x64-gnu": "4.59.0", 2029 2481 "@rollup/rollup-win32-x64-msvc": "4.59.0", 2030 2482 "fsevents": "~2.3.2" 2483 + } 2484 + }, 2485 + "node_modules/saxes": { 2486 + "version": "6.0.0", 2487 + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", 2488 + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", 2489 + "dev": true, 2490 + "license": "ISC", 2491 + "dependencies": { 2492 + "xmlchars": "^2.2.0" 2493 + }, 2494 + "engines": { 2495 + "node": ">=v12.22.7" 2031 2496 } 2032 2497 }, 2033 2498 "node_modules/semver": { ··· 2255 2720 "node": ">=8" 2256 2721 } 2257 2722 }, 2723 + "node_modules/symbol-tree": { 2724 + "version": "3.2.4", 2725 + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", 2726 + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", 2727 + "dev": true, 2728 + "license": "MIT" 2729 + }, 2258 2730 "node_modules/test-exclude": { 2259 2731 "version": "7.0.2", 2260 2732 "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", ··· 2331 2803 "node": ">=14.0.0" 2332 2804 } 2333 2805 }, 2806 + "node_modules/tldts": { 2807 + "version": "7.0.25", 2808 + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", 2809 + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", 2810 + "dev": true, 2811 + "license": "MIT", 2812 + "dependencies": { 2813 + "tldts-core": "^7.0.25" 2814 + }, 2815 + "bin": { 2816 + "tldts": "bin/cli.js" 2817 + } 2818 + }, 2819 + "node_modules/tldts-core": { 2820 + "version": "7.0.25", 2821 + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", 2822 + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", 2823 + "dev": true, 2824 + "license": "MIT" 2825 + }, 2826 + "node_modules/tough-cookie": { 2827 + "version": "6.0.0", 2828 + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", 2829 + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", 2830 + "dev": true, 2831 + "license": "BSD-3-Clause", 2832 + "dependencies": { 2833 + "tldts": "^7.0.5" 2834 + }, 2835 + "engines": { 2836 + "node": ">=16" 2837 + } 2838 + }, 2839 + "node_modules/tr46": { 2840 + "version": "6.0.0", 2841 + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", 2842 + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", 2843 + "dev": true, 2844 + "license": "MIT", 2845 + "dependencies": { 2846 + "punycode": "^2.3.1" 2847 + }, 2848 + "engines": { 2849 + "node": ">=20" 2850 + } 2851 + }, 2334 2852 "node_modules/tslib": { 2335 2853 "version": "2.8.1", 2336 2854 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", ··· 2352 2870 "node": ">=14.17" 2353 2871 } 2354 2872 }, 2873 + "node_modules/undici": { 2874 + "version": "7.22.0", 2875 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", 2876 + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", 2877 + "dev": true, 2878 + "license": "MIT", 2879 + "engines": { 2880 + "node": ">=20.18.1" 2881 + } 2882 + }, 2355 2883 "node_modules/undici-types": { 2356 2884 "version": "6.21.0", 2357 2885 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", ··· 2971 3499 "license": "MIT", 2972 3500 "peer": true 2973 3501 }, 3502 + "node_modules/w3c-xmlserializer": { 3503 + "version": "5.0.0", 3504 + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", 3505 + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", 3506 + "dev": true, 3507 + "license": "MIT", 3508 + "dependencies": { 3509 + "xml-name-validator": "^5.0.0" 3510 + }, 3511 + "engines": { 3512 + "node": ">=18" 3513 + } 3514 + }, 3515 + "node_modules/webidl-conversions": { 3516 + "version": "8.0.1", 3517 + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", 3518 + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", 3519 + "dev": true, 3520 + "license": "BSD-2-Clause", 3521 + "engines": { 3522 + "node": ">=20" 3523 + } 3524 + }, 3525 + "node_modules/whatwg-mimetype": { 3526 + "version": "5.0.0", 3527 + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", 3528 + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", 3529 + "dev": true, 3530 + "license": "MIT", 3531 + "engines": { 3532 + "node": ">=20" 3533 + } 3534 + }, 3535 + "node_modules/whatwg-url": { 3536 + "version": "16.0.1", 3537 + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", 3538 + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", 3539 + "dev": true, 3540 + "license": "MIT", 3541 + "dependencies": { 3542 + "@exodus/bytes": "^1.11.0", 3543 + "tr46": "^6.0.0", 3544 + "webidl-conversions": "^8.0.1" 3545 + }, 3546 + "engines": { 3547 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" 3548 + } 3549 + }, 2974 3550 "node_modules/which": { 2975 3551 "version": "2.0.2", 2976 3552 "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", ··· 3101 3677 "engines": { 3102 3678 "node": ">=8" 3103 3679 } 3680 + }, 3681 + "node_modules/xml-name-validator": { 3682 + "version": "5.0.0", 3683 + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", 3684 + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", 3685 + "dev": true, 3686 + "license": "Apache-2.0", 3687 + "engines": { 3688 + "node": ">=18" 3689 + } 3690 + }, 3691 + "node_modules/xmlchars": { 3692 + "version": "2.2.0", 3693 + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", 3694 + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", 3695 + "dev": true, 3696 + "license": "MIT" 3104 3697 } 3105 3698 } 3106 3699 }
+1
package.json
··· 20 20 "builtin-modules": "^3.3.0", 21 21 "esbuild": "^0.20.0", 22 22 "fast-check": "^3.0.0", 23 + "jsdom": "^28.1.0", 23 24 "obsidian": "latest", 24 25 "tslib": "^2.6.0", 25 26 "typescript": "^5.3.0",
+40 -19
src/geocoder.ts
··· 14 14 15 15 export interface GeocodeCallbacks { 16 16 onProgress?: (place: Place, result: GeoResult | null) => void; 17 + /** Called when the user should be shown a notice (e.g., repeated geocoding failures). */ 18 + onNotice?: (message: string) => void; 17 19 } 18 20 19 21 /** Minimum delay between sequential Nominatim requests (ms). */ ··· 31 33 /** 32 34 * Geocode an array of places via Nominatim. 33 35 * 36 + * **Mutates** the input `places` array in-place — successfully geocoded places 37 + * have their `lat` and `lng` properties set directly on the original objects. 38 + * Returns the same array reference (not a copy). 39 + * 34 40 * - Only geocodes places where lat AND lng are both undefined/null. 35 41 * - Deduplicates by case-insensitive trimmed name. 36 42 * - Rate-limits to 1100ms between sequential requests. 37 43 * - Supports external cancellation via AbortSignal. 38 - * - Reports progress via onProgress callback. 44 + * - Reports progress via onProgress callback (once per unique geocode, not per duplicate). 39 45 */ 40 46 export async function geocodePlaces( 41 47 places: Place[], ··· 105 111 consecutiveFailures++; 106 112 // Contract 12: Notice on exactly the 3rd consecutive failure (not every subsequent one) 107 113 if (consecutiveFailures === CONSECUTIVE_FAILURE_NOTICE_THRESHOLD) { 108 - try { 109 - // eslint-disable-next-line no-undef 110 - new (globalThis as any).Notice( 111 - "Map Viewer: Geocoding issues — check your network connection" 112 - ); 113 - } catch { 114 - // Notice may not exist outside Obsidian — swallow silently 115 - } 114 + callbacks?.onNotice?.( 115 + "Map Viewer: Geocoding issues — check your network connection" 116 + ); 116 117 } 117 118 } else { 118 119 consecutiveFailures = 0; ··· 151 152 // Combine external signal and timeout signal 152 153 const combinedController = new AbortController(); 153 154 155 + // Named handlers for cleanup — pass reason for debuggability 156 + const onExternalAbort = () => combinedController.abort("cancelled"); 157 + const onTimeoutAbort = () => combinedController.abort("timeout"); 158 + 154 159 // If external signal aborts, abort combined 155 - const onExternalAbort = () => combinedController.abort(); 156 160 if (externalSignal) { 157 161 if (externalSignal.aborted) { 158 162 clearTimeout(timeoutId); ··· 162 166 } 163 167 164 168 // If timeout aborts, abort combined 165 - timeoutController.signal.addEventListener("abort", () => 166 - combinedController.abort() 167 - ); 169 + timeoutController.signal.addEventListener("abort", onTimeoutAbort); 168 170 169 171 // Contract 2: build Nominatim URL 170 172 const url = `${NOMINATIM_BASE}?format=json&limit=1&q=${encodeURIComponent(name)}`; ··· 176 178 signal: combinedController.signal, 177 179 }); 178 180 181 + if (!response.ok) { 182 + console.warn("[MapViewer] HTTP error for:", name, response.status); 183 + return null; 184 + } 185 + 179 186 const data = await response.json(); 180 187 181 188 // Empty results ··· 188 195 const lat = parseFloat(data[0].lat); 189 196 const lng = parseFloat(data[0].lon); 190 197 191 - if (isNaN(lat) || isNaN(lng)) { 192 - console.warn("[MapViewer] Invalid coordinates for:", name); 198 + if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { 199 + console.warn("[MapViewer] Invalid coordinates for:", name, { lat, lng }); 193 200 return null; 194 201 } 195 202 196 203 return { lat, lng }; 197 204 } catch (err) { 198 205 // Contract 8 & 12: network failures logged, place skipped 199 - console.warn("[MapViewer] Geocode failed for:", name, err); 206 + if (err instanceof DOMException && err.name === "AbortError") { 207 + const reason = combinedController.signal.reason; 208 + if (reason === "timeout") { 209 + console.warn("[MapViewer] Geocode timed out for:", name); 210 + } else { 211 + console.warn("[MapViewer] Geocode cancelled for:", name); 212 + } 213 + } else { 214 + console.warn("[MapViewer] Geocode failed for:", name, err); 215 + } 200 216 return null; 201 217 } finally { 202 218 clearTimeout(timeoutId); 219 + timeoutController.signal.removeEventListener("abort", onTimeoutAbort); 203 220 if (externalSignal) { 204 221 externalSignal.removeEventListener("abort", onExternalAbort); 205 222 } ··· 213 230 resolve(); 214 231 return; 215 232 } 216 - const timerId = setTimeout(resolve, ms); 217 - signal?.addEventListener("abort", () => { 233 + const onAbort = () => { 218 234 clearTimeout(timerId); 219 235 resolve(); 220 - }, { once: true }); 236 + }; 237 + const timerId = setTimeout(() => { 238 + signal?.removeEventListener("abort", onAbort); 239 + resolve(); 240 + }, ms); 241 + signal?.addEventListener("abort", onAbort, { once: true }); 221 242 }); 222 243 }
+122
src/main.ts
··· 1 + /** 2 + * main.ts — Plugin Entry (Effectful/Obsidian API) 3 + * 4 + * Registers the map viewer plugin, its custom view, commands, 5 + * ribbon icon, and workspace event handlers. 6 + */ 7 + 8 + import { Plugin, MarkdownView } from "obsidian"; 9 + import type { WorkspaceLeaf, TAbstractFile, TFile } from "obsidian"; 10 + import { VIEW_TYPE, MapViewerView } from "./mapView"; 11 + 12 + export default class MapViewerPlugin extends Plugin { 13 + private lastActiveFilePath: string | null = null; 14 + 15 + async onload(): Promise<void> { 16 + // Contract #1: Register view type with factory 17 + this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => { 18 + return new MapViewerView(leaf); 19 + }); 20 + 21 + // Contract #2: Ribbon icon 22 + this.addRibbonIcon("map-pin", "Open map view", () => { 23 + this.activateView(); 24 + }); 25 + 26 + // Contract #3: Command 27 + this.addCommand({ 28 + id: "open-map-view", 29 + name: "Open map view", 30 + callback: () => { 31 + this.activateView(); 32 + }, 33 + }); 34 + 35 + // Contract #5: active-leaf-change event 36 + // NOTE: mapView.ts also registers this event for view-specific logic (e.g., 37 + // clearMap on non-MarkdownView). Both handlers may fire; the view's debounce 38 + // in scheduleRefresh() collapses duplicate triggers. main.ts adds 39 + // lastActiveFilePath dedup to avoid unnecessary refresh calls. 40 + this.registerEvent( 41 + this.app.workspace.on("active-leaf-change", (leaf: WorkspaceLeaf | null) => { 42 + this.onActiveLeafChange(leaf); 43 + }) 44 + ); 45 + 46 + // Contract #6: vault modify event 47 + // NOTE: mapView.ts also registers this event with its own write-guard check. 48 + // Both handlers may fire; the view's debounce collapses duplicate triggers. 49 + this.registerEvent( 50 + this.app.vault.on("modify", (file: TAbstractFile) => { 51 + this.onFileModify(file as TFile); 52 + }) 53 + ); 54 + } 55 + 56 + onunload(): void { 57 + // Contract #7: Obsidian handles view deregistration automatically. 58 + // No special cleanup needed. 59 + } 60 + 61 + /** 62 + * Contract #4: Open the map view in the right sidebar as a singleton. 63 + */ 64 + private async activateView(): Promise<void> { 65 + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE); 66 + 67 + if (leaves.length > 0) { 68 + // Reveal existing leaf 69 + this.app.workspace.revealLeaf(leaves[0]); 70 + return; 71 + } 72 + 73 + // Create new leaf in right sidebar 74 + const leaf = this.app.workspace.getRightLeaf(false); 75 + if (!leaf) return; 76 + await leaf.setViewState({ 77 + type: VIEW_TYPE, 78 + active: true, 79 + }); 80 + this.app.workspace.revealLeaf(leaf); 81 + } 82 + 83 + /** 84 + * Contract #5: When active leaf changes, call the view's refresh() 85 + * if the new leaf is a MarkdownView and the file has changed. 86 + */ 87 + private onActiveLeafChange(_leaf: WorkspaceLeaf | null): void { 88 + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 89 + if (!mdView) return; 90 + 91 + // Dedup: skip if same file as last time 92 + const filePath = mdView.file?.path ?? null; 93 + if (filePath !== null && filePath === this.lastActiveFilePath) return; 94 + this.lastActiveFilePath = filePath; 95 + 96 + this.refreshMapView(); 97 + } 98 + 99 + /** 100 + * Contract #6: When a file is modified, refresh the map view 101 + * if the modified file is the currently active file. 102 + */ 103 + private onFileModify(file: TFile): void { 104 + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 105 + if (!mdView?.file || mdView.file !== file) return; 106 + 107 + this.refreshMapView(); 108 + } 109 + 110 + /** 111 + * Find the map-viewer view and call its refresh() method. 112 + */ 113 + private refreshMapView(): void { 114 + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE); 115 + if (leaves.length === 0) return; 116 + 117 + const view = leaves[0].view; 118 + if (view && typeof (view as MapViewerView).refresh === "function") { 119 + (view as MapViewerView).refresh(); 120 + } 121 + } 122 + }
+569
src/mapRenderer.ts
··· 1 + /** 2 + * mapRenderer.ts — Leaflet Map (Effectful/DOM) 3 + * 4 + * Create and manage a Leaflet map with place markers, selection highlighting, 5 + * and popups. Uses Stadia Maps Watercolor + CartoDB Light Labels tiles. 6 + */ 7 + 8 + import * as L from "leaflet"; 9 + import type { Place } from "./parser"; 10 + 11 + // ─── Types ──────────────────────────────────────────────────────────── 12 + 13 + export interface MapCallbacks { 14 + onPlaceSelect?: (places: Place[]) => void; 15 + } 16 + 17 + export interface MapController { 18 + updateMarkers(places: Place[], fitBounds?: boolean): void; 19 + selectPlace(place: Place | null): void; 20 + fitBounds(): void; 21 + invalidateSize(): void; 22 + destroy(): void; 23 + } 24 + 25 + // ─── Constants ──────────────────────────────────────────────────────── 26 + 27 + // Stamen Watercolor tiles hosted by the Smithsonian / Cooper Hewitt (free, no API key) 28 + const WATERCOLOR_URL = 29 + "https://watercolormaps.collection.cooperhewitt.org/tile/watercolor/{z}/{x}/{y}.jpg"; 30 + // CartoDB light labels overlay (free, no API key) 31 + const LABELS_URL = 32 + "https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png"; 33 + 34 + const DEFAULT_CENTER: L.LatLngExpression = [20, 0]; 35 + const DEFAULT_ZOOM = 2; 36 + const SINGLE_MARKER_ZOOM = 13; 37 + 38 + // ─── Leaflet CSS Injection ──────────────────────────────────────────── 39 + 40 + /** Leaflet CSS as a minimal inline string — injected into document.head once. */ 41 + const LEAFLET_CSS_ID = "leaflet-css"; 42 + 43 + function injectLeafletCSS(): void { 44 + if (document.getElementById(LEAFLET_CSS_ID)) return; 45 + 46 + const style = document.createElement("style"); 47 + style.id = LEAFLET_CSS_ID; 48 + // Structural Leaflet CSS only — positioning, z-index, overflow, cursors. 49 + // All cosmetic/visual styles (colors, backgrounds, shadows, animations) 50 + // are owned by styles.css for Obsidian theme compatibility. 51 + style.textContent = ` 52 + .leaflet-pane, 53 + .leaflet-tile, 54 + .leaflet-marker-icon, 55 + .leaflet-marker-shadow, 56 + .leaflet-tile-container, 57 + .leaflet-pane > svg, 58 + .leaflet-pane > canvas, 59 + .leaflet-zoom-box, 60 + .leaflet-image-layer, 61 + .leaflet-layer { position: absolute; left: 0; top: 0; } 62 + .leaflet-container { overflow: hidden; -webkit-tap-highlight-color: transparent; } 63 + .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow { user-select: none; -webkit-user-select: none; } 64 + .leaflet-tile::selection { background: transparent; } 65 + .leaflet-safari .leaflet-tile { image-rendering: -webkit-optimize-contrast; } 66 + .leaflet-tile { filter: inherit; visibility: hidden; } 67 + .leaflet-tile-loaded { visibility: inherit; } 68 + .leaflet-zoom-box { width: 0; height: 0; box-sizing: border-box; z-index: 800; } 69 + .leaflet-overlay-pane svg { -moz-user-select: none; } 70 + .leaflet-pane { z-index: 400; } 71 + .leaflet-tile-pane { z-index: 200; } 72 + .leaflet-overlay-pane { z-index: 400; } 73 + .leaflet-shadow-pane { z-index: 500; } 74 + .leaflet-marker-pane { z-index: 600; } 75 + .leaflet-tooltip-pane { z-index: 650; } 76 + .leaflet-popup-pane { z-index: 700; } 77 + .leaflet-map-pane canvas { z-index: 100; } 78 + .leaflet-map-pane svg { z-index: 200; } 79 + .leaflet-control { position: relative; z-index: 800; pointer-events: visiblePainted; pointer-events: auto; } 80 + .leaflet-top, .leaflet-bottom { position: absolute; z-index: 1000; pointer-events: none; } 81 + .leaflet-top { top: 0; } 82 + .leaflet-right { right: 0; } 83 + .leaflet-bottom { bottom: 0; } 84 + .leaflet-left { left: 0; } 85 + .leaflet-popup-content-wrapper { padding: 1px; text-align: left; } 86 + .leaflet-popup-tip-container { width: 40px; height: 20px; position: absolute; left: 50%; margin-left: -20px; overflow: hidden; pointer-events: none; } 87 + .leaflet-popup-tip { width: 17px; height: 17px; padding: 1px; margin: -10px auto 0; transform: rotate(45deg); } 88 + .leaflet-popup-close-button { position: absolute; top: 0; right: 0; border: none; text-align: center; width: 24px; height: 24px; font: 16px/24px Tahoma, Verdana, sans-serif; text-decoration: none; background: transparent; cursor: pointer; } 89 + .leaflet-popup { position: absolute; text-align: center; margin-bottom: 20px; } 90 + .leaflet-control-zoom a { width: 30px; height: 30px; line-height: 30px; display: block; text-align: center; text-decoration: none; font: bold 18px 'Lucida Console', Monaco, monospace; cursor: pointer; } 91 + .leaflet-control-zoom-in { border-top-left-radius: 2px; border-top-right-radius: 2px; } 92 + .leaflet-control-zoom-out { border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; } 93 + .leaflet-control-attribution { padding: 0 5px; } 94 + .leaflet-control-attribution a { text-decoration: none; } 95 + .leaflet-grab { cursor: grab; } 96 + .leaflet-dragging .leaflet-grab { cursor: grabbing; } 97 + .leaflet-fade-anim .leaflet-popup { opacity: 1; transition: opacity 0.2s linear; } 98 + .leaflet-zoom-anim .leaflet-zoom-animated { transition: transform 0.25s cubic-bezier(0,0,0.25,1); } 99 + `; 100 + document.head.appendChild(style); 101 + } 102 + 103 + // ─── Color Utilities ────────────────────────────────────────────────── 104 + 105 + /** 106 + * Parse any CSS color string to RGB components using a canvas 2d context. 107 + * Handles hex, rgb(), rgba(), hsl(), hsla(), oklch(), named colors, etc. 108 + * Falls back to regex parsing if canvas is unavailable (e.g., in tests). 109 + * Returns null if the color cannot be parsed. 110 + */ 111 + function parseColorToRgb(color: string): { r: number; g: number; b: number } | null { 112 + const trimmed = color.trim(); 113 + if (!trimmed) return null; 114 + 115 + // Try canvas-based parsing first (handles all CSS color formats) 116 + try { 117 + const ctx = document.createElement("canvas").getContext("2d"); 118 + if (ctx) { 119 + ctx.fillStyle = "#000000"; // reset to known value 120 + ctx.fillStyle = trimmed; 121 + // If the browser didn't recognize the color, fillStyle stays "#000000" 122 + const result = ctx.fillStyle; 123 + if (result === "#000000" && trimmed.toLowerCase() !== "#000000" && trimmed.toLowerCase() !== "black") { 124 + // Color was not recognized — fall through to regex 125 + } else { 126 + // Parse the result (always #rrggbb or an rgb()/rgba() string) 127 + const hexMatch = result.match(/^#([0-9a-f]{6})$/i); 128 + if (hexMatch) { 129 + return { 130 + r: parseInt(hexMatch[1].substring(0, 2), 16), 131 + g: parseInt(hexMatch[1].substring(2, 4), 16), 132 + b: parseInt(hexMatch[1].substring(4, 6), 16), 133 + }; 134 + } 135 + const rgbMatch = result.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); 136 + if (rgbMatch) { 137 + return { 138 + r: parseInt(rgbMatch[1], 10), 139 + g: parseInt(rgbMatch[2], 10), 140 + b: parseInt(rgbMatch[3], 10), 141 + }; 142 + } 143 + } 144 + } 145 + } catch { 146 + // Canvas unavailable — fall through to regex 147 + } 148 + 149 + // Regex fallback for environments without canvas (tests, SSR) 150 + const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); 151 + if (hex6Match) { 152 + return { 153 + r: parseInt(hex6Match[1], 16), 154 + g: parseInt(hex6Match[2], 16), 155 + b: parseInt(hex6Match[3], 16), 156 + }; 157 + } 158 + 159 + const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); 160 + if (hex3Match) { 161 + return { 162 + r: parseInt(hex3Match[1] + hex3Match[1], 16), 163 + g: parseInt(hex3Match[2] + hex3Match[2], 16), 164 + b: parseInt(hex3Match[3] + hex3Match[3], 16), 165 + }; 166 + } 167 + 168 + const rgbMatch = trimmed.match(/^rgb\(\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*\)$/i); 169 + if (rgbMatch) { 170 + const r = parseInt(rgbMatch[1], 10); 171 + const g = parseInt(rgbMatch[2], 10); 172 + const b = parseInt(rgbMatch[3], 10); 173 + if (r <= 255 && g <= 255 && b <= 255) { 174 + return { r, g, b }; 175 + } 176 + } 177 + 178 + return null; 179 + } 180 + 181 + /** 182 + * Convert RGB to hex string. 183 + */ 184 + function rgbToHex(r: number, g: number, b: number): string { 185 + const toHex = (n: number) => 186 + Math.max(0, Math.min(255, Math.round(n))) 187 + .toString(16) 188 + .padStart(2, "0"); 189 + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; 190 + } 191 + 192 + const FALLBACK_ACCENT = "#7b6cd9"; 193 + 194 + /** 195 + * Darken a CSS color by a percentage (0-100). 196 + * Falls back to darkening the default accent if the color can't be parsed. 197 + */ 198 + function darkenColor(color: string, percent: number): string { 199 + const rgb = parseColorToRgb(color); 200 + if (!rgb) { 201 + // Can't parse — darken the fallback instead 202 + const fallback = parseColorToRgb(FALLBACK_ACCENT)!; 203 + const factor = 1 - percent / 100; 204 + return rgbToHex(fallback.r * factor, fallback.g * factor, fallback.b * factor); 205 + } 206 + const factor = 1 - percent / 100; 207 + return rgbToHex(rgb.r * factor, rgb.g * factor, rgb.b * factor); 208 + } 209 + 210 + /** 211 + * Get the accent color from Obsidian CSS variables, with a sensible fallback. 212 + * Always returns a value that can be used in SVG fill/stroke attributes. 213 + */ 214 + function getAccentColor(): string { 215 + try { 216 + const style = getComputedStyle(document.body); 217 + const accent = style.getPropertyValue("--interactive-accent").trim(); 218 + if (!accent) return FALLBACK_ACCENT; 219 + // Validate that we can parse it — if not, return fallback hex 220 + const rgb = parseColorToRgb(accent); 221 + if (!rgb) return FALLBACK_ACCENT; 222 + return rgbToHex(rgb.r, rgb.g, rgb.b); 223 + } catch { 224 + return FALLBACK_ACCENT; 225 + } 226 + } 227 + 228 + // ─── SVG Pin Icon ───────────────────────────────────────────────────── 229 + 230 + /** Validate that a string is a safe hex color for SVG attributes. */ 231 + function isHexColor(s: string): boolean { 232 + return /^#[0-9a-f]{6}$/i.test(s); 233 + } 234 + 235 + /** 236 + * Create an SVG teardrop pin icon for map markers. 237 + */ 238 + function createPinIcon(fillColor: string, strokeColor: string): L.DivIcon { 239 + // Ensure colors are safe hex values to prevent SVG injection 240 + const safeFill = isHexColor(fillColor) ? fillColor : FALLBACK_ACCENT; 241 + const safeStroke = isHexColor(strokeColor) ? strokeColor : FALLBACK_ACCENT; 242 + 243 + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="36" viewBox="0 0 24 36" class="teardrop-pin"> 244 + <path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" 245 + fill="${safeFill}" stroke="${safeStroke}" stroke-width="1.5"/> 246 + <circle cx="12" cy="12" r="4" fill="white" opacity="0.9"/> 247 + </svg>`; 248 + 249 + return L.divIcon({ 250 + html: svg, 251 + className: "map-viewer-pin", 252 + iconSize: [24, 36] as L.PointExpression, 253 + iconAnchor: [12, 36] as L.PointExpression, 254 + popupAnchor: [0, -36] as L.PointExpression, 255 + }); 256 + } 257 + 258 + // ─── Marker Grouping ────────────────────────────────────────────────── 259 + 260 + interface MarkerGroup { 261 + key: string; 262 + lat: number; 263 + lng: number; 264 + places: Place[]; 265 + } 266 + 267 + /** 268 + * Group places by identical coordinates using toFixed(6) for comparison. 269 + * Only includes places with both lat and lng defined. 270 + */ 271 + function groupPlacesByLocation(places: Place[]): MarkerGroup[] { 272 + const groups = new Map<string, MarkerGroup>(); 273 + 274 + for (const place of places) { 275 + if (place.lat == null || place.lng == null) continue; 276 + 277 + const key = `${place.lat.toFixed(6)},${place.lng.toFixed(6)}`; 278 + 279 + if (groups.has(key)) { 280 + groups.get(key)!.places.push(place); 281 + } else { 282 + groups.set(key, { 283 + key, 284 + lat: place.lat, 285 + lng: place.lng, 286 + places: [place], 287 + }); 288 + } 289 + } 290 + 291 + return Array.from(groups.values()); 292 + } 293 + 294 + // ─── Popup Content ──────────────────────────────────────────────────── 295 + 296 + /** 297 + * Build popup HTML for a marker group. 298 + * Each place is shown as a linked name (if url exists) or plain text. 299 + */ 300 + function buildPopupContent(places: Place[]): string { 301 + const items = places.map((p) => { 302 + if (p.url && isSafeUrl(p.url)) { 303 + return `<a href="${escapeAttr(p.url)}" target="_blank" rel="noopener">${escapeHtml(p.name)}</a>`; 304 + } 305 + return escapeHtml(p.name); 306 + }); 307 + 308 + return items.join("<br>"); 309 + } 310 + 311 + /** Check if a URL is safe for use in href (reject javascript:, data:, vbscript:, etc.) */ 312 + function isSafeUrl(url: string): boolean { 313 + const trimmed = url.trim().toLowerCase(); 314 + // Only allow http and https 315 + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { 316 + return true; 317 + } 318 + // Reject protocol-relative URLs (//evil.com) and any explicit protocol 319 + if (trimmed.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { 320 + return false; 321 + } 322 + // Allow relative URLs (no protocol) 323 + return true; 324 + } 325 + 326 + function escapeHtml(text: string): string { 327 + return text 328 + .replace(/&/g, "&amp;") 329 + .replace(/</g, "&lt;") 330 + .replace(/>/g, "&gt;") 331 + .replace(/"/g, "&quot;") 332 + .replace(/'/g, "&#39;"); 333 + } 334 + 335 + /** 336 + * Escape a string for safe use in a double-quoted HTML attribute value. 337 + * Only safe for `attr="..."` contexts — not single-quoted or unquoted attributes. 338 + */ 339 + function escapeAttr(text: string): string { 340 + return escapeHtml(text); 341 + } 342 + 343 + // ─── createMap ──────────────────────────────────────────────────────── 344 + 345 + export function createMap( 346 + container: HTMLElement, 347 + places: Place[], 348 + callbacks: MapCallbacks 349 + ): MapController { 350 + // Contract 1: Inject Leaflet CSS (idempotent) 351 + injectLeafletCSS(); 352 + 353 + // Contract 2: Create map div inside container 354 + const mapDiv = document.createElement("div"); 355 + mapDiv.className = "map-viewer-map"; 356 + container.appendChild(mapDiv); 357 + 358 + // Initialize Leaflet map 359 + const map = L.map(mapDiv, { 360 + zoomControl: true, 361 + attributionControl: true, 362 + }); 363 + 364 + // Contract 3: Add tile layers 365 + const TILE_ATTRIBUTION = 366 + 'Map tiles by <a href="https://stamen.com/">Stamen Design</a>, ' + 367 + 'hosted by <a href="https://collection.cooperhewitt.org/">Cooper Hewitt</a>. ' + 368 + 'Labels by <a href="https://carto.com/">CARTO</a>. ' + 369 + 'Data &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'; 370 + 371 + L.tileLayer(WATERCOLOR_URL, { 372 + maxZoom: 18, 373 + attribution: TILE_ATTRIBUTION, 374 + }).addTo(map); 375 + 376 + L.tileLayer(LABELS_URL, { 377 + maxZoom: 18, 378 + attribution: TILE_ATTRIBUTION, 379 + subdomains: "abcd", 380 + }).addTo(map); 381 + 382 + // Internal state 383 + let currentMarkers: L.Marker[] = []; 384 + let currentGroups: MarkerGroup[] = []; 385 + let highlightRing: L.CircleMarker | null = null; 386 + let destroyed = false; 387 + 388 + // Map from startLine to MarkerGroup for selectPlace lookup 389 + let startLineToGroup = new Map<number, MarkerGroup>(); 390 + // Map from group key to L.Marker 391 + let groupKeyToMarker = new Map<string, L.Marker>(); 392 + 393 + // ─── Marker Management ────────────────────────────────────────── 394 + 395 + function clearMarkers(): void { 396 + // Remove highlight ring 397 + removeHighlight(); 398 + 399 + // Remove all markers 400 + for (const marker of currentMarkers) { 401 + marker.remove(); 402 + } 403 + currentMarkers = []; 404 + currentGroups = []; 405 + startLineToGroup = new Map(); 406 + groupKeyToMarker = new Map(); 407 + } 408 + 409 + function createMarkers(newPlaces: Place[]): void { 410 + const accentColor = getAccentColor(); 411 + const strokeColor = darkenColor(accentColor, 25); 412 + const icon = createPinIcon(accentColor, strokeColor); 413 + 414 + const groups = groupPlacesByLocation(newPlaces); 415 + currentGroups = groups; 416 + 417 + // Build lookup maps 418 + for (const group of groups) { 419 + for (const place of group.places) { 420 + startLineToGroup.set(place.startLine, group); 421 + } 422 + } 423 + 424 + // Create markers 425 + for (const group of groups) { 426 + const marker = L.marker([group.lat, group.lng], { icon }).addTo(map); 427 + 428 + // Contract 6: Popup 429 + const popupContent = buildPopupContent(group.places); 430 + marker.bindPopup(popupContent); 431 + 432 + // Contract 6: Click handler 433 + marker.on("click", (e: unknown) => { 434 + L.DomEvent.stopPropagation(e as L.LeafletEvent); 435 + callbacks.onPlaceSelect?.(group.places); 436 + }); 437 + 438 + currentMarkers.push(marker); 439 + groupKeyToMarker.set(group.key, marker); 440 + } 441 + } 442 + 443 + function removeHighlight(): void { 444 + if (highlightRing) { 445 + highlightRing.remove(); 446 + highlightRing = null; 447 + } 448 + } 449 + 450 + function applyFitBounds(): void { 451 + if (currentGroups.length === 0) { 452 + // Zero markers: show default world view 453 + return; // View already set or preserve current 454 + } 455 + 456 + if (currentGroups.length === 1) { 457 + // Single marker: center at zoom 13 458 + const group = currentGroups[0]; 459 + map.setView({ lat: group.lat, lng: group.lng }, SINGLE_MARKER_ZOOM); 460 + return; 461 + } 462 + 463 + // Multiple markers: fit bounds with padding 464 + const bounds = L.latLngBounds( 465 + currentGroups.map((g) => [g.lat, g.lng] as L.LatLngExpression) 466 + ); 467 + map.fitBounds(bounds, { padding: [50, 50] }); 468 + } 469 + 470 + // ─── Initial Setup ────────────────────────────────────────────── 471 + 472 + // Create initial markers from provided places 473 + createMarkers(places); 474 + 475 + // Set initial view based on marker count 476 + if (currentGroups.length === 0) { 477 + map.setView(DEFAULT_CENTER, DEFAULT_ZOOM); 478 + } else { 479 + applyFitBounds(); 480 + } 481 + 482 + // Contract 13: ResizeObserver 483 + const resizeObserver = new ResizeObserver(() => { 484 + if (!destroyed) { 485 + map.invalidateSize(); 486 + } 487 + }); 488 + resizeObserver.observe(container); 489 + 490 + // ─── Controller ───────────────────────────────────────────────── 491 + 492 + const controller: MapController = { 493 + updateMarkers(newPlaces: Place[], fitBoundsArg?: boolean): void { 494 + if (destroyed) return; 495 + 496 + const shouldFit = fitBoundsArg !== false; // default true 497 + 498 + clearMarkers(); 499 + createMarkers(newPlaces); 500 + 501 + if (shouldFit) { 502 + applyFitBounds(); 503 + } 504 + }, 505 + 506 + selectPlace(place: Place | null): void { 507 + if (destroyed) return; 508 + 509 + // Remove existing highlight 510 + removeHighlight(); 511 + 512 + if (place === null) return; 513 + 514 + // Find the group for this place's startLine 515 + const group = startLineToGroup.get(place.startLine); 516 + if (!group) return; 517 + 518 + const marker = groupKeyToMarker.get(group.key); 519 + if (!marker) return; 520 + 521 + // Pan/zoom to marker — never zoom out, only zoom in if needed 522 + const targetZoom = Math.max(map.getZoom(), SINGLE_MARKER_ZOOM); 523 + map.setView({ lat: group.lat, lng: group.lng }, targetZoom); 524 + 525 + // Show highlight ring 526 + highlightRing = L.circleMarker([group.lat, group.lng], { 527 + radius: 20, 528 + color: getAccentColor(), 529 + weight: 3, 530 + fillOpacity: 0, 531 + className: "map-marker-highlight", 532 + }).addTo(map); 533 + 534 + // Open popup 535 + marker.openPopup(); 536 + }, 537 + 538 + fitBounds(): void { 539 + if (destroyed) return; 540 + applyFitBounds(); 541 + }, 542 + 543 + invalidateSize(): void { 544 + if (destroyed) return; 545 + map.invalidateSize(); 546 + }, 547 + 548 + destroy(): void { 549 + if (destroyed) return; 550 + destroyed = true; 551 + 552 + // Disconnect ResizeObserver 553 + resizeObserver.disconnect(); 554 + 555 + // Clear all markers, highlight, and lookup maps 556 + clearMarkers(); 557 + 558 + // Remove map 559 + map.remove(); 560 + 561 + // Remove map div from container 562 + if (mapDiv.parentNode) { 563 + mapDiv.parentNode.removeChild(mapDiv); 564 + } 565 + }, 566 + }; 567 + 568 + return controller; 569 + }
+401
src/mapView.ts
··· 1 + /** 2 + * mapView.ts — Sidebar View (Effectful/Obsidian API) 3 + * 4 + * Obsidian ItemView subclass that reads the active note, parses places, 5 + * manages geocoding, writes geo data back to the note, and synchronizes 6 + * cursor position with map markers. 7 + */ 8 + 9 + import { ItemView, MarkdownView, Notice } from "obsidian"; 10 + import type { WorkspaceLeaf, TAbstractFile, TFile } from "obsidian"; 11 + import { parsePlaces, GEO_LINE_RE } from "./parser"; 12 + import type { Place } from "./parser"; 13 + import { geocodePlaces } from "./geocoder"; 14 + import { createMap } from "./mapRenderer"; 15 + import type { MapController } from "./mapRenderer"; 16 + 17 + export const VIEW_TYPE = "map-viewer"; 18 + 19 + const DEBOUNCE_MS = 300; 20 + // Grace period after vault.process() completes before re-enabling modify 21 + // event handling. Obsidian fires modify events asynchronously after writes; 22 + // this window suppresses self-triggered refreshes. Trade-off: legitimate 23 + // external edits within this window are also suppressed. 24 + const WRITE_GUARD_MS = 500; 25 + // Obsidian has no first-class cursor-move event. CodeMirror's cursorActivity 26 + // extension requires @codemirror/view (external). Polling is the standard 27 + // approach for Obsidian plugins; 200ms balances responsiveness vs cost. 28 + const CURSOR_POLL_MS = 200; 29 + 30 + function computeFingerprint(places: Place[]): string { 31 + return places 32 + .map((p) => `${p.name}::${p.lat}::${p.lng}::${p.url ?? ""}::${p.startLine}`) 33 + .join("|"); 34 + } 35 + 36 + export class MapViewerView extends ItemView { 37 + private mapController: MapController | null = null; 38 + private currentPlaces: Place[] = []; 39 + private lastFingerprint: string | null = null; 40 + private writeGuardCounter = 0; 41 + private currentAbortController: AbortController | null = null; 42 + private debounceTimer: ReturnType<typeof setTimeout> | null = null; 43 + private destroyed = false; 44 + private lastSelectedStartLine: number | null = null; 45 + private inFlightRefresh: Promise<void> | null = null; 46 + 47 + constructor(leaf: WorkspaceLeaf) { 48 + super(leaf); 49 + } 50 + 51 + getViewType(): string { 52 + return VIEW_TYPE; 53 + } 54 + 55 + getDisplayText(): string { 56 + return "Map"; 57 + } 58 + 59 + getIcon(): string { 60 + return "map-pin"; 61 + } 62 + 63 + async onOpen(): Promise<void> { 64 + const container = document.createElement("div"); 65 + container.className = "map-viewer-container"; 66 + this.contentEl.appendChild(container); 67 + 68 + this.mapController = createMap(container, [], { 69 + onPlaceSelect: (places: Place[]) => this.handlePlaceSelect(places), 70 + }); 71 + 72 + this.registerEvents(); 73 + this.startCursorSync(); 74 + this.scheduleRefresh(); 75 + } 76 + 77 + async onClose(): Promise<void> { 78 + this.destroyed = true; 79 + 80 + if (this.debounceTimer !== null) { 81 + clearTimeout(this.debounceTimer); 82 + this.debounceTimer = null; 83 + } 84 + 85 + if (this.currentAbortController) { 86 + this.currentAbortController.abort(); 87 + this.currentAbortController = null; 88 + } 89 + 90 + if (this.mapController) { 91 + this.mapController.destroy(); 92 + this.mapController = null; 93 + } 94 + } 95 + 96 + // NOTE: main.ts also registers active-leaf-change and modify handlers for 97 + // plugin-level concerns (lastActiveFilePath dedup, active file check). Both 98 + // sets of handlers may fire; scheduleRefresh() debounce collapses duplicates. 99 + // The view's handlers add view-specific logic: clearMap on non-MarkdownView 100 + // leaf change, and write-guard checks on modify. 101 + private registerEvents(): void { 102 + this.registerEvent( 103 + this.app.vault.on("modify", (file: TAbstractFile) => this.onFileModify(file as TFile)) 104 + ); 105 + 106 + this.registerEvent( 107 + this.app.workspace.on( 108 + "active-leaf-change", 109 + (leaf) => this.onActiveLeafChange(leaf) 110 + ) 111 + ); 112 + } 113 + 114 + private onFileModify(file: TFile): void { 115 + if (this.writeGuardCounter > 0) return; 116 + 117 + // Only refresh if the modified file is the currently active file 118 + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 119 + if (!mdView?.file || mdView.file !== file) return; 120 + 121 + this.scheduleRefresh(); 122 + } 123 + 124 + private onActiveLeafChange(_leaf: WorkspaceLeaf | null): void { 125 + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 126 + if (!mdView) { 127 + this.clearMap(); 128 + return; 129 + } 130 + this.scheduleRefresh(); 131 + } 132 + 133 + /** 134 + * Public entry point for external callers (e.g., main.ts event handlers). 135 + * Delegates to the debounced scheduleRefresh(). 136 + */ 137 + refresh(): void { 138 + this.scheduleRefresh(); 139 + } 140 + 141 + private scheduleRefresh(): void { 142 + if (this.debounceTimer !== null) { 143 + clearTimeout(this.debounceTimer); 144 + } 145 + 146 + this.debounceTimer = setTimeout(() => { 147 + this.debounceTimer = null; 148 + const promise = this.doRefresh().catch((err) => { 149 + if (!this.destroyed) { 150 + console.warn("[MapViewer] Refresh failed:", err); 151 + } 152 + }); 153 + this.inFlightRefresh = promise; 154 + promise.finally(() => { 155 + if (this.inFlightRefresh === promise) { 156 + this.inFlightRefresh = null; 157 + } 158 + }); 159 + }, DEBOUNCE_MS); 160 + } 161 + 162 + private async doRefresh(): Promise<void> { 163 + // Abort any in-flight operation from a previous refresh 164 + if (this.currentAbortController) { 165 + this.currentAbortController.abort(); 166 + this.currentAbortController = null; 167 + } 168 + 169 + // Create an abort controller for the entire refresh lifecycle 170 + const abortController = new AbortController(); 171 + this.currentAbortController = abortController; 172 + 173 + if (this.destroyed) return; 174 + 175 + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 176 + if (!mdView || !mdView.file) { 177 + this.clearMap(); 178 + return; 179 + } 180 + 181 + const file = mdView.file; 182 + const content = await this.app.vault.cachedRead(file); 183 + 184 + // Check for cancellation after async boundary 185 + if (abortController.signal.aborted || this.destroyed) return; 186 + 187 + const places = parsePlaces(content); 188 + const fingerprint = computeFingerprint(places); 189 + 190 + // Always store fresh places (fresh line ranges for cursor sync) 191 + this.currentPlaces = places; 192 + // Reset cursor sync state — line numbers may have shifted after re-parse 193 + this.lastSelectedStartLine = null; 194 + 195 + if (this.lastFingerprint === null || fingerprint !== this.lastFingerprint) { 196 + this.lastFingerprint = fingerprint; 197 + if (this.mapController) { 198 + this.mapController.updateMarkers(places); 199 + } 200 + } 201 + 202 + // Only geocode places that don't already have coordinates 203 + const placesToGeocode = places.filter( 204 + (p) => p.lat == null || p.lng == null 205 + ); 206 + 207 + if (placesToGeocode.length > 0) { 208 + await this.geocodeAndWriteBack( 209 + places, 210 + placesToGeocode, 211 + file, 212 + abortController 213 + ); 214 + } 215 + } 216 + 217 + private async geocodeAndWriteBack( 218 + allPlaces: Place[], 219 + placesToGeocode: Place[], 220 + file: TFile, 221 + abortController: AbortController 222 + ): Promise<void> { 223 + try { 224 + // Pass only the places that need geocoding. The geocoder mutates in-place. 225 + await geocodePlaces(placesToGeocode, { 226 + onNotice: (msg) => new Notice(msg), 227 + }, abortController.signal); 228 + 229 + if (abortController.signal.aborted || this.destroyed) return; 230 + 231 + // Check only the places that were ATTEMPTED for geocoding 232 + const successfullyGeocoded = placesToGeocode.filter( 233 + (p) => p.lat != null && p.lng != null 234 + ); 235 + 236 + if (successfullyGeocoded.length === 0) { 237 + new Notice("Map Viewer: No places could be geocoded"); 238 + console.warn("[MapViewer] Geocoding produced zero results"); 239 + return; 240 + } 241 + 242 + // geocodePlaces() mutates in-place. placesToGeocode holds the same object 243 + // references as allPlaces (via Array.filter), so allPlaces already has the 244 + // geocoded coordinates. No explicit copy-back needed. 245 + 246 + // Only write back newly geocoded places, not pre-existing ones 247 + this.writeGuardCounter++; 248 + 249 + try { 250 + await this.app.vault.process(file, (currentContent: string) => { 251 + return this.applyGeoWriteBack(currentContent, successfullyGeocoded); 252 + }); 253 + } finally { 254 + setTimeout(() => { 255 + this.writeGuardCounter--; 256 + }, WRITE_GUARD_MS); 257 + } 258 + 259 + if (abortController.signal.aborted || this.destroyed) return; 260 + 261 + // Update map with new coordinates (allPlaces now has geocoded coords) 262 + if (this.mapController) { 263 + const newFingerprint = computeFingerprint(allPlaces); 264 + if (newFingerprint !== this.lastFingerprint) { 265 + this.lastFingerprint = newFingerprint; 266 + this.mapController.updateMarkers(allPlaces); 267 + } 268 + } 269 + } catch (err) { 270 + if (!abortController.signal.aborted && !this.destroyed) { 271 + console.warn("[MapViewer] Geocoding failed:", err); 272 + } 273 + } 274 + } 275 + 276 + private applyGeoWriteBack( 277 + currentContent: string, 278 + geocodedPlaces: Place[] 279 + ): string { 280 + const currentPlaces = parsePlaces(currentContent); 281 + const lines = currentContent.split("\n"); 282 + 283 + // Build lookup: normalized name -> geocoded Place (first occurrence wins for coords) 284 + const geocodedByName = new Map<string, Place>(); 285 + for (const p of geocodedPlaces) { 286 + if (p.lat != null && p.lng != null) { 287 + const key = p.name.trim().toLowerCase(); 288 + if (!geocodedByName.has(key)) { 289 + geocodedByName.set(key, p); 290 + } 291 + } 292 + } 293 + 294 + const operations: Array<{ 295 + type: "insert" | "replace"; 296 + lineIndex: number; 297 + content: string; 298 + }> = []; 299 + 300 + for (const currentPlace of currentPlaces) { 301 + const normalizedName = currentPlace.name.trim().toLowerCase(); 302 + const geocoded = geocodedByName.get(normalizedName); 303 + if (!geocoded || geocoded.lat == null || geocoded.lng == null) continue; 304 + 305 + const geoLine = `\t* geo: ${geocoded.lat.toFixed(6)},${geocoded.lng.toFixed(6)}`; 306 + 307 + let existingGeoLine = -1; 308 + for (let i = currentPlace.startLine + 1; i <= currentPlace.endLine; i++) { 309 + if (GEO_LINE_RE.test(lines[i])) { 310 + existingGeoLine = i; 311 + break; 312 + } 313 + } 314 + 315 + if (existingGeoLine >= 0) { 316 + operations.push({ 317 + type: "replace", 318 + lineIndex: existingGeoLine, 319 + content: geoLine, 320 + }); 321 + } else { 322 + operations.push({ 323 + type: "insert", 324 + lineIndex: currentPlace.endLine, 325 + content: geoLine, 326 + }); 327 + } 328 + } 329 + 330 + operations.sort((a, b) => b.lineIndex - a.lineIndex); 331 + 332 + for (const op of operations) { 333 + if (op.type === "replace") { 334 + lines[op.lineIndex] = op.content; 335 + } else { 336 + lines.splice(op.lineIndex + 1, 0, op.content); 337 + } 338 + } 339 + 340 + return lines.join("\n"); 341 + } 342 + 343 + private startCursorSync(): void { 344 + const intervalId = window.setInterval(() => { 345 + this.pollCursorPosition(); 346 + }, CURSOR_POLL_MS); 347 + this.registerInterval(intervalId); 348 + } 349 + 350 + private pollCursorPosition(): void { 351 + if (!this.mapController || this.currentPlaces.length === 0) return; 352 + 353 + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 354 + if (!mdView || !mdView.editor) return; 355 + 356 + const cursor = mdView.editor.getCursor(); 357 + const cursorLine = cursor.line; 358 + 359 + for (const place of this.currentPlaces) { 360 + if (cursorLine >= place.startLine && cursorLine <= place.endLine) { 361 + // Skip redundant selectPlace calls when cursor hasn't moved to a new place 362 + if (this.lastSelectedStartLine === place.startLine) return; 363 + this.lastSelectedStartLine = place.startLine; 364 + this.mapController.selectPlace(place); 365 + return; 366 + } 367 + } 368 + 369 + // Deselect — cursor is in a dead zone 370 + if (this.lastSelectedStartLine !== null) { 371 + this.lastSelectedStartLine = null; 372 + this.mapController.selectPlace(null); 373 + } 374 + } 375 + 376 + private handlePlaceSelect(places: Place[]): void { 377 + if (!places || places.length === 0) return; 378 + 379 + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); 380 + if (!mdView || !mdView.editor) return; 381 + 382 + const firstPlace = places[0]; 383 + mdView.editor.setCursor({ line: firstPlace.startLine, ch: 0 }); 384 + mdView.editor.scrollIntoView( 385 + { 386 + from: { line: firstPlace.startLine, ch: 0 }, 387 + to: { line: firstPlace.startLine, ch: 0 }, 388 + }, 389 + true 390 + ); 391 + } 392 + 393 + private clearMap(): void { 394 + this.currentPlaces = []; 395 + this.lastFingerprint = null; 396 + this.lastSelectedStartLine = null; 397 + if (this.mapController) { 398 + this.mapController.updateMarkers([]); 399 + } 400 + } 401 + }
+8 -2
src/parser.ts
··· 26 26 */ 27 27 const SUB_BULLET_RE = /^[\t ]{2,}[*-] |^\t[*-] /; 28 28 29 - /** Regex for structured field: single word key, colon, space, then value */ 30 - const FIELD_RE = /^(\w+): (.*)$/; 29 + /** Regex for structured field: key (word chars + hyphens), colon, space, then value */ 30 + const FIELD_RE = /^([\w-]+): (.*)$/; 31 31 32 32 /** Regex for markdown link: [text](url) or [text](url "title") */ 33 33 const MD_LINK_RE = /^\[([^\]]*)\]\(([^)"]*?)(?:\s+"[^"]*")?\)$/; 34 34 35 35 /** Regex for wiki-link: [[Page]] or [[Target|Display]] */ 36 36 const WIKI_LINK_RE = /^\[\[([^\]]*)\]\]$/; 37 + 38 + /** 39 + * Regex matching a `geo:` sub-bullet line in raw note content. 40 + * Shared between parser and mapView write-back logic. 41 + */ 42 + export const GEO_LINE_RE = /^[\t ]+[*-] geo: .*/; 37 43 38 44 /** 39 45 * Regex for valid geo coordinates.
+127
styles.css
··· 1 + /* Map Viewer - styles.css */ 2 + /* All colors use Obsidian CSS variables for theme compatibility. */ 3 + /* No fixed widths/heights — responsive to sidebar resize. */ 4 + /* All Leaflet overrides scoped under .map-viewer-container to avoid */ 5 + /* bleeding into other plugins that use Leaflet. */ 6 + 7 + /* ── Map container ── */ 8 + 9 + .map-viewer-container { 10 + display: flex; 11 + flex-direction: column; 12 + width: 100%; 13 + height: 100%; 14 + overflow: hidden; 15 + } 16 + 17 + /* ── Map element ── */ 18 + 19 + .map-viewer-map { 20 + flex: 1; 21 + min-height: 0; 22 + overflow: hidden; 23 + } 24 + 25 + /* ── SVG teardrop pin markers ── */ 26 + 27 + .map-viewer-pin { 28 + background: none !important; 29 + border: none !important; 30 + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35)); 31 + } 32 + 33 + /* ── Marker highlight pulse ── */ 34 + 35 + @keyframes map-pulse { 36 + 0% { 37 + stroke-opacity: 0.8; 38 + } 39 + 100% { 40 + stroke-opacity: 0.3; 41 + } 42 + } 43 + 44 + .map-marker-highlight { 45 + animation: map-pulse 1.5s ease-in-out infinite alternate; 46 + } 47 + 48 + /* ── Popup styles (Obsidian theme integration) ── */ 49 + /* Scoped under .map-viewer-container so we don't affect other plugins. */ 50 + 51 + .map-viewer-container .leaflet-popup-content-wrapper { 52 + background: var(--background-primary); 53 + color: var(--text-normal); 54 + border-radius: 6px; 55 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 56 + font-family: var(--font-interface); 57 + font-size: 12px; 58 + } 59 + 60 + .map-viewer-container .leaflet-popup-tip { 61 + background: var(--background-primary); 62 + box-shadow: none; 63 + } 64 + 65 + .map-viewer-container .leaflet-popup-content { 66 + margin: 8px 12px; 67 + line-height: 1.5; 68 + } 69 + 70 + .map-viewer-container .leaflet-popup-content p { 71 + margin: 4px 0; 72 + } 73 + 74 + .map-viewer-container .leaflet-popup-content a { 75 + color: var(--text-accent); 76 + text-decoration: none; 77 + } 78 + 79 + .map-viewer-container .leaflet-popup-content a:hover { 80 + text-decoration: underline; 81 + } 82 + 83 + .map-viewer-container .leaflet-popup-close-button { 84 + color: var(--text-muted); 85 + } 86 + 87 + .map-viewer-container .leaflet-popup-close-button:hover, 88 + .map-viewer-container .leaflet-popup-close-button:focus { 89 + color: var(--text-normal); 90 + } 91 + 92 + /* ── Zoom control styles (theme-aware) ── */ 93 + 94 + .map-viewer-container .leaflet-control-zoom { 95 + border: 2px solid var(--background-modifier-border); 96 + border-radius: 4px; 97 + margin-top: 6px; 98 + margin-right: 6px; 99 + } 100 + 101 + .map-viewer-container .leaflet-control-zoom a { 102 + background-color: var(--background-primary); 103 + border-bottom: 1px solid var(--background-modifier-border); 104 + color: var(--text-normal); 105 + } 106 + 107 + .map-viewer-container .leaflet-control-zoom a:hover { 108 + background-color: var(--background-modifier-hover); 109 + } 110 + 111 + /* ── Attribution styles ── */ 112 + 113 + .map-viewer-container .leaflet-control-attribution { 114 + font-size: 9px; 115 + background: var(--background-primary-alt) !important; 116 + color: var(--text-faint); 117 + } 118 + 119 + .map-viewer-container .leaflet-control-attribution a { 120 + color: var(--text-muted); 121 + } 122 + 123 + /* ── Leaflet container background for dark themes ── */ 124 + 125 + .map-viewer-container .leaflet-container { 126 + background: var(--background-primary-alt); 127 + }
+116
tests/__mocks__/obsidian.ts
··· 1 + /** 2 + * Stub module for the `obsidian` package. 3 + * 4 + * The real `obsidian` npm package is types-only (no JS entry point). 5 + * This stub provides minimal runtime classes so that `import { ItemView } from "obsidian"` 6 + * resolves during testing. Tests override these via vi.doMock() for full mock behavior. 7 + */ 8 + 9 + export class Component { 10 + load() {} 11 + onload() {} 12 + unload() {} 13 + onunload() {} 14 + addChild(c: any) { return c; } 15 + removeChild(c: any) { return c; } 16 + register(_cb: () => void) {} 17 + registerEvent(_ref: any) {} 18 + registerInterval(id: number) { return id; } 19 + registerDomEvent() {} 20 + } 21 + 22 + export class View extends Component { 23 + app: any; 24 + leaf: any; 25 + containerEl: HTMLElement; 26 + icon = ""; 27 + navigation = true; 28 + 29 + constructor(leaf: any) { 30 + super(); 31 + this.leaf = leaf; 32 + this.app = leaf?.app; 33 + this.containerEl = document.createElement("div"); 34 + } 35 + 36 + getViewType(): string { return ""; } 37 + getDisplayText(): string { return ""; } 38 + getIcon(): string { return ""; } 39 + onOpen(): Promise<void> { return Promise.resolve(); } 40 + onClose(): Promise<void> { return Promise.resolve(); } 41 + onResize() {} 42 + } 43 + 44 + export class ItemView extends View { 45 + contentEl: HTMLElement; 46 + 47 + constructor(leaf: any) { 48 + super(leaf); 49 + this.contentEl = document.createElement("div"); 50 + this.containerEl.appendChild(this.contentEl); 51 + } 52 + } 53 + 54 + export class MarkdownView extends ItemView { 55 + editor: any = null; 56 + file: any = null; 57 + 58 + constructor(leaf?: any) { 59 + super(leaf || {}); 60 + } 61 + 62 + getViewType(): string { return "markdown"; } 63 + } 64 + 65 + export class Notice { 66 + noticeEl: HTMLElement; 67 + messageEl: HTMLElement; 68 + 69 + constructor(public message: string | DocumentFragment, public duration?: number) { 70 + this.noticeEl = document.createElement("div"); 71 + this.messageEl = document.createElement("div"); 72 + } 73 + 74 + setMessage(message: string | DocumentFragment) { this.message = message; return this; } 75 + hide() {} 76 + } 77 + 78 + export class Plugin extends Component { 79 + app: any; 80 + manifest: any; 81 + 82 + constructor(app: any, manifest: any) { 83 + super(); 84 + this.app = app; 85 + this.manifest = manifest; 86 + } 87 + 88 + addCommand() { return {} as any; } 89 + addRibbonIcon() { return document.createElement("div"); } 90 + registerView() {} 91 + } 92 + 93 + export class TFile { 94 + path = ""; 95 + name = ""; 96 + basename = ""; 97 + extension = "md"; 98 + stat = { ctime: 0, mtime: 0, size: 0 }; 99 + parent = null; 100 + vault: any = null; 101 + } 102 + 103 + export class TFolder { 104 + path = ""; 105 + name = ""; 106 + parent = null; 107 + children: any[] = []; 108 + vault: any = null; 109 + isRoot() { return this.path === "/"; } 110 + } 111 + 112 + export interface WorkspaceLeaf { 113 + view: View; 114 + } 115 + 116 + export interface EventRef {}
+131 -17
tests/geocoder.test.ts
··· 47 47 beforeEach(() => { 48 48 mockFetch = vi.fn(); 49 49 vi.stubGlobal("fetch", mockFetch); 50 - vi.stubGlobal("Notice", vi.fn()); 51 50 vi.spyOn(console, "warn").mockImplementation(() => {}); 52 51 }); 53 52 ··· 144 143 expect(parsedUrl.searchParams.get("format")).toBe("json"); 145 144 expect(parsedUrl.searchParams.get("limit")).toBe("1"); 146 145 expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia"); 146 + }); 147 + 148 + it("properly encodes special characters and unicode in place names", async () => { 149 + mockFetch 150 + .mockResolvedValueOnce(nominatimOk(48.137, 11.575)) 151 + .mockResolvedValueOnce(nominatimOk(48.856, 2.352)) 152 + .mockResolvedValueOnce(nominatimOk(35.659, 139.700)); 153 + 154 + vi.useFakeTimers(); 155 + const places = [ 156 + makePlace("München"), 157 + makePlace("Café & Bar"), 158 + makePlace("東京タワー"), 159 + ]; 160 + const promise = geocodePlaces(places); 161 + await vi.runAllTimersAsync(); 162 + await promise; 163 + 164 + // Verify each name was correctly passed through URL encoding 165 + const call0Url = new URL(mockFetch.mock.calls[0][0]); 166 + expect(call0Url.searchParams.get("q")).toBe("München"); 167 + 168 + const call1Url = new URL(mockFetch.mock.calls[1][0]); 169 + expect(call1Url.searchParams.get("q")).toBe("Café & Bar"); 170 + 171 + const call2Url = new URL(mockFetch.mock.calls[2][0]); 172 + expect(call2Url.searchParams.get("q")).toBe("東京タワー"); 173 + 174 + vi.useRealTimers(); 147 175 }); 148 176 }); 149 177 ··· 331 359 ); 332 360 }); 333 361 334 - it("calls onProgress for all duplicate places, not just the first", async () => { 362 + it("calls onProgress once per unique geocode, not per duplicate place", async () => { 335 363 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 336 364 337 365 const onProgress = vi.fn(); ··· 341 369 ]; 342 370 await geocodePlaces(places, { onProgress }); 343 371 344 - // onProgress should be called once for the unique geocode, but reported 345 - // for each place that shares the name 372 + // One API call = one onProgress call, reported with the first place in the group. 373 + // Duplicate places still get their lat/lng set, but onProgress fires once per unique query. 346 374 expect(onProgress).toHaveBeenCalledTimes(1); 347 - // The first place triggers the call 348 375 expect(onProgress).toHaveBeenCalledWith( 349 376 expect.objectContaining({ name: "Sagrada Familia" }), 350 377 { lat: 41.4036, lng: 2.1744 } ··· 394 421 expect(result[0].lng).toBeUndefined(); 395 422 }); 396 423 424 + it("rejects non-200 responses even if body contains valid lat/lon JSON", async () => { 425 + // A proxy or CDN could return valid-looking JSON with a non-200 status 426 + mockFetch.mockResolvedValueOnce( 427 + new Response( 428 + JSON.stringify([{ lat: "99.999", lon: "99.999" }]), 429 + { status: 500 } 430 + ) 431 + ); 432 + 433 + const result = await geocodePlaces([makePlace("Server Error Place")]); 434 + expect(result[0].lat).toBeUndefined(); 435 + expect(result[0].lng).toBeUndefined(); 436 + }); 437 + 397 438 it("handles invalid JSON response body gracefully", async () => { 398 439 mockFetch.mockResolvedValueOnce( 399 440 new Response("<html>Server Error</html>", { ··· 403 444 ); 404 445 405 446 const result = await geocodePlaces([makePlace("Bad Response")]); 447 + expect(result[0].lat).toBeUndefined(); 448 + expect(result[0].lng).toBeUndefined(); 449 + }); 450 + 451 + it("rejects out-of-range coordinates from Nominatim", async () => { 452 + mockFetch.mockResolvedValueOnce( 453 + new Response(JSON.stringify([{ lat: "999", lon: "999" }]), { 454 + status: 200, 455 + headers: { "Content-Type": "application/json" }, 456 + }) 457 + ); 458 + 459 + const result = await geocodePlaces([makePlace("Bad Coords")]); 406 460 expect(result[0].lat).toBeUndefined(); 407 461 expect(result[0].lng).toBeUndefined(); 408 462 }); ··· 454 508 expect(result[0].lat).toBe(41.4036); 455 509 expect(result[0].lng).toBe(2.1744); 456 510 }); 511 + 512 + it("handles mixed array: already-geocoded, needs-geocoding, and duplicates", async () => { 513 + vi.useFakeTimers(); 514 + // Only 2 unique names need geocoding: "The Louvre" and "Blue Bottle Coffee" 515 + // "Sagrada Familia" already has coords, "the louvre" is a duplicate 516 + mockFetch 517 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)) // The Louvre 518 + .mockResolvedValueOnce(nominatimOk(35.659, 139.700)); // Blue Bottle Coffee 519 + 520 + const places = [ 521 + makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }), 522 + makePlace("The Louvre"), 523 + makePlace("the louvre"), // duplicate, case-insensitive 524 + makePlace("Blue Bottle Coffee"), 525 + ]; 526 + const promise = geocodePlaces(places); 527 + await vi.runAllTimersAsync(); 528 + const result = await promise; 529 + 530 + expect(result).toHaveLength(4); 531 + // Already geocoded — unchanged 532 + expect(result[0].lat).toBe(41.4036); 533 + expect(result[0].lng).toBe(2.1744); 534 + // Geocoded 535 + expect(result[1].lat).toBe(48.8606); 536 + expect(result[1].lng).toBe(2.3376); 537 + // Duplicate — same result as The Louvre 538 + expect(result[2].lat).toBe(48.8606); 539 + expect(result[2].lng).toBe(2.3376); 540 + // Geocoded 541 + expect(result[3].lat).toBe(35.659); 542 + expect(result[3].lng).toBe(139.700); 543 + // Only 2 API calls (skip already-geocoded, dedup duplicate) 544 + expect(mockFetch).toHaveBeenCalledTimes(2); 545 + 546 + vi.useRealTimers(); 547 + }); 457 548 }); 458 549 459 550 // ─── Contract 10: 10-second fetch timeout ───────────────────────────── ··· 631 722 expect(warnCall[0]).toContain("[MapViewer]"); 632 723 }); 633 724 634 - it("shows Obsidian Notice after 3 consecutive failures", async () => { 725 + it("calls onNotice after 3 consecutive failures", async () => { 635 726 vi.useFakeTimers(); 636 727 mockFetch 637 728 .mockRejectedValueOnce(new Error("fail 1")) 638 729 .mockRejectedValueOnce(new Error("fail 2")) 639 730 .mockRejectedValueOnce(new Error("fail 3")); 640 731 732 + const onNotice = vi.fn(); 641 733 const places = [ 642 734 makePlace("Bad 1"), 643 735 makePlace("Bad 2"), 644 736 makePlace("Bad 3"), 645 737 ]; 646 - const promise = geocodePlaces(places); 738 + const promise = geocodePlaces(places, { onNotice }); 647 739 await vi.runAllTimersAsync(); 648 740 await promise; 649 741 650 - expect(Notice).toHaveBeenCalledWith( 742 + expect(onNotice).toHaveBeenCalledWith( 651 743 "Map Viewer: Geocoding issues — check your network connection" 652 744 ); 653 745 vi.useRealTimers(); 654 746 }); 655 747 656 - it("shows Notice exactly once even with more than 3 consecutive failures", async () => { 748 + it("calls onNotice exactly once even with more than 3 consecutive failures", async () => { 657 749 vi.useFakeTimers(); 658 750 mockFetch 659 751 .mockRejectedValueOnce(new Error("fail 1")) ··· 662 754 .mockRejectedValueOnce(new Error("fail 4")) 663 755 .mockRejectedValueOnce(new Error("fail 5")); 664 756 757 + const onNotice = vi.fn(); 665 758 const places = [ 666 759 makePlace("Bad 1"), 667 760 makePlace("Bad 2"), ··· 669 762 makePlace("Bad 4"), 670 763 makePlace("Bad 5"), 671 764 ]; 672 - const promise = geocodePlaces(places); 765 + const promise = geocodePlaces(places, { onNotice }); 673 766 await vi.runAllTimersAsync(); 674 767 await promise; 675 768 676 - // Notice should fire exactly once (on the 3rd failure), not on 4th and 5th 677 - expect(Notice).toHaveBeenCalledTimes(1); 769 + expect(onNotice).toHaveBeenCalledTimes(1); 678 770 vi.useRealTimers(); 679 771 }); 680 772 681 - it("does NOT show Notice for fewer than 3 consecutive failures", async () => { 773 + it("does NOT call onNotice for fewer than 3 consecutive failures", async () => { 682 774 vi.useFakeTimers(); 683 775 mockFetch 684 776 .mockRejectedValueOnce(new Error("fail 1")) 685 777 .mockRejectedValueOnce(new Error("fail 2")); 686 778 779 + const onNotice = vi.fn(); 687 780 const places = [makePlace("Bad 1"), makePlace("Bad 2")]; 688 - const promise = geocodePlaces(places); 781 + const promise = geocodePlaces(places, { onNotice }); 689 782 await vi.runAllTimersAsync(); 690 783 await promise; 691 784 692 - expect(Notice).not.toHaveBeenCalled(); 785 + expect(onNotice).not.toHaveBeenCalled(); 693 786 vi.useRealTimers(); 694 787 }); 695 788 ··· 702 795 .mockRejectedValueOnce(new Error("fail 3")) 703 796 .mockRejectedValueOnce(new Error("fail 4")); 704 797 798 + const onNotice = vi.fn(); 705 799 const places = [ 706 800 makePlace("Bad 1"), 707 801 makePlace("Bad 2"), ··· 709 803 makePlace("Bad 3"), 710 804 makePlace("Bad 4"), 711 805 ]; 806 + const promise = geocodePlaces(places, { onNotice }); 807 + await vi.runAllTimersAsync(); 808 + await promise; 809 + 810 + // Counter was reset by "Good", so only 2 consecutive after that 811 + expect(onNotice).not.toHaveBeenCalled(); 812 + vi.useRealTimers(); 813 + }); 814 + 815 + it("does not crash if onNotice is not provided", async () => { 816 + vi.useFakeTimers(); 817 + mockFetch 818 + .mockRejectedValueOnce(new Error("fail 1")) 819 + .mockRejectedValueOnce(new Error("fail 2")) 820 + .mockRejectedValueOnce(new Error("fail 3")); 821 + 822 + const places = [ 823 + makePlace("Bad 1"), 824 + makePlace("Bad 2"), 825 + makePlace("Bad 3"), 826 + ]; 827 + // No callbacks at all — should not throw 712 828 const promise = geocodePlaces(places); 713 829 await vi.runAllTimersAsync(); 714 830 await promise; 715 831 716 - // Counter was reset by "Good", so only 2 consecutive after that 717 - expect(Notice).not.toHaveBeenCalled(); 718 832 vi.useRealTimers(); 719 833 }); 720 834 });
+683
tests/main.test.ts
··· 1 + /** 2 + * main.test.ts — Tests for all main.ts behavioral contracts 3 + * 4 + * Mocks Obsidian Plugin API (registerView, addRibbonIcon, addCommand, 5 + * workspace.getRightLeaf, workspace.on, vault.on), MapViewerView, and 6 + * tests view registration, activateView singleton logic, active-leaf-change 7 + * filtering for MarkdownView, vault modify event wiring, and lastActiveFilePath dedup. 8 + * 9 + * @vitest-environment jsdom 10 + */ 11 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 12 + 13 + // ─── Mock Types ─────────────────────────────────────────────────────── 14 + 15 + interface MockEditor { 16 + getCursor: ReturnType<typeof vi.fn>; 17 + setCursor: ReturnType<typeof vi.fn>; 18 + scrollIntoView: ReturnType<typeof vi.fn>; 19 + } 20 + 21 + interface MockTFile { 22 + path: string; 23 + name: string; 24 + basename: string; 25 + extension: string; 26 + stat: { ctime: number; mtime: number; size: number }; 27 + parent: null; 28 + vault: unknown; 29 + } 30 + 31 + interface MockMarkdownView { 32 + editor: MockEditor; 33 + file: MockTFile | null; 34 + getViewType: () => string; 35 + _isMarkdownView: true; 36 + } 37 + 38 + // ─── Obsidian Mock Infrastructure ───────────────────────────────────── 39 + 40 + type EventCallback = (...args: unknown[]) => unknown; 41 + 42 + class MockEvents { 43 + private _handlers: Map<string, Set<EventCallback>> = new Map(); 44 + 45 + on(name: string, callback: EventCallback): { id: string } { 46 + if (!this._handlers.has(name)) { 47 + this._handlers.set(name, new Set()); 48 + } 49 + this._handlers.get(name)!.add(callback); 50 + return { id: `${name}-${Math.random()}` }; 51 + } 52 + 53 + off(name: string, callback: EventCallback): void { 54 + this._handlers.get(name)?.delete(callback); 55 + } 56 + 57 + offref(_ref: unknown): void {} 58 + 59 + trigger(name: string, ...data: unknown[]): void { 60 + const handlers = this._handlers.get(name); 61 + if (handlers) { 62 + for (const handler of handlers) { 63 + handler(...data); 64 + } 65 + } 66 + } 67 + } 68 + 69 + // ─── Module-level mock state ────────────────────────────────────────── 70 + 71 + let mockVault: MockEvents & { 72 + cachedRead: ReturnType<typeof vi.fn>; 73 + process: ReturnType<typeof vi.fn>; 74 + }; 75 + 76 + let mockWorkspace: MockEvents & { 77 + getActiveViewOfType: ReturnType<typeof vi.fn>; 78 + getLeavesOfType: ReturnType<typeof vi.fn>; 79 + getRightLeaf: ReturnType<typeof vi.fn>; 80 + revealLeaf: ReturnType<typeof vi.fn>; 81 + }; 82 + 83 + let mockApp: { 84 + workspace: typeof mockWorkspace; 85 + vault: typeof mockVault; 86 + }; 87 + 88 + // Track plugin API calls 89 + let registeredViews: Array<{ type: string; factory: (leaf: unknown) => unknown }>; 90 + let registeredCommands: Array<{ id: string; name: string; callback: () => void }>; 91 + let ribbonIcons: Array<{ icon: string; title: string; callback: () => void }>; 92 + let registeredEvents: Array<{ id: string }>; 93 + 94 + // Mock MapViewerView 95 + let mockRefresh: ReturnType<typeof vi.fn>; 96 + let MockMapViewerViewInstances: Array<{ refresh: ReturnType<typeof vi.fn>; getViewType: () => string }>; 97 + 98 + // Mock leaf for sidebar 99 + let mockRightLeaf: { 100 + view: unknown; 101 + setViewState: ReturnType<typeof vi.fn>; 102 + }; 103 + 104 + // ─── Helpers ────────────────────────────────────────────────────────── 105 + 106 + function createMockFile(name = "test-note.md", path = "test-note.md"): MockTFile { 107 + return { 108 + path, 109 + name, 110 + basename: name.replace(/\.md$/, ""), 111 + extension: "md", 112 + stat: { ctime: Date.now(), mtime: Date.now(), size: 100 }, 113 + parent: null, 114 + vault: mockVault, 115 + }; 116 + } 117 + 118 + function createMockEditor(): MockEditor { 119 + return { 120 + getCursor: vi.fn().mockReturnValue({ line: 0, ch: 0 }), 121 + setCursor: vi.fn(), 122 + scrollIntoView: vi.fn(), 123 + }; 124 + } 125 + 126 + function createMockMarkdownView( 127 + editor: MockEditor, 128 + file: MockTFile | null 129 + ): MockMarkdownView { 130 + return { 131 + editor, 132 + file, 133 + getViewType: () => "markdown", 134 + _isMarkdownView: true, 135 + }; 136 + } 137 + 138 + // ─── Setup / Teardown ───────────────────────────────────────────────── 139 + 140 + beforeEach(() => { 141 + registeredViews = []; 142 + registeredCommands = []; 143 + ribbonIcons = []; 144 + registeredEvents = []; 145 + MockMapViewerViewInstances = []; 146 + mockRefresh = vi.fn(); 147 + 148 + // Build vault mock 149 + const vaultEvents = new MockEvents(); 150 + mockVault = Object.assign(vaultEvents, { 151 + cachedRead: vi.fn().mockResolvedValue(""), 152 + process: vi.fn().mockImplementation( 153 + async (_file: MockTFile, fn: (data: string) => string) => fn("") 154 + ), 155 + }); 156 + 157 + // Build workspace mock 158 + const workspaceEvents = new MockEvents(); 159 + mockWorkspace = Object.assign(workspaceEvents, { 160 + getActiveViewOfType: vi.fn().mockReturnValue(null), 161 + getLeavesOfType: vi.fn().mockReturnValue([]), 162 + getRightLeaf: vi.fn(), 163 + revealLeaf: vi.fn().mockResolvedValue(undefined), 164 + }); 165 + 166 + // Build right leaf 167 + mockRightLeaf = { 168 + view: null, 169 + setViewState: vi.fn().mockResolvedValue(undefined), 170 + }; 171 + mockWorkspace.getRightLeaf.mockReturnValue(mockRightLeaf); 172 + 173 + // Build app 174 + mockApp = { 175 + workspace: mockWorkspace, 176 + vault: mockVault, 177 + }; 178 + 179 + // ── Module mocks ── 180 + 181 + vi.doMock("../src/mapView", () => { 182 + class MapViewerView { 183 + leaf: unknown; 184 + refresh: ReturnType<typeof vi.fn>; 185 + constructor(leaf: unknown) { 186 + this.leaf = leaf; 187 + this.refresh = mockRefresh; 188 + MockMapViewerViewInstances.push(this); 189 + } 190 + getViewType() { 191 + return "map-viewer"; 192 + } 193 + } 194 + return { 195 + VIEW_TYPE: "map-viewer", 196 + MapViewerView, 197 + }; 198 + }); 199 + 200 + vi.doMock("obsidian", () => { 201 + class MarkdownView { 202 + static _isMarkdownView = true; 203 + editor: MockEditor; 204 + file: MockTFile | null; 205 + constructor() { 206 + this.editor = createMockEditor(); 207 + this.file = null; 208 + } 209 + getViewType() { 210 + return "markdown"; 211 + } 212 + } 213 + 214 + class ItemView { 215 + app: unknown; 216 + leaf: unknown; 217 + containerEl: HTMLElement; 218 + contentEl: HTMLElement; 219 + 220 + constructor(leaf: unknown) { 221 + this.leaf = leaf; 222 + this.app = mockApp; 223 + this.containerEl = document.createElement("div"); 224 + this.contentEl = document.createElement("div"); 225 + this.containerEl.appendChild(this.contentEl); 226 + } 227 + getViewType() { return ""; } 228 + getDisplayText() { return ""; } 229 + getIcon() { return ""; } 230 + register() {} 231 + registerEvent(ref: { id: string }) { 232 + registeredEvents.push(ref); 233 + } 234 + registerInterval() { return 0; } 235 + addChild(_c: unknown) { return _c; } 236 + removeChild(_c: unknown) { return _c; } 237 + onload() {} 238 + onunload() {} 239 + } 240 + 241 + class Notice { 242 + message: string; 243 + duration?: number; 244 + noticeEl: HTMLElement; 245 + constructor(message: string, duration?: number) { 246 + this.message = message; 247 + this.duration = duration; 248 + this.noticeEl = document.createElement("div"); 249 + } 250 + hide() {} 251 + } 252 + 253 + class Plugin { 254 + app: unknown; 255 + manifest: unknown; 256 + constructor(app: unknown, manifest: unknown) { 257 + this.app = app; 258 + this.manifest = manifest; 259 + } 260 + registerView(type: string, factory: (leaf: unknown) => unknown) { 261 + registeredViews.push({ type, factory }); 262 + } 263 + addCommand(cmd: { id: string; name: string; callback: () => void }) { 264 + registeredCommands.push(cmd); 265 + return cmd; 266 + } 267 + addRibbonIcon(icon: string, title: string, callback: () => void) { 268 + ribbonIcons.push({ icon, title, callback }); 269 + return document.createElement("div"); 270 + } 271 + register() {} 272 + registerEvent(ref: { id: string }) { 273 + registeredEvents.push(ref); 274 + } 275 + registerInterval() { return 0; } 276 + } 277 + 278 + class TFile { 279 + path = ""; 280 + name = ""; 281 + basename = ""; 282 + extension = "md"; 283 + stat = { ctime: 0, mtime: 0, size: 0 }; 284 + parent = null; 285 + } 286 + 287 + return { 288 + ItemView, 289 + MarkdownView, 290 + Notice, 291 + Plugin, 292 + TFile, 293 + }; 294 + }); 295 + }); 296 + 297 + afterEach(() => { 298 + vi.restoreAllMocks(); 299 + vi.resetModules(); 300 + }); 301 + 302 + // ─── Import Helper ──────────────────────────────────────────────────── 303 + 304 + async function importMain() { 305 + const mod = await import("../src/main"); 306 + return mod; 307 + } 308 + 309 + async function createPlugin() { 310 + const mod = await importMain(); 311 + const PluginClass = mod.default; 312 + const plugin = new PluginClass(mockApp, { id: "map-viewer", name: "Map Viewer" }); 313 + // Ensure plugin.app is set (Plugin constructor should do this) 314 + (plugin as any).app = mockApp; 315 + return { plugin, mod }; 316 + } 317 + 318 + async function createAndLoadPlugin() { 319 + const { plugin, mod } = await createPlugin(); 320 + await plugin.onload(); 321 + return { plugin, mod }; 322 + } 323 + 324 + // ─── Tests ──────────────────────────────────────────────────────────── 325 + 326 + describe("main.ts — Plugin Entry", () => { 327 + // ── Contract #1: View Registration ── 328 + 329 + describe("Contract #1: View Registration", () => { 330 + it("registers view type 'map-viewer' during onload", async () => { 331 + await createAndLoadPlugin(); 332 + 333 + expect(registeredViews).toHaveLength(1); 334 + expect(registeredViews[0].type).toBe("map-viewer"); 335 + }); 336 + 337 + it("view factory creates a MapViewerView instance, passing the leaf", async () => { 338 + await createAndLoadPlugin(); 339 + 340 + const factory = registeredViews[0].factory; 341 + const fakeleaf = { id: "test-leaf" }; 342 + const view = factory(fakeleaf); 343 + 344 + expect(MockMapViewerViewInstances).toHaveLength(1); 345 + expect(MockMapViewerViewInstances[0].leaf).toBe(fakeleaf); 346 + expect(view).toBe(MockMapViewerViewInstances[0]); 347 + }); 348 + }); 349 + 350 + // ── Contract #2: Ribbon Icon ── 351 + 352 + describe("Contract #2: Ribbon Icon", () => { 353 + it("adds a ribbon icon with 'map-pin' icon", async () => { 354 + await createAndLoadPlugin(); 355 + 356 + expect(ribbonIcons).toHaveLength(1); 357 + expect(ribbonIcons[0].icon).toBe("map-pin"); 358 + }); 359 + 360 + it("ribbon icon click calls activateView()", async () => { 361 + await createAndLoadPlugin(); 362 + 363 + // No existing map leaves 364 + mockWorkspace.getLeavesOfType.mockReturnValue([]); 365 + 366 + await ribbonIcons[0].callback(); 367 + 368 + // Should try to find existing leaves first 369 + expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); 370 + // Should create new leaf since none exists 371 + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); 372 + expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ 373 + type: "map-viewer", 374 + active: true, 375 + }); 376 + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf); 377 + }); 378 + }); 379 + 380 + // ── Contract #3: Command Registration ── 381 + 382 + describe("Contract #3: Command Registration", () => { 383 + it("registers command with id 'open-map-view' and name 'Open map view'", async () => { 384 + await createAndLoadPlugin(); 385 + 386 + expect(registeredCommands).toHaveLength(1); 387 + expect(registeredCommands[0].id).toBe("open-map-view"); 388 + expect(registeredCommands[0].name).toBe("Open map view"); 389 + }); 390 + 391 + it("command callback calls activateView()", async () => { 392 + await createAndLoadPlugin(); 393 + 394 + mockWorkspace.getLeavesOfType.mockReturnValue([]); 395 + 396 + await registeredCommands[0].callback(); 397 + 398 + expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); 399 + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); 400 + expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ 401 + type: "map-viewer", 402 + active: true, 403 + }); 404 + }); 405 + }); 406 + 407 + // ── Contract #4: activateView() Singleton Logic ── 408 + 409 + describe("Contract #4: activateView() — Singleton Logic", () => { 410 + it("reveals existing leaf if map-viewer leaf already exists", async () => { 411 + await createAndLoadPlugin(); 412 + 413 + const existingLeaf = { view: { getViewType: () => "map-viewer" } }; 414 + mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]); 415 + 416 + await ribbonIcons[0].callback(); 417 + 418 + expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); 419 + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf); 420 + // Should NOT create a new leaf 421 + expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled(); 422 + }); 423 + 424 + it("creates new right leaf if no map-viewer leaf exists", async () => { 425 + await createAndLoadPlugin(); 426 + 427 + mockWorkspace.getLeavesOfType.mockReturnValue([]); 428 + 429 + await ribbonIcons[0].callback(); 430 + 431 + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); 432 + expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ 433 + type: "map-viewer", 434 + active: true, 435 + }); 436 + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf); 437 + }); 438 + }); 439 + 440 + // ── Contract #5: active-leaf-change Event ── 441 + 442 + describe("Contract #5: active-leaf-change Event", () => { 443 + it("registers workspace active-leaf-change event", async () => { 444 + await createAndLoadPlugin(); 445 + 446 + // The plugin should have registered an event handler for active-leaf-change 447 + // We verify by triggering the event and checking behavior 448 + const file = createMockFile(); 449 + const editor = createMockEditor(); 450 + const mdView = createMockMarkdownView(editor, file); 451 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); 452 + 453 + // Find the map-viewer leaf so we can get the view's refresh method 454 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 455 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 456 + 457 + mockWorkspace.trigger("active-leaf-change", { view: mdView }); 458 + 459 + expect(mockRefresh).toHaveBeenCalled(); 460 + }); 461 + 462 + it("only processes event when new leaf is a MarkdownView", async () => { 463 + await createAndLoadPlugin(); 464 + 465 + // Set up a map-viewer leaf with refresh 466 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 467 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 468 + 469 + // Trigger with a non-MarkdownView leaf (e.g., the map sidebar itself) 470 + mockWorkspace.getActiveViewOfType.mockReturnValue(null); 471 + mockWorkspace.trigger("active-leaf-change", { 472 + view: { getViewType: () => "some-other-view" }, 473 + }); 474 + 475 + expect(mockRefresh).not.toHaveBeenCalled(); 476 + }); 477 + 478 + it("uses lastActiveFilePath to avoid redundant refreshes for same file", async () => { 479 + await createAndLoadPlugin(); 480 + 481 + const file = createMockFile("notes.md", "notes.md"); 482 + const editor = createMockEditor(); 483 + const mdView = createMockMarkdownView(editor, file); 484 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); 485 + 486 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 487 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 488 + 489 + // First trigger — should call refresh 490 + mockWorkspace.trigger("active-leaf-change", { view: mdView }); 491 + expect(mockRefresh).toHaveBeenCalledTimes(1); 492 + 493 + // Second trigger with same file — should NOT call refresh (dedup) 494 + mockWorkspace.trigger("active-leaf-change", { view: mdView }); 495 + expect(mockRefresh).toHaveBeenCalledTimes(1); 496 + }); 497 + 498 + it("calls refresh when switching to a different file", async () => { 499 + await createAndLoadPlugin(); 500 + 501 + const file1 = createMockFile("file1.md", "file1.md"); 502 + const file2 = createMockFile("file2.md", "file2.md"); 503 + const editor = createMockEditor(); 504 + 505 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 506 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 507 + 508 + // First file 509 + const mdView1 = createMockMarkdownView(editor, file1); 510 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView1); 511 + mockWorkspace.trigger("active-leaf-change", { view: mdView1 }); 512 + expect(mockRefresh).toHaveBeenCalledTimes(1); 513 + 514 + // Different file — should call refresh 515 + const mdView2 = createMockMarkdownView(editor, file2); 516 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView2); 517 + mockWorkspace.trigger("active-leaf-change", { view: mdView2 }); 518 + expect(mockRefresh).toHaveBeenCalledTimes(2); 519 + }); 520 + }); 521 + 522 + // ── Contract #6: vault modify Event ── 523 + 524 + describe("Contract #6: vault modify Event", () => { 525 + it("registers vault modify event", async () => { 526 + await createAndLoadPlugin(); 527 + 528 + const file = createMockFile(); 529 + const editor = createMockEditor(); 530 + const mdView = createMockMarkdownView(editor, file); 531 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); 532 + 533 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 534 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 535 + 536 + // Trigger vault modify with the active file 537 + mockVault.trigger("modify", file); 538 + 539 + expect(mockRefresh).toHaveBeenCalled(); 540 + }); 541 + 542 + it("does not refresh if modified file is not the active file", async () => { 543 + await createAndLoadPlugin(); 544 + 545 + const activeFile = createMockFile("active.md", "active.md"); 546 + const otherFile = createMockFile("other.md", "other.md"); 547 + const editor = createMockEditor(); 548 + const mdView = createMockMarkdownView(editor, activeFile); 549 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); 550 + 551 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 552 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 553 + 554 + // Trigger vault modify with a different file 555 + mockVault.trigger("modify", otherFile); 556 + 557 + expect(mockRefresh).not.toHaveBeenCalled(); 558 + }); 559 + 560 + it("does not refresh if no MarkdownView is active", async () => { 561 + await createAndLoadPlugin(); 562 + 563 + mockWorkspace.getActiveViewOfType.mockReturnValue(null); 564 + 565 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 566 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 567 + 568 + const file = createMockFile(); 569 + mockVault.trigger("modify", file); 570 + 571 + expect(mockRefresh).not.toHaveBeenCalled(); 572 + }); 573 + }); 574 + 575 + // ── Contract #7: onunload() ── 576 + 577 + describe("Contract #7: onunload()", () => { 578 + it("onunload exists and does not throw", async () => { 579 + const { plugin } = await createAndLoadPlugin(); 580 + 581 + // Obsidian handles view deregistration automatically 582 + // Just verify onunload doesn't throw 583 + expect(() => plugin.onunload()).not.toThrow(); 584 + }); 585 + }); 586 + 587 + // ── Edge Cases ── 588 + 589 + describe("Edge Cases", () => { 590 + it("plugin loaded with no files open — view shows empty map (no errors)", async () => { 591 + mockWorkspace.getActiveViewOfType.mockReturnValue(null); 592 + 593 + // Should not throw 594 + await createAndLoadPlugin(); 595 + 596 + // No refresh should have been called since there's no active view 597 + // (The view itself handles empty state) 598 + }); 599 + 600 + it("activateView called multiple times only reveals one leaf", async () => { 601 + await createAndLoadPlugin(); 602 + 603 + mockWorkspace.getLeavesOfType.mockReturnValue([]); 604 + 605 + // First activation creates a leaf 606 + await ribbonIcons[0].callback(); 607 + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledTimes(1); 608 + 609 + // Now there's an existing leaf 610 + const existingLeaf = { view: { getViewType: () => "map-viewer" } }; 611 + mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]); 612 + mockWorkspace.getRightLeaf.mockClear(); 613 + 614 + // Second activation should reveal existing, not create new 615 + await ribbonIcons[0].callback(); 616 + expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled(); 617 + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf); 618 + }); 619 + 620 + it("activateView handles getRightLeaf returning null gracefully", async () => { 621 + await createAndLoadPlugin(); 622 + 623 + mockWorkspace.getLeavesOfType.mockReturnValue([]); 624 + mockWorkspace.getRightLeaf.mockReturnValue(null); 625 + 626 + // Should not throw 627 + await ribbonIcons[0].callback(); 628 + 629 + // Should not try to call setViewState on null 630 + expect(mockRightLeaf.setViewState).not.toHaveBeenCalled(); 631 + }); 632 + 633 + it("active-leaf-change with MarkdownView where file is null always refreshes (no dedup)", async () => { 634 + await createAndLoadPlugin(); 635 + 636 + const editor = createMockEditor(); 637 + const mdViewNoFile = createMockMarkdownView(editor, null); 638 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdViewNoFile); 639 + 640 + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; 641 + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); 642 + 643 + // First trigger with null file — should call refresh 644 + mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile }); 645 + expect(mockRefresh).toHaveBeenCalledTimes(1); 646 + 647 + // Second trigger with null file — should also refresh (no dedup for null paths) 648 + mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile }); 649 + expect(mockRefresh).toHaveBeenCalledTimes(2); 650 + }); 651 + 652 + it("active-leaf-change with MarkdownView but no map-viewer leaf does not crash", async () => { 653 + await createAndLoadPlugin(); 654 + 655 + const file = createMockFile(); 656 + const editor = createMockEditor(); 657 + const mdView = createMockMarkdownView(editor, file); 658 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); 659 + mockWorkspace.getLeavesOfType.mockReturnValue([]); // no map view leaf 660 + 661 + // Should not throw, and refresh should not be called (no view to refresh) 662 + mockWorkspace.trigger("active-leaf-change", { view: mdView }); 663 + expect(mockRefresh).not.toHaveBeenCalled(); 664 + }); 665 + 666 + it("does not crash if map-viewer leaf exists but view has no refresh method", async () => { 667 + await createAndLoadPlugin(); 668 + 669 + const file = createMockFile(); 670 + const editor = createMockEditor(); 671 + const mdView = createMockMarkdownView(editor, file); 672 + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); 673 + 674 + // Leaf exists but view has no refresh method (e.g., stale or initializing view) 675 + const brokenLeaf = { view: { getViewType: () => "map-viewer" } }; 676 + mockWorkspace.getLeavesOfType.mockReturnValue([brokenLeaf]); 677 + 678 + // Should not throw 679 + mockWorkspace.trigger("active-leaf-change", { view: mdView }); 680 + expect(mockRefresh).not.toHaveBeenCalled(); 681 + }); 682 + }); 683 + });
+1153
tests/mapRenderer.test.ts
··· 1 + /** 2 + * mapRenderer.test.ts — Tests for all mapRenderer.ts behavioral contracts 3 + * 4 + * Tests mock Leaflet, DOM APIs, and CSS variable access. 5 + * Each contract from the spec has its own describe block. 6 + * 7 + * @vitest-environment jsdom 8 + */ 9 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 10 + import type { Place } from "../src/parser"; 11 + 12 + // ─── Helpers ────────────────────────────────────────────────────────── 13 + 14 + function makePlace( 15 + name: string, 16 + overrides: Partial<Place> = {} 17 + ): Place { 18 + return { 19 + name, 20 + fields: {}, 21 + notes: [], 22 + startLine: 0, 23 + endLine: 0, 24 + ...overrides, 25 + }; 26 + } 27 + 28 + // ─── Leaflet Mock ───────────────────────────────────────────────────── 29 + 30 + // We build a comprehensive mock of the Leaflet APIs used by mapRenderer. 31 + // Each test gets a fresh set of mock objects. 32 + 33 + interface MockMarker { 34 + setLatLng: ReturnType<typeof vi.fn>; 35 + setIcon: ReturnType<typeof vi.fn>; 36 + bindPopup: ReturnType<typeof vi.fn>; 37 + openPopup: ReturnType<typeof vi.fn>; 38 + on: ReturnType<typeof vi.fn>; 39 + off: ReturnType<typeof vi.fn>; 40 + addTo: ReturnType<typeof vi.fn>; 41 + remove: ReturnType<typeof vi.fn>; 42 + getLatLng: ReturnType<typeof vi.fn>; 43 + _latlng: { lat: number; lng: number }; 44 + _events: Record<string, ((...args: unknown[]) => void)[]>; 45 + } 46 + 47 + interface MockCircleMarker { 48 + addTo: ReturnType<typeof vi.fn>; 49 + remove: ReturnType<typeof vi.fn>; 50 + setLatLng: ReturnType<typeof vi.fn>; 51 + getElement: ReturnType<typeof vi.fn>; 52 + } 53 + 54 + interface MockMap { 55 + setView: ReturnType<typeof vi.fn>; 56 + fitBounds: ReturnType<typeof vi.fn>; 57 + remove: ReturnType<typeof vi.fn>; 58 + invalidateSize: ReturnType<typeof vi.fn>; 59 + getZoom: ReturnType<typeof vi.fn>; 60 + on: ReturnType<typeof vi.fn>; 61 + off: ReturnType<typeof vi.fn>; 62 + _events: Record<string, ((...args: unknown[]) => void)[]>; 63 + } 64 + 65 + interface MockTileLayer { 66 + addTo: ReturnType<typeof vi.fn>; 67 + } 68 + 69 + interface MockLayerGroup { 70 + addTo: ReturnType<typeof vi.fn>; 71 + clearLayers: ReturnType<typeof vi.fn>; 72 + addLayer: ReturnType<typeof vi.fn>; 73 + getLayers: ReturnType<typeof vi.fn>; 74 + eachLayer: ReturnType<typeof vi.fn>; 75 + } 76 + 77 + interface MockIcon { 78 + options: Record<string, unknown>; 79 + } 80 + 81 + interface MockLatLngBounds { 82 + extend: ReturnType<typeof vi.fn>; 83 + isValid: ReturnType<typeof vi.fn>; 84 + getCenter: ReturnType<typeof vi.fn>; 85 + pad: ReturnType<typeof vi.fn>; 86 + } 87 + 88 + let mockMapInstance: MockMap; 89 + let mockMarkers: MockMarker[]; 90 + let mockCircleMarkers: MockCircleMarker[]; 91 + let mockTileLayer: MockTileLayer; 92 + let mockLayerGroup: MockLayerGroup; 93 + let mockIcons: MockIcon[]; 94 + let mockBounds: MockLatLngBounds; 95 + 96 + function createMockMarker(lat: number, lng: number): MockMarker { 97 + const marker: MockMarker = { 98 + setLatLng: vi.fn().mockReturnThis(), 99 + setIcon: vi.fn().mockReturnThis(), 100 + bindPopup: vi.fn().mockReturnThis(), 101 + openPopup: vi.fn().mockReturnThis(), 102 + on: vi.fn().mockImplementation(function (this: MockMarker, event: string, handler: (...args: unknown[]) => void) { 103 + if (!this._events[event]) this._events[event] = []; 104 + this._events[event].push(handler); 105 + return this; 106 + }), 107 + off: vi.fn().mockReturnThis(), 108 + addTo: vi.fn().mockReturnThis(), 109 + remove: vi.fn().mockReturnThis(), 110 + getLatLng: vi.fn().mockReturnValue({ lat, lng }), 111 + _latlng: { lat, lng }, 112 + _events: {}, 113 + }; 114 + mockMarkers.push(marker); 115 + return marker; 116 + } 117 + 118 + function createMockCircleMarker(): MockCircleMarker { 119 + const cm: MockCircleMarker = { 120 + addTo: vi.fn().mockReturnThis(), 121 + remove: vi.fn().mockReturnThis(), 122 + setLatLng: vi.fn().mockReturnThis(), 123 + getElement: vi.fn().mockReturnValue(document.createElement("div")), 124 + }; 125 + mockCircleMarkers.push(cm); 126 + return cm; 127 + } 128 + 129 + function setupLeafletMock(): typeof import("leaflet") { 130 + mockMapInstance = { 131 + setView: vi.fn().mockReturnThis(), 132 + fitBounds: vi.fn().mockReturnThis(), 133 + remove: vi.fn(), 134 + invalidateSize: vi.fn(), 135 + getZoom: vi.fn().mockReturnValue(2), 136 + on: vi.fn().mockImplementation(function (this: MockMap, event: string, handler: (...args: unknown[]) => void) { 137 + if (!this._events[event]) this._events[event] = []; 138 + this._events[event].push(handler); 139 + return this; 140 + }), 141 + off: vi.fn().mockReturnThis(), 142 + _events: {}, 143 + }; 144 + 145 + mockTileLayer = { 146 + addTo: vi.fn().mockReturnThis(), 147 + }; 148 + 149 + mockLayerGroup = { 150 + addTo: vi.fn().mockReturnThis(), 151 + clearLayers: vi.fn(), 152 + addLayer: vi.fn(), 153 + getLayers: vi.fn().mockReturnValue([]), 154 + eachLayer: vi.fn(), 155 + }; 156 + 157 + mockBounds = { 158 + extend: vi.fn().mockReturnThis(), 159 + isValid: vi.fn().mockReturnValue(true), 160 + getCenter: vi.fn().mockReturnValue({ lat: 0, lng: 0 }), 161 + pad: vi.fn().mockReturnThis(), 162 + }; 163 + 164 + const L = { 165 + map: vi.fn().mockReturnValue(mockMapInstance), 166 + tileLayer: vi.fn().mockReturnValue(mockTileLayer), 167 + layerGroup: vi.fn().mockReturnValue(mockLayerGroup), 168 + marker: vi.fn().mockImplementation((latlng: [number, number]) => { 169 + return createMockMarker(latlng[0], latlng[1]); 170 + }), 171 + circleMarker: vi.fn().mockImplementation(() => { 172 + return createMockCircleMarker(); 173 + }), 174 + divIcon: vi.fn().mockImplementation((opts: Record<string, unknown>) => { 175 + const icon: MockIcon = { options: opts }; 176 + mockIcons.push(icon); 177 + return icon; 178 + }), 179 + icon: vi.fn().mockImplementation((opts: Record<string, unknown>) => { 180 + const icon: MockIcon = { options: opts }; 181 + mockIcons.push(icon); 182 + return icon; 183 + }), 184 + latLngBounds: vi.fn().mockReturnValue(mockBounds), 185 + latLng: vi.fn().mockImplementation((lat: number, lng: number) => ({ lat, lng })), 186 + DomEvent: { 187 + stopPropagation: vi.fn(), 188 + stop: vi.fn(), 189 + }, 190 + Util: { 191 + stamp: vi.fn().mockReturnValue(1), 192 + }, 193 + }; 194 + 195 + return L as unknown as typeof import("leaflet"); 196 + } 197 + 198 + // ─── Mock ResizeObserver ────────────────────────────────────────────── 199 + 200 + let resizeObserverCallback: ResizeObserverCallback | null = null; 201 + let resizeObserverDisconnected = false; 202 + 203 + class MockResizeObserver { 204 + callback: ResizeObserverCallback; 205 + constructor(callback: ResizeObserverCallback) { 206 + this.callback = callback; 207 + resizeObserverCallback = callback; 208 + resizeObserverDisconnected = false; 209 + } 210 + observe() {} 211 + unobserve() {} 212 + disconnect() { 213 + resizeObserverDisconnected = true; 214 + } 215 + } 216 + 217 + // ─── Test Setup ─────────────────────────────────────────────────────── 218 + 219 + let L: ReturnType<typeof setupLeafletMock>; 220 + let createMap: typeof import("../src/mapRenderer").createMap; 221 + 222 + beforeEach(async () => { 223 + mockMarkers = []; 224 + mockCircleMarkers = []; 225 + mockIcons = []; 226 + 227 + L = setupLeafletMock(); 228 + 229 + // Mock leaflet module 230 + vi.doMock("leaflet", () => ({ default: L, ...L })); 231 + 232 + // Mock ResizeObserver globally 233 + vi.stubGlobal("ResizeObserver", MockResizeObserver); 234 + resizeObserverCallback = null; 235 + resizeObserverDisconnected = false; 236 + 237 + // Mock getComputedStyle for CSS variable access 238 + vi.stubGlobal( 239 + "getComputedStyle", 240 + vi.fn().mockReturnValue({ 241 + getPropertyValue: vi.fn().mockImplementation((prop: string) => { 242 + if (prop === "--interactive-accent") return "#7b6cd9"; 243 + return ""; 244 + }), 245 + }) 246 + ); 247 + 248 + // Import fresh module for each test 249 + const mod = await import("../src/mapRenderer"); 250 + createMap = mod.createMap; 251 + }); 252 + 253 + afterEach(() => { 254 + vi.restoreAllMocks(); 255 + vi.resetModules(); 256 + vi.unstubAllGlobals(); 257 + }); 258 + 259 + // ─── Contract 1: Leaflet CSS injection ──────────────────────────────── 260 + 261 + describe("Contract 1: Leaflet CSS injection", () => { 262 + it("injects a <style> tag with id='leaflet-css' into document.head", () => { 263 + const container = document.createElement("div"); 264 + createMap(container, [], {}); 265 + 266 + const style = document.getElementById("leaflet-css"); 267 + expect(style).not.toBeNull(); 268 + expect(style?.tagName.toLowerCase()).toBe("style"); 269 + }); 270 + 271 + it("does not inject a second <style> tag on repeat calls (idempotent)", () => { 272 + const container1 = document.createElement("div"); 273 + const container2 = document.createElement("div"); 274 + createMap(container1, [], {}); 275 + const ctrl2 = createMap(container2, [], {}); 276 + 277 + const styles = document.querySelectorAll("#leaflet-css"); 278 + expect(styles.length).toBe(1); 279 + 280 + ctrl2.destroy(); 281 + }); 282 + }); 283 + 284 + // ─── Contract 2: Map container setup ────────────────────────────────── 285 + 286 + describe("Contract 2: Map container div", () => { 287 + it("creates a child div with class 'map-viewer-map' inside container", () => { 288 + const container = document.createElement("div"); 289 + createMap(container, [], {}); 290 + 291 + const mapDiv = container.querySelector(".map-viewer-map"); 292 + expect(mapDiv).not.toBeNull(); 293 + }); 294 + 295 + it("passes the map div to L.map()", () => { 296 + const container = document.createElement("div"); 297 + createMap(container, [], {}); 298 + 299 + expect(L.map).toHaveBeenCalledTimes(1); 300 + const mapDiv = container.querySelector(".map-viewer-map"); 301 + expect(L.map).toHaveBeenCalledWith(mapDiv, expect.anything()); 302 + }); 303 + }); 304 + 305 + // ─── Contract 3: Tile layers ────────────────────────────────────────── 306 + 307 + describe("Contract 3: Tile layers", () => { 308 + it("creates Cooper Hewitt Watercolor base layer", () => { 309 + const container = document.createElement("div"); 310 + createMap(container, [], {}); 311 + 312 + const calls = (L.tileLayer as ReturnType<typeof vi.fn>).mock.calls; 313 + const watercolorCall = calls.find( 314 + (c: unknown[]) => 315 + typeof c[0] === "string" && 316 + c[0].includes("watercolor") 317 + ); 318 + expect(watercolorCall).toBeDefined(); 319 + expect(watercolorCall![0]).toContain("watercolormaps.collection.cooperhewitt.org"); 320 + }); 321 + 322 + it("creates CartoDB Light Labels overlay", () => { 323 + const container = document.createElement("div"); 324 + createMap(container, [], {}); 325 + 326 + const calls = (L.tileLayer as ReturnType<typeof vi.fn>).mock.calls; 327 + const labelsCall = calls.find( 328 + (c: unknown[]) => 329 + typeof c[0] === "string" && 330 + c[0].includes("light_only_labels") 331 + ); 332 + expect(labelsCall).toBeDefined(); 333 + expect(labelsCall![0]).toContain("basemaps.cartocdn.com"); 334 + }); 335 + 336 + it("adds both tile layers to the map", () => { 337 + const container = document.createElement("div"); 338 + createMap(container, [], {}); 339 + 340 + // tileLayer is called twice, each result gets addTo called 341 + expect(L.tileLayer).toHaveBeenCalledTimes(2); 342 + expect(mockTileLayer.addTo).toHaveBeenCalledTimes(2); 343 + }); 344 + }); 345 + 346 + // ─── Contract 4: Marker grouping by identical lat/lng ───────────────── 347 + 348 + describe("Contract 4: Marker grouping by toFixed(6)", () => { 349 + it("creates one marker for two places at the exact same coordinates", () => { 350 + const container = document.createElement("div"); 351 + const places = [ 352 + makePlace("Place A", { lat: 41.403600, lng: 2.174400, startLine: 0 }), 353 + makePlace("Place B", { lat: 41.403600, lng: 2.174400, startLine: 3 }), 354 + ]; 355 + createMap(container, places, {}); 356 + 357 + // Only one L.marker call despite two places 358 + expect(L.marker).toHaveBeenCalledTimes(1); 359 + }); 360 + 361 + it("creates separate markers for places at different coordinates", () => { 362 + const container = document.createElement("div"); 363 + const places = [ 364 + makePlace("Place A", { lat: 41.403600, lng: 2.174400, startLine: 0 }), 365 + makePlace("Place B", { lat: 48.860600, lng: 2.337600, startLine: 3 }), 366 + ]; 367 + createMap(container, places, {}); 368 + 369 + expect(L.marker).toHaveBeenCalledTimes(2); 370 + }); 371 + 372 + it("groups using toFixed(6) — tiny differences below precision are merged", () => { 373 + const container = document.createElement("div"); 374 + const places = [ 375 + makePlace("Place A", { lat: 41.4036001, lng: 2.1744001, startLine: 0 }), 376 + makePlace("Place B", { lat: 41.4036002, lng: 2.1744002, startLine: 3 }), 377 + ]; 378 + createMap(container, places, {}); 379 + 380 + // Both round to 41.403600, 2.174400 — should be one marker 381 + expect(L.marker).toHaveBeenCalledTimes(1); 382 + }); 383 + 384 + it("does not group places where toFixed(6) differs", () => { 385 + const container = document.createElement("div"); 386 + const places = [ 387 + makePlace("Place A", { lat: 41.403600, lng: 2.174400, startLine: 0 }), 388 + makePlace("Place B", { lat: 41.403601, lng: 2.174400, startLine: 3 }), 389 + ]; 390 + createMap(container, places, {}); 391 + 392 + // 41.403600 vs 41.403601 — different at 6th decimal 393 + expect(L.marker).toHaveBeenCalledTimes(2); 394 + }); 395 + }); 396 + 397 + // ─── Contract 5: Custom SVG teardrop pin icons ──────────────────────── 398 + 399 + describe("Contract 5: SVG teardrop pin icons", () => { 400 + it("creates markers with a custom divIcon containing SVG", () => { 401 + const container = document.createElement("div"); 402 + const places = [ 403 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 404 + ]; 405 + createMap(container, places, {}); 406 + 407 + // Should use divIcon for the marker 408 + expect(L.divIcon).toHaveBeenCalled(); 409 + const iconOpts = mockIcons[0]?.options; 410 + expect(iconOpts).toBeDefined(); 411 + // The icon HTML should contain an SVG 412 + const html = iconOpts?.html as string; 413 + expect(html).toBeDefined(); 414 + expect(html.toLowerCase()).toContain("<svg"); 415 + expect(html.toLowerCase()).toContain("teardrop"); 416 + }); 417 + 418 + it("uses --interactive-accent CSS variable for fill color", () => { 419 + const container = document.createElement("div"); 420 + const places = [ 421 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 422 + ]; 423 + createMap(container, places, {}); 424 + 425 + const iconOpts = mockIcons[0]?.options; 426 + const html = iconOpts?.html as string; 427 + // Should contain the accent color from CSS variable 428 + expect(html).toContain("#7b6cd9"); 429 + }); 430 + 431 + it("uses a darkened stroke color (25% darker than fill)", () => { 432 + const container = document.createElement("div"); 433 + const places = [ 434 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 435 + ]; 436 + createMap(container, places, {}); 437 + 438 + const iconOpts = mockIcons[0]?.options; 439 + const html = iconOpts?.html as string; 440 + // Stroke should be present and different from fill 441 + // The exact darkened color depends on implementation, but it should have a stroke 442 + expect(html.toLowerCase()).toContain("stroke"); 443 + }); 444 + }); 445 + 446 + // ─── Contract 6: Marker click → onPlaceSelect ──────────────────────── 447 + 448 + describe("Contract 6: Marker click calls onPlaceSelect", () => { 449 + it("calls onPlaceSelect with all places at the marker's location on click", () => { 450 + const container = document.createElement("div"); 451 + const onPlaceSelect = vi.fn(); 452 + const places = [ 453 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 454 + makePlace("Place B", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 455 + ]; 456 + createMap(container, places, { onPlaceSelect }); 457 + 458 + // Simulate click on the marker 459 + const marker = mockMarkers[0]; 460 + expect(marker.on).toHaveBeenCalled(); 461 + const clickHandler = marker._events["click"]?.[0]; 462 + expect(clickHandler).toBeDefined(); 463 + clickHandler!({}); 464 + 465 + expect(onPlaceSelect).toHaveBeenCalledTimes(1); 466 + const calledWith = onPlaceSelect.mock.calls[0][0]; 467 + expect(calledWith).toHaveLength(2); 468 + expect(calledWith[0].name).toBe("Place A"); 469 + expect(calledWith[1].name).toBe("Place B"); 470 + }); 471 + 472 + it("shows popup with place names on marker click", () => { 473 + const container = document.createElement("div"); 474 + const places = [ 475 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 476 + ]; 477 + createMap(container, places, {}); 478 + 479 + // Marker should have popup bound 480 + const marker = mockMarkers[0]; 481 + expect(marker.bindPopup).toHaveBeenCalled(); 482 + }); 483 + 484 + it("popup shows linked name if place has url, plain text otherwise", () => { 485 + const container = document.createElement("div"); 486 + const places = [ 487 + makePlace("Place A", { 488 + lat: 41.4036, 489 + lng: 2.1744, 490 + startLine: 0, 491 + url: "https://example.com", 492 + }), 493 + makePlace("Place B", { 494 + lat: 41.4036, 495 + lng: 2.1744, 496 + startLine: 3, 497 + }), 498 + ]; 499 + createMap(container, places, {}); 500 + 501 + const marker = mockMarkers[0]; 502 + const popupContent = marker.bindPopup.mock.calls[0][0] as string; 503 + 504 + // Should contain an <a> for Place A (has url) 505 + expect(popupContent).toContain("<a"); 506 + expect(popupContent).toContain("https://example.com"); 507 + expect(popupContent).toContain("Place A"); 508 + 509 + // Place B should be plain text (no url) 510 + expect(popupContent).toContain("Place B"); 511 + }); 512 + 513 + it("uses L.DomEvent.stopPropagation on marker click", () => { 514 + const container = document.createElement("div"); 515 + const places = [ 516 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 517 + ]; 518 + createMap(container, places, {}); 519 + 520 + const marker = mockMarkers[0]; 521 + const clickHandler = marker._events["click"]?.[0]; 522 + expect(clickHandler).toBeDefined(); 523 + 524 + const fakeEvent = { originalEvent: new Event("click") }; 525 + clickHandler!(fakeEvent); 526 + 527 + expect(L.DomEvent.stopPropagation).toHaveBeenCalled(); 528 + }); 529 + }); 530 + 531 + // ─── Contract 7: selectPlace by startLine ───────────────────────────── 532 + 533 + describe("Contract 7: selectPlace matches by startLine", () => { 534 + it("pans/zooms to the marker matching the place's startLine", () => { 535 + const container = document.createElement("div"); 536 + const places = [ 537 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 538 + makePlace("Place B", { lat: 48.8606, lng: 2.3376, startLine: 5 }), 539 + ]; 540 + const ctrl = createMap(container, places, {}); 541 + 542 + ctrl.selectPlace(places[1]); 543 + 544 + // Should pan to Place B 545 + expect(mockMapInstance.setView).toHaveBeenCalled(); 546 + }); 547 + 548 + it("selects the group marker if the place is part of a group", () => { 549 + const container = document.createElement("div"); 550 + const places = [ 551 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 552 + makePlace("Place B", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 553 + ]; 554 + const ctrl = createMap(container, places, {}); 555 + 556 + // Select Place B — should select the shared marker 557 + ctrl.selectPlace(places[1]); 558 + 559 + // Should still pan to the grouped marker location 560 + expect(mockMapInstance.setView).toHaveBeenCalled(); 561 + }); 562 + 563 + it("shows a highlight ring (CircleMarker) on selection", () => { 564 + const container = document.createElement("div"); 565 + const places = [ 566 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 567 + ]; 568 + const ctrl = createMap(container, places, {}); 569 + 570 + ctrl.selectPlace(places[0]); 571 + 572 + // A CircleMarker should be created for the highlight 573 + expect(L.circleMarker).toHaveBeenCalled(); 574 + expect(mockCircleMarkers.length).toBeGreaterThan(0); 575 + expect(mockCircleMarkers[0].addTo).toHaveBeenCalled(); 576 + }); 577 + }); 578 + 579 + // ─── Contract 8: selectPlace(null) removes highlight ────────────────── 580 + 581 + describe("Contract 8: selectPlace(null) deselects", () => { 582 + it("removes highlight ring when selectPlace(null) is called", () => { 583 + const container = document.createElement("div"); 584 + const places = [ 585 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 586 + ]; 587 + const ctrl = createMap(container, places, {}); 588 + 589 + ctrl.selectPlace(places[0]); 590 + expect(mockCircleMarkers.length).toBeGreaterThan(0); 591 + 592 + ctrl.selectPlace(null); 593 + expect(mockCircleMarkers[0].remove).toHaveBeenCalled(); 594 + }); 595 + 596 + it("does nothing if no place is selected and selectPlace(null) is called", () => { 597 + const container = document.createElement("div"); 598 + const ctrl = createMap(container, [], {}); 599 + 600 + // Should not throw 601 + expect(() => ctrl.selectPlace(null)).not.toThrow(); 602 + }); 603 + }); 604 + 605 + // ─── Contract 9: updateMarkers clears and recreates ─────────────────── 606 + 607 + describe("Contract 9: updateMarkers clears and recreates", () => { 608 + it("clears all existing markers on updateMarkers", () => { 609 + const container = document.createElement("div"); 610 + const places = [ 611 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 612 + ]; 613 + const ctrl = createMap(container, places, {}); 614 + 615 + const newPlaces = [ 616 + makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 617 + ]; 618 + ctrl.updateMarkers(newPlaces); 619 + 620 + // Old markers should be removed 621 + expect(mockMarkers[0].remove).toHaveBeenCalled(); 622 + }); 623 + 624 + it("removes highlight ring on updateMarkers", () => { 625 + const container = document.createElement("div"); 626 + const places = [ 627 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 628 + ]; 629 + const ctrl = createMap(container, places, {}); 630 + 631 + ctrl.selectPlace(places[0]); 632 + expect(mockCircleMarkers.length).toBeGreaterThan(0); 633 + 634 + ctrl.updateMarkers([]); 635 + 636 + // Highlight should be removed 637 + expect(mockCircleMarkers[0].remove).toHaveBeenCalled(); 638 + }); 639 + 640 + it("creates new markers from the provided places", () => { 641 + const container = document.createElement("div"); 642 + const ctrl = createMap(container, [], {}); 643 + 644 + const markerCountBefore = mockMarkers.length; 645 + 646 + const newPlaces = [ 647 + makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 648 + makePlace("Place D", { lat: 48.8606, lng: 2.3376, startLine: 3 }), 649 + ]; 650 + ctrl.updateMarkers(newPlaces); 651 + 652 + // New markers should be created 653 + expect(mockMarkers.length).toBeGreaterThan(markerCountBefore); 654 + }); 655 + 656 + it("fits bounds by default (fitBounds=true)", () => { 657 + const container = document.createElement("div"); 658 + const ctrl = createMap(container, [], {}); 659 + 660 + const newPlaces = [ 661 + makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 662 + makePlace("Place D", { lat: 48.8606, lng: 2.3376, startLine: 3 }), 663 + ]; 664 + ctrl.updateMarkers(newPlaces); 665 + 666 + expect(mockMapInstance.fitBounds).toHaveBeenCalled(); 667 + }); 668 + 669 + it("does not fit bounds when fitBounds=false", () => { 670 + const container = document.createElement("div"); 671 + const ctrl = createMap(container, [], {}); 672 + 673 + // Reset any calls from initial createMap (which sets default world view) 674 + mockMapInstance.fitBounds.mockClear(); 675 + mockMapInstance.setView.mockClear(); 676 + 677 + const newPlaces = [ 678 + makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 679 + ]; 680 + ctrl.updateMarkers(newPlaces, false); 681 + 682 + // Neither fitBounds nor setView should be called by updateMarkers when fitBounds=false 683 + expect(mockMapInstance.fitBounds).not.toHaveBeenCalled(); 684 + expect(mockMapInstance.setView).not.toHaveBeenCalled(); 685 + }); 686 + }); 687 + 688 + // ─── Contract 10: fitBounds behavior ────────────────────────────────── 689 + 690 + describe("Contract 10: fitBounds behavior", () => { 691 + it("does nothing with zero markers (preserves current view)", () => { 692 + const container = document.createElement("div"); 693 + const ctrl = createMap(container, [], {}); 694 + 695 + // Clear initial calls 696 + mockMapInstance.fitBounds.mockClear(); 697 + mockMapInstance.setView.mockClear(); 698 + 699 + ctrl.fitBounds(); 700 + 701 + expect(mockMapInstance.fitBounds).not.toHaveBeenCalled(); 702 + expect(mockMapInstance.setView).not.toHaveBeenCalled(); 703 + }); 704 + 705 + it("centers on single marker at zoom 13", () => { 706 + const container = document.createElement("div"); 707 + const places = [ 708 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 709 + ]; 710 + const ctrl = createMap(container, places, {}); 711 + 712 + // Clear initial calls 713 + mockMapInstance.setView.mockClear(); 714 + 715 + ctrl.fitBounds(); 716 + 717 + expect(mockMapInstance.setView).toHaveBeenCalledWith( 718 + expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 719 + 13 720 + ); 721 + }); 722 + 723 + it("fits bounds to show all markers with multiple places", () => { 724 + const container = document.createElement("div"); 725 + const places = [ 726 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 727 + makePlace("Place B", { lat: 48.8606, lng: 2.3376, startLine: 3 }), 728 + ]; 729 + const ctrl = createMap(container, places, {}); 730 + 731 + // Clear initial calls 732 + mockMapInstance.fitBounds.mockClear(); 733 + 734 + ctrl.fitBounds(); 735 + 736 + expect(mockMapInstance.fitBounds).toHaveBeenCalled(); 737 + }); 738 + }); 739 + 740 + // ─── Contract 11: invalidateSize ────────────────────────────────────── 741 + 742 + describe("Contract 11: invalidateSize", () => { 743 + it("calls Leaflet's invalidateSize()", () => { 744 + const container = document.createElement("div"); 745 + const ctrl = createMap(container, [], {}); 746 + 747 + ctrl.invalidateSize(); 748 + 749 + expect(mockMapInstance.invalidateSize).toHaveBeenCalled(); 750 + }); 751 + }); 752 + 753 + // ─── Contract 12: destroy ───────────────────────────────────────────── 754 + 755 + describe("Contract 12: destroy", () => { 756 + it("removes the Leaflet map instance", () => { 757 + const container = document.createElement("div"); 758 + const ctrl = createMap(container, [], {}); 759 + 760 + ctrl.destroy(); 761 + 762 + expect(mockMapInstance.remove).toHaveBeenCalled(); 763 + }); 764 + 765 + it("removes the map div from the container", () => { 766 + const container = document.createElement("div"); 767 + const ctrl = createMap(container, [], {}); 768 + 769 + expect(container.querySelector(".map-viewer-map")).not.toBeNull(); 770 + 771 + ctrl.destroy(); 772 + 773 + expect(container.querySelector(".map-viewer-map")).toBeNull(); 774 + }); 775 + 776 + it("calling destroy() twice does not throw", () => { 777 + const container = document.createElement("div"); 778 + const ctrl = createMap(container, [], {}); 779 + 780 + ctrl.destroy(); 781 + expect(() => ctrl.destroy()).not.toThrow(); 782 + }); 783 + }); 784 + 785 + // ─── Contract 13: ResizeObserver ────────────────────────────────────── 786 + 787 + describe("Contract 13: ResizeObserver", () => { 788 + it("creates a ResizeObserver on the map container", () => { 789 + const container = document.createElement("div"); 790 + createMap(container, [], {}); 791 + 792 + expect(resizeObserverCallback).not.toBeNull(); 793 + }); 794 + 795 + it("calls invalidateSize when the container resizes", () => { 796 + const container = document.createElement("div"); 797 + createMap(container, [], {}); 798 + 799 + expect(resizeObserverCallback).not.toBeNull(); 800 + 801 + // Simulate a resize event 802 + resizeObserverCallback!( 803 + [{ contentRect: { width: 500, height: 400 } }] as unknown as ResizeObserverEntry[], 804 + {} as ResizeObserver 805 + ); 806 + 807 + expect(mockMapInstance.invalidateSize).toHaveBeenCalled(); 808 + }); 809 + 810 + it("disconnects ResizeObserver on destroy()", () => { 811 + const container = document.createElement("div"); 812 + const ctrl = createMap(container, [], {}); 813 + 814 + ctrl.destroy(); 815 + 816 + expect(resizeObserverDisconnected).toBe(true); 817 + }); 818 + }); 819 + 820 + // ─── Edge Cases ─────────────────────────────────────────────────────── 821 + 822 + describe("Edge cases", () => { 823 + it("zero places → map shows default world view (center [20, 0], zoom 2)", () => { 824 + const container = document.createElement("div"); 825 + createMap(container, [], {}); 826 + 827 + expect(mockMapInstance.setView).toHaveBeenCalledWith( 828 + [20, 0], 829 + 2 830 + ); 831 + }); 832 + 833 + it("single place → map centers on it at zoom 13", () => { 834 + const container = document.createElement("div"); 835 + const places = [ 836 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 837 + ]; 838 + createMap(container, places, {}); 839 + 840 + expect(mockMapInstance.setView).toHaveBeenCalledWith( 841 + expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 842 + 13 843 + ); 844 + }); 845 + 846 + it("places without lat/lng are silently excluded from markers", () => { 847 + const container = document.createElement("div"); 848 + const places = [ 849 + makePlace("No Coords", { startLine: 0 }), 850 + makePlace("Has Coords", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 851 + ]; 852 + createMap(container, places, {}); 853 + 854 + // Only one marker should be created (for the place with coords) 855 + expect(L.marker).toHaveBeenCalledTimes(1); 856 + }); 857 + 858 + it("all places at same location → single marker, popup lists all names", () => { 859 + const container = document.createElement("div"); 860 + const places = [ 861 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 862 + makePlace("Place B", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 863 + makePlace("Place C", { lat: 41.4036, lng: 2.1744, startLine: 6 }), 864 + ]; 865 + createMap(container, places, {}); 866 + 867 + expect(L.marker).toHaveBeenCalledTimes(1); 868 + 869 + const marker = mockMarkers[0]; 870 + const popupContent = marker.bindPopup.mock.calls[0][0] as string; 871 + expect(popupContent).toContain("Place A"); 872 + expect(popupContent).toContain("Place B"); 873 + expect(popupContent).toContain("Place C"); 874 + }); 875 + 876 + it("places with only lat but no lng are excluded from markers", () => { 877 + const container = document.createElement("div"); 878 + const places = [ 879 + makePlace("Half Place", { lat: 41.4036, startLine: 0 }), 880 + ]; 881 + createMap(container, places, {}); 882 + 883 + expect(L.marker).not.toHaveBeenCalled(); 884 + }); 885 + 886 + it("places with only lng but no lat are excluded from markers", () => { 887 + const container = document.createElement("div"); 888 + const places = [ 889 + makePlace("Half Place", { lng: 2.1744, startLine: 0 }), 890 + ]; 891 + createMap(container, places, {}); 892 + 893 + expect(L.marker).not.toHaveBeenCalled(); 894 + }); 895 + 896 + it("selectPlace with a place not in the current markers does nothing", () => { 897 + const container = document.createElement("div"); 898 + const places = [ 899 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 900 + ]; 901 + const ctrl = createMap(container, places, {}); 902 + 903 + const unknownPlace = makePlace("Unknown", { 904 + lat: 99, lng: 99, startLine: 999, 905 + }); 906 + 907 + // Should not throw 908 + expect(() => ctrl.selectPlace(unknownPlace)).not.toThrow(); 909 + }); 910 + }); 911 + 912 + // ─── Adversary-identified gaps: destroyed-state methods ─────────────── 913 + 914 + describe("Post-destroy safety", () => { 915 + it("updateMarkers is a no-op after destroy", () => { 916 + const container = document.createElement("div"); 917 + const ctrl = createMap(container, [], {}); 918 + ctrl.destroy(); 919 + 920 + const markerCountBefore = mockMarkers.length; 921 + ctrl.updateMarkers([ 922 + makePlace("X", { lat: 10, lng: 20, startLine: 0 }), 923 + ]); 924 + 925 + // No new markers should be created 926 + expect(mockMarkers.length).toBe(markerCountBefore); 927 + }); 928 + 929 + it("selectPlace is a no-op after destroy", () => { 930 + const container = document.createElement("div"); 931 + const places = [ 932 + makePlace("A", { lat: 10, lng: 20, startLine: 0 }), 933 + ]; 934 + const ctrl = createMap(container, places, {}); 935 + ctrl.destroy(); 936 + 937 + // Should not throw or create highlight 938 + const cmCountBefore = mockCircleMarkers.length; 939 + expect(() => ctrl.selectPlace(places[0])).not.toThrow(); 940 + expect(mockCircleMarkers.length).toBe(cmCountBefore); 941 + }); 942 + 943 + it("fitBounds is a no-op after destroy", () => { 944 + const container = document.createElement("div"); 945 + const ctrl = createMap(container, [], {}); 946 + ctrl.destroy(); 947 + 948 + mockMapInstance.fitBounds.mockClear(); 949 + mockMapInstance.setView.mockClear(); 950 + 951 + expect(() => ctrl.fitBounds()).not.toThrow(); 952 + expect(mockMapInstance.fitBounds).not.toHaveBeenCalled(); 953 + expect(mockMapInstance.setView).not.toHaveBeenCalled(); 954 + }); 955 + 956 + it("invalidateSize is a no-op after destroy", () => { 957 + const container = document.createElement("div"); 958 + const ctrl = createMap(container, [], {}); 959 + ctrl.destroy(); 960 + 961 + mockMapInstance.invalidateSize.mockClear(); 962 + 963 + expect(() => ctrl.invalidateSize()).not.toThrow(); 964 + expect(mockMapInstance.invalidateSize).not.toHaveBeenCalled(); 965 + }); 966 + }); 967 + 968 + // ─── Adversary-identified gaps: XSS and HTML escaping ───────────────── 969 + 970 + describe("Popup security: XSS prevention", () => { 971 + it("escapes HTML entities in place names in popup content", () => { 972 + const container = document.createElement("div"); 973 + const places = [ 974 + makePlace('<script>alert("xss")</script>', { 975 + lat: 41.4036, 976 + lng: 2.1744, 977 + startLine: 0, 978 + }), 979 + ]; 980 + createMap(container, places, {}); 981 + 982 + const marker = mockMarkers[0]; 983 + const popupContent = marker.bindPopup.mock.calls[0][0] as string; 984 + 985 + expect(popupContent).not.toContain("<script>"); 986 + expect(popupContent).toContain("&lt;script&gt;"); 987 + }); 988 + 989 + it("escapes URLs in popup href attributes to prevent attribute breakout", () => { 990 + const container = document.createElement("div"); 991 + const places = [ 992 + makePlace("Evil Place", { 993 + lat: 41.4036, 994 + lng: 2.1744, 995 + startLine: 0, 996 + url: '" onclick="alert(1)', 997 + }), 998 + ]; 999 + createMap(container, places, {}); 1000 + 1001 + const marker = mockMarkers[0]; 1002 + const popupContent = marker.bindPopup.mock.calls[0][0] as string; 1003 + 1004 + // The double quote in the URL should be escaped 1005 + expect(popupContent).not.toContain('href="" onclick'); 1006 + expect(popupContent).toContain("&quot;"); 1007 + }); 1008 + 1009 + it("rejects javascript: protocol URLs in popup links", () => { 1010 + const container = document.createElement("div"); 1011 + const places = [ 1012 + makePlace("Malicious", { 1013 + lat: 41.4036, 1014 + lng: 2.1744, 1015 + startLine: 0, 1016 + url: "javascript:alert(1)", 1017 + }), 1018 + ]; 1019 + createMap(container, places, {}); 1020 + 1021 + const marker = mockMarkers[0]; 1022 + const popupContent = marker.bindPopup.mock.calls[0][0] as string; 1023 + 1024 + // Should NOT contain an <a> tag with javascript: URL 1025 + expect(popupContent).not.toContain("javascript:"); 1026 + expect(popupContent).not.toContain("<a"); 1027 + }); 1028 + 1029 + it("escapes single quotes in popup content", () => { 1030 + const container = document.createElement("div"); 1031 + const places = [ 1032 + makePlace("O'Malley's Pub", { 1033 + lat: 41.4036, 1034 + lng: 2.1744, 1035 + startLine: 0, 1036 + }), 1037 + ]; 1038 + createMap(container, places, {}); 1039 + 1040 + const marker = mockMarkers[0]; 1041 + const popupContent = marker.bindPopup.mock.calls[0][0] as string; 1042 + 1043 + expect(popupContent).toContain("&#39;"); 1044 + expect(popupContent).not.toContain("O'Malley"); 1045 + }); 1046 + }); 1047 + 1048 + // ─── Adversary-identified gaps: click with no callback ──────────────── 1049 + 1050 + describe("Marker click with no onPlaceSelect callback", () => { 1051 + it("does not throw when clicking a marker with no onPlaceSelect provided", () => { 1052 + const container = document.createElement("div"); 1053 + const places = [ 1054 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1055 + ]; 1056 + createMap(container, places, {}); 1057 + 1058 + const marker = mockMarkers[0]; 1059 + const clickHandler = marker._events["click"]?.[0]; 1060 + expect(clickHandler).toBeDefined(); 1061 + 1062 + // Click with empty callbacks — should not throw 1063 + expect(() => clickHandler!({})).not.toThrow(); 1064 + }); 1065 + }); 1066 + 1067 + // ─── Adversary-identified gaps: highlight ring className ────────────── 1068 + 1069 + describe("Highlight ring CSS class", () => { 1070 + it("circleMarker is created with className 'map-marker-highlight'", () => { 1071 + const container = document.createElement("div"); 1072 + const places = [ 1073 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1074 + ]; 1075 + const ctrl = createMap(container, places, {}); 1076 + 1077 + ctrl.selectPlace(places[0]); 1078 + 1079 + expect(L.circleMarker).toHaveBeenCalled(); 1080 + const cmCall = (L.circleMarker as ReturnType<typeof vi.fn>).mock.calls[0]; 1081 + const options = cmCall[1]; 1082 + expect(options.className).toBe("map-marker-highlight"); 1083 + }); 1084 + }); 1085 + 1086 + // ─── Adversary-identified gaps: selectPlace zoom behavior ───────────── 1087 + 1088 + describe("selectPlace zoom behavior", () => { 1089 + it("does not zoom out if already zoomed in past 13", () => { 1090 + const container = document.createElement("div"); 1091 + const places = [ 1092 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1093 + ]; 1094 + const ctrl = createMap(container, places, {}); 1095 + 1096 + // Simulate the user being zoomed in to level 18 1097 + mockMapInstance.getZoom.mockReturnValue(18); 1098 + mockMapInstance.setView.mockClear(); 1099 + 1100 + ctrl.selectPlace(places[0]); 1101 + 1102 + // Should use zoom 18 (current), not 13 1103 + expect(mockMapInstance.setView).toHaveBeenCalledWith( 1104 + expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 1105 + 18 1106 + ); 1107 + }); 1108 + 1109 + it("zooms in to 13 if currently zoomed out further", () => { 1110 + const container = document.createElement("div"); 1111 + const places = [ 1112 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1113 + ]; 1114 + const ctrl = createMap(container, places, {}); 1115 + 1116 + // Simulate the user being zoomed out to level 5 1117 + mockMapInstance.getZoom.mockReturnValue(5); 1118 + mockMapInstance.setView.mockClear(); 1119 + 1120 + ctrl.selectPlace(places[0]); 1121 + 1122 + // Should use zoom 13 (minimum for selection) 1123 + expect(mockMapInstance.setView).toHaveBeenCalledWith( 1124 + expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 1125 + 13 1126 + ); 1127 + }); 1128 + }); 1129 + 1130 + // ─── Adversary-identified gaps: sequential selectPlace ──────────────── 1131 + 1132 + describe("Sequential selectPlace removes previous highlight", () => { 1133 + it("removes old highlight ring before creating new one", () => { 1134 + const container = document.createElement("div"); 1135 + const places = [ 1136 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1137 + makePlace("Place B", { lat: 48.8606, lng: 2.3376, startLine: 5 }), 1138 + ]; 1139 + const ctrl = createMap(container, places, {}); 1140 + 1141 + ctrl.selectPlace(places[0]); 1142 + expect(mockCircleMarkers).toHaveLength(1); 1143 + const firstRing = mockCircleMarkers[0]; 1144 + 1145 + ctrl.selectPlace(places[1]); 1146 + 1147 + // First ring should have been removed 1148 + expect(firstRing.remove).toHaveBeenCalled(); 1149 + // A new ring should have been created 1150 + expect(mockCircleMarkers).toHaveLength(2); 1151 + expect(mockCircleMarkers[1].addTo).toHaveBeenCalled(); 1152 + }); 1153 + });
+1820
tests/mapView.test.ts
··· 1 + /** 2 + * mapView.test.ts — Tests for all mapView.ts behavioral contracts 3 + * 4 + * Mocks Obsidian API (Vault, Workspace, MarkdownView, Editor), 5 + * parser, geocoder, and mapRenderer modules. Tests debounce, fingerprinting, 6 + * geo write-back, write guards, geocoding mutex, cursor sync, and error handling. 7 + * 8 + * @vitest-environment jsdom 9 + */ 10 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 11 + import type { Place } from "../src/parser"; 12 + import type { MapController, MapCallbacks } from "../src/mapRenderer"; 13 + 14 + // ─── Helpers ────────────────────────────────────────────────────────── 15 + 16 + function makePlace( 17 + name: string, 18 + overrides: Partial<Place> = {} 19 + ): Place { 20 + return { 21 + name, 22 + fields: {}, 23 + notes: [], 24 + startLine: 0, 25 + endLine: 0, 26 + ...overrides, 27 + }; 28 + } 29 + 30 + // ─── Mock Types ─────────────────────────────────────────────────────── 31 + 32 + interface MockEditor { 33 + getCursor: ReturnType<typeof vi.fn>; 34 + setCursor: ReturnType<typeof vi.fn>; 35 + scrollIntoView: ReturnType<typeof vi.fn>; 36 + } 37 + 38 + interface MockTFile { 39 + path: string; 40 + name: string; 41 + basename: string; 42 + extension: string; 43 + stat: { ctime: number; mtime: number; size: number }; 44 + parent: null; 45 + vault: unknown; 46 + } 47 + 48 + interface MockMarkdownView { 49 + editor: MockEditor; 50 + file: MockTFile | null; 51 + getViewType: () => string; 52 + // Used for instanceof checks — we tag it 53 + _isMarkdownView: true; 54 + } 55 + 56 + interface MockWorkspaceLeaf { 57 + view: MockMarkdownView | { getViewType: () => string }; 58 + } 59 + 60 + // ─── Obsidian Mock Infrastructure ───────────────────────────────────── 61 + 62 + // Event system mock to emulate Obsidian's Events class 63 + type EventCallback = (...args: unknown[]) => unknown; 64 + 65 + class MockEvents { 66 + private _handlers: Map<string, Set<EventCallback>> = new Map(); 67 + 68 + on(name: string, callback: EventCallback): { id: string } { 69 + if (!this._handlers.has(name)) { 70 + this._handlers.set(name, new Set()); 71 + } 72 + this._handlers.get(name)!.add(callback); 73 + return { id: `${name}-${Math.random()}` }; 74 + } 75 + 76 + off(name: string, callback: EventCallback): void { 77 + this._handlers.get(name)?.delete(callback); 78 + } 79 + 80 + offref(_ref: unknown): void { 81 + // In tests we don't track refs to handlers 82 + } 83 + 84 + trigger(name: string, ...data: unknown[]): void { 85 + const handlers = this._handlers.get(name); 86 + if (handlers) { 87 + for (const handler of handlers) { 88 + handler(...data); 89 + } 90 + } 91 + } 92 + } 93 + 94 + // ─── Module-level mock state ────────────────────────────────────────── 95 + 96 + let mockVault: MockEvents & { 97 + cachedRead: ReturnType<typeof vi.fn>; 98 + process: ReturnType<typeof vi.fn>; 99 + }; 100 + 101 + let mockWorkspace: MockEvents & { 102 + getActiveViewOfType: ReturnType<typeof vi.fn>; 103 + getLeavesOfType: ReturnType<typeof vi.fn>; 104 + getLeaf: ReturnType<typeof vi.fn>; 105 + revealLeaf: ReturnType<typeof vi.fn>; 106 + detachLeavesOfType: ReturnType<typeof vi.fn>; 107 + onLayoutReady: ReturnType<typeof vi.fn>; 108 + }; 109 + 110 + let mockApp: { 111 + workspace: typeof mockWorkspace; 112 + vault: typeof mockVault; 113 + }; 114 + 115 + let mockLeaf: { 116 + view: unknown; 117 + getViewState: ReturnType<typeof vi.fn>; 118 + setViewState: ReturnType<typeof vi.fn>; 119 + detach: ReturnType<typeof vi.fn>; 120 + }; 121 + 122 + let mockEditor: MockEditor; 123 + let mockFile: MockTFile; 124 + let mockMarkdownView: MockMarkdownView; 125 + 126 + // Parser mock 127 + let mockParsePlaces: ReturnType<typeof vi.fn>; 128 + 129 + // Geocoder mock 130 + let mockGeocodePlaces: ReturnType<typeof vi.fn>; 131 + 132 + // MapRenderer mock 133 + let mockMapController: { 134 + updateMarkers: ReturnType<typeof vi.fn>; 135 + selectPlace: ReturnType<typeof vi.fn>; 136 + fitBounds: ReturnType<typeof vi.fn>; 137 + invalidateSize: ReturnType<typeof vi.fn>; 138 + destroy: ReturnType<typeof vi.fn>; 139 + }; 140 + let mockCreateMap: ReturnType<typeof vi.fn>; 141 + 142 + // Notice mock 143 + let mockNoticeInstances: Array<{ message: string; duration?: number }>; 144 + 145 + // Track registerEvent and registerInterval calls 146 + let registeredEvents: Array<{ id: string }>; 147 + let registeredIntervals: number[]; 148 + let registeredCleanups: Array<() => void>; 149 + 150 + // ─── Setup / Teardown ───────────────────────────────────────────────── 151 + 152 + function createMockFile(name = "test-note.md", path = "test-note.md"): MockTFile { 153 + return { 154 + path, 155 + name, 156 + basename: name.replace(/\.md$/, ""), 157 + extension: "md", 158 + stat: { ctime: Date.now(), mtime: Date.now(), size: 100 }, 159 + parent: null, 160 + vault: mockVault, 161 + }; 162 + } 163 + 164 + function createMockEditor(): MockEditor { 165 + return { 166 + getCursor: vi.fn().mockReturnValue({ line: 0, ch: 0 }), 167 + setCursor: vi.fn(), 168 + scrollIntoView: vi.fn(), 169 + }; 170 + } 171 + 172 + function createMockMarkdownView( 173 + editor: MockEditor, 174 + file: MockTFile | null 175 + ): MockMarkdownView { 176 + return { 177 + editor, 178 + file, 179 + getViewType: () => "markdown", 180 + _isMarkdownView: true, 181 + }; 182 + } 183 + 184 + beforeEach(() => { 185 + vi.useFakeTimers(); 186 + mockNoticeInstances = []; 187 + registeredEvents = []; 188 + registeredIntervals = []; 189 + registeredCleanups = []; 190 + 191 + // Build vault mock 192 + const vaultEvents = new MockEvents(); 193 + mockVault = Object.assign(vaultEvents, { 194 + cachedRead: vi.fn().mockResolvedValue(""), 195 + process: vi.fn().mockImplementation( 196 + async (_file: MockTFile, fn: (data: string) => string) => { 197 + const result = fn(""); 198 + return result; 199 + } 200 + ), 201 + }); 202 + 203 + // Build workspace mock 204 + const workspaceEvents = new MockEvents(); 205 + mockWorkspace = Object.assign(workspaceEvents, { 206 + getActiveViewOfType: vi.fn().mockReturnValue(null), 207 + getLeavesOfType: vi.fn().mockReturnValue([]), 208 + getLeaf: vi.fn(), 209 + revealLeaf: vi.fn().mockResolvedValue(undefined), 210 + detachLeavesOfType: vi.fn(), 211 + onLayoutReady: vi.fn().mockImplementation((cb: () => void) => cb()), 212 + }); 213 + 214 + // Build editor and view 215 + mockFile = createMockFile(); 216 + mockEditor = createMockEditor(); 217 + mockMarkdownView = createMockMarkdownView(mockEditor, mockFile); 218 + 219 + // Build leaf 220 + mockLeaf = { 221 + view: mockMarkdownView, 222 + getViewState: vi.fn(), 223 + setViewState: vi.fn(), 224 + detach: vi.fn(), 225 + }; 226 + 227 + // Build app 228 + mockApp = { 229 + workspace: mockWorkspace, 230 + vault: mockVault, 231 + }; 232 + 233 + // Build map controller mock 234 + mockMapController = { 235 + updateMarkers: vi.fn(), 236 + selectPlace: vi.fn(), 237 + fitBounds: vi.fn(), 238 + invalidateSize: vi.fn(), 239 + destroy: vi.fn(), 240 + }; 241 + mockCreateMap = vi.fn().mockReturnValue(mockMapController); 242 + 243 + // Parser mock — returns empty by default 244 + mockParsePlaces = vi.fn().mockReturnValue([]); 245 + 246 + // Geocoder mock — resolves immediately, returns the input places 247 + mockGeocodePlaces = vi.fn().mockImplementation( 248 + async (places: Place[]) => places 249 + ); 250 + 251 + // ── Module mocks ── 252 + 253 + vi.doMock("../src/parser", () => ({ 254 + parsePlaces: mockParsePlaces, 255 + GEO_LINE_RE: /^[\t ]+[*-] geo: .*/, 256 + })); 257 + 258 + vi.doMock("../src/geocoder", () => ({ 259 + geocodePlaces: mockGeocodePlaces, 260 + })); 261 + 262 + vi.doMock("../src/mapRenderer", () => ({ 263 + createMap: mockCreateMap, 264 + })); 265 + 266 + // Mock the obsidian module 267 + vi.doMock("obsidian", () => { 268 + // We provide a MarkdownView constructor that we can use for instanceof checks 269 + class MarkdownView { 270 + static _isMarkdownView = true; 271 + editor: MockEditor; 272 + file: MockTFile | null; 273 + constructor() { 274 + this.editor = createMockEditor(); 275 + this.file = null; 276 + } 277 + getViewType() { 278 + return "markdown"; 279 + } 280 + } 281 + 282 + class ItemView { 283 + app: unknown; 284 + leaf: unknown; 285 + containerEl: HTMLElement; 286 + contentEl: HTMLElement; 287 + 288 + constructor(leaf: unknown) { 289 + this.leaf = leaf; 290 + this.app = mockApp; 291 + this.containerEl = document.createElement("div"); 292 + this.contentEl = document.createElement("div"); 293 + this.containerEl.appendChild(this.contentEl); 294 + } 295 + getViewType() { 296 + return ""; 297 + } 298 + getDisplayText() { 299 + return ""; 300 + } 301 + getIcon() { 302 + return ""; 303 + } 304 + register(cb: () => void) { 305 + registeredCleanups.push(cb); 306 + } 307 + registerEvent(ref: { id: string }) { 308 + registeredEvents.push(ref); 309 + } 310 + registerInterval(id: number) { 311 + registeredIntervals.push(id); 312 + return id; 313 + } 314 + addChild(_c: unknown) { 315 + return _c; 316 + } 317 + removeChild(_c: unknown) { 318 + return _c; 319 + } 320 + onload() {} 321 + onunload() {} 322 + } 323 + 324 + class Notice { 325 + message: string; 326 + duration?: number; 327 + noticeEl: HTMLElement; 328 + constructor(message: string, duration?: number) { 329 + this.message = message; 330 + this.duration = duration; 331 + this.noticeEl = document.createElement("div"); 332 + mockNoticeInstances.push({ message, duration }); 333 + } 334 + hide() {} 335 + } 336 + 337 + class Plugin { 338 + app: unknown; 339 + manifest: unknown; 340 + constructor(app: unknown, manifest: unknown) { 341 + this.app = app; 342 + this.manifest = manifest; 343 + } 344 + registerView() {} 345 + addCommand() {} 346 + register() {} 347 + registerEvent() {} 348 + registerInterval() { 349 + return 0; 350 + } 351 + } 352 + 353 + class TFile { 354 + path = ""; 355 + name = ""; 356 + basename = ""; 357 + extension = "md"; 358 + stat = { ctime: 0, mtime: 0, size: 0 }; 359 + parent = null; 360 + } 361 + 362 + return { 363 + ItemView, 364 + MarkdownView, 365 + Notice, 366 + Plugin, 367 + TFile, 368 + }; 369 + }); 370 + }); 371 + 372 + afterEach(() => { 373 + vi.useRealTimers(); 374 + vi.restoreAllMocks(); 375 + vi.resetModules(); 376 + }); 377 + 378 + // ─── Microtask Flush Helper ─────────────────────────────────────────── 379 + 380 + /** 381 + * Flush pending microtasks (resolved promises) without advancing fake timers. 382 + * This avoids triggering infinite setInterval loops from cursor sync polling. 383 + * Call this instead of vi.runAllTimersAsync() when you need promises to settle. 384 + */ 385 + async function flushMicrotasks(): Promise<void> { 386 + // Multiple rounds to handle chained promises 387 + for (let i = 0; i < 10; i++) { 388 + await Promise.resolve(); 389 + } 390 + } 391 + 392 + // ─── Import Helper ──────────────────────────────────────────────────── 393 + 394 + // Import the module fresh for each test group 395 + async function importMapView() { 396 + const mod = await import("../src/mapView"); 397 + return mod; 398 + } 399 + 400 + // Helper to create a MapViewerView instance for testing 401 + async function createTestView() { 402 + const mod = await importMapView(); 403 + const ViewClass = mod.MapViewerView; 404 + // Construct with our mock leaf 405 + const view = new ViewClass(mockLeaf as any); 406 + // Manually set app since super(leaf) uses it 407 + (view as any).app = mockApp; 408 + return { view, mod }; 409 + } 410 + 411 + // Helper to create a view and call onOpen 412 + async function createAndOpenView() { 413 + const { view, mod } = await createTestView(); 414 + await (view as any).onOpen(); 415 + return { view, mod }; 416 + } 417 + 418 + // ─── Contract 1: View type registration ─────────────────────────────── 419 + 420 + describe("Contract 1: Registered as view type 'map-viewer'", () => { 421 + it("getViewType() returns 'map-viewer'", async () => { 422 + const { view } = await createTestView(); 423 + expect(view.getViewType()).toBe("map-viewer"); 424 + }); 425 + 426 + it("getDisplayText() returns 'Map'", async () => { 427 + const { view } = await createTestView(); 428 + expect(view.getDisplayText()).toBe("Map"); 429 + }); 430 + 431 + it("getIcon() returns 'map-pin'", async () => { 432 + const { view } = await createTestView(); 433 + expect(view.getIcon()).toBe("map-pin"); 434 + }); 435 + }); 436 + 437 + // ─── Contract 2: On open — map container and initialization ─────────── 438 + 439 + describe("Contract 2: On open creates map container", () => { 440 + it("creates a full-height map container div on open", async () => { 441 + const { view } = await createAndOpenView(); 442 + 443 + // createMap should have been called 444 + expect(mockCreateMap).toHaveBeenCalledTimes(1); 445 + 446 + // First argument is the container element 447 + const container = mockCreateMap.mock.calls[0][0]; 448 + expect(container).toBeInstanceOf(HTMLElement); 449 + }); 450 + 451 + it("passes initial places (empty for no file) to createMap", async () => { 452 + // No active markdown view 453 + mockWorkspace.getActiveViewOfType.mockReturnValue(null); 454 + 455 + const { view } = await createAndOpenView(); 456 + 457 + // createMap called with empty places array 458 + expect(mockCreateMap).toHaveBeenCalledWith( 459 + expect.any(HTMLElement), 460 + expect.any(Array), 461 + expect.any(Object) 462 + ); 463 + }); 464 + 465 + it("initializes map via createMap()", async () => { 466 + const { view } = await createAndOpenView(); 467 + expect(mockCreateMap).toHaveBeenCalledTimes(1); 468 + }); 469 + }); 470 + 471 + // ─── Contract 3: Debounced refresh (trailing edge, 300ms) ───────────── 472 + 473 + describe("Contract 3: refresh() is debounced with trailing edge, 300ms", () => { 474 + it("does not refresh immediately when called", async () => { 475 + const { view } = await createAndOpenView(); 476 + 477 + // Set up active markdown view with content 478 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 479 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 480 + mockParsePlaces.mockReturnValue([ 481 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 482 + ]); 483 + 484 + // Trigger refresh (e.g., via file modify event) 485 + mockVault.trigger("modify", mockFile); 486 + 487 + // Should NOT have called parsePlaces yet (debounce not elapsed) 488 + expect(mockParsePlaces).not.toHaveBeenCalled(); 489 + }); 490 + 491 + it("refreshes after 300ms debounce period", async () => { 492 + const { view } = await createAndOpenView(); 493 + 494 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 495 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 496 + mockParsePlaces.mockReturnValue([ 497 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 498 + ]); 499 + 500 + mockVault.trigger("modify", mockFile); 501 + 502 + // Advance past debounce 503 + await vi.advanceTimersByTimeAsync(300); 504 + 505 + expect(mockParsePlaces).toHaveBeenCalled(); 506 + }); 507 + 508 + it("coalesces rapid triggers into a single refresh", async () => { 509 + const { view } = await createAndOpenView(); 510 + 511 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 512 + mockVault.cachedRead.mockResolvedValue("* Place A"); 513 + mockParsePlaces.mockReturnValue([makePlace("Place A", { startLine: 0, endLine: 0 })]); 514 + 515 + // Trigger multiple modify events rapidly 516 + mockVault.trigger("modify", mockFile); 517 + await vi.advanceTimersByTimeAsync(100); 518 + mockVault.trigger("modify", mockFile); 519 + await vi.advanceTimersByTimeAsync(100); 520 + mockVault.trigger("modify", mockFile); 521 + 522 + // Advance past debounce from last trigger 523 + await vi.advanceTimersByTimeAsync(300); 524 + 525 + // Should only have been called once (trailing edge) 526 + expect(mockParsePlaces).toHaveBeenCalledTimes(1); 527 + }); 528 + }); 529 + 530 + // ─── Contract 4: refresh reads active file and parses ───────────────── 531 + 532 + describe("Contract 4: refresh reads active file content and parses", () => { 533 + it("calls vault.cachedRead with the active file", async () => { 534 + const { view } = await createAndOpenView(); 535 + 536 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 537 + mockVault.cachedRead.mockResolvedValue("* Sagrada Familia\n\t* geo: 41.4,2.1"); 538 + mockParsePlaces.mockReturnValue([ 539 + makePlace("Sagrada Familia", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 540 + ]); 541 + 542 + mockVault.trigger("modify", mockFile); 543 + await vi.advanceTimersByTimeAsync(300); 544 + 545 + expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile); 546 + }); 547 + 548 + it("calls parsePlaces with the file content", async () => { 549 + const { view } = await createAndOpenView(); 550 + 551 + const content = "* Sagrada Familia\n\t* geo: 41.4,2.1"; 552 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 553 + mockVault.cachedRead.mockResolvedValue(content); 554 + mockParsePlaces.mockReturnValue([]); 555 + 556 + mockVault.trigger("modify", mockFile); 557 + await vi.advanceTimersByTimeAsync(300); 558 + 559 + expect(mockParsePlaces).toHaveBeenCalledWith(content); 560 + }); 561 + 562 + it("calls updateMarkers on the map controller", async () => { 563 + const { view } = await createAndOpenView(); 564 + 565 + const places = [ 566 + makePlace("Sagrada Familia", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 567 + ]; 568 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 569 + mockVault.cachedRead.mockResolvedValue("* Sagrada Familia\n\t* geo: 41.4,2.1"); 570 + mockParsePlaces.mockReturnValue(places); 571 + 572 + mockVault.trigger("modify", mockFile); 573 + await vi.advanceTimersByTimeAsync(300); 574 + 575 + expect(mockMapController.updateMarkers).toHaveBeenCalled(); 576 + }); 577 + }); 578 + 579 + // ─── Contract 5: Fingerprinting for map rebuild ─────────────────────── 580 + 581 + describe("Contract 5: Fingerprint skip — name::lat::lng joined by |", () => { 582 + it("skips updateMarkers if fingerprint is unchanged", async () => { 583 + const { view } = await createAndOpenView(); 584 + 585 + const places = [ 586 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 587 + ]; 588 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 589 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 590 + mockParsePlaces.mockReturnValue(places); 591 + 592 + // First refresh 593 + mockVault.trigger("modify", mockFile); 594 + await vi.advanceTimersByTimeAsync(300); 595 + expect(mockMapController.updateMarkers).toHaveBeenCalledTimes(1); 596 + 597 + mockMapController.updateMarkers.mockClear(); 598 + 599 + // Same places again (same fingerprint) 600 + mockParsePlaces.mockReturnValue([ 601 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 602 + ]); 603 + mockVault.trigger("modify", mockFile); 604 + await vi.advanceTimersByTimeAsync(300); 605 + 606 + // Should NOT call updateMarkers again — fingerprint unchanged 607 + expect(mockMapController.updateMarkers).not.toHaveBeenCalled(); 608 + }); 609 + 610 + it("calls updateMarkers if fingerprint changes", async () => { 611 + const { view } = await createAndOpenView(); 612 + 613 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 614 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 615 + mockParsePlaces.mockReturnValue([ 616 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 617 + ]); 618 + 619 + // First refresh 620 + mockVault.trigger("modify", mockFile); 621 + await vi.advanceTimersByTimeAsync(300); 622 + mockMapController.updateMarkers.mockClear(); 623 + 624 + // Different places (different fingerprint) 625 + mockParsePlaces.mockReturnValue([ 626 + makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 0, endLine: 1 }), 627 + ]); 628 + mockVault.trigger("modify", mockFile); 629 + await vi.advanceTimersByTimeAsync(300); 630 + 631 + expect(mockMapController.updateMarkers).toHaveBeenCalled(); 632 + }); 633 + 634 + it("always re-parses even when fingerprint unchanged (fresh line ranges)", async () => { 635 + const { view } = await createAndOpenView(); 636 + 637 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 638 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 639 + mockParsePlaces.mockReturnValue([ 640 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 641 + ]); 642 + 643 + // First refresh 644 + mockVault.trigger("modify", mockFile); 645 + await vi.advanceTimersByTimeAsync(300); 646 + expect(mockParsePlaces).toHaveBeenCalledTimes(1); 647 + 648 + // Second refresh (same fingerprint) 649 + mockParsePlaces.mockReturnValue([ 650 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 651 + ]); 652 + mockVault.trigger("modify", mockFile); 653 + await vi.advanceTimersByTimeAsync(300); 654 + 655 + // Parser ALWAYS called, even though map won't update 656 + expect(mockParsePlaces).toHaveBeenCalledTimes(2); 657 + }); 658 + }); 659 + 660 + // ─── Contract 6: Geo write-back via vault.process ───────────────────── 661 + 662 + describe("Contract 6: Geo write-back after geocoding", () => { 663 + it("calls vault.process after successful geocoding", async () => { 664 + const { view } = await createAndOpenView(); 665 + 666 + const placesBeforeGeocode = [ 667 + makePlace("Place A", { startLine: 0, endLine: 0 }), 668 + ]; 669 + const placesAfterGeocode = [ 670 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0, endLine: 0 }), 671 + ]; 672 + 673 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 674 + mockVault.cachedRead.mockResolvedValue("* Place A"); 675 + mockParsePlaces.mockReturnValue(placesBeforeGeocode); 676 + 677 + // Geocoder resolves with coordinates 678 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 679 + places[0].lat = 41.4036; 680 + places[0].lng = 2.1744; 681 + return places; 682 + }); 683 + 684 + mockVault.trigger("modify", mockFile); 685 + await vi.advanceTimersByTimeAsync(300); 686 + 687 + // Let geocoding resolve 688 + await flushMicrotasks(); 689 + 690 + expect(mockVault.process).toHaveBeenCalledWith( 691 + mockFile, 692 + expect.any(Function) 693 + ); 694 + }); 695 + 696 + it("re-parses CURRENT content inside vault.process callback", async () => { 697 + const { view } = await createAndOpenView(); 698 + 699 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 700 + mockVault.cachedRead.mockResolvedValue("* Place A"); 701 + mockParsePlaces 702 + .mockReturnValueOnce([makePlace("Place A", { startLine: 0, endLine: 0 })]) 703 + .mockReturnValue([makePlace("Place A", { startLine: 0, endLine: 0 })]); 704 + 705 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 706 + places[0].lat = 41.4036; 707 + places[0].lng = 2.1744; 708 + return places; 709 + }); 710 + 711 + // vault.process calls fn with current content — capture the fn 712 + mockVault.process.mockImplementation( 713 + async (_file: unknown, fn: (data: string) => string) => { 714 + const currentContent = "* Place A"; 715 + return fn(currentContent); 716 + } 717 + ); 718 + 719 + mockVault.trigger("modify", mockFile); 720 + await vi.advanceTimersByTimeAsync(300); 721 + await flushMicrotasks(); 722 + 723 + // parsePlaces should be called again INSIDE vault.process (re-parse) 724 + // First call from refresh, second call from inside vault.process 725 + expect(mockParsePlaces).toHaveBeenCalledTimes(2); 726 + }); 727 + 728 + it("inserts geo: sub-bullet after endLine for places without existing geo", async () => { 729 + const { view } = await createAndOpenView(); 730 + 731 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 732 + mockVault.cachedRead.mockResolvedValue("* Place A"); 733 + 734 + // First parse: no geo 735 + mockParsePlaces.mockReturnValue([ 736 + makePlace("Place A", { startLine: 0, endLine: 0 }), 737 + ]); 738 + 739 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 740 + places[0].lat = 41.403600; 741 + places[0].lng = 2.174400; 742 + return places; 743 + }); 744 + 745 + // Capture what vault.process does with the content 746 + let processedContent = ""; 747 + mockVault.process.mockImplementation( 748 + async (_file: unknown, fn: (data: string) => string) => { 749 + // Re-parse inside vault.process returns place at line 0 750 + mockParsePlaces.mockReturnValueOnce([ 751 + makePlace("Place A", { startLine: 0, endLine: 0 }), 752 + ]); 753 + processedContent = fn("* Place A"); 754 + return processedContent; 755 + } 756 + ); 757 + 758 + mockVault.trigger("modify", mockFile); 759 + await vi.advanceTimersByTimeAsync(300); 760 + await flushMicrotasks(); 761 + 762 + // Should have inserted a geo line after the place 763 + expect(processedContent).toContain("\t* geo: 41.403600,2.174400"); 764 + }); 765 + 766 + it("replaces existing geo: line when place already has one", async () => { 767 + const { view } = await createAndOpenView(); 768 + 769 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 770 + // Place has an INVALID geo line — parser leaves lat/lng undefined 771 + const content = "* Place A\n\t* geo: invalid"; 772 + mockVault.cachedRead.mockResolvedValue(content); 773 + 774 + // Parse returns place with geo field but no valid lat/lng (needs re-geocode) 775 + mockParsePlaces.mockReturnValue([ 776 + makePlace("Place A", { 777 + startLine: 0, 778 + endLine: 1, 779 + fields: { geo: "invalid" }, 780 + // lat and lng are undefined — parser couldn't parse "invalid" 781 + }), 782 + ]); 783 + 784 + // Geocoder gives new coordinates 785 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 786 + places[0].lat = 41.403600; 787 + places[0].lng = 2.174400; 788 + return places; 789 + }); 790 + 791 + let processedContent = ""; 792 + mockVault.process.mockImplementation( 793 + async (_file: unknown, fn: (data: string) => string) => { 794 + // Re-parse inside vault.process shows the place still has the invalid geo line 795 + mockParsePlaces.mockReturnValueOnce([ 796 + makePlace("Place A", { 797 + startLine: 0, 798 + endLine: 1, 799 + fields: { geo: "invalid" }, 800 + }), 801 + ]); 802 + processedContent = fn(content); 803 + return processedContent; 804 + } 805 + ); 806 + 807 + mockVault.trigger("modify", mockFile); 808 + await vi.advanceTimersByTimeAsync(300); 809 + await flushMicrotasks(); 810 + 811 + // The old geo line should be replaced with new coordinates 812 + expect(processedContent).toContain("\t* geo: 41.403600,2.174400"); 813 + expect(processedContent).not.toContain("invalid"); 814 + }); 815 + 816 + it("writes geo with 6 decimal places (toFixed(6))", async () => { 817 + const { view } = await createAndOpenView(); 818 + 819 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 820 + mockVault.cachedRead.mockResolvedValue("* Place A"); 821 + mockParsePlaces.mockReturnValue([ 822 + makePlace("Place A", { startLine: 0, endLine: 0 }), 823 + ]); 824 + 825 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 826 + places[0].lat = 41.40359999; 827 + places[0].lng = 2.17440001; 828 + return places; 829 + }); 830 + 831 + let processedContent = ""; 832 + mockVault.process.mockImplementation( 833 + async (_file: unknown, fn: (data: string) => string) => { 834 + mockParsePlaces.mockReturnValueOnce([ 835 + makePlace("Place A", { startLine: 0, endLine: 0 }), 836 + ]); 837 + processedContent = fn("* Place A"); 838 + return processedContent; 839 + } 840 + ); 841 + 842 + mockVault.trigger("modify", mockFile); 843 + await vi.advanceTimersByTimeAsync(300); 844 + await flushMicrotasks(); 845 + 846 + expect(processedContent).toContain("41.403600,2.174400"); 847 + }); 848 + 849 + it("matches geocoded places to parsed places by name (case-insensitive)", async () => { 850 + const { view } = await createAndOpenView(); 851 + 852 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 853 + mockVault.cachedRead.mockResolvedValue("* Place A\n* Place B"); 854 + 855 + // Initial parse 856 + mockParsePlaces.mockReturnValue([ 857 + makePlace("Place A", { startLine: 0, endLine: 0 }), 858 + makePlace("Place B", { startLine: 1, endLine: 1 }), 859 + ]); 860 + 861 + // Only geocode Place A 862 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 863 + for (const p of places) { 864 + if (p.name.toLowerCase() === "place a") { 865 + p.lat = 41.4036; 866 + p.lng = 2.1744; 867 + } 868 + } 869 + return places; 870 + }); 871 + 872 + let processedContent = ""; 873 + mockVault.process.mockImplementation( 874 + async (_file: unknown, fn: (data: string) => string) => { 875 + mockParsePlaces.mockReturnValueOnce([ 876 + makePlace("Place A", { startLine: 0, endLine: 0 }), 877 + makePlace("Place B", { startLine: 1, endLine: 1 }), 878 + ]); 879 + processedContent = fn("* Place A\n* Place B"); 880 + return processedContent; 881 + } 882 + ); 883 + 884 + mockVault.trigger("modify", mockFile); 885 + await vi.advanceTimersByTimeAsync(300); 886 + await flushMicrotasks(); 887 + 888 + // Only Place A should get a geo line inserted 889 + const lines = processedContent.split("\n"); 890 + const geoLines = lines.filter((l: string) => l.includes("geo:")); 891 + expect(geoLines).toHaveLength(1); 892 + expect(geoLines[0]).toContain("41.403600,2.174400"); 893 + }); 894 + 895 + it("processes insertions from bottom-to-top to preserve line numbers", async () => { 896 + const { view } = await createAndOpenView(); 897 + 898 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 899 + const content = "* Place A\n* Place B"; 900 + mockVault.cachedRead.mockResolvedValue(content); 901 + 902 + mockParsePlaces.mockReturnValue([ 903 + makePlace("Place A", { startLine: 0, endLine: 0 }), 904 + makePlace("Place B", { startLine: 1, endLine: 1 }), 905 + ]); 906 + 907 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 908 + places[0].lat = 41.4036; 909 + places[0].lng = 2.1744; 910 + places[1].lat = 48.8606; 911 + places[1].lng = 2.3376; 912 + return places; 913 + }); 914 + 915 + let processedContent = ""; 916 + mockVault.process.mockImplementation( 917 + async (_file: unknown, fn: (data: string) => string) => { 918 + mockParsePlaces.mockReturnValueOnce([ 919 + makePlace("Place A", { startLine: 0, endLine: 0 }), 920 + makePlace("Place B", { startLine: 1, endLine: 1 }), 921 + ]); 922 + processedContent = fn(content); 923 + return processedContent; 924 + } 925 + ); 926 + 927 + mockVault.trigger("modify", mockFile); 928 + await vi.advanceTimersByTimeAsync(300); 929 + await flushMicrotasks(); 930 + 931 + // Both places should have geo lines and content should be well-formed 932 + expect(processedContent).toContain("41.403600,2.174400"); 933 + expect(processedContent).toContain("48.860600,2.337600"); 934 + 935 + // Place A should come before Place B in the output 936 + const idxA = processedContent.indexOf("41.403600"); 937 + const idxB = processedContent.indexOf("48.860600"); 938 + expect(idxA).toBeLessThan(idxB); 939 + }); 940 + 941 + it("write-back format: tab-indented bullet with geo prefix", async () => { 942 + const { view } = await createAndOpenView(); 943 + 944 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 945 + mockVault.cachedRead.mockResolvedValue("* Place A"); 946 + mockParsePlaces.mockReturnValue([ 947 + makePlace("Place A", { startLine: 0, endLine: 0 }), 948 + ]); 949 + 950 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 951 + places[0].lat = 41.4036; 952 + places[0].lng = 2.1744; 953 + return places; 954 + }); 955 + 956 + let processedContent = ""; 957 + mockVault.process.mockImplementation( 958 + async (_file: unknown, fn: (data: string) => string) => { 959 + mockParsePlaces.mockReturnValueOnce([ 960 + makePlace("Place A", { startLine: 0, endLine: 0 }), 961 + ]); 962 + processedContent = fn("* Place A"); 963 + return processedContent; 964 + } 965 + ); 966 + 967 + mockVault.trigger("modify", mockFile); 968 + await vi.advanceTimersByTimeAsync(300); 969 + await flushMicrotasks(); 970 + 971 + // Check exact format: \t* geo: <lat>,<lng> 972 + expect(processedContent).toMatch(/\t\* geo: \d+\.\d{6},\d+\.\d{6}/); 973 + }); 974 + }); 975 + 976 + // ─── Contract 7: Write guard counter mechanism ──────────────────────── 977 + 978 + describe("Contract 7: Write guard prevents self-triggered refresh", () => { 979 + it("increments write guard before vault.process and decrements after 500ms", async () => { 980 + const { view } = await createAndOpenView(); 981 + 982 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 983 + mockVault.cachedRead.mockResolvedValue("* Place A"); 984 + mockParsePlaces.mockReturnValue([ 985 + makePlace("Place A", { startLine: 0, endLine: 0 }), 986 + ]); 987 + 988 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 989 + places[0].lat = 41.4036; 990 + places[0].lng = 2.1744; 991 + return places; 992 + }); 993 + 994 + mockVault.process.mockImplementation( 995 + async (_file: unknown, fn: (data: string) => string) => { 996 + mockParsePlaces.mockReturnValueOnce([ 997 + makePlace("Place A", { startLine: 0, endLine: 0 }), 998 + ]); 999 + return fn("* Place A"); 1000 + } 1001 + ); 1002 + 1003 + // Trigger initial refresh 1004 + mockVault.trigger("modify", mockFile); 1005 + await vi.advanceTimersByTimeAsync(300); 1006 + await flushMicrotasks(); 1007 + 1008 + // Now the write guard should be active (counter > 0) 1009 + // A modify event during this window should be skipped 1010 + mockParsePlaces.mockClear(); 1011 + mockVault.trigger("modify", mockFile); 1012 + await vi.advanceTimersByTimeAsync(300); 1013 + 1014 + // The refresh should have been suppressed by write guard 1015 + expect(mockParsePlaces).not.toHaveBeenCalled(); 1016 + }); 1017 + 1018 + it("allows refresh after 500ms write guard window expires", async () => { 1019 + const { view } = await createAndOpenView(); 1020 + 1021 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1022 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1023 + mockParsePlaces.mockReturnValue([ 1024 + makePlace("Place A", { startLine: 0, endLine: 0 }), 1025 + ]); 1026 + 1027 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 1028 + places[0].lat = 41.4036; 1029 + places[0].lng = 2.1744; 1030 + return places; 1031 + }); 1032 + 1033 + mockVault.process.mockImplementation( 1034 + async (_file: unknown, fn: (data: string) => string) => { 1035 + mockParsePlaces.mockReturnValueOnce([ 1036 + makePlace("Place A", { startLine: 0, endLine: 0 }), 1037 + ]); 1038 + return fn("* Place A"); 1039 + } 1040 + ); 1041 + 1042 + // Trigger initial refresh with geocoding + write-back 1043 + mockVault.trigger("modify", mockFile); 1044 + await vi.advanceTimersByTimeAsync(300); 1045 + await flushMicrotasks(); 1046 + 1047 + // Wait for write guard to expire (500ms) 1048 + await vi.advanceTimersByTimeAsync(500); 1049 + 1050 + // Now a modify event should be allowed through 1051 + mockParsePlaces.mockClear(); 1052 + mockParsePlaces.mockReturnValue([ 1053 + makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0, endLine: 1 }), 1054 + ]); 1055 + mockVault.trigger("modify", mockFile); 1056 + await vi.advanceTimersByTimeAsync(300); 1057 + 1058 + expect(mockParsePlaces).toHaveBeenCalled(); 1059 + }); 1060 + }); 1061 + 1062 + // ─── Contract 8: Geocoding concurrency — AbortController mutex ──────── 1063 + 1064 + describe("Contract 8: Only one geocoding operation in-flight at a time", () => { 1065 + it("passes an AbortSignal to geocodePlaces", async () => { 1066 + const { view } = await createAndOpenView(); 1067 + 1068 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1069 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1070 + mockParsePlaces.mockReturnValue([ 1071 + makePlace("Place A", { startLine: 0, endLine: 0 }), 1072 + ]); 1073 + 1074 + mockVault.trigger("modify", mockFile); 1075 + await vi.advanceTimersByTimeAsync(300); 1076 + 1077 + // Geocoder should have been called with an AbortSignal as third argument 1078 + expect(mockGeocodePlaces).toHaveBeenCalled(); 1079 + const args = mockGeocodePlaces.mock.calls[0]; 1080 + // args: [places, callbacks, signal] 1081 + expect(args[2]).toBeInstanceOf(AbortSignal); 1082 + }); 1083 + 1084 + it("aborts previous geocoding when a new refresh starts", async () => { 1085 + const { view } = await createAndOpenView(); 1086 + 1087 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1088 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1089 + mockParsePlaces.mockReturnValue([ 1090 + makePlace("Place A", { startLine: 0, endLine: 0 }), 1091 + ]); 1092 + 1093 + // First geocode: hang forever (never resolve) 1094 + let firstSignal: AbortSignal | undefined; 1095 + mockGeocodePlaces.mockImplementationOnce( 1096 + async (places: Place[], _callbacks: unknown, signal?: AbortSignal) => { 1097 + firstSignal = signal; 1098 + return new Promise(() => {}); // Never resolves 1099 + } 1100 + ); 1101 + 1102 + // Trigger first refresh 1103 + mockVault.trigger("modify", mockFile); 1104 + await vi.advanceTimersByTimeAsync(300); 1105 + 1106 + expect(firstSignal).toBeDefined(); 1107 + expect(firstSignal!.aborted).toBe(false); 1108 + 1109 + // Second geocode 1110 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => places); 1111 + mockParsePlaces.mockReturnValue([ 1112 + makePlace("Place B", { startLine: 0, endLine: 0 }), 1113 + ]); 1114 + 1115 + // Trigger second refresh 1116 + mockVault.trigger("modify", mockFile); 1117 + await vi.advanceTimersByTimeAsync(300); 1118 + 1119 + // First signal should now be aborted 1120 + expect(firstSignal!.aborted).toBe(true); 1121 + }); 1122 + }); 1123 + 1124 + // ─── Contract 9: Cursor sync (editor → map) ────────────────────────── 1125 + 1126 + describe("Contract 9: Cursor sync — editor to map", () => { 1127 + it("registers a polling interval for cursor position", async () => { 1128 + const { view } = await createAndOpenView(); 1129 + 1130 + // Should have registered at least one interval (the 200ms cursor poll) 1131 + expect(registeredIntervals.length).toBeGreaterThan(0); 1132 + }); 1133 + 1134 + it("selects the place whose line range contains the cursor", async () => { 1135 + const { view } = await createAndOpenView(); 1136 + 1137 + // Set up places with line ranges 1138 + const places = [ 1139 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 2 }), 1140 + makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 4, endLine: 6 }), 1141 + ]; 1142 + 1143 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1144 + mockVault.cachedRead.mockResolvedValue("..."); 1145 + mockParsePlaces.mockReturnValue(places); 1146 + 1147 + // Trigger refresh 1148 + mockVault.trigger("modify", mockFile); 1149 + await vi.advanceTimersByTimeAsync(300); 1150 + 1151 + // Cursor is on line 5 — within Place B range [4, 6] 1152 + mockEditor.getCursor.mockReturnValue({ line: 5, ch: 0 }); 1153 + 1154 + // Advance to trigger cursor poll (200ms) 1155 + await vi.advanceTimersByTimeAsync(200); 1156 + 1157 + // mapController.selectPlace should be called with Place B 1158 + expect(mockMapController.selectPlace).toHaveBeenCalledWith( 1159 + expect.objectContaining({ name: "Place B" }) 1160 + ); 1161 + }); 1162 + 1163 + it("deselects when cursor is in a dead zone (outside all place ranges)", async () => { 1164 + const { view } = await createAndOpenView(); 1165 + 1166 + const places = [ 1167 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 1168 + makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 4, endLine: 5 }), 1169 + ]; 1170 + 1171 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1172 + mockVault.cachedRead.mockResolvedValue("..."); 1173 + mockParsePlaces.mockReturnValue(places); 1174 + 1175 + mockVault.trigger("modify", mockFile); 1176 + await vi.advanceTimersByTimeAsync(300); 1177 + 1178 + // First, place cursor inside Place A so something is selected 1179 + mockEditor.getCursor.mockReturnValue({ line: 0, ch: 0 }); 1180 + await vi.advanceTimersByTimeAsync(200); 1181 + 1182 + mockMapController.selectPlace.mockClear(); 1183 + 1184 + // Now move cursor to line 3 — between Place A and Place B (dead zone) 1185 + mockEditor.getCursor.mockReturnValue({ line: 3, ch: 0 }); 1186 + 1187 + await vi.advanceTimersByTimeAsync(200); 1188 + 1189 + expect(mockMapController.selectPlace).toHaveBeenCalledWith(null); 1190 + }); 1191 + }); 1192 + 1193 + // ─── Contract 10: Cursor sync (map → editor) ───────────────────────── 1194 + 1195 + describe("Contract 10: Cursor sync — map to editor (marker click)", () => { 1196 + it("scrolls editor to startLine of first place when marker is clicked", async () => { 1197 + const { view } = await createAndOpenView(); 1198 + 1199 + // Get the onPlaceSelect callback that was passed to createMap 1200 + expect(mockCreateMap).toHaveBeenCalled(); 1201 + const callbacks: MapCallbacks = mockCreateMap.mock.calls[0][2]; 1202 + expect(callbacks.onPlaceSelect).toBeDefined(); 1203 + 1204 + // Set up active markdown view 1205 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1206 + 1207 + // Simulate marker click 1208 + const clickedPlaces = [ 1209 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 3, endLine: 5 }), 1210 + makePlace("Place B", { lat: 41.4, lng: 2.1, startLine: 7, endLine: 9 }), 1211 + ]; 1212 + callbacks.onPlaceSelect!(clickedPlaces); 1213 + 1214 + // Editor should be scrolled to startLine of the FIRST place 1215 + expect(mockEditor.setCursor).toHaveBeenCalledWith( 1216 + expect.objectContaining({ line: 3, ch: 0 }) 1217 + ); 1218 + }); 1219 + 1220 + it("calls editor.scrollIntoView after setCursor", async () => { 1221 + const { view } = await createAndOpenView(); 1222 + 1223 + const callbacks: MapCallbacks = mockCreateMap.mock.calls[0][2]; 1224 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1225 + 1226 + callbacks.onPlaceSelect!([ 1227 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 10, endLine: 12 }), 1228 + ]); 1229 + 1230 + expect(mockEditor.scrollIntoView).toHaveBeenCalled(); 1231 + }); 1232 + }); 1233 + 1234 + // ─── Contract 11: Active file null → clear markers ──────────────────── 1235 + 1236 + describe("Contract 11: Active file null — clear all markers", () => { 1237 + it("clears markers when active file is null", async () => { 1238 + const { view } = await createAndOpenView(); 1239 + 1240 + // Set up with some places first 1241 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1242 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 1243 + mockParsePlaces.mockReturnValue([ 1244 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 1245 + ]); 1246 + mockVault.trigger("modify", mockFile); 1247 + await vi.advanceTimersByTimeAsync(300); 1248 + 1249 + mockMapController.updateMarkers.mockClear(); 1250 + 1251 + // Now no active markdown view (file switched to non-markdown or closed) 1252 + mockWorkspace.getActiveViewOfType.mockReturnValue(null); 1253 + 1254 + // Trigger active-leaf-change with null 1255 + mockWorkspace.trigger("active-leaf-change", null); 1256 + await vi.advanceTimersByTimeAsync(300); 1257 + 1258 + // Map should be cleared (updateMarkers with empty array) 1259 + expect(mockMapController.updateMarkers).toHaveBeenCalledWith([]); 1260 + }); 1261 + }); 1262 + 1263 + // ─── Contract 12: Active file with no places → empty map ───────────── 1264 + 1265 + describe("Contract 12: File with no places — empty map", () => { 1266 + it("calls updateMarkers with empty array for a file with no bullets", async () => { 1267 + const { view } = await createAndOpenView(); 1268 + 1269 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1270 + mockVault.cachedRead.mockResolvedValue("Just some plain text, no bullets"); 1271 + mockParsePlaces.mockReturnValue([]); 1272 + 1273 + mockVault.trigger("modify", mockFile); 1274 + await vi.advanceTimersByTimeAsync(300); 1275 + 1276 + expect(mockMapController.updateMarkers).toHaveBeenCalledWith([]); 1277 + }); 1278 + }); 1279 + 1280 + // ─── Contract 13: Only respond to MarkdownView ─────────────────────── 1281 + 1282 + describe("Contract 13: Only respond to MarkdownView active-leaf-change", () => { 1283 + it("does not refresh when active leaf is not a MarkdownView", async () => { 1284 + const { view } = await createAndOpenView(); 1285 + 1286 + mockWorkspace.getActiveViewOfType.mockReturnValue(null); 1287 + 1288 + // Trigger active-leaf-change with a non-markdown leaf 1289 + const nonMdLeaf = { 1290 + view: { getViewType: () => "map-viewer" }, 1291 + }; 1292 + mockWorkspace.trigger("active-leaf-change", nonMdLeaf); 1293 + await vi.advanceTimersByTimeAsync(300); 1294 + 1295 + // Should not have tried to read file content via cachedRead 1296 + // (beyond any initial setup reads) 1297 + expect(mockVault.cachedRead).not.toHaveBeenCalled(); 1298 + }); 1299 + 1300 + it("refreshes when active leaf is a MarkdownView", async () => { 1301 + const { view } = await createAndOpenView(); 1302 + 1303 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1304 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 1305 + mockParsePlaces.mockReturnValue([ 1306 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 1307 + ]); 1308 + 1309 + // Trigger active-leaf-change with a markdown leaf 1310 + mockWorkspace.trigger("active-leaf-change", { 1311 + view: mockMarkdownView, 1312 + }); 1313 + await vi.advanceTimersByTimeAsync(300); 1314 + 1315 + expect(mockVault.cachedRead).toHaveBeenCalled(); 1316 + expect(mockParsePlaces).toHaveBeenCalled(); 1317 + }); 1318 + }); 1319 + 1320 + // ─── Contract 14: Error reporting ───────────────────────────────────── 1321 + 1322 + describe("Contract 14: Error reporting for geocoding failures", () => { 1323 + it("logs geocoding failures via console.warn with [MapViewer] prefix", async () => { 1324 + const { view } = await createAndOpenView(); 1325 + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 1326 + 1327 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1328 + mockVault.cachedRead.mockResolvedValue("* Nonexistent Place"); 1329 + mockParsePlaces.mockReturnValue([ 1330 + makePlace("Nonexistent Place", { startLine: 0, endLine: 0 }), 1331 + ]); 1332 + 1333 + mockGeocodePlaces.mockRejectedValue(new Error("Network error")); 1334 + 1335 + mockVault.trigger("modify", mockFile); 1336 + await vi.advanceTimersByTimeAsync(300); 1337 + await flushMicrotasks(); 1338 + 1339 + expect(warnSpy).toHaveBeenCalledWith( 1340 + expect.stringContaining("[MapViewer]"), 1341 + expect.anything() 1342 + ); 1343 + 1344 + warnSpy.mockRestore(); 1345 + }); 1346 + 1347 + it("shows an Obsidian Notice when geocoding batch produces zero results", async () => { 1348 + const { view } = await createAndOpenView(); 1349 + 1350 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1351 + mockVault.cachedRead.mockResolvedValue("* Place A\n* Place B"); 1352 + mockParsePlaces.mockReturnValue([ 1353 + makePlace("Place A", { startLine: 0, endLine: 0 }), 1354 + makePlace("Place B", { startLine: 1, endLine: 1 }), 1355 + ]); 1356 + 1357 + // Geocoder returns places with no coordinates set (all failed) 1358 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 1359 + // Intentionally leave lat/lng undefined — zero successful results 1360 + return places; 1361 + }); 1362 + 1363 + mockVault.trigger("modify", mockFile); 1364 + await vi.advanceTimersByTimeAsync(300); 1365 + await flushMicrotasks(); 1366 + 1367 + // Should have shown a Notice 1368 + expect(mockNoticeInstances.length).toBeGreaterThan(0); 1369 + }); 1370 + }); 1371 + 1372 + // ─── Edge Cases ─────────────────────────────────────────────────────── 1373 + 1374 + describe("Edge cases", () => { 1375 + it("file change while geocoding is in progress — aborts and restarts", async () => { 1376 + const { view } = await createAndOpenView(); 1377 + 1378 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1379 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1380 + mockParsePlaces.mockReturnValue([ 1381 + makePlace("Place A", { startLine: 0, endLine: 0 }), 1382 + ]); 1383 + 1384 + let firstAbortSignal: AbortSignal | undefined; 1385 + mockGeocodePlaces.mockImplementationOnce( 1386 + async (_places: Place[], _cb: unknown, signal?: AbortSignal) => { 1387 + firstAbortSignal = signal; 1388 + return new Promise(() => {}); // Hang forever 1389 + } 1390 + ); 1391 + 1392 + // First modify 1393 + mockVault.trigger("modify", mockFile); 1394 + await vi.advanceTimersByTimeAsync(300); 1395 + 1396 + // While first geocode is pending, simulate file change 1397 + mockParsePlaces.mockReturnValue([ 1398 + makePlace("Place B", { startLine: 0, endLine: 0 }), 1399 + ]); 1400 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => places); 1401 + 1402 + mockVault.trigger("modify", mockFile); 1403 + await vi.advanceTimersByTimeAsync(300); 1404 + 1405 + // First signal should be aborted 1406 + expect(firstAbortSignal!.aborted).toBe(true); 1407 + // Second geocode should have started 1408 + expect(mockGeocodePlaces).toHaveBeenCalledTimes(2); 1409 + }); 1410 + 1411 + it("rapid file switching — debounce prevents excessive re-parsing", async () => { 1412 + const { view } = await createAndOpenView(); 1413 + 1414 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1415 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1416 + mockParsePlaces.mockReturnValue([]); 1417 + 1418 + // Rapidly switch files (trigger active-leaf-change multiple times) 1419 + for (let i = 0; i < 5; i++) { 1420 + mockWorkspace.trigger("active-leaf-change", { view: mockMarkdownView }); 1421 + await vi.advanceTimersByTimeAsync(50); 1422 + } 1423 + 1424 + // Only wait for debounce from the last one 1425 + await vi.advanceTimersByTimeAsync(300); 1426 + 1427 + // Parser should only be called once (debounce coalesced) 1428 + expect(mockParsePlaces).toHaveBeenCalledTimes(1); 1429 + }); 1430 + 1431 + it("onClose destroys the map controller", async () => { 1432 + const { view } = await createAndOpenView(); 1433 + 1434 + await (view as any).onClose(); 1435 + 1436 + expect(mockMapController.destroy).toHaveBeenCalled(); 1437 + }); 1438 + }); 1439 + 1440 + // ─── Adversary Finding Tests ────────────────────────────────────────── 1441 + 1442 + describe("Adversary finding #1: onFileModify checks file identity", () => { 1443 + it("ignores modify events for files other than the active file", async () => { 1444 + const { view } = await createAndOpenView(); 1445 + 1446 + // Drain the initial refresh triggered by onOpen 1447 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1448 + mockVault.cachedRead.mockResolvedValue(""); 1449 + mockParsePlaces.mockReturnValue([]); 1450 + await vi.advanceTimersByTimeAsync(300); 1451 + await flushMicrotasks(); 1452 + 1453 + // Clear mocks so we can check that modify with a different file does NOT trigger reads 1454 + mockVault.cachedRead.mockClear(); 1455 + mockParsePlaces.mockClear(); 1456 + 1457 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1458 + mockParsePlaces.mockReturnValue([ 1459 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }), 1460 + ]); 1461 + 1462 + // Trigger modify with a DIFFERENT file (not the active one) 1463 + const otherFile = createMockFile("other-note.md", "other-note.md"); 1464 + mockVault.trigger("modify", otherFile); 1465 + await vi.advanceTimersByTimeAsync(300); 1466 + 1467 + // Should NOT have called cachedRead since the file doesn't match 1468 + expect(mockVault.cachedRead).not.toHaveBeenCalled(); 1469 + }); 1470 + 1471 + it("responds to modify events for the active file", async () => { 1472 + const { view } = await createAndOpenView(); 1473 + 1474 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1475 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1476 + mockParsePlaces.mockReturnValue([ 1477 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }), 1478 + ]); 1479 + 1480 + // Trigger modify with the SAME file as the active view 1481 + mockVault.trigger("modify", mockFile); 1482 + await vi.advanceTimersByTimeAsync(300); 1483 + 1484 + expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile); 1485 + }); 1486 + }); 1487 + 1488 + describe("Adversary finding #2: Notice for mixed batches checks only attempted places", () => { 1489 + it("does not show Notice when pre-geocoded places exist but new ones also succeed", async () => { 1490 + const { view } = await createAndOpenView(); 1491 + 1492 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1493 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B"); 1494 + 1495 + // Place A already has coordinates, Place B does not 1496 + mockParsePlaces.mockReturnValue([ 1497 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 1498 + makePlace("Place B", { startLine: 2, endLine: 2 }), 1499 + ]); 1500 + 1501 + // Geocoder gives coordinates to Place B 1502 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 1503 + for (const p of places) { 1504 + if (p.name === "Place B") { 1505 + p.lat = 48.8606; 1506 + p.lng = 2.3376; 1507 + } 1508 + } 1509 + return places; 1510 + }); 1511 + 1512 + mockVault.process.mockImplementation( 1513 + async (_file: unknown, fn: (data: string) => string) => { 1514 + mockParsePlaces.mockReturnValueOnce([ 1515 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 1516 + makePlace("Place B", { startLine: 2, endLine: 2 }), 1517 + ]); 1518 + return fn("* Place A\n\t* geo: 41.400000,2.100000\n* Place B"); 1519 + } 1520 + ); 1521 + 1522 + mockVault.trigger("modify", mockFile); 1523 + await vi.advanceTimersByTimeAsync(300); 1524 + await flushMicrotasks(); 1525 + 1526 + // No Notice should be shown since Place B was successfully geocoded 1527 + expect(mockNoticeInstances).toHaveLength(0); 1528 + }); 1529 + 1530 + it("shows Notice when attempted places all fail, even if pre-geocoded ones exist", async () => { 1531 + const { view } = await createAndOpenView(); 1532 + 1533 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1534 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B"); 1535 + 1536 + // Place A already has coordinates, Place B does not 1537 + mockParsePlaces.mockReturnValue([ 1538 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 1539 + makePlace("Place B", { startLine: 2, endLine: 2 }), 1540 + ]); 1541 + 1542 + // Geocoder returns places but does NOT set coordinates on Place B 1543 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 1544 + // Place B remains ungeocoded 1545 + return places; 1546 + }); 1547 + 1548 + mockVault.trigger("modify", mockFile); 1549 + await vi.advanceTimersByTimeAsync(300); 1550 + await flushMicrotasks(); 1551 + 1552 + // Notice should fire because the ATTEMPTED place (Place B) got zero results 1553 + expect(mockNoticeInstances.length).toBeGreaterThan(0); 1554 + expect(mockNoticeInstances[0].message).toContain("No places could be geocoded"); 1555 + }); 1556 + }); 1557 + 1558 + describe("Adversary finding #5: only ungeocoded places trigger geocoding", () => { 1559 + it("does not call geocodePlaces when all places already have coordinates", async () => { 1560 + const { view } = await createAndOpenView(); 1561 + 1562 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1563 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); 1564 + mockParsePlaces.mockReturnValue([ 1565 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), 1566 + ]); 1567 + 1568 + mockVault.trigger("modify", mockFile); 1569 + await vi.advanceTimersByTimeAsync(300); 1570 + await flushMicrotasks(); 1571 + 1572 + // Geocoder should NOT have been called 1573 + expect(mockGeocodePlaces).not.toHaveBeenCalled(); 1574 + }); 1575 + 1576 + it("only passes ungeocoded places to the geocoder in a mixed batch", async () => { 1577 + const { view } = await createAndOpenView(); 1578 + 1579 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1580 + mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B"); 1581 + 1582 + const placeA = makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }); 1583 + const placeB = makePlace("Place B", { startLine: 2, endLine: 2 }); 1584 + mockParsePlaces.mockReturnValue([placeA, placeB]); 1585 + 1586 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => places); 1587 + 1588 + mockVault.trigger("modify", mockFile); 1589 + await vi.advanceTimersByTimeAsync(300); 1590 + await flushMicrotasks(); 1591 + 1592 + // Geocoder should be called — but the allPlaces array includes both 1593 + // (geocodePlaces receives allPlaces because it may need context, but 1594 + // the implementation filters placesToGeocode for Notice logic) 1595 + expect(mockGeocodePlaces).toHaveBeenCalledTimes(1); 1596 + }); 1597 + }); 1598 + 1599 + describe("Adversary finding #7: duplicate place name write-back safety", () => { 1600 + it("writes geo to ALL occurrences of a duplicate name", async () => { 1601 + const { view } = await createAndOpenView(); 1602 + 1603 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1604 + const content = "* Cafe\n\t* Morning visit\n* Cafe\n\t* Evening visit"; 1605 + mockVault.cachedRead.mockResolvedValue(content); 1606 + 1607 + // Both parsed as separate places with the same name 1608 + mockParsePlaces.mockReturnValue([ 1609 + makePlace("Cafe", { startLine: 0, endLine: 1 }), 1610 + makePlace("Cafe", { startLine: 2, endLine: 3 }), 1611 + ]); 1612 + 1613 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 1614 + // Geocoder sets coords on both (since they share a name, Nominatim would return the same result) 1615 + for (const p of places) { 1616 + if (p.name === "Cafe") { 1617 + p.lat = 40.0; 1618 + p.lng = -74.0; 1619 + } 1620 + } 1621 + return places; 1622 + }); 1623 + 1624 + let processedContent = ""; 1625 + mockVault.process.mockImplementation( 1626 + async (_file: unknown, fn: (data: string) => string) => { 1627 + mockParsePlaces.mockReturnValueOnce([ 1628 + makePlace("Cafe", { startLine: 0, endLine: 1 }), 1629 + makePlace("Cafe", { startLine: 2, endLine: 3 }), 1630 + ]); 1631 + processedContent = fn(content); 1632 + return processedContent; 1633 + } 1634 + ); 1635 + 1636 + mockVault.trigger("modify", mockFile); 1637 + await vi.advanceTimersByTimeAsync(300); 1638 + await flushMicrotasks(); 1639 + 1640 + // Count geo lines — BOTH occurrences should get geo written back 1641 + // to prevent infinite re-geocoding of the second occurrence 1642 + const geoLines = processedContent.split("\n").filter( 1643 + (line: string) => line.includes("geo:") 1644 + ); 1645 + expect(geoLines).toHaveLength(2); 1646 + 1647 + // Both should have the same coordinates 1648 + for (const line of geoLines) { 1649 + expect(line).toContain("40.000000,-74.000000"); 1650 + } 1651 + }); 1652 + }); 1653 + 1654 + describe("Adversary finding #9: cachedRead rejection handled gracefully", () => { 1655 + it("does not throw when cachedRead rejects", async () => { 1656 + const { view } = await createAndOpenView(); 1657 + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 1658 + 1659 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1660 + mockVault.cachedRead.mockRejectedValue(new Error("File deleted")); 1661 + 1662 + // This should not throw an unhandled rejection 1663 + mockVault.trigger("modify", mockFile); 1664 + await vi.advanceTimersByTimeAsync(300); 1665 + await flushMicrotasks(); 1666 + 1667 + // The error should be caught and logged 1668 + expect(warnSpy).toHaveBeenCalledWith( 1669 + expect.stringContaining("[MapViewer]"), 1670 + expect.anything() 1671 + ); 1672 + 1673 + warnSpy.mockRestore(); 1674 + }); 1675 + }); 1676 + 1677 + describe("Adversary finding #17: VIEW_TYPE is exported", () => { 1678 + it("exports VIEW_TYPE constant", async () => { 1679 + const mod = await importMapView(); 1680 + expect(mod.VIEW_TYPE).toBe("map-viewer"); 1681 + }); 1682 + }); 1683 + 1684 + // ─── onClose lifecycle tests ────────────────────────────────────────── 1685 + 1686 + describe("onClose lifecycle", () => { 1687 + it("cancels pending debounce timer on close", async () => { 1688 + const { view } = await createAndOpenView(); 1689 + 1690 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1691 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1692 + mockParsePlaces.mockReturnValue([ 1693 + makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }), 1694 + ]); 1695 + 1696 + // Trigger a refresh that hasn't fired yet (still in debounce window) 1697 + mockVault.trigger("modify", mockFile); 1698 + 1699 + // Close before debounce fires 1700 + await (view as any).onClose(); 1701 + 1702 + // Now advance past debounce — the refresh should NOT happen 1703 + mockParsePlaces.mockClear(); 1704 + await vi.advanceTimersByTimeAsync(300); 1705 + await flushMicrotasks(); 1706 + 1707 + expect(mockParsePlaces).not.toHaveBeenCalled(); 1708 + }); 1709 + 1710 + it("aborts in-flight geocoding on close", async () => { 1711 + const { view } = await createAndOpenView(); 1712 + 1713 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1714 + mockVault.cachedRead.mockResolvedValue("* Place A"); 1715 + mockParsePlaces.mockReturnValue([ 1716 + makePlace("Place A", { startLine: 0, endLine: 0 }), 1717 + ]); 1718 + 1719 + let capturedSignal: AbortSignal | undefined; 1720 + mockGeocodePlaces.mockImplementationOnce( 1721 + async (_places: Place[], _cb: unknown, signal?: AbortSignal) => { 1722 + capturedSignal = signal; 1723 + return new Promise(() => {}); // Hang forever 1724 + } 1725 + ); 1726 + 1727 + // Trigger refresh — starts geocoding 1728 + mockVault.trigger("modify", mockFile); 1729 + await vi.advanceTimersByTimeAsync(300); 1730 + 1731 + expect(capturedSignal).toBeDefined(); 1732 + expect(capturedSignal!.aborted).toBe(false); 1733 + 1734 + // Close the view — should abort the geocoding signal 1735 + await (view as any).onClose(); 1736 + 1737 + expect(capturedSignal!.aborted).toBe(true); 1738 + }); 1739 + 1740 + it("post-close doRefresh is a no-op due to destroyed flag", async () => { 1741 + const { view } = await createAndOpenView(); 1742 + 1743 + // Drain the initial refresh 1744 + await vi.advanceTimersByTimeAsync(300); 1745 + await flushMicrotasks(); 1746 + 1747 + // Close the view 1748 + await (view as any).onClose(); 1749 + 1750 + // Set up mocks for a refresh that shouldn't happen 1751 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1752 + mockVault.cachedRead.mockClear(); 1753 + mockParsePlaces.mockClear(); 1754 + 1755 + // Directly invoke doRefresh on the closed view 1756 + await (view as any).doRefresh(); 1757 + 1758 + // cachedRead should NOT have been called — destroyed flag prevents it 1759 + expect(mockVault.cachedRead).not.toHaveBeenCalled(); 1760 + }); 1761 + }); 1762 + 1763 + // ─── Write-back safety: content divergence ──────────────────────────── 1764 + 1765 + describe("Write-back safety: content changes between cachedRead and vault.process", () => { 1766 + it("re-parses inside vault.process and only writes to places found in current content", async () => { 1767 + const { view } = await createAndOpenView(); 1768 + 1769 + mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); 1770 + 1771 + // cachedRead returns original content with one place 1772 + const originalContent = "* Cafe\n\t* Morning visit"; 1773 + mockVault.cachedRead.mockResolvedValue(originalContent); 1774 + 1775 + mockParsePlaces.mockReturnValue([ 1776 + makePlace("Cafe", { startLine: 0, endLine: 1 }), 1777 + ]); 1778 + 1779 + mockGeocodePlaces.mockImplementation(async (places: Place[]) => { 1780 + for (const p of places) { 1781 + p.lat = 40.0; 1782 + p.lng = -74.0; 1783 + } 1784 + return places; 1785 + }); 1786 + 1787 + // By the time vault.process runs, content has changed — new place added 1788 + const changedContent = "* Restaurant\n\t* Dinner spot\n* Cafe\n\t* Morning visit"; 1789 + let processedContent = ""; 1790 + mockVault.process.mockImplementation( 1791 + async (_file: unknown, fn: (data: string) => string) => { 1792 + // Re-parse returns new content's places — Cafe is now at different lines 1793 + mockParsePlaces.mockReturnValueOnce([ 1794 + makePlace("Restaurant", { startLine: 0, endLine: 1 }), 1795 + makePlace("Cafe", { startLine: 2, endLine: 3 }), 1796 + ]); 1797 + processedContent = fn(changedContent); 1798 + return processedContent; 1799 + } 1800 + ); 1801 + 1802 + mockVault.trigger("modify", mockFile); 1803 + await vi.advanceTimersByTimeAsync(300); 1804 + await flushMicrotasks(); 1805 + 1806 + // Should have written geo for Cafe (the geocoded place) at its NEW line position 1807 + const lines = processedContent.split("\n"); 1808 + const geoLines = lines.filter((line: string) => line.includes("geo:")); 1809 + expect(geoLines).toHaveLength(1); 1810 + expect(geoLines[0]).toContain("40.000000,-74.000000"); 1811 + 1812 + // Restaurant should NOT have a geo line (it wasn't geocoded) 1813 + // Check that no geo line appears in Restaurant's line range (lines 0-1) 1814 + expect(lines[0]).not.toContain("geo:"); 1815 + expect(lines[1]).not.toContain("geo:"); 1816 + // Geo line should appear after Cafe's block, not after Restaurant 1817 + const geoLineIndex = lines.findIndex((line: string) => line.includes("geo:")); 1818 + expect(geoLineIndex).toBeGreaterThan(2); // After Cafe's startLine 1819 + }); 1820 + });
+5 -5
tsconfig.json
··· 4 4 "inlineSourceMap": true, 5 5 "inlineSources": true, 6 6 "module": "ESNext", 7 - "target": "ES6", 7 + "target": "ES2018", 8 8 "allowJs": true, 9 - "noImplicitAny": true, 9 + "strict": true, 10 + "skipLibCheck": true, 10 11 "moduleResolution": "node", 11 12 "importHelpers": true, 12 13 "isolatedModules": true, 13 - "strictNullChecks": true, 14 - "lib": ["DOM", "ES5", "ES6", "ES7"] 14 + "lib": ["DOM", "ES5", "ES6", "ES7", "ES2018"] 15 15 }, 16 - "include": ["**/*.ts"], 16 + "include": ["src/**/*.ts"], 17 17 "exclude": ["node_modules"] 18 18 }
+6
vitest.config.ts
··· 1 1 import { defineConfig } from "vitest/config"; 2 + import path from "path"; 2 3 3 4 export default defineConfig({ 5 + resolve: { 6 + alias: { 7 + obsidian: path.resolve(__dirname, "tests/__mocks__/obsidian.ts"), 8 + }, 9 + }, 4 10 test: { 5 11 globals: true, 6 12 environment: "node",