diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 8c94231..ec79023 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -199,19 +199,8 @@ beforeAll(() => { Use data-driven testing across **all 5 routing universes**: ```typescript -export const ROUTING_UNIVERSES: { - hash: Hash | undefined; - implicitMode: RoutingOptions['implicitMode']; - hashMode: Exclude; - text: string; - name: string; -}[] = [ - { hash: undefined, implicitMode: 'path', hashMode: 'single', text: "IMP", name: "Implicit Path Routing" }, - { hash: undefined, implicitMode: 'hash', hashMode: 'single', text: "IMH", name: "Implicit Hash Routing" }, - { hash: false, implicitMode: 'path', hashMode: 'single', text: "PR", name: "Path Routing" }, - { hash: true, implicitMode: 'path', hashMode: 'single', text: "HR", name: "Hash Routing" }, - { hash: 'p1', implicitMode: 'path', hashMode: 'multi', text: "MHR", name: "Multi Hash Routing" }, -] as const; +// Import the complete universe definitions +import { ROUTING_UNIVERSES } from "../testing/test-utils.js"; ROUTING_UNIVERSES.forEach((ru) => { describe(`Component - ${ru.text}`, () => { @@ -239,6 +228,8 @@ ROUTING_UNIVERSES.forEach((ru) => { }); ``` +See `src/testing/test-utils.ts` for the complete `ROUTING_UNIVERSES` array definition with all universe configurations. + ### Context Setup ```typescript @@ -299,10 +290,16 @@ addNonMatchingRoute(router, 'optionalRouteName'); // Add multiple routes at once addRoutes(router, { matching: 2, // Adds 2 matching routes - nonMatching: 1, // Adds 1 non-matching route - ignoreForFallback: 1 // Adds 1 route that ignores fallback + nonMatching: 1 // Adds 1 non-matching route }); +// Add explicit custom routes using rest parameters +addRoutes(router, + { matching: 1 }, + { pattern: "/api/:id", name: "api-route" }, + { regex: /^\/test$/ } // Auto-generated name +); + // Manual route addition router.routes["routeName"] = { pattern: "/some/path", @@ -341,7 +338,7 @@ import { createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES -} from "$lib/testing/test-utils.js"; +} from "../testing/test-utils.js"; // Note: moved outside $lib ``` ### Snippet Creation for Testing @@ -452,6 +449,211 @@ afterAll(() => { 7. **Reactivity**: Remember to call `flushSync()` after changing reactive state 8. **Prop vs State Reactivity**: Test both prop changes AND reactive dependency changes +## Advanced Testing Infrastructure + +### Browser API Mocking + +For testing components that rely on `window.location` and `window.history` (like `RouterEngine`), use the comprehensive browser mocking utilities: + +```typescript +import { setupBrowserMocks } from "../testing/test-utils.js"; + +describe("Component requiring browser APIs", () => { + beforeEach(() => { + // Automatically mocks window.location, window.history, and integrates with library Location + setupBrowserMocks("/initial/path"); + }); + + test("Should respond to location changes", () => { + // Browser APIs are now mocked and integrated with library + window.history.pushState({}, "", "/new/path"); + // Test component behavior + }); +}); +``` + +**What `setupBrowserMocks()` provides**: +- Complete `window.location` mock with all properties (href, pathname, hash, search, etc.) +- Full `window.history` mock with `pushState`, `replaceState`, and state management +- Automatic `popstate` event triggering on location changes +- Integration with library's `LocationLite` for synchronized state +- Proper cleanup between tests + +### Enhanced Route Management + +The `addRoutes()` utility supports multiple approaches for flexible route setup: + +```typescript +// Simple route counts +addRoutes(router, { matching: 2, nonMatching: 1 }); + +// RouteSpecs approach for custom route definitions +addRoutes(router, { + matching: { count: 2, specs: { pattern: "/custom/:id" } }, + nonMatching: { count: 1, specs: { pattern: "/other" } } +}); + +// Rest parameters for explicit route definitions (NEW) +addRoutes(router, + { matching: 1, nonMatching: 0 }, + { pattern: "/api/users/:id", name: "user-detail" }, + { regex: /^\/products\/\d+$/, name: "product" }, + { pattern: "/settings" } // Name auto-generated if not provided +); + +// Combined approach +addRoutes(router, + { matching: 2 }, // Generate 2 matching routes + { pattern: "/custom", name: "custom-route" }, // Add specific route + { pattern: "/another" } // Add another with auto-generated name +); +``` + +**Rest Parameters Benefits:** +- **Explicit control**: Define exact routes with specific patterns/regex +- **Named routes**: Optional `name` property for predictable route keys +- **Type safety**: Full IntelliSense support for `RouteInfo` properties +- **Flexible mixing**: Combine generated routes with explicit definitions + +Refer to `src/testing/test-utils.ts` for complete function signatures and type definitions. + +### Universe-Based Testing Pattern + +**Complete test coverage across all 5 routing universes** using the standardized pattern: + +```typescript +import { ROUTING_UNIVERSES } from "../testing/test-utils.js"; + +// ✅ Recommended: Test ALL universes with single loop +ROUTING_UNIVERSES.forEach((universe) => { + describe(`Component (${universe.text})`, () => { + let cleanup: () => void; + let setup: ReturnType; + + beforeAll(() => { + cleanup = init({ + implicitMode: universe.implicitMode, + hashMode: universe.hashMode + }); + setup = createRouterTestSetup(universe.hash); + }); + + afterAll(() => { + cleanup(); + setup.dispose(); + }); + + beforeEach(() => { + setup.init(); // Fresh router per test + setupBrowserMocks("/"); // Fresh browser state + }); + + test(`Should behave correctly in ${universe.text}`, () => { + // Test logic that works across all universes + const { hash, context, router } = setup; + + // Use universe.text for concise test descriptions + expect(universe.text).toMatch(/^(IMP|IMH|PR|HR|MHR)$/); + }); + }); +}); +``` + +**Benefits**: +- **100% Universe Coverage**: Ensures behavior works across all routing modes +- **Consistent Test Structure**: Standardized setup and teardown patterns +- **Efficient Execution**: Vitest's dynamic skipping capabilities maintain performance +- **Clear Reporting**: Each universe shows as separate test suite with meaningful names + +### Self-Documenting Test Constants + +Use dictionary-based constants for better maintainability: + +```typescript +// Import self-documenting hash values +import { ALL_HASHES } from "../testing/test-utils.js"; + +// Usage in tests +test("Should validate hash compatibility", () => { + expect(() => { + new RouterEngine({ hash: ALL_HASHES.single }); + }).not.toThrow(); +}); +``` + +See `src/testing/test-utils.ts` for the complete `ALL_HASHES` dictionary definition. + +**Dictionary Benefits**: +- **Self-Documentation**: `ALL_HASHES.single` is clearer than `true` +- **Single Source of Truth**: Change values in one place +- **Type Safety**: TypeScript can validate usage +- **Discoverability**: IDE autocomplete shows available options + +### Constructor Validation Testing + +For components with runtime validation, test all error scenarios systematically: + +```typescript +describe("Constructor hash validation", () => { + test.each([ + { parent: ALL_HASHES.path, child: ALL_HASHES.single, desc: 'path parent vs hash child' }, + { parent: ALL_HASHES.single, child: ALL_HASHES.path, desc: 'hash parent vs path child' }, + { parent: ALL_HASHES.multi, child: ALL_HASHES.path, desc: 'multi-hash parent vs path child' }, + { parent: ALL_HASHES.path, child: ALL_HASHES.multi, desc: 'path parent vs multi-hash child' } + ])("Should throw error when parent and child have different hash modes: '$desc'", ({ parent, child }) => { + expect(() => { + const parentRouter = new RouterEngine({ hash: parent }); + new RouterEngine(parentRouter, { hash: child }); + }).toThrow("Parent and child routers must use the same hash mode"); + }); + + test.each([ + { parent: ALL_HASHES.path, desc: 'path parent' }, + { parent: ALL_HASHES.single, desc: 'hash parent' }, + { parent: ALL_HASHES.multi, desc: 'multi-hash parent' } + ])("Should allow child router without explicit hash to inherit parent's hash: '$desc'", ({ parent }) => { + expect(() => { + const parentRouter = new RouterEngine({ hash: parent }); + new RouterEngine(parentRouter); + }).not.toThrow(); + }); +}); +``` + +### Performance Optimizations + +**Browser Mock State Synchronization**: +```typescript +// ✅ Automatic state sync - setupBrowserMocks handles this +// Best practice: pass the library's location object for full integration +setupBrowserMocks("/initial", libraryLocationObject); +window.history.pushState({}, "", "/new"); // Automatically triggers popstate + +// ❌ Manual sync required (old approach) +mockLocation.pathname = "/new"; +window.dispatchEvent(new PopStateEvent('popstate')); // Manual event trigger +``` + +**Efficient Test Assertions**: +```typescript +// ✅ Fast negative assertions +expect(queryByText("should not exist")).toBeNull(); + +// ❌ Slow - waits for timeout +await expect(findByText("should not exist")).rejects.toThrow(); + +// ✅ Use findByText for elements that should exist +const element = await findByText("should exist"); +expect(element).toBeInTheDocument(); +``` + ## Test Utilities Location -Test utilities are located in `src/lib/testing/` and excluded from the published package via the `"files"` property in `package.json`. During development, they build to `dist/testing/` but are not included in `npm pack`. +Test utilities are centralized in `src/testing/test-utils.ts` (moved from `src/lib/testing/` for better organization) and excluded from the published package via the `"files"` property in `package.json`. During development, they build to `dist/testing/` but are not included in `npm pack`. + +**Key utilities**: +- `setupBrowserMocks()`: Complete browser API mocking with library integration +- `addRoutes()`: Enhanced route management with RouteSpecs support +- `createRouterTestSetup()`: Standardized router setup with proper lifecycle +- `ROUTING_UNIVERSES`: Complete universe definitions for comprehensive testing +- `ALL_HASHES`: Self-documenting hash value constants diff --git a/src/lib/core/RouterEngine.svelte.test.ts b/src/lib/core/RouterEngine.svelte.test.ts index 1468bc1..1b1127e 100644 --- a/src/lib/core/RouterEngine.svelte.test.ts +++ b/src/lib/core/RouterEngine.svelte.test.ts @@ -1,9 +1,10 @@ -import { describe, test, expect, beforeAll, afterAll, vi, beforeEach } from "vitest"; +import { describe, test, expect, beforeAll, afterAll, afterEach, vi, beforeEach } from "vitest"; import { routePatternsKey, RouterEngine } from "./RouterEngine.svelte.js"; import { init, type Hash, type RouteInfo } from "$lib/index.js"; import { registerRouter } from "./trace.svelte.js"; import { location } from "./Location.js"; import type { State } from "$lib/types.js"; +import { setupBrowserMocks, addRoutes, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js"; describe("RouterEngine", () => { describe('constructor', () => { @@ -16,14 +17,14 @@ describe("RouterEngine", () => { registerRouter: registerRouterMock, }; }); - test("Should throw an error if the library hasn't been initialized", () => { + test("Should throw an error if the library hasn't been initialized.", () => { // Act. const act = () => new RouterEngine(); // Assert. expect(act).toThrowError(); }); - test("Should register the router if traceOptions.routerHierarchy is true", () => { + test("Should register the router if traceOptions.routerHierarchy is true.", () => { // Arrange. const cleanup = init({ trace: { routerHierarchy: true } }); @@ -37,582 +38,620 @@ describe("RouterEngine", () => { cleanup(); }); }); -}); - -describe("RouterEngine (default init)", () => { - let _href: string; - let cleanup: () => void; - let interceptedState: any = null; - const pushStateMock = vi.fn((state, _, url) => { - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); - }); - const replaceStateMock = vi.fn((state, _, url) => { - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); - }); - beforeAll(() => { - cleanup = init(); - // @ts-expect-error Many missing features. - globalThis.window.location = { - get href() { - return _href; - }, - set href(value) { - _href = value; + + describe('constructor hash validation', () => { + let cleanupFn: (() => void) | null = null; + + afterEach(() => { + if (cleanupFn) { + cleanupFn(); + cleanupFn = null; } - }; - // @ts-expect-error Many missing features. - globalThis.window.history = { - get state() { - return interceptedState; - }, - pushState: pushStateMock, - replaceState: replaceStateMock - }; - }); - beforeEach(() => { - location.url.href = globalThis.window.location.href = "http://example.com"; - }); - afterAll(() => { - cleanup(); + }); + + test.each([ + { parentHash: ALL_HASHES.path, childHash: ALL_HASHES.single, mode: 'single' as const, description: "path parent vs hash child" }, + { parentHash: ALL_HASHES.single, childHash: ALL_HASHES.path, mode: 'single' as const, description: "hash parent vs path child" }, + { parentHash: ALL_HASHES.multi, childHash: ALL_HASHES.path, mode: 'multi' as const, description: "multi-hash parent vs path child" }, + { parentHash: ALL_HASHES.path, childHash: ALL_HASHES.multi, mode: 'multi' as const, description: "path parent vs multi-hash child" }, + ])("Should throw error when parent and child have different hash modes: $description", ({ parentHash, childHash, mode }) => { + // Arrange + cleanupFn = init({ hashMode: mode }); + + // Act & Assert + expect(() => { + const parent = new RouterEngine({ hash: parentHash }); + new RouterEngine({ parent, hash: childHash }); + }).toThrowError("The parent router's hash mode must match the child router's hash mode."); + }); + + test.each([ + { parentHash: ALL_HASHES.path, mode: 'single' as const, description: "path parent" }, + { parentHash: ALL_HASHES.single, mode: 'single' as const, description: "hash parent" }, + { parentHash: ALL_HASHES.multi, mode: 'multi' as const, description: "multi-hash parent" }, + ])("Should allow child router without explicit hash to inherit parent's hash: $description", ({ parentHash, mode }) => { + // Arrange + cleanupFn = init({ hashMode: mode }); + + // Act & Assert + expect(() => { + const parent = new RouterEngine({ hash: parentHash }); + const child = new RouterEngine(parent); // No explicit hash - should inherit + expect(child).toBeDefined(); + }).not.toThrow(); + }); + + test("Should throw error when using hash path ID without multi hash mode", () => { + // Arrange + cleanupFn = init({ hashMode: 'single' }); + + // Act & Assert + expect(() => { + new RouterEngine({ hash: ALL_HASHES.multi }); + }).toThrowError("A hash path ID was given, but is only allowed when the library's hash mode has been set to 'multi'."); + }); + + test("Should throw error when using non-string hash in multi hash mode", () => { + // Arrange + cleanupFn = init({ hashMode: 'multi' }); + + // Act & Assert + expect(() => { + new RouterEngine({ hash: ALL_HASHES.single }); // boolean not allowed in multi mode + }).toThrowError("The specified hash value is not valid for the 'multi' hash mode. Either don't specify a hash for path routing, or correct the hash value."); + }); + + test("Should allow valid hash path ID in multi hash mode", () => { + // Arrange + cleanupFn = init({ hashMode: 'multi' }); + + // Act & Assert + expect(() => { + const router = new RouterEngine({ hash: ALL_HASHES.multi }); + expect(router).toBeDefined(); + }).not.toThrow(); + }); }); - describe('basePath', () => { - test("Should be '/' by default", () => { - // Act. - const router = new RouterEngine(); +}); - // Assert. - expect(router.basePath).toBe('/'); +// ======================================== +// Comprehensive Universe-Based Tests +// ======================================== + +ROUTING_UNIVERSES.forEach(universe => { + describe(`RouterEngine (${universe.text})`, () => { + let cleanup: () => void; + let browserMocks: ReturnType; + + beforeAll(() => { + browserMocks = setupBrowserMocks("http://example.com/", location); + cleanup = init({ + hashMode: universe.hashMode, + implicitMode: universe.implicitMode + }); }); - test("Should be the parent's basePath plus the router's basePath", () => { - // Arrange. - const parent = new RouterEngine(); - parent.basePath = '/parent'; - const router = new RouterEngine(parent); - router.basePath = '/child'; + + beforeEach(() => { + browserMocks.setUrl("http://example.com"); + }); + + afterAll(() => { + cleanup(); + browserMocks.cleanup(); + }); + + describe('constructor', () => { + test("Should create router with correct hash configuration", () => { + // Act. + const router = new RouterEngine({ hash: universe.hash }); + + // Assert. + expect(router).toBeDefined(); + // Additional assertions could check internal hash configuration + }); + }); + + describe('basePath', () => { + test("Should be '/' by default", () => { + // Act. + const router = new RouterEngine({ hash: universe.hash }); - // Act. - const basePath = router.basePath; + // Assert. + expect(router.basePath).toBe('/'); + }); + + test("Should be the parent's basePath plus the router's basePath", () => { + // Arrange. + const parent = new RouterEngine({ hash: universe.hash }); + parent.basePath = '/parent'; + const router = new RouterEngine(parent); + router.basePath = '/child'; - // Assert. - expect(basePath).toBe('/parent/child'); - }); - test("Should remove the trailing slash.", () => { - // Arrange. - const router = new RouterEngine(); - router.basePath = '/abc/'; + // Act. + const basePath = router.basePath; - // Act. - const basePath = router.basePath; + // Assert. + expect(basePath).toBe('/parent/child'); + }); + + test("Should remove the trailing slash.", () => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + router.basePath = '/abc/'; - // Assert. - expect(basePath).toBe('/abc'); + // Act. + const basePath = router.basePath; + + // Assert. + expect(basePath).toBe('/abc'); + }); }); - }); - describe('url', () => { - test("Should return the current URL.", () => { - // Arrange. - const router = new RouterEngine(); - location.url.href = "http://example.com/path?query#hash"; + + describe('url', () => { + test("Should return the current URL.", () => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + location.url.href = "http://example.com/path?query#hash"; - // Act. - const url = router.url; + // Act. + const url = router.url; - // Assert. - expect(url).toBe(location.url); + // Assert. + expect(url).toBe(location.url); + }); }); - }); - describe('state', () => { - test.each<{ hash: Hash; getter: (state: State) => any }>([ - { - hash: false, - getter: (state) => state.path, - }, - { - hash: true, - getter: (state) => state.hash.single, - }, - ])("Should return the current state for hash $hash .", ({ hash, getter }) => { - // Arrange. - const router = new RouterEngine({ hash }); - const state: State = { path: 1, hash: { single: 2, custom: 3 } }; + + describe('state', () => { + test("Should return the current state for the routing universe", () => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + const state: State = { path: 1, hash: { single: 2, p1: 3 } }; - // Act. - globalThis.window.history.pushState(state, '', 'http://example.com/other'); + // Act. + browserMocks.history.pushState(state, '', 'http://example.com/other'); + browserMocks.triggerPopstate(state); - // Assert. - expect(router.state).toBe(getter(globalThis.window.history.state)); + // Assert. + let expectedState: any; + if (universe.hash === false) { + expectedState = state.path; + } else if (universe.hash === true) { + expectedState = state.hash.single; + } else if (typeof universe.hash === 'string') { + expectedState = state.hash[universe.hash]; + } else { + // For implicit modes (hash === undefined), the behavior depends on implicitMode + expectedState = universe.implicitMode === 'path' ? state.path : state.hash.single; + } + expect(router.state).toBe(expectedState); + }); }); - }); - describe('routes', () => { - test("Should recalculate the route patterns whenever a new route is added.", () => { - // Arrange. - const router = new RouterEngine(); - const route: RouteInfo = { - pattern: '/path', - caseSensitive: false, - }; - expect(Object.keys(router.routes).length).toBe(0); + + describe('routes', () => { + test("Should recalculate the route patterns whenever a new route is added.", () => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + const route: RouteInfo = { + pattern: '/path', + caseSensitive: false, + }; + expect(Object.keys(router.routes).length).toBe(0); - // Act. - router.routes['route'] = route; + // Act. + router.routes['route'] = route; - // Assert. - expect(router[routePatternsKey]().has('route')).toBe(true); - }); - test("Should recalculate the route patterns whenever a route is removed.", () => { - // Arrange. - const router = new RouterEngine(); - const route: RouteInfo = { - pattern: '/path', - caseSensitive: false, - }; - router.routes['route'] = route; - expect(Object.keys(router.routes).length).toBe(1); + // Assert. + expect(router[routePatternsKey]().has('route')).toBe(true); + }); + + test("Should recalculate the route patterns whenever a route is removed.", () => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + const route: RouteInfo = { + pattern: '/path', + caseSensitive: false, + }; + router.routes['route'] = route; + expect(Object.keys(router.routes).length).toBe(1); - // Act. - delete router.routes['route']; + // Act. + delete router.routes['route']; - // Assert. - expect(router[routePatternsKey]().has('route')).toBe(false); - }); - test("Should recalculate the route patterns whenever a route is updated.", () => { - // Arrange. - const router = new RouterEngine(); - const route: RouteInfo = { - pattern: '/path', - caseSensitive: false, - }; - router.routes['route'] = route; - expect(Object.keys(router.routes).length).toBe(1); + // Assert. + expect(router[routePatternsKey]().has('route')).toBe(false); + }); + + test("Should recalculate the route patterns whenever a route is updated.", () => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + const route: RouteInfo = { + pattern: '/path', + caseSensitive: false, + }; + router.routes['route'] = route; + expect(Object.keys(router.routes).length).toBe(1); - // Act. - router.routes['route'].pattern = '/other'; + // Act. + router.routes['route'].pattern = '/other'; - // Assert. - expect(router[routePatternsKey]().has('route')).toBe(true); - expect(router[routePatternsKey]().get('route')!.regex!.test('/other')).toBe(true); - }); - describe('Route Patterns', () => { - test.each( - [ + // Assert. + expect(router[routePatternsKey]().has('route')).toBe(true); + expect(router[routePatternsKey]().get('route')!.regex!.test('/other')).toBe(true); + }); + + describe('Route Patterns', () => { + test.each( + [ + { + pattern: '/path', + testPathname: '/path', + text: 'zero parameters', + }, + { + pattern: '/:one', + testPathname: '/path', + text: '1 parameter', + params: { + one: 'path', + } + }, + { + pattern: '/:one/:two', + testPathname: '/path/value', + text: '2 parameters', + params: { + one: 'path', + two: 'value', + } + }, + { + pattern: '/some-:one/:two', + testPathname: '/some-path/value', + text: '2 parameters', + params: { + one: 'path', + two: 'value', + } + }, + { + pattern: '/path/:one', + testPathname: '/path/value', + text: '1 parameter', + params: { + one: 'value', + } + }, + { + pattern: '/path/:one/sub/:two', + testPathname: '/path/value/sub/other', + text: '2 parameters', + params: { + one: 'value', + two: 'other', + } + }, + { + pattern: '/:one/sub/:two', + testPathname: '/value/sub/other', + text: '2 parameters', + params: { + one: 'value', + two: 'other', + } + }, + { + pattern: '/path-to.:one/sub/:two', + testPathname: '/path-to.value/sub/other', + text: '2 parameters', + params: { + one: 'value', + two: 'other', + } + }, + { + pattern: '/*', + testPathname: '/value', + text: '1 parameter', + params: { + rest: '/value', + } + }, + { + pattern: '/*', + testPathname: '/value/two', + text: '1 parameter', + params: { + rest: '/value/two', + } + }, + { + pattern: '/path/*', + testPathname: '/path/two', + text: '1 parameter', + params: { + rest: '/two', + } + }, + { + pattern: '/path/:one/*', + testPathname: '/path/to/two', + text: '2 parameters', + params: { + one: 'to', + rest: '/two', + } + }, + { + pattern: '/path-:one/*', + testPathname: '/path-to/two', + text: '2 parameters', + params: { + one: 'to', + rest: '/two', + } + }, + ] as { pattern: string; testPathname: string; text: string; params?: Record }[] + )("Should identify $text in pattern $pattern testing with $testPathname .", ({ pattern, testPathname, params }) => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + const route: RouteInfo = { + pattern, + caseSensitive: false, + }; + router.routes['route'] = route; + + // Act. + const matches = router[routePatternsKey]().get('route')!.regex?.exec(testPathname); + + // Assert. + expect(matches).toBeDefined(); + if (params) { + expect(matches!.groups).toBeDefined(); + expect(Object.keys(matches!.groups!).length).toBe(Object.keys(params).length); + for (let key in params) { + expect(matches!.groups![key]).toBe(params[key]); + } + } + }); + + test.each([ { pattern: '/path', - testPathname: '/path', - text: 'zero parameters', + testPathname: '/other', }, { pattern: '/:one', - testPathname: '/path', - text: '1 parameter', - params: { - one: 'path', - } + testPathname: '/path/other', }, { pattern: '/:one/:two', - testPathname: '/path/value', - text: '2 parameters', - params: { - one: 'path', - two: 'value', - } + testPathname: '/path', }, { - pattern: '/some-:one/:two', - testPathname: '/some-path/value', - text: '2 parameters', - params: { - one: 'path', - two: 'value', - } + pattern: '/path/:one', + testPathname: '/path', }, { - pattern: '/path/:one', - testPathname: '/path/value', + pattern: '/path/:one/sub/:two', + testPathname: '/path/value/sub', + }, + { + pattern: '/:one/sub/:two', + testPathname: '/value/sub', + }, + { + pattern: '/path/*', + testPathname: '/value', + }, + { + pattern: '/path/*', + testPathname: '/other/two', + }, + ])("Should not match pattern $pattern with pathname $testPathname .", ({ pattern, testPathname }) => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + const route: RouteInfo = { + pattern, + caseSensitive: false, + }; + router.routes['route'] = route; + + // Act. + const matches = router[routePatternsKey]().get('route')!.regex?.exec(testPathname); + + // Assert. + expect(matches).toBeNull(); + }); + + test.each([ + { + pattern: '/:one?', + testPathname: '/path', text: '1 parameter', + willMatch: true, params: { - one: 'value', - } + one: 'path', + }, }, { - pattern: '/path/:one/sub/:two', - testPathname: '/path/value/sub/other', - text: '2 parameters', - params: { - one: 'value', - two: 'other', - } + pattern: '/:one?', + testPathname: '/', + text: '0 parameters', + willMatch: true, }, { - pattern: '/:one/sub/:two', - testPathname: '/value/sub/other', + pattern: '/:one?', + testPathname: '/abc/def', + text: '0 parameters', + willMatch: false, + }, + { + pattern: '/:one/:two?', + testPathname: '/abc/def', text: '2 parameters', + willMatch: true, params: { - one: 'value', - two: 'other', - } + one: 'abc', + two: 'def', + }, }, { - pattern: '/path-to.:one/sub/:two', - testPathname: '/path-to.value/sub/other', + pattern: '/:one/or%20:two?', + testPathname: '/abc/or%20def', text: '2 parameters', + willMatch: true, params: { - one: 'value', - two: 'other', - } + one: 'abc', + two: 'def', + }, }, { - pattern: '/*', - testPathname: '/value', - text: '1 parameter', + pattern: '/:one/:two?', + testPathname: '/abc/', + text: '2 parameters', + willMatch: true, params: { - rest: '/value', - } + one: 'abc', + two: undefined, + }, }, { - pattern: '/*', - testPathname: '/value/two', - text: '1 parameter', + pattern: '/:one/:two?', + testPathname: '/abc', + text: '2 parameters', + willMatch: true, params: { - rest: '/value/two', - } + one: 'abc', + two: undefined, + }, }, { - pattern: '/path/*', - testPathname: '/path/two', - text: '1 parameter', + pattern: '/:one?/:two', + testPathname: '/abc', + text: '2 parameters', + willMatch: true, params: { - rest: '/two', - } + one: undefined, + two: 'abc', + }, }, { - pattern: '/path/:one/*', - testPathname: '/path/to/two', + pattern: '/:one?/:two', + testPathname: '/abc/def', text: '2 parameters', + willMatch: true, params: { - one: 'to', - rest: '/two', - } + one: 'abc', + two: 'def', + }, }, { - pattern: '/path-:one/*', - testPathname: '/path-to/two', + pattern: '/maybe-:one?/:two', + testPathname: '/maybe-/def', text: '2 parameters', + willMatch: true, params: { - one: 'to', - rest: '/two', - } + one: undefined, + two: 'def', + }, }, - ] as { pattern: string; testPathname: string; text: string; params?: Record }[] - )("Should identify $text in pattern $pattern testing with $testPathname .", ({ pattern, testPathname, params }) => { - // Arrange. - const router = new RouterEngine(); - const route: RouteInfo = { - pattern, - caseSensitive: false, - }; - router.routes['route'] = route; + ] as { + pattern: string; + testPathname: string; + text: string; + willMatch: boolean; + params?: Record; + }[])("Should match $text in pattern $pattern with pathname $testPathname .", ({ pattern, testPathname, willMatch, params }) => { + // Arrange. + const router = new RouterEngine({ hash: universe.hash }); + const route: RouteInfo = { + pattern, + caseSensitive: false, + }; + router.routes['route'] = route; + + // Act. + const matches = router[routePatternsKey]().get('route')!.regex?.exec(testPathname); + + // Assert. + expect(!!matches).toBe(willMatch); + if (willMatch && params) { + expect(matches!.groups).toBeDefined(); + expect(Object.keys(matches!.groups!).length).toBe(Object.keys(params).length); + for (let key in params) { + expect(matches!.groups![key]).toBe(params[key]); + } + } + }); + }); + }); + + describe('noMatches', () => { + test("Should be true whenever there are no routes registered.", () => { + // Act. + const router = new RouterEngine({ hash: universe.hash }); + // Assert. + expect(router.noMatches).toBe(true); + }); + + test("Should be true whenever there are no matching routes.", () => { // Act. - const matches = router[routePatternsKey]().get('route')!.regex?.exec(testPathname); + const router = new RouterEngine({ hash: universe.hash }); + router.routes['route'] = { + pattern: '/:one/:two?', + caseSensitive: false, + }; // Assert. - expect(matches).toBeDefined(); - if (params) { - expect(matches!.groups).toBeDefined(); - expect(Object.keys(matches!.groups!).length).toBe(Object.keys(params).length); - for (let key in params) { - expect(matches!.groups![key]).toBe(params[key]); - } - } + expect(router.noMatches).toBe(true); }); + test.each([ { - pattern: '/path', - testPathname: '/other', - }, - { - pattern: '/:one', - testPathname: '/path/other', - }, - { - pattern: '/:one/:two', - testPathname: '/path', - }, - { - pattern: '/path/:one', - testPathname: '/path', - }, - { - pattern: '/path/:one/sub/:two', - testPathname: '/path/value/sub', + text: "is", + routeCount: 1, + totalRoutes: 5 }, { - pattern: '/:one/sub/:two', - testPathname: '/value/sub', + text: "are", + routeCount: 2, + totalRoutes: 5 }, { - pattern: '/path/*', - testPathname: '/value', + text: "are", + routeCount: 5, + totalRoutes: 5 }, - { - pattern: '/path/*', - testPathname: '/other/two', - }, - ])("Should not match pattern $pattern with pathname $testPathname .", ({ pattern, testPathname }) => { + ])("Should be false whenever there $text $routeCount matching route(s) out of $totalRoutes route(s).", ({ routeCount, totalRoutes }) => { // Arrange. - const router = new RouterEngine(); - const route: RouteInfo = { - pattern, - caseSensitive: false, - }; - router.routes['route'] = route; - + const router = new RouterEngine({ hash: universe.hash }); + const nonMatchingCount = totalRoutes - routeCount; + // Act. - const matches = router[routePatternsKey]().get('route')!.regex?.exec(testPathname); + addRoutes(router, { matching: routeCount, nonMatching: nonMatchingCount }); // Assert. - expect(matches).toBeNull(); + expect(router.noMatches).toBe(false); }); + test.each([ - { - pattern: '/:one?', - testPathname: '/path', - text: '1 parameter', - willMatch: true, - params: { - one: 'path', - }, - }, - { - pattern: '/:one?', - testPathname: '/', - text: '0 parameters', - willMatch: true, - }, - { - pattern: '/:one?', - testPathname: '/abc/def', - text: '0 parameters', - willMatch: false, - }, - { - pattern: '/:one/:two?', - testPathname: '/abc/def', - text: '2 parameters', - willMatch: true, - params: { - one: 'abc', - two: 'def', - }, - }, - { - pattern: '/:one/or%20:two?', - testPathname: '/abc/or%20def', - text: '2 parameters', - willMatch: true, - params: { - one: 'abc', - two: 'def', - }, - }, - { - pattern: '/:one/:two?', - testPathname: '/abc/', - text: '2 parameters', - willMatch: true, - params: { - one: 'abc', - two: undefined, - }, - }, - { - pattern: '/:one/:two?', - testPathname: '/abc', - text: '2 parameters', - willMatch: true, - params: { - one: 'abc', - two: undefined, - }, - }, - { - pattern: '/:one?/:two', - testPathname: '/abc', - text: '2 parameters', - willMatch: true, - params: { - one: undefined, - two: 'abc', - }, - }, - { - pattern: '/:one?/:two', - testPathname: '/abc/def', - text: '2 parameters', - willMatch: true, - params: { - one: 'abc', - two: 'def', - }, - }, - { - pattern: '/maybe-:one?/:two', - testPathname: '/maybe-/def', - text: '2 parameters', - willMatch: true, - params: { - one: undefined, - two: 'def', - }, - }, - ] as { - pattern: string; - testPathname: string; - text: string; - willMatch: boolean; - params?: Record; - }[])("Should match $text in pattern $pattern with pathname $testPathname .", ({ pattern, testPathname, willMatch, params }) => { + 1, 2, 5 + ])("Should be true whenever the %d matching route(s) are ignored for fallback.", (routeCount) => { // Arrange. - const router = new RouterEngine(); - const route: RouteInfo = { - pattern, - caseSensitive: false, - }; - router.routes['route'] = route; - + const router = new RouterEngine({ hash: universe.hash }); + // Act. - const matches = router[routePatternsKey]().get('route')!.regex?.exec(testPathname); + addRoutes(router, { + matching: { + count: routeCount, + specs: { ignoreForFallback: true } + } + }); // Assert. - expect(!!matches).toBe(willMatch); - if (willMatch && params) { - expect(matches!.groups).toBeDefined(); - expect(Object.keys(matches!.groups!).length).toBe(Object.keys(params).length); - for (let key in params) { - expect(matches!.groups![key]).toBe(params[key]); - } - } + expect(router.noMatches).toBe(true); }); }); }); - describe('noMatches', () => { - test("Should be true whenever there are no routes registered.", () => { - // Act. - const router = new RouterEngine(); - - // Assert. - expect(router.noMatches).toBe(true); - }); - test("Should be true whenever there are no matching routes.", () => { - // Act. - const router = new RouterEngine(); - router.routes['route'] = { - pattern: '/:one/:two?', - caseSensitive: false, - }; - - // Assert. - expect(router.noMatches).toBe(true); - }); - test.each([ - { - text: "is", - routeCount: 1, - totalRoutes: 5 - }, - { - text: "are", - routeCount: 2, - totalRoutes: 5 - }, - { - text: "are", - routeCount: 5, - totalRoutes: 5 - }, - ])("Should be false whenever there $text $routeCount matching route(s) out of $totalRoutes route(s).", ({ routeCount, totalRoutes }) => { - // Act. - const router = new RouterEngine(); - for (let i = 0; i < routeCount; i++) { - router.routes[`route${i}`] = { - and: () => i < routeCount - }; - } - - // Assert. - expect(router.noMatches).toBe(false); - }); - test.each([ - 1, 2, 5 - ])("Should be true whenever the %d matching route(s) are ignored for fallback.", (routeCount) => { - // Act. - const router = new RouterEngine(); - for (let i = 0; i < routeCount; i++) { - router.routes[`route${i}`] = { - and: () => true, - ignoreForFallback: true - }; - } - - // Assert. - expect(router.noMatches).toBe(true); - }); - }); -}); - -describe("RouterEngine (multi hash)", () => { - let _href: string; - let cleanup: () => void; - let interceptedState: any = null; - const pushStateMock = vi.fn((state, _, url) => { - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); - }); - const replaceStateMock = vi.fn((state, _, url) => { - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); - }); - beforeAll(() => { - cleanup = init({ hashMode: 'multi' }); - // @ts-expect-error Many missing features. - globalThis.window.location = { - get href() { - return _href; - }, - set href(value) { - _href = value; - } - }; - // @ts-expect-error Many missing features. - globalThis.window.history = { - get state() { - return interceptedState; - }, - pushState: pushStateMock, - replaceState: replaceStateMock - }; - }); - afterAll(() => { - cleanup(); - }); - describe('state', () => { - test("Should return the current state for a named hash path.", () => { - // Arrange. - const router = new RouterEngine({ hash: 'custom' }); - const state: State = { path: 1, hash: { single: 2, custom: 3 } }; - - // Act. - globalThis.window.history.pushState(state, '', 'http://example.com/other'); - - // Assert. - expect(router.state).toBe(state.hash.custom); - }); - }); }); diff --git a/src/testing/test-utils.ts b/src/testing/test-utils.ts index ee6fb36..94b94e0 100644 --- a/src/testing/test-utils.ts +++ b/src/testing/test-utils.ts @@ -1,9 +1,10 @@ -import { type Hash } from "$lib/types.js"; +import { type Hash, type RouteInfo } from "$lib/types.js"; import { RouterEngine } from "$lib/core/RouterEngine.svelte.js"; import { getRouterContextKey } from "../lib/Router/Router.svelte"; import { createRawSnippet } from "svelte"; import type { RoutingOptions } from "$lib/core/options.js"; import { resolveHashValue } from "$lib/core/resolveHashValue.js"; +import { vi } from "vitest"; /** * Standard routing universe test configurations @@ -22,6 +23,16 @@ export const ROUTING_UNIVERSES: { { hash: 'p1', implicitMode: 'path', hashMode: 'multi', text: "MHR", name: "Multi Hash Routing" }, ] as const; +/** + * All possible hash values for testing hash compatibility + */ +export const ALL_HASHES = { + path: false, // Path routing + single: true, // Hash routing (single) + multi: 'p1', // Multi-hash routing + implicit: undefined // Implicit routing (depends on implicitMode) +} as const; + /** * Creates a router and context setup for testing */ @@ -78,36 +89,253 @@ export function newRandomRouteKey() { return `route-${Math.random().toString(36).substring(2, 11)}`; } +function addRoute(router: RouterEngine, matching: boolean, options?: RouteSpecs['specs']) { + const { name = newRandomRouteKey() } = options || {}; + delete options?.name; + router.routes[name] = { + ...options, + and: () => matching + }; + return name; +} + /** * Adds a matching route to the router */ -export function addMatchingRoute(router: RouterEngine, routeName?: string) { - routeName ||= newRandomRouteKey(); - router.routes[routeName] = { - and: () => true - }; - return routeName; +export function addMatchingRoute(router: RouterEngine, options?: RouteSpecs['specs']) { + return addRoute(router, true, options); } /** * Adds a non-matching route to the router */ -export function addNonMatchingRoute(router: RouterEngine, routeName?: string) { - routeName ||= newRandomRouteKey(); - router.routes[routeName] = { - and: () => false - }; - return routeName; +export function addNonMatchingRoute(router: RouterEngine, options?: RouteSpecs['specs']) { + return addRoute(router, false, options); +} + +type RouteSpecs = { + count: number; + specs: Omit & { name?: string; }; } -export function addRoutes(router: RouterEngine, routes: { matching?: number; nonMatching?: number; }) { +export function addRoutes(router: RouterEngine, routes: { matching?: number; nonMatching?: number; }, ...add: (RouteInfo & { name?: string; })[]): string[]; +export function addRoutes(router: RouterEngine, routes: { matching?: RouteSpecs ; nonMatching?: RouteSpecs; }, ...add: (RouteInfo & { name?: string; })[]): string[]; +export function addRoutes(router: RouterEngine, routes: { matching?: number | RouteSpecs; nonMatching?: number | RouteSpecs; }, ...add: (RouteInfo & { name?: string; })[]): string[] { const { matching = 0, nonMatching = 0 } = routes; - const routeNames = []; - for (let i = 0; i < matching; i++) { - routeNames.push(addMatchingRoute(router)); - } - for (let i = 0; i < nonMatching; i++) { - routeNames.push(addNonMatchingRoute(router)); + const routeNames: string[] = []; + [[matching, addMatchingRoute] as const, [nonMatching, addNonMatchingRoute] as const].forEach(x => { + const [r, fn] = x; + if (typeof r === 'number') { + for (let i = 0; i < r; i++) { + routeNames.push(fn(router)); + } + } else { + for (let i = 0; i < r.count; i++) { + routeNames.push(fn(router, r.specs)); + } + } + }); + for (let route of add) { + const name = route.name || newRandomRouteKey(); + delete route.name; + router.routes[name] = route; + routeNames.push(name); } return routeNames; } + +// ======================================== +// Browser API Mocking Utilities +// ======================================== + +/** + * Mock window.location object with getter/setter for href and other properties + */ +export function createLocationMock(initialUrl = "http://example.com/") { + let _url = new URL(initialUrl); + + return { + get href() { return _url.href; }, + set href(value: string) { _url = new URL(value, _url); }, + // Add other location properties as needed + get pathname() { return _url.pathname; }, + get search() { return _url.search; }, + get hash() { return _url.hash; }, + get origin() { return _url.origin; }, + get protocol() { return _url.protocol; }, + get host() { return _url.host; }, + get hostname() { return _url.hostname; }, + get port() { return _url.port; }, + }; +} + +/** + * Mock window.history object with state management and navigation methods + */ +export function createHistoryMock() { + let _state: any = null; + + const pushStateMock = vi.fn((state: any, title: string, url?: string) => { + _state = state; + if (url) { + // Update location href if URL is provided + if (globalThis.window?.location) { + globalThis.window.location.href = url; + } + } + }); + + const replaceStateMock = vi.fn((state: any, title: string, url?: string) => { + _state = state; + if (url) { + // Update location href if URL is provided + if (globalThis.window?.location) { + globalThis.window.location.href = url; + } + } + }); + + return { + get state() { return _state; }, + // According to MDN, state is read-only, but I would be OK with this setter if it eases unit testing. + set state(value: any) { _state = value; }, + pushState: pushStateMock, + replaceState: replaceStateMock, + get length() { return 1; }, // Simple mock value + go: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + }; +} + +/** + * Mock full window object with location, history, and event handling + */ +export function createWindowMock(initialUrl = "http://example.com/") { + const locationMock = createLocationMock(initialUrl); + const historyMock = createHistoryMock(); + const eventListeners = new Map(); + + return { + location: locationMock, + history: historyMock, + + // Event handling + addEventListener: vi.fn((type: string, listener: EventListener) => { + if (!eventListeners.has(type)) { + eventListeners.set(type, []); + } + eventListeners.get(type)!.push(listener); + }), + + removeEventListener: vi.fn((type: string, listener: EventListener) => { + const listeners = eventListeners.get(type); + if (listeners) { + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + } + }), + + dispatchEvent: vi.fn((event: Event) => { + const listeners = eventListeners.get(event.type); + if (listeners) { + listeners.forEach(listener => listener(event)); + } + return true; + }), + + // Utility methods for testing + _getEventListeners: () => eventListeners, + _clearEventListeners: () => eventListeners.clear(), + }; +} + +/** + * Sets up browser API mocks for testing + * Returns cleanup function to restore original values + */ +export function setupBrowserMocks(initialUrl = "http://example.com/", libraryLocation?: { url: { href: string } }) { + const originalWindow = globalThis.window; + const windowMock = createWindowMock(initialUrl); + + // @ts-expect-error - Mocking window for testing + globalThis.window = windowMock; + + return { + window: windowMock, + location: windowMock.location, + history: windowMock.history, + + // Cleanup function + cleanup: () => { + globalThis.window = originalWindow; + }, + + // Utility functions + setUrl: (url: string) => { + windowMock.location.href = url; + // Also update library location if provided + if (libraryLocation) { + libraryLocation.url.href = url; + } + // Trigger popstate to notify location service (simulates natural browser behavior) + const event = new PopStateEvent('popstate', { state: windowMock.history.state }); + windowMock.dispatchEvent(event); + }, + + setState: (state: any) => { + windowMock.history.state = state; + }, + + triggerPopstate: (state?: any) => { + const event = new PopStateEvent('popstate', { state: state ?? windowMock.history.state }); + windowMock.dispatchEvent(event); + }, + + // For tests that need to simulate external history changes + simulateHistoryChange: (state: any, url?: string) => { + if (url) { + windowMock.location.href = url; + } + windowMock.history.state = state; + // Trigger popstate to notify location service + const event = new PopStateEvent('popstate', { state }); + windowMock.dispatchEvent(event); + }, + }; +} + +/** + * Simpler mock setup using vi.stubGlobal (alternative approach) + */ +export function setupSimpleBrowserMocks(initialUrl = "http://example.com/") { + const locationMock = createLocationMock(initialUrl); + const historyMock = createHistoryMock(); + + vi.stubGlobal('window', { + location: locationMock, + history: historyMock, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + return { + location: locationMock, + history: historyMock, + + cleanup: () => { + vi.unstubAllGlobals(); + }, + + setUrl: (url: string) => { + locationMock.href = url; + }, + + setState: (state: any) => { + historyMock.state = state; + }, + }; +}