From 25ae5a89adb4e9ef123b1f3b85809e0d55995bee Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Sun, 17 Aug 2025 17:19:19 +0200 Subject: [PATCH] test: more resolver tests --- .../old/matcher-resolve.spec.ts | 1002 ----------------- .../old/resolver-dynamic.test-d.ts | 81 -- .../route-resolver/old/resolver-dynamic.ts | 475 -------- .../route-resolver/resolver-fixed.spec.ts | 388 ++++++- 4 files changed, 339 insertions(+), 1607 deletions(-) delete mode 100644 packages/router/src/experimental/route-resolver/old/matcher-resolve.spec.ts delete mode 100644 packages/router/src/experimental/route-resolver/old/resolver-dynamic.test-d.ts delete mode 100644 packages/router/src/experimental/route-resolver/old/resolver-dynamic.ts diff --git a/packages/router/src/experimental/route-resolver/old/matcher-resolve.spec.ts b/packages/router/src/experimental/route-resolver/old/matcher-resolve.spec.ts deleted file mode 100644 index 7b1720cd..00000000 --- a/packages/router/src/experimental/route-resolver/old/matcher-resolve.spec.ts +++ /dev/null @@ -1,1002 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { defineComponent } from 'vue' -import { RouteComponent, RouteMeta, RouteRecordRaw } from '../../../types' -import { NEW_stringifyURL } from '../../../location' -import { mockWarn } from '../../../../__tests__/vitest-mock-warn' -import { - type MatcherLocationRaw, - type ResolverLocationResolved, - NO_MATCH_LOCATION, -} from '../resolver-abstract' -import { type NEW_MatcherRecord } from './resolver-dynamic' -import { type NEW_MatcherRecordRaw } from './resolver-dynamic' -import { createCompiledMatcher } from './resolver-dynamic' -import { miss } from '../matchers/errors' -import { - MatcherPatternPath, - MatcherPatternPathStatic, -} from '../matchers/matcher-pattern' -import { EXPERIMENTAL_RouterOptions } from '../../router' -import { stringifyQuery } from '../../../query' -import type { ResolverLocationAsPathAbsolute } from '../resolver-abstract' -import type { ResolverLocationAsNamed } from '../resolver-abstract' -// TODO: should be moved to a different test file -// used to check backward compatible paths -import { - PATH_PARSER_OPTIONS_DEFAULTS, - PathParams, - tokensToParser, -} from '../../../matcher/pathParserRanker' -import { tokenizePath } from '../../../matcher/pathTokenizer' -import { mergeOptions } from '../../../utils' - -// FIXME: this type was removed, it will be a new one once a dynamic resolver is implemented -export interface EXPERIMENTAL_RouteRecordRaw extends NEW_MatcherRecordRaw { - /** - * Arbitrary data attached to the record. - */ - meta?: RouteMeta - - components?: Record - component?: unknown - - redirect?: unknown - score: Array - readonly options: EXPERIMENTAL_RouterOptions -} - -// for raw route record -const component: RouteComponent = defineComponent({}) -// for normalized route records -const components = { default: component } - -function isMatchable(record: RouteRecordRaw): boolean { - return !!( - record.name || - (record.components && Object.keys(record.components).length) || - record.redirect - ) -} - -export function joinPaths(a: string | undefined, b: string) { - if (a?.endsWith('/')) { - return a + b - } - return a + '/' + b -} - -function compileRouteRecord( - record: RouteRecordRaw, - parentRecord?: RouteRecordRaw -): NEW_MatcherRecordRaw { - // we adapt the path to ensure they are absolute - // TODO: aliases? they could be handled directly in the path matcher - if (!parentRecord && !record.path.startsWith('/')) { - throw new Error(`Record without parent must have an absolute path`) - } - const path = record.path.startsWith('/') - ? record.path - : joinPaths(parentRecord?.path, record.path) - record.path = path - const parser = tokensToParser( - tokenizePath(record.path), - mergeOptions(PATH_PARSER_OPTIONS_DEFAULTS, record) - ) - - return { - group: !isMatchable(record), - name: record.name, - score: parser.score, - - path: { - match(value) { - const params = parser.parse(value) - if (params) { - return params - } - throw miss() - }, - build(params) { - // TODO: normalize params? - return parser.stringify(params) - }, - } satisfies MatcherPatternPath, - - children: record.children?.map(childRecord => - compileRouteRecord(childRecord, record) - ), - } -} - -describe('RouterMatcher.resolve', () => { - mockWarn() - type Matcher = ReturnType - type MatcherResolvedLocation = ReturnType - - const START_LOCATION: MatcherResolvedLocation = { - name: Symbol('START'), - params: {}, - path: '/', - fullPath: '/', - query: {}, - hash: '', - matched: [], - // meta: {}, - } - - function isMatcherLocationResolved( - location: unknown - ): location is ResolverLocationResolved { - return !!( - location && - typeof location === 'object' && - 'matched' in location && - 'fullPath' in location && - Array.isArray(location.matched) - ) - } - - function isExperimentalRouteRecordRaw( - record: Record - ): record is EXPERIMENTAL_RouteRecordRaw { - return typeof record.path !== 'string' - } - - // TODO: rework with object param for clarity - - function assertRecordMatch( - record: - | EXPERIMENTAL_RouteRecordRaw - | EXPERIMENTAL_RouteRecordRaw[] - | RouteRecordRaw - | RouteRecordRaw[], - toLocation: Exclude | `/${string}`, - expectedLocation: Partial, - fromLocation: - | ResolverLocationResolved - // absolute locations only that can be resolved for convenience - | `/${string}` - | ResolverLocationAsNamed - | ResolverLocationAsPathAbsolute = START_LOCATION - ) { - const records = (Array.isArray(record) ? record : [record]).map( - (record): NEW_MatcherRecordRaw => - isExperimentalRouteRecordRaw(record) - ? { components, ...record } - : compileRouteRecord(record) - ) - const matcher = createCompiledMatcher() - for (const record of records) { - matcher.addMatcher(record) - } - - const path = - typeof toLocation === 'string' ? toLocation : toLocation.path || '/' - - const resolved: Omit = { - // FIXME: to add later - // meta: records[0].meta || {}, - path, - query: {}, - hash: '', - // by default we have a symbol on every route - name: expect.any(Symbol) as symbol, - // must non enumerable - // matched: [], - params: (typeof toLocation === 'object' && toLocation.params) || {}, - fullPath: NEW_stringifyURL( - stringifyQuery, - expectedLocation.path || path || '/', - expectedLocation.query, - expectedLocation.hash - ), - ...expectedLocation, - } - - Object.defineProperty(resolved, 'matched', { - writable: true, - configurable: true, - enumerable: false, - // FIXME: build it - value: [], - }) - - const resolvedFrom = isMatcherLocationResolved(fromLocation) - ? fromLocation - : matcher.resolve( - // FIXME: is this a ts bug? - // @ts-expect-error - fromLocation - ) - - const result = matcher.resolve( - // FIXME: should work now - // @ts-expect-error - toLocation, - resolvedFrom === START_LOCATION ? undefined : resolvedFrom - ) - - if ( - expectedLocation.name === undefined || - expectedLocation.name !== NO_MATCH_LOCATION.name - ) { - expect(result.name).not.toBe(NO_MATCH_LOCATION.name) - } - - expect(result).toMatchObject(resolved) - } - - describe('LocationAsPath', () => { - it('resolves a normal path', () => { - assertRecordMatch({ path: '/', name: 'Home', components }, '/', { - name: 'Home', - path: '/', - params: {}, - }) - }) - - it('resolves a normal path without name', () => { - assertRecordMatch({ path: '/', components }, '/', { - path: '/', - params: {}, - }) - assertRecordMatch( - { path: '/', components }, - { path: '/' }, - { path: '/', params: {} } - ) - }) - - it('resolves a path with params', () => { - assertRecordMatch( - { path: '/users/:id', name: 'User', components }, - { path: '/users/posva' }, - { name: 'User', params: { id: 'posva' } } - ) - }) - - it('resolves an array of params for a repeatable params', () => { - assertRecordMatch( - { path: '/a/:p+', name: 'a', components }, - { name: 'a', params: { p: ['b', 'c', 'd'] } }, - { name: 'a', path: '/a/b/c/d', params: { p: ['b', 'c', 'd'] } } - ) - }) - - it('resolves single params for a repeatable params', () => { - assertRecordMatch( - { path: '/a/:p+', name: 'a', components }, - { name: 'a', params: { p: 'b' } }, - { name: 'a', path: '/a/b', params: { p: 'b' } } - ) - }) - - it('keeps repeated params as a single one when provided through path', () => { - assertRecordMatch( - { path: '/a/:p+', name: 'a', components }, - { path: '/a/b/c' }, - { name: 'a', params: { p: ['b', 'c'] } } - ) - }) - - it('resolves a path with multiple params', () => { - assertRecordMatch( - { path: '/users/:id/:other', name: 'User', components }, - { path: '/users/posva/hey' }, - { name: 'User', params: { id: 'posva', other: 'hey' } } - ) - }) - - it('resolves a path with multiple params but no name', () => { - assertRecordMatch( - { path: '/users/:id/:other', components }, - { path: '/users/posva/hey' }, - { name: expect.any(Symbol), params: { id: 'posva', other: 'hey' } } - ) - }) - - it('returns an empty match when the path does not exist', () => { - assertRecordMatch( - { path: '/', components }, - { path: '/foo' }, - NO_MATCH_LOCATION - ) - }) - - it('allows an optional trailing slash', () => { - assertRecordMatch( - { path: '/home/', name: 'Home', components }, - { path: '/home/' }, - { name: 'Home', path: '/home/' } - ) - }) - - it('allows an optional trailing slash with optional param', () => { - assertRecordMatch( - { path: '/:a', components, name: 'a' }, - { path: '/a/' }, - { path: '/a/', params: { a: 'a' }, name: 'a' } - ) - assertRecordMatch( - { path: '/a/:a', components, name: 'a' }, - { path: '/a/a/' }, - { path: '/a/a/', params: { a: 'a' }, name: 'a' } - ) - }) - - it('allows an optional trailing slash with missing optional param', () => { - assertRecordMatch( - { path: '/:a?', components, name: 'a' }, - { path: '/' }, - { path: '/', params: { a: '' }, name: 'a' } - ) - assertRecordMatch( - { path: '/a/:a?', components, name: 'a' }, - { path: '/a/' }, - { path: '/a/', params: { a: '' }, name: 'a' } - ) - }) - - it('keeps required trailing slash (strict: true)', () => { - const record = { - path: '/home/', - name: 'Home', - components, - strict: true, - } - assertRecordMatch(record, { path: '/home' }, NO_MATCH_LOCATION) - assertRecordMatch( - record, - { path: '/home/' }, - { name: 'Home', path: '/home/' } - ) - }) - - it('rejects a trailing slash when strict', () => { - const record = { - path: '/home', - name: 'Home', - components, - strict: true, - } - assertRecordMatch( - record, - { path: '/home' }, - { name: 'Home', path: '/home' } - ) - assertRecordMatch(record, { path: '/home/' }, NO_MATCH_LOCATION) - }) - }) - - describe('LocationAsName', () => { - it('matches a name', () => { - assertRecordMatch( - { path: '/home', name: 'Home', components }, - // TODO: allow a name only without the params? - { name: 'Home', params: {} }, - { name: 'Home', path: '/home' } - ) - }) - - it('matches a name and fill params', () => { - assertRecordMatch( - { path: '/users/:id/m/:role', name: 'UserEdit', components }, - { name: 'UserEdit', params: { id: 'posva', role: 'admin' } }, - { - name: 'UserEdit', - path: '/users/posva/m/admin', - params: { id: 'posva', role: 'admin' }, - } - ) - }) - - it('throws if the named route does not exists', () => { - const matcher = createCompiledMatcher([]) - expect(() => matcher.resolve({ name: 'Home', params: {} })).toThrowError( - 'Matcher "Home" not found' - ) - }) - - it('merges params', () => { - assertRecordMatch( - { path: '/:a/:b', name: 'p', components }, - { params: { b: 'b' } }, - { name: 'p', path: '/A/b', params: { a: 'A', b: 'b' } }, - '/A/B' - ) - }) - - // TODO: this test doesn't seem useful, it's the same as the test above - // maybe remove it? - it('only keep existing params', () => { - assertRecordMatch( - { path: '/:a/:b', name: 'p', components }, - { name: 'p', params: { b: 'b' } }, - { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, - '/a/c' - ) - }) - - // TODO: implement parent children - it.todo('keep optional params from parent record', () => { - const Child_A = { path: 'a', name: 'child_a', components } - const Child_B = { path: 'b', name: 'child_b', components } - const Parent = { - path: '/:optional?/parent', - name: 'parent', - components, - children: [Child_A, Child_B], - } - assertRecordMatch( - Parent, - {}, - { - name: 'child_b', - path: '/foo/parent/b', - params: { optional: 'foo' }, - matched: [ - Parent as any, - { - ...Child_B, - path: `${Parent.path}/${Child_B.path}`, - }, - ], - }, - { - params: { optional: 'foo' }, - // matched: [], - name: 'child_a', - } - ) - }) - // TODO: check if needed by the active matching, if not just test that the param is dropped - - it.todo('discards non existent params', () => { - assertRecordMatch( - { path: '/', name: 'home', components }, - { name: 'home', params: { a: 'a', b: 'b' } }, - { name: 'home', path: '/', params: {} } - ) - expect('invalid param(s) "a", "b" ').toHaveBeenWarned() - assertRecordMatch( - { path: '/:b', name: 'a', components }, - { name: 'a', params: { a: 'a', b: 'b' } }, - { name: 'a', path: '/b', params: { b: 'b' } } - ) - expect('invalid param(s) "a"').toHaveBeenWarned() - }) - - it('drops optional params in absolute location', () => { - assertRecordMatch( - { path: '/:a/:b?', name: 'p', components }, - { name: 'p', params: { a: 'b' } }, - { name: 'p', path: '/b', params: { a: 'b' } } - ) - }) - - it('keeps optional params passed as empty strings', () => { - assertRecordMatch( - { path: '/:a/:b?', name: 'p', components }, - { name: 'p', params: { a: 'b', b: '' } }, - { name: 'p', path: '/b', params: { a: 'b', b: '' } } - ) - }) - - it('resolves root path with optional params', () => { - assertRecordMatch( - { path: '/:tab?', name: 'h', components }, - { name: 'h', params: {} }, - { name: 'h', path: '/', params: {} } - ) - assertRecordMatch( - { path: '/:tab?/:other?', name: 'h', components }, - { name: 'h', params: {} }, - { name: 'h', path: '/', params: {} } - ) - }) - }) - - describe('LocationAsRelative', () => { - // TODO: not sure where this warning should appear now - it.todo('warns if a path isn not absolute', () => { - const matcher = createCompiledMatcher([ - { path: new MatcherPatternPathStatic('/'), score: [[80]] }, - ]) - matcher.resolve({ path: 'two' }, matcher.resolve({ path: '/' })) - expect('received "two"').toHaveBeenWarned() - }) - - it('matches with nothing', () => { - const record = { path: '/home', name: 'Home', components } - assertRecordMatch( - record, - {}, - { name: 'Home', path: '/home' }, - { - name: 'Home', - params: {}, - } - ) - }) - - it('replace params even with no name', () => { - const record = { path: '/users/:id/m/:role', components } - assertRecordMatch( - record, - { params: { id: 'posva', role: 'admin' } }, - { path: '/users/posva/m/admin' }, - { - path: '/users/ed/m/user', - // params: { id: 'ed', role: 'user' }, - // matched: [record] as any, - } - ) - }) - - it('replace params', () => { - const record = { - path: '/users/:id/m/:role', - name: 'UserEdit', - components, - } - assertRecordMatch( - record, - { params: { id: 'posva', role: 'admin' } }, - { name: 'UserEdit', path: '/users/posva/m/admin' }, - { - // path: '/users/ed/m/user', - name: 'UserEdit', - params: { id: 'ed', role: 'user' }, - // matched: [], - } - ) - }) - - it('keep params if not provided', () => { - const record = { - path: '/users/:id/m/:role', - name: 'UserEdit', - components, - } - assertRecordMatch( - record, - {}, - { - name: 'UserEdit', - path: '/users/ed/m/user', - params: { id: 'ed', role: 'user' }, - }, - { - // path: '/users/ed/m/user', - name: 'UserEdit', - params: { id: 'ed', role: 'user' }, - matched: [record] as any, - } - ) - }) - - it('keep params if not provided even with no name', () => { - const record = { path: '/users/:id/m/:role', components } - assertRecordMatch( - record, - {}, - { - path: '/users/ed/m/user', - params: { id: 'ed', role: 'user' }, - }, - { - path: '/users/ed/m/user', - // name: undefined, - // params: { id: 'ed', role: 'user' }, - // matched: [record] as any, - } - ) - }) - - it('merges params', () => { - assertRecordMatch( - { path: '/:a/:b?', name: 'p', components }, - { params: { b: 'b' } }, - { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, - { - name: 'p', - params: { a: 'a' }, - // path: '/a', - // matched: [], - } - ) - }) - - it('keep optional params', () => { - assertRecordMatch( - { path: '/:a/:b?', name: 'p', components }, - {}, - { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, - { - name: 'p', - params: { a: 'a', b: 'b' }, - // path: '/a/b', - matched: [], - } - ) - }) - - it('merges optional params', () => { - assertRecordMatch( - { path: '/:a/:b?', name: 'p', components }, - { params: { a: 'c' } }, - { name: 'p', path: '/c/b', params: { a: 'c', b: 'b' } }, - { - name: 'p', - params: { a: 'a', b: 'b' }, - // path: '/a/b', - matched: [], - } - ) - }) - - it('throws if the current named route does not exists', () => { - const matcher = createCompiledMatcher([]) - expect(() => - matcher.resolve( - {}, - { - name: 'ko', - params: {}, - fullPath: '/', - hash: '', - matched: [], - path: '/', - query: {}, - } - ) - ).toThrowError('Matcher "ko" not found') - }) - - it('avoids records with children without a component nor name', () => { - assertRecordMatch( - { - path: '/articles', - children: [{ path: ':id', components }], - }, - { path: '/articles' }, - NO_MATCH_LOCATION - ) - }) - - it('avoids deeply nested records with children without a component nor name', () => { - assertRecordMatch( - { - path: '/app', - components, - children: [ - { - path: '/articles', - children: [{ path: ':id', components }], - }, - ], - }, - { path: '/articles' }, - NO_MATCH_LOCATION - ) - }) - - it('can reach a named route with children and no component if named', () => { - assertRecordMatch( - { - path: '/articles', - name: 'ArticlesParent', - children: [{ path: ':id', components }], - }, - { name: 'ArticlesParent', params: {} }, - { name: 'ArticlesParent', path: '/articles' } - ) - }) - }) - - describe('children', () => { - const ChildA: RouteRecordRaw = { path: 'a', name: 'child-a', components } - const ChildB: RouteRecordRaw = { path: 'b', name: 'child-b', components } - const ChildC: RouteRecordRaw = { path: 'c', name: 'child-c', components } - const ChildD: RouteRecordRaw = { - path: '/absolute', - name: 'absolute', - components, - } - const ChildWithParam: RouteRecordRaw = { - path: ':p', - name: 'child-params', - components, - } - const NestedChildWithParam: RouteRecordRaw = { - ...ChildWithParam, - name: 'nested-child-params', - } - const NestedChildA: RouteRecordRaw = { ...ChildA, name: 'nested-child-a' } - const NestedChildB: RouteRecordRaw = { ...ChildB, name: 'nested-child-b' } - const NestedChildC: RouteRecordRaw = { ...ChildC, name: 'nested-child-c' } - const Nested: RouteRecordRaw = { - path: 'nested', - name: 'nested', - components, - children: [NestedChildA, NestedChildB, NestedChildC], - } - const NestedWithParam: RouteRecordRaw = { - path: 'nested/:n', - name: 'nested', - components, - children: [NestedChildWithParam], - } - - it('resolves children', () => { - const Foo: RouteRecordRaw = { - path: '/foo', - name: 'Foo', - components, - children: [ChildA, ChildB, ChildC], - } - assertRecordMatch( - Foo, - { path: '/foo/b' }, - { - name: 'child-b', - path: '/foo/b', - params: {}, - // TODO: - // matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }], - } - ) - }) - - it('resolves children with empty paths', () => { - const Nested: RouteRecordRaw = { path: '', name: 'nested', components } - const Foo: RouteRecordRaw = { - path: '/foo', - name: 'Foo', - components, - children: [Nested], - } - assertRecordMatch( - Foo, - { path: '/foo' }, - { - name: 'nested', - path: '/foo', - params: {}, - matched: [Foo as any, { ...Nested, path: `${Foo.path}` }], - } - ) - }) - - it('resolves nested children with empty paths', () => { - const NestedNested = { path: '', name: 'nested', components } - const Nested = { - path: '', - name: 'nested-nested', - components, - children: [NestedNested], - } - const Foo = { - path: '/foo', - name: 'Foo', - components, - children: [Nested], - } - assertRecordMatch( - Foo, - { path: '/foo' }, - { - name: 'nested', - path: '/foo', - params: {}, - matched: [ - Foo as any, - { ...Nested, path: `${Foo.path}` }, - { ...NestedNested, path: `${Foo.path}` }, - ], - } - ) - }) - - it('resolves nested children', () => { - const Foo = { - path: '/foo', - name: 'Foo', - components, - children: [Nested], - } - assertRecordMatch( - Foo, - { path: '/foo/nested/a' }, - { - name: 'nested-child-a', - path: '/foo/nested/a', - params: {}, - matched: [ - Foo as any, - { ...Nested, path: `${Foo.path}/${Nested.path}` }, - { - ...NestedChildA, - path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, - }, - ], - } - ) - }) - - it('resolves nested children with named location', () => { - const Foo = { - path: '/foo', - name: 'Foo', - components, - children: [Nested], - } - assertRecordMatch( - Foo, - { name: 'nested-child-a', params: {} }, - { - name: 'nested-child-a', - path: '/foo/nested/a', - params: {}, - // TODO: - // matched: [ - // Foo as any, - // { ...Nested, path: `${Foo.path}/${Nested.path}` }, - // { - // ...NestedChildA, - // path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, - // }, - // ], - } - ) - }) - - it('resolves nested children with relative location', () => { - const Foo = { - path: '/foo', - name: 'Foo', - components, - children: [Nested], - } - assertRecordMatch( - Foo, - {}, - { - name: 'nested-child-a', - path: '/foo/nested/a', - params: {}, - matched: [ - Foo as any, - { ...Nested, path: `${Foo.path}/${Nested.path}` }, - { - ...NestedChildA, - path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, - }, - ], - }, - { - name: 'nested-child-a', - params: {}, - } - ) - }) - - it('resolves nested children with params', () => { - const Foo = { - path: '/foo', - name: 'Foo', - components, - children: [NestedWithParam], - } - assertRecordMatch( - Foo, - { path: '/foo/nested/a/b' }, - { - name: 'nested-child-params', - path: '/foo/nested/a/b', - params: { p: 'b', n: 'a' }, - matched: [ - Foo as any, - { - ...NestedWithParam, - path: `${Foo.path}/${NestedWithParam.path}`, - }, - { - ...NestedChildWithParam, - path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, - }, - ], - } - ) - }) - - it('resolves nested children with params with named location', () => { - const Foo = { - path: '/foo', - name: 'Foo', - components, - children: [NestedWithParam], - } - assertRecordMatch( - Foo, - { name: 'nested-child-params', params: { p: 'a', n: 'b' } }, - { - name: 'nested-child-params', - path: '/foo/nested/b/a', - params: { p: 'a', n: 'b' }, - matched: [ - Foo as any, - { - ...NestedWithParam, - path: `${Foo.path}/${NestedWithParam.path}`, - }, - { - ...NestedChildWithParam, - path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, - }, - ], - } - ) - }) - - it('resolves absolute path children', () => { - const Foo = { - path: '/foo', - name: 'Foo', - components, - children: [ChildA, ChildD], - } - assertRecordMatch( - Foo, - { path: '/absolute' }, - { - name: 'absolute', - path: '/absolute', - params: {}, - // TODO: - // matched: [Foo, ChildD], - } - ) - }) - - it('resolves children with root as the parent', () => { - const Nested = { path: 'nested', name: 'nested', components } - const Parent = { - path: '/', - name: 'parent', - components, - children: [Nested], - } - assertRecordMatch( - Parent, - { path: '/nested' }, - { - name: 'nested', - path: '/nested', - params: {}, - matched: [Parent as any, { ...Nested, path: `/nested` }], - } - ) - }) - - it('resolves children with parent with trailing slash', () => { - const Nested = { path: 'nested', name: 'nested', components } - const Parent = { - path: '/parent/', - name: 'parent', - components, - children: [Nested], - } - assertRecordMatch( - Parent, - { path: '/parent/nested' }, - { - name: 'nested', - path: '/parent/nested', - params: {}, - matched: [Parent as any, { ...Nested, path: `/parent/nested` }], - } - ) - }) - }) -}) diff --git a/packages/router/src/experimental/route-resolver/old/resolver-dynamic.test-d.ts b/packages/router/src/experimental/route-resolver/old/resolver-dynamic.test-d.ts deleted file mode 100644 index e249fe28..00000000 --- a/packages/router/src/experimental/route-resolver/old/resolver-dynamic.test-d.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest' -import { ResolverLocationResolved } from '../resolver-abstract' -import { NEW_MatcherRecordRaw } from './resolver-dynamic' -import { NEW_RouterResolver } from './resolver-dynamic' -import { EXPERIMENTAL_RouteRecordNormalized } from '../../router' - -describe('Matcher', () => { - type TMatcherRecordRaw = NEW_MatcherRecordRaw - type TMatcherRecord = EXPERIMENTAL_RouteRecordNormalized - - const matcher: NEW_RouterResolver = - {} as any - - describe('matcher.resolve()', () => { - it('resolves absolute string locations', () => { - expectTypeOf(matcher.resolve({ path: '/foo' })).toEqualTypeOf< - ResolverLocationResolved - >() - expectTypeOf(matcher.resolve('/foo')).toEqualTypeOf< - ResolverLocationResolved - >() - }) - - it('fails on non absolute location without a currentLocation', () => { - // @ts-expect-error: needs currentLocation - matcher.resolve('foo') - // @ts-expect-error: needs currentLocation - matcher.resolve({ path: 'foo' }) - }) - - it('resolves relative locations', () => { - expectTypeOf( - matcher.resolve( - { path: 'foo' }, - {} as ResolverLocationResolved - ) - ).toEqualTypeOf>() - expectTypeOf( - matcher.resolve('foo', {} as ResolverLocationResolved) - ).toEqualTypeOf>() - }) - - it('resolved named locations', () => { - expectTypeOf(matcher.resolve({ name: 'foo', params: {} })).toEqualTypeOf< - ResolverLocationResolved - >() - }) - - it('fails on object relative location without a currentLocation', () => { - // @ts-expect-error: needs currentLocation - matcher.resolve({ params: { id: '1' } }) - // @ts-expect-error: needs currentLocation - matcher.resolve({ query: { id: '1' } }) - }) - - it('resolves object relative locations with a currentLocation', () => { - expectTypeOf( - matcher.resolve( - { params: { id: 1 } }, - {} as ResolverLocationResolved - ) - ).toEqualTypeOf>() - }) - }) - - it('does not allow a name + path', () => { - matcher.resolve({ - // ...({} as NEW_LocationResolved), - name: 'foo', - params: {}, - // @ts-expect-error: name + path - path: '/e', - }) - matcher.resolve( - // @ts-expect-error: name + currentLocation - { name: 'a', params: {} }, - // - {} as ResolverLocationResolved - ) - }) -}) diff --git a/packages/router/src/experimental/route-resolver/old/resolver-dynamic.ts b/packages/router/src/experimental/route-resolver/old/resolver-dynamic.ts deleted file mode 100644 index 1fc4cfc6..00000000 --- a/packages/router/src/experimental/route-resolver/old/resolver-dynamic.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { - NEW_stringifyURL, - LocationNormalized, - parseURL, - resolveRelativePath, -} from '../../../location' -import { normalizeQuery, stringifyQuery, parseQuery } from '../../../query' -import type { MatcherParamsFormatted } from '../matchers/matcher-pattern' -import type { ResolverLocationAsRelative } from '../resolver-abstract' -import type { ResolverLocationAsPathAbsolute } from '../resolver-abstract' -import type { ResolverLocationAsPathRelative } from '../resolver-abstract' -import type { ResolverLocationAsNamed } from '../resolver-abstract' -import { - EXPERIMENTAL_Resolver_Base, - NO_MATCH_LOCATION, - RecordName, - ResolverLocationResolved, -} from '../resolver-abstract' -import { MatcherQueryParams } from '../matchers/matcher-pattern' -import { comparePathParserScore } from '../../../matcher/pathParserRanker' -import { warn } from '../../../warning' -import type { - MatcherPatternPath, - MatcherPatternQuery, - MatcherPatternHash, -} from '../matchers/matcher-pattern' - -/** - * Manage and resolve routes. Also handles the encoding, decoding, parsing and - * serialization of params, query, and hash. - * - * - `TMatcherRecordRaw` represents the raw record type passed to {@link addMatcher}. - * - `TMatcherRecord` represents the normalized record type returned by {@link getRecords}. - */ - -export interface NEW_RouterResolver - extends EXPERIMENTAL_Resolver_Base { - /** - * Add a matcher record. Previously named `addRoute()`. - * @param matcher - The matcher record to add. - * @param parent - The parent matcher record if this is a child. - */ - addMatcher( - matcher: TMatcherRecordRaw, - parent?: TMatcherRecord - ): TMatcherRecord - - /** - * Remove a matcher by its name. Previously named `removeRoute()`. - * @param matcher - The matcher (returned by {@link addMatcher}) to remove. - */ - removeMatcher(matcher: TMatcherRecord): void - - /** - * Remove all matcher records. Previously named `clearRoutes()`. - */ - clearMatchers(): void -} -export function createCompiledMatcher< - TMatcherRecord extends NEW_MatcherDynamicRecord, ->( - records: NEW_MatcherRecordRaw[] = [] -): NEW_RouterResolver { - // TODO: we also need an array that has the correct order - const matcherMap = new Map() - const matchers: TMatcherRecord[] = [] - - // TODO: allow custom encode/decode functions - // const encodeParams = applyToParams.bind(null, encodeParam) - // const decodeParams = transformObject.bind(null, String, decode) - // const encodeQuery = transformObject.bind( - // null, - // _encodeQueryKey, - // encodeQueryValue - // ) - // const decodeQuery = transformObject.bind(null, decode, decode) - // NOTE: because of the overloads, we need to manually type the arguments - type MatcherResolveArgs = - | [absoluteLocation: `/${string}`, currentLocation?: undefined] - | [ - relativeLocation: string, - currentLocation: ResolverLocationResolved, - ] - | [ - absoluteLocation: ResolverLocationAsPathAbsolute, - // Same as above - // currentLocation?: NEW_LocationResolved | undefined - currentLocation?: undefined, - ] - | [ - relativeLocation: ResolverLocationAsPathRelative, - currentLocation: ResolverLocationResolved, - ] - | [ - location: ResolverLocationAsNamed, - // Same as above - // currentLocation?: NEW_LocationResolved | undefined - currentLocation?: undefined, - ] - | [ - relativeLocation: ResolverLocationAsRelative, - currentLocation: ResolverLocationResolved, - ] - - function resolve( - ...args: MatcherResolveArgs - ): ResolverLocationResolved { - const [to, currentLocation] = args - - if (typeof to === 'object' && (to.name || to.path == null)) { - // relative location or by name - if (__DEV__ && to.name == null && currentLocation == null) { - warn( - `Cannot resolve an unnamed relative location without a current location. This will throw in production.`, - to - ) - // NOTE: normally there is no query, hash or path but this helps debug - // what kind of object location was passed - // @ts-expect-error: to is never - const query = normalizeQuery(to.query) - // @ts-expect-error: to is never - const hash = to.hash ?? '' - // @ts-expect-error: to is never - const path = to.path ?? '/' - return { - ...NO_MATCH_LOCATION, - fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), - path, - query, - hash, - } - } - - // either one of them must be defined and is catched by the dev only warn above - const name = to.name ?? currentLocation?.name - // FIXME: remove once name cannot be null - const matcher = name != null && matcherMap.get(name) - if (!matcher) { - throw new Error(`Matcher "${String(name)}" not found`) - } - - // unencoded params in a formatted form that the user came up with - const params: MatcherParamsFormatted = { - ...currentLocation?.params, - ...to.params, - } - const path = matcher.path.build(params) - const hash = matcher.hash?.build(params) ?? '' - const matched = buildMatched(matcher) - const query = Object.assign( - { - ...currentLocation?.query, - ...normalizeQuery(to.query), - }, - ...matched.map(matcher => matcher.query?.build(params)) - ) - - return { - name, - fullPath: NEW_stringifyURL(stringifyQuery, path, query, hash), - path, - query, - hash, - params, - matched, - } - // string location, e.g. '/foo', '../bar', 'baz', '?page=1' - } else { - // parseURL handles relative paths - let url: LocationNormalized - if (typeof to === 'string') { - url = parseURL(parseQuery, to, currentLocation?.path) - } else { - const query = normalizeQuery(to.query) - url = { - fullPath: NEW_stringifyURL(stringifyQuery, to.path, query, to.hash), - path: resolveRelativePath(to.path, currentLocation?.path || '/'), - query, - hash: to.hash || '', - } - } - - let matcher: TMatcherRecord | undefined - let matched: - | ResolverLocationResolved['matched'] - | undefined - let parsedParams: MatcherParamsFormatted | null | undefined - - for (matcher of matchers) { - // match the path because the path matcher only needs to be matched here - // match the hash because only the deepest child matters - // End up by building up the matched array, (reversed so it goes from - // root to child) and then match and merge all queries - try { - const pathParams = matcher.path.match(url.path) - const hashParams = matcher.hash?.match(url.hash) - matched = buildMatched(matcher) - const queryParams: MatcherQueryParams = Object.assign( - {}, - ...matched.map(matcher => matcher.query?.match(url.query)) - ) - // TODO: test performance - // for (const matcher of matched) { - // Object.assign(queryParams, matcher.query?.match(url.query)) - // } - parsedParams = { ...pathParams, ...queryParams, ...hashParams } - // we found our match! - break - } catch (e) { - // for debugging tests - // console.log('❌ ERROR matching', e) - } - } - - // No match location - if (!parsedParams || !matched) { - return { - ...url, - ...NO_MATCH_LOCATION, - // already decoded - // query: url.query, - // hash: url.hash, - } - } - - return { - ...url, - // matcher exists if matched exists - name: matcher!.name, - params: parsedParams, - matched, - } - // TODO: handle object location { path, query, hash } - } - } - - function addMatcher(record: NEW_MatcherRecordRaw, parent?: TMatcherRecord) { - const name = record.name ?? (__DEV__ ? Symbol('unnamed-route') : Symbol()) - // FIXME: proper normalization of the record - // @ts-expect-error: we are not properly normalizing the record yet - const normalizedRecord: TMatcherRecord = { - ...record, - name, - parent, - children: [], - } - - // insert the matcher if it's matchable - if (!normalizedRecord.group) { - const index = findInsertionIndex(normalizedRecord, matchers) - matchers.splice(index, 0, normalizedRecord) - // only add the original record to the name map - if (normalizedRecord.name && !isAliasRecord(normalizedRecord)) - matcherMap.set(normalizedRecord.name, normalizedRecord) - // matchers.set(name, normalizedRecord) - } - - record.children?.forEach(childRecord => - normalizedRecord.children.push(addMatcher(childRecord, normalizedRecord)) - ) - - return normalizedRecord - } - - for (const record of records) { - addMatcher(record) - } - - function removeMatcher(matcher: TMatcherRecord) { - matcherMap.delete(matcher.name) - for (const child of matcher.children) { - removeMatcher(child) - } - // TODO: delete from matchers - // TODO: delete children and aliases - } - - function clearMatchers() { - matchers.splice(0, matchers.length) - matcherMap.clear() - } - - function getRecords() { - return matchers - } - - function getRecord(name: RecordName) { - return matcherMap.get(name) - } - - return { - resolve, - - addMatcher, - removeMatcher, - clearMatchers, - getRecord, - getRecords, - } -} - -/** - * Performs a binary search to find the correct insertion index for a new matcher. - * - * Matchers are primarily sorted by their score. If scores are tied then we also consider parent/child relationships, - * with descendants coming before ancestors. If there's still a tie, new routes are inserted after existing routes. - * - * @param matcher - new matcher to be inserted - * @param matchers - existing matchers - */ - -export function findInsertionIndex( - matcher: T, - matchers: T[] -) { - // First phase: binary search based on score - let lower = 0 - let upper = matchers.length - - while (lower !== upper) { - const mid = (lower + upper) >> 1 - const sortOrder = comparePathParserScore(matcher, matchers[mid]) - - if (sortOrder < 0) { - upper = mid - } else { - lower = mid + 1 - } - } - - // Second phase: check for an ancestor with the same score - const insertionAncestor = getInsertionAncestor(matcher) - - if (insertionAncestor) { - upper = matchers.lastIndexOf(insertionAncestor, upper - 1) - - if (__DEV__ && upper < 0) { - // This should never happen - warn( - // TODO: fix stringifying new matchers - `Finding ancestor route "${insertionAncestor.path}" failed for "${matcher.path}"` - ) - } - } - - return upper -} -export function getInsertionAncestor( - matcher: T -) { - let ancestor: T | undefined = matcher - - while ((ancestor = ancestor.parent)) { - if (!ancestor.group && comparePathParserScore(matcher, ancestor) === 0) { - return ancestor - } - } - - return -} - -/** - * Checks if a record or any of its parent is an alias - * @param record - */ -export function isAliasRecord( - record: T | undefined -): boolean { - while (record) { - if (record.aliasOf) return true - record = record.parent - } - - return false -} // pathEncoded`/users/${1}` -// TODO: -// pathEncoded`/users/${null}/end` -// const a: RouteRecordRaw = {} as any -/** - * Build the `matched` array of a record that includes all parent records from the root to the current one. - */ - -export function buildMatched( - record: T -): T[] { - const matched: T[] = [] - let node: T | undefined = record - while (node) { - matched.unshift(node) - node = node.parent - } - return matched -} -export interface EXPERIMENTAL_ResolverRecord_Base { - /** - * Name of the matcher. Unique across all matchers. - */ - name: RecordName - - /** - * {@link MatcherPattern} for the path section of the URI. - */ - path: MatcherPatternPath - - /** - * {@link MatcherPattern} for the query section of the URI. - */ - query?: MatcherPatternQuery - - /** - * {@link MatcherPattern} for the hash section of the URI. - */ - hash?: MatcherPatternHash - - // TODO: here or in router - // redirect?: RouteRecordRedirectOption - parent?: this - // FIXME: this property is only needed for dynamic routing - children: this[] - aliasOf?: this - - /** - * Is this a record that groups children. Cannot be matched - */ - group?: boolean -} -export interface NEW_MatcherDynamicRecord - extends EXPERIMENTAL_ResolverRecord_Base { - // TODO: the score shouldn't be always needed, it's only needed with dynamic routing - score: Array -} // FIXME: later on, the MatcherRecord should be compatible with RouteRecordRaw (which can miss a path, have children, etc) -/** - * Experimental new matcher record base type. - * - * @experimental - */ - -export interface NEW_MatcherRecordRaw { - path: MatcherPatternPath - query?: MatcherPatternQuery - hash?: MatcherPatternHash - - // NOTE: matchers do not handle `redirect` the redirect option, the router - // does. They can still match the correct record but they will let the router - // retrigger a whole navigation to the new location. - // TODO: probably as `aliasOf`. Maybe a different format with the path, query and has matchers? - /** - * Aliases for the record. Allows defining extra paths that will behave like a - * copy of the record. Allows having paths shorthands like `/users/:id` and - * `/u/:id`. All `alias` and `path` values must share the same params. - */ - // alias?: string | string[] - /** - * Name for the route record. Must be unique. Will be set to `Symbol()` if - * not set. - */ - name?: RecordName - - /** - * Array of nested routes. - */ - children?: NEW_MatcherRecordRaw[] - - /** - * Is this a record that groups children. Cannot be matched - */ - group?: boolean - - score: Array -} - -/** - * Normalized version of a {@link NEW_MatcherRecordRaw} record. - */ -export interface NEW_MatcherRecord extends NEW_MatcherDynamicRecord {} diff --git a/packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts b/packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts index 1050f668..144eae29 100644 --- a/packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts +++ b/packages/router/src/experimental/route-resolver/resolver-fixed.spec.ts @@ -13,6 +13,59 @@ import { ANY_HASH_PATTERN_MATCHER, PAGE_QUERY_PATTERN_MATCHER, } from './matchers/test-utils' +import { miss } from './matchers/errors' +import { MatcherPatternPath } from './matchers/matcher-pattern' + +// Additional pattern matchers for testing advanced scenarios +const USERS_ID_OTHER_PATH_MATCHER: MatcherPatternPath<{ + id: string + other: string +}> = { + match(path) { + const match = path.match(/^\/users\/([^/]+)\/([^/]+)$/) + if (!match) throw miss() + return { id: match[1], other: match[2] } + }, + build({ id, other }) { + return `/users/${id}/${other}` + }, +} + +const AB_PARAMS_PATH_MATCHER: MatcherPatternPath<{ a: string; b: string }> = { + match(path) { + const match = path.match(/^\/([^/]+)\/([^/]+)$/) + if (!match) throw miss() + return { a: match[1], b: match[2] } + }, + build({ a, b }) { + return `/${a}/${b}` + }, +} + +const AB_OPTIONAL_PATH_MATCHER: MatcherPatternPath<{ a: string; b?: string }> = + { + match(path) { + const match = path.match(/^\/([^/]+)(?:\/([^/]+))?$/) + if (!match) throw miss() + return { a: match[1], b: match[2] || '' } + }, + build({ a, b }) { + return b ? `/${a}/${b}` : `/${a}` + }, + } + +const REPEATABLE_PARAM_MATCHER: MatcherPatternPath<{ p: string | string[] }> = { + match(path) { + const match = path.match(/^\/a\/(.+)$/) + if (!match) throw miss() + const segments = match[1].split('/') + return { p: segments.length === 1 ? segments[0] : segments } + }, + build({ p }) { + const segments = Array.isArray(p) ? p : [p] + return `/a/${segments.join('/')}` + }, +} describe('fixed resolver', () => { describe('new matchers', () => { @@ -154,17 +207,43 @@ describe('fixed resolver', () => { }) }) - describe('relative locations as strings', () => { - it('resolves a simple object relative location', () => { + describe('relative locations', () => { + it('resolves relative string locations', () => { const resolver = createFixedResolver([ { name: 'any-path', path: ANY_PATH_PATTERN_MATCHER }, ]) + const currentLocation = resolver.resolve({ path: '/nested/' }) + + expect(resolver.resolve('foo', currentLocation)).toMatchObject({ + params: {}, + path: '/nested/foo', + query: {}, + hash: '', + }) + expect(resolver.resolve('../foo', currentLocation)).toMatchObject({ + params: {}, + path: '/foo', + query: {}, + hash: '', + }) + expect(resolver.resolve('./foo', currentLocation)).toMatchObject({ + params: {}, + path: '/nested/foo', + query: {}, + hash: '', + }) + }) + + it('resolves relative object locations', () => { + const resolver = createFixedResolver([ + { name: 'any-path', path: ANY_PATH_PATTERN_MATCHER }, + ]) + + const currentLocation = resolver.resolve({ path: '/nested/' }) + expect( - resolver.resolve( - { path: 'foo' }, - resolver.resolve({ path: '/nested/' }) - ) + resolver.resolve({ path: 'foo' }, currentLocation) ).toMatchObject({ params: {}, path: '/nested/foo', @@ -173,10 +252,7 @@ describe('fixed resolver', () => { hash: '', }) expect( - resolver.resolve( - { path: '../foo' }, - resolver.resolve({ path: '/nested/' }) - ) + resolver.resolve({ path: '../foo' }, currentLocation) ).toMatchObject({ params: {}, path: '/foo', @@ -185,10 +261,7 @@ describe('fixed resolver', () => { hash: '', }) expect( - resolver.resolve( - { path: './foo' }, - resolver.resolve({ path: '/nested/' }) - ) + resolver.resolve({ path: './foo' }, currentLocation) ).toMatchObject({ params: {}, path: '/nested/foo', @@ -197,45 +270,61 @@ describe('fixed resolver', () => { hash: '', }) }) - }) - it('resolves a simple string relative location', () => { - const resolver = createFixedResolver([ - { name: 'any-path', path: ANY_PATH_PATTERN_MATCHER }, - ]) + it('merges params with current location', () => { + const resolver = createFixedResolver([ + { name: 'ab', path: AB_PARAMS_PATH_MATCHER }, + ]) - expect( - resolver.resolve('foo', resolver.resolve({ path: '/nested/' })) - ).toMatchObject({ - params: {}, - path: '/nested/foo', - query: {}, - hash: '', + const currentLocation = resolver.resolve({ path: '/A/B' }) + + expect( + resolver.resolve({ params: { b: 'b' } }, currentLocation) + ).toMatchObject({ + name: 'ab', + path: '/A/b', + params: { a: 'A', b: 'b' }, + }) }) - expect( - resolver.resolve('../foo', resolver.resolve({ path: '/nested/' })) - ).toMatchObject({ - params: {}, - path: '/foo', - query: {}, - hash: '', + + it('keeps params if not provided', () => { + const resolver = createFixedResolver([ + { name: 'user-edit', path: USERS_ID_OTHER_PATH_MATCHER }, + ]) + + const currentLocation = resolver.resolve({ path: '/users/ed/user' }) + + expect(resolver.resolve({}, currentLocation)).toMatchObject({ + name: 'user-edit', + path: '/users/ed/user', + params: { id: 'ed', other: 'user' }, + }) }) - expect( - resolver.resolve('./foo', resolver.resolve({ path: '/nested/' })) - ).toMatchObject({ - params: {}, - path: '/nested/foo', - query: {}, - hash: '', + + it('replaces params even with no name', () => { + const resolver = createFixedResolver([ + { name: 'user-edit', path: USERS_ID_OTHER_PATH_MATCHER }, + ]) + + const currentLocation = resolver.resolve({ path: '/users/ed/user' }) + + expect( + resolver.resolve( + { params: { id: 'posva', other: 'admin' } }, + currentLocation + ) + ).toMatchObject({ + path: '/users/posva/admin', + }) }) }) describe('absolute locations', () => { - it('resolves an object location', () => { + it('resolves an absolute string location', () => { const resolver = createFixedResolver([ { name: 'root', path: EMPTY_PATH_PATTERN_MATCHER }, ]) - expect(resolver.resolve({ path: '/' })).toMatchObject({ + expect(resolver.resolve('/')).toMatchObject({ fullPath: '/', path: '/', params: {}, @@ -244,11 +333,11 @@ describe('fixed resolver', () => { }) }) - it('resolves an absolute string location', () => { + it('resolves an absolute object location', () => { const resolver = createFixedResolver([ { name: 'root', path: EMPTY_PATH_PATTERN_MATCHER }, ]) - expect(resolver.resolve('/')).toMatchObject({ + expect(resolver.resolve({ path: '/' })).toMatchObject({ fullPath: '/', path: '/', params: {}, @@ -263,10 +352,10 @@ describe('fixed resolver', () => { ]) // Object with path containing query/hash should treat entire string as pathname expect(resolver.resolve({ path: '/?a=a&b=b#h' })).toMatchObject({ - path: '/?a=a&b=b#h', // Full string treated as path - query: {}, // Empty query - hash: '', // Empty hash - params: { pathMatch: '/?a=a&b=b#h' }, // Matcher sees full string + path: '/?a=a&b=b#h', + query: {}, + hash: '', + params: { pathMatch: '/?a=a&b=b#h' }, }) }) }) @@ -288,6 +377,31 @@ describe('fixed resolver', () => { hash: '', }) }) + + it('resolves named locations with params', () => { + const resolver = createFixedResolver([ + { name: 'user-edit', path: USERS_ID_OTHER_PATH_MATCHER }, + ]) + + expect( + resolver.resolve({ + name: 'user-edit', + params: { id: 'posva', other: 'admin' }, + }) + ).toMatchObject({ + name: 'user-edit', + path: '/users/posva/admin', + params: { id: 'posva', other: 'admin' }, + }) + }) + + it('throws if named route does not exist', () => { + const resolver = createFixedResolver([]) + + expect(() => + resolver.resolve({ name: 'nonexistent', params: {} }) + ).toThrowError('Record "nonexistent" not found') + }) }) describe('encoding', () => { @@ -300,7 +414,7 @@ describe('fixed resolver', () => { fullPath: '/%23%2F%3F', path: '/%23%2F%3F', query: {}, - // we don't tests params here becuase it's matcher's responsibility to encode the path + // we don't test params here because it's matcher's responsibility to encode the path hash: '', }) }) @@ -362,5 +476,181 @@ describe('fixed resolver', () => { }) }) }) + + describe('multiple parameters', () => { + it('resolves paths with multiple params', () => { + const resolver = createFixedResolver([ + { name: 'user', path: USERS_ID_OTHER_PATH_MATCHER }, + { name: 'ab', path: AB_PARAMS_PATH_MATCHER }, + ]) + + expect(resolver.resolve({ path: '/users/posva/hey' })).toMatchObject({ + name: 'user', + params: { id: 'posva', other: 'hey' }, + path: '/users/posva/hey', + }) + + expect(resolver.resolve({ path: '/foo/bar' })).toMatchObject({ + name: 'ab', + params: { a: 'foo', b: 'bar' }, + path: '/foo/bar', + }) + }) + }) + + describe('repeatable parameters', () => { + it('resolves array of params for repeatable params', () => { + const resolver = createFixedResolver([ + { name: 'repeatable', path: REPEATABLE_PARAM_MATCHER }, + ]) + + expect( + resolver.resolve({ + name: 'repeatable', + params: { p: ['b', 'c', 'd'] }, + }) + ).toMatchObject({ + name: 'repeatable', + path: '/a/b/c/d', + params: { p: ['b', 'c', 'd'] }, + }) + }) + + it('resolves single param for repeatable params', () => { + const resolver = createFixedResolver([ + { name: 'repeatable', path: REPEATABLE_PARAM_MATCHER }, + ]) + + expect( + resolver.resolve({ name: 'repeatable', params: { p: 'b' } }) + ).toMatchObject({ + name: 'repeatable', + path: '/a/b', + params: { p: 'b' }, + }) + }) + + it('keeps repeated params as array when provided through path', () => { + const resolver = createFixedResolver([ + { name: 'repeatable', path: REPEATABLE_PARAM_MATCHER }, + ]) + + expect(resolver.resolve({ path: '/a/b/c' })).toMatchObject({ + name: 'repeatable', + params: { p: ['b', 'c'] }, + }) + }) + }) + + describe('optional parameters', () => { + it('handles optional trailing param', () => { + const resolver = createFixedResolver([ + { name: 'optional', path: AB_OPTIONAL_PATH_MATCHER }, + ]) + + expect(resolver.resolve({ path: '/foo' })).toMatchObject({ + name: 'optional', + params: { a: 'foo', b: '' }, + path: '/foo', + }) + + expect(resolver.resolve({ path: '/foo/bar' })).toMatchObject({ + name: 'optional', + params: { a: 'foo', b: 'bar' }, + path: '/foo/bar', + }) + }) + + it('drops optional params in named location', () => { + const resolver = createFixedResolver([ + { name: 'optional', path: AB_OPTIONAL_PATH_MATCHER }, + ]) + + expect( + resolver.resolve({ name: 'optional', params: { a: 'b' } }) + ).toMatchObject({ + name: 'optional', + path: '/b', + params: { a: 'b' }, + }) + }) + + it('keeps optional params passed as empty strings', () => { + const resolver = createFixedResolver([ + { name: 'optional', path: AB_OPTIONAL_PATH_MATCHER }, + ]) + + expect( + resolver.resolve({ name: 'optional', params: { a: 'b', b: '' } }) + ).toMatchObject({ + name: 'optional', + path: '/b', + params: { a: 'b', b: '' }, + }) + }) + }) + + it('has strict trailing slash handling', () => { + const resolver = createFixedResolver([ + { name: 'home', path: new MatcherPatternPathStatic('/home') }, + { name: 'home-slash', path: new MatcherPatternPathStatic('/home/') }, + ]) + + expect(resolver.resolve({ path: '/home' })).toMatchObject({ + name: 'home', + path: '/home', + }) + + expect(resolver.resolve({ path: '/home/' })).toMatchObject({ + name: 'home-slash', + path: '/home/', + }) + }) + + describe('nested routes', () => { + it('resolves child routes with parent-child relationships', () => { + const parentRecord = { + name: 'parent', + path: new MatcherPatternPathStatic('/parent'), + parent: null, + } + + const childRecord = { + name: 'child', + path: new MatcherPatternPathStatic('/child'), + parent: parentRecord, + } + + const resolver = createFixedResolver([parentRecord, childRecord]) + + expect(resolver.resolve({ path: '/child' })).toMatchObject({ + name: 'child', + path: '/child', + matched: [parentRecord, childRecord], + }) + }) + + it('resolves child routes with params', () => { + const parentRecord = { + name: 'users', + path: new MatcherPatternPathStatic('/users'), + parent: null, + } + + const childRecord = { + name: 'user-detail', + path: USER_ID_PATH_PATTERN_MATCHER, + parent: parentRecord, + } + + const resolver = createFixedResolver([parentRecord, childRecord]) + + expect(resolver.resolve({ path: '/users/123' })).toMatchObject({ + name: 'user-detail', + params: { id: 123 }, + matched: [parentRecord, childRecord], + }) + }) + }) }) }) -- 2.47.3