additional theme support tests

This commit is contained in:
Kristof De Langhe
2021-02-26 15:33:16 +01:00
committed by Art Lowel
parent 5a6e4b1278
commit 91ca5af1d4
7 changed files with 400 additions and 114 deletions

View File

@@ -1,7 +1,7 @@
import { Store, StoreModule } from '@ngrx/store'; import { Store, StoreModule } from '@ngrx/store';
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 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 { ActivatedRoute, Router } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
@@ -32,7 +32,9 @@ import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { authReducer } from './core/auth/auth.reducer'; import { authReducer } from './core/auth/auth.reducer';
import { provideMockStore } from '@ngrx/store/testing'; 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 comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
@@ -73,6 +75,7 @@ describe('App component', () => {
{ provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
{ provide: LocaleService, useValue: getMockLocaleService() }, { provide: LocaleService, useValue: getMockLocaleService() },
{ provide: ThemeService, useValue: getMockThemeService() },
provideMockStore({ initialState }), provideMockStore({ initialState }),
AppComponent, AppComponent,
RouteService 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);
});
});
}); });

View File

@@ -1,7 +1,9 @@
import { ThemeService } from '../theme-support/theme.service'; 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', { return jasmine.createSpyObj('themeService', {
getThemeName: 'base' getThemeName: themeName,
getThemeName$: observableOf(themeName)
}); });
} }

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
// noinspection AngularMissingOrInvalidDeclarationInModule
@Component({
selector: 'ds-test-component',
template: ''
})
export class TestComponent {
type = 'themed';
testInput = 'unset';
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
// noinspection AngularMissingOrInvalidDeclarationInModule
@Component({
selector: 'ds-test-component',
template: ''
})
export class TestComponent {
type = 'default';
testInput = 'unset';
}

View File

@@ -1,9 +1,8 @@
import { ThemeEffects } from './theme.effects'; import { ThemeEffects } from './theme.effects';
import { Observable } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing'; import { provideMockActions } from '@ngrx/effects/testing';
import { LinkService } from '../../core/cache/builders/link.service'; import { LinkService } from '../../core/cache/builders/link.service';
import { getMockLinkService } from '../mocks/link-service.mock';
import { cold, hot } from 'jasmine-marbles'; import { cold, hot } from 'jasmine-marbles';
import { ROOT_EFFECTS_INIT } from '@ngrx/effects'; import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
import { SetThemeAction } from './theme.actions'; 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 { Item } from '../../core/shared/item.model';
import { Collection } from '../../core/shared/collection.model'; import { Collection } from '../../core/shared/collection.model';
import { COLLECTION } from '../../core/shared/collection.resource-type'; import { COLLECTION } from '../../core/shared/collection.resource-type';
import {
createNoContentRemoteDataObject$,
createSuccessfulRemoteDataObject$
} from '../remote-data.utils';
import { BASE_THEME_NAME } from './theme.constants'; 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', () => { describe('ThemeEffects', () => {
let themeEffects: ThemeEffects; let themeEffects: ThemeEffects;
let linkService: LinkService; let linkService: LinkService;
let initialState; let initialState;
let actions: Observable<any>;
let ancestorDSOs: DSpaceObject[];
function init() { 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 = { initialState = {
theme: {
currentTheme: 'custom', currentTheme: 'custom',
},
}; };
} }
beforeEach(() => { function setupEffectsWithActions(mockActions) {
init(); init();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
ThemeEffects, ThemeEffects,
{ provide: LinkService, useValue: linkService }, { provide: LinkService, useValue: linkService },
provideMockStore({ initialState }), provideMockStore({ initialState }),
provideMockActions(() => actions) provideMockActions(() => mockActions)
] ]
}); });
themeEffects = TestBed.inject(ThemeEffects); themeEffects = TestBed.inject(ThemeEffects);
}); }
describe('initTheme$', () => { describe('initTheme$', () => {
it('should set the default theme', () => { beforeEach(() => {
actions = hot('--a-', { setupEffectsWithActions(
hot('--a-', {
a: { a: {
type: ROOT_EFFECTS_INIT type: ROOT_EFFECTS_INIT
} }
})
);
}); });
it('should set the default theme', () => {
const expected = cold('--b-', { const expected = cold('--b-', {
b: new SetThemeAction(BASE_THEME_NAME) b: new SetThemeAction(BASE_THEME_NAME)
}); });
@@ -64,15 +111,23 @@ describe('ThemeEffects', () => {
}); });
}); });
// TODO: Fix test describe('updateThemeOnRouteChange$', () => {
xdescribe('updateThemeOnRouteChange$', () => {
it('test', () => {
const url = '/test/route'; const url = '/test/route';
const dso = Object.assign(new Community(), { const dso = Object.assign(new Community(), {
type: COMMUNITY.value, type: COMMUNITY.value,
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', uuid: '0958c910-2037-42a9-81c7-dca80e3892b4',
}); });
actions = hot('--ab-', {
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: { a: {
type: ROUTER_NAVIGATED, type: ROUTER_NAVIGATED,
payload: { routerState: { url } }, payload: { routerState: { url } },
@@ -81,25 +136,79 @@ describe('ThemeEffects', () => {
type: ResolverActionTypes.RESOLVED, type: ResolverActionTypes.RESOLVED,
payload: { url, dso }, payload: { url, dso },
} }
})
);
spyOnPrivateMethods();
}); });
it('should set the theme it receives from the DSO', () => {
const expected = cold('--b-', { const expected = cold('--b-', {
b: new SetThemeAction('Publications community (uuid)') b: new SetThemeAction('custom')
}); });
expect(themeEffects.initTheme$).toBeObservable(expected); expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
}); });
}); });
describe('when no resolved action is present', () => {
beforeEach(() => {
setupEffectsWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
spyOnPrivateMethods();
});
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('private functions', () => {
beforeEach(() => {
setupEffectsWithActions(hot('-', {}));
});
describe('getActionForMatch', () => { describe('getActionForMatch', () => {
it('should return a SET action if the name doesn\'t match', () => { it('should return a SET action if the new theme differs from the current theme', () => {
const theme = new Theme({ name: 'new-theme' }); const theme = new Theme({ name: 'new-theme' });
expect((themeEffects as any).getActionForMatch(theme, 'not-matching')).toEqual(new SetThemeAction('new-theme')); expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme'));
}); });
it('should return an empty action if the name matches', () => { it('should return an empty action if the new theme equals the current theme', () => {
const theme = new Theme({ name: 'new-theme' }); const theme = new Theme({ name: 'old-theme' });
expect((themeEffects as any).getActionForMatch(theme, 'new-theme')).toEqual(new NoOpAction()); expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction());
}); });
}); });
@@ -170,4 +279,36 @@ describe('ThemeEffects', () => {
}); });
}); });
}); });
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',
_links: { owningCollection: { href: 'owning-collection-link' } },
});
observableOf(dso).pipe(
(themeEffects as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso, ...ancestorDSOs]);
done();
});
});
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',
};
observableOf(dso).pipe(
(themeEffects as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso]);
done();
});
});
});
});
}); });

View File

@@ -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<TestComponent> {
protected inAndOutputNames: (keyof TestComponent & keyof this)[] = ['testInput'];
testInput = 'unset';
protected getComponentName(): string {
return 'TestComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`./test/${themeName}/themed-test.component.spec`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./test/test.component.spec');
}
}
describe('ThemedComponent', () => {
let component: TestThemedComponent;
let fixture: ComponentFixture<TestThemedComponent>;
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 */

View File

@@ -194,34 +194,27 @@ export const environment: Partial<GlobalConfig> = {
}, },
themes: [ themes: [
{ {
// name: 'default',
name: 'full-item-page-theme', name: 'full-item-page-theme',
regex: 'items/aa6c6c83-3a83-4953-95d1-2bc2e67854d2/full' regex: 'items/aa6c6c83-3a83-4953-95d1-2bc2e67854d2/full'
}, },
{ {
// name: 'default',
name: 'error-theme', name: 'error-theme',
regex: 'collections/aaaa.*' regex: 'collections/aaaa.*'
}, },
{ {
// name: 'default',
name: 'Item (handle)', name: 'Item (handle)',
handle: '10673/1233' // Item inside publications community handle: '10673/1233'
}, },
{ {
// name: 'mantis',
name: 'blue', name: 'blue',
regex: 'collections\/e8043bc2.*' // Publications/Thesis collections regex: 'collections\/e8043bc2.*'
}, },
{ {
// name: 'mantis',
name: 'Publications community (uuid)', name: 'Publications community (uuid)',
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' // Publications community uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
}, },
{ {
// name: 'default',
name: 'base', name: 'base',
// name: 'blue',
}, },
], ],
}; };