From: Eduardo San Martin Morote Date: Mon, 21 Jul 2025 14:17:47 +0000 (+0200) Subject: refactor: matchers tests X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=38e5ed2b7abcd39436fab372e44a8253c2d9b0ff;p=thirdparty%2Fvuejs%2Frouter.git refactor: matchers tests --- diff --git a/packages/router/src/new-route-resolver/matcher-pattern.spec.ts b/packages/router/src/new-route-resolver/matcher-pattern.spec.ts new file mode 100644 index 00000000..c3f8f021 --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher-pattern.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + MatcherPatternPathStatic, + MatcherPatternPathStar, +} from './matcher-pattern' + +describe('MatcherPatternPathStatic', () => { + describe('match()', () => { + it('matches exact path', () => { + const pattern = new MatcherPatternPathStatic('/team') + expect(pattern.match('/team')).toEqual({}) + }) + + it('matches root path', () => { + const pattern = new MatcherPatternPathStatic('/') + expect(pattern.match('/')).toEqual({}) + }) + + it('throws for non-matching path', () => { + const pattern = new MatcherPatternPathStatic('/team') + expect(() => pattern.match('/users')).toThrow() + expect(() => pattern.match('/')).toThrow() + }) + + it('is case insensitive', () => { + const pattern = new MatcherPatternPathStatic('/Team') + expect(pattern.match('/team')).toEqual({}) + expect(pattern.match('/TEAM')).toEqual({}) + expect(pattern.match('/tEAm')).toEqual({}) + }) + }) + + describe('build()', () => { + it('returns the original path', () => { + const pattern = new MatcherPatternPathStatic('/team') + expect(pattern.build()).toBe('/team') + }) + + it('returns root path', () => { + const pattern = new MatcherPatternPathStatic('/') + expect(pattern.build()).toBe('/') + }) + }) +}) + +describe('MatcherPatternPathStar', () => { + describe('match()', () => { + it('matches everything by default', () => { + const pattern = new MatcherPatternPathStar() + expect(pattern.match('/anything')).toEqual({ pathMatch: '/anything' }) + expect(pattern.match('/')).toEqual({ pathMatch: '/' }) + }) + + it('can match with a prefix', () => { + const pattern = new MatcherPatternPathStar('/team') + expect(pattern.match('/team')).toEqual({ pathMatch: '' }) + expect(pattern.match('/team/')).toEqual({ pathMatch: '/' }) + expect(pattern.match('/team/123')).toEqual({ pathMatch: '/123' }) + expect(pattern.match('/team/123/456')).toEqual({ pathMatch: '/123/456' }) + }) + + it('throws if prefix does not match', () => { + const pattern = new MatcherPatternPathStar('/teams') + expect(() => pattern.match('/users')).toThrow() + expect(() => pattern.match('/team')).toThrow() + }) + + it('is case insensitive', () => { + const pattern = new MatcherPatternPathStar('/Team') + expect(pattern.match('/team')).toEqual({ pathMatch: '' }) + expect(pattern.match('/TEAM')).toEqual({ pathMatch: '' }) + expect(pattern.match('/team/123')).toEqual({ pathMatch: '/123' }) + }) + + it('keeps the case of the pathMatch', () => { + const pattern = new MatcherPatternPathStar('/team') + expect(pattern.match('/team/Hello')).toEqual({ pathMatch: '/Hello' }) + expect(pattern.match('/team/Hello/World')).toEqual({ + pathMatch: '/Hello/World', + }) + expect(pattern.match('/tEaM/HElLo')).toEqual({ pathMatch: '/HElLo' }) + }) + }) + + describe('build()', () => { + it('builds path with pathMatch parameter', () => { + const pattern = new MatcherPatternPathStar('/team') + expect(pattern.build({ pathMatch: '/123' })).toBe('/team/123') + expect(pattern.build({ pathMatch: '-ok' })).toBe('/team-ok') + }) + + it('builds path with empty pathMatch', () => { + const pattern = new MatcherPatternPathStar('/team') + expect(pattern.build({ pathMatch: '' })).toBe('/team') + }) + + it('keep paths as is', () => { + const pattern = new MatcherPatternPathStar('/team/') + expect(pattern.build({ pathMatch: '/hey' })).toBe('/team//hey') + }) + }) +}) diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index e0efb2ee..e6eec914 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -1,6 +1,7 @@ import { decode, MatcherQueryParams } from './resolver' import { EmptyParams, MatcherParamsFormatted } from './matcher-location' import { miss } from './matchers/errors' +import { joinPaths } from './matcher-resolve.spec' /** * Base interface for matcher patterns that extract params from a URL. @@ -47,13 +48,27 @@ export interface MatcherPatternPath< TParams extends MatcherParamsFormatted = MatcherParamsFormatted, // | null // | undefined // | void // so it might be a bit more convenient > extends MatcherPattern {} +/** + * Allows matching a static path. + * + * @example + * ```ts + * const matcher = new MatcherPatternPathStatic('/team') + * matcher.match('/team') // {} + * matcher.match('/team/123') // throws MatchMiss + * matcher.build() // '/team' + * ``` + */ export class MatcherPatternPathStatic implements MatcherPatternPath { - constructor(private path: string) {} + private path: string + constructor(path: string) { + this.path = path.toLowerCase() + } match(path: string): EmptyParams { - if (path !== this.path) { + if (path.toLowerCase() !== this.path) { throw miss() } return {} @@ -63,6 +78,43 @@ export class MatcherPatternPathStatic return this.path } } + +/** + * Allows matching a static path folllowed by anything. + * + * @example + * + * ```ts + * const matcher = new MatcherPatternPathStar('/team') + * matcher.match('/team/123') // { pathMatch: '/123' } + * matcher.match('/team-123') // { pathMatch: '-123' } + * matcher.match('/team') // { pathMatch: '' } + * matcher.build({ pathMatch: '/123' }) // '/team/123' + * ``` + */ +export class MatcherPatternPathStar + implements MatcherPatternPath<{ pathMatch: string }> +{ + private path: string + constructor(path: string = '') { + this.path = path.toLowerCase() + } + + match(path: string): { pathMatch: string } { + const pathMatchIndex = path.toLowerCase().indexOf(this.path) + if (pathMatchIndex < 0) { + throw miss() + } + return { + pathMatch: path.slice(pathMatchIndex + this.path.length), + } + } + + build(params: { pathMatch: string }): string { + return this.path + params.pathMatch + } +} + // example of a static matcher built at runtime // new MatcherPatternPathStatic('/') // new MatcherPatternPathStatic('/team') @@ -132,6 +184,10 @@ export type ParamsFromParsers

> = { : never } +/** + * Matcher for dynamic paths, e.g. `/team/:id/:name`. + * Supports one, one or zero, one or more and zero or more params. + */ export class MatcherPatternPathDynamic< TParams extends MatcherParamsFormatted = MatcherParamsFormatted, > implements MatcherPatternPath @@ -183,7 +239,7 @@ export class MatcherPatternPathDynamic< if (__DEV__ && i !== match.length) { console.warn( - `Regexp matched ${match.length} params, but ${i} params are defined` + `Regexp matched ${match.length} params, but ${i} params are defined. Found when matching "${path}" against ${String(this.re)}` ) } return params diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts index af02741e..79d93813 100644 --- a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -57,7 +57,7 @@ function isMatchable(record: RouteRecordRaw): boolean { ) } -function joinPaths(a: string | undefined, b: string) { +export function joinPaths(a: string | undefined, b: string) { if (a?.endsWith('/')) { return a + b } diff --git a/packages/router/src/new-route-resolver/resolver-static.ts b/packages/router/src/new-route-resolver/resolver-static.ts index 1558fa8c..669d5589 100644 --- a/packages/router/src/new-route-resolver/resolver-static.ts +++ b/packages/router/src/new-route-resolver/resolver-static.ts @@ -13,23 +13,102 @@ import { MatcherParamsFormatted, } from './matcher-location' import { - buildMatched, - EXPERIMENTAL_ResolverRecord_Base, RecordName, MatcherQueryParams, NEW_LocationResolved, NEW_RouterResolver_Base, NO_MATCH_LOCATION, } from './resolver' +import type { + MatcherPatternPath, + MatcherPatternQuery, + MatcherPatternHash, +} from './matcher-pattern' -export interface EXPERIMENTAL_ResolverStaticRecord - extends EXPERIMENTAL_ResolverRecord_Base {} +// TODO: find a better name than static that doesn't conflict with static params +// maybe fixed or simple + +export interface EXPERIMENTAL_ResolverRecord_Base { + /** + * Name of the matcher. Unique across all matchers. If missing, this record + * cannot be matched. This is useful for grouping records. + */ + 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?: EXPERIMENTAL_ResolverRecord // the parent can be matchable or not + // TODO: implement aliases + // aliasOf?: this +} + +/** + * A group can contain other useful properties like `meta` defined by the router. + */ +export interface EXPERIMENTAL_ResolverRecord_Group + extends EXPERIMENTAL_ResolverRecord_Base { + name?: undefined + path?: undefined + query?: undefined + hash?: undefined +} + +export interface EXPERIMENTAL_ResolverRecord_Matchable + extends EXPERIMENTAL_ResolverRecord_Base { + name: RecordName + path: MatcherPatternPath +} + +export type EXPERIMENTAL_ResolverRecord = + | EXPERIMENTAL_ResolverRecord_Matchable + | EXPERIMENTAL_ResolverRecord_Group + +export type EXPERIMENTAL_ResolverStaticRecord = EXPERIMENTAL_ResolverRecord export interface EXPERIMENTAL_ResolverStatic extends NEW_RouterResolver_Base {} +/** + * 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 as T + } + return matched +} + +/** + * Creates a simple resolver that must have all records defined at creation + * time. + * + * @template TRecord - extended type of the records + * @param {TRecord[]} records - Ordered array of records that will be used to resolve routes + * @returns a resolver that can be passed to the router + */ export function createStaticResolver< - TRecord extends EXPERIMENTAL_ResolverStaticRecord, + TRecord extends EXPERIMENTAL_ResolverRecord_Matchable, >(records: TRecord[]): EXPERIMENTAL_ResolverStatic { // allows fast access to a matcher by name const recordMap = new Map() @@ -37,7 +116,7 @@ export function createStaticResolver< recordMap.set(record.name, record) } - // NOTE: because of the overloads, we need to manually type the arguments + // NOTE: because of the overloads for `resolve`, we need to manually type the arguments type _resolveArgs = | [absoluteLocation: `/${string}`, currentLocation?: undefined] | [relativeLocation: string, currentLocation: NEW_LocationResolved] diff --git a/packages/router/src/query.ts b/packages/router/src/query.ts index 00cfb9dd..1400d273 100644 --- a/packages/router/src/query.ts +++ b/packages/router/src/query.ts @@ -92,7 +92,6 @@ export function parseQuery(search: string): LocationQuery { export function stringifyQuery(query: LocationQueryRaw | undefined): string { let search = '' for (let key in query) { - // FIXME: we could do search ||= '?' so that the returned value already has the leading ? const value = query[key] key = encodeQueryKey(key) if (value == null) {