From 55de6e2f8f126d4b1851388e3fb377a06b14c69b Mon Sep 17 00:00:00 2001 From: jnizet Date: Fri, 18 Feb 2022 18:03:43 +0100 Subject: [PATCH] feat: introduce stubRoute and deprecate fakeRoute and fakeSnapshot --- projects/ngx-speculoos/src/lib/route.spec.ts | 221 ++++++++++++- projects/ngx-speculoos/src/lib/route.ts | 321 ++++++++++++++++++- 2 files changed, 539 insertions(+), 3 deletions(-) diff --git a/projects/ngx-speculoos/src/lib/route.spec.ts b/projects/ngx-speculoos/src/lib/route.spec.ts index 97c6579c..f916b2f0 100644 --- a/projects/ngx-speculoos/src/lib/route.spec.ts +++ b/projects/ngx-speculoos/src/lib/route.spec.ts @@ -1,15 +1,17 @@ -import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; -import { fakeRoute, fakeSnapshot } from './route'; +import { ActivatedRoute, ActivatedRouteSnapshot, Data, ParamMap, Params, UrlSegment } from '@angular/router'; +import { fakeRoute, fakeSnapshot, stubRoute } from './route'; import { of } from 'rxjs'; describe('routes', () => { describe('fakeSnapshot', () => { it('should create fake snapshot', () => { + // eslint-disable-next-line deprecation/deprecation const snapshot: ActivatedRouteSnapshot = fakeSnapshot({}); expect(snapshot).not.toBeNull(); }); it('should convert params to paramMap', () => { + // eslint-disable-next-line deprecation/deprecation const snapshot: ActivatedRouteSnapshot = fakeSnapshot({ params: { foo: 'bar' @@ -23,6 +25,7 @@ describe('routes', () => { }); it('should convert queryParams to queryParamMap', () => { + // eslint-disable-next-line deprecation/deprecation const snapshot: ActivatedRouteSnapshot = fakeSnapshot({ queryParams: { foo: 'bar' @@ -38,11 +41,13 @@ describe('routes', () => { describe('fakeRoute', () => { it('should create fake route', () => { + // eslint-disable-next-line deprecation/deprecation const route: ActivatedRoute = fakeRoute({}); expect(route).not.toBeNull(); }); it('should convert params to paramMap', () => { + // eslint-disable-next-line deprecation/deprecation const route: ActivatedRoute = fakeRoute({ params: of({ foo: 'bar' @@ -58,6 +63,7 @@ describe('routes', () => { }); it('should convert queryParams to queryParamMap', () => { + // eslint-disable-next-line deprecation/deprecation const route: ActivatedRoute = fakeRoute({ queryParams: of({ foo: 'bar' @@ -73,8 +79,11 @@ describe('routes', () => { }); it('should allow accessing parent.snapshot or snapshot.parent when parent has snapshot and route has no snapshot', () => { + // eslint-disable-next-line deprecation/deprecation const route = fakeRoute({ + // eslint-disable-next-line deprecation/deprecation parent: fakeRoute({ + // eslint-disable-next-line deprecation/deprecation snapshot: fakeSnapshot({ data: { baz: 'bing' @@ -88,9 +97,13 @@ describe('routes', () => { }); it('should allow accessing parent.snapshot or snapshot.parent when parent has snapshot and route has snapshot', () => { + // eslint-disable-next-line deprecation/deprecation const route = fakeRoute({ + // eslint-disable-next-line deprecation/deprecation snapshot: fakeSnapshot({}), + // eslint-disable-next-line deprecation/deprecation parent: fakeRoute({ + // eslint-disable-next-line deprecation/deprecation snapshot: fakeSnapshot({ data: { baz: 'bing' @@ -104,8 +117,11 @@ describe('routes', () => { }); it('should allow accessing parent.snapshot or snapshot.parent when snapshot has parent and route has no parent', () => { + // eslint-disable-next-line deprecation/deprecation const route = fakeRoute({ + // eslint-disable-next-line deprecation/deprecation snapshot: fakeSnapshot({ + // eslint-disable-next-line deprecation/deprecation parent: fakeSnapshot({ data: { baz: 'bing' @@ -119,14 +135,18 @@ describe('routes', () => { }); it('should allow accessing parent.snapshot or snapshot.parent when snapshot has parent and route has parent', () => { + // eslint-disable-next-line deprecation/deprecation const route = fakeRoute({ + // eslint-disable-next-line deprecation/deprecation snapshot: fakeSnapshot({ + // eslint-disable-next-line deprecation/deprecation parent: fakeSnapshot({ data: { baz: 'bing' } }) }), + // eslint-disable-next-line deprecation/deprecation parent: fakeRoute({}) }); @@ -134,4 +154,201 @@ describe('routes', () => { expect(route.parent.snapshot.data.baz).toBe('bing'); }); }); + + describe('stubRoute', () => { + it('should fill the snapshot and the route with empty values if no options are provided', () => { + const route = stubRoute(); + + expect(route.snapshot.params).toEqual({}); + expect(route.snapshot.paramMap.keys).toEqual([]); + expect(route.snapshot.queryParams).toEqual({}); + expect(route.snapshot.queryParamMap.keys).toEqual([]); + expect(route.snapshot.data).toEqual({}); + expect(route.snapshot.fragment).toBeNull(); + expect(route.snapshot.url).toEqual([]); + expect(route.snapshot.parent).toBeNull(); + expect(route.snapshot.children).toEqual([]); + expect(route.snapshot.firstChild).toBeNull(); + expect(route.snapshot.pathFromRoot).toEqual([route.snapshot]); + expect(route.snapshot.root).toBe(route.snapshot); + + let params: Params; + route.params.subscribe(p => (params = p)); + expect(params).toEqual({}); + + let paramMap: ParamMap; + route.paramMap.subscribe(p => (paramMap = p)); + expect(paramMap.keys).toEqual([]); + + let queryParams: Params; + route.queryParams.subscribe(p => (queryParams = p)); + expect(queryParams).toEqual({}); + + let queryParamMap: ParamMap; + route.queryParamMap.subscribe(p => (queryParamMap = p)); + expect(queryParamMap.keys).toEqual([]); + + let fragment: string; + route.fragment.subscribe(p => (fragment = p)); + expect(fragment).toBeNull(); + + let data: Data; + route.data.subscribe(p => (data = p)); + expect(data).toEqual({}); + + let url: Array; + route.url.subscribe(p => (url = p)); + expect(url).toEqual([]); + + expect(route.parent).toBeNull(); + expect(route.children).toEqual([]); + expect(route.firstChild).toBeNull(); + expect(route.pathFromRoot).toEqual([route]); + expect(route.root).toBe(route); + }); + + it('should fill the snapshot and the route with values if options are provided', () => { + const providedParams = { foo: 'bar' }; + const providedQueryParams = { baz: 'bing' }; + const providedData = { jing: 'zoom' }; + const providedFragment = 'hello'; + const providedUrl = [new UrlSegment('/path', {})]; + + const parent = stubRoute(); + const firstChild = stubRoute(); + const children = [firstChild]; + + const route = stubRoute({ + params: providedParams, + queryParams: providedQueryParams, + data: providedData, + fragment: providedFragment, + url: providedUrl, + parent, + firstChild, + children + }); + + expect(route.snapshot.params).toBe(providedParams); + expect(route.snapshot.paramMap.get('foo')).toBe('bar'); + expect(route.snapshot.queryParams).toBe(providedQueryParams); + expect(route.snapshot.queryParamMap.get('baz')).toBe('bing'); + expect(route.snapshot.data).toEqual(providedData); + expect(route.snapshot.fragment).toBe(providedFragment); + expect(route.snapshot.url).toBe(providedUrl); + expect(route.snapshot.parent).toBe(parent.snapshot); + expect(route.snapshot.children).toEqual(children.map(c => c.snapshot)); + expect(route.snapshot.firstChild).toBe(firstChild.snapshot); + expect(route.snapshot.pathFromRoot).toEqual([parent.snapshot, route.snapshot]); + expect(route.snapshot.root).toBe(parent.snapshot); + + let params: Params; + route.params.subscribe(p => (params = p)); + expect(params).toBe(providedParams); + + let paramMap: ParamMap; + route.paramMap.subscribe(p => (paramMap = p)); + expect(paramMap.get('foo')).toBe('bar'); + + let queryParams: Params; + route.queryParams.subscribe(p => (queryParams = p)); + expect(queryParams).toBe(providedQueryParams); + + let queryParamMap: ParamMap; + route.queryParamMap.subscribe(p => (queryParamMap = p)); + expect(queryParamMap.get('baz')).toBe('bing'); + + let fragment: string; + route.fragment.subscribe(p => (fragment = p)); + expect(fragment).toBe(providedFragment); + + let data: Data; + route.data.subscribe(p => (data = p)); + expect(data).toBe(providedData); + + let url: Array; + route.url.subscribe(p => (url = p)); + expect(url).toBe(providedUrl); + + expect(route.parent).toBe(parent); + expect(route.children).toBe(children); + expect(route.firstChild).toBe(firstChild); + expect(route.pathFromRoot).toEqual([parent, route]); + expect(route.root).toBe(parent); + }); + + it('should set a param', () => { + const route = stubRoute({ params: { a: 'a1', b: 'b1' } }); + + const expectedParams = { a: 'a2', b: 'b1' }; + + let queryParamEmissionCount = 0; + let snapshotAtEmissionTime: ActivatedRouteSnapshot; + route.queryParams.subscribe(() => queryParamEmissionCount++); + let params: Params; + route.params.subscribe(p => { + params = p; + snapshotAtEmissionTime = route.snapshot; + }); + + route.setParam('a', 'a2'); + + expect(route.snapshot.params).toEqual(expectedParams); + expect(snapshotAtEmissionTime.params).toEqual(expectedParams); + expect(params).toEqual(expectedParams); + expect(queryParamEmissionCount).toBe(1); + }); + + it('should set a query param', () => { + const route = stubRoute({ queryParams: { a: 'a1', b: 'b1' } }); + + const expectedQueryParams = { a: 'a2', b: 'b1' }; + let queryParams: Params; + route.queryParams.subscribe(p => (queryParams = p)); + + route.setQueryParam('a', 'a2'); + + expect(route.snapshot.queryParams).toEqual(expectedQueryParams); + expect(queryParams).toEqual(expectedQueryParams); + }); + + it('should set a datum', () => { + const route = stubRoute({ data: { a: 'a1', b: 'b1' } }); + + const expectedData = { a: 'a2', b: 'b1' }; + let data: Data; + route.data.subscribe(p => (data = p)); + + route.setDataItem('a', 'a2'); + + expect(route.snapshot.data).toEqual(expectedData); + expect(data).toEqual(expectedData); + }); + + it('should set a fragment', () => { + const route = stubRoute(); + + const expectedFragment = 'hello'; + let fragment: string; + route.fragment.subscribe(p => (fragment = p)); + + route.setFragment('hello'); + + expect(route.snapshot.fragment).toEqual(expectedFragment); + expect(fragment).toEqual(expectedFragment); + }); + + it('should set the url', () => { + const route = stubRoute(); + + const expectedUrl = [new UrlSegment('/foo', {})]; + let url: Array; + route.url.subscribe(p => (url = p)); + + route.setUrl(expectedUrl); + + expect(route.snapshot.url).toEqual(expectedUrl); + expect(url).toEqual(expectedUrl); + }); + }); }); diff --git a/projects/ngx-speculoos/src/lib/route.ts b/projects/ngx-speculoos/src/lib/route.ts index 22d71911..bbde1a6d 100644 --- a/projects/ngx-speculoos/src/lib/route.ts +++ b/projects/ngx-speculoos/src/lib/route.ts @@ -1,5 +1,5 @@ import { ActivatedRoute, ActivatedRouteSnapshot, convertToParamMap, Data, Params, Route, UrlSegment } from '@angular/router'; -import { Observable, map } from 'rxjs'; +import { BehaviorSubject, map, Observable } from 'rxjs'; import { Type } from '@angular/core'; /** @@ -17,6 +17,7 @@ import { Type } from '@angular/core'; * `route.parent.snapshot` or `route.snapshot.parent`. * * @returns a partially populated, fake ActivatedRoute, depending on what you passed in + * @deprecated favor stubRoute, which creates an easier to use and more logical stub */ export function fakeRoute(options: { url?: Observable; @@ -68,6 +69,7 @@ export function fakeRoute(options: { for (let route: null | ActivatedRoute = result; route; route = route.parent) { if (route.parent && route.parent.snapshot && !route.snapshot) { + // eslint-disable-next-line deprecation/deprecation route.snapshot = fakeSnapshot({}); } if (route.parent && route.parent.snapshot && !route.snapshot.parent) { @@ -75,6 +77,7 @@ export function fakeRoute(options: { } if (route.snapshot && route.snapshot.parent && !route.parent) { + // eslint-disable-next-line deprecation/deprecation (route as Omit & { parent: ActivatedRoute }).parent = fakeRoute({}); } if (route.snapshot && route.snapshot.parent && route.parent && !route.parent.snapshot) { @@ -92,6 +95,7 @@ export function fakeRoute(options: { * The same goes for queryParams and queryParamMap. * * @returns a partially populated, fake ActivatedRoute, depending on what you passed in + * @deprecated favor stubRoute, which creates an easier to use and more logical stub for both the route and its snapshot */ export function fakeSnapshot(options: { url?: UrlSegment[]; @@ -138,3 +142,318 @@ export function fakeSnapshot(options: { pathFromRoot: options.pathFromRoot } as ActivatedRouteSnapshot; } + +/** + * The options that are passed when creating an ActivatedRouteStub. + */ +export interface ActivatedRouteStubOptions { + /** + * The initial values of the parameters of the route + */ + params?: Params; + /** + * The initial values of the query parameters of the route + */ + queryParams?: Params; + /** + * The initial values of the data of the route + */ + data?: Data; + /** + * The initial fragment of the route + */ + fragment?: string | null; + /** + * The initial url of the route + */ + url?: UrlSegment[]; + /** + * The parent of the route + */ + parent?: ActivatedRouteStub | null; + /** + * The first child of the route + */ + firstChild?: ActivatedRouteStub | null; + /** + * The children of the route + */ + children?: ActivatedRouteStub[] | null; +} + +class ActivatedRouteSnapshotStub extends ActivatedRouteSnapshot { + private _parent: ActivatedRouteSnapshot | null = null; + private _root: ActivatedRouteSnapshot; + private _firstChild: ActivatedRouteSnapshot | null = null; + private _children: Array = []; + private _pathFromRoot: Array = []; + + get parent(): ActivatedRouteSnapshot | null { + return this._parent; + } + + set parent(value: ActivatedRouteSnapshot | null) { + this._parent = value; + } + + get root(): ActivatedRouteSnapshot { + return this._root; + } + + set root(value: ActivatedRouteSnapshot) { + this._root = value; + } + + get firstChild(): ActivatedRouteSnapshot | null { + return this._firstChild; + } + + set firstChild(value: ActivatedRouteSnapshot | null) { + this._firstChild = value; + } + + get children(): Array { + return this._children; + } + + set children(value: Array) { + this._children = value; + } + + get pathFromRoot(): Array { + return this._pathFromRoot; + } + + set pathFromRoot(value: Array) { + this._pathFromRoot = value; + } + + constructor() { + super(); + this._root = this; + } +} + +/** + * A stub for ActivatedRoute. It behaves almost the same way as the actual ActivatedRoute, exposing a snapshot + * and observables for the params, query params etc., which are kept in sync. + * + * In addition, this stub allows simulating a navigation by changing the params, the query params, the fragment, etc. + * When that happens, the snapshot is modified, then the relevant observables emit the new values. + * + * There are some things that don't really work the same way as the real ActivatedRoute though: + * - the handling of the firstChild and of the children is entirely under the tester's responsibility. Setting the parent + * of a route stub does not add this route to the children of its parent, for example. + * - when changing the params, query params, fragment, etc., their associated observable emits unconditionally, instead of + * first checking if the value is actually different from before. It's thus the responsibility of the tester to not + * change the values if they're the same as before. + */ +export class ActivatedRouteStub extends ActivatedRoute { + private _firstChild: ActivatedRouteStub | null; + private _children: Array; + + private readonly paramsSubject: BehaviorSubject; + private readonly queryParamsSubject: BehaviorSubject; + private readonly dataSubject: BehaviorSubject; + private readonly fragmentSubject: BehaviorSubject; + private readonly urlSubject: BehaviorSubject>; + + private _parent: ActivatedRouteStub | null; + private _root: ActivatedRouteStub; + private _pathFromRoot: Array; + + /** + * Constructs a new instance, based on the given options. + * If an option is not provided (or if no option is provided at all), then the route has a default value for this option + * (empty parameters for example, null fragment, etc.) + * If no parent is passed, then this route has no parent and is thus set as the root. Otherwise, the root and the path + * from root are created based on the root and path from root of the given parent route. + */ + constructor(options?: ActivatedRouteStubOptions) { + super(); + + const snapshot = new ActivatedRouteSnapshotStub(); + this.snapshot = snapshot; + + this._firstChild = options?.firstChild ?? null; + this._children = options?.children ?? []; + this._parent = options?.parent ?? null; + this._root = this.parent?.root ?? this; + this._pathFromRoot = this.parent ? [...this.parent.pathFromRoot, this] : [this]; + + snapshot.params = options?.params ?? {}; + snapshot.queryParams = options?.queryParams ?? {}; + snapshot.data = options?.data ?? {}; + snapshot.fragment = options?.fragment ?? null; + snapshot.url = options?.url ?? []; + + snapshot.firstChild = this.firstChild?.snapshot ?? null; + snapshot.children = this.children?.map(route => route.snapshot) ?? []; + snapshot.parent = this.parent?.snapshot ?? null; + snapshot.root = this.root.snapshot; + snapshot.pathFromRoot = this.pathFromRoot.map(route => route.snapshot); + + this.paramsSubject = new BehaviorSubject(this.snapshot.params); + this.queryParamsSubject = new BehaviorSubject(this.snapshot.queryParams); + this.dataSubject = new BehaviorSubject(this.snapshot.data); + this.fragmentSubject = new BehaviorSubject(this.snapshot.fragment); + this.urlSubject = new BehaviorSubject>(this.snapshot.url); + + this.params = this.paramsSubject.asObservable(); + this.queryParams = this.queryParamsSubject.asObservable(); + this.data = this.dataSubject.asObservable(); + this.fragment = this.fragmentSubject.asObservable(); + this.url = this.urlSubject.asObservable(); + } + + get root() { + return this._root; + } + + get parent(): ActivatedRouteStub | null { + return this._parent; + } + + get pathFromRoot(): Array { + return this._pathFromRoot; + } + + get firstChild(): ActivatedRouteStub | null { + return this._firstChild; + } + + get children(): Array { + return this._children; + } + + /** + * Triggers a navigation with the given new parameters. All the other parts (query params etc.) stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change the parameters. + */ + public setParams(params: Params): void { + this.triggerNavigation({ params }); + } + + /** + * Triggers a navigation with the given new parameter. The other parameters, as well as all the other parts (query params etc.) + * stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change one parameter. + */ + public setParam(name: string, value: string): void { + this.setParams({ ...this.snapshot.params, [name]: value }); + } + + /** + * Triggers a navigation with the given new query parameters. All the other parts (params etc.) stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change the query parameters. + */ + public setQueryParams(queryParams: Params): void { + this.triggerNavigation({ queryParams }); + } + + /** + * Triggers a navigation with the given new parameter. The other query parameters, as well as all the other parts (params etc.) + * stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change one query parameter. + */ + public setQueryParam(name: string, value: string): void { + this.setQueryParams({ ...this.snapshot.queryParams, [name]: value }); + } + + /** + * Triggers a navigation with the given new data. The other parameters, as well as all the other parts (params etc.) + * stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change the data. + */ + public setData(data: Data): void { + this.triggerNavigation({ data }); + } + + /** + * Triggers a navigation with the given new data item. The other data, as well as all the other parts (params etc.) + * stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change one data item. + */ + public setDataItem(name: string, value: unknown): void { + this.setData({ ...this.snapshot.data, [name]: value }); + } + + /** + * Triggers a navigation with the given new fragment. The other parts (params etc.) stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change the fragment. + */ + public setFragment(fragment: string | null): void { + this.triggerNavigation({ fragment }); + } + + /** + * Triggers a navigation with the given new url. The other parts (params etc.) stay as the are. + * This is a shortcut to `triggerNavigation` that can be used to only change the url. + */ + public setUrl(url: Array): void { + this.triggerNavigation({ url }); + } + + /** + * Triggers a navigation based on the given options. If an option is undefined or null, it's ignored. Except for fragment, which is only + * ignored if it's undefined, because null is a valid value for a fragment. + * + * The non-ignored values are used to change the snapshot of the route. Once the snapshot has been modified, + * the observables corresponding to the updated parts emit the new value. + * + * So, setting params and query params will make the params and queryParams observables emit, but not the fragment, data and + * url observables for example. This is consistent to how the router behaves. + */ + public triggerNavigation(options: { + params?: Params; + queryParams?: Params; + fragment?: string | null; + data?: Data | null; + url?: Array | null; + }): void { + // set the snapshot first + if (options.params) { + this.snapshot.params = options.params; + } + if (options.queryParams) { + this.snapshot.queryParams = options.queryParams; + } + if (options.fragment !== undefined) { + this.snapshot.fragment = options.fragment; + } + if (options.data) { + this.snapshot.data = options.data; + } + if (options.url) { + this.snapshot.url = options.url; + } + + // then emit everything that has changed + if (options.params) { + this.paramsSubject.next(this.snapshot.params); + } + if (options.queryParams) { + this.queryParamsSubject.next(this.snapshot.queryParams); + } + if (options.fragment !== undefined) { + this.fragmentSubject.next(this.snapshot.fragment); + } + if (options.data) { + this.dataSubject.next(this.snapshot.data); + } + if (options.url) { + this.urlSubject.next(this.snapshot.url); + } + } + + public toString(): string { + return 'ActivatedRouteStub'; + } +} + +/** + * Creates a new ActivatedRouteStub, by calling its constructor. + */ +export function stubRoute(options?: ActivatedRouteStubOptions): ActivatedRouteStub { + return new ActivatedRouteStub(options); +}