diff --git a/README.md b/README.md index dd40688..23c79d2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > **📝 Small and Unique!** > -> + Less than **1,200** lines of code, including TypeScript typing. +> + Less than **1,250** lines of code, including TypeScript typing. > + Always-on path and hash routing. Simultaneous and independent routing modes. > + The router that invented multi hash routing. @@ -121,7 +121,7 @@ location.goTo('/'); For applications that also run in the browser, condition the navigation to Electron only. See the [Electron page](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/introduction/electron-support) online for more details. -> **⚠️ Important:** Hash routing doesn't require this extra step. +> **⚠️ Important:** Hash routing doesn't require this extra navigation step. ### Define the Routes diff --git a/src/lib/core/LocationFull.test.ts b/src/lib/core/LocationFull.test.ts index 70647d5..29cd4e6 100644 --- a/src/lib/core/LocationFull.test.ts +++ b/src/lib/core/LocationFull.test.ts @@ -1,51 +1,21 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { LocationFull } from "./LocationFull.js"; import type { State, Location } from "$lib/types.js"; -import { joinPaths } from "./RouterEngine.svelte.js"; +import { setupBrowserMocks, ALL_HASHES } from "../../testing/test-utils.js"; describe("LocationFull", () => { const initialUrl = "http://example.com/"; - let interceptedState: State; - const pushStateMock = vi.fn((state, _, url) => { - url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - }); - const replaceStateMock = vi.fn((state, _, url) => { - url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - }); let location: Location; - let _href: string; - beforeAll(() => { - // @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 - }; - }); + let browserMocks: ReturnType; + beforeEach(() => { - globalThis.window.location.href = initialUrl; - interceptedState = { path: undefined, hash: {} }; - pushStateMock.mockReset(); - replaceStateMock.mockReset(); + browserMocks = setupBrowserMocks(initialUrl); location = new LocationFull(); }); + afterEach(() => { location.dispose(); + browserMocks.cleanup(); }); describe('constructor', () => { test("Should create a new instance with the expected default values.", () => { @@ -60,7 +30,7 @@ describe("LocationFull", () => { const unSub = location.on('beforeNavigate', callback); // Act. - globalThis.window.history.pushState(null, '', 'http://example.com/other'); + browserMocks.history.pushState(null, '', 'http://example.com/other'); // Assert. expect(callback).toHaveBeenCalledOnce(); @@ -77,7 +47,7 @@ describe("LocationFull", () => { unSub(); // Assert. - globalThis.window.history.pushState(null, '', 'http://example.com/other'); + browserMocks.history.pushState(null, '', 'http://example.com/other'); expect(callback).not.toHaveBeenCalled(); }); test("Should not affect other handlers when unregistering one of the event handlers.", () => { @@ -91,7 +61,7 @@ describe("LocationFull", () => { unSub1(); // Assert. - globalThis.window.history.pushState(null, '', 'http://example.com/other'); + browserMocks.history.pushState(null, '', 'http://example.com/other'); expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledOnce(); @@ -115,7 +85,7 @@ describe("LocationFull", () => { // Act. // @ts-expect-error stateFn cannot enumerate history. - globalThis.window.history[stateFn](state, '', 'http://example.com/other'); + browserMocks.history[stateFn](state, '', 'http://example.com/other'); // Assert. expect(callback).toHaveBeenCalledWith({ @@ -137,7 +107,7 @@ describe("LocationFull", () => { const unSub2 = location.on('beforeNavigate', callback); // Act. - globalThis.window.history.pushState(null, '', 'http://example.com/other'); + browserMocks.history.pushState(null, '', 'http://example.com/other'); // Assert. expect(callback).toHaveBeenCalledWith({ @@ -161,7 +131,7 @@ describe("LocationFull", () => { const unSub3 = location.on('beforeNavigate', callback); // Act. - globalThis.window.history.pushState(null, '', 'http://example.com/other'); + browserMocks.history.pushState(null, '', 'http://example.com/other'); // Assert. expect(callback).toHaveBeenCalledWith({ @@ -190,11 +160,11 @@ describe("LocationFull", () => { const unSub = location.on('beforeNavigate', callback); // Act. - globalThis.window.history[stateFn](null, '', 'http://example.com/other'); + browserMocks.history[stateFn](null, '', 'http://example.com/other'); // Assert. expect(callback).toHaveBeenCalledOnce(); - expect(globalThis.window.history.state).deep.equal(state); + expect(browserMocks.history.state).deep.equal(state); // Cleanup. unSub(); @@ -206,7 +176,7 @@ describe("LocationFull", () => { const unSub2 = location.on('navigationCancelled', callback); // Act. - globalThis.window.history.pushState(null, '', 'http://example.com/other'); + browserMocks.history.pushState(null, '', 'http://example.com/other'); // Assert. expect(callback).toHaveBeenCalledOnce(); @@ -224,7 +194,7 @@ describe("LocationFull", () => { const unSub2 = location.on('navigationCancelled', callback); // Act. - globalThis.window.history.pushState(state, '', 'http://example.com/other'); + browserMocks.history.pushState(state, '', 'http://example.com/other'); // Assert. expect(callback).toHaveBeenCalledWith({ url: 'http://example.com/other', cause: 'test', method: 'push', state }); @@ -243,7 +213,7 @@ describe("LocationFull", () => { const newUrl = "http://example.com/new"; // Act. - globalThis.window.history[fn](null, '', newUrl); + browserMocks.history[fn](null, '', newUrl); // Assert. expect(location.url.href).toBe(newUrl); @@ -258,11 +228,11 @@ describe("LocationFull", () => { const state: State = { path: { test: 'value' }, hash: { single: '/abc', p1: '/def' } }; // Act. - globalThis.window.history[fn](state, '', 'http://example.com/new'); + browserMocks.history[fn](state, '', 'http://example.com/new'); // Assert. - expect(location.getState(false)).toEqual(state.path); - expect(location.getState(true)).toEqual(state.hash.single); + expect(location.getState(ALL_HASHES.path)).toEqual(state.path); + expect(location.getState(ALL_HASHES.single)).toEqual(state.hash.single); expect(location.getState('p1')).toEqual(state.hash.p1); }); }); @@ -273,14 +243,14 @@ describe("LocationFull", () => { ])("Should preserve the previous valid state whenever %s is called with non-conformant state.", (stateFn) => { // Arrange. const validState = { path: { test: 'value' }, hash: {} }; - globalThis.window.history[stateFn](validState, '', 'http://example.com/'); + browserMocks.history[stateFn](validState, '', 'http://example.com/'); const state = { test: 'value' }; // Act. - globalThis.window.history[stateFn](state, '', 'http://example.com/other'); + browserMocks.history[stateFn](state, '', 'http://example.com/other'); // Assert. - expect(globalThis.window.history.state).deep.equals(validState); + expect(browserMocks.history.state).deep.equals(validState); }); }); }); \ No newline at end of file diff --git a/src/lib/core/LocationLite.svelte.test.ts b/src/lib/core/LocationLite.svelte.test.ts index c24fdc0..b6826bc 100644 --- a/src/lib/core/LocationLite.svelte.test.ts +++ b/src/lib/core/LocationLite.svelte.test.ts @@ -1,54 +1,22 @@ -import { describe, test, expect, beforeEach, beforeAll, vi, afterEach, afterAll } from "vitest"; +import { describe, test, expect, beforeEach, afterEach, afterAll } from "vitest"; import { LocationLite } from "./LocationLite.svelte.js"; import { LocationState } from "./LocationState.svelte.js"; -import type { Hash, Location, State } from "$lib/types.js"; -import { joinPaths } from "./RouterEngine.svelte.js"; -import { init } from "$lib/index.js"; -import { location as iLoc } from "./Location.js"; +import type { Hash, Location } from "$lib/types.js"; +import { setupBrowserMocks, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js"; describe("LocationLite", () => { const initialUrl = "http://example.com/"; - let interceptedState: State; - const pushStateMock = vi.fn((state, _, url) => { - url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - }); - const replaceStateMock = vi.fn((state, _, url) => { - url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - }); let location: Location; - let _href: string; - beforeAll(() => { - // @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 - }; - }) + let browserMocks: ReturnType; + beforeEach(() => { - globalThis.window.location.href = initialUrl; - interceptedState = { path: undefined, hash: {} }; - pushStateMock.mockReset(); - replaceStateMock.mockReset(); + browserMocks = setupBrowserMocks(initialUrl); location = new LocationLite(); }); + afterEach(() => { location.dispose(); + browserMocks.cleanup(); }); describe("constructor", () => { test("Should create a new instance with the expected default values.", () => { @@ -84,8 +52,7 @@ describe("LocationLite", () => { const newUrl = "http://example.com/new"; // Act. - globalThis.window.location.href = newUrl; - globalThis.window.dispatchEvent(new PopStateEvent('popstate')); + browserMocks.setUrl(newUrl); // Assert. expect(location.url.href).toBe(newUrl); @@ -94,11 +61,11 @@ describe("LocationLite", () => { describe("getState", () => { test.each<{ hash: Hash; expectedState: any; }>([ { - hash: false, + hash: ALL_HASHES.path, expectedState: 1, }, { - hash: true, + hash: ALL_HASHES.single, expectedState: 2, }, { @@ -107,13 +74,14 @@ describe("LocationLite", () => { }, ])(`Should return the state associated with the "$hash" hash value.`, ({ hash, expectedState }) => { // Arrange. - interceptedState = { + const testState = { path: 1, hash: { single: 2, abc: 3 } }; + browserMocks.setState(testState); location = new LocationLite(); // Act. @@ -127,7 +95,7 @@ describe("LocationLite", () => { const pathState = 1; const singleHashState = 2; const abcHashState = 3; - interceptedState = { + const testState = { path: pathState, hash: { single: singleHashState, @@ -136,189 +104,287 @@ describe("LocationLite", () => { }; // Act. - globalThis.window.dispatchEvent(new PopStateEvent('popstate')); + browserMocks.simulateHistoryChange(testState); // Assert. - expect(location.getState(false)).toBe(pathState); - expect(location.getState(true)).toBe(singleHashState); + expect(location.getState(ALL_HASHES.path)).toBe(pathState); + expect(location.getState(ALL_HASHES.single)).toBe(singleHashState); expect(location.getState('abc')).toBe(abcHashState); }); }); -}); + describe("Event Synchronization", () => { + test.each([ + { + event: 'popstate', + }, + { + event: 'hashchange', + } + ])("Should carry over previous state when a $event occurs that carries no state.", ({ event }) => { + // Arrange - Set up initial state in LocationLite + const initialState = { + path: { preserved: "path-data" }, + hash: { + single: { preserved: "hash-data" }, + multi1: { preserved: "multi-data" } + } + }; + browserMocks.simulateHistoryChange(initialState); + + // Verify initial state is set + expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path-data" }); + expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "hash-data" }); + expect(location.getState("multi1")).toEqual({ preserved: "multi-data" }); -describe("LocationLite (Integration)", () => { - let cleanup: Function; - describe("navigate", () => { - const initialUrl = "http://example.com/"; - let interceptedState: State; - let _href: string; - const pushStateMock = vi.fn((state, _, url) => { - url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; - globalThis.window.location.href = new URL(url).href; - interceptedState = state; - }); - const replaceStateMock = vi.fn((state, _, url) => { - url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; - globalThis.window.location.href = new URL(url).href; - interceptedState = state; + // Act - Trigger event with no state (simulates anchor hash navigation) + const newUrl = "http://example.com/#new-anchor"; + browserMocks.setUrl(newUrl); + browserMocks.history.state = null; // Simulate no state from browser + + // Trigger the specific event type + const eventObj = event === 'popstate' + ? new PopStateEvent('popstate', { state: null }) + : new HashChangeEvent('hashchange'); + browserMocks.window.dispatchEvent(eventObj); + + // Assert - Previous state should be preserved + expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path-data" }); + expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "hash-data" }); + expect(location.getState("multi1")).toEqual({ preserved: "multi-data" }); + + // URL should be updated though + expect(location.url.href).toBe(newUrl); }); - beforeAll(() => { - // @ts-expect-error Many missing features. - globalThis.window.location = { - get href() { - return _href; - }, - set href(value) { - _href = value; - } + + test("Should update state when browser event carries new state", () => { + // Arrange - Set up initial state + const initialState = { + path: { initial: "data" }, + hash: { single: { initial: "hash" } } }; - globalThis.window.location.href = initialUrl; - // @ts-expect-error Many missing features. - globalThis.window.history = { - get state() { - return interceptedState; - }, - pushState: pushStateMock, - replaceState: replaceStateMock + browserMocks.simulateHistoryChange(initialState); + + // Act - Trigger popstate with new state + const newState = { + path: { updated: "data" }, + hash: { single: { updated: "hash" } } }; - }) - beforeEach(() => { - globalThis.window.location.href = initialUrl; - interceptedState = { path: undefined, hash: {} }; - pushStateMock.mockReset(); - replaceStateMock.mockReset(); + const newUrl = "http://example.com/updated"; + browserMocks.simulateHistoryChange(newState, newUrl); + + // Assert - State should be updated + expect(location.getState(ALL_HASHES.path)).toEqual({ updated: "data" }); + expect(location.getState(ALL_HASHES.single)).toEqual({ updated: "hash" }); + expect(location.url.href).toBe(newUrl); }); - beforeAll(() => { - cleanup = init(); + + test("Should preserve state when history.state is undefined", () => { + // Arrange - Set up initial state + const initialState = { + path: { preserved: "path" }, + hash: { single: { preserved: "single" } } + }; + browserMocks.simulateHistoryChange(initialState); + + // Act - Simulate browser event with undefined state + browserMocks.history.state = undefined; + const event = new PopStateEvent('popstate', { state: undefined }); + browserMocks.window.dispatchEvent(event); + + // Assert - State should be preserved due to nullish coalescing + expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path" }); + expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "single" }); }); - afterAll(() => { - cleanup?.(); + + test("Should preserve state when history.state is null", () => { + // Arrange - Set up initial state + const initialState = { + path: { preserved: "path" }, + hash: { single: { preserved: "single" } } + }; + browserMocks.simulateHistoryChange(initialState); + + // Act - Simulate browser event with null state + browserMocks.history.state = null; + const event = new PopStateEvent('popstate', { state: null }); + browserMocks.window.dispatchEvent(event); + + // Assert - State should be preserved due to nullish coalescing + expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path" }); + expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "single" }); }); - test.each([ - { - replace: false, - expectedMethod: pushStateMock, - text: 'pushState', - hash: false, - hashText: 'path', - }, - { - replace: true, - expectedMethod: replaceStateMock, - text: 'replaceState', - hash: false, - hashText: 'path', - }, - { - replace: false, - expectedMethod: pushStateMock, - text: 'pushState', - hash: true, - hashText: 'hash.single', - }, - { - replace: true, - expectedMethod: replaceStateMock, - text: 'replaceState', - hash: true, - hashText: 'hash.single', - }, - ])("Should call $text whenever the 'replace' option is $replace and save state under $hashText .", ({ replace, expectedMethod, hash }) => { - // Arrange. - const newUrl = "/new"; - const newState = { some: "state" }; - - // For hash routing, we need to account for any existing path state that gets preserved - let expectedArg: any; - if (hash) { - // Get the current state to see what path state might be preserved - const currentPathState = iLoc.getState(false); - expectedArg = { hash: { single: newState } }; - if (currentPathState !== undefined) { - expectedArg.path = currentPathState; - } - } else { - expectedArg = { path: newState, hash: {} }; - } + }); +}); - const expectedUrl = hash ? `#${newUrl}` : newUrl; +describe("LocationLite - Browser API Integration", () => { + const initialUrl = "http://example.com/"; + let location: Location; + let browserMocks: ReturnType; + + beforeEach(() => { + browserMocks = setupBrowserMocks(initialUrl); + location = new LocationLite(); + }); + + afterEach(() => { + location.dispose(); + browserMocks.cleanup(); + }); - // Act. - iLoc.navigate(newUrl, { replace, hash, state: newState }); + describe("Browser history integration", () => { + test("Should properly sync with browser pushState operations", () => { + // Arrange + const newUrl = "http://example.com/new-path"; + const newState = { path: { test: "data" }, hash: {} }; - // Assert. - expect(expectedMethod).toHaveBeenCalledWith(expectedArg, '', expectedUrl); + // Act - Simulate external pushState call + browserMocks.history.pushState(newState, "", newUrl); + browserMocks.triggerPopstate(newState); + + // Assert + expect(location.url.href).toBe(newUrl); + expect(location.getState(ALL_HASHES.path)).toEqual({ test: "data" }); }); - test("Should trigger an update on the location's URL and state values when doing path routing navigation.", () => { - // Arrange. - const newPath = '/new'; - const state = 123; - // Act. - iLoc.navigate(newPath, { state }); + test("Should properly sync with browser replaceState operations", () => { + // Arrange + const newUrl = "http://example.com/replaced-path"; + const newState = { path: { replaced: true }, hash: {} }; - // Assert. - expect(iLoc.url.pathname).toBe(newPath); - expect(iLoc.getState(false)).toBe(state); + // Act - Simulate external replaceState call + browserMocks.history.replaceState(newState, "", newUrl); + browserMocks.triggerPopstate(newState); + + // Assert + expect(location.url.href).toBe(newUrl); + expect(location.getState(ALL_HASHES.path)).toEqual({ replaced: true }); }); - test("Should trigger an update on the location's URL and state values when doing hash routing navigation.", () => { - // Arrange. - const newPath = '/new'; - const state = 456; - // Act. - iLoc.navigate(newPath, { state, hash: true }); + test("Should handle hash-based state updates", () => { + // Arrange + const newUrl = "http://example.com/#/hash-path"; + const newState = { path: undefined, hash: { single: { hash: "data" } } }; - // Assert. - expect(iLoc.url.hash).toBe(`#${newPath}`); - expect(iLoc.getState(true)).toBe(state); + // Act + browserMocks.simulateHistoryChange(newState, newUrl); + + // Assert + expect(location.url.href).toBe(newUrl); + expect(location.getState(ALL_HASHES.single)).toEqual({ hash: "data" }); }); - test("Should trigger an update on the location's URL and state values when doing multi hash routing navigation.", () => { - // Arrange. - const hash = 'abc'; - const newPath = '/new'; - const state = 456; - cleanup?.(); - try { - cleanup = init({ hashMode: 'multi' }); - - // Act. - iLoc.navigate(newPath, { hash, state }); - - // Assert. - expect(iLoc.url.hash).toBe(`#${hash}=${newPath}`); - expect(iLoc.getState(hash)).toBe(state); - } - finally { - //Clean up. - cleanup?.(); - cleanup = init(); - } + + test("Should handle multi-hash state updates", () => { + // Arrange + const hashId = "testHash"; + const newUrl = "http://example.com/#testHash=/multi-path"; + const newState = { + path: undefined, + hash: { [hashId]: { multi: "data" } } + }; + + // Act + browserMocks.simulateHistoryChange(newState, newUrl); + + // Assert + expect(location.url.href).toBe(newUrl); + expect(location.getState(hashId)).toEqual({ multi: "data" }); }); - test("Should update the state whenever shallow routing is used (path routing).", () => { - // Arrange. - const currentUrl = iLoc.url.href; - const newState = 123; - // Act. - iLoc.navigate('', { state: newState }); + test("Should preserve existing state during partial updates", () => { + // Arrange - Set initial state + const initialState = { + path: { initial: "path" }, + hash: { + single: { initial: "single" }, + multi1: { initial: "multi1" } + } + }; + browserMocks.simulateHistoryChange(initialState); - // Assert. - expect(iLoc.getState(false)).toBe(newState); - expect(iLoc.url.href).toBe(currentUrl); + // Act - Update only single hash + const updatedState = { + path: { initial: "path" }, + hash: { + single: { updated: "single" }, + multi1: { initial: "multi1" } // Should be preserved + } + }; + browserMocks.simulateHistoryChange(updatedState); + + // Assert + expect(location.getState(ALL_HASHES.path)).toEqual({ initial: "path" }); + expect(location.getState(ALL_HASHES.single)).toEqual({ updated: "single" }); + expect(location.getState("multi1")).toEqual({ initial: "multi1" }); }); - test("Should update the state whenever shallow routing is used (multi hash routing).", () => { - // Arrange. - const currentUrl = iLoc.url.href; - const newState = 123; - const pathName = 'p1'; + }); +}); - // Act. - iLoc.navigate('', { hash: pathName, state: newState }); +// Test across all routing universes for comprehensive coverage +ROUTING_UNIVERSES.forEach((universe) => { + describe(`LocationLite - ${universe.text}`, () => { + let browserMocks: ReturnType; + let location: Location; + + beforeEach(() => { + browserMocks = setupBrowserMocks("http://example.com/"); + location = new LocationLite(); + }); + + afterEach(() => { + location.dispose(); + browserMocks.cleanup(); + }); - // Assert. - expect(iLoc.getState(pathName)).toBe(newState); - expect(iLoc.url.href).toBe(currentUrl); + describe("State management across universes", () => { + test("Should properly handle state storage and retrieval", () => { + // Arrange + const testState = { universe: universe.text, data: 123 }; + + // Act - Store state for this universe + if (universe.hash === ALL_HASHES.implicit) { + // For implicit hash, we can't directly test storage without navigation + // This is handled by the routing system, so we skip this test + return; + } else { + // Set state directly and trigger popstate to notify LocationLite + const newState = universe.hash === ALL_HASHES.path + ? { path: testState, hash: {} } + : universe.hash === ALL_HASHES.single + ? { path: undefined, hash: { single: testState } } + : { path: undefined, hash: { [universe.hash]: testState } }; + + browserMocks.simulateHistoryChange(newState); + + // Assert + expect(location.getState(universe.hash)).toEqual(testState); + } + }); + + test("Should handle popstate events correctly", () => { + // Arrange + const testState = { popstate: 'test', value: 456 }; + const newUrl = universe.hash === ALL_HASHES.path + ? "http://example.com/test-path" + : "http://example.com/#test-hash"; + + // Act + browserMocks.simulateHistoryChange( + universe.hash === ALL_HASHES.path + ? { path: testState, hash: {} } + : universe.hash === ALL_HASHES.single + ? { path: undefined, hash: { single: testState } } + : typeof universe.hash === 'string' + ? { path: undefined, hash: { [universe.hash]: testState } } + : { path: undefined, hash: {} }, // implicit case + newUrl + ); + + // Assert + expect(location.url.href).toBe(newUrl); + if (universe.hash !== ALL_HASHES.implicit) { + expect(location.getState(universe.hash)).toEqual(testState); + } + }); }); }); });