@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
166 lines • 7.5 kB
JavaScript
import { createInjector } from '@furystack/inject';
import { usingAsync } from '@furystack/utils';
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import { LocationService } from '../services/location-service.js';
import { buildNestedNavigateUrl, createNestedNavigate, nestedNavigate } from './nested-navigate.js';
describe('nestedNavigate', () => {
it('Should navigate to a simple path', async () => {
await usingAsync(createInjector(), async (injector) => {
const locationService = injector.get(LocationService);
const spy = vi.spyOn(locationService, 'navigate');
nestedNavigate(injector, { path: '/buttons' });
expect(spy).toHaveBeenCalledWith('/buttons');
});
});
it('Should compile params into the path', async () => {
await usingAsync(createInjector(), async (injector) => {
const locationService = injector.get(LocationService);
const spy = vi.spyOn(locationService, 'navigate');
nestedNavigate(injector, { path: '/users/:id', params: { id: '42' } });
expect(spy).toHaveBeenCalledWith('/users/42');
});
});
it('Should compile multiple params into the path', async () => {
await usingAsync(createInjector(), async (injector) => {
const locationService = injector.get(LocationService);
const spy = vi.spyOn(locationService, 'navigate');
nestedNavigate(injector, {
path: '/users/:userId/posts/:postId',
params: { userId: '1', postId: '99' },
});
expect(spy).toHaveBeenCalledWith('/users/1/posts/99');
});
});
it('Should append serialized query string when provided', async () => {
await usingAsync(createInjector(), async (injector) => {
const locationService = injector.get(LocationService);
const spy = vi.spyOn(locationService, 'navigate');
nestedNavigate(injector, { path: '/buttons', query: { page: 2 } });
expect(spy).toHaveBeenCalledTimes(1);
const navigatedUrl = spy.mock.calls[0][0];
expect(navigatedUrl.startsWith('/buttons?')).toBe(true);
const { deserializeQueryString } = locationService;
const search = navigatedUrl.slice(navigatedUrl.indexOf('?') + 1);
expect(deserializeQueryString(search)).toEqual({ page: 2 });
});
});
it('Should omit the query string when no keys are provided', async () => {
await usingAsync(createInjector(), async (injector) => {
const locationService = injector.get(LocationService);
const spy = vi.spyOn(locationService, 'navigate');
nestedNavigate(injector, { path: '/buttons', query: {} });
expect(spy).toHaveBeenCalledWith('/buttons');
});
});
it('Should append the hash segment when provided', async () => {
await usingAsync(createInjector(), async (injector) => {
const locationService = injector.get(LocationService);
const spy = vi.spyOn(locationService, 'navigate');
nestedNavigate(injector, { path: '/buttons', hash: 'overview' });
expect(spy).toHaveBeenCalledWith('/buttons#overview');
});
});
it('Should combine params, query and hash in the correct order', async () => {
await usingAsync(createInjector(), async (injector) => {
const locationService = injector.get(LocationService);
const spy = vi.spyOn(locationService, 'navigate');
nestedNavigate(injector, {
path: '/users/:id',
params: { id: '7' },
query: { tab: 'profile' },
hash: 'notes',
});
expect(spy).toHaveBeenCalledTimes(1);
const navigatedUrl = spy.mock.calls[0][0];
expect(navigatedUrl.startsWith('/users/7?')).toBe(true);
expect(navigatedUrl.endsWith('#notes')).toBe(true);
});
});
});
describe('buildNestedNavigateUrl', () => {
it('Should build a bare path URL', () => {
expect(buildNestedNavigateUrl({ path: '/buttons' })).toBe('/buttons');
});
it('Should build a URL with compiled params', () => {
expect(buildNestedNavigateUrl({ path: '/users/:id', params: { id: '42' } })).toBe('/users/42');
});
it('Should append a hash segment', () => {
expect(buildNestedNavigateUrl({ path: '/buttons', hash: 'top' })).toBe('/buttons#top');
});
});
describe('Type utilities', () => {
describe('TypedNestedNavigateArgs', () => {
it('Should make params optional for paths without parameters', () => {
expectTypeOf().toExtend();
expectTypeOf().toExtend();
});
it('Should require params for parameterized paths', () => {
expectTypeOf().toExtend();
});
it('Should require all params for multi-param paths', () => {
expectTypeOf().toExtend();
});
});
describe('createNestedNavigate', () => {
it('Should constrain path to valid route paths', () => {
const appNavigate = createNestedNavigate();
expectTypeOf((appNavigate))
.parameter(0)
.toEqualTypeOf();
});
it('Should require params for parameterized routes in the tree', () => {
const appNavigate = createNestedNavigate();
expectTypeOf((appNavigate))
.parameter(1)
.toExtend();
});
it('Should make params optional for non-parameterized routes', () => {
const appNavigate = createNestedNavigate();
expectTypeOf((appNavigate))
.parameter(1)
.toExtend();
});
it('Should reject invalid paths', () => {
const appNavigate = createNestedNavigate();
// @ts-expect-error -- '/nonexistent' is not a valid route path
appNavigate(createInjector(), { path: '/nonexistent' });
});
it('Should accept routes with typed match parameters (NestedRoute<T>)', () => {
const typedRoute = {
component: ({ match }) => match.params.stackName,
};
const routes = {
'/stacks/:stackName': typedRoute,
};
const appNavigate = createNestedNavigate();
expectTypeOf((appNavigate))
.parameter(1)
.toExtend();
});
it('Should enforce a route-declared required query shape', () => {
const routes = {
'/list': {
component: () => null,
query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null),
},
};
const appNavigate = createNestedNavigate();
expectTypeOf((appNavigate))
.parameter(1)
.toExtend();
});
it('Should narrow hash to the declared literal tuple', () => {
const routes = {
'/tabs': {
component: () => null,
hash: ['overview', 'details'],
},
};
const appNavigate = createNestedNavigate();
expectTypeOf((appNavigate))
.parameter(1)
.toExtend();
});
});
});
//# sourceMappingURL=nested-navigate.spec.js.map