diff --git a/.gitignore b/.gitignore index 0e713600f8..f691aae4b2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ npm-debug.log /dist/ +/coverage/ .idea *.ngfactory.ts diff --git a/README.md b/README.md index a7b658b5c4..8ff0bdfd6e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Then go to [http://localhost:3000](http://localhost:3000) in your browser * [Running the app](#running-the-app) * [Running in production mode](#running-in-production-mode) * [Cleaning](#cleaning) +* [Testing](#testing) * [Other commands](#other-commands) * [Recommended Editors/IDEs](#recommended-editorsides) * [Collaborating](#collaborating) @@ -85,6 +86,49 @@ npm run clean:prod npm run clean:dist ``` +## Testing +### Unit Test +Unit tests use Karma. You can find the configuration file at the same level of this README file: +`./karma.conf.js` +If you are going to use a remote test enviroment you need to edit the './karma.conf.js'. Follow the instructions you will find inside it. +To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. +A coverage report is also available at: +http://localhost:9876/ +after you run: +`npm run coverage`. + +To correctly run the tests you need to run the build once with: +`npm run build`. + +The default browser is Google Chrome. + +Place your tests in the same location of the application source code files that they test. + +and run: +`npm run test` + +### E2E test +E2E tests use Protractor + Selenium server + browsers. You can find the configuration file at the same level of this README file: +`./protractor.conf.js` +Protractor is installed as 'local' as a dev dependency. +If you are going to execute tests locally you need to run (once time only): +`npm run webdriver:update`. + +If you are going to use a remote test enviroment you need to edit the './protractor.conf.js'. Follow the instructions you will find inside it. + +The default browser is Google Chrome. + +Protractor needs a functional instance of the DSpace interface to run the E2E tests, so you need to run: +`npm run watch:dev` + +or any command that bring up the DSpace interface. + +Place your tests at the following path: +`./e2e` + +and run: +`npm run e2e` + ## Other commands There are many more commands in the `scripts` section of `package.json`. Most of these are executed by one of the commands mentioned above. A command with a name that starts with `pre` or `post` will be executed automatically before or after the script with the matching name. e.g. if you type `npm run start` the `prestart` script will run first, then the `start` script will trigger. @@ -111,8 +155,12 @@ See [the guide on the wiki](https://wiki.duraspace.org/display/DSPACE/DSpace+7+- dspace-angular ├── README.md * This document ├── app.json * Application manifest file +├── e2e * Folder for e2e test files +├── karma.conf.js * Unit Test configuration file ├── nodemon.json * Nodemon (https://nodemon.io/) configuration ├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc. +├── postcss.config.json * PostCSS (http://postcss.org/) configuration file +├── protractor.conf.js * E2E tests configuration file ├── resources * Folder for static resources │   ├── i18n * Folder for i18n translations │   └── images * Folder for images @@ -138,12 +186,13 @@ dspace-angular ├── tsconfig.json * TypeScript config for development build ├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration ├── webpack.config.ts * Webpack (https://webpack.github.io/) config for development builds +├── webpack.test.config.ts * Webpack (https://webpack.github.io/) config for testing └── webpack.prod.config.ts * Webpack (https://webpack.github.io/) config for production builds ``` ## 3rd Party Library Installation -Install your library via `npm install lib-name --save` and import it in your code. `--save` will add it to `package.json`. +Install your library via `npm install lib-name --save` and import it in your code. `--save` will add it to `package.json`. If the library does not include typings, you can install them using npm: @@ -179,7 +228,7 @@ import * as _ from 'lodash'; ## Frequently asked questions * Why is my service, aka provider, is not injecting a parameter correctly? - * Please use `@Injectable()` for your service for typescript to correctly attach the metadata + * Please use `@Injectable()` for your service for typescript to correctly attach the metadata * Where do I write my tests? * You can write your tests next to your component files. e.g. for `src/app/home/home.component.ts` call it `src/app/home/home.component.spec.ts` * How do I start the app when I get `EACCES` and `EADDRINUSE` errors? diff --git a/e2e/app.e2e-spec.ts b/e2e/app.e2e-spec.ts new file mode 100644 index 0000000000..ee7b101f96 --- /dev/null +++ b/e2e/app.e2e-spec.ts @@ -0,0 +1,19 @@ +import { ProtractorPage } from './app.po'; + +describe('protractor App', function() { + let page: ProtractorPage; + + beforeEach(() => { + page = new ProtractorPage(); + }); + + it('should display title "DSpace"', () => { + page.navigateTo(); + expect(page.getPageTitleText()).toEqual('DSpace'); + }); + + it('should display title "Hello, World!"', () => { + page.navigateTo(); + expect(page.getFirstPText()).toEqual('Hello, World!'); + }); +}); diff --git a/e2e/app.po.ts b/e2e/app.po.ts new file mode 100644 index 0000000000..164c524620 --- /dev/null +++ b/e2e/app.po.ts @@ -0,0 +1,15 @@ +import { browser, element, by } from 'protractor'; + +export class ProtractorPage { + navigateTo() { + return browser.get('/'); + } + + getPageTitleText() { + return browser.getTitle(); + } + + getFirstPText() { + return element(by.xpath('//p[1]')).getText(); + } +} \ No newline at end of file diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000000..656bdb14ff --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "declaration": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "commonjs", + "moduleResolution": "node", + "outDir": "../dist/out-tsc-e2e", + "sourceMap": true, + "target": "es5", + "typeRoots": [ + "../node_modules/@types" + ] + } +} diff --git a/helpers.js b/helpers.js new file mode 100644 index 0000000000..ed4330d342 --- /dev/null +++ b/helpers.js @@ -0,0 +1,24 @@ +/** + * @author: @AngularClass + */ +var path = require('path'); + +// Helper functions +var ROOT = path.resolve(__dirname, '.'); + +function hasProcessFlag(flag) { + return process.argv.join('').indexOf(flag) > -1; +} + +function isWebpackDevServer() { + return process.argv[1] && !! (/webpack-dev-server/.exec(process.argv[1])); +} + +function root(args) { + args = Array.prototype.slice.call(arguments, 0); + return path.join.apply(path, [ROOT].concat(args)); +} + +exports.hasProcessFlag = hasProcessFlag; +exports.isWebpackDevServer = isWebpackDevServer; +exports.root = root; diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000000..43ad307a3c --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,149 @@ +/** + * @author: @AngularClass + */ + +module.exports = function(config) { + + var testWebpackConfig = require('./webpack.test.config.js')({env: 'test'}); + + // Uncomment and change to run tests on a remote Selenium server + var webdriverConfig = { + hostname: 'localhost', + port: 4444 + }; + + var configuration = { + + // base path that will be used to resolve all patterns (e.g. files, exclude) + basePath: '.', + + /* + * Frameworks to use + * + * available frameworks: https://npmjs.org/browse/keyword/karma-adapter + */ + frameworks: ['jasmine'], + + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-phantomjs-launcher'), + require('karma-webdriver-launcher'), + require('karma-coverage'), + require('karma-mocha-reporter'), + require('karma-remap-istanbul'), + require('karma-sourcemap-loader'), + require('karma-webpack') + ], + + // list of files to exclude + exclude: [ ], + + /* + * list of files / patterns to load in the browser + * + * we are building the test environment in ./spec-bundle.js + */ + files: [ { pattern: './spec-bundle.js', watched: false } ], + + /* + * preprocess matching files before serving them to the browser + * available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + */ + preprocessors: { './spec-bundle.js': ['coverage', 'webpack', 'sourcemap'] }, + + // Webpack Config at ./webpack.test.js + webpack: testWebpackConfig, + + coverageReporter: { + reporters:[ + {type: 'in-memory'}, + {type: 'json', subdir: '.', file: 'coverage-final.json'}, + {type: 'html', dir : 'coverage/'} + ] + }, + + remapCoverageReporter: { + 'text-summary': null, + json: './coverage/coverage.json', + html: './coverage/html' + }, + + remapIstanbulReporter: { + reports: { + html: 'coverage' + } + }, + + // Webpack please don't spam the console when running in karma! + webpackMiddleware: { stats: 'errors-only'}, + + /* + * test results reporter to use + * + * possible values: 'dots', 'progress' + * available reporters: https://npmjs.org/browse/keyword/karma-reporter + */ + reporters: [ 'mocha', 'coverage', 'karma-remap-istanbul' ], + + // Karma web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + /* + * level of logging + * possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + */ + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + //autoWatch: true, + + /* + * start these browsers + * available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + */ + browsers: [ + 'Chrome' + //'ChromeTravisCi', + //'SeleniumChrome', + //'SeleniumFirefox' + ], + + customLaunchers: { + // Continuous integraation with Chrome - launcher + 'ChromeTravisCi': { + base: 'Chrome', + flags: ['--no-sandbox'] + }, + // Remote Selenium Server with Chrome - launcher + 'SeleniumChrome': { + base: 'WebDriver', + config: webdriverConfig, + browserName: 'chrome' + }, + // Remote Selenium Server with Firefox - launcher + 'SeleniumFirefox': { + base: 'WebDriver', + config: webdriverConfig, + browserName: 'firefox' + } + }, + + /* + * Continuous Integration mode + * if true, Karma captures browsers, runs the tests and exits + */ + //singleRun: true + }; + + if (process.env.TRAVIS){ + configuration.browsers = [ + 'ChromeTravisCi' + ]; + } + + config.set(configuration); +}; diff --git a/package.json b/package.json index 520f7b3677..e43a0f3df2 100644 --- a/package.json +++ b/package.json @@ -12,22 +12,25 @@ "clean:node": "rimraf node_modules/*", "clean:ngc": "rimraf **/*.ngfactory.ts", "clean:json": "rimraf *.records.json", - "clean:css": "rimraf **/*.css", - "clean:css:ts": "rimraf **/*.css.ts", - "clean:scss:ts": "rimraf **/*.scss.ts", - "clean:css:shim:ts": "rimraf **/*.css.shim.ts", - "clean:scss:shim:ts": "rimraf **/*.scss.shim.ts", + "clean:css": "rimraf src/**/*.css", + "clean:css:ts": "rimraf src/**/*.css.ts", + "clean:scss:ts": "rimraf src/**/*.scss.ts", + "clean:css:shim:ts": "rimraf src/**/*.css.shim.ts", + "clean:scss:shim:ts": "rimraf src/**/*.scss.shim.ts", + "clean:coverage": "rimraf coverage", "clean:prod": "npm run clean:ngc && npm run clean:json && npm run clean:css && npm run clean:css:ts && npm run clean:scss:ts && npm run clean:css:shim:ts && npm run clean:scss:shim:ts && npm run clean:dist", - "clean": "npm run clean:log && npm run clean:prod && npm run clean:node", + "clean": "npm run clean:log && npm run clean:prod && npm run clean:coverage && npm run clean:node", "sass": "node-sass src -o src --include-path node_modules --output-style compressed -q", - "sass:watch": "node-sass -w src -o src --include-path node_modules --output-style compressed -q", + "postcss": "node_modules/postcss-cli/bin/postcss -c postcss.config.json", + "style": "npm run sass && npm run postcss", + "style:watch": "nodemon -e scss -w src -x \"npm run style\"", "rollup": "rollup -c rollup-server.js && rollup -c rollup-client.js", - "prebuild": "npm run clean:dist && npm run sass", + "prebuild": "npm run clean:dist && npm run style", "build": "webpack --progress", "build:prod": "webpack --config webpack.prod.config.ts", "build:prod:rollup": "npm run build:prod && npm run rollup", - "build:prod:ngc": "npm run clean:prod && npm run sass && npm run ngc && npm run build:prod:rollup", - "build:prod:ngc:json": "npm run clean:prod && npm run sass && npm run ngc && npm run build:prod:json:rollup", + "build:prod:ngc": "npm run clean:prod && npm run style && npm run ngc && npm run build:prod:rollup", + "build:prod:ngc:json": "npm run clean:prod && npm run style && npm run ngc && npm run build:prod:json:rollup", "build:prod:json": "webpack --config webpack.prod.config.ts --json | webpack-bundle-size-analyzer", "build:prod:json:rollup": "npm run build:prod:json && npm run rollup", "ngc": "ngc -p tsconfig.aot.json", @@ -36,7 +39,7 @@ "server:dev": "nodemon --debug dist/server/index.js", "start": "npm run server", "start:dev": "npm run clean:prod && npm run build && npm run server", - "watch": "webpack -w & npm run sass:watch", + "watch": "webpack -w & npm run style:watch", "watch:dev:server": "concurrently \"npm run server:dev\" \"npm run watch\"", "watch:dev": "npm run clean:prod && npm run build && npm run watch:dev:server", "watch:prod:server": "concurrently \"npm run server\" \"npm run watch\"", @@ -49,7 +52,14 @@ "debug:build:prod": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --config webpack.prod.config.ts", "docs": "typedoc --options typedoc.json ./src", "lint": "tslint \"src/**/*.ts\" || true", - "global": "npm install -g angular-cli nodemon npm-check-updates rimraf ts-node typedoc typescript webpack webpack-bundle-size-analyzer rollup marked node-gyp" + "global": "npm install -g angular-cli nodemon npm-check-updates rimraf ts-node typedoc typescript webpack webpack-bundle-size-analyzer rollup marked node-gyp", + "protractor": "node node_modules/protractor/bin/protractor", + "e2e": "npm run protractor", + "test": "karma start --single-run", + "test:watch": "karma start --no-single-run --auto-watch", + "coverage": "http-server -c-1 -o -p 9875 ./coverage", + "webdriver:start": "node node_modules/protractor/bin/webdriver-manager start --seleniumPort 4444", + "webdriver:update": "node node_modules/protractor/bin/webdriver-manager update --standalone" }, "dependencies": { "@angular/common": "2.2.3", @@ -75,11 +85,13 @@ "angular2-platform-node": "2.1.0-rc.1", "angular2-universal": "2.1.0-rc.1", "angular2-universal-polyfills": "2.1.0-rc.1", + "autoprefixer": "^6.5.4", "body-parser": "1.15.2", "bootstrap": "4.0.0-alpha.5", "compression": "1.6.2", "express": "4.14.0", "font-awesome": "4.7.0", + "http-server": "^0.9.0", "js.clone": "0.0.3", "methods": "1.1.2", "morgan": "1.7.0", @@ -94,15 +106,19 @@ "@types/body-parser": "0.0.33", "@types/compression": "0.0.33", "@types/cookie-parser": "1.3.30", + "@types/deep-freeze": "0.0.29", "@types/express": "4.0.34", "@types/express-serve-static-core": "4.0.39", "@types/hammerjs": "2.0.33", + "@types/jasmine": "^2.2.34", "@types/memory-cache": "0.0.29", "@types/mime": "0.0.29", "@types/morgan": "1.7.32", "@types/node": "6.0.52", "@types/serve-static": "1.7.31", "@types/webfontloader": "1.6.27", + "ajv": "4.2.0", + "ajv-keywords": "1.1.1", "angular2-template-loader": "0.6.0", "autoprefixer": "6.5.4", "awesome-typescript-loader": "2.2.4", @@ -110,10 +126,30 @@ "concurrently": "3.1.0", "cookie-parser": "1.4.3", "copy-webpack-plugin": "4.0.1", + "css-loader": "^0.26.0", + "deep-freeze": "0.0.1", + "html-webpack-plugin": "^2.21.0", "imports-loader": "0.7.0", + "istanbul-instrumenter-loader": "^0.2.0", + "jasmine-core": "~2.5.2", + "jasmine-spec-reporter": "~2.7.0", "json-loader": "0.5.4", + "karma": "^1.2.0", + "karma-chrome-launcher": "^2.0.0", + "karma-cli": "^1.0.1", + "karma-coverage": "^1.1.1", + "karma-jasmine": "^1.0.2", + "karma-mocha-reporter": "^2.0.0", + "karma-phantomjs-launcher": "^1.0.2", + "karma-remap-istanbul": "^0.2.1", + "karma-sourcemap-loader": "^0.3.7", + "karma-webdriver-launcher": "^1.0.4", + "karma-webpack": "1.8.0", "node-sass": "4.0.0", "nodemon": "1.11.0", + "postcss-cli": "^2.6.0", + "protractor": "~4.0.14", + "protractor-istanbul-plugin": "~2.0.0", "raw-loader": "0.5.1", "reflect-metadata": "0.1.8", "rimraf": "2.5.4", @@ -122,7 +158,9 @@ "rollup-plugin-node-globals": "1.1.0", "rollup-plugin-node-resolve": "2.0.0", "rollup-plugin-uglify": "1.0.1", + "source-map-loader": "^0.1.5", "string-replace-loader": "1.0.5", + "to-string-loader": "^1.1.4", "ts-helpers": "1.1.2", "ts-node": "1.7.2", "tslint": "4.0.2", diff --git a/postcss.config.json b/postcss.config.json new file mode 100644 index 0000000000..70df875975 --- /dev/null +++ b/postcss.config.json @@ -0,0 +1,9 @@ +{ + "use": ["autoprefixer"], + "input": "src/**/*.css", + "replace": true, + "local-plugins": true, + "autoprefixer": { + "browsers": "last 2 versions" + } +} diff --git a/protractor.conf.js b/protractor.conf.js new file mode 100644 index 0000000000..56a84875a1 --- /dev/null +++ b/protractor.conf.js @@ -0,0 +1,85 @@ +// Protractor configuration file, see link for more information +// https://github.com/angular/protractor/blob/master/docs/referenceConf.js + +/*global jasmine */ +var SpecReporter = require('jasmine-spec-reporter'); + +exports.config = { + allScriptsTimeout: 11000, + // ----------------------------------------------------------------- + // Uncomment to run tests using a remote Selenium server + //seleniumAddress: 'http://selenium.address:4444/wd/hub', + // Change to 'false' to run tests using a remote Selenium server + directConnect: true, + // Change if the website to test is not on the localhost + baseUrl: 'http://localhost:3000/', + // ----------------------------------------------------------------- + specs: [ + './e2e/**/*.e2e-spec.ts' + ], + // ----------------------------------------------------------------- + // Browser and Capabilities: PhantomJS + // ----------------------------------------------------------------- + // capabilities: { + // 'browserName': 'phantomjs', + // 'version': '', + // 'platform': 'ANY' + // }, + // ----------------------------------------------------------------- + // Browser and Capabilities: Chrome + // ----------------------------------------------------------------- + capabilities: { + 'browserName': 'chrome', + 'version': '', + 'platform': 'ANY' + }, + // ----------------------------------------------------------------- + // Browser and Capabilities: Firefox + // ----------------------------------------------------------------- + // capabilities: { + // 'browserName': 'firefox', + // 'version': '', + // 'platform': 'ANY' + // }, + + // ----------------------------------------------------------------- + // Browser and Capabilities: MultiCapabilities + // ----------------------------------------------------------------- + //multiCapabilities: [ + // { + // 'browserName': 'phantomjs', + // 'version': '', + // 'platform': 'ANY' + // }, + // { + // 'browserName': 'chrome', + // 'version': '', + // 'platform': 'ANY' + // } + // { + // 'browserName': 'firefox', + // 'version': '', + // 'platform': 'ANY' + // } + //], + + plugins : [{ + path: 'node_modules/protractor-istanbul-plugin' + }], + + framework: 'jasmine', + jasmineNodeOpts: { + showColors: true, + defaultTimeoutInterval: 30000, + print: function() {} + }, + useAllAngular2AppRoots: true, + beforeLaunch: function() { + require('ts-node').register({ + project: 'e2e' + }); + }, + onPrepare: function() { + jasmine.getEnv().addReporter(new SpecReporter()); + } +}; diff --git a/spec-bundle.js b/spec-bundle.js new file mode 100644 index 0000000000..36026d530f --- /dev/null +++ b/spec-bundle.js @@ -0,0 +1,62 @@ +/** + * @author: @AngularClass + */ + +/* + * When testing with webpack and ES6, we have to do some extra + * things to get testing to work right. Because we are gonna write tests + * in ES6 too, we have to compile those as well. That's handled in + * karma.conf.js with the karma-webpack plugin. This is the entry + * file for webpack test. Just like webpack will create a bundle.js + * file for our client, when we run test, it will compile and bundle them + * all here! Crazy huh. So we need to do some setup + */ +Error.stackTraceLimit = Infinity; + +require('core-js/es6'); +require('core-js/es7/reflect'); + +// Typescript emit helpers polyfill +require('ts-helpers'); + +require('zone.js/dist/zone'); +require('zone.js/dist/long-stack-trace-zone'); +require('zone.js/dist/proxy'); // since zone.js 0.6.15 +require('zone.js/dist/sync-test'); +require('zone.js/dist/jasmine-patch'); // put here since zone.js 0.6.14 +require('zone.js/dist/async-test'); +require('zone.js/dist/fake-async-test'); + +// RxJS +require('rxjs/Rx'); + +var testing = require('@angular/core/testing'); +var browser = require('@angular/platform-browser-dynamic/testing'); + +testing.TestBed.initTestEnvironment( + browser.BrowserDynamicTestingModule, + browser.platformBrowserDynamicTesting() +); + +/* + * Ok, this is kinda crazy. We can use the context method on + * require that webpack created in order to tell webpack + * what files we actually want to require or import. + * Below, context will be a function/object with file names as keys. + * Using that regex we are saying look in ../src then find + * any file that ends with spec.ts and get its path. By passing in true + * we say do this recursively + */ +var testContext = require.context('./src', true, /\.spec\.ts/); + +/* + * get all the files, for each file, call the context function + * that will require the file and load it up here. Context will + * loop and require those spec files here + */ +function requireAll(requireContext) { + return requireContext.keys().map(requireContext); +} + +// requires and returns all modules that match +var modules = requireAll(testContext); diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts new file mode 100644 index 0000000000..2664b5f27b --- /dev/null +++ b/src/app/app.component.spec.ts @@ -0,0 +1,81 @@ +// ... test imports +import { + async, + ComponentFixture, + inject, + TestBed +} from '@angular/core/testing'; +import { + CUSTOM_ELEMENTS_SCHEMA, + DebugElement +} from "@angular/core"; +import { By } from '@angular/platform-browser'; +import { TranslateModule, TranslateLoader } from "ng2-translate"; +import { Store, StoreModule } from "@ngrx/store"; + +// Load the implementations that should be tested +import { AppComponent } from './app.component'; + +import { CommonModule } from '@angular/common'; +import { HostWindowState } from "./shared/host-window.reducer"; +import { HostWindowResizeAction } from "./shared/host-window.actions"; +import { MockTranslateLoader } from "./shared/testing/mock-translate-loader"; + +let comp: AppComponent; +let fixture: ComponentFixture; +let de: DebugElement; +let el: HTMLElement; + +describe('App component', () => { + + // async beforeEach + beforeEach(async(() => { + return TestBed.configureTestingModule({ + imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({ + provide: TranslateLoader, + useClass: MockTranslateLoader + })], + declarations: [AppComponent], // declare the test component + providers: [ + AppComponent + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }) + })); + + // synchronous beforeEach + beforeEach(() => { + fixture = TestBed.createComponent(AppComponent); + + comp = fixture.componentInstance; // component test instance + + // query for the title

by CSS element selector + de = fixture.debugElement.query(By.css('p')); + el = de.nativeElement; + }); + + it('should create component', inject([AppComponent], (app: AppComponent) => { + // Perform test using fixture and service + expect(app).toBeTruthy(); + })); + + describe("when the window is resized", () => { + let width: number; + let height: number; + let store: Store; + + beforeEach(() => { + store = fixture.debugElement.injector.get(Store); + spyOn(store, 'dispatch'); + + window.dispatchEvent(new Event('resize')); + width = window.innerWidth; + height = window.innerHeight; + }); + + it("should dispatch a HostWindowResizeAction with the width and height of the window as its payload", () => { + expect(store.dispatch).toHaveBeenCalledWith(new HostWindowResizeAction(width, height)); + }); + + }); +}); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 15485eb6a6..0b861bb75c 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -8,7 +8,7 @@ import { import { TranslateService } from "ng2-translate"; import { HostWindowState } from "./shared/host-window.reducer"; import { Store } from "@ngrx/store"; -import { HostWindowActions } from "./shared/host-window.actions"; +import { HostWindowResizeAction } from "./shared/host-window.actions"; @Component({ changeDetection: ChangeDetectionStrategy.Default, @@ -52,7 +52,7 @@ export class AppComponent implements OnDestroy, OnInit { @HostListener('window:resize', ['$event']) private onResize(event): void { this.store.dispatch( - HostWindowActions.resize(event.target.innerWidth, event.target.innerHeight) + new HostWindowResizeAction(event.target.innerWidth, event.target.innerHeight) ); } diff --git a/src/app/header/header.actions.ts b/src/app/header/header.actions.ts index e3f64c1019..c333fc5df7 100644 --- a/src/app/header/header.actions.ts +++ b/src/app/header/header.actions.ts @@ -1,24 +1,43 @@ import { Action } from "@ngrx/store"; +import { type } from "../shared/ngrx/type"; -export class HeaderActions { - static COLLAPSE = 'dspace/header/COLLAPSE'; - static collapse(): Action { - return { - type: HeaderActions.COLLAPSE - } - } + /** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const HeaderActionTypes = { + COLLAPSE: type('dspace/header/COLLAPSE'), + EXPAND: type('dspace/header/EXPAND'), + TOGGLE: type('dspace/header/TOGGLE') +}; - static EXPAND = 'dspace/header/EXPAND'; - static expand(): Action { - return { - type: HeaderActions.EXPAND - } - } +export class HeaderCollapseAction implements Action { + type = HeaderActionTypes.COLLAPSE; - static TOGGLE = 'dspace/header/TOGGLE'; - static toggle(): Action { - return { - type: HeaderActions.TOGGLE - } - } + constructor() {} } + +export class HeaderExpandAction implements Action { + type = HeaderActionTypes.EXPAND; + + constructor() {} +} + +export class HeaderToggleAction implements Action { + type = HeaderActionTypes.TOGGLE; + + constructor() {} +} + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ +export type HeaderAction + = HeaderCollapseAction + | HeaderExpandAction + | HeaderToggleAction diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts new file mode 100644 index 0000000000..ab0e591bc9 --- /dev/null +++ b/src/app/header/header.component.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { HeaderComponent } from "./header.component"; +import { Store, StoreModule } from "@ngrx/store"; +import { HeaderState } from "./header.reducer"; +import Spy = jasmine.Spy; +import { HeaderToggleAction } from "./header.actions"; +import { TranslateModule } from "ng2-translate"; +import { NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap"; +import { Observable } from "rxjs"; + +let comp: HeaderComponent; +let fixture: ComponentFixture; +let store: Store; + +describe('HeaderComponent', () => { + + // async beforeEach + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ StoreModule.provideStore({}), TranslateModule.forRoot(), NgbCollapseModule.forRoot() ], + declarations: [ HeaderComponent ] + }) + .compileComponents(); // compile template and css + })); + + // synchronous beforeEach + beforeEach(() => { + fixture = TestBed.createComponent(HeaderComponent); + + comp = fixture.componentInstance; + + + store = fixture.debugElement.injector.get(Store); + spyOn(store, 'dispatch'); + }); + + describe('when the toggle button is clicked', () => { + + beforeEach(() => { + let navbarToggler = fixture.debugElement.query(By.css('.navbar-toggler')); + navbarToggler.triggerEventHandler('click', null); + }); + + it("should dispatch a HeaderToggleAction", () => { + expect(store.dispatch).toHaveBeenCalledWith(new HeaderToggleAction()); + }); + + }); + + describe("when navCollapsed in the store is true", () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement; + spyOn(store, 'select').and.returnValue(Observable.of({ navCollapsed: true })); + fixture.detectChanges(); + }); + + it("should close the menu", () => { + expect(menu.classList).not.toContain('in'); + }); + + }); + + describe("when navCollapsed in the store is false", () => { + let menu: HTMLElement; + + beforeEach(() => { + menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement; + spyOn(store, 'select').and.returnValue(Observable.of({ navCollapsed: false })); + fixture.detectChanges(); + }); + + it("should open the menu", () => { + expect(menu.classList).toContain('in'); + }); + + }); + +}); diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 69da1aea68..76a9cbe05b 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -1,9 +1,8 @@ import { Component, OnInit } from "@angular/core"; import { Store } from "@ngrx/store"; import { HeaderState } from "./header.reducer"; -import { HeaderActions } from "./header.actions"; import { Observable } from "rxjs"; -import 'rxjs/add/operator/filter'; +import { HeaderToggleAction } from "./header.actions"; @Component({ selector: 'ds-header', @@ -24,16 +23,8 @@ export class HeaderComponent implements OnInit { .map(({ navCollapsed }: HeaderState) => navCollapsed); } - private collapse(): void { - this.store.dispatch(HeaderActions.collapse()); - } - - private expand(): void { - this.store.dispatch(HeaderActions.expand()); - } - public toggle(): void { - this.store.dispatch(HeaderActions.toggle()); + this.store.dispatch(new HeaderToggleAction()); } } diff --git a/src/app/header/header.effects.spec.ts b/src/app/header/header.effects.spec.ts new file mode 100644 index 0000000000..7c5f40228d --- /dev/null +++ b/src/app/header/header.effects.spec.ts @@ -0,0 +1,53 @@ +import { TestBed, inject } from "@angular/core/testing"; +import { EffectsTestingModule, EffectsRunner } from '@ngrx/effects/testing'; +import { HeaderEffects } from "./header.effects"; +import { HeaderCollapseAction } from "./header.actions"; +import { HostWindowResizeAction } from "../shared/host-window.actions"; +import { routerActions } from "@ngrx/router-store"; + +describe('HeaderEffects', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [ + EffectsTestingModule + ], + providers: [ + HeaderEffects + ] + })); + + let runner: EffectsRunner; + let headerEffects: HeaderEffects; + + beforeEach(inject([ + EffectsRunner, HeaderEffects + ], + (_runner, _headerEffects) => { + runner = _runner; + headerEffects = _headerEffects; + } + )); + + describe('resize$', () => { + + it('should return a COLLAPSE action in response to a RESIZE action', () => { + runner.queue(new HostWindowResizeAction(800,600)); + + headerEffects.resize$.subscribe(result => { + expect(result).toEqual(new HeaderCollapseAction()); + }); + }); + + }); + + describe('routeChange$', () => { + + it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => { + runner.queue({ type: routerActions.UPDATE_LOCATION }); + + headerEffects.resize$.subscribe(result => { + expect(result).toEqual(new HeaderCollapseAction()); + }); + }); + + }); +}); diff --git a/src/app/header/header.effects.ts b/src/app/header/header.effects.ts index d9ea53adda..33888e074d 100644 --- a/src/app/header/header.effects.ts +++ b/src/app/header/header.effects.ts @@ -1,8 +1,8 @@ import { Injectable } from "@angular/core"; import { Effect, Actions } from '@ngrx/effects' -import { HeaderActions } from "./header.actions"; -import { HostWindowActions } from "../shared/host-window.actions"; +import { HostWindowActionTypes } from "../shared/host-window.actions"; import { routerActions } from "@ngrx/router-store"; +import { HeaderCollapseAction } from "./header.actions"; @Injectable() export class HeaderEffects { @@ -12,10 +12,10 @@ export class HeaderEffects { ) { } @Effect() resize$ = this.actions$ - .ofType(HostWindowActions.RESIZE) - .map(() => HeaderActions.collapse()); + .ofType(HostWindowActionTypes.RESIZE) + .map(() => new HeaderCollapseAction()); @Effect() routeChange$ = this.actions$ .ofType(routerActions.UPDATE_LOCATION) - .map(() => HeaderActions.collapse()); + .map(() => new HeaderCollapseAction()); } diff --git a/src/app/header/header.reducer.spec.ts b/src/app/header/header.reducer.spec.ts new file mode 100644 index 0000000000..2b8c26f523 --- /dev/null +++ b/src/app/header/header.reducer.spec.ts @@ -0,0 +1,89 @@ +import * as deepFreeze from "deep-freeze"; + +import { headerReducer } from "./header.reducer"; +import { + HeaderCollapseAction, + HeaderExpandAction, + HeaderToggleAction +} from "./header.actions"; + +class NullAction extends HeaderCollapseAction { + type = null; + + constructor() { + super(); + } +} + +describe("headerReducer", () => { + it("should return the current state when no valid actions have been made", () => { + const state = { navCollapsed: false }; + const action = new NullAction(); + const newState = headerReducer(state, action); + + expect(newState).toEqual(state); + }); + + it("should start with navCollapsed = true", () => { + const action = new NullAction(); + const initialState = headerReducer(undefined, action); + + // The navigation starts collapsed + expect(initialState.navCollapsed).toEqual(true); + }); + + it("should set navCollapsed to true in response to the COLLAPSE action", () => { + const state = { navCollapsed: false }; + const action = new HeaderCollapseAction(); + const newState = headerReducer(state, action); + + expect(newState.navCollapsed).toEqual(true); + }); + + it("should perform the COLLAPSE action without affecting the previous state", () => { + const state = { navCollapsed: false }; + deepFreeze(state); + + const action = new HeaderCollapseAction(); + headerReducer(state, action); + + //no expect required, deepFreeze will ensure an exception is thrown if the state + //is mutated, and any uncaught exception will cause the test to fail + }); + + it("should set navCollapsed to false in response to the EXPAND action", () => { + const state = { navCollapsed: true }; + const action = new HeaderExpandAction(); + const newState = headerReducer(state, action); + + expect(newState.navCollapsed).toEqual(false); + }); + + it("should perform the EXPAND action without affecting the previous state", () => { + const state = { navCollapsed: true }; + deepFreeze(state); + + const action = new HeaderExpandAction(); + headerReducer(state, action); + }); + + it("should flip the value of navCollapsed in response to the TOGGLE action", () => { + const state1 = { navCollapsed: true }; + const action = new HeaderToggleAction(); + + const state2 = headerReducer(state1, action); + const state3 = headerReducer(state2, action); + + expect(state2.navCollapsed).toEqual(false); + expect(state3.navCollapsed).toEqual(true); + }); + + it("should perform the TOGGLE action without affecting the previous state", () => { + const state = { navCollapsed: true }; + deepFreeze(state); + + const action = new HeaderToggleAction(); + headerReducer(state, action); + }); + +}); diff --git a/src/app/header/header.reducer.ts b/src/app/header/header.reducer.ts index af153fba24..c1d0fef7ea 100644 --- a/src/app/header/header.reducer.ts +++ b/src/app/header/header.reducer.ts @@ -1,5 +1,4 @@ -import { Action } from "@ngrx/store"; -import { HeaderActions } from "./header.actions"; +import { HeaderAction, HeaderActionTypes } from "./header.actions"; export interface HeaderState { navCollapsed: boolean; @@ -9,23 +8,23 @@ const initialState: HeaderState = { navCollapsed: true }; -export const headerReducer = (state = initialState, action: Action): HeaderState => { +export const headerReducer = (state = initialState, action: HeaderAction): HeaderState => { switch (action.type) { - case HeaderActions.COLLAPSE: { + case HeaderActionTypes.COLLAPSE: { return Object.assign({}, state, { navCollapsed: true }); } - case HeaderActions.EXPAND: { + case HeaderActionTypes.EXPAND: { return Object.assign({}, state, { navCollapsed: false }); } - case HeaderActions.TOGGLE: { + case HeaderActionTypes.TOGGLE: { return Object.assign({}, state, { navCollapsed: !state.navCollapsed }); diff --git a/src/app/shared/host-window.actions.ts b/src/app/shared/host-window.actions.ts index de41c69564..99be01cdc2 100644 --- a/src/app/shared/host-window.actions.ts +++ b/src/app/shared/host-window.actions.ts @@ -1,14 +1,21 @@ import { Action } from "@ngrx/store"; +import { type } from "./ngrx/type"; -export class HostWindowActions { - static RESIZE = 'dspace/host-window/RESIZE'; - static resize(newWidth: number, newHeight: number): Action { - return { - type: HostWindowActions.RESIZE, - payload: { - width: newWidth, - height: newHeight - } - } +export const HostWindowActionTypes = { + RESIZE: type('dspace/host-window/RESIZE') +}; + +export class HostWindowResizeAction implements Action { + type = HostWindowActionTypes.RESIZE; + payload: { + width: number; + height: number; + }; + + constructor(width: number, height: number) { + this.payload = { width, height } } } + +export type HostWindowAction + = HostWindowResizeAction; diff --git a/src/app/shared/host-window.reducer.spec.ts b/src/app/shared/host-window.reducer.spec.ts new file mode 100644 index 0000000000..19d4f67697 --- /dev/null +++ b/src/app/shared/host-window.reducer.spec.ts @@ -0,0 +1,48 @@ +import * as deepFreeze from "deep-freeze"; +import { hostWindowReducer } from "./host-window.reducer"; +import { HostWindowResizeAction } from "./host-window.actions"; + +class NullAction extends HostWindowResizeAction { + type = null; + + constructor() { + super(0,0); + } +} + +describe('hostWindowReducer', () => { + + it("should return the current state when no valid actions have been made", () => { + const state = { width: 800, height: 600 }; + const action = new NullAction(); + const newState = hostWindowReducer(state, action); + + expect(newState).toEqual(state); + }); + + it("should start with width = null and height = null", () => { + const action = new NullAction(); + const initialState = hostWindowReducer(undefined, action); + + expect(initialState.width).toEqual(null); + expect(initialState.height).toEqual(null); + }); + + it("should update the width and height in the state in response to a RESIZE action", () => { + const state = { width: 800, height: 600 }; + const action = new HostWindowResizeAction(1024, 768); + const newState = hostWindowReducer(state, action); + + expect(newState.width).toEqual(1024); + expect(newState.height).toEqual(768); + }); + + it("should perform the RESIZE action without affecting the previous state", () => { + const state = { width: 800, height: 600 }; + deepFreeze(state); + + const action = new HostWindowResizeAction(1024, 768); + hostWindowReducer(state, action); + }); + +}); diff --git a/src/app/shared/host-window.reducer.ts b/src/app/shared/host-window.reducer.ts index 4b8e1d3cb9..03349b3a91 100644 --- a/src/app/shared/host-window.reducer.ts +++ b/src/app/shared/host-window.reducer.ts @@ -1,5 +1,4 @@ -import { Action } from "@ngrx/store"; -import { HostWindowActions } from "./host-window.actions"; +import { HostWindowAction, HostWindowActionTypes } from "./host-window.actions"; export interface HostWindowState { width: number; @@ -11,10 +10,10 @@ const initialState: HostWindowState = { height: null }; -export const hostWindowReducer = (state = initialState, action: Action): HostWindowState => { +export const hostWindowReducer = (state = initialState, action: HostWindowAction): HostWindowState => { switch (action.type) { - case HostWindowActions.RESIZE: { + case HostWindowActionTypes.RESIZE: { return Object.assign({}, state, action.payload); } diff --git a/src/app/shared/ngrx/type.ts b/src/app/shared/ngrx/type.ts new file mode 100644 index 0000000000..9b50c1d6d0 --- /dev/null +++ b/src/app/shared/ngrx/type.ts @@ -0,0 +1,24 @@ + +/** + * Based on + * https://github.com/ngrx/example-app/blob/master/src/app/util.ts + * + * This function coerces a string into a string literal type. + * Using tagged union types in TypeScript 2.0, this enables + * powerful typechecking of our reducers. + * + * Since every action label passes through this function it + * is a good place to ensure all of our action labels + * are unique. + */ + +let typeCache: { [label: string]: boolean } = {}; +export function type(label: T | ''): T { + if (typeCache[label]) { + throw new Error(`Action type "${label}" is not unique"`); + } + + typeCache[label] = true; + + return label; +} diff --git a/src/app/shared/testing/mock-translate-loader.ts b/src/app/shared/testing/mock-translate-loader.ts new file mode 100644 index 0000000000..a780766b25 --- /dev/null +++ b/src/app/shared/testing/mock-translate-loader.ts @@ -0,0 +1,8 @@ +import { TranslateLoader } from "ng2-translate"; +import { Observable } from "rxjs"; + +export class MockTranslateLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + return Observable.of({}); + } +} diff --git a/tsconfig.json b/tsconfig.json index 226f3dbe19..6926f8cfff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "removeComments": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, "allowUnreachableCode": false, "allowUnusedLabels": false, "noEmitHelpers": true, diff --git a/webpack.config.ts b/webpack.config.ts index 1d4b927e13..065ac1cf6e 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -67,6 +67,7 @@ export var commonConfig = { } ], }, + plugins: [ // Use commonPlugins. ] diff --git a/webpack.test.config.js b/webpack.test.config.js new file mode 100644 index 0000000000..cd288ce894 --- /dev/null +++ b/webpack.test.config.js @@ -0,0 +1,242 @@ +/** + * @author: @AngularClass + */ + +const helpers = require('./helpers'); +const path = require('path'); + +/** + * Webpack Plugins + */ +const ProvidePlugin = require('webpack/lib/ProvidePlugin'); +const DefinePlugin = require('webpack/lib/DefinePlugin'); +const LoaderOptionsPlugin = require('webpack/lib/LoaderOptionsPlugin'); +const ContextReplacementPlugin = require('webpack/lib/ContextReplacementPlugin'); + +/** + * Webpack Constants + */ +const ENV = process.env.ENV = process.env.NODE_ENV = 'test'; + +/** + * Webpack configuration + * + * See: http://webpack.github.io/docs/configuration.html#cli + */ +module.exports = function (options) { + return { + + /** + * Source map for Karma from the help of karma-sourcemap-loader & karma-webpack + * + * Do not change, leave as is or it wont work. + * See: https://github.com/webpack/karma-webpack#source-maps + */ + devtool: 'inline-source-map', + + /** + * Options affecting the resolving of modules. + * + * See: http://webpack.github.io/docs/configuration.html#resolve + */ + resolve: { + + /** + * An array of extensions that should be used to resolve modules. + * + * See: http://webpack.github.io/docs/configuration.html#resolve-extensions + */ + extensions: ['.ts', '.js'], + + /** + * Make sure root is src + */ + modules: [ path.resolve(__dirname, 'src'), 'node_modules' ] + + }, + + /** + * Options affecting the normal modules. + * + * See: http://webpack.github.io/docs/configuration.html#module + * + * 'use:' revered back to 'loader:' as a temp. workaround for #1188 + * See: https://github.com/AngularClass/angular2-webpack-starter/issues/1188#issuecomment-262872034 + */ + module: { + + rules: [ + + /** + * Source map loader support for *.js files + * Extracts SourceMaps for source files that as added as sourceMappingURL comment. + * + * See: https://github.com/webpack/source-map-loader + */ + { + enforce: 'pre', + test: /\.js$/, + loader: 'source-map-loader', + exclude: [ + // these packages have problems with their sourcemaps + helpers.root('node_modules/rxjs'), + helpers.root('node_modules/@angular') + ] + }, + + /** + * Typescript loader support for .ts and Angular 2 async routes via .async.ts + * + * See: https://github.com/s-panferov/awesome-typescript-loader + */ + { + test: /\.ts$/, + loaders: [ + { + loader: 'awesome-typescript-loader', + query: { + // use inline sourcemaps for "karma-remap-coverage" reporter + sourceMap: false, + inlineSourceMap: true, + sourceRoot: false, + compilerOptions: { + // Remove TypeScript helpers to be injected + // below by DefinePlugin + removeComments: true + } + } + }, + 'angular2-template-loader' + ], + exclude: [/\.e2e\.ts$/] + }, + + /** + * Json loader support for *.json files. + * + * See: https://github.com/webpack/json-loader + */ + { + test: /\.json$/, + loader: 'json-loader', + exclude: [helpers.root('src/index.html')] + }, + + /** + * Raw loader support for *.css files + * Returns file content as string + * + * See: https://github.com/webpack/raw-loader + */ + { + test: /\.css$/, + loader: ['to-string-loader', 'css-loader'], + exclude: [helpers.root('src/index.html')] + }, + + /** + * Raw loader support for *.html + * Returns file content as string + * + * See: https://github.com/webpack/raw-loader + */ + { + test: /\.html$/, + loader: 'raw-loader', + exclude: [helpers.root('src/index.html')] + }, + + /** + * Instruments JS files with Istanbul for subsequent code coverage reporting. + * Instrument only testing sources. + * + * See: https://github.com/deepsweet/istanbul-instrumenter-loader + */ + { + enforce: 'post', + test: /\.(js|ts)$/, + loader: 'istanbul-instrumenter-loader', + include: helpers.root('src'), + exclude: [ + /\.(e2e|spec)\.ts$/, + /node_modules/ + ] + } + + ] + }, + + /** + * Add additional plugins to the compiler. + * + * See: http://webpack.github.io/docs/configuration.html#plugins + */ + plugins: [ + + /** + * Plugin: DefinePlugin + * Description: Define free variables. + * Useful for having development builds with debug logging or adding global constants. + * + * Environment helpers + * + * See: https://webpack.github.io/docs/list-of-plugins.html#defineplugin + */ + // NOTE: when adding more properties make sure you include them in custom-typings.d.ts + new DefinePlugin({ + 'ENV': JSON.stringify(ENV), + 'HMR': false, + 'process.env': { + 'ENV': JSON.stringify(ENV), + 'NODE_ENV': JSON.stringify(ENV), + 'HMR': false, + } + }), + + /** + * Plugin: ContextReplacementPlugin + * Description: Provides context to Angular's use of System.import + * + * See: https://webpack.github.io/docs/list-of-plugins.html#contextreplacementplugin + * See: https://github.com/angular/angular/issues/11580 + */ + new ContextReplacementPlugin( + // The (\\|\/) piece accounts for path separators in *nix and Windows + /angular(\\|\/)core(\\|\/)(esm(\\|\/)src|src)(\\|\/)linker/, + helpers.root('src'), // location of your src + { + // your Angular Async Route paths relative to this root directory + } + ), + + /** + * Plugin LoaderOptionsPlugin (experimental) + * + * See: https://gist.github.com/sokra/27b24881210b56bbaff7 + */ + new LoaderOptionsPlugin({ + debug: true, + options: { + + } + }), + + ], + + /** + * Include polyfills or mocks for various node stuff + * Description: Node configuration + * + * See: https://webpack.github.io/docs/configuration.html#node + */ + node: { + global: true, + process: false, + crypto: 'empty', + module: false, + clearImmediate: false, + setImmediate: false + } + + }; +}