UNPKG

@wroud/navigation

Version:

A flexible, pattern-matching navigation system for JavaScript applications with built-in routing, browser integration, and navigation state management

1,312 lines (1,083 loc) 52.6 kB
import { describe, test, expect, beforeEach, vi, afterEach } from "vitest"; import { TriePatternMatching } from "./index.js"; import type { ExtractRouteParams } from "./types.js"; import * as parameterUtils from "./parameter-utils.js"; describe("TriePatternMatching", () => { let patternMatcher: TriePatternMatching; beforeEach(() => { patternMatcher = new TriePatternMatching({ trailingSlash: false }); vi.restoreAllMocks(); // Restore all mocks before each test }); afterEach(() => { vi.restoreAllMocks(); }); describe("Pattern registration and removal", () => { test("should add patterns", () => { patternMatcher.addPattern("/user/:id"); patternMatcher.addPattern("/user/:id/settings"); patternMatcher.addPattern("/product/:category/:id"); patternMatcher.addPattern("/posts/:path*/edit"); const patterns = patternMatcher.getPatterns(); expect(patterns).toHaveLength(4); expect(patterns).toContain("/user/:id"); expect(patterns).toContain("/user/:id/settings"); expect(patterns).toContain("/product/:category/:id"); expect(patterns).toContain("/posts/:path*/edit"); }); test("should not add duplicate patterns", () => { patternMatcher.addPattern("/user/:id"); patternMatcher.addPattern("/user/:id"); const patterns = patternMatcher.getPatterns(); expect(patterns).toHaveLength(1); expect(patterns).toContain("/user/:id"); }); test("should handle root path pattern correctly", () => { patternMatcher.addPattern("/"); const match = patternMatcher.match("/"); expect(match).not.toBeNull(); expect(match?.id).toBe("/"); expect(match?.params).toEqual({}); }); test("should handle invalid pattern formats gracefully", () => { // Test with unusual path formats that the implementation actually supports patternMatcher.addPattern("no-leading-slash"); // Verify it was registered const patterns = patternMatcher.getPatterns(); expect(patterns).toContain("no-leading-slash"); // Should be able to match patterns without leading slash const match = patternMatcher.match("no-leading-slash"); expect(match?.id).toBe("no-leading-slash"); }); test("should remove patterns", () => { // Add some patterns patternMatcher.addPattern("/route1"); patternMatcher.addPattern("/route2"); patternMatcher.addPattern("/route3/:id"); // Verify they were added expect(patternMatcher.getPatterns()).toContain("/route1"); expect(patternMatcher.getPatterns()).toContain("/route2"); expect(patternMatcher.getPatterns()).toContain("/route3/:id"); // Should match before removal expect(patternMatcher.match("/route1")).not.toBeNull(); expect(patternMatcher.match("/route2")).not.toBeNull(); expect(patternMatcher.match("/route3/123")).not.toBeNull(); // Remove a pattern patternMatcher.removePattern("/route2"); // Verify it was removed expect(patternMatcher.getPatterns()).not.toContain("/route2"); expect(patternMatcher.match("/route2")).toBeNull(); // Other patterns should still work expect(patternMatcher.match("/route1")).not.toBeNull(); expect(patternMatcher.match("/route3/123")).not.toBeNull(); }); test("should gracefully handle removing non-existent patterns", () => { // This should not throw an error expect(() => patternMatcher.removePattern("/not-added")).not.toThrow(); }); test("should clear caches when removing patterns", () => { // Add pattern patternMatcher.addPattern("/product/:id"); // Create cache entries const url = patternMatcher.encode("/product/:id", { id: "123" }); expect(url).toBe("/product/123"); const match = patternMatcher.match("/product/123"); expect(match?.id).toBe("/product/:id"); // Remove pattern - should invalidate caches patternMatcher.removePattern("/product/:id"); // Pattern should no longer match expect(patternMatcher.match("/product/123")).toBeNull(); }); }); describe("Static route matching", () => { beforeEach(() => { patternMatcher.addPattern("/about"); patternMatcher.addPattern("/contact"); patternMatcher.addPattern("/products/list"); }); test("should match static routes", () => { const match = patternMatcher.match("/about"); expect(match).not.toBeNull(); expect(match?.id).toBe("/about"); expect(match?.params).toEqual({}); }); test("should match multi-segment static routes", () => { const match = patternMatcher.match("/products/list"); expect(match).not.toBeNull(); expect(match?.id).toBe("/products/list"); expect(match?.params).toEqual({}); }); test("should return null for non-matching routes", () => { const match = patternMatcher.match("/nonexistent"); expect(match).toBeNull(); }); test("should handle undefined or empty segments", () => { // Don't actually pass undefined to match since splitUrl doesn't handle it // Instead, test empty string which is a valid edge case const emptyMatch = patternMatcher.match(""); expect(emptyMatch).toBeNull(); // Test with just a slash const slashMatch = patternMatcher.match("/"); // This should find our root pattern if it exists, or return null if (patternMatcher.getPatterns().includes("/")) { expect(slashMatch).not.toBeNull(); expect(slashMatch?.id).toBe("/"); } else { expect(slashMatch).toBeNull(); } }); }); describe("Parameter route matching", () => { beforeEach(() => { patternMatcher.addPattern("/user/:id"); patternMatcher.addPattern("/product/:category/:id"); patternMatcher.addPattern("/blog/:year/:month/:slug"); }); test("should match single parameter routes", () => { const match = patternMatcher.match("/user/123"); expect(match).not.toBeNull(); expect(match?.id).toBe("/user/:id"); expect(match?.params).toEqual({ id: "123" }); }); test("should match multiple parameter routes", () => { const match = patternMatcher.match("/product/electronics/456"); expect(match).not.toBeNull(); expect(match?.id).toBe("/product/:category/:id"); expect(match?.params).toEqual({ category: "electronics", id: "456" }); }); test("should match routes with multiple parameters", () => { const match = patternMatcher.match("/blog/2023/05/hello-world"); expect(match).not.toBeNull(); expect(match?.id).toBe("/blog/:year/:month/:slug"); expect(match?.params).toEqual({ year: "2023", month: "05", slug: "hello-world", }); }); test("should handle parameters with special characters", () => { // Add a pattern with parameters patternMatcher.addPattern("/path/:param"); // Test with various special characters in the parameter const match1 = patternMatcher.match("/path/special@character.com"); expect(match1?.id).toBe("/path/:param"); expect(match1?.params).toEqual({ param: "special@character.com" }); const match2 = patternMatcher.match("/path/with spaces"); expect(match2?.id).toBe("/path/:param"); expect(match2?.params).toEqual({ param: "with spaces" }); const match3 = patternMatcher.match("/path/with-dash"); expect(match3?.id).toBe("/path/:param"); expect(match3?.params).toEqual({ param: "with-dash" }); const match4 = patternMatcher.match("/path/with_underscore"); expect(match4?.id).toBe("/path/:param"); expect(match4?.params).toEqual({ param: "with_underscore" }); }); test("should handle empty parameter values", () => { // Test with empty parameter values // This might behave differently depending on the implementation // We don't care about the result, just that it doesn't throw expect(() => patternMatcher.match("/user/")).not.toThrow(); }); }); describe("Wildcard parameter matching", () => { beforeEach(() => { patternMatcher.addPattern("/files/:path*"); patternMatcher.addPattern("/docs/:section/:path*/edit"); patternMatcher.addPattern("/api/:version/users/:id"); }); test("should match wildcard routes with multiple segments", () => { const match = patternMatcher.match( "/files/documents/reports/annual/2023.pdf", ); expect(match).not.toBeNull(); expect(match?.id).toBe("/files/:path*"); expect(match?.params).toEqual({ path: ["documents", "reports", "annual", "2023.pdf"], }); }); test("should match routes with wildcards in the middle", () => { const match = patternMatcher.match( "/docs/tutorial/chapter1/section2/subsection3/edit", ); expect(match).not.toBeNull(); expect(match?.id).toBe("/docs/:section/:path*/edit"); expect(match?.params).toEqual({ section: "tutorial", path: ["chapter1", "section2", "subsection3"], }); }); test("should match routes with a single wildcard segment", () => { const match = patternMatcher.match("/files/single-file.txt"); expect(match).not.toBeNull(); expect(match?.id).toBe("/files/:path*"); expect(match?.params).toEqual({ path: ["single-file.txt"] }); }); test("should handle empty wildcard paths", () => { // We don't care about the result, just that it doesn't throw expect(() => patternMatcher.match("/files/")).not.toThrow(); }); test("should handle wildcards with special characters", () => { const match = patternMatcher.match( "/files/path with spaces/file.name.with.dots", ); expect(match?.id).toBe("/files/:path*"); expect(match?.params).toEqual({ path: ["path with spaces", "file.name.with.dots"], }); }); }); describe("Route priority", () => { beforeEach(() => { patternMatcher.addPattern("/user/:id/settings"); patternMatcher.addPattern("/user/:id/:action"); patternMatcher.addPattern("/user/profile"); patternMatcher.addPattern("/posts/:path*/edit"); }); test("should prioritize static routes over parameter routes", () => { const match = patternMatcher.match("/user/profile"); expect(match).not.toBeNull(); expect(match?.id).toBe("/user/profile"); expect(match?.params).toEqual({}); }); test("should match parameter routes when no static route matches", () => { const match = patternMatcher.match("/user/123/settings"); expect(match).not.toBeNull(); expect(match?.id).toBe("/user/:id/settings"); expect(match?.params).toEqual({ id: "123" }); }); test("should match wider parameter routes when no specific route matches", () => { const match = patternMatcher.match("/user/123/delete"); expect(match).not.toBeNull(); expect(match?.id).toBe("/user/:id/:action"); expect(match?.params).toEqual({ id: "123", action: "delete" }); }); test("should match wildcard routes for complex paths", () => { const match = patternMatcher.match("/posts/2023/05/my-post/edit"); expect(match).not.toBeNull(); expect(match?.id).toBe("/posts/:path*/edit"); expect(match?.params).toEqual({ path: ["2023", "05", "my-post"] }); }); test("should correctly sort results with different pattern types", () => { // This test only verifies the integration through TriePatternMatching // Detailed tests of sortMatchResults are in matcher.test.ts // Add patterns that would result in multiple potential matches patternMatcher.addPattern("/content/:type/view"); // Parameter patternMatcher.addPattern("/content/article/view"); // Static patternMatcher.addPattern("/content/:path*/view"); // Wildcard // This should match the static route with highest priority const match1 = patternMatcher.match("/content/article/view"); expect(match1?.id).toBe("/content/article/view"); // This should match the parameter route with middle priority const match2 = patternMatcher.match("/content/video/view"); expect(match2?.id).toBe("/content/:type/view"); // This should match the wildcard route with lowest priority const match3 = patternMatcher.match( "/content/category/subcategory/item/view", ); expect(match3?.id).toBe("/content/:path*/view"); }); test("should handle ambiguous routes correctly", () => { // Add ambiguous routes patternMatcher.addPattern("/items/:id"); patternMatcher.addPattern("/items/new"); patternMatcher.addPattern("/items/:category/:id"); // Static route should take priority const match1 = patternMatcher.match("/items/new"); expect(match1?.id).toBe("/items/new"); // Parameter route should match const match2 = patternMatcher.match("/items/123"); expect(match2?.id).toBe("/items/:id"); // Multi-parameter route should match const match3 = patternMatcher.match("/items/books/456"); expect(match3?.id).toBe("/items/:category/:id"); }); }); describe("Decoding parameters", () => { test("should decode parameters from static routes", () => { const pattern = "/about"; const url = "/about"; const params = patternMatcher.decode(pattern, url); expect(params).toEqual({}); }); test("should decode parameters from parameter routes", () => { const pattern = "/user/:id/:action"; const url = "/user/123/edit"; const params = patternMatcher.decode(pattern, url); expect(params).toEqual({ id: "123", action: "edit" }); }); test("should decode parameters from wildcard routes", () => { const pattern = "/posts/:path*/edit"; const url = "/posts/2023/05/hello-world/edit"; const params = patternMatcher.decode(pattern, url); expect(params).toEqual({ path: ["2023", "05", "hello-world"] }); }); test("should return null for non-matching URLs", () => { const pattern = "/user/:id"; const url = "/not-matching/123"; const params = patternMatcher.decode(pattern, url); expect(params).toBeNull(); }); test("should return null for URLs with wrong segment count", () => { const pattern = "/user/:id/:action"; const url = "/user/123"; const params = patternMatcher.decode(pattern, url); expect(params).toBeNull(); }); test("should register pattern if not already registered when decoding", () => { const addPatternSpy = vi.spyOn(patternMatcher, "addPattern"); const newPattern = "/new-route/:id"; patternMatcher.decode(newPattern, "/new-route/456"); expect(addPatternSpy).toHaveBeenCalledWith(newPattern); }); test("should decode typed number and boolean parameters", () => { const pattern = "/blog/:year<number>/:month<number>/:slug"; const url = "/blog/2023/1/hello"; const params = patternMatcher.decode(pattern, url); expect(params).toEqual({ year: 2023, month: 1, slug: "hello" }); }); test("should decode boolean parameter", () => { const pattern = "/user/enable/:state<boolean>"; const url = "/user/enable/true"; const params = patternMatcher.decode(pattern, url); expect(params).toEqual({ state: true }); }); test("should decode typed wildcard parameters", () => { const pattern = "/users/:id<number>*"; const url = "/users/1/2/3"; const params = patternMatcher.decode(pattern, url); expect(params).toEqual({ id: [1, 2, 3] }); }); }); describe("Encoding parameters", () => { test("should encode parameters for static routes", () => { const pattern = "/about"; const params = {} as ExtractRouteParams<"/about">; const url = patternMatcher.encode(pattern, params); expect(url).toBe("/about"); }); test("should encode parameters for parameter routes", () => { const pattern = "/user/:id/:action"; const params = { id: "123", action: "edit", } as ExtractRouteParams<"/user/:id/:action">; const url = patternMatcher.encode(pattern, params); expect(url).toBe("/user/123/edit"); }); test("should encode wildcard parameters with arrays", () => { const pattern = "/posts/:path*/edit"; const params = { path: ["2023", "05", "hello-world"], } as ExtractRouteParams<"/posts/:path*/edit">; const url = patternMatcher.encode(pattern, params); expect(url).toBe("/posts/2023/05/hello-world/edit"); }); test("should encode wildcard parameters with string", () => { const pattern = "/files/:path*"; const params = { path: "document.pdf", } as unknown as ExtractRouteParams<"/files/:path*">; const url = patternMatcher.encode(pattern, params); expect(url).toBe("/files/document.pdf"); }); test("should throw error for missing parameters", () => { const pattern = "/user/:id/:action"; const params = { id: "123" } as Partial< ExtractRouteParams<"/user/:id/:action"> >; expect(() => patternMatcher.encode(pattern, params as any)).toThrow( /Parameter 'action' is not of type 'string'/i, ); }); test("should throw error for missing wildcard parameters", () => { const pattern = "/posts/:path*/edit"; const params = {} as Partial<ExtractRouteParams<"/posts/:path*/edit">>; expect(() => patternMatcher.encode(pattern, params as any)).toThrow( /Parameter 'path' is not of type 'string'/i, ); }); test("should handle array with undefined values in wildcard parameters", () => { const pattern = "/files/:path*"; const params = { path: ["docs", undefined as unknown as string, "file.txt"], } as ExtractRouteParams<"/files/:path*">; expect(() => patternMatcher.encode(pattern, params)).toThrow( "Parameter 'path' at index 1 is not of type 'string'", ); }); test("should handle invalid parameter values", () => { const pattern = "/user/:id"; const params = { id: [] as unknown as string, } as ExtractRouteParams<"/user/:id">; expect(() => patternMatcher.encode(pattern, params)).toThrow( /Parameter 'id' is not of type 'string'/i, ); }); }); describe("Parameter edge cases", () => { test("should handle wildcards with single string value", () => { // Add pattern with a wildcard parameter patternMatcher.addPattern("/files/:path*"); // Test with a single value as a string instead of an array const url = patternMatcher.encode("/files/:path*", { // @ts-expect-error - Intentionally passing string instead of array to test edge case path: "single-file.txt", }); // Should handle it gracefully expect(url).toBe("/files/single-file.txt"); }); test("should throw error for undefined values in wildcard arrays", () => { patternMatcher.addPattern("/nested/:sections*/:id"); // Intentionally include undefined in the array to test error throwing expect(() => patternMatcher.encode("/nested/:sections*/:id", { // @ts-expect-error - Intentionally including undefined to test error sections: ["valid", undefined, "also-valid"], id: "123", }), ).toThrow("Parameter 'sections' at index 1 is not of type 'string'"); }); }); describe("Path utilities edge cases", () => { test("should handle joining long path segments", () => { // Create a pattern with many segments to test path joining const manySegments = Array(10) .fill(0) .map((_, i) => `segment${i}`); // Construct the pattern in a way that correctly alternates static and parameter segments let pattern = ""; const params: Record<string, string> = {}; manySegments.forEach((segment, index) => { pattern += `/${segment}`; if (index < manySegments.length - 1) { const paramName = `param${index}`; pattern += `/:${paramName}`; params[paramName] = `value${index}`; } }); patternMatcher.addPattern(pattern); // Encode the URL with many segments const url = patternMatcher.encode(pattern, params); // Verify all segments are present for (let i = 0; i < manySegments.length; i++) { expect(url).toContain(`segment${i}`); if (i < manySegments.length - 1) { // All except the last segment will have params expect(url).toContain(`value${i}`); } } }); test("should properly handle cache eviction in path joining", () => { // This is hard to test directly, so we'll just verify basic path joining works // with many different paths to trigger potential cache evictions for (let i = 0; i < 150; i++) { // More than MAX_CACHE_SIZE const pattern = `/cache-test-${i}/:id`; patternMatcher.addPattern(pattern); const url = patternMatcher.encode(pattern, { id: `value-${i}` }); expect(url).toBe(`/cache-test-${i}/value-${i}`); } // Verify the last few still work after potential evictions for (let i = 145; i < 150; i++) { const pattern = `/cache-test-${i}/:id`; const url = patternMatcher.encode(pattern, { id: `value-${i}` }); expect(url).toBe(`/cache-test-${i}/value-${i}`); } }); }); describe("Caching mechanism", () => { test("should cache decode results and reuse them", () => { const pattern = "/user/:id"; const url = "/user/123"; // First call should compute the result const matchSpy = vi.spyOn(patternMatcher, "match"); patternMatcher.decode(pattern, url); expect(matchSpy).toHaveBeenCalledTimes(1); // Second call should use cache matchSpy.mockClear(); patternMatcher.decode(pattern, url); expect(matchSpy).not.toHaveBeenCalled(); }); test("should cache encode results and reuse them", () => { const pattern = "/user/:id"; const params = { id: "123" }; // Setup spy on the imported utility functions const validateSpy = vi.spyOn(parameterUtils, "validateParameters"); // First call should compute the result patternMatcher.encode(pattern, params); expect(validateSpy).toHaveBeenCalledTimes(1); // Second call with the same params should use cache validateSpy.mockClear(); patternMatcher.encode(pattern, params); expect(validateSpy).not.toHaveBeenCalled(); // Different params should not use cache validateSpy.mockClear(); patternMatcher.encode(pattern, { id: "456" }); expect(validateSpy).toHaveBeenCalledTimes(1); }); test("should clear caches when adding a new pattern", () => { // Set up some cache entries patternMatcher.decode("/user/:id", "/user/123"); patternMatcher.encode("/user/:id", { id: "123" }); // Add a new pattern, which should clear caches const matchSpy = vi.spyOn(patternMatcher, "match"); patternMatcher.addPattern("/new-pattern"); // Cache should be cleared, so this should call match again patternMatcher.decode("/user/:id", "/user/123"); expect(matchSpy).toHaveBeenCalledTimes(1); }); test("should handle decode cache eviction when iterator.next().value is undefined", () => { // EXPECTED BEHAVIOR: The matcher should not throw errors when managing cache, // even if there's an issue with the cache iteration // Generate a large number of patterns to force potential cache eviction for (let i = 0; i < 30; i++) { const pattern = `/test-pattern-${i}`; patternMatcher.decode(pattern, `/test-url-${i}`); } // No error should occur, and the function should continue working const pattern = "/another-pattern"; const result = patternMatcher.decode(pattern, "/another-url"); expect(result).toBeNull(); // Should be null since the pattern doesn't match }); test("should handle encode cache eviction when iterator.next().value is undefined", () => { // EXPECTED BEHAVIOR: The matcher should not throw errors when managing cache, // even if there's an issue with the cache iteration // Generate a large number of patterns to force potential cache eviction for (let i = 0; i < 30; i++) { const pattern = `/test-pattern-${i}`; patternMatcher.addPattern(pattern); patternMatcher.encode(pattern, { id: i.toString() }); } // No error should occur, and the function should continue working const pattern = "/user/:id"; patternMatcher.addPattern(pattern); const result = patternMatcher.encode(pattern, { id: "test" }); expect(result).toBe("/user/test"); }); test("should handle decode cache with limited size", () => { // EXPECTED BEHAVIOR: Cache has limited size, older entries should be dropped // Create a large number of patterns to exceed any reasonable cache size const LARGE_NUMBER = 2000; // Should be larger than any expected cache size // First pattern we'll test later to see if it's been evicted const firstPattern = "/first-pattern"; patternMatcher.decode(firstPattern, "/first-url"); // Add many patterns to force eviction of older cache entries for (let i = 0; i < LARGE_NUMBER; i++) { patternMatcher.decode(`/pattern-${i}`, `/url-${i}`); } // Add a spy after filling the cache const matchSpy = vi.spyOn(patternMatcher, "match"); // Test if the first pattern was evicted (should call match again) patternMatcher.decode(firstPattern, "/first-url"); // Verify match was called again (because entry was evicted) expect(matchSpy).toHaveBeenCalledTimes(1); }); test("should handle encode cache with limited size", () => { // EXPECTED BEHAVIOR: Cache has limited size, older entries should be dropped // Create a large number of patterns to exceed any reasonable cache size const LARGE_NUMBER = 2000; // Should be larger than any expected cache size // First pattern we'll test later to see if it's been evicted const firstPattern = "/first-pattern/:id"; patternMatcher.addPattern(firstPattern); patternMatcher.encode(firstPattern, { id: "test" }); // Add many patterns to force eviction of older cache entries for (let i = 0; i < LARGE_NUMBER; i++) { const pattern = `/pattern-${i}/:id`; patternMatcher.addPattern(pattern); patternMatcher.encode(pattern, { id: i.toString() }); } // Add a spy after filling the cache const validateSpy = vi.spyOn(parameterUtils, "validateParameters"); // Test if the first pattern was evicted (should validate again) patternMatcher.encode(firstPattern, { id: "test" }); // Verify validation was called again (because entry was evicted) expect(validateSpy).toHaveBeenCalledTimes(1); }); test("should explicitly clear caches with clearCaches method", () => { // Set up some cache entries patternMatcher.decode("/user/:id", "/user/123"); patternMatcher.encode("/blog/:slug", { slug: "post" }); // Clear caches patternMatcher.clearCaches(); // Access private properties for verification const instance = patternMatcher as any; expect(instance.decodeCache.size).toBe(0); expect(instance.encodeCache.size).toBe(0); }); test("should handle null coalescing for cache gets", () => { // This test verifies the `??` operator works correctly // For decoding - directly test the decoded value const instance = patternMatcher as any; // Add pattern for decode test patternMatcher.addPattern("/test"); // Create a pattern cache with a direct null value const patternMap = new Map([["/test-url", null]]); instance.decodeCache.set("/test", patternMap); // Should return null as expected expect(patternMatcher.decode("/test", "/test-url")).toBeNull(); // For encoding - directly test with undefined value // Make sure pattern is registered patternMatcher.addPattern("/encode-test"); // Create a param cache with undefined value const paramMap = new Map([ ["{}", ""], // Empty string to test ?? "" ]); instance.encodeCache.set("/encode-test", paramMap); // The encode result should be an empty string, not undefined expect(patternMatcher.encode("/encode-test", {})).toBe(""); }); test("should handle wildcards with undefined segments", () => { patternMatcher.addPattern("/files/:path*"); // Create a URL that will match after splitting and removing empty segments const match = patternMatcher.match("/files/document.pdf"); expect(match).not.toBeNull(); expect(match?.id).toBe("/files/:path*"); expect(match?.params).toEqual({ path: ["document.pdf"] }); }); test("should correctly sort patterns with and without wildcards", () => { // Test routing behavior with different pattern types patternMatcher.addPattern("/users/profile"); // Static route patternMatcher.addPattern("/users/:id"); // Parameter route patternMatcher.addPattern("/users/:path*"); // Wildcard route // Static should match first expect(patternMatcher.match("/users/profile")?.id).toBe("/users/profile"); // Parameter should match when no static pattern matches expect(patternMatcher.match("/users/123")?.id).toBe("/users/:id"); // Wildcard should match deeper paths expect(patternMatcher.match("/users/a/b/c")?.id).toBe("/users/:path*"); }); test("should optimize when there are static and param results but no wildcard nodes", () => { // Create a matcher with multiple route types but no wildcards patternMatcher.addPattern("/api/users"); // Static patternMatcher.addPattern("/api/:entity"); // Parameter // This should match the static route const match1 = patternMatcher.match("/api/users"); expect(match1?.id).toBe("/api/users"); // This should match the parameter route const match2 = patternMatcher.match("/api/products"); expect(match2?.id).toBe("/api/:entity"); }); test("should handle empty segments appropriately", () => { /** * EXPECTED BEHAVIOR SPECIFICATION FOR EMPTY SEGMENTS: * 1. Leading and trailing slashes should be normalized * - "/user" should match "user" pattern * - "user/" should match "user" pattern * 2. Root path "/" should match root pattern "/" * 3. Known limitations (these tests document current behavior): * - URLs with empty segments in the middle (like "/user//profile") are not matched */ // Register some patterns for testing patternMatcher.addPattern("/user/:id"); patternMatcher.addPattern("/profile"); patternMatcher.addPattern("/"); // Test 1: Normal URL without empty segments - using match instead of decode const normalMatch = patternMatcher.match("/user/123"); expect(normalMatch).not.toBeNull(); expect(normalMatch?.id).toBe("/user/:id"); expect(normalMatch?.params).toEqual({ id: "123" }); // For decode, the first parameter is the pattern, second is the URL // decode returns only the params, not the full match result const result1 = patternMatcher.decode("/user/:id", "/user/123"); expect(result1).toEqual({ id: "123" }); // Test 2: Trailing empty segment should normalize correctly const trailingEmptyMatch = patternMatcher.match("/profile/"); expect(trailingEmptyMatch).not.toBeNull(); expect(trailingEmptyMatch?.id).toBe("/profile"); const result2 = patternMatcher.decode("/profile", "/profile/"); expect(result2).toEqual({}); // Test 3: Root path should match root pattern const rootMatch = patternMatcher.match("/"); expect(rootMatch).not.toBeNull(); expect(rootMatch?.id).toBe("/"); const result3 = patternMatcher.decode("/", "/"); expect(result3).toEqual({}); // Test 4: Double leading slashes (URL normalization issue) // Current implementation doesn't match this, but ideally it should const leadingEmptyMatch = patternMatcher.match("//profile"); expect(leadingEmptyMatch).toBeNull(); // NOTE: Current implementation limitation - double leading slashes aren't matched // Test 5: Empty segment in the middle // Current implementation doesn't match this, but ideally it should const middleEmptyMatch = patternMatcher.match("/user//123"); expect(middleEmptyMatch).toBeNull(); // NOTE: Current implementation limitation - empty segments in the middle aren't matched }); }); describe("Edge cases", () => { test("should handle nested patterns with same prefix", () => { patternMatcher.addPattern("/users"); patternMatcher.addPattern("/users/new"); patternMatcher.addPattern("/users/:id"); patternMatcher.addPattern("/users/:id/edit"); patternMatcher.addPattern("/users/:id/posts/:postId"); // Test static routes expect(patternMatcher.match("/users")?.id).toBe("/users"); expect(patternMatcher.match("/users/new")?.id).toBe("/users/new"); // Test parameter routes expect(patternMatcher.match("/users/123")?.id).toBe("/users/:id"); expect(patternMatcher.match("/users/123/edit")?.id).toBe( "/users/:id/edit", ); // Test multi-parameter routes const match = patternMatcher.match("/users/123/posts/456"); expect(match?.id).toBe("/users/:id/posts/:postId"); expect(match?.params).toEqual({ id: "123", postId: "456" }); }); test("should handle route IDs that don't start with /", () => { patternMatcher.addPattern("no-leading-slash"); const match = patternMatcher.match("no-leading-slash"); expect(match?.id).toBe("no-leading-slash"); }); }); describe("Pattern sorting edge cases", () => { // Direct tests of the matcher.sortMatchResults function have been moved to matcher.test.ts test("should handle pattern sorting with complex routes", () => { // Add various patterns to test routing priority patternMatcher.addPattern("/test/static"); patternMatcher.addPattern("/test/:param"); patternMatcher.addPattern("/test/:path*"); // Verify route priorities through match behavior expect(patternMatcher.match("/test/static")?.id).toBe("/test/static"); expect(patternMatcher.match("/test/param-value")?.id).toBe( "/test/:param", ); expect(patternMatcher.match("/test/multi/segment/path")?.id).toBe( "/test/:path*", ); }); test("should correctly prioritize static segments in wildcard routes", () => { // Add patterns with same type but different static segment counts patternMatcher.addPattern("/api/:version/:path*/data"); // 2 static segments patternMatcher.addPattern("/api/:path*"); // 1 static segment // Should prioritize the pattern with more static segments const match = patternMatcher.match("/api/v1/users/list/data"); expect(match?.id).toBe("/api/:version/:path*/data"); }); }); describe("TrailingSlash option", () => { test("should add trailing slash when trailingSlash is true (default)", () => { const matcher = new TriePatternMatching({ trailingSlash: true }); matcher.addPattern("/about"); const url = matcher.encode("/about", {}); expect(url).toBe("/about/"); }); test("should remove trailing slash when trailingSlash is false", () => { const matcher = new TriePatternMatching({ trailingSlash: false }); matcher.addPattern("/about"); const url = matcher.encode("/about", {}); expect(url).toBe("/about"); }); test("should add trailing slash to all encoded URLs when trailingSlash is true", () => { const matcher = new TriePatternMatching({ trailingSlash: true }); matcher.addPattern("/user/:id"); matcher.addPattern("/files/:path*"); const url1 = matcher.encode("/user/:id", { id: "123" }); expect(url1).toBe("/user/123/"); const url2 = matcher.encode("/files/:path*", { path: ["docs", "file.pdf"], }); expect(url2).toBe("/files/docs/file.pdf/"); }); test("should remove trailing slash from all encoded URLs when trailingSlash is false", () => { const matcher = new TriePatternMatching({ trailingSlash: false }); matcher.addPattern("/user/:id/"); matcher.addPattern("/files/:path*/"); const url1 = matcher.encode("/user/:id/", { id: "123" }); expect(url1).toBe("/user/123"); const url2 = matcher.encode("/files/:path*/", { path: ["docs", "file.pdf"], }); expect(url2).toBe("/files/docs/file.pdf"); }); test("should always match URLs regardless of trailing slash in the URL", () => { // Both matchers should match regardless of slash in the URL, as that's a normalization step const matcherWithSlash = new TriePatternMatching({ trailingSlash: true }); const matcherWithoutSlash = new TriePatternMatching({ trailingSlash: false, }); matcherWithSlash.addPattern("/about"); matcherWithoutSlash.addPattern("/about"); // Both matchers should match these URLs expect(matcherWithSlash.match("/about")).not.toBeNull(); expect(matcherWithSlash.match("/about/")).not.toBeNull(); expect(matcherWithoutSlash.match("/about")).not.toBeNull(); expect(matcherWithoutSlash.match("/about/")).not.toBeNull(); }); }); describe("Route state and URL conversion", () => { beforeEach(() => { patternMatcher.addPattern("/"); patternMatcher.addPattern("/user/:id"); patternMatcher.addPattern("/blog/:year/:month/:day/:slug"); patternMatcher.addPattern("/files/:path*"); }); test("should convert between route states and URLs", () => { // Root path const rootState = { id: "/", params: {} }; const rootUrl = patternMatcher.stateToUrl(rootState); expect(rootUrl).toBe("/"); // Single parameter const userState = { id: "/user/:id", params: { id: "123" } }; const userUrl = patternMatcher.stateToUrl(userState); expect(userUrl).toBe("/user/123"); // Multiple parameters const blogState = { id: "/blog/:year/:month/:day/:slug", params: { year: "2023", month: "05", day: "15", slug: "hello-world" }, }; const blogUrl = patternMatcher.stateToUrl(blogState); expect(blogUrl).toBe("/blog/2023/05/15/hello-world"); // Wildcard parameter const filesState = { id: "/files/:path*", params: { path: ["docs", "reports", "2023.pdf"] }, }; const filesUrl = patternMatcher.stateToUrl(filesState); expect(filesUrl).toBe("/files/docs/reports/2023.pdf"); }); test("should correctly convert URLs to route states", () => { // Root path const rootState = patternMatcher.urlToState("/"); expect(rootState).toEqual({ id: "/", params: {} }); // Single parameter const userState = patternMatcher.urlToState("/user/123"); expect(userState).toEqual({ id: "/user/:id", params: { id: "123" } }); // Multiple parameters const blogState = patternMatcher.urlToState( "/blog/2023/05/15/hello-world", ); expect(blogState).toEqual({ id: "/blog/:year/:month/:day/:slug", params: { year: "2023", month: "05", day: "15", slug: "hello-world" }, }); // Wildcard parameter const filesState = patternMatcher.urlToState( "/files/docs/reports/2023.pdf", ); expect(filesState).toEqual({ id: "/files/:path*", params: { path: ["docs", "reports", "2023.pdf"] }, }); }); test("should return null for non-matching URLs", () => { const state = patternMatcher.urlToState("/nonexistent/path"); expect(state).toBeNull(); }); test("should handle URL encoding/decoding correctly", () => { patternMatcher.addPattern("/search/:query"); // Test with URL-encodable characters const state = { id: "/search/:query", params: { query: "test with spaces & special chars" }, }; const url = patternMatcher.stateToUrl(state); // The URL should be usable for navigation expect(url).toBe("/search/test with spaces & special chars"); // And we should be able to convert it back const parsedState = patternMatcher.urlToState( "/search/test with spaces & special chars", ); expect(parsedState).toEqual(state); }); }); describe("Pattern ancestor and descendant detection", () => { beforeEach(() => { patternMatcher.addPattern("/"); patternMatcher.addPattern("/app"); patternMatcher.addPattern("/app/users"); patternMatcher.addPattern("/app/users/:id"); patternMatcher.addPattern("/app/settings"); patternMatcher.addPattern("/blog"); patternMatcher.addPattern("/blog/:id"); }); test("should find pattern ancestors correctly", () => { // Root has no ancestors const rootAncestors = patternMatcher.getPatternAncestors("/"); expect(rootAncestors).toEqual([]); // Test with a pattern we know exists patternMatcher.addPattern("/products"); patternMatcher.addPattern("/products/items"); // Check that the child has the parent in ancestors const itemsAncestors = patternMatcher.getPatternAncestors("/products/items"); expect(itemsAncestors).toContain("/products"); }); test("should find pattern descendants correctly", () => { // Test with patterns we know exist patternMatcher.addPattern("/categories"); patternMatcher.addPattern("/categories/featured"); patternMatcher.addPattern("/categories/popular"); // Categories should have featured and popular as descendants const descendants = patternMatcher.getPatternDescendants("/categories"); expect(descendants).toContain("/categories/featured"); expect(descendants).toContain("/categories/popular"); }); test("should handle non-existent patterns", () => { const ancestors = patternMatcher.getPatternAncestors("/nonexistent"); expect(ancestors).toEqual([]); const descendants = patternMatcher.getPatternDescendants("/nonexistent"); expect(descendants).toEqual([]); }); }); describe("Cache management", () => { beforeEach(() => { patternMatcher.addPattern("/user/:id"); patternMatcher.addPattern("/blog/:slug"); }); test("should use and clear caches appropriately", () => { // First access should populate the cache const url1 = patternMatcher.encode("/user/:id", { id: "123" }); expect(url1).toBe("/user/123"); const url2 = patternMatcher.encode("/user/:id", { id: "123" }); expect(url2).toBe("/user/123"); // Clear the caches patternMatcher.clearCaches(); // Access again - should still work const url3 = patternMatcher.encode("/user/:id", { id: "123" }); expect(url3).toBe("/user/123"); }); }); describe("Trailing slash handling", () => { test("should respect trailingSlash option when true", () => { const withTrailingSlash = new TriePatternMatching({ trailingSlash: true, }); withTrailingSlash.addPattern("/user/:id"); const url = withTrailingSlash.encode("/user/:id", { id: "123" }); expect(url).toBe("/user/123/"); }); test("should respect trailingSlash option when false", () => { const withoutTrailingSlash = new TriePatternMatching({ trailingSlash: false, }); withoutTrailingSlash.addPattern("/user/:id"); const url = withoutTrailingSlash.encode("/user/:id", { id: "123" }); expect(url).toBe("/user/123"); }); test("should match URLs regardless of trailing slash", () => { // Should match with or without trailing slash patternMatcher.addPattern("/about"); const match1 = patternMatcher.match("/about"); expect(match1?.id).toBe("/about"); const match2 = patternMatcher.match("/about/"); expect(match2?.id).toBe("/about"); }); }); describe("Base path handling", () => { test("should handle base paths correctly", () => { const withBase = new TriePatternMatching({ trailingSlash: false, base: "/api", }); withBase.addPattern("/users/:id"); // Should add the base to the URL const url = withBase.encode("/users/:id", { id: "123" }); expect(url).toBe("/api/users/123"); // Should match URLs with the base const match = withBase.match("/api/users/123"); expect(match?.id).toBe("/users/:id"); expect(match?.params).toEqual({ id: "123" }); }); }); describe("Query parameters", () => { test("should match URL with query parameters", () => { patternMatcher.addPattern("/user/:id?tab=:tab"); const match = patternMatcher.match("/user/42?tab=settings"); expect(match?.id).toBe("/user/:id?tab=:tab"); expect(match?.params).toEqual({ id: "42", tab: "settings" }); }); test("should match URL with multiple query parameters", () => { patternMatcher.addPattern("/search?q=:query&page=:page<number>"); const match = patternMatcher.match("/search?q=hello&page=3"); expect(match?.id).toBe("/search?q=:query&page=:page<number>"); expect(match?.params).toEqual({ query: "hello", page: 3 }); }); test("should match URL when query params are missing (optional)", () => { patternMatcher.addPattern("/user/:id?tab=:tab"); const match = patternMatcher.match("/user/42"); expect(match?.id).toBe("/user/:id?tab=:tab"); expect(match?.params).toEqual({ id: "42" }); }); test("should encode URL with query parameters", () => { patternMatcher.addPattern("/user/:id?tab=:tab"); const url = patternMatcher.encode("/user/:id?tab=:tab", { id: "42", tab: "settings", } as any); expect(url).toBe("/user/42?tab=settings"); }); test("should encode URL with typed query parameters", () => { patternMatcher.addPattern("/search?q=:query&page=:page<number>"); const url = patternMatcher.encode("/search?q=:query&page=:page<number>", { query: "hello", page: 3, } as any); expect(url).toBe("/search?q=hello&page=3"); }); test("should decode URL with query parameters", () => { patternMatcher.addPattern("/user/:id?tab=:tab"); const params = patternMatcher.decode( "/user/:id?tab=:tab", "/user/42?tab=settings", ); expect(params).toEqual({ id: "42", tab: "settings" }); }); test("should handle query params with date type", () => { patternMatcher.addPattern("/events?from=:from<date>"); const match = patternMatcher.match( "/events?from=2024-01-15T00:00:00.000Z", ); expect(match?.id).toBe("/events?from=:from<date>"); expect((match?.params as any).from).toBeInstanceOf(Date); }); test("should handle query params with json type", () => { patternMatcher.addPattern("/api?filter=:filter<json>"); const match = patternMatcher.match('/api?filter={"status":"active"}'); expect(match?.id).toBe("/api?filter=:filter<json>"); expect((match?.params as any).filter).toEqual({ status: "active" }); }); test("should work with base URL and query parameters", () => { const withBase = new TriePatternMatching({ base: "/app", trailingSlash: false, }); withBase.addPattern("/users/:id?tab=:tab"); const url = withBase.encode("/users/:id?tab=:tab", { id: "1", tab: "profile", } as any); expect(url).toBe("/app/users/1?tab=profile"); const match = withBase.match("/app/users/1?tab=profile"); expect(match?.params).toEqual({ id: "1", tab: "profile" }); }); test("should get ancestors for pattern with query params", () => { patternMatcher.addPattern("/user"); patternMatcher.addPattern("/user/:id?tab=:tab"); const ancestors = patternMatcher.getPatternAncestors("/user/:id?tab=:tab"); expect(ancestors).toContain("/user"); }); test("should convert stateToUrl with query params", () => { patternMatcher.addPattern("/user/:id?tab=:tab"); const url = patternMatcher.stateToUrl({ id: "/user/:id?tab=:tab", params: { id: "5", tab: "info" }, } as any); expect(url).toBe("/user/5?tab=info"); }); test("should convert urlToState with query params", () => { patternMatcher.addPattern("/user/:id?tab=:tab"); const state = patternMatcher.urlToState("/user/5?tab=info"); expect(state?.id).toBe("/user/:id?tab=:tab"); expect(state?.params).toEqual({ id: "5", tab: "info" }); }); test("should not match URL when required query param is missing", () => { patternMatcher.addPattern("/search?q=:query!&page=:page<number>"); const match = patternMatcher.match("/search?page=3"); expect(match).toBeNull(); }); test("should match URL when required query param is present", () => { patternMatcher.addPattern("/search?q=:query!&page=:page<number>"); const match = patternMatcher.match("/search?q=hello"); expect(mat