]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
refactor: matchers tests
authorEduardo San Martin Morote <posva13@gmail.com>
Mon, 21 Jul 2025 14:17:47 +0000 (16:17 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Mon, 21 Jul 2025 14:17:47 +0000 (16:17 +0200)
packages/router/src/new-route-resolver/matcher-pattern.spec.ts [new file with mode: 0644]
packages/router/src/new-route-resolver/matcher-pattern.ts
packages/router/src/new-route-resolver/matcher-resolve.spec.ts
packages/router/src/new-route-resolver/resolver-static.ts
packages/router/src/query.ts

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 (file)
index 0000000..c3f8f02
--- /dev/null
@@ -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')
+    })
+  })
+})
index e0efb2eea9d7aa719cee072cf1a3af1e8dd8cec1..e6eec914e0241ff5ef776b7b901db1936de4b34f 100644 (file)
@@ -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<string, TParams> {}
 
+/**
+ * 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<EmptyParams>
 {
-  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<P extends Record<string, ParamParser_Generic>> = {
     : 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<TParams>
@@ -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
index af02741eb124b8bc6d1339aa16caf2de6d8c8427..79d93813ea381b2cdfd7e56a8eefd902001c2414 100644 (file)
@@ -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
   }
index 1558fa8c2cd34ee8af7e5e81ffe941fdae161ad6..669d558915ac0da186fc661c64aefa018efe9572 100644 (file)
@@ -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<TRecord>
   extends NEW_RouterResolver_Base<TRecord> {}
 
+/**
+ * Build the `matched` array of a record that includes all parent records from the root to the current one.
+ */
+export function buildMatched<T extends EXPERIMENTAL_ResolverRecord>(
+  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<TRecord> {
   // allows fast access to a matcher by name
   const recordMap = new Map<RecordName, TRecord>()
@@ -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<TRecord>]
index 00cfb9dd68c7ac4fae33d469de72a4708810df5c..1400d2730004e596a95a82cf80807a6a0a7396bd 100644 (file)
@@ -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) {