From 47efedcc14004e6f98d56c8d5e16c962c6424842 Mon Sep 17 00:00:00 2001 From: Sumit Date: Wed, 8 Jan 2020 22:47:15 +0530 Subject: [PATCH] test(component): update usage of TestBed.get to TestBed.inject angular 9 compatibillity changes for TestBed Closes #2240 --- .../dataservices/default-data.service.spec.ts | 1198 +++++----- .../dataservices/entity-data.service.spec.ts | 350 +-- .../spec/effects/entity-cache-effects.spec.ts | 422 ++-- .../effects/entity-effects.marbles.spec.ts | 1040 ++++----- .../data/spec/effects/entity-effects.spec.ts | 1118 ++++----- modules/data/spec/entity-data.module.spec.ts | 529 +++-- .../entity-definition.service.spec.ts | 288 +-- .../entity-collection-service.spec.ts | 1252 +++++----- .../entity-services/entity-services.spec.ts | 477 ++-- .../reducers/entity-cache-reducer.spec.ts | 1401 ++++++----- ...entity-collection-reducer-registry.spec.ts | 691 +++--- .../related-entity-selectors.spec.ts | 968 ++++---- .../spec/utils/default-pluralizer.spec.ts | 196 +- modules/effects/spec/effect_sources.spec.ts | 1218 +++++----- .../spec/effects_feature_module.spec.ts | 350 +-- .../effects/spec/effects_root_module.spec.ts | 98 +- modules/effects/spec/integration.spec.ts | 282 +-- modules/router-store/spec/integration.spec.ts | 2063 ++++++++--------- .../spec/router_store_module.spec.ts | 428 ++-- .../store-devtools/spec/integration.spec.ts | 192 +- modules/store-devtools/spec/store.spec.ts | 8 +- modules/store/spec/edge.spec.ts | 122 +- modules/store/spec/integration.spec.ts | 996 ++++---- modules/store/spec/modules.spec.ts | 438 ++-- modules/store/spec/runtime_checks.spec.ts | 170 +- modules/store/spec/state.spec.ts | 91 +- modules/store/spec/store.spec.ts | 1410 +++++------ modules/store/testing/spec/mock_store.spec.ts | 780 +++---- modules/store/testing/src/mock_store.ts | 7 +- .../containers/login-page.component.spec.ts | 2 +- .../src/app/auth/effects/auth.effects.spec.ts | 10 +- .../auth/services/auth-guard.service.spec.ts | 74 +- .../collection-page.component.spec.ts | 2 +- .../find-book-page.component.spec.ts | 2 +- .../selected-book-page.component.spec.ts | 2 +- .../view-book-page.component.spec.ts | 4 +- .../app/books/effects/book.effects.spec.ts | 186 +- .../books/effects/collection.effects.spec.ts | 304 +-- .../app/core/effects/router.effects.spec.ts | 82 +- .../src/app/core/effects/user.effects.spec.ts | 124 +- .../services/book-storage.service.spec.ts | 296 +-- .../services/google-books.service.spec.ts | 114 +- .../src/app/user-greeting.component.spec.ts | 74 +- .../ngrx.io/content/guide/migration/v4.md | 1138 ++++----- .../ngrx.io/src/app/app.component.spec.ts | 1242 +++++----- .../code/code.component.spec.ts | 652 +++--- .../file-not-found-search.component.spec.ts | 94 +- .../custom-elements/toc/toc.component.spec.ts | 1048 ++++----- .../doc-viewer/doc-viewer.component.spec.ts | 1466 ++++++------ .../notification.component.spec.ts | 270 +-- 50 files changed, 12850 insertions(+), 12919 deletions(-) diff --git a/modules/data/spec/dataservices/default-data.service.spec.ts b/modules/data/spec/dataservices/default-data.service.spec.ts index 00378d4ad8..6c0cb0bf5d 100644 --- a/modules/data/spec/dataservices/default-data.service.spec.ts +++ b/modules/data/spec/dataservices/default-data.service.spec.ts @@ -1,596 +1,602 @@ -import { TestBed } from '@angular/core/testing'; - -import { HttpClient } from '@angular/common/http'; -import { - HttpClientTestingModule, - HttpTestingController, -} from '@angular/common/http/testing'; - -import { of } from 'rxjs'; - -import { Update } from '@ngrx/entity'; - -import { - DefaultDataService, - DefaultDataServiceFactory, - DefaultHttpUrlGenerator, - HttpUrlGenerator, - DefaultDataServiceConfig, - DataServiceError, -} from '../../'; - -class Hero { - id: number; - name: string; - version?: number; -} - -//////// Tests ///////////// -describe('DefaultDataService', () => { - let httpClient: HttpClient; - let httpTestingController: HttpTestingController; - const heroUrl = 'api/hero/'; - const heroesUrl = 'api/heroes/'; - let httpUrlGenerator: HttpUrlGenerator; - let service: DefaultDataService; - - //// HttpClient testing boilerplate - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - httpClient = TestBed.get(HttpClient); - httpTestingController = TestBed.get(HttpTestingController); - - httpUrlGenerator = new DefaultHttpUrlGenerator(null as any); - httpUrlGenerator.registerHttpResourceUrls({ - Hero: { - entityResourceUrl: heroUrl, - collectionResourceUrl: heroesUrl, - }, - }); - - service = new DefaultDataService('Hero', httpClient, httpUrlGenerator); - }); - - afterEach(() => { - // After every test, assert that there are no pending requests. - httpTestingController.verify(); - }); - /////////////////// - - describe('property inspection', () => { - // Test wrapper exposes protected properties - class TestService extends DefaultDataService { - properties = { - entityUrl: this.entityUrl, - entitiesUrl: this.entitiesUrl, - getDelay: this.getDelay, - saveDelay: this.saveDelay, - timeout: this.timeout, - }; - } - - // tslint:disable-next-line:no-shadowed-variable - let service: TestService; - - beforeEach(() => { - // use test wrapper class to get to protected properties - service = new TestService('Hero', httpClient, httpUrlGenerator); - }); - - it('has expected name', () => { - expect(service.name).toBe('Hero DefaultDataService'); - }); - - it('has expected single-entity url', () => { - expect(service.properties.entityUrl).toBe(heroUrl); - }); - - it('has expected multiple-entities url', () => { - expect(service.properties.entitiesUrl).toBe(heroesUrl); - }); - }); - - describe('#getAll', () => { - let expectedHeroes: Hero[]; - - beforeEach(() => { - expectedHeroes = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] as Hero[]; - }); - - it('should return expected heroes (called once)', () => { - service - .getAll() - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); - - // HeroService should have made one request to GET heroes from expected URL - const req = httpTestingController.expectOne(heroesUrl); - expect(req.request.method).toEqual('GET'); - - expect(req.request.body).toBeNull('should not send data'); - - // Respond with the mock heroes - req.flush(expectedHeroes); - }); - - it('should be OK returning no heroes', () => { - service - .getAll() - .subscribe( - heroes => - expect(heroes.length).toEqual(0, 'should have empty heroes array'), - fail - ); - - const req = httpTestingController.expectOne(heroesUrl); - req.flush([]); // Respond with no heroes - }); - - it('should return expected heroes (called multiple times)', () => { - service.getAll().subscribe(); - service.getAll().subscribe(); - service - .getAll() - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); - - const requests = httpTestingController.match(heroesUrl); - expect(requests.length).toEqual(3, 'calls to getAll()'); - - // Respond to each request with different mock hero results - requests[0].flush([]); - requests[1].flush([{ id: 1, name: 'bob' }]); - requests[2].flush(expectedHeroes); - }); - - it('should turn 404 into Observable', () => { - const msg = 'deliberate 404 error'; - - service.getAll().subscribe( - heroes => fail('getAll succeeded when expected it to fail with a 404'), - err => { - expect(err).toBeDefined('request should have failed'); - expect(err instanceof DataServiceError).toBe( - true, - 'is DataServiceError' - ); - expect(err.error.status).toEqual(404, 'has 404 status'); - expect(err.message).toEqual(msg, 'has expected error message'); - } - ); - - const req = httpTestingController.expectOne(heroesUrl); - - const errorEvent = { - // Source of the service's not-so-friendly user-facing message - message: msg, - - // The rest of this is optional and not used. Just showing that you could. - filename: 'DefaultDataService.ts', - lineno: 42, - colno: 21, - } as ErrorEvent; - - req.error(errorEvent, { status: 404, statusText: 'Not Found' }); - }); - }); - - describe('#getById', () => { - let expectedHero: Hero; - const heroUrlId1 = heroUrl + '1'; - - it('should return expected hero when id is found', () => { - expectedHero = { id: 1, name: 'A' }; - - service - .getById(1) - .subscribe( - hero => - expect(hero).toEqual(expectedHero, 'should return expected hero'), - fail - ); - - // One request to GET hero from expected URL - const req = httpTestingController.expectOne(heroUrlId1); - - expect(req.request.body).toBeNull('should not send data'); - - // Respond with the expected hero - req.flush(expectedHero); - }); - - it('should turn 404 when id not found', () => { - service.getById(1).subscribe( - heroes => fail('getById succeeded when expected it to fail with a 404'), - err => { - expect(err instanceof DataServiceError).toBe(true); - } - ); - - const req = httpTestingController.expectOne(heroUrlId1); - const errorEvent = { message: 'boom!' } as ErrorEvent; - req.error(errorEvent, { status: 404, statusText: 'Not Found' }); - }); - - it('should throw when no id given', () => { - service.getById(undefined as any).subscribe( - heroes => fail('getById succeeded when expected it to fail'), - err => { - expect(err.error).toMatch(/No "Hero" key/); - } - ); - }); - }); - - describe('#getWithQuery', () => { - let expectedHeroes: Hero[]; - - beforeEach(() => { - expectedHeroes = [{ id: 1, name: 'BA' }, { id: 2, name: 'BB' }] as Hero[]; - }); - - it('should return expected selected heroes w/ object params', () => { - service - .getWithQuery({ name: 'B' }) - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); - - // HeroService should have made one request to GET heroes - // from expected URL with query params - const req = httpTestingController.expectOne(heroesUrl + '?name=B'); - expect(req.request.method).toEqual('GET'); - - expect(req.request.body).toBeNull('should not send data'); - - // Respond with the mock heroes - req.flush(expectedHeroes); - }); - - it('should return expected selected heroes w/ string params', () => { - service - .getWithQuery('name=B') - .subscribe( - heroes => - expect(heroes).toEqual( - expectedHeroes, - 'should return expected heroes' - ), - fail - ); - - // HeroService should have made one request to GET heroes - // from expected URL with query params - const req = httpTestingController.expectOne(heroesUrl + '?name=B'); - expect(req.request.method).toEqual('GET'); - - // Respond with the mock heroes - req.flush(expectedHeroes); - }); - - it('should be OK returning no heroes', () => { - service - .getWithQuery({ name: 'B' }) - .subscribe( - heroes => - expect(heroes.length).toEqual(0, 'should have empty heroes array'), - fail - ); - - const req = httpTestingController.expectOne(heroesUrl + '?name=B'); - req.flush([]); // Respond with no heroes - }); - - it('should turn 404 into Observable', () => { - const msg = 'deliberate 404 error'; - - service.getWithQuery({ name: 'B' }).subscribe( - heroes => - fail('getWithQuery succeeded when expected it to fail with a 404'), - err => { - expect(err).toBeDefined('request should have failed'); - expect(err instanceof DataServiceError).toBe( - true, - 'is DataServiceError' - ); - expect(err.error.status).toEqual(404, 'has 404 status'); - expect(err.message).toEqual(msg, 'has expected error message'); - } - ); - - const req = httpTestingController.expectOne(heroesUrl + '?name=B'); - - const errorEvent = { message: msg } as ErrorEvent; - - req.error(errorEvent, { status: 404, statusText: 'Not Found' }); - }); - }); - - describe('#add', () => { - let expectedHero: Hero; - - it('should return expected hero with id', () => { - expectedHero = { id: 42, name: 'A' }; - const heroData: Hero = { id: undefined, name: 'A' } as any; - - service - .add(heroData) - .subscribe( - hero => - expect(hero).toEqual(expectedHero, 'should return expected hero'), - fail - ); - - // One request to POST hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'POST' && r.url === heroUrl - ); - - expect(req.request.body).toEqual(heroData, 'should send entity data'); - - // Respond with the expected hero - req.flush(expectedHero); - }); - - it('should throw when no entity given', () => { - service.add(undefined as any).subscribe( - heroes => fail('add succeeded when expected it to fail'), - err => { - expect(err.error).toMatch(/No "Hero" entity/); - } - ); - }); - }); - - describe('#delete', () => { - const heroUrlId1 = heroUrl + '1'; - - it('should delete by hero id', () => { - service - .delete(1) - .subscribe( - result => - expect(result).toEqual(1, 'should return the deleted entity id'), - fail - ); - - // One request to DELETE hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'DELETE' && r.url === heroUrlId1 - ); - - expect(req.request.body).toBeNull('should not send data'); - - // Respond with empty nonsense object - req.flush({}); - }); - - it('should return successfully when id not found and delete404OK is true (default)', () => { - service - .delete(1) - .subscribe( - result => - expect(result).toEqual(1, 'should return the deleted entity id'), - fail - ); - - // One request to DELETE hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'DELETE' && r.url === heroUrlId1 - ); - - // Respond with empty nonsense object - req.flush({}); - }); - - it('should return 404 when id not found and delete404OK is false', () => { - service = new DefaultDataService('Hero', httpClient, httpUrlGenerator, { - delete404OK: false, - }); - service.delete(1).subscribe( - heroes => fail('delete succeeded when expected it to fail with a 404'), - err => { - expect(err instanceof DataServiceError).toBe(true); - } - ); - - const req = httpTestingController.expectOne(heroUrlId1); - const errorEvent = { message: 'boom!' } as ErrorEvent; - req.error(errorEvent, { status: 404, statusText: 'Not Found' }); - }); - - it('should throw when no id given', () => { - service.delete(undefined as any).subscribe( - heroes => fail('delete succeeded when expected it to fail'), - err => { - expect(err.error).toMatch(/No "Hero" key/); - } - ); - }); - }); - - describe('#update', () => { - const heroUrlId1 = heroUrl + '1'; - - it('should return expected hero with id', () => { - // Call service.update with an Update arg - const updateArg: Update = { - id: 1, - changes: { id: 1, name: 'B' }, - }; - - // The server makes the update AND updates the version concurrency property. - const expectedHero: Hero = { id: 1, name: 'B', version: 2 }; - - service - .update(updateArg) - .subscribe( - updated => - expect(updated).toEqual( - expectedHero, - 'should return the expected hero' - ), - fail - ); - - // One request to PUT hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'PUT' && r.url === heroUrlId1 - ); - - expect(req.request.body).toEqual( - updateArg.changes, - 'should send update entity data' - ); - - // Respond with the expected hero - req.flush(expectedHero); - }); - - it('should return 404 when id not found', () => { - service.update({ id: 1, changes: { id: 1, name: 'B' } }).subscribe( - update => fail('update succeeded when expected it to fail with a 404'), - err => { - expect(err instanceof DataServiceError).toBe(true); - } - ); - - const req = httpTestingController.expectOne(heroUrlId1); - const errorEvent = { message: 'boom!' } as ErrorEvent; - req.error(errorEvent, { status: 404, statusText: 'Not Found' }); - }); - - it('should throw when no update given', () => { - service.update(undefined as any).subscribe( - heroes => fail('update succeeded when expected it to fail'), - err => { - expect(err.error).toMatch(/No "Hero" update data/); - } - ); - }); - }); - - describe('#upsert', () => { - let expectedHero: Hero; - - it('should return expected hero with id', () => { - expectedHero = { id: 42, name: 'A' }; - const heroData: Hero = { id: undefined, name: 'A' } as any; - - service - .upsert(heroData) - .subscribe( - hero => - expect(hero).toEqual(expectedHero, 'should return expected hero'), - fail - ); - - // One request to POST hero from expected URL - const req = httpTestingController.expectOne( - r => r.method === 'POST' && r.url === heroUrl - ); - - expect(req.request.body).toEqual(heroData, 'should send entity data'); - - // Respond with the expected hero - req.flush(expectedHero); - }); - - it('should throw when no entity given', () => { - service.upsert(undefined as any).subscribe( - heroes => fail('add succeeded when expected it to fail'), - err => { - expect(err.error).toMatch(/No "Hero" entity/); - } - ); - }); - }); -}); - -describe('DefaultDataServiceFactory', () => { - const heroUrl = 'api/hero'; - const heroesUrl = 'api/heroes'; - - let http: any; - let httpUrlGenerator: HttpUrlGenerator; - - beforeEach(() => { - httpUrlGenerator = new DefaultHttpUrlGenerator(null as any); - httpUrlGenerator.registerHttpResourceUrls({ - Hero: { - entityResourceUrl: heroUrl, - collectionResourceUrl: heroesUrl, - }, - }); - http = jasmine.createSpyObj('HttpClient', ['get', 'delete', 'post', 'put']); - http.get.and.returnValue(of([])); - }); - - describe('(no config)', () => { - it('can create factory', () => { - const factory = new DefaultDataServiceFactory(http, httpUrlGenerator); - const heroDS = factory.create('Hero'); - expect(heroDS.name).toBe('Hero DefaultDataService'); - }); - - it('should produce hero data service that gets all heroes with expected URL', () => { - const factory = new DefaultDataServiceFactory(http, httpUrlGenerator); - const heroDS = factory.create('Hero'); - heroDS.getAll(); - expect(http.get).toHaveBeenCalledWith('api/heroes', undefined); - }); - }); - - describe('(with config)', () => { - it('can create factory', () => { - const config: DefaultDataServiceConfig = { root: 'api' }; - const factory = new DefaultDataServiceFactory( - http, - httpUrlGenerator, - config - ); - const heroDS = factory.create('Hero'); - expect(heroDS.name).toBe('Hero DefaultDataService'); - }); - - it('should produce hero data service that gets heroes via hero HttpResourceUrls', () => { - const newHeroesUrl = 'some/other/api/heroes'; - const config: DefaultDataServiceConfig = { - root: 'api', - entityHttpResourceUrls: { - Hero: { - entityResourceUrl: heroUrl, - collectionResourceUrl: newHeroesUrl, - }, - }, - }; - const factory = new DefaultDataServiceFactory( - http, - httpUrlGenerator, - config - ); - const heroDS = factory.create('Hero'); - heroDS.getAll(); - expect(http.get).toHaveBeenCalledWith(newHeroesUrl, undefined); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; + +import { HttpClient } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; + +import { of } from 'rxjs'; + +import { Update } from '@ngrx/entity'; + +import { + DefaultDataService, + DefaultDataServiceFactory, + DefaultHttpUrlGenerator, + HttpUrlGenerator, + DefaultDataServiceConfig, + DataServiceError, +} from '../../'; + +class Hero { + id: number; + name: string; + version?: number; +} + +//////// Tests ///////////// +describe('DefaultDataService', () => { + let httpClient: HttpClient; + let httpTestingController: HttpTestingController; + const heroUrl = 'api/hero/'; + const heroesUrl = 'api/heroes/'; + let httpUrlGenerator: HttpUrlGenerator; + let service: DefaultDataService; + + //// HttpClient testing boilerplate + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + httpClient = TestBed.inject(HttpClient); + httpTestingController = TestBed.inject(HttpTestingController); + + httpUrlGenerator = new DefaultHttpUrlGenerator(null as any); + httpUrlGenerator.registerHttpResourceUrls({ + Hero: { + entityResourceUrl: heroUrl, + collectionResourceUrl: heroesUrl, + }, + }); + + service = new DefaultDataService('Hero', httpClient, httpUrlGenerator); + }); + + afterEach(() => { + // After every test, assert that there are no pending requests. + httpTestingController.verify(); + }); + /////////////////// + + describe('property inspection', () => { + // Test wrapper exposes protected properties + class TestService extends DefaultDataService { + properties = { + entityUrl: this.entityUrl, + entitiesUrl: this.entitiesUrl, + getDelay: this.getDelay, + saveDelay: this.saveDelay, + timeout: this.timeout, + }; + } + + // tslint:disable-next-line:no-shadowed-variable + let service: TestService; + + beforeEach(() => { + // use test wrapper class to get to protected properties + service = new TestService('Hero', httpClient, httpUrlGenerator); + }); + + it('has expected name', () => { + expect(service.name).toBe('Hero DefaultDataService'); + }); + + it('has expected single-entity url', () => { + expect(service.properties.entityUrl).toBe(heroUrl); + }); + + it('has expected multiple-entities url', () => { + expect(service.properties.entitiesUrl).toBe(heroesUrl); + }); + }); + + describe('#getAll', () => { + let expectedHeroes: Hero[]; + + beforeEach(() => { + expectedHeroes = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ] as Hero[]; + }); + + it('should return expected heroes (called once)', () => { + service + .getAll() + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + // HeroService should have made one request to GET heroes from expected URL + const req = httpTestingController.expectOne(heroesUrl); + expect(req.request.method).toEqual('GET'); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should be OK returning no heroes', () => { + service + .getAll() + .subscribe( + heroes => + expect(heroes.length).toEqual(0, 'should have empty heroes array'), + fail + ); + + const req = httpTestingController.expectOne(heroesUrl); + req.flush([]); // Respond with no heroes + }); + + it('should return expected heroes (called multiple times)', () => { + service.getAll().subscribe(); + service.getAll().subscribe(); + service + .getAll() + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + const requests = httpTestingController.match(heroesUrl); + expect(requests.length).toEqual(3, 'calls to getAll()'); + + // Respond to each request with different mock hero results + requests[0].flush([]); + requests[1].flush([{ id: 1, name: 'bob' }]); + requests[2].flush(expectedHeroes); + }); + + it('should turn 404 into Observable', () => { + const msg = 'deliberate 404 error'; + + service.getAll().subscribe( + heroes => fail('getAll succeeded when expected it to fail with a 404'), + err => { + expect(err).toBeDefined('request should have failed'); + expect(err instanceof DataServiceError).toBe( + true, + 'is DataServiceError' + ); + expect(err.error.status).toEqual(404, 'has 404 status'); + expect(err.message).toEqual(msg, 'has expected error message'); + } + ); + + const req = httpTestingController.expectOne(heroesUrl); + + const errorEvent = { + // Source of the service's not-so-friendly user-facing message + message: msg, + + // The rest of this is optional and not used. Just showing that you could. + filename: 'DefaultDataService.ts', + lineno: 42, + colno: 21, + } as ErrorEvent; + + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + }); + + describe('#getById', () => { + let expectedHero: Hero; + const heroUrlId1 = heroUrl + '1'; + + it('should return expected hero when id is found', () => { + expectedHero = { id: 1, name: 'A' }; + + service + .getById(1) + .subscribe( + hero => + expect(hero).toEqual(expectedHero, 'should return expected hero'), + fail + ); + + // One request to GET hero from expected URL + const req = httpTestingController.expectOne(heroUrlId1); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should turn 404 when id not found', () => { + service.getById(1).subscribe( + heroes => fail('getById succeeded when expected it to fail with a 404'), + err => { + expect(err instanceof DataServiceError).toBe(true); + } + ); + + const req = httpTestingController.expectOne(heroUrlId1); + const errorEvent = { message: 'boom!' } as ErrorEvent; + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + + it('should throw when no id given', () => { + service.getById(undefined as any).subscribe( + heroes => fail('getById succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" key/); + } + ); + }); + }); + + describe('#getWithQuery', () => { + let expectedHeroes: Hero[]; + + beforeEach(() => { + expectedHeroes = [ + { id: 1, name: 'BA' }, + { id: 2, name: 'BB' }, + ] as Hero[]; + }); + + it('should return expected selected heroes w/ object params', () => { + service + .getWithQuery({ name: 'B' }) + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + // HeroService should have made one request to GET heroes + // from expected URL with query params + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + expect(req.request.method).toEqual('GET'); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should return expected selected heroes w/ string params', () => { + service + .getWithQuery('name=B') + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + // HeroService should have made one request to GET heroes + // from expected URL with query params + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + expect(req.request.method).toEqual('GET'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should be OK returning no heroes', () => { + service + .getWithQuery({ name: 'B' }) + .subscribe( + heroes => + expect(heroes.length).toEqual(0, 'should have empty heroes array'), + fail + ); + + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + req.flush([]); // Respond with no heroes + }); + + it('should turn 404 into Observable', () => { + const msg = 'deliberate 404 error'; + + service.getWithQuery({ name: 'B' }).subscribe( + heroes => + fail('getWithQuery succeeded when expected it to fail with a 404'), + err => { + expect(err).toBeDefined('request should have failed'); + expect(err instanceof DataServiceError).toBe( + true, + 'is DataServiceError' + ); + expect(err.error.status).toEqual(404, 'has 404 status'); + expect(err.message).toEqual(msg, 'has expected error message'); + } + ); + + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + + const errorEvent = { message: msg } as ErrorEvent; + + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + }); + + describe('#add', () => { + let expectedHero: Hero; + + it('should return expected hero with id', () => { + expectedHero = { id: 42, name: 'A' }; + const heroData: Hero = { id: undefined, name: 'A' } as any; + + service + .add(heroData) + .subscribe( + hero => + expect(hero).toEqual(expectedHero, 'should return expected hero'), + fail + ); + + // One request to POST hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'POST' && r.url === heroUrl + ); + + expect(req.request.body).toEqual(heroData, 'should send entity data'); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should throw when no entity given', () => { + service.add(undefined as any).subscribe( + heroes => fail('add succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" entity/); + } + ); + }); + }); + + describe('#delete', () => { + const heroUrlId1 = heroUrl + '1'; + + it('should delete by hero id', () => { + service + .delete(1) + .subscribe( + result => + expect(result).toEqual(1, 'should return the deleted entity id'), + fail + ); + + // One request to DELETE hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'DELETE' && r.url === heroUrlId1 + ); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with empty nonsense object + req.flush({}); + }); + + it('should return successfully when id not found and delete404OK is true (default)', () => { + service + .delete(1) + .subscribe( + result => + expect(result).toEqual(1, 'should return the deleted entity id'), + fail + ); + + // One request to DELETE hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'DELETE' && r.url === heroUrlId1 + ); + + // Respond with empty nonsense object + req.flush({}); + }); + + it('should return 404 when id not found and delete404OK is false', () => { + service = new DefaultDataService('Hero', httpClient, httpUrlGenerator, { + delete404OK: false, + }); + service.delete(1).subscribe( + heroes => fail('delete succeeded when expected it to fail with a 404'), + err => { + expect(err instanceof DataServiceError).toBe(true); + } + ); + + const req = httpTestingController.expectOne(heroUrlId1); + const errorEvent = { message: 'boom!' } as ErrorEvent; + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + + it('should throw when no id given', () => { + service.delete(undefined as any).subscribe( + heroes => fail('delete succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" key/); + } + ); + }); + }); + + describe('#update', () => { + const heroUrlId1 = heroUrl + '1'; + + it('should return expected hero with id', () => { + // Call service.update with an Update arg + const updateArg: Update = { + id: 1, + changes: { id: 1, name: 'B' }, + }; + + // The server makes the update AND updates the version concurrency property. + const expectedHero: Hero = { id: 1, name: 'B', version: 2 }; + + service + .update(updateArg) + .subscribe( + updated => + expect(updated).toEqual( + expectedHero, + 'should return the expected hero' + ), + fail + ); + + // One request to PUT hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'PUT' && r.url === heroUrlId1 + ); + + expect(req.request.body).toEqual( + updateArg.changes, + 'should send update entity data' + ); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should return 404 when id not found', () => { + service.update({ id: 1, changes: { id: 1, name: 'B' } }).subscribe( + update => fail('update succeeded when expected it to fail with a 404'), + err => { + expect(err instanceof DataServiceError).toBe(true); + } + ); + + const req = httpTestingController.expectOne(heroUrlId1); + const errorEvent = { message: 'boom!' } as ErrorEvent; + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + + it('should throw when no update given', () => { + service.update(undefined as any).subscribe( + heroes => fail('update succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" update data/); + } + ); + }); + }); + + describe('#upsert', () => { + let expectedHero: Hero; + + it('should return expected hero with id', () => { + expectedHero = { id: 42, name: 'A' }; + const heroData: Hero = { id: undefined, name: 'A' } as any; + + service + .upsert(heroData) + .subscribe( + hero => + expect(hero).toEqual(expectedHero, 'should return expected hero'), + fail + ); + + // One request to POST hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'POST' && r.url === heroUrl + ); + + expect(req.request.body).toEqual(heroData, 'should send entity data'); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should throw when no entity given', () => { + service.upsert(undefined as any).subscribe( + heroes => fail('add succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" entity/); + } + ); + }); + }); +}); + +describe('DefaultDataServiceFactory', () => { + const heroUrl = 'api/hero'; + const heroesUrl = 'api/heroes'; + + let http: any; + let httpUrlGenerator: HttpUrlGenerator; + + beforeEach(() => { + httpUrlGenerator = new DefaultHttpUrlGenerator(null as any); + httpUrlGenerator.registerHttpResourceUrls({ + Hero: { + entityResourceUrl: heroUrl, + collectionResourceUrl: heroesUrl, + }, + }); + http = jasmine.createSpyObj('HttpClient', ['get', 'delete', 'post', 'put']); + http.get.and.returnValue(of([])); + }); + + describe('(no config)', () => { + it('can create factory', () => { + const factory = new DefaultDataServiceFactory(http, httpUrlGenerator); + const heroDS = factory.create('Hero'); + expect(heroDS.name).toBe('Hero DefaultDataService'); + }); + + it('should produce hero data service that gets all heroes with expected URL', () => { + const factory = new DefaultDataServiceFactory(http, httpUrlGenerator); + const heroDS = factory.create('Hero'); + heroDS.getAll(); + expect(http.get).toHaveBeenCalledWith('api/heroes', undefined); + }); + }); + + describe('(with config)', () => { + it('can create factory', () => { + const config: DefaultDataServiceConfig = { root: 'api' }; + const factory = new DefaultDataServiceFactory( + http, + httpUrlGenerator, + config + ); + const heroDS = factory.create('Hero'); + expect(heroDS.name).toBe('Hero DefaultDataService'); + }); + + it('should produce hero data service that gets heroes via hero HttpResourceUrls', () => { + const newHeroesUrl = 'some/other/api/heroes'; + const config: DefaultDataServiceConfig = { + root: 'api', + entityHttpResourceUrls: { + Hero: { + entityResourceUrl: heroUrl, + collectionResourceUrl: newHeroesUrl, + }, + }, + }; + const factory = new DefaultDataServiceFactory( + http, + httpUrlGenerator, + config + ); + const heroDS = factory.create('Hero'); + heroDS.getAll(); + expect(http.get).toHaveBeenCalledWith(newHeroesUrl, undefined); + }); + }); +}); diff --git a/modules/data/spec/dataservices/entity-data.service.spec.ts b/modules/data/spec/dataservices/entity-data.service.spec.ts index 51bb5d2736..7363249956 100644 --- a/modules/data/spec/dataservices/entity-data.service.spec.ts +++ b/modules/data/spec/dataservices/entity-data.service.spec.ts @@ -1,175 +1,175 @@ -import { NgModule, Optional } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { HttpClient } from '@angular/common/http'; - -import { Observable } from 'rxjs'; - -import { Update } from '@ngrx/entity'; - -import { - DefaultDataService, - DefaultDataServiceFactory, - HttpUrlGenerator, - EntityHttpResourceUrls, - EntityDataService, - EntityCollectionDataService, - QueryParams, -} from '../../'; - -// region Test Helpers -///// Test Helpers ///// - -export class CustomDataService { - name: string; - constructor(name: string) { - this.name = name + ' CustomDataService'; - } -} - -export class Bazinga { - id: number; - wow: string; -} - -export class BazingaDataService - implements EntityCollectionDataService { - name: string; - - // TestBed bug requires `@Optional` even though http is always provided. - constructor(@Optional() private http: HttpClient) { - if (!http) { - throw new Error('Where is HttpClient?'); - } - this.name = 'Bazinga custom data service'; - } - - add(entity: Bazinga): Observable { - return this.bazinga(); - } - delete(id: any): Observable { - return this.bazinga(); - } - getAll(): Observable { - return this.bazinga(); - } - getById(id: any): Observable { - return this.bazinga(); - } - getWithQuery(params: string | QueryParams): Observable { - return this.bazinga(); - } - update(update: Update): Observable { - return this.bazinga(); - } - upsert(entity: Bazinga): Observable { - return this.bazinga(); - } - - private bazinga(): any { - bazingaFail(); - return undefined; - } -} - -@NgModule({ - providers: [BazingaDataService], -}) -export class CustomDataServiceModule { - constructor( - entityDataService: EntityDataService, - bazingaService: BazingaDataService - ) { - entityDataService.registerService('Bazinga', bazingaService); - } -} - -function bazingaFail() { - throw new Error('Bazinga! This method is not implemented.'); -} - -/** Test version always returns canned Hero resource base URLs */ -class TestHttpUrlGenerator implements HttpUrlGenerator { - entityResource(entityName: string, root: string): string { - return 'api/hero/'; - } - collectionResource(entityName: string, root: string): string { - return 'api/heroes/'; - } - registerHttpResourceUrls( - entityHttpResourceUrls: EntityHttpResourceUrls - ): void {} -} - -// endregion - -///// Tests begin //// -describe('EntityDataService', () => { - const nullHttp = {}; - let entityDataService: EntityDataService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [CustomDataServiceModule], - providers: [ - DefaultDataServiceFactory, - EntityDataService, - { provide: HttpClient, useValue: nullHttp }, - { provide: HttpUrlGenerator, useClass: TestHttpUrlGenerator }, - ], - }); - entityDataService = TestBed.get(EntityDataService); - }); - - describe('#getService', () => { - it('can create a data service for "Hero" entity', () => { - const service = entityDataService.getService('Hero'); - expect(service).toBeDefined(); - }); - - it('data service should be a DefaultDataService by default', () => { - const service = entityDataService.getService('Hero'); - expect(service instanceof DefaultDataService).toBe(true); - }); - - it('gets the same service every time you ask for it', () => { - const service1 = entityDataService.getService('Hero'); - const service2 = entityDataService.getService('Hero'); - expect(service1).toBe(service2); - }); - }); - - describe('#register...', () => { - it('can register a custom service for "Hero"', () => { - const customService: any = new CustomDataService('Hero'); - entityDataService.registerService('Hero', customService); - - const service = entityDataService.getService('Hero'); - expect(service).toBe(customService); - }); - - it('can register multiple custom services at the same time', () => { - const customHeroService: any = new CustomDataService('Hero'); - const customVillainService: any = new CustomDataService('Villain'); - entityDataService.registerServices({ - Hero: customHeroService, - Villain: customVillainService, - }); - - let service = entityDataService.getService('Hero'); - expect(service).toBe(customHeroService, 'custom Hero data service'); - expect(service.name).toBe('Hero CustomDataService'); - - service = entityDataService.getService('Villain'); - expect(service).toBe(customVillainService, 'custom Villain data service'); - - // Other services are still DefaultDataServices - service = entityDataService.getService('Foo'); - expect(service.name).toBe('Foo DefaultDataService'); - }); - - it('can register a custom service using a module import', () => { - const service = entityDataService.getService('Bazinga'); - expect(service instanceof BazingaDataService).toBe(true); - }); - }); -}); +import { NgModule, Optional } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { Update } from '@ngrx/entity'; + +import { + DefaultDataService, + DefaultDataServiceFactory, + HttpUrlGenerator, + EntityHttpResourceUrls, + EntityDataService, + EntityCollectionDataService, + QueryParams, +} from '../../'; + +// region Test Helpers +///// Test Helpers ///// + +export class CustomDataService { + name: string; + constructor(name: string) { + this.name = name + ' CustomDataService'; + } +} + +export class Bazinga { + id: number; + wow: string; +} + +export class BazingaDataService + implements EntityCollectionDataService { + name: string; + + // TestBed bug requires `@Optional` even though http is always provided. + constructor(@Optional() private http: HttpClient) { + if (!http) { + throw new Error('Where is HttpClient?'); + } + this.name = 'Bazinga custom data service'; + } + + add(entity: Bazinga): Observable { + return this.bazinga(); + } + delete(id: any): Observable { + return this.bazinga(); + } + getAll(): Observable { + return this.bazinga(); + } + getById(id: any): Observable { + return this.bazinga(); + } + getWithQuery(params: string | QueryParams): Observable { + return this.bazinga(); + } + update(update: Update): Observable { + return this.bazinga(); + } + upsert(entity: Bazinga): Observable { + return this.bazinga(); + } + + private bazinga(): any { + bazingaFail(); + return undefined; + } +} + +@NgModule({ + providers: [BazingaDataService], +}) +export class CustomDataServiceModule { + constructor( + entityDataService: EntityDataService, + bazingaService: BazingaDataService + ) { + entityDataService.registerService('Bazinga', bazingaService); + } +} + +function bazingaFail() { + throw new Error('Bazinga! This method is not implemented.'); +} + +/** Test version always returns canned Hero resource base URLs */ +class TestHttpUrlGenerator implements HttpUrlGenerator { + entityResource(entityName: string, root: string): string { + return 'api/hero/'; + } + collectionResource(entityName: string, root: string): string { + return 'api/heroes/'; + } + registerHttpResourceUrls( + entityHttpResourceUrls: EntityHttpResourceUrls + ): void {} +} + +// endregion + +///// Tests begin //// +describe('EntityDataService', () => { + const nullHttp = {}; + let entityDataService: EntityDataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CustomDataServiceModule], + providers: [ + DefaultDataServiceFactory, + EntityDataService, + { provide: HttpClient, useValue: nullHttp }, + { provide: HttpUrlGenerator, useClass: TestHttpUrlGenerator }, + ], + }); + entityDataService = TestBed.inject(EntityDataService); + }); + + describe('#getService', () => { + it('can create a data service for "Hero" entity', () => { + const service = entityDataService.getService('Hero'); + expect(service).toBeDefined(); + }); + + it('data service should be a DefaultDataService by default', () => { + const service = entityDataService.getService('Hero'); + expect(service instanceof DefaultDataService).toBe(true); + }); + + it('gets the same service every time you ask for it', () => { + const service1 = entityDataService.getService('Hero'); + const service2 = entityDataService.getService('Hero'); + expect(service1).toBe(service2); + }); + }); + + describe('#register...', () => { + it('can register a custom service for "Hero"', () => { + const customService: any = new CustomDataService('Hero'); + entityDataService.registerService('Hero', customService); + + const service = entityDataService.getService('Hero'); + expect(service).toBe(customService); + }); + + it('can register multiple custom services at the same time', () => { + const customHeroService: any = new CustomDataService('Hero'); + const customVillainService: any = new CustomDataService('Villain'); + entityDataService.registerServices({ + Hero: customHeroService, + Villain: customVillainService, + }); + + let service = entityDataService.getService('Hero'); + expect(service).toBe(customHeroService, 'custom Hero data service'); + expect(service.name).toBe('Hero CustomDataService'); + + service = entityDataService.getService('Villain'); + expect(service).toBe(customVillainService, 'custom Villain data service'); + + // Other services are still DefaultDataServices + service = entityDataService.getService('Foo'); + expect(service.name).toBe('Foo DefaultDataService'); + }); + + it('can register a custom service using a module import', () => { + const service = entityDataService.getService('Bazinga'); + expect(service instanceof BazingaDataService).toBe(true); + }); + }); +}); diff --git a/modules/data/spec/effects/entity-cache-effects.spec.ts b/modules/data/spec/effects/entity-cache-effects.spec.ts index 14210a0fe9..756467c56c 100644 --- a/modules/data/spec/effects/entity-cache-effects.spec.ts +++ b/modules/data/spec/effects/entity-cache-effects.spec.ts @@ -1,211 +1,211 @@ -// Not using marble testing -import { TestBed } from '@angular/core/testing'; -import { Action } from '@ngrx/store'; -import { Actions } from '@ngrx/effects'; -import { observeOn } from 'rxjs/operators'; -import { asapScheduler, ReplaySubject, Subject } from 'rxjs'; - -import { - EntityCacheEffects, - EntityActionFactory, - EntityCacheDataService, - SaveEntities, - SaveEntitiesSuccess, - SaveEntitiesCancel, - SaveEntitiesCanceled, - SaveEntitiesError, - HttpMethods, - DataServiceError, - ChangeSet, - ChangeSetItem, - ChangeSetOperation, - Logger, - MergeStrategy, -} from '../..'; - -describe('EntityCacheEffects (normal testing)', () => { - let actions$: ReplaySubject; - let correlationId: string; - let dataService: TestEntityCacheDataService; - let effects: EntityCacheEffects; - let logger: Logger; - let mergeStrategy: MergeStrategy | undefined; - let options: { - correlationId: typeof correlationId; - mergeStrategy: typeof mergeStrategy; - }; - - function expectCompletion(completion: any, done: DoneFn) { - effects.saveEntities$.subscribe(result => { - expect(result).toEqual(completion); - done(); - }, fail); - } - - beforeEach(() => { - actions$ = new ReplaySubject(1); - correlationId = 'CORID42'; - logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - mergeStrategy = undefined; - options = { correlationId, mergeStrategy }; - - const eaFactory = new EntityActionFactory(); // doesn't change. - - TestBed.configureTestingModule({ - providers: [ - EntityCacheEffects, - { provide: EntityActionFactory, useValue: eaFactory }, - { provide: Actions, useValue: actions$ }, - /* tslint:disable-next-line:no-use-before-declare */ - { - provide: EntityCacheDataService, - useClass: TestEntityCacheDataService, - }, - { provide: Logger, useValue: logger }, - ], - }); - - actions$ = TestBed.get(Actions); - effects = TestBed.get(EntityCacheEffects); - dataService = TestBed.get(EntityCacheDataService); - }); - - it('should return a SAVE_ENTITIES_SUCCESS with the expected ChangeSet on success', (done: DoneFn) => { - const cs = createChangeSet(); - const action = new SaveEntities(cs, 'test/save', options); - const completion = new SaveEntitiesSuccess(cs, 'test/save', options); - - expectCompletion(completion, done); - - actions$.next(action); - dataService.setResponse(cs); - }); - - it('should not emit SAVE_ENTITIES_SUCCESS if cancel arrives in time', (done: DoneFn) => { - const cs = createChangeSet(); - const action = new SaveEntities(cs, 'test/save', options); - const cancel = new SaveEntitiesCancel(correlationId, 'Test Cancel'); - - effects.saveEntities$.subscribe(result => { - expect(result instanceof SaveEntitiesSuccess).toBe(false); - expect(result instanceof SaveEntitiesCanceled).toBe(true); // instead - done(); - }, done.fail); - - actions$.next(action); - actions$.next(cancel); - dataService.setResponse(cs); - }); - - it('should emit SAVE_ENTITIES_SUCCESS if cancel arrives too late', (done: DoneFn) => { - const cs = createChangeSet(); - const action = new SaveEntities(cs, 'test/save', options); - const cancel = new SaveEntitiesCancel(correlationId, 'Test Cancel'); - - effects.saveEntities$.subscribe(result => { - expect(result instanceof SaveEntitiesSuccess).toBe(true); - done(); - }, done.fail); - - actions$.next(action); - dataService.setResponse(cs); - setTimeout(() => actions$.next(cancel), 1); - }); - - it('should emit SAVE_ENTITIES_SUCCESS immediately if no changes to save', (done: DoneFn) => { - const action = new SaveEntities({ changes: [] }, 'test/save', options); - effects.saveEntities$.subscribe(result => { - expect(result instanceof SaveEntitiesSuccess).toBe(true); - expect(dataService.saveEntities).not.toHaveBeenCalled(); - done(); - }, done.fail); - actions$.next(action); - }); - - xit('should return a SAVE_ENTITIES_ERROR when data service fails', (done: DoneFn) => { - const cs = createChangeSet(); - const action = new SaveEntities(cs, 'test/save', options); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('POST', httpError); - const completion = new SaveEntitiesError(error, action); - - expectCompletion(completion, done); - - actions$.next(action); - dataService.setErrorResponse(error); - }); -}); - -// #region test helpers -export class TestEntityCacheDataService { - response$ = new Subject(); - - saveEntities = jasmine - .createSpy('saveEntities') - .and.returnValue(this.response$.pipe(observeOn(asapScheduler))); - - setResponse(data: any) { - this.response$.next(data); - } - - setErrorResponse(error: any) { - this.response$.error(error); - } -} - -/** make error produced by the EntityDataService */ -function makeDataServiceError( - /** Http method for that action */ - method: HttpMethods, - /** Http error from the web api */ - httpError?: any, - /** Options sent with the request */ - options?: any -) { - let url = 'test/save'; - if (httpError) { - url = httpError.url || url; - } else { - httpError = { error: new Error('Test error'), status: 500, url }; - } - return new DataServiceError(httpError, { method, url, options }); -} - -function createChangeSet(): ChangeSet { - const changes: ChangeSetItem[] = [ - { - op: ChangeSetOperation.Add, - entityName: 'Hero', - entities: [{ id: 1, name: 'A1 Add' }], - }, - { - op: ChangeSetOperation.Delete, - entityName: 'Hero', - entities: [2, 3], - }, - { - op: ChangeSetOperation.Update, - entityName: 'Villain', - entities: [ - { id: 4, changes: { id: 4, name: 'V4 Update' } }, - { id: 5, changes: { id: 5, name: 'V5 Update' } }, - { id: 6, changes: { id: 6, name: 'V6 Update' } }, - ], - }, - { - op: ChangeSetOperation.Upsert, - entityName: 'Villain', - entities: [ - { id: 7, name: 'V7 Upsert new' }, - { id: 4, name: 'V4 Upsert existing' }, - ], - }, - ]; - - return { - changes, - extras: { foo: 'anything' }, - tag: 'Test', - }; -} -// #endregion test helpers +// Not using marble testing +import { TestBed } from '@angular/core/testing'; +import { Action } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; +import { observeOn } from 'rxjs/operators'; +import { asapScheduler, ReplaySubject, Subject } from 'rxjs'; + +import { + EntityCacheEffects, + EntityActionFactory, + EntityCacheDataService, + SaveEntities, + SaveEntitiesSuccess, + SaveEntitiesCancel, + SaveEntitiesCanceled, + SaveEntitiesError, + HttpMethods, + DataServiceError, + ChangeSet, + ChangeSetItem, + ChangeSetOperation, + Logger, + MergeStrategy, +} from '../..'; + +describe('EntityCacheEffects (normal testing)', () => { + let actions$: ReplaySubject; + let correlationId: string; + let dataService: TestEntityCacheDataService; + let effects: EntityCacheEffects; + let logger: Logger; + let mergeStrategy: MergeStrategy | undefined; + let options: { + correlationId: typeof correlationId; + mergeStrategy: typeof mergeStrategy; + }; + + function expectCompletion(completion: any, done: DoneFn) { + effects.saveEntities$.subscribe(result => { + expect(result).toEqual(completion); + done(); + }, fail); + } + + beforeEach(() => { + actions$ = new ReplaySubject(1); + correlationId = 'CORID42'; + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + mergeStrategy = undefined; + options = { correlationId, mergeStrategy }; + + const eaFactory = new EntityActionFactory(); // doesn't change. + + TestBed.configureTestingModule({ + providers: [ + EntityCacheEffects, + { provide: EntityActionFactory, useValue: eaFactory }, + { provide: Actions, useValue: actions$ }, + /* tslint:disable-next-line:no-use-before-declare */ + { + provide: EntityCacheDataService, + useClass: TestEntityCacheDataService, + }, + { provide: Logger, useValue: logger }, + ], + }); + + actions$ = TestBed.inject(Actions); + effects = TestBed.inject(EntityCacheEffects); + dataService = TestBed.inject(EntityCacheDataService); + }); + + it('should return a SAVE_ENTITIES_SUCCESS with the expected ChangeSet on success', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const completion = new SaveEntitiesSuccess(cs, 'test/save', options); + + expectCompletion(completion, done); + + actions$.next(action); + dataService.setResponse(cs); + }); + + it('should not emit SAVE_ENTITIES_SUCCESS if cancel arrives in time', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const cancel = new SaveEntitiesCancel(correlationId, 'Test Cancel'); + + effects.saveEntities$.subscribe(result => { + expect(result instanceof SaveEntitiesSuccess).toBe(false); + expect(result instanceof SaveEntitiesCanceled).toBe(true); // instead + done(); + }, done.fail); + + actions$.next(action); + actions$.next(cancel); + dataService.setResponse(cs); + }); + + it('should emit SAVE_ENTITIES_SUCCESS if cancel arrives too late', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const cancel = new SaveEntitiesCancel(correlationId, 'Test Cancel'); + + effects.saveEntities$.subscribe(result => { + expect(result instanceof SaveEntitiesSuccess).toBe(true); + done(); + }, done.fail); + + actions$.next(action); + dataService.setResponse(cs); + setTimeout(() => actions$.next(cancel), 1); + }); + + it('should emit SAVE_ENTITIES_SUCCESS immediately if no changes to save', (done: DoneFn) => { + const action = new SaveEntities({ changes: [] }, 'test/save', options); + effects.saveEntities$.subscribe(result => { + expect(result instanceof SaveEntitiesSuccess).toBe(true); + expect(dataService.saveEntities).not.toHaveBeenCalled(); + done(); + }, done.fail); + actions$.next(action); + }); + + xit('should return a SAVE_ENTITIES_ERROR when data service fails', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('POST', httpError); + const completion = new SaveEntitiesError(error, action); + + expectCompletion(completion, done); + + actions$.next(action); + dataService.setErrorResponse(error); + }); +}); + +// #region test helpers +export class TestEntityCacheDataService { + response$ = new Subject(); + + saveEntities = jasmine + .createSpy('saveEntities') + .and.returnValue(this.response$.pipe(observeOn(asapScheduler))); + + setResponse(data: any) { + this.response$.next(data); + } + + setErrorResponse(error: any) { + this.response$.error(error); + } +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'test/save'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +function createChangeSet(): ChangeSet { + const changes: ChangeSetItem[] = [ + { + op: ChangeSetOperation.Add, + entityName: 'Hero', + entities: [{ id: 1, name: 'A1 Add' }], + }, + { + op: ChangeSetOperation.Delete, + entityName: 'Hero', + entities: [2, 3], + }, + { + op: ChangeSetOperation.Update, + entityName: 'Villain', + entities: [ + { id: 4, changes: { id: 4, name: 'V4 Update' } }, + { id: 5, changes: { id: 5, name: 'V5 Update' } }, + { id: 6, changes: { id: 6, name: 'V6 Update' } }, + ], + }, + { + op: ChangeSetOperation.Upsert, + entityName: 'Villain', + entities: [ + { id: 7, name: 'V7 Upsert new' }, + { id: 4, name: 'V4 Upsert existing' }, + ], + }, + ]; + + return { + changes, + extras: { foo: 'anything' }, + tag: 'Test', + }; +} +// #endregion test helpers diff --git a/modules/data/spec/effects/entity-effects.marbles.spec.ts b/modules/data/spec/effects/entity-effects.marbles.spec.ts index 32094c00f2..15524b1312 100644 --- a/modules/data/spec/effects/entity-effects.marbles.spec.ts +++ b/modules/data/spec/effects/entity-effects.marbles.spec.ts @@ -1,520 +1,520 @@ -// Using marble testing -import { TestBed } from '@angular/core/testing'; - -import { cold, hot, getTestScheduler } from 'jasmine-marbles'; -import { Observable } from 'rxjs'; - -import { Actions } from '@ngrx/effects'; -import { Update } from '@ngrx/entity'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { - EntityEffects, - EntityActionFactory, - EntityDataService, - PersistenceResultHandler, - DefaultPersistenceResultHandler, - EntityOp, - HttpMethods, - DataServiceError, - EntityAction, - makeErrorOp, - EntityActionDataServiceError, - Logger, -} from '../..'; -import { ENTITY_EFFECTS_SCHEDULER } from '../../src/effects/entity-effects-scheduler'; - -//////// Tests begin //////// -describe('EntityEffects (marble testing)', () => { - let effects: EntityEffects; - let entityActionFactory: EntityActionFactory; - let dataService: TestDataService; - let actions: Observable; - let logger: Logger; - - beforeEach(() => { - logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - - TestBed.configureTestingModule({ - providers: [ - EntityEffects, - provideMockActions(() => actions), - EntityActionFactory, - // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md - { provide: ENTITY_EFFECTS_SCHEDULER, useFactory: getTestScheduler }, - /* tslint:disable-next-line:no-use-before-declare */ - { provide: EntityDataService, useClass: TestDataService }, - { provide: Logger, useValue: logger }, - { - provide: PersistenceResultHandler, - useClass: DefaultPersistenceResultHandler, - }, - ], - }); - actions = TestBed.get(Actions); - dataService = TestBed.get(EntityDataService); - entityActionFactory = TestBed.get(EntityActionFactory); - effects = TestBed.get(EntityEffects); - }); - - it('should return a QUERY_ALL_SUCCESS with the heroes on success', () => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - - const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_ALL_SUCCESS, - heroes - ); - - const x = hot('-a---', { a: action }); - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: heroes }); - const expected = cold('----b', { b: completion }); - dataService.getAll.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a QUERY_ALL_ERROR when data service fails', () => { - const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('GET', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('------b', { b: completion }); - dataService.getAll.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); - }); - - it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', () => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY_SUCCESS, - hero - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: hero }); - const expected = cold('----b', { b: completion }); - dataService.getById.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a QUERY_BY_KEY_ERROR when data service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY, - 42 - ); - const httpError = { error: new Error('Entity not found'), status: 404 }; - const error = makeDataServiceError('GET', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('------b', { b: completion }); - dataService.getById.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a QUERY_MANY_SUCCESS with selected heroes on success', () => { - const hero1 = { id: 1, name: 'BA' } as Hero; - const hero2 = { id: 2, name: 'BB' } as Hero; - const heroes = [hero1, hero2]; - - const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { - name: 'B', - }); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_MANY_SUCCESS, - heroes - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: heroes }); - const expected = cold('----b', { b: completion }); - dataService.getWithQuery.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a QUERY_MANY_ERROR when data service fails', () => { - const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { - name: 'B', - }); - const httpError = { error: new Error('Resource not found'), status: 404 }; - const error = makeDataServiceError('GET', httpError, { - name: 'B', - }); - const completion = makeEntityErrorCompletion(action, error); - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('------b', { b: completion }); - dataService.getWithQuery.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_MANY_ERROR); - }); - - it('should return a SAVE_ADD_ONE_SUCCESS (optimistic) with the hero on success', () => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_SUCCESS, - hero, - { isOptimistic: true } - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: hero }); - const expected = cold('----b', { b: completion }); - dataService.add.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_ADD_ONE_SUCCESS (pessimistic) with the hero on success', () => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_SUCCESS, - hero - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: hero }); - const expected = cold('----b', { b: completion }); - dataService.add.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_ADD_ONE_ERROR when data service fails', () => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('PUT', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('------b', { b: completion }); - dataService.add.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) with the delete id on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_SUCCESS, - 42, - { isOptimistic: true } - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: 42 }); - const expected = cold('----b', { b: completion }); - dataService.delete.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_SUCCESS, - 42 - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: 42 }); - const expected = cold('----b', { b: completion }); - dataService.delete.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_DELETE_ONE_ERROR when data service fails', () => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('DELETE', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('------b', { b: completion }); - dataService.delete.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', () => { - const updateEntity = { id: 1, name: 'A' }; - const update = { id: 1, changes: updateEntity } as Update; - const updateResponse = { ...update, changed: true }; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_SUCCESS, - updateResponse, - { isOptimistic: true } - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: updateEntity }); - const expected = cold('----b', { b: completion }); - dataService.update.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', () => { - const updateEntity = { id: 1, name: 'A' }; - const update = { id: 1, changes: updateEntity } as Update; - const updateResponse = { ...update, changed: true }; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_SUCCESS, - updateResponse - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: updateEntity }); - const expected = cold('----b', { b: completion }); - dataService.update.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', () => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('PUT', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('------b', { b: completion }); - dataService.update.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_UPSERT_ONE_SUCCESS (optimistic) with the hero on success', () => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE, - hero, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE_SUCCESS, - hero, - { isOptimistic: true } - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: hero }); - const expected = cold('----b', { b: completion }); - dataService.upsert.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_UPSERT_ONE_SUCCESS (pessimistic) with the hero on success', () => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE_SUCCESS, - hero - ); - - actions = hot('-a---', { a: action }); - // delay the response 3 frames - const response = cold('---a|', { a: hero }); - const expected = cold('----b', { b: completion }); - dataService.upsert.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it('should return a SAVE_UPSERT_ONE_ERROR when data service fails', () => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE, - hero - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('POST', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions = hot('-a---', { a: action }); - const response = cold('----#|', {}, error); - const expected = cold('------b', { b: completion }); - dataService.upsert.and.returnValue(response); - - expect(effects.persist$).toBeObservable(expected); - }); - - it(`should not do anything with an irrelevant action`, () => { - // Would clear the cached collection - const action = entityActionFactory.create('Hero', EntityOp.REMOVE_ALL); - - actions = hot('-a---', { a: action }); - const expected = cold('---'); - - expect(effects.persist$).toBeObservable(expected); - }); -}); - -// #region test helpers -export class Hero { - id: number; - name: string; -} - -/** make error produced by the EntityDataService */ -function makeDataServiceError( - /** Http method for that action */ - method: HttpMethods, - /** Http error from the web api */ - httpError?: any, - /** Options sent with the request */ - options?: any -) { - let url = 'api/heroes'; - if (httpError) { - url = httpError.url || url; - } else { - httpError = { error: new Error('Test error'), status: 500, url }; - } - return new DataServiceError(httpError, { method, url, options }); -} - -/** Make an EntityDataService error */ -function makeEntityErrorCompletion( - /** The action that initiated the data service call */ - originalAction: EntityAction, - /** error produced by the EntityDataService */ - error: DataServiceError -) { - const errOp = makeErrorOp(originalAction.payload.entityOp); - - // Entity Error Action - const eaFactory = new EntityActionFactory(); - return eaFactory.create('Hero', errOp, { - originalAction, - error, - }); -} - -export interface TestDataServiceMethod { - add: jasmine.Spy; - delete: jasmine.Spy; - getAll: jasmine.Spy; - getById: jasmine.Spy; - getWithQuery: jasmine.Spy; - update: jasmine.Spy; - upsert: jasmine.Spy; -} - -export class TestDataService { - add = jasmine.createSpy('add'); - delete = jasmine.createSpy('delete'); - getAll = jasmine.createSpy('getAll'); - getById = jasmine.createSpy('getById'); - getWithQuery = jasmine.createSpy('getWithQuery'); - update = jasmine.createSpy('update'); - upsert = jasmine.createSpy('upsert'); - - getService(): TestDataServiceMethod { - return this; - } - - setResponse(methodName: keyof TestDataServiceMethod, data$: Observable) { - this[methodName].and.returnValue(data$); - } -} -// #endregion test helpers +// Using marble testing +import { TestBed } from '@angular/core/testing'; + +import { cold, hot, getTestScheduler } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + +import { Actions } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { + EntityEffects, + EntityActionFactory, + EntityDataService, + PersistenceResultHandler, + DefaultPersistenceResultHandler, + EntityOp, + HttpMethods, + DataServiceError, + EntityAction, + makeErrorOp, + EntityActionDataServiceError, + Logger, +} from '../..'; +import { ENTITY_EFFECTS_SCHEDULER } from '../../src/effects/entity-effects-scheduler'; + +//////// Tests begin //////// +describe('EntityEffects (marble testing)', () => { + let effects: EntityEffects; + let entityActionFactory: EntityActionFactory; + let dataService: TestDataService; + let actions: Observable; + let logger: Logger; + + beforeEach(() => { + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityEffects, + provideMockActions(() => actions), + EntityActionFactory, + // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md + { provide: ENTITY_EFFECTS_SCHEDULER, useFactory: getTestScheduler }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, + { provide: Logger, useValue: logger }, + { + provide: PersistenceResultHandler, + useClass: DefaultPersistenceResultHandler, + }, + ], + }); + actions = TestBed.inject(Actions); + dataService = TestBed.inject(EntityDataService); + entityActionFactory = TestBed.inject(EntityActionFactory); + effects = TestBed.inject(EntityEffects); + }); + + it('should return a QUERY_ALL_SUCCESS with the heroes on success', () => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL_SUCCESS, + heroes + ); + + const x = hot('-a---', { a: action }); + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: heroes }); + const expected = cold('----b', { b: completion }); + dataService.getAll.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_ALL_ERROR when data service fails', () => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.getAll.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); + }); + + it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY_SUCCESS, + hero + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.getById.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_BY_KEY_ERROR when data service fails', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY, + 42 + ); + const httpError = { error: new Error('Entity not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.getById.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_MANY_SUCCESS with selected heroes on success', () => { + const hero1 = { id: 1, name: 'BA' } as Hero; + const hero2 = { id: 2, name: 'BB' } as Hero; + const heroes = [hero1, hero2]; + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_MANY_SUCCESS, + heroes + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: heroes }); + const expected = cold('----b', { b: completion }); + dataService.getWithQuery.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_MANY_ERROR when data service fails', () => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const httpError = { error: new Error('Resource not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError, { + name: 'B', + }); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.getWithQuery.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_MANY_ERROR); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (optimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.add.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (pessimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.add.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_ADD_ONE_ERROR when data service fails', () => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.add.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) with the delete id on success', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: 42 }); + const expected = cold('----b', { b: completion }); + dataService.delete.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42 + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: 42 }); + const expected = cold('----b', { b: completion }); + dataService.delete.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_DELETE_ONE_ERROR when data service fails', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('DELETE', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.delete.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', () => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: updateEntity }); + const expected = cold('----b', { b: completion }); + dataService.update.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', () => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: updateEntity }); + const expected = cold('----b', { b: completion }); + dataService.update.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', () => { + const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.update.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (optimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.upsert.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (pessimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.upsert.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPSERT_ONE_ERROR when data service fails', () => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('POST', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.upsert.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it(`should not do anything with an irrelevant action`, () => { + // Would clear the cached collection + const action = entityActionFactory.create('Hero', EntityOp.REMOVE_ALL); + + actions = hot('-a---', { a: action }); + const expected = cold('---'); + + expect(effects.persist$).toBeObservable(expected); + }); +}); + +// #region test helpers +export class Hero { + id: number; + name: string; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +/** Make an EntityDataService error */ +function makeEntityErrorCompletion( + /** The action that initiated the data service call */ + originalAction: EntityAction, + /** error produced by the EntityDataService */ + error: DataServiceError +) { + const errOp = makeErrorOp(originalAction.payload.entityOp); + + // Entity Error Action + const eaFactory = new EntityActionFactory(); + return eaFactory.create('Hero', errOp, { + originalAction, + error, + }); +} + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; + upsert: jasmine.Spy; +} + +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + upsert = jasmine.createSpy('upsert'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data$: Observable) { + this[methodName].and.returnValue(data$); + } +} +// #endregion test helpers diff --git a/modules/data/spec/effects/entity-effects.spec.ts b/modules/data/spec/effects/entity-effects.spec.ts index a2c4b4ddbc..9d736b0d5f 100644 --- a/modules/data/spec/effects/entity-effects.spec.ts +++ b/modules/data/spec/effects/entity-effects.spec.ts @@ -1,559 +1,559 @@ -// Not using marble testing -import { TestBed } from '@angular/core/testing'; -import { Action } from '@ngrx/store'; -import { Actions } from '@ngrx/effects'; -import { Update } from '@ngrx/entity'; - -import { of, merge, ReplaySubject, throwError, timer } from 'rxjs'; -import { delay, first, mergeMap } from 'rxjs/operators'; - -import { - EntityActionFactory, - EntityEffects, - EntityAction, - EntityDataService, - PersistenceResultHandler, - DefaultPersistenceResultHandler, - EntityOp, - HttpMethods, - DataServiceError, - makeErrorOp, - EntityActionDataServiceError, - Logger, -} from '../..'; - -describe('EntityEffects (normal testing)', () => { - // factory never changes in these tests - const entityActionFactory = new EntityActionFactory(); - - let actions$: ReplaySubject; - let effects: EntityEffects; - let logger: Logger; - let dataService: TestDataService; - - function expectCompletion(completion: EntityAction, done: DoneFn) { - effects.persist$.subscribe( - result => { - expect(result).toEqual(completion); - done(); - }, - error => { - fail(error); - } - ); - } - - beforeEach(() => { - logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - actions$ = new ReplaySubject(1); - - TestBed.configureTestingModule({ - providers: [ - EntityEffects, - { provide: Actions, useValue: actions$ }, - { provide: EntityActionFactory, useValue: entityActionFactory }, - /* tslint:disable-next-line:no-use-before-declare */ - { provide: EntityDataService, useClass: TestDataService }, - { provide: Logger, useValue: logger }, - { - provide: PersistenceResultHandler, - useClass: DefaultPersistenceResultHandler, - }, - ], - }); - - actions$ = TestBed.get(Actions); - effects = TestBed.get(EntityEffects); - dataService = TestBed.get(EntityDataService); - }); - - it('cancel$ should emit correlation id for CANCEL_PERSIST', (done: DoneFn) => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.CANCEL_PERSIST, - undefined, - { correlationId: 42 } - ); - effects.cancel$.subscribe((crid: any) => { - expect(crid).toBe(42); - done(); - }); - actions$.next(action); - }); - - it('should return a QUERY_ALL_SUCCESS with the heroes on success', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - dataService.setResponse('getAll', heroes); - - const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_ALL_SUCCESS, - heroes - ); - - actions$.next(action); - expectCompletion(completion, done); - }); - - it('should perform QUERY_ALL when dispatch custom tagged action', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - dataService.setResponse('getAll', heroes); - - const action = entityActionFactory.create({ - entityName: 'Hero', - entityOp: EntityOp.QUERY_ALL, - tag: 'Custom Hero Tag', - }); - - const completion = entityActionFactory.createFromAction(action, { - entityOp: EntityOp.QUERY_ALL_SUCCESS, - data: heroes, - }); - - actions$.next(action); - expectCompletion(completion, done); - }); - - it('should perform QUERY_ALL when dispatch custom action w/ that entityOp', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - dataService.setResponse('getAll', heroes); - - const action = { - type: 'some/arbitrary/type/text', - payload: { - entityName: 'Hero', - entityOp: EntityOp.QUERY_ALL, - }, - }; - - const completion = entityActionFactory.createFromAction(action, { - entityOp: EntityOp.QUERY_ALL_SUCCESS, - data: heroes, - }); - - actions$.next(action); - expectCompletion(completion, done); - }); - - it('should return a QUERY_ALL_ERROR when data service fails', (done: DoneFn) => { - const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('GET', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions$.next(action); - dataService.setErrorResponse('getAll', error); - - expectCompletion(completion, done); - expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); - }); - - it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY_SUCCESS, - hero - ); - - actions$.next(action); - dataService.setResponse('getById', hero); - - expectCompletion(completion, done); - }); - - it('should return a QUERY_BY_KEY_ERROR when data service fails', (done: DoneFn) => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_BY_KEY, - 42 - ); - const httpError = { error: new Error('Entity not found'), status: 404 }; - const error = makeDataServiceError('GET', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions$.next(action); - dataService.setErrorResponse('getById', error); - - expectCompletion(completion, done); - }); - - it('should return a QUERY_MANY_SUCCESS with selected heroes on success', (done: DoneFn) => { - const hero1 = { id: 1, name: 'BA' } as Hero; - const hero2 = { id: 2, name: 'BB' } as Hero; - const heroes = [hero1, hero2]; - - const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { - name: 'B', - }); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.QUERY_MANY_SUCCESS, - heroes - ); - - actions$.next(action); - dataService.setResponse('getWithQuery', heroes); - - expectCompletion(completion, done); - }); - - it('should return a QUERY_MANY_ERROR when data service fails', (done: DoneFn) => { - const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { - name: 'B', - }); - const httpError = { error: new Error('Resource not found'), status: 404 }; - const error = makeDataServiceError('GET', httpError, { - name: 'B', - }); - const completion = makeEntityErrorCompletion(action, error); - - actions$.next(action); - dataService.setErrorResponse('getWithQuery', error); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_ADD_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_SUCCESS, - hero, - { isOptimistic: true } - ); - - actions$.next(action); - dataService.setResponse('add', hero); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_ADD_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE_SUCCESS, - hero - ); - - actions$.next(action); - dataService.setResponse('add', hero); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_ADD_ONE_ERROR when data service fails', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_ADD_ONE, - hero - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('PUT', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions$.next(action); - dataService.setErrorResponse('add', error); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) on success with delete id', (done: DoneFn) => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_SUCCESS, - 42, - { isOptimistic: true } - ); - - actions$.next(action); - dataService.setResponse('delete', 42); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', (done: DoneFn) => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE_SUCCESS, - 42 - ); - - actions$.next(action); - dataService.setResponse('delete', 42); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_DELETE_ONE_ERROR when data service fails', (done: DoneFn) => { - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_DELETE_ONE, - 42 - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('DELETE', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions$.next(action); - dataService.setErrorResponse('delete', error); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { - const updateEntity = { id: 1, name: 'A' }; - const update = { id: 1, changes: updateEntity } as Update; - const updateResponse = { ...update, changed: true }; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_SUCCESS, - updateResponse, - { isOptimistic: true } - ); - - actions$.next(action); - dataService.setResponse('update', updateEntity); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { - const updateEntity = { id: 1, name: 'A' }; - const update = { id: 1, changes: updateEntity } as Update; - const updateResponse = { ...update, changed: true }; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE_SUCCESS, - updateResponse - ); - - actions$.next(action); - dataService.setResponse('update', updateEntity); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', (done: DoneFn) => { - const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPDATE_ONE, - update - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('PUT', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions$.next(action); - dataService.setErrorResponse('update', error); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_UPSERT_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE, - hero, - { isOptimistic: true } - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE_SUCCESS, - hero, - { isOptimistic: true } - ); - - actions$.next(action); - dataService.setResponse('upsert', hero); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_UPSERT_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE, - hero - ); - const completion = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE_SUCCESS, - hero - ); - - actions$.next(action); - dataService.setResponse('upsert', hero); - - expectCompletion(completion, done); - }); - - it('should return a SAVE_UPSERT_ONE_ERROR when data service fails', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - const action = entityActionFactory.create( - 'Hero', - EntityOp.SAVE_UPSERT_ONE, - hero - ); - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('POST', httpError); - const completion = makeEntityErrorCompletion(action, error); - - actions$.next(action); - dataService.setErrorResponse('upsert', error); - - expectCompletion(completion, done); - }); - it(`should not do anything with an irrelevant action`, (done: DoneFn) => { - // Would clear the cached collection - const action = entityActionFactory.create('Hero', EntityOp.REMOVE_ALL); - - actions$.next(action); - const sentinel = 'no persist$ effect'; - - merge( - effects.persist$, - of(sentinel).pipe(delay(1)) - // of(entityActionFactory.create('Hero', EntityOp.QUERY_ALL)) // will cause test to fail - ) - .pipe(first()) - .subscribe( - result => expect(result).toEqual(sentinel), - err => { - fail(err); - done(); - }, - done - ); - }); -}); - -// #region test helpers -export class Hero { - id: number; - name: string; -} - -/** make error produced by the EntityDataService */ -function makeDataServiceError( - /** Http method for that action */ - method: HttpMethods, - /** Http error from the web api */ - httpError?: any, - /** Options sent with the request */ - options?: any -) { - let url = 'api/heroes'; - if (httpError) { - url = httpError.url || url; - } else { - httpError = { error: new Error('Test error'), status: 500, url }; - } - return new DataServiceError(httpError, { method, url, options }); -} - -/** Make an EntityDataService error */ -function makeEntityErrorCompletion( - /** The action that initiated the data service call */ - originalAction: EntityAction, - /** error produced by the EntityDataService */ - error: DataServiceError -) { - const errOp = makeErrorOp(originalAction.payload.entityOp); - - // Entity Error Action - const eaFactory = new EntityActionFactory(); - return eaFactory.create('Hero', errOp, { - originalAction, - error, - }); -} - -export interface TestDataServiceMethod { - add: jasmine.Spy; - delete: jasmine.Spy; - getAll: jasmine.Spy; - getById: jasmine.Spy; - getWithQuery: jasmine.Spy; - update: jasmine.Spy; - upsert: jasmine.Spy; -} -export class TestDataService { - add = jasmine.createSpy('add'); - delete = jasmine.createSpy('delete'); - getAll = jasmine.createSpy('getAll'); - getById = jasmine.createSpy('getById'); - getWithQuery = jasmine.createSpy('getWithQuery'); - update = jasmine.createSpy('update'); - upsert = jasmine.createSpy('upsert'); - - getService(): TestDataServiceMethod { - return this; - } - - setResponse(methodName: keyof TestDataServiceMethod, data: any) { - this[methodName].and.returnValue(of(data).pipe(delay(1))); - } - - setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { - // Following won't quite work because delay does not appear to delay an error - // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); - // Use timer instead - this[methodName].and.returnValue( - timer(1).pipe(mergeMap(() => throwError(error))) - ); - } -} -// #endregion test helpers +// Not using marble testing +import { TestBed } from '@angular/core/testing'; +import { Action } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; + +import { of, merge, ReplaySubject, throwError, timer } from 'rxjs'; +import { delay, first, mergeMap } from 'rxjs/operators'; + +import { + EntityActionFactory, + EntityEffects, + EntityAction, + EntityDataService, + PersistenceResultHandler, + DefaultPersistenceResultHandler, + EntityOp, + HttpMethods, + DataServiceError, + makeErrorOp, + EntityActionDataServiceError, + Logger, +} from '../..'; + +describe('EntityEffects (normal testing)', () => { + // factory never changes in these tests + const entityActionFactory = new EntityActionFactory(); + + let actions$: ReplaySubject; + let effects: EntityEffects; + let logger: Logger; + let dataService: TestDataService; + + function expectCompletion(completion: EntityAction, done: DoneFn) { + effects.persist$.subscribe( + result => { + expect(result).toEqual(completion); + done(); + }, + error => { + fail(error); + } + ); + } + + beforeEach(() => { + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + actions$ = new ReplaySubject(1); + + TestBed.configureTestingModule({ + providers: [ + EntityEffects, + { provide: Actions, useValue: actions$ }, + { provide: EntityActionFactory, useValue: entityActionFactory }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, + { provide: Logger, useValue: logger }, + { + provide: PersistenceResultHandler, + useClass: DefaultPersistenceResultHandler, + }, + ], + }); + + actions$ = TestBed.inject(Actions); + effects = TestBed.inject(EntityEffects); + dataService = TestBed.inject(EntityDataService); + }); + + it('cancel$ should emit correlation id for CANCEL_PERSIST', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.CANCEL_PERSIST, + undefined, + { correlationId: 42 } + ); + effects.cancel$.subscribe((crid: any) => { + expect(crid).toBe(42); + done(); + }); + actions$.next(action); + }); + + it('should return a QUERY_ALL_SUCCESS with the heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL_SUCCESS, + heroes + ); + + actions$.next(action); + expectCompletion(completion, done); + }); + + it('should perform QUERY_ALL when dispatch custom tagged action', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + + const action = entityActionFactory.create({ + entityName: 'Hero', + entityOp: EntityOp.QUERY_ALL, + tag: 'Custom Hero Tag', + }); + + const completion = entityActionFactory.createFromAction(action, { + entityOp: EntityOp.QUERY_ALL_SUCCESS, + data: heroes, + }); + + actions$.next(action); + expectCompletion(completion, done); + }); + + it('should perform QUERY_ALL when dispatch custom action w/ that entityOp', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + + const action = { + type: 'some/arbitrary/type/text', + payload: { + entityName: 'Hero', + entityOp: EntityOp.QUERY_ALL, + }, + }; + + const completion = entityActionFactory.createFromAction(action, { + entityOp: EntityOp.QUERY_ALL_SUCCESS, + data: heroes, + }); + + actions$.next(action); + expectCompletion(completion, done); + }); + + it('should return a QUERY_ALL_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('getAll', error); + + expectCompletion(completion, done); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); + }); + + it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY_SUCCESS, + hero + ); + + actions$.next(action); + dataService.setResponse('getById', hero); + + expectCompletion(completion, done); + }); + + it('should return a QUERY_BY_KEY_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY, + 42 + ); + const httpError = { error: new Error('Entity not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('getById', error); + + expectCompletion(completion, done); + }); + + it('should return a QUERY_MANY_SUCCESS with selected heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'BA' } as Hero; + const hero2 = { id: 2, name: 'BB' } as Hero; + const heroes = [hero1, hero2]; + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_MANY_SUCCESS, + heroes + ); + + actions$.next(action); + dataService.setResponse('getWithQuery', heroes); + + expectCompletion(completion, done); + }); + + it('should return a QUERY_MANY_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const httpError = { error: new Error('Resource not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError, { + name: 'B', + }); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('getWithQuery', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('add', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero + ); + + actions$.next(action); + dataService.setResponse('add', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_ADD_ONE_ERROR when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('add', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) on success with delete id', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('delete', 42); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42 + ); + + actions$.next(action); + dataService.setResponse('delete', 42); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_DELETE_ONE_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('DELETE', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('delete', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('update', updateEntity); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse + ); + + actions$.next(action); + dataService.setResponse('update', updateEntity); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', (done: DoneFn) => { + const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('update', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('upsert', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero + ); + + actions$.next(action); + dataService.setResponse('upsert', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPSERT_ONE_ERROR when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('POST', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('upsert', error); + + expectCompletion(completion, done); + }); + it(`should not do anything with an irrelevant action`, (done: DoneFn) => { + // Would clear the cached collection + const action = entityActionFactory.create('Hero', EntityOp.REMOVE_ALL); + + actions$.next(action); + const sentinel = 'no persist$ effect'; + + merge( + effects.persist$, + of(sentinel).pipe(delay(1)) + // of(entityActionFactory.create('Hero', EntityOp.QUERY_ALL)) // will cause test to fail + ) + .pipe(first()) + .subscribe( + result => expect(result).toEqual(sentinel), + err => { + fail(err); + done(); + }, + done + ); + }); +}); + +// #region test helpers +export class Hero { + id: number; + name: string; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +/** Make an EntityDataService error */ +function makeEntityErrorCompletion( + /** The action that initiated the data service call */ + originalAction: EntityAction, + /** error produced by the EntityDataService */ + error: DataServiceError +) { + const errOp = makeErrorOp(originalAction.payload.entityOp); + + // Entity Error Action + const eaFactory = new EntityActionFactory(); + return eaFactory.create('Hero', errOp, { + originalAction, + error, + }); +} + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; + upsert: jasmine.Spy; +} +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + upsert = jasmine.createSpy('upsert'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data: any) { + this[methodName].and.returnValue(of(data).pipe(delay(1))); + } + + setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { + // Following won't quite work because delay does not appear to delay an error + // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); + // Use timer instead + this[methodName].and.returnValue( + timer(1).pipe(mergeMap(() => throwError(error))) + ); + } +} +// #endregion test helpers diff --git a/modules/data/spec/entity-data.module.spec.ts b/modules/data/spec/entity-data.module.spec.ts index 5964711ce3..470553fffa 100644 --- a/modules/data/spec/entity-data.module.spec.ts +++ b/modules/data/spec/entity-data.module.spec.ts @@ -1,266 +1,263 @@ -import { Injectable, InjectionToken } from '@angular/core'; -import { - Action, - ActionReducer, - MetaReducer, - Store, - StoreModule, -} from '@ngrx/store'; -import { Actions, EffectsModule, createEffect } from '@ngrx/effects'; - -// Not using marble testing -import { TestBed } from '@angular/core/testing'; - -import { Observable } from 'rxjs'; -import { map, skip } from 'rxjs/operators'; - -import { - EntityCache, - ofEntityOp, - persistOps, - EntityAction, - EntityActionFactory, - EntityDataModule, - EntityCacheEffects, - EntityEffects, - EntityOp, - EntityCollectionCreator, - EntityCollection, -} from '..'; - -const TEST_ACTION = 'test/get-everything-succeeded'; -const EC_METAREDUCER_TOKEN = new InjectionToken< - MetaReducer ->('EC MetaReducer'); - -@Injectable() -class TestEntityEffects { - test$: Observable = createEffect(() => - this.actions.pipe( - // tap(action => console.log('test$ effect', action)), - ofEntityOp(persistOps), - map(this.testHook) - ) - ); - - testHook(action: EntityAction) { - return { - type: 'test-action', - payload: action, // the incoming action - entityName: action.payload.entityName, - }; - } - - constructor(private actions: Actions) {} -} - -class Hero { - id: number; - name: string; - power?: string; -} - -class Villain { - id: string; - name: string; -} - -const entityMetadata = { - Hero: {}, - Villain: {}, -}; - -//////// Tests begin //////// - -describe('EntityDataModule', () => { - describe('with replaced EntityEffects', () => { - // factory never changes in these tests - const entityActionFactory = new EntityActionFactory(); - - let actions$: Actions; - let store: Store; - let testEffects: TestEntityEffects; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - EffectsModule.forRoot([]), - EntityDataModule.forRoot({ - entityMetadata: entityMetadata, - }), - ], - providers: [ - { provide: EntityCacheEffects, useValue: {} }, - { provide: EntityEffects, useClass: TestEntityEffects }, - ], - }); - - actions$ = TestBed.get(Actions); - store = TestBed.get(Store); - - testEffects = TestBed.get(EntityEffects); - spyOn(testEffects, 'testHook').and.callThrough(); - }); - - it('should invoke test effect with an EntityAction', () => { - const actions: Action[] = []; - - // listen for actions after the next dispatched action - actions$ - .pipe( - // tap(act => console.log('test action', act)), - skip(1) // Skip QUERY_ALL - ) - .subscribe(act => actions.push(act)); - - const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); - store.dispatch(action); - expect(actions.length).toBe(1, 'expect one effect action'); - expect(actions[0].type).toBe('test-action'); - }); - - it('should not invoke test effect with non-EntityAction', () => { - const actions: Action[] = []; - - // listen for actions after the next dispatched action - actions$.pipe(skip(1)).subscribe(act => actions.push(act)); - - store.dispatch({ type: 'not-an-entity-action' }); - expect(actions.length).toBe(0); - }); - }); - - describe('with EntityCacheMetaReducer', () => { - let cacheSelector$: Observable; - let eaFactory: EntityActionFactory; - let metaReducerLog: string[]; - let store: Store<{ entityCache: EntityCache }>; - - function loggingEntityCacheMetaReducer( - reducer: ActionReducer - ): ActionReducer { - return (state, action) => { - metaReducerLog.push(`MetaReducer saw "${action.type}"`); - return reducer(state, action); - }; - } - - beforeEach(() => { - metaReducerLog = []; - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - EffectsModule.forRoot([]), - EntityDataModule.forRoot({ - entityMetadata: entityMetadata, - entityCacheMetaReducers: [ - loggingEntityCacheMetaReducer, - EC_METAREDUCER_TOKEN, - ], - }), - ], - providers: [ - { provide: EntityCacheEffects, useValue: {} }, - { provide: EntityEffects, useValue: {} }, - { - // Here's how you add an EntityCache metareducer with an injected service - provide: EC_METAREDUCER_TOKEN, - useFactory: entityCacheMetaReducerFactory, - deps: [EntityCollectionCreator], - }, - ], - }); - - store = TestBed.get(Store); - cacheSelector$ = store.select(state => state.entityCache); - eaFactory = TestBed.get(EntityActionFactory); - }); - - it('should log an ordinary entity action', () => { - const action = eaFactory.create('Hero', EntityOp.SET_LOADING); - store.dispatch(action); - expect(metaReducerLog.join('|')).toContain( - EntityOp.SET_LOADING, - 'logged entity action' - ); - }); - - it('should respond to action handled by custom EntityCacheMetaReducer', () => { - const data = { - Hero: [ - { id: 2, name: 'B', power: 'Fast' }, - { id: 1, name: 'A', power: 'invisible' }, - ], - Villain: [{ id: 30, name: 'Dr. Evil' }], - }; - const action = { - type: TEST_ACTION, - payload: data, - }; - store.dispatch(action); - cacheSelector$.subscribe(cache => { - try { - expect(cache.Hero.entities[1]).toEqual( - data.Hero[1], - 'has expected hero' - ); - expect(cache.Villain.entities[30]).toEqual( - data.Villain[0], - 'has expected hero' - ); - expect(metaReducerLog.join('|')).toContain( - TEST_ACTION, - 'logged test action' - ); - } catch (error) { - fail(error); - } - }, fail); - }); - }); -}); - -// #region helpers - -/** Create the test entityCacheMetaReducer, injected in tests */ -function entityCacheMetaReducerFactory( - collectionCreator: EntityCollectionCreator -) { - return (reducer: ActionReducer) => { - return (state: EntityCache, action: { type: string; payload?: any }) => { - switch (action.type) { - case TEST_ACTION: { - const mergeState = { - Hero: createCollection('Hero', action.payload['Hero'] || []), - Villain: createCollection( - 'Villain', - action.payload['Villain'] || [] - ), - }; - return { ...state, ...mergeState }; - } - } - return reducer(state, action); - }; - }; - - function createCollection( - entityName: string, - data: T[] - ) { - return { - ...collectionCreator.create(entityName), - ids: data.map(e => e.id), - entities: data.reduce( - (acc, e) => { - acc[e.id] = e; - return acc; - }, - {} as any - ), - } as EntityCollection; - } -} -// #endregion +import { Injectable, InjectionToken } from '@angular/core'; +import { + Action, + ActionReducer, + MetaReducer, + Store, + StoreModule, +} from '@ngrx/store'; +import { Actions, EffectsModule, createEffect } from '@ngrx/effects'; + +// Not using marble testing +import { TestBed } from '@angular/core/testing'; + +import { Observable } from 'rxjs'; +import { map, skip } from 'rxjs/operators'; + +import { + EntityCache, + ofEntityOp, + persistOps, + EntityAction, + EntityActionFactory, + EntityDataModule, + EntityCacheEffects, + EntityEffects, + EntityOp, + EntityCollectionCreator, + EntityCollection, +} from '..'; + +const TEST_ACTION = 'test/get-everything-succeeded'; +const EC_METAREDUCER_TOKEN = new InjectionToken< + MetaReducer +>('EC MetaReducer'); + +@Injectable() +class TestEntityEffects { + test$: Observable = createEffect(() => + this.actions.pipe( + // tap(action => console.log('test$ effect', action)), + ofEntityOp(persistOps), + map(this.testHook) + ) + ); + + testHook(action: EntityAction) { + return { + type: 'test-action', + payload: action, // the incoming action + entityName: action.payload.entityName, + }; + } + + constructor(private actions: Actions) {} +} + +class Hero { + id: number; + name: string; + power?: string; +} + +class Villain { + id: string; + name: string; +} + +const entityMetadata = { + Hero: {}, + Villain: {}, +}; + +//////// Tests begin //////// + +describe('EntityDataModule', () => { + describe('with replaced EntityEffects', () => { + // factory never changes in these tests + const entityActionFactory = new EntityActionFactory(); + + let actions$: Actions; + let store: Store; + let testEffects: TestEntityEffects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + EntityDataModule.forRoot({ + entityMetadata: entityMetadata, + }), + ], + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + { provide: EntityEffects, useClass: TestEntityEffects }, + ], + }); + + actions$ = TestBed.inject(Actions); + store = TestBed.inject(Store); + + testEffects = TestBed.inject(EntityEffects); + spyOn(testEffects, 'testHook').and.callThrough(); + }); + + it('should invoke test effect with an EntityAction', () => { + const actions: Action[] = []; + + // listen for actions after the next dispatched action + actions$ + .pipe( + // tap(act => console.log('test action', act)), + skip(1) // Skip QUERY_ALL + ) + .subscribe(act => actions.push(act)); + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + store.dispatch(action); + expect(actions.length).toBe(1, 'expect one effect action'); + expect(actions[0].type).toBe('test-action'); + }); + + it('should not invoke test effect with non-EntityAction', () => { + const actions: Action[] = []; + + // listen for actions after the next dispatched action + actions$.pipe(skip(1)).subscribe(act => actions.push(act)); + + store.dispatch({ type: 'not-an-entity-action' }); + expect(actions.length).toBe(0); + }); + }); + + describe('with EntityCacheMetaReducer', () => { + let cacheSelector$: Observable; + let eaFactory: EntityActionFactory; + let metaReducerLog: string[]; + let store: Store<{ entityCache: EntityCache }>; + + function loggingEntityCacheMetaReducer( + reducer: ActionReducer + ): ActionReducer { + return (state, action) => { + metaReducerLog.push(`MetaReducer saw "${action.type}"`); + return reducer(state, action); + }; + } + + beforeEach(() => { + metaReducerLog = []; + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + EntityDataModule.forRoot({ + entityMetadata: entityMetadata, + entityCacheMetaReducers: [ + loggingEntityCacheMetaReducer, + EC_METAREDUCER_TOKEN, + ], + }), + ], + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + { provide: EntityEffects, useValue: {} }, + { + // Here's how you add an EntityCache metareducer with an injected service + provide: EC_METAREDUCER_TOKEN, + useFactory: entityCacheMetaReducerFactory, + deps: [EntityCollectionCreator], + }, + ], + }); + + store = TestBed.inject(Store); + cacheSelector$ = store.select(state => state.entityCache); + eaFactory = TestBed.inject(EntityActionFactory); + }); + + it('should log an ordinary entity action', () => { + const action = eaFactory.create('Hero', EntityOp.SET_LOADING); + store.dispatch(action); + expect(metaReducerLog.join('|')).toContain( + EntityOp.SET_LOADING, + 'logged entity action' + ); + }); + + it('should respond to action handled by custom EntityCacheMetaReducer', () => { + const data = { + Hero: [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'invisible' }, + ], + Villain: [{ id: 30, name: 'Dr. Evil' }], + }; + const action = { + type: TEST_ACTION, + payload: data, + }; + store.dispatch(action); + cacheSelector$.subscribe(cache => { + try { + expect(cache.Hero.entities[1]).toEqual( + data.Hero[1], + 'has expected hero' + ); + expect(cache.Villain.entities[30]).toEqual( + data.Villain[0], + 'has expected hero' + ); + expect(metaReducerLog.join('|')).toContain( + TEST_ACTION, + 'logged test action' + ); + } catch (error) { + fail(error); + } + }, fail); + }); + }); +}); + +// #region helpers + +/** Create the test entityCacheMetaReducer, injected in tests */ +function entityCacheMetaReducerFactory( + collectionCreator: EntityCollectionCreator +) { + return (reducer: ActionReducer) => { + return (state: EntityCache, action: { type: string; payload?: any }) => { + switch (action.type) { + case TEST_ACTION: { + const mergeState = { + Hero: createCollection('Hero', action.payload['Hero'] || []), + Villain: createCollection( + 'Villain', + action.payload['Villain'] || [] + ), + }; + return { ...state, ...mergeState }; + } + } + return reducer(state, action); + }; + }; + + function createCollection( + entityName: string, + data: T[] + ) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => e.id), + entities: data.reduce((acc, e) => { + acc[e.id] = e; + return acc; + }, {} as any), + } as EntityCollection; + } +} +// #endregion diff --git a/modules/data/spec/entity-metadata/entity-definition.service.spec.ts b/modules/data/spec/entity-metadata/entity-definition.service.spec.ts index cfc90b7698..53e7bf4034 100644 --- a/modules/data/spec/entity-metadata/entity-definition.service.spec.ts +++ b/modules/data/spec/entity-metadata/entity-definition.service.spec.ts @@ -1,144 +1,144 @@ -import { NgModule } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; - -import { - createEntityDefinition, - EntityDefinitionService, - EntityMetadataMap, - ENTITY_METADATA_TOKEN, -} from '../..'; - -@NgModule({}) -class LazyModule { - lazyMetadataMap = { - Sidekick: {}, - }; - - constructor(entityDefinitionService: EntityDefinitionService) { - entityDefinitionService.registerMetadataMap(this.lazyMetadataMap); - } -} - -describe('EntityDefinitionService', () => { - let service: EntityDefinitionService; - let metadataMap: EntityMetadataMap; - - beforeEach(() => { - metadataMap = { - Hero: {}, - Villain: {}, - }; - - TestBed.configureTestingModule({ - // Not actually lazy but demonstrates a module that registers metadata - imports: [LazyModule], - providers: [ - EntityDefinitionService, - { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadataMap }, - ], - }); - service = TestBed.get(EntityDefinitionService); - }); - - describe('#getDefinition', () => { - it('returns definition for known entity', () => { - const def = service.getDefinition('Hero'); - expect(def).toBeDefined(); - }); - - it('throws if request definition for unknown entity', () => { - expect(() => service.getDefinition('Foo')).toThrowError(/no entity/i); - }); - - it('returns undefined if request definition for unknown entity and `shouldThrow` is false', () => { - const def = service.getDefinition('foo', /* shouldThrow */ false); - expect(def).not.toBeDefined(); - }); - }); - - describe('#registerMetadata(Map)', () => { - it('can register a new definition by metadata', () => { - service.registerMetadata({ entityName: 'Foo' }); - - let def = service.getDefinition('Foo'); - expect(def).toBeDefined(); - // Hero is still defined after registering Foo - def = service.getDefinition('Hero'); - expect(def).toBeDefined('Hero still defined'); - }); - - it('can register new definitions by metadata map', () => { - service.registerMetadataMap({ - Foo: {}, - Bar: {}, - }); - - let def = service.getDefinition('Foo'); - expect(def).toBeDefined('Foo'); - def = service.getDefinition('Bar'); - expect(def).toBeDefined('Bar'); - def = service.getDefinition('Hero'); - expect(def).toBeDefined('Hero still defined'); - }); - - it('entityName property should trump map key', () => { - service.registerMetadataMap({ - 1: { entityName: 'Foo' }, // key and entityName differ - }); - - let def = service.getDefinition('Foo'); - expect(def).toBeDefined('Foo'); - def = service.getDefinition('Hero'); - expect(def).toBeDefined('Hero still defined'); - }); - - it('a (lazy-loaded) module can register metadata with its constructor', () => { - // The `Sidekick` metadata are registered by LazyModule's ctor - // Although LazyModule is actually eagerly-loaded in this test, - // the registration technique is the important thing. - const def = service.getDefinition('Sidekick'); - expect(def).toBeDefined('Sidekick'); - }); - }); - - describe('#registerDefinition(s)', () => { - it('can register a new definition', () => { - const newDef = createEntityDefinition({ entityName: 'Foo' }); - service.registerDefinition(newDef); - - let def = service.getDefinition('Foo'); - expect(def).toBeDefined(); - // Hero is still defined after registering Foo - def = service.getDefinition('Hero'); - expect(def).toBeDefined('Hero still defined'); - }); - - it('can register a map of several definitions', () => { - const newDefMap = { - Foo: createEntityDefinition({ entityName: 'Foo' }), - Bar: createEntityDefinition({ entityName: 'Bar' }), - }; - service.registerDefinitions(newDefMap); - - let def = service.getDefinition('Foo'); - expect(def).toBeDefined('Foo'); - def = service.getDefinition('Bar'); - expect(def).toBeDefined('Bar'); - def = service.getDefinition('Hero'); - expect(def).toBeDefined('Hero still defined'); - }); - - it('can re-register an existing definition', () => { - const testSelectId = (entity: any) => 'test-id'; - const newDef = createEntityDefinition({ - entityName: 'Hero', - selectId: testSelectId, - }); - service.registerDefinition(newDef); - - const def = service.getDefinition('Hero'); - expect(def).toBeDefined('Hero still defined'); - expect(def.selectId).toBe(testSelectId, 'updated w/ new selectId'); - }); - }); -}); +import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { + createEntityDefinition, + EntityDefinitionService, + EntityMetadataMap, + ENTITY_METADATA_TOKEN, +} from '../..'; + +@NgModule({}) +class LazyModule { + lazyMetadataMap = { + Sidekick: {}, + }; + + constructor(entityDefinitionService: EntityDefinitionService) { + entityDefinitionService.registerMetadataMap(this.lazyMetadataMap); + } +} + +describe('EntityDefinitionService', () => { + let service: EntityDefinitionService; + let metadataMap: EntityMetadataMap; + + beforeEach(() => { + metadataMap = { + Hero: {}, + Villain: {}, + }; + + TestBed.configureTestingModule({ + // Not actually lazy but demonstrates a module that registers metadata + imports: [LazyModule], + providers: [ + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadataMap }, + ], + }); + service = TestBed.inject(EntityDefinitionService); + }); + + describe('#getDefinition', () => { + it('returns definition for known entity', () => { + const def = service.getDefinition('Hero'); + expect(def).toBeDefined(); + }); + + it('throws if request definition for unknown entity', () => { + expect(() => service.getDefinition('Foo')).toThrowError(/no entity/i); + }); + + it('returns undefined if request definition for unknown entity and `shouldThrow` is false', () => { + const def = service.getDefinition('foo', /* shouldThrow */ false); + expect(def).not.toBeDefined(); + }); + }); + + describe('#registerMetadata(Map)', () => { + it('can register a new definition by metadata', () => { + service.registerMetadata({ entityName: 'Foo' }); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined(); + // Hero is still defined after registering Foo + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('can register new definitions by metadata map', () => { + service.registerMetadataMap({ + Foo: {}, + Bar: {}, + }); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined('Foo'); + def = service.getDefinition('Bar'); + expect(def).toBeDefined('Bar'); + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('entityName property should trump map key', () => { + service.registerMetadataMap({ + 1: { entityName: 'Foo' }, // key and entityName differ + }); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined('Foo'); + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('a (lazy-loaded) module can register metadata with its constructor', () => { + // The `Sidekick` metadata are registered by LazyModule's ctor + // Although LazyModule is actually eagerly-loaded in this test, + // the registration technique is the important thing. + const def = service.getDefinition('Sidekick'); + expect(def).toBeDefined('Sidekick'); + }); + }); + + describe('#registerDefinition(s)', () => { + it('can register a new definition', () => { + const newDef = createEntityDefinition({ entityName: 'Foo' }); + service.registerDefinition(newDef); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined(); + // Hero is still defined after registering Foo + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('can register a map of several definitions', () => { + const newDefMap = { + Foo: createEntityDefinition({ entityName: 'Foo' }), + Bar: createEntityDefinition({ entityName: 'Bar' }), + }; + service.registerDefinitions(newDefMap); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined('Foo'); + def = service.getDefinition('Bar'); + expect(def).toBeDefined('Bar'); + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('can re-register an existing definition', () => { + const testSelectId = (entity: any) => 'test-id'; + const newDef = createEntityDefinition({ + entityName: 'Hero', + selectId: testSelectId, + }); + service.registerDefinition(newDef); + + const def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + expect(def.selectId).toBe(testSelectId, 'updated w/ new selectId'); + }); + }); +}); diff --git a/modules/data/spec/entity-services/entity-collection-service.spec.ts b/modules/data/spec/entity-services/entity-collection-service.spec.ts index 36fe10df22..c6f720e0b5 100644 --- a/modules/data/spec/entity-services/entity-collection-service.spec.ts +++ b/modules/data/spec/entity-services/entity-collection-service.spec.ts @@ -1,626 +1,626 @@ -import { Injectable } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { HttpErrorResponse } from '@angular/common/http'; -import { Action, StoreModule, Store } from '@ngrx/store'; -import { Actions, EffectsModule } from '@ngrx/effects'; - -import { Observable, of, throwError, timer } from 'rxjs'; -import { delay, filter, mergeMap, tap, withLatestFrom } from 'rxjs/operators'; - -import { commandDispatchTest } from '../dispatchers/entity-dispatcher.spec'; -import { - EntityCollectionService, - EntityActionOptions, - PersistanceCanceled, - EntityDispatcherDefaultOptions, - EntityAction, - EntityActionFactory, - EntityCache, - EntityOp, - EntityMetadataMap, - EntityDataModule, - EntityCacheEffects, - EntityDataService, - EntityDispatcherFactory, - EntityServices, - OP_SUCCESS, - HttpMethods, - DataServiceError, - Logger, -} from '../..'; - -describe('EntityCollectionService', () => { - describe('Command dispatching', () => { - // Borrowing the dispatcher tests from entity-dispatcher.spec. - // The critical difference: those test didn't invoke the reducers; they do when run here. - commandDispatchTest(getDispatcher); - - function getDispatcher() { - const { heroCollectionService, store } = entityServicesSetup(); - const dispatcher = heroCollectionService.dispatcher; - return { dispatcher, store }; - } - }); - - // TODO: test the effect of MergeStrategy when there are entities in cache with changes - // This concern is largely met by EntityChangeTracker tests but integration tests would be reassuring. - describe('queries', () => { - let heroCollectionService: EntityCollectionService; - let dataService: TestDataService; - let reducedActions$Snoop: () => void; - - beforeEach(() => { - ({ - heroCollectionService, - reducedActions$Snoop, - dataService, - } = entityServicesSetup()); - }); - - // Compare to next test which subscribes to getAll() result - it('can use loading$ to learn when getAll() succeeds', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - dataService.setResponse('getAll', heroes); - heroCollectionService.getAll(); - - // N.B.: This technique does not detect errors - heroCollectionService.loading$ - .pipe( - filter(loading => !loading), - withLatestFrom(heroCollectionService.entities$) - ) - .subscribe(([loading, data]) => { - expect(data).toEqual(heroes); - done(); - }); - }); - - // Compare to previous test the waits for loading$ flag to flip - it('getAll observable should emit heroes on success', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - dataService.setResponse('getAll', heroes); - heroCollectionService.getAll().subscribe(expectDataToBe(heroes, done)); - - // reducedActions$Snoop(); // diagnostic - }); - - it('getAll observable should emit expected error when data service fails', (done: DoneFn) => { - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('GET', httpError); - dataService.setErrorResponse('getAll', error); - heroCollectionService.getAll().subscribe(expectErrorToBe(error, done)); - }); - - it('getByKey observable should emit a hero on success', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - dataService.setResponse('getById', hero); - heroCollectionService.getByKey(1).subscribe(expectDataToBe(hero, done)); - }); - - it('getByKey observable should emit expected error when data service fails', (done: DoneFn) => { - // Simulate HTTP 'Not Found' response - const httpError = new HttpErrorResponse({ - error: 'Entity not found', - status: 404, - statusText: 'Not Found', - url: 'bad/location', - }); - - // For test purposes, the following would have been effectively the same thing - // const httpError = { error: new Error('Entity not found'), status: 404 }; - - const error = makeDataServiceError('GET', httpError); - dataService.setErrorResponse('getById', error); - heroCollectionService - .getByKey(42) - .subscribe(expectErrorToBe(error, done)); - }); - - it('getWithQuery observable should emit heroes on success', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - dataService.setResponse('getWithQuery', heroes); - heroCollectionService - .getWithQuery({ name: 'foo' }) - .subscribe(expectDataToBe(heroes, done)); - - // reducedActions$Snoop(); // diagnostic - }); - - it('getWithQuery observable should emit expected error when data service fails', (done: DoneFn) => { - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('GET', httpError); - dataService.setErrorResponse('getWithQuery', error); - heroCollectionService - .getWithQuery({ name: 'foo' }) - .subscribe(expectErrorToBe(error, done)); - }); - - it('load observable should emit heroes on success', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - dataService.setResponse('getAll', heroes); - heroCollectionService.load().subscribe(expectDataToBe(heroes, done)); - }); - - it('load observable should emit expected error when data service fails', (done: DoneFn) => { - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('GET', httpError); - dataService.setErrorResponse('getAll', error); - heroCollectionService.load().subscribe(expectErrorToBe(error, done)); - }); - }); - - describe('cancel', () => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - - let heroCollectionService: EntityCollectionService; - let dataService: TestDataService; - let reducedActions$Snoop: () => void; - - beforeEach(() => { - ({ - dataService, - heroCollectionService, - reducedActions$Snoop, - } = entityServicesSetup()); - }); - - it('can cancel a long running query', (done: DoneFn) => { - const responseDelay = 4; - dataService['getAll'].and.returnValue( - of(heroes).pipe(delay(responseDelay)) - ); - - // Create the correlation id yourself to know which action to cancel. - const correlationId = 'CRID007'; - const options: EntityActionOptions = { correlationId }; - heroCollectionService.getAll(options).subscribe( - data => fail('should not have data but got data'), - error => { - expect(error instanceof PersistanceCanceled).toBe( - true, - 'is PersistanceCanceled' - ); - expect(error.message).toBe('Test cancel'); - done(); - } - ); - - heroCollectionService.cancel(correlationId, 'Test cancel'); - }); - - it('has no effect on action with different correlationId', (done: DoneFn) => { - const responseDelay = 4; - dataService['getAll'].and.returnValue( - of(heroes).pipe(delay(responseDelay)) - ); - - const correlationId = 'CRID007'; - const options: EntityActionOptions = { correlationId }; - heroCollectionService.getAll(options).subscribe(data => { - expect(data).toEqual(heroes); - done(); - }, fail); - - heroCollectionService.cancel('not-the-crid'); - }); - - it('has no effect when too late', (done: DoneFn) => { - const responseDelay = 4; - dataService['getAll'].and.returnValue( - of(heroes).pipe(delay(responseDelay)) - ); - - const correlationId = 'CRID007'; - const options: EntityActionOptions = { correlationId }; - heroCollectionService - .getAll(options) - .subscribe(data => expect(data).toEqual(heroes), fail); - - setTimeout( - () => heroCollectionService.cancel(correlationId), - responseDelay + 2 - ); - setTimeout(done, responseDelay + 4); // wait for all to complete - }); - }); - - xdescribe('saves (optimistic)', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - /* tslint:disable-next-line:no-use-before-declare */ - providers: [ - { - provide: EntityDispatcherDefaultOptions, - useClass: OptimisticDispatcherDefaultOptions, - }, - ], - }); - }); - - combinedSaveTests(true); - }); - - xdescribe('saves (pessimistic)', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - /* tslint:disable-next-line:no-use-before-declare */ - providers: [ - { - provide: EntityDispatcherDefaultOptions, - useClass: PessimisticDispatcherDefaultOptions, - }, - ], - }); - }); - - combinedSaveTests(false); - }); - - /** Save tests to be run both optimistically and pessimistically */ - function combinedSaveTests(isOptimistic: boolean) { - let heroCollectionService: EntityCollectionService; - let dataService: TestDataService; - let expectOptimisticSuccess: (expect: boolean) => () => void; - let reducedActions$Snoop: () => void; - let successActions$: Observable; - - beforeEach(() => { - ({ - dataService, - expectOptimisticSuccess, - heroCollectionService, - reducedActions$Snoop, - successActions$, - } = entityServicesSetup()); - }); - - it('add() should save a new entity and return it', (done: DoneFn) => { - const extra = expectOptimisticSuccess(isOptimistic); - const hero = { id: 1, name: 'A' } as Hero; - dataService.setResponse('add', hero); - heroCollectionService - .add(hero) - .subscribe(expectDataToBe(hero, done, undefined, extra)); - }); - - it('add() observable should emit expected error when data service fails', (done: DoneFn) => { - const hero = { id: 1, name: 'A' } as Hero; - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('PUT', httpError); - dataService.setErrorResponse('add', error); - heroCollectionService.add(hero).subscribe(expectErrorToBe(error, done)); - }); - - it('delete() should send delete for entity not in cache and return its id', (done: DoneFn) => { - const extra = expectOptimisticSuccess(isOptimistic); - dataService.setResponse('delete', 42); - heroCollectionService - .delete(42) - .subscribe(expectDataToBe(42, done, undefined, extra)); - }); - - it('delete() should skip delete for added entity cache', (done: DoneFn) => { - // reducedActions$Snoop(); - let wasSkipped: boolean; - successActions$.subscribe( - (act: EntityAction) => (wasSkipped = act.payload.skip === true) - ); - const extra = () => - expect(wasSkipped).toBe(true, 'expected to be skipped'); - - const hero = { id: 1, name: 'A' } as Hero; - heroCollectionService.addOneToCache(hero); - dataService.setResponse('delete', 1); - heroCollectionService - .delete(1) - .subscribe(expectDataToBe(1, done, undefined, extra)); - }); - - it('delete() observable should emit expected error when data service fails', (done: DoneFn) => { - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('DELETE', httpError); - dataService.setErrorResponse('delete', error); - heroCollectionService.delete(42).subscribe(expectErrorToBe(error, done)); - }); - - it('update() should save updated entity and return it', (done: DoneFn) => { - const extra = expectOptimisticSuccess(isOptimistic); - const preUpdate = { id: 1, name: 'A' } as Hero; - heroCollectionService.addAllToCache([preUpdate]); // populate cache - const update = { ...preUpdate, name: 'Updated A' }; - dataService.setResponse('update', null); // server returns nothing after update - heroCollectionService - .update(update) - .subscribe(expectDataToBe(update, done, undefined, extra)); - }); - - it('update() should save updated entity and return server-changed version', (done: DoneFn) => { - const extra = expectOptimisticSuccess(isOptimistic); - const preUpdate = { id: 1, name: 'A' } as Hero; - heroCollectionService.addAllToCache([preUpdate]); // populate cache - const update = { ...preUpdate, name: 'Updated A' }; - const postUpdate = { - ...preUpdate, - name: 'Updated A', - saying: 'Server set this', - }; - dataService.setResponse('update', postUpdate); // server returns entity with server-side changes - heroCollectionService - .update(update) - .subscribe(expectDataToBe(postUpdate, done, undefined, extra)); - }); - - it('update() observable should emit expected error when data service fails', (done: DoneFn) => { - const preUpdate = { id: 1, name: 'A' } as Hero; - heroCollectionService.addAllToCache([preUpdate]); // populate cache - const update = { ...preUpdate, name: 'Updated A' }; - const httpError = { error: new Error('Test Failure'), status: 501 }; - const error = makeDataServiceError('PUT', httpError); - dataService.setErrorResponse('update', error); - heroCollectionService - .update(update) - .subscribe(expectErrorToBe(error, done)); - }); - - it('can handle out-of-order save results', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - let successActionCount = 0; - const delayMs = 5; - let responseDelay = delayMs; - const savedHeroes: Hero[] = []; - - successActions$.pipe(delay(1)).subscribe(act => { - successActionCount += 1; - if (successActionCount === 2) { - // Confirm hero2 actually saved before hero1 - expect(savedHeroes).toEqual([hero2, hero1], 'savedHeroes'); - done(); - } - }); - - // dataService.add returns odd responses later than even responses - // so add of hero2 should complete before add of hero1 - dataService['add'].and.callFake((data: Hero) => { - const result = of(data).pipe( - delay(responseDelay), - tap(h => savedHeroes.push(h)) - ); - responseDelay = delayMs === responseDelay ? 1 : responseDelay; - return result; - }); - - // Save hero1 before hero2 - // Confirm that each add returns with its own hero - heroCollectionService - .add(hero1) - .subscribe(data => expect(data).toEqual(hero1, 'first hero')); - - heroCollectionService - .add(hero2) - .subscribe(data => expect(data).toEqual(hero2, 'second hero')); - }); - } - - describe('selectors$', () => { - let entityActionFactory: EntityActionFactory; - let heroCollectionService: EntityCollectionService; - let store: Store; - - function dispatchedAction() { - return (store.dispatch).calls.argsFor(0)[0]; - } - - beforeEach(() => { - const setup = entityServicesSetup(); - ({ entityActionFactory, heroCollectionService, store } = setup); - spyOn(store, 'dispatch').and.callThrough(); - }); - - it('can get collection from collection$', () => { - const action = entityActionFactory.create('Hero', EntityOp.ADD_ALL, [ - { id: 1, name: 'A' }, - ]); - store.dispatch(action); - heroCollectionService.collection$.subscribe(collection => { - expect(collection.ids).toEqual([1]); - }); - }); - }); -}); - -// #region test helpers -class Hero { - id: number; - name: string; - saying?: string; -} -class Villain { - key: string; - name: string; -} - -const entityMetadata: EntityMetadataMap = { - Hero: {}, - Villain: { selectId: villain => villain.key }, -}; - -function entityServicesSetup() { - const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - EffectsModule.forRoot([]), - EntityDataModule.forRoot({ - entityMetadata: entityMetadata, - }), - ], - providers: [ - { provide: EntityCacheEffects, useValue: {} }, - /* tslint:disable-next-line:no-use-before-declare */ - { provide: EntityDataService, useClass: TestDataService }, - { provide: Logger, useValue: logger }, - ], - }); - - const actions$: Observable = TestBed.get(Actions); - const dataService: TestDataService = TestBed.get(EntityDataService); - const entityActionFactory: EntityActionFactory = TestBed.get( - EntityActionFactory - ); - const entityDispatcherFactory: EntityDispatcherFactory = TestBed.get( - EntityDispatcherFactory - ); - const entityServices: EntityServices = TestBed.get(EntityServices); - const heroCollectionService = entityServices.getEntityCollectionService( - 'Hero' - ); - const reducedActions$: Observable = - entityDispatcherFactory.reducedActions$; - const store: Store = TestBed.get(Store); - const successActions$: Observable = reducedActions$.pipe( - filter( - (act: any) => act.payload && act.payload.entityOp.endsWith(OP_SUCCESS) - ) - ); - - /** Returns fn that confirms EntityAction was (or was not Optimistic) after success */ - function expectOptimisticSuccess(expected: boolean) { - let wasOptimistic: boolean; - const msg = `${expected ? 'Optimistic' : 'Pessimistic'} save `; - successActions$.subscribe( - (act: EntityAction) => (wasOptimistic = act.payload.isOptimistic === true) - ); - return () => expect(wasOptimistic).toBe(expected, msg); - } - - /** Snoop on reducedActions$ while debugging a test */ - function reducedActions$Snoop() { - reducedActions$.subscribe(act => { - console.log('scannedActions$', act); - }); - } - - return { - actions$, - dataService, - entityActionFactory, - entityServices, - expectOptimisticSuccess, - heroCollectionService, - reducedActions$, - reducedActions$Snoop, - store, - successActions$, - }; -} - -function expectDataToBe( - expected: any, - done: DoneFn, - message?: string, - extra?: () => void -) { - return { - next: (data: any) => { - expect(data).toEqual(expected, message); - if (extra) { - extra(); // extra expectations before done - } - done(); - }, - error: fail, - }; -} - -function expectErrorToBe(expected: any, done: DoneFn, message?: string) { - return { - next: (data: any) => { - fail(`Expected error response but got data: '${JSON.stringify(data)}'`); - done(); - }, - error: (error: any) => { - expect(error).toEqual(expected, message); - done(); - }, - }; -} - -/** make error produced by the EntityDataService */ -function makeDataServiceError( - /** Http method for that action */ - method: HttpMethods, - /** Http error from the web api */ - httpError?: any, - /** Options sent with the request */ - options?: any -) { - let url = 'api/heroes'; - if (httpError) { - url = httpError.url || url; - } else { - httpError = { error: new Error('Test error'), status: 500, url }; - } - return new DataServiceError(httpError, { method, url, options }); -} - -@Injectable() -export class OptimisticDispatcherDefaultOptions { - optimisticAdd = true; - optimisticDelete = true; - optimisticUpdate = true; -} - -@Injectable() -export class PessimisticDispatcherDefaultOptions { - optimisticAdd = false; - optimisticDelete = false; - optimisticUpdate = false; -} - -export interface TestDataServiceMethod { - add: jasmine.Spy; - delete: jasmine.Spy; - getAll: jasmine.Spy; - getById: jasmine.Spy; - getWithQuery: jasmine.Spy; - update: jasmine.Spy; -} - -export class TestDataService { - add = jasmine.createSpy('add'); - delete = jasmine.createSpy('delete'); - getAll = jasmine.createSpy('getAll'); - getById = jasmine.createSpy('getById'); - getWithQuery = jasmine.createSpy('getWithQuery'); - update = jasmine.createSpy('update'); - - getService(): TestDataServiceMethod { - return this; - } - - setResponse(methodName: keyof TestDataServiceMethod, data: any) { - this[methodName].and.returnValue(of(data).pipe(delay(1))); - } - - setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { - // Following won't quite work because delay does not appear to delay an error - // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); - // Use timer instead - this[methodName].and.returnValue( - timer(1).pipe(mergeMap(() => throwError(error))) - ); - } -} -// #endregion test helpers +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Action, StoreModule, Store } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; + +import { Observable, of, throwError, timer } from 'rxjs'; +import { delay, filter, mergeMap, tap, withLatestFrom } from 'rxjs/operators'; + +import { commandDispatchTest } from '../dispatchers/entity-dispatcher.spec'; +import { + EntityCollectionService, + EntityActionOptions, + PersistanceCanceled, + EntityDispatcherDefaultOptions, + EntityAction, + EntityActionFactory, + EntityCache, + EntityOp, + EntityMetadataMap, + EntityDataModule, + EntityCacheEffects, + EntityDataService, + EntityDispatcherFactory, + EntityServices, + OP_SUCCESS, + HttpMethods, + DataServiceError, + Logger, +} from '../..'; + +describe('EntityCollectionService', () => { + describe('Command dispatching', () => { + // Borrowing the dispatcher tests from entity-dispatcher.spec. + // The critical difference: those test didn't invoke the reducers; they do when run here. + commandDispatchTest(getDispatcher); + + function getDispatcher() { + const { heroCollectionService, store } = entityServicesSetup(); + const dispatcher = heroCollectionService.dispatcher; + return { dispatcher, store }; + } + }); + + // TODO: test the effect of MergeStrategy when there are entities in cache with changes + // This concern is largely met by EntityChangeTracker tests but integration tests would be reassuring. + describe('queries', () => { + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let reducedActions$Snoop: () => void; + + beforeEach(() => { + ({ + heroCollectionService, + reducedActions$Snoop, + dataService, + } = entityServicesSetup()); + }); + + // Compare to next test which subscribes to getAll() result + it('can use loading$ to learn when getAll() succeeds', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.getAll(); + + // N.B.: This technique does not detect errors + heroCollectionService.loading$ + .pipe( + filter(loading => !loading), + withLatestFrom(heroCollectionService.entities$) + ) + .subscribe(([loading, data]) => { + expect(data).toEqual(heroes); + done(); + }); + }); + + // Compare to previous test the waits for loading$ flag to flip + it('getAll observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.getAll().subscribe(expectDataToBe(heroes, done)); + + // reducedActions$Snoop(); // diagnostic + }); + + it('getAll observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getAll', error); + heroCollectionService.getAll().subscribe(expectErrorToBe(error, done)); + }); + + it('getByKey observable should emit a hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + dataService.setResponse('getById', hero); + heroCollectionService.getByKey(1).subscribe(expectDataToBe(hero, done)); + }); + + it('getByKey observable should emit expected error when data service fails', (done: DoneFn) => { + // Simulate HTTP 'Not Found' response + const httpError = new HttpErrorResponse({ + error: 'Entity not found', + status: 404, + statusText: 'Not Found', + url: 'bad/location', + }); + + // For test purposes, the following would have been effectively the same thing + // const httpError = { error: new Error('Entity not found'), status: 404 }; + + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getById', error); + heroCollectionService + .getByKey(42) + .subscribe(expectErrorToBe(error, done)); + }); + + it('getWithQuery observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getWithQuery', heroes); + heroCollectionService + .getWithQuery({ name: 'foo' }) + .subscribe(expectDataToBe(heroes, done)); + + // reducedActions$Snoop(); // diagnostic + }); + + it('getWithQuery observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getWithQuery', error); + heroCollectionService + .getWithQuery({ name: 'foo' }) + .subscribe(expectErrorToBe(error, done)); + }); + + it('load observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.load().subscribe(expectDataToBe(heroes, done)); + }); + + it('load observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getAll', error); + heroCollectionService.load().subscribe(expectErrorToBe(error, done)); + }); + }); + + describe('cancel', () => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let reducedActions$Snoop: () => void; + + beforeEach(() => { + ({ + dataService, + heroCollectionService, + reducedActions$Snoop, + } = entityServicesSetup()); + }); + + it('can cancel a long running query', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue( + of(heroes).pipe(delay(responseDelay)) + ); + + // Create the correlation id yourself to know which action to cancel. + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService.getAll(options).subscribe( + data => fail('should not have data but got data'), + error => { + expect(error instanceof PersistanceCanceled).toBe( + true, + 'is PersistanceCanceled' + ); + expect(error.message).toBe('Test cancel'); + done(); + } + ); + + heroCollectionService.cancel(correlationId, 'Test cancel'); + }); + + it('has no effect on action with different correlationId', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue( + of(heroes).pipe(delay(responseDelay)) + ); + + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService.getAll(options).subscribe(data => { + expect(data).toEqual(heroes); + done(); + }, fail); + + heroCollectionService.cancel('not-the-crid'); + }); + + it('has no effect when too late', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue( + of(heroes).pipe(delay(responseDelay)) + ); + + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService + .getAll(options) + .subscribe(data => expect(data).toEqual(heroes), fail); + + setTimeout( + () => heroCollectionService.cancel(correlationId), + responseDelay + 2 + ); + setTimeout(done, responseDelay + 4); // wait for all to complete + }); + }); + + xdescribe('saves (optimistic)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + /* tslint:disable-next-line:no-use-before-declare */ + providers: [ + { + provide: EntityDispatcherDefaultOptions, + useClass: OptimisticDispatcherDefaultOptions, + }, + ], + }); + }); + + combinedSaveTests(true); + }); + + xdescribe('saves (pessimistic)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + /* tslint:disable-next-line:no-use-before-declare */ + providers: [ + { + provide: EntityDispatcherDefaultOptions, + useClass: PessimisticDispatcherDefaultOptions, + }, + ], + }); + }); + + combinedSaveTests(false); + }); + + /** Save tests to be run both optimistically and pessimistically */ + function combinedSaveTests(isOptimistic: boolean) { + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let expectOptimisticSuccess: (expect: boolean) => () => void; + let reducedActions$Snoop: () => void; + let successActions$: Observable; + + beforeEach(() => { + ({ + dataService, + expectOptimisticSuccess, + heroCollectionService, + reducedActions$Snoop, + successActions$, + } = entityServicesSetup()); + }); + + it('add() should save a new entity and return it', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const hero = { id: 1, name: 'A' } as Hero; + dataService.setResponse('add', hero); + heroCollectionService + .add(hero) + .subscribe(expectDataToBe(hero, done, undefined, extra)); + }); + + it('add() observable should emit expected error when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + dataService.setErrorResponse('add', error); + heroCollectionService.add(hero).subscribe(expectErrorToBe(error, done)); + }); + + it('delete() should send delete for entity not in cache and return its id', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + dataService.setResponse('delete', 42); + heroCollectionService + .delete(42) + .subscribe(expectDataToBe(42, done, undefined, extra)); + }); + + it('delete() should skip delete for added entity cache', (done: DoneFn) => { + // reducedActions$Snoop(); + let wasSkipped: boolean; + successActions$.subscribe( + (act: EntityAction) => (wasSkipped = act.payload.skip === true) + ); + const extra = () => + expect(wasSkipped).toBe(true, 'expected to be skipped'); + + const hero = { id: 1, name: 'A' } as Hero; + heroCollectionService.addOneToCache(hero); + dataService.setResponse('delete', 1); + heroCollectionService + .delete(1) + .subscribe(expectDataToBe(1, done, undefined, extra)); + }); + + it('delete() observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('DELETE', httpError); + dataService.setErrorResponse('delete', error); + heroCollectionService.delete(42).subscribe(expectErrorToBe(error, done)); + }); + + it('update() should save updated entity and return it', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + dataService.setResponse('update', null); // server returns nothing after update + heroCollectionService + .update(update) + .subscribe(expectDataToBe(update, done, undefined, extra)); + }); + + it('update() should save updated entity and return server-changed version', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + const postUpdate = { + ...preUpdate, + name: 'Updated A', + saying: 'Server set this', + }; + dataService.setResponse('update', postUpdate); // server returns entity with server-side changes + heroCollectionService + .update(update) + .subscribe(expectDataToBe(postUpdate, done, undefined, extra)); + }); + + it('update() observable should emit expected error when data service fails', (done: DoneFn) => { + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + dataService.setErrorResponse('update', error); + heroCollectionService + .update(update) + .subscribe(expectErrorToBe(error, done)); + }); + + it('can handle out-of-order save results', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + let successActionCount = 0; + const delayMs = 5; + let responseDelay = delayMs; + const savedHeroes: Hero[] = []; + + successActions$.pipe(delay(1)).subscribe(act => { + successActionCount += 1; + if (successActionCount === 2) { + // Confirm hero2 actually saved before hero1 + expect(savedHeroes).toEqual([hero2, hero1], 'savedHeroes'); + done(); + } + }); + + // dataService.add returns odd responses later than even responses + // so add of hero2 should complete before add of hero1 + dataService['add'].and.callFake((data: Hero) => { + const result = of(data).pipe( + delay(responseDelay), + tap(h => savedHeroes.push(h)) + ); + responseDelay = delayMs === responseDelay ? 1 : responseDelay; + return result; + }); + + // Save hero1 before hero2 + // Confirm that each add returns with its own hero + heroCollectionService + .add(hero1) + .subscribe(data => expect(data).toEqual(hero1, 'first hero')); + + heroCollectionService + .add(hero2) + .subscribe(data => expect(data).toEqual(hero2, 'second hero')); + }); + } + + describe('selectors$', () => { + let entityActionFactory: EntityActionFactory; + let heroCollectionService: EntityCollectionService; + let store: Store; + + function dispatchedAction() { + return (store.dispatch).calls.argsFor(0)[0]; + } + + beforeEach(() => { + const setup = entityServicesSetup(); + ({ entityActionFactory, heroCollectionService, store } = setup); + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('can get collection from collection$', () => { + const action = entityActionFactory.create('Hero', EntityOp.ADD_ALL, [ + { id: 1, name: 'A' }, + ]); + store.dispatch(action); + heroCollectionService.collection$.subscribe(collection => { + expect(collection.ids).toEqual([1]); + }); + }); + }); +}); + +// #region test helpers +class Hero { + id: number; + name: string; + saying?: string; +} +class Villain { + key: string; + name: string; +} + +const entityMetadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: villain => villain.key }, +}; + +function entityServicesSetup() { + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + EntityDataModule.forRoot({ + entityMetadata: entityMetadata, + }), + ], + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, + { provide: Logger, useValue: logger }, + ], + }); + + const actions$: Observable = TestBed.inject(Actions); + const dataService: TestDataService = TestBed.inject(EntityDataService); + const entityActionFactory: EntityActionFactory = TestBed.inject( + EntityActionFactory + ); + const entityDispatcherFactory: EntityDispatcherFactory = TestBed.inject( + EntityDispatcherFactory + ); + const entityServices: EntityServices = TestBed.inject(EntityServices); + const heroCollectionService = entityServices.getEntityCollectionService( + 'Hero' + ); + const reducedActions$: Observable = + entityDispatcherFactory.reducedActions$; + const store: Store = TestBed.inject(Store); + const successActions$: Observable = reducedActions$.pipe( + filter( + (act: any) => act.payload && act.payload.entityOp.endsWith(OP_SUCCESS) + ) + ); + + /** Returns fn that confirms EntityAction was (or was not Optimistic) after success */ + function expectOptimisticSuccess(expected: boolean) { + let wasOptimistic: boolean; + const msg = `${expected ? 'Optimistic' : 'Pessimistic'} save `; + successActions$.subscribe( + (act: EntityAction) => (wasOptimistic = act.payload.isOptimistic === true) + ); + return () => expect(wasOptimistic).toBe(expected, msg); + } + + /** Snoop on reducedActions$ while debugging a test */ + function reducedActions$Snoop() { + reducedActions$.subscribe(act => { + console.log('scannedActions$', act); + }); + } + + return { + actions$, + dataService, + entityActionFactory, + entityServices, + expectOptimisticSuccess, + heroCollectionService, + reducedActions$, + reducedActions$Snoop, + store, + successActions$, + }; +} + +function expectDataToBe( + expected: any, + done: DoneFn, + message?: string, + extra?: () => void +) { + return { + next: (data: any) => { + expect(data).toEqual(expected, message); + if (extra) { + extra(); // extra expectations before done + } + done(); + }, + error: fail, + }; +} + +function expectErrorToBe(expected: any, done: DoneFn, message?: string) { + return { + next: (data: any) => { + fail(`Expected error response but got data: '${JSON.stringify(data)}'`); + done(); + }, + error: (error: any) => { + expect(error).toEqual(expected, message); + done(); + }, + }; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +@Injectable() +export class OptimisticDispatcherDefaultOptions { + optimisticAdd = true; + optimisticDelete = true; + optimisticUpdate = true; +} + +@Injectable() +export class PessimisticDispatcherDefaultOptions { + optimisticAdd = false; + optimisticDelete = false; + optimisticUpdate = false; +} + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; +} + +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data: any) { + this[methodName].and.returnValue(of(data).pipe(delay(1))); + } + + setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { + // Following won't quite work because delay does not appear to delay an error + // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); + // Use timer instead + this[methodName].and.returnValue( + timer(1).pipe(mergeMap(() => throwError(error))) + ); + } +} +// #endregion test helpers diff --git a/modules/data/spec/entity-services/entity-services.spec.ts b/modules/data/spec/entity-services/entity-services.spec.ts index 4dec048237..9eefd14483 100644 --- a/modules/data/spec/entity-services/entity-services.spec.ts +++ b/modules/data/spec/entity-services/entity-services.spec.ts @@ -1,241 +1,236 @@ -import { TestBed } from '@angular/core/testing'; -import { Action, StoreModule, Store } from '@ngrx/store'; -import { Actions, EffectsModule } from '@ngrx/effects'; -import { Observable } from 'rxjs'; -import { first, skip } from 'rxjs/operators'; - -import { - EntityAction, - EntityOp, - EntityCacheQuerySet, - MergeQuerySet, - EntityMetadataMap, - EntityDataModule, - EntityCacheEffects, - EntityDataService, - EntityActionFactory, - EntityDispatcherFactory, - EntityServices, - EntityCache, - HttpMethods, - DataServiceError, - Logger, -} from '../..'; - -describe('EntityServices', () => { - describe('entityActionErrors$', () => { - it('should emit EntityAction errors for multiple entity types', () => { - const errors: EntityAction[] = []; - const { entityActionFactory, entityServices } = entityServicesSetup(); - entityServices.entityActionErrors$.subscribe(error => errors.push(error)); - - entityServices.dispatch({ type: 'not-an-entity-action' }); - entityServices.dispatch( - entityActionFactory.create('Hero', EntityOp.QUERY_ALL) - ); // not an error - entityServices.dispatch( - entityActionFactory.create( - 'Hero', - EntityOp.QUERY_ALL_ERROR, - makeDataServiceError('GET', new Error('Bad hero news')) - ) - ); - entityServices.dispatch( - entityActionFactory.create('Villain', EntityOp.QUERY_ALL) - ); // not an error - entityServices.dispatch( - entityActionFactory.create( - 'Villain', - EntityOp.SAVE_ADD_ONE_ERROR, - makeDataServiceError('PUT', new Error('Bad villain news')) - ) - ); - - expect(errors.length).toBe(2); - }); - }); - - describe('entityCache$', () => { - it('should observe the entire entity cache', () => { - const entityCacheValues: any = []; - - const { - entityActionFactory, - entityServices, - store, - } = entityServicesSetup(); - - // entityCache$.subscribe() callback invoked immediately. The cache is empty at first. - entityServices.entityCache$.subscribe(ec => entityCacheValues.push(ec)); - - // This first action to go through the Hero's EntityCollectionReducer - // creates the collection in the EntityCache as a side-effect, - // triggering the second entityCache$.subscribe() callback - const heroAction = entityActionFactory.create( - 'Hero', - EntityOp.SET_FILTER, - 'test' - ); - store.dispatch(heroAction); - - expect(entityCacheValues.length).toEqual( - 2, - 'entityCache$ callback twice' - ); - expect(entityCacheValues[0]).toEqual({}, 'empty at first'); - expect(entityCacheValues[1].Hero).toBeDefined('has Hero collection'); - }); - }); - - describe('dispatch(MergeQuerySet)', () => { - // using async test to guard against false test pass. - it('should update entityCache$ twice after merging two individual collections', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - - const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; - - const { entityServices } = entityServicesSetup(); - const heroCollectionService = entityServices.getEntityCollectionService< - Hero - >('Hero'); - const villainCollectionService = entityServices.getEntityCollectionService< - Villain - >('Villain'); - - const entityCacheValues: any = []; - entityServices.entityCache$.subscribe(cache => { - entityCacheValues.push(cache); - if (entityCacheValues.length === 3) { - expect(entityCacheValues[0]).toEqual({}, '#1 empty at first'); - expect(entityCacheValues[1]['Hero'].ids).toEqual( - [1, 2], - '#2 has heroes' - ); - expect(entityCacheValues[1]['Villain']).toBeUndefined( - '#2 does not have Villain collection' - ); - expect(entityCacheValues[2]['Villain'].entities['DE']).toEqual( - villain, - '#3 has villain' - ); - done(); - } - }); - - // Emulate what would happen if had queried collections separately - heroCollectionService.createAndDispatch( - EntityOp.QUERY_MANY_SUCCESS, - heroes - ); - villainCollectionService.createAndDispatch( - EntityOp.QUERY_BY_KEY_SUCCESS, - villain - ); - }); - - // using async test to guard against false test pass. - it('should update entityCache$ once when MergeQuerySet multiple collections', (done: DoneFn) => { - const hero1 = { id: 1, name: 'A' } as Hero; - const hero2 = { id: 2, name: 'B' } as Hero; - const heroes = [hero1, hero2]; - const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; - const querySet: EntityCacheQuerySet = { - Hero: heroes, - Villain: [villain], - }; - const action = new MergeQuerySet(querySet); - - const { entityServices } = entityServicesSetup(); - - // Skip initial value. Want the first one after merge is dispatched - entityServices.entityCache$ - .pipe( - skip(1), - first() - ) - .subscribe(cache => { - expect(cache['Hero'].ids).toEqual([1, 2], 'has merged heroes'); - expect(cache['Villain'].entities['DE']).toEqual( - villain, - 'has merged villain' - ); - done(); - }); - entityServices.dispatch(action); - }); - }); -}); - -// #region test helpers -class Hero { - id: number; - name: string; - saying?: string; -} -class Villain { - key: string; - name: string; -} - -const entityMetadata: EntityMetadataMap = { - Hero: {}, - Villain: { selectId: villain => villain.key }, -}; - -function entityServicesSetup() { - const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - EffectsModule.forRoot([]), - EntityDataModule.forRoot({ - entityMetadata: entityMetadata, - }), - ], - /* tslint:disable-next-line:no-use-before-declare */ - providers: [ - { provide: EntityCacheEffects, useValue: {} }, - { provide: EntityDataService, useValue: null }, - { provide: Logger, useValue: logger }, - ], - }); - - const actions$: Observable = TestBed.get(Actions); - const entityActionFactory: EntityActionFactory = TestBed.get( - EntityActionFactory - ); - const entityDispatcherFactory: EntityDispatcherFactory = TestBed.get( - EntityDispatcherFactory - ); - const entityServices: EntityServices = TestBed.get(EntityServices); - const store: Store = TestBed.get(Store); - - return { - actions$, - entityActionFactory, - entityServices, - store, - }; -} - -/** make error produced by the EntityDataService */ -function makeDataServiceError( - /** Http method for that action */ - method: HttpMethods, - /** Http error from the web api */ - httpError?: any, - /** Options sent with the request */ - options?: any -) { - let url = 'api/heroes'; - if (httpError) { - url = httpError.url || url; - } else { - httpError = { error: new Error('Test error'), status: 500, url }; - } - return new DataServiceError(httpError, { method, url, options }); -} -// #endregion test helpers +import { TestBed } from '@angular/core/testing'; +import { Action, StoreModule, Store } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; +import { Observable } from 'rxjs'; +import { first, skip } from 'rxjs/operators'; + +import { + EntityAction, + EntityOp, + EntityCacheQuerySet, + MergeQuerySet, + EntityMetadataMap, + EntityDataModule, + EntityCacheEffects, + EntityDataService, + EntityActionFactory, + EntityDispatcherFactory, + EntityServices, + EntityCache, + HttpMethods, + DataServiceError, + Logger, +} from '../..'; + +describe('EntityServices', () => { + describe('entityActionErrors$', () => { + it('should emit EntityAction errors for multiple entity types', () => { + const errors: EntityAction[] = []; + const { entityActionFactory, entityServices } = entityServicesSetup(); + entityServices.entityActionErrors$.subscribe(error => errors.push(error)); + + entityServices.dispatch({ type: 'not-an-entity-action' }); + entityServices.dispatch( + entityActionFactory.create('Hero', EntityOp.QUERY_ALL) + ); // not an error + entityServices.dispatch( + entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL_ERROR, + makeDataServiceError('GET', new Error('Bad hero news')) + ) + ); + entityServices.dispatch( + entityActionFactory.create('Villain', EntityOp.QUERY_ALL) + ); // not an error + entityServices.dispatch( + entityActionFactory.create( + 'Villain', + EntityOp.SAVE_ADD_ONE_ERROR, + makeDataServiceError('PUT', new Error('Bad villain news')) + ) + ); + + expect(errors.length).toBe(2); + }); + }); + + describe('entityCache$', () => { + it('should observe the entire entity cache', () => { + const entityCacheValues: any = []; + + const { + entityActionFactory, + entityServices, + store, + } = entityServicesSetup(); + + // entityCache$.subscribe() callback invoked immediately. The cache is empty at first. + entityServices.entityCache$.subscribe(ec => entityCacheValues.push(ec)); + + // This first action to go through the Hero's EntityCollectionReducer + // creates the collection in the EntityCache as a side-effect, + // triggering the second entityCache$.subscribe() callback + const heroAction = entityActionFactory.create( + 'Hero', + EntityOp.SET_FILTER, + 'test' + ); + store.dispatch(heroAction); + + expect(entityCacheValues.length).toEqual( + 2, + 'entityCache$ callback twice' + ); + expect(entityCacheValues[0]).toEqual({}, 'empty at first'); + expect(entityCacheValues[1].Hero).toBeDefined('has Hero collection'); + }); + }); + + describe('dispatch(MergeQuerySet)', () => { + // using async test to guard against false test pass. + it('should update entityCache$ twice after merging two individual collections', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; + + const { entityServices } = entityServicesSetup(); + const heroCollectionService = entityServices.getEntityCollectionService< + Hero + >('Hero'); + const villainCollectionService = entityServices.getEntityCollectionService< + Villain + >('Villain'); + + const entityCacheValues: any = []; + entityServices.entityCache$.subscribe(cache => { + entityCacheValues.push(cache); + if (entityCacheValues.length === 3) { + expect(entityCacheValues[0]).toEqual({}, '#1 empty at first'); + expect(entityCacheValues[1]['Hero'].ids).toEqual( + [1, 2], + '#2 has heroes' + ); + expect(entityCacheValues[1]['Villain']).toBeUndefined( + '#2 does not have Villain collection' + ); + expect(entityCacheValues[2]['Villain'].entities['DE']).toEqual( + villain, + '#3 has villain' + ); + done(); + } + }); + + // Emulate what would happen if had queried collections separately + heroCollectionService.createAndDispatch( + EntityOp.QUERY_MANY_SUCCESS, + heroes + ); + villainCollectionService.createAndDispatch( + EntityOp.QUERY_BY_KEY_SUCCESS, + villain + ); + }); + + // using async test to guard against false test pass. + it('should update entityCache$ once when MergeQuerySet multiple collections', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; + const querySet: EntityCacheQuerySet = { + Hero: heroes, + Villain: [villain], + }; + const action = new MergeQuerySet(querySet); + + const { entityServices } = entityServicesSetup(); + + // Skip initial value. Want the first one after merge is dispatched + entityServices.entityCache$.pipe(skip(1), first()).subscribe(cache => { + expect(cache['Hero'].ids).toEqual([1, 2], 'has merged heroes'); + expect(cache['Villain'].entities['DE']).toEqual( + villain, + 'has merged villain' + ); + done(); + }); + entityServices.dispatch(action); + }); + }); +}); + +// #region test helpers +class Hero { + id: number; + name: string; + saying?: string; +} +class Villain { + key: string; + name: string; +} + +const entityMetadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: villain => villain.key }, +}; + +function entityServicesSetup() { + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + EntityDataModule.forRoot({ + entityMetadata: entityMetadata, + }), + ], + /* tslint:disable-next-line:no-use-before-declare */ + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + { provide: EntityDataService, useValue: null }, + { provide: Logger, useValue: logger }, + ], + }); + + const actions$: Observable = TestBed.inject(Actions); + const entityActionFactory: EntityActionFactory = TestBed.inject( + EntityActionFactory + ); + const entityDispatcherFactory: EntityDispatcherFactory = TestBed.inject( + EntityDispatcherFactory + ); + const entityServices: EntityServices = TestBed.inject(EntityServices); + const store: Store = TestBed.inject(Store); + + return { + actions$, + entityActionFactory, + entityServices, + store, + }; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} +// #endregion test helpers diff --git a/modules/data/spec/reducers/entity-cache-reducer.spec.ts b/modules/data/spec/reducers/entity-cache-reducer.spec.ts index 2d5235bdf7..0c91e2dc0b 100644 --- a/modules/data/spec/reducers/entity-cache-reducer.spec.ts +++ b/modules/data/spec/reducers/entity-cache-reducer.spec.ts @@ -1,702 +1,699 @@ -import { TestBed } from '@angular/core/testing'; -import { Action, ActionReducer } from '@ngrx/store'; -import { IdSelector } from '@ngrx/entity'; - -import { - EntityMetadataMap, - EntityCollectionCreator, - EntityActionFactory, - EntityCache, - EntityCacheReducerFactory, - EntityCollectionReducerMethodsFactory, - EntityCollectionReducerFactory, - EntityCollectionReducerRegistry, - EntityDefinitionService, - ENTITY_METADATA_TOKEN, - EntityOp, - ClearCollections, - EntityCacheQuerySet, - LoadCollections, - MergeQuerySet, - SetEntityCache, - SaveEntities, - SaveEntitiesCancel, - SaveEntitiesSuccess, - DataServiceError, - SaveEntitiesError, - EntityCollection, - ChangeSet, - ChangeSetOperation, - Logger, -} from '../..'; - -class Hero { - id: number; - name: string; - power?: string; -} -class Villain { - key: string; - name: string; -} - -const metadata: EntityMetadataMap = { - Fool: {}, - Hero: {}, - Knave: {}, - Villain: { selectId: villain => villain.key }, -}; - -describe('EntityCacheReducer', () => { - let collectionCreator: EntityCollectionCreator; - let entityActionFactory: EntityActionFactory; - let entityCacheReducer: ActionReducer; - - beforeEach(() => { - entityActionFactory = new EntityActionFactory(); - const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - - TestBed.configureTestingModule({ - providers: [ - EntityCacheReducerFactory, - EntityCollectionCreator, - { - provide: EntityCollectionReducerMethodsFactory, - useClass: EntityCollectionReducerMethodsFactory, - }, - EntityCollectionReducerFactory, - EntityCollectionReducerRegistry, - EntityDefinitionService, - { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, - { provide: Logger, useValue: logger }, - ], - }); - - collectionCreator = TestBed.get(EntityCollectionCreator); - const entityCacheReducerFactory = TestBed.get( - EntityCacheReducerFactory - ) as EntityCacheReducerFactory; - entityCacheReducer = entityCacheReducerFactory.create(); - }); - - describe('#create', () => { - it('creates a default hero reducer when QUERY_ALL for hero', () => { - const hero: Hero = { id: 42, name: 'Bobby' }; - const action = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - hero - ); - - const state = entityCacheReducer({}, action); - const collection = state['Hero']; - expect(collection.ids.length).toBe(1, 'should have added one'); - expect(collection.entities[42]).toEqual(hero, 'should be added hero'); - }); - - it('throws when ask for reducer of unknown entity type', () => { - const action = entityActionFactory.create('Foo', EntityOp.QUERY_ALL); - expect(() => entityCacheReducer({}, action)).toThrowError( - /no EntityDefinition/i - ); - }); - }); - - /** - * Test the EntityCache-level actions, SET and MERGE, which can - * be used to restore the entity cache from a know state such as - * re-hydrating from browser storage. - * Useful for an offline-capable app. - */ - describe('EntityCache-level actions', () => { - let initialHeroes: Hero[]; - let initialCache: EntityCache; - - beforeEach(() => { - initialHeroes = [ - { id: 2, name: 'B', power: 'Fast' }, - { id: 1, name: 'A', power: 'invisible' }, - ]; - initialCache = createInitialCache({ Hero: initialHeroes }); - }); - - describe('CLEAR_COLLECTIONS', () => { - beforeEach(() => { - const heroes = [ - { id: 2, name: 'B', power: 'Fast' }, - { id: 1, name: 'A', power: 'invisible' }, - ]; - const villains = [{ key: 'DE', name: 'Dr. Evil' }]; - const fools = [{ id: 66, name: 'Fool 66' }]; - - initialCache = createInitialCache({ - Hero: heroes, - Villain: villains, - Fool: fools, - }); - }); - - it('should clear an existing cached collection', () => { - const collections = ['Hero']; - const action = new ClearCollections(collections); - const state = entityCacheReducer(initialCache, action); - expect(state['Hero'].ids).toEqual([], 'empty Hero collection'); - expect(state['Fool'].ids.length).toBeGreaterThan(0, 'Fools remain'); - expect(state['Villain'].ids.length).toBeGreaterThan( - 0, - 'Villains remain' - ); - }); - - it('should clear multiple existing cached collections', () => { - const collections = ['Hero', 'Villain']; - const action = new ClearCollections(collections); - const state = entityCacheReducer(initialCache, action); - expect(state['Hero'].ids).toEqual([], 'empty Hero collection'); - expect(state['Villain'].ids).toEqual([], 'empty Villain collection'); - expect(state['Fool'].ids.length).toBeGreaterThan(0, 'Fools remain'); - }); - - it('should initialize an empty cache with the collections', () => { - // because ANY call to a reducer creates the collection! - const collections = ['Hero', 'Villain']; - const action = new ClearCollections(collections); - - const state = entityCacheReducer({}, action); - expect(Object.keys(state)).toEqual( - ['Hero', 'Villain'], - 'created collections' - ); - expect(state['Villain'].ids).toEqual([], 'Hero id'); - expect(state['Hero'].ids).toEqual([], 'Villain ids'); - }); - - it('should return cache matching existing cache for empty collections array', () => { - const collections: string[] = []; - const action = new ClearCollections(collections); - const state = entityCacheReducer(initialCache, action); - expect(state).toEqual(initialCache); - }); - - it('should clear every collection in an existing cache when collections is falsy', () => { - const action = new ClearCollections(undefined); - const state = entityCacheReducer(initialCache, action); - expect(Object.keys(state).sort()).toEqual( - ['Fool', 'Hero', 'Villain'], - 'collections still exist' - ); - expect(state['Fool'].ids).toEqual([], 'no Fool ids'); - expect(state['Hero'].ids).toEqual([], 'no Villain ids'); - expect(state['Villain'].ids).toEqual([], 'no Hero id'); - }); - }); - - describe('LOAD_COLLECTIONS', () => { - function shouldHaveExpectedHeroes(entityCache: EntityCache) { - const heroCollection = entityCache['Hero']; - expect(heroCollection.ids).toEqual([2, 1], 'Hero ids'); - expect(heroCollection.entities).toEqual({ - 1: initialHeroes[1], - 2: initialHeroes[0], - }); - expect(heroCollection.loaded).toBe(true, 'Heroes loaded'); - } - - it('should initialize an empty cache with the collections', () => { - const collections: EntityCacheQuerySet = { - Hero: initialHeroes, - Villain: [{ key: 'DE', name: 'Dr. Evil' }], - }; - - const action = new LoadCollections(collections); - - const state = entityCacheReducer({}, action); - shouldHaveExpectedHeroes(state); - expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); - expect(state['Villain'].loaded).toBe(true, 'Villains loaded'); - }); - - it('should return cache matching existing cache when collections set is empty', () => { - const action = new LoadCollections({}); - const state = entityCacheReducer(initialCache, action); - expect(state).toEqual(initialCache); - }); - - it('should add a new collection to existing cache', () => { - const collections: EntityCacheQuerySet = { - Knave: [{ id: 96, name: 'Sneaky Pete' }], - }; - const action = new LoadCollections(collections); - const state = entityCacheReducer(initialCache, action); - expect(state['Knave'].ids).toEqual([96], 'Knave ids'); - expect(state['Knave'].loaded).toBe(true, 'Knave loaded'); - }); - - it('should replace an existing cached collection', () => { - const collections: EntityCacheQuerySet = { - Hero: [{ id: 42, name: 'Bobby' }], - }; - const action = new LoadCollections(collections); - const state = entityCacheReducer(initialCache, action); - const heroCollection = state['Hero']; - expect(heroCollection.ids).toEqual([42], 'only the loaded hero'); - expect(heroCollection.entities[42]).toEqual( - { id: 42, name: 'Bobby' }, - 'loaded hero' - ); - }); - }); - - describe('MERGE_QUERY_SET', () => { - function shouldHaveExpectedHeroes(entityCache: EntityCache) { - expect(entityCache['Hero'].ids).toEqual([2, 1], 'Hero ids'); - expect(entityCache['Hero'].entities).toEqual({ - 1: initialHeroes[1], - 2: initialHeroes[0], - }); - } - - it('should initialize an empty cache with query set', () => { - const querySet: EntityCacheQuerySet = { - Hero: initialHeroes, - Villain: [{ key: 'DE', name: 'Dr. Evil' }], - }; - - const action = new MergeQuerySet(querySet); - - const state = entityCacheReducer({}, action); - shouldHaveExpectedHeroes(state); - expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); - }); - - it('should return cache matching existing cache when query set is empty', () => { - const action = new MergeQuerySet({}); - const state = entityCacheReducer(initialCache, action); - shouldHaveExpectedHeroes(state); - }); - - it('should add a new collection to existing cache', () => { - const querySet: EntityCacheQuerySet = { - Villain: [{ key: 'DE', name: 'Dr. Evil' }], - }; - const action = new MergeQuerySet(querySet); - const state = entityCacheReducer(initialCache, action); - shouldHaveExpectedHeroes(state); - expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); - }); - - it('should merge into an existing cached collection', () => { - const querySet: EntityCacheQuerySet = { - Hero: [{ id: 42, name: 'Bobby' }], - }; - const action = new MergeQuerySet(querySet); - const state = entityCacheReducer(initialCache, action); - const heroCollection = state['Hero']; - const expectedIds = initialHeroes.map(h => h.id).concat(42); - expect(heroCollection.ids).toEqual(expectedIds, 'merged ids'); - expect(heroCollection.entities[42]).toEqual( - { id: 42, name: 'Bobby' }, - 'merged hero' - ); - }); - }); - - describe('SET_ENTITY_CACHE', () => { - it('should initialize cache', () => { - const cache = createInitialCache({ - Hero: initialHeroes, - Villain: [{ key: 'DE', name: 'Dr. Evil' }], - }); - - const action = new SetEntityCache(cache); - // const action = { // equivalent - // type: SET_ENTITY_CACHE, - // payload: cache - // }; - - const state = entityCacheReducer(cache, action); - expect(state['Hero'].ids).toEqual([2, 1], 'Hero ids'); - expect(state['Hero'].entities).toEqual({ - 1: initialHeroes[1], - 2: initialHeroes[0], - }); - expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); - }); - - it('should clear the cache when set with empty object', () => { - const action = new SetEntityCache({}); - const state = entityCacheReducer(initialCache, action); - expect(Object.keys(state)).toEqual([]); - }); - - it('should replace prior cache with new cache', () => { - const priorCache = createInitialCache({ - Hero: initialHeroes, - Villain: [{ key: 'DE', name: 'Dr. Evil' }], - }); - - const newHeroes = [{ id: 42, name: 'Bobby' }]; - const newCache = createInitialCache({ Hero: newHeroes }); - - const action = new SetEntityCache(newCache); - const state = entityCacheReducer(priorCache, action); - expect(state['Villain']).toBeUndefined('No villains'); - - const heroCollection = state['Hero']; - expect(heroCollection.ids).toEqual([42], 'hero ids'); - expect(heroCollection.entities[42]).toEqual(newHeroes[0], 'heroes'); - }); - }); - - describe('SAVE_ENTITIES', () => { - it('should turn on loading flags for affected collections and nothing more when pessimistic', () => { - const changeSet = createTestChangeSet(); - const action = new SaveEntities(changeSet, 'api/save', { - isOptimistic: false, - }); - - const entityCache = entityCacheReducer({}, action); - - expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); - expect(entityCache['Hero'].ids).toEqual([], 'Hero ids'); - expect(entityCache['Knave'].ids).toEqual([], 'Knave ids'); - expect(entityCache['Villain'].ids).toEqual([], 'Villain ids'); - expectLoadingFlags(entityCache, true); - }); - - it('should initialize an empty cache with entities when optimistic and turn on loading flags', () => { - const changeSet = createTestChangeSet(); - const action = new SaveEntities(changeSet, 'api/save', { - isOptimistic: true, - }); - - const entityCache = entityCacheReducer({}, action); - - expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); - expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); - expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); - expect(entityCache['Villain'].ids).toEqual( - ['44', '45'], - 'added Villain ids' - ); - expectLoadingFlags(entityCache, true); - }); - - it('should modify existing cache with entities when optimistic and turn on loading flags', () => { - const initialEntities = createInitialSaveTestEntities(); - let entityCache = createInitialCache(initialEntities); - - const changeSet = createTestChangeSet(); - const action = new SaveEntities(changeSet, 'api/save', { - isOptimistic: true, - }); - - entityCache = entityCacheReducer(entityCache, action); - - expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); - expect(entityCache['Fool'].entities[1].skill).toEqual( - 'Updated Skill 1' - ); - - expect(entityCache['Hero'].ids).toEqual( - [4, 42, 43], - 'Hero ids - 2 new, {3,5} deleted' - ); - - expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); - expect(entityCache['Knave'].entities[6].name).toEqual( - 'Upsert Update Knave 6' - ); - expect(entityCache['Knave'].entities[66].name).toEqual( - 'Upsert Add Knave 66' - ); - - expect(entityCache['Villain'].ids).toEqual( - ['7', '8', '10', '44', '45'], - 'Villain ids' - ); - - expectLoadingFlags(entityCache, true); - }); - }); - - describe('SAVE_ENTITIES_CANCEL', () => { - const corid = 'CORID42'; - - it('should not turn off loading flags if you do not specify collections', () => { - const changeSet = createTestChangeSet(); - let action: Action = new SaveEntities(changeSet, 'api/save', { - correlationId: corid, - isOptimistic: false, - }); - - // Pessimistic save turns on loading flags - let entityCache = entityCacheReducer({}, action); - expectLoadingFlags(entityCache, true); - - action = new SaveEntitiesCancel(corid, 'Test Cancel'); // no names so no flags turned off. - entityCache = entityCacheReducer(entityCache, action); - expectLoadingFlags(entityCache, true); - }); - - it('should turn off loading flags for collections that you specify', () => { - const changeSet = createTestChangeSet(); - let action: Action = new SaveEntities(changeSet, 'api/save', { - correlationId: corid, - isOptimistic: false, - }); - - // Pessimistic save turns on loading flags - let entityCache = entityCacheReducer({}, action); - expectLoadingFlags(entityCache, true); - - action = new SaveEntitiesCancel(corid, 'Test Cancel', ['Hero', 'Fool']); - entityCache = entityCacheReducer(entityCache, action); - expectLoadingFlags(entityCache, false, ['Hero', 'Fool']); - expectLoadingFlags(entityCache, true, ['Knave', 'Villain']); - }); - }); - - describe('SAVE_ENTITIES_SUCCESS', () => { - it('should initialize an empty cache with entities when pessimistic', () => { - const changeSet = createTestChangeSet(); - const action = new SaveEntitiesSuccess(changeSet, 'api/save', { - isOptimistic: false, - }); - - const entityCache = entityCacheReducer({}, action); - - expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); - expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); - expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); - expect(entityCache['Villain'].ids).toEqual( - ['44', '45'], - 'added Villain ids' - ); - expectLoadingFlags(entityCache, false); - }); - - it('should modify existing cache with entities when pessimistic', () => { - const initialEntities = createInitialSaveTestEntities(); - let entityCache = createInitialCache(initialEntities); - - const changeSet = createTestChangeSet(); - const action = new SaveEntitiesSuccess(changeSet, 'api/save', { - isOptimistic: false, - }); - - entityCache = entityCacheReducer(entityCache, action); - - expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); - expect(entityCache['Fool'].entities[1].skill).toEqual( - 'Updated Skill 1' - ); - - expect(entityCache['Hero'].ids).toEqual( - [4, 42, 43], - 'Hero ids - 2 new, {3,5} deleted' - ); - - expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); - expect(entityCache['Knave'].entities[6].name).toEqual( - 'Upsert Update Knave 6' - ); - expect(entityCache['Knave'].entities[66].name).toEqual( - 'Upsert Add Knave 66' - ); - - expect(entityCache['Villain'].ids).toEqual( - ['7', '8', '10', '44', '45'], - 'Villain ids' - ); - - expectLoadingFlags(entityCache, false); - }); - - it('should modify existing cache with entities when optimistic', () => { - const initialEntities = createInitialSaveTestEntities(); - let entityCache = createInitialCache(initialEntities); - - const changeSet = createTestChangeSet(); - const action = new SaveEntitiesSuccess(changeSet, 'api/save', { - isOptimistic: true, - }); - - entityCache = entityCacheReducer(entityCache, action); - - expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); - expect(entityCache['Fool'].entities[1].skill).toEqual( - 'Updated Skill 1' - ); - - expect(entityCache['Hero'].ids).toEqual( - [4, 42, 43], - 'Hero ids - 2 new, {3,5} deleted' - ); - - expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); - expect(entityCache['Knave'].entities[6].name).toEqual( - 'Upsert Update Knave 6' - ); - expect(entityCache['Knave'].entities[66].name).toEqual( - 'Upsert Add Knave 66' - ); - - expect(entityCache['Villain'].ids).toEqual( - ['7', '8', '10', '44', '45'], - 'Villain ids' - ); - - expectLoadingFlags(entityCache, false); - }); - }); - - describe('SAVE_ENTITIES_ERROR', () => { - it('should turn loading flags off', () => { - // Begin as if saving optimistically - const changeSet = createTestChangeSet(); - const saveAction = new SaveEntities(changeSet, 'api/save', { - isOptimistic: true, - }); - let entityCache = entityCacheReducer({}, saveAction); - - expectLoadingFlags(entityCache, true); - - const dsError = new DataServiceError(new Error('Test Error'), { - url: 'api/save', - } as any); - const errorAction = new SaveEntitiesError(dsError, saveAction); - entityCache = entityCacheReducer(entityCache, errorAction); - - expectLoadingFlags(entityCache, false); - - // Added entities remain in cache (if not on the server), with pending changeState - expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); - const heroChangeState = entityCache['Hero'].changeState; - expect(heroChangeState[42]).toBeDefined('Hero [42] has changeState'); - expect(heroChangeState[43]).toBeDefined('Hero [43] has changeState'); - }); - }); - }); - - // #region helpers - function createCollection( - entityName: string, - data: T[], - selectId: IdSelector - ) { - return { - ...collectionCreator.create(entityName), - ids: data.map(e => selectId(e)) as string[] | number[], - entities: data.reduce( - (acc, e) => { - acc[selectId(e)] = e; - return acc; - }, - {} as any - ), - } as EntityCollection; - } - - function createInitialCache(entityMap: { [entityName: string]: any[] }) { - const cache: EntityCache = {}; - // tslint:disable-next-line:forin - for (const entityName in entityMap) { - const selectId = - metadata[entityName].selectId || ((entity: any) => entity.id); - cache[entityName] = createCollection( - entityName, - entityMap[entityName], - selectId - ); - } - - return cache; - } - - function createInitialSaveTestEntities() { - const entities: { [entityName: string]: any[] } = { - Fool: [ - { id: 1, name: 'Fool 1', skill: 'Skill 1' }, - { id: 2, name: 'Fool 2', skill: 'Skill 2' }, - ], - Hero: [ - { id: 3, name: 'Hero 3', power: 'Power 3' }, - { id: 4, name: 'Hero 4', power: 'Power 4' }, - { id: 5, name: 'Hero 5', power: 'Power 5' }, - ], - Knave: [{ id: 6, name: 'Knave 1', weakness: 'Weakness 6' }], - Villain: [ - { key: '7', name: 'Villain 7', sin: 'Sin 7' }, - { key: '8', name: 'Villain 8', sin: 'Sin 8' }, - { key: '9', name: 'Villain 9', sin: 'Sin 9' }, - { key: '10', name: 'Villain 10', sin: 'Sin 10' }, - ], - }; - return entities; - } - - function createTestChangeSet() { - const changeSet: ChangeSet = { - changes: [ - { - entityName: 'Hero', - op: ChangeSetOperation.Add, - entities: [ - { id: 42, name: 'Hero 42' }, - { id: 43, name: 'Hero 43', power: 'Power 43' }, - ] as Hero[], - }, - { entityName: 'Hero', op: ChangeSetOperation.Delete, entities: [3, 5] }, - { - entityName: 'Villain', - op: ChangeSetOperation.Delete, - entities: ['9'], - }, - { - entityName: 'Villain', - op: ChangeSetOperation.Add, - entities: [ - { key: '44', name: 'Villain 44' }, - { key: '45', name: 'Villain 45', sin: 'Sin 45' }, - ] as Villain[], - }, - { - entityName: 'Fool', - op: ChangeSetOperation.Update, - entities: [{ id: 1, changes: { id: 1, skill: 'Updated Skill 1' } }], - }, - { - entityName: 'Knave', - op: ChangeSetOperation.Upsert, - entities: [ - { id: 6, name: 'Upsert Update Knave 6' }, - { id: 66, name: 'Upsert Add Knave 66' }, - ], - }, - ], - }; - return changeSet; - } - - /** - * Expect the loading flags of the named EntityCache collections to be in the `flag` state. - * @param entityCache cache to check - * @param flag True if should be loading; false if should not be loading - * @param entityNames names of collections to check; if undefined, check all collections - */ - function expectLoadingFlags( - entityCache: EntityCache, - flag: boolean, - entityNames?: string[] - ) { - entityNames = entityNames ? [] : Object.keys(entityCache); - entityNames.forEach(name => { - expect(entityCache[name].loading).toBe( - flag, - `${name}${flag ? '' : ' not'} loading` - ); - }); - } - // #endregion helpers -}); +import { TestBed } from '@angular/core/testing'; +import { Action, ActionReducer } from '@ngrx/store'; +import { IdSelector } from '@ngrx/entity'; + +import { + EntityMetadataMap, + EntityCollectionCreator, + EntityActionFactory, + EntityCache, + EntityCacheReducerFactory, + EntityCollectionReducerMethodsFactory, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + ENTITY_METADATA_TOKEN, + EntityOp, + ClearCollections, + EntityCacheQuerySet, + LoadCollections, + MergeQuerySet, + SetEntityCache, + SaveEntities, + SaveEntitiesCancel, + SaveEntitiesSuccess, + DataServiceError, + SaveEntitiesError, + EntityCollection, + ChangeSet, + ChangeSetOperation, + Logger, +} from '../..'; + +class Hero { + id: number; + name: string; + power?: string; +} +class Villain { + key: string; + name: string; +} + +const metadata: EntityMetadataMap = { + Fool: {}, + Hero: {}, + Knave: {}, + Villain: { selectId: villain => villain.key }, +}; + +describe('EntityCacheReducer', () => { + let collectionCreator: EntityCollectionCreator; + let entityActionFactory: EntityActionFactory; + let entityCacheReducer: ActionReducer; + + beforeEach(() => { + entityActionFactory = new EntityActionFactory(); + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory, + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: Logger, useValue: logger }, + ], + }); + + collectionCreator = TestBed.inject(EntityCollectionCreator); + const entityCacheReducerFactory = TestBed.inject( + EntityCacheReducerFactory + ) as EntityCacheReducerFactory; + entityCacheReducer = entityCacheReducerFactory.create(); + }); + + describe('#create', () => { + it('creates a default hero reducer when QUERY_ALL for hero', () => { + const hero: Hero = { id: 42, name: 'Bobby' }; + const action = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + hero + ); + + const state = entityCacheReducer({}, action); + const collection = state['Hero']; + expect(collection.ids.length).toBe(1, 'should have added one'); + expect(collection.entities[42]).toEqual(hero, 'should be added hero'); + }); + + it('throws when ask for reducer of unknown entity type', () => { + const action = entityActionFactory.create('Foo', EntityOp.QUERY_ALL); + expect(() => entityCacheReducer({}, action)).toThrowError( + /no EntityDefinition/i + ); + }); + }); + + /** + * Test the EntityCache-level actions, SET and MERGE, which can + * be used to restore the entity cache from a know state such as + * re-hydrating from browser storage. + * Useful for an offline-capable app. + */ + describe('EntityCache-level actions', () => { + let initialHeroes: Hero[]; + let initialCache: EntityCache; + + beforeEach(() => { + initialHeroes = [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'invisible' }, + ]; + initialCache = createInitialCache({ Hero: initialHeroes }); + }); + + describe('CLEAR_COLLECTIONS', () => { + beforeEach(() => { + const heroes = [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'invisible' }, + ]; + const villains = [{ key: 'DE', name: 'Dr. Evil' }]; + const fools = [{ id: 66, name: 'Fool 66' }]; + + initialCache = createInitialCache({ + Hero: heroes, + Villain: villains, + Fool: fools, + }); + }); + + it('should clear an existing cached collection', () => { + const collections = ['Hero']; + const action = new ClearCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state['Hero'].ids).toEqual([], 'empty Hero collection'); + expect(state['Fool'].ids.length).toBeGreaterThan(0, 'Fools remain'); + expect(state['Villain'].ids.length).toBeGreaterThan( + 0, + 'Villains remain' + ); + }); + + it('should clear multiple existing cached collections', () => { + const collections = ['Hero', 'Villain']; + const action = new ClearCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state['Hero'].ids).toEqual([], 'empty Hero collection'); + expect(state['Villain'].ids).toEqual([], 'empty Villain collection'); + expect(state['Fool'].ids.length).toBeGreaterThan(0, 'Fools remain'); + }); + + it('should initialize an empty cache with the collections', () => { + // because ANY call to a reducer creates the collection! + const collections = ['Hero', 'Villain']; + const action = new ClearCollections(collections); + + const state = entityCacheReducer({}, action); + expect(Object.keys(state)).toEqual( + ['Hero', 'Villain'], + 'created collections' + ); + expect(state['Villain'].ids).toEqual([], 'Hero id'); + expect(state['Hero'].ids).toEqual([], 'Villain ids'); + }); + + it('should return cache matching existing cache for empty collections array', () => { + const collections: string[] = []; + const action = new ClearCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state).toEqual(initialCache); + }); + + it('should clear every collection in an existing cache when collections is falsy', () => { + const action = new ClearCollections(undefined); + const state = entityCacheReducer(initialCache, action); + expect(Object.keys(state).sort()).toEqual( + ['Fool', 'Hero', 'Villain'], + 'collections still exist' + ); + expect(state['Fool'].ids).toEqual([], 'no Fool ids'); + expect(state['Hero'].ids).toEqual([], 'no Villain ids'); + expect(state['Villain'].ids).toEqual([], 'no Hero id'); + }); + }); + + describe('LOAD_COLLECTIONS', () => { + function shouldHaveExpectedHeroes(entityCache: EntityCache) { + const heroCollection = entityCache['Hero']; + expect(heroCollection.ids).toEqual([2, 1], 'Hero ids'); + expect(heroCollection.entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0], + }); + expect(heroCollection.loaded).toBe(true, 'Heroes loaded'); + } + + it('should initialize an empty cache with the collections', () => { + const collections: EntityCacheQuerySet = { + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }; + + const action = new LoadCollections(collections); + + const state = entityCacheReducer({}, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + expect(state['Villain'].loaded).toBe(true, 'Villains loaded'); + }); + + it('should return cache matching existing cache when collections set is empty', () => { + const action = new LoadCollections({}); + const state = entityCacheReducer(initialCache, action); + expect(state).toEqual(initialCache); + }); + + it('should add a new collection to existing cache', () => { + const collections: EntityCacheQuerySet = { + Knave: [{ id: 96, name: 'Sneaky Pete' }], + }; + const action = new LoadCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state['Knave'].ids).toEqual([96], 'Knave ids'); + expect(state['Knave'].loaded).toBe(true, 'Knave loaded'); + }); + + it('should replace an existing cached collection', () => { + const collections: EntityCacheQuerySet = { + Hero: [{ id: 42, name: 'Bobby' }], + }; + const action = new LoadCollections(collections); + const state = entityCacheReducer(initialCache, action); + const heroCollection = state['Hero']; + expect(heroCollection.ids).toEqual([42], 'only the loaded hero'); + expect(heroCollection.entities[42]).toEqual( + { id: 42, name: 'Bobby' }, + 'loaded hero' + ); + }); + }); + + describe('MERGE_QUERY_SET', () => { + function shouldHaveExpectedHeroes(entityCache: EntityCache) { + expect(entityCache['Hero'].ids).toEqual([2, 1], 'Hero ids'); + expect(entityCache['Hero'].entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0], + }); + } + + it('should initialize an empty cache with query set', () => { + const querySet: EntityCacheQuerySet = { + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }; + + const action = new MergeQuerySet(querySet); + + const state = entityCacheReducer({}, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should return cache matching existing cache when query set is empty', () => { + const action = new MergeQuerySet({}); + const state = entityCacheReducer(initialCache, action); + shouldHaveExpectedHeroes(state); + }); + + it('should add a new collection to existing cache', () => { + const querySet: EntityCacheQuerySet = { + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }; + const action = new MergeQuerySet(querySet); + const state = entityCacheReducer(initialCache, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should merge into an existing cached collection', () => { + const querySet: EntityCacheQuerySet = { + Hero: [{ id: 42, name: 'Bobby' }], + }; + const action = new MergeQuerySet(querySet); + const state = entityCacheReducer(initialCache, action); + const heroCollection = state['Hero']; + const expectedIds = initialHeroes.map(h => h.id).concat(42); + expect(heroCollection.ids).toEqual(expectedIds, 'merged ids'); + expect(heroCollection.entities[42]).toEqual( + { id: 42, name: 'Bobby' }, + 'merged hero' + ); + }); + }); + + describe('SET_ENTITY_CACHE', () => { + it('should initialize cache', () => { + const cache = createInitialCache({ + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }); + + const action = new SetEntityCache(cache); + // const action = { // equivalent + // type: SET_ENTITY_CACHE, + // payload: cache + // }; + + const state = entityCacheReducer(cache, action); + expect(state['Hero'].ids).toEqual([2, 1], 'Hero ids'); + expect(state['Hero'].entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0], + }); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should clear the cache when set with empty object', () => { + const action = new SetEntityCache({}); + const state = entityCacheReducer(initialCache, action); + expect(Object.keys(state)).toEqual([]); + }); + + it('should replace prior cache with new cache', () => { + const priorCache = createInitialCache({ + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }); + + const newHeroes = [{ id: 42, name: 'Bobby' }]; + const newCache = createInitialCache({ Hero: newHeroes }); + + const action = new SetEntityCache(newCache); + const state = entityCacheReducer(priorCache, action); + expect(state['Villain']).toBeUndefined('No villains'); + + const heroCollection = state['Hero']; + expect(heroCollection.ids).toEqual([42], 'hero ids'); + expect(heroCollection.entities[42]).toEqual(newHeroes[0], 'heroes'); + }); + }); + + describe('SAVE_ENTITIES', () => { + it('should turn on loading flags for affected collections and nothing more when pessimistic', () => { + const changeSet = createTestChangeSet(); + const action = new SaveEntities(changeSet, 'api/save', { + isOptimistic: false, + }); + + const entityCache = entityCacheReducer({}, action); + + expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); + expect(entityCache['Hero'].ids).toEqual([], 'Hero ids'); + expect(entityCache['Knave'].ids).toEqual([], 'Knave ids'); + expect(entityCache['Villain'].ids).toEqual([], 'Villain ids'); + expectLoadingFlags(entityCache, true); + }); + + it('should initialize an empty cache with entities when optimistic and turn on loading flags', () => { + const changeSet = createTestChangeSet(); + const action = new SaveEntities(changeSet, 'api/save', { + isOptimistic: true, + }); + + const entityCache = entityCacheReducer({}, action); + + expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); + expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Villain'].ids).toEqual( + ['44', '45'], + 'added Villain ids' + ); + expectLoadingFlags(entityCache, true); + }); + + it('should modify existing cache with entities when optimistic and turn on loading flags', () => { + const initialEntities = createInitialSaveTestEntities(); + let entityCache = createInitialCache(initialEntities); + + const changeSet = createTestChangeSet(); + const action = new SaveEntities(changeSet, 'api/save', { + isOptimistic: true, + }); + + entityCache = entityCacheReducer(entityCache, action); + + expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); + expect(entityCache['Fool'].entities[1].skill).toEqual( + 'Updated Skill 1' + ); + + expect(entityCache['Hero'].ids).toEqual( + [4, 42, 43], + 'Hero ids - 2 new, {3,5} deleted' + ); + + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Knave'].entities[6].name).toEqual( + 'Upsert Update Knave 6' + ); + expect(entityCache['Knave'].entities[66].name).toEqual( + 'Upsert Add Knave 66' + ); + + expect(entityCache['Villain'].ids).toEqual( + ['7', '8', '10', '44', '45'], + 'Villain ids' + ); + + expectLoadingFlags(entityCache, true); + }); + }); + + describe('SAVE_ENTITIES_CANCEL', () => { + const corid = 'CORID42'; + + it('should not turn off loading flags if you do not specify collections', () => { + const changeSet = createTestChangeSet(); + let action: Action = new SaveEntities(changeSet, 'api/save', { + correlationId: corid, + isOptimistic: false, + }); + + // Pessimistic save turns on loading flags + let entityCache = entityCacheReducer({}, action); + expectLoadingFlags(entityCache, true); + + action = new SaveEntitiesCancel(corid, 'Test Cancel'); // no names so no flags turned off. + entityCache = entityCacheReducer(entityCache, action); + expectLoadingFlags(entityCache, true); + }); + + it('should turn off loading flags for collections that you specify', () => { + const changeSet = createTestChangeSet(); + let action: Action = new SaveEntities(changeSet, 'api/save', { + correlationId: corid, + isOptimistic: false, + }); + + // Pessimistic save turns on loading flags + let entityCache = entityCacheReducer({}, action); + expectLoadingFlags(entityCache, true); + + action = new SaveEntitiesCancel(corid, 'Test Cancel', ['Hero', 'Fool']); + entityCache = entityCacheReducer(entityCache, action); + expectLoadingFlags(entityCache, false, ['Hero', 'Fool']); + expectLoadingFlags(entityCache, true, ['Knave', 'Villain']); + }); + }); + + describe('SAVE_ENTITIES_SUCCESS', () => { + it('should initialize an empty cache with entities when pessimistic', () => { + const changeSet = createTestChangeSet(); + const action = new SaveEntitiesSuccess(changeSet, 'api/save', { + isOptimistic: false, + }); + + const entityCache = entityCacheReducer({}, action); + + expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); + expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Villain'].ids).toEqual( + ['44', '45'], + 'added Villain ids' + ); + expectLoadingFlags(entityCache, false); + }); + + it('should modify existing cache with entities when pessimistic', () => { + const initialEntities = createInitialSaveTestEntities(); + let entityCache = createInitialCache(initialEntities); + + const changeSet = createTestChangeSet(); + const action = new SaveEntitiesSuccess(changeSet, 'api/save', { + isOptimistic: false, + }); + + entityCache = entityCacheReducer(entityCache, action); + + expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); + expect(entityCache['Fool'].entities[1].skill).toEqual( + 'Updated Skill 1' + ); + + expect(entityCache['Hero'].ids).toEqual( + [4, 42, 43], + 'Hero ids - 2 new, {3,5} deleted' + ); + + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Knave'].entities[6].name).toEqual( + 'Upsert Update Knave 6' + ); + expect(entityCache['Knave'].entities[66].name).toEqual( + 'Upsert Add Knave 66' + ); + + expect(entityCache['Villain'].ids).toEqual( + ['7', '8', '10', '44', '45'], + 'Villain ids' + ); + + expectLoadingFlags(entityCache, false); + }); + + it('should modify existing cache with entities when optimistic', () => { + const initialEntities = createInitialSaveTestEntities(); + let entityCache = createInitialCache(initialEntities); + + const changeSet = createTestChangeSet(); + const action = new SaveEntitiesSuccess(changeSet, 'api/save', { + isOptimistic: true, + }); + + entityCache = entityCacheReducer(entityCache, action); + + expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); + expect(entityCache['Fool'].entities[1].skill).toEqual( + 'Updated Skill 1' + ); + + expect(entityCache['Hero'].ids).toEqual( + [4, 42, 43], + 'Hero ids - 2 new, {3,5} deleted' + ); + + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Knave'].entities[6].name).toEqual( + 'Upsert Update Knave 6' + ); + expect(entityCache['Knave'].entities[66].name).toEqual( + 'Upsert Add Knave 66' + ); + + expect(entityCache['Villain'].ids).toEqual( + ['7', '8', '10', '44', '45'], + 'Villain ids' + ); + + expectLoadingFlags(entityCache, false); + }); + }); + + describe('SAVE_ENTITIES_ERROR', () => { + it('should turn loading flags off', () => { + // Begin as if saving optimistically + const changeSet = createTestChangeSet(); + const saveAction = new SaveEntities(changeSet, 'api/save', { + isOptimistic: true, + }); + let entityCache = entityCacheReducer({}, saveAction); + + expectLoadingFlags(entityCache, true); + + const dsError = new DataServiceError(new Error('Test Error'), { + url: 'api/save', + } as any); + const errorAction = new SaveEntitiesError(dsError, saveAction); + entityCache = entityCacheReducer(entityCache, errorAction); + + expectLoadingFlags(entityCache, false); + + // Added entities remain in cache (if not on the server), with pending changeState + expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); + const heroChangeState = entityCache['Hero'].changeState; + expect(heroChangeState[42]).toBeDefined('Hero [42] has changeState'); + expect(heroChangeState[43]).toBeDefined('Hero [43] has changeState'); + }); + }); + }); + + // #region helpers + function createCollection( + entityName: string, + data: T[], + selectId: IdSelector + ) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => selectId(e)) as string[] | number[], + entities: data.reduce((acc, e) => { + acc[selectId(e)] = e; + return acc; + }, {} as any), + } as EntityCollection; + } + + function createInitialCache(entityMap: { [entityName: string]: any[] }) { + const cache: EntityCache = {}; + // tslint:disable-next-line:forin + for (const entityName in entityMap) { + const selectId = + metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection( + entityName, + entityMap[entityName], + selectId + ); + } + + return cache; + } + + function createInitialSaveTestEntities() { + const entities: { [entityName: string]: any[] } = { + Fool: [ + { id: 1, name: 'Fool 1', skill: 'Skill 1' }, + { id: 2, name: 'Fool 2', skill: 'Skill 2' }, + ], + Hero: [ + { id: 3, name: 'Hero 3', power: 'Power 3' }, + { id: 4, name: 'Hero 4', power: 'Power 4' }, + { id: 5, name: 'Hero 5', power: 'Power 5' }, + ], + Knave: [{ id: 6, name: 'Knave 1', weakness: 'Weakness 6' }], + Villain: [ + { key: '7', name: 'Villain 7', sin: 'Sin 7' }, + { key: '8', name: 'Villain 8', sin: 'Sin 8' }, + { key: '9', name: 'Villain 9', sin: 'Sin 9' }, + { key: '10', name: 'Villain 10', sin: 'Sin 10' }, + ], + }; + return entities; + } + + function createTestChangeSet() { + const changeSet: ChangeSet = { + changes: [ + { + entityName: 'Hero', + op: ChangeSetOperation.Add, + entities: [ + { id: 42, name: 'Hero 42' }, + { id: 43, name: 'Hero 43', power: 'Power 43' }, + ] as Hero[], + }, + { entityName: 'Hero', op: ChangeSetOperation.Delete, entities: [3, 5] }, + { + entityName: 'Villain', + op: ChangeSetOperation.Delete, + entities: ['9'], + }, + { + entityName: 'Villain', + op: ChangeSetOperation.Add, + entities: [ + { key: '44', name: 'Villain 44' }, + { key: '45', name: 'Villain 45', sin: 'Sin 45' }, + ] as Villain[], + }, + { + entityName: 'Fool', + op: ChangeSetOperation.Update, + entities: [{ id: 1, changes: { id: 1, skill: 'Updated Skill 1' } }], + }, + { + entityName: 'Knave', + op: ChangeSetOperation.Upsert, + entities: [ + { id: 6, name: 'Upsert Update Knave 6' }, + { id: 66, name: 'Upsert Add Knave 66' }, + ], + }, + ], + }; + return changeSet; + } + + /** + * Expect the loading flags of the named EntityCache collections to be in the `flag` state. + * @param entityCache cache to check + * @param flag True if should be loading; false if should not be loading + * @param entityNames names of collections to check; if undefined, check all collections + */ + function expectLoadingFlags( + entityCache: EntityCache, + flag: boolean, + entityNames?: string[] + ) { + entityNames = entityNames ? [] : Object.keys(entityCache); + entityNames.forEach(name => { + expect(entityCache[name].loading).toBe( + flag, + `${name}${flag ? '' : ' not'} loading` + ); + }); + } + // #endregion helpers +}); diff --git a/modules/data/spec/reducers/entity-collection-reducer-registry.spec.ts b/modules/data/spec/reducers/entity-collection-reducer-registry.spec.ts index abf3f535a2..d2e7187f54 100644 --- a/modules/data/spec/reducers/entity-collection-reducer-registry.spec.ts +++ b/modules/data/spec/reducers/entity-collection-reducer-registry.spec.ts @@ -1,347 +1,344 @@ -import { TestBed } from '@angular/core/testing'; -import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; -import { IdSelector } from '@ngrx/entity'; - -import { - EntityMetadataMap, - EntityCollectionCreator, - EntityActionFactory, - EntityCache, - EntityCollectionReducerRegistry, - EntityCacheReducerFactory, - EntityCollectionReducerMethodsFactory, - EntityCollectionReducerFactory, - EntityDefinitionService, - ENTITY_METADATA_TOKEN, - EntityOp, - EntityCollectionReducers, - EntityCollection, - EntityAction, - ENTITY_COLLECTION_META_REDUCERS, - Logger, -} from '../..'; - -class Bar { - id: number; - bar: string; -} -class Foo { - id: string; - foo: string; -} -class Hero { - id: number; - name: string; - power?: string; -} -class Villain { - key: string; - name: string; -} - -const metadata: EntityMetadataMap = { - Hero: {}, - Villain: { selectId: villain => villain.key }, -}; -describe('EntityCollectionReducerRegistry', () => { - let collectionCreator: EntityCollectionCreator; - let entityActionFactory: EntityActionFactory; - let entityCacheReducer: ActionReducer; - let entityCollectionReducerRegistry: EntityCollectionReducerRegistry; - let logger: jasmine.Spy; - - beforeEach(() => { - entityActionFactory = new EntityActionFactory(); - logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); - - TestBed.configureTestingModule({ - providers: [ - EntityCacheReducerFactory, - EntityCollectionCreator, - { - provide: EntityCollectionReducerMethodsFactory, - useClass: EntityCollectionReducerMethodsFactory, - }, - EntityCollectionReducerFactory, - EntityCollectionReducerRegistry, - EntityDefinitionService, - { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, - { provide: Logger, useValue: logger }, - ], - }); - }); - - /** Sets the test variables with injected values. Closes TestBed configuration. */ - function setup() { - collectionCreator = TestBed.get(EntityCollectionCreator); - const entityCacheReducerFactory = TestBed.get( - EntityCacheReducerFactory - ) as EntityCacheReducerFactory; - entityCacheReducer = entityCacheReducerFactory.create(); - entityCollectionReducerRegistry = TestBed.get( - EntityCollectionReducerRegistry - ); - } - - describe('#registerReducer', () => { - beforeEach(setup); - - it('can register a new reducer', () => { - const reducer = createNoopReducer(); - entityCollectionReducerRegistry.registerReducer('Foo', reducer); - const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { - id: 'forty-two', - foo: 'fooz', - }); - // Must initialize the state by hand - const state = entityCacheReducer({}, action); - const collection = state['Foo']; - expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); - }); - - it('can replace existing reducer by registering with same name', () => { - // Just like ADD_ONE test above with default reducer - // but this time should not add the hero. - const hero: Hero = { id: 42, name: 'Bobby' }; - const reducer = createNoopReducer(); - entityCollectionReducerRegistry.registerReducer('Hero', reducer); - const action = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - hero - ); - const state = entityCacheReducer({}, action); - const collection = state['Hero']; - expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); - }); - }); - - describe('#registerReducers', () => { - beforeEach(setup); - - it('can register several reducers at the same time.', () => { - const reducer = createNoopReducer(); - const reducers: EntityCollectionReducers = { - Foo: reducer, - Bar: reducer, - }; - entityCollectionReducerRegistry.registerReducers(reducers); - - const fooAction = entityActionFactory.create( - 'Foo', - EntityOp.ADD_ONE, - { id: 'forty-two', foo: 'fooz' } - ); - const barAction = entityActionFactory.create( - 'Bar', - EntityOp.ADD_ONE, - { id: 84, bar: 'baz' } - ); - - let state = entityCacheReducer({}, fooAction); - state = entityCacheReducer(state, barAction); - - expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); - expect(state['Bar'].ids.length).toBe(0, 'ADD_ONE Bar should not add'); - }); - - it('can register several reducers that may override.', () => { - const reducer = createNoopReducer(); - const reducers: EntityCollectionReducers = { - Foo: reducer, - Hero: reducer, - }; - entityCollectionReducerRegistry.registerReducers(reducers); - - const fooAction = entityActionFactory.create( - 'Foo', - EntityOp.ADD_ONE, - { id: 'forty-two', foo: 'fooz' } - ); - const heroAction = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - { id: 84, name: 'Alex' } - ); - - let state = entityCacheReducer({}, fooAction); - state = entityCacheReducer(state, heroAction); - - expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); - expect(state['Hero'].ids.length).toBe(0, 'ADD_ONE Hero should not add'); - }); - }); - - describe('with EntityCollectionMetadataReducers', () => { - let metaReducerA: MetaReducer; - let metaReducerB: MetaReducer; - let metaReducerOutput: any[]; - - // Create MetaReducer that reports how it was called on the way in and out - function testMetadataReducerFactory(name: string) { - // Return the MetaReducer - return (r: ActionReducer) => { - // Return the wrapped reducer - return (state: EntityCollection, action: EntityAction) => { - // entered - metaReducerOutput.push({ metaReducer: name, inOut: 'in', action }); - // called reducer - const newState = r(state, action); - // exited - metaReducerOutput.push({ metaReducer: name, inOut: 'out', action }); - return newState; - }; - }; - } - - let addOneAction: EntityAction; - let hero: Hero; - - beforeEach(() => { - metaReducerOutput = []; - metaReducerA = jasmine - .createSpy('metaReducerA') - .and.callFake(testMetadataReducerFactory('A')); - metaReducerB = jasmine - .createSpy('metaReducerA') - .and.callFake(testMetadataReducerFactory('B')); - const metaReducers = [metaReducerA, metaReducerB]; - - TestBed.configureTestingModule({ - providers: [ - EntityCacheReducerFactory, - EntityCollectionCreator, - { - provide: EntityCollectionReducerMethodsFactory, - useClass: EntityCollectionReducerMethodsFactory, - }, - EntityCollectionReducerFactory, - EntityCollectionReducerRegistry, - EntityDefinitionService, - { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, - { provide: ENTITY_COLLECTION_META_REDUCERS, useValue: metaReducers }, - { provide: Logger, useValue: logger }, - ], - }); - - setup(); - - hero = { id: 42, name: 'Bobby' }; - addOneAction = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - hero - ); - }); - - it('should run inner default reducer as expected', () => { - const state = entityCacheReducer({}, addOneAction); - - // inner default reducer worked as expected - const collection = state['Hero']; - expect(collection.ids.length).toBe(1, 'should have added one'); - expect(collection.entities[42]).toEqual(hero, 'should be added hero'); - }); - - it('should call meta reducers for inner default reducer as expected', () => { - const expected = [ - { metaReducer: 'A', inOut: 'in', action: addOneAction }, - { metaReducer: 'B', inOut: 'in', action: addOneAction }, - { metaReducer: 'B', inOut: 'out', action: addOneAction }, - { metaReducer: 'A', inOut: 'out', action: addOneAction }, - ]; - - const state = entityCacheReducer({}, addOneAction); - expect(metaReducerA).toHaveBeenCalled(); - expect(metaReducerB).toHaveBeenCalled(); - expect(metaReducerOutput).toEqual(expected); - }); - - it('should call meta reducers for custom registered reducer', () => { - const reducer = createNoopReducer(); - entityCollectionReducerRegistry.registerReducer('Foo', reducer); - const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { - id: 'forty-two', - foo: 'fooz', - }); - - const state = entityCacheReducer({}, action); - expect(metaReducerA).toHaveBeenCalled(); - expect(metaReducerB).toHaveBeenCalled(); - }); - - it('should call meta reducers for multiple registered reducers', () => { - const reducer = createNoopReducer(); - const reducers: EntityCollectionReducers = { - Foo: reducer, - Hero: reducer, - }; - entityCollectionReducerRegistry.registerReducers(reducers); - - const fooAction = entityActionFactory.create( - 'Foo', - EntityOp.ADD_ONE, - { id: 'forty-two', foo: 'fooz' } - ); - - entityCacheReducer({}, fooAction); - expect(metaReducerA).toHaveBeenCalled(); - expect(metaReducerB).toHaveBeenCalled(); - - const heroAction = entityActionFactory.create( - 'Hero', - EntityOp.ADD_ONE, - { id: 84, name: 'Alex' } - ); - - entityCacheReducer({}, heroAction); - expect(metaReducerA).toHaveBeenCalledTimes(2); - expect(metaReducerB).toHaveBeenCalledTimes(2); - }); - }); - - // #region helpers - function createCollection( - entityName: string, - data: T[], - selectId: IdSelector - ) { - return { - ...collectionCreator.create(entityName), - ids: data.map(e => selectId(e)) as string[] | number[], - entities: data.reduce( - (acc, e) => { - acc[selectId(e)] = e; - return acc; - }, - {} as any - ), - } as EntityCollection; - } - - function createInitialCache(entityMap: { [entityName: string]: any[] }) { - const cache: EntityCache = {}; - // tslint:disable-next-line:forin - for (const entityName in entityMap) { - const selectId = - metadata[entityName].selectId || ((entity: any) => entity.id); - cache[entityName] = createCollection( - entityName, - entityMap[entityName], - selectId - ); - } - - return cache; - } - - function createNoopReducer() { - return function NoopReducer( - collection: EntityCollection, - action: EntityAction - ): EntityCollection { - return collection; - }; - } - // #endregion helpers -}); +import { TestBed } from '@angular/core/testing'; +import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; +import { IdSelector } from '@ngrx/entity'; + +import { + EntityMetadataMap, + EntityCollectionCreator, + EntityActionFactory, + EntityCache, + EntityCollectionReducerRegistry, + EntityCacheReducerFactory, + EntityCollectionReducerMethodsFactory, + EntityCollectionReducerFactory, + EntityDefinitionService, + ENTITY_METADATA_TOKEN, + EntityOp, + EntityCollectionReducers, + EntityCollection, + EntityAction, + ENTITY_COLLECTION_META_REDUCERS, + Logger, +} from '../..'; + +class Bar { + id: number; + bar: string; +} +class Foo { + id: string; + foo: string; +} +class Hero { + id: number; + name: string; + power?: string; +} +class Villain { + key: string; + name: string; +} + +const metadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: villain => villain.key }, +}; +describe('EntityCollectionReducerRegistry', () => { + let collectionCreator: EntityCollectionCreator; + let entityActionFactory: EntityActionFactory; + let entityCacheReducer: ActionReducer; + let entityCollectionReducerRegistry: EntityCollectionReducerRegistry; + let logger: jasmine.Spy; + + beforeEach(() => { + entityActionFactory = new EntityActionFactory(); + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory, + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: Logger, useValue: logger }, + ], + }); + }); + + /** Sets the test variables with injected values. Closes TestBed configuration. */ + function setup() { + collectionCreator = TestBed.inject(EntityCollectionCreator); + const entityCacheReducerFactory = TestBed.inject( + EntityCacheReducerFactory + ) as EntityCacheReducerFactory; + entityCacheReducer = entityCacheReducerFactory.create(); + entityCollectionReducerRegistry = TestBed.inject( + EntityCollectionReducerRegistry + ); + } + + describe('#registerReducer', () => { + beforeEach(setup); + + it('can register a new reducer', () => { + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Foo', reducer); + const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { + id: 'forty-two', + foo: 'fooz', + }); + // Must initialize the state by hand + const state = entityCacheReducer({}, action); + const collection = state['Foo']; + expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); + }); + + it('can replace existing reducer by registering with same name', () => { + // Just like ADD_ONE test above with default reducer + // but this time should not add the hero. + const hero: Hero = { id: 42, name: 'Bobby' }; + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Hero', reducer); + const action = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + hero + ); + const state = entityCacheReducer({}, action); + const collection = state['Hero']; + expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); + }); + }); + + describe('#registerReducers', () => { + beforeEach(setup); + + it('can register several reducers at the same time.', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Bar: reducer, + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create( + 'Foo', + EntityOp.ADD_ONE, + { id: 'forty-two', foo: 'fooz' } + ); + const barAction = entityActionFactory.create( + 'Bar', + EntityOp.ADD_ONE, + { id: 84, bar: 'baz' } + ); + + let state = entityCacheReducer({}, fooAction); + state = entityCacheReducer(state, barAction); + + expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); + expect(state['Bar'].ids.length).toBe(0, 'ADD_ONE Bar should not add'); + }); + + it('can register several reducers that may override.', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Hero: reducer, + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create( + 'Foo', + EntityOp.ADD_ONE, + { id: 'forty-two', foo: 'fooz' } + ); + const heroAction = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + { id: 84, name: 'Alex' } + ); + + let state = entityCacheReducer({}, fooAction); + state = entityCacheReducer(state, heroAction); + + expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); + expect(state['Hero'].ids.length).toBe(0, 'ADD_ONE Hero should not add'); + }); + }); + + describe('with EntityCollectionMetadataReducers', () => { + let metaReducerA: MetaReducer; + let metaReducerB: MetaReducer; + let metaReducerOutput: any[]; + + // Create MetaReducer that reports how it was called on the way in and out + function testMetadataReducerFactory(name: string) { + // Return the MetaReducer + return (r: ActionReducer) => { + // Return the wrapped reducer + return (state: EntityCollection, action: EntityAction) => { + // entered + metaReducerOutput.push({ metaReducer: name, inOut: 'in', action }); + // called reducer + const newState = r(state, action); + // exited + metaReducerOutput.push({ metaReducer: name, inOut: 'out', action }); + return newState; + }; + }; + } + + let addOneAction: EntityAction; + let hero: Hero; + + beforeEach(() => { + metaReducerOutput = []; + metaReducerA = jasmine + .createSpy('metaReducerA') + .and.callFake(testMetadataReducerFactory('A')); + metaReducerB = jasmine + .createSpy('metaReducerA') + .and.callFake(testMetadataReducerFactory('B')); + const metaReducers = [metaReducerA, metaReducerB]; + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory, + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: ENTITY_COLLECTION_META_REDUCERS, useValue: metaReducers }, + { provide: Logger, useValue: logger }, + ], + }); + + setup(); + + hero = { id: 42, name: 'Bobby' }; + addOneAction = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + hero + ); + }); + + it('should run inner default reducer as expected', () => { + const state = entityCacheReducer({}, addOneAction); + + // inner default reducer worked as expected + const collection = state['Hero']; + expect(collection.ids.length).toBe(1, 'should have added one'); + expect(collection.entities[42]).toEqual(hero, 'should be added hero'); + }); + + it('should call meta reducers for inner default reducer as expected', () => { + const expected = [ + { metaReducer: 'A', inOut: 'in', action: addOneAction }, + { metaReducer: 'B', inOut: 'in', action: addOneAction }, + { metaReducer: 'B', inOut: 'out', action: addOneAction }, + { metaReducer: 'A', inOut: 'out', action: addOneAction }, + ]; + + const state = entityCacheReducer({}, addOneAction); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + expect(metaReducerOutput).toEqual(expected); + }); + + it('should call meta reducers for custom registered reducer', () => { + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Foo', reducer); + const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { + id: 'forty-two', + foo: 'fooz', + }); + + const state = entityCacheReducer({}, action); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + }); + + it('should call meta reducers for multiple registered reducers', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Hero: reducer, + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create( + 'Foo', + EntityOp.ADD_ONE, + { id: 'forty-two', foo: 'fooz' } + ); + + entityCacheReducer({}, fooAction); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + + const heroAction = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + { id: 84, name: 'Alex' } + ); + + entityCacheReducer({}, heroAction); + expect(metaReducerA).toHaveBeenCalledTimes(2); + expect(metaReducerB).toHaveBeenCalledTimes(2); + }); + }); + + // #region helpers + function createCollection( + entityName: string, + data: T[], + selectId: IdSelector + ) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => selectId(e)) as string[] | number[], + entities: data.reduce((acc, e) => { + acc[selectId(e)] = e; + return acc; + }, {} as any), + } as EntityCollection; + } + + function createInitialCache(entityMap: { [entityName: string]: any[] }) { + const cache: EntityCache = {}; + // tslint:disable-next-line:forin + for (const entityName in entityMap) { + const selectId = + metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection( + entityName, + entityMap[entityName], + selectId + ); + } + + return cache; + } + + function createNoopReducer() { + return function NoopReducer( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return collection; + }; + } + // #endregion helpers +}); diff --git a/modules/data/spec/selectors/related-entity-selectors.spec.ts b/modules/data/spec/selectors/related-entity-selectors.spec.ts index 612b1dbb24..33193cfe76 100644 --- a/modules/data/spec/selectors/related-entity-selectors.spec.ts +++ b/modules/data/spec/selectors/related-entity-selectors.spec.ts @@ -1,487 +1,481 @@ -import { TestBed } from '@angular/core/testing'; -import { createSelector, StoreModule, Store } from '@ngrx/store'; -import { Actions } from '@ngrx/effects'; -import { Update } from '@ngrx/entity'; - -import { Observable } from 'rxjs'; -import { skip } from 'rxjs/operators'; - -import { - EntityMetadataMap, - EntityActionFactory, - EntitySelectorsFactory, - EntityCache, - EntityDataModuleWithoutEffects, - ENTITY_METADATA_TOKEN, - EntityOp, - EntityAction, -} from '../../'; - -const entityMetadataMap: EntityMetadataMap = { - Battle: {}, - Hero: {}, - HeroPowerMap: {}, - Power: { - sortComparer: sortByName, - }, - Sidekick: {}, -}; - -describe('Related-entity Selectors', () => { - // #region setup - let eaFactory: EntityActionFactory; - let entitySelectorsFactory: EntitySelectorsFactory; - let store: Store; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [StoreModule.forRoot({}), EntityDataModuleWithoutEffects], - providers: [ - // required by EntityData but not used in these tests - { provide: Actions, useValue: null }, - { - provide: ENTITY_METADATA_TOKEN, - multi: true, - useValue: entityMetadataMap, - }, - ], - }); - - store = TestBed.get(Store); - eaFactory = TestBed.get(EntityActionFactory); - entitySelectorsFactory = TestBed.get(EntitySelectorsFactory); - initializeCache(eaFactory, store); - }); - - // #endregion setup - - describe('hero -> sidekick (1-1)', () => { - function setCollectionSelectors() { - const heroSelectors = entitySelectorsFactory.create('Hero'); - const selectHeroMap = heroSelectors.selectEntityMap; - - const sidekickSelectors = entitySelectorsFactory.create( - 'Sidekick' - ); - const selectSidekickMap = sidekickSelectors.selectEntityMap; - - return { - selectHeroMap, - selectSidekickMap, - }; - } - - function createHeroSidekickSelector$(heroId: number): Observable { - const { selectHeroMap, selectSidekickMap } = setCollectionSelectors(); - const selectHero = createSelector( - selectHeroMap, - heroes => heroes[heroId] - ); - const selectSideKick = createSelector( - selectHero, - selectSidekickMap, - (hero, sidekicks) => { - const sidekickId = hero && hero.sidekickFk!; - return sidekickId && sidekicks[sidekickId]; - } - ); - return store.select(selectSideKick) as Observable; - } - - // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. - - it('should get Alpha Hero sidekick', (done: DoneFn) => { - createHeroSidekickSelector$(1).subscribe(sk => { - expect(sk.name).toBe('Bob'); - done(); - }); - }); - - it('should get Alpha Hero updated sidekick', (done: DoneFn) => { - // Skip the initial sidekick and check the one after update - createHeroSidekickSelector$(1) - .pipe(skip(1)) - .subscribe(sk => { - expect(sk.name).toBe('Robert'); - done(); - }); - - // update the related sidekick - const action = eaFactory.create>( - 'Sidekick', - EntityOp.UPDATE_ONE, - { id: 1, changes: { id: 1, name: 'Robert' } } - ); - store.dispatch(action); - }); - - it('should get Alpha Hero changed sidekick', (done: DoneFn) => { - // Skip the initial sidekick and check the one after update - createHeroSidekickSelector$(1) - .pipe(skip(1)) - .subscribe(sk => { - expect(sk.name).toBe('Sally'); - done(); - }); - - // update the hero's sidekick from fk=1 to fk=2 - const action = eaFactory.create>( - 'Hero', - EntityOp.UPDATE_ONE, - { id: 1, changes: { id: 1, sidekickFk: 2 } } // Sally - ); - store.dispatch(action); - }); - - it('changing a different hero should NOT trigger first hero selector', (done: DoneFn) => { - let alphaCount = 0; - - createHeroSidekickSelector$(1).subscribe(sk => { - alphaCount += 1; - }); - - // update a different hero's sidekick from fk=2 (Sally) to fk=1 (Bob) - createHeroSidekickSelector$(2) - .pipe(skip(1)) - .subscribe(sk => { - expect(sk.name).toBe('Bob'); - expect(alphaCount).toEqual( - 1, - 'should only callback for Hero #1 once' - ); - done(); - }); - - const action = eaFactory.create>( - 'Hero', - EntityOp.UPDATE_ONE, - { id: 2, changes: { id: 2, sidekickFk: 1 } } // Bob - ); - store.dispatch(action); - }); - - it('should get undefined sidekick if hero not found', (done: DoneFn) => { - createHeroSidekickSelector$(1234).subscribe(sk => { - expect(sk).toBeUndefined(); - done(); - }); - }); - - it('should get undefined sidekick from Gamma because it has no sidekickFk', (done: DoneFn) => { - createHeroSidekickSelector$(3).subscribe(sk => { - expect(sk).toBeUndefined(); - done(); - }); - }); - - it('should get Gamma sidekick after creating and assigning one', (done: DoneFn) => { - // Skip(1), the initial state in which Gamma has no sidekick - // Note that BOTH dispatches complete synchronously, before the selector updates - // so we only have to skip one. - createHeroSidekickSelector$(3) - .pipe(skip(1)) - .subscribe(sk => { - expect(sk.name).toBe('Robin'); - done(); - }); - - // create a new sidekick - let action: EntityAction = eaFactory.create( - 'Sidekick', - EntityOp.ADD_ONE, - { - id: 42, - name: 'Robin', - } - ); - store.dispatch(action); - - // assign new sidekick to Gamma - action = eaFactory.create>('Hero', EntityOp.UPDATE_ONE, { - id: 3, - changes: { id: 3, sidekickFk: 42 }, - }); - store.dispatch(action); - }); - }); - - describe('hero -> battles (1-m)', () => { - function setCollectionSelectors() { - const heroSelectors = entitySelectorsFactory.create('Hero'); - const selectHeroMap = heroSelectors.selectEntityMap; - - const battleSelectors = entitySelectorsFactory.create('Battle'); - const selectBattleEntities = battleSelectors.selectEntities; - - const selectHeroBattleMap = createSelector( - selectBattleEntities, - battles => - battles.reduce( - (acc, battle) => { - const hid = battle.heroFk; - if (hid) { - const hbs = acc[hid]; - if (hbs) { - hbs.push(battle); - } else { - acc[hid] = [battle]; - } - } - return acc; - }, - {} as { [heroId: number]: Battle[] } - ) - ); - - return { - selectHeroMap, - selectHeroBattleMap, - }; - } - - function createHeroBattlesSelector$(heroId: number): Observable { - const { selectHeroMap, selectHeroBattleMap } = setCollectionSelectors(); - - const selectHero = createSelector( - selectHeroMap, - heroes => heroes[heroId] - ); - - const selectHeroBattles = createSelector( - selectHero, - selectHeroBattleMap, - (hero, heroBattleMap) => { - const hid = hero && hero.id; - return (hid && heroBattleMap[hid]) || []; - } - ); - return store.select(selectHeroBattles); - } - - // TODO: more tests - // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. - - it('should get Alpha Hero battles', (done: DoneFn) => { - createHeroBattlesSelector$(1).subscribe(battles => { - expect(battles.length).toBe(3, 'Alpha should have 3 battles'); - done(); - }); - }); - - it('should get Alpha Hero battles again after updating one of its battles', (done: DoneFn) => { - // Skip the initial sidekick and check the one after update - createHeroBattlesSelector$(1) - .pipe(skip(1)) - .subscribe(battles => { - expect(battles[0].name).toBe('Scalliwag'); - done(); - }); - - // update the first of the related battles - const action = eaFactory.create>( - 'Battle', - EntityOp.UPDATE_ONE, - { id: 100, changes: { id: 100, name: 'Scalliwag' } } - ); - store.dispatch(action); - }); - - it('Gamma Hero should have no battles', (done: DoneFn) => { - createHeroBattlesSelector$(3).subscribe(battles => { - expect(battles.length).toBe(0, 'Gamma should have no battles'); - done(); - }); - }); - }); - - describe('hero -> heropower <- power (m-m)', () => { - function setCollectionSelectors() { - const heroSelectors = entitySelectorsFactory.create('Hero'); - const selectHeroMap = heroSelectors.selectEntityMap; - - const powerSelectors = entitySelectorsFactory.create('Power'); - const selectPowerMap = powerSelectors.selectEntityMap; - - const heroPowerMapSelectors = entitySelectorsFactory.create( - 'HeroPowerMap' - ); - const selectHeroPowerMapEntities = heroPowerMapSelectors.selectEntities; - - const selectHeroPowerIds = createSelector( - selectHeroPowerMapEntities, - hpMaps => - hpMaps.reduce( - (acc, hpMap) => { - const hid = hpMap.heroFk; - if (hid) { - const hpIds = acc[hid]; - if (hpIds) { - hpIds.push(hpMap.powerFk); - } else { - acc[hid] = [hpMap.powerFk]; - } - } - return acc; - }, - {} as { [heroId: number]: number[] } - ) - ); - - return { - selectHeroMap, - selectHeroPowerIds, - selectPowerMap, - }; - } - - function createHeroPowersSelector$(heroId: number): Observable { - const { - selectHeroMap, - selectHeroPowerIds, - selectPowerMap, - } = setCollectionSelectors(); - - const selectHero = createSelector( - selectHeroMap, - heroes => heroes[heroId] - ); - - const selectHeroPowers = createSelector( - selectHero, - selectHeroPowerIds, - selectPowerMap, - (hero, heroPowerIds, powerMap) => { - const hid = hero && hero.id; - const pids = (hid && heroPowerIds[hid]) || []; - const powers = pids.map(id => powerMap[id]).filter(power => power); - return powers; - } - ); - return store.select(selectHeroPowers) as Observable; - } - - // TODO: more tests - // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. - - it('should get Alpha Hero powers', (done: DoneFn) => { - createHeroPowersSelector$(1).subscribe(powers => { - expect(powers.length).toBe(3, 'Alpha should have 3 powers'); - done(); - }); - }); - - it('should get Beta Hero power', (done: DoneFn) => { - createHeroPowersSelector$(2).subscribe(powers => { - expect(powers.length).toBe(1, 'Beta should have 1 power'); - expect(powers[0].name).toBe('Invisibility'); - done(); - }); - }); - - it('Beta Hero should have no powers after delete', (done: DoneFn) => { - createHeroPowersSelector$(2) - .pipe(skip(1)) - .subscribe(powers => { - expect(powers.length).toBe(0, 'Beta should have no powers'); - done(); - }); - - // delete Beta's one power via the HeroPowerMap - const action: EntityAction = eaFactory.create( - 'HeroPowerMap', - EntityOp.REMOVE_ONE, - 96 - ); - store.dispatch(action); - }); - - it('Gamma Hero should have no powers', (done: DoneFn) => { - createHeroPowersSelector$(3).subscribe(powers => { - expect(powers.length).toBe(0, 'Gamma should have no powers'); - done(); - }); - }); - }); -}); - -// #region Test support - -interface Hero { - id: number; - name: string; - saying?: string; - sidekickFk?: number; -} - -interface Battle { - id: number; - name: string; - heroFk: number; - won: boolean; -} - -interface HeroPowerMap { - id: number; - heroFk: number; - powerFk: number; -} - -interface Power { - id: number; - name: string; -} - -interface Sidekick { - id: number; - name: string; -} - -/** Sort Comparer to sort the entity collection by its name property */ -export function sortByName(a: { name: string }, b: { name: string }): number { - return a.name.localeCompare(b.name); -} - -function initializeCache( - eaFactory: EntityActionFactory, - store: Store -) { - let action: EntityAction; - - action = eaFactory.create('Sidekick', EntityOp.ADD_ALL, [ - { id: 1, name: 'Bob' }, - { id: 2, name: 'Sally' }, - ]); - store.dispatch(action); - - action = eaFactory.create('Hero', EntityOp.ADD_ALL, [ - { id: 1, name: 'Alpha', sidekickFk: 1 }, - { id: 2, name: 'Beta', sidekickFk: 2 }, - { id: 3, name: 'Gamma' }, // no sidekick - ]); - store.dispatch(action); - - action = eaFactory.create('Battle', EntityOp.ADD_ALL, [ - { id: 100, heroFk: 1, name: 'Plains of Yon', won: true }, - { id: 200, heroFk: 1, name: 'Yippee-kai-eh', won: false }, - { id: 300, heroFk: 1, name: 'Yada Yada', won: true }, - { id: 400, heroFk: 2, name: 'Tally-hoo', won: true }, - ]); - store.dispatch(action); - - action = eaFactory.create('Power', EntityOp.ADD_ALL, [ - { id: 10, name: 'Speed' }, - { id: 20, name: 'Strength' }, - { id: 30, name: 'Invisibility' }, - ]); - store.dispatch(action); - - action = eaFactory.create('HeroPowerMap', EntityOp.ADD_ALL, [ - { id: 99, heroFk: 1, powerFk: 10 }, - { id: 98, heroFk: 1, powerFk: 20 }, - { id: 97, heroFk: 1, powerFk: 30 }, - { id: 96, heroFk: 2, powerFk: 30 }, - // Gamma has no powers - ]); - store.dispatch(action); -} -// #endregion Test support +import { TestBed } from '@angular/core/testing'; +import { createSelector, StoreModule, Store } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; + +import { Observable } from 'rxjs'; +import { skip } from 'rxjs/operators'; + +import { + EntityMetadataMap, + EntityActionFactory, + EntitySelectorsFactory, + EntityCache, + EntityDataModuleWithoutEffects, + ENTITY_METADATA_TOKEN, + EntityOp, + EntityAction, +} from '../../'; + +const entityMetadataMap: EntityMetadataMap = { + Battle: {}, + Hero: {}, + HeroPowerMap: {}, + Power: { + sortComparer: sortByName, + }, + Sidekick: {}, +}; + +describe('Related-entity Selectors', () => { + // #region setup + let eaFactory: EntityActionFactory; + let entitySelectorsFactory: EntitySelectorsFactory; + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({}), EntityDataModuleWithoutEffects], + providers: [ + // required by EntityData but not used in these tests + { provide: Actions, useValue: null }, + { + provide: ENTITY_METADATA_TOKEN, + multi: true, + useValue: entityMetadataMap, + }, + ], + }); + + store = TestBed.inject(Store); + eaFactory = TestBed.inject(EntityActionFactory); + entitySelectorsFactory = TestBed.inject(EntitySelectorsFactory); + initializeCache(eaFactory, store); + }); + + // #endregion setup + + describe('hero -> sidekick (1-1)', () => { + function setCollectionSelectors() { + const heroSelectors = entitySelectorsFactory.create('Hero'); + const selectHeroMap = heroSelectors.selectEntityMap; + + const sidekickSelectors = entitySelectorsFactory.create( + 'Sidekick' + ); + const selectSidekickMap = sidekickSelectors.selectEntityMap; + + return { + selectHeroMap, + selectSidekickMap, + }; + } + + function createHeroSidekickSelector$(heroId: number): Observable { + const { selectHeroMap, selectSidekickMap } = setCollectionSelectors(); + const selectHero = createSelector( + selectHeroMap, + heroes => heroes[heroId] + ); + const selectSideKick = createSelector( + selectHero, + selectSidekickMap, + (hero, sidekicks) => { + const sidekickId = hero && hero.sidekickFk!; + return sidekickId && sidekicks[sidekickId]; + } + ); + return store.select(selectSideKick) as Observable; + } + + // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. + + it('should get Alpha Hero sidekick', (done: DoneFn) => { + createHeroSidekickSelector$(1).subscribe(sk => { + expect(sk.name).toBe('Bob'); + done(); + }); + }); + + it('should get Alpha Hero updated sidekick', (done: DoneFn) => { + // Skip the initial sidekick and check the one after update + createHeroSidekickSelector$(1) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Robert'); + done(); + }); + + // update the related sidekick + const action = eaFactory.create>( + 'Sidekick', + EntityOp.UPDATE_ONE, + { id: 1, changes: { id: 1, name: 'Robert' } } + ); + store.dispatch(action); + }); + + it('should get Alpha Hero changed sidekick', (done: DoneFn) => { + // Skip the initial sidekick and check the one after update + createHeroSidekickSelector$(1) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Sally'); + done(); + }); + + // update the hero's sidekick from fk=1 to fk=2 + const action = eaFactory.create>( + 'Hero', + EntityOp.UPDATE_ONE, + { id: 1, changes: { id: 1, sidekickFk: 2 } } // Sally + ); + store.dispatch(action); + }); + + it('changing a different hero should NOT trigger first hero selector', (done: DoneFn) => { + let alphaCount = 0; + + createHeroSidekickSelector$(1).subscribe(sk => { + alphaCount += 1; + }); + + // update a different hero's sidekick from fk=2 (Sally) to fk=1 (Bob) + createHeroSidekickSelector$(2) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Bob'); + expect(alphaCount).toEqual( + 1, + 'should only callback for Hero #1 once' + ); + done(); + }); + + const action = eaFactory.create>( + 'Hero', + EntityOp.UPDATE_ONE, + { id: 2, changes: { id: 2, sidekickFk: 1 } } // Bob + ); + store.dispatch(action); + }); + + it('should get undefined sidekick if hero not found', (done: DoneFn) => { + createHeroSidekickSelector$(1234).subscribe(sk => { + expect(sk).toBeUndefined(); + done(); + }); + }); + + it('should get undefined sidekick from Gamma because it has no sidekickFk', (done: DoneFn) => { + createHeroSidekickSelector$(3).subscribe(sk => { + expect(sk).toBeUndefined(); + done(); + }); + }); + + it('should get Gamma sidekick after creating and assigning one', (done: DoneFn) => { + // Skip(1), the initial state in which Gamma has no sidekick + // Note that BOTH dispatches complete synchronously, before the selector updates + // so we only have to skip one. + createHeroSidekickSelector$(3) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Robin'); + done(); + }); + + // create a new sidekick + let action: EntityAction = eaFactory.create( + 'Sidekick', + EntityOp.ADD_ONE, + { + id: 42, + name: 'Robin', + } + ); + store.dispatch(action); + + // assign new sidekick to Gamma + action = eaFactory.create>('Hero', EntityOp.UPDATE_ONE, { + id: 3, + changes: { id: 3, sidekickFk: 42 }, + }); + store.dispatch(action); + }); + }); + + describe('hero -> battles (1-m)', () => { + function setCollectionSelectors() { + const heroSelectors = entitySelectorsFactory.create('Hero'); + const selectHeroMap = heroSelectors.selectEntityMap; + + const battleSelectors = entitySelectorsFactory.create('Battle'); + const selectBattleEntities = battleSelectors.selectEntities; + + const selectHeroBattleMap = createSelector( + selectBattleEntities, + battles => + battles.reduce((acc, battle) => { + const hid = battle.heroFk; + if (hid) { + const hbs = acc[hid]; + if (hbs) { + hbs.push(battle); + } else { + acc[hid] = [battle]; + } + } + return acc; + }, {} as { [heroId: number]: Battle[] }) + ); + + return { + selectHeroMap, + selectHeroBattleMap, + }; + } + + function createHeroBattlesSelector$(heroId: number): Observable { + const { selectHeroMap, selectHeroBattleMap } = setCollectionSelectors(); + + const selectHero = createSelector( + selectHeroMap, + heroes => heroes[heroId] + ); + + const selectHeroBattles = createSelector( + selectHero, + selectHeroBattleMap, + (hero, heroBattleMap) => { + const hid = hero && hero.id; + return (hid && heroBattleMap[hid]) || []; + } + ); + return store.select(selectHeroBattles); + } + + // TODO: more tests + // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. + + it('should get Alpha Hero battles', (done: DoneFn) => { + createHeroBattlesSelector$(1).subscribe(battles => { + expect(battles.length).toBe(3, 'Alpha should have 3 battles'); + done(); + }); + }); + + it('should get Alpha Hero battles again after updating one of its battles', (done: DoneFn) => { + // Skip the initial sidekick and check the one after update + createHeroBattlesSelector$(1) + .pipe(skip(1)) + .subscribe(battles => { + expect(battles[0].name).toBe('Scalliwag'); + done(); + }); + + // update the first of the related battles + const action = eaFactory.create>( + 'Battle', + EntityOp.UPDATE_ONE, + { id: 100, changes: { id: 100, name: 'Scalliwag' } } + ); + store.dispatch(action); + }); + + it('Gamma Hero should have no battles', (done: DoneFn) => { + createHeroBattlesSelector$(3).subscribe(battles => { + expect(battles.length).toBe(0, 'Gamma should have no battles'); + done(); + }); + }); + }); + + describe('hero -> heropower <- power (m-m)', () => { + function setCollectionSelectors() { + const heroSelectors = entitySelectorsFactory.create('Hero'); + const selectHeroMap = heroSelectors.selectEntityMap; + + const powerSelectors = entitySelectorsFactory.create('Power'); + const selectPowerMap = powerSelectors.selectEntityMap; + + const heroPowerMapSelectors = entitySelectorsFactory.create( + 'HeroPowerMap' + ); + const selectHeroPowerMapEntities = heroPowerMapSelectors.selectEntities; + + const selectHeroPowerIds = createSelector( + selectHeroPowerMapEntities, + hpMaps => + hpMaps.reduce((acc, hpMap) => { + const hid = hpMap.heroFk; + if (hid) { + const hpIds = acc[hid]; + if (hpIds) { + hpIds.push(hpMap.powerFk); + } else { + acc[hid] = [hpMap.powerFk]; + } + } + return acc; + }, {} as { [heroId: number]: number[] }) + ); + + return { + selectHeroMap, + selectHeroPowerIds, + selectPowerMap, + }; + } + + function createHeroPowersSelector$(heroId: number): Observable { + const { + selectHeroMap, + selectHeroPowerIds, + selectPowerMap, + } = setCollectionSelectors(); + + const selectHero = createSelector( + selectHeroMap, + heroes => heroes[heroId] + ); + + const selectHeroPowers = createSelector( + selectHero, + selectHeroPowerIds, + selectPowerMap, + (hero, heroPowerIds, powerMap) => { + const hid = hero && hero.id; + const pids = (hid && heroPowerIds[hid]) || []; + const powers = pids.map(id => powerMap[id]).filter(power => power); + return powers; + } + ); + return store.select(selectHeroPowers) as Observable; + } + + // TODO: more tests + // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. + + it('should get Alpha Hero powers', (done: DoneFn) => { + createHeroPowersSelector$(1).subscribe(powers => { + expect(powers.length).toBe(3, 'Alpha should have 3 powers'); + done(); + }); + }); + + it('should get Beta Hero power', (done: DoneFn) => { + createHeroPowersSelector$(2).subscribe(powers => { + expect(powers.length).toBe(1, 'Beta should have 1 power'); + expect(powers[0].name).toBe('Invisibility'); + done(); + }); + }); + + it('Beta Hero should have no powers after delete', (done: DoneFn) => { + createHeroPowersSelector$(2) + .pipe(skip(1)) + .subscribe(powers => { + expect(powers.length).toBe(0, 'Beta should have no powers'); + done(); + }); + + // delete Beta's one power via the HeroPowerMap + const action: EntityAction = eaFactory.create( + 'HeroPowerMap', + EntityOp.REMOVE_ONE, + 96 + ); + store.dispatch(action); + }); + + it('Gamma Hero should have no powers', (done: DoneFn) => { + createHeroPowersSelector$(3).subscribe(powers => { + expect(powers.length).toBe(0, 'Gamma should have no powers'); + done(); + }); + }); + }); +}); + +// #region Test support + +interface Hero { + id: number; + name: string; + saying?: string; + sidekickFk?: number; +} + +interface Battle { + id: number; + name: string; + heroFk: number; + won: boolean; +} + +interface HeroPowerMap { + id: number; + heroFk: number; + powerFk: number; +} + +interface Power { + id: number; + name: string; +} + +interface Sidekick { + id: number; + name: string; +} + +/** Sort Comparer to sort the entity collection by its name property */ +export function sortByName(a: { name: string }, b: { name: string }): number { + return a.name.localeCompare(b.name); +} + +function initializeCache( + eaFactory: EntityActionFactory, + store: Store +) { + let action: EntityAction; + + action = eaFactory.create('Sidekick', EntityOp.ADD_ALL, [ + { id: 1, name: 'Bob' }, + { id: 2, name: 'Sally' }, + ]); + store.dispatch(action); + + action = eaFactory.create('Hero', EntityOp.ADD_ALL, [ + { id: 1, name: 'Alpha', sidekickFk: 1 }, + { id: 2, name: 'Beta', sidekickFk: 2 }, + { id: 3, name: 'Gamma' }, // no sidekick + ]); + store.dispatch(action); + + action = eaFactory.create('Battle', EntityOp.ADD_ALL, [ + { id: 100, heroFk: 1, name: 'Plains of Yon', won: true }, + { id: 200, heroFk: 1, name: 'Yippee-kai-eh', won: false }, + { id: 300, heroFk: 1, name: 'Yada Yada', won: true }, + { id: 400, heroFk: 2, name: 'Tally-hoo', won: true }, + ]); + store.dispatch(action); + + action = eaFactory.create('Power', EntityOp.ADD_ALL, [ + { id: 10, name: 'Speed' }, + { id: 20, name: 'Strength' }, + { id: 30, name: 'Invisibility' }, + ]); + store.dispatch(action); + + action = eaFactory.create('HeroPowerMap', EntityOp.ADD_ALL, [ + { id: 99, heroFk: 1, powerFk: 10 }, + { id: 98, heroFk: 1, powerFk: 20 }, + { id: 97, heroFk: 1, powerFk: 30 }, + { id: 96, heroFk: 2, powerFk: 30 }, + // Gamma has no powers + ]); + store.dispatch(action); +} +// #endregion Test support diff --git a/modules/data/spec/utils/default-pluralizer.spec.ts b/modules/data/spec/utils/default-pluralizer.spec.ts index 79fb1699e9..7c8faec8ad 100644 --- a/modules/data/spec/utils/default-pluralizer.spec.ts +++ b/modules/data/spec/utils/default-pluralizer.spec.ts @@ -1,98 +1,98 @@ -import { TestBed } from '@angular/core/testing'; - -import { DefaultPluralizer, Pluralizer, PLURAL_NAMES_TOKEN } from '../../'; - -describe('DefaultPluralizer', () => { - describe('without plural names', () => { - let pluralizer: Pluralizer; - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ provide: Pluralizer, useClass: DefaultPluralizer }], - }); - - pluralizer = TestBed.get(Pluralizer); - }); - it('should turn "Hero" to "Heros" because no plural names map', () => { - // No map so 'Hero' gets default pluralization - expect(pluralizer.pluralize('Hero')).toBe('Heros'); - }); - - it('should pluralize "Villain" which is not in plural names', () => { - // default pluralization with 's' - expect(pluralizer.pluralize('Villain')).toBe('Villains'); - }); - - it('should pluralize "consonant + y" with "-ies"', () => { - expect(pluralizer.pluralize('Company')).toBe('Companies'); - }); - - it('should pluralize "vowel + y" with "-es"', () => { - expect(pluralizer.pluralize('Cowboy')).toBe('Cowboys'); - }); - - it('should pluralize "Information" as "Information ', () => { - // known "uncoumtables" - expect(pluralizer.pluralize('Information')).toBe('Information'); - }); - - it('should pluralize "SkyBox" which is not in plural names', () => { - // default pluralization of word ending in 'x' - expect(pluralizer.pluralize('SkyBox')).toBe('SkyBoxes'); - }); - }); - - describe('with injected plural names', () => { - let pluralizer: Pluralizer; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - // Demonstrate multi-provider - // Default would turn "Hero" into "Heros". Fix it. - { - provide: PLURAL_NAMES_TOKEN, - multi: true, - useValue: { Hero: 'Heroes' }, - }, - // "Foots" is deliberately wrong. Count on override in next provider - { - provide: PLURAL_NAMES_TOKEN, - multi: true, - useValue: { Foot: 'Foots' }, - }, - // Demonstrate overwrite of 'Foot' while setting multiple names - { - provide: PLURAL_NAMES_TOKEN, - multi: true, - useValue: { Foot: 'Feet', Person: 'People' }, - }, - { provide: Pluralizer, useClass: DefaultPluralizer }, - ], - }); - - pluralizer = TestBed.get(Pluralizer); - }); - - it('should pluralize "Villain" which is not in plural names', () => { - // default pluralization with 's' - expect(pluralizer.pluralize('Villain')).toBe('Villains'); - }); - - it('should pluralize "Hero" using plural names', () => { - expect(pluralizer.pluralize('Hero')).toBe('Heroes'); - }); - - it('should be case sensitive when using map', () => { - // uses default pluralization rule, not the names map - expect(pluralizer.pluralize('hero')).toBe('heros'); - }); - - it('should pluralize "Person" using a plural name added to the map later', () => { - expect(pluralizer.pluralize('Person')).toBe('People'); - }); - - it('later plural name map replaces earlier one', () => { - expect(pluralizer.pluralize('Foot')).toBe('Feet'); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; + +import { DefaultPluralizer, Pluralizer, PLURAL_NAMES_TOKEN } from '../../'; + +describe('DefaultPluralizer', () => { + describe('without plural names', () => { + let pluralizer: Pluralizer; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: Pluralizer, useClass: DefaultPluralizer }], + }); + + pluralizer = TestBed.inject(Pluralizer); + }); + it('should turn "Hero" to "Heros" because no plural names map', () => { + // No map so 'Hero' gets default pluralization + expect(pluralizer.pluralize('Hero')).toBe('Heros'); + }); + + it('should pluralize "Villain" which is not in plural names', () => { + // default pluralization with 's' + expect(pluralizer.pluralize('Villain')).toBe('Villains'); + }); + + it('should pluralize "consonant + y" with "-ies"', () => { + expect(pluralizer.pluralize('Company')).toBe('Companies'); + }); + + it('should pluralize "vowel + y" with "-es"', () => { + expect(pluralizer.pluralize('Cowboy')).toBe('Cowboys'); + }); + + it('should pluralize "Information" as "Information ', () => { + // known "uncoumtables" + expect(pluralizer.pluralize('Information')).toBe('Information'); + }); + + it('should pluralize "SkyBox" which is not in plural names', () => { + // default pluralization of word ending in 'x' + expect(pluralizer.pluralize('SkyBox')).toBe('SkyBoxes'); + }); + }); + + describe('with injected plural names', () => { + let pluralizer: Pluralizer; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + // Demonstrate multi-provider + // Default would turn "Hero" into "Heros". Fix it. + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: { Hero: 'Heroes' }, + }, + // "Foots" is deliberately wrong. Count on override in next provider + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: { Foot: 'Foots' }, + }, + // Demonstrate overwrite of 'Foot' while setting multiple names + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: { Foot: 'Feet', Person: 'People' }, + }, + { provide: Pluralizer, useClass: DefaultPluralizer }, + ], + }); + + pluralizer = TestBed.inject(Pluralizer); + }); + + it('should pluralize "Villain" which is not in plural names', () => { + // default pluralization with 's' + expect(pluralizer.pluralize('Villain')).toBe('Villains'); + }); + + it('should pluralize "Hero" using plural names', () => { + expect(pluralizer.pluralize('Hero')).toBe('Heroes'); + }); + + it('should be case sensitive when using map', () => { + // uses default pluralization rule, not the names map + expect(pluralizer.pluralize('hero')).toBe('heros'); + }); + + it('should pluralize "Person" using a plural name added to the map later', () => { + expect(pluralizer.pluralize('Person')).toBe('People'); + }); + + it('later plural name map replaces earlier one', () => { + expect(pluralizer.pluralize('Foot')).toBe('Feet'); + }); + }); +}); diff --git a/modules/effects/spec/effect_sources.spec.ts b/modules/effects/spec/effect_sources.spec.ts index f50797b1f0..7874899302 100644 --- a/modules/effects/spec/effect_sources.spec.ts +++ b/modules/effects/spec/effect_sources.spec.ts @@ -1,609 +1,609 @@ -import { ErrorHandler } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { cold, hot, getTestScheduler } from 'jasmine-marbles'; -import { concat, NEVER, Observable, of, throwError, timer } from 'rxjs'; -import { concatMap, map } from 'rxjs/operators'; - -import { - Effect, - EffectSources, - OnIdentifyEffects, - OnInitEffects, - createEffect, -} from '../'; -import { Store } from '@ngrx/store'; - -describe('EffectSources', () => { - let mockErrorReporter: ErrorHandler; - let effectSources: EffectSources; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - EffectSources, - { - provide: Store, - useValue: { - dispatch: jasmine.createSpy('dispatch'), - }, - }, - ], - }); - - mockErrorReporter = TestBed.get(ErrorHandler); - effectSources = TestBed.get(EffectSources); - - spyOn(mockErrorReporter, 'handleError'); - }); - - it('should have an "addEffects" method to push new source instances', () => { - const effectSource = {}; - spyOn(effectSources, 'next'); - - effectSources.addEffects(effectSource); - - expect(effectSources.next).toHaveBeenCalledWith(effectSource); - }); - - it('should dispatch an action on ngrxOnInitEffects after being registered', () => { - class EffectWithInitAction implements OnInitEffects { - ngrxOnInitEffects() { - return { type: '[EffectWithInitAction] Init' }; - } - } - - effectSources.addEffects(new EffectWithInitAction()); - - const store = TestBed.get(Store); - expect(store.dispatch).toHaveBeenCalledWith({ - type: '[EffectWithInitAction] Init', - }); - }); - - describe('toActions() Operator', () => { - describe('with @Effect()', () => { - const a = { type: 'From Source A' }; - const b = { type: 'From Source B' }; - const c = { type: 'From Source C that completes' }; - const d = { not: 'a valid action' }; - const e = undefined; - const f = null; - const i = { type: 'From Source Identifier' }; - const i2 = { type: 'From Source Identifier 2' }; - - let circularRef = {} as any; - circularRef.circularRef = circularRef; - const g = { circularRef }; - - const error = new Error('An Error'); - - class SourceA { - @Effect() a$ = alwaysOf(a); - } - - class SourceB { - @Effect() b$ = alwaysOf(b); - } - - class SourceC { - @Effect() c$ = of(c); - } - - class SourceD { - @Effect() d$ = alwaysOf(d); - } - - class SourceE { - @Effect() e$ = alwaysOf(e); - } - - class SourceF { - @Effect() f$ = alwaysOf(f); - } - - class SourceG { - @Effect() g$ = alwaysOf(g); - } - - class SourceError { - @Effect() e$ = throwError(error); - } - - class SourceH { - @Effect() empty = of('value'); - @Effect() - never = timer(50, getTestScheduler() as any).pipe(map(() => 'update')); - } - - class SourceWithIdentifier implements OnIdentifyEffects { - effectIdentifier: string; - @Effect() i$ = alwaysOf(i); - - ngrxOnIdentifyEffects() { - return this.effectIdentifier; - } - - constructor(identifier: string) { - this.effectIdentifier = identifier; - } - } - - class SourceWithIdentifier2 implements OnIdentifyEffects { - effectIdentifier: string; - @Effect() i2$ = alwaysOf(i2); - - ngrxOnIdentifyEffects() { - return this.effectIdentifier; - } - - constructor(identifier: string) { - this.effectIdentifier = identifier; - } - } - - it('should resolve effects from instances', () => { - const sources$ = cold('--a--', { a: new SourceA() }); - const expected = cold('--a--', { a }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should ignore duplicate sources', () => { - const sources$ = cold('--a--a--a--', { - a: new SourceA(), - }); - const expected = cold('--a--------', { a }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should resolve effects with different identifiers', () => { - const sources$ = cold('--a--b--c--', { - a: new SourceWithIdentifier('a'), - b: new SourceWithIdentifier('b'), - c: new SourceWithIdentifier('c'), - }); - const expected = cold('--i--i--i--', { i }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should ignore effects with the same identifier', () => { - const sources$ = cold('--a--b--c--', { - a: new SourceWithIdentifier('a'), - b: new SourceWithIdentifier('a'), - c: new SourceWithIdentifier('a'), - }); - const expected = cold('--i--------', { i }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should resolve effects with same identifiers but different classes', () => { - const sources$ = cold('--a--b--c--d--', { - a: new SourceWithIdentifier('a'), - b: new SourceWithIdentifier2('a'), - c: new SourceWithIdentifier('b'), - d: new SourceWithIdentifier2('b'), - }); - const expected = cold('--a--b--a--b--', { a: i, b: i2 }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should report an error if an effect dispatches an invalid action', () => { - const sources$ = of(new SourceD()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error( - 'Effect "SourceD.d$" dispatched an invalid action: {"not":"a valid action"}' - ) - ); - }); - - it('should report an error if an effect dispatches an `undefined`', () => { - const sources$ = of(new SourceE()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error( - 'Effect "SourceE.e$" dispatched an invalid action: undefined' - ) - ); - }); - - it('should report an error if an effect dispatches a `null`', () => { - const sources$ = of(new SourceF()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error('Effect "SourceF.f$" dispatched an invalid action: null') - ); - }); - - it('should report an error if an effect throws one', () => { - const sources$ = of(new SourceError()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error('An Error') - ); - }); - - it('should resubscribe on error by default', () => { - class Eff { - @Effect() - b$ = hot('a--e--b--e--c--e--d').pipe( - map(v => { - if (v == 'e') throw new Error('An Error'); - return v; - }) - ); - } - - const sources$ = of(new Eff()); - - // 👇 'e' is ignored. - const expected = cold('a-----b-----c-----d'); - expect(toActions(sources$)).toBeObservable(expected); - }); - - it('should not resubscribe on error when resubscribeOnError is false', () => { - class Eff { - @Effect({ resubscribeOnError: false }) - b$ = hot('a--b--c--d').pipe( - map(v => { - if (v == 'b') throw new Error('An Error'); - return v; - }) - ); - } - - const sources$ = of(new Eff()); - - // 👇 completes. - const expected = cold('a--|'); - - expect(toActions(sources$)).toBeObservable(expected); - }); - - it(`should not break when the action in the error message can't be stringified`, () => { - const sources$ = of(new SourceG()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error( - 'Effect "SourceG.g$" dispatched an invalid action: [object Object]' - ) - ); - }); - - it('should not complete the group if just one effect completes', () => { - const sources$ = cold('g', { - g: new SourceH(), - }); - const expected = cold('a----b-----', { a: 'value', b: 'update' }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - function toActions(source: any): Observable { - source['errorHandler'] = mockErrorReporter; - return (effectSources as any)['toActions'].call(source); - } - }); - - describe('with createEffect()', () => { - const a = { type: 'From Source A' }; - const b = { type: 'From Source B' }; - const c = { type: 'From Source C that completes' }; - const d = { not: 'a valid action' }; - const e = undefined; - const f = null; - const i = { type: 'From Source Identifier' }; - const i2 = { type: 'From Source Identifier 2' }; - - let circularRef = {} as any; - circularRef.circularRef = circularRef; - const g = { circularRef }; - - const error = new Error('An Error'); - - class SourceA { - a$ = createEffect(() => alwaysOf(a)); - } - - class SourceB { - b$ = createEffect(() => alwaysOf(b)); - } - - class SourceC { - c$ = createEffect(() => of(c)); - } - - class SourceD { - // typed as `any` because otherwise there would be compile errors - // createEffect is typed that it always has to return an action - d$ = createEffect(() => alwaysOf(d) as any); - } - - class SourceE { - // typed as `any` because otherwise there would be compile errors - // createEffect is typed that it always has to return an action - e$ = createEffect(() => alwaysOf(e) as any); - } - - class SourceF { - // typed as `any` because otherwise there would be compile errors - // createEffect is typed that it always has to return an action - f$ = createEffect(() => alwaysOf(f) as any); - } - - class SourceG { - // typed as `any` because otherwise there would be compile errors - // createEffect is typed that it always has to return an action - g$ = createEffect(() => alwaysOf(g) as any); - } - - class SourceError { - e$ = createEffect(() => throwError(error)); - } - - class SourceH { - // typed as `any` because otherwise there would be compile errors - // createEffect is typed that it always has to return an action - empty = createEffect(() => of('value') as any); - never = createEffect( - () => - // typed as `any` because otherwise there would be compile errors - // createEffect is typed that it always has to return an action - timer(50, getTestScheduler() as any).pipe( - map(() => 'update') - ) as any - ); - } - - class SourceWithIdentifier implements OnIdentifyEffects { - effectIdentifier: string; - i$ = createEffect(() => alwaysOf(i)); - - ngrxOnIdentifyEffects() { - return this.effectIdentifier; - } - - constructor(identifier: string) { - this.effectIdentifier = identifier; - } - } - - class SourceWithIdentifier2 implements OnIdentifyEffects { - effectIdentifier: string; - i2$ = createEffect(() => alwaysOf(i2)); - - ngrxOnIdentifyEffects() { - return this.effectIdentifier; - } - - constructor(identifier: string) { - this.effectIdentifier = identifier; - } - } - - it('should resolve effects from instances', () => { - const sources$ = cold('--a--', { a: new SourceA() }); - const expected = cold('--a--', { a }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should ignore duplicate sources', () => { - const sources$ = cold('--a--a--a--', { - a: new SourceA(), - }); - const expected = cold('--a--------', { a }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should resolve effects with different identifiers', () => { - const sources$ = cold('--a--b--c--', { - a: new SourceWithIdentifier('a'), - b: new SourceWithIdentifier('b'), - c: new SourceWithIdentifier('c'), - }); - const expected = cold('--i--i--i--', { i }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should ignore effects with the same identifier', () => { - const sources$ = cold('--a--b--c--', { - a: new SourceWithIdentifier('a'), - b: new SourceWithIdentifier('a'), - c: new SourceWithIdentifier('a'), - }); - const expected = cold('--i--------', { i }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should resolve effects with same identifiers but different classes', () => { - const sources$ = cold('--a--b--c--d--', { - a: new SourceWithIdentifier('a'), - b: new SourceWithIdentifier2('a'), - c: new SourceWithIdentifier('b'), - d: new SourceWithIdentifier2('b'), - }); - const expected = cold('--a--b--a--b--', { a: i, b: i2 }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - it('should report an error if an effect dispatches an invalid action', () => { - const sources$ = of(new SourceD()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error( - 'Effect "SourceD.d$" dispatched an invalid action: {"not":"a valid action"}' - ) - ); - }); - - it('should report an error if an effect dispatches an `undefined`', () => { - const sources$ = of(new SourceE()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error( - 'Effect "SourceE.e$" dispatched an invalid action: undefined' - ) - ); - }); - - it('should report an error if an effect dispatches a `null`', () => { - const sources$ = of(new SourceF()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error('Effect "SourceF.f$" dispatched an invalid action: null') - ); - }); - - it('should report an error if an effect throws one', () => { - const sources$ = of(new SourceError()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error('An Error') - ); - }); - - it('should resubscribe on error by default', () => { - const sources$ = of( - new class { - b$ = createEffect(() => - hot('a--e--b--e--c--e--d').pipe( - map(v => { - if (v == 'e') throw new Error('An Error'); - return v; - }) - ) - ); - }() - ); - - // 👇 'e' is ignored. - const expected = cold('a-----b-----c-----d'); - - expect(toActions(sources$)).toBeObservable(expected); - }); - - it('should resubscribe on error by default when dispatch is false', () => { - const sources$ = of( - new class { - b$ = createEffect( - () => - hot('a--b--c--d').pipe( - map(v => { - if (v == 'b') throw new Error('An Error'); - return v; - }) - ), - { dispatch: false } - ); - }() - ); - // 👇 doesn't complete and doesn't dispatch - const expected = cold('----------'); - - expect(toActions(sources$)).toBeObservable(expected); - }); - - it('should not resubscribe on error when resubscribeOnError is false', () => { - const sources$ = of( - new class { - b$ = createEffect( - () => - hot('a--b--c--d').pipe( - map(v => { - if (v == 'b') throw new Error('An Error'); - return v; - }) - ), - { dispatch: false, resubscribeOnError: false } - ); - }() - ); - // 👇 errors with dispatch false - const expected = cold('---#', undefined, new Error('An Error')); - - expect(toActions(sources$)).toBeObservable(expected); - }); - - it(`should not break when the action in the error message can't be stringified`, () => { - const sources$ = of(new SourceG()); - - toActions(sources$).subscribe(); - - expect(mockErrorReporter.handleError).toHaveBeenCalledWith( - new Error( - 'Effect "SourceG.g$" dispatched an invalid action: [object Object]' - ) - ); - }); - - it('should not complete the group if just one effect completes', () => { - const sources$ = cold('g', { - g: new SourceH(), - }); - const expected = cold('a----b-----', { a: 'value', b: 'update' }); - - const output = toActions(sources$); - - expect(output).toBeObservable(expected); - }); - - function toActions(source: any): Observable { - source['errorHandler'] = mockErrorReporter; - return (effectSources as any)['toActions'].call(source); - } - }); - }); - - function alwaysOf(value: T) { - return concat(of(value), NEVER); - } -}); +import { ErrorHandler } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { cold, hot, getTestScheduler } from 'jasmine-marbles'; +import { concat, NEVER, Observable, of, throwError, timer } from 'rxjs'; +import { concatMap, map } from 'rxjs/operators'; + +import { + Effect, + EffectSources, + OnIdentifyEffects, + OnInitEffects, + createEffect, +} from '../'; +import { Store } from '@ngrx/store'; + +describe('EffectSources', () => { + let mockErrorReporter: ErrorHandler; + let effectSources: EffectSources; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + EffectSources, + { + provide: Store, + useValue: { + dispatch: jasmine.createSpy('dispatch'), + }, + }, + ], + }); + + mockErrorReporter = TestBed.inject(ErrorHandler); + effectSources = TestBed.inject(EffectSources); + + spyOn(mockErrorReporter, 'handleError'); + }); + + it('should have an "addEffects" method to push new source instances', () => { + const effectSource = {}; + spyOn(effectSources, 'next'); + + effectSources.addEffects(effectSource); + + expect(effectSources.next).toHaveBeenCalledWith(effectSource); + }); + + it('should dispatch an action on ngrxOnInitEffects after being registered', () => { + class EffectWithInitAction implements OnInitEffects { + ngrxOnInitEffects() { + return { type: '[EffectWithInitAction] Init' }; + } + } + + effectSources.addEffects(new EffectWithInitAction()); + + const store = TestBed.inject(Store); + expect(store.dispatch).toHaveBeenCalledWith({ + type: '[EffectWithInitAction] Init', + }); + }); + + describe('toActions() Operator', () => { + describe('with @Effect()', () => { + const a = { type: 'From Source A' }; + const b = { type: 'From Source B' }; + const c = { type: 'From Source C that completes' }; + const d = { not: 'a valid action' }; + const e = undefined; + const f = null; + const i = { type: 'From Source Identifier' }; + const i2 = { type: 'From Source Identifier 2' }; + + let circularRef = {} as any; + circularRef.circularRef = circularRef; + const g = { circularRef }; + + const error = new Error('An Error'); + + class SourceA { + @Effect() a$ = alwaysOf(a); + } + + class SourceB { + @Effect() b$ = alwaysOf(b); + } + + class SourceC { + @Effect() c$ = of(c); + } + + class SourceD { + @Effect() d$ = alwaysOf(d); + } + + class SourceE { + @Effect() e$ = alwaysOf(e); + } + + class SourceF { + @Effect() f$ = alwaysOf(f); + } + + class SourceG { + @Effect() g$ = alwaysOf(g); + } + + class SourceError { + @Effect() e$ = throwError(error); + } + + class SourceH { + @Effect() empty = of('value'); + @Effect() + never = timer(50, getTestScheduler() as any).pipe(map(() => 'update')); + } + + class SourceWithIdentifier implements OnIdentifyEffects { + effectIdentifier: string; + @Effect() i$ = alwaysOf(i); + + ngrxOnIdentifyEffects() { + return this.effectIdentifier; + } + + constructor(identifier: string) { + this.effectIdentifier = identifier; + } + } + + class SourceWithIdentifier2 implements OnIdentifyEffects { + effectIdentifier: string; + @Effect() i2$ = alwaysOf(i2); + + ngrxOnIdentifyEffects() { + return this.effectIdentifier; + } + + constructor(identifier: string) { + this.effectIdentifier = identifier; + } + } + + it('should resolve effects from instances', () => { + const sources$ = cold('--a--', { a: new SourceA() }); + const expected = cold('--a--', { a }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should ignore duplicate sources', () => { + const sources$ = cold('--a--a--a--', { + a: new SourceA(), + }); + const expected = cold('--a--------', { a }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should resolve effects with different identifiers', () => { + const sources$ = cold('--a--b--c--', { + a: new SourceWithIdentifier('a'), + b: new SourceWithIdentifier('b'), + c: new SourceWithIdentifier('c'), + }); + const expected = cold('--i--i--i--', { i }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should ignore effects with the same identifier', () => { + const sources$ = cold('--a--b--c--', { + a: new SourceWithIdentifier('a'), + b: new SourceWithIdentifier('a'), + c: new SourceWithIdentifier('a'), + }); + const expected = cold('--i--------', { i }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should resolve effects with same identifiers but different classes', () => { + const sources$ = cold('--a--b--c--d--', { + a: new SourceWithIdentifier('a'), + b: new SourceWithIdentifier2('a'), + c: new SourceWithIdentifier('b'), + d: new SourceWithIdentifier2('b'), + }); + const expected = cold('--a--b--a--b--', { a: i, b: i2 }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should report an error if an effect dispatches an invalid action', () => { + const sources$ = of(new SourceD()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error( + 'Effect "SourceD.d$" dispatched an invalid action: {"not":"a valid action"}' + ) + ); + }); + + it('should report an error if an effect dispatches an `undefined`', () => { + const sources$ = of(new SourceE()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error( + 'Effect "SourceE.e$" dispatched an invalid action: undefined' + ) + ); + }); + + it('should report an error if an effect dispatches a `null`', () => { + const sources$ = of(new SourceF()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error('Effect "SourceF.f$" dispatched an invalid action: null') + ); + }); + + it('should report an error if an effect throws one', () => { + const sources$ = of(new SourceError()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error('An Error') + ); + }); + + it('should resubscribe on error by default', () => { + class Eff { + @Effect() + b$ = hot('a--e--b--e--c--e--d').pipe( + map(v => { + if (v == 'e') throw new Error('An Error'); + return v; + }) + ); + } + + const sources$ = of(new Eff()); + + // 👇 'e' is ignored. + const expected = cold('a-----b-----c-----d'); + expect(toActions(sources$)).toBeObservable(expected); + }); + + it('should not resubscribe on error when resubscribeOnError is false', () => { + class Eff { + @Effect({ resubscribeOnError: false }) + b$ = hot('a--b--c--d').pipe( + map(v => { + if (v == 'b') throw new Error('An Error'); + return v; + }) + ); + } + + const sources$ = of(new Eff()); + + // 👇 completes. + const expected = cold('a--|'); + + expect(toActions(sources$)).toBeObservable(expected); + }); + + it(`should not break when the action in the error message can't be stringified`, () => { + const sources$ = of(new SourceG()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error( + 'Effect "SourceG.g$" dispatched an invalid action: [object Object]' + ) + ); + }); + + it('should not complete the group if just one effect completes', () => { + const sources$ = cold('g', { + g: new SourceH(), + }); + const expected = cold('a----b-----', { a: 'value', b: 'update' }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + function toActions(source: any): Observable { + source['errorHandler'] = mockErrorReporter; + return (effectSources as any)['toActions'].call(source); + } + }); + + describe('with createEffect()', () => { + const a = { type: 'From Source A' }; + const b = { type: 'From Source B' }; + const c = { type: 'From Source C that completes' }; + const d = { not: 'a valid action' }; + const e = undefined; + const f = null; + const i = { type: 'From Source Identifier' }; + const i2 = { type: 'From Source Identifier 2' }; + + let circularRef = {} as any; + circularRef.circularRef = circularRef; + const g = { circularRef }; + + const error = new Error('An Error'); + + class SourceA { + a$ = createEffect(() => alwaysOf(a)); + } + + class SourceB { + b$ = createEffect(() => alwaysOf(b)); + } + + class SourceC { + c$ = createEffect(() => of(c)); + } + + class SourceD { + // typed as `any` because otherwise there would be compile errors + // createEffect is typed that it always has to return an action + d$ = createEffect(() => alwaysOf(d) as any); + } + + class SourceE { + // typed as `any` because otherwise there would be compile errors + // createEffect is typed that it always has to return an action + e$ = createEffect(() => alwaysOf(e) as any); + } + + class SourceF { + // typed as `any` because otherwise there would be compile errors + // createEffect is typed that it always has to return an action + f$ = createEffect(() => alwaysOf(f) as any); + } + + class SourceG { + // typed as `any` because otherwise there would be compile errors + // createEffect is typed that it always has to return an action + g$ = createEffect(() => alwaysOf(g) as any); + } + + class SourceError { + e$ = createEffect(() => throwError(error)); + } + + class SourceH { + // typed as `any` because otherwise there would be compile errors + // createEffect is typed that it always has to return an action + empty = createEffect(() => of('value') as any); + never = createEffect( + () => + // typed as `any` because otherwise there would be compile errors + // createEffect is typed that it always has to return an action + timer(50, getTestScheduler() as any).pipe( + map(() => 'update') + ) as any + ); + } + + class SourceWithIdentifier implements OnIdentifyEffects { + effectIdentifier: string; + i$ = createEffect(() => alwaysOf(i)); + + ngrxOnIdentifyEffects() { + return this.effectIdentifier; + } + + constructor(identifier: string) { + this.effectIdentifier = identifier; + } + } + + class SourceWithIdentifier2 implements OnIdentifyEffects { + effectIdentifier: string; + i2$ = createEffect(() => alwaysOf(i2)); + + ngrxOnIdentifyEffects() { + return this.effectIdentifier; + } + + constructor(identifier: string) { + this.effectIdentifier = identifier; + } + } + + it('should resolve effects from instances', () => { + const sources$ = cold('--a--', { a: new SourceA() }); + const expected = cold('--a--', { a }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should ignore duplicate sources', () => { + const sources$ = cold('--a--a--a--', { + a: new SourceA(), + }); + const expected = cold('--a--------', { a }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should resolve effects with different identifiers', () => { + const sources$ = cold('--a--b--c--', { + a: new SourceWithIdentifier('a'), + b: new SourceWithIdentifier('b'), + c: new SourceWithIdentifier('c'), + }); + const expected = cold('--i--i--i--', { i }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should ignore effects with the same identifier', () => { + const sources$ = cold('--a--b--c--', { + a: new SourceWithIdentifier('a'), + b: new SourceWithIdentifier('a'), + c: new SourceWithIdentifier('a'), + }); + const expected = cold('--i--------', { i }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should resolve effects with same identifiers but different classes', () => { + const sources$ = cold('--a--b--c--d--', { + a: new SourceWithIdentifier('a'), + b: new SourceWithIdentifier2('a'), + c: new SourceWithIdentifier('b'), + d: new SourceWithIdentifier2('b'), + }); + const expected = cold('--a--b--a--b--', { a: i, b: i2 }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + it('should report an error if an effect dispatches an invalid action', () => { + const sources$ = of(new SourceD()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error( + 'Effect "SourceD.d$" dispatched an invalid action: {"not":"a valid action"}' + ) + ); + }); + + it('should report an error if an effect dispatches an `undefined`', () => { + const sources$ = of(new SourceE()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error( + 'Effect "SourceE.e$" dispatched an invalid action: undefined' + ) + ); + }); + + it('should report an error if an effect dispatches a `null`', () => { + const sources$ = of(new SourceF()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error('Effect "SourceF.f$" dispatched an invalid action: null') + ); + }); + + it('should report an error if an effect throws one', () => { + const sources$ = of(new SourceError()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error('An Error') + ); + }); + + it('should resubscribe on error by default', () => { + const sources$ = of( + new (class { + b$ = createEffect(() => + hot('a--e--b--e--c--e--d').pipe( + map(v => { + if (v == 'e') throw new Error('An Error'); + return v; + }) + ) + ); + })() + ); + + // 👇 'e' is ignored. + const expected = cold('a-----b-----c-----d'); + + expect(toActions(sources$)).toBeObservable(expected); + }); + + it('should resubscribe on error by default when dispatch is false', () => { + const sources$ = of( + new (class { + b$ = createEffect( + () => + hot('a--b--c--d').pipe( + map(v => { + if (v == 'b') throw new Error('An Error'); + return v; + }) + ), + { dispatch: false } + ); + })() + ); + // 👇 doesn't complete and doesn't dispatch + const expected = cold('----------'); + + expect(toActions(sources$)).toBeObservable(expected); + }); + + it('should not resubscribe on error when resubscribeOnError is false', () => { + const sources$ = of( + new (class { + b$ = createEffect( + () => + hot('a--b--c--d').pipe( + map(v => { + if (v == 'b') throw new Error('An Error'); + return v; + }) + ), + { dispatch: false, resubscribeOnError: false } + ); + })() + ); + // 👇 errors with dispatch false + const expected = cold('---#', undefined, new Error('An Error')); + + expect(toActions(sources$)).toBeObservable(expected); + }); + + it(`should not break when the action in the error message can't be stringified`, () => { + const sources$ = of(new SourceG()); + + toActions(sources$).subscribe(); + + expect(mockErrorReporter.handleError).toHaveBeenCalledWith( + new Error( + 'Effect "SourceG.g$" dispatched an invalid action: [object Object]' + ) + ); + }); + + it('should not complete the group if just one effect completes', () => { + const sources$ = cold('g', { + g: new SourceH(), + }); + const expected = cold('a----b-----', { a: 'value', b: 'update' }); + + const output = toActions(sources$); + + expect(output).toBeObservable(expected); + }); + + function toActions(source: any): Observable { + source['errorHandler'] = mockErrorReporter; + return (effectSources as any)['toActions'].call(source); + } + }); + }); + + function alwaysOf(value: T) { + return concat(of(value), NEVER); + } +}); diff --git a/modules/effects/spec/effects_feature_module.spec.ts b/modules/effects/spec/effects_feature_module.spec.ts index 69b8936fed..cb30bbe2c4 100644 --- a/modules/effects/spec/effects_feature_module.spec.ts +++ b/modules/effects/spec/effects_feature_module.spec.ts @@ -1,175 +1,175 @@ -import { Injectable, NgModule } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { - Action, - createFeatureSelector, - createSelector, - select, - Store, - StoreModule, -} from '@ngrx/store'; -import { map, withLatestFrom } from 'rxjs/operators'; -import { Actions, Effect, EffectsModule, ofType, createEffect } from '../'; -import { EffectsFeatureModule } from '../src/effects_feature_module'; -import { EffectsRootModule } from '../src/effects_root_module'; -import { FEATURE_EFFECTS } from '../src/tokens'; - -describe('Effects Feature Module', () => { - describe('when registered', () => { - const sourceA = 'sourceA'; - const sourceB = 'sourceB'; - const sourceC = 'sourceC'; - const effectSourceGroups = [[sourceA], [sourceB], [sourceC]]; - - let mockEffectSources: { addEffects: jasmine.Spy }; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - { - provide: EffectsRootModule, - useValue: { - addEffects: jasmine.createSpy('addEffects'), - }, - }, - { - provide: FEATURE_EFFECTS, - useValue: effectSourceGroups, - }, - EffectsFeatureModule, - ], - }); - - mockEffectSources = TestBed.get(EffectsRootModule); - }); - - it('should add all effects when instantiated', () => { - TestBed.get(EffectsFeatureModule); - - expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceA); - expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceB); - expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceC); - }); - }); - - describe('when registered in a different NgModule from the feature state', () => { - let effects: FeatureEffects; - let store: Store; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [AppModule], - }); - - effects = TestBed.get(FeatureEffects); - store = TestBed.get(Store); - }); - - it('should have the feature state defined to select from the effect', (done: any) => { - const action = { type: 'INCREMENT' }; - const result = { type: 'INCREASE' }; - - effects.effectWithStore.subscribe(res => { - expect(res).toEqual(result); - }); - - store.dispatch(action); - - store.pipe(select(getDataState)).subscribe(data => { - expect(data).toBe(110); - done(); - }); - }); - - it('should have the feature state defined to select from the createEffect', (done: any) => { - const action = { type: 'CREATE_INCREMENT' }; - const result = { type: 'CREATE_INCREASE' }; - - effects.createEffectWithStore.subscribe(res => { - expect(res).toEqual(result); - }); - - store.dispatch(action); - - store.pipe(select(getCreateDataState)).subscribe(data => { - expect(data).toBe(220); - done(); - }); - }); - }); -}); - -const FEATURE_KEY = 'feature'; - -interface State { - FEATURE_KEY: DataState; -} - -interface DataState { - data: number; - createData: number; -} - -const initialState: DataState = { - data: 100, - createData: 200, -}; - -function reducer(state: DataState = initialState, action: Action) { - switch (action.type) { - case 'INCREASE': - return { - ...state, - data: state.data + 10, - }; - case 'CREATE_INCREASE': - return { - ...state, - createData: state.createData + 20, - }; - } - return state; -} - -const getFeatureState = createFeatureSelector(FEATURE_KEY); - -const getDataState = createSelector(getFeatureState, state => state.data); -const getCreateDataState = createSelector( - getFeatureState, - state => state.createData -); - -@Injectable() -class FeatureEffects { - constructor(private actions: Actions, private store: Store) {} - - @Effect() - effectWithStore = this.actions.pipe( - ofType('INCREMENT'), - withLatestFrom(this.store.select(getDataState)), - map(([action, state]) => ({ type: 'INCREASE' })) - ); - - createEffectWithStore = createEffect(() => - this.actions.pipe( - ofType('CREATE_INCREMENT'), - withLatestFrom(this.store.select(getDataState)), - map(([action, state]) => ({ type: 'CREATE_INCREASE' })) - ) - ); -} - -@NgModule({ - imports: [EffectsModule.forFeature([FeatureEffects])], -}) -class FeatureEffectsModule {} - -@NgModule({ - imports: [FeatureEffectsModule, StoreModule.forFeature(FEATURE_KEY, reducer)], -}) -class FeatureModule {} - -@NgModule({ - imports: [StoreModule.forRoot({}), EffectsModule.forRoot([]), FeatureModule], -}) -class AppModule {} +import { Injectable, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + Action, + createFeatureSelector, + createSelector, + select, + Store, + StoreModule, +} from '@ngrx/store'; +import { map, withLatestFrom } from 'rxjs/operators'; +import { Actions, Effect, EffectsModule, ofType, createEffect } from '../'; +import { EffectsFeatureModule } from '../src/effects_feature_module'; +import { EffectsRootModule } from '../src/effects_root_module'; +import { FEATURE_EFFECTS } from '../src/tokens'; + +describe('Effects Feature Module', () => { + describe('when registered', () => { + const sourceA = 'sourceA'; + const sourceB = 'sourceB'; + const sourceC = 'sourceC'; + const effectSourceGroups = [[sourceA], [sourceB], [sourceC]]; + + let mockEffectSources: { addEffects: jasmine.Spy }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: EffectsRootModule, + useValue: { + addEffects: jasmine.createSpy('addEffects'), + }, + }, + { + provide: FEATURE_EFFECTS, + useValue: effectSourceGroups, + }, + EffectsFeatureModule, + ], + }); + + mockEffectSources = TestBed.inject(EffectsRootModule); + }); + + it('should add all effects when instantiated', () => { + TestBed.inject(EffectsFeatureModule); + + expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceA); + expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceB); + expect(mockEffectSources.addEffects).toHaveBeenCalledWith(sourceC); + }); + }); + + describe('when registered in a different NgModule from the feature state', () => { + let effects: FeatureEffects; + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [AppModule], + }); + + effects = TestBed.inject(FeatureEffects); + store = TestBed.inject(Store); + }); + + it('should have the feature state defined to select from the effect', (done: any) => { + const action = { type: 'INCREMENT' }; + const result = { type: 'INCREASE' }; + + effects.effectWithStore.subscribe(res => { + expect(res).toEqual(result); + }); + + store.dispatch(action); + + store.pipe(select(getDataState)).subscribe(data => { + expect(data).toBe(110); + done(); + }); + }); + + it('should have the feature state defined to select from the createEffect', (done: any) => { + const action = { type: 'CREATE_INCREMENT' }; + const result = { type: 'CREATE_INCREASE' }; + + effects.createEffectWithStore.subscribe(res => { + expect(res).toEqual(result); + }); + + store.dispatch(action); + + store.pipe(select(getCreateDataState)).subscribe(data => { + expect(data).toBe(220); + done(); + }); + }); + }); +}); + +const FEATURE_KEY = 'feature'; + +interface State { + FEATURE_KEY: DataState; +} + +interface DataState { + data: number; + createData: number; +} + +const initialState: DataState = { + data: 100, + createData: 200, +}; + +function reducer(state: DataState = initialState, action: Action) { + switch (action.type) { + case 'INCREASE': + return { + ...state, + data: state.data + 10, + }; + case 'CREATE_INCREASE': + return { + ...state, + createData: state.createData + 20, + }; + } + return state; +} + +const getFeatureState = createFeatureSelector(FEATURE_KEY); + +const getDataState = createSelector(getFeatureState, state => state.data); +const getCreateDataState = createSelector( + getFeatureState, + state => state.createData +); + +@Injectable() +class FeatureEffects { + constructor(private actions: Actions, private store: Store) {} + + @Effect() + effectWithStore = this.actions.pipe( + ofType('INCREMENT'), + withLatestFrom(this.store.select(getDataState)), + map(([action, state]) => ({ type: 'INCREASE' })) + ); + + createEffectWithStore = createEffect(() => + this.actions.pipe( + ofType('CREATE_INCREMENT'), + withLatestFrom(this.store.select(getDataState)), + map(([action, state]) => ({ type: 'CREATE_INCREASE' })) + ) + ); +} + +@NgModule({ + imports: [EffectsModule.forFeature([FeatureEffects])], +}) +class FeatureEffectsModule {} + +@NgModule({ + imports: [FeatureEffectsModule, StoreModule.forFeature(FEATURE_KEY, reducer)], +}) +class FeatureModule {} + +@NgModule({ + imports: [StoreModule.forRoot({}), EffectsModule.forRoot([]), FeatureModule], +}) +class AppModule {} diff --git a/modules/effects/spec/effects_root_module.spec.ts b/modules/effects/spec/effects_root_module.spec.ts index 5b043d6e60..7c1e9d7449 100644 --- a/modules/effects/spec/effects_root_module.spec.ts +++ b/modules/effects/spec/effects_root_module.spec.ts @@ -1,49 +1,49 @@ -import { TestBed } from '@angular/core/testing'; -import { INIT, Store, StoreModule } from '@ngrx/store'; - -import { EffectsModule } from '../src/effects_module'; -import { ROOT_EFFECTS_INIT } from '../src/effects_root_module'; - -describe('Effects Root Module', () => { - const foo = 'foo'; - const reducer = jasmine.createSpy('reducer').and.returnValue(foo); - - beforeEach(() => { - reducer.calls.reset(); - }); - - it('dispatches the root effects init action', () => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({ reducer }, { initialState: { reducer: foo } }), - EffectsModule.forRoot([]), - ], - }); - - const store = TestBed.get(Store); - - expect(reducer).toHaveBeenCalledWith(foo, { - type: INIT, - }); - expect(reducer).toHaveBeenCalledWith(foo, { - type: ROOT_EFFECTS_INIT, - }); - }); - - it(`doesn't dispatch the root effects init action when EffectsModule isn't used`, () => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({ reducer }, { initialState: { reducer: foo } }), - ], - }); - - const store = TestBed.get(Store); - - expect(reducer).toHaveBeenCalledWith(foo, { - type: INIT, - }); - expect(reducer).not.toHaveBeenCalledWith(foo, { - type: ROOT_EFFECTS_INIT, - }); - }); -}); +import { TestBed } from '@angular/core/testing'; +import { INIT, Store, StoreModule } from '@ngrx/store'; + +import { EffectsModule } from '../src/effects_module'; +import { ROOT_EFFECTS_INIT } from '../src/effects_root_module'; + +describe('Effects Root Module', () => { + const foo = 'foo'; + const reducer = jasmine.createSpy('reducer').and.returnValue(foo); + + beforeEach(() => { + reducer.calls.reset(); + }); + + it('dispatches the root effects init action', () => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ reducer }, { initialState: { reducer: foo } }), + EffectsModule.forRoot([]), + ], + }); + + const store = TestBed.inject(Store); + + expect(reducer).toHaveBeenCalledWith(foo, { + type: INIT, + }); + expect(reducer).toHaveBeenCalledWith(foo, { + type: ROOT_EFFECTS_INIT, + }); + }); + + it(`doesn't dispatch the root effects init action when EffectsModule isn't used`, () => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ reducer }, { initialState: { reducer: foo } }), + ], + }); + + const store = TestBed.inject(Store); + + expect(reducer).toHaveBeenCalledWith(foo, { + type: INIT, + }); + expect(reducer).not.toHaveBeenCalledWith(foo, { + type: ROOT_EFFECTS_INIT, + }); + }); +}); diff --git a/modules/effects/spec/integration.spec.ts b/modules/effects/spec/integration.spec.ts index fe58788a17..0a7ee45126 100644 --- a/modules/effects/spec/integration.spec.ts +++ b/modules/effects/spec/integration.spec.ts @@ -1,140 +1,142 @@ -import { NgModuleFactoryLoader, NgModule } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { - RouterTestingModule, - SpyNgModuleFactoryLoader, -} from '@angular/router/testing'; -import { Router } from '@angular/router'; -import { Store, Action } from '@ngrx/store'; -import { - EffectsModule, - OnInitEffects, - ROOT_EFFECTS_INIT, - OnIdentifyEffects, - EffectSources, -} from '..'; - -describe('NgRx Effects Integration spec', () => { - let dispatch: jasmine.Spy; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - EffectsModule.forRoot([ - RootEffectWithInitAction, - RootEffectWithoutLifecycle, - RootEffectWithInitActionWithPayload, - ]), - EffectsModule.forFeature([FeatEffectWithInitAction]), - RouterTestingModule.withRoutes([]), - ], - providers: [ - { - provide: Store, - useValue: { - dispatch: jasmine.createSpy('dispatch'), - }, - }, - ], - }); - - const store = TestBed.get(Store) as Store; - - const effectSources = TestBed.get(EffectSources) as EffectSources; - effectSources.addEffects(new FeatEffectWithIdentifierAndInitAction('one')); - effectSources.addEffects(new FeatEffectWithIdentifierAndInitAction('two')); - effectSources.addEffects(new FeatEffectWithIdentifierAndInitAction('one')); - - dispatch = store.dispatch as jasmine.Spy; - }); - - it('should dispatch init actions in the correct order', () => { - expect(dispatch.calls.count()).toBe(7); - - // All of the root effects init actions are dispatched first - expect(dispatch.calls.argsFor(0)).toEqual([ - { type: '[RootEffectWithInitAction]: INIT' }, - ]); - - expect(dispatch.calls.argsFor(1)).toEqual([new ActionWithPayload()]); - - // After all of the root effects are registered, the ROOT_EFFECTS_INIT action is dispatched - expect(dispatch.calls.argsFor(2)).toEqual([{ type: ROOT_EFFECTS_INIT }]); - - // After the root effects init, the feature effects are dispatched - expect(dispatch.calls.argsFor(3)).toEqual([ - { type: '[FeatEffectWithInitAction]: INIT' }, - ]); - - expect(dispatch.calls.argsFor(4)).toEqual([ - { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }, - ]); - - expect(dispatch.calls.argsFor(5)).toEqual([ - { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }, - ]); - - // While the effect has the same identifier the init effect action is still being dispatched - expect(dispatch.calls.argsFor(6)).toEqual([ - { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }, - ]); - }); - - it('throws if forRoot() is used more than once', (done: DoneFn) => { - let router: Router = TestBed.get(Router); - const loader: SpyNgModuleFactoryLoader = TestBed.get(NgModuleFactoryLoader); - - loader.stubbedModules = { feature: FeatModuleWithForRoot }; - router.resetConfig([{ path: 'feature-path', loadChildren: 'feature' }]); - - router.navigateByUrl('/feature-path').catch((err: TypeError) => { - expect(err.message).toBe( - 'EffectsModule.forRoot() called twice. Feature modules should use EffectsModule.forFeature() instead.' - ); - done(); - }); - }); - - class RootEffectWithInitAction implements OnInitEffects { - ngrxOnInitEffects(): Action { - return { type: '[RootEffectWithInitAction]: INIT' }; - } - } - - class ActionWithPayload implements Action { - readonly type = '[RootEffectWithInitActionWithPayload]: INIT'; - readonly payload = 47; - } - - class RootEffectWithInitActionWithPayload implements OnInitEffects { - ngrxOnInitEffects(): Action { - return new ActionWithPayload(); - } - } - - class RootEffectWithoutLifecycle {} - - class FeatEffectWithInitAction implements OnInitEffects { - ngrxOnInitEffects(): Action { - return { type: '[FeatEffectWithInitAction]: INIT' }; - } - } - - class FeatEffectWithIdentifierAndInitAction - implements OnInitEffects, OnIdentifyEffects { - ngrxOnIdentifyEffects(): string { - return this.effectIdentifier; - } - - ngrxOnInitEffects(): Action { - return { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }; - } - - constructor(private effectIdentifier: string) {} - } - - @NgModule({ - imports: [EffectsModule.forRoot([])], - }) - class FeatModuleWithForRoot {} -}); +import { NgModuleFactoryLoader, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + RouterTestingModule, + SpyNgModuleFactoryLoader, +} from '@angular/router/testing'; +import { Router } from '@angular/router'; +import { Store, Action } from '@ngrx/store'; +import { + EffectsModule, + OnInitEffects, + ROOT_EFFECTS_INIT, + OnIdentifyEffects, + EffectSources, +} from '..'; + +describe('NgRx Effects Integration spec', () => { + let dispatch: jasmine.Spy; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + EffectsModule.forRoot([ + RootEffectWithInitAction, + RootEffectWithoutLifecycle, + RootEffectWithInitActionWithPayload, + ]), + EffectsModule.forFeature([FeatEffectWithInitAction]), + RouterTestingModule.withRoutes([]), + ], + providers: [ + { + provide: Store, + useValue: { + dispatch: jasmine.createSpy('dispatch'), + }, + }, + ], + }); + + const store = TestBed.inject(Store) as Store; + + const effectSources = TestBed.inject(EffectSources) as EffectSources; + effectSources.addEffects(new FeatEffectWithIdentifierAndInitAction('one')); + effectSources.addEffects(new FeatEffectWithIdentifierAndInitAction('two')); + effectSources.addEffects(new FeatEffectWithIdentifierAndInitAction('one')); + + dispatch = store.dispatch as jasmine.Spy; + }); + + it('should dispatch init actions in the correct order', () => { + expect(dispatch.calls.count()).toBe(7); + + // All of the root effects init actions are dispatched first + expect(dispatch.calls.argsFor(0)).toEqual([ + { type: '[RootEffectWithInitAction]: INIT' }, + ]); + + expect(dispatch.calls.argsFor(1)).toEqual([new ActionWithPayload()]); + + // After all of the root effects are registered, the ROOT_EFFECTS_INIT action is dispatched + expect(dispatch.calls.argsFor(2)).toEqual([{ type: ROOT_EFFECTS_INIT }]); + + // After the root effects init, the feature effects are dispatched + expect(dispatch.calls.argsFor(3)).toEqual([ + { type: '[FeatEffectWithInitAction]: INIT' }, + ]); + + expect(dispatch.calls.argsFor(4)).toEqual([ + { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }, + ]); + + expect(dispatch.calls.argsFor(5)).toEqual([ + { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }, + ]); + + // While the effect has the same identifier the init effect action is still being dispatched + expect(dispatch.calls.argsFor(6)).toEqual([ + { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }, + ]); + }); + + it('throws if forRoot() is used more than once', (done: DoneFn) => { + let router: Router = TestBed.inject(Router); + const loader: SpyNgModuleFactoryLoader = TestBed.inject( + NgModuleFactoryLoader + ); + + loader.stubbedModules = { feature: FeatModuleWithForRoot }; + router.resetConfig([{ path: 'feature-path', loadChildren: 'feature' }]); + + router.navigateByUrl('/feature-path').catch((err: TypeError) => { + expect(err.message).toBe( + 'EffectsModule.forRoot() called twice. Feature modules should use EffectsModule.forFeature() instead.' + ); + done(); + }); + }); + + class RootEffectWithInitAction implements OnInitEffects { + ngrxOnInitEffects(): Action { + return { type: '[RootEffectWithInitAction]: INIT' }; + } + } + + class ActionWithPayload implements Action { + readonly type = '[RootEffectWithInitActionWithPayload]: INIT'; + readonly payload = 47; + } + + class RootEffectWithInitActionWithPayload implements OnInitEffects { + ngrxOnInitEffects(): Action { + return new ActionWithPayload(); + } + } + + class RootEffectWithoutLifecycle {} + + class FeatEffectWithInitAction implements OnInitEffects { + ngrxOnInitEffects(): Action { + return { type: '[FeatEffectWithInitAction]: INIT' }; + } + } + + class FeatEffectWithIdentifierAndInitAction + implements OnInitEffects, OnIdentifyEffects { + ngrxOnIdentifyEffects(): string { + return this.effectIdentifier; + } + + ngrxOnInitEffects(): Action { + return { type: '[FeatEffectWithIdentifierAndInitAction]: INIT' }; + } + + constructor(private effectIdentifier: string) {} + } + + @NgModule({ + imports: [EffectsModule.forRoot([])], + }) + class FeatModuleWithForRoot {} +}); diff --git a/modules/router-store/spec/integration.spec.ts b/modules/router-store/spec/integration.spec.ts index 822b907346..e1df35309f 100644 --- a/modules/router-store/spec/integration.spec.ts +++ b/modules/router-store/spec/integration.spec.ts @@ -1,1033 +1,1030 @@ -import { Injectable, ErrorHandler } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { - NavigationEnd, - Router, - RouterStateSnapshot, - NavigationCancel, - NavigationError, - ActivatedRouteSnapshot, -} from '@angular/router'; -import { Store, ScannedActionsSubject } from '@ngrx/store'; -import { filter, first, mapTo, take } from 'rxjs/operators'; - -import { - NavigationActionTiming, - ROUTER_CANCEL, - ROUTER_ERROR, - ROUTER_NAVIGATED, - ROUTER_NAVIGATION, - ROUTER_REQUEST, - RouterAction, - routerReducer, - RouterReducerState, - RouterStateSerializer, - StateKeyOrSelector, -} from '../src'; -import { createTestModule } from './utils'; - -describe('integration spec', () => { - it('should work', (done: any) => { - const reducer = (state: string = '', action: RouterAction) => { - if (action.type === ROUTER_NAVIGATION) { - return action.payload.routerState.url.toString(); - } else { - return state; - } - }; - - createTestModule({ reducers: { reducer } }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - expect(log).toEqual([ - { type: 'store', state: '' }, // init event. has nothing to do with the router - { type: 'store', state: '' }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/' }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/' }, - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/' }, - { type: 'router', event: 'ResolveStart', url: '/' }, - { type: 'router', event: 'ResolveEnd', url: '/' }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/' }, - ]); - }) - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .then(() => { - expect(log).toEqual([ - { type: 'store', state: '/' }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'store', state: '/next' }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - { type: 'store', state: '/next' }, // ROUTER_NAVIGATED event in the store - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/next' }, - ]); - - done(); - }); - }); - - it('should have the routerState in the payload', (done: any) => { - const actionLog: RouterAction[] = []; - const reducer = (state: string = '', action: RouterAction) => { - switch (action.type) { - case ROUTER_CANCEL: - case ROUTER_ERROR: - case ROUTER_NAVIGATED: - case ROUTER_NAVIGATION: - case ROUTER_REQUEST: - actionLog.push(action); - return state; - default: - return state; - } - }; - - createTestModule({ - reducers: { reducer }, - canActivate: ( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot - ) => state.url !== 'next', - }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - const hasRouterState = (action: RouterAction) => - !!action.payload.routerState; - - router - .navigateByUrl('/') - .then(() => { - expect(actionLog.filter(hasRouterState).length).toBe(actionLog.length); - }) - .then(() => { - actionLog.splice(0); - return router.navigateByUrl('next'); - }) - .then(() => { - expect(actionLog.filter(hasRouterState).length).toBe(actionLog.length); - done(); - }); - }); - - xit('should support preventing navigation', (done: any) => { - const reducer = (state: string = '', action: RouterAction) => { - if ( - action.type === ROUTER_NAVIGATION && - action.payload.routerState.url.toString() === '/next' - ) { - throw new Error('You shall not pass!'); - } else { - return state; - } - }; - - createTestModule({ reducers: { reducer } }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .catch(e => { - expect(e.message).toEqual('You shall not pass!'); - expect(log).toEqual([ - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - { type: 'router', event: 'NavigationError', url: '/next' }, - ]); - - done(); - }); - }); - - it('should support rolling back if navigation gets canceled (navigation initialized through router)', (done: any) => { - const reducer = (state: string = '', action: RouterAction): any => { - if (action.type === ROUTER_NAVIGATION) { - return { - url: action.payload.routerState.url.toString(), - lastAction: ROUTER_NAVIGATION, - }; - } else if (action.type === ROUTER_CANCEL) { - return { - url: action.payload.routerState.url.toString(), - storeState: action.payload.storeState.reducer, - lastAction: ROUTER_CANCEL, - }; - } else { - return state; - } - }; - - createTestModule({ - reducers: { reducer, routerReducer }, - canActivate: () => false, - }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .then(r => { - expect(r).toEqual(false); - - expect(log).toEqual([ - { - type: 'store', - state: { url: '/', lastAction: ROUTER_NAVIGATION }, - }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { - type: 'store', - state: { url: '/next', lastAction: ROUTER_NAVIGATION }, - }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - - /* new Router Lifecycle in Angular 4.3 - m */ - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - // { type: 'router', event: 'ResolveStart', url: '/next' }, - // { type: 'router', event: 'ResolveEnd', url: '/next' }, - { - type: 'store', - state: { - url: '/', - lastAction: ROUTER_CANCEL, - storeState: { url: '/', lastAction: ROUTER_NAVIGATION }, - }, - }, - { type: 'action', action: ROUTER_CANCEL }, - { type: 'router', event: 'NavigationCancel', url: '/next' }, - ]); - - done(); - }); - }); - - it('should support rolling back if navigation gets canceled (navigation initialized through store)', (done: any) => { - const CHANGE_ROUTE = 'CHANGE_ROUTE'; - const reducer = ( - state: RouterReducerState, - action: any - ): RouterReducerState => { - if (action.type === CHANGE_ROUTE) { - return { - state: { url: '/next', root: {} }, - navigationId: 123, - }; - } else { - const nextState = routerReducer(state, action); - if (nextState && nextState.state) { - return { - ...nextState, - state: { - ...nextState.state, - root: {} as any, - }, - }; - } - return nextState; - } - }; - - createTestModule({ - reducers: { reducer }, - canActivate: () => false, - config: { stateKey: 'reducer' }, - }); - - const router: Router = TestBed.get(Router); - const store: Store = TestBed.get(Store); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - store.dispatch({ type: CHANGE_ROUTE }); - return waitForNavigation(router, NavigationCancel); - }) - .then(() => { - expect(log).toEqual([ - { type: 'router', event: 'NavigationStart', url: '/next' }, - { - type: 'store', - state: { state: { url: '/next', root: {} }, navigationId: 123 }, - }, - { type: 'action', action: CHANGE_ROUTE }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { - type: 'store', - state: { state: { url: '/', root: {} }, navigationId: 2 }, - }, - { type: 'action', action: ROUTER_CANCEL }, - { type: 'router', event: 'NavigationCancel', url: '/next' }, - ]); - - done(); - }); - }); - - it('should support rolling back if navigation errors (navigation initialized through router)', (done: any) => { - const reducer = (state: string = '', action: RouterAction): any => { - if (action.type === ROUTER_NAVIGATION) { - return { - url: action.payload.routerState.url.toString(), - lastAction: ROUTER_NAVIGATION, - }; - } else if (action.type === ROUTER_ERROR) { - return { - url: action.payload.routerState.url.toString(), - storeState: action.payload.storeState.reducer, - lastAction: ROUTER_ERROR, - }; - } else { - return state; - } - }; - - createTestModule({ - reducers: { reducer, routerReducer }, - canActivate: () => { - throw new Error('BOOM!'); - }, - }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .catch(e => { - expect(e.message).toEqual('BOOM!'); - - expect(log).toEqual([ - { - type: 'store', - state: { url: '/', lastAction: ROUTER_NAVIGATION }, - }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { - type: 'store', - state: { url: '/next', lastAction: ROUTER_NAVIGATION }, - }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - - { - type: 'store', - state: { - url: '/', - lastAction: ROUTER_ERROR, - storeState: { url: '/', lastAction: ROUTER_NAVIGATION }, - }, - }, - { type: 'action', action: ROUTER_ERROR }, - { type: 'router', event: 'NavigationError', url: '/next' }, - ]); - - done(); - }); - }); - - it('should support rolling back if navigation errors and hand error to error handler (navigation initialized through store)', (done: any) => { - const CHANGE_ROUTE = 'CHANGE_ROUTE'; - const reducer = ( - state: RouterReducerState, - action: any - ): RouterReducerState => { - if (action.type === CHANGE_ROUTE) { - return { - state: { url: '/next', root: {} }, - navigationId: 123, - }; - } else { - const nextState = routerReducer(state, action); - if (nextState && nextState.state) { - return { - ...nextState, - state: { - ...nextState.state, - root: {} as any, - }, - }; - } - return nextState; - } - }; - - const routerError = new Error('BOOM!'); - class SilentErrorHandler implements ErrorHandler { - handleError(error: any) { - expect(error).toBe(routerError); - } - } - - createTestModule({ - reducers: { reducer }, - canActivate: () => { - throw routerError; - }, - providers: [{ provide: ErrorHandler, useClass: SilentErrorHandler }], - config: { stateKey: 'reducer' }, - }); - - const router: Router = TestBed.get(Router); - const store: Store = TestBed.get(Store); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - store.dispatch({ type: CHANGE_ROUTE }); - return waitForNavigation(router, NavigationError); - }) - .then(() => { - expect(log).toEqual([ - { type: 'router', event: 'NavigationStart', url: '/next' }, - { - type: 'store', - state: { state: { url: '/next', root: {} }, navigationId: 123 }, - }, - { type: 'action', action: CHANGE_ROUTE }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { - type: 'store', - state: { state: { url: '/', root: {} }, navigationId: 2 }, - }, - { type: 'action', action: ROUTER_ERROR }, - { type: 'router', event: 'NavigationError', url: '/next' }, - ]); - - done(); - }); - }); - - it('should call navigateByUrl when resetting state of the routerReducer', (done: any) => { - const reducer = (state: any, action: RouterAction) => { - const r = routerReducer(state, action); - return r && r.state - ? { url: r.state.url, navigationId: r.navigationId } - : null; - }; - - createTestModule({ reducers: { router: routerReducer, reducer } }); - - const router = TestBed.get(Router); - const store = TestBed.get(Store); - const log = logOfRouterAndActionsAndStore(); - - const routerReducerStates: any[] = []; - store.subscribe((state: any) => { - if (state.router) { - routerReducerStates.push(state.router); - } - }); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .then(() => { - expect(log).toEqual([ - { type: 'store', state: null }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'store', state: { url: '/next', navigationId: 2 } }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - { type: 'store', state: null }, // ROUTER_NAVIGATED event in the store - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/next' }, - ]); - log.splice(0); - - store.dispatch({ - type: ROUTER_NAVIGATION, - payload: { - routerState: routerReducerStates[0].state, - event: { id: routerReducerStates[0].navigationId }, - }, - }); - return waitForNavigation(router); - }) - .then(() => { - expect(log).toEqual([ - { type: 'router', event: 'NavigationStart', url: '/' }, - { type: 'store', state: { url: '/', navigationId: 1 } }, // restored - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/' }, - - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/' }, - { type: 'router', event: 'ResolveStart', url: '/' }, - { type: 'router', event: 'ResolveEnd', url: '/' }, - - { type: 'router', event: 'NavigationEnd', url: '/' }, - ]); - log.splice(0); - }) - .then(() => { - store.dispatch({ - type: ROUTER_NAVIGATION, - payload: { - routerState: routerReducerStates[3].state, - event: { id: routerReducerStates[3].navigationId }, - }, - }); - return waitForNavigation(router); - }) - .then(() => { - expect(log).toEqual([ - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'store', state: { url: '/next', navigationId: 2 } }, // restored - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - - { type: 'router', event: 'NavigationEnd', url: '/next' }, - ]); - done(); - }); - }); - - it('should support cancellation of initial navigation using canLoad guard', (done: any) => { - const reducer = (state: any, action: RouterAction) => { - const r = routerReducer(state, action); - return r && r.state - ? { url: r.state.url, navigationId: r.navigationId } - : null; - }; - - createTestModule({ - reducers: { routerReducer, reducer }, - canLoad: () => false, - }); - - const router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router.navigateByUrl('/load').then((r: boolean) => { - expect(r).toBe(false); - - expect(log).toEqual([ - { type: 'store', state: null }, // initial state - { type: 'store', state: null }, // ROUTER_REQEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/load' }, - { type: 'store', state: { url: '', navigationId: 1 } }, - { type: 'action', action: ROUTER_CANCEL }, - { type: 'router', event: 'NavigationCancel', url: '/load' }, - ]); - done(); - }); - }); - - it('should support cancellation of initial navigation when canLoad guard rejects', (done: any) => { - const reducer = (state: any, action: RouterAction) => { - const r = routerReducer(state, action); - return r && r.state - ? { url: r.state.url, navigationId: r.navigationId } - : null; - }; - - createTestModule({ - reducers: { routerReducer, reducer }, - canLoad: () => Promise.reject('boom'), - }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/load') - .then(() => { - fail(`Shouldn't be called`); - }) - .catch(err => { - expect(err).toBe('boom'); - - expect(log).toEqual([ - { type: 'store', state: null }, // initial state - { type: 'store', state: null }, // ROUTER_REQEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/load' }, - { type: 'store', state: { url: '', navigationId: 1 } }, - { type: 'action', action: ROUTER_ERROR }, - { type: 'router', event: 'NavigationError', url: '/load' }, - ]); - - done(); - }); - }); - - function shouldSupportCustomSerializer( - serializerThroughConfig: boolean, - done: Function - ) { - interface SerializedState { - url: string; - params: any; - } - - const reducer = ( - state: any, - action: RouterAction - ) => { - const r = routerReducer(state, action); - return r && r.state - ? { - url: r.state.url, - navigationId: r.navigationId, - params: r.state.params, - } - : null; - }; - - @Injectable() - class CustomSerializer implements RouterStateSerializer { - constructor(store: Store) { - // Requiring store to test Serializer with injected arguments works. - } - serialize(routerState: RouterStateSnapshot): SerializedState { - const url = `${routerState.url}-custom`; - const params = { test: 1 }; - - return { url, params }; - } - } - - if (serializerThroughConfig) { - createTestModule({ - reducers: { routerReducer, reducer }, - config: { serializer: CustomSerializer }, - }); - } else { - const providers = [ - { provide: RouterStateSerializer, useClass: CustomSerializer }, - ]; - createTestModule({ reducers: { routerReducer, reducer }, providers }); - } - - const router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .then(() => { - expect(log).toEqual([ - { type: 'store', state: null }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { - type: 'store', - state: { - url: '/next-custom', - navigationId: 2, - params: { test: 1 }, - }, - }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - { type: 'store', state: null }, // ROUTER_NAVIGATED event in the store - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/next' }, - ]); - log.splice(0); - done(); - }); - } - - it('should support a custom RouterStateSnapshot serializer via provider', (done: any) => { - shouldSupportCustomSerializer(false, done); - }); - - it('should support a custom RouterStateSnapshot serializer via config', (done: any) => { - shouldSupportCustomSerializer(true, done); - }); - - it('should support event during an async canActivate guard', (done: any) => { - createTestModule({ - reducers: { routerReducer }, - canActivate: () => { - store.dispatch({ type: 'USER_EVENT' }); - return store.pipe( - take(1), - mapTo(true) - ); - }, - }); - - const router: Router = TestBed.get(Router); - const store: Store = TestBed.get(Store); - const log = logOfRouterAndActionsAndStore(); - - router - .navigateByUrl('/') - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .then(() => { - expect(log).toEqual([ - { type: 'store', state: undefined }, // after ROUTER_REQUEST - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'store', state: undefined }, // after ROUTER_NAVIGATION - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'store', state: undefined }, // after USER_EVENT - { type: 'action', action: 'USER_EVENT' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - { type: 'store', state: undefined }, // after ROUTER_NAVIGATED - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/next' }, - ]); - - done(); - }); - }); - - it('should work when defining state key', (done: any) => { - const reducer = (state: string = '', action: RouterAction) => { - if (action.type === ROUTER_NAVIGATION) { - return action.payload.routerState.url.toString(); - } else { - return state; - } - }; - - createTestModule({ - reducers: { 'router-reducer': reducer }, - config: { stateKey: 'router-reducer' }, - }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore({ stateKey: 'router-reducer' }); - - router - .navigateByUrl('/') - .then(() => { - expect(log).toEqual([ - { type: 'store', state: '' }, // init event. has nothing to do with the router - { type: 'store', state: '' }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/' }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/' }, - { type: 'router', event: 'GuardsCheckStart', url: '/' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/' }, - { type: 'router', event: 'ResolveStart', url: '/' }, - { type: 'router', event: 'ResolveEnd', url: '/' }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/' }, - ]); - }) - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .then(() => { - expect(log).toEqual([ - { type: 'store', state: '/' }, - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'store', state: '/next' }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - { type: 'store', state: '/next' }, - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/next' }, - ]); - - done(); - }); - }); - - it('should work when defining state selector', (done: any) => { - const reducer = (state: string = '', action: RouterAction) => { - if (action.type === ROUTER_NAVIGATION) { - return action.payload.routerState.url.toString(); - } else { - return state; - } - }; - - createTestModule({ - reducers: { routerReducer: reducer }, - config: { stateKey: (state: any) => state.routerReducer }, - }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore({ - stateKey: (state: any) => state.routerReducer, - }); - - router - .navigateByUrl('/') - .then(() => { - expect(log).toEqual([ - { type: 'store', state: '' }, // init event. has nothing to do with the router - { type: 'store', state: '' }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/' }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/' }, - { type: 'router', event: 'GuardsCheckStart', url: '/' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/' }, - { type: 'router', event: 'ResolveStart', url: '/' }, - { type: 'router', event: 'ResolveEnd', url: '/' }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/' }, - ]); - }) - .then(() => { - log.splice(0); - return router.navigateByUrl('next'); - }) - .then(() => { - expect(log).toEqual([ - { type: 'store', state: '/' }, - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'store', state: '/next' }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - { type: 'store', state: '/next' }, - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/next' }, - ]); - - done(); - }); - }); - - it('should continue to react to navigation after state initiates router change', (done: Function) => { - const reducer = (state: any = { state: { url: '/' } }, action: any) => { - if (action.type === ROUTER_NAVIGATION) { - return { state: { url: action.payload.routerState.url.toString() } }; - } else { - return state; - } - }; - - createTestModule({ - reducers: { reducer }, - config: { stateKey: 'reducer' }, - }); - - const router: Router = TestBed.get(Router); - const store = TestBed.get(Store); - const log = logOfRouterAndActionsAndStore(); - - store.dispatch({ - type: ROUTER_NAVIGATION, - payload: { routerState: { url: '/next' } }, - }); - waitForNavigation(router) - .then(() => { - router.navigate(['/']); - return waitForNavigation(router); - }) - .then(() => { - expect(log).toEqual([ - { type: 'store', state: { state: { url: '/' } } }, - { type: 'router', event: 'NavigationStart', url: '/next' }, - { type: 'store', state: { state: { url: '/next' } } }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/next' }, - { type: 'router', event: 'GuardsCheckStart', url: '/next' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, - { type: 'router', event: 'ResolveStart', url: '/next' }, - { type: 'router', event: 'ResolveEnd', url: '/next' }, - { type: 'router', event: 'NavigationEnd', url: '/next' }, - { type: 'store', state: { state: { url: '/next' } } }, - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/' }, - { type: 'store', state: { state: { url: '/' } } }, - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'router', event: 'RoutesRecognized', url: '/' }, - { type: 'router', event: 'GuardsCheckStart', url: '/' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/' }, - { type: 'router', event: 'ResolveStart', url: '/' }, - { type: 'router', event: 'ResolveEnd', url: '/' }, - { type: 'store', state: { state: { url: '/' } } }, - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/' }, - ]); - done(); - }); - }); - - it('should dispatch ROUTER_NAVIGATION later when config options set to true', () => { - const reducer = (state: string = '', action: RouterAction) => { - if (action.type === ROUTER_NAVIGATION) { - return action.payload.routerState.url.toString(); - } else { - return state; - } - }; - - createTestModule({ - reducers: { reducer }, - config: { navigationActionTiming: NavigationActionTiming.PostActivation }, - }); - - const router: Router = TestBed.get(Router); - const log = logOfRouterAndActionsAndStore(); - - router.navigateByUrl('/').then(() => { - expect(log).toEqual([ - { type: 'store', state: '' }, // init event. has nothing to do with the router - { type: 'store', state: '' }, // ROUTER_REQUEST event in the store - { type: 'action', action: ROUTER_REQUEST }, - { type: 'router', event: 'NavigationStart', url: '/' }, - { type: 'router', event: 'RoutesRecognized', url: '/' }, - /* new Router Lifecycle in Angular 4.3 */ - { type: 'router', event: 'GuardsCheckStart', url: '/' }, - { type: 'router', event: 'GuardsCheckEnd', url: '/' }, - { type: 'router', event: 'ResolveStart', url: '/' }, - { type: 'router', event: 'ResolveEnd', url: '/' }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store - { type: 'action', action: ROUTER_NAVIGATION }, - { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store - { type: 'action', action: ROUTER_NAVIGATED }, - { type: 'router', event: 'NavigationEnd', url: '/' }, - ]); - }); - }); -}); - -function waitForNavigation(router: Router, event: any = NavigationEnd) { - return router.events - .pipe( - filter(e => e instanceof event), - first() - ) - .toPromise(); -} - -/** - * Logs the events of router, store and actions$. - * Note: Because of the synchronous nature of many of those events, it may sometimes - * appear that the order is "mixed" up even if its correct. - * Example: router event is fired -> store is updated -> store log appears before router log - * Also, actions$ always fires the next action AFTER the store is updated - */ -function logOfRouterAndActionsAndStore( - options: { stateKey: StateKeyOrSelector } = { - stateKey: 'reducer', - } -): any[] { - const router: Router = TestBed.get(Router); - const store: Store = TestBed.get(Store); - // Not using effects' Actions to avoid @ngrx/effects dependency - const actions$: ScannedActionsSubject = TestBed.get(ScannedActionsSubject); - const log: any[] = []; - router.events.subscribe(e => { - if (e.hasOwnProperty('url')) { - log.push({ - type: 'router', - event: e.constructor.name, - url: (e).url.toString(), - }); - } - }); - actions$.subscribe(action => - log.push({ type: 'action', action: action.type }) - ); - store.subscribe(store => { - if (typeof options.stateKey === 'function') { - log.push({ type: 'store', state: options.stateKey(store) }); - } else { - log.push({ type: 'store', state: store[options.stateKey] }); - } - }); - return log; -} +import { Injectable, ErrorHandler } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + NavigationEnd, + Router, + RouterStateSnapshot, + NavigationCancel, + NavigationError, + ActivatedRouteSnapshot, +} from '@angular/router'; +import { Store, ScannedActionsSubject } from '@ngrx/store'; +import { filter, first, mapTo, take } from 'rxjs/operators'; + +import { + NavigationActionTiming, + ROUTER_CANCEL, + ROUTER_ERROR, + ROUTER_NAVIGATED, + ROUTER_NAVIGATION, + ROUTER_REQUEST, + RouterAction, + routerReducer, + RouterReducerState, + RouterStateSerializer, + StateKeyOrSelector, +} from '../src'; +import { createTestModule } from './utils'; + +describe('integration spec', () => { + it('should work', (done: any) => { + const reducer = (state: string = '', action: RouterAction) => { + if (action.type === ROUTER_NAVIGATION) { + return action.payload.routerState.url.toString(); + } else { + return state; + } + }; + + createTestModule({ reducers: { reducer } }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '' }, // init event. has nothing to do with the router + { type: 'store', state: '' }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/' }, + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/' }, + { type: 'router', event: 'ResolveStart', url: '/' }, + { type: 'router', event: 'ResolveEnd', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/' }, + ]); + }) + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '/' }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: '/next' }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'store', state: '/next' }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + + done(); + }); + }); + + it('should have the routerState in the payload', (done: any) => { + const actionLog: RouterAction[] = []; + const reducer = (state: string = '', action: RouterAction) => { + switch (action.type) { + case ROUTER_CANCEL: + case ROUTER_ERROR: + case ROUTER_NAVIGATED: + case ROUTER_NAVIGATION: + case ROUTER_REQUEST: + actionLog.push(action); + return state; + default: + return state; + } + }; + + createTestModule({ + reducers: { reducer }, + canActivate: ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ) => state.url !== 'next', + }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + const hasRouterState = (action: RouterAction) => + !!action.payload.routerState; + + router + .navigateByUrl('/') + .then(() => { + expect(actionLog.filter(hasRouterState).length).toBe(actionLog.length); + }) + .then(() => { + actionLog.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(actionLog.filter(hasRouterState).length).toBe(actionLog.length); + done(); + }); + }); + + xit('should support preventing navigation', (done: any) => { + const reducer = (state: string = '', action: RouterAction) => { + if ( + action.type === ROUTER_NAVIGATION && + action.payload.routerState.url.toString() === '/next' + ) { + throw new Error('You shall not pass!'); + } else { + return state; + } + }; + + createTestModule({ reducers: { reducer } }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .catch(e => { + expect(e.message).toEqual('You shall not pass!'); + expect(log).toEqual([ + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'router', event: 'NavigationError', url: '/next' }, + ]); + + done(); + }); + }); + + it('should support rolling back if navigation gets canceled (navigation initialized through router)', (done: any) => { + const reducer = (state: string = '', action: RouterAction): any => { + if (action.type === ROUTER_NAVIGATION) { + return { + url: action.payload.routerState.url.toString(), + lastAction: ROUTER_NAVIGATION, + }; + } else if (action.type === ROUTER_CANCEL) { + return { + url: action.payload.routerState.url.toString(), + storeState: action.payload.storeState.reducer, + lastAction: ROUTER_CANCEL, + }; + } else { + return state; + } + }; + + createTestModule({ + reducers: { reducer, routerReducer }, + canActivate: () => false, + }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(r => { + expect(r).toEqual(false); + + expect(log).toEqual([ + { + type: 'store', + state: { url: '/', lastAction: ROUTER_NAVIGATION }, + }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { + type: 'store', + state: { url: '/next', lastAction: ROUTER_NAVIGATION }, + }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + + /* new Router Lifecycle in Angular 4.3 - m */ + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + // { type: 'router', event: 'ResolveStart', url: '/next' }, + // { type: 'router', event: 'ResolveEnd', url: '/next' }, + { + type: 'store', + state: { + url: '/', + lastAction: ROUTER_CANCEL, + storeState: { url: '/', lastAction: ROUTER_NAVIGATION }, + }, + }, + { type: 'action', action: ROUTER_CANCEL }, + { type: 'router', event: 'NavigationCancel', url: '/next' }, + ]); + + done(); + }); + }); + + it('should support rolling back if navigation gets canceled (navigation initialized through store)', (done: any) => { + const CHANGE_ROUTE = 'CHANGE_ROUTE'; + const reducer = ( + state: RouterReducerState, + action: any + ): RouterReducerState => { + if (action.type === CHANGE_ROUTE) { + return { + state: { url: '/next', root: {} }, + navigationId: 123, + }; + } else { + const nextState = routerReducer(state, action); + if (nextState && nextState.state) { + return { + ...nextState, + state: { + ...nextState.state, + root: {} as any, + }, + }; + } + return nextState; + } + }; + + createTestModule({ + reducers: { reducer }, + canActivate: () => false, + config: { stateKey: 'reducer' }, + }); + + const router: Router = TestBed.inject(Router); + const store: Store = TestBed.inject(Store); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + store.dispatch({ type: CHANGE_ROUTE }); + return waitForNavigation(router, NavigationCancel); + }) + .then(() => { + expect(log).toEqual([ + { type: 'router', event: 'NavigationStart', url: '/next' }, + { + type: 'store', + state: { state: { url: '/next', root: {} }, navigationId: 123 }, + }, + { type: 'action', action: CHANGE_ROUTE }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { + type: 'store', + state: { state: { url: '/', root: {} }, navigationId: 2 }, + }, + { type: 'action', action: ROUTER_CANCEL }, + { type: 'router', event: 'NavigationCancel', url: '/next' }, + ]); + + done(); + }); + }); + + it('should support rolling back if navigation errors (navigation initialized through router)', (done: any) => { + const reducer = (state: string = '', action: RouterAction): any => { + if (action.type === ROUTER_NAVIGATION) { + return { + url: action.payload.routerState.url.toString(), + lastAction: ROUTER_NAVIGATION, + }; + } else if (action.type === ROUTER_ERROR) { + return { + url: action.payload.routerState.url.toString(), + storeState: action.payload.storeState.reducer, + lastAction: ROUTER_ERROR, + }; + } else { + return state; + } + }; + + createTestModule({ + reducers: { reducer, routerReducer }, + canActivate: () => { + throw new Error('BOOM!'); + }, + }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .catch(e => { + expect(e.message).toEqual('BOOM!'); + + expect(log).toEqual([ + { + type: 'store', + state: { url: '/', lastAction: ROUTER_NAVIGATION }, + }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { + type: 'store', + state: { url: '/next', lastAction: ROUTER_NAVIGATION }, + }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + + { + type: 'store', + state: { + url: '/', + lastAction: ROUTER_ERROR, + storeState: { url: '/', lastAction: ROUTER_NAVIGATION }, + }, + }, + { type: 'action', action: ROUTER_ERROR }, + { type: 'router', event: 'NavigationError', url: '/next' }, + ]); + + done(); + }); + }); + + it('should support rolling back if navigation errors and hand error to error handler (navigation initialized through store)', (done: any) => { + const CHANGE_ROUTE = 'CHANGE_ROUTE'; + const reducer = ( + state: RouterReducerState, + action: any + ): RouterReducerState => { + if (action.type === CHANGE_ROUTE) { + return { + state: { url: '/next', root: {} }, + navigationId: 123, + }; + } else { + const nextState = routerReducer(state, action); + if (nextState && nextState.state) { + return { + ...nextState, + state: { + ...nextState.state, + root: {} as any, + }, + }; + } + return nextState; + } + }; + + const routerError = new Error('BOOM!'); + class SilentErrorHandler implements ErrorHandler { + handleError(error: any) { + expect(error).toBe(routerError); + } + } + + createTestModule({ + reducers: { reducer }, + canActivate: () => { + throw routerError; + }, + providers: [{ provide: ErrorHandler, useClass: SilentErrorHandler }], + config: { stateKey: 'reducer' }, + }); + + const router: Router = TestBed.inject(Router); + const store: Store = TestBed.inject(Store); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + store.dispatch({ type: CHANGE_ROUTE }); + return waitForNavigation(router, NavigationError); + }) + .then(() => { + expect(log).toEqual([ + { type: 'router', event: 'NavigationStart', url: '/next' }, + { + type: 'store', + state: { state: { url: '/next', root: {} }, navigationId: 123 }, + }, + { type: 'action', action: CHANGE_ROUTE }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { + type: 'store', + state: { state: { url: '/', root: {} }, navigationId: 2 }, + }, + { type: 'action', action: ROUTER_ERROR }, + { type: 'router', event: 'NavigationError', url: '/next' }, + ]); + + done(); + }); + }); + + it('should call navigateByUrl when resetting state of the routerReducer', (done: any) => { + const reducer = (state: any, action: RouterAction) => { + const r = routerReducer(state, action); + return r && r.state + ? { url: r.state.url, navigationId: r.navigationId } + : null; + }; + + createTestModule({ reducers: { router: routerReducer, reducer } }); + + const router = TestBed.inject(Router); + const store = TestBed.inject(Store); + const log = logOfRouterAndActionsAndStore(); + + const routerReducerStates: any[] = []; + store.subscribe((state: any) => { + if (state.router) { + routerReducerStates.push(state.router); + } + }); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: null }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: { url: '/next', navigationId: 2 } }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'store', state: null }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + log.splice(0); + + store.dispatch({ + type: ROUTER_NAVIGATION, + payload: { + routerState: routerReducerStates[0].state, + event: { id: routerReducerStates[0].navigationId }, + }, + }); + return waitForNavigation(router); + }) + .then(() => { + expect(log).toEqual([ + { type: 'router', event: 'NavigationStart', url: '/' }, + { type: 'store', state: { url: '/', navigationId: 1 } }, // restored + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/' }, + + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/' }, + { type: 'router', event: 'ResolveStart', url: '/' }, + { type: 'router', event: 'ResolveEnd', url: '/' }, + + { type: 'router', event: 'NavigationEnd', url: '/' }, + ]); + log.splice(0); + }) + .then(() => { + store.dispatch({ + type: ROUTER_NAVIGATION, + payload: { + routerState: routerReducerStates[3].state, + event: { id: routerReducerStates[3].navigationId }, + }, + }); + return waitForNavigation(router); + }) + .then(() => { + expect(log).toEqual([ + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: { url: '/next', navigationId: 2 } }, // restored + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + done(); + }); + }); + + it('should support cancellation of initial navigation using canLoad guard', (done: any) => { + const reducer = (state: any, action: RouterAction) => { + const r = routerReducer(state, action); + return r && r.state + ? { url: r.state.url, navigationId: r.navigationId } + : null; + }; + + createTestModule({ + reducers: { routerReducer, reducer }, + canLoad: () => false, + }); + + const router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router.navigateByUrl('/load').then((r: boolean) => { + expect(r).toBe(false); + + expect(log).toEqual([ + { type: 'store', state: null }, // initial state + { type: 'store', state: null }, // ROUTER_REQEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/load' }, + { type: 'store', state: { url: '', navigationId: 1 } }, + { type: 'action', action: ROUTER_CANCEL }, + { type: 'router', event: 'NavigationCancel', url: '/load' }, + ]); + done(); + }); + }); + + it('should support cancellation of initial navigation when canLoad guard rejects', (done: any) => { + const reducer = (state: any, action: RouterAction) => { + const r = routerReducer(state, action); + return r && r.state + ? { url: r.state.url, navigationId: r.navigationId } + : null; + }; + + createTestModule({ + reducers: { routerReducer, reducer }, + canLoad: () => Promise.reject('boom'), + }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/load') + .then(() => { + fail(`Shouldn't be called`); + }) + .catch(err => { + expect(err).toBe('boom'); + + expect(log).toEqual([ + { type: 'store', state: null }, // initial state + { type: 'store', state: null }, // ROUTER_REQEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/load' }, + { type: 'store', state: { url: '', navigationId: 1 } }, + { type: 'action', action: ROUTER_ERROR }, + { type: 'router', event: 'NavigationError', url: '/load' }, + ]); + + done(); + }); + }); + + function shouldSupportCustomSerializer( + serializerThroughConfig: boolean, + done: Function + ) { + interface SerializedState { + url: string; + params: any; + } + + const reducer = ( + state: any, + action: RouterAction + ) => { + const r = routerReducer(state, action); + return r && r.state + ? { + url: r.state.url, + navigationId: r.navigationId, + params: r.state.params, + } + : null; + }; + + @Injectable() + class CustomSerializer implements RouterStateSerializer { + constructor(store: Store) { + // Requiring store to test Serializer with injected arguments works. + } + serialize(routerState: RouterStateSnapshot): SerializedState { + const url = `${routerState.url}-custom`; + const params = { test: 1 }; + + return { url, params }; + } + } + + if (serializerThroughConfig) { + createTestModule({ + reducers: { routerReducer, reducer }, + config: { serializer: CustomSerializer }, + }); + } else { + const providers = [ + { provide: RouterStateSerializer, useClass: CustomSerializer }, + ]; + createTestModule({ reducers: { routerReducer, reducer }, providers }); + } + + const router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: null }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { + type: 'store', + state: { + url: '/next-custom', + navigationId: 2, + params: { test: 1 }, + }, + }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'store', state: null }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + log.splice(0); + done(); + }); + } + + it('should support a custom RouterStateSnapshot serializer via provider', (done: any) => { + shouldSupportCustomSerializer(false, done); + }); + + it('should support a custom RouterStateSnapshot serializer via config', (done: any) => { + shouldSupportCustomSerializer(true, done); + }); + + it('should support event during an async canActivate guard', (done: any) => { + createTestModule({ + reducers: { routerReducer }, + canActivate: () => { + store.dispatch({ type: 'USER_EVENT' }); + return store.pipe(take(1), mapTo(true)); + }, + }); + + const router: Router = TestBed.inject(Router); + const store: Store = TestBed.inject(Store); + const log = logOfRouterAndActionsAndStore(); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: undefined }, // after ROUTER_REQUEST + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: undefined }, // after ROUTER_NAVIGATION + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'store', state: undefined }, // after USER_EVENT + { type: 'action', action: 'USER_EVENT' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'store', state: undefined }, // after ROUTER_NAVIGATED + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + + done(); + }); + }); + + it('should work when defining state key', (done: any) => { + const reducer = (state: string = '', action: RouterAction) => { + if (action.type === ROUTER_NAVIGATION) { + return action.payload.routerState.url.toString(); + } else { + return state; + } + }; + + createTestModule({ + reducers: { 'router-reducer': reducer }, + config: { stateKey: 'router-reducer' }, + }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore({ stateKey: 'router-reducer' }); + + router + .navigateByUrl('/') + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '' }, // init event. has nothing to do with the router + { type: 'store', state: '' }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/' }, + { type: 'router', event: 'GuardsCheckStart', url: '/' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/' }, + { type: 'router', event: 'ResolveStart', url: '/' }, + { type: 'router', event: 'ResolveEnd', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/' }, + ]); + }) + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '/' }, + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: '/next' }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'store', state: '/next' }, + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + + done(); + }); + }); + + it('should work when defining state selector', (done: any) => { + const reducer = (state: string = '', action: RouterAction) => { + if (action.type === ROUTER_NAVIGATION) { + return action.payload.routerState.url.toString(); + } else { + return state; + } + }; + + createTestModule({ + reducers: { routerReducer: reducer }, + config: { stateKey: (state: any) => state.routerReducer }, + }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore({ + stateKey: (state: any) => state.routerReducer, + }); + + router + .navigateByUrl('/') + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '' }, // init event. has nothing to do with the router + { type: 'store', state: '' }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/' }, + { type: 'router', event: 'GuardsCheckStart', url: '/' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/' }, + { type: 'router', event: 'ResolveStart', url: '/' }, + { type: 'router', event: 'ResolveEnd', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/' }, + ]); + }) + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: '/' }, + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: '/next' }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'store', state: '/next' }, + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + + done(); + }); + }); + + it('should continue to react to navigation after state initiates router change', (done: Function) => { + const reducer = (state: any = { state: { url: '/' } }, action: any) => { + if (action.type === ROUTER_NAVIGATION) { + return { state: { url: action.payload.routerState.url.toString() } }; + } else { + return state; + } + }; + + createTestModule({ + reducers: { reducer }, + config: { stateKey: 'reducer' }, + }); + + const router: Router = TestBed.inject(Router); + const store = TestBed.inject(Store); + const log = logOfRouterAndActionsAndStore(); + + store.dispatch({ + type: ROUTER_NAVIGATION, + payload: { routerState: { url: '/next' } }, + }); + waitForNavigation(router) + .then(() => { + router.navigate(['/']); + return waitForNavigation(router); + }) + .then(() => { + expect(log).toEqual([ + { type: 'store', state: { state: { url: '/' } } }, + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'store', state: { state: { url: '/next' } } }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'router', event: 'GuardsCheckStart', url: '/next' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/next' }, + { type: 'router', event: 'ResolveStart', url: '/next' }, + { type: 'router', event: 'ResolveEnd', url: '/next' }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + { type: 'store', state: { state: { url: '/next' } } }, + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/' }, + { type: 'store', state: { state: { url: '/' } } }, + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'router', event: 'RoutesRecognized', url: '/' }, + { type: 'router', event: 'GuardsCheckStart', url: '/' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/' }, + { type: 'router', event: 'ResolveStart', url: '/' }, + { type: 'router', event: 'ResolveEnd', url: '/' }, + { type: 'store', state: { state: { url: '/' } } }, + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/' }, + ]); + done(); + }); + }); + + it('should dispatch ROUTER_NAVIGATION later when config options set to true', () => { + const reducer = (state: string = '', action: RouterAction) => { + if (action.type === ROUTER_NAVIGATION) { + return action.payload.routerState.url.toString(); + } else { + return state; + } + }; + + createTestModule({ + reducers: { reducer }, + config: { navigationActionTiming: NavigationActionTiming.PostActivation }, + }); + + const router: Router = TestBed.inject(Router); + const log = logOfRouterAndActionsAndStore(); + + router.navigateByUrl('/').then(() => { + expect(log).toEqual([ + { type: 'store', state: '' }, // init event. has nothing to do with the router + { type: 'store', state: '' }, // ROUTER_REQUEST event in the store + { type: 'action', action: ROUTER_REQUEST }, + { type: 'router', event: 'NavigationStart', url: '/' }, + { type: 'router', event: 'RoutesRecognized', url: '/' }, + /* new Router Lifecycle in Angular 4.3 */ + { type: 'router', event: 'GuardsCheckStart', url: '/' }, + { type: 'router', event: 'GuardsCheckEnd', url: '/' }, + { type: 'router', event: 'ResolveStart', url: '/' }, + { type: 'router', event: 'ResolveEnd', url: '/' }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATION event in the store + { type: 'action', action: ROUTER_NAVIGATION }, + { type: 'store', state: '/' }, // ROUTER_NAVIGATED event in the store + { type: 'action', action: ROUTER_NAVIGATED }, + { type: 'router', event: 'NavigationEnd', url: '/' }, + ]); + }); + }); +}); + +function waitForNavigation(router: Router, event: any = NavigationEnd) { + return router.events + .pipe( + filter(e => e instanceof event), + first() + ) + .toPromise(); +} + +/** + * Logs the events of router, store and actions$. + * Note: Because of the synchronous nature of many of those events, it may sometimes + * appear that the order is "mixed" up even if its correct. + * Example: router event is fired -> store is updated -> store log appears before router log + * Also, actions$ always fires the next action AFTER the store is updated + */ +function logOfRouterAndActionsAndStore( + options: { stateKey: StateKeyOrSelector } = { + stateKey: 'reducer', + } +): any[] { + const router: Router = TestBed.inject(Router); + const store: Store = TestBed.inject(Store); + // Not using effects' Actions to avoid @ngrx/effects dependency + const actions$: ScannedActionsSubject = TestBed.inject(ScannedActionsSubject); + const log: any[] = []; + router.events.subscribe(e => { + if (e.hasOwnProperty('url')) { + log.push({ + type: 'router', + event: e.constructor.name, + url: (e).url.toString(), + }); + } + }); + actions$.subscribe(action => + log.push({ type: 'action', action: action.type }) + ); + store.subscribe(store => { + if (typeof options.stateKey === 'function') { + log.push({ type: 'store', state: options.stateKey(store) }); + } else { + log.push({ type: 'store', state: store[options.stateKey] }); + } + }); + return log; +} diff --git a/modules/router-store/spec/router_store_module.spec.ts b/modules/router-store/spec/router_store_module.spec.ts index 0dc0bfd6b1..253e4d42c4 100644 --- a/modules/router-store/spec/router_store_module.spec.ts +++ b/modules/router-store/spec/router_store_module.spec.ts @@ -1,216 +1,212 @@ -import { TestBed } from '@angular/core/testing'; -import { Router, RouterEvent } from '@angular/router'; -import { - routerReducer, - RouterReducerState, - StoreRouterConnectingModule, - RouterAction, - RouterState, - RouterStateSerializer, - MinimalRouterStateSerializer, - DefaultRouterStateSerializer, -} from '@ngrx/router-store'; -import { select, Store, ActionsSubject } from '@ngrx/store'; -import { withLatestFrom, filter } from 'rxjs/operators'; - -import { createTestModule } from './utils'; - -describe('Router Store Module', () => { - describe('with defining state key', () => { - const customStateKey = 'router-reducer'; - let storeRouterConnectingModule: StoreRouterConnectingModule; - let store: Store; - let router: Router; - - interface State { - [customStateKey]: RouterReducerState; - } - - beforeEach(() => { - createTestModule({ - reducers: { - [customStateKey]: routerReducer, - }, - config: { - stateKey: customStateKey, - }, - }); - - store = TestBed.get(Store); - router = TestBed.get(Router); - storeRouterConnectingModule = TestBed.get(StoreRouterConnectingModule); - }); - - it('should have custom state key as own property', () => { - expect((storeRouterConnectingModule).stateKey).toBe(customStateKey); - }); - - it('should call navigateIfNeeded with args selected by custom state key', (done: any) => { - let logs: any[] = []; - store - .pipe( - select(customStateKey), - withLatestFrom(store) - ) - .subscribe(([routerStoreState, storeState]) => { - logs.push([routerStoreState, storeState]); - }); - - spyOn( - storeRouterConnectingModule, - 'navigateIfNeeded' as never - ).and.callThrough(); - logs = []; - - // this dispatches `@ngrx/router-store/navigation` action - // and store emits its payload. - router.navigateByUrl('/').then(() => { - const actual = (( - storeRouterConnectingModule - )).navigateIfNeeded.calls.allArgs(); - - expect(actual.length).toBe(1); - expect(actual[0]).toEqual(logs[0]); - done(); - }); - }); - }); - - describe('with defining state selector', () => { - const customStateKey = 'routerReducer'; - const customStateSelector = (state: State) => state.routerReducer; - - let storeRouterConnectingModule: StoreRouterConnectingModule; - let store: Store; - let router: Router; - - interface State { - [customStateKey]: RouterReducerState; - } - - beforeEach(() => { - createTestModule({ - reducers: { - [customStateKey]: routerReducer, - }, - config: { - stateKey: customStateSelector, - }, - }); - - store = TestBed.get(Store); - router = TestBed.get(Router); - storeRouterConnectingModule = TestBed.get(StoreRouterConnectingModule); - }); - - it('should have same state selector as own property', () => { - expect((storeRouterConnectingModule).stateKey).toBe( - customStateSelector - ); - }); - - it('should call navigateIfNeeded with args selected by custom state selector', (done: any) => { - let logs: any[] = []; - store - .pipe( - select(customStateSelector), - withLatestFrom(store) - ) - .subscribe(([routerStoreState, storeState]) => { - logs.push([routerStoreState, storeState]); - }); - - spyOn( - storeRouterConnectingModule, - 'navigateIfNeeded' as never - ).and.callThrough(); - logs = []; - - // this dispatches `@ngrx/router-store/navigation` action - // and store emits its payload. - router.navigateByUrl('/').then(() => { - const actual = (( - storeRouterConnectingModule - )).navigateIfNeeded.calls.allArgs(); - - expect(actual.length).toBe(1); - expect(actual[0]).toEqual(logs[0]); - done(); - }); - }); - }); - - describe('routerState', () => { - function setup(routerState: RouterState, serializer?: any) { - createTestModule({ - reducers: {}, - config: { - routerState, - serializer, - }, - }); - - return { - actions: TestBed.get(ActionsSubject) as ActionsSubject, - router: TestBed.get(Router) as Router, - serializer: TestBed.get(RouterStateSerializer) as RouterStateSerializer, - }; - } - - const onlyRouterActions = (a: any): a is RouterAction => - a.payload && a.payload.event; - - describe('Full', () => { - it('should dispatch the full event', async () => { - const { actions, router } = setup(RouterState.Full); - actions - .pipe(filter(onlyRouterActions)) - .subscribe(({ payload }) => - expect(payload.event instanceof RouterEvent).toBe(true) - ); - - await router.navigateByUrl('/'); - }); - - it('should use the default router serializer', () => { - const { serializer } = setup(RouterState.Full); - expect(serializer).toEqual(new DefaultRouterStateSerializer()); - }); - - it('should use the provided serializer if one is provided', () => { - const { serializer } = setup( - RouterState.Full, - MinimalRouterStateSerializer - ); - expect(serializer).toEqual(new MinimalRouterStateSerializer()); - }); - }); - - describe('Minimal', () => { - it('should dispatch the navigation id with url', async () => { - const { actions, router } = setup(RouterState.Minimal); - actions - .pipe(filter(onlyRouterActions)) - .subscribe(({ payload }: any) => { - expect(payload.event instanceof RouterEvent).toBe(false); - expect(payload.event).toEqual({ id: 1, url: '/' }); - }); - - await router.navigateByUrl('/'); - }); - - it('should use the minimal router serializer', () => { - const { serializer } = setup(RouterState.Minimal); - expect(serializer).toEqual(new MinimalRouterStateSerializer()); - }); - - it('should use the provided serializer if one is provided', () => { - const { serializer } = setup( - RouterState.Minimal, - DefaultRouterStateSerializer - ); - expect(serializer).toEqual(new DefaultRouterStateSerializer()); - }); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; +import { Router, RouterEvent } from '@angular/router'; +import { + routerReducer, + RouterReducerState, + StoreRouterConnectingModule, + RouterAction, + RouterState, + RouterStateSerializer, + MinimalRouterStateSerializer, + DefaultRouterStateSerializer, +} from '@ngrx/router-store'; +import { select, Store, ActionsSubject } from '@ngrx/store'; +import { withLatestFrom, filter } from 'rxjs/operators'; + +import { createTestModule } from './utils'; + +describe('Router Store Module', () => { + describe('with defining state key', () => { + const customStateKey = 'router-reducer'; + let storeRouterConnectingModule: StoreRouterConnectingModule; + let store: Store; + let router: Router; + + interface State { + [customStateKey]: RouterReducerState; + } + + beforeEach(() => { + createTestModule({ + reducers: { + [customStateKey]: routerReducer, + }, + config: { + stateKey: customStateKey, + }, + }); + + store = TestBed.inject(Store); + router = TestBed.inject(Router); + storeRouterConnectingModule = TestBed.inject(StoreRouterConnectingModule); + }); + + it('should have custom state key as own property', () => { + expect((storeRouterConnectingModule).stateKey).toBe(customStateKey); + }); + + it('should call navigateIfNeeded with args selected by custom state key', (done: any) => { + let logs: any[] = []; + store + .pipe(select(customStateKey), withLatestFrom(store)) + .subscribe(([routerStoreState, storeState]) => { + logs.push([routerStoreState, storeState]); + }); + + spyOn( + storeRouterConnectingModule, + 'navigateIfNeeded' as never + ).and.callThrough(); + logs = []; + + // this dispatches `@ngrx/router-store/navigation` action + // and store emits its payload. + router.navigateByUrl('/').then(() => { + const actual = (( + storeRouterConnectingModule + )).navigateIfNeeded.calls.allArgs(); + + expect(actual.length).toBe(1); + expect(actual[0]).toEqual(logs[0]); + done(); + }); + }); + }); + + describe('with defining state selector', () => { + const customStateKey = 'routerReducer'; + const customStateSelector = (state: State) => state.routerReducer; + + let storeRouterConnectingModule: StoreRouterConnectingModule; + let store: Store; + let router: Router; + + interface State { + [customStateKey]: RouterReducerState; + } + + beforeEach(() => { + createTestModule({ + reducers: { + [customStateKey]: routerReducer, + }, + config: { + stateKey: customStateSelector, + }, + }); + + store = TestBed.inject(Store); + router = TestBed.inject(Router); + storeRouterConnectingModule = TestBed.inject(StoreRouterConnectingModule); + }); + + it('should have same state selector as own property', () => { + expect((storeRouterConnectingModule).stateKey).toBe( + customStateSelector + ); + }); + + it('should call navigateIfNeeded with args selected by custom state selector', (done: any) => { + let logs: any[] = []; + store + .pipe(select(customStateSelector), withLatestFrom(store)) + .subscribe(([routerStoreState, storeState]) => { + logs.push([routerStoreState, storeState]); + }); + + spyOn( + storeRouterConnectingModule, + 'navigateIfNeeded' as never + ).and.callThrough(); + logs = []; + + // this dispatches `@ngrx/router-store/navigation` action + // and store emits its payload. + router.navigateByUrl('/').then(() => { + const actual = (( + storeRouterConnectingModule + )).navigateIfNeeded.calls.allArgs(); + + expect(actual.length).toBe(1); + expect(actual[0]).toEqual(logs[0]); + done(); + }); + }); + }); + + describe('routerState', () => { + function setup(routerState: RouterState, serializer?: any) { + createTestModule({ + reducers: {}, + config: { + routerState, + serializer, + }, + }); + + return { + actions: TestBed.inject(ActionsSubject) as ActionsSubject, + router: TestBed.inject(Router) as Router, + serializer: TestBed.inject( + RouterStateSerializer + ) as RouterStateSerializer, + }; + } + + const onlyRouterActions = (a: any): a is RouterAction => + a.payload && a.payload.event; + + describe('Full', () => { + it('should dispatch the full event', async () => { + const { actions, router } = setup(RouterState.Full); + actions + .pipe(filter(onlyRouterActions)) + .subscribe(({ payload }) => + expect(payload.event instanceof RouterEvent).toBe(true) + ); + + await router.navigateByUrl('/'); + }); + + it('should use the default router serializer', () => { + const { serializer } = setup(RouterState.Full); + expect(serializer).toEqual(new DefaultRouterStateSerializer()); + }); + + it('should use the provided serializer if one is provided', () => { + const { serializer } = setup( + RouterState.Full, + MinimalRouterStateSerializer + ); + expect(serializer).toEqual(new MinimalRouterStateSerializer()); + }); + }); + + describe('Minimal', () => { + it('should dispatch the navigation id with url', async () => { + const { actions, router } = setup(RouterState.Minimal); + actions + .pipe(filter(onlyRouterActions)) + .subscribe(({ payload }: any) => { + expect(payload.event instanceof RouterEvent).toBe(false); + expect(payload.event).toEqual({ id: 1, url: '/' }); + }); + + await router.navigateByUrl('/'); + }); + + it('should use the minimal router serializer', () => { + const { serializer } = setup(RouterState.Minimal); + expect(serializer).toEqual(new MinimalRouterStateSerializer()); + }); + + it('should use the provided serializer if one is provided', () => { + const { serializer } = setup( + RouterState.Minimal, + DefaultRouterStateSerializer + ); + expect(serializer).toEqual(new DefaultRouterStateSerializer()); + }); + }); + }); +}); diff --git a/modules/store-devtools/spec/integration.spec.ts b/modules/store-devtools/spec/integration.spec.ts index 5321ea9554..eb237e74ca 100644 --- a/modules/store-devtools/spec/integration.spec.ts +++ b/modules/store-devtools/spec/integration.spec.ts @@ -1,96 +1,96 @@ -import { NgModule } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { StoreModule, Store, Action } from '@ngrx/store'; -import { - StoreDevtoolsModule, - StoreDevtools, - StoreDevtoolsOptions, -} from '@ngrx/store-devtools'; - -describe('Devtools Integration', () => { - function setup(options: Partial = {}) { - @NgModule({ - imports: [ - StoreModule.forFeature('a', (state: any, action: any) => state), - ], - }) - class EagerFeatureModule {} - - @NgModule({ - imports: [ - StoreModule.forRoot({}), - EagerFeatureModule, - StoreDevtoolsModule.instrument(options), - ], - }) - class RootModule {} - - TestBed.configureTestingModule({ - imports: [RootModule], - }); - - const store = TestBed.get(Store) as Store; - const devtools = TestBed.get(StoreDevtools) as StoreDevtools; - return { store, devtools }; - } - - afterEach(() => { - const devtools = TestBed.get(StoreDevtools) as StoreDevtools; - devtools.reset(); - }); - - it('should load the store eagerly', () => { - let error = false; - - try { - const { store } = setup(); - store.subscribe(); - } catch (e) { - error = true; - } - - expect(error).toBeFalsy(); - }); - - it('should not throw if actions are ignored', (done: any) => { - const { store, devtools } = setup({ - predicate: (_, { type }: Action) => type !== 'FOO', - }); - store.subscribe(); - devtools.dispatcher.subscribe((action: Action) => { - if (action.type === 'REFRESH') { - done(); - } - }); - store.dispatch({ type: 'FOO' }); - devtools.refresh(); - }); - - it('should not throw if actions are blocked', (done: any) => { - const { store, devtools } = setup({ - actionsBlocklist: ['FOO'], - }); - store.subscribe(); - devtools.dispatcher.subscribe((action: Action) => { - if (action.type === 'REFRESH') { - done(); - } - }); - store.dispatch({ type: 'FOO' }); - devtools.refresh(); - }); - - it('should not throw if actions are safe', (done: any) => { - const { store, devtools } = setup({ - actionsSafelist: ['BAR'], - }); - store.subscribe(); - devtools.dispatcher.subscribe((action: Action) => { - if (action.type === 'REFRESH') { - done(); - } - }); - store.dispatch({ type: 'FOO' }); - devtools.refresh(); - }); -}); +import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { StoreModule, Store, Action } from '@ngrx/store'; +import { + StoreDevtoolsModule, + StoreDevtools, + StoreDevtoolsOptions, +} from '@ngrx/store-devtools'; + +describe('Devtools Integration', () => { + function setup(options: Partial = {}) { + @NgModule({ + imports: [ + StoreModule.forFeature('a', (state: any, action: any) => state), + ], + }) + class EagerFeatureModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot({}), + EagerFeatureModule, + StoreDevtoolsModule.instrument(options), + ], + }) + class RootModule {} + + TestBed.configureTestingModule({ + imports: [RootModule], + }); + + const store = TestBed.inject(Store) as Store; + const devtools = TestBed.inject(StoreDevtools) as StoreDevtools; + return { store, devtools }; + } + + afterEach(() => { + const devtools = TestBed.inject(StoreDevtools) as StoreDevtools; + devtools.reset(); + }); + + it('should load the store eagerly', () => { + let error = false; + + try { + const { store } = setup(); + store.subscribe(); + } catch (e) { + error = true; + } + + expect(error).toBeFalsy(); + }); + + it('should not throw if actions are ignored', (done: any) => { + const { store, devtools } = setup({ + predicate: (_, { type }: Action) => type !== 'FOO', + }); + store.subscribe(); + devtools.dispatcher.subscribe((action: Action) => { + if (action.type === 'REFRESH') { + done(); + } + }); + store.dispatch({ type: 'FOO' }); + devtools.refresh(); + }); + + it('should not throw if actions are blocked', (done: any) => { + const { store, devtools } = setup({ + actionsBlocklist: ['FOO'], + }); + store.subscribe(); + devtools.dispatcher.subscribe((action: Action) => { + if (action.type === 'REFRESH') { + done(); + } + }); + store.dispatch({ type: 'FOO' }); + devtools.refresh(); + }); + + it('should not throw if actions are safe', (done: any) => { + const { store, devtools } = setup({ + actionsSafelist: ['BAR'], + }); + store.subscribe(); + devtools.dispatcher.subscribe((action: Action) => { + if (action.type === 'REFRESH') { + done(); + } + }); + store.dispatch({ type: 'FOO' }); + devtools.refresh(); + }); +}); diff --git a/modules/store-devtools/spec/store.spec.ts b/modules/store-devtools/spec/store.spec.ts index 530214f537..3b9baee302 100644 --- a/modules/store-devtools/spec/store.spec.ts +++ b/modules/store-devtools/spec/store.spec.ts @@ -92,10 +92,10 @@ function createStore( }); const testbed: TestBed = getTestBed(); - const store: Store = testbed.get(Store); - const devtools: StoreDevtools = testbed.get(StoreDevtools); - const state: StateObservable = testbed.get(StateObservable); - const reducerManager: ReducerManager = testbed.get(ReducerManager); + const store: Store = testbed.inject(Store); + const devtools: StoreDevtools = testbed.inject(StoreDevtools); + const state: StateObservable = testbed.inject(StateObservable); + const reducerManager: ReducerManager = testbed.inject(ReducerManager); let liftedValue: LiftedState; let value: any; diff --git a/modules/store/spec/edge.spec.ts b/modules/store/spec/edge.spec.ts index 0cced79ec0..aae2d4c7da 100644 --- a/modules/store/spec/edge.spec.ts +++ b/modules/store/spec/edge.spec.ts @@ -1,61 +1,61 @@ -import { TestBed } from '@angular/core/testing'; -import { select, Store, StoreModule } from '@ngrx/store'; - -import { todoCount, todos } from './fixtures/edge_todos'; - -interface TestAppSchema { - counter1: number; - counter2: number; - counter3: number; -} - -interface Todo {} - -interface TodoAppSchema { - todoCount: number; - todos: Todo[]; -} - -describe('ngRx Store', () => { - describe('basic store actions', () => { - let store: Store; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({ todos, todoCount } as any), - ], - }); - - store = TestBed.get(Store); - }); - - it('should provide an Observable Store', () => { - expect(store).toBeDefined(); - }); - - it('should handle re-entrancy', (done: any) => { - let todosNextCount = 0; - let todosCountNextCount = 0; - - store.pipe(select('todos')).subscribe(todos => { - todosNextCount++; - store.dispatch({ type: 'SET_COUNT', payload: todos.length }); - }); - - store.pipe(select('todoCount')).subscribe(count => { - todosCountNextCount++; - }); - - store.dispatch({ type: 'ADD_TODO', payload: { name: 'test' } }); - expect(todosNextCount).toBe(2); - expect(todosCountNextCount).toBe(2); - - setTimeout(() => { - expect(todosNextCount).toBe(2); - expect(todosCountNextCount).toBe(2); - done(); - }, 10); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; +import { select, Store, StoreModule } from '@ngrx/store'; + +import { todoCount, todos } from './fixtures/edge_todos'; + +interface TestAppSchema { + counter1: number; + counter2: number; + counter3: number; +} + +interface Todo {} + +interface TodoAppSchema { + todoCount: number; + todos: Todo[]; +} + +describe('ngRx Store', () => { + describe('basic store actions', () => { + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ todos, todoCount } as any), + ], + }); + + store = TestBed.inject(Store); + }); + + it('should provide an Observable Store', () => { + expect(store).toBeDefined(); + }); + + it('should handle re-entrancy', (done: any) => { + let todosNextCount = 0; + let todosCountNextCount = 0; + + store.pipe(select('todos')).subscribe(todos => { + todosNextCount++; + store.dispatch({ type: 'SET_COUNT', payload: todos.length }); + }); + + store.pipe(select('todoCount')).subscribe(count => { + todosCountNextCount++; + }); + + store.dispatch({ type: 'ADD_TODO', payload: { name: 'test' } }); + expect(todosNextCount).toBe(2); + expect(todosCountNextCount).toBe(2); + + setTimeout(() => { + expect(todosNextCount).toBe(2); + expect(todosCountNextCount).toBe(2); + done(); + }, 10); + }); + }); +}); diff --git a/modules/store/spec/integration.spec.ts b/modules/store/spec/integration.spec.ts index 9958afd561..66d72260fb 100644 --- a/modules/store/spec/integration.spec.ts +++ b/modules/store/spec/integration.spec.ts @@ -1,508 +1,488 @@ -import { TestBed } from '@angular/core/testing'; -import { - ActionReducer, - ActionReducerMap, - select, - Store, - StoreModule, - createFeatureSelector, - createSelector, -} from '@ngrx/store'; -import { combineLatest } from 'rxjs'; -import { first, toArray, take } from 'rxjs/operators'; - -import { INITIAL_STATE, ReducerManager, State } from '../src/private_export'; -import { - ADD_TODO, - COMPLETE_ALL_TODOS, - COMPLETE_TODO, - SET_VISIBILITY_FILTER, - todos, - visibilityFilter, - VisibilityFilters, - resetId, -} from './fixtures/todos'; -import { - RouterTestingModule, - SpyNgModuleFactoryLoader, -} from '@angular/router/testing'; -import { NgModuleFactoryLoader, NgModule } from '@angular/core'; -import { Router } from '@angular/router'; - -interface Todo { - id: number; - text: string; - completed: boolean; -} - -interface TodoAppSchema { - visibilityFilter: string; - todos: Todo[]; -} - -describe('ngRx Integration spec', () => { - describe('todo integration spec', function() { - let store: Store; - let state: State; - - const initialState = { - todos: [], - visibilityFilter: VisibilityFilters.SHOW_ALL, - }; - const reducers: ActionReducerMap = { - todos: todos, - visibilityFilter: visibilityFilter, - }; - - beforeEach(() => { - resetId(); - spyOn(reducers, 'todos').and.callThrough(); - - TestBed.configureTestingModule({ - imports: [StoreModule.forRoot(reducers, { initialState })], - }); - - store = TestBed.get(Store); - state = TestBed.get(State); - }); - - it('should successfully instantiate', () => { - expect(store).toBeDefined(); - }); - - it('should combine reducers automatically if a key/value map is provided', () => { - const action = { type: 'Test Action' }; - const reducer$: ReducerManager = TestBed.get(ReducerManager); - - reducer$.pipe(first()).subscribe((reducer: ActionReducer) => { - expect(reducer).toBeDefined(); - expect(typeof reducer === 'function').toBe(true); - - reducer({ todos: [] }, action); - - expect(reducers.todos).toHaveBeenCalledWith([], action); - }); - }); - - it('should use a provided initial state', () => { - const resolvedInitialState = TestBed.get(INITIAL_STATE); - - expect(resolvedInitialState).toEqual(initialState); - }); - - it('should start with no todos and showing all filter', () => { - expect(state.value.todos.length).toEqual(0); - expect(state.value.visibilityFilter).toEqual(VisibilityFilters.SHOW_ALL); - }); - - it('should add a todo', () => { - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - - expect(state.value.todos.length).toEqual(1); - expect(state.value.todos[0].text).toEqual('first todo'); - expect(state.value.todos[0].completed).toEqual(false); - }); - - it('should add another todo', () => { - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); - - expect(state.value.todos.length).toEqual(2); - expect(state.value.todos[1].text).toEqual('second todo'); - expect(state.value.todos[1].completed).toEqual(false); - }); - - it('should complete the first todo', () => { - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ - type: COMPLETE_TODO, - payload: { id: state.value.todos[0].id }, - }); - - expect(state.value.todos[0].completed).toEqual(true); - }); - - describe('using the store.select', () => { - it('should use visibilityFilter to filter todos', () => { - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); - store.dispatch({ - type: COMPLETE_TODO, - payload: { id: state.value.todos[0].id }, - }); - - const filterVisibleTodos = (visibilityFilter: any, todos: any) => { - let predicate; - if (visibilityFilter === VisibilityFilters.SHOW_ALL) { - predicate = () => true; - } else if (visibilityFilter === VisibilityFilters.SHOW_ACTIVE) { - predicate = (todo: any) => !todo.completed; - } else { - predicate = (todo: any) => todo.completed; - } - return todos.filter(predicate); - }; - - let currentlyVisibleTodos: Todo[] = []; - - combineLatest( - store.select('visibilityFilter'), - store.select('todos'), - filterVisibleTodos - ).subscribe(visibleTodos => { - currentlyVisibleTodos = visibleTodos; - }); - - expect(currentlyVisibleTodos.length).toBe(2); - - store.dispatch({ - type: SET_VISIBILITY_FILTER, - payload: VisibilityFilters.SHOW_ACTIVE, - }); - - expect(currentlyVisibleTodos.length).toBe(1); - expect(currentlyVisibleTodos[0].completed).toBe(false); - - store.dispatch({ - type: SET_VISIBILITY_FILTER, - payload: VisibilityFilters.SHOW_COMPLETED, - }); - - expect(currentlyVisibleTodos.length).toBe(1); - expect(currentlyVisibleTodos[0].completed).toBe(true); - - store.dispatch({ type: COMPLETE_ALL_TODOS }); - - expect(currentlyVisibleTodos.length).toBe(2); - expect(currentlyVisibleTodos[0].completed).toBe(true); - expect(currentlyVisibleTodos[1].completed).toBe(true); - - store.dispatch({ - type: SET_VISIBILITY_FILTER, - payload: VisibilityFilters.SHOW_ACTIVE, - }); - - expect(currentlyVisibleTodos.length).toBe(0); - }); - - it('should use props to get a todo', (done: DoneFn) => { - const getTodosById = createSelector( - (state: TodoAppSchema) => state.todos, - (todos: Todo[], id: number) => { - return todos.find(p => p.id === id); - } - ); - - const todo$ = store.select(getTodosById, 2); - todo$ - .pipe( - take(3), - toArray() - ) - .subscribe(res => { - expect(res).toEqual([ - undefined, - { - id: 2, - text: 'second todo', - completed: false, - }, - { - id: 2, - text: 'second todo', - completed: true, - }, - ]); - done(); - }); - - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); - store.dispatch({ - type: COMPLETE_TODO, - payload: { id: 2 }, - }); - }); - - it('should use the selector and props to get a todo', (done: DoneFn) => { - const getTodosState = createFeatureSelector( - 'todos' - ); - const getTodos = createSelector(getTodosState, todos => todos); - const getTodosById = createSelector( - getTodos, - (state: TodoAppSchema, id: number) => id, - (todos, id) => todos.find(todo => todo.id === id) - ); - - const todo$ = store.select(getTodosById, 2); - todo$ - .pipe( - take(3), - toArray() - ) - .subscribe(res => { - expect(res).toEqual([ - undefined, - { id: 2, text: 'second todo', completed: false }, - { id: 2, text: 'second todo', completed: true }, - ]); - done(); - }); - - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); - store.dispatch({ - type: COMPLETE_TODO, - payload: { id: 2 }, - }); - }); - }); - - describe('using the select operator', () => { - it('should use visibilityFilter to filter todos', () => { - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); - store.dispatch({ - type: COMPLETE_TODO, - payload: { id: state.value.todos[0].id }, - }); - - const filterVisibleTodos = (visibilityFilter: any, todos: any) => { - let predicate; - if (visibilityFilter === VisibilityFilters.SHOW_ALL) { - predicate = () => true; - } else if (visibilityFilter === VisibilityFilters.SHOW_ACTIVE) { - predicate = (todo: any) => !todo.completed; - } else { - predicate = (todo: any) => todo.completed; - } - return todos.filter(predicate); - }; - - let currentlyVisibleTodos: Todo[] = []; - - combineLatest( - store.pipe(select('visibilityFilter')), - store.pipe(select('todos')), - filterVisibleTodos - ).subscribe(visibleTodos => { - currentlyVisibleTodos = visibleTodos; - }); - - expect(currentlyVisibleTodos.length).toBe(2); - - store.dispatch({ - type: SET_VISIBILITY_FILTER, - payload: VisibilityFilters.SHOW_ACTIVE, - }); - - expect(currentlyVisibleTodos.length).toBe(1); - expect(currentlyVisibleTodos[0].completed).toBe(false); - - store.dispatch({ - type: SET_VISIBILITY_FILTER, - payload: VisibilityFilters.SHOW_COMPLETED, - }); - - expect(currentlyVisibleTodos.length).toBe(1); - expect(currentlyVisibleTodos[0].completed).toBe(true); - - store.dispatch({ type: COMPLETE_ALL_TODOS }); - - expect(currentlyVisibleTodos.length).toBe(2); - expect(currentlyVisibleTodos[0].completed).toBe(true); - expect(currentlyVisibleTodos[1].completed).toBe(true); - - store.dispatch({ - type: SET_VISIBILITY_FILTER, - payload: VisibilityFilters.SHOW_ACTIVE, - }); - - expect(currentlyVisibleTodos.length).toBe(0); - }); - - it('should use the selector and props to get a todo', (done: DoneFn) => { - const getTodosState = createFeatureSelector( - 'todos' - ); - const getTodos = createSelector(getTodosState, todos => todos); - const getTodosById = createSelector( - getTodos, - (state: TodoAppSchema, id: number) => id, - (todos, id) => todos.find(todo => todo.id === id) - ); - - const todo$ = store.pipe(select(getTodosById, 2)); - todo$ - .pipe( - take(3), - toArray() - ) - .subscribe(res => { - expect(res).toEqual([ - undefined, - { id: 2, text: 'second todo', completed: false }, - { id: 2, text: 'second todo', completed: true }, - ]); - done(); - }); - - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); - store.dispatch({ - type: COMPLETE_TODO, - payload: { id: 2 }, - }); - }); - - it('should use the props in the projector to get a todo', (done: DoneFn) => { - const getTodosState = createFeatureSelector( - 'todos' - ); - - const getTodosById = createSelector( - getTodosState, - (todos: Todo[], { id }: { id: number }) => - todos.find(todo => todo.id === id) - ); - - const todo$ = store.pipe(select(getTodosById, { id: 2 })); - todo$ - .pipe( - take(3), - toArray() - ) - .subscribe(res => { - expect(res).toEqual([ - undefined, - { - id: 2, - text: 'second todo', - completed: false, - }, - { - id: 2, - text: 'second todo', - completed: true, - }, - ]); - done(); - }); - - store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); - store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); - store.dispatch({ - type: COMPLETE_TODO, - payload: { id: 2 }, - }); - }); - }); - }); - - describe('feature state', () => { - it('should initialize properly', () => { - const initialState = { - todos: [ - { - id: 1, - text: 'do things', - completed: false, - }, - ], - visibilityFilter: VisibilityFilters.SHOW_ALL, - }; - - const reducers: ActionReducerMap = { - todos: todos, - visibilityFilter: visibilityFilter, - }; - - const featureInitialState = [{ id: 1, completed: false, text: 'Item' }]; - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot(reducers, { initialState }), - StoreModule.forFeature('items', todos, { - initialState: featureInitialState, - }), - ], - }); - - const store: Store = TestBed.get(Store); - - let expected = [ - { - todos: initialState.todos, - visibilityFilter: initialState.visibilityFilter, - items: featureInitialState, - }, - ]; - - store.pipe(select(state => state)).subscribe(state => { - expect(state).toEqual(expected.shift()); - }); - }); - - it('should initialize properly with a partial state', () => { - const initialState = { - items: [{ id: 1, completed: false, text: 'Item' }], - }; - - const reducers: ActionReducerMap = { - todos: todos, - visibilityFilter: visibilityFilter, - }; - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({} as any, { - initialState, - }), - StoreModule.forFeature('todos', reducers), - StoreModule.forFeature('items', todos), - ], - }); - - const store: Store = TestBed.get(Store); - - const expected = { - todos: { - todos: [], - visibilityFilter: VisibilityFilters.SHOW_ALL, - }, - items: [{ id: 1, completed: false, text: 'Item' }], - }; - - store.pipe(select(state => state)).subscribe(state => { - expect(state).toEqual(expected); - }); - }); - - it('throws if forRoot() is used more than once', (done: DoneFn) => { - @NgModule({ - imports: [StoreModule.forRoot({})], - }) - class FeatureModule {} - - TestBed.configureTestingModule({ - imports: [StoreModule.forRoot({}), RouterTestingModule.withRoutes([])], - }); - - let router: Router = TestBed.get(Router); - const loader: SpyNgModuleFactoryLoader = TestBed.get( - NgModuleFactoryLoader - ); - - loader.stubbedModules = { feature: FeatureModule }; - router.resetConfig([{ path: 'feature-path', loadChildren: 'feature' }]); - - router.navigateByUrl('/feature-path').catch((err: TypeError) => { - expect(err.message).toBe( - 'StoreModule.forRoot() called twice. Feature modules should use StoreModule.forFeature() instead.' - ); - done(); - }); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; +import { + ActionReducer, + ActionReducerMap, + select, + Store, + StoreModule, + createFeatureSelector, + createSelector, +} from '@ngrx/store'; +import { combineLatest } from 'rxjs'; +import { first, toArray, take } from 'rxjs/operators'; + +import { INITIAL_STATE, ReducerManager, State } from '../src/private_export'; +import { + ADD_TODO, + COMPLETE_ALL_TODOS, + COMPLETE_TODO, + SET_VISIBILITY_FILTER, + todos, + visibilityFilter, + VisibilityFilters, + resetId, +} from './fixtures/todos'; +import { + RouterTestingModule, + SpyNgModuleFactoryLoader, +} from '@angular/router/testing'; +import { NgModuleFactoryLoader, NgModule } from '@angular/core'; +import { Router } from '@angular/router'; + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +interface TodoAppSchema { + visibilityFilter: string; + todos: Todo[]; +} + +describe('ngRx Integration spec', () => { + describe('todo integration spec', function() { + let store: Store; + let state: State; + + const initialState = { + todos: [], + visibilityFilter: VisibilityFilters.SHOW_ALL, + }; + const reducers: ActionReducerMap = { + todos: todos, + visibilityFilter: visibilityFilter, + }; + + beforeEach(() => { + resetId(); + spyOn(reducers, 'todos').and.callThrough(); + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot(reducers, { initialState })], + }); + + store = TestBed.inject(Store); + state = TestBed.inject(State); + }); + + it('should successfully instantiate', () => { + expect(store).toBeDefined(); + }); + + it('should combine reducers automatically if a key/value map is provided', () => { + const action = { type: 'Test Action' }; + const reducer$: ReducerManager = TestBed.inject(ReducerManager); + + reducer$.pipe(first()).subscribe((reducer: ActionReducer) => { + expect(reducer).toBeDefined(); + expect(typeof reducer === 'function').toBe(true); + + reducer({ todos: [] }, action); + + expect(reducers.todos).toHaveBeenCalledWith([], action); + }); + }); + + it('should use a provided initial state', () => { + const resolvedInitialState = TestBed.inject(INITIAL_STATE); + + expect(resolvedInitialState).toEqual(initialState); + }); + + it('should start with no todos and showing all filter', () => { + expect(state.value.todos.length).toEqual(0); + expect(state.value.visibilityFilter).toEqual(VisibilityFilters.SHOW_ALL); + }); + + it('should add a todo', () => { + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + + expect(state.value.todos.length).toEqual(1); + expect(state.value.todos[0].text).toEqual('first todo'); + expect(state.value.todos[0].completed).toEqual(false); + }); + + it('should add another todo', () => { + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); + + expect(state.value.todos.length).toEqual(2); + expect(state.value.todos[1].text).toEqual('second todo'); + expect(state.value.todos[1].completed).toEqual(false); + }); + + it('should complete the first todo', () => { + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ + type: COMPLETE_TODO, + payload: { id: state.value.todos[0].id }, + }); + + expect(state.value.todos[0].completed).toEqual(true); + }); + + describe('using the store.select', () => { + it('should use visibilityFilter to filter todos', () => { + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); + store.dispatch({ + type: COMPLETE_TODO, + payload: { id: state.value.todos[0].id }, + }); + + const filterVisibleTodos = (visibilityFilter: any, todos: any) => { + let predicate; + if (visibilityFilter === VisibilityFilters.SHOW_ALL) { + predicate = () => true; + } else if (visibilityFilter === VisibilityFilters.SHOW_ACTIVE) { + predicate = (todo: any) => !todo.completed; + } else { + predicate = (todo: any) => todo.completed; + } + return todos.filter(predicate); + }; + + let currentlyVisibleTodos: Todo[] = []; + + combineLatest( + store.select('visibilityFilter'), + store.select('todos'), + filterVisibleTodos + ).subscribe(visibleTodos => { + currentlyVisibleTodos = visibleTodos; + }); + + expect(currentlyVisibleTodos.length).toBe(2); + + store.dispatch({ + type: SET_VISIBILITY_FILTER, + payload: VisibilityFilters.SHOW_ACTIVE, + }); + + expect(currentlyVisibleTodos.length).toBe(1); + expect(currentlyVisibleTodos[0].completed).toBe(false); + + store.dispatch({ + type: SET_VISIBILITY_FILTER, + payload: VisibilityFilters.SHOW_COMPLETED, + }); + + expect(currentlyVisibleTodos.length).toBe(1); + expect(currentlyVisibleTodos[0].completed).toBe(true); + + store.dispatch({ type: COMPLETE_ALL_TODOS }); + + expect(currentlyVisibleTodos.length).toBe(2); + expect(currentlyVisibleTodos[0].completed).toBe(true); + expect(currentlyVisibleTodos[1].completed).toBe(true); + + store.dispatch({ + type: SET_VISIBILITY_FILTER, + payload: VisibilityFilters.SHOW_ACTIVE, + }); + + expect(currentlyVisibleTodos.length).toBe(0); + }); + + it('should use props to get a todo', (done: DoneFn) => { + const getTodosById = createSelector( + (state: TodoAppSchema) => state.todos, + (todos: Todo[], id: number) => { + return todos.find(p => p.id === id); + } + ); + + const todo$ = store.select(getTodosById, 2); + todo$.pipe(take(3), toArray()).subscribe(res => { + expect(res).toEqual([ + undefined, + { + id: 2, + text: 'second todo', + completed: false, + }, + { + id: 2, + text: 'second todo', + completed: true, + }, + ]); + done(); + }); + + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); + store.dispatch({ + type: COMPLETE_TODO, + payload: { id: 2 }, + }); + }); + + it('should use the selector and props to get a todo', (done: DoneFn) => { + const getTodosState = createFeatureSelector( + 'todos' + ); + const getTodos = createSelector(getTodosState, todos => todos); + const getTodosById = createSelector( + getTodos, + (state: TodoAppSchema, id: number) => id, + (todos, id) => todos.find(todo => todo.id === id) + ); + + const todo$ = store.select(getTodosById, 2); + todo$.pipe(take(3), toArray()).subscribe(res => { + expect(res).toEqual([ + undefined, + { id: 2, text: 'second todo', completed: false }, + { id: 2, text: 'second todo', completed: true }, + ]); + done(); + }); + + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); + store.dispatch({ + type: COMPLETE_TODO, + payload: { id: 2 }, + }); + }); + }); + + describe('using the select operator', () => { + it('should use visibilityFilter to filter todos', () => { + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); + store.dispatch({ + type: COMPLETE_TODO, + payload: { id: state.value.todos[0].id }, + }); + + const filterVisibleTodos = (visibilityFilter: any, todos: any) => { + let predicate; + if (visibilityFilter === VisibilityFilters.SHOW_ALL) { + predicate = () => true; + } else if (visibilityFilter === VisibilityFilters.SHOW_ACTIVE) { + predicate = (todo: any) => !todo.completed; + } else { + predicate = (todo: any) => todo.completed; + } + return todos.filter(predicate); + }; + + let currentlyVisibleTodos: Todo[] = []; + + combineLatest( + store.pipe(select('visibilityFilter')), + store.pipe(select('todos')), + filterVisibleTodos + ).subscribe(visibleTodos => { + currentlyVisibleTodos = visibleTodos; + }); + + expect(currentlyVisibleTodos.length).toBe(2); + + store.dispatch({ + type: SET_VISIBILITY_FILTER, + payload: VisibilityFilters.SHOW_ACTIVE, + }); + + expect(currentlyVisibleTodos.length).toBe(1); + expect(currentlyVisibleTodos[0].completed).toBe(false); + + store.dispatch({ + type: SET_VISIBILITY_FILTER, + payload: VisibilityFilters.SHOW_COMPLETED, + }); + + expect(currentlyVisibleTodos.length).toBe(1); + expect(currentlyVisibleTodos[0].completed).toBe(true); + + store.dispatch({ type: COMPLETE_ALL_TODOS }); + + expect(currentlyVisibleTodos.length).toBe(2); + expect(currentlyVisibleTodos[0].completed).toBe(true); + expect(currentlyVisibleTodos[1].completed).toBe(true); + + store.dispatch({ + type: SET_VISIBILITY_FILTER, + payload: VisibilityFilters.SHOW_ACTIVE, + }); + + expect(currentlyVisibleTodos.length).toBe(0); + }); + + it('should use the selector and props to get a todo', (done: DoneFn) => { + const getTodosState = createFeatureSelector( + 'todos' + ); + const getTodos = createSelector(getTodosState, todos => todos); + const getTodosById = createSelector( + getTodos, + (state: TodoAppSchema, id: number) => id, + (todos, id) => todos.find(todo => todo.id === id) + ); + + const todo$ = store.pipe(select(getTodosById, 2)); + todo$.pipe(take(3), toArray()).subscribe(res => { + expect(res).toEqual([ + undefined, + { id: 2, text: 'second todo', completed: false }, + { id: 2, text: 'second todo', completed: true }, + ]); + done(); + }); + + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); + store.dispatch({ + type: COMPLETE_TODO, + payload: { id: 2 }, + }); + }); + + it('should use the props in the projector to get a todo', (done: DoneFn) => { + const getTodosState = createFeatureSelector( + 'todos' + ); + + const getTodosById = createSelector( + getTodosState, + (todos: Todo[], { id }: { id: number }) => + todos.find(todo => todo.id === id) + ); + + const todo$ = store.pipe(select(getTodosById, { id: 2 })); + todo$.pipe(take(3), toArray()).subscribe(res => { + expect(res).toEqual([ + undefined, + { + id: 2, + text: 'second todo', + completed: false, + }, + { + id: 2, + text: 'second todo', + completed: true, + }, + ]); + done(); + }); + + store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } }); + store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } }); + store.dispatch({ + type: COMPLETE_TODO, + payload: { id: 2 }, + }); + }); + }); + }); + + describe('feature state', () => { + it('should initialize properly', () => { + const initialState = { + todos: [ + { + id: 1, + text: 'do things', + completed: false, + }, + ], + visibilityFilter: VisibilityFilters.SHOW_ALL, + }; + + const reducers: ActionReducerMap = { + todos: todos, + visibilityFilter: visibilityFilter, + }; + + const featureInitialState = [{ id: 1, completed: false, text: 'Item' }]; + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(reducers, { initialState }), + StoreModule.forFeature('items', todos, { + initialState: featureInitialState, + }), + ], + }); + + const store: Store = TestBed.inject(Store); + + let expected = [ + { + todos: initialState.todos, + visibilityFilter: initialState.visibilityFilter, + items: featureInitialState, + }, + ]; + + store.pipe(select(state => state)).subscribe(state => { + expect(state).toEqual(expected.shift()); + }); + }); + + it('should initialize properly with a partial state', () => { + const initialState = { + items: [{ id: 1, completed: false, text: 'Item' }], + }; + + const reducers: ActionReducerMap = { + todos: todos, + visibilityFilter: visibilityFilter, + }; + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({} as any, { + initialState, + }), + StoreModule.forFeature('todos', reducers), + StoreModule.forFeature('items', todos), + ], + }); + + const store: Store = TestBed.inject(Store); + + const expected = { + todos: { + todos: [], + visibilityFilter: VisibilityFilters.SHOW_ALL, + }, + items: [{ id: 1, completed: false, text: 'Item' }], + }; + + store.pipe(select(state => state)).subscribe(state => { + expect(state).toEqual(expected); + }); + }); + + it('throws if forRoot() is used more than once', (done: DoneFn) => { + @NgModule({ + imports: [StoreModule.forRoot({})], + }) + class FeatureModule {} + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({}), RouterTestingModule.withRoutes([])], + }); + + let router: Router = TestBed.inject(Router); + const loader: SpyNgModuleFactoryLoader = TestBed.inject( + NgModuleFactoryLoader + ); + + loader.stubbedModules = { feature: FeatureModule }; + router.resetConfig([{ path: 'feature-path', loadChildren: 'feature' }]); + + router.navigateByUrl('/feature-path').catch((err: TypeError) => { + expect(err.message).toBe( + 'StoreModule.forRoot() called twice. Feature modules should use StoreModule.forFeature() instead.' + ); + done(); + }); + }); + }); +}); diff --git a/modules/store/spec/modules.spec.ts b/modules/store/spec/modules.spec.ts index 1d122d9631..914f831954 100644 --- a/modules/store/spec/modules.spec.ts +++ b/modules/store/spec/modules.spec.ts @@ -1,219 +1,219 @@ -import { InjectionToken, NgModule } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { - ActionReducer, - ActionReducerMap, - combineReducers, - Store, - StoreModule, -} from '@ngrx/store'; -import { take } from 'rxjs/operators'; - -describe(`Store Modules`, () => { - type RootState = { fruit: string }; - type FeatureAState = number; - type FeatureBState = { list: number[]; index: number }; - type State = RootState & { a: FeatureAState } & { b: FeatureBState }; - - let store: Store; - - const reducersToken = new InjectionToken>( - 'Root Reducers' - ); - - const featureToken = new InjectionToken>( - 'Feature Reducers' - ); - - // Trigger here is basically an action type used to trigger state update - const createDummyReducer = (def: T, trigger: string): ActionReducer => ( - s = def, - { type, payload }: any - ) => (type === trigger ? payload : s); - const rootFruitReducer = createDummyReducer('apple', 'fruit'); - const featureAReducer = createDummyReducer(5, 'a'); - const featureBListReducer = createDummyReducer([1, 2, 3], 'bList'); - const featureBIndexReducer = createDummyReducer(2, 'bIndex'); - const featureBReducerMap: ActionReducerMap = { - list: featureBListReducer, - index: featureBIndexReducer, - }; - - describe(`: Config`, () => { - let featureAReducerFactory: any; - let rootReducerFactory: any; - - const featureAInitial = () => ({ a: 42 }); - const rootInitial = { fruit: 'orange' }; - - beforeEach(() => { - featureAReducerFactory = jasmine - .createSpy('featureAReducerFactory') - .and.callFake((rm: any, initialState?: any) => { - return (state: any, action: any) => 4; - }); - rootReducerFactory = jasmine - .createSpy('rootReducerFactory') - .and.callFake(combineReducers); - - @NgModule({ - imports: [ - StoreModule.forFeature( - 'a', - { a: featureAReducer }, - { - initialState: featureAInitial, - reducerFactory: featureAReducerFactory, - } - ), - ], - }) - class FeatureAModule {} - - @NgModule({ - imports: [ - StoreModule.forRoot(reducersToken, { - initialState: rootInitial, - reducerFactory: rootReducerFactory, - }), - FeatureAModule, - ], - providers: [ - { - provide: reducersToken, - useValue: { fruit: rootFruitReducer }, - }, - ], - }) - class RootModule {} - - TestBed.configureTestingModule({ - imports: [RootModule], - }); - - store = TestBed.get(Store); - }); - - it(`should accept configurations`, () => { - expect(featureAReducerFactory).toHaveBeenCalledWith({ - a: featureAReducer, - }); - - expect(rootReducerFactory).toHaveBeenCalledWith({ - fruit: rootFruitReducer, - }); - }); - - it(`should should use config.reducerFactory`, () => { - store.dispatch({ type: 'fruit', payload: 'banana' }); - store.dispatch({ type: 'a', payload: 42 }); - - store.pipe(take(1)).subscribe((s: any) => { - expect(s).toEqual({ - fruit: 'banana', - a: 4, - }); - }); - }); - }); - - describe(`: With initial state`, () => { - const initialState: RootState = { fruit: 'banana' }; - const reducerMap: ActionReducerMap = { fruit: rootFruitReducer }; - const noopMetaReducer = (r: Function) => (state: any, action: any) => { - return r(state, action); - }; - - const testWithMetaReducers = (metaReducers: any[]) => () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot(reducerMap, { initialState, metaReducers }), - ], - }); - - store = TestBed.get(Store); - }); - - it('should have initial state', () => { - store.pipe(take(1)).subscribe((s: any) => { - expect(s).toEqual(initialState); - }); - }); - }; - - describe( - 'should add initial state with no meta-reducers', - testWithMetaReducers([]) - ); - - describe( - 'should add initial state with registered meta-reducers', - testWithMetaReducers([noopMetaReducer]) - ); - }); - - describe(`: Nested`, () => { - @NgModule({ - imports: [StoreModule.forFeature('a', featureAReducer)], - }) - class FeatureAModule {} - - @NgModule({ - imports: [StoreModule.forFeature('b', featureBReducerMap)], - }) - class FeatureBModule {} - - @NgModule({ - imports: [StoreModule.forFeature('c', featureToken)], - providers: [ - { - provide: featureToken, - useValue: featureBReducerMap, - }, - ], - }) - class FeatureCModule {} - - @NgModule({ - imports: [ - StoreModule.forRoot(reducersToken), - FeatureAModule, - FeatureBModule, - FeatureCModule, - ], - providers: [ - { - provide: reducersToken, - useValue: { fruit: rootFruitReducer }, - }, - ], - }) - class RootModule {} - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [RootModule], - }); - - store = TestBed.get(Store); - }); - - it('should nest the child module in the root store object', () => { - store.pipe(take(1)).subscribe((state: State) => { - expect(state).toEqual({ - fruit: 'apple', - a: 5, - b: { - list: [1, 2, 3], - index: 2, - }, - c: { - list: [1, 2, 3], - index: 2, - }, - } as State); - }); - }); - }); -}); +import { InjectionToken, NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + ActionReducer, + ActionReducerMap, + combineReducers, + Store, + StoreModule, +} from '@ngrx/store'; +import { take } from 'rxjs/operators'; + +describe(`Store Modules`, () => { + type RootState = { fruit: string }; + type FeatureAState = number; + type FeatureBState = { list: number[]; index: number }; + type State = RootState & { a: FeatureAState } & { b: FeatureBState }; + + let store: Store; + + const reducersToken = new InjectionToken>( + 'Root Reducers' + ); + + const featureToken = new InjectionToken>( + 'Feature Reducers' + ); + + // Trigger here is basically an action type used to trigger state update + const createDummyReducer = (def: T, trigger: string): ActionReducer => ( + s = def, + { type, payload }: any + ) => (type === trigger ? payload : s); + const rootFruitReducer = createDummyReducer('apple', 'fruit'); + const featureAReducer = createDummyReducer(5, 'a'); + const featureBListReducer = createDummyReducer([1, 2, 3], 'bList'); + const featureBIndexReducer = createDummyReducer(2, 'bIndex'); + const featureBReducerMap: ActionReducerMap = { + list: featureBListReducer, + index: featureBIndexReducer, + }; + + describe(`: Config`, () => { + let featureAReducerFactory: any; + let rootReducerFactory: any; + + const featureAInitial = () => ({ a: 42 }); + const rootInitial = { fruit: 'orange' }; + + beforeEach(() => { + featureAReducerFactory = jasmine + .createSpy('featureAReducerFactory') + .and.callFake((rm: any, initialState?: any) => { + return (state: any, action: any) => 4; + }); + rootReducerFactory = jasmine + .createSpy('rootReducerFactory') + .and.callFake(combineReducers); + + @NgModule({ + imports: [ + StoreModule.forFeature( + 'a', + { a: featureAReducer }, + { + initialState: featureAInitial, + reducerFactory: featureAReducerFactory, + } + ), + ], + }) + class FeatureAModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot(reducersToken, { + initialState: rootInitial, + reducerFactory: rootReducerFactory, + }), + FeatureAModule, + ], + providers: [ + { + provide: reducersToken, + useValue: { fruit: rootFruitReducer }, + }, + ], + }) + class RootModule {} + + TestBed.configureTestingModule({ + imports: [RootModule], + }); + + store = TestBed.inject(Store); + }); + + it(`should accept configurations`, () => { + expect(featureAReducerFactory).toHaveBeenCalledWith({ + a: featureAReducer, + }); + + expect(rootReducerFactory).toHaveBeenCalledWith({ + fruit: rootFruitReducer, + }); + }); + + it(`should should use config.reducerFactory`, () => { + store.dispatch({ type: 'fruit', payload: 'banana' }); + store.dispatch({ type: 'a', payload: 42 }); + + store.pipe(take(1)).subscribe((s: any) => { + expect(s).toEqual({ + fruit: 'banana', + a: 4, + }); + }); + }); + }); + + describe(`: With initial state`, () => { + const initialState: RootState = { fruit: 'banana' }; + const reducerMap: ActionReducerMap = { fruit: rootFruitReducer }; + const noopMetaReducer = (r: Function) => (state: any, action: any) => { + return r(state, action); + }; + + const testWithMetaReducers = (metaReducers: any[]) => () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(reducerMap, { initialState, metaReducers }), + ], + }); + + store = TestBed.inject(Store); + }); + + it('should have initial state', () => { + store.pipe(take(1)).subscribe((s: any) => { + expect(s).toEqual(initialState); + }); + }); + }; + + describe( + 'should add initial state with no meta-reducers', + testWithMetaReducers([]) + ); + + describe( + 'should add initial state with registered meta-reducers', + testWithMetaReducers([noopMetaReducer]) + ); + }); + + describe(`: Nested`, () => { + @NgModule({ + imports: [StoreModule.forFeature('a', featureAReducer)], + }) + class FeatureAModule {} + + @NgModule({ + imports: [StoreModule.forFeature('b', featureBReducerMap)], + }) + class FeatureBModule {} + + @NgModule({ + imports: [StoreModule.forFeature('c', featureToken)], + providers: [ + { + provide: featureToken, + useValue: featureBReducerMap, + }, + ], + }) + class FeatureCModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot(reducersToken), + FeatureAModule, + FeatureBModule, + FeatureCModule, + ], + providers: [ + { + provide: reducersToken, + useValue: { fruit: rootFruitReducer }, + }, + ], + }) + class RootModule {} + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RootModule], + }); + + store = TestBed.inject(Store); + }); + + it('should nest the child module in the root store object', () => { + store.pipe(take(1)).subscribe((state: State) => { + expect(state).toEqual({ + fruit: 'apple', + a: 5, + b: { + list: [1, 2, 3], + index: 2, + }, + c: { + list: [1, 2, 3], + index: 2, + }, + } as State); + }); + }); + }); +}); diff --git a/modules/store/spec/runtime_checks.spec.ts b/modules/store/spec/runtime_checks.spec.ts index 958d7df579..e6171a0713 100644 --- a/modules/store/spec/runtime_checks.spec.ts +++ b/modules/store/spec/runtime_checks.spec.ts @@ -79,7 +79,7 @@ describe('Runtime checks:', () => { ], }); - const _store = TestBed.get>(Store); + const _store = TestBed.inject>(Store); expect(serializationCheckMetaReducerSpy).toHaveBeenCalled(); }); @@ -101,7 +101,7 @@ describe('Runtime checks:', () => { ], }); - const _store = TestBed.get>(Store); + const _store = TestBed.inject>(Store); expect(serializationCheckMetaReducerSpy).not.toHaveBeenCalled(); }); @@ -125,7 +125,7 @@ describe('Runtime checks:', () => { ], }); - const _store = TestBed.get>(Store); + const _store = TestBed.inject>(Store); expect(serializationCheckMetaReducerSpy).not.toHaveBeenCalled(); expect(immutabilityCheckMetaReducerSpy).toHaveBeenCalled(); }); @@ -166,7 +166,7 @@ describe('Runtime checks:', () => { ], }); - const store: Store = TestBed.get(Store); + const store: Store = TestBed.inject(Store); const expected = ['internal-single-one', 'internal-single-two', 'user']; expect(logs).toEqual(expected); @@ -180,29 +180,23 @@ describe('Runtime checks:', () => { describe('State Serialization:', () => { const invalidAction = () => ({ type: ErrorTypes.UnserializableState }); - it( - 'should throw when enabled', - fakeAsync(() => { - const store = setupStore({ strictStateSerializability: true }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).toThrowError(/Detected unserializable state/); - }) - ); - - it( - 'should not throw when disabled', - fakeAsync(() => { - const store = setupStore({ strictStateSerializability: false }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).not.toThrow(); - }) - ); + it('should throw when enabled', fakeAsync(() => { + const store = setupStore({ strictStateSerializability: true }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).toThrowError(/Detected unserializable state/); + })); + + it('should not throw when disabled', fakeAsync(() => { + const store = setupStore({ strictStateSerializability: false }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).not.toThrow(); + })); }); describe('Action Serialization:', () => { @@ -211,29 +205,23 @@ describe('Runtime checks:', () => { invalid: new Date(), }); - it( - 'should throw when enabled', - fakeAsync(() => { - const store = setupStore({ strictActionSerializability: true }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).toThrowError(/Detected unserializable action/); - }) - ); - - it( - 'should not throw when disabled', - fakeAsync(() => { - const store = setupStore({ strictActionSerializability: false }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).not.toThrow(); - }) - ); + it('should throw when enabled', fakeAsync(() => { + const store = setupStore({ strictActionSerializability: true }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).toThrowError(/Detected unserializable action/); + })); + + it('should not throw when disabled', fakeAsync(() => { + const store = setupStore({ strictActionSerializability: false }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).not.toThrow(); + })); }); describe('State Mutations', () => { @@ -241,29 +229,23 @@ describe('Runtime checks:', () => { type: ErrorTypes.MutateState, }); - it( - 'should throw when enabled', - fakeAsync(() => { - const store = setupStore({ strictStateImmutability: true }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).toThrowError(/Cannot add property/); - }) - ); - - it( - 'should not throw when disabled', - fakeAsync(() => { - const store = setupStore({ strictStateImmutability: false }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).not.toThrow(); - }) - ); + it('should throw when enabled', fakeAsync(() => { + const store = setupStore({ strictStateImmutability: true }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).toThrowError(/Cannot add property/); + })); + + it('should not throw when disabled', fakeAsync(() => { + const store = setupStore({ strictStateImmutability: false }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).not.toThrow(); + })); }); describe('Action Mutations', () => { @@ -272,29 +254,23 @@ describe('Runtime checks:', () => { foo: 'foo', }); - it( - 'should throw when enabled', - fakeAsync(() => { - const store = setupStore({ strictActionImmutability: true }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).toThrowError(/Cannot assign to read only property/); - }) - ); - - it( - 'should not throw when disabled', - fakeAsync(() => { - const store = setupStore({ strictActionImmutability: false }); - - expect(() => { - store.dispatch(invalidAction()); - flush(); - }).not.toThrow(); - }) - ); + it('should throw when enabled', fakeAsync(() => { + const store = setupStore({ strictActionImmutability: true }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).toThrowError(/Cannot assign to read only property/); + })); + + it('should not throw when disabled', fakeAsync(() => { + const store = setupStore({ strictActionImmutability: false }); + + expect(() => { + store.dispatch(invalidAction()); + flush(); + }).not.toThrow(); + })); }); }); @@ -310,7 +286,7 @@ function setupStore(runtimeChecks?: Partial): Store { ], }); - return TestBed.get(Store); + return TestBed.inject(Store); } enum ErrorTypes { diff --git a/modules/store/spec/state.spec.ts b/modules/store/spec/state.spec.ts index 9693fe17fa..5990a4dc6d 100644 --- a/modules/store/spec/state.spec.ts +++ b/modules/store/spec/state.spec.ts @@ -1,47 +1,44 @@ -import { TestBed, fakeAsync, flush } from '@angular/core/testing'; -import { INIT, Store, StoreModule, Action } from '@ngrx/store'; - -describe('ngRx State', () => { - it('should call the reducer to scan over the dispatcher', () => { - const initialState = 123; - const reducer = jasmine.createSpy('reducer').and.returnValue(initialState); - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot( - { key: reducer }, - { initialState: { key: initialState } } - ), - ], - }); - - TestBed.get(Store); - - expect(reducer).toHaveBeenCalledWith(initialState, { - type: INIT, - }); - }); - - it( - 'should fail synchronously', - fakeAsync(() => { - function reducer(state: any, action: Action) { - if (action.type === 'THROW_ERROR') { - throw new Error('(╯°□°)╯︵ ┻━┻'); - } - - return state; - } - - TestBed.configureTestingModule({ - imports: [StoreModule.forRoot({ reducer })], - }); - - const store = TestBed.get(Store) as Store; - expect(() => { - store.dispatch({ type: 'THROW_ERROR' }); - flush(); - }).toThrow(); - }) - ); -}); +import { TestBed, fakeAsync, flush } from '@angular/core/testing'; +import { INIT, Store, StoreModule, Action } from '@ngrx/store'; + +describe('ngRx State', () => { + it('should call the reducer to scan over the dispatcher', () => { + const initialState = 123; + const reducer = jasmine.createSpy('reducer').and.returnValue(initialState); + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot( + { key: reducer }, + { initialState: { key: initialState } } + ), + ], + }); + + TestBed.inject(Store); + + expect(reducer).toHaveBeenCalledWith(initialState, { + type: INIT, + }); + }); + + it('should fail synchronously', fakeAsync(() => { + function reducer(state: any, action: Action) { + if (action.type === 'THROW_ERROR') { + throw new Error('(╯°□°)╯︵ ┻━┻'); + } + + return state; + } + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({ reducer })], + }); + + const store = TestBed.inject(Store) as Store; + expect(() => { + store.dispatch({ type: 'THROW_ERROR' }); + flush(); + }).toThrow(); + })); +}); diff --git a/modules/store/spec/store.spec.ts b/modules/store/spec/store.spec.ts index 1920f08e1d..e7b79a53f2 100644 --- a/modules/store/spec/store.spec.ts +++ b/modules/store/spec/store.spec.ts @@ -1,705 +1,705 @@ -import { InjectionToken } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { hot } from 'jasmine-marbles'; -import { - ActionsSubject, - ReducerManager, - Store, - StoreModule, - select, - ReducerManagerDispatcher, - UPDATE, - ActionReducer, - Action, -} from '../'; -import { StoreConfig } from '../src/store_module'; -import { combineReducers } from '../src/utils'; -import { - counterReducer, - INCREMENT, - DECREMENT, - RESET, - counterReducer2, -} from './fixtures/counter'; -import Spy = jasmine.Spy; -import { take } from 'rxjs/operators'; - -interface TestAppSchema { - counter1: number; - counter2: number; - counter3: number; - counter4?: number; -} - -describe('ngRx Store', () => { - let store: Store; - let dispatcher: ActionsSubject; - - function setup( - initialState: any = { counter1: 0, counter2: 1 }, - metaReducers: any = [] - ) { - const reducers = { - counter1: counterReducer, - counter2: counterReducer, - counter3: counterReducer, - }; - - TestBed.configureTestingModule({ - imports: [StoreModule.forRoot(reducers, { initialState, metaReducers })], - }); - - store = TestBed.get(Store); - dispatcher = TestBed.get(ActionsSubject); - } - - describe('initial state', () => { - it('should handle an initial state object', (done: any) => { - setup(); - testStoreValue({ counter1: 0, counter2: 1, counter3: 0 }, done); - }); - - it('should handle an initial state function', (done: any) => { - setup(() => ({ counter1: 0, counter2: 5 })); - testStoreValue({ counter1: 0, counter2: 5, counter3: 0 }, done); - }); - - it('should keep initial state values when state is partially initialized', (done: DoneFn) => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({} as any, { - initialState: { - feature1: { - counter1: 1, - }, - feature3: { - counter3: 3, - }, - }, - }), - StoreModule.forFeature('feature1', { counter1: counterReducer }), - StoreModule.forFeature('feature2', { counter2: counterReducer }), - StoreModule.forFeature('feature3', { counter3: counterReducer }), - ], - }); - - testStoreValue( - { - feature1: { counter1: 1 }, - feature2: { counter2: 0 }, - feature3: { counter3: 3 }, - }, - done - ); - }); - - it('should reset to initial state when undefined (root ActionReducerMap)', () => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot( - { counter1: counterReducer }, - { initialState: { counter1: 1 } } - ), - ], - }); - - testInitialState(); - }); - - it('should reset to initial state when undefined (feature ActionReducer)', () => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - StoreModule.forFeature('counter1', counterReducer, { - initialState: 1, - }), - ], - }); - - testInitialState(); - }); - - it('should reset to initial state when undefined (feature ActionReducerMap)', () => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - StoreModule.forFeature( - 'feature1', - { counter1: counterReducer }, - { initialState: { counter1: 1 } } - ), - ], - }); - - testInitialState('feature1'); - }); - - function testInitialState(feature?: string) { - store = TestBed.get(Store); - dispatcher = TestBed.get(ActionsSubject); - - const actionSequence = '--a--b--c--d--e--f--g'; - const stateSequence = 'i-w-----x-----y--z---'; - const actionValues = { - a: { type: INCREMENT }, - b: { type: 'OTHER' }, - c: { type: RESET }, - d: { type: 'OTHER' }, // reproduces https://github.com/ngrx/platform/issues/880 because state is falsey - e: { type: INCREMENT }, - f: { type: INCREMENT }, - g: { type: 'OTHER' }, - }; - const counterSteps = hot(actionSequence, actionValues); - counterSteps.subscribe(action => store.dispatch(action)); - - const counterStateWithString = feature - ? (store as any).select(feature, 'counter1') - : store.select('counter1'); - - const counter1Values = { i: 1, w: 2, x: 0, y: 1, z: 2 }; - - expect(counterStateWithString).toBeObservable( - hot(stateSequence, counter1Values) - ); - } - - function testStoreValue(expected: any, done: DoneFn) { - store = TestBed.get(Store); - - store.pipe(take(1)).subscribe({ - next(val) { - expect(val).toEqual(expected); - }, - error: done, - complete: done, - }); - } - }); - - describe('basic store actions', () => { - beforeEach(() => setup()); - - it('should provide an Observable Store', () => { - expect(store).toBeDefined(); - }); - - const actionSequence = '--a--b--c--d--e'; - const actionValues = { - a: { type: INCREMENT }, - b: { type: INCREMENT }, - c: { type: DECREMENT }, - d: { type: RESET }, - e: { type: INCREMENT }, - }; - - it('should let you select state with a key name', () => { - const counterSteps = hot(actionSequence, actionValues); - - counterSteps.subscribe(action => store.dispatch(action)); - - const counterStateWithString = store.pipe(select('counter1')); - - const stateSequence = 'i-v--w--x--y--z'; - const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; - - expect(counterStateWithString).toBeObservable( - hot(stateSequence, counter1Values) - ); - }); - - it('should let you select state with a selector function', () => { - const counterSteps = hot(actionSequence, actionValues); - - counterSteps.subscribe(action => store.dispatch(action)); - - const counterStateWithFunc = store.pipe(select(s => s.counter1)); - - const stateSequence = 'i-v--w--x--y--z'; - const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; - - expect(counterStateWithFunc).toBeObservable( - hot(stateSequence, counter1Values) - ); - }); - - it('should correctly lift itself', () => { - const result = store.pipe(select('counter1')); - - expect(result instanceof Store).toBe(true); - }); - - it('should increment and decrement counter1', () => { - const counterSteps = hot(actionSequence, actionValues); - - counterSteps.subscribe(action => store.dispatch(action)); - - const counterState = store.pipe(select('counter1')); - - const stateSequence = 'i-v--w--x--y--z'; - const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; - - expect(counterState).toBeObservable(hot(stateSequence, counter1Values)); - }); - - it('should increment and decrement counter1 using the dispatcher', () => { - const counterSteps = hot(actionSequence, actionValues); - - counterSteps.subscribe(action => dispatcher.next(action)); - - const counterState = store.pipe(select('counter1')); - - const stateSequence = 'i-v--w--x--y--z'; - const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; - - expect(counterState).toBeObservable(hot(stateSequence, counter1Values)); - }); - - it('should increment and decrement counter2 separately', () => { - const counterSteps = hot(actionSequence, actionValues); - - counterSteps.subscribe(action => store.dispatch(action)); - - const counter1State = store.pipe(select('counter1')); - const counter2State = store.pipe(select('counter2')); - - const stateSequence = 'i-v--w--x--y--z'; - const counter2Values = { i: 1, v: 2, w: 3, x: 2, y: 0, z: 1 }; - - expect(counter2State).toBeObservable(hot(stateSequence, counter2Values)); - }); - - it('should implement the observer interface forwarding actions and errors to the dispatcher', () => { - spyOn(dispatcher, 'next'); - spyOn(dispatcher, 'error'); - - store.next(1); - store.error(2); - - expect(dispatcher.next).toHaveBeenCalledWith(1); - expect(dispatcher.error).toHaveBeenCalledWith(2); - }); - - it('should not be completable', () => { - const storeSubscription = store.subscribe(); - const dispatcherSubscription = dispatcher.subscribe(); - - store.complete(); - dispatcher.complete(); - - expect(storeSubscription.closed).toBe(false); - expect(dispatcherSubscription.closed).toBe(false); - }); - - it('should complete if the dispatcher is destroyed', () => { - const storeSubscription = store.subscribe(); - const dispatcherSubscription = dispatcher.subscribe(); - - dispatcher.ngOnDestroy(); - - expect(dispatcherSubscription.closed).toBe(true); - }); - }); - - describe(`add/remove reducers`, () => { - let addReducerSpy: Spy; - let removeReducerSpy: Spy; - let reducerManagerDispatcherSpy: Spy; - const key = 'counter4'; - - beforeEach(() => { - setup(); - const reducerManager = TestBed.get(ReducerManager); - const dispatcher = TestBed.get(ReducerManagerDispatcher); - addReducerSpy = spyOn(reducerManager, 'addReducer').and.callThrough(); - removeReducerSpy = spyOn( - reducerManager, - 'removeReducer' - ).and.callThrough(); - reducerManagerDispatcherSpy = spyOn(dispatcher, 'next').and.callThrough(); - }); - - it(`should delegate add/remove to ReducerManager`, () => { - store.addReducer(key, counterReducer); - expect(addReducerSpy).toHaveBeenCalledWith(key, counterReducer); - - store.removeReducer(key); - expect(removeReducerSpy).toHaveBeenCalledWith(key); - }); - - it(`should work with added / removed reducers`, () => { - store.addReducer(key, counterReducer); - store.pipe(take(1)).subscribe(val => { - expect(val.counter4).toBe(0); - }); - - store.removeReducer(key); - store.dispatch({ type: INCREMENT }); - store.pipe(take(1)).subscribe(val => { - expect(val.counter4).toBeUndefined(); - }); - }); - - it('should dispatch an update reducers action when a reducer is added', () => { - store.addReducer(key, counterReducer); - expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ - type: UPDATE, - features: [key], - }); - }); - - it('should dispatch an update reducers action when a reducer is removed', () => { - store.removeReducer(key); - expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ - type: UPDATE, - features: [key], - }); - }); - }); - - describe('add/remove features', () => { - let reducerManager: ReducerManager; - let reducerManagerDispatcherSpy: Spy; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [StoreModule.forRoot({})], - }); - - reducerManager = TestBed.get(ReducerManager); - const dispatcher = TestBed.get(ReducerManagerDispatcher); - reducerManagerDispatcherSpy = spyOn(dispatcher, 'next').and.callThrough(); - }); - - it('should dispatch an update reducers action when a feature is added', () => { - reducerManager.addFeature({ - key: 'feature1', - reducers: {}, - reducerFactory: combineReducers, - }); - - expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ - type: UPDATE, - features: ['feature1'], - }); - }); - - it('should dispatch an update reducers action when multiple features are added', () => { - reducerManager.addFeatures([ - { - key: 'feature1', - reducers: {}, - reducerFactory: combineReducers, - }, - { - key: 'feature2', - reducers: {}, - reducerFactory: combineReducers, - }, - ]); - - expect(reducerManagerDispatcherSpy).toHaveBeenCalledTimes(1); - expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ - type: UPDATE, - features: ['feature1', 'feature2'], - }); - }); - - it('should dispatch an update reducers action when a feature is removed', () => { - reducerManager.removeFeature( - createFeature({ - key: 'feature1', - }) - ); - - expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ - type: UPDATE, - features: ['feature1'], - }); - }); - - it('should dispatch an update reducers action when multiple features are removed', () => { - reducerManager.removeFeatures([ - createFeature({ - key: 'feature1', - }), - createFeature({ - key: 'feature2', - }), - ]); - - expect(reducerManagerDispatcherSpy).toHaveBeenCalledTimes(1); - expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ - type: UPDATE, - features: ['feature1', 'feature2'], - }); - }); - - function createFeature({ key }: { key: string }) { - return { - key, - reducers: {}, - reducerFactory: jasmine.createSpy(`reducerFactory_${key}`), - }; - } - }); - - describe('Meta Reducers', () => { - let metaReducerContainer: any; - let metaReducerSpy1: Spy; - let metaReducerSpy2: Spy; - - beforeEach(() => { - metaReducerContainer = (function() { - function metaReducer1(reducer: ActionReducer) { - return function(state: any, action: Action) { - return reducer(state, action); - }; - } - - function metaReducer2(reducer: ActionReducer) { - return function(state: any, action: Action) { - return reducer(state, action); - }; - } - - return { - metaReducer1: metaReducer1, - metaReducer2: metaReducer2, - }; - })(); - - metaReducerSpy1 = spyOn( - metaReducerContainer, - 'metaReducer1' - ).and.callThrough(); - - metaReducerSpy2 = spyOn( - metaReducerContainer, - 'metaReducer2' - ).and.callThrough(); - }); - - it('should create a meta reducer for root and call it through', () => { - setup({}, [metaReducerContainer.metaReducer1]); - const action = { type: INCREMENT }; - store.dispatch(action); - expect(metaReducerSpy1).toHaveBeenCalled(); - }); - - it('should call two meta reducers', () => { - setup({}, [ - metaReducerContainer.metaReducer1, - metaReducerContainer.metaReducer2, - ]); - const action = { type: INCREMENT }; - store.dispatch(action); - - expect(metaReducerSpy1).toHaveBeenCalled(); - expect(metaReducerSpy2).toHaveBeenCalled(); - }); - - it('should create a meta reducer for feature and call it with the expected reducer', () => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - StoreModule.forFeature('counter1', counterReducer, { - metaReducers: [metaReducerContainer.metaReducer1], - }), - StoreModule.forFeature('counter2', counterReducer2, { - metaReducers: [metaReducerContainer.metaReducer2], - }), - ], - }); - - const mockStore = TestBed.get(Store); - const action = { type: INCREMENT }; - - mockStore.dispatch(action); - - expect(metaReducerSpy1).toHaveBeenCalledWith(counterReducer); - expect(metaReducerSpy2).toHaveBeenCalledWith(counterReducer2); - }); - - it('should initial state with value', (done: DoneFn) => { - const counterInitialState = 2; - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - StoreModule.forFeature( - 'counterState', - { counter: counterReducer }, - { - initialState: { counter: counterInitialState }, - metaReducers: [metaReducerContainer.metaReducer1], - } - ), - ], - }); - - const mockStore = TestBed.get(Store); - - mockStore.pipe(take(1)).subscribe({ - next(val: any) { - expect(val['counterState'].counter).toEqual(counterInitialState); - }, - error: done, - complete: done, - }); - }); - }); - - describe('Feature config token', () => { - let FEATURE_CONFIG_TOKEN: InjectionToken>; - let FEATURE_CONFIG2_TOKEN: InjectionToken>; - - beforeEach(() => { - FEATURE_CONFIG_TOKEN = new InjectionToken('Feature Config'); - FEATURE_CONFIG2_TOKEN = new InjectionToken('Feature Config2'); - }); - - it('should initial state with value', (done: DoneFn) => { - const initialState = { counter1: 1 }; - const featureKey = 'counter'; - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - StoreModule.forFeature( - featureKey, - counterReducer, - FEATURE_CONFIG_TOKEN - ), - ], - providers: [ - { - provide: FEATURE_CONFIG_TOKEN, - useValue: { initialState: initialState }, - }, - ], - }); - - const mockStore = TestBed.get(Store); - - mockStore.pipe(take(1)).subscribe({ - next(val: any) { - expect(val[featureKey]).toEqual(initialState); - }, - error: done, - complete: done, - }); - }); - - it('should initial state with value for multi features', (done: DoneFn) => { - const initialState = 1; - const initialState2 = 2; - const initialState3 = 3; - const featureKey = 'counter'; - const featureKey2 = 'counter2'; - const featureKey3 = 'counter3'; - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - StoreModule.forFeature( - featureKey, - counterReducer, - FEATURE_CONFIG_TOKEN - ), - StoreModule.forFeature( - featureKey2, - counterReducer, - FEATURE_CONFIG2_TOKEN - ), - StoreModule.forFeature(featureKey3, counterReducer, { - initialState: initialState3, - }), - ], - providers: [ - { - provide: FEATURE_CONFIG_TOKEN, - useValue: { initialState: initialState }, - }, - { - provide: FEATURE_CONFIG2_TOKEN, - useValue: { initialState: initialState2 }, - }, - ], - }); - - const mockStore = TestBed.get(Store); - - mockStore.pipe(take(1)).subscribe({ - next(val: any) { - expect(val[featureKey]).toEqual(initialState); - expect(val[featureKey2]).toEqual(initialState2); - expect(val[featureKey3]).toEqual(initialState3); - }, - error: done, - complete: done, - }); - }); - - it('should create a meta reducer with config injection token and call it with the expected reducer', () => { - const metaReducerContainer = (function() { - function metaReducer1(reducer: ActionReducer) { - return function(state: any, action: Action) { - return reducer(state, action); - }; - } - - function metaReducer2(reducer: ActionReducer) { - return function(state: any, action: Action) { - return reducer(state, action); - }; - } - - return { - metaReducer1: metaReducer1, - metaReducer2: metaReducer2, - }; - })(); - - const metaReducerSpy1 = spyOn( - metaReducerContainer, - 'metaReducer1' - ).and.callThrough(); - - const metaReducerSpy2 = spyOn( - metaReducerContainer, - 'metaReducer2' - ).and.callThrough(); - - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({}), - StoreModule.forFeature( - 'counter1', - counterReducer, - FEATURE_CONFIG_TOKEN - ), - StoreModule.forFeature( - 'counter2', - counterReducer2, - FEATURE_CONFIG2_TOKEN - ), - ], - providers: [ - { - provide: FEATURE_CONFIG_TOKEN, - useValue: { metaReducers: [metaReducerContainer.metaReducer1] }, - }, - { - provide: FEATURE_CONFIG2_TOKEN, - useValue: { metaReducers: [metaReducerContainer.metaReducer2] }, - }, - ], - }); - const mockStore = TestBed.get(Store); - const action = { type: INCREMENT }; - mockStore.dispatch(action); - - expect(metaReducerSpy1).toHaveBeenCalledWith(counterReducer); - expect(metaReducerSpy2).toHaveBeenCalledWith(counterReducer2); - }); - }); -}); +import { InjectionToken } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { hot } from 'jasmine-marbles'; +import { + ActionsSubject, + ReducerManager, + Store, + StoreModule, + select, + ReducerManagerDispatcher, + UPDATE, + ActionReducer, + Action, +} from '../'; +import { StoreConfig } from '../src/store_module'; +import { combineReducers } from '../src/utils'; +import { + counterReducer, + INCREMENT, + DECREMENT, + RESET, + counterReducer2, +} from './fixtures/counter'; +import Spy = jasmine.Spy; +import { take } from 'rxjs/operators'; + +interface TestAppSchema { + counter1: number; + counter2: number; + counter3: number; + counter4?: number; +} + +describe('ngRx Store', () => { + let store: Store; + let dispatcher: ActionsSubject; + + function setup( + initialState: any = { counter1: 0, counter2: 1 }, + metaReducers: any = [] + ) { + const reducers = { + counter1: counterReducer, + counter2: counterReducer, + counter3: counterReducer, + }; + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot(reducers, { initialState, metaReducers })], + }); + + store = TestBed.inject(Store); + dispatcher = TestBed.inject(ActionsSubject); + } + + describe('initial state', () => { + it('should handle an initial state object', (done: any) => { + setup(); + testStoreValue({ counter1: 0, counter2: 1, counter3: 0 }, done); + }); + + it('should handle an initial state function', (done: any) => { + setup(() => ({ counter1: 0, counter2: 5 })); + testStoreValue({ counter1: 0, counter2: 5, counter3: 0 }, done); + }); + + it('should keep initial state values when state is partially initialized', (done: DoneFn) => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({} as any, { + initialState: { + feature1: { + counter1: 1, + }, + feature3: { + counter3: 3, + }, + }, + }), + StoreModule.forFeature('feature1', { counter1: counterReducer }), + StoreModule.forFeature('feature2', { counter2: counterReducer }), + StoreModule.forFeature('feature3', { counter3: counterReducer }), + ], + }); + + testStoreValue( + { + feature1: { counter1: 1 }, + feature2: { counter2: 0 }, + feature3: { counter3: 3 }, + }, + done + ); + }); + + it('should reset to initial state when undefined (root ActionReducerMap)', () => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot( + { counter1: counterReducer }, + { initialState: { counter1: 1 } } + ), + ], + }); + + testInitialState(); + }); + + it('should reset to initial state when undefined (feature ActionReducer)', () => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature('counter1', counterReducer, { + initialState: 1, + }), + ], + }); + + testInitialState(); + }); + + it('should reset to initial state when undefined (feature ActionReducerMap)', () => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + 'feature1', + { counter1: counterReducer }, + { initialState: { counter1: 1 } } + ), + ], + }); + + testInitialState('feature1'); + }); + + function testInitialState(feature?: string) { + store = TestBed.inject(Store); + dispatcher = TestBed.inject(ActionsSubject); + + const actionSequence = '--a--b--c--d--e--f--g'; + const stateSequence = 'i-w-----x-----y--z---'; + const actionValues = { + a: { type: INCREMENT }, + b: { type: 'OTHER' }, + c: { type: RESET }, + d: { type: 'OTHER' }, // reproduces https://github.com/ngrx/platform/issues/880 because state is falsey + e: { type: INCREMENT }, + f: { type: INCREMENT }, + g: { type: 'OTHER' }, + }; + const counterSteps = hot(actionSequence, actionValues); + counterSteps.subscribe(action => store.dispatch(action)); + + const counterStateWithString = feature + ? (store as any).select(feature, 'counter1') + : store.select('counter1'); + + const counter1Values = { i: 1, w: 2, x: 0, y: 1, z: 2 }; + + expect(counterStateWithString).toBeObservable( + hot(stateSequence, counter1Values) + ); + } + + function testStoreValue(expected: any, done: DoneFn) { + store = TestBed.inject(Store); + + store.pipe(take(1)).subscribe({ + next(val) { + expect(val).toEqual(expected); + }, + error: done, + complete: done, + }); + } + }); + + describe('basic store actions', () => { + beforeEach(() => setup()); + + it('should provide an Observable Store', () => { + expect(store).toBeDefined(); + }); + + const actionSequence = '--a--b--c--d--e'; + const actionValues = { + a: { type: INCREMENT }, + b: { type: INCREMENT }, + c: { type: DECREMENT }, + d: { type: RESET }, + e: { type: INCREMENT }, + }; + + it('should let you select state with a key name', () => { + const counterSteps = hot(actionSequence, actionValues); + + counterSteps.subscribe(action => store.dispatch(action)); + + const counterStateWithString = store.pipe(select('counter1')); + + const stateSequence = 'i-v--w--x--y--z'; + const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; + + expect(counterStateWithString).toBeObservable( + hot(stateSequence, counter1Values) + ); + }); + + it('should let you select state with a selector function', () => { + const counterSteps = hot(actionSequence, actionValues); + + counterSteps.subscribe(action => store.dispatch(action)); + + const counterStateWithFunc = store.pipe(select(s => s.counter1)); + + const stateSequence = 'i-v--w--x--y--z'; + const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; + + expect(counterStateWithFunc).toBeObservable( + hot(stateSequence, counter1Values) + ); + }); + + it('should correctly lift itself', () => { + const result = store.pipe(select('counter1')); + + expect(result instanceof Store).toBe(true); + }); + + it('should increment and decrement counter1', () => { + const counterSteps = hot(actionSequence, actionValues); + + counterSteps.subscribe(action => store.dispatch(action)); + + const counterState = store.pipe(select('counter1')); + + const stateSequence = 'i-v--w--x--y--z'; + const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; + + expect(counterState).toBeObservable(hot(stateSequence, counter1Values)); + }); + + it('should increment and decrement counter1 using the dispatcher', () => { + const counterSteps = hot(actionSequence, actionValues); + + counterSteps.subscribe(action => dispatcher.next(action)); + + const counterState = store.pipe(select('counter1')); + + const stateSequence = 'i-v--w--x--y--z'; + const counter1Values = { i: 0, v: 1, w: 2, x: 1, y: 0, z: 1 }; + + expect(counterState).toBeObservable(hot(stateSequence, counter1Values)); + }); + + it('should increment and decrement counter2 separately', () => { + const counterSteps = hot(actionSequence, actionValues); + + counterSteps.subscribe(action => store.dispatch(action)); + + const counter1State = store.pipe(select('counter1')); + const counter2State = store.pipe(select('counter2')); + + const stateSequence = 'i-v--w--x--y--z'; + const counter2Values = { i: 1, v: 2, w: 3, x: 2, y: 0, z: 1 }; + + expect(counter2State).toBeObservable(hot(stateSequence, counter2Values)); + }); + + it('should implement the observer interface forwarding actions and errors to the dispatcher', () => { + spyOn(dispatcher, 'next'); + spyOn(dispatcher, 'error'); + + store.next(1); + store.error(2); + + expect(dispatcher.next).toHaveBeenCalledWith(1); + expect(dispatcher.error).toHaveBeenCalledWith(2); + }); + + it('should not be completable', () => { + const storeSubscription = store.subscribe(); + const dispatcherSubscription = dispatcher.subscribe(); + + store.complete(); + dispatcher.complete(); + + expect(storeSubscription.closed).toBe(false); + expect(dispatcherSubscription.closed).toBe(false); + }); + + it('should complete if the dispatcher is destroyed', () => { + const storeSubscription = store.subscribe(); + const dispatcherSubscription = dispatcher.subscribe(); + + dispatcher.ngOnDestroy(); + + expect(dispatcherSubscription.closed).toBe(true); + }); + }); + + describe(`add/remove reducers`, () => { + let addReducerSpy: Spy; + let removeReducerSpy: Spy; + let reducerManagerDispatcherSpy: Spy; + const key = 'counter4'; + + beforeEach(() => { + setup(); + const reducerManager = TestBed.inject(ReducerManager); + const dispatcher = TestBed.inject(ReducerManagerDispatcher); + addReducerSpy = spyOn(reducerManager, 'addReducer').and.callThrough(); + removeReducerSpy = spyOn( + reducerManager, + 'removeReducer' + ).and.callThrough(); + reducerManagerDispatcherSpy = spyOn(dispatcher, 'next').and.callThrough(); + }); + + it(`should delegate add/remove to ReducerManager`, () => { + store.addReducer(key, counterReducer); + expect(addReducerSpy).toHaveBeenCalledWith(key, counterReducer); + + store.removeReducer(key); + expect(removeReducerSpy).toHaveBeenCalledWith(key); + }); + + it(`should work with added / removed reducers`, () => { + store.addReducer(key, counterReducer); + store.pipe(take(1)).subscribe(val => { + expect(val.counter4).toBe(0); + }); + + store.removeReducer(key); + store.dispatch({ type: INCREMENT }); + store.pipe(take(1)).subscribe(val => { + expect(val.counter4).toBeUndefined(); + }); + }); + + it('should dispatch an update reducers action when a reducer is added', () => { + store.addReducer(key, counterReducer); + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + features: [key], + }); + }); + + it('should dispatch an update reducers action when a reducer is removed', () => { + store.removeReducer(key); + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + features: [key], + }); + }); + }); + + describe('add/remove features', () => { + let reducerManager: ReducerManager; + let reducerManagerDispatcherSpy: Spy; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + }); + + reducerManager = TestBed.inject(ReducerManager); + const dispatcher = TestBed.inject(ReducerManagerDispatcher); + reducerManagerDispatcherSpy = spyOn(dispatcher, 'next').and.callThrough(); + }); + + it('should dispatch an update reducers action when a feature is added', () => { + reducerManager.addFeature({ + key: 'feature1', + reducers: {}, + reducerFactory: combineReducers, + }); + + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + features: ['feature1'], + }); + }); + + it('should dispatch an update reducers action when multiple features are added', () => { + reducerManager.addFeatures([ + { + key: 'feature1', + reducers: {}, + reducerFactory: combineReducers, + }, + { + key: 'feature2', + reducers: {}, + reducerFactory: combineReducers, + }, + ]); + + expect(reducerManagerDispatcherSpy).toHaveBeenCalledTimes(1); + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + features: ['feature1', 'feature2'], + }); + }); + + it('should dispatch an update reducers action when a feature is removed', () => { + reducerManager.removeFeature( + createFeature({ + key: 'feature1', + }) + ); + + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + features: ['feature1'], + }); + }); + + it('should dispatch an update reducers action when multiple features are removed', () => { + reducerManager.removeFeatures([ + createFeature({ + key: 'feature1', + }), + createFeature({ + key: 'feature2', + }), + ]); + + expect(reducerManagerDispatcherSpy).toHaveBeenCalledTimes(1); + expect(reducerManagerDispatcherSpy).toHaveBeenCalledWith({ + type: UPDATE, + features: ['feature1', 'feature2'], + }); + }); + + function createFeature({ key }: { key: string }) { + return { + key, + reducers: {}, + reducerFactory: jasmine.createSpy(`reducerFactory_${key}`), + }; + } + }); + + describe('Meta Reducers', () => { + let metaReducerContainer: any; + let metaReducerSpy1: Spy; + let metaReducerSpy2: Spy; + + beforeEach(() => { + metaReducerContainer = (function() { + function metaReducer1(reducer: ActionReducer) { + return function(state: any, action: Action) { + return reducer(state, action); + }; + } + + function metaReducer2(reducer: ActionReducer) { + return function(state: any, action: Action) { + return reducer(state, action); + }; + } + + return { + metaReducer1: metaReducer1, + metaReducer2: metaReducer2, + }; + })(); + + metaReducerSpy1 = spyOn( + metaReducerContainer, + 'metaReducer1' + ).and.callThrough(); + + metaReducerSpy2 = spyOn( + metaReducerContainer, + 'metaReducer2' + ).and.callThrough(); + }); + + it('should create a meta reducer for root and call it through', () => { + setup({}, [metaReducerContainer.metaReducer1]); + const action = { type: INCREMENT }; + store.dispatch(action); + expect(metaReducerSpy1).toHaveBeenCalled(); + }); + + it('should call two meta reducers', () => { + setup({}, [ + metaReducerContainer.metaReducer1, + metaReducerContainer.metaReducer2, + ]); + const action = { type: INCREMENT }; + store.dispatch(action); + + expect(metaReducerSpy1).toHaveBeenCalled(); + expect(metaReducerSpy2).toHaveBeenCalled(); + }); + + it('should create a meta reducer for feature and call it with the expected reducer', () => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature('counter1', counterReducer, { + metaReducers: [metaReducerContainer.metaReducer1], + }), + StoreModule.forFeature('counter2', counterReducer2, { + metaReducers: [metaReducerContainer.metaReducer2], + }), + ], + }); + + const mockStore = TestBed.inject(Store); + const action = { type: INCREMENT }; + + mockStore.dispatch(action); + + expect(metaReducerSpy1).toHaveBeenCalledWith(counterReducer); + expect(metaReducerSpy2).toHaveBeenCalledWith(counterReducer2); + }); + + it('should initial state with value', (done: DoneFn) => { + const counterInitialState = 2; + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + 'counterState', + { counter: counterReducer }, + { + initialState: { counter: counterInitialState }, + metaReducers: [metaReducerContainer.metaReducer1], + } + ), + ], + }); + + const mockStore = TestBed.inject(Store); + + mockStore.pipe(take(1)).subscribe({ + next(val: any) { + expect(val['counterState'].counter).toEqual(counterInitialState); + }, + error: done, + complete: done, + }); + }); + }); + + describe('Feature config token', () => { + let FEATURE_CONFIG_TOKEN: InjectionToken>; + let FEATURE_CONFIG2_TOKEN: InjectionToken>; + + beforeEach(() => { + FEATURE_CONFIG_TOKEN = new InjectionToken('Feature Config'); + FEATURE_CONFIG2_TOKEN = new InjectionToken('Feature Config2'); + }); + + it('should initial state with value', (done: DoneFn) => { + const initialState = { counter1: 1 }; + const featureKey = 'counter'; + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + featureKey, + counterReducer, + FEATURE_CONFIG_TOKEN + ), + ], + providers: [ + { + provide: FEATURE_CONFIG_TOKEN, + useValue: { initialState: initialState }, + }, + ], + }); + + const mockStore = TestBed.inject(Store); + + mockStore.pipe(take(1)).subscribe({ + next(val: any) { + expect(val[featureKey]).toEqual(initialState); + }, + error: done, + complete: done, + }); + }); + + it('should initial state with value for multi features', (done: DoneFn) => { + const initialState = 1; + const initialState2 = 2; + const initialState3 = 3; + const featureKey = 'counter'; + const featureKey2 = 'counter2'; + const featureKey3 = 'counter3'; + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + featureKey, + counterReducer, + FEATURE_CONFIG_TOKEN + ), + StoreModule.forFeature( + featureKey2, + counterReducer, + FEATURE_CONFIG2_TOKEN + ), + StoreModule.forFeature(featureKey3, counterReducer, { + initialState: initialState3, + }), + ], + providers: [ + { + provide: FEATURE_CONFIG_TOKEN, + useValue: { initialState: initialState }, + }, + { + provide: FEATURE_CONFIG2_TOKEN, + useValue: { initialState: initialState2 }, + }, + ], + }); + + const mockStore = TestBed.inject(Store); + + mockStore.pipe(take(1)).subscribe({ + next(val: any) { + expect(val[featureKey]).toEqual(initialState); + expect(val[featureKey2]).toEqual(initialState2); + expect(val[featureKey3]).toEqual(initialState3); + }, + error: done, + complete: done, + }); + }); + + it('should create a meta reducer with config injection token and call it with the expected reducer', () => { + const metaReducerContainer = (function() { + function metaReducer1(reducer: ActionReducer) { + return function(state: any, action: Action) { + return reducer(state, action); + }; + } + + function metaReducer2(reducer: ActionReducer) { + return function(state: any, action: Action) { + return reducer(state, action); + }; + } + + return { + metaReducer1: metaReducer1, + metaReducer2: metaReducer2, + }; + })(); + + const metaReducerSpy1 = spyOn( + metaReducerContainer, + 'metaReducer1' + ).and.callThrough(); + + const metaReducerSpy2 = spyOn( + metaReducerContainer, + 'metaReducer2' + ).and.callThrough(); + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature( + 'counter1', + counterReducer, + FEATURE_CONFIG_TOKEN + ), + StoreModule.forFeature( + 'counter2', + counterReducer2, + FEATURE_CONFIG2_TOKEN + ), + ], + providers: [ + { + provide: FEATURE_CONFIG_TOKEN, + useValue: { metaReducers: [metaReducerContainer.metaReducer1] }, + }, + { + provide: FEATURE_CONFIG2_TOKEN, + useValue: { metaReducers: [metaReducerContainer.metaReducer2] }, + }, + ], + }); + const mockStore = TestBed.inject(Store); + const action = { type: INCREMENT }; + mockStore.dispatch(action); + + expect(metaReducerSpy1).toHaveBeenCalledWith(counterReducer); + expect(metaReducerSpy2).toHaveBeenCalledWith(counterReducer2); + }); + }); +}); diff --git a/modules/store/testing/spec/mock_store.spec.ts b/modules/store/testing/spec/mock_store.spec.ts index 5ecb49ca91..1984032474 100644 --- a/modules/store/testing/spec/mock_store.spec.ts +++ b/modules/store/testing/spec/mock_store.spec.ts @@ -1,390 +1,390 @@ -import { TestBed, ComponentFixture } from '@angular/core/testing'; -import { skip, take, tap } from 'rxjs/operators'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { - Store, - createSelector, - select, - StoreModule, - MemoizedSelector, - createFeatureSelector, -} from '@ngrx/store'; -import { INCREMENT } from '../../spec/fixtures/counter'; -import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { By } from '@angular/platform-browser'; - -interface TestAppSchema { - counter1: number; - counter2: number; - counter3: number; - counter4?: number; -} - -describe('Mock Store', () => { - let mockStore: MockStore; - const initialState = { counter1: 0, counter2: 1, counter4: 3 }; - const stringSelector = 'counter4'; - const memoizedSelector = createSelector( - () => initialState, - state => state.counter4 - ); - const selectorWithPropMocked = createSelector( - () => initialState, - (state: typeof initialState, add: number) => state.counter4 + add - ); - - const selectorWithProp = createSelector( - () => initialState, - (state: typeof initialState, add: number) => state.counter4 + add - ); - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - provideMockStore({ - initialState, - selectors: [ - { selector: stringSelector, value: 87 }, - { selector: memoizedSelector, value: 98 }, - { selector: selectorWithPropMocked, value: 99 }, - ], - }), - ], - }); - - mockStore = TestBed.get(Store); - }); - - afterEach(() => { - memoizedSelector.release(); - selectorWithProp.release(); - selectorWithPropMocked.release(); - mockStore.resetSelectors(); - }); - - it('should set the initial state to a mocked one', (done: DoneFn) => { - const fixedState = { - counter1: 17, - counter2: 11, - counter3: 25, - }; - mockStore.setState(fixedState); - mockStore.pipe(take(1)).subscribe({ - next(val) { - expect(val).toEqual(fixedState); - }, - error: done.fail, - complete: done, - }); - }); - - it('should allow tracing dispatched actions', () => { - const action = { type: INCREMENT }; - mockStore.scannedActions$ - .pipe(skip(1)) - .subscribe(scannedAction => expect(scannedAction).toEqual(action)); - mockStore.dispatch(action); - }); - - it('should allow mocking of store.select with string selector using provideMockStore', () => { - const expectedValue = 87; - - mockStore - .select(stringSelector) - .subscribe(result => expect(result).toBe(expectedValue)); - }); - - it('should allow mocking of store.select with a memoized selector using provideMockStore', () => { - const expectedValue = 98; - - mockStore - .select(memoizedSelector) - .subscribe(result => expect(result).toBe(expectedValue)); - }); - - it('should allow mocking of store.pipe(select()) with a memoized selector using provideMockStore', () => { - const expectedValue = 98; - - mockStore - .pipe(select(memoizedSelector)) - .subscribe(result => expect(result).toBe(expectedValue)); - }); - - it('should allow mocking of store.select with a memoized selector with Prop using provideMockStore', () => { - const expectedValue = 99; - - mockStore - .select(selectorWithPropMocked, 100) - .subscribe(result => expect(result).toBe(expectedValue)); - }); - - it('should allow mocking of store.pipe(select()) with a memoized selector with Prop using provideMockStore', () => { - const expectedValue = 99; - - mockStore - .pipe(select(selectorWithPropMocked, 200)) - .subscribe(result => expect(result).toBe(expectedValue)); - }); - - it('should allow mocking of store.select with string selector using overrideSelector', () => { - const mockValue = 5; - - mockStore.overrideSelector('counter1', mockValue); - - mockStore - .select('counter1') - .subscribe(result => expect(result).toBe(mockValue)); - }); - - it('should allow mocking of store.select with a memoized selector using overrideSelector', () => { - const mockValue = 5; - const selector = createSelector( - () => initialState, - state => state.counter1 - ); - - mockStore.overrideSelector(selector, mockValue); - - mockStore - .select(selector) - .subscribe(result => expect(result).toBe(mockValue)); - }); - - it('should allow mocking of store.pipe(select()) with a memoized selector using overrideSelector', () => { - const mockValue = 5; - const selector = createSelector( - () => initialState, - state => state.counter2 - ); - - mockStore.overrideSelector(selector, mockValue); - - mockStore - .pipe(select(selector)) - .subscribe(result => expect(result).toBe(mockValue)); - }); - - it('should allow mocking of store.select with a memoized selector with Prop using overrideSelector', () => { - const mockValue = 100; - - mockStore.overrideSelector(selectorWithProp, mockValue); - - mockStore - .select(selectorWithProp, 200) - .subscribe(result => expect(result).toBe(mockValue)); - }); - - it('should allow mocking of store.pipe(select()) with a memoized selector with Prop using overrideSelector', () => { - const mockValue = 1000; - - mockStore.overrideSelector(selectorWithProp, mockValue); - - mockStore - .pipe(select(selectorWithProp, 200)) - .subscribe(result => expect(result).toBe(mockValue)); - }); - - it('should pass through unmocked selectors with Props using store.pipe(select())', () => { - const selectorWithProp = createSelector( - () => initialState, - (state: typeof initialState, add: number) => state.counter4 + add - ); - - mockStore - .pipe(select(selectorWithProp, 6)) - .subscribe(result => expect(result).toBe(9)); - }); - - it('should pass through unmocked selectors with Props using store.select', () => { - const selectorWithProp = createSelector( - () => initialState, - (state: typeof initialState, add: number) => state.counter4 + add - ); - - (mockStore as Store<{}>) - .select(selectorWithProp, 7) - .subscribe(result => expect(result).toBe(10)); - }); - - it('should pass through unmocked selectors', () => { - const mockValue = 5; - const selector = createSelector( - () => initialState, - state => state.counter1 - ); - const selector2 = createSelector( - () => initialState, - state => state.counter2 - ); - const selector3 = createSelector( - selector, - selector2, - (sel1, sel2) => sel1 + sel2 - ); - - mockStore.overrideSelector(selector, mockValue); - - mockStore - .pipe(select(selector2)) - .subscribe(result => expect(result).toBe(1)); - mockStore - .pipe(select(selector3)) - .subscribe(result => expect(result).toBe(6)); - }); - - it('should allow you reset mocked selectors', () => { - const mockValue = 5; - const selector = createSelector( - () => initialState, - state => state.counter1 - ); - const selector2 = createSelector( - () => initialState, - state => state.counter2 - ); - const selector3 = createSelector( - selector, - selector2, - (sel1, sel2) => sel1 + sel2 - ); - - mockStore - .pipe(select(selector3)) - .subscribe(result => expect(result).toBe(1)); - - mockStore.overrideSelector(selector, mockValue); - mockStore.overrideSelector(selector2, mockValue); - selector3.release(); - - mockStore - .pipe(select(selector3)) - .subscribe(result => expect(result).toBe(10)); - - mockStore.resetSelectors(); - selector3.release(); - - mockStore - .pipe(select(selector3)) - .subscribe(result => expect(result).toBe(1)); - }); -}); - -describe('Refreshing state', () => { - type TodoState = { - items: { name: string; done: boolean }[]; - }; - const selectTodosState = createFeatureSelector('todos'); - const todos = createSelector(selectTodosState, todos => todos.items); - const getTodoItems = (elSelector: string) => - fixture.debugElement.queryAll(By.css(elSelector)); - let mockStore: MockStore; - let mockSelector: MemoizedSelector; - const initialTodos = [{ name: 'aaa', done: false }]; - let fixture: ComponentFixture; - - @Component({ - selector: 'app-todos', - template: ` -
    -
  • - {{ todo.name }} -
  • - -

    - {{ todo.name }} -

    -
- `, - }) - class TodosComponent implements OnInit { - todos: Observable; - todosSelect: Observable; - - constructor(private store: Store<{}>) {} - - ngOnInit() { - this.todos = this.store.pipe(select(todos)); - this.todosSelect = this.store.select(todos); - } - } - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [TodosComponent], - providers: [provideMockStore()], - }).compileComponents(); - - mockStore = TestBed.get(Store); - mockSelector = mockStore.overrideSelector(todos, initialTodos); - - fixture = TestBed.createComponent(TodosComponent); - fixture.detectChanges(); - }); - - it('should work with store and select operator', () => { - const newTodos = [{ name: 'bbb', done: true }]; - mockSelector.setResult(newTodos); - mockStore.refreshState(); - - fixture.detectChanges(); - - expect(getTodoItems('li').length).toBe(1); - expect(getTodoItems('li')[0].nativeElement.textContent.trim()).toBe('bbb'); - }); - - it('should work with store.select method', () => { - const newTodos = [{ name: 'bbb', done: true }]; - mockSelector.setResult(newTodos); - mockStore.refreshState(); - - fixture.detectChanges(); - - expect(getTodoItems('p').length).toBe(1); - expect(getTodoItems('p')[0].nativeElement.textContent.trim()).toBe('bbb'); - }); -}); - -describe('Cleans up after each test', () => { - const selectData = createSelector( - (state: any) => state, - state => state.value - ); - - it('should return the mocked selectors value', (done: DoneFn) => { - TestBed.configureTestingModule({ - providers: [ - provideMockStore({ - initialState: { - value: 100, - }, - selectors: [{ selector: selectData, value: 200 }], - }), - ], - }); - - const store = TestBed.get>(Store) as Store; - store.pipe(select(selectData)).subscribe(v => { - expect(v).toBe(200); - done(); - }); - }); - - it('should return the real value', (done: DoneFn) => { - TestBed.configureTestingModule({ - imports: [ - StoreModule.forRoot({} as any, { - initialState: { - value: 300, - }, - }), - ], - }); - - const store = TestBed.get>(Store) as Store; - store.pipe(select(selectData)).subscribe(v => { - expect(v).toBe(300); - done(); - }); - }); -}); +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { skip, take, tap } from 'rxjs/operators'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { + Store, + createSelector, + select, + StoreModule, + MemoizedSelector, + createFeatureSelector, +} from '@ngrx/store'; +import { INCREMENT } from '../../spec/fixtures/counter'; +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { By } from '@angular/platform-browser'; + +interface TestAppSchema { + counter1: number; + counter2: number; + counter3: number; + counter4?: number; +} + +describe('Mock Store', () => { + let mockStore: MockStore; + const initialState = { counter1: 0, counter2: 1, counter4: 3 }; + const stringSelector = 'counter4'; + const memoizedSelector = createSelector( + () => initialState, + state => state.counter4 + ); + const selectorWithPropMocked = createSelector( + () => initialState, + (state: typeof initialState, add: number) => state.counter4 + add + ); + + const selectorWithProp = createSelector( + () => initialState, + (state: typeof initialState, add: number) => state.counter4 + add + ); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + initialState, + selectors: [ + { selector: stringSelector, value: 87 }, + { selector: memoizedSelector, value: 98 }, + { selector: selectorWithPropMocked, value: 99 }, + ], + }), + ], + }); + + mockStore = TestBed.inject(Store); + }); + + afterEach(() => { + memoizedSelector.release(); + selectorWithProp.release(); + selectorWithPropMocked.release(); + mockStore.resetSelectors(); + }); + + it('should set the initial state to a mocked one', (done: DoneFn) => { + const fixedState = { + counter1: 17, + counter2: 11, + counter3: 25, + }; + mockStore.setState(fixedState); + mockStore.pipe(take(1)).subscribe({ + next(val) { + expect(val).toEqual(fixedState); + }, + error: done.fail, + complete: done, + }); + }); + + it('should allow tracing dispatched actions', () => { + const action = { type: INCREMENT }; + mockStore.scannedActions$ + .pipe(skip(1)) + .subscribe(scannedAction => expect(scannedAction).toEqual(action)); + mockStore.dispatch(action); + }); + + it('should allow mocking of store.select with string selector using provideMockStore', () => { + const expectedValue = 87; + + mockStore + .select(stringSelector) + .subscribe(result => expect(result).toBe(expectedValue)); + }); + + it('should allow mocking of store.select with a memoized selector using provideMockStore', () => { + const expectedValue = 98; + + mockStore + .select(memoizedSelector) + .subscribe(result => expect(result).toBe(expectedValue)); + }); + + it('should allow mocking of store.pipe(select()) with a memoized selector using provideMockStore', () => { + const expectedValue = 98; + + mockStore + .pipe(select(memoizedSelector)) + .subscribe(result => expect(result).toBe(expectedValue)); + }); + + it('should allow mocking of store.select with a memoized selector with Prop using provideMockStore', () => { + const expectedValue = 99; + + mockStore + .select(selectorWithPropMocked, 100) + .subscribe(result => expect(result).toBe(expectedValue)); + }); + + it('should allow mocking of store.pipe(select()) with a memoized selector with Prop using provideMockStore', () => { + const expectedValue = 99; + + mockStore + .pipe(select(selectorWithPropMocked, 200)) + .subscribe(result => expect(result).toBe(expectedValue)); + }); + + it('should allow mocking of store.select with string selector using overrideSelector', () => { + const mockValue = 5; + + mockStore.overrideSelector('counter1', mockValue); + + mockStore + .select('counter1') + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should allow mocking of store.select with a memoized selector using overrideSelector', () => { + const mockValue = 5; + const selector = createSelector( + () => initialState, + state => state.counter1 + ); + + mockStore.overrideSelector(selector, mockValue); + + mockStore + .select(selector) + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should allow mocking of store.pipe(select()) with a memoized selector using overrideSelector', () => { + const mockValue = 5; + const selector = createSelector( + () => initialState, + state => state.counter2 + ); + + mockStore.overrideSelector(selector, mockValue); + + mockStore + .pipe(select(selector)) + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should allow mocking of store.select with a memoized selector with Prop using overrideSelector', () => { + const mockValue = 100; + + mockStore.overrideSelector(selectorWithProp, mockValue); + + mockStore + .select(selectorWithProp, 200) + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should allow mocking of store.pipe(select()) with a memoized selector with Prop using overrideSelector', () => { + const mockValue = 1000; + + mockStore.overrideSelector(selectorWithProp, mockValue); + + mockStore + .pipe(select(selectorWithProp, 200)) + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should pass through unmocked selectors with Props using store.pipe(select())', () => { + const selectorWithProp = createSelector( + () => initialState, + (state: typeof initialState, add: number) => state.counter4 + add + ); + + mockStore + .pipe(select(selectorWithProp, 6)) + .subscribe(result => expect(result).toBe(9)); + }); + + it('should pass through unmocked selectors with Props using store.select', () => { + const selectorWithProp = createSelector( + () => initialState, + (state: typeof initialState, add: number) => state.counter4 + add + ); + + (mockStore as Store<{}>) + .select(selectorWithProp, 7) + .subscribe(result => expect(result).toBe(10)); + }); + + it('should pass through unmocked selectors', () => { + const mockValue = 5; + const selector = createSelector( + () => initialState, + state => state.counter1 + ); + const selector2 = createSelector( + () => initialState, + state => state.counter2 + ); + const selector3 = createSelector( + selector, + selector2, + (sel1, sel2) => sel1 + sel2 + ); + + mockStore.overrideSelector(selector, mockValue); + + mockStore + .pipe(select(selector2)) + .subscribe(result => expect(result).toBe(1)); + mockStore + .pipe(select(selector3)) + .subscribe(result => expect(result).toBe(6)); + }); + + it('should allow you reset mocked selectors', () => { + const mockValue = 5; + const selector = createSelector( + () => initialState, + state => state.counter1 + ); + const selector2 = createSelector( + () => initialState, + state => state.counter2 + ); + const selector3 = createSelector( + selector, + selector2, + (sel1, sel2) => sel1 + sel2 + ); + + mockStore + .pipe(select(selector3)) + .subscribe(result => expect(result).toBe(1)); + + mockStore.overrideSelector(selector, mockValue); + mockStore.overrideSelector(selector2, mockValue); + selector3.release(); + + mockStore + .pipe(select(selector3)) + .subscribe(result => expect(result).toBe(10)); + + mockStore.resetSelectors(); + selector3.release(); + + mockStore + .pipe(select(selector3)) + .subscribe(result => expect(result).toBe(1)); + }); +}); + +describe('Refreshing state', () => { + type TodoState = { + items: { name: string; done: boolean }[]; + }; + const selectTodosState = createFeatureSelector('todos'); + const todos = createSelector(selectTodosState, todos => todos.items); + const getTodoItems = (elSelector: string) => + fixture.debugElement.queryAll(By.css(elSelector)); + let mockStore: MockStore; + let mockSelector: MemoizedSelector; + const initialTodos = [{ name: 'aaa', done: false }]; + let fixture: ComponentFixture; + + @Component({ + selector: 'app-todos', + template: ` +
    +
  • + {{ todo.name }} +
  • + +

    + {{ todo.name }} +

    +
+ `, + }) + class TodosComponent implements OnInit { + todos: Observable; + todosSelect: Observable; + + constructor(private store: Store<{}>) {} + + ngOnInit() { + this.todos = this.store.pipe(select(todos)); + this.todosSelect = this.store.select(todos); + } + } + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TodosComponent], + providers: [provideMockStore()], + }).compileComponents(); + + mockStore = TestBed.inject(Store); + mockSelector = mockStore.overrideSelector(todos, initialTodos); + + fixture = TestBed.createComponent(TodosComponent); + fixture.detectChanges(); + }); + + it('should work with store and select operator', () => { + const newTodos = [{ name: 'bbb', done: true }]; + mockSelector.setResult(newTodos); + mockStore.refreshState(); + + fixture.detectChanges(); + + expect(getTodoItems('li').length).toBe(1); + expect(getTodoItems('li')[0].nativeElement.textContent.trim()).toBe('bbb'); + }); + + it('should work with store.select method', () => { + const newTodos = [{ name: 'bbb', done: true }]; + mockSelector.setResult(newTodos); + mockStore.refreshState(); + + fixture.detectChanges(); + + expect(getTodoItems('p').length).toBe(1); + expect(getTodoItems('p')[0].nativeElement.textContent.trim()).toBe('bbb'); + }); +}); + +describe('Cleans up after each test', () => { + const selectData = createSelector( + (state: any) => state, + state => state.value + ); + + it('should return the mocked selectors value', (done: DoneFn) => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + initialState: { + value: 100, + }, + selectors: [{ selector: selectData, value: 200 }], + }), + ], + }); + + const store = TestBed.inject>(Store) as Store; + store.pipe(select(selectData)).subscribe(v => { + expect(v).toBe(200); + done(); + }); + }); + + it('should return the real value', (done: DoneFn) => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({} as any, { + initialState: { + value: 300, + }, + }), + ], + }); + + const store = TestBed.inject>(Store) as Store; + store.pipe(select(selectData)).subscribe(v => { + expect(v).toBe(300); + done(); + }); + }); +}); diff --git a/modules/store/testing/src/mock_store.ts b/modules/store/testing/src/mock_store.ts index e3860cb98f..ca99922da6 100644 --- a/modules/store/testing/src/mock_store.ts +++ b/modules/store/testing/src/mock_store.ts @@ -18,7 +18,7 @@ import { MOCK_SELECTORS } from './tokens'; if (typeof afterEach === 'function') { afterEach(() => { try { - const store = TestBed.get(Store) as MockStore; + const store = TestBed.inject(Store) as MockStore; if (store && 'resetSelectors' in store) { store.resetSelectors(); } @@ -88,7 +88,10 @@ export class MockStore extends Store { MockStore.selectors.set(selector, value); if (typeof selector === 'string') { - const stringSelector = createSelector(() => {}, () => value); + const stringSelector = createSelector( + () => {}, + () => value + ); return stringSelector; } diff --git a/projects/example-app/src/app/auth/containers/login-page.component.spec.ts b/projects/example-app/src/app/auth/containers/login-page.component.spec.ts index 2dfdbbdfdb..a120ae79aa 100644 --- a/projects/example-app/src/app/auth/containers/login-page.component.spec.ts +++ b/projects/example-app/src/app/auth/containers/login-page.component.spec.ts @@ -29,7 +29,7 @@ describe('Login Page', () => { fixture = TestBed.createComponent(LoginPageComponent); instance = fixture.componentInstance; - store = TestBed.get(Store); + store = TestBed.inject(Store); spyOn(store, 'dispatch'); }); diff --git a/projects/example-app/src/app/auth/effects/auth.effects.spec.ts b/projects/example-app/src/app/auth/effects/auth.effects.spec.ts index 90954bb6b0..8bc26b1a4e 100644 --- a/projects/example-app/src/app/auth/effects/auth.effects.spec.ts +++ b/projects/example-app/src/app/auth/effects/auth.effects.spec.ts @@ -44,11 +44,11 @@ describe('AuthEffects', () => { ], }); - effects = TestBed.get(AuthEffects); - authService = TestBed.get(AuthService); - actions$ = TestBed.get(Actions); - routerService = TestBed.get(Router); - dialog = TestBed.get(MatDialog); + effects = TestBed.inject(AuthEffects); + authService = TestBed.inject(AuthService); + actions$ = TestBed.inject(Actions); + routerService = TestBed.inject(Router); + dialog = TestBed.inject(MatDialog); spyOn(routerService, 'navigate').and.callThrough(); }); diff --git a/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts b/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts index a2483de2ba..95d3f2eadd 100644 --- a/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts +++ b/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts @@ -1,37 +1,37 @@ -import { TestBed } from '@angular/core/testing'; -import { Store, MemoizedSelector } from '@ngrx/store'; -import { cold } from 'jasmine-marbles'; -import { AuthGuard } from '@example-app/auth/services'; -import * as fromAuth from '@example-app/auth/reducers'; -import { provideMockStore, MockStore } from '@ngrx/store/testing'; - -describe('Auth Guard', () => { - let guard: AuthGuard; - let store: MockStore; - let loggedIn: MemoizedSelector; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [AuthGuard, provideMockStore()], - }); - - store = TestBed.get(Store); - guard = TestBed.get(AuthGuard); - - loggedIn = store.overrideSelector(fromAuth.selectLoggedIn, false); - }); - - it('should return false if the user state is not logged in', () => { - const expected = cold('(a|)', { a: false }); - - expect(guard.canActivate()).toBeObservable(expected); - }); - - it('should return true if the user state is logged in', () => { - const expected = cold('(a|)', { a: true }); - - loggedIn.setResult(true); - - expect(guard.canActivate()).toBeObservable(expected); - }); -}); +import { TestBed } from '@angular/core/testing'; +import { Store, MemoizedSelector } from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; +import { AuthGuard } from '@example-app/auth/services'; +import * as fromAuth from '@example-app/auth/reducers'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; + +describe('Auth Guard', () => { + let guard: AuthGuard; + let store: MockStore; + let loggedIn: MemoizedSelector; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [AuthGuard, provideMockStore()], + }); + + store = TestBed.inject(Store); + guard = TestBed.inject(AuthGuard); + + loggedIn = store.overrideSelector(fromAuth.selectLoggedIn, false); + }); + + it('should return false if the user state is not logged in', () => { + const expected = cold('(a|)', { a: false }); + + expect(guard.canActivate()).toBeObservable(expected); + }); + + it('should return true if the user state is logged in', () => { + const expected = cold('(a|)', { a: true }); + + loggedIn.setResult(true); + + expect(guard.canActivate()).toBeObservable(expected); + }); +}); diff --git a/projects/example-app/src/app/books/containers/collection-page.component.spec.ts b/projects/example-app/src/app/books/containers/collection-page.component.spec.ts index dcb057922f..62ac854016 100644 --- a/projects/example-app/src/app/books/containers/collection-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/collection-page.component.spec.ts @@ -42,7 +42,7 @@ describe('Collection Page', () => { fixture = TestBed.createComponent(CollectionPageComponent); instance = fixture.componentInstance; - store = TestBed.get(Store); + store = TestBed.inject(Store); spyOn(store, 'dispatch'); }); diff --git a/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts index 8c9ff345c8..5444654538 100644 --- a/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts @@ -55,7 +55,7 @@ describe('Find Book Page', () => { fixture = TestBed.createComponent(FindBookPageComponent); instance = fixture.componentInstance; - store = TestBed.get(Store); + store = TestBed.inject(Store); spyOn(store, 'dispatch'); }); diff --git a/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts index 6a69c6226c..c8b6af7e42 100644 --- a/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts @@ -34,7 +34,7 @@ describe('Selected Book Page', () => { fixture = TestBed.createComponent(SelectedBookPageComponent); instance = fixture.componentInstance; - store = TestBed.get(Store); + store = TestBed.inject(Store); spyOn(store, 'dispatch'); }); diff --git a/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts index 4e1800f24d..51c3d6b86d 100644 --- a/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts @@ -41,8 +41,8 @@ describe('View Book Page', () => { }); fixture = TestBed.createComponent(ViewBookPageComponent); - store = TestBed.get(Store); - route = TestBed.get(ActivatedRoute); + store = TestBed.inject(Store); + route = TestBed.inject(ActivatedRoute); jest.spyOn(store, 'dispatch'); }); diff --git a/projects/example-app/src/app/books/effects/book.effects.spec.ts b/projects/example-app/src/app/books/effects/book.effects.spec.ts index 3c9cac2d37..c3d398ba1d 100644 --- a/projects/example-app/src/app/books/effects/book.effects.spec.ts +++ b/projects/example-app/src/app/books/effects/book.effects.spec.ts @@ -1,93 +1,93 @@ -import { TestBed } from '@angular/core/testing'; - -import { Actions } from '@ngrx/effects'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { Observable } from 'rxjs'; - -import { - BooksApiActions, - FindBookPageActions, -} from '@example-app/books/actions'; -import { BookEffects } from '@example-app/books/effects'; -import { Book } from '@example-app/books/models'; -import { GoogleBooksService } from '@example-app/core/services/google-books.service'; - -describe('BookEffects', () => { - let effects: BookEffects; - let googleBooksService: any; - let actions$: Observable; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - BookEffects, - { - provide: GoogleBooksService, - useValue: { searchBooks: jest.fn() }, - }, - provideMockActions(() => actions$), - ], - }); - - effects = TestBed.get(BookEffects); - googleBooksService = TestBed.get(GoogleBooksService); - actions$ = TestBed.get(Actions); - }); - - describe('search$', () => { - it('should return a book.SearchComplete, with the books, on success, after the de-bounce', () => { - const book1 = { id: '111', volumeInfo: {} } as Book; - const book2 = { id: '222', volumeInfo: {} } as Book; - const books = [book1, book2]; - const action = FindBookPageActions.searchBooks({ query: 'query' }); - const completion = BooksApiActions.searchSuccess({ books }); - - actions$ = hot('-a---', { a: action }); - const response = cold('-a|', { a: books }); - const expected = cold('-----b', { b: completion }); - googleBooksService.searchBooks = jest.fn(() => response); - - expect( - effects.search$({ - debounce: 30, - scheduler: getTestScheduler(), - }) - ).toBeObservable(expected); - }); - - it('should return a book.SearchError if the books service throws', () => { - const action = FindBookPageActions.searchBooks({ query: 'query' }); - const completion = BooksApiActions.searchFailure({ - errorMsg: 'Unexpected Error. Try again later.', - }); - const error = { message: 'Unexpected Error. Try again later.' }; - - actions$ = hot('-a---', { a: action }); - const response = cold('-#|', {}, error); - const expected = cold('-----b', { b: completion }); - googleBooksService.searchBooks = jest.fn(() => response); - - expect( - effects.search$({ - debounce: 30, - scheduler: getTestScheduler(), - }) - ).toBeObservable(expected); - }); - - it(`should not do anything if the query is an empty string`, () => { - const action = FindBookPageActions.searchBooks({ query: '' }); - - actions$ = hot('-a---', { a: action }); - const expected = cold('---'); - - expect( - effects.search$({ - debounce: 30, - scheduler: getTestScheduler(), - }) - ).toBeObservable(expected); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; + +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + +import { + BooksApiActions, + FindBookPageActions, +} from '@example-app/books/actions'; +import { BookEffects } from '@example-app/books/effects'; +import { Book } from '@example-app/books/models'; +import { GoogleBooksService } from '@example-app/core/services/google-books.service'; + +describe('BookEffects', () => { + let effects: BookEffects; + let googleBooksService: any; + let actions$: Observable; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + BookEffects, + { + provide: GoogleBooksService, + useValue: { searchBooks: jest.fn() }, + }, + provideMockActions(() => actions$), + ], + }); + + effects = TestBed.inject(BookEffects); + googleBooksService = TestBed.inject(GoogleBooksService); + actions$ = TestBed.inject(Actions); + }); + + describe('search$', () => { + it('should return a book.SearchComplete, with the books, on success, after the de-bounce', () => { + const book1 = { id: '111', volumeInfo: {} } as Book; + const book2 = { id: '222', volumeInfo: {} } as Book; + const books = [book1, book2]; + const action = FindBookPageActions.searchBooks({ query: 'query' }); + const completion = BooksApiActions.searchSuccess({ books }); + + actions$ = hot('-a---', { a: action }); + const response = cold('-a|', { a: books }); + const expected = cold('-----b', { b: completion }); + googleBooksService.searchBooks = jest.fn(() => response); + + expect( + effects.search$({ + debounce: 30, + scheduler: getTestScheduler(), + }) + ).toBeObservable(expected); + }); + + it('should return a book.SearchError if the books service throws', () => { + const action = FindBookPageActions.searchBooks({ query: 'query' }); + const completion = BooksApiActions.searchFailure({ + errorMsg: 'Unexpected Error. Try again later.', + }); + const error = { message: 'Unexpected Error. Try again later.' }; + + actions$ = hot('-a---', { a: action }); + const response = cold('-#|', {}, error); + const expected = cold('-----b', { b: completion }); + googleBooksService.searchBooks = jest.fn(() => response); + + expect( + effects.search$({ + debounce: 30, + scheduler: getTestScheduler(), + }) + ).toBeObservable(expected); + }); + + it(`should not do anything if the query is an empty string`, () => { + const action = FindBookPageActions.searchBooks({ query: '' }); + + actions$ = hot('-a---', { a: action }); + const expected = cold('---'); + + expect( + effects.search$({ + debounce: 30, + scheduler: getTestScheduler(), + }) + ).toBeObservable(expected); + }); + }); +}); diff --git a/projects/example-app/src/app/books/effects/collection.effects.spec.ts b/projects/example-app/src/app/books/effects/collection.effects.spec.ts index 2b5f0cf34e..28d37f22f3 100644 --- a/projects/example-app/src/app/books/effects/collection.effects.spec.ts +++ b/projects/example-app/src/app/books/effects/collection.effects.spec.ts @@ -1,152 +1,152 @@ -import { TestBed } from '@angular/core/testing'; - -import { - CollectionApiActions, - CollectionPageActions, - SelectedBookPageActions, -} from '@example-app/books/actions'; -import { CollectionEffects } from '@example-app/books/effects'; -import { Book } from '@example-app/books/models'; -import { - BookStorageService, - LOCAL_STORAGE_TOKEN, -} from '@example-app/core/services'; -import { Actions } from '@ngrx/effects'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable } from 'rxjs'; - -describe('CollectionEffects', () => { - let db: any; - let effects: CollectionEffects; - let actions$: Observable; - - const book1 = { id: '111', volumeInfo: {} } as Book; - const book2 = { id: '222', volumeInfo: {} } as Book; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - CollectionEffects, - { - provide: BookStorageService, - useValue: { - supported: jest.fn(), - deleteStoredCollection: jest.fn(), - addToCollection: jest.fn(), - getCollection: jest.fn(), - removeFromCollection: jest.fn(), - }, - }, - { - provide: LOCAL_STORAGE_TOKEN, - useValue: { - removeItem: jest.fn(), - setItem: jest.fn(), - getItem: jest.fn(_ => JSON.stringify([])), - }, - }, - provideMockActions(() => actions$), - ], - }); - - db = TestBed.get(BookStorageService); - effects = TestBed.get(CollectionEffects); - actions$ = TestBed.get(Actions); - }); - describe('checkStorageSupport$', () => { - it('should call db.checkStorageSupport when initially subscribed to', () => { - effects.checkStorageSupport$.subscribe(); - expect(db.supported).toHaveBeenCalled(); - }); - }); - describe('loadCollection$', () => { - it('should return a collection.LoadSuccess, with the books, on success', () => { - const action = CollectionPageActions.loadCollection(); - const completion = CollectionApiActions.loadBooksSuccess({ - books: [book1, book2], - }); - - actions$ = hot('-a', { a: action }); - const response = cold('-a|', { a: [book1, book2] }); - const expected = cold('--c', { c: completion }); - db.getCollection = jest.fn(() => response); - - expect(effects.loadCollection$).toBeObservable(expected); - }); - - it('should return a collection.LoadFail, if the query throws', () => { - const action = CollectionPageActions.loadCollection(); - const error = 'Error!'; - const completion = CollectionApiActions.loadBooksFailure({ error }); - - actions$ = hot('-a', { a: action }); - const response = cold('-#', {}, error); - const expected = cold('--c', { c: completion }); - db.getCollection = jest.fn(() => response); - - expect(effects.loadCollection$).toBeObservable(expected); - }); - }); - - describe('addBookToCollection$', () => { - it('should return a collection.AddBookSuccess, with the book, on success', () => { - const action = SelectedBookPageActions.addBook({ book: book1 }); - const completion = CollectionApiActions.addBookSuccess({ book: book1 }); - - actions$ = hot('-a', { a: action }); - const response = cold('-b', { b: true }); - const expected = cold('--c', { c: completion }); - db.addToCollection = jest.fn(() => response); - - expect(effects.addBookToCollection$).toBeObservable(expected); - expect(db.addToCollection).toHaveBeenCalledWith([book1]); - }); - - it('should return a collection.AddBookFail, with the book, when the db insert throws', () => { - const action = SelectedBookPageActions.addBook({ book: book1 }); - const completion = CollectionApiActions.addBookFailure({ book: book1 }); - const error = 'Error!'; - - actions$ = hot('-a', { a: action }); - const response = cold('-#', {}, error); - const expected = cold('--c', { c: completion }); - db.addToCollection = jest.fn(() => response); - - expect(effects.addBookToCollection$).toBeObservable(expected); - }); - - describe('removeBookFromCollection$', () => { - it('should return a collection.RemoveBookSuccess, with the book, on success', () => { - const action = SelectedBookPageActions.removeBook({ book: book1 }); - const completion = CollectionApiActions.removeBookSuccess({ - book: book1, - }); - - actions$ = hot('-a', { a: action }); - const response = cold('-b', { b: true }); - const expected = cold('--c', { c: completion }); - db.removeFromCollection = jest.fn(() => response); - - expect(effects.removeBookFromCollection$).toBeObservable(expected); - expect(db.removeFromCollection).toHaveBeenCalledWith([book1.id]); - }); - - it('should return a collection.RemoveBookFail, with the book, when the db insert throws', () => { - const action = SelectedBookPageActions.removeBook({ book: book1 }); - const completion = CollectionApiActions.removeBookFailure({ - book: book1, - }); - const error = 'Error!'; - - actions$ = hot('-a', { a: action }); - const response = cold('-#', {}, error); - const expected = cold('--c', { c: completion }); - db.removeFromCollection = jest.fn(() => response); - - expect(effects.removeBookFromCollection$).toBeObservable(expected); - expect(db.removeFromCollection).toHaveBeenCalledWith([book1.id]); - }); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; + +import { + CollectionApiActions, + CollectionPageActions, + SelectedBookPageActions, +} from '@example-app/books/actions'; +import { CollectionEffects } from '@example-app/books/effects'; +import { Book } from '@example-app/books/models'; +import { + BookStorageService, + LOCAL_STORAGE_TOKEN, +} from '@example-app/core/services'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + +describe('CollectionEffects', () => { + let db: any; + let effects: CollectionEffects; + let actions$: Observable; + + const book1 = { id: '111', volumeInfo: {} } as Book; + const book2 = { id: '222', volumeInfo: {} } as Book; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CollectionEffects, + { + provide: BookStorageService, + useValue: { + supported: jest.fn(), + deleteStoredCollection: jest.fn(), + addToCollection: jest.fn(), + getCollection: jest.fn(), + removeFromCollection: jest.fn(), + }, + }, + { + provide: LOCAL_STORAGE_TOKEN, + useValue: { + removeItem: jest.fn(), + setItem: jest.fn(), + getItem: jest.fn(_ => JSON.stringify([])), + }, + }, + provideMockActions(() => actions$), + ], + }); + + db = TestBed.inject(BookStorageService); + effects = TestBed.inject(CollectionEffects); + actions$ = TestBed.inject(Actions); + }); + describe('checkStorageSupport$', () => { + it('should call db.checkStorageSupport when initially subscribed to', () => { + effects.checkStorageSupport$.subscribe(); + expect(db.supported).toHaveBeenCalled(); + }); + }); + describe('loadCollection$', () => { + it('should return a collection.LoadSuccess, with the books, on success', () => { + const action = CollectionPageActions.loadCollection(); + const completion = CollectionApiActions.loadBooksSuccess({ + books: [book1, book2], + }); + + actions$ = hot('-a', { a: action }); + const response = cold('-a|', { a: [book1, book2] }); + const expected = cold('--c', { c: completion }); + db.getCollection = jest.fn(() => response); + + expect(effects.loadCollection$).toBeObservable(expected); + }); + + it('should return a collection.LoadFail, if the query throws', () => { + const action = CollectionPageActions.loadCollection(); + const error = 'Error!'; + const completion = CollectionApiActions.loadBooksFailure({ error }); + + actions$ = hot('-a', { a: action }); + const response = cold('-#', {}, error); + const expected = cold('--c', { c: completion }); + db.getCollection = jest.fn(() => response); + + expect(effects.loadCollection$).toBeObservable(expected); + }); + }); + + describe('addBookToCollection$', () => { + it('should return a collection.AddBookSuccess, with the book, on success', () => { + const action = SelectedBookPageActions.addBook({ book: book1 }); + const completion = CollectionApiActions.addBookSuccess({ book: book1 }); + + actions$ = hot('-a', { a: action }); + const response = cold('-b', { b: true }); + const expected = cold('--c', { c: completion }); + db.addToCollection = jest.fn(() => response); + + expect(effects.addBookToCollection$).toBeObservable(expected); + expect(db.addToCollection).toHaveBeenCalledWith([book1]); + }); + + it('should return a collection.AddBookFail, with the book, when the db insert throws', () => { + const action = SelectedBookPageActions.addBook({ book: book1 }); + const completion = CollectionApiActions.addBookFailure({ book: book1 }); + const error = 'Error!'; + + actions$ = hot('-a', { a: action }); + const response = cold('-#', {}, error); + const expected = cold('--c', { c: completion }); + db.addToCollection = jest.fn(() => response); + + expect(effects.addBookToCollection$).toBeObservable(expected); + }); + + describe('removeBookFromCollection$', () => { + it('should return a collection.RemoveBookSuccess, with the book, on success', () => { + const action = SelectedBookPageActions.removeBook({ book: book1 }); + const completion = CollectionApiActions.removeBookSuccess({ + book: book1, + }); + + actions$ = hot('-a', { a: action }); + const response = cold('-b', { b: true }); + const expected = cold('--c', { c: completion }); + db.removeFromCollection = jest.fn(() => response); + + expect(effects.removeBookFromCollection$).toBeObservable(expected); + expect(db.removeFromCollection).toHaveBeenCalledWith([book1.id]); + }); + + it('should return a collection.RemoveBookFail, with the book, when the db insert throws', () => { + const action = SelectedBookPageActions.removeBook({ book: book1 }); + const completion = CollectionApiActions.removeBookFailure({ + book: book1, + }); + const error = 'Error!'; + + actions$ = hot('-a', { a: action }); + const response = cold('-#', {}, error); + const expected = cold('--c', { c: completion }); + db.removeFromCollection = jest.fn(() => response); + + expect(effects.removeBookFromCollection$).toBeObservable(expected); + expect(db.removeFromCollection).toHaveBeenCalledWith([book1.id]); + }); + }); + }); +}); diff --git a/projects/example-app/src/app/core/effects/router.effects.spec.ts b/projects/example-app/src/app/core/effects/router.effects.spec.ts index 71ea5de9a1..b5a918be49 100644 --- a/projects/example-app/src/app/core/effects/router.effects.spec.ts +++ b/projects/example-app/src/app/core/effects/router.effects.spec.ts @@ -1,41 +1,41 @@ -import { Router, ActivatedRoute, NavigationEnd } from '@angular/router'; -import { Title } from '@angular/platform-browser'; -import { TestBed } from '@angular/core/testing'; - -import { of } from 'rxjs'; - -import { RouterEffects } from '@example-app/core/effects'; - -describe('RouterEffects', () => { - let effects: RouterEffects; - let titleService: Title; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - RouterEffects, - { - provide: Router, - useValue: { events: of(new NavigationEnd(1, '', '')) }, - }, - { - provide: ActivatedRoute, - useValue: { data: of({ title: 'Search' }) }, - }, - { provide: Title, useValue: { setTitle: jest.fn() } }, - ], - }); - - effects = TestBed.get(RouterEffects); - titleService = TestBed.get(Title); - }); - - describe('updateTitle$', () => { - it('should update the title on router navigation', () => { - effects.updateTitle$.subscribe(); - expect(titleService.setTitle).toHaveBeenCalledWith( - 'Book Collection - Search' - ); - }); - }); -}); +import { Router, ActivatedRoute, NavigationEnd } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { TestBed } from '@angular/core/testing'; + +import { of } from 'rxjs'; + +import { RouterEffects } from '@example-app/core/effects'; + +describe('RouterEffects', () => { + let effects: RouterEffects; + let titleService: Title; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RouterEffects, + { + provide: Router, + useValue: { events: of(new NavigationEnd(1, '', '')) }, + }, + { + provide: ActivatedRoute, + useValue: { data: of({ title: 'Search' }) }, + }, + { provide: Title, useValue: { setTitle: jest.fn() } }, + ], + }); + + effects = TestBed.inject(RouterEffects); + titleService = TestBed.inject(Title); + }); + + describe('updateTitle$', () => { + it('should update the title on router navigation', () => { + effects.updateTitle$.subscribe(); + expect(titleService.setTitle).toHaveBeenCalledWith( + 'Book Collection - Search' + ); + }); + }); +}); diff --git a/projects/example-app/src/app/core/effects/user.effects.spec.ts b/projects/example-app/src/app/core/effects/user.effects.spec.ts index 6e06615f54..d19c304be6 100644 --- a/projects/example-app/src/app/core/effects/user.effects.spec.ts +++ b/projects/example-app/src/app/core/effects/user.effects.spec.ts @@ -1,65 +1,59 @@ -import { Action } from '@ngrx/store'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; - -import { UserEffects } from '@example-app/core/effects'; -import { UserActions } from '@example-app/core/actions'; - -describe('UserEffects', () => { - let effects: UserEffects; - const eventsMap: { [key: string]: any } = {}; - - beforeAll(() => { - document.addEventListener = jest.fn((event, cb) => { - eventsMap[event] = cb; - }); - }); - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [UserEffects], - }); - - effects = TestBed.get(UserEffects); - }); - - describe('idle$', () => { - it( - 'should trigger idleTimeout action after 5 minutes', - fakeAsync(() => { - let action: Action | undefined; - effects.idle$.subscribe(res => (action = res)); - - // Initial action to trigger the effect - eventsMap['click'](); - - tick(2 * 60 * 1000); - expect(action).toBeUndefined(); - - tick(3 * 60 * 1000); - expect(action).toBeDefined(); - expect(action!.type).toBe(UserActions.idleTimeout.type); - }) - ); - - it( - 'should reset timeout on user activity', - fakeAsync(() => { - let action: Action | undefined; - effects.idle$.subscribe(res => (action = res)); - - // Initial action to trigger the effect - eventsMap['keydown'](); - - tick(4 * 60 * 1000); - eventsMap['mousemove'](); - - tick(4 * 60 * 1000); - expect(action).toBeUndefined(); - - tick(1 * 60 * 1000); - expect(action).toBeDefined(); - expect(action!.type).toBe(UserActions.idleTimeout.type); - }) - ); - }); -}); +import { Action } from '@ngrx/store'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; + +import { UserEffects } from '@example-app/core/effects'; +import { UserActions } from '@example-app/core/actions'; + +describe('UserEffects', () => { + let effects: UserEffects; + const eventsMap: { [key: string]: any } = {}; + + beforeAll(() => { + document.addEventListener = jest.fn((event, cb) => { + eventsMap[event] = cb; + }); + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [UserEffects], + }); + + effects = TestBed.inject(UserEffects); + }); + + describe('idle$', () => { + it('should trigger idleTimeout action after 5 minutes', fakeAsync(() => { + let action: Action | undefined; + effects.idle$.subscribe(res => (action = res)); + + // Initial action to trigger the effect + eventsMap['click'](); + + tick(2 * 60 * 1000); + expect(action).toBeUndefined(); + + tick(3 * 60 * 1000); + expect(action).toBeDefined(); + expect(action!.type).toBe(UserActions.idleTimeout.type); + })); + + it('should reset timeout on user activity', fakeAsync(() => { + let action: Action | undefined; + effects.idle$.subscribe(res => (action = res)); + + // Initial action to trigger the effect + eventsMap['keydown'](); + + tick(4 * 60 * 1000); + eventsMap['mousemove'](); + + tick(4 * 60 * 1000); + expect(action).toBeUndefined(); + + tick(1 * 60 * 1000); + expect(action).toBeDefined(); + expect(action!.type).toBe(UserActions.idleTimeout.type); + })); + }); +}); diff --git a/projects/example-app/src/app/core/services/book-storage.service.spec.ts b/projects/example-app/src/app/core/services/book-storage.service.spec.ts index 00e5949ae4..217f7021b8 100644 --- a/projects/example-app/src/app/core/services/book-storage.service.spec.ts +++ b/projects/example-app/src/app/core/services/book-storage.service.spec.ts @@ -1,148 +1,148 @@ -import { TestBed } from '@angular/core/testing'; - -import { cold } from 'jasmine-marbles'; - -import { Book } from '@example-app/books/models'; -import { - BookStorageService, - LOCAL_STORAGE_TOKEN, -} from '@example-app/core/services/book-storage.service'; - -describe('BookStorageService', () => { - let fixture: any; - - let localStorageFake: Storage & any = { - removeItem: jest.fn(), - setItem: jest.fn(), - getItem: jest.fn(_ => JSON.stringify(persistedCollection)), - }; - - const book1 = { id: '111', volumeInfo: {} } as Book; - const book2 = { id: '222', volumeInfo: {} } as Book; - const book3 = { id: '333', volumeInfo: {} } as Book; - const book4 = { id: '444', volumeInfo: {} } as Book; - - const persistedStorageKey = 'books-app'; - const persistedCollection = [book2, book4]; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - { - provide: LOCAL_STORAGE_TOKEN, - useValue: localStorageFake, - }, - ], - }); - fixture = TestBed.get(BookStorageService); - }); - - describe('supported', () => { - it('should have truthy value if localStorage provider set', () => { - const expected = cold('(-a|)', { a: true }); - expect(fixture.supported()).toBeObservable(expected); - }); - - it('should throw error if localStorage provider not available', () => { - TestBed.resetTestingModule().configureTestingModule({ - providers: [ - { - provide: LOCAL_STORAGE_TOKEN, - useValue: null, - }, - ], - }); - - fixture = TestBed.get(BookStorageService); - const expected = cold('#', {}, 'Local Storage Not Supported'); - expect(fixture.supported()).toBeObservable(expected); - }); - }); - - describe('getCollection', () => { - it('should call get collection', () => { - const expected = cold('(-a|)', { a: persistedCollection }); - expect(fixture.getCollection()).toBeObservable(expected); - expect(localStorageFake.getItem).toHaveBeenCalledWith( - persistedStorageKey - ); - localStorageFake.getItem.mockClear(); - }); - }); - - describe('addToCollection', () => { - it('should add single item', () => { - const result = [...persistedCollection, book1]; - const expected = cold('(-a|)', { a: result }); - expect(fixture.addToCollection([book1])).toBeObservable(expected); - expect(localStorageFake.setItem).toHaveBeenCalledWith( - persistedStorageKey, - JSON.stringify(result) - ); - - localStorageFake.setItem.mockClear(); - }); - - it('should add multiple items', () => { - const result = [...persistedCollection, book1, book3]; - const expected = cold('(-a|)', { a: result }); - expect(fixture.addToCollection([book1, book3])).toBeObservable(expected); - expect(localStorageFake.setItem).toHaveBeenCalledWith( - persistedStorageKey, - JSON.stringify(result) - ); - localStorageFake.setItem.mockClear(); - }); - }); - - describe('removeFromCollection', () => { - it('should remove item from collection', () => { - const filterCollection = persistedCollection.filter( - f => f.id !== book2.id - ); - const expected = cold('(-a|)', { a: filterCollection }); - expect(fixture.removeFromCollection([book2.id])).toBeObservable(expected); - expect(localStorageFake.getItem).toHaveBeenCalledWith( - persistedStorageKey - ); - expect(localStorageFake.setItem).toHaveBeenCalledWith( - persistedStorageKey, - JSON.stringify(filterCollection) - ); - localStorageFake.getItem.mockClear(); - }); - - it('should remove multiple items from collection', () => { - const filterCollection = persistedCollection.filter( - f => f.id !== book4.id - ); - const expected = cold('(-a|)', { a: filterCollection }); - expect(fixture.removeFromCollection([book4.id])).toBeObservable(expected); - expect(localStorageFake.getItem).toHaveBeenCalledWith( - persistedStorageKey - ); - expect(localStorageFake.setItem).toHaveBeenCalledWith( - persistedStorageKey, - JSON.stringify(filterCollection) - ); - localStorageFake.getItem.mockClear(); - }); - - it('should ignore items not present in collection', () => { - const filterCollection = persistedCollection; - const expected = cold('(-a|)', { a: filterCollection }); - expect(fixture.removeFromCollection([book1.id])).toBeObservable(expected); - }); - }); - - describe('deleteCollection', () => { - it('should delete storage key and collection', () => { - const expected = cold('(-a|)', { a: true }); - expect(fixture.deleteCollection()).toBeObservable(expected); - expect(localStorageFake.removeItem).toHaveBeenCalledWith( - persistedStorageKey - ); - localStorageFake.removeItem.mockClear(); - }); - }); -}); +import { TestBed } from '@angular/core/testing'; + +import { cold } from 'jasmine-marbles'; + +import { Book } from '@example-app/books/models'; +import { + BookStorageService, + LOCAL_STORAGE_TOKEN, +} from '@example-app/core/services/book-storage.service'; + +describe('BookStorageService', () => { + let fixture: any; + + let localStorageFake: Storage & any = { + removeItem: jest.fn(), + setItem: jest.fn(), + getItem: jest.fn(_ => JSON.stringify(persistedCollection)), + }; + + const book1 = { id: '111', volumeInfo: {} } as Book; + const book2 = { id: '222', volumeInfo: {} } as Book; + const book3 = { id: '333', volumeInfo: {} } as Book; + const book4 = { id: '444', volumeInfo: {} } as Book; + + const persistedStorageKey = 'books-app'; + const persistedCollection = [book2, book4]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: LOCAL_STORAGE_TOKEN, + useValue: localStorageFake, + }, + ], + }); + fixture = TestBed.inject(BookStorageService); + }); + + describe('supported', () => { + it('should have truthy value if localStorage provider set', () => { + const expected = cold('(-a|)', { a: true }); + expect(fixture.supported()).toBeObservable(expected); + }); + + it('should throw error if localStorage provider not available', () => { + TestBed.resetTestingModule().configureTestingModule({ + providers: [ + { + provide: LOCAL_STORAGE_TOKEN, + useValue: null, + }, + ], + }); + + fixture = TestBed.inject(BookStorageService); + const expected = cold('#', {}, 'Local Storage Not Supported'); + expect(fixture.supported()).toBeObservable(expected); + }); + }); + + describe('getCollection', () => { + it('should call get collection', () => { + const expected = cold('(-a|)', { a: persistedCollection }); + expect(fixture.getCollection()).toBeObservable(expected); + expect(localStorageFake.getItem).toHaveBeenCalledWith( + persistedStorageKey + ); + localStorageFake.getItem.mockClear(); + }); + }); + + describe('addToCollection', () => { + it('should add single item', () => { + const result = [...persistedCollection, book1]; + const expected = cold('(-a|)', { a: result }); + expect(fixture.addToCollection([book1])).toBeObservable(expected); + expect(localStorageFake.setItem).toHaveBeenCalledWith( + persistedStorageKey, + JSON.stringify(result) + ); + + localStorageFake.setItem.mockClear(); + }); + + it('should add multiple items', () => { + const result = [...persistedCollection, book1, book3]; + const expected = cold('(-a|)', { a: result }); + expect(fixture.addToCollection([book1, book3])).toBeObservable(expected); + expect(localStorageFake.setItem).toHaveBeenCalledWith( + persistedStorageKey, + JSON.stringify(result) + ); + localStorageFake.setItem.mockClear(); + }); + }); + + describe('removeFromCollection', () => { + it('should remove item from collection', () => { + const filterCollection = persistedCollection.filter( + f => f.id !== book2.id + ); + const expected = cold('(-a|)', { a: filterCollection }); + expect(fixture.removeFromCollection([book2.id])).toBeObservable(expected); + expect(localStorageFake.getItem).toHaveBeenCalledWith( + persistedStorageKey + ); + expect(localStorageFake.setItem).toHaveBeenCalledWith( + persistedStorageKey, + JSON.stringify(filterCollection) + ); + localStorageFake.getItem.mockClear(); + }); + + it('should remove multiple items from collection', () => { + const filterCollection = persistedCollection.filter( + f => f.id !== book4.id + ); + const expected = cold('(-a|)', { a: filterCollection }); + expect(fixture.removeFromCollection([book4.id])).toBeObservable(expected); + expect(localStorageFake.getItem).toHaveBeenCalledWith( + persistedStorageKey + ); + expect(localStorageFake.setItem).toHaveBeenCalledWith( + persistedStorageKey, + JSON.stringify(filterCollection) + ); + localStorageFake.getItem.mockClear(); + }); + + it('should ignore items not present in collection', () => { + const filterCollection = persistedCollection; + const expected = cold('(-a|)', { a: filterCollection }); + expect(fixture.removeFromCollection([book1.id])).toBeObservable(expected); + }); + }); + + describe('deleteCollection', () => { + it('should delete storage key and collection', () => { + const expected = cold('(-a|)', { a: true }); + expect(fixture.deleteCollection()).toBeObservable(expected); + expect(localStorageFake.removeItem).toHaveBeenCalledWith( + persistedStorageKey + ); + localStorageFake.removeItem.mockClear(); + }); + }); +}); diff --git a/projects/example-app/src/app/core/services/google-books.service.spec.ts b/projects/example-app/src/app/core/services/google-books.service.spec.ts index c51b29fbc1..da252dc05c 100644 --- a/projects/example-app/src/app/core/services/google-books.service.spec.ts +++ b/projects/example-app/src/app/core/services/google-books.service.spec.ts @@ -1,57 +1,57 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpClient } from '@angular/common/http'; - -import { cold } from 'jasmine-marbles'; - -import { GoogleBooksService } from '@example-app/core/services/google-books.service'; - -describe('Service: GoogleBooks', () => { - let service: GoogleBooksService; - let http: HttpClient; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ provide: HttpClient, useValue: { get: jest.fn() } }], - }); - - service = TestBed.get(GoogleBooksService); - http = TestBed.get(HttpClient); - }); - - const data = { - title: 'Book Title', - author: 'John Smith', - volumeId: '12345', - }; - - const books = { - items: [ - { id: '12345', volumeInfo: { title: 'Title' } }, - { id: '67890', volumeInfo: { title: 'Another Title' } }, - ], - }; - - const queryTitle = 'Book Title'; - - it('should call the search api and return the search results', () => { - const response = cold('-a|', { a: books }); - const expected = cold('-b|', { b: books.items }); - http.get = jest.fn(() => response); - - expect(service.searchBooks(queryTitle)).toBeObservable(expected); - expect(http.get).toHaveBeenCalledWith( - `https://www.googleapis.com/books/v1/volumes?orderBy=newest&q=${queryTitle}` - ); - }); - - it('should retrieve the book from the volumeId', () => { - const response = cold('-a|', { a: data }); - const expected = cold('-b|', { b: data }); - http.get = jest.fn(() => response); - - expect(service.retrieveBook(data.volumeId)).toBeObservable(expected); - expect(http.get).toHaveBeenCalledWith( - `https://www.googleapis.com/books/v1/volumes/${data.volumeId}` - ); - }); -}); +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; + +import { cold } from 'jasmine-marbles'; + +import { GoogleBooksService } from '@example-app/core/services/google-books.service'; + +describe('Service: GoogleBooks', () => { + let service: GoogleBooksService; + let http: HttpClient; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: HttpClient, useValue: { get: jest.fn() } }], + }); + + service = TestBed.inject(GoogleBooksService); + http = TestBed.inject(HttpClient); + }); + + const data = { + title: 'Book Title', + author: 'John Smith', + volumeId: '12345', + }; + + const books = { + items: [ + { id: '12345', volumeInfo: { title: 'Title' } }, + { id: '67890', volumeInfo: { title: 'Another Title' } }, + ], + }; + + const queryTitle = 'Book Title'; + + it('should call the search api and return the search results', () => { + const response = cold('-a|', { a: books }); + const expected = cold('-b|', { b: books.items }); + http.get = jest.fn(() => response); + + expect(service.searchBooks(queryTitle)).toBeObservable(expected); + expect(http.get).toHaveBeenCalledWith( + `https://www.googleapis.com/books/v1/volumes?orderBy=newest&q=${queryTitle}` + ); + }); + + it('should retrieve the book from the volumeId', () => { + const response = cold('-a|', { a: data }); + const expected = cold('-b|', { b: data }); + http.get = jest.fn(() => response); + + expect(service.retrieveBook(data.volumeId)).toBeObservable(expected); + expect(http.get).toHaveBeenCalledWith( + `https://www.googleapis.com/books/v1/volumes/${data.volumeId}` + ); + }); +}); diff --git a/projects/ngrx.io/content/examples/testing-store/src/app/user-greeting.component.spec.ts b/projects/ngrx.io/content/examples/testing-store/src/app/user-greeting.component.spec.ts index 44bdc4d960..a60d67a6be 100644 --- a/projects/ngrx.io/content/examples/testing-store/src/app/user-greeting.component.spec.ts +++ b/projects/ngrx.io/content/examples/testing-store/src/app/user-greeting.component.spec.ts @@ -1,37 +1,37 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Store, MemoizedSelector } from '@ngrx/store'; -import { provideMockStore, MockStore } from '@ngrx/store/testing'; -import { UserGreetingComponent } from './user-greeting.component'; -import * as fromAuth from './reducers'; - -describe('User Greeting Component', () => { - let fixture: ComponentFixture; - let mockStore: MockStore; - let mockUsernameSelector: MemoizedSelector; - const queryDivText = () => - fixture.debugElement.queryAll(By.css('div'))[0].nativeElement.textContent; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideMockStore()], - declarations: [UserGreetingComponent], - }); - - fixture = TestBed.createComponent(UserGreetingComponent); - mockStore = TestBed.get(Store); - mockUsernameSelector = mockStore.overrideSelector(fromAuth.getUsername, 'John'); - fixture.detectChanges(); - }); - - it('should greet John when the username is John', () => { - expect(queryDivText()).toBe('Greetings, John!'); - }); - - it('should greet Brandon when the username is Brandon', () => { - mockUsernameSelector.setResult('Brandon'); - mockStore.refreshState(); - fixture.detectChanges(); - expect(queryDivText()).toBe('Greetings, Brandon!'); - }); -}); +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Store, MemoizedSelector } from '@ngrx/store'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { UserGreetingComponent } from './user-greeting.component'; +import * as fromAuth from './reducers'; + +describe('User Greeting Component', () => { + let fixture: ComponentFixture; + let mockStore: MockStore; + let mockUsernameSelector: MemoizedSelector; + const queryDivText = () => + fixture.debugElement.queryAll(By.css('div'))[0].nativeElement.textContent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideMockStore()], + declarations: [UserGreetingComponent], + }); + + fixture = TestBed.createComponent(UserGreetingComponent); + mockStore = TestBed.inject(Store); + mockUsernameSelector = mockStore.overrideSelector(fromAuth.getUsername, 'John'); + fixture.detectChanges(); + }); + + it('should greet John when the username is John', () => { + expect(queryDivText()).toBe('Greetings, John!'); + }); + + it('should greet Brandon when the username is Brandon', () => { + mockUsernameSelector.setResult('Brandon'); + mockStore.refreshState(); + fixture.detectChanges(); + expect(queryDivText()).toBe('Greetings, Brandon!'); + }); +}); diff --git a/projects/ngrx.io/content/guide/migration/v4.md b/projects/ngrx.io/content/guide/migration/v4.md index e27ca2d682..2603a71a8b 100644 --- a/projects/ngrx.io/content/guide/migration/v4.md +++ b/projects/ngrx.io/content/guide/migration/v4.md @@ -1,569 +1,569 @@ -# V4 Update Guide - -## Dependencies - -You need to have the latest versions of TypeScript and RxJS to use NgRx version 4 libraries. - -TypeScript 2.4.x -RxJS 5.4.x - -## @ngrx/core - -`@ngrx/core` is no longer needed and conflicts with @ngrx/store. Remove the dependency from your project. - -BEFORE: - -```ts -import { compose } from '@ngrx/core/compose'; -``` - -AFTER: - -```ts -import { compose } from '@ngrx/store'; -``` - -## @ngrx/store - -### Action interface - -The `payload` property has been removed from the `Action` interface. It was a source of type-safety -issues, especially when used with `@ngrx/effects`. If your interface/class has a payload, you need to provide -the type. - -BEFORE: - -```ts -import { Action } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Effect, Actions } from '@ngrx/effects'; - -@Injectable() -export class MyEffects { - @Effect() - someEffect$: Observable = this.actions$ - .ofType(UserActions.LOGIN) - .pipe( - map(action => action.payload), - map(() => new AnotherAction()) - ); - - constructor(private actions$: Actions) {} -} -``` - -AFTER: - -```ts -import { Action } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Effect, Actions } from '@ngrx/effects'; -import { Login } from '../actions/auth'; - -@Injectable() -export class MyEffects { - @Effect() - someEffect$: Observable = this.actions$ - .ofType(UserActions.LOGIN) - .pipe( - map(action => action.payload), - map(() => new AnotherAction()) - ); - - constructor(private actions$: Actions) {} -} -``` - -If you prefer to keep the `payload` interface property, you can provide your own parameterized version. - -```ts -export interface ActionWithPayload extends Action { - payload: T; -} -``` - -And if you need an unsafe version to help with transition. - -```ts -export interface UnsafeAction extends Action { - payload?: any; -} -``` - -### Registering Reducers - -Previously to be AOT compatible, it was required to pass a function to the `provideStore` method to compose the reducers into one root reducer. The `initialState` was also provided to the method as an object in the second argument. - -BEFORE: - -`reducers/index.ts` - -```ts -const reducers = { - auth: fromAuth.reducer, - layout: fromLayout.reducer, -}; - -const rootReducer = combineReducers(reducers); - -export function reducer(state: any, action: any) { - return rootReducer(state, action); -} -``` - -`app.module.ts` - -```ts -import { StoreModule } from '@ngrx/store'; -import { reducer } from './reducers'; - -@NgModule({ - imports: [ - StoreModule.provideStore(reducer, { - auth: { - loggedIn: true, - }, - }), - ], -}) -export class AppModule {} -``` - -This has been simplified to only require a map of reducers that will be composed together by the library. A second argument is a configuration object where you provide the `initialState`. - -AFTER: - -`reducers/index.ts` - -```ts -import { ActionReducerMap } from '@ngrx/store'; - -export interface State { - auth: fromAuth.State; - layout: fromLayout.State; -} - -export const reducers: ActionReducerMap = { - auth: fromAuth.reducer, - layout: fromLayout.reducer, -}; -``` - -`app.module.ts` - -```ts -import { StoreModule } from '@ngrx/store'; -import { reducers } from './reducers'; - -@NgModule({ - imports: [ - StoreModule.forRoot(reducers, { - initialState: { - auth: { - loggedIn: true, - }, - }, - }), - ], -}) -export class AppModule {} -``` - -## @ngrx/effects - -### Registering Effects - -BEFORE: - -`app.module.ts` - -```ts -@NgModule({ - imports: [EffectsModule.run(SourceA), EffectsModule.run(SourceB)], -}) -export class AppModule {} -``` - -AFTER: - -The `EffectsModule.forRoot` method is _required_ in your root `AppModule`. Provide an empty array -if you don't need to register any root-level effects. - -`app.module.ts` - -```ts -@NgModule({ - imports: [EffectsModule.forRoot([SourceA, SourceB, SourceC])], -}) -export class AppModule {} -``` - -Import `EffectsModule.forFeature` in any NgModule, whether be the `AppModule` or a feature module. - -`feature.module.ts` - -```ts -@NgModule({ - imports: [ - EffectsModule.forFeature([FeatureSourceA, FeatureSourceB, FeatureSourceC]), - ], -}) -export class FeatureModule {} -``` - -### Init Action - -The `@ngrx/store/init` action now fires prior to effects starting. Use defer() for the same behaviour. - -BEFORE: - -`app.effects.ts` - -```ts -import { Dispatcher, Action } from '@ngrx/store'; -import { Actions, Effect } from '@ngrx/effects'; - -import * as auth from '../actions/auth.actions'; - -@Injectable() -export class AppEffects { - @Effect() - init$: Observable = this.actions$ - .ofType(Dispatcher.INIT) - .switchMap(action => { - return of(new auth.LoginAction()); - }); - - constructor(private actions$: Actions) {} -} -``` - -AFTER: - -`app.effects.ts` - -```ts -import { Action } from '@ngrx/store'; -import { Actions, Effect } from '@ngrx/effects'; -import { defer } from 'rxjs'; - -import * as auth from '../actions/auth.actions'; - -@Injectable() -export class AppEffects { - @Effect() - init$: Observable = defer(() => { - return of(new auth.LoginAction()); - }); - - constructor(private actions$: Actions) {} -} -``` - -### Testing Effects - -BEFORE: - -```ts -import { EffectsTestingModule, EffectsRunner } from '@ngrx/effects/testing'; -import { MyEffects } from './my-effects'; - -describe('My Effects', () => { - let effects: MyEffects; - let runner: EffectsRunner; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [EffectsTestingModule], - providers: [ - MyEffects, - // other providers - ], - }); - - effects = TestBed.get(MyEffects); - runner = TestBed.get(EffectsRunner); - }); - - it('should work', () => { - runner.queue(SomeAction); - - effects.someSource$.subscribe(result => { - expect(result).toBe(AnotherAction); - }); - }); -}); -``` - -AFTER: - -```ts -import { TestBed } from '@angular/core/testing'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { hot, cold } from 'jasmine-marbles'; -import { MyEffects } from './my-effects'; -import { ReplaySubject } from 'rxjs/ReplaySubject'; - -describe('My Effects', () => { - let effects: MyEffects; - let actions: Observable; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - MyEffects, - provideMockActions(() => actions), - // other providers - ], - }); - - effects = TestBed.get(MyEffects); - }); - - it('should work', () => { - actions = hot('--a-', { a: SomeAction, ... }); - - const expected = cold('--b', { b: AnotherAction }); - - expect(effects.someSource$).toBeObservable(expected); - }); - - it('should work also', () => { - actions = new ReplaySubject(1); - - actions.next(SomeAction); - - effects.someSource$.subscribe(result => { - expect(result).toBe(AnotherAction); - }); - }); -}); -``` - -## @ngrx/router-store - -### Registering the module - -BEFORE: - -`reducers/index.ts` - -```ts -import * as fromRouter from '@ngrx/router-store'; - -export interface State { - router: fromRouter.RouterState; -} - -const reducers = { - router: fromRouter.routerReducer, -}; - -const rootReducer = combineReducers(reducers); - -export function reducer(state: any, action: any) { - return rootReducer(state, action); -} -``` - -`app.module.ts` - -```ts -import { RouterModule } from '@angular/router'; -import { RouterStoreModule } from '@ngrx/router-store'; -import { reducer } from './reducers'; - -@NgModule({ - imports: [ - StoreModule.provideStore(reducer), - RouterModule.forRoot([ - // some routes - ]) - RouterStoreModule.connectRouter() - ] -}) -export class AppModule {} -``` - -AFTER: - -`reducers/index.ts` - -```ts -import * as fromRouter from '@ngrx/router-store'; - -export interface State { - routerReducer: fromRouter.RouterReducerState; -} - -export const reducers = { - routerReducer: fromRouter.routerReducer, -}; -``` - -`app.module.ts` - -```ts -import { StoreRouterConnectingModule } from '@ngrx/router-store'; -import { reducers } from './reducers'; - -@NgModule({ - imports: [ - StoreModule.forRoot(reducers), - RouterModule.forRoot([ - // some routes - ]), - StoreRouterConnectingModule, - ], -}) -export class AppModule {} -``` - -### Navigation actions - -Navigation actions are not provided as part of the V4 package. You provide your own -custom navigation actions that use the `Router` within effects to navigate. - -BEFORE: - -```ts -import { go, back, forward } from '@ngrx/router-store'; - -store.dispatch( - go(['/path', { routeParam: 1 }], { page: 1 }, { replaceUrl: false }) -); - -store.dispatch(back()); - -store.dispatch(forward()); -``` - -AFTER: - -```ts -import { Action } from '@ngrx/store'; -import { NavigationExtras } from '@angular/router'; - -export const GO = '[Router] Go'; -export const BACK = '[Router] Back'; -export const FORWARD = '[Router] Forward'; - -export class Go implements Action { - readonly type = GO; - - constructor( - public payload: { - path: any[]; - query?: object; - extras?: NavigationExtras; - } - ) {} -} - -export class Back implements Action { - readonly type = BACK; -} - -export class Forward implements Action { - readonly type = FORWARD; -} - -export type Actions = Go | Back | Forward; -``` - -```ts -import * as RouterActions from './actions/router'; - -store.dispatch(new RouterActions.Go({ - path: ['/path', { routeParam: 1 }], - query: { page: 1 }, - extras: { replaceUrl: false } -}); - -store.dispatch(new RouterActions.Back()); - -store.dispatch(new RouterActions.Forward()); -``` - -```ts -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { Location } from '@angular/common'; -import { Effect, Actions } from '@ngrx/effects'; -import { map, tap } from 'rxjs/operators'; -import * as RouterActions from './actions/router'; - -@Injectable() -export class RouterEffects { - @Effect({ dispatch: false }) - navigate$ = this.actions$.ofType(RouterActions.GO).pipe( - map((action: RouterActions.Go) => action.payload), - tap(({ path, query: queryParams, extras }) => - this.router.navigate(path, { queryParams, ...extras }) - ) - ); - - @Effect({ dispatch: false }) - navigateBack$ = this.actions$ - .ofType(RouterActions.BACK) - .do(() => this.location.back()); - - @Effect({ dispatch: false }) - navigateForward$ = this.actions$ - .ofType(RouterActions.FORWARD) - .do(() => this.location.forward()); - - constructor( - private actions$: Actions, - private router: Router, - private location: Location - ) {} -} -``` - -## @ngrx/store-devtools - -### Instrumentation method - -**NOTE:** store-devtools currently causes severe performance problems when -used with router-store. We are working to -[fix this](https://github.com/ngrx/platform/issues/97), but for now, avoid -using them together. - -BEFORE: - -`app.module.ts` - -```ts -import { StoreDevtoolsModule } from '@ngrx/store-devtools'; - -@NgModule({ - imports: [ - StoreDevtoolsModule.instrumentStore({ maxAge: 50 }), - // OR - StoreDevtoolsModule.instrumentOnlyWithExtension({ - maxAge: 50, - }), - ], -}) -export class AppModule {} -``` - -AFTER: - -`app.module.ts` - -```ts -import { StoreDevtoolsModule } from '@ngrx/store-devtools'; -import { environment } from '../environments/environment'; // Angular CLI environment - -@NgModule({ - imports: [ - !environment.production - ? StoreDevtoolsModule.instrument({ maxAge: 50 }) - : [], - ], -}) -export class AppModule {} -``` +# V4 Update Guide + +## Dependencies + +You need to have the latest versions of TypeScript and RxJS to use NgRx version 4 libraries. + +TypeScript 2.4.x +RxJS 5.4.x + +## @ngrx/core + +`@ngrx/core` is no longer needed and conflicts with @ngrx/store. Remove the dependency from your project. + +BEFORE: + +```ts +import { compose } from '@ngrx/core/compose'; +``` + +AFTER: + +```ts +import { compose } from '@ngrx/store'; +``` + +## @ngrx/store + +### Action interface + +The `payload` property has been removed from the `Action` interface. It was a source of type-safety +issues, especially when used with `@ngrx/effects`. If your interface/class has a payload, you need to provide +the type. + +BEFORE: + +```ts +import { Action } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Effect, Actions } from '@ngrx/effects'; + +@Injectable() +export class MyEffects { + @Effect() + someEffect$: Observable = this.actions$ + .ofType(UserActions.LOGIN) + .pipe( + map(action => action.payload), + map(() => new AnotherAction()) + ); + + constructor(private actions$: Actions) {} +} +``` + +AFTER: + +```ts +import { Action } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Effect, Actions } from '@ngrx/effects'; +import { Login } from '../actions/auth'; + +@Injectable() +export class MyEffects { + @Effect() + someEffect$: Observable = this.actions$ + .ofType(UserActions.LOGIN) + .pipe( + map(action => action.payload), + map(() => new AnotherAction()) + ); + + constructor(private actions$: Actions) {} +} +``` + +If you prefer to keep the `payload` interface property, you can provide your own parameterized version. + +```ts +export interface ActionWithPayload extends Action { + payload: T; +} +``` + +And if you need an unsafe version to help with transition. + +```ts +export interface UnsafeAction extends Action { + payload?: any; +} +``` + +### Registering Reducers + +Previously to be AOT compatible, it was required to pass a function to the `provideStore` method to compose the reducers into one root reducer. The `initialState` was also provided to the method as an object in the second argument. + +BEFORE: + +`reducers/index.ts` + +```ts +const reducers = { + auth: fromAuth.reducer, + layout: fromLayout.reducer, +}; + +const rootReducer = combineReducers(reducers); + +export function reducer(state: any, action: any) { + return rootReducer(state, action); +} +``` + +`app.module.ts` + +```ts +import { StoreModule } from '@ngrx/store'; +import { reducer } from './reducers'; + +@NgModule({ + imports: [ + StoreModule.provideStore(reducer, { + auth: { + loggedIn: true, + }, + }), + ], +}) +export class AppModule {} +``` + +This has been simplified to only require a map of reducers that will be composed together by the library. A second argument is a configuration object where you provide the `initialState`. + +AFTER: + +`reducers/index.ts` + +```ts +import { ActionReducerMap } from '@ngrx/store'; + +export interface State { + auth: fromAuth.State; + layout: fromLayout.State; +} + +export const reducers: ActionReducerMap = { + auth: fromAuth.reducer, + layout: fromLayout.reducer, +}; +``` + +`app.module.ts` + +```ts +import { StoreModule } from '@ngrx/store'; +import { reducers } from './reducers'; + +@NgModule({ + imports: [ + StoreModule.forRoot(reducers, { + initialState: { + auth: { + loggedIn: true, + }, + }, + }), + ], +}) +export class AppModule {} +``` + +## @ngrx/effects + +### Registering Effects + +BEFORE: + +`app.module.ts` + +```ts +@NgModule({ + imports: [EffectsModule.run(SourceA), EffectsModule.run(SourceB)], +}) +export class AppModule {} +``` + +AFTER: + +The `EffectsModule.forRoot` method is _required_ in your root `AppModule`. Provide an empty array +if you don't need to register any root-level effects. + +`app.module.ts` + +```ts +@NgModule({ + imports: [EffectsModule.forRoot([SourceA, SourceB, SourceC])], +}) +export class AppModule {} +``` + +Import `EffectsModule.forFeature` in any NgModule, whether be the `AppModule` or a feature module. + +`feature.module.ts` + +```ts +@NgModule({ + imports: [ + EffectsModule.forFeature([FeatureSourceA, FeatureSourceB, FeatureSourceC]), + ], +}) +export class FeatureModule {} +``` + +### Init Action + +The `@ngrx/store/init` action now fires prior to effects starting. Use defer() for the same behaviour. + +BEFORE: + +`app.effects.ts` + +```ts +import { Dispatcher, Action } from '@ngrx/store'; +import { Actions, Effect } from '@ngrx/effects'; + +import * as auth from '../actions/auth.actions'; + +@Injectable() +export class AppEffects { + @Effect() + init$: Observable = this.actions$ + .ofType(Dispatcher.INIT) + .switchMap(action => { + return of(new auth.LoginAction()); + }); + + constructor(private actions$: Actions) {} +} +``` + +AFTER: + +`app.effects.ts` + +```ts +import { Action } from '@ngrx/store'; +import { Actions, Effect } from '@ngrx/effects'; +import { defer } from 'rxjs'; + +import * as auth from '../actions/auth.actions'; + +@Injectable() +export class AppEffects { + @Effect() + init$: Observable = defer(() => { + return of(new auth.LoginAction()); + }); + + constructor(private actions$: Actions) {} +} +``` + +### Testing Effects + +BEFORE: + +```ts +import { EffectsTestingModule, EffectsRunner } from '@ngrx/effects/testing'; +import { MyEffects } from './my-effects'; + +describe('My Effects', () => { + let effects: MyEffects; + let runner: EffectsRunner; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EffectsTestingModule], + providers: [ + MyEffects, + // other providers + ], + }); + + effects = TestBed.inject(MyEffects); + runner = TestBed.inject(EffectsRunner); + }); + + it('should work', () => { + runner.queue(SomeAction); + + effects.someSource$.subscribe(result => { + expect(result).toBe(AnotherAction); + }); + }); +}); +``` + +AFTER: + +```ts +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { hot, cold } from 'jasmine-marbles'; +import { MyEffects } from './my-effects'; +import { ReplaySubject } from 'rxjs/ReplaySubject'; + +describe('My Effects', () => { + let effects: MyEffects; + let actions: Observable; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + MyEffects, + provideMockActions(() => actions), + // other providers + ], + }); + + effects = TestBed.get(MyEffects); + }); + + it('should work', () => { + actions = hot('--a-', { a: SomeAction, ... }); + + const expected = cold('--b', { b: AnotherAction }); + + expect(effects.someSource$).toBeObservable(expected); + }); + + it('should work also', () => { + actions = new ReplaySubject(1); + + actions.next(SomeAction); + + effects.someSource$.subscribe(result => { + expect(result).toBe(AnotherAction); + }); + }); +}); +``` + +## @ngrx/router-store + +### Registering the module + +BEFORE: + +`reducers/index.ts` + +```ts +import * as fromRouter from '@ngrx/router-store'; + +export interface State { + router: fromRouter.RouterState; +} + +const reducers = { + router: fromRouter.routerReducer, +}; + +const rootReducer = combineReducers(reducers); + +export function reducer(state: any, action: any) { + return rootReducer(state, action); +} +``` + +`app.module.ts` + +```ts +import { RouterModule } from '@angular/router'; +import { RouterStoreModule } from '@ngrx/router-store'; +import { reducer } from './reducers'; + +@NgModule({ + imports: [ + StoreModule.provideStore(reducer), + RouterModule.forRoot([ + // some routes + ]) + RouterStoreModule.connectRouter() + ] +}) +export class AppModule {} +``` + +AFTER: + +`reducers/index.ts` + +```ts +import * as fromRouter from '@ngrx/router-store'; + +export interface State { + routerReducer: fromRouter.RouterReducerState; +} + +export const reducers = { + routerReducer: fromRouter.routerReducer, +}; +``` + +`app.module.ts` + +```ts +import { StoreRouterConnectingModule } from '@ngrx/router-store'; +import { reducers } from './reducers'; + +@NgModule({ + imports: [ + StoreModule.forRoot(reducers), + RouterModule.forRoot([ + // some routes + ]), + StoreRouterConnectingModule, + ], +}) +export class AppModule {} +``` + +### Navigation actions + +Navigation actions are not provided as part of the V4 package. You provide your own +custom navigation actions that use the `Router` within effects to navigate. + +BEFORE: + +```ts +import { go, back, forward } from '@ngrx/router-store'; + +store.dispatch( + go(['/path', { routeParam: 1 }], { page: 1 }, { replaceUrl: false }) +); + +store.dispatch(back()); + +store.dispatch(forward()); +``` + +AFTER: + +```ts +import { Action } from '@ngrx/store'; +import { NavigationExtras } from '@angular/router'; + +export const GO = '[Router] Go'; +export const BACK = '[Router] Back'; +export const FORWARD = '[Router] Forward'; + +export class Go implements Action { + readonly type = GO; + + constructor( + public payload: { + path: any[]; + query?: object; + extras?: NavigationExtras; + } + ) {} +} + +export class Back implements Action { + readonly type = BACK; +} + +export class Forward implements Action { + readonly type = FORWARD; +} + +export type Actions = Go | Back | Forward; +``` + +```ts +import * as RouterActions from './actions/router'; + +store.dispatch(new RouterActions.Go({ + path: ['/path', { routeParam: 1 }], + query: { page: 1 }, + extras: { replaceUrl: false } +}); + +store.dispatch(new RouterActions.Back()); + +store.dispatch(new RouterActions.Forward()); +``` + +```ts +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { Effect, Actions } from '@ngrx/effects'; +import { map, tap } from 'rxjs/operators'; +import * as RouterActions from './actions/router'; + +@Injectable() +export class RouterEffects { + @Effect({ dispatch: false }) + navigate$ = this.actions$.ofType(RouterActions.GO).pipe( + map((action: RouterActions.Go) => action.payload), + tap(({ path, query: queryParams, extras }) => + this.router.navigate(path, { queryParams, ...extras }) + ) + ); + + @Effect({ dispatch: false }) + navigateBack$ = this.actions$ + .ofType(RouterActions.BACK) + .do(() => this.location.back()); + + @Effect({ dispatch: false }) + navigateForward$ = this.actions$ + .ofType(RouterActions.FORWARD) + .do(() => this.location.forward()); + + constructor( + private actions$: Actions, + private router: Router, + private location: Location + ) {} +} +``` + +## @ngrx/store-devtools + +### Instrumentation method + +**NOTE:** store-devtools currently causes severe performance problems when +used with router-store. We are working to +[fix this](https://github.com/ngrx/platform/issues/97), but for now, avoid +using them together. + +BEFORE: + +`app.module.ts` + +```ts +import { StoreDevtoolsModule } from '@ngrx/store-devtools'; + +@NgModule({ + imports: [ + StoreDevtoolsModule.instrumentStore({ maxAge: 50 }), + // OR + StoreDevtoolsModule.instrumentOnlyWithExtension({ + maxAge: 50, + }), + ], +}) +export class AppModule {} +``` + +AFTER: + +`app.module.ts` + +```ts +import { StoreDevtoolsModule } from '@ngrx/store-devtools'; +import { environment } from '../environments/environment'; // Angular CLI environment + +@NgModule({ + imports: [ + !environment.production + ? StoreDevtoolsModule.instrument({ maxAge: 50 }) + : [], + ], +}) +export class AppModule {} +``` diff --git a/projects/ngrx.io/src/app/app.component.spec.ts b/projects/ngrx.io/src/app/app.component.spec.ts index 30427af502..a4133dd2f1 100644 --- a/projects/ngrx.io/src/app/app.component.spec.ts +++ b/projects/ngrx.io/src/app/app.component.spec.ts @@ -1,621 +1,621 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { AppComponent } from './app.component'; -import { Deployment } from './shared/deployment.service'; -import { DocumentService, DocumentContents } from './documents/document.service'; -import { ElementRef, Component, Input } from '@angular/core'; -import { of, Observable, ReplaySubject } from 'rxjs'; -import { LocationService } from './shared/location.service'; -import { NavigationService, CurrentNodes, VersionInfo, NavigationViews } from './navigation/navigation.service'; -import { ScrollService } from './shared/scroll.service'; -import { SearchService } from './search/search.service'; -import { TocService, TocItem } from './shared/toc.service'; -import { MatProgressBarModule, MatToolbarModule, MatSidenavModule, MatSidenav } from '@angular/material'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { MockSearchService } from 'testing/search.service'; -import { NotificationComponent } from './layout/notification/notification.component'; -import { By } from '@angular/platform-browser'; -import { SearchResultsComponent } from './shared/search-results/search-results.component'; - -const hideToCBreakPoint = 800; - -describe('AppComponent', () => { - let component: AppComponent; - let fixture: ComponentFixture; - let searchService: SearchService; - let deployment: Deployment; - let locationService: LocationService; - let locationServiceReplaceSpy: jasmine.Spy; - let scrollService: ScrollService; - let tocService: TocService; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ - AppComponent, - MockMatIconComponent, - MockAioNotificationComponent, - MockAioTopMenuComponent, - SearchResultsComponent, - MockAioNavMenuComponent, - MockAioSelectComponent, - MockAioModeBannerComponent, - MockAioDocViewerComponent, - MockAioDtComponent, - MockAioLazyCeComponent, - MockAioFooterComponent, - MockAioSearchBoxComponent - ], - imports: [ - MatProgressBarModule, - MatToolbarModule, - MatSidenavModule, - BrowserAnimationsModule - ], - providers: [ - { provide: Deployment, - useClass: MockDeployment - }, - { provide: DocumentService, - useClass: MockDocumentService - }, - { provide: ElementRef, - useClass: MockElementRef - }, - { - provide: LocationService, - useClass: MockLocationService, - }, - { provide: NavigationService, - useClass: MockNavigationService - }, - { provide: ScrollService, - useClass: MockScrollService - }, - { provide: SearchService, - useClass: MockSearchService - }, - { provide: TocService, - useClass: MockTocService - } - ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(AppComponent); - component = fixture.componentInstance; - component.notification = { showNotification: 'show' } as NotificationComponent; - component.sidenav = { opened: true, toggle: () => {}} as MatSidenav; - spyOn(component, 'onResize').and.callThrough(); - searchService = TestBed.get(SearchService); - deployment = TestBed.get(Deployment); - locationService = TestBed.get(LocationService); - locationServiceReplaceSpy = spyOn(locationService, 'replace'); - scrollService = TestBed.get(ScrollService); - tocService = TestBed.get(TocService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('ngOnInit', () => { - it('should set the currentDocument from the DocumentService', () => { - expect(component.currentDocument).toEqual({ id: '1', contents: 'contents' }); - }); - - it('should size the window', () => { - expect(component.onResize).toHaveBeenCalledTimes(1); - }); - - it('should initialize the search worker', () => { - expect(searchService.initWorker).toHaveBeenCalledWith('app/search/search-worker.js', 2000); - }); - - describe('archive redirection', () => { - - it('should redirect to docs if we are in archive mode and are not hitting a docs, api, guide, or tutorial page', () => { - deployment.mode = 'archive'; - locationService.currentPath = of('events'); - component.ngOnInit(); - expect(locationService.replace).toHaveBeenCalledWith('docs'); - }); - - it('should not redirect to docs if we are hitting a docs page', () => { - deployment.mode = 'archive'; - locationService.currentPath = of('docs'); - locationServiceReplaceSpy.calls.reset(); - component.ngOnInit(); - expect(locationService.replace).not.toHaveBeenCalled(); - }); - - it('should not redirect to docs if we are hitting a api page', () => { - deployment.mode = 'archive'; - locationService.currentPath = of('api'); - locationServiceReplaceSpy.calls.reset(); - component.ngOnInit(); - expect(locationService.replace).not.toHaveBeenCalled(); - }); - - it('should not redirect to docs if we are hitting a guide page', () => { - deployment.mode = 'archive'; - locationService.currentPath = of('guide'); - locationServiceReplaceSpy.calls.reset(); - component.ngOnInit(); - expect(locationService.replace).not.toHaveBeenCalled(); - }); - - it('should not redirect to docs if we are hitting a tutorial page', () => { - deployment.mode = 'archive'; - locationService.currentPath = of('tutorial'); - locationServiceReplaceSpy.calls.reset(); - component.ngOnInit(); - expect(locationService.replace).not.toHaveBeenCalled(); - }); - }); - - it('should auto scroll if the path changes while on the current page', () => { - component.currentPath = 'docs'; - locationService.currentPath = of('docs'); - spyOn(scrollService, 'scroll'); - component.ngOnInit(); - expect(scrollService.scroll).toHaveBeenCalledTimes(1); - }); - - it('should not auto scroll if the path changes to a different page', () => { - component.currentPath = 'docs'; - locationService.currentPath = of('events'); - spyOn(scrollService, 'scroll'); - component.ngOnInit(); - expect(scrollService.scroll).not.toHaveBeenCalled(); - }); - - describe('version picker', () => { - it('should contain next and stable by default', () => { - expect(component.docVersions).toContain({ title: 'next', url: 'https://next.ngrx.io/docs' }); - expect(component.docVersions).toContain({ title: 'stable (v6.3)', url: 'https://ngrx.io/docs' }); - }); - - it('should add the current version if in archive mode', () => { - deployment.mode = 'archive'; - component.ngOnInit(); - expect(component.docVersions).toContain({ title: 'v6 (v6.3)'}); - }); - - it('should find the current version by deployment mode and append the raw version info to the title', () => { - expect(component.currentDocVersion.title).toBe('stable (v6.3)'); - }); - - it('should find the current version by the current maajor version and append the raw version info to the title', () => { - deployment.mode = 'archive'; - component.ngOnInit(); - expect(component.currentDocVersion.title).toBe('v6 (v6.3)'); - }); - }); - - describe('hasFloatingToc', () => { - it('should initially be false', () => { - const fixture2 = TestBed.createComponent(AppComponent); - const component2 = fixture2.componentInstance; - - expect(component2.hasFloatingToc).toBe(false); - }); - - it('should be false on narrow screens', () => { - component.onResize(hideToCBreakPoint - 1); - - tocService.tocList.next([{}, {}, {}] as TocItem[]); - expect(component.hasFloatingToc).toBe(false); - - tocService.tocList.next([]); - expect(component.hasFloatingToc).toBe(false); - - tocService.tocList.next([{}, {}, {}] as TocItem[]); - expect(component.hasFloatingToc).toBe(false); - }); - - it('should be true on wide screens unless the toc is empty', () => { - component.onResize(hideToCBreakPoint + 1); - - tocService.tocList.next([{}, {}, {}] as TocItem[]); - expect(component.hasFloatingToc).toBe(true); - - tocService.tocList.next([]); - expect(component.hasFloatingToc).toBe(false); - - tocService.tocList.next([{}, {}, {}] as TocItem[]); - expect(component.hasFloatingToc).toBe(true); - }); - - it('should be false when toc is empty', () => { - tocService.tocList.next([]); - - component.onResize(hideToCBreakPoint + 1); - expect(component.hasFloatingToc).toBe(false); - - component.onResize(hideToCBreakPoint - 1); - expect(component.hasFloatingToc).toBe(false); - - component.onResize(hideToCBreakPoint + 1); - expect(component.hasFloatingToc).toBe(false); - }); - - it('should be true when toc is not empty unless the screen is narrow', () => { - tocService.tocList.next([{}, {}, {}] as TocItem[]); - - component.onResize(hideToCBreakPoint + 1); - expect(component.hasFloatingToc).toBe(true); - - component.onResize(hideToCBreakPoint - 1); - expect(component.hasFloatingToc).toBe(false); - - component.onResize(hideToCBreakPoint + 1); - expect(component.hasFloatingToc).toBe(true); - }); - }); - }); - - describe('onDocVersionChange', () => { - it('should navigate to the new version url', () => { - component.docVersions = [ - { title: 'next', url: 'https://next.ngrx.io'}, - { title: 'stable (v6.3)', url: 'https://ngrx.io' } - ]; - spyOn(locationService, 'go'); - component.onDocVersionChange(1); - expect(locationService.go).toHaveBeenCalledTimes(1); - }); - it('should not navigate to new version if it does not define a Url', () => { - component.docVersions = [ - { title: 'next', url: 'https://next.ngrx.io'}, - { title: 'stable (v6.3)', url: 'https://ngrx.io' }, - { title: 'v1'} - ]; - spyOn(locationService, 'go'); - component.onDocVersionChange(2); - expect(locationService.go).not.toHaveBeenCalled(); - }); - }); - - describe('onResize', () => { - it('should set isSideBySide to true if the window width is greater than 992 pixels', () => { - component.isSideBySide = false; - component.onResize(993); - expect(component.isSideBySide).toBeTruthy(); - }); - - it('should set isSideBySide to false if the window width is less than or equal to 992 pixels', () => { - component.isSideBySide = true; - component.onResize(992); - expect(component.isSideBySide).toBeFalsy(); - }); - - it('should set hasFloatingToc to true if the window width is greater than 800 and the toc list is greater than zero', () => { - tocService.tocList.next([{}] as TocItem[]); - component.onResize(801); - expect(component.hasFloatingToc).toBeTruthy(); - }); - - it('should set hasFloatingToc to false if the window width is less than or equal to 800 and the toc list is greater than zero', () => { - tocService.tocList.next([{}] as TocItem[]); - component.onResize(800); - expect(component.hasFloatingToc).toBeFalsy(); - }); - - it('should toggle the sidenav closed if it is not a doc page and the screen is wide enough to display menu items ' - + 'in the top-bar', () => { - const sideNavToggleSpy = spyOn(component.sidenav, 'toggle'); - sideNavToggleSpy.calls.reset(); - component.updateSideNav(); - component.onResize(993); - expect(component.sidenav.toggle).toHaveBeenCalledWith(false); - }); - }); - - // describe('click handler', () => { - // it('should hide the search results if we clicked outside both the "search box" and the "search results"', () => { - // component.searchElements = new QueryList(); - // component.searchElements. - // console.log(component.searchElements.length); - // }); - // }); - - describe('search', () => { - - let docViewer: HTMLElement; - - beforeEach(() => { - const documentViewerDebugElement = fixture.debugElement.query(By.css('aio-doc-viewer')); - docViewer = documentViewerDebugElement.nativeElement; - }); - - describe('click handling', () => { - it('should intercept clicks not on the search elements and hide the search results', () => { - component.showSearchResults = true; - fixture.detectChanges(); - // docViewer is a commonly-clicked, non-search element - docViewer.click(); - expect(component.showSearchResults).toBe(false); - }); - - it('should clear "only" the search query param from the URL', () => { - // Mock out the current state of the URL query params - spyOn(locationService, 'search').and.returnValue({ - a: 'some-A', - b: 'some-B', - search: 'some-C', - }); - spyOn(locationService, 'setSearch'); - // docViewer is a commonly-clicked, non-search element - docViewer.click(); - // Check that the query params were updated correctly - expect(locationService.setSearch).toHaveBeenCalledWith('', { - a: 'some-A', - b: 'some-B', - search: undefined, - }); - }); - - it('should not intercept clicks on the searchResults', () => { - component.showSearchResults = true; - fixture.detectChanges(); - - const searchResults = fixture.debugElement.query( - By.directive(SearchResultsComponent) - ); - searchResults.nativeElement.click(); - fixture.detectChanges(); - - expect(component.showSearchResults).toBe(true); - }); - - it('should return the result of handleAnchorClick when anchor is clicked', () => { - const anchorElement: HTMLAnchorElement = document.createElement('a'); - spyOn(locationService, 'handleAnchorClick').and.returnValue(true); - expect(component.onClick(anchorElement, 1, false, false, true)).toBeTruthy(); - expect(locationService.handleAnchorClick).toHaveBeenCalledTimes(1); - }); - }); - }); - - it('updateHostClasses', () => { - component.notificationAnimating = true; - component.hostClasses = ''; - component.updateHostClasses(); - expect(component.hostClasses) - .toBe('mode-stable sidenav-open page-1 folder-1 view-view aio-notification-show aio-notification-animating'); - }); - - describe('updateSideNav', () => { - it('should preserve the current sidenav open state if view type does not change', () => { - component.isSideBySide = true; - component.sidenav.opened = true; - const toggleSpy = spyOn(component.sidenav, 'toggle'); - - component.updateSideNav(); - expect(component.sidenav.toggle).toHaveBeenCalledWith(true); - expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); - - component.isSideBySide = false; - toggleSpy.calls.reset(); - component.updateSideNav(); - expect(component.sidenav.toggle).toHaveBeenCalledWith(false); - expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); - }); - - it('should open if changed from a non sidenav doc to a sidenav doc and close if changed from sidenav doc to non sidenav doc', () => { - const toggleSpy = spyOn(component.sidenav, 'toggle'); - component.isSideBySide = true; - component.currentNodes = { - 'SideNav': { url: '', view: '', nodes: [] } - }; - component.updateSideNav(); - expect(component.sidenav.toggle).toHaveBeenCalledWith(true); - expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); - - component.currentNodes = {}; - toggleSpy.calls.reset(); - component.updateSideNav(); - expect(component.sidenav.toggle).toHaveBeenCalledWith(false); - expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); - }); - }); - - describe('restrain scrolling inside an element when the cursor is over it', () => { - it('should prevent scrolling up when already at the top', () => { - const scrollUpEvent = { - deltaY: -1, - currentTarget: { scrollTop: 0 }, - preventDefault: () => {} - } as any; - spyOn(scrollUpEvent, 'preventDefault'); - component.restrainScrolling(scrollUpEvent); - expect(scrollUpEvent.preventDefault).toHaveBeenCalledTimes(1); - }); - - it('should prevent scrolling down when already at the bottom', () => { - const scrollUpEvent = { - deltaY: 1, - currentTarget: { - scrollTop: 10, - scrollHeight: 20, - clientHeight: 10 - }, - preventDefault: () => {} - } as any; - spyOn(scrollUpEvent, 'preventDefault'); - component.restrainScrolling(scrollUpEvent); - expect(scrollUpEvent.preventDefault).toHaveBeenCalledTimes(1); - }); - }); - - describe('key handling', () => { - - beforeEach(() => { - spyOn(component, 'focusSearchBox'); - spyOn(component, 'hideSearchResults'); - }); - - it('should focus search box on forward slash key "/"', () => { - component.onKeyUp('/', 190); - expect(component.focusSearchBox).toHaveBeenCalledTimes(1); - }); - - it('should focus search box on forward slash keycode', () => { - component.onKeyUp('', 191); - expect(component.focusSearchBox).toHaveBeenCalledTimes(1); - }); - - it('should hide the search results and focus search box if results are being shown on escape key', () => { - component.showSearchResults = true; - component.onKeyUp('Escape', 28); - expect(component.focusSearchBox).toHaveBeenCalledTimes(1); - expect(component.hideSearchResults).toHaveBeenCalledTimes(1); - }); - - it('should hide the search results and focus search box if results are being shown on escape keycode', () => { - component.showSearchResults = true; - component.onKeyUp('', 27); - expect(component.focusSearchBox).toHaveBeenCalledTimes(1); - expect(component.hideSearchResults).toHaveBeenCalledTimes(1); - }); - }); -}); - -// Mock Dependencies - -class MockLocationService { - currentPath = of('path'); - replace = () => {}; - go = () => {}; - handleAnchorClick = () => true; - setSearch = () => {}; - search = () => {}; -} - - -class MockDeployment { - mode = 'stable'; -} - -class MockDocumentService { - currentDocument: Observable = of({ id: '1', contents: 'contents' }); -} - -class MockElementRef { - nativeElement: Element; -} - -class MockNavigationService { - currentNodes: Observable = of({ 'view': { url: 'path', view: 'view', nodes: []}}) - versionInfo: Observable = of({ major: 6, raw: '6.3'}); - navigationViews: Observable = of({ 'docVersions' : [{ title: 'v5'}]}); -} - -export class MockTocService { - genToc = jasmine.createSpy('TocService#genToc'); - reset = jasmine.createSpy('TocService#reset'); - tocList = new ReplaySubject(1); -} - -class MockScrollService { - scroll = () => {}; - scrollToTop = () => {}; -} - -// Mock Child Components - - -@Component({ - // tslint:disable-next-line:component-selector - selector: 'mat-icon', - template: '' -}) -class MockMatIconComponent { - @Input() svgIcon; -} - -@Component({ - selector: 'aio-notification', - template: '' -}) -class MockAioNotificationComponent { - @Input() dismissOnContentClick; - showNotification = 'show' -} - -@Component({ - selector: 'aio-top-menu', - template: '' -}) -class MockAioTopMenuComponent { - @Input() nodes; -} - -@Component({ - selector: 'aio-nav-menu', - template: '' -}) -class MockAioNavMenuComponent { - @Input() nodes; - @Input() currentNode; - @Input() isWide; -} - -@Component({ - selector: 'aio-select', - template: '' -}) -class MockAioSelectComponent { - @Input() options; - @Input() selected; -} - -@Component({ - selector: 'aio-mode-banner', - template: '' -}) -class MockAioModeBannerComponent { - @Input() mode; - @Input() version; -} - -@Component({ - selector: 'aio-doc-viewer', - template: '' -}) -class MockAioDocViewerComponent { - @Input() doc; -} - -@Component({ - selector: 'aio-dt', - template: '' -}) -class MockAioDtComponent { - @Input() on; - @Input() doc; -} - -@Component({ - selector: 'aio-lazy-ce', - template: '' -}) -class MockAioLazyCeComponent { } - -@Component({ - selector: 'aio-footer', - template: '' -}) -class MockAioFooterComponent { - @Input() nodes; - @Input() versionInfo; -} - -@Component({ - selector: 'aio-search-box', - template: '' -}) -class MockAioSearchBoxComponent {} +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { Deployment } from './shared/deployment.service'; +import { DocumentService, DocumentContents } from './documents/document.service'; +import { ElementRef, Component, Input } from '@angular/core'; +import { of, Observable, ReplaySubject } from 'rxjs'; +import { LocationService } from './shared/location.service'; +import { NavigationService, CurrentNodes, VersionInfo, NavigationViews } from './navigation/navigation.service'; +import { ScrollService } from './shared/scroll.service'; +import { SearchService } from './search/search.service'; +import { TocService, TocItem } from './shared/toc.service'; +import { MatProgressBarModule, MatToolbarModule, MatSidenavModule, MatSidenav } from '@angular/material'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MockSearchService } from 'testing/search.service'; +import { NotificationComponent } from './layout/notification/notification.component'; +import { By } from '@angular/platform-browser'; +import { SearchResultsComponent } from './shared/search-results/search-results.component'; + +const hideToCBreakPoint = 800; + +describe('AppComponent', () => { + let component: AppComponent; + let fixture: ComponentFixture; + let searchService: SearchService; + let deployment: Deployment; + let locationService: LocationService; + let locationServiceReplaceSpy: jasmine.Spy; + let scrollService: ScrollService; + let tocService: TocService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + AppComponent, + MockMatIconComponent, + MockAioNotificationComponent, + MockAioTopMenuComponent, + SearchResultsComponent, + MockAioNavMenuComponent, + MockAioSelectComponent, + MockAioModeBannerComponent, + MockAioDocViewerComponent, + MockAioDtComponent, + MockAioLazyCeComponent, + MockAioFooterComponent, + MockAioSearchBoxComponent + ], + imports: [ + MatProgressBarModule, + MatToolbarModule, + MatSidenavModule, + BrowserAnimationsModule + ], + providers: [ + { provide: Deployment, + useClass: MockDeployment + }, + { provide: DocumentService, + useClass: MockDocumentService + }, + { provide: ElementRef, + useClass: MockElementRef + }, + { + provide: LocationService, + useClass: MockLocationService, + }, + { provide: NavigationService, + useClass: MockNavigationService + }, + { provide: ScrollService, + useClass: MockScrollService + }, + { provide: SearchService, + useClass: MockSearchService + }, + { provide: TocService, + useClass: MockTocService + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + component.notification = { showNotification: 'show' } as NotificationComponent; + component.sidenav = { opened: true, toggle: () => {}} as MatSidenav; + spyOn(component, 'onResize').and.callThrough(); + searchService = TestBed.inject(SearchService); + deployment = TestBed.inject(Deployment); + locationService = TestBed.inject(LocationService); + locationServiceReplaceSpy = spyOn(locationService, 'replace'); + scrollService = TestBed.inject(ScrollService); + tocService = TestBed.inject(TocService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should set the currentDocument from the DocumentService', () => { + expect(component.currentDocument).toEqual({ id: '1', contents: 'contents' }); + }); + + it('should size the window', () => { + expect(component.onResize).toHaveBeenCalledTimes(1); + }); + + it('should initialize the search worker', () => { + expect(searchService.initWorker).toHaveBeenCalledWith('app/search/search-worker.js', 2000); + }); + + describe('archive redirection', () => { + + it('should redirect to docs if we are in archive mode and are not hitting a docs, api, guide, or tutorial page', () => { + deployment.mode = 'archive'; + locationService.currentPath = of('events'); + component.ngOnInit(); + expect(locationService.replace).toHaveBeenCalledWith('docs'); + }); + + it('should not redirect to docs if we are hitting a docs page', () => { + deployment.mode = 'archive'; + locationService.currentPath = of('docs'); + locationServiceReplaceSpy.calls.reset(); + component.ngOnInit(); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('should not redirect to docs if we are hitting a api page', () => { + deployment.mode = 'archive'; + locationService.currentPath = of('api'); + locationServiceReplaceSpy.calls.reset(); + component.ngOnInit(); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('should not redirect to docs if we are hitting a guide page', () => { + deployment.mode = 'archive'; + locationService.currentPath = of('guide'); + locationServiceReplaceSpy.calls.reset(); + component.ngOnInit(); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + + it('should not redirect to docs if we are hitting a tutorial page', () => { + deployment.mode = 'archive'; + locationService.currentPath = of('tutorial'); + locationServiceReplaceSpy.calls.reset(); + component.ngOnInit(); + expect(locationService.replace).not.toHaveBeenCalled(); + }); + }); + + it('should auto scroll if the path changes while on the current page', () => { + component.currentPath = 'docs'; + locationService.currentPath = of('docs'); + spyOn(scrollService, 'scroll'); + component.ngOnInit(); + expect(scrollService.scroll).toHaveBeenCalledTimes(1); + }); + + it('should not auto scroll if the path changes to a different page', () => { + component.currentPath = 'docs'; + locationService.currentPath = of('events'); + spyOn(scrollService, 'scroll'); + component.ngOnInit(); + expect(scrollService.scroll).not.toHaveBeenCalled(); + }); + + describe('version picker', () => { + it('should contain next and stable by default', () => { + expect(component.docVersions).toContain({ title: 'next', url: 'https://next.ngrx.io/docs' }); + expect(component.docVersions).toContain({ title: 'stable (v6.3)', url: 'https://ngrx.io/docs' }); + }); + + it('should add the current version if in archive mode', () => { + deployment.mode = 'archive'; + component.ngOnInit(); + expect(component.docVersions).toContain({ title: 'v6 (v6.3)'}); + }); + + it('should find the current version by deployment mode and append the raw version info to the title', () => { + expect(component.currentDocVersion.title).toBe('stable (v6.3)'); + }); + + it('should find the current version by the current maajor version and append the raw version info to the title', () => { + deployment.mode = 'archive'; + component.ngOnInit(); + expect(component.currentDocVersion.title).toBe('v6 (v6.3)'); + }); + }); + + describe('hasFloatingToc', () => { + it('should initially be false', () => { + const fixture2 = TestBed.createComponent(AppComponent); + const component2 = fixture2.componentInstance; + + expect(component2.hasFloatingToc).toBe(false); + }); + + it('should be false on narrow screens', () => { + component.onResize(hideToCBreakPoint - 1); + + tocService.tocList.next([{}, {}, {}] as TocItem[]); + expect(component.hasFloatingToc).toBe(false); + + tocService.tocList.next([]); + expect(component.hasFloatingToc).toBe(false); + + tocService.tocList.next([{}, {}, {}] as TocItem[]); + expect(component.hasFloatingToc).toBe(false); + }); + + it('should be true on wide screens unless the toc is empty', () => { + component.onResize(hideToCBreakPoint + 1); + + tocService.tocList.next([{}, {}, {}] as TocItem[]); + expect(component.hasFloatingToc).toBe(true); + + tocService.tocList.next([]); + expect(component.hasFloatingToc).toBe(false); + + tocService.tocList.next([{}, {}, {}] as TocItem[]); + expect(component.hasFloatingToc).toBe(true); + }); + + it('should be false when toc is empty', () => { + tocService.tocList.next([]); + + component.onResize(hideToCBreakPoint + 1); + expect(component.hasFloatingToc).toBe(false); + + component.onResize(hideToCBreakPoint - 1); + expect(component.hasFloatingToc).toBe(false); + + component.onResize(hideToCBreakPoint + 1); + expect(component.hasFloatingToc).toBe(false); + }); + + it('should be true when toc is not empty unless the screen is narrow', () => { + tocService.tocList.next([{}, {}, {}] as TocItem[]); + + component.onResize(hideToCBreakPoint + 1); + expect(component.hasFloatingToc).toBe(true); + + component.onResize(hideToCBreakPoint - 1); + expect(component.hasFloatingToc).toBe(false); + + component.onResize(hideToCBreakPoint + 1); + expect(component.hasFloatingToc).toBe(true); + }); + }); + }); + + describe('onDocVersionChange', () => { + it('should navigate to the new version url', () => { + component.docVersions = [ + { title: 'next', url: 'https://next.ngrx.io'}, + { title: 'stable (v6.3)', url: 'https://ngrx.io' } + ]; + spyOn(locationService, 'go'); + component.onDocVersionChange(1); + expect(locationService.go).toHaveBeenCalledTimes(1); + }); + it('should not navigate to new version if it does not define a Url', () => { + component.docVersions = [ + { title: 'next', url: 'https://next.ngrx.io'}, + { title: 'stable (v6.3)', url: 'https://ngrx.io' }, + { title: 'v1'} + ]; + spyOn(locationService, 'go'); + component.onDocVersionChange(2); + expect(locationService.go).not.toHaveBeenCalled(); + }); + }); + + describe('onResize', () => { + it('should set isSideBySide to true if the window width is greater than 992 pixels', () => { + component.isSideBySide = false; + component.onResize(993); + expect(component.isSideBySide).toBeTruthy(); + }); + + it('should set isSideBySide to false if the window width is less than or equal to 992 pixels', () => { + component.isSideBySide = true; + component.onResize(992); + expect(component.isSideBySide).toBeFalsy(); + }); + + it('should set hasFloatingToc to true if the window width is greater than 800 and the toc list is greater than zero', () => { + tocService.tocList.next([{}] as TocItem[]); + component.onResize(801); + expect(component.hasFloatingToc).toBeTruthy(); + }); + + it('should set hasFloatingToc to false if the window width is less than or equal to 800 and the toc list is greater than zero', () => { + tocService.tocList.next([{}] as TocItem[]); + component.onResize(800); + expect(component.hasFloatingToc).toBeFalsy(); + }); + + it('should toggle the sidenav closed if it is not a doc page and the screen is wide enough to display menu items ' + + 'in the top-bar', () => { + const sideNavToggleSpy = spyOn(component.sidenav, 'toggle'); + sideNavToggleSpy.calls.reset(); + component.updateSideNav(); + component.onResize(993); + expect(component.sidenav.toggle).toHaveBeenCalledWith(false); + }); + }); + + // describe('click handler', () => { + // it('should hide the search results if we clicked outside both the "search box" and the "search results"', () => { + // component.searchElements = new QueryList(); + // component.searchElements. + // console.log(component.searchElements.length); + // }); + // }); + + describe('search', () => { + + let docViewer: HTMLElement; + + beforeEach(() => { + const documentViewerDebugElement = fixture.debugElement.query(By.css('aio-doc-viewer')); + docViewer = documentViewerDebugElement.nativeElement; + }); + + describe('click handling', () => { + it('should intercept clicks not on the search elements and hide the search results', () => { + component.showSearchResults = true; + fixture.detectChanges(); + // docViewer is a commonly-clicked, non-search element + docViewer.click(); + expect(component.showSearchResults).toBe(false); + }); + + it('should clear "only" the search query param from the URL', () => { + // Mock out the current state of the URL query params + spyOn(locationService, 'search').and.returnValue({ + a: 'some-A', + b: 'some-B', + search: 'some-C', + }); + spyOn(locationService, 'setSearch'); + // docViewer is a commonly-clicked, non-search element + docViewer.click(); + // Check that the query params were updated correctly + expect(locationService.setSearch).toHaveBeenCalledWith('', { + a: 'some-A', + b: 'some-B', + search: undefined, + }); + }); + + it('should not intercept clicks on the searchResults', () => { + component.showSearchResults = true; + fixture.detectChanges(); + + const searchResults = fixture.debugElement.query( + By.directive(SearchResultsComponent) + ); + searchResults.nativeElement.click(); + fixture.detectChanges(); + + expect(component.showSearchResults).toBe(true); + }); + + it('should return the result of handleAnchorClick when anchor is clicked', () => { + const anchorElement: HTMLAnchorElement = document.createElement('a'); + spyOn(locationService, 'handleAnchorClick').and.returnValue(true); + expect(component.onClick(anchorElement, 1, false, false, true)).toBeTruthy(); + expect(locationService.handleAnchorClick).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('updateHostClasses', () => { + component.notificationAnimating = true; + component.hostClasses = ''; + component.updateHostClasses(); + expect(component.hostClasses) + .toBe('mode-stable sidenav-open page-1 folder-1 view-view aio-notification-show aio-notification-animating'); + }); + + describe('updateSideNav', () => { + it('should preserve the current sidenav open state if view type does not change', () => { + component.isSideBySide = true; + component.sidenav.opened = true; + const toggleSpy = spyOn(component.sidenav, 'toggle'); + + component.updateSideNav(); + expect(component.sidenav.toggle).toHaveBeenCalledWith(true); + expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); + + component.isSideBySide = false; + toggleSpy.calls.reset(); + component.updateSideNav(); + expect(component.sidenav.toggle).toHaveBeenCalledWith(false); + expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); + }); + + it('should open if changed from a non sidenav doc to a sidenav doc and close if changed from sidenav doc to non sidenav doc', () => { + const toggleSpy = spyOn(component.sidenav, 'toggle'); + component.isSideBySide = true; + component.currentNodes = { + 'SideNav': { url: '', view: '', nodes: [] } + }; + component.updateSideNav(); + expect(component.sidenav.toggle).toHaveBeenCalledWith(true); + expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); + + component.currentNodes = {}; + toggleSpy.calls.reset(); + component.updateSideNav(); + expect(component.sidenav.toggle).toHaveBeenCalledWith(false); + expect(component.sidenav.toggle).toHaveBeenCalledTimes(1); + }); + }); + + describe('restrain scrolling inside an element when the cursor is over it', () => { + it('should prevent scrolling up when already at the top', () => { + const scrollUpEvent = { + deltaY: -1, + currentTarget: { scrollTop: 0 }, + preventDefault: () => {} + } as any; + spyOn(scrollUpEvent, 'preventDefault'); + component.restrainScrolling(scrollUpEvent); + expect(scrollUpEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('should prevent scrolling down when already at the bottom', () => { + const scrollUpEvent = { + deltaY: 1, + currentTarget: { + scrollTop: 10, + scrollHeight: 20, + clientHeight: 10 + }, + preventDefault: () => {} + } as any; + spyOn(scrollUpEvent, 'preventDefault'); + component.restrainScrolling(scrollUpEvent); + expect(scrollUpEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + }); + + describe('key handling', () => { + + beforeEach(() => { + spyOn(component, 'focusSearchBox'); + spyOn(component, 'hideSearchResults'); + }); + + it('should focus search box on forward slash key "/"', () => { + component.onKeyUp('/', 190); + expect(component.focusSearchBox).toHaveBeenCalledTimes(1); + }); + + it('should focus search box on forward slash keycode', () => { + component.onKeyUp('', 191); + expect(component.focusSearchBox).toHaveBeenCalledTimes(1); + }); + + it('should hide the search results and focus search box if results are being shown on escape key', () => { + component.showSearchResults = true; + component.onKeyUp('Escape', 28); + expect(component.focusSearchBox).toHaveBeenCalledTimes(1); + expect(component.hideSearchResults).toHaveBeenCalledTimes(1); + }); + + it('should hide the search results and focus search box if results are being shown on escape keycode', () => { + component.showSearchResults = true; + component.onKeyUp('', 27); + expect(component.focusSearchBox).toHaveBeenCalledTimes(1); + expect(component.hideSearchResults).toHaveBeenCalledTimes(1); + }); + }); +}); + +// Mock Dependencies + +class MockLocationService { + currentPath = of('path'); + replace = () => {}; + go = () => {}; + handleAnchorClick = () => true; + setSearch = () => {}; + search = () => {}; +} + + +class MockDeployment { + mode = 'stable'; +} + +class MockDocumentService { + currentDocument: Observable = of({ id: '1', contents: 'contents' }); +} + +class MockElementRef { + nativeElement: Element; +} + +class MockNavigationService { + currentNodes: Observable = of({ 'view': { url: 'path', view: 'view', nodes: []}}) + versionInfo: Observable = of({ major: 6, raw: '6.3'}); + navigationViews: Observable = of({ 'docVersions' : [{ title: 'v5'}]}); +} + +export class MockTocService { + genToc = jasmine.createSpy('TocService#genToc'); + reset = jasmine.createSpy('TocService#reset'); + tocList = new ReplaySubject(1); +} + +class MockScrollService { + scroll = () => {}; + scrollToTop = () => {}; +} + +// Mock Child Components + + +@Component({ + // tslint:disable-next-line:component-selector + selector: 'mat-icon', + template: '' +}) +class MockMatIconComponent { + @Input() svgIcon; +} + +@Component({ + selector: 'aio-notification', + template: '' +}) +class MockAioNotificationComponent { + @Input() dismissOnContentClick; + showNotification = 'show' +} + +@Component({ + selector: 'aio-top-menu', + template: '' +}) +class MockAioTopMenuComponent { + @Input() nodes; +} + +@Component({ + selector: 'aio-nav-menu', + template: '' +}) +class MockAioNavMenuComponent { + @Input() nodes; + @Input() currentNode; + @Input() isWide; +} + +@Component({ + selector: 'aio-select', + template: '' +}) +class MockAioSelectComponent { + @Input() options; + @Input() selected; +} + +@Component({ + selector: 'aio-mode-banner', + template: '' +}) +class MockAioModeBannerComponent { + @Input() mode; + @Input() version; +} + +@Component({ + selector: 'aio-doc-viewer', + template: '' +}) +class MockAioDocViewerComponent { + @Input() doc; +} + +@Component({ + selector: 'aio-dt', + template: '' +}) +class MockAioDtComponent { + @Input() on; + @Input() doc; +} + +@Component({ + selector: 'aio-lazy-ce', + template: '' +}) +class MockAioLazyCeComponent { } + +@Component({ + selector: 'aio-footer', + template: '' +}) +class MockAioFooterComponent { + @Input() nodes; + @Input() versionInfo; +} + +@Component({ + selector: 'aio-search-box', + template: '' +}) +class MockAioSearchBoxComponent {} diff --git a/projects/ngrx.io/src/app/custom-elements/code/code.component.spec.ts b/projects/ngrx.io/src/app/custom-elements/code/code.component.spec.ts index badb1f16a5..820158ca05 100644 --- a/projects/ngrx.io/src/app/custom-elements/code/code.component.spec.ts +++ b/projects/ngrx.io/src/app/custom-elements/code/code.component.spec.ts @@ -1,326 +1,326 @@ -import { Component, ViewChild, AfterViewInit } from '@angular/core'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBar } from '@angular/material'; -import { By } from '@angular/platform-browser'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { first } from 'rxjs/operators'; - -import { CodeComponent } from './code.component'; -import { CodeModule } from './code.module'; -import { CopierService } from 'app/shared//copier.service'; -import { Logger } from 'app/shared/logger.service'; -import { PrettyPrinter } from './pretty-printer.service'; - -const oneLineCode = 'const foo = "bar";'; - -const smallMultiLineCode = ` -<hero-details> - <h2>Bah Dah Bing</h2> - <hero-team> - <h3>NYC Team</h3> - </hero-team> -</hero-details>`; - -const bigMultiLineCode = - smallMultiLineCode + smallMultiLineCode + smallMultiLineCode; - -describe('CodeComponent', () => { - let hostComponent: HostComponent; - let fixture: ComponentFixture; - - // WARNING: Chance of cross-test pollution - // CodeComponent injects PrettyPrintService - // Once PrettyPrintService runs once _anywhere_, its ctor loads `prettify.js` - // which sets `window['prettyPrintOne']` - // That global survives these tests unless - // we take strict measures to wipe it out in the `afterAll` - // and make sure THAT runs after the tests by making component creation async - afterAll(() => { - delete (window as any)['prettyPrint']; - delete (window as any)['prettyPrintOne']; - }); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, CodeModule], - declarations: [HostComponent], - providers: [ - PrettyPrinter, - CopierService, - { provide: Logger, useClass: TestLogger }, - ], - }).compileComponents(); - }); - - // Must be async because - // CodeComponent creates PrettyPrintService which async loads `prettify.js`. - // If not async, `afterAll` finishes before tests do! - beforeEach(async(() => { - fixture = TestBed.createComponent(HostComponent); - hostComponent = fixture.componentInstance; - - fixture.detectChanges(); - })); - - describe('pretty printing', () => { - const untilCodeFormatted = () => { - const emitter = hostComponent.codeComponent.codeFormatted; - return emitter.pipe(first()).toPromise(); - }; - const hasLineNumbers = async () => { - // presence of `
  • `s are a tell-tale for line numbers - await untilCodeFormatted(); - return 0 < fixture.nativeElement.querySelectorAll('li').length; - }; - - it('should format a one-line code sample', async () => { - hostComponent.setCode(oneLineCode); - await untilCodeFormatted(); - - // 'pln' spans are a tell-tale for syntax highlighting - const spans = fixture.nativeElement.querySelectorAll('span.pln'); - expect(spans.length).toBeGreaterThan(0, 'formatted spans'); - }); - - it('should format a one-line code sample without linenums by default', async () => { - hostComponent.setCode(oneLineCode); - expect(await hasLineNumbers()).toBe(false); - }); - - it('should add line numbers to one-line code sample when linenums set true', async () => { - hostComponent.linenums = 'true'; - fixture.detectChanges(); - - expect(await hasLineNumbers()).toBe(true); - }); - - it('should format a small multi-line code without linenums by default', async () => { - hostComponent.setCode(smallMultiLineCode); - expect(await hasLineNumbers()).toBe(false); - }); - - it('should add line numbers to a big multi-line code by default', async () => { - hostComponent.setCode(bigMultiLineCode); - expect(await hasLineNumbers()).toBe(true); - }); - - it('should format big multi-line code without linenums when linenums set false', async () => { - hostComponent.linenums = false; - fixture.detectChanges(); - - hostComponent.setCode(bigMultiLineCode); - expect(await hasLineNumbers()).toBe(false); - }); - }); - - describe('whitespace handling', () => { - it('should remove common indentation from the code before rendering', () => { - hostComponent.linenums = false; - fixture.detectChanges(); - - hostComponent.setCode( - " abc\n let x = text.split('\\n');\n ghi\n\n jkl\n" - ); - const codeContent = fixture.nativeElement.querySelector('code') - .textContent; - expect(codeContent).toEqual( - "abc\n let x = text.split('\\n');\nghi\n\njkl" - ); - }); - - it('should trim whitespace from the code before rendering', () => { - hostComponent.linenums = false; - fixture.detectChanges(); - - hostComponent.setCode('\n\n\n' + smallMultiLineCode + '\n\n\n'); - const codeContent = fixture.nativeElement.querySelector('code') - .textContent; - expect(codeContent).toEqual(codeContent.trim()); - }); - - it('should trim whitespace from code before computing whether to format linenums', () => { - hostComponent.setCode('\n\n\n' + oneLineCode + '\n\n\n'); - - // `
  • `s are a tell-tale for line numbers - const lis = fixture.nativeElement.querySelectorAll('li'); - expect(lis.length).toBe(0, 'should be no linenums'); - }); - }); - - describe('error message', () => { - function getErrorMessage() { - const missing: HTMLElement = fixture.nativeElement.querySelector( - '.code-missing' - ); - return missing ? missing.textContent : null; - } - - it('should not display "code-missing" class when there is some code', () => { - expect(getErrorMessage()).toBeNull( - 'should not have element with "code-missing" class' - ); - }); - - it('should display error message when there is no code (after trimming)', () => { - hostComponent.setCode(' \n '); - expect(getErrorMessage()).toContain('missing'); - }); - - it('should show path and region in missing-code error message', () => { - hostComponent.path = 'fizz/buzz/foo.html'; - hostComponent.region = 'something'; - fixture.detectChanges(); - - hostComponent.setCode(' \n '); - expect(getErrorMessage()).toMatch( - /for[\s\S]fizz\/buzz\/foo\.html#something$/ - ); - }); - - it('should show path only in missing-code error message when no region', () => { - hostComponent.path = 'fizz/buzz/foo.html'; - fixture.detectChanges(); - - hostComponent.setCode(' \n '); - expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html$/); - }); - - it('should show simple missing-code error message when no path/region', () => { - hostComponent.setCode(' \n '); - expect(getErrorMessage()).toMatch(/missing.$/); - }); - }); - - describe('copy button', () => { - function getButton() { - const btnDe = fixture.debugElement.query(By.css('button')); - return btnDe ? btnDe.nativeElement : null; - } - - it('should be hidden if the `hideCopy` input is true', () => { - hostComponent.hideCopy = true; - fixture.detectChanges(); - expect(getButton()).toBe(null); - }); - - it('should have title', () => { - expect(getButton().title).toBe('Copy code snippet'); - }); - - it('should have no aria-label by default', () => { - expect(getButton().getAttribute('aria-label')).toBe(''); - }); - - it('should have aria-label explaining what is being copied when header passed in', () => { - hostComponent.header = 'a/b/c/foo.ts'; - fixture.detectChanges(); - expect(getButton().getAttribute('aria-label')).toContain( - hostComponent.header - ); - }); - - it('should call copier service when clicked', () => { - const copierService: CopierService = TestBed.get(CopierService); - const spy = spyOn(copierService, 'copyText'); - expect(spy.calls.count()).toBe(0, 'before click'); - getButton().click(); - expect(spy.calls.count()).toBe(1, 'after click'); - }); - - it('should copy code text when clicked', () => { - const copierService: CopierService = TestBed.get(CopierService); - const spy = spyOn(copierService, 'copyText'); - getButton().click(); - expect(spy.calls.argsFor(0)[0]).toBe(oneLineCode, 'after click'); - }); - - it('should preserve newlines in the copied code', () => { - const copierService: CopierService = TestBed.get(CopierService); - const spy = spyOn(copierService, 'copyText'); - const expectedCode = smallMultiLineCode - .trim() - .replace(/</g, '<') - .replace(/>/g, '>'); - let actualCode; - - hostComponent.setCode(smallMultiLineCode); - - [false, true, 42].forEach(linenums => { - hostComponent.linenums = linenums; - fixture.detectChanges(); - getButton().click(); - actualCode = spy.calls.mostRecent().args[0]; - - expect(actualCode).toBe(expectedCode, `when linenums=${linenums}`); - expect(actualCode.match(/\r?\n/g).length).toBe(5); - - spy.calls.reset(); - }); - }); - - it('should display a message when copy succeeds', () => { - const snackBar: MatSnackBar = TestBed.get(MatSnackBar); - const copierService: CopierService = TestBed.get(CopierService); - spyOn(snackBar, 'open'); - spyOn(copierService, 'copyText').and.returnValue(true); - getButton().click(); - expect(snackBar.open).toHaveBeenCalledWith('Code Copied', '', { - duration: 800, - }); - }); - - it('should display an error when copy fails', () => { - const snackBar: MatSnackBar = TestBed.get(MatSnackBar); - const copierService: CopierService = TestBed.get(CopierService); - const logger: TestLogger = TestBed.get(Logger); - spyOn(snackBar, 'open'); - spyOn(copierService, 'copyText').and.returnValue(false); - getButton().click(); - expect(snackBar.open).toHaveBeenCalledWith( - 'Copy failed. Please try again!', - '', - { duration: 800 } - ); - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.error).toHaveBeenCalledWith(jasmine.any(Error)); - expect(logger.error.calls.mostRecent().args[0].message).toMatch( - /^ERROR copying code to clipboard:/ - ); - }); - }); -}); - -//// Test helpers //// -// tslint:disable:member-ordering -@Component({ - selector: 'aio-host-comp', - template: ` - - `, -}) -class HostComponent implements AfterViewInit { - hideCopy: boolean; - language: string; - linenums: boolean | number | string; - path: string; - region: string; - header: string; - - @ViewChild(CodeComponent) codeComponent: CodeComponent; - - ngAfterViewInit() { - this.setCode(oneLineCode); - } - - /** Changes the displayed code on the code component. */ - setCode(code: string) { - this.codeComponent.code = code; - } -} - -class TestLogger { - log = jasmine.createSpy('log'); - error = jasmine.createSpy('error'); -} +import { Component, ViewChild, AfterViewInit } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { first } from 'rxjs/operators'; + +import { CodeComponent } from './code.component'; +import { CodeModule } from './code.module'; +import { CopierService } from 'app/shared//copier.service'; +import { Logger } from 'app/shared/logger.service'; +import { PrettyPrinter } from './pretty-printer.service'; + +const oneLineCode = 'const foo = "bar";'; + +const smallMultiLineCode = ` +<hero-details> + <h2>Bah Dah Bing</h2> + <hero-team> + <h3>NYC Team</h3> + </hero-team> +</hero-details>`; + +const bigMultiLineCode = + smallMultiLineCode + smallMultiLineCode + smallMultiLineCode; + +describe('CodeComponent', () => { + let hostComponent: HostComponent; + let fixture: ComponentFixture; + + // WARNING: Chance of cross-test pollution + // CodeComponent injects PrettyPrintService + // Once PrettyPrintService runs once _anywhere_, its ctor loads `prettify.js` + // which sets `window['prettyPrintOne']` + // That global survives these tests unless + // we take strict measures to wipe it out in the `afterAll` + // and make sure THAT runs after the tests by making component creation async + afterAll(() => { + delete (window as any)['prettyPrint']; + delete (window as any)['prettyPrintOne']; + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, CodeModule], + declarations: [HostComponent], + providers: [ + PrettyPrinter, + CopierService, + { provide: Logger, useClass: TestLogger }, + ], + }).compileComponents(); + }); + + // Must be async because + // CodeComponent creates PrettyPrintService which async loads `prettify.js`. + // If not async, `afterAll` finishes before tests do! + beforeEach(async(() => { + fixture = TestBed.createComponent(HostComponent); + hostComponent = fixture.componentInstance; + + fixture.detectChanges(); + })); + + describe('pretty printing', () => { + const untilCodeFormatted = () => { + const emitter = hostComponent.codeComponent.codeFormatted; + return emitter.pipe(first()).toPromise(); + }; + const hasLineNumbers = async () => { + // presence of `
  • `s are a tell-tale for line numbers + await untilCodeFormatted(); + return 0 < fixture.nativeElement.querySelectorAll('li').length; + }; + + it('should format a one-line code sample', async () => { + hostComponent.setCode(oneLineCode); + await untilCodeFormatted(); + + // 'pln' spans are a tell-tale for syntax highlighting + const spans = fixture.nativeElement.querySelectorAll('span.pln'); + expect(spans.length).toBeGreaterThan(0, 'formatted spans'); + }); + + it('should format a one-line code sample without linenums by default', async () => { + hostComponent.setCode(oneLineCode); + expect(await hasLineNumbers()).toBe(false); + }); + + it('should add line numbers to one-line code sample when linenums set true', async () => { + hostComponent.linenums = 'true'; + fixture.detectChanges(); + + expect(await hasLineNumbers()).toBe(true); + }); + + it('should format a small multi-line code without linenums by default', async () => { + hostComponent.setCode(smallMultiLineCode); + expect(await hasLineNumbers()).toBe(false); + }); + + it('should add line numbers to a big multi-line code by default', async () => { + hostComponent.setCode(bigMultiLineCode); + expect(await hasLineNumbers()).toBe(true); + }); + + it('should format big multi-line code without linenums when linenums set false', async () => { + hostComponent.linenums = false; + fixture.detectChanges(); + + hostComponent.setCode(bigMultiLineCode); + expect(await hasLineNumbers()).toBe(false); + }); + }); + + describe('whitespace handling', () => { + it('should remove common indentation from the code before rendering', () => { + hostComponent.linenums = false; + fixture.detectChanges(); + + hostComponent.setCode( + " abc\n let x = text.split('\\n');\n ghi\n\n jkl\n" + ); + const codeContent = fixture.nativeElement.querySelector('code') + .textContent; + expect(codeContent).toEqual( + "abc\n let x = text.split('\\n');\nghi\n\njkl" + ); + }); + + it('should trim whitespace from the code before rendering', () => { + hostComponent.linenums = false; + fixture.detectChanges(); + + hostComponent.setCode('\n\n\n' + smallMultiLineCode + '\n\n\n'); + const codeContent = fixture.nativeElement.querySelector('code') + .textContent; + expect(codeContent).toEqual(codeContent.trim()); + }); + + it('should trim whitespace from code before computing whether to format linenums', () => { + hostComponent.setCode('\n\n\n' + oneLineCode + '\n\n\n'); + + // `
  • `s are a tell-tale for line numbers + const lis = fixture.nativeElement.querySelectorAll('li'); + expect(lis.length).toBe(0, 'should be no linenums'); + }); + }); + + describe('error message', () => { + function getErrorMessage() { + const missing: HTMLElement = fixture.nativeElement.querySelector( + '.code-missing' + ); + return missing ? missing.textContent : null; + } + + it('should not display "code-missing" class when there is some code', () => { + expect(getErrorMessage()).toBeNull( + 'should not have element with "code-missing" class' + ); + }); + + it('should display error message when there is no code (after trimming)', () => { + hostComponent.setCode(' \n '); + expect(getErrorMessage()).toContain('missing'); + }); + + it('should show path and region in missing-code error message', () => { + hostComponent.path = 'fizz/buzz/foo.html'; + hostComponent.region = 'something'; + fixture.detectChanges(); + + hostComponent.setCode(' \n '); + expect(getErrorMessage()).toMatch( + /for[\s\S]fizz\/buzz\/foo\.html#something$/ + ); + }); + + it('should show path only in missing-code error message when no region', () => { + hostComponent.path = 'fizz/buzz/foo.html'; + fixture.detectChanges(); + + hostComponent.setCode(' \n '); + expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html$/); + }); + + it('should show simple missing-code error message when no path/region', () => { + hostComponent.setCode(' \n '); + expect(getErrorMessage()).toMatch(/missing.$/); + }); + }); + + describe('copy button', () => { + function getButton() { + const btnDe = fixture.debugElement.query(By.css('button')); + return btnDe ? btnDe.nativeElement : null; + } + + it('should be hidden if the `hideCopy` input is true', () => { + hostComponent.hideCopy = true; + fixture.detectChanges(); + expect(getButton()).toBe(null); + }); + + it('should have title', () => { + expect(getButton().title).toBe('Copy code snippet'); + }); + + it('should have no aria-label by default', () => { + expect(getButton().getAttribute('aria-label')).toBe(''); + }); + + it('should have aria-label explaining what is being copied when header passed in', () => { + hostComponent.header = 'a/b/c/foo.ts'; + fixture.detectChanges(); + expect(getButton().getAttribute('aria-label')).toContain( + hostComponent.header + ); + }); + + it('should call copier service when clicked', () => { + const copierService: CopierService = TestBed.inject(CopierService); + const spy = spyOn(copierService, 'copyText'); + expect(spy.calls.count()).toBe(0, 'before click'); + getButton().click(); + expect(spy.calls.count()).toBe(1, 'after click'); + }); + + it('should copy code text when clicked', () => { + const copierService: CopierService = TestBed.inject(CopierService); + const spy = spyOn(copierService, 'copyText'); + getButton().click(); + expect(spy.calls.argsFor(0)[0]).toBe(oneLineCode, 'after click'); + }); + + it('should preserve newlines in the copied code', () => { + const copierService: CopierService = TestBed.inject(CopierService); + const spy = spyOn(copierService, 'copyText'); + const expectedCode = smallMultiLineCode + .trim() + .replace(/</g, '<') + .replace(/>/g, '>'); + let actualCode; + + hostComponent.setCode(smallMultiLineCode); + + [false, true, 42].forEach(linenums => { + hostComponent.linenums = linenums; + fixture.detectChanges(); + getButton().click(); + actualCode = spy.calls.mostRecent().args[0]; + + expect(actualCode).toBe(expectedCode, `when linenums=${linenums}`); + expect(actualCode.match(/\r?\n/g).length).toBe(5); + + spy.calls.reset(); + }); + }); + + it('should display a message when copy succeeds', () => { + const snackBar: MatSnackBar = TestBed.inject(MatSnackBar); + const copierService: CopierService = TestBed.inject(CopierService); + spyOn(snackBar, 'open'); + spyOn(copierService, 'copyText').and.returnValue(true); + getButton().click(); + expect(snackBar.open).toHaveBeenCalledWith('Code Copied', '', { + duration: 800, + }); + }); + + it('should display an error when copy fails', () => { + const snackBar: MatSnackBar = TestBed.inject(MatSnackBar); + const copierService: CopierService = TestBed.inject(CopierService); + const logger: TestLogger = TestBed.inject(Logger); + spyOn(snackBar, 'open'); + spyOn(copierService, 'copyText').and.returnValue(false); + getButton().click(); + expect(snackBar.open).toHaveBeenCalledWith( + 'Copy failed. Please try again!', + '', + { duration: 800 } + ); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(jasmine.any(Error)); + expect(logger.error.calls.mostRecent().args[0].message).toMatch( + /^ERROR copying code to clipboard:/ + ); + }); + }); +}); + +//// Test helpers //// +// tslint:disable:member-ordering +@Component({ + selector: 'aio-host-comp', + template: ` + + `, +}) +class HostComponent implements AfterViewInit { + hideCopy: boolean; + language: string; + linenums: boolean | number | string; + path: string; + region: string; + header: string; + + @ViewChild(CodeComponent) codeComponent: CodeComponent; + + ngAfterViewInit() { + this.setCode(oneLineCode); + } + + /** Changes the displayed code on the code component. */ + setCode(code: string) { + this.codeComponent.code = code; + } +} + +class TestLogger { + log = jasmine.createSpy('log'); + error = jasmine.createSpy('error'); +} diff --git a/projects/ngrx.io/src/app/custom-elements/search/file-not-found-search.component.spec.ts b/projects/ngrx.io/src/app/custom-elements/search/file-not-found-search.component.spec.ts index 3cf746e7a9..ed999b6d3b 100644 --- a/projects/ngrx.io/src/app/custom-elements/search/file-not-found-search.component.spec.ts +++ b/projects/ngrx.io/src/app/custom-elements/search/file-not-found-search.component.spec.ts @@ -1,47 +1,47 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { Subject } from 'rxjs'; -import { LocationService } from 'app/shared/location.service'; -import { MockLocationService } from 'testing/location.service'; -import { SearchResults } from 'app/search/interfaces'; -import { SearchResultsComponent } from 'app/shared/search-results/search-results.component'; -import { SearchService } from 'app/search/search.service'; -import { FileNotFoundSearchComponent } from './file-not-found-search.component'; - - -describe('FileNotFoundSearchComponent', () => { - let fixture: ComponentFixture; - let searchService: SearchService; - let searchResultSubject: Subject; - - beforeEach(() => { - - TestBed.configureTestingModule({ - declarations: [ FileNotFoundSearchComponent, SearchResultsComponent ], - providers: [ - { provide: LocationService, useValue: new MockLocationService('base/initial-url?some-query') }, - SearchService - ] - }); - - fixture = TestBed.createComponent(FileNotFoundSearchComponent); - searchService = TestBed.get(SearchService); - searchResultSubject = new Subject(); - spyOn(searchService, 'search').and.callFake(() => searchResultSubject.asObservable()); - fixture.detectChanges(); - }); - - it('should run a search with a query built from the current url', () => { - expect(searchService.search).toHaveBeenCalledWith('base initial url'); - }); - - it('should pass through any results to the `aio-search-results` component', () => { - const searchResultsComponent = fixture.debugElement.query(By.directive(SearchResultsComponent)).componentInstance; - expect(searchResultsComponent.searchResults).toBe(null); - - const results = { query: 'base initial url', results: []}; - searchResultSubject.next(results); - fixture.detectChanges(); - expect(searchResultsComponent.searchResults).toEqual(results); - }); -}); +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Subject } from 'rxjs'; +import { LocationService } from 'app/shared/location.service'; +import { MockLocationService } from 'testing/location.service'; +import { SearchResults } from 'app/search/interfaces'; +import { SearchResultsComponent } from 'app/shared/search-results/search-results.component'; +import { SearchService } from 'app/search/search.service'; +import { FileNotFoundSearchComponent } from './file-not-found-search.component'; + + +describe('FileNotFoundSearchComponent', () => { + let fixture: ComponentFixture; + let searchService: SearchService; + let searchResultSubject: Subject; + + beforeEach(() => { + + TestBed.configureTestingModule({ + declarations: [ FileNotFoundSearchComponent, SearchResultsComponent ], + providers: [ + { provide: LocationService, useValue: new MockLocationService('base/initial-url?some-query') }, + SearchService + ] + }); + + fixture = TestBed.createComponent(FileNotFoundSearchComponent); + searchService = TestBed.inject(SearchService); + searchResultSubject = new Subject(); + spyOn(searchService, 'search').and.callFake(() => searchResultSubject.asObservable()); + fixture.detectChanges(); + }); + + it('should run a search with a query built from the current url', () => { + expect(searchService.search).toHaveBeenCalledWith('base initial url'); + }); + + it('should pass through any results to the `aio-search-results` component', () => { + const searchResultsComponent = fixture.debugElement.query(By.directive(SearchResultsComponent)).componentInstance; + expect(searchResultsComponent.searchResults).toBe(null); + + const results = { query: 'base initial url', results: []}; + searchResultSubject.next(results); + fixture.detectChanges(); + expect(searchResultsComponent.searchResults).toEqual(results); + }); +}); diff --git a/projects/ngrx.io/src/app/custom-elements/toc/toc.component.spec.ts b/projects/ngrx.io/src/app/custom-elements/toc/toc.component.spec.ts index b3c885296c..be0c5153db 100644 --- a/projects/ngrx.io/src/app/custom-elements/toc/toc.component.spec.ts +++ b/projects/ngrx.io/src/app/custom-elements/toc/toc.component.spec.ts @@ -1,524 +1,524 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { asapScheduler as asap, BehaviorSubject } from 'rxjs'; - -import { ScrollService } from 'app/shared/scroll.service'; -import { TocItem, TocService } from 'app/shared/toc.service'; -import { TocComponent } from './toc.component'; - -describe('TocComponent', () => { - let tocComponentDe: DebugElement; - let tocComponent: TocComponent; - let tocService: TestTocService; - - let page: { - listItems: DebugElement[]; - tocHeading: DebugElement; - tocHeadingButtonEmbedded: DebugElement; - tocH1Heading: DebugElement; - tocMoreButton: DebugElement; - }; - - function setPage(): typeof page { - return { - listItems: tocComponentDe.queryAll(By.css('ul.toc-list>li')), - tocHeading: tocComponentDe.query(By.css('.toc-heading')), - tocHeadingButtonEmbedded: tocComponentDe.query( - By.css('button.toc-heading.embedded') - ), - tocH1Heading: tocComponentDe.query(By.css('.h1')), - tocMoreButton: tocComponentDe.query(By.css('button.toc-more-items')), - }; - } - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ - HostEmbeddedTocComponent, - HostNotEmbeddedTocComponent, - TocComponent, - ], - providers: [ - { provide: ScrollService, useClass: TestScrollService }, - { provide: TocService, useClass: TestTocService }, - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }); - }); - - describe('when embedded in doc body', () => { - let fixture: ComponentFixture; - - beforeEach(() => { - fixture = TestBed.createComponent(HostEmbeddedTocComponent); - tocComponentDe = fixture.debugElement.children[0]; - tocComponent = tocComponentDe.componentInstance; - tocService = TestBed.get(TocService); - }); - - it('should create tocComponent', () => { - expect(tocComponent).toBeTruthy(); - }); - - it('should be in embedded state', () => { - expect(tocComponent.isEmbedded).toEqual(true); - }); - - it('should not display a ToC initially', () => { - expect(tocComponent.type).toEqual('None'); - }); - - it('should not display anything when no h2 or h3 TocItems', () => { - tocService.tocList.next([tocItem('H1', 'h1')]); - fixture.detectChanges(); - expect(tocComponentDe.children.length).toEqual(0); - }); - - it('should update when the TocItems are updated', () => { - tocService.tocList.next([tocItem('Heading A')]); - fixture.detectChanges(); - expect(tocComponentDe.queryAll(By.css('li')).length).toBe(1); - - tocService.tocList.next([ - tocItem('Heading A'), - tocItem('Heading B'), - tocItem('Heading C'), - ]); - fixture.detectChanges(); - expect(tocComponentDe.queryAll(By.css('li')).length).toBe(3); - }); - - it('should only display H2 and H3 TocItems', () => { - tocService.tocList.next([ - tocItem('Heading A', 'h1'), - tocItem('Heading B'), - tocItem('Heading C', 'h3'), - ]); - fixture.detectChanges(); - - const tocItems = tocComponentDe.queryAll(By.css('li')); - const textContents = tocItems.map(item => - item.nativeNode.textContent.trim() - ); - - expect(tocItems.length).toBe(2); - expect(textContents.find(text => text === 'Heading A')).toBeFalsy(); - expect(textContents.find(text => text === 'Heading B')).toBeTruthy(); - expect(textContents.find(text => text === 'Heading C')).toBeTruthy(); - expect(setPage().tocH1Heading).toBeFalsy(); - }); - - it('should stop listening for TocItems once destroyed', () => { - tocService.tocList.next([tocItem('Heading A')]); - fixture.detectChanges(); - expect(tocComponentDe.queryAll(By.css('li')).length).toBe(1); - - tocComponent.ngOnDestroy(); - tocService.tocList.next([ - tocItem('Heading A', 'h1'), - tocItem('Heading B'), - tocItem('Heading C'), - ]); - fixture.detectChanges(); - expect(tocComponentDe.queryAll(By.css('li')).length).toBe(1); - }); - - describe('when fewer than `maxPrimary` TocItems', () => { - beforeEach(() => { - tocService.tocList.next([ - tocItem('Heading A'), - tocItem('Heading B'), - tocItem('Heading C'), - tocItem('Heading D'), - ]); - fixture.detectChanges(); - page = setPage(); - }); - - it('should have four displayed items', () => { - expect(page.listItems.length).toEqual(4); - }); - - it('should not have secondary items', () => { - expect(tocComponent.type).toEqual('EmbeddedSimple'); - const aSecond = page.listItems.find(item => item.classes.secondary); - expect(aSecond).toBeFalsy('should not find a secondary'); - }); - - it('should not display expando buttons', () => { - expect(page.tocHeadingButtonEmbedded).toBeFalsy( - 'top expand/collapse button' - ); - expect(page.tocMoreButton).toBeFalsy('bottom more button'); - }); - }); - - describe('when many TocItems', () => { - let scrollToTopSpy: jasmine.Spy; - - beforeEach(() => { - fixture.detectChanges(); - page = setPage(); - scrollToTopSpy = TestBed.get(ScrollService).scrollToTop; - }); - - it('should have more than 4 displayed items', () => { - expect(page.listItems.length).toBeGreaterThan(4); - }); - - it('should not display the h1 item', () => { - expect(page.listItems.find(item => item.classes.h1)).toBeFalsy( - 'should not find h1 item' - ); - }); - - it('should be in "collapsed" (not expanded) state at the start', () => { - expect(tocComponent.isCollapsed).toBeTruthy(); - }); - - it('should have "collapsed" class at the start', () => { - expect(tocComponentDe.children[0].classes.collapsed).toEqual(true); - }); - - it('should display expando buttons', () => { - expect(page.tocHeadingButtonEmbedded).toBeTruthy( - 'top expand/collapse button' - ); - expect(page.tocMoreButton).toBeTruthy('bottom more button'); - }); - - it('should have secondary items', () => { - expect(tocComponent.type).toEqual('EmbeddedExpandable'); - }); - - // CSS will hide items with the secondary class when collapsed - it('should have secondary item with a secondary class', () => { - const aSecondary = page.listItems.find(item => item.classes.secondary); - expect(aSecondary).toBeTruthy('should find a secondary'); - }); - - describe('after click tocHeading button', () => { - beforeEach(() => { - page.tocHeadingButtonEmbedded.nativeElement.click(); - fixture.detectChanges(); - }); - - it('should not be "collapsed"', () => { - expect(tocComponent.isCollapsed).toEqual(false); - }); - - it('should not have "collapsed" class', () => { - expect(tocComponentDe.children[0].classes.collapsed).toBeFalsy(); - }); - - it('should not scroll', () => { - expect(scrollToTopSpy).not.toHaveBeenCalled(); - }); - - it('should be "collapsed" after clicking again', () => { - page.tocHeadingButtonEmbedded.nativeElement.click(); - fixture.detectChanges(); - expect(tocComponent.isCollapsed).toEqual(true); - }); - - it('should not scroll after clicking again', () => { - page.tocHeadingButtonEmbedded.nativeElement.click(); - fixture.detectChanges(); - expect(scrollToTopSpy).not.toHaveBeenCalled(); - }); - }); - - describe('after click tocMore button', () => { - beforeEach(() => { - page.tocMoreButton.nativeElement.click(); - fixture.detectChanges(); - }); - - it('should not be "collapsed"', () => { - expect(tocComponent.isCollapsed).toEqual(false); - }); - - it('should not have "collapsed" class', () => { - expect(tocComponentDe.children[0].classes.collapsed).toBeFalsy(); - }); - - it('should not scroll', () => { - expect(scrollToTopSpy).not.toHaveBeenCalled(); - }); - - it('should be "collapsed" after clicking again', () => { - page.tocMoreButton.nativeElement.click(); - fixture.detectChanges(); - expect(tocComponent.isCollapsed).toEqual(true); - }); - - it('should be "collapsed" after clicking tocHeadingButton', () => { - page.tocMoreButton.nativeElement.click(); - fixture.detectChanges(); - expect(tocComponent.isCollapsed).toEqual(true); - }); - - it('should scroll after clicking again', () => { - page.tocMoreButton.nativeElement.click(); - fixture.detectChanges(); - expect(scrollToTopSpy).toHaveBeenCalled(); - }); - }); - }); - }); - - describe('when in side panel (not embedded)', () => { - let fixture: ComponentFixture; - - beforeEach(() => { - fixture = TestBed.createComponent(HostNotEmbeddedTocComponent); - - tocComponentDe = fixture.debugElement.children[0]; - tocComponent = tocComponentDe.componentInstance; - tocService = TestBed.get(TocService); - - fixture.detectChanges(); - page = setPage(); - }); - - it('should not be in embedded state', () => { - expect(tocComponent.isEmbedded).toEqual(false); - expect(tocComponent.type).toEqual('Floating'); - }); - - it('should display all items (including h1s)', () => { - expect(page.listItems.length).toEqual(getTestTocList().length); - }); - - it('should not have secondary items', () => { - expect(tocComponent.type).toEqual('Floating'); - const aSecond = page.listItems.find(item => item.classes.secondary); - expect(aSecond).toBeFalsy('should not find a secondary'); - }); - - it('should not display expando buttons', () => { - expect(page.tocHeadingButtonEmbedded).toBeFalsy( - 'top expand/collapse button' - ); - expect(page.tocMoreButton).toBeFalsy('bottom more button'); - }); - - it('should display H1 title', () => { - expect(page.tocH1Heading).toBeTruthy(); - }); - - describe('#activeIndex', () => { - it("should keep track of `TocService`'s `activeItemIndex`", () => { - expect(tocComponent.activeIndex).toBeNull(); - - tocService.setActiveIndex(42); - expect(tocComponent.activeIndex).toBe(42); - - tocService.setActiveIndex(null); - expect(tocComponent.activeIndex).toBeNull(); - }); - - it('should stop tracking `activeItemIndex` once destroyed', () => { - tocService.setActiveIndex(42); - expect(tocComponent.activeIndex).toBe(42); - - tocComponent.ngOnDestroy(); - - tocService.setActiveIndex(43); - expect(tocComponent.activeIndex).toBe(42); - - tocService.setActiveIndex(null); - expect(tocComponent.activeIndex).toBe(42); - }); - - it('should set the `active` class to the active anchor (and only that)', () => { - expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); - - tocComponent.activeIndex = 1; - fixture.detectChanges(); - expect(page.listItems.filter(By.css('.active')).length).toBe(1); - expect(page.listItems.findIndex(By.css('.active'))).toBe(1); - - tocComponent.activeIndex = null; - fixture.detectChanges(); - expect(page.listItems.filter(By.css('.active')).length).toBe(0); - expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); - - tocComponent.activeIndex = 0; - fixture.detectChanges(); - expect(page.listItems.filter(By.css('.active')).length).toBe(1); - expect(page.listItems.findIndex(By.css('.active'))).toBe(0); - - tocComponent.activeIndex = 1337; - fixture.detectChanges(); - expect(page.listItems.filter(By.css('.active')).length).toBe(0); - expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); - - tocComponent.activeIndex = page.listItems.length - 1; - fixture.detectChanges(); - expect(page.listItems.filter(By.css('.active')).length).toBe(1); - expect(page.listItems.findIndex(By.css('.active'))).toBe( - page.listItems.length - 1 - ); - }); - - it('should re-apply the `active` class when the list elements change', () => { - const getActiveTextContent = () => - page.listItems - .find(By.css('.active'))! - .nativeElement.textContent.trim(); - - tocComponent.activeIndex = 1; - fixture.detectChanges(); - expect(getActiveTextContent()).toBe('Heading one'); - - tocComponent.tocList = [tocItem('New 1'), tocItem('New 2')]; - fixture.detectChanges(); - page = setPage(); - expect(getActiveTextContent()).toBe('New 2'); - - tocComponent.tocList.unshift(tocItem('New 0')); - fixture.detectChanges(); - page = setPage(); - expect(getActiveTextContent()).toBe('New 1'); - - tocComponent.tocList = [tocItem('Very New 1')]; - fixture.detectChanges(); - page = setPage(); - expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); - - tocComponent.activeIndex = 0; - fixture.detectChanges(); - expect(getActiveTextContent()).toBe('Very New 1'); - }); - - describe('should scroll the active ToC item into viewport (if not already visible)', () => { - let parentScrollTop: number; - - beforeEach(() => { - const hostElem = fixture.nativeElement; - const firstItem = page.listItems[0].nativeElement; - - Object.assign(hostElem.style, { - display: 'block', - maxHeight: `${hostElem.clientHeight - firstItem.clientHeight}px`, - overflow: 'auto', - position: 'relative', - }); - Object.defineProperty(hostElem, 'scrollTop', { - get: () => parentScrollTop, - set: v => (parentScrollTop = v), - }); - - parentScrollTop = 0; - }); - - it('when the `activeIndex` changes', () => { - tocService.setActiveIndex(0); - fixture.detectChanges(); - - expect(parentScrollTop).toBe(0); - - tocService.setActiveIndex(1); - fixture.detectChanges(); - - expect(parentScrollTop).toBe(0); - - tocService.setActiveIndex(page.listItems.length - 1); - fixture.detectChanges(); - - expect(parentScrollTop).toBeGreaterThan(0); - }); - - it('when the `tocList` changes', () => { - const tocList = tocComponent.tocList; - - tocComponent.tocList = []; - fixture.detectChanges(); - - expect(parentScrollTop).toBe(0); - - tocService.setActiveIndex(tocList.length - 1); - fixture.detectChanges(); - - expect(parentScrollTop).toBe(0); - - tocComponent.tocList = tocList; - fixture.detectChanges(); - - expect(parentScrollTop).toBeGreaterThan(0); - }); - - it('not after it has been destroyed', () => { - const tocList = tocComponent.tocList; - tocComponent.ngOnDestroy(); - - tocService.setActiveIndex(page.listItems.length - 1); - fixture.detectChanges(); - - expect(parentScrollTop).toBe(0); - - tocComponent.tocList = []; - fixture.detectChanges(); - - expect(parentScrollTop).toBe(0); - - tocComponent.tocList = tocList; - fixture.detectChanges(); - - expect(parentScrollTop).toBe(0); - }); - }); - }); - }); -}); - -//// helpers //// -@Component({ - selector: 'aio-embedded-host', - template: '', -}) -class HostEmbeddedTocComponent {} - -@Component({ - selector: 'aio-not-embedded-host', - template: '', -}) -class HostNotEmbeddedTocComponent {} - -class TestScrollService { - scrollToTop = jasmine.createSpy('scrollToTop'); -} - -class TestTocService { - tocList = new BehaviorSubject(getTestTocList()); - activeItemIndex = new BehaviorSubject(null); - setActiveIndex(index: number | null) { - this.activeItemIndex.next(index); - if (asap.scheduled !== undefined) { - asap.flush(); - } - } -} - -function tocItem(title: string, level = 'h2', href = '', content = title) { - return { title, href, level, content }; -} - -function getTestTocList() { - return [ - tocItem('Title', 'h1', 'fizz/buzz#title', 'Title'), - tocItem( - 'Heading one', - 'h2', - 'fizz/buzz#heading-one-special-id', - 'Heading one' - ), - tocItem('H2 Two', 'h2', 'fizz/buzz#h2-two', 'H2 Two'), - tocItem('H2 Three', 'h2', 'fizz/buzz#h2-three', 'H2 Three'), - tocItem('H3 3a', 'h3', 'fizz/buzz#h3-3a', 'H3 3a'), - tocItem('H3 3b', 'h3', 'fizz/buzz#h3-3b', 'H3 3b'), - tocItem('H2 4', 'h2', 'fizz/buzz#h2-four', 'H2 four'), - ]; -} +import { Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { asapScheduler as asap, BehaviorSubject } from 'rxjs'; + +import { ScrollService } from 'app/shared/scroll.service'; +import { TocItem, TocService } from 'app/shared/toc.service'; +import { TocComponent } from './toc.component'; + +describe('TocComponent', () => { + let tocComponentDe: DebugElement; + let tocComponent: TocComponent; + let tocService: TestTocService; + + let page: { + listItems: DebugElement[]; + tocHeading: DebugElement; + tocHeadingButtonEmbedded: DebugElement; + tocH1Heading: DebugElement; + tocMoreButton: DebugElement; + }; + + function setPage(): typeof page { + return { + listItems: tocComponentDe.queryAll(By.css('ul.toc-list>li')), + tocHeading: tocComponentDe.query(By.css('.toc-heading')), + tocHeadingButtonEmbedded: tocComponentDe.query( + By.css('button.toc-heading.embedded') + ), + tocH1Heading: tocComponentDe.query(By.css('.h1')), + tocMoreButton: tocComponentDe.query(By.css('button.toc-more-items')), + }; + } + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + HostEmbeddedTocComponent, + HostNotEmbeddedTocComponent, + TocComponent, + ], + providers: [ + { provide: ScrollService, useClass: TestScrollService }, + { provide: TocService, useClass: TestTocService }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + }); + + describe('when embedded in doc body', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(HostEmbeddedTocComponent); + tocComponentDe = fixture.debugElement.children[0]; + tocComponent = tocComponentDe.componentInstance; + tocService = TestBed.inject(TocService); + }); + + it('should create tocComponent', () => { + expect(tocComponent).toBeTruthy(); + }); + + it('should be in embedded state', () => { + expect(tocComponent.isEmbedded).toEqual(true); + }); + + it('should not display a ToC initially', () => { + expect(tocComponent.type).toEqual('None'); + }); + + it('should not display anything when no h2 or h3 TocItems', () => { + tocService.tocList.next([tocItem('H1', 'h1')]); + fixture.detectChanges(); + expect(tocComponentDe.children.length).toEqual(0); + }); + + it('should update when the TocItems are updated', () => { + tocService.tocList.next([tocItem('Heading A')]); + fixture.detectChanges(); + expect(tocComponentDe.queryAll(By.css('li')).length).toBe(1); + + tocService.tocList.next([ + tocItem('Heading A'), + tocItem('Heading B'), + tocItem('Heading C'), + ]); + fixture.detectChanges(); + expect(tocComponentDe.queryAll(By.css('li')).length).toBe(3); + }); + + it('should only display H2 and H3 TocItems', () => { + tocService.tocList.next([ + tocItem('Heading A', 'h1'), + tocItem('Heading B'), + tocItem('Heading C', 'h3'), + ]); + fixture.detectChanges(); + + const tocItems = tocComponentDe.queryAll(By.css('li')); + const textContents = tocItems.map(item => + item.nativeNode.textContent.trim() + ); + + expect(tocItems.length).toBe(2); + expect(textContents.find(text => text === 'Heading A')).toBeFalsy(); + expect(textContents.find(text => text === 'Heading B')).toBeTruthy(); + expect(textContents.find(text => text === 'Heading C')).toBeTruthy(); + expect(setPage().tocH1Heading).toBeFalsy(); + }); + + it('should stop listening for TocItems once destroyed', () => { + tocService.tocList.next([tocItem('Heading A')]); + fixture.detectChanges(); + expect(tocComponentDe.queryAll(By.css('li')).length).toBe(1); + + tocComponent.ngOnDestroy(); + tocService.tocList.next([ + tocItem('Heading A', 'h1'), + tocItem('Heading B'), + tocItem('Heading C'), + ]); + fixture.detectChanges(); + expect(tocComponentDe.queryAll(By.css('li')).length).toBe(1); + }); + + describe('when fewer than `maxPrimary` TocItems', () => { + beforeEach(() => { + tocService.tocList.next([ + tocItem('Heading A'), + tocItem('Heading B'), + tocItem('Heading C'), + tocItem('Heading D'), + ]); + fixture.detectChanges(); + page = setPage(); + }); + + it('should have four displayed items', () => { + expect(page.listItems.length).toEqual(4); + }); + + it('should not have secondary items', () => { + expect(tocComponent.type).toEqual('EmbeddedSimple'); + const aSecond = page.listItems.find(item => item.classes.secondary); + expect(aSecond).toBeFalsy('should not find a secondary'); + }); + + it('should not display expando buttons', () => { + expect(page.tocHeadingButtonEmbedded).toBeFalsy( + 'top expand/collapse button' + ); + expect(page.tocMoreButton).toBeFalsy('bottom more button'); + }); + }); + + describe('when many TocItems', () => { + let scrollToTopSpy: jasmine.Spy; + + beforeEach(() => { + fixture.detectChanges(); + page = setPage(); + scrollToTopSpy = TestBed.inject(ScrollService).scrollToTop; + }); + + it('should have more than 4 displayed items', () => { + expect(page.listItems.length).toBeGreaterThan(4); + }); + + it('should not display the h1 item', () => { + expect(page.listItems.find(item => item.classes.h1)).toBeFalsy( + 'should not find h1 item' + ); + }); + + it('should be in "collapsed" (not expanded) state at the start', () => { + expect(tocComponent.isCollapsed).toBeTruthy(); + }); + + it('should have "collapsed" class at the start', () => { + expect(tocComponentDe.children[0].classes.collapsed).toEqual(true); + }); + + it('should display expando buttons', () => { + expect(page.tocHeadingButtonEmbedded).toBeTruthy( + 'top expand/collapse button' + ); + expect(page.tocMoreButton).toBeTruthy('bottom more button'); + }); + + it('should have secondary items', () => { + expect(tocComponent.type).toEqual('EmbeddedExpandable'); + }); + + // CSS will hide items with the secondary class when collapsed + it('should have secondary item with a secondary class', () => { + const aSecondary = page.listItems.find(item => item.classes.secondary); + expect(aSecondary).toBeTruthy('should find a secondary'); + }); + + describe('after click tocHeading button', () => { + beforeEach(() => { + page.tocHeadingButtonEmbedded.nativeElement.click(); + fixture.detectChanges(); + }); + + it('should not be "collapsed"', () => { + expect(tocComponent.isCollapsed).toEqual(false); + }); + + it('should not have "collapsed" class', () => { + expect(tocComponentDe.children[0].classes.collapsed).toBeFalsy(); + }); + + it('should not scroll', () => { + expect(scrollToTopSpy).not.toHaveBeenCalled(); + }); + + it('should be "collapsed" after clicking again', () => { + page.tocHeadingButtonEmbedded.nativeElement.click(); + fixture.detectChanges(); + expect(tocComponent.isCollapsed).toEqual(true); + }); + + it('should not scroll after clicking again', () => { + page.tocHeadingButtonEmbedded.nativeElement.click(); + fixture.detectChanges(); + expect(scrollToTopSpy).not.toHaveBeenCalled(); + }); + }); + + describe('after click tocMore button', () => { + beforeEach(() => { + page.tocMoreButton.nativeElement.click(); + fixture.detectChanges(); + }); + + it('should not be "collapsed"', () => { + expect(tocComponent.isCollapsed).toEqual(false); + }); + + it('should not have "collapsed" class', () => { + expect(tocComponentDe.children[0].classes.collapsed).toBeFalsy(); + }); + + it('should not scroll', () => { + expect(scrollToTopSpy).not.toHaveBeenCalled(); + }); + + it('should be "collapsed" after clicking again', () => { + page.tocMoreButton.nativeElement.click(); + fixture.detectChanges(); + expect(tocComponent.isCollapsed).toEqual(true); + }); + + it('should be "collapsed" after clicking tocHeadingButton', () => { + page.tocMoreButton.nativeElement.click(); + fixture.detectChanges(); + expect(tocComponent.isCollapsed).toEqual(true); + }); + + it('should scroll after clicking again', () => { + page.tocMoreButton.nativeElement.click(); + fixture.detectChanges(); + expect(scrollToTopSpy).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when in side panel (not embedded)', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(HostNotEmbeddedTocComponent); + + tocComponentDe = fixture.debugElement.children[0]; + tocComponent = tocComponentDe.componentInstance; + tocService = TestBed.inject(TocService); + + fixture.detectChanges(); + page = setPage(); + }); + + it('should not be in embedded state', () => { + expect(tocComponent.isEmbedded).toEqual(false); + expect(tocComponent.type).toEqual('Floating'); + }); + + it('should display all items (including h1s)', () => { + expect(page.listItems.length).toEqual(getTestTocList().length); + }); + + it('should not have secondary items', () => { + expect(tocComponent.type).toEqual('Floating'); + const aSecond = page.listItems.find(item => item.classes.secondary); + expect(aSecond).toBeFalsy('should not find a secondary'); + }); + + it('should not display expando buttons', () => { + expect(page.tocHeadingButtonEmbedded).toBeFalsy( + 'top expand/collapse button' + ); + expect(page.tocMoreButton).toBeFalsy('bottom more button'); + }); + + it('should display H1 title', () => { + expect(page.tocH1Heading).toBeTruthy(); + }); + + describe('#activeIndex', () => { + it("should keep track of `TocService`'s `activeItemIndex`", () => { + expect(tocComponent.activeIndex).toBeNull(); + + tocService.setActiveIndex(42); + expect(tocComponent.activeIndex).toBe(42); + + tocService.setActiveIndex(null); + expect(tocComponent.activeIndex).toBeNull(); + }); + + it('should stop tracking `activeItemIndex` once destroyed', () => { + tocService.setActiveIndex(42); + expect(tocComponent.activeIndex).toBe(42); + + tocComponent.ngOnDestroy(); + + tocService.setActiveIndex(43); + expect(tocComponent.activeIndex).toBe(42); + + tocService.setActiveIndex(null); + expect(tocComponent.activeIndex).toBe(42); + }); + + it('should set the `active` class to the active anchor (and only that)', () => { + expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); + + tocComponent.activeIndex = 1; + fixture.detectChanges(); + expect(page.listItems.filter(By.css('.active')).length).toBe(1); + expect(page.listItems.findIndex(By.css('.active'))).toBe(1); + + tocComponent.activeIndex = null; + fixture.detectChanges(); + expect(page.listItems.filter(By.css('.active')).length).toBe(0); + expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); + + tocComponent.activeIndex = 0; + fixture.detectChanges(); + expect(page.listItems.filter(By.css('.active')).length).toBe(1); + expect(page.listItems.findIndex(By.css('.active'))).toBe(0); + + tocComponent.activeIndex = 1337; + fixture.detectChanges(); + expect(page.listItems.filter(By.css('.active')).length).toBe(0); + expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); + + tocComponent.activeIndex = page.listItems.length - 1; + fixture.detectChanges(); + expect(page.listItems.filter(By.css('.active')).length).toBe(1); + expect(page.listItems.findIndex(By.css('.active'))).toBe( + page.listItems.length - 1 + ); + }); + + it('should re-apply the `active` class when the list elements change', () => { + const getActiveTextContent = () => + page.listItems + .find(By.css('.active'))! + .nativeElement.textContent.trim(); + + tocComponent.activeIndex = 1; + fixture.detectChanges(); + expect(getActiveTextContent()).toBe('Heading one'); + + tocComponent.tocList = [tocItem('New 1'), tocItem('New 2')]; + fixture.detectChanges(); + page = setPage(); + expect(getActiveTextContent()).toBe('New 2'); + + tocComponent.tocList.unshift(tocItem('New 0')); + fixture.detectChanges(); + page = setPage(); + expect(getActiveTextContent()).toBe('New 1'); + + tocComponent.tocList = [tocItem('Very New 1')]; + fixture.detectChanges(); + page = setPage(); + expect(page.listItems.findIndex(By.css('.active'))).toBe(-1); + + tocComponent.activeIndex = 0; + fixture.detectChanges(); + expect(getActiveTextContent()).toBe('Very New 1'); + }); + + describe('should scroll the active ToC item into viewport (if not already visible)', () => { + let parentScrollTop: number; + + beforeEach(() => { + const hostElem = fixture.nativeElement; + const firstItem = page.listItems[0].nativeElement; + + Object.assign(hostElem.style, { + display: 'block', + maxHeight: `${hostElem.clientHeight - firstItem.clientHeight}px`, + overflow: 'auto', + position: 'relative', + }); + Object.defineProperty(hostElem, 'scrollTop', { + get: () => parentScrollTop, + set: v => (parentScrollTop = v), + }); + + parentScrollTop = 0; + }); + + it('when the `activeIndex` changes', () => { + tocService.setActiveIndex(0); + fixture.detectChanges(); + + expect(parentScrollTop).toBe(0); + + tocService.setActiveIndex(1); + fixture.detectChanges(); + + expect(parentScrollTop).toBe(0); + + tocService.setActiveIndex(page.listItems.length - 1); + fixture.detectChanges(); + + expect(parentScrollTop).toBeGreaterThan(0); + }); + + it('when the `tocList` changes', () => { + const tocList = tocComponent.tocList; + + tocComponent.tocList = []; + fixture.detectChanges(); + + expect(parentScrollTop).toBe(0); + + tocService.setActiveIndex(tocList.length - 1); + fixture.detectChanges(); + + expect(parentScrollTop).toBe(0); + + tocComponent.tocList = tocList; + fixture.detectChanges(); + + expect(parentScrollTop).toBeGreaterThan(0); + }); + + it('not after it has been destroyed', () => { + const tocList = tocComponent.tocList; + tocComponent.ngOnDestroy(); + + tocService.setActiveIndex(page.listItems.length - 1); + fixture.detectChanges(); + + expect(parentScrollTop).toBe(0); + + tocComponent.tocList = []; + fixture.detectChanges(); + + expect(parentScrollTop).toBe(0); + + tocComponent.tocList = tocList; + fixture.detectChanges(); + + expect(parentScrollTop).toBe(0); + }); + }); + }); + }); +}); + +//// helpers //// +@Component({ + selector: 'aio-embedded-host', + template: '', +}) +class HostEmbeddedTocComponent {} + +@Component({ + selector: 'aio-not-embedded-host', + template: '', +}) +class HostNotEmbeddedTocComponent {} + +class TestScrollService { + scrollToTop = jasmine.createSpy('scrollToTop'); +} + +class TestTocService { + tocList = new BehaviorSubject(getTestTocList()); + activeItemIndex = new BehaviorSubject(null); + setActiveIndex(index: number | null) { + this.activeItemIndex.next(index); + if (asap.scheduled !== undefined) { + asap.flush(); + } + } +} + +function tocItem(title: string, level = 'h2', href = '', content = title) { + return { title, href, level, content }; +} + +function getTestTocList() { + return [ + tocItem('Title', 'h1', 'fizz/buzz#title', 'Title'), + tocItem( + 'Heading one', + 'h2', + 'fizz/buzz#heading-one-special-id', + 'Heading one' + ), + tocItem('H2 Two', 'h2', 'fizz/buzz#h2-two', 'H2 Two'), + tocItem('H2 Three', 'h2', 'fizz/buzz#h2-three', 'H2 Three'), + tocItem('H3 3a', 'h3', 'fizz/buzz#h3-3a', 'H3 3a'), + tocItem('H3 3b', 'h3', 'fizz/buzz#h3-3b', 'H3 3b'), + tocItem('H2 4', 'h2', 'fizz/buzz#h2-four', 'H2 four'), + ]; +} diff --git a/projects/ngrx.io/src/app/layout/doc-viewer/doc-viewer.component.spec.ts b/projects/ngrx.io/src/app/layout/doc-viewer/doc-viewer.component.spec.ts index 93c11102dd..1e6f0bd840 100644 --- a/projects/ngrx.io/src/app/layout/doc-viewer/doc-viewer.component.spec.ts +++ b/projects/ngrx.io/src/app/layout/doc-viewer/doc-viewer.component.spec.ts @@ -1,733 +1,733 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Meta, Title } from '@angular/platform-browser'; - -import { Observable, of } from 'rxjs'; - -import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service'; -import { Logger } from 'app/shared/logger.service'; -import { CustomElementsModule } from 'app/custom-elements/custom-elements.module'; -import { TocService } from 'app/shared/toc.service'; -import { ElementsLoader } from 'app/custom-elements/elements-loader'; -import { -MockTitle, MockTocService, ObservableWithSubscriptionSpies, -TestDocViewerComponent, TestModule, TestParentComponent, MockElementsLoader -} from 'testing/doc-viewer-utils'; -import { MockLogger } from 'testing/logger.service'; -import { DocViewerComponent, NO_ANIMATIONS } from './doc-viewer.component'; - -describe('DocViewerComponent', () => { - let parentFixture: ComponentFixture; - let parentComponent: TestParentComponent; - let docViewerEl: HTMLElement; - let docViewer: TestDocViewerComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [CustomElementsModule, TestModule], - }); - - parentFixture = TestBed.createComponent(TestParentComponent); - parentComponent = parentFixture.componentInstance; - - parentFixture.detectChanges(); - - docViewerEl = parentFixture.debugElement.children[0].nativeElement; - docViewer = parentComponent.docViewer as any; - }); - - it('should create a `DocViewer`', () => { - expect(docViewer).toEqual(jasmine.any(DocViewerComponent)); - }); - - describe('#doc', () => { - let renderSpy: jasmine.Spy; - - const setCurrentDoc = (contents: string|null, id = 'fizz/buzz') => { - parentComponent.currentDoc = {contents, id}; - parentFixture.detectChanges(); - }; - - beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.returnValue([null])); - - it('should render the new document', () => { - setCurrentDoc('foo', 'bar'); - expect(renderSpy).toHaveBeenCalledTimes(1); - expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]); - - setCurrentDoc(null, 'baz'); - expect(renderSpy).toHaveBeenCalledTimes(2); - expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]); - }); - - it('should unsubscribe from the previous "render" observable upon new document', () => { - const obs = new ObservableWithSubscriptionSpies(); - renderSpy.and.returnValue(obs); - - setCurrentDoc('foo', 'bar'); - expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); - expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); - - setCurrentDoc('baz', 'qux'); - expect(obs.subscribeSpy).toHaveBeenCalledTimes(2); - expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); - }); - - it('should ignore falsy document values', () => { - parentComponent.currentDoc = null; - parentFixture.detectChanges(); - - expect(renderSpy).not.toHaveBeenCalled(); - - parentComponent.currentDoc = undefined; - parentFixture.detectChanges(); - - expect(renderSpy).not.toHaveBeenCalled(); - }); - }); - - describe('#ngOnDestroy()', () => { - it('should stop responding to document changes', () => { - const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]); - - expect(renderSpy).not.toHaveBeenCalled(); - - docViewer.doc = {contents: 'Some content', id: 'some-id'}; - expect(renderSpy).toHaveBeenCalledTimes(1); - - docViewer.ngOnDestroy(); - - docViewer.doc = {contents: 'Other content', id: 'other-id'}; - expect(renderSpy).toHaveBeenCalledTimes(1); - - docViewer.doc = {contents: 'More content', id: 'more-id'}; - expect(renderSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('#prepareTitleAndToc()', () => { - const EMPTY_DOC = ''; - const DOC_WITHOUT_H1 = 'Some content'; - const DOC_WITH_H1 = '

    Features

    Some content'; - const DOC_WITH_NO_TOC_H1 = '

    Features

    Some content'; - const DOC_WITH_EMBEDDED_TOC = '

    Features

    Some content'; - const DOC_WITH_EMBEDDED_TOC_WITHOUT_H1 = 'Some content'; - const DOC_WITH_EMBEDDED_TOC_WITH_NO_TOC_H1 = 'Some content'; - const DOC_WITH_HIDDEN_H1_CONTENT = '

    linkFeatures

    Some content'; - let titleService: MockTitle; - let tocService: MockTocService; - let targetEl: HTMLElement; - - const getTocEl = () => targetEl.querySelector('aio-toc'); - const doPrepareTitleAndToc = (contents: string, docId = '') => { - targetEl.innerHTML = contents; - return docViewer.prepareTitleAndToc(targetEl, docId); - }; - const doAddTitleAndToc = (contents: string, docId = '') => { - const addTitleAndToc = doPrepareTitleAndToc(contents, docId); - return addTitleAndToc(); - }; - - beforeEach(() => { - titleService = TestBed.get(Title); - tocService = TestBed.get(TocService); - - targetEl = document.createElement('div'); - document.body.appendChild(targetEl); // Required for `innerText` to work as expected. - }); - - afterEach(() => document.body.removeChild(targetEl)); - - it('should return a function for doing the actual work', () => { - const addTitleAndToc = doPrepareTitleAndToc(DOC_WITH_H1); - - expect(getTocEl()).toBeTruthy(); - expect(titleService.setTitle).not.toHaveBeenCalled(); - expect(tocService.reset).not.toHaveBeenCalled(); - expect(tocService.genToc).not.toHaveBeenCalled(); - - addTitleAndToc(); - - expect(titleService.setTitle).toHaveBeenCalledTimes(1); - expect(tocService.reset).toHaveBeenCalledTimes(1); - expect(tocService.genToc).toHaveBeenCalledTimes(1); - }); - - describe('(title)', () => { - it('should set the title if there is an `

    ` heading', () => { - doAddTitleAndToc(DOC_WITH_H1); - expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Features'); - }); - - it('should set the title if there is a `.no-toc` `

    ` heading', () => { - doAddTitleAndToc(DOC_WITH_NO_TOC_H1); - expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Features'); - }); - - it('should set the default title if there is no `

    ` heading', () => { - doAddTitleAndToc(DOC_WITHOUT_H1); - expect(titleService.setTitle).toHaveBeenCalledWith('NgRx'); - - doAddTitleAndToc(EMPTY_DOC); - expect(titleService.setTitle).toHaveBeenCalledWith('NgRx'); - }); - - it('should not include hidden content of the `

    ` heading in the title', () => { - doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); - expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Features'); - }); - - it('should fall back to `textContent` if `innerText` is not available', () => { - const querySelector_ = targetEl.querySelector; - spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { - const elem = querySelector_.call(targetEl, selector); - return elem && Object.defineProperties(elem, { - innerText: {value: undefined}, - textContent: {value: 'Text Content'}, - }); - }); - - doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); - - expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Text Content'); - }); - - it('should still use `innerText` if available but empty', () => { - const querySelector_ = targetEl.querySelector; - spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { - const elem = querySelector_.call(targetEl, selector); - return elem && Object.defineProperties(elem, { - innerText: { value: '' }, - textContent: { value: 'Text Content' } - }); - }); - - doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); - - expect(titleService.setTitle).toHaveBeenCalledWith('NgRx'); - }); - }); - - describe('(ToC)', () => { - describe('needed', () => { - it('should add an embedded ToC element if there is an `

    ` heading', () => { - doPrepareTitleAndToc(DOC_WITH_H1); - const tocEl = getTocEl()!; - - expect(tocEl).toBeTruthy(); - expect(tocEl.classList.contains('embedded')).toBe(true); - }); - - it('should not add a second ToC element if there a hard coded one in place', () => { - doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC); - expect(targetEl.querySelectorAll('aio-toc').length).toEqual(1); - }); - }); - - - describe('not needed', () => { - it('should not add a ToC element if there is a `.no-toc` `

    ` heading', () => { - doPrepareTitleAndToc(DOC_WITH_NO_TOC_H1); - expect(getTocEl()).toBeFalsy(); - }); - - it('should not add a ToC element if there is no `

    ` heading', () => { - doPrepareTitleAndToc(DOC_WITHOUT_H1); - expect(getTocEl()).toBeFalsy(); - - doPrepareTitleAndToc(EMPTY_DOC); - expect(getTocEl()).toBeFalsy(); - }); - - it('should remove ToC a hard coded one', () => { - doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC_WITHOUT_H1); - expect(getTocEl()).toBeFalsy(); - - doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC_WITH_NO_TOC_H1); - expect(getTocEl()).toBeFalsy(); - }); - }); - - - it('should generate ToC entries if there is an `

    ` heading', () => { - doAddTitleAndToc(DOC_WITH_H1, 'foo'); - - expect(tocService.genToc).toHaveBeenCalledTimes(1); - expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); - }); - - it('should not generate ToC entries if there is a `.no-toc` `

    ` heading', () => { - doAddTitleAndToc(DOC_WITH_NO_TOC_H1); - expect(tocService.genToc).not.toHaveBeenCalled(); - }); - - it('should not generate ToC entries if there is no `

    ` heading', () => { - doAddTitleAndToc(DOC_WITHOUT_H1); - doAddTitleAndToc(EMPTY_DOC); - - expect(tocService.genToc).not.toHaveBeenCalled(); - }); - - it('should always reset the ToC (before generating the new one)', () => { - doAddTitleAndToc(DOC_WITH_H1, 'foo'); - expect(tocService.reset).toHaveBeenCalledTimes(1); - expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc); - expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); - - tocService.genToc.calls.reset(); - - doAddTitleAndToc(DOC_WITH_NO_TOC_H1, 'bar'); - expect(tocService.reset).toHaveBeenCalledTimes(2); - expect(tocService.genToc).not.toHaveBeenCalled(); - - doAddTitleAndToc(DOC_WITHOUT_H1, 'baz'); - expect(tocService.reset).toHaveBeenCalledTimes(3); - expect(tocService.genToc).not.toHaveBeenCalled(); - - doAddTitleAndToc(EMPTY_DOC, 'qux'); - expect(tocService.reset).toHaveBeenCalledTimes(4); - expect(tocService.genToc).not.toHaveBeenCalled(); - }); - }); - }); - - describe('#render()', () => { - let prepareTitleAndTocSpy: jasmine.Spy; - let swapViewsSpy: jasmine.Spy; - let loadElementsSpy: jasmine.Spy; - - const doRender = (contents: string | null, id = 'foo') => - docViewer.render({contents, id}).toPromise(); - - beforeEach(() => { - const elementsLoader = TestBed.get(ElementsLoader) as MockElementsLoader; - loadElementsSpy = elementsLoader.loadContainedCustomElements.and.returnValue(of(undefined)); - prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc'); - swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined)); - }); - - it('should return an `Observable`', () => { - expect(docViewer.render({contents: '', id: ''})).toEqual(jasmine.any(Observable)); - }); - - describe('(contents, title, ToC)', () => { - beforeEach(() => swapViewsSpy.and.callThrough()); - - it('should display the document contents', async () => { - const contents = '

    Hello,

    world!
    '; - await doRender(contents); - - expect(docViewerEl.innerHTML).toContain(contents); - expect(docViewerEl.textContent).toBe('Hello, world!'); - }); - - it('should display nothing if the document has no contents', async () => { - await doRender('Test'); - expect(docViewerEl.textContent).toBe('Test'); - - await doRender(''); - expect(docViewerEl.textContent).toBe(''); - - docViewer.currViewContainer.innerHTML = 'Test'; - expect(docViewerEl.textContent).toBe('Test'); - - await doRender(null); - expect(docViewerEl.textContent).toBe(''); - }); - - it('should prepare the title and ToC (before embedding components)', async () => { - prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => { - expect(targetEl.innerHTML).toBe('Some content'); - expect(docId).toBe('foo'); - }); - - await doRender('Some content', 'foo'); - - expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(loadElementsSpy); - }); - - it('should set the title and ToC (after the content has been set)', async () => { - const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); - prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); - - addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content')); - await doRender('Foo content'); - expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); - - addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content')); - await doRender('Bar content'); - expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2); - - addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('')); - await doRender(''); - expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3); - - addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content')); - await doRender('Qux content'); - expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4); - }); - - it('should remove the "noindex" meta tag if the document is valid', async () => { - await doRender('foo', 'bar'); - expect(TestBed.get(Meta).removeTag).toHaveBeenCalledWith('name="robots"'); - }); - - it('should add the "noindex" meta tag if the document is 404', async () => { - await doRender('missing', FILE_NOT_FOUND_ID); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); - - it('should add a "noindex" meta tag if the document fetching fails', async () => { - await doRender('error', FETCHING_ERROR_ID); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); - }); - - describe('(embedding components)', () => { - it('should embed components', async () => { - await doRender('Some content'); - expect(loadElementsSpy).toHaveBeenCalledTimes(1); - expect(loadElementsSpy).toHaveBeenCalledWith(docViewer.nextViewContainer); - }); - - it('should attempt to embed components even if the document is empty', async () => { - await doRender(''); - await doRender(null); - - expect(loadElementsSpy).toHaveBeenCalledTimes(2); - expect(loadElementsSpy.calls.argsFor(0)).toEqual([docViewer.nextViewContainer]); - expect(loadElementsSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]); - }); - - it('should unsubscribe from the previous "embed" observable when unsubscribed from', () => { - const obs = new ObservableWithSubscriptionSpies(); - loadElementsSpy.and.returnValue(obs); - - const renderObservable = docViewer.render({contents: 'Some content', id: 'foo'}); - const subscription = renderObservable.subscribe(); - - expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); - expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); - - subscription.unsubscribe(); - - expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); - expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); - }); - }); - - describe('(swapping views)', () => { - it('should still swap the views if the document is empty', async () => { - await doRender(''); - expect(swapViewsSpy).toHaveBeenCalledTimes(1); - - await doRender(null); - expect(swapViewsSpy).toHaveBeenCalledTimes(2); - }); - - it('should pass the `addTitleAndToc` callback', async () => { - const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); - prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); - - await doRender('
    '); - - expect(swapViewsSpy).toHaveBeenCalledWith(addTitleAndTocSpy); - }); - - it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => { - const obs = new ObservableWithSubscriptionSpies(); - swapViewsSpy.and.returnValue(obs); - - const renderObservable = docViewer.render({contents: 'Hello, world!', id: 'foo'}); - const subscription = renderObservable.subscribe(); - - expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); - expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); - - subscription.unsubscribe(); - - expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); - expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); - }); - }); - - describe('(on error) should clean up, log the error and recover', () => { - let logger: MockLogger; - - beforeEach(() => logger = TestBed.get(Logger)); - - it('when `prepareTitleAndTocSpy()` fails', async () => { - const error = Error('Typical `prepareTitleAndToc()` error'); - prepareTitleAndTocSpy.and.callFake(() => { - expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); - throw error; - }); - - await doRender('Some content', 'foo'); - - expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(swapViewsSpy).not.toHaveBeenCalled(); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - expect(logger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'foo': ${error.stack}`); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); - - it('when `EmbedComponentsService.embedInto()` fails', async () => { - const error = Error('Typical `embedInto()` error'); - loadElementsSpy.and.callFake(() => { - expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); - throw error; - }); - - await doRender('Some content', 'bar'); - - expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(loadElementsSpy).toHaveBeenCalledTimes(1); - expect(swapViewsSpy).not.toHaveBeenCalled(); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - expect(logger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); - - it('when `swapViews()` fails', async () => { - const error = Error('Typical `swapViews()` error'); - swapViewsSpy.and.callFake(() => { - expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); - throw error; - }); - - await doRender('Some content', 'qux'); - - expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(swapViewsSpy).toHaveBeenCalledTimes(1); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - expect(logger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'qux': ${error.stack}`); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); - - it('when something fails with non-Error', async () => { - const error = 'Typical string error'; - swapViewsSpy.and.callFake(() => { - expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); - throw error; - }); - - await doRender('Some content', 'qux'); - - expect(swapViewsSpy).toHaveBeenCalledTimes(1); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - expect(logger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'qux': ${error}`); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); - }); - - describe('(events)', () => { - it('should emit `docReady` after loading elements', async () => { - const onDocReadySpy = jasmine.createSpy('onDocReady'); - docViewer.docReady.subscribe(onDocReadySpy); - - await doRender('Some content'); - - expect(onDocReadySpy).toHaveBeenCalledTimes(1); - expect(loadElementsSpy).toHaveBeenCalledBefore(onDocReadySpy); - }); - - it('should emit `docReady` before swapping views', async () => { - const onDocReadySpy = jasmine.createSpy('onDocReady'); - docViewer.docReady.subscribe(onDocReadySpy); - - await doRender('Some content'); - - expect(onDocReadySpy).toHaveBeenCalledTimes(1); - expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy); - }); - - it('should emit `docRendered` after swapping views', async () => { - const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); - docViewer.docRendered.subscribe(onDocRenderedSpy); - - await doRender('Some content'); - - expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); - expect(swapViewsSpy).toHaveBeenCalledBefore(onDocRenderedSpy); - }); - }); - }); - - describe('#swapViews()', () => { - let oldCurrViewContainer: HTMLElement; - let oldNextViewContainer: HTMLElement; - - const doSwapViews = (cb?: () => void) => - new Promise((resolve, reject) => - docViewer.swapViews(cb).subscribe(resolve, reject)); - - beforeEach(() => { - oldCurrViewContainer = docViewer.currViewContainer; - oldNextViewContainer = docViewer.nextViewContainer; - - oldCurrViewContainer.innerHTML = 'Current view'; - oldNextViewContainer.innerHTML = 'Next view'; - - docViewerEl.appendChild(oldCurrViewContainer); - - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); - }); - - [true, false].forEach(animationsEnabled => { - describe(`(animationsEnabled: ${animationsEnabled})`, () => { - beforeEach(() => DocViewerComponent.animationsEnabled = animationsEnabled); - afterEach(() => DocViewerComponent.animationsEnabled = true); - - [true, false].forEach(noAnimations => { - describe(`(.${NO_ANIMATIONS}: ${noAnimations})`, () => { - beforeEach(() => docViewerEl.classList[noAnimations ? 'add' : 'remove'](NO_ANIMATIONS)); - - it('should return an observable', done => { - docViewer.swapViews().subscribe(done, done.fail); - }); - - it('should swap the views', async () => { - await doSwapViews(); - - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - expect(docViewer.currViewContainer).toBe(oldNextViewContainer); - expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); - - await doSwapViews(); - - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); - expect(docViewer.currViewContainer).toBe(oldCurrViewContainer); - expect(docViewer.nextViewContainer).toBe(oldNextViewContainer); - }); - - it('should emit `docRemoved` after removing the leaving view', async () => { - const onDocRemovedSpy = jasmine.createSpy('onDocRemoved').and.callFake(() => { - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); - }); - - docViewer.docRemoved.subscribe(onDocRemovedSpy); - - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); - - await doSwapViews(); - - expect(onDocRemovedSpy).toHaveBeenCalledTimes(1); - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - }); - - it('should not emit `docRemoved` if the leaving view is already removed', async () => { - const onDocRemovedSpy = jasmine.createSpy('onDocRemoved'); - - docViewer.docRemoved.subscribe(onDocRemovedSpy); - docViewerEl.removeChild(oldCurrViewContainer); - - await doSwapViews(); - - expect(onDocRemovedSpy).not.toHaveBeenCalled(); - }); - - it('should emit `docInserted` after inserting the entering view', async () => { - const onDocInsertedSpy = jasmine.createSpy('onDocInserted').and.callFake(() => { - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - }); - - docViewer.docInserted.subscribe(onDocInsertedSpy); - - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); - - await doSwapViews(); - - expect(onDocInsertedSpy).toHaveBeenCalledTimes(1); - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - }); - - it('should call the callback after inserting the entering view', async () => { - const onInsertedCb = jasmine.createSpy('onInsertedCb').and.callFake(() => { - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - }); - const onDocInsertedSpy = jasmine.createSpy('onDocInserted'); - - docViewer.docInserted.subscribe(onDocInsertedSpy); - - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); - - await doSwapViews(onInsertedCb); - - expect(onInsertedCb).toHaveBeenCalledTimes(1); - expect(onInsertedCb).toHaveBeenCalledBefore(onDocInsertedSpy); - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - }); - - it('should empty the previous view', async () => { - await doSwapViews(); - - expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - - docViewer.nextViewContainer.innerHTML = 'Next view 2'; - await doSwapViews(); - - expect(docViewer.currViewContainer.innerHTML).toBe('Next view 2'); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - }); - - if (animationsEnabled && !noAnimations) { - // Only test this when there are animations. Without animations, the views are swapped - // synchronously, so there is no need (or way) to abort. - it('should abort swapping if the returned observable is unsubscribed from', async () => { - docViewer.swapViews().subscribe().unsubscribe(); - await doSwapViews(); - - // Since the first call was cancelled, only one swapping should have taken place. - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - expect(docViewer.currViewContainer).toBe(oldNextViewContainer); - expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); - expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - }); - } else { - it('should swap views synchronously when animations are disabled', () => { - const cbSpy = jasmine.createSpy('cb'); - - docViewer.swapViews(cbSpy).subscribe(); - - expect(cbSpy).toHaveBeenCalledTimes(1); - expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); - expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); - expect(docViewer.currViewContainer).toBe(oldNextViewContainer); - expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); - expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - }); - } - }); - }); - }); - }); - }); -}); +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Meta, Title } from '@angular/platform-browser'; + +import { Observable, of } from 'rxjs'; + +import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service'; +import { Logger } from 'app/shared/logger.service'; +import { CustomElementsModule } from 'app/custom-elements/custom-elements.module'; +import { TocService } from 'app/shared/toc.service'; +import { ElementsLoader } from 'app/custom-elements/elements-loader'; +import { +MockTitle, MockTocService, ObservableWithSubscriptionSpies, +TestDocViewerComponent, TestModule, TestParentComponent, MockElementsLoader +} from 'testing/doc-viewer-utils'; +import { MockLogger } from 'testing/logger.service'; +import { DocViewerComponent, NO_ANIMATIONS } from './doc-viewer.component'; + +describe('DocViewerComponent', () => { + let parentFixture: ComponentFixture; + let parentComponent: TestParentComponent; + let docViewerEl: HTMLElement; + let docViewer: TestDocViewerComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CustomElementsModule, TestModule], + }); + + parentFixture = TestBed.createComponent(TestParentComponent); + parentComponent = parentFixture.componentInstance; + + parentFixture.detectChanges(); + + docViewerEl = parentFixture.debugElement.children[0].nativeElement; + docViewer = parentComponent.docViewer as any; + }); + + it('should create a `DocViewer`', () => { + expect(docViewer).toEqual(jasmine.any(DocViewerComponent)); + }); + + describe('#doc', () => { + let renderSpy: jasmine.Spy; + + const setCurrentDoc = (contents: string|null, id = 'fizz/buzz') => { + parentComponent.currentDoc = {contents, id}; + parentFixture.detectChanges(); + }; + + beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.returnValue([null])); + + it('should render the new document', () => { + setCurrentDoc('foo', 'bar'); + expect(renderSpy).toHaveBeenCalledTimes(1); + expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]); + + setCurrentDoc(null, 'baz'); + expect(renderSpy).toHaveBeenCalledTimes(2); + expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]); + }); + + it('should unsubscribe from the previous "render" observable upon new document', () => { + const obs = new ObservableWithSubscriptionSpies(); + renderSpy.and.returnValue(obs); + + setCurrentDoc('foo', 'bar'); + expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); + expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); + + setCurrentDoc('baz', 'qux'); + expect(obs.subscribeSpy).toHaveBeenCalledTimes(2); + expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); + }); + + it('should ignore falsy document values', () => { + parentComponent.currentDoc = null; + parentFixture.detectChanges(); + + expect(renderSpy).not.toHaveBeenCalled(); + + parentComponent.currentDoc = undefined; + parentFixture.detectChanges(); + + expect(renderSpy).not.toHaveBeenCalled(); + }); + }); + + describe('#ngOnDestroy()', () => { + it('should stop responding to document changes', () => { + const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]); + + expect(renderSpy).not.toHaveBeenCalled(); + + docViewer.doc = {contents: 'Some content', id: 'some-id'}; + expect(renderSpy).toHaveBeenCalledTimes(1); + + docViewer.ngOnDestroy(); + + docViewer.doc = {contents: 'Other content', id: 'other-id'}; + expect(renderSpy).toHaveBeenCalledTimes(1); + + docViewer.doc = {contents: 'More content', id: 'more-id'}; + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('#prepareTitleAndToc()', () => { + const EMPTY_DOC = ''; + const DOC_WITHOUT_H1 = 'Some content'; + const DOC_WITH_H1 = '

    Features

    Some content'; + const DOC_WITH_NO_TOC_H1 = '

    Features

    Some content'; + const DOC_WITH_EMBEDDED_TOC = '

    Features

    Some content'; + const DOC_WITH_EMBEDDED_TOC_WITHOUT_H1 = 'Some content'; + const DOC_WITH_EMBEDDED_TOC_WITH_NO_TOC_H1 = 'Some content'; + const DOC_WITH_HIDDEN_H1_CONTENT = '

    linkFeatures

    Some content'; + let titleService: MockTitle; + let tocService: MockTocService; + let targetEl: HTMLElement; + + const getTocEl = () => targetEl.querySelector('aio-toc'); + const doPrepareTitleAndToc = (contents: string, docId = '') => { + targetEl.innerHTML = contents; + return docViewer.prepareTitleAndToc(targetEl, docId); + }; + const doAddTitleAndToc = (contents: string, docId = '') => { + const addTitleAndToc = doPrepareTitleAndToc(contents, docId); + return addTitleAndToc(); + }; + + beforeEach(() => { + titleService = TestBed.inject(Title); + tocService = TestBed.inject(TocService); + + targetEl = document.createElement('div'); + document.body.appendChild(targetEl); // Required for `innerText` to work as expected. + }); + + afterEach(() => document.body.removeChild(targetEl)); + + it('should return a function for doing the actual work', () => { + const addTitleAndToc = doPrepareTitleAndToc(DOC_WITH_H1); + + expect(getTocEl()).toBeTruthy(); + expect(titleService.setTitle).not.toHaveBeenCalled(); + expect(tocService.reset).not.toHaveBeenCalled(); + expect(tocService.genToc).not.toHaveBeenCalled(); + + addTitleAndToc(); + + expect(titleService.setTitle).toHaveBeenCalledTimes(1); + expect(tocService.reset).toHaveBeenCalledTimes(1); + expect(tocService.genToc).toHaveBeenCalledTimes(1); + }); + + describe('(title)', () => { + it('should set the title if there is an `

    ` heading', () => { + doAddTitleAndToc(DOC_WITH_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Features'); + }); + + it('should set the title if there is a `.no-toc` `

    ` heading', () => { + doAddTitleAndToc(DOC_WITH_NO_TOC_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Features'); + }); + + it('should set the default title if there is no `

    ` heading', () => { + doAddTitleAndToc(DOC_WITHOUT_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('NgRx'); + + doAddTitleAndToc(EMPTY_DOC); + expect(titleService.setTitle).toHaveBeenCalledWith('NgRx'); + }); + + it('should not include hidden content of the `

    ` heading in the title', () => { + doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); + expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Features'); + }); + + it('should fall back to `textContent` if `innerText` is not available', () => { + const querySelector_ = targetEl.querySelector; + spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { + const elem = querySelector_.call(targetEl, selector); + return elem && Object.defineProperties(elem, { + innerText: {value: undefined}, + textContent: {value: 'Text Content'}, + }); + }); + + doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); + + expect(titleService.setTitle).toHaveBeenCalledWith('NgRx - Text Content'); + }); + + it('should still use `innerText` if available but empty', () => { + const querySelector_ = targetEl.querySelector; + spyOn(targetEl, 'querySelector').and.callFake((selector: string) => { + const elem = querySelector_.call(targetEl, selector); + return elem && Object.defineProperties(elem, { + innerText: { value: '' }, + textContent: { value: 'Text Content' } + }); + }); + + doAddTitleAndToc(DOC_WITH_HIDDEN_H1_CONTENT); + + expect(titleService.setTitle).toHaveBeenCalledWith('NgRx'); + }); + }); + + describe('(ToC)', () => { + describe('needed', () => { + it('should add an embedded ToC element if there is an `

    ` heading', () => { + doPrepareTitleAndToc(DOC_WITH_H1); + const tocEl = getTocEl()!; + + expect(tocEl).toBeTruthy(); + expect(tocEl.classList.contains('embedded')).toBe(true); + }); + + it('should not add a second ToC element if there a hard coded one in place', () => { + doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC); + expect(targetEl.querySelectorAll('aio-toc').length).toEqual(1); + }); + }); + + + describe('not needed', () => { + it('should not add a ToC element if there is a `.no-toc` `

    ` heading', () => { + doPrepareTitleAndToc(DOC_WITH_NO_TOC_H1); + expect(getTocEl()).toBeFalsy(); + }); + + it('should not add a ToC element if there is no `

    ` heading', () => { + doPrepareTitleAndToc(DOC_WITHOUT_H1); + expect(getTocEl()).toBeFalsy(); + + doPrepareTitleAndToc(EMPTY_DOC); + expect(getTocEl()).toBeFalsy(); + }); + + it('should remove ToC a hard coded one', () => { + doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC_WITHOUT_H1); + expect(getTocEl()).toBeFalsy(); + + doPrepareTitleAndToc(DOC_WITH_EMBEDDED_TOC_WITH_NO_TOC_H1); + expect(getTocEl()).toBeFalsy(); + }); + }); + + + it('should generate ToC entries if there is an `

    ` heading', () => { + doAddTitleAndToc(DOC_WITH_H1, 'foo'); + + expect(tocService.genToc).toHaveBeenCalledTimes(1); + expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); + }); + + it('should not generate ToC entries if there is a `.no-toc` `

    ` heading', () => { + doAddTitleAndToc(DOC_WITH_NO_TOC_H1); + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + + it('should not generate ToC entries if there is no `

    ` heading', () => { + doAddTitleAndToc(DOC_WITHOUT_H1); + doAddTitleAndToc(EMPTY_DOC); + + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + + it('should always reset the ToC (before generating the new one)', () => { + doAddTitleAndToc(DOC_WITH_H1, 'foo'); + expect(tocService.reset).toHaveBeenCalledTimes(1); + expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc); + expect(tocService.genToc).toHaveBeenCalledWith(targetEl, 'foo'); + + tocService.genToc.calls.reset(); + + doAddTitleAndToc(DOC_WITH_NO_TOC_H1, 'bar'); + expect(tocService.reset).toHaveBeenCalledTimes(2); + expect(tocService.genToc).not.toHaveBeenCalled(); + + doAddTitleAndToc(DOC_WITHOUT_H1, 'baz'); + expect(tocService.reset).toHaveBeenCalledTimes(3); + expect(tocService.genToc).not.toHaveBeenCalled(); + + doAddTitleAndToc(EMPTY_DOC, 'qux'); + expect(tocService.reset).toHaveBeenCalledTimes(4); + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + }); + }); + + describe('#render()', () => { + let prepareTitleAndTocSpy: jasmine.Spy; + let swapViewsSpy: jasmine.Spy; + let loadElementsSpy: jasmine.Spy; + + const doRender = (contents: string | null, id = 'foo') => + docViewer.render({contents, id}).toPromise(); + + beforeEach(() => { + const elementsLoader = TestBed.inject(ElementsLoader) as MockElementsLoader; + loadElementsSpy = elementsLoader.loadContainedCustomElements.and.returnValue(of(undefined)); + prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc'); + swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined)); + }); + + it('should return an `Observable`', () => { + expect(docViewer.render({contents: '', id: ''})).toEqual(jasmine.any(Observable)); + }); + + describe('(contents, title, ToC)', () => { + beforeEach(() => swapViewsSpy.and.callThrough()); + + it('should display the document contents', async () => { + const contents = '

    Hello,

    world!
    '; + await doRender(contents); + + expect(docViewerEl.innerHTML).toContain(contents); + expect(docViewerEl.textContent).toBe('Hello, world!'); + }); + + it('should display nothing if the document has no contents', async () => { + await doRender('Test'); + expect(docViewerEl.textContent).toBe('Test'); + + await doRender(''); + expect(docViewerEl.textContent).toBe(''); + + docViewer.currViewContainer.innerHTML = 'Test'; + expect(docViewerEl.textContent).toBe('Test'); + + await doRender(null); + expect(docViewerEl.textContent).toBe(''); + }); + + it('should prepare the title and ToC (before embedding components)', async () => { + prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => { + expect(targetEl.innerHTML).toBe('Some content'); + expect(docId).toBe('foo'); + }); + + await doRender('Some content', 'foo'); + + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); + expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(loadElementsSpy); + }); + + it('should set the title and ToC (after the content has been set)', async () => { + const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); + prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); + + addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Foo content')); + await doRender('Foo content'); + expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); + + addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Bar content')); + await doRender('Bar content'); + expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2); + + addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('')); + await doRender(''); + expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3); + + addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.textContent).toBe('Qux content')); + await doRender('Qux content'); + expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4); + }); + + it('should remove the "noindex" meta tag if the document is valid', async () => { + await doRender('foo', 'bar'); + expect(TestBed.inject(Meta).removeTag).toHaveBeenCalledWith('name="robots"'); + }); + + it('should add the "noindex" meta tag if the document is 404', async () => { + await doRender('missing', FILE_NOT_FOUND_ID); + expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); + }); + + it('should add a "noindex" meta tag if the document fetching fails', async () => { + await doRender('error', FETCHING_ERROR_ID); + expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); + }); + }); + + describe('(embedding components)', () => { + it('should embed components', async () => { + await doRender('Some content'); + expect(loadElementsSpy).toHaveBeenCalledTimes(1); + expect(loadElementsSpy).toHaveBeenCalledWith(docViewer.nextViewContainer); + }); + + it('should attempt to embed components even if the document is empty', async () => { + await doRender(''); + await doRender(null); + + expect(loadElementsSpy).toHaveBeenCalledTimes(2); + expect(loadElementsSpy.calls.argsFor(0)).toEqual([docViewer.nextViewContainer]); + expect(loadElementsSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]); + }); + + it('should unsubscribe from the previous "embed" observable when unsubscribed from', () => { + const obs = new ObservableWithSubscriptionSpies(); + loadElementsSpy.and.returnValue(obs); + + const renderObservable = docViewer.render({contents: 'Some content', id: 'foo'}); + const subscription = renderObservable.subscribe(); + + expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); + expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + + expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); + expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); + }); + }); + + describe('(swapping views)', () => { + it('should still swap the views if the document is empty', async () => { + await doRender(''); + expect(swapViewsSpy).toHaveBeenCalledTimes(1); + + await doRender(null); + expect(swapViewsSpy).toHaveBeenCalledTimes(2); + }); + + it('should pass the `addTitleAndToc` callback', async () => { + const addTitleAndTocSpy = jasmine.createSpy('addTitleAndToc'); + prepareTitleAndTocSpy.and.returnValue(addTitleAndTocSpy); + + await doRender('
    '); + + expect(swapViewsSpy).toHaveBeenCalledWith(addTitleAndTocSpy); + }); + + it('should unsubscribe from the previous "swap" observable when unsubscribed from', () => { + const obs = new ObservableWithSubscriptionSpies(); + swapViewsSpy.and.returnValue(obs); + + const renderObservable = docViewer.render({contents: 'Hello, world!', id: 'foo'}); + const subscription = renderObservable.subscribe(); + + expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); + expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + + expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); + expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); + }); + }); + + describe('(on error) should clean up, log the error and recover', () => { + let logger: MockLogger; + + beforeEach(() => logger = TestBed.inject(Logger)); + + it('when `prepareTitleAndTocSpy()` fails', async () => { + const error = Error('Typical `prepareTitleAndToc()` error'); + prepareTitleAndTocSpy.and.callFake(() => { + expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); + throw error; + }); + + await doRender('Some content', 'foo'); + + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); + expect(swapViewsSpy).not.toHaveBeenCalled(); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + expect(logger.output.error).toEqual([ + [jasmine.any(Error)] + ]); + expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'foo': ${error.stack}`); + expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); + }); + + it('when `EmbedComponentsService.embedInto()` fails', async () => { + const error = Error('Typical `embedInto()` error'); + loadElementsSpy.and.callFake(() => { + expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); + throw error; + }); + + await doRender('Some content', 'bar'); + + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); + expect(loadElementsSpy).toHaveBeenCalledTimes(1); + expect(swapViewsSpy).not.toHaveBeenCalled(); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + expect(logger.output.error).toEqual([ + [jasmine.any(Error)] + ]); + expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); + }); + + it('when `swapViews()` fails', async () => { + const error = Error('Typical `swapViews()` error'); + swapViewsSpy.and.callFake(() => { + expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); + throw error; + }); + + await doRender('Some content', 'qux'); + + expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); + expect(swapViewsSpy).toHaveBeenCalledTimes(1); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + expect(logger.output.error).toEqual([ + [jasmine.any(Error)] + ]); + expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'qux': ${error.stack}`); + expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); + }); + + it('when something fails with non-Error', async () => { + const error = 'Typical string error'; + swapViewsSpy.and.callFake(() => { + expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); + throw error; + }); + + await doRender('Some content', 'qux'); + + expect(swapViewsSpy).toHaveBeenCalledTimes(1); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + expect(logger.output.error).toEqual([ + [jasmine.any(Error)] + ]); + expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'qux': ${error}`); + expect(TestBed.inject(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); + }); + }); + + describe('(events)', () => { + it('should emit `docReady` after loading elements', async () => { + const onDocReadySpy = jasmine.createSpy('onDocReady'); + docViewer.docReady.subscribe(onDocReadySpy); + + await doRender('Some content'); + + expect(onDocReadySpy).toHaveBeenCalledTimes(1); + expect(loadElementsSpy).toHaveBeenCalledBefore(onDocReadySpy); + }); + + it('should emit `docReady` before swapping views', async () => { + const onDocReadySpy = jasmine.createSpy('onDocReady'); + docViewer.docReady.subscribe(onDocReadySpy); + + await doRender('Some content'); + + expect(onDocReadySpy).toHaveBeenCalledTimes(1); + expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy); + }); + + it('should emit `docRendered` after swapping views', async () => { + const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); + docViewer.docRendered.subscribe(onDocRenderedSpy); + + await doRender('Some content'); + + expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); + expect(swapViewsSpy).toHaveBeenCalledBefore(onDocRenderedSpy); + }); + }); + }); + + describe('#swapViews()', () => { + let oldCurrViewContainer: HTMLElement; + let oldNextViewContainer: HTMLElement; + + const doSwapViews = (cb?: () => void) => + new Promise((resolve, reject) => + docViewer.swapViews(cb).subscribe(resolve, reject)); + + beforeEach(() => { + oldCurrViewContainer = docViewer.currViewContainer; + oldNextViewContainer = docViewer.nextViewContainer; + + oldCurrViewContainer.innerHTML = 'Current view'; + oldNextViewContainer.innerHTML = 'Next view'; + + docViewerEl.appendChild(oldCurrViewContainer); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + }); + + [true, false].forEach(animationsEnabled => { + describe(`(animationsEnabled: ${animationsEnabled})`, () => { + beforeEach(() => DocViewerComponent.animationsEnabled = animationsEnabled); + afterEach(() => DocViewerComponent.animationsEnabled = true); + + [true, false].forEach(noAnimations => { + describe(`(.${NO_ANIMATIONS}: ${noAnimations})`, () => { + beforeEach(() => docViewerEl.classList[noAnimations ? 'add' : 'remove'](NO_ANIMATIONS)); + + it('should return an observable', done => { + docViewer.swapViews().subscribe(done, done.fail); + }); + + it('should swap the views', async () => { + await doSwapViews(); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + expect(docViewer.currViewContainer).toBe(oldNextViewContainer); + expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); + + await doSwapViews(); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + expect(docViewer.currViewContainer).toBe(oldCurrViewContainer); + expect(docViewer.nextViewContainer).toBe(oldNextViewContainer); + }); + + it('should emit `docRemoved` after removing the leaving view', async () => { + const onDocRemovedSpy = jasmine.createSpy('onDocRemoved').and.callFake(() => { + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + }); + + docViewer.docRemoved.subscribe(onDocRemovedSpy); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + + await doSwapViews(); + + expect(onDocRemovedSpy).toHaveBeenCalledTimes(1); + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + + it('should not emit `docRemoved` if the leaving view is already removed', async () => { + const onDocRemovedSpy = jasmine.createSpy('onDocRemoved'); + + docViewer.docRemoved.subscribe(onDocRemovedSpy); + docViewerEl.removeChild(oldCurrViewContainer); + + await doSwapViews(); + + expect(onDocRemovedSpy).not.toHaveBeenCalled(); + }); + + it('should emit `docInserted` after inserting the entering view', async () => { + const onDocInsertedSpy = jasmine.createSpy('onDocInserted').and.callFake(() => { + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + + docViewer.docInserted.subscribe(onDocInsertedSpy); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + + await doSwapViews(); + + expect(onDocInsertedSpy).toHaveBeenCalledTimes(1); + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + + it('should call the callback after inserting the entering view', async () => { + const onInsertedCb = jasmine.createSpy('onInsertedCb').and.callFake(() => { + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + const onDocInsertedSpy = jasmine.createSpy('onDocInserted'); + + docViewer.docInserted.subscribe(onDocInsertedSpy); + + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(true); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(false); + + await doSwapViews(onInsertedCb); + + expect(onInsertedCb).toHaveBeenCalledTimes(1); + expect(onInsertedCb).toHaveBeenCalledBefore(onDocInsertedSpy); + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + }); + + it('should empty the previous view', async () => { + await doSwapViews(); + + expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + + docViewer.nextViewContainer.innerHTML = 'Next view 2'; + await doSwapViews(); + + expect(docViewer.currViewContainer.innerHTML).toBe('Next view 2'); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + }); + + if (animationsEnabled && !noAnimations) { + // Only test this when there are animations. Without animations, the views are swapped + // synchronously, so there is no need (or way) to abort. + it('should abort swapping if the returned observable is unsubscribed from', async () => { + docViewer.swapViews().subscribe().unsubscribe(); + await doSwapViews(); + + // Since the first call was cancelled, only one swapping should have taken place. + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + expect(docViewer.currViewContainer).toBe(oldNextViewContainer); + expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); + expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + }); + } else { + it('should swap views synchronously when animations are disabled', () => { + const cbSpy = jasmine.createSpy('cb'); + + docViewer.swapViews(cbSpy).subscribe(); + + expect(cbSpy).toHaveBeenCalledTimes(1); + expect(docViewerEl.contains(oldCurrViewContainer)).toBe(false); + expect(docViewerEl.contains(oldNextViewContainer)).toBe(true); + expect(docViewer.currViewContainer).toBe(oldNextViewContainer); + expect(docViewer.nextViewContainer).toBe(oldCurrViewContainer); + expect(docViewer.currViewContainer.innerHTML).toBe('Next view'); + expect(docViewer.nextViewContainer.innerHTML).toBe(''); + }); + } + }); + }); + }); + }); + }); +}); diff --git a/projects/ngrx.io/src/app/layout/notification/notification.component.spec.ts b/projects/ngrx.io/src/app/layout/notification/notification.component.spec.ts index b823ad518f..8f24588348 100644 --- a/projects/ngrx.io/src/app/layout/notification/notification.component.spec.ts +++ b/projects/ngrx.io/src/app/layout/notification/notification.component.spec.ts @@ -1,135 +1,135 @@ -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { CurrentDateToken } from 'app/shared/current-date'; -import { NotificationComponent } from './notification.component'; -import { WindowToken } from 'app/shared/window'; - -describe('NotificationComponent', () => { - let component: NotificationComponent; - let fixture: ComponentFixture; - - function configTestingModule(now = new Date('2018-01-20')) { - TestBed.configureTestingModule({ - declarations: [TestComponent, NotificationComponent], - providers: [ - { provide: WindowToken, useClass: MockWindow }, - { provide: CurrentDateToken, useValue: now }, - ], - imports: [NoopAnimationsModule], - schemas: [NO_ERRORS_SCHEMA] - }); - } - - function createComponent() { - fixture = TestBed.createComponent(TestComponent); - const debugElement = fixture.debugElement.query(By.directive(NotificationComponent)); - component = debugElement.componentInstance; - component.ngOnInit(); - fixture.detectChanges(); - } - - describe('content projection', () => { - it('should display the message text', () => { - configTestingModule(); - createComponent(); - expect(fixture.nativeElement.innerHTML).toContain('Version 6 of Angular Now Available!'); - }); - - it('should render HTML elements', () => { - configTestingModule(); - createComponent(); - const button = fixture.debugElement.query(By.css('.action-button')); - expect(button.nativeElement.textContent).toEqual('Learn More'); - }); - - it('should process Angular directives', () => { - configTestingModule(); - createComponent(); - const badSpans = fixture.debugElement.queryAll(By.css('.bad')); - expect(badSpans.length).toEqual(0); - }); - }); - - it('should call dismiss() when the message link is clicked, if dismissOnContentClick is true', () => { - configTestingModule(); - createComponent(); - spyOn(component, 'dismiss'); - component.dismissOnContentClick = true; - const message: HTMLSpanElement = fixture.debugElement.query(By.css('.messageholder')).nativeElement; - message.click(); - expect(component.dismiss).toHaveBeenCalled(); - }); - - it('should not call dismiss() when the message link is clicked, if dismissOnContentClick is false', () => { - configTestingModule(); - createComponent(); - spyOn(component, 'dismiss'); - component.dismissOnContentClick = false; - const message: HTMLSpanElement = fixture.debugElement.query(By.css('.messageholder')).nativeElement; - message.click(); - expect(component.dismiss).not.toHaveBeenCalled(); - }); - - it('should call dismiss() when the close button is clicked', () => { - configTestingModule(); - createComponent(); - spyOn(component, 'dismiss'); - fixture.debugElement.query(By.css('button')).triggerEventHandler('click', null); - fixture.detectChanges(); - expect(component.dismiss).toHaveBeenCalled(); - }); - - it('should hide the notification when dismiss is called', () => { - configTestingModule(); - createComponent(); - expect(component.showNotification).toBe('show'); - component.dismiss(); - expect(component.showNotification).toBe('hide'); - }); - - it('should update localStorage key when dismiss is called', () => { - configTestingModule(); - createComponent(); - const setItemSpy: jasmine.Spy = TestBed.get(WindowToken).localStorage.setItem; - component.dismiss(); - expect(setItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018', 'hide'); - }); - - it('should not show the notification if the date is after the expiry date', () => { - configTestingModule(new Date('2018-01-23')); - createComponent(); - expect(component.showNotification).toBe('hide'); - }); - - it('should not show the notification if the there is a "hide" flag in localStorage', () => { - configTestingModule(); - const getItemSpy: jasmine.Spy = TestBed.get(WindowToken).localStorage.getItem; - getItemSpy.and.returnValue('hide'); - createComponent(); - expect(getItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018'); - expect(component.showNotification).toBe('hide'); - }); -}); - -@Component({ - template: ` - - - - This should not appear - Version 6 of Angular Now Available! - Learn More - - - ` -}) -class TestComponent { -} - -class MockWindow { - localStorage = jasmine.createSpyObj('localStorage', ['getItem', 'setItem']); -} +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CurrentDateToken } from 'app/shared/current-date'; +import { NotificationComponent } from './notification.component'; +import { WindowToken } from 'app/shared/window'; + +describe('NotificationComponent', () => { + let component: NotificationComponent; + let fixture: ComponentFixture; + + function configTestingModule(now = new Date('2018-01-20')) { + TestBed.configureTestingModule({ + declarations: [TestComponent, NotificationComponent], + providers: [ + { provide: WindowToken, useClass: MockWindow }, + { provide: CurrentDateToken, useValue: now }, + ], + imports: [NoopAnimationsModule], + schemas: [NO_ERRORS_SCHEMA] + }); + } + + function createComponent() { + fixture = TestBed.createComponent(TestComponent); + const debugElement = fixture.debugElement.query(By.directive(NotificationComponent)); + component = debugElement.componentInstance; + component.ngOnInit(); + fixture.detectChanges(); + } + + describe('content projection', () => { + it('should display the message text', () => { + configTestingModule(); + createComponent(); + expect(fixture.nativeElement.innerHTML).toContain('Version 6 of Angular Now Available!'); + }); + + it('should render HTML elements', () => { + configTestingModule(); + createComponent(); + const button = fixture.debugElement.query(By.css('.action-button')); + expect(button.nativeElement.textContent).toEqual('Learn More'); + }); + + it('should process Angular directives', () => { + configTestingModule(); + createComponent(); + const badSpans = fixture.debugElement.queryAll(By.css('.bad')); + expect(badSpans.length).toEqual(0); + }); + }); + + it('should call dismiss() when the message link is clicked, if dismissOnContentClick is true', () => { + configTestingModule(); + createComponent(); + spyOn(component, 'dismiss'); + component.dismissOnContentClick = true; + const message: HTMLSpanElement = fixture.debugElement.query(By.css('.messageholder')).nativeElement; + message.click(); + expect(component.dismiss).toHaveBeenCalled(); + }); + + it('should not call dismiss() when the message link is clicked, if dismissOnContentClick is false', () => { + configTestingModule(); + createComponent(); + spyOn(component, 'dismiss'); + component.dismissOnContentClick = false; + const message: HTMLSpanElement = fixture.debugElement.query(By.css('.messageholder')).nativeElement; + message.click(); + expect(component.dismiss).not.toHaveBeenCalled(); + }); + + it('should call dismiss() when the close button is clicked', () => { + configTestingModule(); + createComponent(); + spyOn(component, 'dismiss'); + fixture.debugElement.query(By.css('button')).triggerEventHandler('click', null); + fixture.detectChanges(); + expect(component.dismiss).toHaveBeenCalled(); + }); + + it('should hide the notification when dismiss is called', () => { + configTestingModule(); + createComponent(); + expect(component.showNotification).toBe('show'); + component.dismiss(); + expect(component.showNotification).toBe('hide'); + }); + + it('should update localStorage key when dismiss is called', () => { + configTestingModule(); + createComponent(); + const setItemSpy: jasmine.Spy = TestBed.inject(WindowToken).localStorage.setItem; + component.dismiss(); + expect(setItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018', 'hide'); + }); + + it('should not show the notification if the date is after the expiry date', () => { + configTestingModule(new Date('2018-01-23')); + createComponent(); + expect(component.showNotification).toBe('hide'); + }); + + it('should not show the notification if the there is a "hide" flag in localStorage', () => { + configTestingModule(); + const getItemSpy: jasmine.Spy = TestBed.inject(WindowToken).localStorage.getItem; + getItemSpy.and.returnValue('hide'); + createComponent(); + expect(getItemSpy).toHaveBeenCalledWith('aio-notification/survey-january-2018'); + expect(component.showNotification).toBe('hide'); + }); +}); + +@Component({ + template: ` + + + + This should not appear + Version 6 of Angular Now Available! + Learn More + + + ` +}) +class TestComponent { +} + +class MockWindow { + localStorage = jasmine.createSpyObj('localStorage', ['getItem', 'setItem']); +}