diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45aaf76..4cd7457 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,9 @@ jobs: python-version: "3.12" - name: Install dependencies - run: pip install -r requirements.txt + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt - name: Run tests - run: python -m pytest test_travel.py -v + run: python -m pytest -q diff --git a/app/engine/crowd.py b/app/engine/crowd.py index 9af742f..53d5d73 100644 --- a/app/engine/crowd.py +++ b/app/engine/crowd.py @@ -1,51 +1,130 @@ """Crowd prediction and calendar awareness for venues. -Provides predicted crowd levels (1-10 scale) based on venue type, -day of week, season, and known special events. Integrates with the -planner to optimize schedule ordering (hit high-wait attractions -early) and surface crowd-avoidance tips. - -Data sources: - - Historical crowd patterns for major US theme parks - - School holiday calendars (spring break, summer, winter break) - - Known conventions / events that spike attendance +Provides predicted crowd levels (1-10 scale). Prefers live wait-time +data from Queue-Times.com when a venue can be matched to a supported +park and the target date is today (or omitted). Falls back to a +recalibrated day/season/holiday heuristic otherwise. + +Queue-Times attribution is required when live data is used: +https://queue-times.com/ """ from __future__ import annotations +import json import logging -from datetime import date, datetime +import re +import urllib.request +from datetime import date from functools import lru_cache logger = logging.getLogger("plan-it.crowd") +# Queue-Times park IDs (from https://queue-times.com/parks.json) — multi-park coverage +_PARK_IDS: dict[str, int] = { + # Walt Disney World + "magic kingdom": 6, + "disney magic kingdom": 6, + "epcot": 5, + "hollywood studios": 7, + "disney hollywood studios": 7, + "hollywood": 7, + "animal kingdom": 8, + "disney animal kingdom": 8, + # Disneyland Resort + "disney california adventure": 17, + "california adventure": 17, + "disneyland": 16, + # Universal + "islands of adventure": 64, + "islands of adventure at universal orlando": 64, + "universal studios florida": 65, + "universal studios at universal orlando": 65, + "universal studios orlando": 65, + "universal studios hollywood": 66, + "epic universe": 334, + "volcano bay": 67, + "universal volcano bay": 67, + # SeaWorld / Busch + "busch gardens tampa": 24, + "busch gardens": 24, + "busch gardens williamsburg": 23, + "seaworld orlando": 21, + "sea world orlando": 21, + "seaworld san diego": 20, + "sea world san diego": 20, + "seaworld san antonio": 22, + "aquatica orlando": 94, + "adventure island": 97, + "sesame place": 29, + # Six Flags + "six flags great adventure": 37, + "six flags magic mountain": 32, + "six flags over georgia": 35, + "six flags over texas": 34, + "six flags great america": 38, + "six flags discovery kingdom": 33, + "six flags fiesta texas": 39, + "six flags new england": 43, + "six flags st. louis": 36, + "six flags america": 42, + # Cedar Fair / others + "cedar point": 50, + "knott's berry farm": 61, + "knotts berry farm": 61, + "kings island": 60, + "kings dominion": 62, + "carowinds": 59, + "california's great america": 57, + "hersheypark": 15, + "hershey park": 15, + "dollywood": 55, + "silver dollar city": 10, + "legoland florida": 280, + "legoland california": 279, + "legoland new york": 299, + "kennywood": 312, +} + +# Resort / umbrella names → component park IDs (averaged for crowd; searched for waits) +_RESORT_PARKS: dict[str, list[int]] = { + "walt disney world": [6, 5, 7, 8], + "disney world": [6, 5, 7, 8], + "wdw": [6, 5, 7, 8], + "disneyland resort": [16, 17], + "universal orlando": [65, 64, 334, 67], + "universal orlando resort": [65, 64, 334, 67], + "seaworld orlando resort": [21, 94], +} + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- -@lru_cache(maxsize=128) def get_crowd_level(venue_name: str, target_date: date | None = None) -> int: """Predict crowd level for a venue on a given date (1-10 scale). 1 = empty, 5 = average, 10 = packed. + """ + level, _source = get_crowd_level_with_source(venue_name, target_date) + return level - Args: - venue_name: Normalized venue name. - target_date: Date to predict. Defaults to today. - Returns: - Integer 1-10. - """ +def get_crowd_level_with_source( + venue_name: str, target_date: date | None = None +) -> tuple[int, str]: + """Return ``(level, source)`` where source is ``live`` or ``estimate``.""" if target_date is None: target_date = date.today() - base = _base_crowd_for_venue(venue_name) - day_mult = _day_of_week_multiplier(target_date) - season_mult = _season_multiplier(target_date) - holiday_mult = _holiday_multiplier(target_date) + # Live waits are only meaningful for "today" (park timezone ≈ visit day). + if target_date == date.today(): + live = _live_crowd_level(venue_name) + if live is not None: + return live, "live" - raw = base * day_mult * season_mult * holiday_mult - return max(1, min(10, round(raw))) + return _heuristic_crowd_level(venue_name, target_date), "estimate" def get_crowd_tips(venue_name: str, crowd_level: int) -> list[str]: @@ -66,7 +145,6 @@ def get_crowd_tips(venue_name: str, crowd_level: int) -> list[str]: venue_lower = venue_name.lower() - # Venue-specific tips if "disney" in venue_lower or "magic kingdom" in venue_lower: tips.append("Use Genie+ / Lightning Lane for top-tier attractions") if crowd_level >= 6: @@ -94,109 +172,354 @@ def is_holiday_period(target_date: date | None = None) -> bool: """Return True if the date falls within a known high-crowd holiday period.""" if target_date is None: target_date = date.today() - return _holiday_multiplier(target_date) > 1.0 + return _holiday_adjustment(target_date) > 0 + + +def estimate_attraction_wait( + attraction: str, + venue_name: str = "", + *, + target_date: date | None = None, +) -> dict[str, object] | None: + """Return live wait info for an attraction string, or None if unavailable. + + Handles free-form planner strings such as: + - ``Space Mountain`` + - ``Magic Kingdom: Space Mountain, Seven Dwarfs Mine Train`` + - ``Guardians of the Galaxy: Cosmic Rewind`` + + Returns dict with: + wait_min (int), matched (list[str]), park_ids (list[int]), source ("live") + """ + if target_date is None: + target_date = date.today() + if target_date != date.today(): + return None + + park_hint, ride_names = _parse_attraction_label(attraction) + park_ids = _resolve_park_ids(park_hint) if park_hint else [] + if not park_ids: + park_ids = _resolve_park_ids(venue_name) + if not park_ids: + # Last resort: scan all known single-park aliases mentioned in the text + blob = f"{attraction} {venue_name}".lower() + for key, pid in sorted(_PARK_IDS.items(), key=lambda kv: -len(kv[0])): + if key in blob and pid not in park_ids: + park_ids.append(pid) + + if not park_ids: + return None + + # Build ride catalog across candidate parks + catalog: list[tuple[int, str, float]] = [] # park_id, name, wait + for pid in park_ids: + for name, wait in _fetch_park_ride_waits(pid): + catalog.append((pid, name, wait)) + + if not catalog: + return None + + matched_waits: list[float] = [] + matched_names: list[str] = [] + for query in ride_names or [attraction]: + hit = _match_ride(query, catalog) + if hit: + _pid, name, wait = hit + matched_waits.append(wait) + matched_names.append(name) + + if matched_waits: + # Use the highest matched wait so the schedule plans for the worst line + wait_min = int(round(max(matched_waits))) + return { + "wait_min": wait_min, + "matched": matched_names, + "park_ids": park_ids, + "source": "live", + } + + # No ride-level match — fall back to park average across open rides + all_waits = [w for _pid, _n, w in catalog] + if not all_waits: + return None + avg = sum(all_waits) / len(all_waits) + return { + "wait_min": int(round(avg)), + "matched": [], + "park_ids": park_ids, + "source": "live_park_avg", + } + + +def _parse_attraction_label(attraction: str) -> tuple[str, list[str]]: + """Split ``Park: Ride A, Ride B`` into (park_hint, [rides]).""" + text = (attraction or "").split(" — ")[0].strip() + if not text: + return "", [] + + park_hint = "" + rides_part = text + + # "Magic Kingdom: Space Mountain, Seven Dwarfs Mine Train" + if ":" in text: + left, right = text.split(":", 1) + # Only treat left as park if it looks like a park/resort name + if _resolve_park_ids(left) or any(k in left.lower() for k in _PARK_IDS) or any( + k in left.lower() for k in _RESORT_PARKS + ): + park_hint = left.strip() + rides_part = right.strip() + else: + # e.g. "Guardians of the Galaxy: Cosmic Rewind" — whole string is the ride + return "", [text] + + # Split ride list on commas / "and" + parts = re.split(r"\s*,\s*|\s+and\s+", rides_part, flags=re.IGNORECASE) + rides = [p.strip() for p in parts if p.strip()] + return park_hint, rides + + +def _normalize_ride_key(name: str) -> str: + """Lowercase alphanumeric key for fuzzy ride matching.""" + return re.sub(r"[^a-z0-9]+", "", (name or "").lower()) + + +def _match_ride( + query: str, catalog: list[tuple[int, str, float]] +) -> tuple[int, str, float] | None: + """Best fuzzy match of *query* against live ride catalog.""" + q = _normalize_ride_key(query) + if not q or len(q) < 3: + return None + + # Exact normalized match + for pid, name, wait in catalog: + if _normalize_ride_key(name) == q: + return pid, name, wait + + # Substring either direction (prefer longest catalog name that contains query) + candidates: list[tuple[int, int, str, float]] = [] + for pid, name, wait in catalog: + key = _normalize_ride_key(name) + if q in key or key in q: + # Prefer standard queues over Single Rider / Virtual variants + penalty = 0 + lower = name.lower() + if "single rider" in lower: + penalty = 50 + elif "virtual" in lower or "lightning" in lower: + penalty = 20 + candidates.append((len(key) - penalty, pid, name, wait)) + if candidates: + candidates.sort(reverse=True) + _n, pid, name, wait = candidates[0] + return pid, name, wait + + # Token overlap: require most significant tokens + q_tokens = [t for t in re.findall(r"[a-z0-9]+", query.lower()) if len(t) > 2] + if len(q_tokens) >= 2: + best = None + best_score = 0 + for pid, name, wait in catalog: + name_l = name.lower() + score = sum(1 for t in q_tokens if t in name_l) + if score >= max(2, len(q_tokens) - 1) and score > best_score: + best = (pid, name, wait) + best_score = score + if best: + return best + + return None + + +# --------------------------------------------------------------------------- +# Live data (Queue-Times.com) +# --------------------------------------------------------------------------- + +def _resolve_park_ids(venue_name: str) -> list[int]: + """Map a free-form venue name to one or more Queue-Times park IDs.""" + lower = (venue_name or "").lower().strip() + if not lower: + return [] + + # Prefer longer / more specific resort keys first + for key, ids in sorted(_RESORT_PARKS.items(), key=lambda kv: -len(kv[0])): + if key in lower: + return list(ids) + + for key, pid in sorted(_PARK_IDS.items(), key=lambda kv: -len(kv[0])): + if key in lower: + return [pid] + + return [] + + +def _live_crowd_level(venue_name: str) -> int | None: + """Derive a 1-10 crowd level from live average wait times, or None.""" + park_ids = _resolve_park_ids(venue_name) + if not park_ids: + return None + + waits: list[float] = [] + for pid in park_ids: + park_waits = _fetch_open_wait_times(pid) + waits.extend(park_waits) + + if not waits: + return None + + avg = sum(waits) / len(waits) + level = _crowd_from_avg_wait(avg) + logger.info( + "Live crowd for %r: avg_wait=%.1f across %d open rides → %d/10 (parks=%s)", + venue_name[:40], avg, len(waits), level, park_ids, + ) + return level + + +def _crowd_from_avg_wait(avg_wait: float) -> int: + """Map mean open-ride wait (minutes) to a 1–10 crowd score. + + Calibrated so ~20–25 min (typical moderate Disney day) → 5, + not the old heuristic's summer-default 7. + """ + if avg_wait <= 5: + return 2 + if avg_wait <= 12: + return 3 + if avg_wait <= 18: + return 4 + if avg_wait <= 26: + return 5 + if avg_wait <= 34: + return 6 + if avg_wait <= 42: + return 7 + if avg_wait <= 55: + return 8 + if avg_wait <= 70: + return 9 + return 10 + + +@lru_cache(maxsize=64) +def _fetch_park_ride_waits(park_id: int) -> tuple[tuple[str, float], ...]: + """Fetch (ride_name, wait_min) for open rides at a park.""" + url = f"https://queue-times.com/parks/{park_id}/queue_times.json" + try: + req = urllib.request.Request(url, headers={"User-Agent": "Plan-It/0.3 (crowd)"}) + with urllib.request.urlopen(req, timeout=6) as resp: + data = json.loads(resp.read().decode()) + except Exception as exc: + logger.warning("Queue-Times fetch failed for park %s: %s", park_id, exc) + return tuple() + + rides: list[tuple[str, float]] = [] + for land in data.get("lands") or []: + for ride in land.get("rides") or []: + if ride.get("is_open"): + rides.append((str(ride.get("name") or ""), float(ride.get("wait_time") or 0))) + for ride in data.get("rides") or []: + if ride.get("is_open"): + rides.append((str(ride.get("name") or ""), float(ride.get("wait_time") or 0))) + return tuple((n, w) for n, w in rides if n) + + +def _fetch_open_wait_times(park_id: int) -> tuple[float, ...]: + """Open-ride wait minutes for a park (used by crowd-level averaging).""" + return tuple(w for _name, w in _fetch_park_ride_waits(park_id)) # --------------------------------------------------------------------------- -# Internal multipliers +# Heuristic fallback (recalibrated — additive, not multiplicative) # --------------------------------------------------------------------------- +def _heuristic_crowd_level(venue_name: str, target_date: date) -> int: + """Additive model calibrated closer to historical park averages.""" + base = _base_crowd_for_venue(venue_name) + raw = ( + base + + _day_of_week_adjustment(target_date) + + _season_adjustment(target_date) + + _holiday_adjustment(target_date) + ) + return max(1, min(10, round(raw))) + + def _base_crowd_for_venue(venue_name: str) -> float: - """Default average crowd level for a venue type.""" + """Typical mid-week, shoulder-season crowd level for a venue.""" venue_lower = venue_name.lower() - # Major theme parks have higher baseline crowds if any(kw in venue_lower for kw in ( "disney", "magic kingdom", "epcot", "hollywood studios", "animal kingdom", "universal", "islands of adventure", )): - return 6.0 + return 5.0 # was 6.0 — overstated typical days if any(kw in venue_lower for kw in ("busch gardens", "sea world", "seaworld", "legoland")): - return 4.5 + return 4.0 if any(kw in venue_lower for kw in ("six flags", "cedar point")): return 4.0 if any(kw in venue_lower for kw in ("zoo", "aquarium", "museum")): - return 3.5 + return 3.0 if any(kw in venue_lower for kw in ("national park", "yosemite", "yellowstone", "grand canyon")): return 4.0 return 3.0 -def _day_of_week_multiplier(d: date) -> float: - """Weekends and Mondays/Fridays are busier.""" - dow = d.weekday() # 0=Monday, 6=Sunday - if dow in (5, 6): # Saturday, Sunday - return 1.4 - if dow in (0, 4): # Monday, Friday - return 1.15 - return 0.85 # Tuesday–Thursday are quietest - - -def _season_multiplier(d: date) -> float: - """Seasonal crowd variation.""" - month = d.month - # Summer (June–August): peak - if month in (6, 7, 8): - return 1.3 - # Spring break window (March–April) - if month in (3, 4): - return 1.2 - # Holiday season (Nov–Dec) +def _day_of_week_adjustment(d: date) -> float: + dow = d.weekday() # 0=Monday + if dow in (5, 6): # Sat/Sun + return 1.5 + if dow in (0, 4): # Mon/Fri + return 0.5 + return -0.5 # Tue–Thu quieter + + +def _season_adjustment(d: date) -> float: + month, day = d.month, d.day + # Peak summer: mid-June through late July + if month == 6 and day >= 15: + return 1.0 + if month == 7: + return 1.0 if day <= 25 else 0.5 # late July softens + if month == 8 and day <= 15: + return 0.5 + if month == 8: + return -0.5 # back-to-school taper + if month in (3, 4): # spring break window + return 1.0 if month in (11, 12): - return 1.15 - # Shoulder seasons - if month in (5, 9, 10): - return 0.95 - # Dead season (Jan–Feb) - return 0.75 + return 0.5 + if month in (1, 2, 9): + return -1.0 + return 0.0 -def _holiday_multiplier(d: date) -> float: - """Holiday-specific crowd spikes.""" - month = d.month - mday = d.day - # Use weekday() indirectly through known holiday periods +def _holiday_adjustment(d: date) -> float: + month, mday = d.month, d.day - # Christmas / New Year week if month == 12 and mday >= 20: - return 1.6 + return 2.5 if month == 1 and mday <= 3: - return 1.5 - - # Thanksgiving week (4th Thursday of November) + return 2.0 if month == 11 and 20 <= mday <= 30: - return 1.4 - - # Spring break (mid-March through mid-April) + return 2.0 if month == 3 and mday >= 10: - return 1.3 + return 1.0 if month == 4 and mday <= 20: - return 1.3 - - # Memorial Day weekend (late May) + return 1.0 if month == 5 and mday >= 25: - return 1.2 - - # July 4th week + return 1.5 if month == 7 and 1 <= mday <= 7: - return 1.3 - - # Labor Day weekend (early September) + return 2.0 if month == 9 and 1 <= mday <= 7: - return 1.2 - - # Presidents' Day weekend (mid-February) + return 1.5 if month == 2 and 13 <= mday <= 20: - return 1.15 - - # MLK Day weekend (mid-January) + return 1.0 if month == 1 and 13 <= mday <= 20: - return 1.1 - - # Halloween season at theme parks (October) + return 0.5 if month == 10: - return 1.1 - - return 1.0 + return 0.5 + return 0.0 # Known seasonal events by month (US-focused) diff --git a/app/engine/planner.py b/app/engine/planner.py index a13f5b9..a0c19ec 100644 --- a/app/engine/planner.py +++ b/app/engine/planner.py @@ -78,7 +78,7 @@ def build_travel_plan( # 6. Build the schedule schedule = _build_schedule(intent, venue, route, is_multiday, starting_location, - departure_time=final_departure) + departure_time=final_departure, trip_date=trip_date) # 7. Research restaurants — include location for accurate results venue_area = f"near {intent['venue']}" @@ -109,10 +109,13 @@ def build_travel_plan( # 8. Inject restaurant recommendations into schedule items schedule = _inject_restaurants(schedule, restaurants, restaurant_preferences) - # 8a. Crowd prediction — venue-specific crowd level and tips + # 8a. Crowd prediction — prefer live wait data; fall back to estimate crowd_level = 5 # default: average + crowd_source = "estimate" try: - crowd_level = crowd.get_crowd_level(intent["venue"]) + crowd_level, crowd_source = crowd.get_crowd_level_with_source( + intent["venue"], target_date=trip_date + ) crowd_tips = crowd.get_crowd_tips(intent["venue"], crowd_level) if crowd_tips: strategy = list(crowd_tips) + strategy @@ -212,18 +215,20 @@ def build_travel_plan( "hotels": hotels, "trip_date": trip_date.isoformat() if trip_date else "", "crowd_level": crowd_level, + "crowd_source": crowd_source, "total_walking_min": total_walking or None, "total_wait_min": total_wait or None, } logger.info( - "Plan built: %s, %d route legs, %d schedule items, type=%s, multiday=%s, crowd=%d", + "Plan built: %s, %d route legs, %d schedule items, type=%s, multiday=%s, crowd=%d (%s)", intent["venue"][:40], len(route), len(schedule), venue.get("venue_type"), is_multiday, crowd_level, + crowd_source, ) return plan @@ -955,6 +960,7 @@ def _build_schedule( is_multiday: bool = False, starting_location: str | None = None, departure_time: str | None = None, + trip_date: date | None = None, ) -> list[dict]: """Build a full day schedule with support for multi-day trips. @@ -1049,8 +1055,15 @@ def _build_schedule( break walk = random.randint(3, 12) if i > 0 else 0 - # Wait times ramp up as the day progresses - if current_h < 10: + # Prefer live Queue-Times waits; fall back to time-of-day heuristic + live = crowd.estimate_attraction_wait( + attraction, intent.get("venue", ""), target_date=trip_date + ) + wait_source = "estimate" + if live and isinstance(live.get("wait_min"), (int, float)): + wait = max(0, int(live["wait_min"])) + wait_source = str(live.get("source") or "live") + elif current_h < 10: wait = random.randint(5, 15) elif current_h < 12: wait = random.randint(10, 25) @@ -1087,6 +1100,7 @@ def _build_schedule( "priority": priority, "walking_time_min": walk, "wait_time_min": wait, + "wait_source": wait_source, "restaurant": "Restaurant recommendation TBD" if is_lunch_slot else None, "meal_timing_note": "Beat the lunch crowd — dine early or after 1 PM" if is_lunch_slot else None, "reminder_min": None, diff --git a/app/schemas/itinerary.py b/app/schemas/itinerary.py index 72fb628..d8c01b4 100644 --- a/app/schemas/itinerary.py +++ b/app/schemas/itinerary.py @@ -66,6 +66,10 @@ class ScheduleItem(BaseModel): ge=0, description="Expected wait time in minutes for this activity", ) + wait_source: Optional[str] = Field( + default=None, + description="Origin of wait_time_min: 'live' / 'live_park_avg' (Queue-Times) or 'estimate'", + ) restaurant: Optional[str] = Field( default=None, description="Recommended restaurant for this meal stop (e.g. 'Be Our Guest — French, $$, inside park')", diff --git a/static/css/app.css b/static/css/app.css index 6f5f447..90f8088 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2085,6 +2085,11 @@ ul, ol { .crowd-banner-icon { font-size: 1.2rem; } +.crowd-source { + font-weight: 400; + opacity: 0.75; + font-size: 0.92em; +} .crowd-high { background: #fef2f2; color: #991b1b; diff --git a/static/js/app.js b/static/js/app.js index ded9d22..e905909 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -701,9 +701,14 @@ else if (level >= 6) { label = t("crowd.busy"); colorClass = "crowd-med"; } else if (level >= 4) { label = t("crowd.moderate"); colorClass = "crowd-low"; } else { label = t("crowd.light"); colorClass = "crowd-low"; } + var source = plan.crowd_source === "live" ? t("crowd.liveSource") : t("crowd.estimateSource"); + var heading = plan.crowd_source === "live" + ? t("crowd.liveCrowd", { level: level }) + : t("crowd.estimatedCrowd", { level: level }); return '
'; } @@ -749,8 +754,12 @@ ); } if (item.wait_time_min != null) { + var waitLabel = t("badge.wait", { min: item.wait_time_min }); + if (item.wait_source === "live" || item.wait_source === "live_park_avg") { + waitLabel = t("badge.waitLive", { min: item.wait_time_min }); + } metaBadges.push( - '' + t("badge.wait", { min: item.wait_time_min }) + '' + '' + waitLabel + '' ); } if (item.reminder_min) { diff --git a/static/js/i18n.js b/static/js/i18n.js index efc4a0e..2180118 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -140,7 +140,11 @@ // Totals // Crowd prediction - "crowd.predictedCrowd": "Predicted crowd: {level}/10", + "crowd.predictedCrowd": "Estimated crowd: {level}/10", + "crowd.estimatedCrowd": "Estimated crowd: {level}/10", + "crowd.liveCrowd": "Live crowd: {level}/10", + "crowd.liveSource": "based on current wait times · Queue-Times.com", + "crowd.estimateSource": "calendar estimate — not live", "crowd.packed": "Packed — expect long waits", "crowd.busy": "Busy — plan ahead", "crowd.moderate": "Moderate — good day to visit", @@ -156,6 +160,7 @@ // Badges "badge.walk": "{min} min walk", "badge.wait": "{min} min wait", + "badge.waitLive": "{min} min wait (live)", "badge.reminder": "{min} min", "badge.priority.high": "high", "badge.priority.medium": "medium", @@ -341,7 +346,11 @@ // Totals // Crowd prediction - "crowd.predictedCrowd": "Multitud prevista: {level}/10", + "crowd.predictedCrowd": "Multitud estimada: {level}/10", + "crowd.estimatedCrowd": "Multitud estimada: {level}/10", + "crowd.liveCrowd": "Multitud en vivo: {level}/10", + "crowd.liveSource": "según tiempos de espera actuales · Queue-Times.com", + "crowd.estimateSource": "estimación de calendario — no en vivo", "crowd.packed": "Lleno — espera largas colas", "crowd.busy": "Ocupado — planifica con antelación", "crowd.moderate": "Moderado — buen día para visitar", @@ -357,6 +366,7 @@ // Badges "badge.walk": "{min} min caminata", "badge.wait": "{min} min espera", + "badge.waitLive": "{min} min espera (en vivo)", "badge.reminder": "{min} min", "badge.priority.high": "alta", "badge.priority.medium": "media", diff --git a/test_crowd.py b/test_crowd.py new file mode 100644 index 0000000..dc60786 --- /dev/null +++ b/test_crowd.py @@ -0,0 +1,140 @@ +"""Tests for crowd prediction and live attraction waits.""" + +from datetime import date, timedelta +from unittest.mock import patch + +from app.engine.crowd import ( + _crowd_from_avg_wait, + _heuristic_crowd_level, + _parse_attraction_label, + _resolve_park_ids, + estimate_attraction_wait, + get_crowd_level_with_source, +) + + +def test_walt_disney_world_resolves_to_four_parks(): + assert _resolve_park_ids("Walt Disney World") == [6, 5, 7, 8] + + +def test_universal_orlando_includes_epic_and_volcano_bay(): + assert _resolve_park_ids("Universal Orlando") == [65, 64, 334, 67] + + +def test_disneyland_does_not_resolve_to_wdw(): + assert _resolve_park_ids("Disneyland") == [16] + + +def test_multi_park_aliases_covered(): + assert _resolve_park_ids("Busch Gardens Tampa") == [24] + assert _resolve_park_ids("Cedar Point") == [50] + assert _resolve_park_ids("Epic Universe") == [334] + assert _resolve_park_ids("SeaWorld Orlando") == [21] + assert _resolve_park_ids("Six Flags Magic Mountain") == [32] + + +def test_avg_wait_24_is_moderate_not_busy(): + assert _crowd_from_avg_wait(24.0) == 5 + + +def test_heuristic_late_july_tuesday_disney_not_stuck_at_7(): + level = _heuristic_crowd_level("Walt Disney World", date(2026, 7, 28)) + assert 4 <= level <= 6 + + +def test_live_source_when_waits_available(): + with patch("app.engine.crowd._fetch_open_wait_times", return_value=(20.0, 25.0, 30.0)): + level, source = get_crowd_level_with_source("Magic Kingdom", date.today()) + assert source == "live" + assert level == _crowd_from_avg_wait(25.0) + + +def test_future_date_uses_estimate_not_live(): + future = date.today() + timedelta(days=30) + with patch("app.engine.crowd._live_crowd_level") as live: + level, source = get_crowd_level_with_source("Magic Kingdom", future) + live.assert_not_called() + assert source == "estimate" + assert 1 <= level <= 10 + + +def test_parse_park_colon_ride_list(): + park, rides = _parse_attraction_label( + "Magic Kingdom: Space Mountain, Seven Dwarfs Mine Train, Big Thunder Mountain" + ) + assert park == "Magic Kingdom" + assert rides == ["Space Mountain", "Seven Dwarfs Mine Train", "Big Thunder Mountain"] + + +def test_parse_ride_with_colon_in_name(): + park, rides = _parse_attraction_label("Guardians of the Galaxy: Cosmic Rewind") + assert park == "" + assert rides == ["Guardians of the Galaxy: Cosmic Rewind"] + + +def test_estimate_attraction_wait_matches_rides_across_parks(): + catalog_mk = ( + ("Space Mountain", 35.0), + ("Seven Dwarfs Mine Train", 65.0), + ("Big Thunder Mountain Railroad", 45.0), + ) + catalog_epcot = ( + ("Guardians of the Galaxy: Cosmic Rewind", 75.0), + ("Soarin' Across America", 40.0), + ("Test Track", 50.0), + ) + + def fake_fetch(park_id): + if park_id == 6: + return catalog_mk + if park_id == 5: + return catalog_epcot + return tuple() + + with patch("app.engine.crowd._fetch_park_ride_waits", side_effect=fake_fetch): + mk = estimate_attraction_wait( + "Magic Kingdom: Space Mountain, Seven Dwarfs Mine Train, Big Thunder Mountain", + "Walt Disney World", + target_date=date.today(), + ) + epcot = estimate_attraction_wait( + "EPCOT: Guardians of the Galaxy: Cosmic Rewind, Soarin', Test Track", + "Walt Disney World", + target_date=date.today(), + ) + + assert mk is not None + assert mk["source"] == "live" + assert mk["wait_min"] == 65 # max of matched + assert "Seven Dwarfs Mine Train" in mk["matched"] + + assert epcot is not None + assert epcot["wait_min"] == 75 + assert any("Guardians" in m for m in epcot["matched"]) + + +def test_estimate_attraction_wait_busch_gardens_ride(): + with patch( + "app.engine.crowd._fetch_park_ride_waits", + return_value=(("Kumba", 5.0), ("Iron Gwazi", 30.0), ("SheiKra", 30.0)), + ): + result = estimate_attraction_wait("Kumba", "Busch Gardens Tampa", target_date=date.today()) + assert result is not None + assert result["wait_min"] == 5 + assert result["matched"] == ["Kumba"] + + +def test_prefers_standard_queue_over_single_rider(): + catalog = ( + ("Star Wars: Rise of the Resistance Single Rider", 20.0), + ("Star Wars: Rise of the Resistance", 45.0), + ) + with patch("app.engine.crowd._fetch_park_ride_waits", return_value=catalog): + result = estimate_attraction_wait( + "Hollywood Studios: Rise of the Resistance", + "Walt Disney World", + target_date=date.today(), + ) + assert result is not None + assert result["matched"] == ["Star Wars: Rise of the Resistance"] + assert result["wait_min"] == 45