From b17c713ab7fa3bb608fb1429f8a2e80e5f70d10f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 20:27:46 +0000 Subject: [PATCH 1/5] Initial plan From acb1d29348f5231bb56f5a4514bccdc20f98b21c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 20:39:03 +0000 Subject: [PATCH 2/5] Implement TestingRouterStore and provideTestingRouterStore Co-authored-by: LayZeeDK <6364586+LayZeeDK@users.noreply.github.com> --- packages/router-component-store/src/index.ts | 2 + .../src/lib/testing/index.ts | 2 + .../provide-testing-router-store.spec.ts | 32 ++ .../testing/provide-testing-router-store.ts | 98 +++++ .../lib/testing/testing-router-store.spec.ts | 413 ++++++++++++++++++ .../src/lib/testing/testing-router-store.ts | 215 +++++++++ 6 files changed, 762 insertions(+) create mode 100644 packages/router-component-store/src/lib/testing/index.ts create mode 100644 packages/router-component-store/src/lib/testing/provide-testing-router-store.spec.ts create mode 100644 packages/router-component-store/src/lib/testing/provide-testing-router-store.ts create mode 100644 packages/router-component-store/src/lib/testing/testing-router-store.spec.ts create mode 100644 packages/router-component-store/src/lib/testing/testing-router-store.ts diff --git a/packages/router-component-store/src/index.ts b/packages/router-component-store/src/index.ts index f4f202a9..139780b1 100644 --- a/packages/router-component-store/src/index.ts +++ b/packages/router-component-store/src/index.ts @@ -9,3 +9,5 @@ export * from './lib/router-store'; export * from './lib/strict-query-params'; export * from './lib/strict-route-data'; export * from './lib/strict-route-params'; +// Testing utilities +export * from './lib/testing'; diff --git a/packages/router-component-store/src/lib/testing/index.ts b/packages/router-component-store/src/lib/testing/index.ts new file mode 100644 index 00000000..65ef0f49 --- /dev/null +++ b/packages/router-component-store/src/lib/testing/index.ts @@ -0,0 +1,2 @@ +export * from './provide-testing-router-store'; +export * from './testing-router-store'; \ No newline at end of file diff --git a/packages/router-component-store/src/lib/testing/provide-testing-router-store.spec.ts b/packages/router-component-store/src/lib/testing/provide-testing-router-store.spec.ts new file mode 100644 index 00000000..86eed18f --- /dev/null +++ b/packages/router-component-store/src/lib/testing/provide-testing-router-store.spec.ts @@ -0,0 +1,32 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterStore } from '../router-store'; +import { provideTestingRouterStore } from './provide-testing-router-store'; +import { TestingRouterStore } from './testing-router-store'; + +describe('provideTestingRouterStore', () => { + it('should provide TestingRouterStore for RouterStore token', () => { + TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], + }); + + const routerStore = TestBed.inject(RouterStore); + + expect(routerStore).toBeInstanceOf(TestingRouterStore); + }); + + it('should allow casting to TestingRouterStore for access to testing methods', () => { + TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], + }); + + const routerStore = TestBed.inject(RouterStore); + const testingRouterStore = routerStore as TestingRouterStore; + + // Should have access to testing methods + expect(typeof testingRouterStore.setUrl).toBe('function'); + expect(typeof testingRouterStore.setRouteParam).toBe('function'); + expect(typeof testingRouterStore.setQueryParam).toBe('function'); + expect(typeof testingRouterStore.setRouteDataParam).toBe('function'); + expect(typeof testingRouterStore.reset).toBe('function'); + }); +}); \ No newline at end of file diff --git a/packages/router-component-store/src/lib/testing/provide-testing-router-store.ts b/packages/router-component-store/src/lib/testing/provide-testing-router-store.ts new file mode 100644 index 00000000..23685899 --- /dev/null +++ b/packages/router-component-store/src/lib/testing/provide-testing-router-store.ts @@ -0,0 +1,98 @@ +import { ClassProvider, Provider } from '@angular/core'; +import { RouterStore } from '../router-store'; +import { TestingRouterStore } from './testing-router-store'; + +/** + * Provide a testing version of `RouterStore` that uses stubbed observables + * for easy test setup and improved developer experience. + * + * This provider replaces the `RouterStore` dependency injection token with + * `TestingRouterStore`, allowing you to easily control router state in your tests. + * + * @returns The providers required for a testing router store. + * + * @example + * ```typescript + * // Basic usage in TestBed + * TestBed.configureTestingModule({ + * providers: [provideTestingRouterStore()], + * }); + * + * const routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + * routerStore.setUrl('/test/123'); + * routerStore.setRouteParam('id', '123'); + * ``` + * + * @example + * ```typescript + * // Usage with component testing + * @Component({ + * template: '
{{ routeId$ | async }}
' + * }) + * class TestComponent { + * private routerStore = inject(RouterStore); + * routeId$ = this.routerStore.selectRouteParam('id'); + * } + * + * describe('TestComponent', () => { + * let component: TestComponent; + * let routerStore: TestingRouterStore; + * + * beforeEach(async () => { + * await TestBed.configureTestingModule({ + * imports: [TestComponent], + * providers: [provideTestingRouterStore()], + * }).compileComponents(); + * + * routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + * }); + * + * it('should display route parameter', () => { + * routerStore.setRouteParam('id', '123'); + * fixture.detectChanges(); + * + * expect(fixture.nativeElement.textContent).toBe('123'); + * }); + * }); + * ``` + * + * @example + * ```typescript + * // Usage with service testing + * class HeroService { + * private routerStore = inject(RouterStore); + * heroId$ = this.routerStore.selectRouteParam('id'); + * } + * + * describe('HeroService', () => { + * let service: HeroService; + * let routerStore: TestingRouterStore; + * + * beforeEach(() => { + * TestBed.configureTestingModule({ + * providers: [HeroService, provideTestingRouterStore()], + * }); + * + * service = TestBed.inject(HeroService); + * routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + * }); + * + * it('should emit hero ID when route param changes', (done) => { + * service.heroId$.subscribe(id => { + * expect(id).toBe('456'); + * done(); + * }); + * + * routerStore.setRouteParam('id', '456'); + * }); + * }); + * ``` + */ +export function provideTestingRouterStore(): Provider[] { + const testingRouterStoreProvider: ClassProvider = { + provide: RouterStore, + useClass: TestingRouterStore, + }; + + return [testingRouterStoreProvider]; +} \ No newline at end of file diff --git a/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts b/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts new file mode 100644 index 00000000..451a2fb6 --- /dev/null +++ b/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts @@ -0,0 +1,413 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { UrlSegment } from '@angular/router'; +import { firstValueFrom, take } from 'rxjs'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { RouterStore } from '../router-store'; +import { provideTestingRouterStore } from './provide-testing-router-store'; +import { TestingRouterStore } from './testing-router-store'; + +@Component({ + standalone: true, + imports: [AsyncPipe], + template: ` +
{{ url$ | async }}
+
{{ fragment$ | async }}
+
{{ title$ | async }}
+
{{ routeParam$ | async }}
+
{{ queryParam$ | async }}
+
{{ routeData$ | async }}
+ `, +}) +class TestComponent { + private routerStore = inject(RouterStore); + url$ = this.routerStore.url$; + fragment$ = this.routerStore.fragment$; + title$ = this.routerStore.title$; + routeParam$ = this.routerStore.selectRouteParam('id'); + queryParam$ = this.routerStore.selectQueryParam('search'); + routeData$ = this.routerStore.selectRouteDataParam('limit'); +} + +describe('TestingRouterStore', () => { + let testingRouterStore: TestingRouterStore; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], + }); + + testingRouterStore = TestBed.inject(RouterStore) as TestingRouterStore; + }); + + it('should be provided by provideTestingRouterStore', () => { + expect(testingRouterStore).toBeInstanceOf(TestingRouterStore); + }); + + describe('default values', () => { + it('should have default currentRoute$', async () => { + const currentRoute = await firstValueFrom(testingRouterStore.currentRoute$); + + expect(currentRoute).toEqual({ + routeConfig: null, + url: [], + params: {}, + queryParams: {}, + fragment: null, + data: {}, + outlet: 'primary', + title: undefined, + firstChild: undefined, + children: [], + }); + }); + + it('should have default fragment$ as null', async () => { + const fragment = await firstValueFrom(testingRouterStore.fragment$); + expect(fragment).toBeNull(); + }); + + it('should have default queryParams$ as empty object', async () => { + const queryParams = await firstValueFrom(testingRouterStore.queryParams$); + expect(queryParams).toEqual({}); + }); + + it('should have default routeData$ as empty object', async () => { + const routeData = await firstValueFrom(testingRouterStore.routeData$); + expect(routeData).toEqual({}); + }); + + it('should have default routeParams$ as empty object', async () => { + const routeParams = await firstValueFrom(testingRouterStore.routeParams$); + expect(routeParams).toEqual({}); + }); + + it('should have default title$ as undefined', async () => { + const title = await firstValueFrom(testingRouterStore.title$); + expect(title).toBeUndefined(); + }); + + it('should have default url$ as "/"', async () => { + const url = await firstValueFrom(testingRouterStore.url$); + expect(url).toBe('/'); + }); + }); + + describe('setUrl', () => { + it('should update url$', async () => { + testingRouterStore.setUrl('/heroes/123'); + const url = await firstValueFrom(testingRouterStore.url$); + expect(url).toBe('/heroes/123'); + }); + }); + + describe('setFragment', () => { + it('should update fragment$', async () => { + testingRouterStore.setFragment('section1'); + const fragment = await firstValueFrom(testingRouterStore.fragment$); + expect(fragment).toBe('section1'); + }); + + it('should allow setting fragment to null', async () => { + testingRouterStore.setFragment('section1'); + testingRouterStore.setFragment(null); + const fragment = await firstValueFrom(testingRouterStore.fragment$); + expect(fragment).toBeNull(); + }); + }); + + describe('setTitle', () => { + it('should update title$', async () => { + testingRouterStore.setTitle('Hero Details'); + const title = await firstValueFrom(testingRouterStore.title$); + expect(title).toBe('Hero Details'); + }); + + it('should allow setting title to undefined', async () => { + testingRouterStore.setTitle('Hero Details'); + testingRouterStore.setTitle(undefined); + const title = await firstValueFrom(testingRouterStore.title$); + expect(title).toBeUndefined(); + }); + }); + + describe('route parameters', () => { + describe('setRouteParams', () => { + it('should update routeParams$', async () => { + testingRouterStore.setRouteParams({ id: '123', type: 'hero' }); + const routeParams = await firstValueFrom(testingRouterStore.routeParams$); + expect(routeParams).toEqual({ id: '123', type: 'hero' }); + }); + }); + + describe('setRouteParam', () => { + it('should update individual route parameter', async () => { + testingRouterStore.setRouteParam('id', '456'); + const routeParams = await firstValueFrom(testingRouterStore.routeParams$); + expect(routeParams).toEqual({ id: '456' }); + }); + + it('should preserve other route parameters when setting individual parameter', async () => { + testingRouterStore.setRouteParams({ id: '123', type: 'hero' }); + testingRouterStore.setRouteParam('id', '456'); + const routeParams = await firstValueFrom(testingRouterStore.routeParams$); + expect(routeParams).toEqual({ id: '456', type: 'hero' }); + }); + }); + + describe('selectRouteParam', () => { + it('should return undefined for non-existent parameter', async () => { + const paramValue = await firstValueFrom(testingRouterStore.selectRouteParam('nonexistent')); + expect(paramValue).toBeUndefined(); + }); + + it('should return parameter value when it exists', async () => { + testingRouterStore.setRouteParam('id', '789'); + const paramValue = await firstValueFrom(testingRouterStore.selectRouteParam('id')); + expect(paramValue).toBe('789'); + }); + + it('should emit new values when parameter changes', (done) => { + const values: (string | undefined)[] = []; + + testingRouterStore.selectRouteParam('id').pipe(take(3)).subscribe({ + next: (value) => values.push(value), + complete: () => { + expect(values).toEqual([undefined, '123', '456']); + done(); + }, + }); + + testingRouterStore.setRouteParam('id', '123'); + testingRouterStore.setRouteParam('id', '456'); + }); + }); + }); + + describe('query parameters', () => { + describe('setQueryParams', () => { + it('should update queryParams$', async () => { + testingRouterStore.setQueryParams({ search: 'hero', page: '1' }); + const queryParams = await firstValueFrom(testingRouterStore.queryParams$); + expect(queryParams).toEqual({ search: 'hero', page: '1' }); + }); + }); + + describe('setQueryParam', () => { + it('should update individual query parameter', async () => { + testingRouterStore.setQueryParam('search', 'villain'); + const queryParams = await firstValueFrom(testingRouterStore.queryParams$); + expect(queryParams).toEqual({ search: 'villain' }); + }); + + it('should preserve other query parameters when setting individual parameter', async () => { + testingRouterStore.setQueryParams({ search: 'hero', page: '1' }); + testingRouterStore.setQueryParam('search', 'villain'); + const queryParams = await firstValueFrom(testingRouterStore.queryParams$); + expect(queryParams).toEqual({ search: 'villain', page: '1' }); + }); + + it('should support array values', async () => { + testingRouterStore.setQueryParam('tags', ['action', 'adventure']); + const queryParams = await firstValueFrom(testingRouterStore.queryParams$); + expect(queryParams).toEqual({ tags: ['action', 'adventure'] }); + }); + }); + + describe('selectQueryParam', () => { + it('should return undefined for non-existent parameter', async () => { + const paramValue = await firstValueFrom(testingRouterStore.selectQueryParam('nonexistent')); + expect(paramValue).toBeUndefined(); + }); + + it('should return parameter value when it exists', async () => { + testingRouterStore.setQueryParam('search', 'test'); + const paramValue = await firstValueFrom(testingRouterStore.selectQueryParam('search')); + expect(paramValue).toBe('test'); + }); + + it('should emit new values when parameter changes', (done) => { + const values: (string | readonly string[] | undefined)[] = []; + + testingRouterStore.selectQueryParam('search').pipe(take(3)).subscribe({ + next: (value) => values.push(value), + complete: () => { + expect(values).toEqual([undefined, 'hero', 'villain']); + done(); + }, + }); + + testingRouterStore.setQueryParam('search', 'hero'); + testingRouterStore.setQueryParam('search', 'villain'); + }); + }); + }); + + describe('route data', () => { + describe('setRouteData', () => { + it('should update routeData$', async () => { + testingRouterStore.setRouteData({ limit: 10, sort: 'name' }); + const routeData = await firstValueFrom(testingRouterStore.routeData$); + expect(routeData).toEqual({ limit: 10, sort: 'name' }); + }); + }); + + describe('setRouteDataParam', () => { + it('should update individual route data parameter', async () => { + testingRouterStore.setRouteDataParam('limit', 20); + const routeData = await firstValueFrom(testingRouterStore.routeData$); + expect(routeData).toEqual({ limit: 20 }); + }); + + it('should preserve other route data when setting individual parameter', async () => { + testingRouterStore.setRouteData({ limit: 10, sort: 'name' }); + testingRouterStore.setRouteDataParam('limit', 20); + const routeData = await firstValueFrom(testingRouterStore.routeData$); + expect(routeData).toEqual({ limit: 20, sort: 'name' }); + }); + }); + + describe('selectRouteDataParam', () => { + it('should return undefined for non-existent parameter', async () => { + const paramValue = await firstValueFrom(testingRouterStore.selectRouteDataParam('nonexistent')); + expect(paramValue).toBeUndefined(); + }); + + it('should return parameter value when it exists', async () => { + testingRouterStore.setRouteDataParam('limit', 30); + const paramValue = await firstValueFrom(testingRouterStore.selectRouteDataParam('limit')); + expect(paramValue).toBe(30); + }); + + it('should emit new values when parameter changes', (done) => { + const values: unknown[] = []; + + testingRouterStore.selectRouteDataParam('limit').pipe(take(3)).subscribe({ + next: (value) => values.push(value), + complete: () => { + expect(values).toEqual([undefined, 10, 20]); + done(); + }, + }); + + testingRouterStore.setRouteDataParam('limit', 10); + testingRouterStore.setRouteDataParam('limit', 20); + }); + }); + + describe('selectRouteData (deprecated)', () => { + it('should work the same as selectRouteDataParam', async () => { + testingRouterStore.setRouteDataParam('test', 'value'); + const value1 = await firstValueFrom(testingRouterStore.selectRouteData('test')); + const value2 = await firstValueFrom(testingRouterStore.selectRouteDataParam('test')); + expect(value1).toBe(value2); + }); + }); + }); + + describe('setCurrentRoute', () => { + it('should update currentRoute$', async () => { + const customRoute: MinimalActivatedRouteSnapshot = { + routeConfig: { path: 'test/:id' }, + url: [new UrlSegment('test', {}), new UrlSegment('123', {})], + params: { id: '123' }, + queryParams: { filter: 'active' }, + fragment: 'top', + data: { title: 'Test Page' }, + outlet: 'primary', + title: 'Test Page', + firstChild: undefined, + children: [], + }; + + testingRouterStore.setCurrentRoute(customRoute); + const currentRoute = await firstValueFrom(testingRouterStore.currentRoute$); + expect(currentRoute).toEqual(customRoute); + }); + }); + + describe('selectRouterEvents', () => { + it('should return NEVER observable', (done) => { + const timeout = setTimeout(() => { + // If we reach here, the observable didn't emit, which is what we expect + done(); + }, 100); + + testingRouterStore.selectRouterEvents().subscribe({ + next: () => { + clearTimeout(timeout); + fail('selectRouterEvents should not emit any values'); + }, + }); + }); + }); + + describe('reset', () => { + it('should reset all values to defaults', async () => { + // Set some test values + testingRouterStore.setUrl('/test'); + testingRouterStore.setFragment('section'); + testingRouterStore.setTitle('Test Title'); + testingRouterStore.setRouteParam('id', '123'); + testingRouterStore.setQueryParam('search', 'test'); + testingRouterStore.setRouteDataParam('limit', 10); + + // Reset + testingRouterStore.reset(); + + // Verify all values are back to defaults + expect(await firstValueFrom(testingRouterStore.url$)).toBe('/'); + expect(await firstValueFrom(testingRouterStore.fragment$)).toBeNull(); + expect(await firstValueFrom(testingRouterStore.title$)).toBeUndefined(); + expect(await firstValueFrom(testingRouterStore.routeParams$)).toEqual({}); + expect(await firstValueFrom(testingRouterStore.queryParams$)).toEqual({}); + expect(await firstValueFrom(testingRouterStore.routeData$)).toEqual({}); + + const defaultRoute = await firstValueFrom(testingRouterStore.currentRoute$); + expect(defaultRoute).toEqual({ + routeConfig: null, + url: [], + params: {}, + queryParams: {}, + fragment: null, + data: {}, + outlet: 'primary', + title: undefined, + firstChild: undefined, + children: [], + }); + }); + }); +}); + +describe('TestingRouterStore integration', () => { + it('should work with components', async () => { + TestBed.configureTestingModule({ + imports: [TestComponent], + providers: [provideTestingRouterStore()], + }); + + const fixture = TestBed.createComponent(TestComponent); + const testingRouterStore = TestBed.inject(RouterStore) as TestingRouterStore; + + // Set test values + testingRouterStore.setUrl('/heroes/123'); + testingRouterStore.setFragment('details'); + testingRouterStore.setTitle('Hero Details'); + testingRouterStore.setRouteParam('id', '123'); + testingRouterStore.setQueryParam('search', 'batman'); + testingRouterStore.setRouteDataParam('limit', 50); + + fixture.detectChanges(); + await fixture.whenStable(); + + const compiled = fixture.nativeElement; + expect(compiled.querySelector('#url')?.textContent?.trim()).toBe('/heroes/123'); + expect(compiled.querySelector('#fragment')?.textContent?.trim()).toBe('details'); + expect(compiled.querySelector('#title')?.textContent?.trim()).toBe('Hero Details'); + expect(compiled.querySelector('#route-param')?.textContent?.trim()).toBe('123'); + expect(compiled.querySelector('#query-param')?.textContent?.trim()).toBe('batman'); + expect(compiled.querySelector('#route-data')?.textContent?.trim()).toBe('50'); + }); +}); \ No newline at end of file diff --git a/packages/router-component-store/src/lib/testing/testing-router-store.ts b/packages/router-component-store/src/lib/testing/testing-router-store.ts new file mode 100644 index 00000000..0861a428 --- /dev/null +++ b/packages/router-component-store/src/lib/testing/testing-router-store.ts @@ -0,0 +1,215 @@ +import { Injectable, Type } from '@angular/core'; +import { Event as RouterEvent } from '@angular/router'; +import { BehaviorSubject, NEVER, Observable } from 'rxjs'; +import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; +import { RouterStore } from '../router-store'; +import { StrictQueryParams } from '../strict-query-params'; +import { StrictRouteData } from '../strict-route-data'; +import { StrictRouteParams } from '../strict-route-params'; + +/** + * A testing implementation of `RouterStore` that provides stubbed observables + * for easy test setup and improved developer experience. + * + * Use `provideTestingRouterStore()` to provide this service in your tests. + * + * @example + * ```typescript + * // In your test setup + * TestBed.configureTestingModule({ + * providers: [provideTestingRouterStore()], + * }); + * + * const routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + * + * // Set test values + * routerStore.setUrl('/test/123'); + * routerStore.setRouteParam('id', '123'); + * ``` + */ +@Injectable() +export class TestingRouterStore implements RouterStore { + private readonly _currentRoute$ = new BehaviorSubject( + this.createDefaultRoute() + ); + private readonly _fragment$ = new BehaviorSubject(null); + private readonly _queryParams$ = new BehaviorSubject({}); + private readonly _routeData$ = new BehaviorSubject({}); + private readonly _routeParams$ = new BehaviorSubject({}); + private readonly _title$ = new BehaviorSubject(undefined); + private readonly _url$ = new BehaviorSubject('/'); + + readonly currentRoute$: Observable = this._currentRoute$.asObservable(); + readonly fragment$: Observable = this._fragment$.asObservable(); + readonly queryParams$: Observable = this._queryParams$.asObservable(); + readonly routeData$: Observable = this._routeData$.asObservable(); + readonly routeParams$: Observable = this._routeParams$.asObservable(); + readonly title$: Observable = this._title$.asObservable(); + readonly url$: Observable = this._url$.asObservable(); + + selectRouteData(key: string): Observable { + return this.selectRouteDataParam(key); + } + + selectRouteDataParam(key: string): Observable { + return new Observable(subscriber => { + const subscription = this._routeData$.subscribe(data => { + subscriber.next(data[key]); + }); + return () => subscription.unsubscribe(); + }); + } + + selectQueryParam(param: string): Observable { + return new Observable(subscriber => { + const subscription = this._queryParams$.subscribe(params => { + subscriber.next(params[param]); + }); + return () => subscription.unsubscribe(); + }); + } + + selectRouteParam(param: string): Observable { + return new Observable(subscriber => { + const subscription = this._routeParams$.subscribe(params => { + subscriber.next(params[param]); + }); + return () => subscription.unsubscribe(); + }); + } + + selectRouterEvents[]>( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ...acceptedEventTypes: [...TAcceptedRouterEvents] + ): Observable> { + // Router events are not typically stubbed in unit tests + // Return NEVER observable to avoid unexpected emissions during tests + return NEVER; + } + + // Testing utility methods + + /** + * Set the current route snapshot. + * + * @param route The route snapshot to set + */ + setCurrentRoute(route: MinimalActivatedRouteSnapshot): void { + this._currentRoute$.next(route); + } + + /** + * Set the current URL fragment. + * + * @param fragment The fragment to set + */ + setFragment(fragment: string | null): void { + this._fragment$.next(fragment); + } + + /** + * Set the current query parameters. + * + * @param queryParams The query parameters to set + */ + setQueryParams(queryParams: StrictQueryParams): void { + this._queryParams$.next(queryParams); + } + + /** + * Set a specific query parameter. + * + * @param param The parameter name + * @param value The parameter value + */ + setQueryParam(param: string, value: string | readonly string[] | undefined): void { + const currentParams = this._queryParams$.value; + this._queryParams$.next({ ...currentParams, [param]: value }); + } + + /** + * Set the current route data. + * + * @param routeData The route data to set + */ + setRouteData(routeData: StrictRouteData): void { + this._routeData$.next(routeData); + } + + /** + * Set a specific route data parameter. + * + * @param key The data key + * @param value The data value + */ + setRouteDataParam(key: string, value: unknown): void { + const currentData = this._routeData$.value; + this._routeData$.next({ ...currentData, [key]: value }); + } + + /** + * Set the current route parameters. + * + * @param routeParams The route parameters to set + */ + setRouteParams(routeParams: StrictRouteParams): void { + this._routeParams$.next(routeParams); + } + + /** + * Set a specific route parameter. + * + * @param param The parameter name + * @param value The parameter value + */ + setRouteParam(param: string, value: string | undefined): void { + const currentParams = this._routeParams$.value; + this._routeParams$.next({ ...currentParams, [param]: value }); + } + + /** + * Set the resolved route title. + * + * @param title The title to set + */ + setTitle(title: string | undefined): void { + this._title$.next(title); + } + + /** + * Set the current URL. + * + * @param url The URL to set + */ + setUrl(url: string): void { + this._url$.next(url); + } + + /** + * Reset all values to their defaults. + */ + reset(): void { + this._currentRoute$.next(this.createDefaultRoute()); + this._fragment$.next(null); + this._queryParams$.next({}); + this._routeData$.next({}); + this._routeParams$.next({}); + this._title$.next(undefined); + this._url$.next('/'); + } + + private createDefaultRoute(): MinimalActivatedRouteSnapshot { + return { + routeConfig: null, + url: [], + params: {}, + queryParams: {}, + fragment: null, + data: {}, + outlet: 'primary', + title: undefined, + firstChild: undefined, + children: [], + }; + } +} \ No newline at end of file From f05507fd95c78e1cdc0e882db9df3f858e189354 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 20:45:06 +0000 Subject: [PATCH 3/5] Add comprehensive documentation and demo tests for TestingRouterStore Co-authored-by: LayZeeDK <6364586+LayZeeDK@users.noreply.github.com> --- packages/router-component-store/README.md | 147 +++++++++++++ .../testing/testing-router-store-demo.spec.ts | 198 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts diff --git a/packages/router-component-store/README.md b/packages/router-component-store/README.md index 748540ed..01b3c09e 100644 --- a/packages/router-component-store/README.md +++ b/packages/router-component-store/README.md @@ -208,3 +208,150 @@ export type StrictRouteParams = { readonly [key: string]: string | undefined; }; ``` + +## Testing + +Router Component Store provides testing utilities to make it easy to test components and services that depend on `RouterStore`. + +### TestingRouterStore + +`TestingRouterStore` is a testing implementation of the `RouterStore` interface that uses stubbed observables. This allows you to easily control router state in your tests without needing to set up complex routing configurations. + +#### Basic usage + +```typescript +import { TestBed } from '@angular/core/testing'; +import { provideTestingRouterStore, TestingRouterStore } from '@ngworker/router-component-store'; + +describe('HeroDetailComponent', () => { + let routerStore: TestingRouterStore; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HeroDetailComponent], + providers: [provideTestingRouterStore()], + }); + + routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + }); + + it('should display hero ID from route param', () => { + const fixture = TestBed.createComponent(HeroDetailComponent); + + // Set route parameter + routerStore.setRouteParam('id', '123'); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Hero: 123'); + }); +}); +``` + +#### Testing different router states + +```typescript +it('should handle various router states', () => { + // Set URL + routerStore.setUrl('/heroes/456?search=batman#details'); + + // Set individual parameters + routerStore.setRouteParam('id', '456'); + routerStore.setQueryParam('search', 'batman'); + routerStore.setFragment('details'); + + // Set route data + routerStore.setRouteDataParam('title', 'Hero Details'); + routerStore.setRouteDataParam('breadcrumbs', ['Home', 'Heroes']); + + // Or set multiple values at once + routerStore.setRouteParams({ id: '456', type: 'superhero' }); + routerStore.setQueryParams({ search: 'batman', page: '1' }); + routerStore.setRouteData({ title: 'Hero Details', allowEdit: true }); + + fixture.detectChanges(); + + // Your assertions here... +}); +``` + +#### Testing with services + +```typescript +class HeroService { + private routerStore = inject(RouterStore); + + currentHeroId$ = this.routerStore.selectRouteParam('id'); + searchQuery$ = this.routerStore.selectQueryParam('q'); +} + +describe('HeroService', () => { + let service: HeroService; + let routerStore: TestingRouterStore; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [HeroService, provideTestingRouterStore()], + }); + + service = TestBed.inject(HeroService); + routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + }); + + it('should emit current hero ID', (done) => { + service.currentHeroId$.subscribe(id => { + expect(id).toBe('789'); + done(); + }); + + routerStore.setRouteParam('id', '789'); + }); +}); +``` + +#### Available testing methods + +| Method | Description | +| --- | --- | +| `setUrl(url: string)` | Set the current URL | +| `setFragment(fragment: string \| null)` | Set the URL fragment | +| `setTitle(title: string \| undefined)` | Set the resolved route title | +| `setRouteParam(param: string, value: string \| undefined)` | Set a single route parameter | +| `setRouteParams(params: StrictRouteParams)` | Set all route parameters | +| `setQueryParam(param: string, value: string \| readonly string[] \| undefined)` | Set a single query parameter | +| `setQueryParams(params: StrictQueryParams)` | Set all query parameters | +| `setRouteDataParam(key: string, value: unknown)` | Set a single route data value | +| `setRouteData(data: StrictRouteData)` | Set all route data | +| `setCurrentRoute(route: MinimalActivatedRouteSnapshot)` | Set the complete current route | +| `reset()` | Reset all values to their defaults | + +#### Integration with RouterTestingModule + +While `TestingRouterStore` is great for isolated unit tests, you might sometimes want to test the full routing behavior. You can still use `RouterTestingModule` with the actual `RouterStore` implementations: + +```typescript +import { provideGlobalRouterStore } from '@ngworker/router-component-store'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('Full routing integration', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { path: 'heroes/:id', component: HeroDetailComponent } + ]) + ], + providers: [provideGlobalRouterStore()], + }); + }); + + it('should work with actual navigation', async () => { + const router = TestBed.inject(Router); + const routerStore = TestBed.inject(RouterStore); + + await router.navigate(['/heroes', '123']); + + const heroId = await firstValueFrom(routerStore.selectRouteParam('id')); + expect(heroId).toBe('123'); + }); +}); +``` diff --git a/packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts b/packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts new file mode 100644 index 00000000..ed2b4f4e --- /dev/null +++ b/packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts @@ -0,0 +1,198 @@ +import { AsyncPipe, NgIf } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { RouterStore } from '../router-store'; +import { provideTestingRouterStore, TestingRouterStore } from '../testing'; + +// Example component that uses RouterStore +@Component({ + standalone: true, + imports: [AsyncPipe, NgIf], + selector: 'demo-hero-detail', + template: ` +

+
+

Hero ID:

+ +

URL:

+

Allow Edit:

+
+ + `, +}) +class DemoHeroDetailComponent { + private routerStore = inject(RouterStore); + + title$ = this.routerStore.title$; + heroId$ = this.routerStore.selectRouteParam('id'); + searchQuery$ = this.routerStore.selectQueryParam('search'); + url$ = this.routerStore.url$; + allowEdit$ = this.routerStore.selectRouteDataParam('allowEdit'); + breadcrumbs$ = this.routerStore.selectRouteDataParam('breadcrumbs'); +} + +// Example service that uses RouterStore +class DemoHeroService { + private routerStore = inject(RouterStore); + + currentHeroId$ = this.routerStore.selectRouteParam('id'); + isEditMode$ = this.routerStore.selectQueryParam('edit'); + + async getCurrentHeroId(): Promise { + return firstValueFrom(this.currentHeroId$); + } +} + +describe('TestingRouterStore Demo', () => { + describe('Component Integration', () => { + let routerStore: TestingRouterStore; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DemoHeroDetailComponent], + providers: [provideTestingRouterStore()], + }); + + routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + }); + + it('should demonstrate complete router state control', async () => { + const fixture = TestBed.createComponent(DemoHeroDetailComponent); + + // Set up a complex router state + routerStore.setUrl('/heroes/123?search=batman&edit=true#details'); + routerStore.setTitle('Batman Details'); + routerStore.setRouteParam('id', '123'); + routerStore.setQueryParams({ search: 'batman', edit: 'true' }); + routerStore.setRouteData({ + allowEdit: true, + breadcrumbs: ['Home', 'Heroes', 'Batman'], + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const compiled = fixture.nativeElement; + + // Verify the component displays all the router state correctly + expect(compiled.querySelector('h1')?.textContent?.trim()).toBe('Batman Details'); + expect(compiled.querySelector('.hero-id span')?.textContent?.trim()).toBe('123'); + expect(compiled.querySelector('.search span')?.textContent?.trim()).toBe('batman'); + expect(compiled.querySelector('.url span')?.textContent?.trim()).toBe('/heroes/123?search=batman&edit=true#details'); + expect(compiled.querySelector('.allow-edit span')?.textContent?.trim()).toBe('Yes'); + expect(compiled.textContent).toContain('Breadcrumbs: Home > Heroes > Batman'); + }); + + it('should handle state changes dynamically', async () => { + const fixture = TestBed.createComponent(DemoHeroDetailComponent); + + // Start with default state + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.hero-id span')?.textContent).toBeFalsy(); + + // Update hero ID + routerStore.setRouteParam('id', '456'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.hero-id span')?.textContent?.trim()).toBe('456'); + + // Reset and verify defaults are restored + routerStore.reset(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.hero-id span')?.textContent).toBeFalsy(); + }); + }); + + describe('Service Integration', () => { + let service: DemoHeroService; + let routerStore: TestingRouterStore; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DemoHeroService, provideTestingRouterStore()], + }); + + service = TestBed.inject(DemoHeroService); + routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + }); + + it('should work with services that depend on RouterStore', async () => { + // Initially no hero ID + expect(await service.getCurrentHeroId()).toBeUndefined(); + + // Set hero ID and verify service picks it up + routerStore.setRouteParam('id', '789'); + expect(await service.getCurrentHeroId()).toBe('789'); + }); + + it('should handle observable streams', (done) => { + let emissionCount = 0; + const expectedValues = [undefined, 'first', 'second']; + + service.currentHeroId$.subscribe(id => { + expect(id).toBe(expectedValues[emissionCount]); + emissionCount++; + + if (emissionCount === 3) { + done(); + } + }); + + // Trigger emissions + routerStore.setRouteParam('id', 'first'); + routerStore.setRouteParam('id', 'second'); + }); + }); + + describe('Complex Scenarios', () => { + let routerStore: TestingRouterStore; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], + }); + + routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + }); + + it('should handle array query parameters', async () => { + routerStore.setQueryParam('tags', ['action', 'superhero', 'dc']); + + const tags = await firstValueFrom(routerStore.selectQueryParam('tags')); + expect(tags).toEqual(['action', 'superhero', 'dc']); + }); + + it('should handle complex route data', async () => { + const complexData = { + permissions: ['read', 'write'], + metadata: { version: '1.0', author: 'test' }, + settings: { theme: 'dark', language: 'en' }, + }; + + routerStore.setRouteDataParam('config', complexData); + + const config = await firstValueFrom(routerStore.selectRouteDataParam('config')); + expect(config).toEqual(complexData); + }); + + it('should preserve independent parameter updates', async () => { + // Set initial state + routerStore.setRouteParams({ id: '1', type: 'hero' }); + routerStore.setQueryParams({ search: 'batman', page: '1' }); + + // Update individual parameters + routerStore.setRouteParam('id', '2'); + routerStore.setQueryParam('search', 'superman'); + + // Verify other parameters are preserved + const routeParams = await firstValueFrom(routerStore.routeParams$); + const queryParams = await firstValueFrom(routerStore.queryParams$); + + expect(routeParams).toEqual({ id: '2', type: 'hero' }); + expect(queryParams).toEqual({ search: 'superman', page: '1' }); + }); + }); +}); \ No newline at end of file From 9668fcf0dc7bc21cdf2b7ca7a116465e2523d5fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 21:22:12 +0000 Subject: [PATCH 4/5] Add injectTestingRouterStore function to avoid casting Co-authored-by: LayZeeDK <6364586+LayZeeDK@users.noreply.github.com> --- packages/router-component-store/README.md | 42 +++++++++- ...nject-testing-router-store.example.spec.ts | 60 ++++++++++++++ .../testing/provide-testing-router-store.ts | 6 ++ .../testing/testing-router-store-demo.spec.ts | 2 +- .../lib/testing/testing-router-store.spec.ts | 80 ++++++++++++++++++- .../src/lib/testing/testing-router-store.ts | 63 ++++++++++++++- 6 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts diff --git a/packages/router-component-store/README.md b/packages/router-component-store/README.md index 01b3c09e..63b55b43 100644 --- a/packages/router-component-store/README.md +++ b/packages/router-component-store/README.md @@ -221,7 +221,11 @@ Router Component Store provides testing utilities to make it easy to test compon ```typescript import { TestBed } from '@angular/core/testing'; -import { provideTestingRouterStore, TestingRouterStore } from '@ngworker/router-component-store'; +import { + provideTestingRouterStore, + TestingRouterStore, + injectTestingRouterStore +} from '@ngworker/router-component-store'; describe('HeroDetailComponent', () => { let routerStore: TestingRouterStore; @@ -232,7 +236,13 @@ describe('HeroDetailComponent', () => { providers: [provideTestingRouterStore()], }); + // Option 1: Manual casting routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + + // Option 2: Using injection helper (recommended) + TestBed.runInInjectionContext(() => { + routerStore = injectTestingRouterStore(); + }); }); it('should display hero ID from route param', () => { @@ -294,7 +304,13 @@ describe('HeroService', () => { }); service = TestBed.inject(HeroService); + // Option 1: Manual casting routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + + // Option 2: Using injection helper (recommended) + TestBed.runInInjectionContext(() => { + routerStore = injectTestingRouterStore(); + }); }); it('should emit current hero ID', (done) => { @@ -308,6 +324,30 @@ describe('HeroService', () => { }); ``` +#### Injection helper + +The `injectTestingRouterStore()` function provides a convenient way to inject the testing router store without manual casting: + +```typescript +import { injectTestingRouterStore } from '@ngworker/router-component-store'; + +// In your test setup +TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], +}); + +// Instead of casting manually +const routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + +// Use the injection helper +TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore(); + routerStore.setRouteParam('id', '123'); // Direct access to testing methods +}); +``` + +**Note:** The `injectTestingRouterStore()` function should only be used when `provideTestingRouterStore()` is provided in the testing module. It must be called within an injection context (e.g., inside `TestBed.runInInjectionContext()` or within a component/service constructor). + #### Available testing methods | Method | Description | diff --git a/packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts b/packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts new file mode 100644 index 00000000..d49bb12a --- /dev/null +++ b/packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts @@ -0,0 +1,60 @@ +import { Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { RouterStore } from '../router-store'; +import { injectTestingRouterStore, provideTestingRouterStore, TestingRouterStore } from '../testing'; + +// Example showing the improvement in developer experience +@Component({ + standalone: true, + selector: 'ngw-example-usage', + template: '

Route param: {{ routeParam$ | async }}

', +}) +class ExampleComponent { + private routerStore = inject(RouterStore); + routeParam$ = this.routerStore.selectRouteParam('id'); +} + +describe('injectTestingRouterStore - Usage Examples', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], + }); + }); + + it('demonstrates the old way vs the new way', () => { + // OLD WAY: Manual casting required + const oldWay = TestBed.inject(RouterStore) as TestingRouterStore; + oldWay.setRouteParam('id', 'old-way'); + + // NEW WAY: No casting needed with injection helper + TestBed.runInInjectionContext(() => { + const newWay = injectTestingRouterStore(); + newWay.setRouteParam('id', 'new-way'); + }); + + // Both approaches work, but the new way provides better type safety + expect(oldWay).toBeInstanceOf(TestingRouterStore); + expect(() => { + TestBed.runInInjectionContext(() => { + const newWay = injectTestingRouterStore(); + expect(newWay).toBeInstanceOf(TestingRouterStore); + }); + }).not.toThrow(); + }); + + it('provides immediate access to testing methods', () => { + TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore(); + + // Direct access to all testing methods without casting + routerStore.setUrl('/test/path'); + routerStore.setRouteParam('id', '123'); + routerStore.setQueryParam('search', 'test'); + routerStore.setFragment('section'); + routerStore.setTitle('Test Page'); + + // TypeScript IntelliSense will work perfectly + expect(typeof routerStore.reset).toBe('function'); + }); + }); +}); \ No newline at end of file diff --git a/packages/router-component-store/src/lib/testing/provide-testing-router-store.ts b/packages/router-component-store/src/lib/testing/provide-testing-router-store.ts index 23685899..f2b79741 100644 --- a/packages/router-component-store/src/lib/testing/provide-testing-router-store.ts +++ b/packages/router-component-store/src/lib/testing/provide-testing-router-store.ts @@ -18,9 +18,15 @@ import { TestingRouterStore } from './testing-router-store'; * providers: [provideTestingRouterStore()], * }); * + * // Option 1: Manual casting * const routerStore = TestBed.inject(RouterStore) as TestingRouterStore; * routerStore.setUrl('/test/123'); * routerStore.setRouteParam('id', '123'); + * + * // Option 2: Using injection helper (recommended) + * const routerStore = injectTestingRouterStore(); + * routerStore.setUrl('/test/123'); + * routerStore.setRouteParam('id', '123'); * ``` * * @example diff --git a/packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts b/packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts index ed2b4f4e..6abc72fb 100644 --- a/packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts +++ b/packages/router-component-store/src/lib/testing/testing-router-store-demo.spec.ts @@ -9,7 +9,7 @@ import { provideTestingRouterStore, TestingRouterStore } from '../testing'; @Component({ standalone: true, imports: [AsyncPipe, NgIf], - selector: 'demo-hero-detail', + selector: 'ngw-demo-hero-detail', template: `

diff --git a/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts b/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts index 451a2fb6..9b2e93c5 100644 --- a/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts +++ b/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts @@ -6,7 +6,7 @@ import { firstValueFrom, take } from 'rxjs'; import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; import { RouterStore } from '../router-store'; import { provideTestingRouterStore } from './provide-testing-router-store'; -import { TestingRouterStore } from './testing-router-store'; +import { injectTestingRouterStore, TestingRouterStore } from './testing-router-store'; @Component({ standalone: true, @@ -410,4 +410,82 @@ describe('TestingRouterStore integration', () => { expect(compiled.querySelector('#query-param')?.textContent?.trim()).toBe('batman'); expect(compiled.querySelector('#route-data')?.textContent?.trim()).toBe('50'); }); +}); + +describe('injectTestingRouterStore', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], + }); + }); + + it('should inject TestingRouterStore without casting', () => { + TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore(); + + expect(routerStore).toBeInstanceOf(TestingRouterStore); + expect(typeof routerStore.setUrl).toBe('function'); + expect(typeof routerStore.setRouteParam).toBe('function'); + expect(typeof routerStore.reset).toBe('function'); + }); + }); + + it('should provide access to all testing methods', () => { + TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore(); + + // Test that all testing methods are accessible + routerStore.setUrl('/test'); + routerStore.setFragment('test'); + routerStore.setTitle('Test'); + routerStore.setRouteParam('id', '123'); + routerStore.setRouteParams({ id: '123', type: 'test' }); + routerStore.setQueryParam('q', 'search'); + routerStore.setQueryParams({ q: 'search', page: '1' }); + routerStore.setRouteDataParam('key', 'value'); + routerStore.setRouteData({ key: 'value' }); + routerStore.setCurrentRoute({ + routeConfig: null, + url: [], + params: {}, + queryParams: {}, + fragment: null, + data: {}, + outlet: 'primary', + title: undefined, + firstChild: undefined, + children: [], + }); + routerStore.reset(); + + // If we get here without errors, all methods are accessible + expect(routerStore).toBeDefined(); + }); + }); + + it('should work in component tests', async () => { + @Component({ + standalone: true, + template: '
', + imports: [AsyncPipe], + }) + class TestInjectionComponent { + private routerStore = injectTestingRouterStore(); + routeId$ = this.routerStore.selectRouteParam('id'); + } + + TestBed.configureTestingModule({ + imports: [TestInjectionComponent], + providers: [provideTestingRouterStore()], + }); + + const fixture = TestBed.createComponent(TestInjectionComponent); + + // The injectTestingRouterStore should be accessible within the component + fixture.detectChanges(); + + // We can't directly access the component's routerStore, but we can verify + // the injection works by checking the component renders properly + expect(fixture.nativeElement.querySelector('div')).toBeTruthy(); + }); }); \ No newline at end of file diff --git a/packages/router-component-store/src/lib/testing/testing-router-store.ts b/packages/router-component-store/src/lib/testing/testing-router-store.ts index 0861a428..7b8b790c 100644 --- a/packages/router-component-store/src/lib/testing/testing-router-store.ts +++ b/packages/router-component-store/src/lib/testing/testing-router-store.ts @@ -1,4 +1,4 @@ -import { Injectable, Type } from '@angular/core'; +import { inject, Injectable, Type } from '@angular/core'; import { Event as RouterEvent } from '@angular/router'; import { BehaviorSubject, NEVER, Observable } from 'rxjs'; import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; @@ -20,9 +20,13 @@ import { StrictRouteParams } from '../strict-route-params'; * providers: [provideTestingRouterStore()], * }); * + * // Option 1: Manual casting * const routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + * routerStore.setUrl('/test/123'); + * routerStore.setRouteParam('id', '123'); * - * // Set test values + * // Option 2: Using injection helper (recommended) + * const routerStore = injectTestingRouterStore(); * routerStore.setUrl('/test/123'); * routerStore.setRouteParam('id', '123'); * ``` @@ -212,4 +216,59 @@ export class TestingRouterStore implements RouterStore { children: [], }; } +} + +/** + * Inject a `TestingRouterStore` instance without the need for casting. + * + * This is a convenience function that injects the `RouterStore` token and + * casts it to `TestingRouterStore`, providing direct access to testing methods. + * + * ⚠️ **Important**: This function should only be used when `provideTestingRouterStore()` + * is provided in the testing module. Using it with the actual router store providers + * will result in a runtime error. + * + * @returns A `TestingRouterStore` instance with direct access to testing methods + * + * @example + * ```typescript + * // In your test setup + * TestBed.configureTestingModule({ + * providers: [provideTestingRouterStore()], + * }); + * + * // Instead of casting manually + * const routerStore = TestBed.inject(RouterStore) as TestingRouterStore; + * + * // Use the injection helper + * const routerStore = injectTestingRouterStore(); + * routerStore.setUrl('/test/123'); + * routerStore.setRouteParam('id', '123'); + * ``` + * + * @example + * ```typescript + * // In component tests + * describe('HeroComponent', () => { + * beforeEach(() => { + * TestBed.configureTestingModule({ + * imports: [HeroComponent], + * providers: [provideTestingRouterStore()], + * }); + * }); + * + * it('should handle route changes', () => { + * const routerStore = injectTestingRouterStore(); + * const fixture = TestBed.createComponent(HeroComponent); + * + * routerStore.setRouteParam('id', '456'); + * fixture.detectChanges(); + * + * expect(fixture.nativeElement.textContent).toContain('456'); + * }); + * }); + * ``` + */ +export function injectTestingRouterStore(): TestingRouterStore { + return inject(RouterStore) as TestingRouterStore; } \ No newline at end of file From e07f738c59876434741a88e40d8143aa64f38bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Sep 2025 21:41:53 +0000 Subject: [PATCH 5/5] Enhance injectTestingRouterStore with InjectOptions and component injector support Co-authored-by: LayZeeDK <6364586+LayZeeDK@users.noreply.github.com> --- packages/router-component-store/README.md | 44 +++- ...nject-testing-router-store.example.spec.ts | 12 - .../lib/testing/testing-router-store.spec.ts | 219 ++++++++++++++++++ .../src/lib/testing/testing-router-store.ts | 149 +++++++++--- 4 files changed, 382 insertions(+), 42 deletions(-) diff --git a/packages/router-component-store/README.md b/packages/router-component-store/README.md index 63b55b43..e7e57fba 100644 --- a/packages/router-component-store/README.md +++ b/packages/router-component-store/README.md @@ -326,7 +326,9 @@ describe('HeroService', () => { #### Injection helper -The `injectTestingRouterStore()` function provides a convenient way to inject the testing router store without manual casting: +The `injectTestingRouterStore()` function provides a convenient way to inject the testing router store without manual casting. It supports injection options similar to Angular's `inject()` function and can also inject from specific component injectors for local router stores. + +##### Basic usage ```typescript import { injectTestingRouterStore } from '@ngworker/router-component-store'; @@ -346,6 +348,46 @@ TestBed.runInInjectionContext(() => { }); ``` +##### With injection options + +```typescript +TestBed.runInInjectionContext(() => { + // With injection options (optional, skipSelf, self, host) + const routerStore = injectTestingRouterStore({ + optional: true, + host: true + }); + routerStore?.setRouteParam('id', '123'); +}); +``` + +##### For local router stores + +```typescript +// When testing components with local router store providers +@Component({ + template: '

Hero: {{ heroId$ | async }}

', + providers: [provideTestingRouterStore()], // Local provider +}) +class HeroComponent { + private routerStore = inject(RouterStore); + heroId$ = this.routerStore.selectRouteParam('id'); +} + +// In your test +const fixture = TestBed.createComponent(ParentComponent); + +// Inject from the specific component's injector +const routerStore = injectTestingRouterStore({ + component: HeroComponent, + fixture, + options: { host: true } // Optional injection options +}); + +routerStore.setRouteParam('id', '123'); +fixture.detectChanges(); +``` + **Note:** The `injectTestingRouterStore()` function should only be used when `provideTestingRouterStore()` is provided in the testing module. It must be called within an injection context (e.g., inside `TestBed.runInInjectionContext()` or within a component/service constructor). #### Available testing methods diff --git a/packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts b/packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts index d49bb12a..b4a2f42b 100644 --- a/packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts +++ b/packages/router-component-store/src/lib/testing/inject-testing-router-store.example.spec.ts @@ -1,19 +1,7 @@ -import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { RouterStore } from '../router-store'; import { injectTestingRouterStore, provideTestingRouterStore, TestingRouterStore } from '../testing'; -// Example showing the improvement in developer experience -@Component({ - standalone: true, - selector: 'ngw-example-usage', - template: '

Route param: {{ routeParam$ | async }}

', -}) -class ExampleComponent { - private routerStore = inject(RouterStore); - routeParam$ = this.routerStore.selectRouteParam('id'); -} - describe('injectTestingRouterStore - Usage Examples', () => { beforeEach(() => { TestBed.configureTestingModule({ diff --git a/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts b/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts index 9b2e93c5..a4cd9db3 100644 --- a/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts +++ b/packages/router-component-store/src/lib/testing/testing-router-store.spec.ts @@ -488,4 +488,223 @@ describe('injectTestingRouterStore', () => { // the injection works by checking the component renders properly expect(fixture.nativeElement.querySelector('div')).toBeTruthy(); }); +}); + +describe('injectTestingRouterStore - Enhanced Options', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideTestingRouterStore()], + }); + }); + + describe('injection options', () => { + it('should support optional injection', () => { + TestBed.runInInjectionContext(() => { + // With optional: false (default behavior) + const routerStore = injectTestingRouterStore(); + expect(routerStore).toBeInstanceOf(TestingRouterStore); + + // With optional: true - should still work since TestingRouterStore is provided + const optionalStore = injectTestingRouterStore({ optional: true }); + expect(optionalStore).toBeInstanceOf(TestingRouterStore); + }); + }); + + it('should support host injection option', () => { + TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore({ host: true }); + expect(routerStore).toBeInstanceOf(TestingRouterStore); + expect(typeof routerStore.setRouteParam).toBe('function'); + }); + }); + + it('should support self injection option', () => { + TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore({ self: true }); + expect(routerStore).toBeInstanceOf(TestingRouterStore); + expect(typeof routerStore.setUrl).toBe('function'); + }); + }); + + it('should support skipSelf injection option', () => { + TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore({ skipSelf: false }); + expect(routerStore).toBeInstanceOf(TestingRouterStore); + expect(typeof routerStore.reset).toBe('function'); + }); + }); + + it('should support combined injection options', () => { + TestBed.runInInjectionContext(() => { + const routerStore = injectTestingRouterStore({ + optional: false, + host: true, + self: false, + }); + expect(routerStore).toBeInstanceOf(TestingRouterStore); + }); + }); + }); + + describe('component injector support', () => { + @Component({ + standalone: true, + selector: 'ngw-test-with-local', + template: '

Test Component

', + providers: [provideTestingRouterStore()], // Local provider + }) + class TestComponentWithLocalStoreComponent {} + + @Component({ + standalone: true, + template: '', + imports: [TestComponentWithLocalStoreComponent], + }) + class TestParentComponent {} + + it('should inject from specific component injector', () => { + TestBed.configureTestingModule({ + imports: [TestComponentWithLocalStoreComponent, TestParentComponent], + }); + + const fixture = TestBed.createComponent(TestParentComponent); + fixture.detectChanges(); + + const routerStore = injectTestingRouterStore({ + component: TestComponentWithLocalStoreComponent, + fixture, + }); + + expect(routerStore).toBeInstanceOf(TestingRouterStore); + expect(typeof routerStore.setRouteParam).toBe('function'); + + // Test that we can use the injected store + routerStore.setRouteParam('test', 'value'); + expect(routerStore).toBeDefined(); + }); + + it('should inject from component with options', () => { + TestBed.configureTestingModule({ + imports: [TestComponentWithLocalStoreComponent, TestParentComponent], + }); + + const fixture = TestBed.createComponent(TestParentComponent); + fixture.detectChanges(); + + const routerStore = injectTestingRouterStore({ + component: TestComponentWithLocalStoreComponent, + fixture, + options: { host: true }, + }); + + expect(routerStore).toBeInstanceOf(TestingRouterStore); + routerStore.setQueryParam('search', 'test'); + expect(routerStore).toBeDefined(); + }); + + it('should throw error if component not found in fixture', () => { + @Component({ + standalone: true, + template: '

Different Component

', + }) + class DifferentComponent {} + + TestBed.configureTestingModule({ + imports: [DifferentComponent], + }); + + const fixture = TestBed.createComponent(DifferentComponent); + + expect(() => { + injectTestingRouterStore({ + component: TestComponentWithLocalStoreComponent, // This component is not in the fixture + fixture, + }); + }).toThrow('Component TestComponentWithLocalStoreComponent not found in fixture'); + }); + }); +}); + +describe('injectTestingRouterStore - Real-world Usage', () => { + @Component({ + standalone: true, + selector: 'ngw-hero-detail', + template: ` +
+

Hero: {{ heroId$ | async }}

+

Search: {{ search$ | async }}

+
+ `, + imports: [AsyncPipe], + providers: [provideTestingRouterStore()], // Local testing store + }) + class HeroDetailComponent { + private routerStore = inject(RouterStore); + heroId$ = this.routerStore.selectRouteParam('id'); + search$ = this.routerStore.selectQueryParam('search'); + } + + @Component({ + standalone: true, + template: '', + imports: [HeroDetailComponent], + }) + class AppComponent {} + + it('should work with local router store in real component', () => { + TestBed.configureTestingModule({ + imports: [AppComponent], + }); + + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + // Inject from the specific component's injector + const routerStore = injectTestingRouterStore({ + component: HeroDetailComponent, + fixture, + }); + + // Set up test data + routerStore.setRouteParam('id', 'superman'); + routerStore.setQueryParam('search', 'hero'); + + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain('Hero: superman'); + expect(compiled.textContent).toContain('Search: hero'); + }); + + it('should demonstrate different injection strategies', () => { + TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [provideTestingRouterStore()], // Global testing store + }); + + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + // Strategy 1: Use global testing store with injection context + TestBed.runInInjectionContext(() => { + const globalStore = injectTestingRouterStore(); + globalStore.setUrl('/global/test'); + expect(globalStore.url$).toBeDefined(); + }); + + // Strategy 2: Use local component store + const localStore = injectTestingRouterStore({ + component: HeroDetailComponent, + fixture, + options: { host: true }, + }); + + localStore.setRouteParam('id', 'batman'); + localStore.setQueryParam('search', 'dark knight'); + + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Hero: batman'); + expect(fixture.nativeElement.textContent).toContain('Search: dark knight'); + }); }); \ No newline at end of file diff --git a/packages/router-component-store/src/lib/testing/testing-router-store.ts b/packages/router-component-store/src/lib/testing/testing-router-store.ts index 7b8b790c..218577e6 100644 --- a/packages/router-component-store/src/lib/testing/testing-router-store.ts +++ b/packages/router-component-store/src/lib/testing/testing-router-store.ts @@ -1,4 +1,6 @@ import { inject, Injectable, Type } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { Event as RouterEvent } from '@angular/router'; import { BehaviorSubject, NEVER, Observable } from 'rxjs'; import { MinimalActivatedRouteSnapshot } from '../@ngrx/router-store/minimal-activated-route-state-snapshot'; @@ -218,6 +220,48 @@ export class TestingRouterStore implements RouterStore { } } +/** + * Options for the `injectTestingRouterStore` function. + */ +export interface InjectTestingRouterStoreOptions { + /** + * Use optional injection, and return `null` if the requested token is not found. + */ + optional?: boolean; + /** + * Start injection at the parent of the current injector. + */ + skipSelf?: boolean; + /** + * Only query the current injector for the token, and don't fall back to the parent injector if + * it's not found. + */ + self?: boolean; + /** + * Stop injection at the host component's injector. Only relevant when injecting from an element + * injector, and a no-op for environment injectors. + */ + host?: boolean; +} + +/** + * Configuration for injecting from a specific component's injector. + */ +export interface InjectFromComponentOptions { + /** + * The component type to get the injector from. + */ + component: Type; + /** + * The component fixture to query for the component instance. + */ + fixture: ComponentFixture; + /** + * Additional injection options. + */ + options?: InjectTestingRouterStoreOptions; +} + /** * Inject a `TestingRouterStore` instance without the need for casting. * @@ -228,47 +272,94 @@ export class TestingRouterStore implements RouterStore { * is provided in the testing module. Using it with the actual router store providers * will result in a runtime error. * + * @param options - Injection options similar to Angular's inject() function * @returns A `TestingRouterStore` instance with direct access to testing methods * * @example * ```typescript - * // In your test setup - * TestBed.configureTestingModule({ - * providers: [provideTestingRouterStore()], + * // Basic usage + * TestBed.runInInjectionContext(() => { + * const routerStore = injectTestingRouterStore(); + * routerStore.setRouteParam('id', '123'); * }); * - * // Instead of casting manually - * const routerStore = TestBed.inject(RouterStore) as TestingRouterStore; - * - * // Use the injection helper - * const routerStore = injectTestingRouterStore(); - * routerStore.setUrl('/test/123'); - * routerStore.setRouteParam('id', '123'); + * // With injection options + * TestBed.runInInjectionContext(() => { + * const routerStore = injectTestingRouterStore({ optional: true, host: true }); + * routerStore?.setRouteParam('id', '123'); + * }); * ``` + */ +export function injectTestingRouterStore(): TestingRouterStore; + +/** + * Inject a `TestingRouterStore` instance with injection options. * - * @example - * ```typescript - * // In component tests - * describe('HeroComponent', () => { - * beforeEach(() => { - * TestBed.configureTestingModule({ - * imports: [HeroComponent], - * providers: [provideTestingRouterStore()], - * }); - * }); + * @param options - Injection options (optional, skipSelf, self, host) + * @returns A `TestingRouterStore` instance or null if optional and not found + */ +export function injectTestingRouterStore( + options: InjectTestingRouterStoreOptions & { optional: true } +): TestingRouterStore | null; + +/** + * Inject a `TestingRouterStore` instance with injection options. * - * it('should handle route changes', () => { - * const routerStore = injectTestingRouterStore(); - * const fixture = TestBed.createComponent(HeroComponent); + * @param options - Injection options (skipSelf, self, host) + * @returns A `TestingRouterStore` instance + */ +export function injectTestingRouterStore( + options: InjectTestingRouterStoreOptions +): TestingRouterStore; + +/** + * Inject a `TestingRouterStore` instance from a specific component's injector. * - * routerStore.setRouteParam('id', '456'); - * fixture.detectChanges(); + * This overload is particularly useful for testing local router stores that are + * provided at the component level. * - * expect(fixture.nativeElement.textContent).toContain('456'); - * }); + * @param config - Configuration specifying the component and fixture + * @returns A `TestingRouterStore` instance from the component's injector + * + * @example + * ```typescript + * // Inject from a specific component (useful for local router stores) + * const fixture = TestBed.createComponent(HeroComponent); + * const routerStore = injectTestingRouterStore({ + * component: HeroComponent, + * fixture, + * options: { host: true } * }); + * routerStore.setRouteParam('id', '456'); * ``` */ -export function injectTestingRouterStore(): TestingRouterStore { - return inject(RouterStore) as TestingRouterStore; +export function injectTestingRouterStore( + config: InjectFromComponentOptions +): TestingRouterStore; + +export function injectTestingRouterStore( + optionsOrConfig?: + | InjectTestingRouterStoreOptions + | InjectFromComponentOptions +): TestingRouterStore | null { + // Check if it's a component injection configuration + if (optionsOrConfig && 'component' in optionsOrConfig) { + const { component, fixture, options = {} } = optionsOrConfig; + const componentDebugElement = fixture.debugElement.query(By.directive(component)); + + if (!componentDebugElement) { + throw new Error( + `Component ${component.name} not found in fixture. ` + + 'Make sure the component is rendered in the fixture.' + ); + } + + const componentInjector = componentDebugElement.injector; + const routerStore = componentInjector.get(RouterStore, undefined, options); + return routerStore as TestingRouterStore; + } + + // Use standard injection with options + const options = optionsOrConfig || {}; + return inject(RouterStore, options) as TestingRouterStore; } \ No newline at end of file