diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..7603320 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,52 @@ +name: Deploy demo to GitHub Pages + +# Publishes the already-committed static demo site under docs/ to GitHub Pages. +# This workflow deliberately does NOT run the precompute: the 95 MB spatial index +# is gitignored and the committed docs/demo/data/*.json snapshot is published as-is. +# The repo's other workflows run on self-hosted runners, but the Pages deploy +# actions require GitHub-hosted runners, so this uses ubuntu-latest. + +on: + push: + branches: [main] + paths: + - "docs/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +# Least-privilege permissions required by actions/deploy-pages. +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress publish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Package docs/ artifact + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Upload docs/ as Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 295388f..2d44688 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Supports all five NYC boroughs. Data is fetched live from NYC Open Data. ## Table of Contents +- [Live Demo](#live-demo) - [Installation](#installation) - [Configuration](#configuration) - [Requirements](#requirements) @@ -24,6 +25,57 @@ Supports all five NYC boroughs. Data is fetched live from NYC Open Data. --- +## Live Demo + +Want to see what the integration produces before installing anything? A self-contained +demo page lives under [`docs/demo/`](docs/demo/). Open it in a browser, click a block on the +Leaflet map, and you'll see exactly what ASP Parking resolves for that location: the parking +rule for the block, the next time you'd need to move your car, the exact Home Assistant sensor +entities and states it would create, and an animated calendar of the weekly cleaning windows. +It's a plain HTML/CSS/JS page — no build step, no install, no server-side code. + +**Where the data comes from.** The demo does **not** call any live API from the browser. +It reads a **dated snapshot** committed to the repo at +[`docs/demo/data/demo.json`](docs/demo/data/demo.json) (snapshot date: **2026-07-28**), +plus the matched segment geometries in `docs/demo/data/demo-segments.geojson`. The snapshot +stores *weekly recurring patterns* rather than absolute datetimes, and the page recomputes the +next move time in your browser at NYC time — so the "next move" stays correct even though the +underlying data is frozen. Because it's a static snapshot, the demo works offline and never +needs a token. + +**Regenerating the snapshot.** The dataset is produced offline (not in CI) by +`scripts/build_demo_dataset.py`. From a checkout with the project installed: + +```bash +.venv/bin/python scripts/build_demo_dataset.py --out-dir docs/demo/data +``` + +This requires two things the demo page itself does not: the **spatial index** must be present +locally, and the script needs **network access to the NYC Open Data (SODA) API**. The index is +gitignored (~95 MB), so build it once with `python scripts/build_index.py` **or** download the +released `index-v1` asset from the [Releases page](https://github.com/Pascal-ZeGerman/GPS2ASP-Resolver/releases). +Setting a `NYC_OPEN_DATA_APP_TOKEN` environment variable is optional but helps avoid SODA rate +limiting. The generated `demo.json`/`demo-segments.geojson` are the only files committed — the +index never is. + +**Running it locally.** Serve the folder over HTTP (opening `index.html` via `file://` won't +let the page `fetch()` its JSON): + +```bash +python -m http.server --directory docs/demo 8000 +``` + +Then visit . + +**Hosting it.** The repo ships a [`.github/workflows/pages.yml`](.github/workflows/pages.yml) +workflow that publishes the committed `docs/` tree to **GitHub Pages** on every push to `docs/` +(and on demand via *workflow_dispatch*). The workflow publishes the snapshot as-is and never +runs the precompute. A maintainer only needs to enable Pages once, under +**Settings → Pages → Source: GitHub Actions**; after that the demo is reachable at the +repository's GitHub Pages URL. + +--- + ## Installation ### Via HACS (recommended) diff --git a/custom_components/asp_parking/caldav_sync.py b/custom_components/asp_parking/caldav_sync.py index 2231e1d..bb20a00 100644 --- a/custom_components/asp_parking/caldav_sync.py +++ b/custom_components/asp_parking/caldav_sync.py @@ -43,6 +43,7 @@ from datetime import datetime, timezone from typing import Any from urllib.parse import quote as _url_quote +from urllib.parse import urlparse import caldav # top-level package — present on all caldav versions from caldav.lib import error as caldav_error @@ -547,17 +548,53 @@ def build_vevent_ical( async def _get_calendar(client: Any, calendar_url: str) -> Any: """Resolve a calendar by URL using the authenticated principal. - Uses ``principal.calendar(cal_url=...)`` for single-calendar lookup - (no extra collection roundtrip — the principal already knows the - calendar-home-set URL after ``get_principal``). + Tries ``principal.calendar(cal_url=...)`` first for single-calendar + lookup (no extra collection roundtrip — the principal already knows + the calendar-home-set URL after ``get_principal``). This is a + **synchronous** constructor in both caldav 2.x (via the + ``_CompatPrincipal`` shim) and caldav 3.x (``AsyncPrincipal.calendar``): + it builds a Calendar object from the URL without any network I/O, so + it must NOT be awaited. + + iCloud shards each account's calendar data onto a per-account host + (e.g. ``p117-caldav.icloud.com``) that differs from the generic login + entry point (``caldav.icloud.com``) ``client`` is constructed with. + caldav 2.x's ``Principal.calendar(cal_url=...)`` resolves the URL via + a purely local ``self.client.url.join(cal_url)`` (caldav/lib/url.py) + which raises ``ValueError`` whenever the two hosts differ — always, + for iCloud, since the stored ``calendar_url`` (captured during + ``list_calendars()``, which follows the redirect over the network) is + on the sharded host while ``client.url`` stays pinned to the login + host. On that specific failure, fall back to ``principal.calendars()`` + — a real network request that also follows the redirect, so the + returned Calendar objects carry the correct host — and match by URL + path, the one thing stable across iCloud's host sharding. + + Only the specific ``URL.join`` cross-host failure triggers the + fallback — any other ``ValueError`` (e.g. a ``None`` client, or a + calendar_url containing spaces) is a genuine, unrelated bug and must + propagate unchanged rather than being misreported as "no calendar + found". - Note: ``principal.calendar()`` is a **synchronous** constructor in both - caldav 2.x (via the ``_CompatPrincipal`` shim) and caldav 3.x - (``AsyncPrincipal.calendar``). It builds a Calendar object from the URL - without any network I/O, so it must NOT be awaited. + Raises: + CalDAVWriteError: if the fallback finds no calendar whose path + matches ``calendar_url``. """ principal = await client.get_principal() - return principal.calendar(cal_url=calendar_url) + try: + return principal.calendar(cal_url=calendar_url) + except ValueError as exc: + if "can't be joined with" not in str(exc): + raise + target_path = urlparse(calendar_url).path.rstrip("/") + calendars = await principal.calendars() + for cal in calendars: + if urlparse(str(cal.url)).path.rstrip("/") == target_path: + return cal + raise CalDAVWriteError( + f"No calendar found matching path {target_path!r} on this " + f"principal (configured calendar_url={calendar_url!r})" + ) from exc def _build_event_url(calendar_url: Any, uid: str) -> str: diff --git a/custom_components/asp_parking/manifest.json b/custom_components/asp_parking/manifest.json index 6d8e1da..84923f8 100644 --- a/custom_components/asp_parking/manifest.json +++ b/custom_components/asp_parking/manifest.json @@ -9,5 +9,5 @@ "issue_tracker": "https://github.com/Pascal-ZeGerman/GPS2ASP-Resolver/issues", "requirements": ["pyproj>=3.7.0", "rtree>=1.4.0", "shapely>=2.1.0", "numpy", "httpx>=0.28.0", "zstandard>=0.21.0", "icalendar>=6.3.1", "caldav==2.1.0"], "single_config_entry": true, - "version": "3.3.0-rc1" + "version": "3.3.0-rc3" } diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/demo/app.js b/docs/demo/app.js new file mode 100644 index 0000000..cbf7b95 --- /dev/null +++ b/docs/demo/app.js @@ -0,0 +1,625 @@ +/* ========================================================================== + ASP Parking demo — client controller (Phase 41, Plan 41-04) + + Single plain-ES controller (no bundler, no npm import) loaded via the + `defer` + + + + + + + + + + +
+

See exactly when to move your car

+

Click a block on the map. This is the same result the Home Assistant integration puts on your dashboard — no install needed.

+ + +
+
+ + +
+ +
+
+ + +
+ + +
+
+

Click a pin to check this block

+
+ + +
+ + +
+

Pick a spot to see it in action

+

Click one of the highlighted demo blocks on the map to see its parking rule, the next time you'd need to move, and the exact Home Assistant sensors this integration creates.

+
+ + + + + + + + + + + + + +
+
+ + + + + diff --git a/docs/demo/styles.css b/docs/demo/styles.css new file mode 100644 index 0000000..e52bb0c --- /dev/null +++ b/docs/demo/styles.css @@ -0,0 +1,348 @@ +/* ========================================================================== + ASP Parking demo — dark-theme stylesheet (Phase 41, Plan 41-03) + Palette + type + spacing adopted verbatim from the project's spike baseline + (.planning/spikes/006-sign-coordinate-side/map.html) so the demo reads as + part of the same project. Implements the 41-UI-SPEC contract exactly. + ========================================================================== */ + +:root { + color-scheme: dark; + + /* --- Semantic palette (UI-SPEC Color, 60/30/10 over a dark base) --- */ + --bg: #0f1115; /* Dominant 60% — page + map frame */ + --surface: #141824; /* Secondary 30% — cards, panels */ + --border: #262b38; /* Card borders / dividers */ + --fg: #e6e8ee; /* Foreground text (AA on --bg) */ + --muted: #9aa3b2; /* Muted meta text (AA on --bg for its sizes) */ + --accent: #4da3ff; /* Accent 10% — reserved list only */ + --warning: #f5a623; /* Urgency amber — "move today" only */ + --positive: #35d67f; /* Safe green — "no restrictions" only */ + + /* --- Spacing scale (8-point, multiples of 4) --- */ + --sp-xs: 4px; + --sp-sm: 8px; + --sp-md: 16px; + --sp-lg: 24px; + --sp-xl: 32px; + --sp-2xl: 48px; + --sp-3xl: 64px; + + /* --- Type families --- */ + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + + --radius: 10px; +} + +/* --- Reset / base --- */ +*, +*::before, +*::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + /* Body role: 16px / 400 / 1.5 */ + font-size: 16px; + font-weight: 400; + line-height: 1.5; +} + +a { color: var(--accent); } /* Accent reserved use #6 — text links */ +a:focus-visible, +button:focus-visible, +[role="radio"]:focus-visible, +#map:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--surface); + color: var(--fg); + padding: var(--sp-sm) var(--sp-md); + border-radius: var(--radius); + z-index: 1000; +} +.skip-link:focus { left: var(--sp-md); } + +/* ========================================================================== + Typography roles (exactly 4 roles, 2 weights) + ========================================================================== */ +.display { font-size: 28px; font-weight: 600; line-height: 1.2; margin: 0; } +.heading { font-size: 20px; font-weight: 600; line-height: 1.25; margin: 0 0 var(--sp-sm); } +.body { font-size: 16px; font-weight: 400; line-height: 1.5; } +.label { font-size: 14px; font-weight: 400; line-height: 1.5; } +.mono { font-family: var(--font-mono); font-size: 14px; font-weight: 400; line-height: 1.5; } +.muted { color: var(--muted); } + +/* ========================================================================== + Hero + ========================================================================== */ +.hero { + padding: var(--sp-3xl) var(--sp-lg) var(--sp-2xl); + max-width: 1200px; + margin: 0 auto; +} +.hero-subhead { + color: var(--muted); + max-width: 70ch; + margin: var(--sp-sm) 0 0; +} + +.mode-switch { + display: flex; + align-items: center; + gap: var(--sp-md); + margin-top: var(--sp-lg); + flex-wrap: wrap; +} +.mode-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.mode-option { + background: var(--surface); + color: var(--muted); + border: 0; + padding: var(--sp-sm) var(--sp-md); + min-height: 44px; /* touch target */ + font: inherit; + font-size: 14px; + cursor: pointer; +} +.mode-option[aria-checked="true"] { color: var(--fg); background: #1b2130; } +.mode-status { color: var(--muted); } + +/* ========================================================================== + Layout — desktop two-column, tablet/mobile single-column + Default (mobile-first): single column stack. + ========================================================================== */ +.layout { + display: grid; + grid-template-columns: 1fr; + gap: var(--sp-xl); + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--sp-lg) var(--sp-3xl); +} +.panel { + display: flex; + flex-direction: column; + gap: var(--sp-md); +} + +/* --- Map region --- */ +.map-region { display: flex; flex-direction: column; gap: var(--sp-sm); } + +/* Explicit map height — Leaflet collapses to 0px otherwise (Pitfall 7). */ +#map { + min-height: 320px; /* below 768px baseline */ + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.primary-cta { /* Accent reserved use #1 */ + color: var(--accent); + font-weight: 600; + margin: 0; +} + +/* ========================================================================== + Cards + ========================================================================== */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-lg); +} +.restriction-summary { margin: 0 0 var(--sp-xs); } +.restriction-meta { margin: 0 0 var(--sp-md); } + +/* --- State chips --- */ +.chip { + display: inline-block; + padding: var(--sp-xs) var(--sp-sm); + border-radius: 999px; + font-size: 14px; + line-height: 1.5; + border: 1px solid var(--border); +} +.chip-positive { color: var(--positive); border-color: var(--positive); } /* safe green */ +.chip-warning { color: var(--warning); border-color: var(--warning); } /* urgency amber */ +.chip-neutral { color: var(--muted); border-color: var(--border); } + +/* --- Profile picker --- */ +.profile-picker { + display: flex; + align-items: center; + gap: var(--sp-sm); + margin-top: var(--sp-md); + flex-wrap: wrap; +} +.profile-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.profile-option { + background: var(--surface); + color: var(--muted); + border: 0; + padding: var(--sp-sm) var(--sp-md); + min-height: 44px; /* touch target */ + font: inherit; + font-size: 14px; + cursor: pointer; +} +.profile-option[aria-checked="true"] { /* Accent reserved use #4 — active profile */ + background: var(--accent); + color: var(--bg); + font-weight: 600; +} + +/* --- HA sensor card --- */ +.sensor { margin-bottom: var(--sp-md); } +.sensor-entity { color: var(--muted); margin-bottom: var(--sp-xs); word-break: break-all; } +.sensor-state { margin: var(--sp-xs) 0 var(--sp-sm); } +.attrs { width: 100%; border-collapse: collapse; } +.attrs th, +.attrs td { text-align: left; padding: var(--sp-xs) 0; vertical-align: top; } +.attrs td:first-child { color: var(--muted); font-family: var(--font-mono); font-size: 14px; padding-right: var(--sp-md); white-space: nowrap; } +.attrs td:last-child { color: var(--fg); font-family: var(--font-mono); font-size: 14px; } + +.copy-row { display: flex; align-items: center; gap: var(--sp-md); margin-top: var(--sp-md); flex-wrap: wrap; } +.btn-copy { + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-sm) var(--sp-md); + min-height: 44px; /* touch target */ + font: inherit; + font-size: 14px; + cursor: pointer; +} +.btn-copy:hover { border-color: var(--accent); } +.copy-status { color: var(--positive); } + +/* --- Error state --- */ +.error-state { border-color: var(--warning); } + +/* ========================================================================== + Surface 4 — calendar + ========================================================================== */ +.calendar { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--sp-sm); + margin-top: var(--sp-md); +} +.calendar-day { + aspect-ratio: 1 / 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + font-size: 14px; +} +/* Highlighted next-move cell — animation defined here, DISABLED under reduced-motion below. */ +.calendar-day.is-next { + color: var(--bg); + background: var(--accent); /* Accent reserved use #5 — next-move cell */ + border-color: var(--accent); + font-weight: 600; + transform: scale(1); + opacity: 1; + animation: cell-in 200ms ease-out both; + transition: transform 200ms ease-out, opacity 200ms ease-out; +} +.calendar-day.is-today { + color: var(--bg); + background: var(--warning); /* urgency amber if the next move is today */ + border-color: var(--warning); + font-weight: 600; + animation: cell-in 200ms ease-out both, ring-pulse 600ms ease-out 1; + transition: transform 200ms ease-out, opacity 200ms ease-out; +} + +@keyframes cell-in { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} +@keyframes ring-pulse { + 0% { box-shadow: 0 0 0 0 var(--warning); } + 100% { box-shadow: 0 0 0 8px rgba(245, 166, 35, 0); } +} + +/* ========================================================================== + Footer + ========================================================================== */ +.site-footer { + max-width: 1200px; + margin: 0 auto; + padding: var(--sp-xl) var(--sp-lg) var(--sp-3xl); + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +/* ========================================================================== + Responsive — tablet (768–1023px) full-width map, then panels stacked + ========================================================================== */ +@media (min-width: 768px) { + #map { min-height: 360px; } /* tablet map height */ +} + +/* ========================================================================== + Responsive — desktop (≥1024px) two columns: map ~60% / panel ~40% + ========================================================================== */ +@media (min-width: 1024px) { + .layout { + grid-template-columns: 3fr 2fr; /* ~60% / ~40% */ + align-items: start; + } + #map { min-height: 480px; } /* explicit desktop map height (Pitfall 7) */ + .panel { + position: sticky; + top: var(--sp-lg); + max-height: calc(100vh - var(--sp-xl)); + overflow-y: auto; + } +} + +/* ========================================================================== + Reduced motion — strip ALL calendar motion; static highlight only. + ========================================================================== */ +@media (prefers-reduced-motion: reduce) { + .calendar-day.is-next, + .calendar-day.is-today { + animation: none; + transition: none; + transform: none; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/docs/explorer/app.js b/docs/explorer/app.js new file mode 100644 index 0000000..a54e204 --- /dev/null +++ b/docs/explorer/app.js @@ -0,0 +1,459 @@ +/* ========================================================================== + Sign-coverage explorer — client controller (Phase 42, Plan 42-04) + + Single plain-ES controller (no bundler, no npm import) loaded via the + `defer` + + + + + + + + + + +
+

Sign-coverage explorer

+

A maintainer QA tool. This map plots every NYC street segment's parsed alternate-side-parking coverage, colored by how confidently each block matched a SODA sign record — so coverage gaps and low-confidence matches are visible and navigable. This is not the onboarding demo; it's for finding and inspecting the holes in the data.

+

Data as of

+
+ + +
+ + +
+
+ + +
+

Confidence tiers

+
    +
  • + + High — exact block match +
  • +
  • + + Medium — approximate match +
  • +
  • + + Low — fuzzy or fallback match +
  • +
  • + + Unresolved — no SODA record +
  • +
+
+
+ + +
+ + +
+

Filter the map

+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + Case-insensitive substring match. +
+ +
+ +
+
+ + + + + + + +
+
+ + + + + diff --git a/docs/explorer/styles.css b/docs/explorer/styles.css new file mode 100644 index 0000000..d44de3f --- /dev/null +++ b/docs/explorer/styles.css @@ -0,0 +1,347 @@ +/* ========================================================================== + Sign-coverage explorer — dark-theme stylesheet (Phase 42, Plan 42-03) + Design tokens, reset/base, and typography roles are adopted VERBATIM from + docs/demo/styles.css:8-85 (D-15) so the explorer reads with the same polish as + the Phase 41 demo — WITHOUT restating the demo's marketing layout. docs/demo/ + is a separate site and is NOT modified (R6). The explorer-specific rules + (legend, filters, tier colors, canvas markers, states) are added below. + ========================================================================== */ + +:root { + color-scheme: dark; + + /* --- Semantic palette (UI-SPEC Color, 60/30/10 over a dark base) --- */ + --bg: #0f1115; /* Dominant 60% — page + map frame */ + --surface: #141824; /* Secondary 30% — cards, panels */ + --border: #262b38; /* Card borders / dividers */ + --fg: #e6e8ee; /* Foreground text (AA on --bg) */ + --muted: #9aa3b2; /* Muted meta text (AA on --bg for its sizes) */ + --accent: #4da3ff; /* Accent 10% — reserved list only */ + --warning: #f5a623; /* Urgency amber — "move today" only */ + --positive: #35d67f; /* Safe green — "no restrictions" only */ + + /* --- Spacing scale (8-point, multiples of 4) --- */ + --sp-xs: 4px; + --sp-sm: 8px; + --sp-md: 16px; + --sp-lg: 24px; + --sp-xl: 32px; + --sp-2xl: 48px; + --sp-3xl: 64px; + + /* --- Type families --- */ + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + + --radius: 10px; + + /* ------------------------------------------------------------------------ + Confidence-tier color scale (red -> green, D-09). Reuses the existing + semantic tokens where they already map to a tier, and mints only the two + values the demo palette lacks (a mid teal-green and a red), so the tier + scale stays consistent with the rest of the project rather than a fresh + palette. This is the HUE channel; the text label (legend + popup) and the + per-tier marker radius below are the second/third channels for colorblind + users (Prohibition 3 / T-42-05). + high -> --positive (green, exact block match) + medium -> teal-green (approximate match) + low -> --warning (amber, fuzzy/fallback match) + unresolved -> red (no SODA record — the gaps we're hunting) + ---------------------------------------------------------------------- */ + --tier-high: var(--positive); /* #35d67f */ + --tier-medium: #2dd4bf; /* mid teal-green between green and amber */ + --tier-low: var(--warning); /* #f5a623 */ + --tier-unresolved: #e5484d; /* red — highest-visibility gap marker */ + + /* ------------------------------------------------------------------------ + Per-tier MARKER RADIUS convention (third, non-hue channel — for 42-04's + canvas circleMarkers to honor). Unresolved is LARGEST so data gaps pop even + at citywide zoom and are distinguishable by size regardless of color + vision; high-confidence (the well-covered common case) is SMALLEST so it + recedes. Ordering: unresolved > low > medium > high. + --marker-r-unresolved: 5px + --marker-r-low: 4px + --marker-r-medium: 3px + --marker-r-high: 2px + (Exposed as variables so 42-04 can read them from getComputedStyle if it + prefers CSS as the single source of truth.) + ---------------------------------------------------------------------- */ + --marker-r-unresolved: 5px; + --marker-r-low: 4px; + --marker-r-medium: 3px; + --marker-r-high: 2px; +} + +/* --- Reset / base (verbatim from docs/demo/styles.css:37-75) --- */ +*, +*::before, +*::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + /* Body role: 16px / 400 / 1.5 */ + font-size: 16px; + font-weight: 400; + line-height: 1.5; +} + +a { color: var(--accent); } /* Accent reserved use #6 — text links */ +a:focus-visible, +button:focus-visible, +select:focus-visible, +input:focus-visible, +#map:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--surface); + color: var(--fg); + padding: var(--sp-sm) var(--sp-md); + border-radius: var(--radius); + z-index: 1000; +} +.skip-link:focus { left: var(--sp-md); } + +/* ========================================================================== + Typography roles (verbatim from docs/demo/styles.css:80-85 — 4 roles, 2 weights) + ========================================================================== */ +.display { font-size: 28px; font-weight: 600; line-height: 1.2; margin: 0; } +.heading { font-size: 20px; font-weight: 600; line-height: 1.25; margin: 0 0 var(--sp-sm); } +.body { font-size: 16px; font-weight: 400; line-height: 1.5; } +.label { font-size: 14px; font-weight: 400; line-height: 1.5; } +.mono { font-family: var(--font-mono); font-size: 14px; font-weight: 400; line-height: 1.5; } +.muted { color: var(--muted); } + +/* ========================================================================== + Hero (coverage-QA copy — same spacing rhythm as the demo, own text) + ========================================================================== */ +.hero { + padding: var(--sp-3xl) var(--sp-lg) var(--sp-2xl); + max-width: 1200px; + margin: 0 auto; +} +.hero-subhead { + color: var(--muted); + max-width: 70ch; + margin: var(--sp-sm) 0 0; +} +.hero-freshness { + margin: var(--sp-md) 0 0; +} + +/* ========================================================================== + Layout — desktop two-column (map ~60% / panel ~40%), single-column on small. + Mobile-first: single-column stack. + ========================================================================== */ +.layout { + display: grid; + grid-template-columns: 1fr; + gap: var(--sp-xl); + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--sp-lg) var(--sp-3xl); +} +.panel { + display: flex; + flex-direction: column; + gap: var(--sp-md); +} + +/* ========================================================================== + Map region + ========================================================================== */ +.map-region { display: flex; flex-direction: column; gap: var(--sp-md); } + +/* Explicit map height — Leaflet collapses to 0px otherwise (Pitfall 7). */ +#map { + min-height: 320px; /* below 768px baseline */ + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +/* Canvas circleMarker fill/stroke colors keyed to the tier scale. 42-04 sets + the fillColor per marker; these classes document the intended mapping and can + be reused if it renders any DOM swatch/marker. Each tier's on-map size is the + third channel (radius convention above). */ +.marker--high { color: var(--tier-high); } +.marker--medium { color: var(--tier-medium); } +.marker--low { color: var(--tier-low); } +.marker--unresolved { color: var(--tier-unresolved); } + +/* ========================================================================== + Legend — ALWAYS visible (D-16). Each row pairs a color swatch (hue) with a + text label (the non-hue channel required by Prohibition 3 / T-42-05). + ========================================================================== */ +#legend, +.legend { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-md) var(--sp-lg); +} +.legend-title { + margin: 0 0 var(--sp-sm); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.legend-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: var(--sp-sm); +} +.legend-row { + display: flex; + align-items: center; + gap: var(--sp-sm); +} +.legend-swatch { + flex: 0 0 auto; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--border); +} +.legend-swatch--high { background: var(--tier-high); } +.legend-swatch--medium { background: var(--tier-medium); } +.legend-swatch--low { background: var(--tier-low); } +.legend-swatch--unresolved { background: var(--tier-unresolved); } +.legend-label { + color: var(--fg); + font-size: 14px; + line-height: 1.4; +} + +/* ========================================================================== + Cards (shared surface for filters / states — mirrors the demo .card) + ========================================================================== */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-lg); +} + +/* ========================================================================== + Filter panel — label + control rows, 44px touch targets (like the demo's + .mode-option). Four independent AND-composed controls (R4). + ========================================================================== */ +#filters, +.filters { display: flex; flex-direction: column; gap: var(--sp-md); } +.filter-row { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} +.filter-row .label { color: var(--muted); } +.filter-control { + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-sm) var(--sp-md); + min-height: 44px; /* touch target — parity with demo .mode-option */ + font: inherit; + font-size: 14px; + width: 100%; +} +.filter-control:hover { border-color: var(--accent); } +/* search input clear affordance stays legible on dark */ +input.filter-control::placeholder { color: var(--muted); } +.filter-hint { display: block; margin-top: var(--sp-xs); } + +.filter-actions { + display: flex; + margin-top: var(--sp-xs); +} +#export-geojson, +.btn-export { + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-sm) var(--sp-md); + min-height: 44px; /* touch target */ + font: inherit; + font-size: 14px; + cursor: pointer; + width: 100%; +} +.btn-export:hover { border-color: var(--accent); } + +/* ========================================================================== + State regions — no-results (R4) and dataset-load error (R2) + ========================================================================== */ +#no-results, +.no-results { + color: var(--muted); + border-color: var(--border); +} +#error-state, +.error-state { border-color: var(--warning); } + +/* ========================================================================== + Footer (mirrors the demo .site-footer) + ========================================================================== */ +.site-footer { + max-width: 1200px; + margin: 0 auto; + padding: var(--sp-xl) var(--sp-lg) var(--sp-3xl); + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +/* ========================================================================== + Responsive — tablet (768–1023px) taller map + ========================================================================== */ +@media (min-width: 768px) { + #map { min-height: 480px; } /* tablet map height */ +} + +/* ========================================================================== + Responsive — desktop (≥1024px) two columns: map ~60% / filter panel ~40% + ========================================================================== */ +@media (min-width: 1024px) { + .layout { + grid-template-columns: 3fr 2fr; /* ~60% / ~40% */ + align-items: start; + } + #map { min-height: 600px; } /* explicit desktop map height (Pitfall 7) */ + .panel { + position: sticky; + top: var(--sp-lg); + max-height: calc(100vh - var(--sp-xl)); + overflow-y: auto; + } +} + +/* ========================================================================== + Reduced motion — no explorer animations, but honor the demo's global guard. + ========================================================================== */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/scripts/build_coverage_dataset.py b/scripts/build_coverage_dataset.py new file mode 100644 index 0000000..caad4f0 --- /dev/null +++ b/scripts/build_coverage_dataset.py @@ -0,0 +1,617 @@ +#!/usr/bin/env python3 +"""Offline, build-time coverage dataset dumper for the static coverage explorer. + +Walks the committed spatial-index segments, resolves each block's ASP schedule +against the SODA API (network wiring lands in plan 42-02), and serialises a +single small committed dataset (``docs/explorer/data/coverage.json``) that the +static explorer page (``docs/explorer/``) renders with no server. + +This is a PRESENTATION-LAYER SNAPSHOT DUMPER — it re-implements no resolver +logic. It reuses ``normalize_to_soda`` for the canonical grouping key and derives +each segment's candidate parking sides from geometry alone; it does NOT recompute +the GPS-point-relative confidence (that needs a live GPS fix — RESEARCH Pitfall 2). + +Two decay traps are deliberately avoided: + + * Date decay (Pitfall 3): the emitted dataset stores the WEEKLY PATTERN + (day-of-week + start/end times) per block, NEVER an absolute next-move + datetime. The client recomputes the next occurrence at page load. + * Feet-vs-degrees (Pitfall 5): segment geometry (EPSG:2263 US survey feet) is + reprojected to WGS84 before it can be drawn on a Leaflet map. Only ONE + midpoint per segment is emitted to keep coverage.json small. + +Security (T-42-01): the NYC SODA app token is a BUILD-TIME env var consumed only +inside the resolver's SODA client. The pure functions in this module never read +or touch any credential, and no token is ever serialised into coverage.json. The +serialization guard test lands in plan 42-02. + +Canonical coverage.json schema is documented in 42-01-PLAN.md's ; this +plan (42-01) locks the deterministic core (grouping key + tier partition). The +network resolve pipeline + main() land in 42-02. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import math +import sys +from datetime import date, datetime +from pathlib import Path + +from pyproj import Transformer +from shapely import wkt + +from gps2asp.schedule import ( + ASPActiveNow, + ScheduleFound, + ScheduleResult, + compute_schedule, +) +from gps2asp.signs import _cross_streets_match, materialize_cached_records +from gps2asp.signs.client import SODAClient +from gps2asp.signs.normalize import normalize_to_soda + +logger = logging.getLogger("build_coverage_dataset") + +# Fixed build-time reference instant fed to compute_schedule. The committed +# dataset stores only the WEEKLY pattern (never an absolute next-move date — +# Pitfall 3), so this instant does not leak into the output; it exists solely to +# make the run deterministic. 04:00 on a weekday sits outside every realistic ASP +# cleaning window, so no block spuriously resolves to "asp_active_now" at build +# time (the "resting" status is schedule_found). It is naive; the schedule layer +# attaches America/New_York. +_BUILD_REFERENCE_TIME = datetime(2025, 1, 1, 4, 0) + +# Reverse of resolver/converter.py's forward transform: EPSG:2263 -> WGS84. +# always_xy=True yields (lon, lat) — exactly GeoJSON coordinate order. +_TO_WGS84 = Transformer.from_crs("EPSG:2263", "EPSG:4326", always_xy=True) + +# CSCL borough code -> human name (mirrors coordinator._BOROUGH_NAMES). +_BOROUGH_NAMES: dict[str, str] = { + "1": "Manhattan", + "2": "Bronx", + "3": "Brooklyn", + "4": "Queens", + "5": "Staten Island", +} + +# SODA fallback level -> confidence (D-18). Level 0 (no match) and any unexpected +# level both map to 0.00 via the .get default. These are geometry-independent +# proxies: they express "how directly did the block match a SODA sign", NOT the +# GPS-point-relative resolver confidence (which needs a live fix — Pitfall 2). +CONFIDENCE_BY_LEVEL: dict[int, float] = {1: 0.90, 2: 0.66, 3: 0.40, 0: 0.00} + +# The ONE half-open partition rule of the closed interval [0, 1]. Each tier owns +# [lower, upper): lower-inclusive, upper-exclusive — EXCEPT the top tier, which is +# inclusive of 1.0 so a perfect score is never orphaned. 0.33 is anchored to the +# resolver's DEFAULT_CONFIDENCE_THRESHOLD ("resolved" floor), so 0.33 lands in +# "low", never "unresolved". The tier NAME (not just a color) is the downstream +# channel: legend labels + per-tier marker radius (42-03/42-04), giving a +# non-hue signal for colorblind accessibility (T-42-05). Ordered high -> low so +# the first matching lower bound wins. +TIER_BOUNDS: tuple[tuple[float, str], ...] = ( + (0.75, "high"), + (0.50, "medium"), + (0.33, "low"), + (0.00, "unresolved"), +) + +# Lazily-loaded segment geometry cache: str(segment_id) -> geometry_wkt. +_SEGMENTS_PATH = ( + Path(__file__).resolve().parents[1] + / "src" + / "gps2asp" + / "data" + / "index" + / "segments.json" +) +_segments_cache: dict[str, str] | None = None + + +def reproject_wkt_to_wgs84(geometry_wkt: str) -> list[list[float]]: + """Reproject an EPSG:2263 LINESTRING WKT to WGS84 ``[[lon, lat], ...]``. + + Args: + geometry_wkt: A ``LINESTRING`` in EPSG:2263 (NY State Plane, US feet). + + Returns: + List of ``[lon, lat]`` coordinate pairs in GeoJSON order (WGS84). + """ + line = wkt.loads(geometry_wkt) + return [list(_TO_WGS84.transform(x, y)) for (x, y) in line.coords] + + +def segment_midpoint_wgs84(geometry_wkt: str) -> tuple[float, float]: + """Reproject a segment's midpoint to WGS84 ``(lat, lon)`` rounded to 6 dp. + + Emitting a single midpoint per segment (rather than the full polyline) keeps + coverage.json small (RESEARCH Pitfall 5). The 0.5 interpolation happens in + EPSG:2263 (equal-area feet) BEFORE reprojection, so it is the true geometric + midpoint, not a lon/lat average. + + Args: + geometry_wkt: A ``LINESTRING`` in EPSG:2263 (NY State Plane, US feet). + + Returns: + ``(lat, lon)`` in WGS84, each rounded to 6 decimal places. + """ + line = wkt.loads(geometry_wkt) + midpoint = line.interpolate(0.5, normalized=True) + lon, lat = _TO_WGS84.transform(midpoint.x, midpoint.y) + return (round(lat, 6), round(lon, 6)) + + +def _borough_name(borocode: str | None) -> str | None: + """Map a CSCL borough code to its human name, or None when unknown.""" + if borocode is None: + return None + return _BOROUGH_NAMES.get(str(borocode)) + + +def _load_segments() -> dict[str, str]: + """Lazily load ``segments.json`` into a ``segment_id -> geometry_wkt`` map.""" + global _segments_cache + if _segments_cache is None: + raw = json.loads(_SEGMENTS_PATH.read_text()) + _segments_cache = { + str(seg_id): rec["geometry_wkt"] + for seg_id, rec in raw.items() + if isinstance(rec, dict) and "geometry_wkt" in rec + } + return _segments_cache + + +def derive_segment_sides(geometry_wkt: str) -> tuple[str, str]: + """Return a segment's two candidate parking sides from its geometry bearing. + + The two sides are derived from the segment's run direction (first -> last + coordinate), NEVER from ``has_asp_left``/``has_asp_right`` (which are always + identical in the source data — D-02). An E-W street (bearing near 0/180 deg) + has North and South curbs; an N-S street (bearing near 90/270 deg) has East + and West curbs. + + Args: + geometry_wkt: A ``LINESTRING`` in EPSG:2263 (NY State Plane, US feet). + + Returns: + ``("N", "S")`` for an E-W segment, ``("E", "W")`` for an N-S segment. + """ + line = wkt.loads(geometry_wkt) + coords = list(line.coords) + x0, y0 = coords[0][0], coords[0][1] + x1, y1 = coords[-1][0], coords[-1][1] + angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) % 360 + # E-W run (bearing within +-45 deg of the E-W axis) -> North/South curbs. + if 315 <= angle or angle < 45 or 135 <= angle < 225: + return ("N", "S") + # Otherwise the segment runs N-S -> East/West curbs. + return ("E", "W") + + +def group_key(full_street_name: str, side: str) -> tuple[str, str]: + """Canonical dedup key ``(normalized_street, side)`` for a block face. + + ``normalize_to_soda`` collapses casing / internal whitespace / abbreviation + variants of the same street to ONE canonical form (D-01), so BROADWAY / + Broadway / "W THAMES ST" all fold onto a single street key. Pairing it with + the derived ``side`` gives two recoverable keys per segment (one per curb), + guaranteeing no segment is double-counted or dropped across group boundaries. + + Args: + full_street_name: The block's on-street / full street name (CSCL form). + side: One compass side letter ("N", "S", "E", or "W"). + + Returns: + ``(canonical_street, side)``. + """ + return (normalize_to_soda(full_street_name), side) + + +def confidence_for_level(level: int) -> float: + """Map a SODA fallback level to its geometry-independent confidence (D-18). + + Levels 1/2/3 -> 0.90/0.66/0.40; level 0 (no match) and any unexpected value + -> 0.00. This is NOT the GPS-point resolver confidence (Pitfall 2). + """ + return CONFIDENCE_BY_LEVEL.get(level, 0.0) + + +def tier_for_confidence(v: float) -> str: + """Partition a confidence in [0, 1] into exactly one named tier. + + Applies the single half-open rule documented on ``TIER_BOUNDS``: + ``[0.00, 0.33) unresolved | [0.33, 0.50) low | [0.50, 0.75) medium | + [0.75, 1.00] high`` (top tier inclusive of 1.0). Returns a NAMED tier string + usable as a text/shape channel downstream, not merely a color (T-42-05). + + Args: + v: A confidence value, expected in the closed interval [0, 1]. + + Returns: + One of ``"high"``, ``"medium"``, ``"low"``, ``"unresolved"``. + """ + for lower, name in TIER_BOUNDS: + if v >= lower: + return name + # Values below 0.0 are not expected; treat them as unresolved defensively. + return "unresolved" + + +def _load_segment_records() -> dict[str, dict]: + """Load ``segments.json`` into a ``segment_id -> full record`` map. + + Unlike ``_load_segments`` (which keeps only the geometry for reprojection), + the whole-index resolve needs each block's street identity too: + ``full_street_name``/``from_street``/``to_street``/``borocode`` plus the + ``geometry_wkt`` used for the map midpoint and the geometry-derived sides. + """ + raw = json.loads(_SEGMENTS_PATH.read_text()) + return { + str(seg_id): rec + for seg_id, rec in raw.items() + if isinstance(rec, dict) and "geometry_wkt" in rec + } + + +async def resolve_group( + client: SODAClient, + normalized_street: str, + side: str, +) -> list[dict]: + """Issue ONE broad SODA query for a whole ``(normalized street, side)`` group. + + This is the R1 dedup primitive: instead of one exact block query per segment + (~105K calls), the build fetches every broom sign on a street+side ONCE, then + recovers per-block precision client-side via the cross-street filter. Mirrors + ``audit_queens_coverage.py``'s ``build_on_street_query`` + ``fetch_signs`` + pattern. + + Fail-soft (Pitfall 4): a failed group logs a WARNING and returns ``[]`` rather + than aborting the whole-index run — every segment in the group then degrades + to an explicit no-match entry (never a silent omission). + + Args: + client: SODAClient (or a stub exposing the same two methods). + normalized_street: Canonical street key (already ``normalize_to_soda``d). + side: Compass side letter ("N", "S", "E", or "W"). + + Returns: + Raw SODA record dicts for the group, or ``[]`` on any query failure. + """ + query = client.build_on_street_query(normalized_street, side) + try: + return await client.fetch_signs(query) + except Exception as exc: # noqa: BLE001 — fail-soft per group (Pitfall 4) + logger.warning( + "resolve_group: SODA query failed for street=%r side=%r: %s — " + "treating group as empty", + normalized_street, + side, + exc, + ) + return [] + + +def cross_streets_match(record: dict, from_street: str, to_street: str) -> bool: + """Whether a SODA record covers this block's cross streets. + + Thin wrapper over the resolver's ``signs._cross_streets_match`` so the build + reuses its variant + swap + empty-field guard (BUG-S-003) instead of a naive + string compare (RESEARCH "Don't Hand-Roll"). + """ + return _cross_streets_match(record, from_street, to_street) + + +def _exact_cross_match(record: dict, from_street: str, to_street: str) -> bool: + """Whether a record's cross streets match EXACTLY (no abbreviation variants). + + Used to separate soda_level 1 (exact from/to or exact swap) from level 2 + (matched only via an abbreviation variant). Compares the canonical + ``normalize_to_soda`` forms directly, without expanding ``name_variants``. + """ + record_from = record.get("from_street", "") + record_to = record.get("to_street", "") + if not record_from or not record_to or not from_street or not to_street: + return False + rf = normalize_to_soda(record_from.upper().strip()) + rt = normalize_to_soda(record_to.upper().strip()) + ff = normalize_to_soda(from_street.upper().strip()) + tt = normalize_to_soda(to_street.upper().strip()) + return (rf == ff and rt == tt) or (rf == tt and rt == ff) + + +def resolve_side( + group_records: list[dict], + on_street: str, + from_street: str, + to_street: str, + side: str, + now: datetime, +) -> tuple[int, ScheduleResult]: + """Resolve ONE side of a block from its group's pre-fetched records. + + Assigns ``soda_level`` by match precision (D-18 confidence follows): + * group empty (street absent from SODA) -> 0 (no-match) + * an exact from/to (or exact swap) match exists -> 1 + * only abbreviation-variant matches exist -> 2 + * the group has records but NONE match this block's cross streets -> 3 + + The filtered records are materialised into the resolver's ``SignRetrievalResult`` + shape and run through ``compute_schedule`` (fixed ``now``) so status/summary/ + weekly come from the SAME pipeline the live resolver uses. For levels 0 and 3 + the filter is empty, so ``materialize_cached_records`` yields ``NoMatchFound`` + -> a ``no_match`` schedule (still an explicit entry). + + Returns: + ``(soda_level, schedule_result)``. + """ + filtered = [ + r for r in group_records if cross_streets_match(r, from_street, to_street) + ] + if not group_records: + soda_level = 0 + elif any(_exact_cross_match(r, from_street, to_street) for r in filtered): + soda_level = 1 + elif filtered: + soda_level = 2 + else: + soda_level = 3 + + sign_result = materialize_cached_records( + filtered, + on_street, + from_street, + to_street, + side, + # For empty `filtered` this marker is unused (NoMatchFound short-circuits); + # clamp to a valid level>=1 for the success shape when records are present. + soda_level if soda_level >= 1 else 1, + ) + schedule = compute_schedule(sign_result, now=now) + return soda_level, schedule + + +def _summary_and_weekly( + schedule: ScheduleResult, +) -> tuple[str | None, list[dict]]: + """Extract ``(summary, weekly_pattern)`` from a schedule result. + + The weekly pattern stores ``{d, s, e}`` = day value + start/end ``%H:%M`` + ONLY — never an absolute date (Pitfall 3) and never the raw sign text (D-04, + unlike ``build_demo_dataset`` which keeps ``sign``). Non-schedule variants + (no_asp / no_match / all_unparseable) yield ``(None, [])``. + """ + if isinstance(schedule, ScheduleFound): + weekly = [ + { + "d": window.day.value, + "s": window.start_time.strftime("%H:%M"), + "e": window.end_time.strftime("%H:%M"), + } + for window in schedule.weekly_schedule.windows + ] + return schedule.summary, weekly + if isinstance(schedule, ASPActiveNow): + # No weekly_schedule on the active variant — only the single active + # window. Still surface it so the client renders a schedule (mirrors + # build_demo_dataset's asp_active_now handling). + window = schedule.active_window + weekly = [ + { + "d": window.day.value, + "s": window.start_time.strftime("%H:%M"), + "e": window.end_time.strftime("%H:%M"), + } + ] + return schedule.summary, weekly + return None, [] + + +def build_segment_entry( + segment_id: str, + seg_record: dict, + side: str, + soda_level: int, + schedule: ScheduleResult, +) -> dict: + """Assemble ONE canonical compact coverage.json segment entry (42-01 schema). + + Emits EXACTLY the locked short keys and no others: + ``id, lat, lon, st, fr, to, sd, bc, lv, cf, status, sm, wk``. No credential + field, no raw sign text, no absolute date. + + Args: + segment_id: The segment id (dataset ``id``). + seg_record: The raw segments.json record (street identity + geometry). + side: The worst-case side chosen for this segment (D-13). + soda_level: Match-precision level for the chosen side. + schedule: The chosen side's schedule result. + """ + lat, lon = segment_midpoint_wgs84(seg_record["geometry_wkt"]) + summary, weekly = _summary_and_weekly(schedule) + return { + "id": segment_id, + "lat": lat, + "lon": lon, + "st": seg_record.get("full_street_name"), + "fr": seg_record.get("from_street"), + "to": seg_record.get("to_street"), + "sd": side, + "bc": str(seg_record.get("borocode")), + "lv": soda_level, + "cf": confidence_for_level(soda_level), + "status": schedule.status, + "sm": summary, + "wk": weekly, + } + + +async def build_coverage( + segments: dict[str, dict], + client: SODAClient, + *, + now: datetime = _BUILD_REFERENCE_TIME, + limit: int | None = None, +) -> dict: + """Resolve the whole index deduped by ``(street, side)`` into the dataset dict. + + Steps (R1): + 1. For each segment, derive its two geometry-based sides and their two + ``group_key``s; collect the DISTINCT set of ``(normalized street, side)`` + groups. + 2. Issue exactly ONE ``resolve_group`` per distinct group, caching records + in memory — this is the dedup: SODA call count == distinct-group count, + far below the segment count. + 3. For each segment, resolve BOTH sides against their group's records and + pick the WORST-CASE side (lower confidence; D-13). A segment is only + high-tier when both sides resolve well. + 4. Build one compact entry per segment — never omit a segment. + + Args: + segments: ``segment_id -> raw segments.json record`` (in-memory; tests + pass a tiny stub map, ``main`` passes the whole index). + client: SODAClient (or a stub exposing build_on_street_query/fetch_signs). + now: Fixed build-time instant for compute_schedule (determinism). + limit: If set, resolve only the first N segments (local smoke runs). + + Returns: + The full dataset dict (``generation_date, boroughs, query_count, + segment_count, segments``). + """ + items = list(segments.items()) + if limit is not None: + items = items[:limit] + + # ---- pass 1: derive per-segment sides + collect the distinct group set ---- + # seg_plan[sid] = (on_street, from_street, to_street, [(side, group_key), ...]) + seg_plan: dict[str, tuple[str, str, str, list[tuple[str, tuple[str, str]]]]] = {} + distinct_groups: dict[tuple[str, str], None] = {} + for sid, rec in items: + on_street = rec["full_street_name"] + from_street = rec["from_street"] + to_street = rec["to_street"] + sides = derive_segment_sides(rec["geometry_wkt"]) + side_keys: list[tuple[str, tuple[str, str]]] = [] + for side in sides: + gk = group_key(on_street, side) + distinct_groups[gk] = None + side_keys.append((side, gk)) + seg_plan[sid] = (on_street, from_street, to_street, side_keys) + + # ---- resolve each distinct group exactly once (the R1 dedup) ---- + group_records: dict[tuple[str, str], list[dict]] = {} + for normalized_street, side in distinct_groups: + group_records[(normalized_street, side)] = await resolve_group( + client, normalized_street, side + ) + query_count = len(distinct_groups) + + # ---- pass 2: worst-case side per segment -> one entry each ---- + segment_entries: list[dict] = [] + for sid, rec in items: + on_street, from_street, to_street, side_keys = seg_plan[sid] + best: tuple[float, int, str, ScheduleResult] | None = None + for side, gk in side_keys: + soda_level, schedule = resolve_side( + group_records[gk], on_street, from_street, to_street, side, now + ) + cf = confidence_for_level(soda_level) + # Worst-case = LOWER confidence (D-13); ties keep the first side. + if best is None or cf < best[0]: + best = (cf, soda_level, side, schedule) + assert best is not None # every segment has at least one side + _, worst_level, worst_side, worst_schedule = best + segment_entries.append( + build_segment_entry(sid, rec, worst_side, worst_level, worst_schedule) + ) + + return { + "generation_date": date.today().isoformat(), + "boroughs": _BOROUGH_NAMES, + "query_count": query_count, + "segment_count": len(segment_entries), + "segments": segment_entries, + } + + +def main(argv: list[str] | None = None) -> int: + """Whole-index SODA resolve + coverage.json writer (R1). + + Reads the committed spatial-index segments (or a ``--segments`` override for + tests/smoke runs), resolves every segment deduped by ``(street, side)``, and + writes the canonical compact ``coverage.json``. The SODA app token is read + ONLY inside ``SODAClient`` (from the environment); this script never reads it + and never serialises any credential (T-42-01). + """ + parser = argparse.ArgumentParser( + description=( + "Offline whole-index coverage dumper: grouped SODA resolve -> " + "coverage.json for the static street-sign coverage explorer." + ), + ) + parser.add_argument( + "--out-dir", + type=Path, + default=Path("docs/explorer/data"), + help="Directory to write coverage.json into.", + ) + parser.add_argument( + "--segments", + type=Path, + default=None, + help="Optional segments.json override (id -> record). Defaults to the " + "committed spatial index.", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Resolve only the first N segments (local smoke runs).", + ) + args = parser.parse_args(argv) + + if args.segments is not None: + segments = json.loads(args.segments.read_text()) + else: + segments = _load_segment_records() + + expected_count = ( + len(segments) if args.limit is None else min(args.limit, len(segments)) + ) + + client = SODAClient() + dataset = asyncio.run(build_coverage(segments, client, limit=args.limit)) + + # Build-time self-check: fail loud if any segment was dropped (R1: never an + # omitted entry). The whole-index build has no per-point fallback, so a count + # mismatch is a hard bug, not a soft-degrade. + actual = len(dataset["segments"]) + if actual != expected_count: + print( + f"build_coverage_dataset: ERROR — expected {expected_count} segment " + f"entries but produced {actual}; refusing to write a lossy dataset.", + file=sys.stderr, + ) + return 1 + + out_dir: Path = args.out_dir + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / "coverage.json" + out_path.write_text(json.dumps(dataset, indent=2) + "\n") + + # R1 verifiability: the low-thousands query claim must be readable from the + # run output. + print( + f"build_coverage_dataset: issued {dataset['query_count']} SODA group " + f"queries for {actual} segments -> {out_path}" + ) + return 0 + + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/scripts/build_demo_dataset.py b/scripts/build_demo_dataset.py new file mode 100644 index 0000000..e85b863 --- /dev/null +++ b/scripts/build_demo_dataset.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +"""Offline, build-time demo dataset dumper for the hosted demo page. + +Runs the existing resolver (``resolve_asp(lat, lon, debug=True)``) over a small +set of hand-picked NYC coordinates and serialises a tiny committed dataset the +static demo page (``docs/demo/``) consumes without any server. + +This is a PRESENTATION-LAYER SNAPSHOT DUMPER — it re-implements no resolver +logic. It calls the single public entrypoint and serialises the result into a +JSON shape the browser renders directly. + +Two decay traps are deliberately avoided: + + * Pitfall 1 (date decay): the emitted dataset stores the WEEKLY PATTERN + (day-of-week + start/end times + sign text), never an absolute next-move + datetime. The client (app.js) recomputes the next occurrence at page load, + pinned to America/New_York. + * Pitfall 5 (feet vs degrees): matched segment geometry (``geometry_wkt`` in + EPSG:2263 US survey feet) is reprojected to WGS84 ``[lon, lat]`` (GeoJSON + order) via pyproj before it can be drawn on a Leaflet map. + +Security (T-41-01): the NYC SODA app token is a BUILD-TIME env var consumed only +by the resolver's SODA client. It is never read or serialised into demo.json or +the GeoJSON. External NYC sign text is stored as-is (untrusted) and MUST be +rendered client-side via ``textContent`` (see 41-04), never ``innerHTML``. + +The dataset FILES are produced by running this script (plan 41-02); this module +only defines the dumper and its offline-testable pure functions. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from datetime import date +from pathlib import Path + +from pyproj import Transformer +from shapely import wkt + +from gps2asp import resolve_asp +from gps2asp.resolver.exceptions import ( + IndexNotFoundError, + NoSegmentFoundError, + OutsideNYCError, +) +from gps2asp.schedule.models import ASPActiveNow, ScheduleFound +from gps2asp.signs.exceptions import IncompleteResultsError, SODAAPIError + +# Reverse of resolver/converter.py's forward transform: EPSG:2263 -> WGS84. +# always_xy=True yields (lon, lat) — exactly GeoJSON coordinate order. +_TO_WGS84 = Transformer.from_crs("EPSG:2263", "EPSG:4326", always_xy=True) + +# CSCL borough code -> human name (mirrors coordinator._BOROUGH_NAMES). +_BOROUGH_NAMES: dict[str, str] = { + "1": "Manhattan", + "2": "Bronx", + "3": "Brooklyn", + "4": "Queens", + "5": "Staten Island", +} + +# side_of_street letter -> display label (mirrors sensor._SIDE_LABELS). +_SIDE_LABELS: dict[str, str] = { + "N": "North side", + "S": "South side", + "E": "East side", + "W": "West side", +} + +# Lazily-loaded segment geometry cache: str(segment_id) -> geometry_wkt. +_SEGMENTS_PATH = ( + Path(__file__).resolve().parents[1] + / "src" + / "gps2asp" + / "data" + / "index" + / "segments.json" +) +_segments_cache: dict[str, str] | None = None + + +# Hand-picked demo coordinates. Includes the canonical Prospect Pl regression +# case, a point expected to have no ASP restrictions, and one deliberately +# outside coverage to exercise the per-point failure path (Pitfall 4). +DEMO_POINTS: list[dict] = [ + {"key": "prospect_pl", "lat": 40.677629, "lon": -73.968527}, + {"key": "williamsburg", "lat": 40.714606, "lon": -73.961216}, + {"key": "astoria", "lat": 40.761897, "lon": -73.925232}, + {"key": "bronx_grand_concourse", "lat": 40.831258, "lon": -73.926617}, + {"key": "staten_island_no_match", "lat": 40.626511, "lon": -74.077902}, + {"key": "oriental_blvd", "lat": 40.578552, "lon": -73.934903}, + {"key": "outside_coverage", "lat": 40.912000, "lon": -73.700000}, +] + +# Sample car/profile assignments demonstrating results vary by location. +# NOTE: every point above (except outside_coverage, which is deliberately +# outside NYC bounds) was verified to actually resolve against the live index +# + SODA API before being committed here — see 41-02-SUMMARY.md's gap-closure +# note for the probe that replaced the original speculative coordinates, three +# of which (east_village, upper_west_side, central_park_no_restrictions) failed +# outright because they weren't actually close enough to an indexed segment. +DEMO_PROFILES: dict[str, dict] = { + "A": {"label": "Car A", "point_key": "prospect_pl"}, + "B": {"label": "Car B", "point_key": "williamsburg"}, +} + + +def reproject_wkt_to_wgs84(geometry_wkt: str) -> list[list[float]]: + """Reproject an EPSG:2263 LINESTRING WKT to WGS84 ``[[lon, lat], ...]``. + + Args: + geometry_wkt: A ``LINESTRING`` in EPSG:2263 (NY State Plane, US feet). + + Returns: + List of ``[lon, lat]`` coordinate pairs in GeoJSON order (WGS84). + """ + line = wkt.loads(geometry_wkt) + return [list(_TO_WGS84.transform(x, y)) for (x, y) in line.coords] + + +def _borough_name(borocode: str | None) -> str | None: + """Map a CSCL borough code to its human name, or None when unknown.""" + if borocode is None: + return None + return _BOROUGH_NAMES.get(str(borocode)) + + +def _cleaning_day_names(result) -> list[str]: + """Ordered unique cleaning-day names from a ScheduleFound/ASPActiveNow schedule.""" + schedule = result.schedule + if isinstance(schedule, ScheduleFound): + windows = schedule.weekly_schedule.windows + elif isinstance(schedule, ASPActiveNow): + windows = [schedule.active_window] + else: + return [] + seen: list[str] = [] + for window in windows: + name = window.day.name.title() + if name not in seen: + seen.append(name) + return seen + + +def build_sensor_shapes(result) -> dict: + """Build the two mock HA sensor objects (next_move + resolved_street). + + Mirrors ``custom_components/asp_parking/sensor.py`` ``extra_state_attributes`` + but emits only STABLE attributes — no date-relative field (state string, + ``next_window_*``, ``urgency``, ``next_move_is_today``/``_tomorrow``, + ``time_window_*``); those are computed client-side by app.js from the weekly + pattern (Pitfall 1). Keys are added only when their source value exists, so + the emitted key set is always a subset of the real sensor key set. + """ + borough = _borough_name(result.borocode) + side = result.side_of_street + side_label = _SIDE_LABELS.get(side) if side is not None else None + + # --- Next Move Time sensor (primary, user-facing) --- + next_move_attrs: dict = {} + cleaning_days = _cleaning_day_names(result) + if cleaning_days: + next_move_attrs["cleaning_days"] = cleaning_days + if isinstance(result.schedule, (ScheduleFound, ASPActiveNow)): + next_move_attrs["schedule_summary"] = result.schedule.summary + if result.on_street is not None: + next_move_attrs["street_name"] = result.on_street + if result.from_street is not None and result.to_street is not None: + next_move_attrs["cross_streets"] = f"{result.from_street} to {result.to_street}" + if side is not None: + next_move_attrs["side_of_street"] = side + if side_label is not None: + next_move_attrs["side_label"] = side_label + next_move_attrs["confidence_score"] = result.confidence + if borough is not None: + next_move_attrs["borough"] = borough + next_move_attrs["soda_level"] = result.soda_level + + # --- Resolved Street sensor (secondary) --- + resolved_attrs: dict = {} + if result.from_street is not None: + resolved_attrs["from_street"] = result.from_street + if result.to_street is not None: + resolved_attrs["to_street"] = result.to_street + if side is not None: + resolved_attrs["side_of_street"] = side + resolved_attrs["confidence_score"] = result.confidence + if borough is not None: + resolved_attrs["borough"] = borough + if result.perpendicular_distance_ft is not None: + resolved_attrs["distance_ft"] = result.perpendicular_distance_ft + if result.street_width_ft is not None: + resolved_attrs["street_width_ft"] = result.street_width_ft + if result.segment_id is not None: + resolved_attrs["segment_id"] = result.segment_id + if side_label is not None: + resolved_attrs["side_label"] = side_label + + return { + "next_move": { + "entity_id": "sensor.asp_parking_monitor_next_move_time", + "attributes": next_move_attrs, + }, + "resolved_street": { + "entity_id": "sensor.asp_parking_monitor_resolved_street", + "attributes": resolved_attrs, + }, + } + + +def build_point_entry(result, lat: float, lon: float) -> dict: + """Assemble the serialisable demo entry for one resolved coordinate. + + Every field is built explicitly (never the dataclass auto-conversion helper + — it chokes on datetime / IntEnum / shapely LineString, Pitfall 8). ``lat``, + ``lon`` and ``status`` are always present, even on failure. + """ + schedule = result.schedule + if result.resolution_failed: + status = "resolution_failed" + elif schedule is not None: + status = schedule.status + else: + status = "unknown" + + weekly: list[dict] = [] + summary: str | None = None + if isinstance(schedule, ScheduleFound): + summary = schedule.summary + weekly = [ + { + "day": window.day.value, + "start": window.start_time.strftime("%H:%M"), + "end": window.end_time.strftime("%H:%M"), + "sign": window.source_sign, + } + for window in schedule.weekly_schedule.windows + ] + elif isinstance(schedule, ASPActiveNow): + # The car is parked during an active cleaning window right now — there is + # no weekly_schedule (only the single active_window), but app.js's + # hasSchedule() treats "asp_active_now" as a schedule-bearing status, so + # summary/weekly must still be populated or the calendar renders empty. + summary = schedule.summary + window = schedule.active_window + weekly = [ + { + "day": window.day.value, + "start": window.start_time.strftime("%H:%M"), + "end": window.end_time.strftime("%H:%M"), + "sign": "; ".join(window.source_signs), + } + ] + + side = result.side_of_street + return { + "lat": lat, + "lon": lon, + "status": status, + "on_street": result.on_street, + "from_street": result.from_street, + "to_street": result.to_street, + "side_of_street": side, + "side_label": _SIDE_LABELS.get(side) if side is not None else None, + "confidence": result.confidence, + "borocode": result.borocode, + "borough": _borough_name(result.borocode), + "segment_id": result.segment_id, + "soda_level": result.soda_level, + "summary": summary, + "weekly": weekly, + "sensors": build_sensor_shapes(result), + } + + +def _load_segments() -> dict[str, str]: + """Lazily load ``segments.json`` into a ``segment_id -> geometry_wkt`` map.""" + global _segments_cache + if _segments_cache is None: + raw = json.loads(_SEGMENTS_PATH.read_text()) + _segments_cache = { + str(seg_id): rec["geometry_wkt"] + for seg_id, rec in raw.items() + if isinstance(rec, dict) and "geometry_wkt" in rec + } + return _segments_cache + + +def _segment_coords(segment_id) -> list[list[float]] | None: + """Reprojected WGS84 coords for a segment id, or None when unavailable.""" + if segment_id is None: + return None + geometry_wkt = _load_segments().get(str(segment_id)) + if geometry_wkt is None: + return None + return reproject_wkt_to_wgs84(geometry_wkt) + + +async def dump_point(lat: float, lon: float) -> dict: + """Resolve one coordinate, fail-soft per-point. + + Returns a dict ``{"entry": , "coords": }``. + On any infrastructural resolver error the point degrades to a minimal entry + with a status naming the error — the whole run is never aborted (Pitfall 4). + """ + try: + result = await resolve_asp(lat, lon, debug=True) + except ( + OutsideNYCError, + NoSegmentFoundError, + IndexNotFoundError, + SODAAPIError, + IncompleteResultsError, + ) as exc: + print( + f"build_demo_dataset: WARNING point ({lat}, {lon}) failed: " + f"{type(exc).__name__}: {exc}", + file=sys.stderr, + ) + entry = { + "lat": lat, + "lon": lon, + "status": type(exc).__name__, + "error": str(exc), + } + return {"entry": entry, "coords": None} + + entry = build_point_entry(result, lat, lon) + coords = _segment_coords(result.segment_id) + return {"entry": entry, "coords": coords} + + +def _read_points(points_file: Path | None) -> list[dict]: + """Return the point list from a JSON file, or the built-in DEMO_POINTS.""" + if points_file is None: + return DEMO_POINTS + data = json.loads(points_file.read_text()) + if not isinstance(data, list): + raise SystemExit( + f"--points file must be a JSON list, got {type(data).__name__}" + ) + return data + + +async def _run(points: list[dict]) -> dict[str, dict]: + """Resolve every point, returning ``point_key -> {entry, coords}``.""" + results: dict[str, dict] = {} + for point in points: + key = point["key"] + results[key] = await dump_point(point["lat"], point["lon"]) + return results + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Offline demo dataset dumper: resolve_asp -> demo.json + " + "demo-segments.geojson for the static demo page." + ), + ) + parser.add_argument( + "--out-dir", + type=Path, + default=Path("docs/demo/data"), + help="Directory to write demo.json and demo-segments.geojson into.", + ) + parser.add_argument( + "--points", + type=Path, + default=None, + help="Optional JSON file: [{key, lat, lon, profile?}]. Defaults to DEMO_POINTS.", + ) + args = parser.parse_args(argv) + + points = _read_points(args.points) + resolved = asyncio.run(_run(points)) + + # Build-time self-check: every DEMO_PROFILES target must have actually + # resolved to a real, renderable status. Without this check a broken + # profile point (e.g. a coordinate too far from any indexed segment) + # silently ships and only surfaces as a dead "Car B" toggle in the + # browser — this is exactly the class of bug this check exists to catch. + broken_profiles = [] + for profile_key, profile in DEMO_PROFILES.items(): + point_key = profile["point_key"] + entry = resolved.get(point_key, {}).get("entry") + if entry is None: + broken_profiles.append( + (profile_key, point_key, "point_key not in resolved set") + ) + continue + status = entry.get("status") + if status in ("NoSegmentFoundError", "resolution_failed", "unknown"): + broken_profiles.append((profile_key, point_key, f"status={status}")) + if broken_profiles: + details = "; ".join( + f"{pk} ({key}): {reason}" for pk, key, reason in broken_profiles + ) + print( + f"build_demo_dataset: ERROR — {len(broken_profiles)} DEMO_PROFILES " + f"target(s) failed to resolve: {details}", + file=sys.stderr, + ) + print( + " Fix: pick a different coordinate for the affected point_key(s) in " + "DEMO_POINTS and re-run.", + file=sys.stderr, + ) + return 1 + + # Assemble the committed demo.json (weekly patterns only; no absolute dates). + dataset = { + "generation_date": date.today().isoformat(), + "profiles": DEMO_PROFILES, + "points": {key: payload["entry"] for key, payload in resolved.items()}, + } + + # One GeoJSON LineString feature per resolved segment (WGS84). + features: list[dict] = [] + for key, payload in resolved.items(): + coords = payload["coords"] + if not coords: + continue + entry = payload["entry"] + features.append( + { + "type": "Feature", + "geometry": {"type": "LineString", "coordinates": coords}, + "properties": { + "point_key": key, + "segment_id": entry.get("segment_id"), + "on_street": entry.get("on_street"), + }, + } + ) + geojson = {"type": "FeatureCollection", "features": features} + + out_dir: Path = args.out_dir + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "demo.json").write_text(json.dumps(dataset, indent=2) + "\n") + (out_dir / "demo-segments.geojson").write_text(json.dumps(geojson, indent=2) + "\n") + + print( + f"build_demo_dataset: wrote {out_dir / 'demo.json'} " + f"({len(dataset['points'])} points) and " + f"{out_dir / 'demo-segments.geojson'} ({len(features)} segments)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_build_coverage_dataset.py b/tests/test_build_coverage_dataset.py new file mode 100644 index 0000000..08bcdce --- /dev/null +++ b/tests/test_build_coverage_dataset.py @@ -0,0 +1,298 @@ +"""Offline Wave-1 unit tests for scripts/build_coverage_dataset.py. + +The coverage dumper is a presentation-layer snapshot producer for the static +street-sign coverage explorer (docs/explorer/). This module exercises the two +hardest-to-get-right, purely-deterministic pieces of the dumper BEFORE any +SODA/network wiring (42-02) or client rendering (42-04) consumes them: + + 1. The street+side GROUPING KEY (D-01/D-02) — decides dedup correctness. Each + segment's two candidate parking sides are derived from its geometry bearing + (E-W street -> {N,S}; N-S street -> {E,W}), and normalize_to_soda collapses + casing/whitespace/abbreviation variants of the same street to ONE key. + 2. The TIER PARTITION — the single documented half-open boundary rule the whole + UI depends on, shared by R2 (marker color), R3 (popup tier label) and R4 + (tier filter). tier_for_confidence maps a confidence in [0,1] to exactly one + of {high, medium, low, unresolved}; confidence_for_level maps SODA level. + +These tests are pure (no network, no SODAClient, no 39 MB spatial index) so CI's +``-m "not integration"`` selection runs them. +""" + +from __future__ import annotations + +import asyncio +import json + +import scripts.build_coverage_dataset as bcd +from scripts.build_coverage_dataset import ( + build_coverage, + confidence_for_level, + derive_segment_sides, + group_key, + tier_for_confidence, +) + +# Every canonical compact-schema key an entry must carry — and no others (42-01). +_CANONICAL_ENTRY_KEYS = { + "id", + "lat", + "lon", + "st", + "fr", + "to", + "sd", + "bc", + "lv", + "cf", + "status", + "sm", + "wk", +} + +# A real parseable ASP broom sign (verified against the schedule parser) so the +# resolve path can exercise a full ScheduleFound entry (summary + weekly windows). +_PARSEABLE_SIGN = "NO PARKING (SANITATION BROOM SYMBOL) MONDAY THURSDAY 11:30AM-1PM <->" + +# EPSG:2263 (NY State Plane, US survey feet) test geometries. Exact coordinates +# are irrelevant to the bearing; only the run direction matters. +# E-W segment: runs horizontally (delta-y == 0) -> bearing 0 deg -> {N, S} +# N-S segment: runs vertically (delta-x == 0) -> bearing 90 deg -> {E, W} +_EW_WKT = "LINESTRING (980000 200000, 980100 200000)" +_NS_WKT = "LINESTRING (980000 200000, 980000 200100)" + + +def _seg( + street: str, + from_street: str, + to_street: str, + *, + wkt: str = _EW_WKT, + boro: str = "1", +) -> dict: + """Build a minimal in-memory segment record (whole-index build input shape).""" + return { + "full_street_name": street, + "from_street": from_street, + "to_street": to_street, + "borocode": boro, + "geometry_wkt": wkt, + } + + +class _StubClient: + """Records every SODA call so tests can assert the grouped query count. + + Mirrors the two SODAClient methods the build touches: the (sync) query + builder and the (async) fetch. No network, no token. ``records_by_query`` + maps a built query string to canned records; ``default`` is returned for + any unlisted query. + """ + + def __init__( + self, + records_by_query: dict[str, list[dict]] | None = None, + default: list[dict] | None = None, + ) -> None: + self.calls: list[str] = [] + self._records = records_by_query or {} + self._default = default if default is not None else [] + + def build_on_street_query(self, on_street: str, side: str) -> str: + return f"{on_street}|{side}" + + async def fetch_signs(self, query: str) -> list[dict]: + self.calls.append(query) + return self._records.get(query, self._default) + + +async def test_query_count_is_grouped(): + """One SODA fetch per distinct (normalized street, side) group, not per segment.""" + # 6 segments across 2 E-W streets (3 each) -> sides {N,S} per street -> + # 2 streets x 2 sides = 4 distinct groups. A per-segment resolve would issue + # up to 12 (6 segments x 2 sides); grouped resolve issues exactly 4. + segments = { + "1": _seg("BROADWAY", "1 ST", "2 ST"), + "2": _seg("BROADWAY", "2 ST", "3 ST"), + "3": _seg("BROADWAY", "3 ST", "4 ST"), + "4": _seg("5 AVENUE", "10 ST", "11 ST"), + "5": _seg("5 AVENUE", "11 ST", "12 ST"), + "6": _seg("5 AVENUE", "12 ST", "13 ST"), + } + client = _StubClient() # every group returns [] + dataset = await build_coverage(segments, client) + + assert dataset["query_count"] == 4 + assert len(client.calls) == 4 + assert dataset["query_count"] < len(segments) + + +async def test_every_segment_has_entry(): + """Exactly one entry per input segment_id — never an omitted segment.""" + segments = { + "1": _seg("BROADWAY", "1 ST", "2 ST"), + "2": _seg("BROADWAY", "2 ST", "3 ST"), + "3": _seg("5 AVENUE", "10 ST", "11 ST", wkt=_NS_WKT), + "42": _seg("W THAMES ST", "GREENWICH ST", "WASHINGTON ST"), + } + client = _StubClient() + dataset = await build_coverage(segments, client) + + assert len(dataset["segments"]) == len(segments) + assert {entry["id"] for entry in dataset["segments"]} == set(segments.keys()) + + +async def test_zero_record_group_no_match(): + """A group whose broad query returns [] still yields an explicit no-match entry.""" + segments = { + "1": _seg("BROADWAY", "1 ST", "2 ST"), + "2": _seg("BROADWAY", "2 ST", "3 ST"), + } + client = _StubClient() # all groups empty + dataset = await build_coverage(segments, client) + + assert len(dataset["segments"]) == 2 + for entry in dataset["segments"]: + assert entry["status"] == "no_match" + assert entry["lv"] == 0 + assert entry["cf"] == 0.0 + + +async def test_no_token_in_output(monkeypatch): + """The serialized dataset never carries the SODA token or its env-var name.""" + monkeypatch.setenv("NYC_OPEN_DATA_APP_TOKEN", "secrettok_ABC123XYZ") + segments = {"1": _seg("BROADWAY", "1 ST", "2 ST")} + # A matching broom record so the entry resolves to a full schedule_found + # (summary + weekly), exercising the richest serialization path. + record = { + "sign_description": _PARSEABLE_SIGN, + "from_street": "1 ST", + "to_street": "2 ST", + } + client = _StubClient(default=[record]) + dataset = await build_coverage(segments, client) + + serialized = json.dumps(dataset) + assert "NYC_OPEN_DATA_APP_TOKEN" not in serialized + assert "secrettok_ABC123XYZ" not in serialized + for entry in dataset["segments"]: + assert "token" not in entry + assert "app_token" not in entry + + # The matching record produced a real schedule_found entry whose weekly + # pattern stores day/start/end ONLY — no raw sign text field (D-04). + entry = dataset["segments"][0] + assert set(entry.keys()) == _CANONICAL_ENTRY_KEYS + assert entry["status"] == "schedule_found" + assert entry["lv"] == 1 + assert entry["wk"], "expected a weekly pattern" + for window in entry["wk"]: + assert set(window.keys()) == {"d", "s", "e"} + + +def test_main_writes_canonical_coverage_json(tmp_path, monkeypatch): + """main() writes coverage.json with the canonical top-level + entry schema.""" + seg_path = tmp_path / "segments.json" + seg_path.write_text( + json.dumps( + { + "1": _seg("BROADWAY", "1 ST", "2 ST"), + "2": _seg("BROADWAY", "2 ST", "3 ST"), + } + ) + ) + out_dir = tmp_path / "out" + # Patch the client so main() issues NO network calls (offline test). + monkeypatch.setattr(bcd, "SODAClient", lambda *a, **k: _StubClient()) + + try: + rc = bcd.main(["--segments", str(seg_path), "--out-dir", str(out_dir)]) + finally: + # main() runs asyncio.run(), which closes the loop and clears the + # current-loop slot; restore one so pytest-asyncio (auto mode) can still + # manage later sync tests on Python 3.13 (get_event_loop no longer + # auto-creates a loop). + asyncio.set_event_loop(asyncio.new_event_loop()) + assert rc == 0 + + data = json.loads((out_dir / "coverage.json").read_text()) + assert set(data) == { + "generation_date", + "boroughs", + "query_count", + "segment_count", + "segments", + } + assert data["segment_count"] == 2 + assert len(data["segments"]) == 2 + # query_count is the distinct-group count, strictly below the segment count. + assert data["query_count"] < data["segment_count"] * 2 + for entry in data["segments"]: + assert set(entry.keys()) == _CANONICAL_ENTRY_KEYS + + +def test_grouping_key_and_side_derivation(): + """Sides come from geometry bearing; group_key canonicalizes the street.""" + # --- side derivation from bearing (D-02): NEVER from has_asp_left/right --- + assert derive_segment_sides(_EW_WKT) == ("N", "S") + assert derive_segment_sides(_NS_WKT) == ("E", "W") + + # --- normalize_to_soda collapses casing/whitespace/abbreviation variants --- + # BROADWAY / Broadway / broadway all canonicalize to one grouping key. + key_upper = group_key("BROADWAY", "N") + key_title = group_key("Broadway", "N") + key_lower = group_key("broadway", "N") + assert key_upper == key_title == key_lower + + # Collapsed internal whitespace ("W THAMES ST") maps to the same key as its + # single-spaced form ("W THAMES ST"). + assert group_key("W THAMES ST", "N") == group_key("W THAMES ST", "N") + + # --- property: no double-count, no drop across the group boundary --- + # Two distinct segments on the same normalized street+side must produce an + # IDENTICAL group_key (so they collapse into one group), and the two sides of + # one street must produce DIFFERENT keys (so neither side is dropped). + seg_a_side = group_key("BROADWAY", "N") + seg_b_side = group_key("broadway", "N") + assert seg_a_side == seg_b_side # same street+side -> one group (no double-count) + + north_key = group_key("BROADWAY", "N") + south_key = group_key("BROADWAY", "S") + assert north_key != south_key # both sides recoverable (no drop) + + # The key exposes the canonical street and side for downstream recovery. + assert north_key[1] == "N" + assert south_key[1] == "S" + assert north_key[0] == south_key[0] # same canonical street on both sides + + +def test_tier_boundary_partition(): + """One documented half-open rule partitions [0,1] into four named tiers.""" + tiers = {"high", "medium", "low", "unresolved"} + + # Fine grid over [0,1] plus the exact boundary values: every value maps to + # exactly one of the four named tiers. + grid = [i / 100 for i in range(0, 101)] + [0.33, 0.50, 0.75] + for v in grid: + tier = tier_for_confidence(v) + assert tier in tiers, f"{v!r} produced non-tier {tier!r}" + + # The four named boundary landings (the single half-open rule): + # [0.00, 0.33) unresolved | [0.33, 0.50) low | [0.50, 0.75) medium | + # [0.75, 1.00] high (top tier inclusive of 1.0) + assert tier_for_confidence(0.0) == "unresolved" + assert tier_for_confidence(0.33) == "low" # boundary lands in low, NOT unresolved + assert tier_for_confidence(0.50) == "medium" + assert tier_for_confidence(0.75) == "high" + assert tier_for_confidence(1.0) == "high" + + # --- confidence_for_level maps SODA level deterministically (D-18) --- + assert confidence_for_level(1) == 0.90 + assert confidence_for_level(2) == 0.66 + assert confidence_for_level(3) == 0.40 + assert confidence_for_level(0) == 0.00 + + # ...and each level's confidence lands in the expected tier. + assert tier_for_confidence(confidence_for_level(1)) == "high" + assert tier_for_confidence(confidence_for_level(2)) == "medium" + assert tier_for_confidence(confidence_for_level(3)) == "low" + assert tier_for_confidence(confidence_for_level(0)) == "unresolved" diff --git a/tests/test_build_demo_dataset.py b/tests/test_build_demo_dataset.py new file mode 100644 index 0000000..c3929f8 --- /dev/null +++ b/tests/test_build_demo_dataset.py @@ -0,0 +1,311 @@ +"""Offline Wave-0 unit tests for scripts/build_demo_dataset.py. + +The demo dumper is a presentation-layer snapshot producer: it calls the +existing ``resolve_asp(debug=True)`` entrypoint and serialises the result into +a small JSON shape the static demo page consumes. These tests exercise the +*pure* functions of the dumper (reprojection + dict assembly) WITHOUT any +network or the 39 MB spatial index — resolver results are fabricated in-test +using the real frozen dataclasses. + +Guardrails proven here (plan 41-01): + - test_reprojection_bounds: geometry reprojects EPSG:2263 -> WGS84 in [lon, lat] + order, inside the NYC bbox (Pitfall 5 / feet-vs-degrees). + - test_no_absolute_nextmove: the entry stores a WEEKLY pattern, never an + absolute next-move datetime (Pitfall 1 / date decay). + - test_sensor_shape: mock-sensor attribute keys are a subset of the real + ASPNextMoveTimeSensor / ASPResolvedStreetSensor key sets (no invented keys). + - test_dataset_completeness_and_status: lat/lon/status always present and the + status literal is correct across schedule_found / resolution_failed / no_match. + +This module is intentionally offline (no ``@pytest.mark.integration``) so CI's +``-m "not integration"`` selection runs it. +""" + +from __future__ import annotations + +import importlib.util +import re +from datetime import datetime, time +from pathlib import Path + +from gps2asp.api_models import ASPDebugResult +from gps2asp.schedule.models import ( + ASPActiveNow, + ASPDay, + CleaningWindow, + NoMatchSchedule, + ScheduleFound, + TimeWindow, + WeeklySchedule, +) + +_MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "build_demo_dataset.py" + + +def _load_dumper(): + spec = importlib.util.spec_from_file_location("build_demo_dataset", _MODULE_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# Loaded at import time — collection FAILS (RED) until the script exists. +dumper = _load_dumper() + + +# The canonical real HA sensor attribute key sets, transcribed from +# custom_components/asp_parking/sensor.py (source of truth). The demo mock may +# emit a SUBSET of these; it must never invent a key outside them. +_ALLOWED_NEXT_MOVE_KEYS = { + "next_move_is_today", + "next_move_is_tomorrow", + "cleaning_days", + "time_window_start", + "time_window_end", + "schedule_summary", + "urgency", + "street_name", + "cross_streets", + "side_of_street", + "side_label", + "confidence_score", + "borough", + "soda_level", +} +_ALLOWED_RESOLVED_STREET_KEYS = { + "from_street", + "to_street", + "side_of_street", + "confidence_score", + "borough", + "distance_ft", + "street_width_ft", + "segment_id", + "side_label", +} + +# ISO-8601 datetime detector (date + 'T' + time). Used to prove the entry never +# carries an absolute next-move timestamp anywhere in its values. +_ISO_DATETIME_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}") + + +def _make_weekly() -> WeeklySchedule: + return WeeklySchedule( + windows=( + TimeWindow( + day=ASPDay.TUESDAY, + start_time=time(11, 30), + end_time=time(13, 0), + source_sign="NO PARKING TUE 11:30AM-1PM STREET CLEANING", + ), + TimeWindow( + day=ASPDay.FRIDAY, + start_time=time(11, 30), + end_time=time(13, 0), + source_sign="NO PARKING FRI 11:30AM-1PM STREET CLEANING", + ), + ) + ) + + +def _make_schedule_found() -> ScheduleFound: + return ScheduleFound( + status="schedule_found", + next_window=None, # deliberately no absolute datetime + weekly_schedule=_make_weekly(), + on_street="PROSPECT PL", + from_street="VANDERBILT AVE", + to_street="CARLTON AVE", + side_of_street="N", + source_signs=["NO PARKING TUE 11:30AM-1PM STREET CLEANING"], + summary="TUE & FRI 11:30 AM - 1:00 PM", + parse_failures=[], + ) + + +def _make_asp_active_now() -> ASPActiveNow: + return ASPActiveNow( + status="asp_active_now", + active_window=CleaningWindow( + day=ASPDay.THURSDAY, + start_time=time(11, 0), + end_time=time(14, 0), + start_datetime=datetime(2026, 7, 30, 11, 0), + end_datetime=datetime(2026, 7, 30, 14, 0), + source_signs=["NO PARKING THU 11AM-2PM STREET CLEANING"], + ), + on_street="ORIENTAL BLVD", + from_street="", + to_street="DECATUR AVE", + side_of_street="N", + source_signs=["NO PARKING THU 11AM-2PM STREET CLEANING"], + summary="MON & THU 11 AM - 2 PM", + ) + + +def _make_debug_result(schedule) -> ASPDebugResult: + """Fabricate a resolved ASPDebugResult (resolution/sign_result unused by dumper).""" + return ASPDebugResult( + schedule=schedule, + resolution_failed=False, + resolution_error=None, + on_street="PROSPECT PL", + from_street="VANDERBILT AVE", + to_street="CARLTON AVE", + side_of_street="N", + resolution=None, + sign_result=None, + confidence=0.87, + state_plane_x=992700.0, + state_plane_y=186200.0, + soda_level=1, + borocode="3", + perpendicular_distance_ft=12.5, + street_width_ft=34.0, + segment_id=123456, + ) + + +def _iter_str_values(obj): + """Yield every string scalar reachable in a nested dict/list structure.""" + if isinstance(obj, str): + yield obj + elif isinstance(obj, dict): + for value in obj.values(): + yield from _iter_str_values(value) + elif isinstance(obj, (list, tuple)): + for value in obj: + yield from _iter_str_values(value) + + +# --- test_reprojection_bounds ----------------------------------------------- + + +def test_reprojection_bounds(): + coords = dumper.reproject_wkt_to_wgs84( + "LINESTRING (979278.28 196558.53, 979500 196700)" + ) + assert isinstance(coords, list) and len(coords) == 2 + for pair in coords: + assert isinstance(pair, list) and len(pair) == 2 + lon, lat = pair + # [lon, lat] order (GeoJSON), inside the NYC bbox. + assert -74.3 <= lon <= -73.6, f"lon {lon} outside NYC bbox (wrong axis order?)" + assert 40.4 <= lat <= 41.0, f"lat {lat} outside NYC bbox (wrong axis order?)" + + +# --- test_no_absolute_nextmove ---------------------------------------------- + + +def test_no_absolute_nextmove(): + entry = dumper.build_point_entry( + _make_debug_result(_make_schedule_found()), 40.677629, -73.968527 + ) + + # Weekly pattern present and shaped correctly. + assert isinstance(entry["weekly"], list) and entry["weekly"] + for window in entry["weekly"]: + assert isinstance(window["day"], int) + assert re.fullmatch(r"\d{2}:\d{2}", window["start"]) + assert re.fullmatch(r"\d{2}:\d{2}", window["end"]) + assert isinstance(window["sign"], str) + + # No absolute next-move keys anywhere (top-level or nested sensor attrs). + def _assert_no_key(node): + if isinstance(node, dict): + for banned in ("next_window_start", "next_window_end", "next_window_day"): + assert banned not in node, f"absolute next-move key leaked: {banned}" + for value in node.values(): + _assert_no_key(value) + elif isinstance(node, (list, tuple)): + for value in node: + _assert_no_key(value) + + _assert_no_key(entry) + + # No ISO-8601 datetime value appears anywhere in the entry. + for value in _iter_str_values(entry): + assert not _ISO_DATETIME_RE.search(value), ( + f"absolute ISO datetime leaked into entry: {value!r}" + ) + + +# --- test_sensor_shape ------------------------------------------------------ + + +def test_sensor_shape(): + entry = dumper.build_point_entry( + _make_debug_result(_make_schedule_found()), 40.677629, -73.968527 + ) + + next_move_attrs = entry["sensors"]["next_move"]["attributes"] + resolved_attrs = entry["sensors"]["resolved_street"]["attributes"] + + assert set(next_move_attrs).issubset(_ALLOWED_NEXT_MOVE_KEYS), ( + f"invented next_move keys: {set(next_move_attrs) - _ALLOWED_NEXT_MOVE_KEYS}" + ) + assert set(resolved_attrs).issubset(_ALLOWED_RESOLVED_STREET_KEYS), ( + f"invented resolved_street keys: " + f"{set(resolved_attrs) - _ALLOWED_RESOLVED_STREET_KEYS}" + ) + # Entities must be shaped as HA entities (entity_id present). + assert entry["sensors"]["next_move"]["entity_id"].startswith("sensor.") + assert entry["sensors"]["resolved_street"]["entity_id"].startswith("sensor.") + + +# --- test_dataset_completeness_and_status ----------------------------------- + + +def test_dataset_completeness_and_status(): + found = dumper.build_point_entry( + _make_debug_result(_make_schedule_found()), 40.677629, -73.968527 + ) + failed = dumper.build_point_entry( + ASPDebugResult.from_error("ambiguous", 992700.0, 186200.0), + 40.5, + -74.0, + ) + no_match = dumper.build_point_entry( + _make_debug_result(NoMatchSchedule()), 40.71, -73.99 + ) + + assert found["status"] == "schedule_found" + assert failed["status"] == "resolution_failed" + assert no_match["status"] == "no_match" + + for entry in (found, failed, no_match): + assert "lat" in entry + assert "lon" in entry + assert "status" in entry + + +# --- test_asp_active_now_populates_weekly ------------------------------------ + + +def test_asp_active_now_populates_weekly(): + """Regression test: ASPActiveNow must populate weekly/summary like ScheduleFound. + + app.js's hasSchedule() treats 'asp_active_now' as a schedule-bearing status + and renders the calendar from `weekly` — an empty weekly array here would + have shipped a broken calendar for any point currently mid-cleaning-window + (found via code review on the committed dataset, see 41-02-SUMMARY.md). + """ + entry = dumper.build_point_entry( + _make_debug_result(_make_asp_active_now()), 40.578552, -73.934903 + ) + + assert entry["status"] == "asp_active_now" + assert entry["summary"] == "MON & THU 11 AM - 2 PM" + assert isinstance(entry["weekly"], list) and entry["weekly"] + window = entry["weekly"][0] + assert window["day"] == ASPDay.THURSDAY.value + assert window["start"] == "11:00" + assert window["end"] == "14:00" + assert "NO PARKING" in window["sign"] + + # build_sensor_shapes()/_cleaning_day_names() had the identical ScheduleFound- + # only gap — cleaning_days/schedule_summary must also populate for asp_active_now. + next_move_attrs = entry["sensors"]["next_move"]["attributes"] + assert next_move_attrs["cleaning_days"] == ["Thursday"] + assert next_move_attrs["schedule_summary"] == "MON & THU 11 AM - 2 PM" diff --git a/tests/test_caldav_sync.py b/tests/test_caldav_sync.py index 1561b80..257beaf 100644 --- a/tests/test_caldav_sync.py +++ b/tests/test_caldav_sync.py @@ -2072,3 +2072,138 @@ async def test_write_or_update_event_include_location_false_omits_all_location_p assert "GEO:" not in unfolded assert "LOCATION:" not in unfolded assert "X-APPLE" not in unfolded + + +# --------------------------------------------------------------------------- +# _get_calendar — iCloud cross-host sharding fallback +# +# Reproduces the production bug: iCloud's login entry point +# (caldav.icloud.com) differs from the per-account host that actually +# hosts the calendar's data (e.g. p117-caldav.icloud.com). caldav 2.x's +# Principal.calendar(cal_url=...) does a purely local URL.join() that +# raises ValueError whenever the client's base host and cal_url's host +# differ (caldav/lib/url.py). _get_calendar must fall back to scanning +# principal.calendars() (a real network call that follows redirects) and +# matching by URL path. +# --------------------------------------------------------------------------- + + +async def test_get_calendar_falls_back_to_calendars_on_cross_host_valueerror(): + cs = _require_caldav_sync() + + calendar_url = ( + "https://p117-caldav.icloud.com:443/278773852/calendars/" + "3ca30c3e0ab029c8487d164bc55c62681c2ba700045366956d8e28dc8f826ccf/" + ) + matching_cal = SimpleNamespace(url=calendar_url) + other_cal = SimpleNamespace( + url="https://p117-caldav.icloud.com:443/278773852/calendars/other/" + ) + + def _raise_cross_host(cal_url: str) -> None: + raise ValueError(f"https://caldav.icloud.com/ can't be joined with {cal_url}") + + principal = SimpleNamespace( + calendar=MagicMock(side_effect=_raise_cross_host), + calendars=AsyncMock(return_value=[other_cal, matching_cal]), + ) + client = SimpleNamespace(get_principal=AsyncMock(return_value=principal)) + + result = await cs._get_calendar(client, calendar_url) + + assert result is matching_cal + principal.calendars.assert_awaited_once() + + +async def test_get_calendar_cross_host_valueerror_no_match_raises_write_error(): + cs = _require_caldav_sync() + + calendar_url = "https://p117-caldav.icloud.com:443/278773852/calendars/missing/" + other_cal = SimpleNamespace( + url="https://p117-caldav.icloud.com:443/278773852/calendars/other/" + ) + + def _raise_cross_host(cal_url: str) -> None: + raise ValueError(f"https://caldav.icloud.com/ can't be joined with {cal_url}") + + principal = SimpleNamespace( + calendar=MagicMock(side_effect=_raise_cross_host), + calendars=AsyncMock(return_value=[other_cal]), + ) + client = SimpleNamespace(get_principal=AsyncMock(return_value=principal)) + + with pytest.raises(cs.CalDAVWriteError): + await cs._get_calendar(client, calendar_url) + + +async def test_get_calendar_same_host_uses_cheap_local_join_no_network_call(): + """Regression guard: same-host servers (Radicale/Nextcloud/Baikal) must NOT + pay the extra principal.calendars() network round-trip — the local join + still works fine when the hosts match.""" + cs = _require_caldav_sync() + + cal = SimpleNamespace(url="https://srv/cal/work/") + principal = SimpleNamespace( + calendar=MagicMock(return_value=cal), + calendars=AsyncMock( + side_effect=AssertionError("must not be called on the fast path") + ), + ) + client = SimpleNamespace(get_principal=AsyncMock(return_value=principal)) + + result = await cs._get_calendar(client, "https://srv/cal/work/") + + assert result is cal + principal.calendars.assert_not_awaited() + + +async def test_get_calendar_unrelated_valueerror_propagates_without_fallback(): + """Only the cross-host URL.join failure should trigger the network + fallback — any other ValueError (e.g. a None client inside the caldav + library) is a genuine, unrelated bug and must propagate unchanged + rather than being misreported as "no calendar found".""" + cs = _require_caldav_sync() + + def _raise_unrelated(cal_url: str) -> None: + raise ValueError("Unexpected value None for self.client") + + principal = SimpleNamespace( + calendar=MagicMock(side_effect=_raise_unrelated), + calendars=AsyncMock( + side_effect=AssertionError("must not be called for unrelated ValueError") + ), + ) + client = SimpleNamespace(get_principal=AsyncMock(return_value=principal)) + + with pytest.raises(ValueError, match="Unexpected value None for self.client"): + await cs._get_calendar(client, "https://srv/cal/work/") + + principal.calendars.assert_not_awaited() + + +async def test_get_calendar_cross_host_valueerror_no_match_chains_original_exception(): + """The raised CalDAVWriteError must chain the original ValueError (not + `from None`) so the real cause survives in the traceback for debugging.""" + cs = _require_caldav_sync() + + calendar_url = "https://p117-caldav.icloud.com:443/278773852/calendars/missing/" + other_cal = SimpleNamespace( + url="https://p117-caldav.icloud.com:443/278773852/calendars/other/" + ) + original_exc = ValueError( + "https://caldav.icloud.com/ can't be joined with " + calendar_url + ) + + def _raise_cross_host(cal_url: str) -> None: + raise original_exc + + principal = SimpleNamespace( + calendar=MagicMock(side_effect=_raise_cross_host), + calendars=AsyncMock(return_value=[other_cal]), + ) + client = SimpleNamespace(get_principal=AsyncMock(return_value=principal)) + + with pytest.raises(cs.CalDAVWriteError) as excinfo: + await cs._get_calendar(client, calendar_url) + + assert excinfo.value.__cause__ is original_exc