@wroud/navigation
Version:
A flexible, pattern-matching navigation system for JavaScript applications with built-in routing, browser integration, and navigation state management
272 lines (216 loc) • 9.23 kB
text/typescript
import { describe, it, expect, beforeEach } from "vitest";
import { Router } from "./Router.js";
import { TriePatternMatching } from "./pattern-matching/TriePatternMatching.js";
function createPatternRouter(options?: { trailingSlash?: boolean }): Router {
return new Router({
matcher: new TriePatternMatching(options),
});
}
describe("Router", () => {
describe("Pattern-based routing", () => {
let router: Router;
beforeEach(() => {
router = createPatternRouter({ trailingSlash: false });
});
describe("Route management", () => {
it("should add and retrieve routes by ID", () => {
router.addRoute({ id: "/" });
router.addRoute({ id: "/app" });
router.addRoute({ id: "/app/users" });
expect(router.getRoute("/")).toBeDefined();
expect(router.getRoute("/app")).toBeDefined();
expect(router.getRoute("/app/users")).toBeDefined();
expect(router.getRoute("/not-added")).toBeUndefined();
});
it("should reject routes without an ID", () => {
// @ts-expect-error - Testing runtime validation
expect(() => router.addRoute({})).toThrow(/Route ID is required/);
});
it("should prevent duplicate routes with the same ID", () => {
router.addRoute({ id: "/app" });
expect(() => router.addRoute({ id: "/app" })).toThrow(/already exists/);
});
it("should provide access to all registered routes", () => {
router.addRoute({ id: "/" });
router.addRoute({ id: "/app" });
const routes = router.routesList;
expect(routes).toHaveLength(2);
expect(routes.some((r) => r.id === "/")).toBe(true);
expect(routes.some((r) => r.id === "/app")).toBe(true);
});
});
describe("URL matching", () => {
beforeEach(() => {
router.addRoute({ id: "/" });
router.addRoute({ id: "/app" });
router.addRoute({ id: "/app/users" });
router.addRoute({ id: "/app/users/:id" });
});
it("should match URLs to their corresponding routes", () => {
const rootMatch = router.matchUrl("/");
expect(rootMatch?.id).toBe("/");
expect(rootMatch?.params).toEqual({});
const appMatch = router.matchUrl("/app");
expect(appMatch?.id).toBe("/app");
expect(appMatch?.params).toEqual({});
const usersMatch = router.matchUrl("/app/users");
expect(usersMatch?.id).toBe("/app/users");
expect(usersMatch?.params).toEqual({});
const userIdMatch = router.matchUrl("/app/users/123");
expect(userIdMatch?.id).toBe("/app/users/:id");
expect(userIdMatch?.params).toEqual({ id: "123" });
});
it("should return null for non-matching URLs", () => {
const noMatch = router.matchUrl("/non-existent");
expect(noMatch).toBeNull();
const partialMatch = router.matchUrl("/app/non-existent");
expect(partialMatch).toBeNull();
});
it("should match URLs regardless of trailing slashes", () => {
const match1 = router.matchUrl("/app");
expect(match1?.id).toBe("/app");
const match2 = router.matchUrl("/app/");
expect(match2?.id).toBe("/app");
});
});
describe("URL building", () => {
beforeEach(() => {
router.addRoute({ id: "/" });
router.addRoute({ id: "/app" });
router.addRoute({ id: "/app/users/:id" });
});
it("should build URLs from route IDs and parameters", () => {
expect(router.buildUrl("/", {})).toBe("/");
expect(router.buildUrl("/app", {})).toBe("/app");
expect(router.buildUrl("/app/users/:id", { id: "123" })).toBe(
"/app/users/123",
);
});
it("should return null when building URL for non-existent route", () => {
expect(router.buildUrl("/non-existent", {})).toBeNull();
});
it("should handle different parameter values correctly", () => {
expect(router.buildUrl("/app/users/:id", { id: "123" })).toBe(
"/app/users/123",
);
expect(router.buildUrl("/app/users/:id", { id: "john-doe" })).toBe(
"/app/users/john-doe",
);
expect(
router.buildUrl("/app/users/:id", { id: "user@example.com" }),
).toBe("/app/users/user@example.com");
});
});
describe("Route hierarchy", () => {
beforeEach(() => {
router.addRoute({ id: "/" });
router.addRoute({ id: "/app" });
router.addRoute({ id: "/app/users" });
router.addRoute({ id: "/app/users/:id" });
});
it("should establish parent-child relationships based on URL structure", () => {
const userIdRoute = router.getRoute("/app/users/:id");
expect(userIdRoute).toBeDefined();
if (userIdRoute) {
expect(userIdRoute.parents).toContain("/");
expect(userIdRoute.parents).toContain("/app");
expect(userIdRoute.parents).toContain("/app/users");
expect(userIdRoute.parents.length).toBe(3);
}
});
it("should provide access to the immediate parent route", () => {
const rootParent = router.getParentRoute("/");
expect(rootParent).toBeUndefined();
const appParent = router.getParentRoute("/app");
expect(appParent?.id).toBe("/");
const usersParent = router.getParentRoute("/app/users");
expect(usersParent?.id).toBe("/app");
});
it("should return the full route ancestry", () => {
const tree = router.getRouteTree("/app/users/:id");
expect(tree).toHaveLength(4);
const ids = tree.map((route) => route.id);
expect(ids).toContain("/");
expect(ids).toContain("/app");
expect(ids).toContain("/app/users");
expect(ids).toContain("/app/users/:id");
});
it("should throw when requesting a route tree for a non-existent route", () => {
expect(() => router.getRouteTree("/non-existent")).toThrow(/not found/);
});
});
describe("Route state conversion", () => {
beforeEach(() => {
router.addRoute({ id: "/" });
router.addRoute({ id: "/app" });
router.addRoute({ id: "/app/users/:id" });
});
it("should convert between route states and URLs", () => {
const state = {
id: "/app/users/:id",
params: { id: "123" },
};
const url = router.stateToUrl(state);
expect(url).toBe("/app/users/123");
const parsedState = router.urlToState("/app/users/456");
expect(parsedState?.id).toBe("/app/users/:id");
expect(parsedState?.params).toEqual({ id: "456" });
});
it("should return null when converting invalid state to URL", () => {
const invalidState = {
id: "/non-existent",
params: {},
};
expect(router.stateToUrl(invalidState)).toBeNull();
});
it("should return null when converting invalid URL to state", () => {
expect(router.urlToState("/non-existent")).toBeNull();
});
});
});
describe("Routing configuration options", () => {
describe("Custom route matcher", () => {
it("should work with a custom matcher implementation", () => {
const customMatcher = new TriePatternMatching({ trailingSlash: false });
const router = new Router({ matcher: customMatcher });
router.addRoute({ id: "/app" });
router.addRoute({ id: "/app/users/:id" });
expect(router.buildUrl("/app", {})).toBe("/app");
expect(router.buildUrl("/app/users/:id", { id: "123" })).toBe(
"/app/users/123",
);
const match = router.matchUrl("/app/users/456");
expect(match?.id).toBe("/app/users/:id");
expect(match?.params).toEqual({ id: "456" });
});
it("should gracefully handle operations when no matcher is provided", () => {
const router = new Router();
router.addRoute({ id: "/app" });
expect(router.matchUrl("/app")).toBeNull();
expect(router.buildUrl("/app", {})).toBeNull();
expect(router.stateToUrl({ id: "/app", params: {} })).toBeNull();
expect(router.urlToState("/app")).toBeNull();
});
});
describe("TrailingSlash option", () => {
it("should respect the trailingSlash option when building URLs", () => {
const routerWithSlash = createPatternRouter({ trailingSlash: true });
routerWithSlash.addRoute({ id: "/app" });
routerWithSlash.addRoute({ id: "/app/users/:id" });
const routerWithoutSlash = createPatternRouter({
trailingSlash: false,
});
routerWithoutSlash.addRoute({ id: "/app" });
routerWithoutSlash.addRoute({ id: "/app/users/:id" });
expect(routerWithSlash.buildUrl("/app", {})).toBe("/app/");
expect(routerWithSlash.buildUrl("/app/users/:id", { id: "123" })).toBe(
"/app/users/123/",
);
expect(routerWithoutSlash.buildUrl("/app", {})).toBe("/app");
expect(
routerWithoutSlash.buildUrl("/app/users/:id", { id: "123" }),
).toBe("/app/users/123");
});
});
});
});