diff --git a/docs/testing-guide.md b/docs/testing-guide.md new file mode 100644 index 0000000..8c94231 --- /dev/null +++ b/docs/testing-guide.md @@ -0,0 +1,457 @@ +# N-Savant Routing Library - Testing Guide + +## Library Architecture Overview + +### Routing Universes Concept + +The @wjfe/n-savant routing library supports simultaneous path and hash routing through "routing universes": + +- **Path Routing** (`hash: false`): Uses URL pathname +- **Single Hash Routing** (`hash: true`): Uses URL hash as a single path (e.g., `#/path/to/route`) +- **Multi Hash Routing** (`hash: 'p1'`): Uses semicolon-separated hash segments (e.g., `#p1=/path;p2=/other`) +- **Implicit Path Routing** (`hash: undefined`, `implicitMode: 'path'`): Resolves to path routing +- **Implicit Hash Routing** (`hash: undefined`, `implicitMode: 'hash'`): Resolves to hash routing + +#### Example Multi-Universe Setup +```svelte + + + + + + + + + + + + +``` + +### Context System + +Context is stored per universe using Svelte's `setContext()` with keys from `getRouterContextKey()`: +- Path routing: `parentCtxKey` symbol +- Single hash routing: `hashParentCtxKey` symbol +- Multi hash routing: `Symbol.for('hsh-${hashId}')` per hash ID + +### RouterEngine Core + +The `RouterEngine` class is the heart of the routing system: + +#### Routes Structure +```typescript +routes: Record +``` + +Where `RouteInfo` contains: +- `pattern?: string` or `regex?: RegExp`: For URL matching +- `and?: (routeParams) => boolean`: Additional predicate for guarded routes +- `ignoreForFallback?: boolean`: Excludes route from fallback calculations + +#### Reactive Properties +- `routeStatus`: Per-route match status and extracted parameters +- `noMatches`: Boolean indicating NO routes matched (excluding `ignoreForFallback` routes) + +### Component Architecture + +#### Router Component +- Creates `RouterEngine` instance +- Sets up context for child components +- Provides `state` and `routeStatus` to children + +#### Route Component +- Registers route patterns with parent router +- Uses context to find parent router +- Props: `path` (string pattern/regex) and `and` (predicate function) + +#### Fallback Component +- Shows content when no routes match +- Props: + - `hash`: Routing universe selector + - `when?: WhenPredicate`: Override default `noMatches` behavior + - `children`: Content snippet + +Render logic: +```svelte +{#if (router && when?.(router.routeStatus, router.noMatches)) || (!when && router?.noMatches)} + {@render children?.(router.state, router.routeStatus)} +{/if} +``` + +## Testing Patterns & Best Practices + +### Test Structure Categories + +#### **1. Default Props Tests** +Test component behavior with minimal/default property values: +```typescript +function defaultPropsTests(setup: ReturnType) { + beforeEach(() => setup.init()); + afterAll(() => setup.dispose()); + + test("Should behave correctly with default props", () => { + // Test with hash: undefined and minimal props + const { hash, context } = setup; + render(Component, { props: { hash, children: content }, context }); + // Assert default behavior + }); +} +``` + +#### **2. Explicit Props Tests** +Test specific property configurations and overrides: +```typescript +function explicitPropsTests(setup: ReturnType) { + test.each([ + { propValue: value1, scenario: "scenario1" }, + { propValue: value2, scenario: "scenario2" } + ])("Should handle $scenario", ({ propValue }) => { + render(Component, { + props: { hash, specificProp: propValue, children: content }, + context + }); + // Assert specific behavior + }); +} +``` + +#### **3. Reactivity Tests** +Test two distinct types of reactivity: + +**A. Prop Value Changes** (Component prop reactivity): +```typescript +test("Should re-render when prop value changes", async () => { + const { rerender } = render(Component, { + props: { when: () => false, children: content } + }); + + // Change the entire prop value + await rerender({ when: () => true, children: content }); + + // Assert new behavior +}); +``` + +**B. Reactive State Changes** (Svelte rune reactivity): +```typescript +test("Should re-render when reactive dependency changes", async () => { + let reactiveState = $state(false); + + render(Component, { + props: { when: () => reactiveState, children: content } // Same function, reactive dependency + }); + + // Change the reactive state + reactiveState = true; + flushSync(); // Ensure reactive updates are processed + + // Assert reactive behavior +}); +``` + +### Testing Principles + +#### **Focus on Observable Behavior, Not Implementation** +```typescript +// ✅ Good - Test what the user sees +test("Should hide content when routes match", () => { + addMatchingRoute(router); + const { queryByText } = render(Component, { props, context }); + expect(queryByText("content")).toBeNull(); +}); + +// ❌ Bad - Test internal implementation +test("Should call router.noMatches", () => { + const spy = vi.spyOn(router, 'noMatches'); + render(Component, { props, context }); + expect(spy).toHaveBeenCalled(); // Testing implementation detail +}); +``` + +#### **Maintain Clear Testing Boundaries** +```typescript +// ✅ Component tests focus on component behavior +test("Component renders when condition is met", () => { + // Setup: Create the condition (however that's achieved) + // Test: Component responds correctly +}); + +// ✅ Router tests focus on router logic (separate file) +test("Router calculates noMatches correctly", () => { + // Test router's internal logic +}); +``` + +#### **Ensure Test Isolation** +```typescript +// ✅ Fresh instance for each test +beforeEach(() => { + setup.init(); // Creates new router +}); + +// ❌ Reusing router instances +beforeAll(() => { + setup.init(); // Same router for all tests - bad! +}); +``` + +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; + +ROUTING_UNIVERSES.forEach((ru) => { + describe(`Component - ${ru.text}`, () => { + let cleanup: () => void; + beforeAll(() => { + cleanup = init({ + implicitMode: ru.implicitMode, + hashMode: ru.hashMode + }); + }); + afterAll(() => { + cleanup(); + }); + + describe("Default Props", () => { + // Component behavior with minimal/default props + }); + describe("Explicit Props", () => { + // Testing specific prop configurations + }); + describe("Reactivity", () => { + // Testing dynamic changes and reactive behavior + }); + }); +}); +``` + +### Context Setup + +```typescript +function createRouterTestSetup(hash: Hash | undefined) { + let router: RouterEngine | undefined; + let context: Map; + + const init = () => { + // Dispose previous router if it exists + router?.dispose(); + + // Create fresh router and context for each test + router = new RouterEngine({ hash }); + context = new Map(); + context.set(getRouterContextKey(hash), router); + }; + + const dispose = () => { + router?.dispose(); + router = undefined; + context = new Map(); + }; + + return { + get hash() { return hash; }, + get router() { + if (!router) throw new Error('Router not initialized. Call init() first.'); + return router; + }, + get context() { + if (!context) throw new Error('Context not initialized. Call init() first.'); + return context; + }, + init, + dispose + }; +} + +// Usage in tests +beforeEach(() => { + setup.init(); // Fresh router for each test +}); + +afterAll(() => { + setup.dispose(); // Clean disposal +}); +``` + +### Route Manipulation + +```typescript +// Add a single matching route +addMatchingRoute(router, 'optionalRouteName'); + +// Add a single non-matching route +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 +}); + +// Manual route addition +router.routes["routeName"] = { + pattern: "/some/path", + and: () => true, + ignoreForFallback: false +}; +``` + +### Testing Library Performance Tips + +- **Use `queryByText`** for negative assertions (element should NOT exist) +- **Use `findByText`** for positive assertions (element should exist) +- **Avoid `expect().rejects`** with `findByText` - it waits 1000ms timeout + +```typescript +// ❌ Slow - waits 1000ms timeout +await expect(findByText(content)).rejects.toThrow(); + +// ✅ Fast - immediate result +expect(queryByText(content)).toBeNull(); +``` + +### Required Imports + +```typescript +import { init, type Hash } from "$lib/index.js"; +import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { render } from "@testing-library/svelte"; +import { RouterEngine } from "$lib/core/RouterEngine.svelte.js"; +import { getRouterContextKey } from "../Router/Router.svelte"; +import { createRawSnippet } from "svelte"; +import { flushSync } from "svelte"; // For Svelte 5 reactivity testing +import { + addMatchingRoute, + addRoutes, + createRouterTestSetup, + createTestSnippet, + ROUTING_UNIVERSES +} from "$lib/testing/test-utils.js"; +``` + +### Snippet Creation for Testing + +```typescript +// Using test utility (recommended) +const content = createTestSnippet("Component content text"); + +// Manual creation +const contentText = "Component content."; +const content = createRawSnippet(() => { + return { + render: () => `
${contentText}
` + }; +}); +``` + +## Test File Naming for Svelte 5 + +**Important**: For tests that use Svelte 5 runes (`$state`, `$derived`, etc.), name your test files with `.svelte.test.ts`: + +``` +✅ Component.svelte.test.ts // Enables Svelte runes +❌ Component.test.ts // Standard test file +``` + +This allows you to use reactive state in tests: +```typescript +test("Should react to state changes", () => { + let reactiveValue = $state(false); + + render(Component, { + props: { when: () => reactiveValue } + }); + + reactiveValue = true; + flushSync(); // Ensure reactive updates are processed +}); +``` + +## Library Initialization + +The `init()` function: +- Passes library configuration to global singleton +- Creates new Location implementation instance +- Required when library configurations change +- Multi hash routing needs cleanup between tests + +```typescript +// Standard init +const cleanup = init(); + +// Multi hash mode +const cleanup = init({ hashMode: 'multi' }); + +// Always cleanup +afterAll(() => { + cleanup(); +}); +``` + +## Component-Specific Testing Notes + +### Fallback Component + +**Purpose**: Render content when no routes match, with override capability + +**Key behaviors to test**: +1. Shows when `router.noMatches` is true +2. Hides when routes are matching +3. Respects `when` predicate override +4. Works across all routing universes + +**Common test scenarios**: +- Empty router (no routes) → should show +- Router with matching routes → should hide +- Router with only `ignoreForFallback` routes → should show +- Custom `when` predicate scenarios + +### Route Component + +**Purpose**: Register route patterns and respond to matches + +**Key behaviors to test**: +1. Route registration with parent router +2. Pattern matching (string patterns, regex, parameters) +3. `and` predicate evaluation +4. `ignoreForFallback` behavior + +### Router Component + +**Purpose**: Create routing context and manage child routes + +**Key behaviors to test**: +1. Context creation and propagation +2. Base path handling +3. Route status calculation +4. State management per universe + +## Common Gotchas + +1. **Context Keys**: Must use correct `getRouterContextKey(hash)` for each universe +2. **Cleanup**: Multi hash routing requires proper `init()` cleanup between test suites +3. **Route Clearing**: Always clear `router.routes` between tests for clean slate +4. **Async Testing**: Use `queryByText` for immediate results vs `findByText` for async waiting +5. **Hash Values**: Ensure hash values match between component props and context setup +6. **File Naming**: Use `.svelte.test.ts` for files that need Svelte runes support +7. **Reactivity**: Remember to call `flushSync()` after changing reactive state +8. **Prop vs State Reactivity**: Test both prop changes AND reactive dependency changes + +## 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`. diff --git a/src/lib/Fallback/Fallback.svelte b/src/lib/Fallback/Fallback.svelte index 1a9c007..3ca5ea7 100644 --- a/src/lib/Fallback/Fallback.svelte +++ b/src/lib/Fallback/Fallback.svelte @@ -1,7 +1,7 @@ -{#if router?.noMatches} +{#if (router && when?.(router.routeStatus, router.noMatches)) || (!when && router?.noMatches)} {@render children?.(router.state, router.routeStatus)} {/if} diff --git a/src/lib/Fallback/Fallback.svelte.test.ts b/src/lib/Fallback/Fallback.svelte.test.ts new file mode 100644 index 0000000..8aa725c --- /dev/null +++ b/src/lib/Fallback/Fallback.svelte.test.ts @@ -0,0 +1,187 @@ +import { init } from "$lib/index.js"; +import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { render } from "@testing-library/svelte"; +import Fallback from "./Fallback.svelte"; +import { addMatchingRoute, addRoutes, createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES } from "../../testing/test-utils.js"; +import { flushSync } from "svelte"; + +function defaultPropsTests(setup: ReturnType) { + const contentText = "Fallback content."; + const content = createTestSnippet(contentText); + + beforeEach(() => { + // Fresh router instance for each test + setup.init(); + }); + + afterAll(() => { + // Clean disposal after all tests + setup.dispose(); + }); + + test("Should render whenever the parent router matches no routes.", async () => { + // Arrange. + const { hash, router, context } = setup; + + // Act. + const { findByText } = render(Fallback, { props: { hash, children: content }, context }); + + // Assert. + await expect(findByText(contentText)).resolves.toBeDefined(); + }); + + test("Should not render whenever the parent router matches at least one route.", async () => { + // Arrange. + const { hash, router, context } = setup; + addMatchingRoute(router); + + // Act. + const { queryByText } = render(Fallback, { props: { hash, children: content }, context }); + + // Assert. + expect(queryByText(contentText)).toBeNull(); + }); +} + +function explicitPropsTests(setup: ReturnType) { + const contentText = "Fallback content."; + const content = createTestSnippet(contentText); + + beforeEach(() => { + // Fresh router instance for each test + setup.init(); + }); + + afterAll(() => { + // Clean disposal after all tests + setup.dispose(); + }); + + test.each([ + { + routes: { + matching: 1 + }, + text: "matching routes" + }, + { + routes: { + nonMatching: 1 + }, + text: "no matching routes" + } + ])("Should render when the 'when' predicate returns true when there are $text .", async ({ routes }) => { + // Arrange. + const { hash, router, context } = setup; + addRoutes(router, routes); + + // Act. + const { findByText } = render(Fallback, { + props: { hash, when: () => true, children: content }, + context + }); + + // Assert. + await expect(findByText(contentText)).resolves.toBeDefined(); + }); + test.each([ + { + routes: { + matching: 1 + }, + text: "matching routes" + }, + { + routes: { + nonMatching: 1 + }, + text: "no matching routes" + } + ])("Should not render when the 'when' predicate returns false when there are $text .", async ({ routes }) => { + // Arrange. + const { hash, router, context } = setup; + addRoutes(router, routes); + + // Act. + const { queryByText } = render(Fallback, { + props: { hash, when: () => false, children: content }, + context + }); + + // Assert. + expect(queryByText(contentText)).toBeNull(); + }); +} + +function reactivityTests(setup: ReturnType) { + const contentText = "Fallback content."; + const content = createTestSnippet(contentText); + + beforeEach(() => { + // Fresh router instance for each test + setup.init(); + }); + + afterAll(() => { + // Clean disposal after all tests + setup.dispose(); + }); + + test("Should re-render when the 'when' predicate function is exchanged.", async () => { + // Arrange. + const { hash, router, context } = setup; + const { findByText, queryByText, rerender } = render(Fallback, { + props: { hash, when: () => false, children: content }, + context + }); + expect(queryByText(contentText)).toBeNull(); + + // Act. + await rerender({ hash, when: () => true, children: content }); + + // Assert. + await expect(findByText(contentText)).resolves.toBeDefined(); + }); + test("Should re-render when the 'when' predicate function reactively changes its return value.", async () => { + // Arrange. + const { hash, router, context } = setup; + let rv = $state(false); + const { findByText, queryByText, rerender } = render(Fallback, { + props: { hash, when: () => rv, children: content }, + context + }); + expect(queryByText(contentText)).toBeNull(); + + // Act. + rv = true; + flushSync(); + + // Assert. + await expect(findByText(contentText)).resolves.toBeDefined(); + }); +} + +ROUTING_UNIVERSES.forEach(ru => { + describe(`Fallback - ${ru.text}`, () => { + const setup = createRouterTestSetup(ru.hash); + let cleanup: () => void; + beforeAll(() => { + cleanup = init({ + implicitMode: ru.implicitMode, + hashMode: ru.hashMode, + }); + }); + afterAll(() => { + cleanup(); + }); + describe("Default Props", () => { + defaultPropsTests(setup); + }); + describe("Explicit Props", () => { + explicitPropsTests(setup); + }); + describe("Reactivity", () => { + reactivityTests(setup); + }); + }); +}); diff --git a/src/lib/Router/Router.svelte b/src/lib/Router/Router.svelte index 7a86adf..0614382 100644 --- a/src/lib/Router/Router.svelte +++ b/src/lib/Router/Router.svelte @@ -1,7 +1,7 @@