import { createInjector } from '@furystack/inject' import { usingAsync } from '@furystack/utils' import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { initializeShadeRoot } from '../initialize.js' import { LocationService } from '../services/location-service.js' import { createComponent } from '../shade-component.js' import { flushUpdates } from '../shade.js' import type { TypedNestedRouteLinkProps } from './nested-route-link.js' import { NestedRouteLink, createNestedRouteLink } from './nested-route-link.js' import type { ConcatPaths, ExtractRouteParams, ExtractRoutePaths, UrlTree } from './nested-route-types.js' import type { NestedRoute } from './nested-router.js' // Minimal route type for type-level tests. Using Pick avoids the // `children?: Record>` from NestedRoute // which would widen literal keys in intersections. type TestRoute = Pick, 'component'> describe('NestedRouteLink', () => { beforeEach(() => { document.body.innerHTML = '
' }) afterEach(() => { document.body.innerHTML = '' }) it('Should render a link with the correct href', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement initializeShadeRoot({ injector, rootElement, jsxElement: ( Buttons ), }) await flushUpdates() expect(document.body.innerHTML).toBe( '', ) }) }) it('Should trigger SPA navigation on click', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement const onRouteChange = vi.fn() injector.get(LocationService).onLocationPathChanged.subscribe(onRouteChange) initializeShadeRoot({ injector, rootElement, jsxElement: ( Buttons ), }) await flushUpdates() expect(onRouteChange).not.toBeCalled() document.getElementById('link')?.click() expect(onRouteChange).toBeCalledTimes(1) }) }) it('Should compile route params in the href', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement initializeShadeRoot({ injector, rootElement, jsxElement: ( User 42 ), }) await flushUpdates() expect(document.body.innerHTML).toBe( '', ) }) }) it('Should compile route params with multiple segments', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement initializeShadeRoot({ injector, rootElement, jsxElement: ( Post ), }) await flushUpdates() expect(document.body.innerHTML).toBe( '', ) }) }) it('Should append a serialized query string to the rendered href', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement initializeShadeRoot({ injector, rootElement, jsxElement: ( Buttons ), }) await flushUpdates() const link = document.getElementById('link') as HTMLAnchorElement expect(link.getAttribute('href')?.startsWith('/buttons?')).toBe(true) }) }) it('Should append the hash segment to the rendered href', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root') as HTMLDivElement initializeShadeRoot({ injector, rootElement, jsxElement: ( Buttons ), }) await flushUpdates() const link = document.getElementById('link') as HTMLAnchorElement expect(link.getAttribute('href')).toBe('/buttons#overview') }) }) }) describe('Type utilities', () => { describe('ConcatPaths', () => { it('Should strip root "/" when concatenating', () => { expectTypeOf>().toEqualTypeOf<'/buttons'>() }) it('Should concatenate non-root parent paths', () => { expectTypeOf>().toEqualTypeOf<'/layout-tests/appbar-only'>() }) it('Should handle deeply nested paths', () => { expectTypeOf>().toEqualTypeOf<'/a/b/c'>() }) }) describe('ExtractRouteParams', () => { it('Should return Record for paths without params', () => { expectTypeOf>().toEqualTypeOf>() }) it('Should extract a single param', () => { expectTypeOf>().toEqualTypeOf<{ id: string }>() }) it('Should extract multiple params', () => { expectTypeOf>().toEqualTypeOf<{ userId: string postId: string }>() }) it('Should handle params at the beginning of the path', () => { expectTypeOf>().toEqualTypeOf<{ id: string }>() }) }) describe('ExtractRoutePaths', () => { it('Should extract top-level paths', () => { type Routes = { '/a': TestRoute '/b': TestRoute } expectTypeOf>().toEqualTypeOf<'/a' | '/b'>() }) it('Should extract nested child paths with root parent', () => { type Routes = { '/': TestRoute & { children: { '/buttons': TestRoute '/inputs': TestRoute } } } expectTypeOf>().toEqualTypeOf<'/' | '/buttons' | '/inputs'>() }) it('Should extract nested child paths with non-root parent', () => { type Routes = { '/layout-tests': TestRoute & { children: { '/appbar-only': TestRoute '/auto-hide': TestRoute } } } expectTypeOf>().toEqualTypeOf< '/layout-tests' | '/layout-tests/appbar-only' | '/layout-tests/auto-hide' >() }) it('Should handle mixed flat and nested routes', () => { type Routes = { '/standalone': TestRoute '/parent': TestRoute & { children: { '/child': TestRoute } } } expectTypeOf>().toEqualTypeOf<'/standalone' | '/parent' | '/parent/child'>() }) }) describe('UrlTree', () => { it('Should accept a flat object of valid paths', () => { type Paths = '/a' | '/b' const urls = { a: '/a', b: '/b', } satisfies UrlTree expectTypeOf(urls).toExtend>() }) it('Should accept nested objects of valid paths', () => { type Paths = '/' | '/buttons' | '/layout-tests' | '/layout-tests/appbar-only' const urls = { home: '/', buttons: '/buttons', layoutTests: { index: '/layout-tests', appBarOnly: '/layout-tests/appbar-only', }, } satisfies UrlTree expectTypeOf(urls).toExtend>() }) }) describe('TypedNestedRouteLinkProps', () => { it('Should make params optional for paths without parameters', () => { type Props = TypedNestedRouteLinkProps<'/buttons'> expectTypeOf().toEqualTypeOf<'/buttons'>() expectTypeOf().toExtend<{ params?: Record }>() }) it('Should require params for parameterized paths', () => { type Props = TypedNestedRouteLinkProps<'/users/:id'> expectTypeOf().toEqualTypeOf<'/users/:id'>() expectTypeOf().toExtend<{ params: { id: string } }>() }) it('Should require all params for multi-param paths', () => { type Props = TypedNestedRouteLinkProps<'/users/:userId/posts/:postId'> expectTypeOf().toExtend<{ params: { userId: string; postId: string } }>() }) }) describe('NestedRouteLink param inference', () => { it('Should infer params as optional when path has no parameters', () => { expectTypeOf(NestedRouteLink).parameter(0).toHaveProperty('params') expectTypeOf(NestedRouteLink<'/buttons'>) .parameter(0) .toExtend<{ params?: Record }>() }) it('Should infer params as required when path has a parameter', () => { expectTypeOf(NestedRouteLink<'/users/:id'>) .parameter(0) .toExtend<{ params: { id: string } }>() }) it('Should infer multiple params from path', () => { expectTypeOf(NestedRouteLink<'/users/:userId/posts/:postId'>) .parameter(0) .toExtend<{ params: { userId: string; postId: string } }>() }) }) describe('createNestedRouteLink', () => { it('Should constrain path to valid route paths', () => { type Routes = { '/': TestRoute & { children: { '/buttons': TestRoute } } } const AppLink = createNestedRouteLink() expectTypeOf(AppLink).parameter(0).toHaveProperty('path') }) it('Should reject invalid paths', () => { type Routes = { '/': TestRoute & { children: { '/buttons': TestRoute } } } const AppLink = createNestedRouteLink() // @ts-expect-error -- '/nonexistent' is not a valid route path AppLink({ path: '/nonexistent' }) }) it('Should require params for parameterized routes in the tree', () => { type Routes = { '/': TestRoute & { children: { '/users/:userId': TestRoute } } } const AppLink = createNestedRouteLink() expectTypeOf(AppLink<'/users/:userId'>) .parameter(0) .toExtend<{ params: { userId: string } }>() }) it('Should require combined params from parent and child route segments', () => { type Routes = { '/users/:userId': TestRoute & { children: { '/posts/:postId': TestRoute } } } const AppLink = createNestedRouteLink() expectTypeOf(AppLink<'/users/:userId/posts/:postId'>) .parameter(0) .toExtend<{ params: { userId: string; postId: string } }>() }) it('Should accept routes with typed match parameters (NestedRoute)', () => { const routes = { '/stacks/:stackName': { component: ({ match }) =>
{match.params.stackName}
, }, } satisfies Record> const AppLink = createNestedRouteLink() expectTypeOf(AppLink<'/stacks/:stackName'>) .parameter(0) .toExtend<{ params: { stackName: string } }>() }) it('Should accept a mixed route tree with typed and untyped match parameters', () => { const usersRoute: NestedRoute<{ userId: string }> = { component: ({ match }) =>
{match.params.userId}
, } const buttonsRoute: NestedRoute = { component: () =>
, } const routes = { '/': { component: ({ outlet }) => outlet ??
, children: { '/buttons': buttonsRoute, '/users/:userId': usersRoute, }, }, } satisfies Record> const AppLink = createNestedRouteLink() expectTypeOf(AppLink).parameter(0).toHaveProperty('path') expectTypeOf(AppLink<'/users/:userId'>) .parameter(0) .toExtend<{ params: { userId: string } }>() }) it('Should enforce a declared required query shape on the link', () => { const routes = { '/list': { component: () => null as unknown as JSX.Element, query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null), }, } satisfies Record> const AppLink = createNestedRouteLink() expectTypeOf(AppLink<'/list'>) .parameter(0) .toExtend<{ query: { page: number } }>() }) it('Should narrow hash on the link to the declared literal tuple', () => { const routes = { '/tabs': { component: () => null as unknown as JSX.Element, hash: ['overview', 'details'] as const, }, } satisfies Record> const AppLink = createNestedRouteLink() expectTypeOf(AppLink<'/tabs'>) .parameter(0) .toExtend<{ hash?: 'overview' | 'details' }>() }) }) })