diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 233f15ccea..c092362c7e 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,7 +1,7 @@ import { Store, StoreModule } from '@ngrx/store'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DOCUMENT } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -32,7 +32,9 @@ import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; import { authReducer } from './core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; -import {GoogleAnalyticsService} from './statistics/google-analytics.service'; +import { GoogleAnalyticsService } from './statistics/google-analytics.service'; +import { ThemeService } from './shared/theme-support/theme.service'; +import { getMockThemeService } from './shared/mocks/theme-service.mock'; let comp: AppComponent; let fixture: ComponentFixture; @@ -73,6 +75,7 @@ describe('App component', () => { { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: LocaleService, useValue: getMockLocaleService() }, + { provide: ThemeService, useValue: getMockThemeService() }, provideMockStore({ initialState }), AppComponent, RouteService @@ -143,4 +146,32 @@ describe('App component', () => { }); }); }); + + describe('when ThemeService returns a custom theme', () => { + let document; + let headSpy; + + beforeEach(() => { + // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset + TestBed.resetTestingModule(); + TestBed.configureTestingModule(defaultTestBedConf); + TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')}); + document = TestBed.inject(DOCUMENT); + headSpy = jasmine.createSpyObj('head', ['appendChild']); + spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]); + fixture = TestBed.createComponent(AppComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should append a link element with the correct attributes to the head element', () => { + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('class', 'theme-css'); + link.setAttribute('href', '/custom-theme.css'); + + expect(headSpy.appendChild).toHaveBeenCalledWith(link); + }); + }); }); diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index fd54e80ad2..3594270807 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -1,7 +1,9 @@ import { ThemeService } from '../theme-support/theme.service'; +import { of as observableOf } from 'rxjs'; -export function getMockThemeService(): ThemeService { +export function getMockThemeService(themeName = 'base'): ThemeService { return jasmine.createSpyObj('themeService', { - getThemeName: 'base' + getThemeName: themeName, + getThemeName$: observableOf(themeName) }); } diff --git a/src/app/shared/theme-support/test/custom/themed-test.component.spec.ts b/src/app/shared/theme-support/test/custom/themed-test.component.spec.ts new file mode 100644 index 0000000000..6df7fe52e6 --- /dev/null +++ b/src/app/shared/theme-support/test/custom/themed-test.component.spec.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +// noinspection AngularMissingOrInvalidDeclarationInModule +@Component({ + selector: 'ds-test-component', + template: '' +}) +export class TestComponent { + type = 'themed'; + testInput = 'unset'; +} diff --git a/src/app/shared/theme-support/test/test.component.spec.ts b/src/app/shared/theme-support/test/test.component.spec.ts new file mode 100644 index 0000000000..85e2e5aaa7 --- /dev/null +++ b/src/app/shared/theme-support/test/test.component.spec.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +// noinspection AngularMissingOrInvalidDeclarationInModule +@Component({ + selector: 'ds-test-component', + template: '' +}) +export class TestComponent { + type = 'default'; + testInput = 'unset'; +} diff --git a/src/app/shared/theme-support/theme.effects.spec.ts b/src/app/shared/theme-support/theme.effects.spec.ts index 94d1645912..7a0e9c8f19 100644 --- a/src/app/shared/theme-support/theme.effects.spec.ts +++ b/src/app/shared/theme-support/theme.effects.spec.ts @@ -1,9 +1,8 @@ import { ThemeEffects } from './theme.effects'; -import { Observable } from 'rxjs'; +import { of as observableOf } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { LinkService } from '../../core/cache/builders/link.service'; -import { getMockLinkService } from '../mocks/link-service.mock'; import { cold, hot } from 'jasmine-marbles'; import { ROOT_EFFECTS_INIT } from '@ngrx/effects'; import { SetThemeAction } from './theme.actions'; @@ -19,43 +18,91 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Item } from '../../core/shared/item.model'; import { Collection } from '../../core/shared/collection.model'; import { COLLECTION } from '../../core/shared/collection.resource-type'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; import { BASE_THEME_NAME } from './theme.constants'; +/** + * LinkService able to mock recursively resolving DSO parent links + * Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until + * none are left, after which it returns a no-content remote-date + */ +class MockLinkService { + index = -1; + + constructor(private ancestorDSOs: DSpaceObject[]) { + } + + resolveLinkWithoutAttaching() { + if (this.index >= this.ancestorDSOs.length - 1) { + return createNoContentRemoteDataObject$(); + } else { + this.index++; + return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]); + } + } +} + describe('ThemeEffects', () => { let themeEffects: ThemeEffects; let linkService: LinkService; let initialState; - let actions: Observable; + + let ancestorDSOs: DSpaceObject[]; function init() { - linkService = getMockLinkService(); + ancestorDSOs = [ + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + _links: { owningCommunity: { href: 'owning-community-link' } } + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'sub-community-uuid', + _links: { parentCommunity: { href: 'parent-community-link' } } + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'top-community-uuid', + }), + ]; + linkService = new MockLinkService(ancestorDSOs) as any; initialState = { - currentTheme: 'custom', + theme: { + currentTheme: 'custom', + }, }; } - beforeEach(() => { + function setupEffectsWithActions(mockActions) { init(); TestBed.configureTestingModule({ providers: [ ThemeEffects, { provide: LinkService, useValue: linkService }, provideMockStore({ initialState }), - provideMockActions(() => actions) + provideMockActions(() => mockActions) ] }); themeEffects = TestBed.inject(ThemeEffects); - }); + } describe('initTheme$', () => { - it('should set the default theme', () => { - actions = hot('--a-', { - a: { - type: ROOT_EFFECTS_INIT - } - }); + beforeEach(() => { + setupEffectsWithActions( + hot('--a-', { + a: { + type: ROOT_EFFECTS_INIT + } + }) + ); + }); + it('should set the default theme', () => { const expected = cold('--b-', { b: new SetThemeAction(BASE_THEME_NAME) }); @@ -64,109 +111,203 @@ describe('ThemeEffects', () => { }); }); - // TODO: Fix test - xdescribe('updateThemeOnRouteChange$', () => { - it('test', () => { - const url = '/test/route'; - const dso = Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', - }); - actions = hot('--ab-', { - a: { - type: ROUTER_NAVIGATED, - payload: { routerState: { url } }, - }, - b: { - type: ResolverActionTypes.RESOLVED, - payload: { url, dso }, - } + describe('updateThemeOnRouteChange$', () => { + const url = '/test/route'; + const dso = Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', + }); + + function spyOnPrivateMethods() { + spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso])); + spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' })); + spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom')); + } + + describe('when a resolved action is present', () => { + beforeEach(() => { + setupEffectsWithActions( + hot('--ab-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + b: { + type: ResolverActionTypes.RESOLVED, + payload: { url, dso }, + } + }) + ); + spyOnPrivateMethods(); }); - const expected = cold('--b-', { - b: new SetThemeAction('Publications community (uuid)') + it('should set the theme it receives from the DSO', () => { + const expected = cold('--b-', { + b: new SetThemeAction('custom') + }); + + expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); + }); + }); + + describe('when no resolved action is present', () => { + beforeEach(() => { + setupEffectsWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); }); - expect(themeEffects.initTheme$).toBeObservable(expected); + it('should set the theme it receives from the route url', () => { + const expected = cold('--b-', { + b: new SetThemeAction('custom') + }); + + expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); + }); + }); + + describe('when no themes are present', () => { + beforeEach(() => { + setupEffectsWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + (themeEffects as any).themes = []; + }); + + it('should return an empty action', () => { + const expected = cold('--b-', { + b: new NoOpAction() + }); + + expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); + }); }); }); - describe('getActionForMatch', () => { - it('should return a SET action if the name doesn\'t match', () => { - const theme = new Theme({ name: 'new-theme' }); - expect((themeEffects as any).getActionForMatch(theme, 'not-matching')).toEqual(new SetThemeAction('new-theme')); - }); - - it('should return an empty action if the name matches', () => { - const theme = new Theme({ name: 'new-theme' }); - expect((themeEffects as any).getActionForMatch(theme, 'new-theme')).toEqual(new NoOpAction()); - }); - }); - - describe('matchThemeToDSOs', () => { - let themes: Theme[]; - let nonMatchingTheme: Theme; - let itemMatchingTheme: Theme; - let communityMatchingTheme: Theme; - let dsos: DSpaceObject[]; - + describe('private functions', () => { beforeEach(() => { - nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { - matches: () => false + setupEffectsWithActions(hot('-', {})); + }); + + describe('getActionForMatch', () => { + it('should return a SET action if the new theme differs from the current theme', () => { + const theme = new Theme({ name: 'new-theme' }); + expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme')); }); - itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { - matches: (url, dso) => (dso as any).type === ITEM.value + + it('should return an empty action if the new theme equals the current theme', () => { + const theme = new Theme({ name: 'old-theme' }); + expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction()); }); - communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { - matches: (url, dso) => (dso as any).type === COMMUNITY.value + }); + + describe('matchThemeToDSOs', () => { + let themes: Theme[]; + let nonMatchingTheme: Theme; + let itemMatchingTheme: Theme; + let communityMatchingTheme: Theme; + let dsos: DSpaceObject[]; + + beforeEach(() => { + nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { + matches: () => false + }); + itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { + matches: (url, dso) => (dso as any).type === ITEM.value + }); + communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { + matches: (url, dso) => (dso as any).type === COMMUNITY.value + }); + dsos = [ + Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + }), + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'community-uuid', + }), + ]; }); - dsos = [ - Object.assign(new Item(), { + + describe('when no themes match any of the DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme ]; + themeEffects.themes = themes; + }); + + it('should return undefined', () => { + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); + }); + }); + + describe('when one of the themes match a DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme, itemMatchingTheme ]; + themeEffects.themes = themes; + }); + + it('should return the matching theme', () => { + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + }); + }); + + describe('when multiple themes match some of the DSOs', () => { + it('should return the first matching theme', () => { + themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; + themeEffects.themes = themes; + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + + themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; + themeEffects.themes = themes; + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); + }); + }); + }); + + describe('getAncestorDSOs', () => { + it('should return an array of the provided DSO and its ancestors', (done) => { + const dso = Object.assign(new Item(), { type: ITEM.value, uuid: 'item-uuid', - }), - Object.assign(new Collection(), { - type: COLLECTION.value, - uuid: 'collection-uuid', - }), - Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: 'community-uuid', - }), - ]; - }); + _links: { owningCollection: { href: 'owning-collection-link' } }, + }); - describe('when no themes match any of the DSOs', () => { - beforeEach(() => { - themes = [ nonMatchingTheme ]; - themeEffects.themes = themes; + observableOf(dso).pipe( + (themeEffects as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso, ...ancestorDSOs]); + done(); + }); }); - it('should return undefined', () => { - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); - }); - }); + it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => { + const dso = { + type: ITEM.value, + uuid: 'item-uuid', + }; - describe('when one of the themes match a DSOs', () => { - beforeEach(() => { - themes = [ nonMatchingTheme, itemMatchingTheme ]; - themeEffects.themes = themes; - }); - - it('should return the matching theme', () => { - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); - }); - }); - - describe('when multiple themes match some of the DSOs', () => { - it('should return the first matching theme', () => { - themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; - themeEffects.themes = themes; - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); - - themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; - themeEffects.themes = themes; - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); + observableOf(dso).pipe( + (themeEffects as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso]); + done(); + }); }); }); }); diff --git a/src/app/shared/theme-support/themed.component.spec.ts b/src/app/shared/theme-support/themed.component.spec.ts new file mode 100644 index 0000000000..abaee28a29 --- /dev/null +++ b/src/app/shared/theme-support/themed.component.spec.ts @@ -0,0 +1,97 @@ +import { ThemedComponent } from './themed.component'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../utils/var.directive'; +import { ThemeService } from './theme.service'; +import { getMockThemeService } from '../mocks/theme-service.mock'; +import { TestComponent } from './test/test.component.spec'; + +/* tslint:disable:max-classes-per-file */ +@Component({ + selector: 'ds-test-themed-component', + templateUrl: './themed.component.html' +}) +class TestThemedComponent extends ThemedComponent { + protected inAndOutputNames: (keyof TestComponent & keyof this)[] = ['testInput']; + + testInput = 'unset'; + + protected getComponentName(): string { + return 'TestComponent'; + } + protected importThemedComponent(themeName: string): Promise { + return import(`./test/${themeName}/themed-test.component.spec`); + } + protected importUnthemedComponent(): Promise { + return import('./test/test.component.spec'); + } +} + +describe('ThemedComponent', () => { + let component: TestThemedComponent; + let fixture: ComponentFixture; + let themeService: ThemeService; + + function setupTestingModuleForTheme(theme: string) { + themeService = getMockThemeService(theme); + TestBed.configureTestingModule({ + imports: [], + declarations: [TestThemedComponent, VarDirective], + providers: [ + { provide: ThemeService, useValue: themeService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + } + + describe('when the current theme matches a themed component', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('custom'); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestThemedComponent); + component = fixture.componentInstance; + component.testInput = 'changed'; + fixture.detectChanges(); + }); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('when the current theme doesn\'t match a themed component', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('non-existing-theme'); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestThemedComponent); + component = fixture.componentInstance; + component.testInput = 'changed'; + fixture.detectChanges(); + }); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); +}); +/* tslint:enable:max-classes-per-file */ diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index 116834df4e..1c01482724 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -194,34 +194,27 @@ export const environment: Partial = { }, themes: [ { - // name: 'default', name: 'full-item-page-theme', regex: 'items/aa6c6c83-3a83-4953-95d1-2bc2e67854d2/full' }, { - // name: 'default', name: 'error-theme', regex: 'collections/aaaa.*' }, { - // name: 'default', name: 'Item (handle)', - handle: '10673/1233' // Item inside publications community + handle: '10673/1233' }, { - // name: 'mantis', name: 'blue', - regex: 'collections\/e8043bc2.*' // Publications/Thesis collections + regex: 'collections\/e8043bc2.*' }, { - // name: 'mantis', name: 'Publications community (uuid)', - uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' // Publications community + uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' }, { - // name: 'default', name: 'base', - // name: 'blue', }, ], };