Skip to content

Commit be0a316

Browse files
committed
Fix snapshot tests
1 parent c6962bd commit be0a316

6 files changed

Lines changed: 115 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- **`src/main.mjs`** — ES module entry; loads **`src/bootstrap.mjs`**.
66
- **`src/bootstrap.mjs`** — Loads data, hash routing for `#/visit`, `#/data`, `#/modes`, and legacy `#/parking` / `#/planner``#/visit` redirects; owns the modes page and data explorer UI.
7-
- **`src/visit/visit.mjs`** — **Parking-for-events** main app at `#/visit`: the goal is to help users **find parking when attending events** at downtown venues—pick a **venue**, see **DASH** routes/stops and nearby garages/lots, and compare costs with **event** pricing preferred in popups when the data includes it (see Parking data → **`pricing`** order). Leaflet map with **DASH** shuttle polylines/stops from `appData.busRoutes`, plus parking pins from `appData.parking` **garages and lots only** (public garages/lots, OSM private garages/lots, and Ellis `ellisGarages` / `ellisLots`—no meters, bike racks, or micromobility). Pins are limited to within **0.75 mi** (Haversine) of a DASH stop on the map. With **no** venue selected (bare **`#/visit`** with no slug), map **`fitBounds`** frames **all listed destinations** plus the **DASH** route geometry (stops + polyline vertices)—not parking pins—so the shuttle loop stays on-screen without zooming out to the full parking set. With **`#/visit/<destination-slug>`** (or legacy **`finish=`** / **`venue=`** / **`destination=`** / **`dest=`** in the query) and **no** **`park=`**, **`fitBounds`** uses **visible parking pins and the selected venue** (candidate picks plus the red finish pin). With **`park=`** set, **`fitBounds`** frames **that parking pick, the venue, and — when the trip uses DASH — the full shuttle leg (board → alight along the loop)** so every trip step stays on-screen. Category toggles and `location=` use ids **`public-garage`**, **`public-lot`**, **`private-garage`**, **`private-lot`** only (mapped to `garages` / `lots` / `osmGarages` / `osmLots` in JSON); Ellis **`ellisGarages` / `ellisLots`** pins appear when the matching private toggle is on and use **`ellis-garage` / `ellis-lot`** in **`park=`** URLs. Legacy **`location=ellis-garage`** / **`ellis-lot`** or **`cats=ellisGarages`** / **`ellisLots`** map onto **`private-garage` / `private-lot`**; legacy `location=garages` etc. and old `cats=` still parse. Legacy **`#/parking?…`** URLs are rewritten to **`#/visit[/slug]?…`** on load (and **`start=`** → **`park=`**). Optional **`maxEvening=<dollars>`** caps evening parking cost: hides pins whose inferred evening rate (pricing **`evening`**, else public ramp/lot **`events`** fallback) parses to a dollar amount above the cap. With **`maxEvening`** omitted, the default cap is **$40** (slider **40**; param omitted for a short **`#/visit`** link). **`0`–`45`** in **`$5`** steps are spelled in the URL when not **40**; **`maxEvening=50`** (or snapped **≥50**, legacy **≥100**) means **any price** (no cap). Pins with **no** pricing tier fields (`evening`, `events`, `hourly`, `rate`, `daily`) are hidden while any finite **`pay`** cap is in effect; prose tiers without `$` amounts but recognized as free evenings/weekends count as **free**; other prose without dollars (still “some data”) stays visible unless **`pay`** is **free-only** (`0`). Optional **`maxWalk=<miles>`** — when a **venue** is selected (path slug or legacy query keys), hides pins whose grid-walk distance (N–S + E–W miles at mid-latitude, not a diagonal shortcut) to the **nearest DASH stop** exceeds that cap; **`maxWalk=0`** (or **`0.0`**) is treated internally as **~100 ft** for that filter (slider **No distance** — walk overlays and auto **pick** stay off); **`maxWalk` omitted** means **0.8 mi** (default; omitted from **`#/visit`** for short links); other **`0.1`**–**`1.5`** in **0.1** steps spell in the URL; minute hints use **`data/config.json`** **`parkingRoutePace.walkMinutesPerMile`** (about **2.5 mph** by default).
7+
- **`src/visit/visit.mjs`** — **Parking-for-events** main app at `#/visit`: the goal is to help users **find parking when attending events** at downtown venues—pick a **venue**, see **DASH** routes/stops and nearby garages/lots, and compare costs with **event** pricing preferred in popups when the data includes it (see Parking data → **`pricing`** order). Leaflet map with **DASH** shuttle polylines/stops from `appData.busRoutes`, plus parking pins from `appData.parking` **garages and lots only** (public garages/lots, OSM private garages/lots, and Ellis `ellisGarages` / `ellisLots`—no meters, bike racks, or micromobility). Pins are limited to within **0.75 mi** (Haversine) of a DASH stop on the map. With **no** venue selected (bare **`#/visit`** with no slug), map **`fitBounds`** frames **all listed destinations** plus the **DASH** route geometry (stops + polyline vertices)—not parking pins—so the shuttle loop stays on-screen without zooming out to the full parking set. With **`#/visit/<destination-slug>`** (or legacy **`finish=`** / **`venue=`** / **`destination=`** / **`dest=`** in the query) and **no** **`park=`**, **`fitBounds`** uses **visible parking pins and the selected venue** (candidate picks plus the red finish pin). With **`park=`** set, **`fitBounds`** frames **that parking pick, the venue, and — when the trip uses DASH — the full shuttle leg (board → alight along the loop)** so every trip step stays on-screen. Category toggles and `location=` use ids **`public-garage`**, **`public-lot`**, **`private-garage`**, **`private-lot`** only (mapped to `garages` / `lots` / `osmGarages` / `osmLots` in JSON); Ellis **`ellisGarages` / `ellisLots`** pins appear when the matching private toggle is on and use **`ellis-garage` / `ellis-lot`** in **`park=`** URLs. Legacy **`location=ellis-garage`** / **`ellis-lot`** or **`cats=ellisGarages`** / **`ellisLots`** map onto **`private-garage` / `private-lot`**; legacy `location=garages` etc. and old `cats=` still parse. Legacy **`#/parking?…`** URLs are rewritten to **`#/visit[/slug]?…`** on load (and **`start=`** → **`park=`**). Optional **`maxEvening=<dollars>`** caps evening parking cost: hides pins whose inferred evening rate (pricing **`evening`**, else public ramp/lot **`events`** fallback) parses to a dollar amount above the cap. With **`maxEvening`** omitted, the default cap is **$40** (slider **40**; param omitted for a short **`#/visit`** link). **`0`–`45`** in **`$5`** steps are spelled in the URL when not **40**; **`maxEvening=50`** (or snapped **≥50**, legacy **≥100**) means **any price** (no cap). Pins with **no** pricing tier fields (`evening`, `events`, `hourly`, `rate`, `daily`) are hidden while any finite **`pay`** cap is in effect; prose tiers without `$` amounts but recognized as free evenings/weekends count as **free**; other prose without dollars (still “some data”) stays visible unless **`pay`** is **free-only** (`0`). Optional **`maxWalk=<miles>`** — when a **venue** is selected (path slug or legacy query keys), hides pins whose grid-walk distance (N–S + E–W miles at mid-latitude, not a diagonal shortcut) to the **nearest DASH stop** exceeds that cap; **`maxWalk=0`** (or **`0.0`**) is treated internally as **~100 ft** for that filter (slider **No distance** — walk overlays and auto **pick** stay off); **`maxWalk` omitted** means **0.8 mi** (default; omitted from **`#/visit`** for short links); other **`0.1`**–**`1.5`** in **0.1** steps spell in the URL; **`walk=2`** (or snapped **`≥2`**, same for legacy **`maxWalk`**) means **any distance** (no cap; slider **Any distance**); minute hints use **`data/config.json`** **`parkingRoutePace.walkMinutesPerMile`** (about **2.5 mph** by default).
88

99
Each parking pin opens a **popup** with **Plan to park here** / **Clear parking selection**: choosing a spot sets **`park=<category>:<lat>,<lng>`** (6 dp; legacy tilde form still parses; legacy **`start=`**, **`spot=`**) and the **green** pin; clearing removes **`park=`** for that spot; the venue uses a **red** pin; reset clears **`park=`**. **`park=`** is added to the URL **only** when the user taps **Plan to park here** (or opens a shared link that already includes **`park=`** / legacy **`start=`** / **`spot=`**). Sliders, destination, and category filters **do not** put **`park=`** in the hash; without it, the map still surfaces up to **3** muted-green recommendation pins on load and when overlays refresh using the same ranking rules — each glyph reflects the pin's role: a **★ star** marks the {@link compareParkingMarkersForRecommendation} **best** pick (also drives the walk overlay and route panel), a **walking-person** glyph marks the pin that puts the **most total foot-miles** on the user (walk-to-DASH-stop **+** walk-from-alight-to-venue for multimodal trips, otherwise the door-to-door grid walk — pins whose endpoints sit on DASH stops have small total walks even when their grid distance from the venue is large) within the active **`pay`** + **`walk`** filters, and a **`$`** sign marks the **most expensive** pin **by the displayed popup price** within those same filters. The dollar-sign rank deliberately uses the price the user sees in the popup (e.g. **`events: "$8–9"`** for GR public spots) rather than the underlying **`evening`** ceiling that drives the **`pay`** cap (often **`$51`** posted overnight max for the same spots) so the **`$`** pin matches the priciest line on screen. Each role picks its top candidate from the pool **excluding pins already taken by an earlier role** (priority **best** > **farthest** > **expensive**), so when one pin happens to win two ranks (e.g. GLC Live's **Area 8 Lot** is both the recommendation winner _and_ the farthest grid-walk in the pool) the **farthest** glyph falls through to the **next-farthest** pin instead of collapsing — each role only goes empty when the pool has no remaining pin for it. Tapping any suggestion commits **`park=`** to the URL like before. **How pins rank** (when **venue** is set and DASH stops exist); for the **auto-recommended** green pick (slot **#1**), **`public-garage` / `public-lot`** sort before **`private-garage` / `private-lot` / `ellis-garage` / `ellis-lot`** (city inventory before third-party sources), then distance / DASH / price as below: pins inferred as **free** ($0 evening/event ceiling) are excluded from the automatic **pick** pool whenever **any other** eligible visible pin has a **paid** (> $0) ceiling (so default **`#/visit`** favors farther paid lots); when **every** pin that passes **`pay`** is free (e.g. a tight **`pay`** cap), free pins remain in the pool. If max walk to the nearest DASH stop is **at most 0.5 mi**, prefer pins whose estimated trip **uses DASH** (multimodal overlay beats straight-line walk) over door-to-door-only pins; among multimodal picks use **farther** grid-walk miles from the venue first (then walk-to-stop / paid-tier / dollars like generous walk). Among **only** door-to-door pins, use **closest** to the venue first, then highest inferred evening/event dollars, then longest walk to DASH. If max walk is **over 0.5 mi**, use **distance before cost**—**farthest** grid-walk miles from the **venue** among pins within the walk-to-DASH cap (farther paid lots), **then** among ties **longest** walk to the nearest DASH stop, **then** pins with a **known paid** rate over free-evening or unknown/ambiguous pricing, **then** higher inferred dollars among ties. When **`pay`** is **any price** (max slider / no cap), the recommendation **never** uses a pin whose cost is **unknown** or **ambiguous-only** if **any** visible pin has a **parseable dollar** ceiling; only when **no** pin has known dollars does it fall back to unknown vs ambiguous. With **short-walk** rules and **any price**, door-to-door-only ties rank by highest inferred dollars before longest walk to DASH. With a **finite** **`pay`** cap under short-walk rules, known dollars rank above unknown or ambiguous before distance within the door-to-door pool. If there are no DASH stops, longest walk to the **venue** substitutes for walk-to-stop in tie-breaks under cost-first rules. When the **`walk`** slider is at index **0** (`walk=0`), pins farther than **~100 ft** from the nearest DASH stop are hidden (**`park=`** is still omitted — no green parking pick). Pins farther than the max-walk cap are hidden when **venue** is set and DASH data exists (**including** **`walk=0`**). Overlap paint order is **`PARKING_CATEGORY_PAINT_ORDER`** (public garage / purple above Ellis and private garage / orange; Ellis lots stack with private lots / yellow).
1010

src/visit/visit.mjs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,17 @@ function getParkingSpotIdForHash() {
10101010
return getParkingCommittedStartSpotIdForHashWrite(undefined);
10111011
}
10121012

1013+
/**
1014+
* Green pick marker: visible committed id, else syntactically valid `park=` when **`walk` ≠ 0** so a
1015+
* shared link still shows one saturated pin (not muted suggestions) even if filters hide the circle.
1016+
*/
1017+
function getParkingCommittedSpotIdForPickMarker() {
1018+
const visible = getParkingSpotIdForHash();
1019+
if (visible) return visible;
1020+
if (getParkingMaxWalkSliderValueForHash() === 0) return undefined;
1021+
return normalizeParkingSpotIdFromHashRaw();
1022+
}
1023+
10131024
/**
10141025
* Both a **destination** (path or `finish=` / legacy venue keys) and committed **`park=`** / legacy **`start=`** / **`spot=`** are in the URL —
10151026
* trip step digits (**1**–**4**) appear on map pins; otherwise badges stay blank.
@@ -3042,7 +3053,7 @@ function syncParkingSpotPickMarker(map) {
30423053
parkingSpotPickLayerGroup = null;
30433054
}
30443055

3045-
const committed = getParkingSpotIdForHash();
3056+
const committed = getParkingCommittedSpotIdForPickMarker();
30463057
if (typeof committed === "string" && committed.length > 0) {
30473058
const p = parseParkingSpotIdToken(committed);
30483059
if (!p) return;
-93.5 KB
Loading

tests/snapshots/phone-3-start.png

-23.8 KB
Loading

tests/snapshots/tablet-3-start.png

-54.4 KB
Loading

tests/visit.spec.js

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2675,6 +2675,95 @@ test.describe("Parking map (#/visit)", () => {
26752675
*/
26762676
const DEFAULT_APP_PAGE = "/";
26772677

2678+
async function closeParkingMapPopups(page) {
2679+
await page.evaluate(() => {
2680+
const map = globalThis.__parkingMapForTest;
2681+
if (map && typeof map.closePopup === "function") map.closePopup();
2682+
});
2683+
}
2684+
2685+
/** Opens popup on a green suggestion or committed pick marker at `spotId`. */
2686+
async function openParkingMarkerPopupForSpot(page, spotId) {
2687+
await closeParkingMapPopups(page);
2688+
const colon = spotId.indexOf(":");
2689+
if (colon <= 0) throw new Error(`invalid spotId for popup: ${spotId}`);
2690+
const rest = spotId.slice(colon + 1);
2691+
const comma = rest.indexOf(",");
2692+
if (comma <= 0) throw new Error(`invalid spotId for popup: ${spotId}`);
2693+
const lat = Number(rest.slice(0, comma));
2694+
const lng = Number(rest.slice(comma + 1));
2695+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
2696+
throw new Error(`invalid spotId for popup: ${spotId}`);
2697+
}
2698+
const wantLat = lat.toFixed(6);
2699+
const wantLng = lng.toFixed(6);
2700+
const opened = await page.evaluate(
2701+
({ wantLat, wantLng }) => {
2702+
const map = globalThis.__parkingMapForTest;
2703+
const L = globalThis.L;
2704+
if (!map || !L) return false;
2705+
let marker = null;
2706+
function visit(layer) {
2707+
if (marker || !layer) return;
2708+
if (
2709+
layer instanceof L.Marker &&
2710+
typeof layer.getLatLng === "function"
2711+
) {
2712+
const ll = layer.getLatLng();
2713+
if (ll.lat.toFixed(6) === wantLat && ll.lng.toFixed(6) === wantLng) {
2714+
marker = layer;
2715+
return;
2716+
}
2717+
}
2718+
if (typeof layer.eachLayer === "function") layer.eachLayer(visit);
2719+
}
2720+
map.eachLayer(visit);
2721+
if (marker && typeof marker.openPopup === "function") {
2722+
marker.openPopup();
2723+
return true;
2724+
}
2725+
return false;
2726+
},
2727+
{ wantLat, wantLng },
2728+
);
2729+
expect(opened).toBe(true);
2730+
}
2731+
2732+
/** Clicks **Plan to park here** on the ★ **best** muted-green suggestion (same as user commit). */
2733+
async function commitBestParkingSuggestion(page) {
2734+
await page.waitForFunction(
2735+
() => {
2736+
const id = globalThis.__chooseBestParkingStartSpotIdForTest?.();
2737+
return typeof id === "string" && id.length > 0;
2738+
},
2739+
{ timeout: 15_000 },
2740+
);
2741+
const bestId = await page.evaluate(
2742+
() => globalThis.__chooseBestParkingStartSpotIdForTest?.() ?? "",
2743+
);
2744+
expect(bestId).toBeTruthy();
2745+
await openParkingMarkerPopupForSpot(page, bestId);
2746+
const popup = page.locator(".leaflet-popup").last();
2747+
const btn = popup.locator("[data-parking-start-btn]");
2748+
await expect(btn).toBeVisible({ timeout: 5000 });
2749+
await expect(popup.locator("[data-parking-start-btn-label]")).toHaveText(
2750+
"Plan to park here",
2751+
);
2752+
await btn.click();
2753+
await expect(page).toHaveURL(/[?&]park=/);
2754+
await page.waitForFunction(
2755+
() => {
2756+
const body = document.querySelector("#parkingRouteInstructionsBody");
2757+
return (
2758+
body != null &&
2759+
!String(body.textContent || "").includes("isn't on the map")
2760+
);
2761+
},
2762+
{ timeout: 10_000 },
2763+
);
2764+
await closeParkingMapPopups(page);
2765+
}
2766+
26782767
/**
26792768
* `#/visit` layout snapshots: **`{device}-{n}-{variant}.png`** (e.g. **`desktop-1-blank.png`**).
26802769
* Hash paths are under `${DEFAULT_APP_PAGE}#/…`.
@@ -2689,8 +2778,8 @@ const PARKING_SNAPSHOT_CASES = [
26892778
{
26902779
n: "3",
26912780
variant: "start",
2692-
hashPath:
2693-
"visit/acrisure-amphitheater?walk=0.5&park=private-lot:42.972319,-85.682491",
2781+
hashPath: "visit/acrisure-amphitheater?walk=0.5",
2782+
commitBestSuggestion: true,
26942783
},
26952784
];
26962785

@@ -2703,7 +2792,7 @@ const PARKING_SNAPSHOT_VIEWPORTS = [
27032792
/** Fixed layout captures for `#/visit` via Playwright snapshot compare (`snapshotPathTemplate` in playwright.config.js). */
27042793
async function assertParkingViewportScreenshot(
27052794
page,
2706-
{ hashPath, snapshotName, width, height },
2795+
{ hashPath, snapshotName, width, height, commitBestSuggestion },
27072796
) {
27082797
await page.setViewportSize({ width, height });
27092798
await page.goto(`${DEFAULT_APP_PAGE}#/${hashPath}`);
@@ -2719,6 +2808,9 @@ async function assertParkingViewportScreenshot(
27192808
);
27202809
await expect(page.locator("#parkingView")).toBeVisible();
27212810
await expect(page.locator("#parkingMapChrome")).toBeVisible();
2811+
if (commitBestSuggestion) {
2812+
await commitBestParkingSuggestion(page);
2813+
}
27222814
await page.evaluate(() => globalThis.__parkingMapForTest?.invalidateSize?.());
27232815
await new Promise((r) => setTimeout(r, 400));
27242816

@@ -2758,7 +2850,12 @@ test.describe(
27582850
/** Avoid hammering `live-server` / data fetches — parallel runs caused flaky loads and unstable tiles. */
27592851
test.describe.configure({ mode: "serial", timeout: 45_000 });
27602852

2761-
for (const { n, variant, hashPath } of PARKING_SNAPSHOT_CASES) {
2853+
for (const {
2854+
n,
2855+
variant,
2856+
hashPath,
2857+
commitBestSuggestion,
2858+
} of PARKING_SNAPSHOT_CASES) {
27622859
test.describe(`${n}-${variant}`, () => {
27632860
for (const {
27642861
name: device,
@@ -2771,6 +2868,7 @@ test.describe(
27712868
snapshotName: `${device}-${n}-${variant}`,
27722869
width,
27732870
height,
2871+
commitBestSuggestion: commitBestSuggestion === true,
27742872
});
27752873
});
27762874
}

0 commit comments

Comments
 (0)