├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── angular.json ├── cypress.config.ts ├── cypress ├── e2e │ ├── counter-helpers.cy.ts │ └── counter.cy.ts ├── support │ ├── commands.ts │ └── e2e.ts └── tsconfig.json ├── docs ├── 3rdpartylicenses.txt ├── assets │ └── counter.json ├── favicon.ico ├── index.html ├── main-es2015.34bbdc20fd140520aee0.js ├── main-es5.34bbdc20fd140520aee0.js ├── polyfills-es2015.f2c5ab749249a66bdf26.js ├── polyfills-es5.049f620af8c864cf4d88.js ├── runtime-es2015.1eba213af0b233498d9d.js ├── runtime-es5.1eba213af0b233498d9d.js └── styles.7639ff453898f4e0c1be.css ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── app │ ├── actions │ │ └── counter.actions.ts │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.spectator.spec.ts │ ├── app.component.ts │ ├── app.module.spec.ts │ ├── app.module.ts │ ├── app.routing.module.spec.ts │ ├── app.routing.module.ts │ ├── components │ │ ├── counter │ │ │ ├── counter.component.css │ │ │ ├── counter.component.html │ │ │ ├── counter.component.spec.ts │ │ │ ├── counter.component.spectator.spec.ts │ │ │ └── counter.component.ts │ │ ├── home │ │ │ ├── home-component.ng-mocks.spec.ts │ │ │ ├── home-component.spectator.spec.ts │ │ │ ├── home.component.css │ │ │ ├── home.component.fake-child.spec.ts │ │ │ ├── home.component.html │ │ │ ├── home.component.spec.ts │ │ │ └── home.component.ts │ │ ├── ngrx-counter │ │ │ ├── ngrx-counter.component.css │ │ │ ├── ngrx-counter.component.html │ │ │ ├── ngrx-counter.component.spec.ts │ │ │ ├── ngrx-counter.component.spectator.spec.ts │ │ │ └── ngrx-counter.component.ts │ │ ├── service-counter │ │ │ ├── service-counter.component.css │ │ │ ├── service-counter.component.html │ │ │ ├── service-counter.component.spec.ts │ │ │ ├── service-counter.component.spectator.spec.ts │ │ │ └── service-counter.component.ts │ │ └── standalone-service-counter │ │ │ ├── service-counter.component.spectator.spec.ts │ │ │ ├── standalone-service-counter.component.css │ │ │ ├── standalone-service-counter.component.html │ │ │ ├── standalone-service-counter.component.spec.ts │ │ │ └── standalone-service-counter.component.ts │ ├── effects │ │ ├── counter.effects.spec.ts │ │ └── counter.effects.ts │ ├── reducers │ │ ├── counter.reducer.spec.ts │ │ ├── counter.reducer.ts │ │ └── index.ts │ ├── services │ │ ├── counter-api.service.spec.ts │ │ ├── counter-api.service.spectator.spec.ts │ │ ├── counter-api.service.ts │ │ ├── counter.service.spec.ts │ │ ├── counter.service.ts │ │ └── todos-service.spec.ts │ ├── shared │ │ ├── app-state.ts │ │ └── selectors.ts │ └── spec-helpers │ │ └── element.spec-helper.ts ├── assets │ ├── .gitkeep │ └── counter.json ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts └── styles.css ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{js,ts}] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["tsconfig.json", "e2e/tsconfig.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "extends": [ 12 | "plugin:@angular-eslint/recommended", 13 | "plugin:@angular-eslint/template/process-inline-templates" 14 | ], 15 | "rules": { 16 | "@angular-eslint/directive-selector": [ 17 | "error", 18 | { 19 | "type": "attribute", 20 | "prefix": "app", 21 | "style": "camelCase" 22 | } 23 | ], 24 | "@angular-eslint/component-selector": [ 25 | "error", 26 | { 27 | "type": "element", 28 | "prefix": "app", 29 | "style": "kebab-case" 30 | } 31 | ] 32 | } 33 | }, 34 | { 35 | "files": ["*.html"], 36 | "extends": ["plugin:@angular-eslint/template/recommended"], 37 | "rules": {} 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "printWidth": 90, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular Counter Component 2 | 3 | 📖 This example is part of the **[free online book: Testing Angular – A Guide to Robust Angular Applications 4 | ](https://testing-angular.com/)**. 📖 5 | 6 | ## Overview 7 | 8 | This repository builds a simple counter with Angular in three ways: 9 | 10 | - [CounterComponent](src/app/components/counter/): Counter that manages its own state. Has an Input and an Output. 11 | - [ServiceCounterComponent](src/app/components/service-counter): Counter that stores the state in shared service. 12 | - [NgRxCounterComponent](src/app/components/ngrx-counter): Counter that uses NgRx to manage the count and NgRx effects to persist them on the server. 13 | 14 | ## Related projects 15 | 16 | - [Angular Flickr Search](https://github.com/9elements/angular-flickr-search) – a more complex example app 17 | - [Angular testing workshop](https://9elements.github.io/angular-testing-workshop/) 18 | 19 | ## Development server 20 | 21 | - Clone the repository, change into the `angular-workshop` directory 22 | - `npm install` 23 | - `npm install -g @angular/cli` 24 | - `ng serve` 25 | - Navigate to http://localhost:4200/ 26 | 27 | ## Running unit & integration tests 28 | 29 | Run `ng test` to execute the unit & integration tests with Karma and Jasmine. 30 | 31 | ## Running end-to-end tests with Cypress 32 | 33 | Run `ng run angular-workshop:cypress-run` to execute the Cypress end-to-end tests. (This starts the development server automatically.) 34 | 35 | Run `ng run angular-workshop:cypress-open` to start the interactive Cypress test runner. 36 | 37 | ## Deployment 38 | 39 | Run `npm run deploy` to the deploy the code to [https://9elements.github.io/angular-workshop/]. 40 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-workshop": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:application": { 10 | "strict": true 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/angular-workshop", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": ["zone.js"], 24 | "tsConfig": "tsconfig.app.json", 25 | "assets": ["src/favicon.ico", "src/assets"], 26 | "styles": ["src/styles.css"], 27 | "scripts": [] 28 | }, 29 | "configurations": { 30 | "production": { 31 | "budgets": [ 32 | { 33 | "type": "initial", 34 | "maximumWarning": "500kb", 35 | "maximumError": "1mb" 36 | }, 37 | { 38 | "type": "anyComponentStyle", 39 | "maximumWarning": "2kb", 40 | "maximumError": "4kb" 41 | } 42 | ], 43 | "fileReplacements": [ 44 | { 45 | "replace": "src/environments/environment.ts", 46 | "with": "src/environments/environment.prod.ts" 47 | } 48 | ], 49 | "outputHashing": "all" 50 | }, 51 | "development": { 52 | "buildOptimizer": false, 53 | "optimization": false, 54 | "vendorChunk": true, 55 | "extractLicenses": false, 56 | "sourceMap": true, 57 | "namedChunks": true 58 | } 59 | }, 60 | "defaultConfiguration": "production" 61 | }, 62 | "serve": { 63 | "builder": "@angular-devkit/build-angular:dev-server", 64 | "configurations": { 65 | "production": { 66 | "browserTarget": "angular-workshop:build:production" 67 | }, 68 | "development": { 69 | "browserTarget": "angular-workshop:build:development" 70 | } 71 | }, 72 | "defaultConfiguration": "development" 73 | }, 74 | "extract-i18n": { 75 | "builder": "@angular-devkit/build-angular:extract-i18n", 76 | "options": { 77 | "browserTarget": "angular-workshop:build" 78 | } 79 | }, 80 | "test": { 81 | "builder": "@angular-devkit/build-angular:karma", 82 | "options": { 83 | "polyfills": ["zone.js", "zone.js/testing"], 84 | "tsConfig": "tsconfig.spec.json", 85 | "karmaConfig": "karma.conf.js", 86 | "assets": ["src/favicon.ico", "src/assets"], 87 | "styles": ["src/styles.css"], 88 | "scripts": [] 89 | } 90 | }, 91 | "lint": { 92 | "builder": "@angular-eslint/builder:lint", 93 | "options": { 94 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 95 | } 96 | }, 97 | "deploy": { 98 | "builder": "angular-cli-ghpages:deploy", 99 | "options": {} 100 | }, 101 | "cypress-run": { 102 | "builder": "@cypress/schematic:cypress", 103 | "options": { 104 | "devServerTarget": "angular-workshop:serve" 105 | }, 106 | "configurations": { 107 | "production": { 108 | "devServerTarget": "angular-workshop:serve:production" 109 | } 110 | } 111 | }, 112 | "cypress-open": { 113 | "builder": "@cypress/schematic:cypress", 114 | "options": { 115 | "watch": true, 116 | "headless": false 117 | } 118 | }, 119 | "e2e": { 120 | "builder": "@cypress/schematic:cypress", 121 | "options": { 122 | "devServerTarget": "angular-workshop:serve", 123 | "watch": true, 124 | "headless": false 125 | }, 126 | "configurations": { 127 | "production": { 128 | "devServerTarget": "angular-workshop:serve:production" 129 | } 130 | } 131 | } 132 | } 133 | } 134 | }, 135 | "cli": { 136 | "analytics": false, 137 | "schematicCollections": ["@cypress/schematic", "@schematics/angular"] 138 | }, 139 | "schematics": { 140 | "@angular-eslint/schematics:application": { 141 | "setParserOptionsProject": true 142 | }, 143 | "@angular-eslint/schematics:library": { 144 | "setParserOptionsProject": true 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:4200', 6 | video: false, 7 | experimentalRunAllSpecs: true, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /cypress/e2e/counter-helpers.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Counter (with helpers)', () => { 2 | beforeEach(() => { 3 | cy.visit('/'); 4 | }); 5 | 6 | it('has the correct title', () => { 7 | cy.title().should('equal', 'Angular Workshop: Counters'); 8 | }); 9 | 10 | it('increments the count', () => { 11 | cy.byTestId('count').first().should('have.text', '5'); 12 | cy.byTestId('increment-button').first().click(); 13 | cy.byTestId('count').first().should('have.text', '6'); 14 | }); 15 | 16 | it('decrements the count', () => { 17 | cy.byTestId('decrement-button').first().click(); 18 | cy.byTestId('count').first().should('have.text', '4'); 19 | }); 20 | 21 | it('resets the count', () => { 22 | cy.byTestId('reset-input').first().type('123'); 23 | cy.byTestId('reset-button').first().click(); 24 | cy.byTestId('count').first().should('have.text', '123'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /cypress/e2e/counter.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Counter', () => { 2 | beforeEach(() => { 3 | cy.visit('/'); 4 | }); 5 | 6 | it('has the correct title', () => { 7 | cy.title().should('equal', 'Angular Workshop: Counters'); 8 | }); 9 | 10 | it('increments the count', () => { 11 | cy.get('[data-testid="count"]').first().should('have.text', '5'); 12 | cy.get('[data-testid="increment-button"]').first().click(); 13 | cy.get('[data-testid="count"]').first().should('have.text', '6'); 14 | }); 15 | 16 | it('decrements the count', () => { 17 | cy.get('[data-testid="decrement-button"]').first().click(); 18 | cy.get('[data-testid="count"]').first().should('have.text', '4'); 19 | }); 20 | 21 | it('resets the count', () => { 22 | cy.get('[data-testid="reset-input"]').first().type('123'); 23 | cy.get('[data-testid="reset-button"]').first().click(); 24 | cy.get('[data-testid="count"]').first().should('have.text', '123'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example namespace declaration will help 3 | // with Intellisense and code completion in your 4 | // IDE or Text Editor. 5 | // *********************************************** 6 | // declare namespace Cypress { 7 | // interface Chainable { 8 | // customCommand(param: any): typeof customCommand; 9 | // } 10 | // } 11 | // 12 | // function customCommand(param: any): void { 13 | // console.warn(param); 14 | // } 15 | // 16 | // NOTE: You can use it like so: 17 | // Cypress.Commands.add('customCommand', customCommand); 18 | // 19 | // *********************************************** 20 | // This example commands.js shows you how to 21 | // create various custom commands and overwrite 22 | // existing commands. 23 | // 24 | // For more comprehensive examples of custom 25 | // commands please read more here: 26 | // https://on.cypress.io/custom-commands 27 | // *********************************************** 28 | // 29 | // 30 | // -- This is a parent command -- 31 | // Cypress.Commands.add("login", (email, password) => { ... }) 32 | // 33 | // 34 | // -- This is a child command -- 35 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 36 | // 37 | // 38 | // -- This is a dual command -- 39 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 40 | // 41 | // 42 | // -- This will overwrite an existing command -- 43 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 44 | 45 | declare namespace Cypress { 46 | interface Chainable { 47 | /** 48 | * Get one or more DOM elements by test id. 49 | * 50 | * @param id The test id 51 | * @param options The same options as cy.get 52 | */ 53 | byTestId( 54 | id: string, 55 | options?: Partial< 56 | Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow 57 | >, 58 | ): Cypress.Chainable>; 59 | } 60 | } 61 | 62 | Cypress.Commands.add( 63 | 'byTestId', 64 | // Borrow the signature from cy.get 65 | ( 66 | id: string, 67 | options?: Partial< 68 | Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow 69 | >, 70 | ): Cypress.Chainable> => cy.get(`[data-testid="${id}"]`, options), 71 | ); 72 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // When a command from ./commands is ready to use, import with `import './commands'` syntax 17 | import './commands'; 18 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "sourceMap": false, 6 | "types": ["cypress"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/3rdpartylicenses.txt: -------------------------------------------------------------------------------- 1 | @angular-devkit/build-angular 2 | MIT 3 | The MIT License 4 | 5 | Copyright (c) 2017 Google, Inc. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | 26 | @angular/common 27 | MIT 28 | 29 | @angular/core 30 | MIT 31 | 32 | @angular/platform-browser 33 | MIT 34 | 35 | @ngrx/effects 36 | MIT 37 | 38 | @ngrx/store 39 | MIT 40 | 41 | @ngrx/store-devtools 42 | MIT 43 | 44 | core-js 45 | MIT 46 | Copyright (c) 2014-2020 Denis Pushkarev 47 | 48 | Permission is hereby granted, free of charge, to any person obtaining a copy 49 | of this software and associated documentation files (the "Software"), to deal 50 | in the Software without restriction, including without limitation the rights 51 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 52 | copies of the Software, and to permit persons to whom the Software is 53 | furnished to do so, subject to the following conditions: 54 | 55 | The above copyright notice and this permission notice shall be included in 56 | all copies or substantial portions of the Software. 57 | 58 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 59 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 60 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 61 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 62 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 63 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 64 | THE SOFTWARE. 65 | 66 | 67 | regenerator-runtime 68 | MIT 69 | MIT License 70 | 71 | Copyright (c) 2014-present, Facebook, Inc. 72 | 73 | Permission is hereby granted, free of charge, to any person obtaining a copy 74 | of this software and associated documentation files (the "Software"), to deal 75 | in the Software without restriction, including without limitation the rights 76 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 77 | copies of the Software, and to permit persons to whom the Software is 78 | furnished to do so, subject to the following conditions: 79 | 80 | The above copyright notice and this permission notice shall be included in all 81 | copies or substantial portions of the Software. 82 | 83 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 84 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 85 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 86 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 87 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 88 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 89 | SOFTWARE. 90 | 91 | 92 | rxjs 93 | Apache-2.0 94 | Apache License 95 | Version 2.0, January 2004 96 | http://www.apache.org/licenses/ 97 | 98 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 99 | 100 | 1. Definitions. 101 | 102 | "License" shall mean the terms and conditions for use, reproduction, 103 | and distribution as defined by Sections 1 through 9 of this document. 104 | 105 | "Licensor" shall mean the copyright owner or entity authorized by 106 | the copyright owner that is granting the License. 107 | 108 | "Legal Entity" shall mean the union of the acting entity and all 109 | other entities that control, are controlled by, or are under common 110 | control with that entity. For the purposes of this definition, 111 | "control" means (i) the power, direct or indirect, to cause the 112 | direction or management of such entity, whether by contract or 113 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 114 | outstanding shares, or (iii) beneficial ownership of such entity. 115 | 116 | "You" (or "Your") shall mean an individual or Legal Entity 117 | exercising permissions granted by this License. 118 | 119 | "Source" form shall mean the preferred form for making modifications, 120 | including but not limited to software source code, documentation 121 | source, and configuration files. 122 | 123 | "Object" form shall mean any form resulting from mechanical 124 | transformation or translation of a Source form, including but 125 | not limited to compiled object code, generated documentation, 126 | and conversions to other media types. 127 | 128 | "Work" shall mean the work of authorship, whether in Source or 129 | Object form, made available under the License, as indicated by a 130 | copyright notice that is included in or attached to the work 131 | (an example is provided in the Appendix below). 132 | 133 | "Derivative Works" shall mean any work, whether in Source or Object 134 | form, that is based on (or derived from) the Work and for which the 135 | editorial revisions, annotations, elaborations, or other modifications 136 | represent, as a whole, an original work of authorship. For the purposes 137 | of this License, Derivative Works shall not include works that remain 138 | separable from, or merely link (or bind by name) to the interfaces of, 139 | the Work and Derivative Works thereof. 140 | 141 | "Contribution" shall mean any work of authorship, including 142 | the original version of the Work and any modifications or additions 143 | to that Work or Derivative Works thereof, that is intentionally 144 | submitted to Licensor for inclusion in the Work by the copyright owner 145 | or by an individual or Legal Entity authorized to submit on behalf of 146 | the copyright owner. For the purposes of this definition, "submitted" 147 | means any form of electronic, verbal, or written communication sent 148 | to the Licensor or its representatives, including but not limited to 149 | communication on electronic mailing lists, source code control systems, 150 | and issue tracking systems that are managed by, or on behalf of, the 151 | Licensor for the purpose of discussing and improving the Work, but 152 | excluding communication that is conspicuously marked or otherwise 153 | designated in writing by the copyright owner as "Not a Contribution." 154 | 155 | "Contributor" shall mean Licensor and any individual or Legal Entity 156 | on behalf of whom a Contribution has been received by Licensor and 157 | subsequently incorporated within the Work. 158 | 159 | 2. Grant of Copyright License. Subject to the terms and conditions of 160 | this License, each Contributor hereby grants to You a perpetual, 161 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 162 | copyright license to reproduce, prepare Derivative Works of, 163 | publicly display, publicly perform, sublicense, and distribute the 164 | Work and such Derivative Works in Source or Object form. 165 | 166 | 3. Grant of Patent License. Subject to the terms and conditions of 167 | this License, each Contributor hereby grants to You a perpetual, 168 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 169 | (except as stated in this section) patent license to make, have made, 170 | use, offer to sell, sell, import, and otherwise transfer the Work, 171 | where such license applies only to those patent claims licensable 172 | by such Contributor that are necessarily infringed by their 173 | Contribution(s) alone or by combination of their Contribution(s) 174 | with the Work to which such Contribution(s) was submitted. If You 175 | institute patent litigation against any entity (including a 176 | cross-claim or counterclaim in a lawsuit) alleging that the Work 177 | or a Contribution incorporated within the Work constitutes direct 178 | or contributory patent infringement, then any patent licenses 179 | granted to You under this License for that Work shall terminate 180 | as of the date such litigation is filed. 181 | 182 | 4. Redistribution. You may reproduce and distribute copies of the 183 | Work or Derivative Works thereof in any medium, with or without 184 | modifications, and in Source or Object form, provided that You 185 | meet the following conditions: 186 | 187 | (a) You must give any other recipients of the Work or 188 | Derivative Works a copy of this License; and 189 | 190 | (b) You must cause any modified files to carry prominent notices 191 | stating that You changed the files; and 192 | 193 | (c) You must retain, in the Source form of any Derivative Works 194 | that You distribute, all copyright, patent, trademark, and 195 | attribution notices from the Source form of the Work, 196 | excluding those notices that do not pertain to any part of 197 | the Derivative Works; and 198 | 199 | (d) If the Work includes a "NOTICE" text file as part of its 200 | distribution, then any Derivative Works that You distribute must 201 | include a readable copy of the attribution notices contained 202 | within such NOTICE file, excluding those notices that do not 203 | pertain to any part of the Derivative Works, in at least one 204 | of the following places: within a NOTICE text file distributed 205 | as part of the Derivative Works; within the Source form or 206 | documentation, if provided along with the Derivative Works; or, 207 | within a display generated by the Derivative Works, if and 208 | wherever such third-party notices normally appear. The contents 209 | of the NOTICE file are for informational purposes only and 210 | do not modify the License. You may add Your own attribution 211 | notices within Derivative Works that You distribute, alongside 212 | or as an addendum to the NOTICE text from the Work, provided 213 | that such additional attribution notices cannot be construed 214 | as modifying the License. 215 | 216 | You may add Your own copyright statement to Your modifications and 217 | may provide additional or different license terms and conditions 218 | for use, reproduction, or distribution of Your modifications, or 219 | for any such Derivative Works as a whole, provided Your use, 220 | reproduction, and distribution of the Work otherwise complies with 221 | the conditions stated in this License. 222 | 223 | 5. Submission of Contributions. Unless You explicitly state otherwise, 224 | any Contribution intentionally submitted for inclusion in the Work 225 | by You to the Licensor shall be under the terms and conditions of 226 | this License, without any additional terms or conditions. 227 | Notwithstanding the above, nothing herein shall supersede or modify 228 | the terms of any separate license agreement you may have executed 229 | with Licensor regarding such Contributions. 230 | 231 | 6. Trademarks. This License does not grant permission to use the trade 232 | names, trademarks, service marks, or product names of the Licensor, 233 | except as required for reasonable and customary use in describing the 234 | origin of the Work and reproducing the content of the NOTICE file. 235 | 236 | 7. Disclaimer of Warranty. Unless required by applicable law or 237 | agreed to in writing, Licensor provides the Work (and each 238 | Contributor provides its Contributions) on an "AS IS" BASIS, 239 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 240 | implied, including, without limitation, any warranties or conditions 241 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 242 | PARTICULAR PURPOSE. You are solely responsible for determining the 243 | appropriateness of using or redistributing the Work and assume any 244 | risks associated with Your exercise of permissions under this License. 245 | 246 | 8. Limitation of Liability. In no event and under no legal theory, 247 | whether in tort (including negligence), contract, or otherwise, 248 | unless required by applicable law (such as deliberate and grossly 249 | negligent acts) or agreed to in writing, shall any Contributor be 250 | liable to You for damages, including any direct, indirect, special, 251 | incidental, or consequential damages of any character arising as a 252 | result of this License or out of the use or inability to use the 253 | Work (including but not limited to damages for loss of goodwill, 254 | work stoppage, computer failure or malfunction, or any and all 255 | other commercial damages or losses), even if such Contributor 256 | has been advised of the possibility of such damages. 257 | 258 | 9. Accepting Warranty or Additional Liability. While redistributing 259 | the Work or Derivative Works thereof, You may choose to offer, 260 | and charge a fee for, acceptance of support, warranty, indemnity, 261 | or other liability obligations and/or rights consistent with this 262 | License. However, in accepting such obligations, You may act only 263 | on Your own behalf and on Your sole responsibility, not on behalf 264 | of any other Contributor, and only if You agree to indemnify, 265 | defend, and hold each Contributor harmless for any liability 266 | incurred by, or claims asserted against, such Contributor by reason 267 | of your accepting any such warranty or additional liability. 268 | 269 | END OF TERMS AND CONDITIONS 270 | 271 | APPENDIX: How to apply the Apache License to your work. 272 | 273 | To apply the Apache License to your work, attach the following 274 | boilerplate notice, with the fields enclosed by brackets "[]" 275 | replaced with your own identifying information. (Don't include 276 | the brackets!) The text should be enclosed in the appropriate 277 | comment syntax for the file format. We also recommend that a 278 | file or class name and description of purpose be included on the 279 | same "printed page" as the copyright notice for easier 280 | identification within third-party archives. 281 | 282 | Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors 283 | 284 | Licensed under the Apache License, Version 2.0 (the "License"); 285 | you may not use this file except in compliance with the License. 286 | You may obtain a copy of the License at 287 | 288 | http://www.apache.org/licenses/LICENSE-2.0 289 | 290 | Unless required by applicable law or agreed to in writing, software 291 | distributed under the License is distributed on an "AS IS" BASIS, 292 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 293 | See the License for the specific language governing permissions and 294 | limitations under the License. 295 | 296 | 297 | 298 | zone.js 299 | MIT 300 | The MIT License 301 | 302 | Copyright (c) 2010-2020 Google LLC. http://angular.io/license 303 | 304 | Permission is hereby granted, free of charge, to any person obtaining a copy 305 | of this software and associated documentation files (the "Software"), to deal 306 | in the Software without restriction, including without limitation the rights 307 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 308 | copies of the Software, and to permit persons to whom the Software is 309 | furnished to do so, subject to the following conditions: 310 | 311 | The above copyright notice and this permission notice shall be included in 312 | all copies or substantial portions of the Software. 313 | 314 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 315 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 316 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 317 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 318 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 319 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 320 | THE SOFTWARE. 321 | -------------------------------------------------------------------------------- /docs/assets/counter.json: -------------------------------------------------------------------------------- 1 | { "description": "placeholder file for testing counter effects" } 2 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9elements/angular-workshop/a4cac55e0be4796879b7030a689add058c8b150b/docs/favicon.ico -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular Workshop: Counters 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/polyfills-es2015.f2c5ab749249a66bdf26.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[2],{2:function(e,t,n){e.exports=n("hN/g")},"hN/g":function(e,t,n){"use strict";n.r(t),n("pDpN")},pDpN:function(e,t,n){var o,r;void 0===(r="function"==typeof(o=function(){"use strict";!function(e){const t=e.performance;function n(e){t&&t.mark&&t.mark(e)}function o(e,n){t&&t.measure&&t.measure(e,n)}n("Zone");const r=e.__Zone_symbol_prefix||"__zone_symbol__";function s(e){return r+e}const a=!0===e[s("forceDuplicateZoneCheck")];if(e.Zone){if(a||"function"!=typeof e.Zone.__symbol__)throw new Error("Zone already loaded.");return e.Zone}class i{constructor(e,t){this._parent=e,this._name=t?t.name||"unnamed":"",this._properties=t&&t.properties||{},this._zoneDelegate=new l(this,this._parent&&this._parent._zoneDelegate,t)}static assertZonePatched(){if(e.Promise!==C.ZoneAwarePromise)throw new Error("Zone.js has detected that ZoneAwarePromise `(window|global).Promise` has been overwritten.\nMost likely cause is that a Promise polyfill has been loaded after Zone.js (Polyfilling Promise api is not necessary when zone.js is loaded. If you must load one, do so before loading zone.js.)")}static get root(){let e=i.current;for(;e.parent;)e=e.parent;return e}static get current(){return z.zone}static get currentTask(){return j}static __load_patch(t,r){if(C.hasOwnProperty(t)){if(a)throw Error("Already loaded patch: "+t)}else if(!e["__Zone_disable_"+t]){const s="Zone:"+t;n(s),C[t]=r(e,i,O),o(s,s)}}get parent(){return this._parent}get name(){return this._name}get(e){const t=this.getZoneWith(e);if(t)return t._properties[e]}getZoneWith(e){let t=this;for(;t;){if(t._properties.hasOwnProperty(e))return t;t=t._parent}return null}fork(e){if(!e)throw new Error("ZoneSpec required!");return this._zoneDelegate.fork(this,e)}wrap(e,t){if("function"!=typeof e)throw new Error("Expecting function got: "+e);const n=this._zoneDelegate.intercept(this,e,t),o=this;return function(){return o.runGuarded(n,this,arguments,t)}}run(e,t,n,o){z={parent:z,zone:this};try{return this._zoneDelegate.invoke(this,e,t,n,o)}finally{z=z.parent}}runGuarded(e,t=null,n,o){z={parent:z,zone:this};try{try{return this._zoneDelegate.invoke(this,e,t,n,o)}catch(r){if(this._zoneDelegate.handleError(this,r))throw r}}finally{z=z.parent}}runTask(e,t,n){if(e.zone!=this)throw new Error("A task can only be run in the zone of creation! (Creation: "+(e.zone||y).name+"; Execution: "+this.name+")");if(e.state===v&&(e.type===P||e.type===D))return;const o=e.state!=E;o&&e._transitionTo(E,T),e.runCount++;const r=j;j=e,z={parent:z,zone:this};try{e.type==D&&e.data&&!e.data.isPeriodic&&(e.cancelFn=void 0);try{return this._zoneDelegate.invokeTask(this,e,t,n)}catch(s){if(this._zoneDelegate.handleError(this,s))throw s}}finally{e.state!==v&&e.state!==Z&&(e.type==P||e.data&&e.data.isPeriodic?o&&e._transitionTo(T,E):(e.runCount=0,this._updateTaskCount(e,-1),o&&e._transitionTo(v,E,v))),z=z.parent,j=r}}scheduleTask(e){if(e.zone&&e.zone!==this){let t=this;for(;t;){if(t===e.zone)throw Error(`can not reschedule task to ${this.name} which is descendants of the original zone ${e.zone.name}`);t=t.parent}}e._transitionTo(b,v);const t=[];e._zoneDelegates=t,e._zone=this;try{e=this._zoneDelegate.scheduleTask(this,e)}catch(n){throw e._transitionTo(Z,b,v),this._zoneDelegate.handleError(this,n),n}return e._zoneDelegates===t&&this._updateTaskCount(e,1),e.state==b&&e._transitionTo(T,b),e}scheduleMicroTask(e,t,n,o){return this.scheduleTask(new u(S,e,t,n,o,void 0))}scheduleMacroTask(e,t,n,o,r){return this.scheduleTask(new u(D,e,t,n,o,r))}scheduleEventTask(e,t,n,o,r){return this.scheduleTask(new u(P,e,t,n,o,r))}cancelTask(e){if(e.zone!=this)throw new Error("A task can only be cancelled in the zone of creation! (Creation: "+(e.zone||y).name+"; Execution: "+this.name+")");e._transitionTo(w,T,E);try{this._zoneDelegate.cancelTask(this,e)}catch(t){throw e._transitionTo(Z,w),this._zoneDelegate.handleError(this,t),t}return this._updateTaskCount(e,-1),e._transitionTo(v,w),e.runCount=0,e}_updateTaskCount(e,t){const n=e._zoneDelegates;-1==t&&(e._zoneDelegates=null);for(let o=0;oe.hasTask(n,o),onScheduleTask:(e,t,n,o)=>e.scheduleTask(n,o),onInvokeTask:(e,t,n,o,r,s)=>e.invokeTask(n,o,r,s),onCancelTask:(e,t,n,o)=>e.cancelTask(n,o)};class l{constructor(e,t,n){this._taskCounts={microTask:0,macroTask:0,eventTask:0},this.zone=e,this._parentDelegate=t,this._forkZS=n&&(n&&n.onFork?n:t._forkZS),this._forkDlgt=n&&(n.onFork?t:t._forkDlgt),this._forkCurrZone=n&&(n.onFork?this.zone:t._forkCurrZone),this._interceptZS=n&&(n.onIntercept?n:t._interceptZS),this._interceptDlgt=n&&(n.onIntercept?t:t._interceptDlgt),this._interceptCurrZone=n&&(n.onIntercept?this.zone:t._interceptCurrZone),this._invokeZS=n&&(n.onInvoke?n:t._invokeZS),this._invokeDlgt=n&&(n.onInvoke?t:t._invokeDlgt),this._invokeCurrZone=n&&(n.onInvoke?this.zone:t._invokeCurrZone),this._handleErrorZS=n&&(n.onHandleError?n:t._handleErrorZS),this._handleErrorDlgt=n&&(n.onHandleError?t:t._handleErrorDlgt),this._handleErrorCurrZone=n&&(n.onHandleError?this.zone:t._handleErrorCurrZone),this._scheduleTaskZS=n&&(n.onScheduleTask?n:t._scheduleTaskZS),this._scheduleTaskDlgt=n&&(n.onScheduleTask?t:t._scheduleTaskDlgt),this._scheduleTaskCurrZone=n&&(n.onScheduleTask?this.zone:t._scheduleTaskCurrZone),this._invokeTaskZS=n&&(n.onInvokeTask?n:t._invokeTaskZS),this._invokeTaskDlgt=n&&(n.onInvokeTask?t:t._invokeTaskDlgt),this._invokeTaskCurrZone=n&&(n.onInvokeTask?this.zone:t._invokeTaskCurrZone),this._cancelTaskZS=n&&(n.onCancelTask?n:t._cancelTaskZS),this._cancelTaskDlgt=n&&(n.onCancelTask?t:t._cancelTaskDlgt),this._cancelTaskCurrZone=n&&(n.onCancelTask?this.zone:t._cancelTaskCurrZone),this._hasTaskZS=null,this._hasTaskDlgt=null,this._hasTaskDlgtOwner=null,this._hasTaskCurrZone=null;const o=n&&n.onHasTask;(o||t&&t._hasTaskZS)&&(this._hasTaskZS=o?n:c,this._hasTaskDlgt=t,this._hasTaskDlgtOwner=this,this._hasTaskCurrZone=e,n.onScheduleTask||(this._scheduleTaskZS=c,this._scheduleTaskDlgt=t,this._scheduleTaskCurrZone=this.zone),n.onInvokeTask||(this._invokeTaskZS=c,this._invokeTaskDlgt=t,this._invokeTaskCurrZone=this.zone),n.onCancelTask||(this._cancelTaskZS=c,this._cancelTaskDlgt=t,this._cancelTaskCurrZone=this.zone))}fork(e,t){return this._forkZS?this._forkZS.onFork(this._forkDlgt,this.zone,e,t):new i(e,t)}intercept(e,t,n){return this._interceptZS?this._interceptZS.onIntercept(this._interceptDlgt,this._interceptCurrZone,e,t,n):t}invoke(e,t,n,o,r){return this._invokeZS?this._invokeZS.onInvoke(this._invokeDlgt,this._invokeCurrZone,e,t,n,o,r):t.apply(n,o)}handleError(e,t){return!this._handleErrorZS||this._handleErrorZS.onHandleError(this._handleErrorDlgt,this._handleErrorCurrZone,e,t)}scheduleTask(e,t){let n=t;if(this._scheduleTaskZS)this._hasTaskZS&&n._zoneDelegates.push(this._hasTaskDlgtOwner),n=this._scheduleTaskZS.onScheduleTask(this._scheduleTaskDlgt,this._scheduleTaskCurrZone,e,t),n||(n=t);else if(t.scheduleFn)t.scheduleFn(t);else{if(t.type!=S)throw new Error("Task is missing scheduleFn.");k(t)}return n}invokeTask(e,t,n,o){return this._invokeTaskZS?this._invokeTaskZS.onInvokeTask(this._invokeTaskDlgt,this._invokeTaskCurrZone,e,t,n,o):t.callback.apply(n,o)}cancelTask(e,t){let n;if(this._cancelTaskZS)n=this._cancelTaskZS.onCancelTask(this._cancelTaskDlgt,this._cancelTaskCurrZone,e,t);else{if(!t.cancelFn)throw Error("Task is not cancelable");n=t.cancelFn(t)}return n}hasTask(e,t){try{this._hasTaskZS&&this._hasTaskZS.onHasTask(this._hasTaskDlgt,this._hasTaskCurrZone,e,t)}catch(n){this.handleError(e,n)}}_updateTaskCount(e,t){const n=this._taskCounts,o=n[e],r=n[e]=o+t;if(r<0)throw new Error("More tasks executed then were scheduled.");0!=o&&0!=r||this.hasTask(this.zone,{microTask:n.microTask>0,macroTask:n.macroTask>0,eventTask:n.eventTask>0,change:e})}}class u{constructor(t,n,o,r,s,a){if(this._zone=null,this.runCount=0,this._zoneDelegates=null,this._state="notScheduled",this.type=t,this.source=n,this.data=r,this.scheduleFn=s,this.cancelFn=a,!o)throw new Error("callback is not defined");this.callback=o;const i=this;this.invoke=t===P&&r&&r.useG?u.invokeTask:function(){return u.invokeTask.call(e,i,this,arguments)}}static invokeTask(e,t,n){e||(e=this),I++;try{return e.runCount++,e.zone.runTask(e,t,n)}finally{1==I&&m(),I--}}get zone(){return this._zone}get state(){return this._state}cancelScheduleRequest(){this._transitionTo(v,b)}_transitionTo(e,t,n){if(this._state!==t&&this._state!==n)throw new Error(`${this.type} '${this.source}': can not transition to '${e}', expecting state '${t}'${n?" or '"+n+"'":""}, was '${this._state}'.`);this._state=e,e==v&&(this._zoneDelegates=null)}toString(){return this.data&&void 0!==this.data.handleId?this.data.handleId.toString():Object.prototype.toString.call(this)}toJSON(){return{type:this.type,state:this.state,source:this.source,zone:this.zone.name,runCount:this.runCount}}}const h=s("setTimeout"),p=s("Promise"),f=s("then");let d,g=[],_=!1;function k(t){if(0===I&&0===g.length)if(d||e[p]&&(d=e[p].resolve(0)),d){let e=d[f];e||(e=d.then),e.call(d,m)}else e[h](m,0);t&&g.push(t)}function m(){if(!_){for(_=!0;g.length;){const t=g;g=[];for(let n=0;nz,onUnhandledError:N,microtaskDrainDone:N,scheduleMicroTask:k,showUncaughtError:()=>!i[s("ignoreConsoleErrorUncaughtError")],patchEventTarget:()=>[],patchOnProperties:N,patchMethod:()=>N,bindArguments:()=>[],patchThen:()=>N,patchMacroTask:()=>N,setNativePromise:e=>{e&&"function"==typeof e.resolve&&(d=e.resolve(0))},patchEventPrototype:()=>N,isIEOrEdge:()=>!1,getGlobalObjects:()=>{},ObjectDefineProperty:()=>N,ObjectGetOwnPropertyDescriptor:()=>{},ObjectCreate:()=>{},ArraySlice:()=>[],patchClass:()=>N,wrapWithCurrentZone:()=>N,filterProperties:()=>[],attachOriginToPatched:()=>N,_redefineProperty:()=>N,patchCallbacks:()=>N};let z={parent:null,zone:new i(null,null)},j=null,I=0;function N(){}o("Zone","Zone"),e.Zone=i}("undefined"!=typeof window&&window||"undefined"!=typeof self&&self||global),Zone.__load_patch("ZoneAwarePromise",(e,t,n)=>{const o=Object.getOwnPropertyDescriptor,r=Object.defineProperty,s=n.symbol,a=[],i=!0===e[s("DISABLE_WRAPPING_UNCAUGHT_PROMISE_REJECTION")],c=s("Promise"),l=s("then");n.onUnhandledError=e=>{if(n.showUncaughtError()){const t=e&&e.rejection;t?console.error("Unhandled Promise rejection:",t instanceof Error?t.message:t,"; Zone:",e.zone.name,"; Task:",e.task&&e.task.source,"; Value:",t,t instanceof Error?t.stack:void 0):console.error(e)}},n.microtaskDrainDone=()=>{for(;a.length;){const t=a.shift();try{t.zone.runGuarded(()=>{throw t})}catch(e){h(e)}}};const u=s("unhandledPromiseRejectionHandler");function h(e){n.onUnhandledError(e);try{const n=t[u];"function"==typeof n&&n.call(this,e)}catch(o){}}function p(e){return e&&e.then}function f(e){return e}function d(e){return D.reject(e)}const g=s("state"),_=s("value"),k=s("finally"),m=s("parentPromiseValue"),y=s("parentPromiseState");function v(e,t){return n=>{try{T(e,t,n)}catch(o){T(e,!1,o)}}}const b=s("currentTaskTrace");function T(e,o,s){const c=function(){let e=!1;return function(t){return function(){e||(e=!0,t.apply(null,arguments))}}}();if(e===s)throw new TypeError("Promise resolved with itself");if(null===e[g]){let h=null;try{"object"!=typeof s&&"function"!=typeof s||(h=s&&s.then)}catch(u){return c(()=>{T(e,!1,u)})(),e}if(!1!==o&&s instanceof D&&s.hasOwnProperty(g)&&s.hasOwnProperty(_)&&null!==s[g])w(s),T(e,s[g],s[_]);else if(!1!==o&&"function"==typeof h)try{h.call(s,c(v(e,o)),c(v(e,!1)))}catch(u){c(()=>{T(e,!1,u)})()}else{e[g]=o;const c=e[_];if(e[_]=s,e[k]===k&&!0===o&&(e[g]=e[y],e[_]=e[m]),!1===o&&s instanceof Error){const e=t.currentTask&&t.currentTask.data&&t.currentTask.data.__creationTrace__;e&&r(s,b,{configurable:!0,enumerable:!1,writable:!0,value:e})}for(let t=0;t{try{const o=e[_],r=!!n&&k===n[k];r&&(n[m]=o,n[y]=s);const i=t.run(a,void 0,r&&a!==d&&a!==f?[]:[o]);T(n,!0,i)}catch(o){T(n,!1,o)}},n)}const S=function(){};class D{static toString(){return"function ZoneAwarePromise() { [native code] }"}static resolve(e){return T(new this(null),!0,e)}static reject(e){return T(new this(null),!1,e)}static race(e){let t,n,o=new this((e,o)=>{t=e,n=o});function r(e){t(e)}function s(e){n(e)}for(let a of e)p(a)||(a=this.resolve(a)),a.then(r,s);return o}static all(e){return D.allWithCallback(e)}static allSettled(e){return(this&&this.prototype instanceof D?this:D).allWithCallback(e,{thenCallback:e=>({status:"fulfilled",value:e}),errorCallback:e=>({status:"rejected",reason:e})})}static allWithCallback(e,t){let n,o,r=new this((e,t)=>{n=e,o=t}),s=2,a=0;const i=[];for(let l of e){p(l)||(l=this.resolve(l));const e=a;try{l.then(o=>{i[e]=t?t.thenCallback(o):o,s--,0===s&&n(i)},r=>{t?(i[e]=t.errorCallback(r),s--,0===s&&n(i)):o(r)})}catch(c){o(c)}s++,a++}return s-=2,0===s&&n(i),r}constructor(e){const t=this;if(!(t instanceof D))throw new Error("Must be an instanceof Promise.");t[g]=null,t[_]=[];try{e&&e(v(t,!0),v(t,!1))}catch(n){T(t,!1,n)}}get[Symbol.toStringTag](){return"Promise"}get[Symbol.species](){return D}then(e,n){let o=this.constructor[Symbol.species];o&&"function"==typeof o||(o=this.constructor||D);const r=new o(S),s=t.current;return null==this[g]?this[_].push(s,r,e,n):Z(this,s,r,e,n),r}catch(e){return this.then(null,e)}finally(e){let n=this.constructor[Symbol.species];n&&"function"==typeof n||(n=D);const o=new n(S);o[k]=k;const r=t.current;return null==this[g]?this[_].push(r,o,e,e):Z(this,r,o,e,e),o}}D.resolve=D.resolve,D.reject=D.reject,D.race=D.race,D.all=D.all;const P=e[c]=e.Promise,C=t.__symbol__("ZoneAwarePromise");let O=o(e,"Promise");O&&!O.configurable||(O&&delete O.writable,O&&delete O.value,O||(O={configurable:!0,enumerable:!0}),O.get=function(){return e[C]?e[C]:e[c]},O.set=function(t){t===D?e[C]=t:(e[c]=t,t.prototype[l]||j(t),n.setNativePromise(t))},r(e,"Promise",O)),e.Promise=D;const z=s("thenPatched");function j(e){const t=e.prototype,n=o(t,"then");if(n&&(!1===n.writable||!n.configurable))return;const r=t.then;t[l]=r,e.prototype.then=function(e,t){return new D((e,t)=>{r.call(this,e,t)}).then(e,t)},e[z]=!0}if(n.patchThen=j,P){j(P);const t=e.fetch;"function"==typeof t&&(e[n.symbol("fetch")]=t,e.fetch=(I=t,function(){let e=I.apply(this,arguments);if(e instanceof D)return e;let t=e.constructor;return t[z]||j(t),e}))}var I;return Promise[t.__symbol__("uncaughtPromiseErrors")]=a,D});const e=Object.getOwnPropertyDescriptor,t=Object.defineProperty,n=Object.getPrototypeOf,o=Object.create,r=Array.prototype.slice,s=Zone.__symbol__("addEventListener"),a=Zone.__symbol__("removeEventListener"),i=Zone.__symbol__("");function c(e,t){return Zone.current.wrap(e,t)}function l(e,t,n,o,r){return Zone.current.scheduleMacroTask(e,t,n,o,r)}const u=Zone.__symbol__,h="undefined"!=typeof window,p=h?window:void 0,f=h&&p||"object"==typeof self&&self||global,d=[null];function g(e,t){for(let n=e.length-1;n>=0;n--)"function"==typeof e[n]&&(e[n]=c(e[n],t+"_"+n));return e}function _(e){return!e||!1!==e.writable&&!("function"==typeof e.get&&void 0===e.set)}const k="undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope,m=!("nw"in f)&&void 0!==f.process&&"[object process]"==={}.toString.call(f.process),y=!m&&!k&&!(!h||!p.HTMLElement),v=void 0!==f.process&&"[object process]"==={}.toString.call(f.process)&&!k&&!(!h||!p.HTMLElement),b={},T=function(e){if(!(e=e||f.event))return;let t=b[e.type];t||(t=b[e.type]=u("ON_PROPERTY"+e.type));const n=this||e.target||f,o=n[t];let r;if(y&&n===p&&"error"===e.type){const t=e;r=o&&o.call(this,t.message,t.filename,t.lineno,t.colno,t.error),!0===r&&e.preventDefault()}else r=o&&o.apply(this,arguments),null==r||r||e.preventDefault();return r};function E(n,o,r){let s=e(n,o);if(!s&&r&&e(r,o)&&(s={enumerable:!0,configurable:!0}),!s||!s.configurable)return;const a=u("on"+o+"patched");if(n.hasOwnProperty(a)&&n[a])return;delete s.writable,delete s.value;const i=s.get,c=s.set,l=o.substr(2);let h=b[l];h||(h=b[l]=u("ON_PROPERTY"+l)),s.set=function(e){let t=this;t||n!==f||(t=f),t&&(t[h]&&t.removeEventListener(l,T),c&&c.apply(t,d),"function"==typeof e?(t[h]=e,t.addEventListener(l,T,!1)):t[h]=null)},s.get=function(){let e=this;if(e||n!==f||(e=f),!e)return null;const t=e[h];if(t)return t;if(i){let t=i&&i.call(this);if(t)return s.set.call(this,t),"function"==typeof e.removeAttribute&&e.removeAttribute(o),t}return null},t(n,o,s),n[a]=!0}function w(e,t,n){if(t)for(let o=0;ofunction(t,o){const s=n(t,o);return s.cbIdx>=0&&"function"==typeof o[s.cbIdx]?l(s.name,o[s.cbIdx],s,r):e.apply(t,o)})}function C(e,t){e[u("OriginalDelegate")]=t}let O=!1,z=!1;function j(){try{const e=p.navigator.userAgent;if(-1!==e.indexOf("MSIE ")||-1!==e.indexOf("Trident/"))return!0}catch(e){}return!1}function I(){if(O)return z;O=!0;try{const e=p.navigator.userAgent;-1===e.indexOf("MSIE ")&&-1===e.indexOf("Trident/")&&-1===e.indexOf("Edge/")||(z=!0)}catch(e){}return z}Zone.__load_patch("toString",e=>{const t=Function.prototype.toString,n=u("OriginalDelegate"),o=u("Promise"),r=u("Error"),s=function(){if("function"==typeof this){const s=this[n];if(s)return"function"==typeof s?t.call(s):Object.prototype.toString.call(s);if(this===Promise){const n=e[o];if(n)return t.call(n)}if(this===Error){const n=e[r];if(n)return t.call(n)}}return t.call(this)};s[n]=t,Function.prototype.toString=s;const a=Object.prototype.toString;Object.prototype.toString=function(){return this instanceof Promise?"[object Promise]":a.call(this)}});let N=!1;if("undefined"!=typeof window)try{const e=Object.defineProperty({},"passive",{get:function(){N=!0}});window.addEventListener("test",e,e),window.removeEventListener("test",e,e)}catch(ie){N=!1}const R={useG:!0},x={},L={},M=new RegExp("^"+i+"(\\w+)(true|false)$"),A=u("propagationStopped");function H(e,t){const n=(t?t(e):e)+"false",o=(t?t(e):e)+"true",r=i+n,s=i+o;x[e]={},x[e].false=r,x[e].true=s}function F(e,t,o){const r=o&&o.add||"addEventListener",s=o&&o.rm||"removeEventListener",a=o&&o.listeners||"eventListeners",c=o&&o.rmAll||"removeAllListeners",l=u(r),h="."+r+":",p=function(e,t,n){if(e.isRemoved)return;const o=e.callback;"object"==typeof o&&o.handleEvent&&(e.callback=e=>o.handleEvent(e),e.originalDelegate=o),e.invoke(e,t,[n]);const r=e.options;r&&"object"==typeof r&&r.once&&t[s].call(t,n.type,e.originalDelegate?e.originalDelegate:e.callback,r)},f=function(t){if(!(t=t||e.event))return;const n=this||t.target||e,o=n[x[t.type].false];if(o)if(1===o.length)p(o[0],n,t);else{const e=o.slice();for(let o=0;ofunction(t,n){t[A]=!0,e&&e.apply(t,n)})}function q(e,t,n,o,r){const s=Zone.__symbol__(o);if(t[s])return;const a=t[s]=t[o];t[o]=function(s,i,c){return i&&i.prototype&&r.forEach((function(t){const r=`${n}.${o}::`+t,s=i.prototype;if(s.hasOwnProperty(t)){const n=e.ObjectGetOwnPropertyDescriptor(s,t);n&&n.value?(n.value=e.wrapWithCurrentZone(n.value,r),e._redefineProperty(i.prototype,t,n)):s[t]&&(s[t]=e.wrapWithCurrentZone(s[t],r))}else s[t]&&(s[t]=e.wrapWithCurrentZone(s[t],r))})),a.call(t,s,i,c)},e.attachOriginToPatched(t[o],a)}const W=["absolutedeviceorientation","afterinput","afterprint","appinstalled","beforeinstallprompt","beforeprint","beforeunload","devicelight","devicemotion","deviceorientation","deviceorientationabsolute","deviceproximity","hashchange","languagechange","message","mozbeforepaint","offline","online","paint","pageshow","pagehide","popstate","rejectionhandled","storage","unhandledrejection","unload","userproximity","vrdisplayconnected","vrdisplaydisconnected","vrdisplaypresentchange"],U=["encrypted","waitingforkey","msneedkey","mozinterruptbegin","mozinterruptend"],V=["load"],$=["blur","error","focus","load","resize","scroll","messageerror"],X=["bounce","finish","start"],J=["loadstart","progress","abort","error","load","progress","timeout","loadend","readystatechange"],Y=["upgradeneeded","complete","abort","success","error","blocked","versionchange","close"],K=["close","error","open","message"],Q=["error","message"],ee=["abort","animationcancel","animationend","animationiteration","auxclick","beforeinput","blur","cancel","canplay","canplaythrough","change","compositionstart","compositionupdate","compositionend","cuechange","click","close","contextmenu","curechange","dblclick","drag","dragend","dragenter","dragexit","dragleave","dragover","drop","durationchange","emptied","ended","error","focus","focusin","focusout","gotpointercapture","input","invalid","keydown","keypress","keyup","load","loadstart","loadeddata","loadedmetadata","lostpointercapture","mousedown","mouseenter","mouseleave","mousemove","mouseout","mouseover","mouseup","mousewheel","orientationchange","pause","play","playing","pointercancel","pointerdown","pointerenter","pointerleave","pointerlockchange","mozpointerlockchange","webkitpointerlockerchange","pointerlockerror","mozpointerlockerror","webkitpointerlockerror","pointermove","pointout","pointerover","pointerup","progress","ratechange","reset","resize","scroll","seeked","seeking","select","selectionchange","selectstart","show","sort","stalled","submit","suspend","timeupdate","volumechange","touchcancel","touchmove","touchstart","touchend","transitioncancel","transitionend","waiting","wheel"].concat(["webglcontextrestored","webglcontextlost","webglcontextcreationerror"],["autocomplete","autocompleteerror"],["toggle"],["afterscriptexecute","beforescriptexecute","DOMContentLoaded","freeze","fullscreenchange","mozfullscreenchange","webkitfullscreenchange","msfullscreenchange","fullscreenerror","mozfullscreenerror","webkitfullscreenerror","msfullscreenerror","readystatechange","visibilitychange","resume"],W,["beforecopy","beforecut","beforepaste","copy","cut","paste","dragstart","loadend","animationstart","search","transitionrun","transitionstart","webkitanimationend","webkitanimationiteration","webkitanimationstart","webkittransitionend"],["activate","afterupdate","ariarequest","beforeactivate","beforedeactivate","beforeeditfocus","beforeupdate","cellchange","controlselect","dataavailable","datasetchanged","datasetcomplete","errorupdate","filterchange","layoutcomplete","losecapture","move","moveend","movestart","propertychange","resizeend","resizestart","rowenter","rowexit","rowsdelete","rowsinserted","command","compassneedscalibration","deactivate","help","mscontentzoom","msmanipulationstatechanged","msgesturechange","msgesturedoubletap","msgestureend","msgesturehold","msgesturestart","msgesturetap","msgotpointercapture","msinertiastart","mslostpointercapture","mspointercancel","mspointerdown","mspointerenter","mspointerhover","mspointerleave","mspointermove","mspointerout","mspointerover","mspointerup","pointerout","mssitemodejumplistitemremoved","msthumbnailclick","stop","storagecommit"]);function te(e,t,n){if(!n||0===n.length)return t;const o=n.filter(t=>t.target===e);if(!o||0===o.length)return t;const r=o[0].ignoreProperties;return t.filter(e=>-1===r.indexOf(e))}function ne(e,t,n,o){e&&w(e,te(e,t,n),o)}function oe(e,t){if(m&&!v)return;if(Zone[e.symbol("patchEvents")])return;const o="undefined"!=typeof WebSocket,r=t.__Zone_ignore_on_properties;if(y){const e=window,t=j?[{target:e,ignoreProperties:["error"]}]:[];ne(e,ee.concat(["messageerror"]),r?r.concat(t):r,n(e)),ne(Document.prototype,ee,r),void 0!==e.SVGElement&&ne(e.SVGElement.prototype,ee,r),ne(Element.prototype,ee,r),ne(HTMLElement.prototype,ee,r),ne(HTMLMediaElement.prototype,U,r),ne(HTMLFrameSetElement.prototype,W.concat($),r),ne(HTMLBodyElement.prototype,W.concat($),r),ne(HTMLFrameElement.prototype,V,r),ne(HTMLIFrameElement.prototype,V,r);const o=e.HTMLMarqueeElement;o&&ne(o.prototype,X,r);const s=e.Worker;s&&ne(s.prototype,Q,r)}const s=t.XMLHttpRequest;s&&ne(s.prototype,J,r);const a=t.XMLHttpRequestEventTarget;a&&ne(a&&a.prototype,J,r),"undefined"!=typeof IDBIndex&&(ne(IDBIndex.prototype,Y,r),ne(IDBRequest.prototype,Y,r),ne(IDBOpenDBRequest.prototype,Y,r),ne(IDBDatabase.prototype,Y,r),ne(IDBTransaction.prototype,Y,r),ne(IDBCursor.prototype,Y,r)),o&&ne(WebSocket.prototype,K,r)}Zone.__load_patch("util",(n,s,a)=>{a.patchOnProperties=w,a.patchMethod=D,a.bindArguments=g,a.patchMacroTask=P;const l=s.__symbol__("BLACK_LISTED_EVENTS"),u=s.__symbol__("UNPATCHED_EVENTS");n[u]&&(n[l]=n[u]),n[l]&&(s[l]=s[u]=n[l]),a.patchEventPrototype=B,a.patchEventTarget=F,a.isIEOrEdge=I,a.ObjectDefineProperty=t,a.ObjectGetOwnPropertyDescriptor=e,a.ObjectCreate=o,a.ArraySlice=r,a.patchClass=S,a.wrapWithCurrentZone=c,a.filterProperties=te,a.attachOriginToPatched=C,a._redefineProperty=Object.defineProperty,a.patchCallbacks=q,a.getGlobalObjects=()=>({globalSources:L,zoneSymbolEventNames:x,eventNames:ee,isBrowser:y,isMix:v,isNode:m,TRUE_STR:"true",FALSE_STR:"false",ZONE_SYMBOL_PREFIX:i,ADD_EVENT_LISTENER_STR:"addEventListener",REMOVE_EVENT_LISTENER_STR:"removeEventListener"})});const re=u("zoneTask");function se(e,t,n,o){let r=null,s=null;n+=o;const a={};function i(t){const n=t.data;return n.args[0]=function(){try{t.invoke.apply(this,arguments)}finally{t.data&&t.data.isPeriodic||("number"==typeof n.handleId?delete a[n.handleId]:n.handleId&&(n.handleId[re]=null))}},n.handleId=r.apply(e,n.args),t}function c(e){return s(e.data.handleId)}r=D(e,t+=o,n=>function(r,s){if("function"==typeof s[0]){const e=l(t,s[0],{isPeriodic:"Interval"===o,delay:"Timeout"===o||"Interval"===o?s[1]||0:void 0,args:s},i,c);if(!e)return e;const n=e.data.handleId;return"number"==typeof n?a[n]=e:n&&(n[re]=e),n&&n.ref&&n.unref&&"function"==typeof n.ref&&"function"==typeof n.unref&&(e.ref=n.ref.bind(n),e.unref=n.unref.bind(n)),"number"==typeof n||n?n:e}return n.apply(e,s)}),s=D(e,n,t=>function(n,o){const r=o[0];let s;"number"==typeof r?s=a[r]:(s=r&&r[re],s||(s=r)),s&&"string"==typeof s.type?"notScheduled"!==s.state&&(s.cancelFn&&s.data.isPeriodic||0===s.runCount)&&("number"==typeof r?delete a[r]:r&&(r[re]=null),s.zone.cancelTask(s)):t.apply(e,o)})}function ae(e,t){if(Zone[t.symbol("patchEventTarget")])return;const{eventNames:n,zoneSymbolEventNames:o,TRUE_STR:r,FALSE_STR:s,ZONE_SYMBOL_PREFIX:a}=t.getGlobalObjects();for(let c=0;c{const t=e[Zone.__symbol__("legacyPatch")];t&&t()}),Zone.__load_patch("timers",e=>{se(e,"set","clear","Timeout"),se(e,"set","clear","Interval"),se(e,"set","clear","Immediate")}),Zone.__load_patch("requestAnimationFrame",e=>{se(e,"request","cancel","AnimationFrame"),se(e,"mozRequest","mozCancel","AnimationFrame"),se(e,"webkitRequest","webkitCancel","AnimationFrame")}),Zone.__load_patch("blocking",(e,t)=>{const n=["alert","prompt","confirm"];for(let o=0;ofunction(o,s){return t.current.run(n,e,s,r)})}),Zone.__load_patch("EventTarget",(e,t,n)=>{!function(e,t){t.patchEventPrototype(e,t)}(e,n),ae(e,n);const o=e.XMLHttpRequestEventTarget;o&&o.prototype&&n.patchEventTarget(e,[o.prototype]),S("MutationObserver"),S("WebKitMutationObserver"),S("IntersectionObserver"),S("FileReader")}),Zone.__load_patch("on_property",(e,t,n)=>{oe(n,e)}),Zone.__load_patch("customElements",(e,t,n)=>{!function(e,t){const{isBrowser:n,isMix:o}=t.getGlobalObjects();(n||o)&&e.customElements&&"customElements"in e&&t.patchCallbacks(t,e.customElements,"customElements","define",["connectedCallback","disconnectedCallback","adoptedCallback","attributeChangedCallback"])}(e,n)}),Zone.__load_patch("XHR",(e,t)=>{!function(e){const p=e.XMLHttpRequest;if(!p)return;const f=p.prototype;let d=f[s],g=f[a];if(!d){const t=e.XMLHttpRequestEventTarget;if(t){const e=t.prototype;d=e[s],g=e[a]}}function _(e){const o=e.data,c=o.target;c[i]=!1,c[h]=!1;const l=c[r];d||(d=c[s],g=c[a]),l&&g.call(c,"readystatechange",l);const u=c[r]=()=>{if(c.readyState===c.DONE)if(!o.aborted&&c[i]&&"scheduled"===e.state){const n=c[t.__symbol__("loadfalse")];if(n&&n.length>0){const r=e.invoke;e.invoke=function(){const n=c[t.__symbol__("loadfalse")];for(let t=0;tfunction(e,t){return e[o]=0==t[2],e[c]=t[1],y.apply(e,t)}),v=u("fetchTaskAborting"),b=u("fetchTaskScheduling"),T=D(f,"send",()=>function(e,n){if(!0===t.current[b])return T.apply(e,n);if(e[o])return T.apply(e,n);{const t={target:e,url:e[c],isPeriodic:!1,args:n,aborted:!1},o=l("XMLHttpRequest.send",k,t,_,m);e&&!0===e[h]&&!t.aborted&&"scheduled"===o.state&&o.invoke()}}),E=D(f,"abort",()=>function(e,o){const r=e[n];if(r&&"string"==typeof r.type){if(null==r.cancelFn||r.data&&r.data.aborted)return;r.zone.cancelTask(r)}else if(!0===t.current[v])return E.apply(e,o)})}(e);const n=u("xhrTask"),o=u("xhrSync"),r=u("xhrListener"),i=u("xhrScheduled"),c=u("xhrURL"),h=u("xhrErrorBeforeScheduled")}),Zone.__load_patch("geolocation",t=>{t.navigator&&t.navigator.geolocation&&function(t,n){const o=t.constructor.name;for(let r=0;r{const t=function(){return e.apply(this,g(arguments,o+"."+s))};return C(t,e),t})(a)}}}(t.navigator.geolocation,["getCurrentPosition","watchPosition"])}),Zone.__load_patch("PromiseRejectionEvent",(e,t)=>{function n(t){return function(n){G(e,t).forEach(o=>{const r=e.PromiseRejectionEvent;if(r){const e=new r(t,{promise:n.promise,reason:n.rejection});o.invoke(e)}})}}e.PromiseRejectionEvent&&(t[u("unhandledPromiseRejectionHandler")]=n("unhandledrejection"),t[u("rejectionHandledHandler")]=n("rejectionhandled"))})})?o.call(t,n,t,e):o)||(e.exports=r)}},[[2,0]]]); -------------------------------------------------------------------------------- /docs/runtime-es2015.1eba213af0b233498d9d.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,l,f=r[0],i=r[1],p=r[2],c=0,s=[];c (https://molily.de)", 18 | "dependencies": { 19 | "@angular/animations": "^15.1.2", 20 | "@angular/common": "^15.1.2", 21 | "@angular/compiler": "^15.1.2", 22 | "@angular/core": "^15.1.2", 23 | "@angular/forms": "^15.1.2", 24 | "@angular/platform-browser": "^15.1.2", 25 | "@angular/platform-browser-dynamic": "^15.1.2", 26 | "@angular/router": "^15.1.2", 27 | "@ngrx/effects": "^15.2.1", 28 | "@ngrx/store": "^15.2.1", 29 | "@ngrx/store-devtools": "^15.2.1", 30 | "rxjs": "~7.8.0", 31 | "tslib": "^2.5.0", 32 | "zone.js": "~0.12.0" 33 | }, 34 | "devDependencies": { 35 | "@angular-devkit/build-angular": "^15.1.3", 36 | "@angular-eslint/builder": "^15.2.0", 37 | "@angular-eslint/eslint-plugin": "^15.2.0", 38 | "@angular-eslint/eslint-plugin-template": "^15.2.0", 39 | "@angular-eslint/schematics": "^15.2.0", 40 | "@angular-eslint/template-parser": "^15.2.0", 41 | "@angular/cli": "~15.1.3", 42 | "@angular/compiler-cli": "^15.1.2", 43 | "@cypress/schematic": "^2.5.0", 44 | "@ngneat/spectator": "^14.0.0", 45 | "@types/jasmine": "~4.3.1", 46 | "@typescript-eslint/eslint-plugin": "^5.49.0", 47 | "@typescript-eslint/parser": "^5.49.0", 48 | "angular-cli-ghpages": "^1.0.5", 49 | "cypress": "^12.4.1", 50 | "eslint": "^8.32.0", 51 | "jasmine-core": "~4.5.0", 52 | "jasmine-spec-reporter": "~7.0.0", 53 | "karma": "~6.4.1", 54 | "karma-chrome-launcher": "~3.1.1", 55 | "karma-coverage": "~2.2.0", 56 | "karma-firefox-launcher": "^2.1.2", 57 | "karma-jasmine": "~5.1.0", 58 | "karma-jasmine-html-reporter": "^2.0.0", 59 | "ng-mocks": "^14.6.0", 60 | "typescript": "~4.9.4" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/actions/counter.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | 3 | import { CounterState } from '../reducers/counter.reducer'; 4 | 5 | export const increment = createAction('[counter] Increment'); 6 | export const decrement = createAction('[counter] Decrement'); 7 | export const reset = createAction('[counter] Reset', props<{ count: CounterState }>()); 8 | 9 | export const saveSuccess = createAction('[counter] Save success'); 10 | export const saveError = createAction('[counter] Save error', props<{ error: Error }>()); 11 | 12 | export type CounterActions = ReturnType< 13 | | typeof increment 14 | | typeof decrement 15 | | typeof reset 16 | | typeof saveSuccess 17 | | typeof saveError 18 | >; 19 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import { findComponent } from './spec-helpers/element.spec-helper'; 6 | 7 | describe('AppComponent', () => { 8 | let fixture: ComponentFixture; 9 | let component: AppComponent; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | declarations: [AppComponent], 14 | schemas: [NO_ERRORS_SCHEMA], 15 | }).compileComponents(); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(AppComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('contains a router outlet', () => { 25 | const el = findComponent(fixture, 'router-outlet'); 26 | expect(el).toBeTruthy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/app.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { createComponentFactory, Spectator } from '@ngneat/spectator'; 2 | 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent with spectator', () => { 6 | let spectator: Spectator; 7 | 8 | const createComponent = createComponentFactory({ 9 | component: AppComponent, 10 | shallow: true, 11 | }); 12 | 13 | beforeEach(() => { 14 | spectator = createComponent(); 15 | }); 16 | 17 | it('contains a router outlet', () => { 18 | const el = spectator.query('router-outlet'); 19 | expect(el).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | }) 7 | export class AppComponent {} 8 | -------------------------------------------------------------------------------- /src/app/app.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { APP_BASE_HREF } from '@angular/common'; 2 | import { TestBed } from '@angular/core/testing'; 3 | 4 | import { AppModule } from './app.module'; 5 | 6 | describe('AppModule', () => { 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({ 9 | imports: [AppModule], 10 | providers: [{ provide: APP_BASE_HREF, useValue: '/' }], 11 | }); 12 | }); 13 | 14 | it('initializes', () => { 15 | const appModule = TestBed.inject(AppModule); 16 | expect(appModule).toBeTruthy(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | 5 | import { environment } from 'src/environments/environment'; 6 | 7 | import { EffectsModule } from '@ngrx/effects'; 8 | import { StoreModule } from '@ngrx/store'; 9 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 10 | 11 | import { AppComponent } from './app.component'; 12 | import { AppRoutingModule } from './app.routing.module'; 13 | import { CounterComponent } from './components/counter/counter.component'; 14 | import { HomeComponent } from './components/home/home.component'; 15 | import { 16 | NgRxCounterComponent, 17 | } from './components/ngrx-counter/ngrx-counter.component'; 18 | import { 19 | ServiceCounterComponent, 20 | } from './components/service-counter/service-counter.component'; 21 | import { 22 | StandaloneServiceCounterComponent, 23 | } from './components/standalone-service-counter/standalone-service-counter.component'; 24 | import { CounterEffects } from './effects/counter.effects'; 25 | import { reducers } from './reducers'; 26 | import { CounterApiService } from './services/counter-api.service'; 27 | import { CounterService } from './services/counter.service'; 28 | 29 | @NgModule({ 30 | declarations: [ 31 | AppComponent, 32 | HomeComponent, 33 | CounterComponent, 34 | ServiceCounterComponent, 35 | NgRxCounterComponent, 36 | ], 37 | imports: [ 38 | BrowserModule, 39 | HttpClientModule, 40 | 41 | AppRoutingModule, 42 | StandaloneServiceCounterComponent, 43 | 44 | // NgRx Store 45 | StoreModule.forRoot(reducers, { 46 | runtimeChecks: { 47 | strictStateImmutability: true, 48 | strictActionImmutability: true, 49 | }, 50 | }), 51 | 52 | // NgRx Effects 53 | EffectsModule.forRoot([CounterEffects]), 54 | 55 | // NgRx Store Dev Tools 56 | StoreDevtoolsModule.instrument({ 57 | maxAge: 25, // Retains last n states 58 | logOnly: environment.production, 59 | }), 60 | ], 61 | providers: [CounterService, CounterApiService], 62 | bootstrap: [AppComponent], 63 | }) 64 | export class AppModule {} 65 | -------------------------------------------------------------------------------- /src/app/app.routing.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APP_BASE_HREF, 3 | Location, 4 | } from '@angular/common'; 5 | import { Type } from '@angular/core'; 6 | import { 7 | ComponentFixture, 8 | TestBed, 9 | } from '@angular/core/testing'; 10 | import { By } from '@angular/platform-browser'; 11 | import { Router } from '@angular/router'; 12 | import { RouterTestingModule } from '@angular/router/testing'; 13 | 14 | import { provideMockStore } from '@ngrx/store/testing'; 15 | 16 | import { AppComponent } from './app.component'; 17 | import { 18 | AppRoutingModule, 19 | routes, 20 | } from './app.routing.module'; 21 | import { CounterComponent } from './components/counter/counter.component'; 22 | import { HomeComponent } from './components/home/home.component'; 23 | import { 24 | NgRxCounterComponent, 25 | } from './components/ngrx-counter/ngrx-counter.component'; 26 | import { 27 | ServiceCounterComponent, 28 | } from './components/service-counter/service-counter.component'; 29 | import { CounterService } from './services/counter.service'; 30 | 31 | describe('AppRoutingModule', () => { 32 | beforeEach(() => { 33 | TestBed.configureTestingModule({ 34 | imports: [AppRoutingModule], 35 | providers: [{ provide: APP_BASE_HREF, useValue: '/' }], 36 | }); 37 | }); 38 | 39 | it('initializes', () => { 40 | const appRoutingModule = TestBed.inject(AppRoutingModule); 41 | expect(appRoutingModule).toBeTruthy(); 42 | }); 43 | }); 44 | 45 | describe('AppRoutingModule: routes', () => { 46 | let fixture: ComponentFixture; 47 | let router: Router; 48 | let location: Location; 49 | 50 | function expectComponent(component: Type): void { 51 | expect(fixture.debugElement.query(By.directive(component))).toBeTruthy(); 52 | } 53 | 54 | beforeEach(async () => { 55 | await TestBed.configureTestingModule({ 56 | imports: [RouterTestingModule.withRoutes(routes)], 57 | declarations: [AppComponent], 58 | providers: [CounterService, provideMockStore({ initialState: { counter: 5 } })], 59 | }).compileComponents(); 60 | }); 61 | 62 | beforeEach(() => { 63 | router = TestBed.inject(Router); 64 | location = TestBed.inject(Location); 65 | 66 | fixture = TestBed.createComponent(AppComponent); 67 | 68 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 69 | fixture.ngZone!.run(() => { 70 | router.initialNavigation(); 71 | }); 72 | }); 73 | 74 | it('routes / to the HomeComponent', async () => { 75 | await router.navigate(['']); 76 | fixture.detectChanges(); 77 | expectComponent(HomeComponent); 78 | }); 79 | 80 | it('routes /counter-component', async () => { 81 | await router.navigate(['counter-component']); 82 | fixture.detectChanges(); 83 | expect(location.path()).toBe('/counter-component'); 84 | expectComponent(CounterComponent); 85 | }); 86 | 87 | it('routes /service-counter-component', async () => { 88 | await router.navigate(['service-counter-component']); 89 | fixture.detectChanges(); 90 | expect(location.path()).toBe('/service-counter-component'); 91 | expectComponent(ServiceCounterComponent); 92 | }); 93 | 94 | it('routes /ngrx-counter-component', async () => { 95 | await router.navigate(['ngrx-counter-component']); 96 | fixture.detectChanges(); 97 | expect(location.path()).toBe('/ngrx-counter-component'); 98 | expectComponent(NgRxCounterComponent); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/app/app.routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { 3 | RouterModule, 4 | Routes, 5 | } from '@angular/router'; 6 | 7 | import { CounterComponent } from './components/counter/counter.component'; 8 | import { HomeComponent } from './components/home/home.component'; 9 | import { 10 | NgRxCounterComponent, 11 | } from './components/ngrx-counter/ngrx-counter.component'; 12 | import { 13 | ServiceCounterComponent, 14 | } from './components/service-counter/service-counter.component'; 15 | import { 16 | StandaloneServiceCounterComponent, 17 | } from './components/standalone-service-counter/standalone-service-counter.component'; 18 | 19 | export const routes: Routes = [ 20 | { path: '', pathMatch: 'full', component: HomeComponent }, 21 | { path: 'counter-component', component: CounterComponent }, 22 | { path: 'service-counter-component', component: ServiceCounterComponent }, 23 | { path: 'standalone-service-counter-component', component: StandaloneServiceCounterComponent }, 24 | { path: 'ngrx-counter-component', component: NgRxCounterComponent }, 25 | ]; 26 | 27 | @NgModule({ 28 | imports: [RouterModule.forRoot(routes)], 29 | exports: [RouterModule], 30 | }) 31 | export class AppRoutingModule {} 32 | -------------------------------------------------------------------------------- /src/app/components/counter/counter.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | margin-bottom: 1rem; 4 | border: 4px solid navy; 5 | border-radius: 2px; 6 | padding: 0 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/components/counter/counter.component.html: -------------------------------------------------------------------------------- 1 |

Independent counter

2 | 3 |

4 | {{ count }} 5 |

6 | 7 |

8 | 9 | 10 |

11 | 12 |

13 | 14 | 15 |

16 | -------------------------------------------------------------------------------- /src/app/components/counter/counter.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { take, toArray } from 'rxjs/operators'; 3 | 4 | import { click, expectText, setFieldValue } from '../../spec-helpers/element.spec-helper'; 5 | import { CounterComponent } from './counter.component'; 6 | 7 | const startCount = 123; 8 | const newCount = 456; 9 | 10 | describe('CounterComponent', () => { 11 | let component: CounterComponent; 12 | let fixture: ComponentFixture; 13 | 14 | function expectCount(count: number): void { 15 | expectText(fixture, 'count', String(count)); 16 | } 17 | 18 | beforeEach(async () => { 19 | await TestBed.configureTestingModule({ 20 | declarations: [CounterComponent], 21 | }).compileComponents(); 22 | 23 | fixture = TestBed.createComponent(CounterComponent); 24 | component = fixture.componentInstance; 25 | component.startCount = startCount; 26 | component.ngOnChanges(); 27 | fixture.detectChanges(); 28 | }); 29 | 30 | it('shows the start count', () => { 31 | expectCount(startCount); 32 | }); 33 | 34 | it('increments the count', () => { 35 | click(fixture, 'increment-button'); 36 | fixture.detectChanges(); 37 | expectCount(startCount + 1); 38 | }); 39 | 40 | it('decrements the count', () => { 41 | click(fixture, 'decrement-button'); 42 | fixture.detectChanges(); 43 | expectCount(startCount - 1); 44 | }); 45 | 46 | it('resets the count', () => { 47 | setFieldValue(fixture, 'reset-input', String(newCount)); 48 | click(fixture, 'reset-button'); 49 | fixture.detectChanges(); 50 | expectCount(newCount); 51 | }); 52 | 53 | it('does not reset if the value is not a number', () => { 54 | const value = 'not a number'; 55 | setFieldValue(fixture, 'reset-input', value); 56 | click(fixture, 'reset-button'); 57 | fixture.detectChanges(); 58 | expectCount(startCount); 59 | }); 60 | 61 | it('emits countChange events', () => { 62 | let actualCounts: number[] | undefined; 63 | component.countChange.pipe(take(3), toArray()).subscribe((counts) => { 64 | actualCounts = counts; 65 | }); 66 | 67 | click(fixture, 'increment-button'); 68 | click(fixture, 'decrement-button'); 69 | setFieldValue(fixture, 'reset-input', String(newCount)); 70 | click(fixture, 'reset-button'); 71 | 72 | expect(actualCounts).toEqual([startCount + 1, startCount, newCount]); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/app/components/counter/counter.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; 2 | import { take, toArray } from 'rxjs/operators'; 3 | 4 | import { CounterComponent } from './counter.component'; 5 | 6 | const startCount = 123; 7 | const newCount = 456; 8 | 9 | describe('CounterComponent with spectator', () => { 10 | let spectator: Spectator; 11 | 12 | function expectCount(count: number): void { 13 | expect(spectator.query(byTestId('count'))).toHaveText(String(count)); 14 | } 15 | 16 | const createComponent = createComponentFactory({ 17 | component: CounterComponent, 18 | shallow: true, 19 | }); 20 | 21 | beforeEach(() => { 22 | spectator = createComponent({ props: { startCount } }); 23 | }); 24 | 25 | it('shows the start count', () => { 26 | expectCount(startCount); 27 | }); 28 | 29 | it('increments the count', () => { 30 | spectator.click(byTestId('increment-button')); 31 | expectCount(startCount + 1); 32 | }); 33 | 34 | it('decrements the count', () => { 35 | spectator.click(byTestId('decrement-button')); 36 | expectCount(startCount - 1); 37 | }); 38 | 39 | it('resets the count', () => { 40 | spectator.click(byTestId('decrement-button')); 41 | spectator.typeInElement(String(newCount), byTestId('reset-input')); 42 | spectator.click(byTestId('reset-button')); 43 | expectCount(newCount); 44 | }); 45 | 46 | it('does not reset if the value is not a number', () => { 47 | const value = 'not a number'; 48 | spectator.typeInElement(String(value), byTestId('reset-input')); 49 | spectator.click(byTestId('reset-button')); 50 | expectCount(startCount); 51 | }); 52 | 53 | it('emits countChange events', () => { 54 | let actualCounts: number[] | undefined; 55 | spectator.component.countChange.pipe(take(3), toArray()).subscribe((counts) => { 56 | actualCounts = counts; 57 | }); 58 | 59 | spectator.click(byTestId('increment-button')); 60 | spectator.click(byTestId('decrement-button')); 61 | spectator.typeInElement(String(newCount), byTestId('reset-input')); 62 | spectator.click(byTestId('reset-button')); 63 | 64 | expect(actualCounts).toEqual([startCount + 1, startCount, newCount]); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/app/components/counter/counter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-counter', 5 | templateUrl: './counter.component.html', 6 | styleUrls: ['./counter.component.css'], 7 | }) 8 | export class CounterComponent implements OnChanges { 9 | @Input() 10 | public startCount = 0; 11 | 12 | @Output() 13 | public countChange = new EventEmitter(); 14 | 15 | public count = 0; 16 | 17 | public ngOnChanges(): void { 18 | this.count = this.startCount; 19 | } 20 | 21 | public increment(): void { 22 | this.count++; 23 | this.notify(); 24 | } 25 | 26 | public decrement(): void { 27 | this.count--; 28 | this.notify(); 29 | } 30 | 31 | public reset(newCount: string): void { 32 | const count = parseInt(newCount, 10); 33 | if (!Number.isNaN(count)) { 34 | this.count = count; 35 | this.notify(); 36 | } 37 | } 38 | 39 | private notify(): void { 40 | this.countChange.emit(this.count); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/components/home/home-component.ng-mocks.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { MockComponent } from 'ng-mocks'; 5 | 6 | import { CounterComponent } from '../counter/counter.component'; 7 | import { HomeComponent } from './home.component'; 8 | 9 | describe('HomeComponent with ng-mocks', () => { 10 | let fixture: ComponentFixture; 11 | let component: HomeComponent; 12 | let counter: CounterComponent; 13 | 14 | beforeEach(async () => { 15 | await TestBed.configureTestingModule({ 16 | declarations: [HomeComponent, MockComponent(CounterComponent)], 17 | schemas: [NO_ERRORS_SCHEMA], 18 | }).compileComponents(); 19 | 20 | fixture = TestBed.createComponent(HomeComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | 24 | const counterEl = fixture.debugElement.query(By.directive(CounterComponent)); 25 | counter = counterEl.componentInstance; 26 | }); 27 | 28 | it('renders an independent counter', () => { 29 | expect(counter).toBeTruthy(); 30 | }); 31 | 32 | it('passes a start count', () => { 33 | expect(counter.startCount).toBe(5); 34 | }); 35 | 36 | it('listens for count changes', () => { 37 | spyOn(console, 'log'); 38 | const count = 5; 39 | counter.countChange.emit(count); 40 | expect(console.log).toHaveBeenCalledWith( 41 | 'countChange event from CounterComponent', 42 | count, 43 | ); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/app/components/home/home-component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { createComponentFactory, Spectator } from '@ngneat/spectator'; 2 | import { MockComponent } from 'ng-mocks'; 3 | 4 | import { CounterComponent } from '../counter/counter.component'; 5 | import { HomeComponent } from './home.component'; 6 | 7 | describe('HomeComponent with spectator and ng-mocks', () => { 8 | let counter: CounterComponent; 9 | 10 | let spectator: Spectator; 11 | 12 | const createComponent = createComponentFactory({ 13 | component: HomeComponent, 14 | declarations: [MockComponent(CounterComponent)], 15 | shallow: true, 16 | }); 17 | 18 | beforeEach(() => { 19 | spectator = createComponent(); 20 | const maybeCounter = spectator.query(CounterComponent); 21 | if (!maybeCounter) { 22 | throw new Error('CounterComponent not found'); 23 | } 24 | counter = maybeCounter; 25 | }); 26 | 27 | it('renders an independent counter', () => { 28 | expect(counter).toBeTruthy(); 29 | }); 30 | 31 | it('passes a start count', () => { 32 | expect(counter.startCount).toBe(5); 33 | }); 34 | 35 | it('listens for count changes', () => { 36 | spyOn(console, 'log'); 37 | const count = 5; 38 | counter.countChange.emit(count); 39 | expect(console.log).toHaveBeenCalledWith( 40 | 'countChange event from CounterComponent', 41 | count, 42 | ); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/app/components/home/home.component.css: -------------------------------------------------------------------------------- 1 | section { 2 | display: flex; 3 | flex-wrap: wrap; 4 | margin: 0 -0.5rem; 5 | } 6 | 7 | section > * { 8 | flex: 1; 9 | margin: 0 0.5rem 1rem; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/home/home.component.fake-child.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, NO_ERRORS_SCHEMA, Output } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | 5 | import { CounterComponent } from '../counter/counter.component'; 6 | import { HomeComponent } from './home.component'; 7 | 8 | @Component({ 9 | selector: 'app-counter', 10 | template: '', 11 | }) 12 | class FakeCounterComponent implements Partial { 13 | @Input() 14 | public startCount = 0; 15 | 16 | @Output() 17 | public countChange = new EventEmitter(); 18 | } 19 | 20 | describe('HomeComponent (faking a child Component)', () => { 21 | let fixture: ComponentFixture; 22 | let component: HomeComponent; 23 | let counter: FakeCounterComponent; 24 | 25 | beforeEach(async () => { 26 | await TestBed.configureTestingModule({ 27 | declarations: [HomeComponent, FakeCounterComponent], 28 | schemas: [NO_ERRORS_SCHEMA], 29 | }).compileComponents(); 30 | 31 | fixture = TestBed.createComponent(HomeComponent); 32 | component = fixture.componentInstance; 33 | fixture.detectChanges(); 34 | 35 | const counterEl = fixture.debugElement.query(By.directive(FakeCounterComponent)); 36 | counter = counterEl.componentInstance; 37 | }); 38 | 39 | it('renders an independent counter', () => { 40 | expect(counter).toBeTruthy(); 41 | }); 42 | 43 | it('passes a start count', () => { 44 | expect(counter.startCount).toBe(5); 45 | }); 46 | 47 | it('listens for count changes', () => { 48 | spyOn(console, 'log'); 49 | const count = 5; 50 | counter.countChange.emit(count); 51 | expect(console.log).toHaveBeenCalledWith( 52 | 'countChange event from CounterComponent', 53 | count, 54 | ); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/app/components/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 |
18 | -------------------------------------------------------------------------------- /src/app/components/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { findComponent } from '../../spec-helpers/element.spec-helper'; 5 | import { HomeComponent } from './home.component'; 6 | 7 | describe('HomeComponent', () => { 8 | let fixture: ComponentFixture; 9 | let component: HomeComponent; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | declarations: [HomeComponent], 14 | schemas: [NO_ERRORS_SCHEMA], 15 | }).compileComponents(); 16 | 17 | fixture = TestBed.createComponent(HomeComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('renders without errors', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | 26 | describe('independent counter', () => { 27 | it('renders an independent counter', () => { 28 | const el = findComponent(fixture, 'app-counter'); 29 | expect(el).toBeTruthy(); 30 | }); 31 | 32 | it('passes a start count', () => { 33 | const el = findComponent(fixture, 'app-counter'); 34 | expect(el.properties.startCount).toBe(5); 35 | }); 36 | 37 | it('listens for count changes', () => { 38 | spyOn(console, 'log'); 39 | const el = findComponent(fixture, 'app-counter'); 40 | const count = 5; 41 | el.triggerEventHandler('countChange', 5); 42 | expect(console.log).toHaveBeenCalledWith( 43 | 'countChange event from CounterComponent', 44 | count, 45 | ); 46 | }); 47 | }); 48 | 49 | it('renders a service counter', () => { 50 | const el = findComponent(fixture, 'app-service-counter'); 51 | expect(el).toBeTruthy(); 52 | }); 53 | 54 | it('renders a NgRx counter', () => { 55 | const el = findComponent(fixture, 'app-ngrx-counter'); 56 | expect(el).toBeTruthy(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/app/components/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-home', 5 | templateUrl: './home.component.html', 6 | styleUrls: ['./home.component.css'], 7 | }) 8 | export class HomeComponent { 9 | public handleCountChange(count: number): void { 10 | console.log('countChange event from CounterComponent', count); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/components/ngrx-counter/ngrx-counter.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | margin-bottom: 1rem; 4 | border: 4px solid darkgreen; 5 | border-radius: 2px; 6 | padding: 0 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/components/ngrx-counter/ngrx-counter.component.html: -------------------------------------------------------------------------------- 1 |

NgRx counter

2 | 3 |

4 | {{ count$ | async }} 5 |

6 | 7 |

8 | 9 | 10 |

11 | 12 |

13 | 14 | 15 |

16 | -------------------------------------------------------------------------------- /src/app/components/ngrx-counter/ngrx-counter.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { Store } from '@ngrx/store'; 3 | import { provideMockStore } from '@ngrx/store/testing'; 4 | 5 | import { decrement, increment, reset } from '../../actions/counter.actions'; 6 | import { AppState } from '../../shared/app-state'; 7 | import { click, expectText, setFieldValue } from '../../spec-helpers/element.spec-helper'; 8 | import { NgRxCounterComponent } from './ngrx-counter.component'; 9 | 10 | const mockState: AppState = { 11 | counter: 5, 12 | }; 13 | 14 | const newCount = 15; 15 | 16 | describe('NgRxCounterComponent', () => { 17 | let fixture: ComponentFixture; 18 | let store: Store; 19 | 20 | beforeEach(async () => { 21 | await TestBed.configureTestingModule({ 22 | declarations: [NgRxCounterComponent], 23 | providers: [provideMockStore({ initialState: mockState })], 24 | }).compileComponents(); 25 | 26 | store = TestBed.inject(Store); 27 | spyOn(store, 'dispatch'); 28 | 29 | fixture = TestBed.createComponent(NgRxCounterComponent); 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('shows the count', () => { 34 | expectText(fixture, 'count', String(mockState.counter)); 35 | }); 36 | 37 | it('increments the count', () => { 38 | click(fixture, 'increment-button'); 39 | expect(store.dispatch).toHaveBeenCalledWith(increment()); 40 | }); 41 | 42 | it('decrements the count', () => { 43 | click(fixture, 'decrement-button'); 44 | expect(store.dispatch).toHaveBeenCalledWith(decrement()); 45 | }); 46 | 47 | it('resets the count', () => { 48 | setFieldValue(fixture, 'reset-input', String(newCount)); 49 | click(fixture, 'reset-button'); 50 | 51 | expect(store.dispatch).toHaveBeenCalledWith(reset({ count: newCount })); 52 | }); 53 | 54 | it('does not reset if the value is not a number', () => { 55 | const value = 'not a number'; 56 | setFieldValue(fixture, 'reset-input', value); 57 | click(fixture, 'reset-button'); 58 | 59 | expect(store.dispatch).not.toHaveBeenCalled(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/components/ngrx-counter/ngrx-counter.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; 3 | import { Store } from '@ngrx/store'; 4 | import { provideMockStore } from '@ngrx/store/testing'; 5 | 6 | import { decrement, increment, reset } from '../../actions/counter.actions'; 7 | import { AppState } from '../../shared/app-state'; 8 | import { NgRxCounterComponent } from './ngrx-counter.component'; 9 | 10 | const mockState: AppState = { 11 | counter: 5, 12 | }; 13 | 14 | const newCount = 15; 15 | 16 | describe('NgRxCounterComponent with spectator', () => { 17 | let spectator: Spectator; 18 | 19 | let store: Store; 20 | 21 | const createComponent = createComponentFactory({ 22 | component: NgRxCounterComponent, 23 | providers: [provideMockStore({ initialState: mockState })], 24 | shallow: true, 25 | }); 26 | 27 | beforeEach(() => { 28 | store = TestBed.inject(Store); 29 | spyOn(store, 'dispatch'); 30 | 31 | spectator = createComponent(); 32 | }); 33 | 34 | it('shows the count', () => { 35 | expect(spectator.query(byTestId('count'))).toHaveText(String(mockState.counter)); 36 | }); 37 | 38 | it('increments the count', () => { 39 | spectator.click(byTestId('increment-button')); 40 | expect(store.dispatch).toHaveBeenCalledWith(increment()); 41 | }); 42 | 43 | it('decrements the count', () => { 44 | spectator.click(byTestId('decrement-button')); 45 | expect(store.dispatch).toHaveBeenCalledWith(decrement()); 46 | }); 47 | 48 | it('resets the count', () => { 49 | spectator.typeInElement(String(newCount), byTestId('reset-input')); 50 | spectator.click(byTestId('reset-button')); 51 | 52 | expect(store.dispatch).toHaveBeenCalledWith(reset({ count: newCount })); 53 | }); 54 | 55 | it('does not reset if the value is not a number', () => { 56 | const value = 'not a number'; 57 | spectator.typeInElement(String(value), byTestId('reset-input')); 58 | spectator.click(byTestId('reset-button')); 59 | 60 | expect(store.dispatch).not.toHaveBeenCalled(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/app/components/ngrx-counter/ngrx-counter.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { select, Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { decrement, increment, reset } from '../../actions/counter.actions'; 6 | import { CounterState } from '../../reducers/counter.reducer'; 7 | import { AppState } from '../../shared/app-state'; 8 | import { selectCounter } from '../../shared/selectors'; 9 | 10 | @Component({ 11 | selector: 'app-ngrx-counter', 12 | templateUrl: './ngrx-counter.component.html', 13 | styleUrls: ['./ngrx-counter.component.css'], 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | }) 16 | export class NgRxCounterComponent { 17 | public count$: Observable; 18 | 19 | constructor(private store: Store) { 20 | this.count$ = store.pipe(select(selectCounter)); 21 | } 22 | 23 | public increment(): void { 24 | this.store.dispatch(increment()); 25 | } 26 | 27 | public decrement(): void { 28 | this.store.dispatch(decrement()); 29 | } 30 | 31 | public reset(newCount: string): void { 32 | const count = parseInt(newCount, 10); 33 | if (!Number.isNaN(count)) { 34 | this.store.dispatch(reset({ count })); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/components/service-counter/service-counter.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | margin-bottom: 1rem; 4 | border: 4px solid maroon; 5 | border-radius: 2px; 6 | padding: 0 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/components/service-counter/service-counter.component.html: -------------------------------------------------------------------------------- 1 |

Counter connected to the service

2 | 3 |

4 | {{ count$ | async }} 5 |

6 | 7 |

8 | 9 | 10 |

11 | 12 |

13 | 14 | 15 |

16 | -------------------------------------------------------------------------------- /src/app/components/service-counter/service-counter.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BehaviorSubject, Observable, of } from 'rxjs'; 4 | 5 | import { CounterService } from '../../services/counter.service'; 6 | import { click, expectText, setFieldValue } from '../../spec-helpers/element.spec-helper'; 7 | import { ServiceCounterComponent } from './service-counter.component'; 8 | 9 | describe('ServiceCounterComponent: integration test', () => { 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async () => { 13 | await TestBed.configureTestingModule({ 14 | declarations: [ServiceCounterComponent], 15 | providers: [CounterService], 16 | }).compileComponents(); 17 | 18 | fixture = TestBed.createComponent(ServiceCounterComponent); 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('shows the start count', () => { 23 | expectText(fixture, 'count', '0'); 24 | }); 25 | 26 | it('increments the count', () => { 27 | click(fixture, 'increment-button'); 28 | fixture.detectChanges(); 29 | expectText(fixture, 'count', '1'); 30 | }); 31 | 32 | it('decrements the count', () => { 33 | click(fixture, 'decrement-button'); 34 | fixture.detectChanges(); 35 | expectText(fixture, 'count', '-1'); 36 | }); 37 | 38 | it('resets the count', () => { 39 | const newCount = 456; 40 | setFieldValue(fixture, 'reset-input', String(newCount)); 41 | click(fixture, 'reset-button'); 42 | fixture.detectChanges(); 43 | expectText(fixture, 'count', String(newCount)); 44 | }); 45 | }); 46 | 47 | describe('ServiceCounterComponent: unit test', () => { 48 | const currentCount = 123; 49 | 50 | let fixture: ComponentFixture; 51 | // Declare shared variable 52 | let fakeCounterService: CounterService; 53 | 54 | beforeEach(async () => { 55 | // Create fake 56 | fakeCounterService = jasmine.createSpyObj('CounterService', { 57 | getCount: of(currentCount), 58 | increment: undefined, 59 | decrement: undefined, 60 | reset: undefined, 61 | }); 62 | 63 | await TestBed.configureTestingModule({ 64 | declarations: [ServiceCounterComponent], 65 | // Use fake instead of original 66 | providers: [{ provide: CounterService, useValue: fakeCounterService }], 67 | }).compileComponents(); 68 | 69 | fixture = TestBed.createComponent(ServiceCounterComponent); 70 | fixture.detectChanges(); 71 | }); 72 | 73 | it('shows the count', () => { 74 | expectText(fixture, 'count', String(currentCount)); 75 | expect(fakeCounterService.getCount).toHaveBeenCalled(); 76 | }); 77 | 78 | it('increments the count', () => { 79 | click(fixture, 'increment-button'); 80 | expect(fakeCounterService.increment).toHaveBeenCalled(); 81 | }); 82 | 83 | it('decrements the count', () => { 84 | click(fixture, 'decrement-button'); 85 | expect(fakeCounterService.decrement).toHaveBeenCalled(); 86 | }); 87 | 88 | it('resets the count', () => { 89 | const newCount = 456; 90 | setFieldValue(fixture, 'reset-input', String(newCount)); 91 | click(fixture, 'reset-button'); 92 | expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount); 93 | }); 94 | }); 95 | 96 | describe('ServiceCounterComponent: unit test with minimal Service logic', () => { 97 | const newCount = 456; 98 | 99 | let component: ServiceCounterComponent; 100 | let fixture: ComponentFixture; 101 | 102 | let fakeCount$: BehaviorSubject; 103 | let fakeCounterService: Pick; 104 | 105 | beforeEach(async () => { 106 | fakeCount$ = new BehaviorSubject(0); 107 | 108 | fakeCounterService = { 109 | getCount() { 110 | return fakeCount$; 111 | }, 112 | increment() { 113 | fakeCount$.next(1); 114 | }, 115 | decrement() { 116 | fakeCount$.next(-1); 117 | }, 118 | reset() { 119 | fakeCount$.next(Number(newCount)); 120 | }, 121 | }; 122 | spyOn(fakeCounterService, 'getCount').and.callThrough(); 123 | spyOn(fakeCounterService, 'increment').and.callThrough(); 124 | spyOn(fakeCounterService, 'decrement').and.callThrough(); 125 | spyOn(fakeCounterService, 'reset').and.callThrough(); 126 | 127 | await TestBed.configureTestingModule({ 128 | declarations: [ServiceCounterComponent], 129 | providers: [{ provide: CounterService, useValue: fakeCounterService }], 130 | }).compileComponents(); 131 | 132 | fixture = TestBed.createComponent(ServiceCounterComponent); 133 | component = fixture.componentInstance; 134 | fixture.detectChanges(); 135 | }); 136 | 137 | it('shows the start count', () => { 138 | expectText(fixture, 'count', '0'); 139 | expect(fakeCounterService.getCount).toHaveBeenCalled(); 140 | }); 141 | 142 | it('increments the count', () => { 143 | click(fixture, 'increment-button'); 144 | fixture.detectChanges(); 145 | 146 | expectText(fixture, 'count', '1'); 147 | expect(fakeCounterService.increment).toHaveBeenCalled(); 148 | }); 149 | 150 | it('decrements the count', () => { 151 | click(fixture, 'decrement-button'); 152 | fixture.detectChanges(); 153 | 154 | expectText(fixture, 'count', '-1'); 155 | expect(fakeCounterService.decrement).toHaveBeenCalled(); 156 | }); 157 | 158 | it('resets the count', () => { 159 | setFieldValue(fixture, 'reset-input', String(newCount)); 160 | click(fixture, 'reset-button'); 161 | fixture.detectChanges(); 162 | 163 | expectText(fixture, 'count', String(newCount)); 164 | expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount); 165 | }); 166 | 167 | it('does not reset if the value is not a number', () => { 168 | const value = 'not a number'; 169 | setFieldValue(fixture, 'reset-input', value); 170 | click(fixture, 'reset-button'); 171 | 172 | expect(fakeCounterService.reset).not.toHaveBeenCalled(); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/app/components/service-counter/service-counter.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { 3 | byTestId, 4 | createComponentFactory, 5 | mockProvider, 6 | Spectator, 7 | } from '@ngneat/spectator'; 8 | import { of } from 'rxjs'; 9 | 10 | import { CounterService } from '../../services/counter.service'; 11 | import { ServiceCounterComponent } from './service-counter.component'; 12 | 13 | const newCount = 123; 14 | 15 | describe('ServiceCounterComponent: integration test with spectator', () => { 16 | let spectator: Spectator; 17 | 18 | function expectCount(count: number): void { 19 | expect(spectator.query(byTestId('count'))).toHaveText(String(count)); 20 | } 21 | 22 | const createComponent = createComponentFactory({ 23 | component: ServiceCounterComponent, 24 | providers: [CounterService], 25 | }); 26 | 27 | beforeEach(() => { 28 | spectator = createComponent(); 29 | }); 30 | 31 | it('shows the start count', () => { 32 | expectCount(0); 33 | }); 34 | 35 | it('increments the count', () => { 36 | spectator.click(byTestId('increment-button')); 37 | expectCount(1); 38 | }); 39 | 40 | it('decrements the count', () => { 41 | spectator.click(byTestId('decrement-button')); 42 | expectCount(-1); 43 | }); 44 | 45 | it('resets the count', () => { 46 | spectator.typeInElement(String(newCount), byTestId('reset-input')); 47 | spectator.click(byTestId('reset-button')); 48 | 49 | expectCount(newCount); 50 | }); 51 | }); 52 | 53 | describe('ServiceCounterComponent: unit test with spectator', () => { 54 | let spectator: Spectator; 55 | 56 | function expectCount(count: number): void { 57 | expect(spectator.query(byTestId('count'))).toHaveText(String(count)); 58 | } 59 | 60 | let counterService: Pick; 61 | 62 | const createComponent = createComponentFactory({ 63 | component: ServiceCounterComponent, 64 | providers: [mockProvider(CounterService)], 65 | }); 66 | 67 | beforeEach(() => { 68 | counterService = TestBed.inject(CounterService); 69 | (counterService as jasmine.SpyObj).getCount.and.returnValue(of(0)); 70 | 71 | spectator = createComponent(); 72 | }); 73 | 74 | it('shows the start count', () => { 75 | expectCount(0); 76 | expect(counterService.getCount).toHaveBeenCalled(); 77 | }); 78 | 79 | it('increments the count', () => { 80 | spectator.click(byTestId('increment-button')); 81 | 82 | // No count expectation here since the fake method does not do anything 83 | expect(counterService.increment).toHaveBeenCalled(); 84 | }); 85 | 86 | it('decrements the count', () => { 87 | spectator.click(byTestId('decrement-button')); 88 | 89 | // No count expectation here since the fake method does not do anything 90 | expect(counterService.decrement).toHaveBeenCalled(); 91 | }); 92 | 93 | it('resets the count', () => { 94 | spectator.typeInElement(String(newCount), byTestId('reset-input')); 95 | spectator.click(byTestId('reset-button')); 96 | 97 | // No count expectation here since the fake method does not do anything 98 | expect(counterService.reset).toHaveBeenCalled(); 99 | }); 100 | 101 | it('does not reset if the value is not a number', () => { 102 | const value = 'not a number'; 103 | spectator.typeInElement(value, byTestId('reset-input')); 104 | spectator.click(byTestId('reset-button')); 105 | 106 | expect(counterService.reset).not.toHaveBeenCalled(); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/app/components/service-counter/service-counter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { CounterState } from '../../reducers/counter.reducer'; 5 | import { CounterService } from '../../services/counter.service'; 6 | 7 | @Component({ 8 | selector: 'app-service-counter', 9 | templateUrl: './service-counter.component.html', 10 | styleUrls: ['./service-counter.component.css'], 11 | }) 12 | export class ServiceCounterComponent { 13 | public count$: Observable; 14 | 15 | constructor(private counterService: CounterService) { 16 | this.count$ = this.counterService.getCount(); 17 | } 18 | 19 | public increment(): void { 20 | this.counterService.increment(); 21 | } 22 | 23 | public decrement(): void { 24 | this.counterService.decrement(); 25 | } 26 | 27 | public reset(newCount: string): void { 28 | const count = parseInt(newCount, 10); 29 | if (!Number.isNaN(count)) { 30 | this.counterService.reset(count); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/components/standalone-service-counter/service-counter.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | -------------------------------------------------------------------------------- /src/app/components/standalone-service-counter/standalone-service-counter.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | margin-bottom: 1rem; 4 | border: 4px solid darkred; 5 | border-radius: 2px; 6 | padding: 0 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/components/standalone-service-counter/standalone-service-counter.component.html: -------------------------------------------------------------------------------- 1 |

Standalone counter connected to the service

2 | 3 |

4 | {{ count$ | async }} 5 |

6 | 7 |

8 | 9 | 10 |

11 | 12 |

13 | 14 | 15 |

16 | -------------------------------------------------------------------------------- /src/app/components/standalone-service-counter/standalone-service-counter.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BehaviorSubject, Observable, of } from 'rxjs'; 4 | 5 | import { CounterService } from '../../services/counter.service'; 6 | import { click, expectText, setFieldValue } from '../../spec-helpers/element.spec-helper'; 7 | import { StandaloneServiceCounterComponent } from './standalone-service-counter.component'; 8 | 9 | describe('StandaloneServiceCounterComponent: integration test', () => { 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async () => { 13 | await TestBed.configureTestingModule({ 14 | imports: [StandaloneServiceCounterComponent], 15 | providers: [CounterService], 16 | }).compileComponents(); 17 | 18 | fixture = TestBed.createComponent(StandaloneServiceCounterComponent); 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('shows the start count', () => { 23 | expectText(fixture, 'count', '0'); 24 | }); 25 | 26 | it('increments the count', () => { 27 | click(fixture, 'increment-button'); 28 | fixture.detectChanges(); 29 | expectText(fixture, 'count', '1'); 30 | }); 31 | 32 | it('decrements the count', () => { 33 | click(fixture, 'decrement-button'); 34 | fixture.detectChanges(); 35 | expectText(fixture, 'count', '-1'); 36 | }); 37 | 38 | it('resets the count', () => { 39 | const newCount = 456; 40 | setFieldValue(fixture, 'reset-input', String(newCount)); 41 | click(fixture, 'reset-button'); 42 | fixture.detectChanges(); 43 | expectText(fixture, 'count', String(newCount)); 44 | }); 45 | }); 46 | 47 | describe('StandaloneServiceCounterComponent: fake Service', () => { 48 | const newCount = 456; 49 | 50 | let fixture: ComponentFixture; 51 | 52 | let fakeCount$: BehaviorSubject; 53 | let fakeCounterService: Pick; 54 | 55 | beforeEach(async () => { 56 | fakeCount$ = new BehaviorSubject(0); 57 | 58 | fakeCounterService = { 59 | getCount() { 60 | return fakeCount$; 61 | }, 62 | increment() { 63 | fakeCount$.next(1); 64 | }, 65 | decrement() { 66 | fakeCount$.next(-1); 67 | }, 68 | reset() { 69 | fakeCount$.next(Number(newCount)); 70 | }, 71 | }; 72 | 73 | await TestBed.configureTestingModule({ 74 | imports: [StandaloneServiceCounterComponent], 75 | providers: [{ provide: CounterService, useValue: fakeCounterService }], 76 | }).compileComponents(); 77 | 78 | fixture = TestBed.createComponent(StandaloneServiceCounterComponent); 79 | fixture.detectChanges(); 80 | }); 81 | 82 | it('shows the start count', () => { 83 | expectText(fixture, 'count', '0'); 84 | }); 85 | 86 | it('increments the count', () => { 87 | click(fixture, 'increment-button'); 88 | fixture.detectChanges(); 89 | 90 | expectText(fixture, 'count', '1'); 91 | }); 92 | 93 | it('decrements the count', () => { 94 | click(fixture, 'decrement-button'); 95 | fixture.detectChanges(); 96 | 97 | expectText(fixture, 'count', '-1'); 98 | }); 99 | 100 | it('resets the count', () => { 101 | setFieldValue(fixture, 'reset-input', String(newCount)); 102 | click(fixture, 'reset-button'); 103 | fixture.detectChanges(); 104 | 105 | expectText(fixture, 'count', String(newCount)); 106 | }); 107 | 108 | it('does not reset if the value is not a number', () => { 109 | const value = 'not a number'; 110 | setFieldValue(fixture, 'reset-input', value); 111 | click(fixture, 'reset-button'); 112 | 113 | expectText(fixture, 'count', '0'); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/app/components/standalone-service-counter/standalone-service-counter.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component } from '@angular/core'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | import { CounterState } from '../../reducers/counter.reducer'; 7 | import { CounterService } from '../../services/counter.service'; 8 | 9 | @Component({ 10 | standalone: true, 11 | selector: 'app-standalone-service-counter', 12 | imports: [CommonModule], 13 | templateUrl: './standalone-service-counter.component.html', 14 | styleUrls: ['./standalone-service-counter.component.css'], 15 | }) 16 | export class StandaloneServiceCounterComponent { 17 | public count$: Observable; 18 | 19 | constructor(private counterService: CounterService) { 20 | this.count$ = this.counterService.getCount(); 21 | } 22 | 23 | public increment(): void { 24 | this.counterService.increment(); 25 | } 26 | 27 | public decrement(): void { 28 | this.counterService.decrement(); 29 | } 30 | 31 | public reset(newCount: string): void { 32 | const count = parseInt(newCount, 10); 33 | if (!Number.isNaN(count)) { 34 | this.counterService.reset(count); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/effects/counter.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Action } from '@ngrx/store'; 4 | import { provideMockStore } from '@ngrx/store/testing'; 5 | import { from, Observable, of, throwError } from 'rxjs'; 6 | import { toArray } from 'rxjs/operators'; 7 | 8 | import { 9 | decrement, 10 | increment, 11 | reset, 12 | saveError, 13 | saveSuccess, 14 | } from '../actions/counter.actions'; 15 | import { CounterApiService } from '../services/counter-api.service'; 16 | import { AppState } from '../shared/app-state'; 17 | import { CounterEffects } from './counter.effects'; 18 | 19 | const counter = 1; 20 | const mockState: Partial = { counter }; 21 | 22 | const apiError = new Error('API Error'); 23 | 24 | const incAction = increment(); 25 | const decAction = decrement(); 26 | const resetAction = reset({ count: 5 }); 27 | const successAction = saveSuccess(); 28 | const errorAction = saveError({ error: apiError }); 29 | 30 | function expectActions(effect: Observable, actions: Action[]): void { 31 | let actualActions: Action[] | undefined; 32 | effect.pipe(toArray()).subscribe((actualActions2) => { 33 | actualActions = actualActions2; 34 | }, fail); 35 | expect(actualActions).toEqual(actions); 36 | } 37 | 38 | // Mocks for CounterApiService 39 | 40 | type PartialCounterApiService = Pick; 41 | 42 | const mockCounterApi: PartialCounterApiService = { 43 | saveCounter(): Observable<{}> { 44 | return of({}); 45 | }, 46 | }; 47 | 48 | const mockCounterApiError: PartialCounterApiService = { 49 | saveCounter(): Observable { 50 | return throwError(apiError); 51 | }, 52 | }; 53 | 54 | function setup(actions: Action[], counterApi: PartialCounterApiService): CounterEffects { 55 | spyOn(counterApi, 'saveCounter').and.callThrough(); 56 | 57 | TestBed.configureTestingModule({ 58 | providers: [ 59 | provideMockActions(from(actions)), 60 | provideMockStore({ initialState: mockState }), 61 | { provide: CounterApiService, useValue: counterApi }, 62 | CounterEffects, 63 | ], 64 | }); 65 | 66 | return TestBed.inject(CounterEffects); 67 | } 68 | 69 | function expectSaveOnChange(action: Action, counterApi: PartialCounterApiService): void { 70 | const counterEffects = setup([action], counterApi); 71 | 72 | expectActions(counterEffects.saveOnChange$, [successAction]); 73 | 74 | expect(counterApi.saveCounter).toHaveBeenCalledWith(counter); 75 | } 76 | 77 | describe('CounterEffects', () => { 78 | it('saves the counter on increment', () => { 79 | expectSaveOnChange(incAction, mockCounterApi); 80 | }); 81 | 82 | it('saves the counter on decrement', () => { 83 | expectSaveOnChange(decAction, mockCounterApi); 84 | }); 85 | 86 | it('saves the counter on reset', () => { 87 | expectSaveOnChange(resetAction, mockCounterApi); 88 | }); 89 | 90 | it('handles an API error', () => { 91 | const actions = [incAction, incAction, incAction]; 92 | const counterEffects = setup(actions, mockCounterApiError); 93 | 94 | expectActions(counterEffects.saveOnChange$, [errorAction, errorAction, errorAction]); 95 | 96 | expect(mockCounterApiError.saveCounter).toHaveBeenCalledWith(counter); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/app/effects/counter.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { Store } from '@ngrx/store'; 4 | import { of } from 'rxjs'; 5 | import { catchError, map, mergeMap, withLatestFrom } from 'rxjs/operators'; 6 | 7 | import { 8 | decrement, 9 | increment, 10 | reset, 11 | saveError, 12 | saveSuccess, 13 | } from '../actions/counter.actions'; 14 | import { CounterApiService } from '../services/counter-api.service'; 15 | import { AppState } from '../shared/app-state'; 16 | 17 | @Injectable() 18 | export class CounterEffects { 19 | constructor( 20 | private actions$: Actions, 21 | private store$: Store, 22 | private counterApiService: CounterApiService, 23 | ) {} 24 | 25 | /* 26 | * Listens for counter changes and sends the state to the server. 27 | * Dispatches SAVE_SUCCESS or SAVE_ERROR. 28 | */ 29 | public saveOnChange$ = createEffect(() => 30 | this.actions$.pipe( 31 | ofType(increment, decrement, reset), 32 | withLatestFrom(this.store$), 33 | mergeMap(([_, state]) => 34 | this.counterApiService.saveCounter(state.counter).pipe( 35 | map(() => saveSuccess()), 36 | catchError((error) => of(saveError({ error }))), 37 | ), 38 | ), 39 | ), 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/reducers/counter.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { decrement, increment, reset } from '../actions/counter.actions'; 2 | import { counterReducer, CounterState } from './counter.reducer'; 3 | 4 | describe('counterReducer', () => { 5 | it('returns an initial state', () => { 6 | const newState = counterReducer(undefined, { type: 'init' }); 7 | expect(newState).toBe(0); 8 | }); 9 | 10 | it('returns the current state', () => { 11 | const state: CounterState = 5; 12 | const newState = counterReducer(state, { type: 'unknown' }); 13 | expect(newState).toBe(state); 14 | }); 15 | 16 | it('increments', () => { 17 | const state: CounterState = 0; 18 | const newState = counterReducer(state, increment()); 19 | expect(newState).toBe(1); 20 | }); 21 | 22 | it('decrements', () => { 23 | const state: CounterState = 1; 24 | const newState = counterReducer(state, decrement()); 25 | expect(newState).toBe(0); 26 | }); 27 | 28 | it('resets', () => { 29 | const newCount: CounterState = 5; 30 | const state: CounterState = 0; 31 | const newState = counterReducer(state, reset({ count: newCount })); 32 | expect(newState).toBe(newCount); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/app/reducers/counter.reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action, createReducer, on } from '@ngrx/store'; 2 | 3 | import { decrement, increment, reset } from '../actions/counter.actions'; 4 | 5 | export type CounterState = number; 6 | 7 | export const initialState: CounterState = 0; 8 | 9 | const reducer = createReducer( 10 | initialState, 11 | on(increment, (state) => state + 1), 12 | on(decrement, (state) => state - 1), 13 | on(reset, (_, action) => action.count), 14 | ); 15 | 16 | export function counterReducer( 17 | state: CounterState | undefined, 18 | action: Action, 19 | ): CounterState { 20 | return reducer(state, action); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducerMap } from '@ngrx/store'; 2 | 3 | import { AppState } from '../shared/app-state'; 4 | import { counterReducer } from './counter.reducer'; 5 | 6 | export const reducers: ActionReducerMap = { 7 | counter: counterReducer, 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/services/counter-api.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { 3 | HttpClientTestingModule, 4 | HttpTestingController, 5 | } from '@angular/common/http/testing'; 6 | import { TestBed } from '@angular/core/testing'; 7 | 8 | import { CounterApiService } from './counter-api.service'; 9 | 10 | const counter = 5; 11 | const expectedURL = `/assets/counter.json?counter=${counter}`; 12 | const serverResponse = {}; 13 | 14 | const errorEvent = new ErrorEvent('API error'); 15 | 16 | describe('CounterApiService', () => { 17 | let counterApiService: CounterApiService; 18 | let httpMock: HttpTestingController; 19 | 20 | beforeEach(() => { 21 | TestBed.configureTestingModule({ 22 | imports: [HttpClientTestingModule], 23 | providers: [CounterApiService], 24 | }); 25 | 26 | counterApiService = TestBed.inject(CounterApiService); 27 | httpMock = TestBed.inject(HttpTestingController); 28 | }); 29 | 30 | it('saves the counter', () => { 31 | let actualResult: any; 32 | counterApiService.saveCounter(counter).subscribe((result) => { 33 | actualResult = result; 34 | }); 35 | 36 | const request = httpMock.expectOne({ method: 'GET', url: expectedURL }); 37 | request.flush(serverResponse); 38 | httpMock.verify(); 39 | 40 | expect(actualResult).toBe(serverResponse); 41 | }); 42 | 43 | it('handles save counter errors', () => { 44 | const status = 500; 45 | const statusText = 'Server error'; 46 | 47 | let actualError: HttpErrorResponse | undefined; 48 | 49 | counterApiService.saveCounter(counter).subscribe( 50 | fail, 51 | (error: HttpErrorResponse) => { 52 | actualError = error; 53 | }, 54 | fail, 55 | ); 56 | 57 | const request = httpMock.expectOne({ method: 'GET', url: expectedURL }); 58 | request.error(errorEvent, { status, statusText }); 59 | httpMock.verify(); 60 | 61 | if (!actualError) { 62 | throw new Error('actualError not defined'); 63 | } 64 | expect(actualError.error).toBe(errorEvent); 65 | expect(actualError.status).toBe(status); 66 | expect(actualError.statusText).toBe(statusText); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/app/services/counter-api.service.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator'; 3 | 4 | import { CounterApiService } from './counter-api.service'; 5 | 6 | const counter = 5; 7 | const expectedURL = `/assets/counter.json?counter=${counter}`; 8 | const serverResponse = {}; 9 | 10 | const errorEvent = new ErrorEvent('API error'); 11 | 12 | describe('CounterApiService with spectator', () => { 13 | let spectator: SpectatorHttp; 14 | const createHttp = createHttpFactory(CounterApiService); 15 | 16 | beforeEach(() => { 17 | spectator = createHttp(); 18 | }); 19 | 20 | it('saves the counter', () => { 21 | let actualResult: any; 22 | spectator.service.saveCounter(counter).subscribe((result) => { 23 | actualResult = result; 24 | }); 25 | 26 | const request = spectator.expectOne(expectedURL, HttpMethod.GET); 27 | request.flush(serverResponse); 28 | 29 | expect(actualResult).toBe(serverResponse); 30 | }); 31 | 32 | it('handles save counter errors', () => { 33 | const status = 500; 34 | const statusText = 'Server error'; 35 | 36 | let actualError: HttpErrorResponse | undefined; 37 | 38 | spectator.service.saveCounter(counter).subscribe( 39 | fail, 40 | (error: HttpErrorResponse) => { 41 | actualError = error; 42 | }, 43 | fail, 44 | ); 45 | 46 | const request = spectator.expectOne(expectedURL, HttpMethod.GET); 47 | request.error(errorEvent, { status, statusText }); 48 | 49 | if (!actualError) { 50 | throw new Error('actualError not defined'); 51 | } 52 | expect(actualError.error).toBe(errorEvent); 53 | expect(actualError.status).toBe(status); 54 | expect(actualError.statusText).toBe(statusText); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/app/services/counter-api.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable() 6 | export class CounterApiService { 7 | constructor(private http: HttpClient) {} 8 | 9 | public saveCounter(counter: number): Observable<{}> { 10 | return this.http.get(`/assets/counter.json?counter=${counter}`); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/services/counter.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { first } from 'rxjs/operators'; 2 | 3 | import { CounterService } from './counter.service'; 4 | 5 | describe('CounterService', () => { 6 | let counterService: CounterService; 7 | 8 | function expectCount(count: number): void { 9 | let actualCount: number | undefined; 10 | counterService 11 | .getCount() 12 | .pipe(first()) 13 | .subscribe((actualCount2) => { 14 | actualCount = actualCount2; 15 | }); 16 | expect(actualCount).toBe(count); 17 | } 18 | 19 | beforeEach(() => { 20 | counterService = new CounterService(); 21 | }); 22 | 23 | it('returns the count', () => { 24 | expectCount(0); 25 | }); 26 | 27 | it('increments the count', () => { 28 | counterService.increment(); 29 | expectCount(1); 30 | }); 31 | 32 | it('decrements the count', () => { 33 | counterService.decrement(); 34 | expectCount(-1); 35 | }); 36 | 37 | it('resets the count', () => { 38 | const newCount = 123; 39 | counterService.reset(newCount); 40 | expectCount(newCount); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/app/services/counter.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | 4 | @Injectable() 5 | export class CounterService { 6 | // Technically, this property is not necessary since the BehaviorSubject 7 | // below already holds the current count. We are keeping it for clarity. 8 | private count = 0; 9 | 10 | private subject: BehaviorSubject; 11 | 12 | constructor() { 13 | this.subject = new BehaviorSubject(this.count); 14 | } 15 | 16 | // Every BehaviorSubject is an Observable and Observer. 17 | // We do not want to expose the Observer trait to the outside, 18 | // so we downcast the BehaviorSubject to a simple Observable only. 19 | public getCount(): Observable { 20 | return this.subject.asObservable(); 21 | } 22 | 23 | public increment(): void { 24 | this.count++; 25 | this.notify(); 26 | } 27 | 28 | public decrement(): void { 29 | this.count--; 30 | this.notify(); 31 | } 32 | 33 | public reset(newCount: number): void { 34 | this.count = newCount; 35 | this.notify(); 36 | } 37 | 38 | private notify(): void { 39 | this.subject.next(this.count); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/services/todos-service.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This service is not used in the counter app, 3 | * but here as an example for Jasmine Spies. 4 | */ 5 | class TodoService { 6 | constructor( 7 | // Bind `fetch ` to `window` to ensure that `window` is the `this` context 8 | private fetch = window.fetch.bind(window), 9 | ) {} 10 | 11 | public async getTodos(): Promise { 12 | const response = await this.fetch('/todos'); 13 | if (!response.ok) { 14 | throw new Error(`HTTP error: ${response.status} ${response.statusText}`); 15 | } 16 | return await response.json(); 17 | } 18 | } 19 | 20 | // Fake todos and response object 21 | const todos = ['shop groceries', 'mow the lawn', 'take the cat to the vet']; 22 | const okResponse = new Response(JSON.stringify(todos), { 23 | status: 200, 24 | statusText: 'OK', 25 | }); 26 | const errorResponse = new Response('Not Found', { 27 | status: 404, 28 | statusText: 'Not Found', 29 | }); 30 | 31 | describe('TodoService', () => { 32 | it('gets the to-dos', async () => { 33 | // Arrange 34 | const fetchSpy = jasmine.createSpy('fetch').and.returnValue(okResponse); 35 | const todoService = new TodoService(fetchSpy); 36 | 37 | // Act 38 | const actualTodos = await todoService.getTodos(); 39 | 40 | // Assert 41 | expect(actualTodos).toEqual(todos); 42 | expect(fetchSpy).toHaveBeenCalledWith('/todos'); 43 | }); 44 | 45 | it('handles an HTTP error when getting the to-dos', async () => { 46 | // Arrange 47 | const fetchSpy = jasmine.createSpy('fetch').and.returnValue(errorResponse); 48 | const todoService = new TodoService(fetchSpy); 49 | 50 | // Act 51 | let error; 52 | try { 53 | await todoService.getTodos(); 54 | } catch (e) { 55 | error = e; 56 | } 57 | 58 | // Assert 59 | expect(error).toEqual(new Error('HTTP error: 404 Not Found')); 60 | expect(fetchSpy).toHaveBeenCalledWith('/todos'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/app/shared/app-state.ts: -------------------------------------------------------------------------------- 1 | import { CounterState } from '../reducers/counter.reducer'; 2 | 3 | export interface AppState { 4 | counter: CounterState; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/shared/selectors.ts: -------------------------------------------------------------------------------- 1 | import { AppState } from './app-state'; 2 | 3 | export const selectCounter = (state: AppState) => state.counter; 4 | -------------------------------------------------------------------------------- /src/app/spec-helpers/element.spec-helper.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { DebugElement } from '@angular/core'; 4 | import { ComponentFixture } from '@angular/core/testing'; 5 | import { By } from '@angular/platform-browser'; 6 | 7 | /** 8 | * Spec helpers for working with the DOM 9 | */ 10 | 11 | /** 12 | * Returns a selector for the `data-testid` attribute with the given attribute value. 13 | * 14 | * @param testId Test id set by `data-testid` 15 | * 16 | */ 17 | export function testIdSelector(testId: string): string { 18 | return `[data-testid="${testId}"]`; 19 | } 20 | 21 | /** 22 | * Finds a single element inside the Component by the given CSS selector. 23 | * Throws an error if no element was found. 24 | * 25 | * @param fixture Component fixture 26 | * @param selector CSS selector 27 | * 28 | */ 29 | export function queryByCss( 30 | fixture: ComponentFixture, 31 | selector: string, 32 | ): DebugElement { 33 | // The return type of DebugElement#query() is declared as DebugElement, 34 | // but the actual return type is DebugElement | null. 35 | // See https://github.com/angular/angular/issues/22449. 36 | const debugElement = fixture.debugElement.query(By.css(selector)); 37 | // Fail on null so the return type is always DebugElement. 38 | if (!debugElement) { 39 | throw new Error(`queryByCss: Element with ${selector} not found`); 40 | } 41 | return debugElement; 42 | } 43 | 44 | /** 45 | * Finds an element inside the Component by the given `data-testid` attribute. 46 | * Throws an error if no element was found. 47 | * 48 | * @param fixture Component fixture 49 | * @param testId Test id set by `data-testid` 50 | * 51 | */ 52 | export function findEl(fixture: ComponentFixture, testId: string): DebugElement { 53 | return queryByCss(fixture, testIdSelector(testId)); 54 | } 55 | 56 | /** 57 | * Finds all elements with the given `data-testid` attribute. 58 | * 59 | * @param fixture Component fixture 60 | * @param testId Test id set by `data-testid` 61 | */ 62 | export function findEls(fixture: ComponentFixture, testId: string): DebugElement[] { 63 | return fixture.debugElement.queryAll(By.css(testIdSelector(testId))); 64 | } 65 | 66 | /** 67 | * Gets the text content of an element with the given `data-testid` attribute. 68 | * 69 | * @param fixture Component fixture 70 | * @param testId Test id set by `data-testid` 71 | */ 72 | export function getText(fixture: ComponentFixture, testId: string): string { 73 | return findEl(fixture, testId).nativeElement.textContent; 74 | } 75 | 76 | /** 77 | * Expects that the element with the given `data-testid` attribute 78 | * has the given text content. 79 | * 80 | * @param fixture Component fixture 81 | * @param testId Test id set by `data-testid` 82 | * @param text Expected text 83 | */ 84 | export function expectText( 85 | fixture: ComponentFixture, 86 | testId: string, 87 | text: string, 88 | ): void { 89 | expect(getText(fixture, testId)).toBe(text); 90 | } 91 | 92 | /** 93 | * Expects that the element with the given `data-testid` attribute 94 | * has the given text content. 95 | * 96 | * @param fixture Component fixture 97 | * @param text Expected text 98 | */ 99 | export function expectContainedText(fixture: ComponentFixture, text: string): void { 100 | expect(fixture.nativeElement.textContent).toContain(text); 101 | } 102 | 103 | /** 104 | * Expects that a component has the given text content. 105 | * Both the component text content and the expected text are trimmed for reliability. 106 | * 107 | * @param fixture Component fixture 108 | * @param text Expected text 109 | */ 110 | export function expectContent(fixture: ComponentFixture, text: string): void { 111 | expect(fixture.nativeElement.textContent).toBe(text); 112 | } 113 | 114 | /** 115 | * Dispatches a fake event (synthetic event) at the given element. 116 | * 117 | * @param element Element that is the target of the event 118 | * @param type Event name, e.g. `input` 119 | * @param bubbles Whether the event bubbles up in the DOM tree 120 | */ 121 | export function dispatchFakeEvent( 122 | element: EventTarget, 123 | type: string, 124 | bubbles: boolean = false, 125 | ): void { 126 | const event = document.createEvent('Event'); 127 | event.initEvent(type, bubbles, false); 128 | element.dispatchEvent(event); 129 | } 130 | 131 | /** 132 | * Enters text into a form field (`input`, `textarea` or `select` element). 133 | * Triggers appropriate events so Angular takes notice of the change. 134 | * If you listen for the `change` event on `input` or `textarea`, 135 | * you need to trigger it separately. 136 | * 137 | * @param element Form field 138 | * @param value Form field value 139 | */ 140 | export function setFieldElementValue( 141 | element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, 142 | value: string, 143 | ): void { 144 | element.value = value; 145 | // Dispatch an `input` or `change` fake event 146 | // so Angular form bindings take notice of the change. 147 | const isSelect = element instanceof HTMLSelectElement; 148 | dispatchFakeEvent(element, isSelect ? 'change' : 'input', isSelect ? false : true); 149 | } 150 | 151 | /** 152 | * Sets the value of a form field with the given `data-testid` attribute. 153 | * 154 | * @param fixture Component fixture 155 | * @param testId Test id set by `data-testid` 156 | * @param value Form field value 157 | */ 158 | export function setFieldValue( 159 | fixture: ComponentFixture, 160 | testId: string, 161 | value: string, 162 | ): void { 163 | setFieldElementValue(findEl(fixture, testId).nativeElement, value); 164 | } 165 | 166 | /** 167 | * Checks or unchecks a checkbox or radio button. 168 | * Triggers appropriate events so Angular takes notice of the change. 169 | * 170 | * @param fixture Component fixture 171 | * @param testId Test id set by `data-testid` 172 | * @param checked Whether the checkbox or radio should be checked 173 | */ 174 | export function checkField( 175 | fixture: ComponentFixture, 176 | testId: string, 177 | checked: boolean, 178 | ): void { 179 | const { nativeElement } = findEl(fixture, testId); 180 | nativeElement.checked = checked; 181 | // Dispatch a `change` fake event so Angular form bindings take notice of the change. 182 | dispatchFakeEvent(nativeElement, 'change'); 183 | } 184 | 185 | /** 186 | * Makes a fake click event that provides the most important properties. 187 | * Sets the button to left. 188 | * The event can be passed to DebugElement#triggerEventHandler. 189 | * 190 | * @param target Element that is the target of the click event 191 | */ 192 | export function makeClickEvent(target: EventTarget): Partial { 193 | return { 194 | preventDefault(): void {}, 195 | stopPropagation(): void {}, 196 | stopImmediatePropagation(): void {}, 197 | type: 'click', 198 | target, 199 | currentTarget: target, 200 | bubbles: true, 201 | cancelable: true, 202 | button: 0, 203 | }; 204 | } 205 | 206 | /** 207 | * Emulates a left click on the element with the given `data-testid` attribute. 208 | * 209 | * @param fixture Component fixture 210 | * @param testId Test id set by `data-testid` 211 | */ 212 | export function click(fixture: ComponentFixture, testId: string): void { 213 | const element = findEl(fixture, testId); 214 | const event = makeClickEvent(element.nativeElement); 215 | element.triggerEventHandler('click', event); 216 | } 217 | 218 | /** 219 | * Finds a nested Component by its selector, e.g. `app-example`. 220 | * Throws an error if no element was found. 221 | * Use this only for shallow component testing. 222 | * When finding other elements, use `findEl` / `findEls` and `data-testid` attributes. 223 | * 224 | * @param fixture Fixture of the parent Component 225 | * @param selector Element selector, e.g. `app-example` 226 | */ 227 | export function findComponent( 228 | fixture: ComponentFixture, 229 | selector: string, 230 | ): DebugElement { 231 | return queryByCss(fixture, selector); 232 | } 233 | 234 | /** 235 | * Finds all nested Components by its selector, e.g. `app-example`. 236 | */ 237 | export function findComponents( 238 | fixture: ComponentFixture, 239 | selector: string, 240 | ): DebugElement[] { 241 | return fixture.debugElement.queryAll(By.css(selector)); 242 | } 243 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9elements/angular-workshop/a4cac55e0be4796879b7030a689add058c8b150b/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/counter.json: -------------------------------------------------------------------------------- 1 | { "description": "placeholder file for testing counter effects" } 2 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9elements/angular-workshop/a4cac55e0be4796879b7030a689add058c8b150b/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular Workshop: Counters 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch((err) => console.error(err)); 14 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | margin: 1rem; 9 | padding: 0; 10 | font-family: sans-serif; 11 | background-color: #fff; 12 | color: black; 13 | } 14 | 15 | h1 { 16 | font-size: 1.2rem; 17 | } 18 | 19 | h1, 20 | p { 21 | margin-top: 1rem; 22 | margin-bottom: 1rem; 23 | } 24 | 25 | button, 26 | input[type='text'], 27 | input[type='number'], 28 | input[type='email'] { 29 | border: 0; 30 | padding: 0 0.5rem; 31 | font-size: inherit; 32 | line-height: 1; 33 | height: 2rem; 34 | } 35 | 36 | button { 37 | min-width: 2rem; 38 | font-size: 1.1rem; 39 | } 40 | 41 | button + button { 42 | margin-left: 3px; 43 | } 44 | 45 | button { 46 | background-color: #1976d2; 47 | color: #fff; 48 | } 49 | 50 | input[type='text'], 51 | input[type='number'], 52 | input[type='email'] { 53 | background-color: #fff; 54 | border: 1px solid #1976d2; 55 | } 56 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"], 9 | "exclude": ["src/**/*.spec.ts", "src/**/*.spec-helper.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "downlevelIteration": true, 14 | "experimentalDecorators": true, 15 | "moduleResolution": "node", 16 | "importHelpers": true, 17 | "target": "ES2022", 18 | "module": "es2020", 19 | "lib": [ 20 | "es2018", 21 | "dom" 22 | ], 23 | "useDefineForClassFields": false 24 | }, 25 | "exclude": [ 26 | // Exclude Cypress here so the Jasmine types are not overwritten 27 | "cypress.config.ts" 28 | ], 29 | "angularCompilerOptions": { 30 | "enableI18nLegacyMessageIdFormat": false, 31 | "strictInjectionParameters": true, 32 | "strictInputAccessModifiers": true, 33 | "strictTemplates": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "include": ["src/**/*.spec.ts", "src/**/*.spec-helper.ts", "src/**/*.d.ts"] 8 | } 9 | --------------------------------------------------------------------------------