diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..e0c99ba4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,161 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Wayfinder is the next-generation OneBusAway web application built with SvelteKit 5. It provides real-time transit information including bus stops, routes, arrivals/departures, and trip planning functionality. The application uses the OneBusAway REST API and supports both OpenStreetMap and Google Maps providers.
+
+## Development Commands
+
+```bash
+# Install dependencies
+npm install
+
+# Copy environment configuration
+cp .env.example .env
+
+# Start development server
+npm run dev
+
+# Build for production
+npm run build
+
+# Preview production build
+npm run preview
+
+# Run tests
+npm run test
+
+# Run tests with coverage
+npm run test:coverage
+
+# Lint code
+npm run lint
+
+# Format code
+npm run format
+
+# Pre-push checks (format, lint, test)
+npm run prepush
+```
+
+## Architecture
+
+### Svelte 5 Usage
+
+This project uses Svelte 5 with the new runes API (`$state`, `$derived`, `$effect`, `$props`) and snippets system. Components use the modern Svelte 5 syntax throughout.
+
+### Key Directories
+
+- `src/components/` - All Svelte components organized by feature
+- `src/lib/` - Reusable utilities and services
+- `src/stores/` - Svelte stores for global state management
+- `src/routes/` - SvelteKit routes (pages and API endpoints)
+- `src/config/` - Configuration files
+- `src/locales/` - Internationalization files
+
+### Component Organization
+
+- `components/navigation/` - Header, modals, menus
+- `components/map/` - Map-related components (MapView, markers, popups)
+- `components/stops/` - Stop information and scheduling
+- `components/routes/` - Route information and modals
+- `components/search/` - Search functionality
+- `components/trip-planner/` - Trip planning interface
+- `components/surveys/` - User survey system
+- `components/service-alerts/` - Alert notifications
+
+### State Management
+
+- Uses Svelte 5 runes (`$state`, `$derived`) for local component state
+- Svelte stores for global state (map loading, modal state, user location, surveys)
+- Modal state managed through a centralized modal system
+
+### API Integration
+
+- OneBusAway SDK integration via `src/lib/obaSdk.js`
+- API routes in `src/routes/api/` proxy requests to OBA servers
+- OpenTripPlanner integration for trip planning
+- Google Maps/Places API integration for geocoding
+
+### Map Providers
+
+- Supports both OpenStreetMap (via Leaflet) and Google Maps
+- Map provider abstraction in `src/lib/Provider/`
+- Configurable via `PUBLIC_OBA_MAP_PROVIDER` environment variable
+
+### Key Features
+
+- Real-time arrivals and departures
+- Interactive maps with stop and vehicle markers
+- Route visualization with polylines
+- Trip planning with multiple itineraries
+- Multi-language support (i18n)
+- Responsive design with mobile menu
+- Analytics integration (Plausible)
+- User surveys and feedback collection
+- Service alerts and notifications
+
+## Environment Configuration
+
+Required environment variables (see `.env.example`):
+
+- `PRIVATE_OBA_API_KEY` - OneBusAway API key
+- `PUBLIC_OBA_SERVER_URL` - OBA server URL
+- `PUBLIC_OBA_REGION_*` - Region configuration
+- `PUBLIC_OBA_MAP_PROVIDER` - Map provider ("osm" or "google")
+
+## Testing
+
+- Tests use Vitest with jsdom environment
+- Test files located in `src/tests/`
+- Coverage reporting available via `npm run test:coverage`
+- Tests cover utilities, formatters, and SDK integration
+
+## Styling
+
+- Uses Tailwind CSS for styling
+- Flowbite components for UI elements
+- FontAwesome icons via Svelte FontAwesome
+- CSS custom properties for theming
+- Dark mode support
+
+## Build Configuration
+
+- Uses SvelteKit with Node.js adapter
+- Vite for bundling and development
+- Path aliases configured for imports (`$components`, `$lib`, etc.)
+- PostCSS for CSS processing
+- Prettier and ESLint for code formatting and linting
+
+## Key Libraries
+
+- `svelte` (v5) - UI framework with runes
+- `@sveltejs/kit` - Full-stack framework
+- `onebusaway-sdk` - OneBusAway API client
+- `leaflet` - Map rendering (OpenStreetMap)
+- `@googlemaps/js-api-loader` - Google Maps integration
+- `svelte-i18n` - Internationalization
+- `flowbite-svelte` - UI components
+- `tailwindcss` - CSS framework
+
+## Working with Components
+
+When creating new components:
+
+- Use Svelte 5 runes syntax (`$state`, `$props`, `$derived`)
+- Follow the existing component structure and naming conventions
+- Use TypeScript JSDoc comments for prop types
+- Implement proper cleanup in `$effect` when needed
+- Use snippets for reusable markup patterns
+- Follow the established CSS classes and Tailwind patterns
+
+## Common Patterns
+
+- Modal management through centralized state
+- Event handling with custom events for cross-component communication
+- Map provider abstraction for supporting multiple mapping services
+- API error handling with standardized error responses
+- Internationalization using `svelte-i18n` with JSON locale files
+- Analytics tracking for user interactions
diff --git a/package-lock.json b/package-lock.json
index e20155bf..e0cdecb4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,9 @@
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/line-clamp": "^0.4.4",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/svelte": "^5.2.8",
+ "@testing-library/user-event": "^14.6.1",
"@types/eslint": "^8.56.7",
"@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.19",
@@ -40,6 +43,7 @@
"flowbite-svelte-icons": "^1.6.2",
"globals": "^15.0.0",
"jsdom": "^26.0.0",
+ "msw": "^2.10.2",
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6",
@@ -50,6 +54,13 @@
"vitest": "^2.1.8"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz",
+ "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -88,6 +99,21 @@
"lru-cache": "^10.4.3"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
@@ -98,9 +124,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
- "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -121,6 +147,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.27.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
+ "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz",
@@ -141,6 +177,63 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@bundled-es-modules/cookie": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
+ "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cookie": "^0.7.2"
+ }
+ },
+ "node_modules/@bundled-es-modules/cookie/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@bundled-es-modules/statuses": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz",
+ "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "statuses": "^2.0.1"
+ }
+ },
+ "node_modules/@bundled-es-modules/tough-cookie": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz",
+ "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@types/tough-cookie": "^4.0.5",
+ "tough-cookie": "^4.1.4"
+ }
+ },
+ "node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+ "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "psl": "^1.1.33",
+ "punycode": "^2.1.1",
+ "universalify": "^0.2.0",
+ "url-parse": "^1.5.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
@@ -964,6 +1057,144 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@inquirer/confirm": {
+ "version": "5.1.13",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.13.tgz",
+ "integrity": "sha512-EkCtvp67ICIVVzjsquUiVSd+V5HRGOGQfsqA4E4vMWhYnB7InUL0pa0TIWt1i+OfP16Gkds8CdIu6yGZwOM1Yw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.1.14",
+ "@inquirer/type": "^3.0.7"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "10.1.14",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.14.tgz",
+ "integrity": "sha512-Ma+ZpOJPewtIYl6HZHZckeX1STvDnHTCB2GVINNUlSEn2Am6LddWwfPkIGY0IUFVjUUrr/93XlBwTK6mfLjf0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/figures": "^1.0.12",
+ "@inquirer/type": "^3.0.7",
+ "ansi-escapes": "^4.3.2",
+ "cli-width": "^4.1.0",
+ "mute-stream": "^2.0.0",
+ "signal-exit": "^4.1.0",
+ "wrap-ansi": "^6.2.0",
+ "yoctocolors-cjs": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@inquirer/core/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@inquirer/core/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@inquirer/core/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@inquirer/core/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz",
+ "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz",
+ "integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1154,6 +1385,24 @@
"license": "ISC",
"peer": true
},
+ "node_modules/@mswjs/interceptors": {
+ "version": "0.39.2",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.2.tgz",
+ "integrity": "sha512-RuzCup9Ct91Y7V79xwCb146RaBRHZ7NBbrIUySumd1rpKqHL5OonaqrGIbug5hNwP/fRyxFMA6ISgw4FTtYFYg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/logger": "^0.3.0",
+ "@open-draft/until": "^2.0.0",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "strict-event-emitter": "^0.5.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1189,6 +1438,31 @@
"node": ">= 8"
}
},
+ "node_modules/@open-draft/deferred-promise": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/logger": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+ "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.0"
+ }
+ },
+ "node_modules/@open-draft/until": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1753,6 +2027,125 @@
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
+ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "chalk": "^4.1.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.6.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
+ "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "chalk": "^3.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "lodash": "^4.17.21",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/chalk": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+ "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/svelte": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.8.tgz",
+ "integrity": "sha512-ucQOtGsJhtawOEtUmbR4rRh53e6RbM1KUluJIXRmh6D4UzxR847iIqqjRtg9mHNFmGQ8Vkam9yVcR5d1mhIHKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@testing-library/dom": "9.x.x || 10.x.x"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
+ "vite": "*",
+ "vitest": "*"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ },
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1885,6 +2278,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/statuses": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@@ -1895,6 +2295,13 @@
"@types/geojson": "*"
}
},
+ "node_modules/@types/tough-cookie": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@vitest/coverage-v8": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.8.tgz",
@@ -2138,6 +2545,35 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.21.3"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-escapes/node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
@@ -2406,9 +2842,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001684",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz",
- "integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==",
+ "version": "1.0.30001726",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
+ "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
"dev": true,
"funding": [
{
@@ -2533,6 +2969,94 @@
"node": ">=0.10"
}
},
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2610,6 +3134,13 @@
"node": ">= 8"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2750,7 +3281,17 @@
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
- "node": ">=0.4.0"
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/devalue": {
@@ -2772,6 +3313,13 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -3616,6 +4164,16 @@
"license": "ISC",
"peer": true
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -3761,6 +4319,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/graphql": {
+ "version": "16.11.0",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
+ "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
"node_modules/gtfs-realtime-bindings": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/gtfs-realtime-bindings/-/gtfs-realtime-bindings-1.1.1.tgz",
@@ -3791,6 +4359,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/headers-polyfill": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
+ "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -3926,6 +4501,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -4029,6 +4614,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -4144,6 +4736,13 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -4481,6 +5080,16 @@
"es5-ext": "~0.10.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.14",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.14.tgz",
@@ -4679,6 +5288,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
@@ -4756,6 +5375,51 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/msw": {
+ "version": "2.10.2",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.10.2.tgz",
+ "integrity": "sha512-RCKM6IZseZQCWcSWlutdf590M8nVfRHG1ImwzOtwz8IYxgT4zhUO0rfTcTvDGiaFE0Rhcc+h43lcF3Jc9gFtwQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bundled-es-modules/cookie": "^2.0.1",
+ "@bundled-es-modules/statuses": "^1.0.1",
+ "@bundled-es-modules/tough-cookie": "^0.1.6",
+ "@inquirer/confirm": "^5.0.0",
+ "@mswjs/interceptors": "^0.39.1",
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/until": "^2.1.0",
+ "@types/cookie": "^0.6.0",
+ "@types/statuses": "^2.0.4",
+ "graphql": "^16.8.1",
+ "headers-polyfill": "^4.0.2",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "path-to-regexp": "^6.3.0",
+ "picocolors": "^1.1.1",
+ "strict-event-emitter": "^0.5.1",
+ "type-fest": "^4.26.1",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "msw": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mswjs"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.8.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
@@ -4763,6 +5427,16 @@
"license": "MIT",
"peer": true
},
+ "node_modules/mute-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
+ "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -4936,6 +5610,13 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/outvariant": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5040,6 +5721,13 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -5419,6 +6107,44 @@
}
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/protobufjs": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
@@ -5507,6 +6233,19 @@
"license": "MIT",
"peer": true
},
+ "node_modules/psl": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+ "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/lupomontero"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5526,6 +6265,13 @@
"node": ">=6"
}
},
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5553,6 +6299,13 @@
"license": "ISC",
"peer": true
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -5586,6 +6339,37 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/requizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz",
@@ -5846,6 +6630,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/std-env": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz",
@@ -5853,6 +6647,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/strict-event-emitter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -5949,6 +6750,19 @@
"node": ">=8"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -6661,6 +7475,19 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
@@ -6691,6 +7518,16 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
+ "node_modules/universalify": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
@@ -6732,6 +7569,17 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -7583,6 +8431,16 @@
"integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==",
"license": "Apache-2.0"
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
@@ -7593,6 +8451,80 @@
"node": ">= 6"
}
},
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -7606,6 +8538,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/yoctocolors-cjs": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz",
+ "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
diff --git a/package.json b/package.json
index 18f5d472..656857a0 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,10 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
+ "test:watch": "vitest --watch",
"test:coverage": "vitest run --coverage",
+ "test:ui": "vitest --ui",
+ "test:components": "vitest run src/components",
"lint": "prettier --check . && eslint .",
"lint:prettier": "prettier --check .",
"lint:eslint": "eslint .",
@@ -23,6 +26,9 @@
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/line-clamp": "^0.4.4",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/svelte": "^5.2.8",
+ "@testing-library/user-event": "^14.6.1",
"@types/eslint": "^8.56.7",
"@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.19",
@@ -34,6 +40,7 @@
"flowbite-svelte-icons": "^1.6.2",
"globals": "^15.0.0",
"jsdom": "^26.0.0",
+ "msw": "^2.10.2",
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6",
diff --git a/src/components/__tests__/LoadingSpinner.test.js b/src/components/__tests__/LoadingSpinner.test.js
new file mode 100644
index 00000000..2535f6f6
--- /dev/null
+++ b/src/components/__tests__/LoadingSpinner.test.js
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/svelte';
+import { expect, test, describe } from 'vitest';
+import LoadingSpinner from '../LoadingSpinner.svelte';
+
+describe('LoadingSpinner', () => {
+ test('displays loading spinner with localized text', () => {
+ render(LoadingSpinner);
+
+ // Check for the loading text (mocked to return the key)
+ expect(screen.getByText('loading...')).toBeInTheDocument();
+
+ // Check for the spinner SVG by class
+ const { container } = render(LoadingSpinner);
+ const spinner = container.querySelector('.animate-spin');
+ expect(spinner).toBeInTheDocument();
+ });
+
+ test('has correct CSS classes for styling', () => {
+ const { container } = render(LoadingSpinner);
+
+ const outerDiv = container.firstChild;
+ expect(outerDiv).toHaveClass(
+ 'absolute',
+ 'inset-0',
+ 'z-50',
+ 'flex',
+ 'items-center',
+ 'justify-center'
+ );
+ expect(outerDiv).toHaveClass('bg-neutral-800', 'bg-opacity-80', 'md:rounded-lg');
+ });
+
+ test('has proper structure for accessibility', () => {
+ const { container } = render(LoadingSpinner);
+
+ // The outer container should be accessible as a loading indicator
+ const loadingContainer = container.firstChild;
+ expect(loadingContainer).toBeInTheDocument();
+
+ // Check for the loading text
+ const loadingText = screen.getByText('loading...');
+ expect(loadingText).toBeInTheDocument();
+ expect(loadingText.closest('div')).toHaveClass('text-white');
+ });
+
+ test('spinner SVG has correct attributes', () => {
+ const { container } = render(LoadingSpinner);
+
+ const svg = container.querySelector('svg');
+ expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg');
+ expect(svg).toHaveAttribute('fill', 'none');
+ expect(svg).toHaveAttribute('viewBox', '0 0 24 24');
+ });
+});
diff --git a/src/components/__tests__/RouteItem.test.js b/src/components/__tests__/RouteItem.test.js
new file mode 100644
index 00000000..5ede9a99
--- /dev/null
+++ b/src/components/__tests__/RouteItem.test.js
@@ -0,0 +1,438 @@
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { expect, test, describe, vi } from 'vitest';
+import RouteItem from '../RouteItem.svelte';
+import { mockRoutesListData } from '../../tests/fixtures/obaData.js';
+
+describe('RouteItem', () => {
+ // Use enhanced fixtures with proper route types and agency info
+ const mockRouteWithShortAndLong = mockRoutesListData[0]; // Bus route 44
+ const mockLightRailRoute = mockRoutesListData[2]; // 1 Line Light Rail
+ const mockFerryRoute = mockRoutesListData[3]; // Ferry route
+ const mockRailRoute = mockRoutesListData[4]; // Sounder North Line
+
+ const mockRouteWithShortAndDescription = {
+ id: 'route_8',
+ shortName: '8',
+ description: 'Capitol Hill - South Lake Union',
+ color: '008080',
+ type: 3,
+ agencyInfo: {
+ id: '1',
+ name: 'King County Metro'
+ }
+ };
+
+ const mockRouteWithLongNameOnly = {
+ id: 'route_express',
+ longName: 'Express Service',
+ color: 'FF6600',
+ type: 3,
+ agencyInfo: {
+ name: 'Metro Transit'
+ }
+ };
+
+ const mockRouteWithDescriptionOnly = {
+ id: 'route_local',
+ description: 'Local Service',
+ color: '333333',
+ type: 3,
+ agencyInfo: {
+ name: 'Sound Transit'
+ }
+ };
+
+ test('displays route with short name and long name', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ expect(screen.getByText('44 - Ballard - University District')).toBeInTheDocument();
+ });
+
+ test('displays route with short name and description when no long name', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndDescription,
+ handleModalRouteClick
+ }
+ });
+
+ expect(screen.getByText('8 - Capitol Hill - South Lake Union')).toBeInTheDocument();
+ });
+
+ test('displays route with agency name when no short name but has long name', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithLongNameOnly,
+ handleModalRouteClick
+ }
+ });
+
+ expect(screen.getByText('Metro Transit - Express Service')).toBeInTheDocument();
+ });
+
+ test('displays route with agency name when no short name but has description', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithDescriptionOnly,
+ handleModalRouteClick
+ }
+ });
+
+ expect(screen.getByText('Sound Transit - Local Service')).toBeInTheDocument();
+ });
+
+ test('applies route color as text color', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const routeNameElement = screen.getByText('44 - Ballard - University District');
+ expect(routeNameElement).toHaveStyle('color: #0066CC');
+ });
+
+ test('calls handleModalRouteClick when clicked', async () => {
+ const user = userEvent.setup();
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const button = screen.getByRole('button');
+ await user.click(button);
+
+ expect(handleModalRouteClick).toHaveBeenCalledWith(mockRouteWithShortAndLong);
+ expect(handleModalRouteClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('has proper button attributes and classes', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('type', 'button');
+ expect(button).toHaveClass('route-item');
+ expect(button).toHaveClass('flex', 'w-full', 'items-center', 'justify-between');
+ expect(button).toHaveClass('border-b', 'border-gray-200', 'bg-[#f9f9f9]');
+ expect(button).toHaveClass('p-4', 'text-left');
+ expect(button).toHaveClass('hover:bg-[#e9e9e9]', 'focus:outline-none');
+ });
+
+ test('route name has proper styling classes', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const routeNameElement = screen.getByText('44 - Ballard - University District');
+ expect(routeNameElement).toHaveClass('text-lg', 'font-semibold');
+ });
+
+ test('handles route with empty or missing properties gracefully', () => {
+ const handleModalRouteClick = vi.fn();
+ const emptyRoute = {
+ id: 'empty_route',
+ color: '000000'
+ };
+
+ // This should not throw an error
+ expect(() => {
+ render(RouteItem, {
+ props: {
+ route: emptyRoute,
+ handleModalRouteClick
+ }
+ });
+ }).not.toThrow();
+ });
+
+ test('is keyboard accessible', async () => {
+ const user = userEvent.setup();
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const button = screen.getByRole('button');
+
+ // Focus the button
+ button.focus();
+ expect(button).toHaveFocus();
+
+ // Activate with Enter key
+ await user.keyboard('{Enter}');
+ expect(handleModalRouteClick).toHaveBeenCalledWith(mockRouteWithShortAndLong);
+
+ // Reset the mock
+ handleModalRouteClick.mockClear();
+
+ // Activate with Space key
+ await user.keyboard(' ');
+ expect(handleModalRouteClick).toHaveBeenCalledWith(mockRouteWithShortAndLong);
+ });
+
+ test('displays light rail route correctly', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockLightRailRoute,
+ handleModalRouteClick
+ }
+ });
+
+ expect(screen.getByText('1 Line - Seattle - University of Washington')).toBeInTheDocument();
+
+ const routeNameElement = screen.getByText('1 Line - Seattle - University of Washington');
+ expect(routeNameElement).toHaveStyle('color: #0077C0');
+ });
+
+ test('displays ferry route with agency name when no short name', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockFerryRoute,
+ handleModalRouteClick
+ }
+ });
+
+ expect(
+ screen.getByText('Washington State Ferries - Fauntleroy - Vashon Island')
+ ).toBeInTheDocument();
+
+ const routeNameElement = screen.getByText(
+ 'Washington State Ferries - Fauntleroy - Vashon Island'
+ );
+ expect(routeNameElement).toHaveStyle('color: #018571');
+ });
+
+ test('displays rail route correctly', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRailRoute,
+ handleModalRouteClick
+ }
+ });
+
+ expect(screen.getByText('N Line - Seattle - Everett')).toBeInTheDocument();
+
+ const routeNameElement = screen.getByText('N Line - Seattle - Everett');
+ expect(routeNameElement).toHaveStyle('color: #8CC8A0');
+ });
+
+ test('handles missing or invalid route colors gracefully', () => {
+ const handleModalRouteClick = vi.fn();
+ const routeWithoutColor = {
+ ...mockRouteWithShortAndLong,
+ color: null
+ };
+
+ render(RouteItem, {
+ props: {
+ route: routeWithoutColor,
+ handleModalRouteClick
+ }
+ });
+
+ const routeNameElement = screen.getByText('44 - Ballard - University District');
+ // Should still render without crashing
+ expect(routeNameElement).toBeInTheDocument();
+ });
+
+ test('has proper ARIA attributes for accessibility', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const button = screen.getByRole('button');
+
+ // Button should be focusable and have proper role
+ expect(button).toHaveAttribute('type', 'button');
+ expect(button).not.toHaveAttribute('aria-hidden', 'true');
+
+ // Should be accessible to screen readers
+ expect(button).toBeVisible();
+ expect(button).not.toHaveAttribute('tabindex', '-1');
+ });
+
+ test('provides meaningful accessible text for screen readers', () => {
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockLightRailRoute,
+ handleModalRouteClick
+ }
+ });
+
+ const button = screen.getByRole('button');
+ const accessibleText = button.textContent;
+
+ // Should contain route information that's meaningful to screen readers
+ expect(accessibleText).toContain('1 Line');
+ expect(accessibleText).toContain('Seattle - University of Washington');
+ });
+
+ test('handles extremely long route names without breaking layout', () => {
+ const handleModalRouteClick = vi.fn();
+ const routeWithLongName = {
+ ...mockRouteWithShortAndLong,
+ shortName: 'VeryLongRouteNameThatCouldPotentiallyBreakLayout',
+ longName:
+ 'This is an extremely long route name that could potentially cause layout issues if not handled properly in the component'
+ };
+
+ render(RouteItem, {
+ props: {
+ route: routeWithLongName,
+ handleModalRouteClick
+ }
+ });
+
+ // Should render without crashing
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ expect(
+ screen.getByText(/VeryLongRouteNameThatCouldPotentiallyBreakLayout/)
+ ).toBeInTheDocument();
+ });
+
+ test('maintains proper focus management', async () => {
+ const user = userEvent.setup();
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const button = screen.getByRole('button');
+
+ // Should be able to receive focus
+ await user.tab();
+ expect(button).toHaveFocus();
+
+ // Should be able to lose focus
+ await user.tab();
+ expect(button).not.toHaveFocus();
+ });
+
+ test('handles click and keyboard events properly', async () => {
+ const user = userEvent.setup();
+ const handleModalRouteClick = vi.fn();
+
+ render(RouteItem, {
+ props: {
+ route: mockRouteWithShortAndLong,
+ handleModalRouteClick
+ }
+ });
+
+ const button = screen.getByRole('button');
+
+ // Test mouse click
+ await user.click(button);
+ expect(handleModalRouteClick).toHaveBeenCalledTimes(1);
+ expect(handleModalRouteClick).toHaveBeenCalledWith(mockRouteWithShortAndLong);
+
+ handleModalRouteClick.mockClear();
+
+ // Test Enter key
+ button.focus();
+ await user.keyboard('{Enter}');
+ expect(handleModalRouteClick).toHaveBeenCalledTimes(1);
+ expect(handleModalRouteClick).toHaveBeenCalledWith(mockRouteWithShortAndLong);
+
+ handleModalRouteClick.mockClear();
+
+ // Test Space key
+ await user.keyboard(' ');
+ expect(handleModalRouteClick).toHaveBeenCalledTimes(1);
+ expect(handleModalRouteClick).toHaveBeenCalledWith(mockRouteWithShortAndLong);
+ });
+
+ test('displays route colors correctly', () => {
+ const handleModalRouteClick = vi.fn();
+
+ // Test dark color route
+ const darkRoute = {
+ ...mockRouteWithShortAndLong,
+ color: '000000'
+ };
+
+ render(RouteItem, {
+ props: {
+ route: darkRoute,
+ handleModalRouteClick
+ }
+ });
+
+ const darkRouteElement = screen.getByText('44 - Ballard - University District');
+ expect(darkRouteElement).toHaveStyle('color: #000000');
+ });
+
+ test('handles various route color formats', () => {
+ const handleModalRouteClick = vi.fn();
+
+ // Test with different color format (no # prefix)
+ const redRoute = {
+ ...mockRouteWithShortAndLong,
+ color: 'FF0000'
+ };
+
+ render(RouteItem, {
+ props: {
+ route: redRoute,
+ handleModalRouteClick
+ }
+ });
+
+ const redRouteElement = screen.getByText('44 - Ballard - University District');
+ expect(redRouteElement).toHaveStyle('color: #FF0000');
+ });
+});
diff --git a/src/components/navigation/AlertsModal.svelte b/src/components/navigation/AlertsModal.svelte
index 2bc3c696..76fbf191 100644
--- a/src/components/navigation/AlertsModal.svelte
+++ b/src/components/navigation/AlertsModal.svelte
@@ -10,22 +10,26 @@
const currentLanguage = String(getLocaleFromNavigator()).split('-')[0];
function getTranslation(translations) {
+ if (!translations || translations.length === 0) {
+ return '';
+ }
return (
translations.find((t) => t.language === currentLanguage)?.text ||
translations.find((t) => t.language === 'en')?.text ||
- translations[0].text
+ translations[0]?.text ||
+ ''
);
}
function getHeaderTextTranslation() {
- return getTranslation(alert.headerText.translation);
+ return getTranslation(alert.headerText?.translation || []);
}
function getBodyTextTranslation() {
- return getTranslation(alert.descriptionText.translation);
+ return getTranslation(alert.descriptionText?.translation || []);
}
function getUrlTranslation() {
- return getTranslation(alert.url.translation);
+ return getTranslation(alert.url?.translation || []);
}
diff --git a/src/components/navigation/MobileMenu.svelte b/src/components/navigation/MobileMenu.svelte
index 30313290..3ac6d651 100644
--- a/src/components/navigation/MobileMenu.svelte
+++ b/src/components/navigation/MobileMenu.svelte
@@ -11,7 +11,7 @@
>
- {:else if results.length > 0}
+ {:else if results && results.length > 0}
diff --git a/src/components/trip-planner/__tests__/TripPlanSearchField.test.js b/src/components/trip-planner/__tests__/TripPlanSearchField.test.js
new file mode 100644
index 00000000..23730154
--- /dev/null
+++ b/src/components/trip-planner/__tests__/TripPlanSearchField.test.js
@@ -0,0 +1,318 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import TripPlanSearchField from '../TripPlanSearchField.svelte';
+import { renderWithUtils, a11yHelpers } from '../../../tests/helpers/test-utils.js';
+
+// Mock FontAwesome icons
+vi.mock('@fortawesome/svelte-fontawesome', () => ({
+ FontAwesomeIcon: vi.fn(() => ({ $$: { component: 'div' } }))
+}));
+
+// Mock svelte-i18n
+vi.mock('svelte-i18n', () => {
+ const translations = {
+ 'trip-planner.search_for_a_place': 'Search for a place',
+ 'trip-planner.loading': 'Loading'
+ };
+
+ return {
+ t: {
+ subscribe: vi.fn((fn) => {
+ fn((key) => translations[key] || key);
+ return { unsubscribe: () => {} };
+ })
+ }
+ };
+});
+
+describe('TripPlanSearchField', () => {
+ let mockOnInput;
+ let mockOnClear;
+ let mockOnSelect;
+ let user;
+ let defaultProps;
+
+ beforeEach(() => {
+ mockOnInput = vi.fn();
+ mockOnClear = vi.fn();
+ mockOnSelect = vi.fn();
+ user = userEvent.setup();
+
+ defaultProps = {
+ label: 'From:',
+ place: '',
+ results: [],
+ isLoading: false,
+ onInput: mockOnInput,
+ onClear: mockOnClear,
+ onSelect: mockOnSelect
+ };
+ });
+
+ describe('Rendering', () => {
+ it('renders with default props', () => {
+ render(TripPlanSearchField, { props: defaultProps });
+
+ expect(screen.getByLabelText('From:')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Search for a place...')).toBeInTheDocument();
+ });
+
+ it('renders with initial place value', () => {
+ const props = { ...defaultProps, place: 'Capitol Hill' };
+ render(TripPlanSearchField, { props });
+
+ const input = screen.getByDisplayValue('Capitol Hill');
+ expect(input).toBeInTheDocument();
+ });
+
+ it('shows clear button when place has value', () => {
+ const props = { ...defaultProps, place: 'Capitol Hill' };
+ render(TripPlanSearchField, { props });
+
+ const clearButton = screen.getByLabelText('Clear');
+ expect(clearButton).toBeInTheDocument();
+ });
+
+ it('hides clear button when place is empty', () => {
+ render(TripPlanSearchField, { props: defaultProps });
+
+ const clearButton = screen.queryByLabelText('Clear');
+ expect(clearButton).not.toBeInTheDocument();
+ });
+
+ it('shows loading state', () => {
+ const props = { ...defaultProps, isLoading: true };
+ render(TripPlanSearchField, { props });
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('shows autocomplete results', () => {
+ const results = [
+ {
+ displayText: 'Capitol Hill, Seattle, WA, USA',
+ name: 'Capitol Hill'
+ },
+ {
+ displayText: 'University District, Seattle, WA, USA',
+ name: 'University District'
+ }
+ ];
+ const props = { ...defaultProps, results };
+ render(TripPlanSearchField, { props });
+
+ expect(screen.getByText('Capitol Hill, Seattle, WA, USA')).toBeInTheDocument();
+ expect(screen.getByText('University District, Seattle, WA, USA')).toBeInTheDocument();
+ });
+ });
+
+ describe('User Interactions', () => {
+ it('calls onInput when user types', async () => {
+ render(TripPlanSearchField, { props: defaultProps });
+
+ const input = screen.getByPlaceholderText('Search for a place...');
+ await user.type(input, 'Capitol');
+
+ // onInput should be called for each character
+ expect(mockOnInput).toHaveBeenCalledWith('C');
+ expect(mockOnInput).toHaveBeenCalledWith('Ca');
+ expect(mockOnInput).toHaveBeenCalledWith('Cap');
+ expect(mockOnInput).toHaveBeenCalledWith('Capi');
+ expect(mockOnInput).toHaveBeenCalledWith('Capit');
+ expect(mockOnInput).toHaveBeenCalledWith('Capito');
+ expect(mockOnInput).toHaveBeenCalledWith('Capitol');
+ });
+
+ it('calls onClear when clear button is clicked', async () => {
+ const props = { ...defaultProps, place: 'Capitol Hill' };
+ render(TripPlanSearchField, { props });
+
+ const clearButton = screen.getByLabelText('Clear');
+ await user.click(clearButton);
+
+ expect(mockOnClear).toHaveBeenCalledOnce();
+ });
+
+ it('calls onSelect when autocomplete result is clicked', async () => {
+ const result = {
+ displayText: 'Capitol Hill, Seattle, WA, USA',
+ name: 'Capitol Hill'
+ };
+ const props = { ...defaultProps, results: [result] };
+ render(TripPlanSearchField, { props });
+
+ const resultButton = screen.getByText('Capitol Hill, Seattle, WA, USA');
+ await user.click(resultButton);
+
+ expect(mockOnSelect).toHaveBeenCalledWith(result);
+ });
+
+ it('result buttons are focusable', async () => {
+ const results = [
+ { displayText: 'Capitol Hill, Seattle, WA, USA', name: 'Capitol Hill' },
+ { displayText: 'University District, Seattle, WA, USA', name: 'University District' }
+ ];
+ const props = { ...defaultProps, results };
+ render(TripPlanSearchField, { props });
+
+ const firstResult = screen.getByText('Capitol Hill, Seattle, WA, USA');
+ const secondResult = screen.getByText('University District, Seattle, WA, USA');
+
+ // Results should be focusable
+ expect(firstResult).toBeInTheDocument();
+ expect(secondResult).toBeInTheDocument();
+
+ // Should be able to focus on results
+ firstResult.focus();
+ expect(firstResult).toHaveFocus();
+
+ secondResult.focus();
+ expect(secondResult).toHaveFocus();
+ });
+
+ it('selects result with Enter key', async () => {
+ const result = {
+ displayText: 'Capitol Hill, Seattle, WA, USA',
+ name: 'Capitol Hill'
+ };
+ const props = { ...defaultProps, results: [result] };
+ render(TripPlanSearchField, { props });
+
+ const resultButton = screen.getByText('Capitol Hill, Seattle, WA, USA');
+ resultButton.focus();
+ await user.keyboard('{Enter}');
+
+ expect(mockOnSelect).toHaveBeenCalledWith(result);
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('has proper ARIA attributes', () => {
+ render(TripPlanSearchField, { props: defaultProps });
+
+ const input = screen.getByLabelText('From:');
+ expect(input).toHaveAttribute('id', 'location-input');
+ expect(input).toHaveAttribute('type', 'text');
+
+ const label = screen.getByText('From:');
+ expect(label).toHaveAttribute('for', 'location-input');
+ });
+
+ it('clear button has proper accessibility label', () => {
+ const props = { ...defaultProps, place: 'Capitol Hill' };
+ render(TripPlanSearchField, { props });
+
+ const clearButton = screen.getByLabelText('Clear');
+ expect(clearButton).toHaveAttribute('aria-label', 'Clear');
+ expect(clearButton).toHaveAttribute('type', 'button');
+ });
+
+ it('autocomplete results are keyboard accessible', () => {
+ const results = [{ displayText: 'Capitol Hill, Seattle, WA, USA', name: 'Capitol Hill' }];
+ const props = { ...defaultProps, results };
+ render(TripPlanSearchField, { props });
+
+ const resultButton = screen.getByText('Capitol Hill, Seattle, WA, USA');
+ expect(a11yHelpers.isFocusable(resultButton)).toBe(true);
+ });
+
+ it('has proper semantic structure', () => {
+ const results = [
+ { displayText: 'Capitol Hill, Seattle, WA, USA', name: 'Capitol Hill' },
+ { displayText: 'University District, Seattle, WA, USA', name: 'University District' }
+ ];
+ const props = { ...defaultProps, results };
+ render(TripPlanSearchField, { props });
+
+ // Results should be in a list
+ const resultsList = screen.getByRole('list', { hidden: true });
+ expect(resultsList).toBeInTheDocument();
+
+ // Each result should be a button within the list
+ const resultButtons = screen.getAllByRole('button');
+ expect(resultButtons).toHaveLength(2); // Excluding clear button which is conditional
+ });
+
+ it('supports screen readers with proper labeling', () => {
+ const props = { ...defaultProps, isLoading: true };
+ render(TripPlanSearchField, { props });
+
+ // Loading message should be announced to screen readers
+ const loadingMessage = screen.getByText('Loading...');
+ expect(loadingMessage).toBeInTheDocument();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles empty results gracefully', () => {
+ const props = { ...defaultProps, results: [] };
+ render(TripPlanSearchField, { props });
+
+ const resultsList = screen.queryByRole('list', { hidden: true });
+ expect(resultsList).not.toBeInTheDocument();
+ });
+
+ it('handles null/undefined results', () => {
+ const props = { ...defaultProps, results: null };
+ expect(() => render(TripPlanSearchField, { props })).not.toThrow();
+ });
+
+ it('handles results without displayText', () => {
+ const results = [
+ { name: 'Capitol Hill' } // Missing displayText
+ ];
+ const props = { ...defaultProps, results };
+
+ expect(() => render(TripPlanSearchField, { props })).not.toThrow();
+ });
+
+ it('handles very long place names', () => {
+ const longPlaceName = 'A'.repeat(200);
+ const props = { ...defaultProps, place: longPlaceName };
+ render(TripPlanSearchField, { props });
+
+ const input = screen.getByDisplayValue(longPlaceName);
+ expect(input).toBeInTheDocument();
+ });
+
+ it('handles rapid input changes', async () => {
+ render(TripPlanSearchField, { props: defaultProps });
+
+ const input = screen.getByPlaceholderText('Search for a place...');
+
+ // Simulate rapid typing
+ await user.type(input, 'Capitol Hill', { delay: 1 });
+
+ // onInput should be called for each character regardless of speed
+ expect(mockOnInput).toHaveBeenCalledTimes(12); // 'Capitol Hill'.length
+ });
+ });
+
+ describe('Integration', () => {
+ it('works with reactive place binding', async () => {
+ renderWithUtils(TripPlanSearchField, { props: defaultProps });
+
+ const input = screen.getByPlaceholderText('Search for a place...');
+ await user.type(input, 'New Place');
+
+ // The component's internal state should update
+ expect(input).toHaveValue('New Place');
+ });
+
+ it('updates when results prop changes', async () => {
+ const newResults = [{ displayText: 'Capitol Hill, Seattle, WA, USA', name: 'Capitol Hill' }];
+ const props = { ...defaultProps, results: newResults };
+ render(TripPlanSearchField, { props });
+
+ expect(screen.getByText('Capitol Hill, Seattle, WA, USA')).toBeInTheDocument();
+ });
+
+ it('updates when loading state changes', async () => {
+ const props = { ...defaultProps, isLoading: true };
+ render(TripPlanSearchField, { props });
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/lib/keybinding.js b/src/lib/keybinding.js
index 3e76b523..3491d991 100644
--- a/src/lib/keybinding.js
+++ b/src/lib/keybinding.js
@@ -10,23 +10,27 @@
*/
export const keybinding = (node, params) => {
let handler;
+ let currentParams = params;
const removeHandler = () => window.removeEventListener('keydown', handler);
- const setHandler = () => {
+ const setHandler = (newParams) => {
+ if (newParams !== undefined) {
+ currentParams = newParams;
+ }
removeHandler();
- if (!params) {
+ if (!currentParams) {
return;
}
handler = (e) => {
if (
- !!params.alt != e.altKey ||
- !!params.shift != e.shiftKey ||
- !!params.control != (e.ctrlKey || e.metaKey) ||
- params.code != e.code
+ !!currentParams.alt != e.altKey ||
+ !!currentParams.shift != e.shiftKey ||
+ !!currentParams.control != (e.ctrlKey || e.metaKey) ||
+ currentParams.code != e.code
)
return;
e.preventDefault();
- params.callback ? params.callback() : node.click();
+ currentParams.callback ? currentParams.callback() : node.click();
};
window.addEventListener('keydown', handler);
};
diff --git a/src/tests/fixtures/obaData.js b/src/tests/fixtures/obaData.js
new file mode 100644
index 00000000..1d9736a3
--- /dev/null
+++ b/src/tests/fixtures/obaData.js
@@ -0,0 +1,814 @@
+/**
+ * Test fixtures with realistic OneBusAway API response data
+ */
+
+export const mockStopData = {
+ id: '1_75403',
+ name: 'Pine St & 3rd Ave',
+ code: '75403',
+ direction: 'N',
+ lat: 47.61053848,
+ lon: -122.33631134,
+ routeIds: ['1_100479', '1_100044'],
+ routes: [
+ {
+ id: '1_100479',
+ shortName: '10',
+ longName: 'Capitol Hill - Downtown Seattle'
+ },
+ {
+ id: '1_100044',
+ shortName: '11',
+ longName: 'Madison Park - Downtown Seattle'
+ }
+ ]
+};
+
+export const mockStopDataWithoutRoutes = {
+ id: '1_75404',
+ name: 'Test Stop Without Routes',
+ code: '75404',
+ direction: 'S',
+ lat: 47.61053848,
+ lon: -122.33631134,
+ routeIds: [],
+ routes: []
+};
+
+export const mockRouteData = {
+ id: '1_100479',
+ shortName: '10',
+ longName: 'Capitol Hill - Downtown Seattle',
+ description: 'Connects Capitol Hill neighborhood with downtown Seattle',
+ type: 3,
+ url: '',
+ color: '0066CC',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '1',
+ name: 'King County Metro'
+ }
+};
+
+export const mockRoutesListData = [
+ {
+ id: 'route_44',
+ shortName: '44',
+ longName: 'Ballard - University District',
+ description: 'Frequent service between Ballard and University District',
+ type: 3,
+ color: '0066CC',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '1',
+ name: 'King County Metro'
+ }
+ },
+ {
+ id: 'route_8',
+ shortName: '8',
+ longName: 'Capitol Hill - South Lake Union',
+ description: 'Connects Capitol Hill with South Lake Union',
+ type: 3,
+ color: '008080',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '1',
+ name: 'King County Metro'
+ }
+ },
+ {
+ id: 'route_1_line',
+ shortName: '1 Line',
+ longName: 'Seattle - University of Washington',
+ description: 'Light rail service from downtown Seattle to UW',
+ type: 0, // Light rail
+ color: '0077C0',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '40',
+ name: 'Sound Transit'
+ }
+ },
+ {
+ id: 'route_ferry_fauntleroy',
+ shortName: '',
+ longName: 'Fauntleroy - Vashon Island',
+ description: 'Ferry service between Fauntleroy and Vashon Island',
+ type: 4, // Ferry
+ color: '018571',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '95',
+ name: 'Washington State Ferries'
+ }
+ },
+ {
+ id: 'route_sounder_north',
+ shortName: 'N Line',
+ longName: 'Seattle - Everett',
+ description: 'Commuter rail service',
+ type: 2, // Rail
+ color: '8CC8A0',
+ textColor: '000000',
+ agencyInfo: {
+ id: '40',
+ name: 'Sound Transit'
+ }
+ }
+];
+
+export const mockStopsForRouteData = [
+ {
+ id: '1_75403',
+ name: 'Pine St & 3rd Ave',
+ code: '75403',
+ direction: 'N',
+ lat: 47.61053848,
+ lon: -122.33631134
+ },
+ {
+ id: '1_75392',
+ name: 'Pine St & 4th Ave',
+ code: '75392',
+ direction: 'N',
+ lat: 47.6109,
+ lon: -122.3357
+ },
+ {
+ id: '1_30521',
+ name: '15th Ave NE & NE 40th St',
+ code: '30521',
+ direction: 'N',
+ lat: 47.6556,
+ lon: -122.3119
+ }
+];
+
+export const mockServiceAlertsData = [
+ {
+ id: 'alert_1',
+ summary: 'Route 10 Detour',
+ description:
+ 'Route 10 is experiencing a detour due to construction on Pine Street. Buses will use alternative routing via Pike Street.',
+ severity: 'warning',
+ reason: 'CONSTRUCTION',
+ effect: 'DETOUR',
+ cause: 'CONSTRUCTION',
+ url: 'https://metro.kingcounty.gov/alerts/route-10-detour',
+ activeWindows: [
+ {
+ from: Date.now() - 86400000, // 1 day ago
+ to: Date.now() + 604800000 // 1 week from now
+ }
+ ],
+ informedEntities: [
+ {
+ agencyId: '1',
+ routeId: '1_100479',
+ stopId: null,
+ tripId: null
+ }
+ ]
+ },
+ {
+ id: 'alert_2',
+ summary: 'Downtown Transit Tunnel Closed',
+ description:
+ 'The Downtown Seattle Transit Tunnel is temporarily closed for maintenance. All bus routes are operating on surface streets.',
+ severity: 'severe',
+ reason: 'MAINTENANCE',
+ effect: 'DETOUR',
+ cause: 'MAINTENANCE',
+ url: 'https://metro.kingcounty.gov/alerts/tunnel-closure',
+ activeWindows: [
+ {
+ from: Date.now() - 3600000, // 1 hour ago
+ to: Date.now() + 7200000 // 2 hours from now
+ }
+ ],
+ informedEntities: [
+ {
+ agencyId: '1',
+ routeId: null,
+ stopId: null,
+ tripId: null
+ }
+ ]
+ }
+];
+
+export const mockArrivalsData = {
+ stopId: '1_75403',
+ arrivalsAndDepartures: [
+ {
+ routeId: '1_100479',
+ routeShortName: '10',
+ routeLongName: 'Capitol Hill - Downtown Seattle',
+ tripId: '1_604138058',
+ tripHeadsign: 'Capitol Hill',
+ serviceDate: Date.now(),
+ predicted: true,
+ scheduleDeviation: 120, // 2 minutes late
+ distanceFromStop: 0.5, // 0.5 miles away
+ numberOfStopsAway: 3,
+ tripStatus: {
+ activeTripId: '1_604138058',
+ vehicleId: '1_4001',
+ position: {
+ lat: 47.6089,
+ lon: -122.335
+ },
+ orientation: 180.0,
+ status: 'IN_TRANSIT_TO'
+ },
+ frequency: null,
+ predictedArrivalTime: Date.now() + 300000, // 5 minutes from now
+ scheduledArrivalTime: Date.now() + 240000, // 4 minutes from now
+ predictedDepartureTime: Date.now() + 300000,
+ scheduledDepartureTime: Date.now() + 240000,
+ status: 'default',
+ lastUpdateTime: Date.now() - 30000, // 30 seconds ago
+ blockTripSequence: 1,
+ vehicleId: '1_4001'
+ },
+ {
+ routeId: '1_100044',
+ routeShortName: '11',
+ routeLongName: 'Madison Park - Downtown Seattle',
+ tripId: '1_604138059',
+ tripHeadsign: 'Madison Park',
+ serviceDate: Date.now(),
+ predicted: false,
+ scheduleDeviation: 0,
+ distanceFromStop: 1.2,
+ numberOfStopsAway: 7,
+ tripStatus: null,
+ frequency: null,
+ predictedArrivalTime: null,
+ scheduledArrivalTime: Date.now() + 840000, // 14 minutes from now
+ predictedDepartureTime: null,
+ scheduledDepartureTime: Date.now() + 840000,
+ status: 'default',
+ lastUpdateTime: Date.now() - 60000, // 1 minute ago
+ blockTripSequence: 1,
+ vehicleId: null
+ }
+ ]
+};
+
+export const mockArrivalsAndDeparturesResponse = {
+ data: {
+ entry: mockArrivalsData,
+ references: {
+ routes: [
+ {
+ id: '1_100479',
+ nullSafeShortName: '10',
+ shortName: '10',
+ longName: 'Capitol Hill - Downtown Seattle',
+ description: 'Connects Capitol Hill neighborhood with downtown Seattle',
+ type: 3,
+ url: '',
+ color: '0066CC',
+ textColor: 'FFFFFF'
+ },
+ {
+ id: '1_100044',
+ nullSafeShortName: '11',
+ shortName: '11',
+ longName: 'Madison Park - Downtown Seattle',
+ description: 'Connects Madison Park with downtown Seattle',
+ type: 3,
+ url: '',
+ color: '008080',
+ textColor: 'FFFFFF'
+ }
+ ],
+ situations: mockServiceAlertsData
+ }
+ }
+};
+
+export const mockEmptyArrivalsData = {
+ stopId: '1_75403',
+ arrivalsAndDepartures: []
+};
+
+export const mockEmptyArrivalsAndDeparturesResponse = {
+ data: {
+ entry: mockEmptyArrivalsData,
+ references: {
+ routes: [],
+ situations: []
+ }
+ }
+};
+
+export const mockTripDetailsData = {
+ tripId: '1_604138058',
+ serviceDate: Date.now(),
+ frequency: null,
+ schedule: {
+ timeZone: 'America/Los_Angeles',
+ stopTimes: [
+ {
+ stopId: '1_75414',
+ stopName: 'Pine St & 2nd Ave',
+ arrivalTime: Date.now() - 120000, // 2 minutes ago
+ departureTime: Date.now() - 120000,
+ distanceAlongTrip: 0.0,
+ historicalOccupancy: 'MANY_SEATS_AVAILABLE'
+ },
+ {
+ stopId: '1_75403',
+ stopName: 'Pine St & 3rd Ave',
+ arrivalTime: Date.now() + 300000, // 5 minutes from now
+ departureTime: Date.now() + 300000,
+ distanceAlongTrip: 0.1,
+ historicalOccupancy: 'MANY_SEATS_AVAILABLE'
+ },
+ {
+ stopId: '1_75392',
+ stopName: 'Pine St & 4th Ave',
+ arrivalTime: Date.now() + 420000, // 7 minutes from now
+ departureTime: Date.now() + 420000,
+ distanceAlongTrip: 0.2,
+ historicalOccupancy: 'FEW_SEATS_AVAILABLE'
+ }
+ ]
+ },
+ status: {
+ activeTripId: '1_604138058',
+ vehicleId: '1_4001',
+ position: {
+ lat: 47.6089,
+ lon: -122.335
+ },
+ orientation: 180.0,
+ status: 'IN_TRANSIT_TO',
+ lastLocationUpdateTime: Date.now() - 15000, // 15 seconds ago
+ lastKnownLocation: {
+ lat: 47.6089,
+ lon: -122.335
+ },
+ lastKnownOrientation: 180.0,
+ occupancyStatus: 'MANY_SEATS_AVAILABLE'
+ }
+};
+
+export const mockSurveyData = {
+ id: 'survey_transit_quality',
+ title: 'Transit Service Quality Survey',
+ description: 'Help us improve your public transportation experience',
+ questions: [
+ {
+ id: 'overall_satisfaction',
+ text: 'How would you rate your overall satisfaction with the transit service?',
+ type: 'rating',
+ required: true,
+ options: [
+ { value: '1', label: 'Very Dissatisfied' },
+ { value: '2', label: 'Dissatisfied' },
+ { value: '3', label: 'Neutral' },
+ { value: '4', label: 'Satisfied' },
+ { value: '5', label: 'Very Satisfied' }
+ ]
+ },
+ {
+ id: 'service_frequency',
+ text: 'How do you feel about the frequency of service on your route?',
+ type: 'multiple_choice',
+ required: true,
+ options: [
+ { value: 'too_frequent', label: 'Too frequent' },
+ { value: 'just_right', label: 'Just right' },
+ { value: 'not_frequent_enough', label: 'Not frequent enough' }
+ ]
+ },
+ {
+ id: 'suggestions',
+ text: 'What suggestions do you have for improving our service?',
+ type: 'text',
+ required: false,
+ maxLength: 500
+ }
+ ],
+ metadata: {
+ createdDate: Date.now() - 86400000, // 1 day ago
+ expirationDate: Date.now() + 2592000000, // 30 days from now
+ targetAudience: 'all_users'
+ }
+};
+
+export const mockTripPlanData = {
+ from: {
+ name: 'Capitol Hill',
+ lat: 47.6205,
+ lon: -122.3212
+ },
+ to: {
+ name: 'University District',
+ lat: 47.6587,
+ lon: -122.3128
+ },
+ plan: {
+ date: Date.now(),
+ itineraries: [
+ {
+ duration: 1980, // 33 minutes
+ startTime: Date.now() + 300000, // 5 minutes from now
+ endTime: Date.now() + 2280000, // 38 minutes from now
+ walkTime: 540, // 9 minutes
+ transitTime: 1440, // 24 minutes
+ waitingTime: 0,
+ walkDistance: 720.5, // meters
+ elevationLost: 0.0,
+ elevationGained: 12.3,
+ transfers: 0,
+ fare: {
+ fare: {
+ regular: {
+ cents: 275,
+ currency: { symbol: '$', currency: 'USD', defaultFractionDigits: 2 }
+ }
+ }
+ },
+ legs: [
+ {
+ startTime: Date.now() + 300000,
+ endTime: Date.now() + 570000, // 4.5 minutes
+ mode: 'WALK',
+ distance: 360.2,
+ duration: 270, // 4.5 minutes in seconds
+ from: {
+ name: 'Capitol Hill',
+ lat: 47.6205,
+ lon: -122.3212
+ },
+ to: {
+ name: 'Pine St & Broadway',
+ lat: 47.6139,
+ lon: -122.321,
+ stopId: '1_11050'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 15
+ },
+ steps: [
+ {
+ distance: 150.5,
+ relativeDirection: 'LEFT',
+ streetName: 'Pine St',
+ absoluteDirection: 'WEST'
+ },
+ {
+ distance: 209.7,
+ relativeDirection: 'RIGHT',
+ streetName: 'Broadway',
+ absoluteDirection: 'NORTH'
+ }
+ ]
+ },
+ {
+ startTime: Date.now() + 570000,
+ endTime: Date.now() + 2010000, // 24 minutes
+ mode: 'BUS',
+ route: '10',
+ routeShortName: '10',
+ routeLongName: 'Capitol Hill - University District',
+ agencyName: 'King County Metro',
+ distance: 4200.8,
+ duration: 1440, // 24 minutes in seconds
+ from: {
+ name: 'Pine St & Broadway',
+ lat: 47.6139,
+ lon: -122.321,
+ stopId: '1_11050'
+ },
+ to: {
+ name: '15th Ave NE & NE 40th St',
+ lat: 47.6556,
+ lon: -122.3119,
+ stopId: '1_30521'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 42
+ },
+ routeColor: '0066CC',
+ routeTextColor: 'FFFFFF'
+ },
+ {
+ startTime: Date.now() + 2010000,
+ endTime: Date.now() + 2280000, // 4.5 minutes
+ mode: 'WALK',
+ distance: 360.3,
+ duration: 270, // 4.5 minutes in seconds
+ from: {
+ name: '15th Ave NE & NE 40th St',
+ lat: 47.6556,
+ lon: -122.3119,
+ stopId: '1_30521'
+ },
+ to: {
+ name: 'University District',
+ lat: 47.6587,
+ lon: -122.3128
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 12
+ },
+ steps: [
+ {
+ distance: 180.1,
+ relativeDirection: 'CONTINUE',
+ streetName: '15th Ave NE',
+ absoluteDirection: 'NORTH'
+ },
+ {
+ distance: 180.2,
+ relativeDirection: 'RIGHT',
+ streetName: 'NE 45th St',
+ absoluteDirection: 'EAST'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ duration: 2280, // 38 minutes
+ startTime: Date.now() + 600000, // 10 minutes from now
+ endTime: Date.now() + 2880000, // 48 minutes from now
+ walkTime: 720, // 12 minutes
+ transitTime: 1560, // 26 minutes
+ waitingTime: 0,
+ walkDistance: 960.8, // meters
+ elevationLost: 2.1,
+ elevationGained: 15.7,
+ transfers: 1,
+ fare: {
+ fare: {
+ regular: {
+ cents: 275,
+ currency: { symbol: '$', currency: 'USD', defaultFractionDigits: 2 }
+ }
+ }
+ },
+ legs: [
+ {
+ startTime: Date.now() + 600000,
+ endTime: Date.now() + 900000, // 5 minutes
+ mode: 'WALK',
+ distance: 400.4,
+ duration: 300, // 5 minutes in seconds
+ from: {
+ name: 'Capitol Hill',
+ lat: 47.6205,
+ lon: -122.3212
+ },
+ to: {
+ name: 'Broadway & E John St',
+ lat: 47.619,
+ lon: -122.3211,
+ stopId: '1_11080'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 18
+ },
+ steps: [
+ {
+ distance: 200.2,
+ relativeDirection: 'LEFT',
+ streetName: 'E John St',
+ absoluteDirection: 'WEST'
+ },
+ {
+ distance: 200.2,
+ relativeDirection: 'RIGHT',
+ streetName: 'Broadway',
+ absoluteDirection: 'SOUTH'
+ }
+ ]
+ },
+ {
+ startTime: Date.now() + 900000,
+ endTime: Date.now() + 1800000, // 15 minutes
+ mode: 'BUS',
+ route: '49',
+ routeShortName: '49',
+ routeLongName: 'Capitol Hill - University District',
+ agencyName: 'King County Metro',
+ distance: 2800.6,
+ duration: 900, // 15 minutes in seconds
+ from: {
+ name: 'Broadway & E John St',
+ lat: 47.619,
+ lon: -122.3211,
+ stopId: '1_11080'
+ },
+ to: {
+ name: 'NE 45th St & 15th Ave NE',
+ lat: 47.6598,
+ lon: -122.3118,
+ stopId: '1_30530'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 35
+ },
+ routeColor: '008080',
+ routeTextColor: 'FFFFFF'
+ },
+ {
+ startTime: Date.now() + 1800000,
+ endTime: Date.now() + 2460000, // 11 minutes
+ mode: 'BUS',
+ route: '271',
+ routeShortName: '271',
+ routeLongName: 'University District - Bellevue',
+ agencyName: 'King County Metro',
+ distance: 1400.3,
+ duration: 660, // 11 minutes in seconds
+ from: {
+ name: 'NE 45th St & 15th Ave NE',
+ lat: 47.6598,
+ lon: -122.3118,
+ stopId: '1_30530'
+ },
+ to: {
+ name: 'NE 43rd St & University Way NE',
+ lat: 47.6576,
+ lon: -122.3138,
+ stopId: '1_30540'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 28
+ },
+ routeColor: 'FF6600',
+ routeTextColor: 'FFFFFF'
+ },
+ {
+ startTime: Date.now() + 2460000,
+ endTime: Date.now() + 2880000, // 7 minutes
+ mode: 'WALK',
+ distance: 560.4,
+ duration: 420, // 7 minutes in seconds
+ from: {
+ name: 'NE 43rd St & University Way NE',
+ lat: 47.6576,
+ lon: -122.3138,
+ stopId: '1_30540'
+ },
+ to: {
+ name: 'University District',
+ lat: 47.6587,
+ lon: -122.3128
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 22
+ },
+ steps: [
+ {
+ distance: 280.2,
+ relativeDirection: 'CONTINUE',
+ streetName: 'University Way NE',
+ absoluteDirection: 'NORTH'
+ },
+ {
+ distance: 280.2,
+ relativeDirection: 'LEFT',
+ streetName: 'NE 45th St',
+ absoluteDirection: 'WEST'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+};
+
+/**
+ * Mock place suggestion data for autocomplete testing
+ */
+export const mockPlaceSuggestions = {
+ predictions: [
+ {
+ description: 'Capitol Hill, Seattle, WA, USA',
+ place_id: 'ChIJm2VpGR0VkFQRPdcXCFoJNZo',
+ structured_formatting: {
+ main_text: 'Capitol Hill',
+ secondary_text: 'Seattle, WA, USA'
+ },
+ name: 'Capitol Hill',
+ displayText: 'Capitol Hill, Seattle, WA, USA'
+ },
+ {
+ description: 'University District, Seattle, WA, USA',
+ place_id: 'ChIJOX3XzGYUkFQRn2hMcJmhHJ4',
+ structured_formatting: {
+ main_text: 'University District',
+ secondary_text: 'Seattle, WA, USA'
+ },
+ name: 'University District',
+ displayText: 'University District, Seattle, WA, USA'
+ },
+ {
+ description: 'Pike Place Market, Seattle, WA, USA',
+ place_id: 'ChIJJ6kJNO8UkFQRe7-NVV1FZ0Q',
+ structured_formatting: {
+ main_text: 'Pike Place Market',
+ secondary_text: 'Seattle, WA, USA'
+ },
+ name: 'Pike Place Market',
+ displayText: 'Pike Place Market, Seattle, WA, USA'
+ }
+ ],
+ suggestions: [
+ {
+ description: 'Capitol Hill, Seattle, WA, USA',
+ place_id: 'ChIJm2VpGR0VkFQRPdcXCFoJNZo',
+ structured_formatting: {
+ main_text: 'Capitol Hill',
+ secondary_text: 'Seattle, WA, USA'
+ },
+ name: 'Capitol Hill',
+ displayText: 'Capitol Hill, Seattle, WA, USA'
+ },
+ {
+ description: 'University District, Seattle, WA, USA',
+ place_id: 'ChIJOX3XzGYUkFQRn2hMcJmhHJ4',
+ structured_formatting: {
+ main_text: 'University District',
+ secondary_text: 'Seattle, WA, USA'
+ },
+ name: 'University District',
+ displayText: 'University District, Seattle, WA, USA'
+ },
+ {
+ description: 'Pike Place Market, Seattle, WA, USA',
+ place_id: 'ChIJJ6kJNO8UkFQRe7-NVV1FZ0Q',
+ structured_formatting: {
+ main_text: 'Pike Place Market',
+ secondary_text: 'Seattle, WA, USA'
+ },
+ name: 'Pike Place Market',
+ displayText: 'Pike Place Market, Seattle, WA, USA'
+ }
+ ]
+};
+
+/**
+ * Mock geocode response data
+ */
+export const mockGeocodeData = {
+ location: {
+ geometry: {
+ location: {
+ lat: 47.6205,
+ lng: -122.3212
+ }
+ },
+ formatted_address: 'Capitol Hill, Seattle, WA, USA',
+ name: 'Capitol Hill'
+ },
+ results: [
+ {
+ geometry: {
+ location: {
+ lat: 47.6205,
+ lng: -122.3212
+ }
+ },
+ formatted_address: 'Capitol Hill, Seattle, WA, USA',
+ name: 'Capitol Hill'
+ }
+ ]
+};
+
+/**
+ * Mock error response for trip planning
+ */
+export const mockTripPlanError = {
+ error: {
+ code: 'NO_TRANSIT_CONNECTION',
+ message: 'No transit connection found between the specified locations',
+ details: 'Please check your start and end locations and try again'
+ }
+};
+
+/**
+ * Mock empty itineraries response
+ */
+export const mockEmptyItinerariesData = {
+ plan: {
+ date: Date.now(),
+ itineraries: []
+ }
+};
diff --git a/src/tests/helpers/test-utils.js b/src/tests/helpers/test-utils.js
new file mode 100644
index 00000000..596a82c7
--- /dev/null
+++ b/src/tests/helpers/test-utils.js
@@ -0,0 +1,219 @@
+import { render } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { vi } from 'vitest';
+
+/**
+ * Custom render function that sets up common testing utilities
+ * @param {*} Component - Svelte component to render
+ * @param {Object} options - Render options
+ * @param {Object} options.props - Component props
+ * @param {Object} options.context - Component context
+ * @param {boolean} options.withUserEvent - Whether to set up user event utilities
+ * @returns {Object} Extended render result with user event utilities
+ */
+export function renderWithUtils(Component, options = {}) {
+ const { props = {}, context = {}, withUserEvent = true, ...renderOptions } = options;
+
+ const result = render(Component, { props, context, ...renderOptions });
+
+ const utils = {
+ ...result,
+ user: withUserEvent ? userEvent.setup() : null
+ };
+
+ return utils;
+}
+
+/**
+ * Create a mock store for testing Svelte stores
+ * @param {*} initialValue - Initial store value
+ * @returns {Object} Mock store with subscribe, set, and update methods
+ */
+export function createMockStore(initialValue) {
+ let value = initialValue;
+ const subscribers = new Set();
+
+ return {
+ subscribe: vi.fn((fn) => {
+ subscribers.add(fn);
+ fn(value);
+ return {
+ unsubscribe: () => {
+ subscribers.delete(fn);
+ }
+ };
+ }),
+ set: vi.fn((newValue) => {
+ value = newValue;
+ subscribers.forEach((fn) => fn(value));
+ }),
+ update: vi.fn((fn) => {
+ value = fn(value);
+ subscribers.forEach((fn) => fn(value));
+ }),
+ // For testing purposes
+ _getValue: () => value,
+ _getSubscribers: () => subscribers
+ };
+}
+
+/**
+ * Mock SvelteKit navigation functions
+ */
+export function mockSvelteKitNavigation() {
+ return {
+ goto: vi.fn(),
+ invalidate: vi.fn(),
+ invalidateAll: vi.fn(),
+ preloadData: vi.fn(),
+ preloadCode: vi.fn(),
+ replaceState: vi.fn(),
+ pushState: vi.fn()
+ };
+}
+
+/**
+ * Mock page store with common properties
+ */
+export function createMockPageStore(overrides = {}) {
+ const defaultPage = {
+ url: new URL('http://localhost:5173/'),
+ params: {},
+ route: { id: '/' },
+ status: 200,
+ error: null,
+ data: {},
+ form: null,
+ state: {},
+ ...overrides
+ };
+
+ return createMockStore(defaultPage);
+}
+
+/**
+ * Create a mock fetch function for testing API calls
+ */
+export function createMockFetch(responses = {}) {
+ return vi.fn().mockImplementation((url, options = {}) => {
+ const key = `${options.method || 'GET'} ${url}`;
+ const response = responses[key] || responses[url];
+
+ if (response) {
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ text: () => Promise.resolve(JSON.stringify(response)),
+ ...response._meta
+ });
+ }
+
+ // Default response if no mock is provided
+ return Promise.resolve({
+ ok: false,
+ status: 404,
+ json: () => Promise.resolve({ error: 'Not found' }),
+ text: () => Promise.resolve('Not found')
+ });
+ });
+}
+
+/**
+ * Wait for Svelte component to update after state changes
+ */
+export function waitForSvelteUpdate() {
+ return new Promise((resolve) => {
+ // Use setTimeout to wait for next tick
+ setTimeout(resolve, 0);
+ });
+}
+
+/**
+ * Simulate viewport resize for responsive testing
+ */
+export function mockViewportSize(width, height) {
+ Object.defineProperty(window, 'innerWidth', {
+ writable: true,
+ configurable: true,
+ value: width
+ });
+
+ Object.defineProperty(window, 'innerHeight', {
+ writable: true,
+ configurable: true,
+ value: height
+ });
+
+ // Trigger resize event
+ window.dispatchEvent(new Event('resize'));
+}
+
+/**
+ * Mock keyboard event helpers
+ */
+export const keyboardHelpers = {
+ pressEscape: () => new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27 }),
+ pressEnter: () => new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13 }),
+ pressTab: () => new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9 }),
+ pressArrowDown: () => new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40 }),
+ pressArrowUp: () => new KeyboardEvent('keydown', { key: 'ArrowUp', keyCode: 38 })
+};
+
+/**
+ * Accessibility testing helpers
+ */
+export const a11yHelpers = {
+ /**
+ * Check if element has proper ARIA attributes
+ */
+ checkARIAAttributes: (element, expectedAttributes) => {
+ const violations = [];
+
+ Object.entries(expectedAttributes).forEach(([attr, expectedValue]) => {
+ const actualValue = element.getAttribute(attr);
+ if (actualValue !== expectedValue) {
+ violations.push(`Expected ${attr}="${expectedValue}", got "${actualValue}"`);
+ }
+ });
+
+ return {
+ isValid: violations.length === 0,
+ violations
+ };
+ },
+
+ /**
+ * Check if element is focusable
+ */
+ isFocusable: (element) => {
+ const focusableElements = [
+ 'button',
+ 'input',
+ 'select',
+ 'textarea',
+ 'a[href]',
+ '[tabindex]:not([tabindex="-1"])'
+ ];
+
+ return (
+ focusableElements.some((selector) => element.matches(selector)) ||
+ element.getAttribute('tabindex') === '0'
+ );
+ }
+};
+
+/**
+ * Mock component props with default values
+ */
+export function createMockProps(component, overrides = {}) {
+ // This is a basic implementation - in practice, you might want to
+ // introspect the component to get its prop definitions
+ const commonProps = {
+ class: '',
+ id: '',
+ ...overrides
+ };
+
+ return commonProps;
+}
diff --git a/src/tests/lib/keybinding.test.js b/src/tests/lib/keybinding.test.js
index 618bae76..8ee12325 100644
--- a/src/tests/lib/keybinding.test.js
+++ b/src/tests/lib/keybinding.test.js
@@ -24,17 +24,12 @@ describe('keybinding action', () => {
})
};
- // Set up mock window
- const originalWindow = global.window;
- global.window = mockWindow;
-
- beforeEach.cleanup = () => {
- global.window = originalWindow;
- };
+ // Mock window globally
+ vi.stubGlobal('window', mockWindow);
});
afterEach(() => {
- beforeEach.cleanup?.();
+ vi.unstubAllGlobals();
vi.clearAllMocks();
currentHandler = null;
});
@@ -68,9 +63,8 @@ describe('keybinding action', () => {
mockWindow.addEventListener.mockClear();
mockWindow.removeEventListener.mockClear();
- // Update the params object directly
- params.code = 'KeyB';
- action.update();
+ // Update with new params
+ action.update({ code: 'KeyB' });
expect(mockWindow.removeEventListener).toHaveBeenCalledTimes(1);
expect(mockWindow.addEventListener).toHaveBeenCalledTimes(1);
@@ -94,18 +88,16 @@ describe('keybinding action', () => {
});
it('responds to updated key code', () => {
- // Create params object that we'll modify
- let params = { code: 'KeyA' };
- const action = keybinding(node, params);
+ // Create initial binding
+ const action = keybinding(node, { code: 'KeyA' });
// Verify initial binding works
triggerKeydown({ code: 'KeyA' });
expect(node.click).toHaveBeenCalledTimes(1);
- // Update params and trigger update
+ // Update with new params
node.click.mockClear();
- params.code = 'KeyB';
- action.update();
+ action.update({ code: 'KeyB' });
// Verify new binding works
triggerKeydown({ code: 'KeyB' });
diff --git a/src/tests/mocks/handlers.js b/src/tests/mocks/handlers.js
new file mode 100644
index 00000000..1293095b
--- /dev/null
+++ b/src/tests/mocks/handlers.js
@@ -0,0 +1,840 @@
+import { http, HttpResponse } from 'msw';
+
+export const handlers = [
+ // OneBusAway API mocks
+ http.get('/api/oba/search', ({ request }) => {
+ const url = new URL(request.url);
+ const query = url.searchParams.get('query');
+
+ return HttpResponse.json({
+ results: [
+ {
+ id: 'stop-123',
+ name: `Test Stop for ${query}`,
+ type: 'stop',
+ coordinates: [47.6062, -122.3321],
+ lat: 47.6062,
+ lon: -122.3321
+ },
+ {
+ id: 'route-456',
+ name: `Test Route for ${query}`,
+ type: 'route',
+ shortName: '44',
+ longName: `${query} Express`
+ }
+ ]
+ });
+ }),
+
+ http.get('/api/oba/arrivals-and-departures-for-stop/:id', ({ params }) => {
+ const { id } = params;
+
+ // Return empty arrivals for specific test case
+ if (id === 'empty_stop') {
+ return HttpResponse.json({
+ data: {
+ entry: {
+ stopId: id,
+ arrivalsAndDepartures: []
+ },
+ references: {
+ routes: [],
+ situations: []
+ }
+ }
+ });
+ }
+
+ // Return error for specific test case
+ if (id === 'error_stop') {
+ return new HttpResponse(null, { status: 500 });
+ }
+
+ return HttpResponse.json({
+ data: {
+ entry: {
+ stopId: id,
+ arrivalsAndDepartures: [
+ {
+ routeId: '1_100479',
+ routeShortName: '10',
+ routeLongName: 'Capitol Hill - Downtown Seattle',
+ tripId: '1_604138058',
+ tripHeadsign: 'Capitol Hill',
+ serviceDate: Date.now(),
+ predicted: true,
+ scheduleDeviation: 120,
+ distanceFromStop: 0.5,
+ numberOfStopsAway: 3,
+ tripStatus: {
+ activeTripId: '1_604138058',
+ vehicleId: '1_4001',
+ position: { lat: 47.6089, lon: -122.335 },
+ orientation: 180.0,
+ status: 'IN_TRANSIT_TO'
+ },
+ frequency: null,
+ predictedArrivalTime: Date.now() + 300000,
+ scheduledArrivalTime: Date.now() + 240000,
+ predictedDepartureTime: Date.now() + 300000,
+ scheduledDepartureTime: Date.now() + 240000,
+ status: 'default',
+ lastUpdateTime: Date.now() - 30000,
+ blockTripSequence: 1,
+ vehicleId: '1_4001'
+ },
+ {
+ routeId: '1_100044',
+ routeShortName: '11',
+ routeLongName: 'Madison Park - Downtown Seattle',
+ tripId: '1_604138059',
+ tripHeadsign: 'Madison Park',
+ serviceDate: Date.now(),
+ predicted: false,
+ scheduleDeviation: 0,
+ distanceFromStop: 1.2,
+ numberOfStopsAway: 7,
+ tripStatus: null,
+ frequency: null,
+ predictedArrivalTime: null,
+ scheduledArrivalTime: Date.now() + 840000,
+ predictedDepartureTime: null,
+ scheduledDepartureTime: Date.now() + 840000,
+ status: 'default',
+ lastUpdateTime: Date.now() - 60000,
+ blockTripSequence: 1,
+ vehicleId: null
+ }
+ ]
+ },
+ references: {
+ routes: [
+ {
+ id: '1_100479',
+ nullSafeShortName: '10',
+ shortName: '10',
+ longName: 'Capitol Hill - Downtown Seattle',
+ description: 'Connects Capitol Hill neighborhood with downtown Seattle',
+ type: 3,
+ url: '',
+ color: '0066CC',
+ textColor: 'FFFFFF'
+ },
+ {
+ id: '1_100044',
+ nullSafeShortName: '11',
+ shortName: '11',
+ longName: 'Madison Park - Downtown Seattle',
+ description: 'Connects Madison Park with downtown Seattle',
+ type: 3,
+ url: '',
+ color: '008080',
+ textColor: 'FFFFFF'
+ }
+ ],
+ situations: [
+ {
+ id: 'alert_1',
+ summary: 'Route 10 Detour',
+ description: 'Route 10 is experiencing a detour due to construction on Pine Street.',
+ severity: 'warning',
+ reason: 'CONSTRUCTION',
+ effect: 'DETOUR',
+ cause: 'CONSTRUCTION',
+ url: 'https://metro.kingcounty.gov/alerts/route-10-detour',
+ activeWindows: [
+ {
+ from: Date.now() - 86400000,
+ to: Date.now() + 604800000
+ }
+ ],
+ informedEntities: [
+ {
+ agencyId: '1',
+ routeId: '1_100479',
+ stopId: null,
+ tripId: null
+ }
+ ]
+ }
+ ]
+ }
+ }
+ });
+ }),
+
+ http.get('/api/oba/stop/:stopID', ({ params }) => {
+ const { stopID } = params;
+
+ // Return different data based on stop ID for testing
+ if (stopID === '1_75404') {
+ return HttpResponse.json({
+ data: {
+ entry: {
+ id: stopID,
+ name: 'Test Stop Without Routes',
+ code: '75404',
+ direction: 'S',
+ lat: 47.61053848,
+ lon: -122.33631134,
+ routeIds: [],
+ routes: []
+ }
+ }
+ });
+ }
+
+ return HttpResponse.json({
+ data: {
+ entry: {
+ id: stopID,
+ name: 'Pine St & 3rd Ave',
+ code: '75403',
+ direction: 'N',
+ lat: 47.61053848,
+ lon: -122.33631134,
+ routeIds: ['1_100479', '1_100044'],
+ routes: [
+ {
+ id: '1_100479',
+ shortName: '10',
+ longName: 'Capitol Hill - Downtown Seattle'
+ },
+ {
+ id: '1_100044',
+ shortName: '11',
+ longName: 'Madison Park - Downtown Seattle'
+ }
+ ]
+ }
+ }
+ });
+ }),
+
+ http.get('/api/oba/routes', () => {
+ return HttpResponse.json({
+ routes: [
+ {
+ id: 'route_44',
+ shortName: '44',
+ longName: 'Ballard - University District',
+ description: 'Frequent service between Ballard and University District',
+ type: 3,
+ color: '0066CC',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '1',
+ name: 'King County Metro'
+ }
+ },
+ {
+ id: 'route_8',
+ shortName: '8',
+ longName: 'Capitol Hill - South Lake Union',
+ description: 'Connects Capitol Hill with South Lake Union',
+ type: 3,
+ color: '008080',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '1',
+ name: 'King County Metro'
+ }
+ },
+ {
+ id: 'route_1_line',
+ shortName: '1 Line',
+ longName: 'Seattle - University of Washington',
+ description: 'Light rail service from downtown Seattle to UW',
+ type: 0,
+ color: '0077C0',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '40',
+ name: 'Sound Transit'
+ }
+ },
+ {
+ id: 'route_ferry_fauntleroy',
+ shortName: '',
+ longName: 'Fauntleroy - Vashon Island',
+ description: 'Ferry service between Fauntleroy and Vashon Island',
+ type: 4,
+ color: '018571',
+ textColor: 'FFFFFF',
+ agencyInfo: {
+ id: '95',
+ name: 'Washington State Ferries'
+ }
+ }
+ ]
+ });
+ }),
+
+ // Add route-specific endpoints
+ http.get('/api/oba/stops-for-route/:routeId', ({ params }) => {
+ const { routeId } = params;
+ return HttpResponse.json({
+ data: {
+ entry: {
+ routeId: routeId,
+ stops: [
+ {
+ id: '1_75403',
+ name: 'Pine St & 3rd Ave',
+ code: '75403',
+ direction: 'N',
+ lat: 47.61053848,
+ lon: -122.33631134
+ },
+ {
+ id: '1_75392',
+ name: 'Pine St & 4th Ave',
+ code: '75392',
+ direction: 'N',
+ lat: 47.6109,
+ lon: -122.3357
+ },
+ {
+ id: '1_30521',
+ name: '15th Ave NE & NE 40th St',
+ code: '30521',
+ direction: 'N',
+ lat: 47.6556,
+ lon: -122.3119
+ }
+ ]
+ }
+ }
+ });
+ }),
+
+ // Add error handling for routes API
+ http.get('/api/oba/routes-error', () => {
+ return HttpResponse.json({ error: 'Failed to fetch routes' }, { status: 500 });
+ }),
+
+ http.get('/api/oba/schedule-for-stop/:stopId', ({ params }) => {
+ const { stopId } = params;
+ return HttpResponse.json({
+ data: {
+ entry: {
+ stopId: stopId,
+ stopTimes: [
+ {
+ arrivalTime: '08:15',
+ departureTime: '08:15',
+ routeShortName: '44',
+ tripHeadsign: 'University District'
+ },
+ {
+ arrivalTime: '08:30',
+ departureTime: '08:30',
+ routeShortName: '44',
+ tripHeadsign: 'University District'
+ }
+ ]
+ }
+ }
+ });
+ }),
+
+ http.get('/api/oba/trip-details/:tripId', ({ params }) => {
+ const { tripId } = params;
+ return HttpResponse.json({
+ data: {
+ entry: {
+ tripId: tripId,
+ routeId: 'route_44',
+ routeShortName: '44',
+ tripHeadsign: 'University District',
+ schedule: {
+ stopTimes: [
+ {
+ stopId: 'stop_1',
+ stopName: 'Pine St & 3rd Ave',
+ arrivalTime: '08:15',
+ departureTime: '08:15'
+ },
+ {
+ stopId: 'stop_2',
+ stopName: 'Pine St & 5th Ave',
+ arrivalTime: '08:17',
+ departureTime: '08:17'
+ }
+ ]
+ }
+ }
+ }
+ });
+ }),
+
+ http.get('/api/oba/surveys', () => {
+ return HttpResponse.json({
+ surveys: [
+ {
+ id: 'survey_1',
+ title: 'Service Quality Survey',
+ description: 'Help us improve your transit experience',
+ questions: [
+ {
+ id: 'q1',
+ text: 'How satisfied are you with the service?',
+ type: 'rating',
+ options: ['1', '2', '3', '4', '5']
+ }
+ ]
+ }
+ ]
+ });
+ }),
+
+ http.post('/api/oba/surveys/submit-survey', () => {
+ return HttpResponse.json({
+ success: true,
+ message: 'Survey submitted successfully'
+ });
+ }),
+
+ http.get('/api/oba/alerts', () => {
+ return HttpResponse.json({
+ data: {
+ list: [
+ {
+ id: 'alert_1',
+ summary: 'Service Alert',
+ description: 'Route 44 experiencing delays due to traffic',
+ severity: 'warning',
+ activeWindows: [
+ {
+ from: Date.now() - 3600000, // 1 hour ago
+ to: Date.now() + 3600000 // 1 hour from now
+ }
+ ]
+ }
+ ]
+ }
+ });
+ }),
+
+ // OpenTripPlanner API mocks
+ http.get('/api/otp/plan', ({ request }) => {
+ const url = new URL(request.url);
+ const from = url.searchParams.get('fromPlace');
+ const to = url.searchParams.get('toPlace');
+
+ // Mock error case for invalid locations
+ if (from === '0,0' || to === '0,0') {
+ return HttpResponse.json(
+ {
+ error: {
+ code: 'NO_TRANSIT_CONNECTION',
+ message: 'No transit connection found between the specified locations',
+ details: 'Please check your start and end locations and try again'
+ }
+ },
+ { status: 400 }
+ );
+ }
+
+ // Mock empty results for specific test case
+ if (from?.includes('NoResults') || to?.includes('NoResults')) {
+ return HttpResponse.json({
+ plan: {
+ date: Date.now(),
+ itineraries: []
+ }
+ });
+ }
+
+ // Default successful response with realistic itineraries
+ const baseTime = Date.now();
+ return HttpResponse.json({
+ plan: {
+ date: baseTime,
+ itineraries: [
+ {
+ duration: 1980, // 33 minutes
+ startTime: baseTime + 300000, // 5 minutes from now
+ endTime: baseTime + 2280000, // 38 minutes from now
+ walkTime: 540, // 9 minutes
+ transitTime: 1440, // 24 minutes
+ waitingTime: 0,
+ walkDistance: 720.5, // meters
+ elevationLost: 0.0,
+ elevationGained: 12.3,
+ transfers: 0,
+ fare: {
+ fare: {
+ regular: {
+ cents: 275,
+ currency: { symbol: '$', currency: 'USD', defaultFractionDigits: 2 }
+ }
+ }
+ },
+ legs: [
+ {
+ startTime: baseTime + 300000,
+ endTime: baseTime + 570000, // 4.5 minutes
+ mode: 'WALK',
+ distance: 360.2,
+ duration: 270, // 4.5 minutes in seconds
+ from: {
+ name: from?.split(',')[0] || 'Origin',
+ lat: parseFloat(from?.split(',')[0]) || 47.6205,
+ lon: parseFloat(from?.split(',')[1]) || -122.3212
+ },
+ to: {
+ name: 'Pine St & Broadway',
+ lat: 47.6139,
+ lon: -122.321,
+ stopId: '1_11050'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 15
+ },
+ steps: [
+ {
+ distance: 150.5,
+ relativeDirection: 'LEFT',
+ streetName: 'Pine St',
+ absoluteDirection: 'WEST'
+ },
+ {
+ distance: 209.7,
+ relativeDirection: 'RIGHT',
+ streetName: 'Broadway',
+ absoluteDirection: 'NORTH'
+ }
+ ]
+ },
+ {
+ startTime: baseTime + 570000,
+ endTime: baseTime + 2010000, // 24 minutes
+ mode: 'BUS',
+ route: '10',
+ routeShortName: '10',
+ routeLongName: 'Capitol Hill - University District',
+ agencyName: 'King County Metro',
+ distance: 4200.8,
+ duration: 1440, // 24 minutes in seconds
+ from: {
+ name: 'Pine St & Broadway',
+ lat: 47.6139,
+ lon: -122.321,
+ stopId: '1_11050'
+ },
+ to: {
+ name: '15th Ave NE & NE 40th St',
+ lat: 47.6556,
+ lon: -122.3119,
+ stopId: '1_30521'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 42
+ },
+ routeColor: '0066CC',
+ routeTextColor: 'FFFFFF'
+ },
+ {
+ startTime: baseTime + 2010000,
+ endTime: baseTime + 2280000, // 4.5 minutes
+ mode: 'WALK',
+ distance: 360.3,
+ duration: 270, // 4.5 minutes in seconds
+ from: {
+ name: '15th Ave NE & NE 40th St',
+ lat: 47.6556,
+ lon: -122.3119,
+ stopId: '1_30521'
+ },
+ to: {
+ name: to?.split(',')[0] || 'Destination',
+ lat: parseFloat(to?.split(',')[0]) || 47.6587,
+ lon: parseFloat(to?.split(',')[1]) || -122.3128
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 12
+ },
+ steps: [
+ {
+ distance: 180.1,
+ relativeDirection: 'CONTINUE',
+ streetName: '15th Ave NE',
+ absoluteDirection: 'NORTH'
+ },
+ {
+ distance: 180.2,
+ relativeDirection: 'RIGHT',
+ streetName: 'NE 45th St',
+ absoluteDirection: 'EAST'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ duration: 2280, // 38 minutes
+ startTime: baseTime + 600000, // 10 minutes from now
+ endTime: baseTime + 2880000, // 48 minutes from now
+ walkTime: 720, // 12 minutes
+ transitTime: 1560, // 26 minutes
+ waitingTime: 0,
+ walkDistance: 960.8, // meters
+ elevationLost: 2.1,
+ elevationGained: 15.7,
+ transfers: 1,
+ fare: {
+ fare: {
+ regular: {
+ cents: 275,
+ currency: { symbol: '$', currency: 'USD', defaultFractionDigits: 2 }
+ }
+ }
+ },
+ legs: [
+ {
+ startTime: baseTime + 600000,
+ endTime: baseTime + 900000, // 5 minutes
+ mode: 'WALK',
+ distance: 400.4,
+ duration: 300, // 5 minutes in seconds
+ from: {
+ name: from?.split(',')[0] || 'Origin',
+ lat: parseFloat(from?.split(',')[0]) || 47.6205,
+ lon: parseFloat(from?.split(',')[1]) || -122.3212
+ },
+ to: {
+ name: 'Broadway & E John St',
+ lat: 47.619,
+ lon: -122.3211,
+ stopId: '1_11080'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 18
+ },
+ steps: [
+ {
+ distance: 200.2,
+ relativeDirection: 'LEFT',
+ streetName: 'E John St',
+ absoluteDirection: 'WEST'
+ },
+ {
+ distance: 200.2,
+ relativeDirection: 'RIGHT',
+ streetName: 'Broadway',
+ absoluteDirection: 'SOUTH'
+ }
+ ]
+ },
+ {
+ startTime: baseTime + 900000,
+ endTime: baseTime + 1800000, // 15 minutes
+ mode: 'BUS',
+ route: '49',
+ routeShortName: '49',
+ routeLongName: 'Capitol Hill - University District',
+ agencyName: 'King County Metro',
+ distance: 2800.6,
+ duration: 900, // 15 minutes in seconds
+ from: {
+ name: 'Broadway & E John St',
+ lat: 47.619,
+ lon: -122.3211,
+ stopId: '1_11080'
+ },
+ to: {
+ name: 'NE 45th St & 15th Ave NE',
+ lat: 47.6598,
+ lon: -122.3118,
+ stopId: '1_30530'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 35
+ },
+ routeColor: '008080',
+ routeTextColor: 'FFFFFF'
+ },
+ {
+ startTime: baseTime + 1800000,
+ endTime: baseTime + 2460000, // 11 minutes
+ mode: 'BUS',
+ route: '271',
+ routeShortName: '271',
+ routeLongName: 'University District - Bellevue',
+ agencyName: 'King County Metro',
+ distance: 1400.3,
+ duration: 660, // 11 minutes in seconds
+ from: {
+ name: 'NE 45th St & 15th Ave NE',
+ lat: 47.6598,
+ lon: -122.3118,
+ stopId: '1_30530'
+ },
+ to: {
+ name: 'NE 43rd St & University Way NE',
+ lat: 47.6576,
+ lon: -122.3138,
+ stopId: '1_30540'
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 28
+ },
+ routeColor: 'FF6600',
+ routeTextColor: 'FFFFFF'
+ },
+ {
+ startTime: baseTime + 2460000,
+ endTime: baseTime + 2880000, // 7 minutes
+ mode: 'WALK',
+ distance: 560.4,
+ duration: 420, // 7 minutes in seconds
+ from: {
+ name: 'NE 43rd St & University Way NE',
+ lat: 47.6576,
+ lon: -122.3138,
+ stopId: '1_30540'
+ },
+ to: {
+ name: to?.split(',')[0] || 'Destination',
+ lat: parseFloat(to?.split(',')[0]) || 47.6587,
+ lon: parseFloat(to?.split(',')[1]) || -122.3128
+ },
+ legGeometry: {
+ points: 'encoded_polyline_points_here',
+ length: 22
+ },
+ steps: [
+ {
+ distance: 280.2,
+ relativeDirection: 'CONTINUE',
+ streetName: 'University Way NE',
+ absoluteDirection: 'NORTH'
+ },
+ {
+ distance: 280.2,
+ relativeDirection: 'LEFT',
+ streetName: 'NE 45th St',
+ absoluteDirection: 'WEST'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ });
+ }),
+
+ // Geocoding API mocks
+ http.get('/api/oba/geocode-location', ({ request }) => {
+ const url = new URL(request.url);
+ const query = url.searchParams.get('query');
+
+ // Mock error case
+ if (query === 'InvalidLocation') {
+ return HttpResponse.json(
+ {
+ error: 'Location not found'
+ },
+ { status: 404 }
+ );
+ }
+
+ // Default geocoding response
+ const coords =
+ query === 'Capitol Hill'
+ ? { lat: 47.6205, lng: -122.3212 }
+ : query === 'University District'
+ ? { lat: 47.6587, lng: -122.3128 }
+ : { lat: 47.6062, lng: -122.3321 }; // Default Seattle coordinates
+
+ return HttpResponse.json({
+ location: {
+ geometry: {
+ location: coords
+ },
+ formatted_address: `${query}, Seattle, WA, USA`,
+ name: query
+ },
+ results: [
+ {
+ geometry: {
+ location: coords
+ },
+ formatted_address: `${query}, Seattle, WA, USA`,
+ name: query
+ }
+ ]
+ });
+ }),
+
+ http.get('/api/oba/place-suggestions', ({ request }) => {
+ const url = new URL(request.url);
+ const query = url.searchParams.get('query');
+
+ // Mock empty results for specific test case
+ if (query === 'NoResults') {
+ return HttpResponse.json({
+ suggestions: [],
+ predictions: []
+ });
+ }
+
+ // Mock error case
+ if (query === 'ErrorCase') {
+ return HttpResponse.json(
+ {
+ error: 'Place suggestions service unavailable'
+ },
+ { status: 500 }
+ );
+ }
+
+ // Default suggestions based on query
+ const suggestions = [
+ {
+ description: `${query} - Test Location`,
+ place_id: `place_${query.replace(/\s+/g, '_').toLowerCase()}`,
+ structured_formatting: {
+ main_text: query,
+ secondary_text: 'Test Location, Seattle, WA, USA'
+ },
+ name: query,
+ displayText: `${query}, Seattle, WA, USA`
+ },
+ {
+ description: `${query} Station`,
+ place_id: `place_${query.replace(/\s+/g, '_').toLowerCase()}_station`,
+ structured_formatting: {
+ main_text: `${query} Station`,
+ secondary_text: 'Transit Station, Seattle, WA, USA'
+ },
+ name: `${query} Station`,
+ displayText: `${query} Station, Seattle, WA, USA`
+ },
+ {
+ description: `${query} Center`,
+ place_id: `place_${query.replace(/\s+/g, '_').toLowerCase()}_center`,
+ structured_formatting: {
+ main_text: `${query} Center`,
+ secondary_text: 'Shopping Center, Seattle, WA, USA'
+ },
+ name: `${query} Center`,
+ displayText: `${query} Center, Seattle, WA, USA`
+ }
+ ];
+
+ return HttpResponse.json({
+ suggestions,
+ predictions: suggestions
+ });
+ })
+];
diff --git a/src/tests/mocks/mapProviders.js b/src/tests/mocks/mapProviders.js
new file mode 100644
index 00000000..53f6d3f3
--- /dev/null
+++ b/src/tests/mocks/mapProviders.js
@@ -0,0 +1,269 @@
+import { vi } from 'vitest';
+
+export const mockLeafletMap = {
+ setView: vi.fn(),
+ addLayer: vi.fn(),
+ removeLayer: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ fitBounds: vi.fn(),
+ getZoom: vi.fn(() => 15),
+ getCenter: vi.fn(() => ({ lat: 47.6062, lng: -122.3321 })),
+ invalidateSize: vi.fn(),
+ remove: vi.fn(),
+ eachLayer: vi.fn(),
+ hasLayer: vi.fn(() => false),
+ getPane: vi.fn(() => document.createElement('div')),
+ getContainer: vi.fn(() => document.createElement('div')),
+ getBounds: vi.fn(() => ({
+ getNorth: () => 47.7,
+ getSouth: () => 47.5,
+ getEast: () => -122.2,
+ getWest: () => -122.4
+ }))
+};
+
+export const mockLeafletMarker = {
+ addTo: vi.fn(),
+ setLatLng: vi.fn(),
+ remove: vi.fn(),
+ bindPopup: vi.fn(),
+ openPopup: vi.fn(),
+ closePopup: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ getLatLng: vi.fn(() => ({ lat: 47.6062, lng: -122.3321 }))
+};
+
+export const mockLeafletTileLayer = {
+ addTo: vi.fn(),
+ remove: vi.fn(),
+ setOpacity: vi.fn()
+};
+
+export const mockLeafletPolyline = {
+ addTo: vi.fn(),
+ remove: vi.fn(),
+ setStyle: vi.fn(),
+ getBounds: vi.fn(() => ({
+ getNorth: () => 47.7,
+ getSouth: () => 47.5,
+ getEast: () => -122.2,
+ getWest: () => -122.4
+ }))
+};
+
+export const mockGoogleMap = {
+ setCenter: vi.fn(),
+ setZoom: vi.fn(),
+ getZoom: vi.fn(() => 15),
+ getCenter: vi.fn(() => ({ lat: () => 47.6062, lng: () => -122.3321 })),
+ fitBounds: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ getDiv: vi.fn(() => document.createElement('div')),
+ getBounds: vi.fn(() => ({
+ getNorthEast: () => ({ lat: () => 47.7, lng: () => -122.2 }),
+ getSouthWest: () => ({ lat: () => 47.5, lng: () => -122.4 })
+ }))
+};
+
+export const mockGoogleMarker = {
+ setPosition: vi.fn(),
+ setMap: vi.fn(),
+ addListener: vi.fn(),
+ getPosition: vi.fn(() => ({ lat: () => 47.6062, lng: () => -122.3321 }))
+};
+
+// Mock Leaflet module
+vi.mock('leaflet', () => ({
+ map: vi.fn(() => mockLeafletMap),
+ tileLayer: vi.fn(() => mockLeafletTileLayer),
+ marker: vi.fn(() => mockLeafletMarker),
+ polyline: vi.fn(() => mockLeafletPolyline),
+ icon: vi.fn(() => ({})),
+ divIcon: vi.fn(() => ({})),
+ latLng: vi.fn((lat, lng) => ({ lat, lng })),
+ latLngBounds: vi.fn(() => ({
+ extend: vi.fn(),
+ isValid: vi.fn(() => true)
+ }))
+}));
+
+// Mock Google Maps module
+vi.mock('$lib/googleMaps.js', () => ({
+ loadGoogleMaps: vi.fn().mockResolvedValue({
+ Map: vi.fn(() => mockGoogleMap),
+ Marker: vi.fn(() => mockGoogleMarker),
+ LatLng: vi.fn((lat, lng) => ({ lat: () => lat, lng: () => lng })),
+ LatLngBounds: vi.fn(() => ({
+ extend: vi.fn(),
+ getNorthEast: vi.fn(),
+ getSouthWest: vi.fn()
+ }))
+ })
+}));
+
+/**
+ * Mock map provider for trip planning tests
+ * Simulates the interface used by trip planning components
+ */
+export function createMockMapProvider() {
+ const markers = new Map();
+ const polylines = [];
+ let markerIdCounter = 0;
+ let polylineIdCounter = 0;
+
+ return {
+ // Map management
+ map: mockLeafletMap,
+
+ // Pin marker management for trip planning
+ addPinMarker: vi.fn((location, label) => {
+ const markerId = `pin_marker_${markerIdCounter++}`;
+ const marker = {
+ ...mockLeafletMarker,
+ id: markerId,
+ location,
+ label,
+ type: 'pin'
+ };
+ markers.set(markerId, marker);
+ return marker;
+ }),
+
+ removePinMarker: vi.fn((marker) => {
+ if (marker && marker.id) {
+ markers.delete(marker.id);
+ }
+ }),
+
+ // Polyline management for route display
+ createPolyline: vi.fn((points, style = {}, decode = false) => {
+ const polylineId = `polyline_${polylineIdCounter++}`;
+ const polyline = {
+ ...mockLeafletPolyline,
+ id: polylineId,
+ points,
+ style,
+ decoded: decode
+ };
+ polylines.push(polyline);
+ return Promise.resolve(polyline);
+ }),
+
+ removePolyline: vi.fn((polyline) => {
+ if (polyline && polyline.id) {
+ const index = polylines.findIndex((p) => p.id === polyline.id);
+ if (index !== -1) {
+ polylines.splice(index, 1);
+ }
+ }
+ }),
+
+ // Stop marker management
+ addStopMarker: vi.fn((stop) => {
+ const markerId = `stop_marker_${markerIdCounter++}`;
+ const marker = {
+ ...mockLeafletMarker,
+ id: markerId,
+ stop,
+ type: 'stop'
+ };
+ markers.set(markerId, marker);
+ return marker;
+ }),
+
+ removeStopMarker: vi.fn((marker) => {
+ if (marker && marker.id) {
+ markers.delete(marker.id);
+ }
+ }),
+
+ // Vehicle marker management
+ addVehicleMarker: vi.fn((vehicle) => {
+ const markerId = `vehicle_marker_${markerIdCounter++}`;
+ const marker = {
+ ...mockLeafletMarker,
+ id: markerId,
+ vehicle,
+ type: 'vehicle'
+ };
+ markers.set(markerId, marker);
+ return marker;
+ }),
+
+ removeVehicleMarker: vi.fn((marker) => {
+ if (marker && marker.id) {
+ markers.delete(marker.id);
+ }
+ }),
+
+ // Map view management
+ setView: vi.fn((center, zoom) => {
+ mockLeafletMap.setView(center, zoom);
+ }),
+
+ fitBounds: vi.fn((bounds) => {
+ mockLeafletMap.fitBounds(bounds);
+ }),
+
+ // Event handling
+ on: vi.fn((event, handler) => {
+ mockLeafletMap.on(event, handler);
+ }),
+
+ off: vi.fn((event, handler) => {
+ mockLeafletMap.off(event, handler);
+ }),
+
+ // Testing utilities
+ _getMarkers: () => markers,
+ _getPolylines: () => polylines,
+ _clearMarkers: () => {
+ markers.clear();
+ markerIdCounter = 0;
+ },
+ _clearPolylines: () => {
+ polylines.length = 0;
+ polylineIdCounter = 0;
+ },
+ _reset: () => {
+ markers.clear();
+ polylines.length = 0;
+ markerIdCounter = 0;
+ polylineIdCounter = 0;
+ }
+ };
+}
+
+/**
+ * Mock map provider with error scenarios for testing
+ */
+export function createErrorMapProvider() {
+ return {
+ addPinMarker: vi.fn(() => {
+ throw new Error('Failed to add pin marker');
+ }),
+
+ removePinMarker: vi.fn(() => {
+ throw new Error('Failed to remove pin marker');
+ }),
+
+ createPolyline: vi.fn(() => {
+ return Promise.reject(new Error('Failed to create polyline'));
+ }),
+
+ removePolyline: vi.fn(() => {
+ throw new Error('Failed to remove polyline');
+ }),
+
+ setView: vi.fn(() => {
+ throw new Error('Failed to set map view');
+ }),
+
+ fitBounds: vi.fn(() => {
+ throw new Error('Failed to fit bounds');
+ })
+ };
+}
diff --git a/src/tests/setup/msw-server.js b/src/tests/setup/msw-server.js
new file mode 100644
index 00000000..d67480bd
--- /dev/null
+++ b/src/tests/setup/msw-server.js
@@ -0,0 +1,22 @@
+import { setupServer } from 'msw/node';
+import { beforeAll, afterAll, afterEach } from 'vitest';
+import { handlers } from '../mocks/handlers.js';
+
+// This configures a Service Worker with the given request handlers.
+export const server = setupServer(...handlers);
+
+// Establish API mocking before all tests.
+beforeAll(() => {
+ server.listen({ onUnhandledRequest: 'warn' });
+});
+
+// Clean up after the tests are finished.
+afterAll(() => {
+ server.close();
+});
+
+// Reset any request handlers that we may add during the tests,
+// so they don't affect other tests.
+afterEach(() => {
+ server.resetHandlers();
+});
diff --git a/vite.config.js b/vite.config.js
index 885a80ea..88813316 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,17 +1,36 @@
import { sveltekit } from '@sveltejs/kit/vite';
+import { svelteTesting } from '@testing-library/svelte/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
- plugins: [sveltekit()],
+ plugins: [sveltekit(), svelteTesting()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reportsDirectory: './coverage',
- reporter: ['html', 'lcov'],
+ reporter: ['html', 'lcov', 'text'],
all: true,
- exclude: ['**/tests', '.svelte-kit', 'build', 'coverage', 'node_modules']
+ exclude: [
+ '**/tests',
+ '.svelte-kit',
+ 'build',
+ 'coverage',
+ 'node_modules',
+ '**/*.d.ts',
+ '**/vendor/**'
+ ],
+ thresholds: {
+ global: {
+ branches: 70,
+ functions: 70,
+ lines: 70,
+ statements: 70
+ }
+ }
},
- environment: 'jsdom'
+ environment: 'jsdom',
+ setupFiles: ['./vitest-setup.js'],
+ globals: true
}
});
diff --git a/vitest-setup.js b/vitest-setup.js
new file mode 100644
index 00000000..56b1ea76
--- /dev/null
+++ b/vitest-setup.js
@@ -0,0 +1,130 @@
+import '@testing-library/jest-dom/vitest';
+import { vi } from 'vitest';
+
+// Mock ResizeObserver
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn()
+}));
+
+// Mock IntersectionObserver
+global.IntersectionObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn()
+}));
+
+// Mock environment variables
+vi.mock('$env/static/public', () => ({
+ PUBLIC_OBA_REGION_NAME: 'Test Region',
+ PUBLIC_OBA_LOGO_URL: '/test-logo.png',
+ PUBLIC_OBA_SERVER_URL: 'https://api.test.com',
+ PUBLIC_OBA_MAP_PROVIDER: 'osm',
+ PUBLIC_NAV_BAR_LINKS: '{"Home": "/", "About": "/about"}',
+ PUBLIC_ANALYTICS_DOMAIN: '',
+ PUBLIC_ANALYTICS_ENABLED: 'false'
+}));
+
+// Mock svelte-i18n
+vi.mock('svelte-i18n', () => ({
+ t: {
+ subscribe: vi.fn((fn) => {
+ fn((key) => key); // Return a function that returns the key
+ return { unsubscribe: () => {} };
+ })
+ },
+ _: vi.fn((key) => key),
+ addMessages: vi.fn(),
+ init: vi.fn(),
+ getLocaleFromNavigator: vi.fn(() => 'en'),
+ locale: {
+ subscribe: vi.fn((fn) => {
+ fn('en');
+ return { unsubscribe: () => {} };
+ })
+ }
+}));
+
+// Mock i18n.js file
+vi.mock('$lib/i18n', () => ({}));
+
+// Mock SvelteKit app stores
+vi.mock('$app/stores', () => ({
+ page: {
+ subscribe: vi.fn((fn) => {
+ fn({
+ url: new URL('https://example.com/stops/1_75403'),
+ params: { stopID: '1_75403' },
+ route: { id: '/stops/[stopID]' },
+ data: {}
+ });
+ return { unsubscribe: vi.fn() };
+ })
+ },
+ navigating: {
+ subscribe: vi.fn((fn) => {
+ fn(null);
+ return { unsubscribe: vi.fn() };
+ })
+ },
+ updated: {
+ subscribe: vi.fn((fn) => {
+ fn(false);
+ return { unsubscribe: vi.fn() };
+ })
+ }
+}));
+
+// Mock geolocation
+global.navigator = {
+ ...global.navigator,
+ geolocation: {
+ getCurrentPosition: vi.fn(),
+ watchPosition: vi.fn(),
+ clearWatch: vi.fn()
+ }
+};
+
+// Mock console methods to reduce noise in tests
+global.console = {
+ ...global.console,
+ warn: vi.fn(),
+ error: vi.fn()
+};
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn()
+ }))
+});
+
+// Mock scrollTo
+global.scrollTo = vi.fn();
+
+// Mock SvelteKit environment
+vi.mock('$app/environment', () => ({
+ browser: false,
+ dev: false,
+ building: false,
+ version: '1.0.0'
+}));
+
+// Mock localStorage
+global.localStorage = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn()
+};
+
+// keybinding action is no longer mocked - let it run normally for testing