diff --git a/.eslintrc.js b/.eslintrc.js index ad61b0ff7..889ad3226 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,77 +3,105 @@ module.exports = { browser: true, es2021: true, }, - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 12, - sourceType: 'module', -// project: 'tsconfig.json', - }, - plugins: [ - '@typescript-eslint', - ], - extends: ['eslint:recommended', 'google', 'plugin:@typescript-eslint/recommended'], - rules: { - 'complexity': ['error', 20], - '@typescript-eslint/typedef': [ - 'warn', - { - 'arrowParameter': true, - 'memberVariableDeclaration': true, - 'propertyDeclaration': true, - 'variableDeclaration': true, - 'parameter': true, - }, + overrides: [{ + files: ['*.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 12, + sourceType: 'module', + // project: 'tsconfig.json', + }, + plugins: [ + '@typescript-eslint', ], -// '@typescript-eslint/strict-boolean-expressions': ['error'], - '@typescript-eslint/no-empty-function': ['off'], - '@typescript-eslint/no-namespace': ['off'], - '@typescript-eslint/ban-types': [ - 'error', - { 'types': { 'String': { 'message': 'Use string instead', 'fixWith': 'string' } } }, + extends: [ + 'eslint:recommended', + 'google', + 'plugin:@typescript-eslint/recommended', + // 'plugin:@angular-eslint/recommended', ], - '@typescript-eslint/no-this-alias': ['warn'], - '@typescript-eslint/no-inferrable-types': ['off'], - '@typescript-eslint/no-redeclare': [ - 'error', - { 'ignoreDeclarationMerge': true }, + rules: { + 'complexity': ['error', 20], + '@typescript-eslint/typedef': [ + 'warn', + { + 'arrowParameter': true, + 'memberVariableDeclaration': true, + 'propertyDeclaration': true, + 'variableDeclaration': true, + 'parameter': true, + }, + ], + // '@typescript-eslint/strict-boolean-expressions': ['error'], + '@typescript-eslint/no-empty-function': ['off'], + '@typescript-eslint/no-namespace': ['off'], + '@typescript-eslint/ban-types': [ + 'error', + { 'types': { 'String': { 'message': 'Use string instead', 'fixWith': 'string' } } }, + ], + '@typescript-eslint/no-this-alias': ['warn'], + '@typescript-eslint/no-inferrable-types': ['off'], + '@typescript-eslint/no-redeclare': [ + 'error', + { 'ignoreDeclarationMerge': true }, + ], + 'max-len': ['warn', { 'code': 120, 'ignoreStrings': true, 'ignoreTemplateLiterals': true }], + 'require-jsdoc': ['warn', { 'require': { + 'FunctionDeclaration': false, + 'MethodDefinition': false, + 'ClassDeclaration': false, + 'ArrowFunctionExpression': false, + 'FunctionExpression': false, + } }], + 'new-cap': ['off'], // Because there are false positives + 'no-undef': ['off'], // Because there are false positives + 'valid-jsdoc': ['off'], // Because we do not use jsdoc + '@typescript-eslint/no-unused-vars': ['error', { 'args': 'none' }], + 'no-invalid-this': ['warn'], + 'indent': [ + 'error', 4, + { + 'SwitchCase': 1, + 'CallExpression': { 'arguments': 'first' }, + 'FunctionDeclaration': { 'parameters': 'first' }, + 'FunctionExpression': { 'parameters': 'first' }, + }, + ], + 'object-curly-spacing': ['error', 'always'], + 'no-redeclare': ['error'], + 'camelcase': ['error'], + 'no-case-declarations': ['off'], + 'padded-blocks': ['off'], + 'space-before-function-paren': ['error', { + 'anonymous': 'never', + 'named': 'never', + 'asyncArrow': 'never', + }], + 'brace-style': ['off'], + 'eqeqeq': ['error', 'always', { + 'null': 'ignore', + }], + 'max-lines-per-function': ['off', 20] + }, + }, { + files: ['*.html'], + parser: '@angular-eslint/template-parser', + plugins: [ ], - - 'max-len': ['warn', { 'code': 120, 'ignoreStrings': true, 'ignoreTemplateLiterals': true }], - 'require-jsdoc': ['warn', { 'require': { - 'FunctionDeclaration': false, - 'MethodDefinition': false, - 'ClassDeclaration': false, - 'ArrowFunctionExpression': false, - 'FunctionExpression': false, - } }], - 'new-cap': ['off'], // Because there are false positives - 'no-undef': ['off'], // Because there are false positives - 'valid-jsdoc': ['off'], // Because we do not use jsdoc - '@typescript-eslint/no-unused-vars': ['error', { 'args': 'none' }], - 'no-invalid-this': ['warn'], - 'indent': [ - 'error', 4, - { - 'SwitchCase': 1, - 'CallExpression': { 'arguments': 'first' }, - 'FunctionDeclaration': { 'parameters': 'first' }, - 'FunctionExpression': { 'parameters': 'first' }, - }, + extends: [ + 'plugin:@angular-eslint/template/recommended', ], - 'object-curly-spacing': ['warn', 'always'], - 'no-redeclare': ['error'], - 'camelcase': ['warn'], - 'no-case-declarations': ['off'], - 'padded-blocks': ['off'], - 'space-before-function-paren': ['error', { - 'anonymous': 'never', - 'named': 'never', - 'asyncArrow': 'never', - }], - 'brace-style': ['off'], - 'eqeqeq': ['error', 'always', { - 'null': 'ignore', - }], - }, + 'rules': { + '@angular-eslint/template/i18n': [ + 'warn', + { + 'checkId': false, + 'checkText': true, + 'checkAttributes': true, + 'ignoreTags': ['title', 'meta', 'app-chat'], + 'ignoreAttributes': ['href', ':xlink:href', 'r', 'points', 'preserveAspectRatio', 'pointer-events', 'stroke-linecap', 'x', 'y', 'transform', 'refX', 'refY', 'marker-end', 'markerWidth', 'markerHeight', 'orient', 'dx', 'dy', 'text-anchor', 'rx', 'ry', 'x1', 'x2', 'y1', 'y2', 'fill-opacity', 'role', 'cx', 'stroke-dasharray', 'name', 'for', 'step', 'min', 'max', 'scope', 'routerLink', 'debugName', 'value', 'aria-label', 'data-target', 'maxlength', 'ngClass'], + } + ] + }, + }] }; diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index caaffd468..687366d01 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -54,7 +54,9 @@ jobs: - name: Install python dependencies uses: BSFishy/pip-action@v1 with: - packages: lxml selenium + packages: lxml selenium pandas + - name: Check coverage + run: python ./scripts/coverage.py check - name: Update and check translations run: bash ./scripts/update-translations.sh && bash ./scripts/check-translations.sh - name: Update images diff --git a/.gitignore b/.gitignore index 2117c5d7b..15a9122ea 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ /.sass-cache /connect.lock /coverage +! coverage/branches.csv +! coverage/functions.csv +! coverage/statements.csv +! coverage/lines.csv /typings *.log diff --git a/README.md b/README.md index 3888b199a..c61a39e1e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# AwesomBoard +# EveryBoard -[AwesomBoard](awesom.eu/board) is a platform to play various abstract strategy games, but also to develop AI for these games and to explore new games. +[EveryBoard](everyboard.org) is a platform to play various abstract strategy games, but also to develop AI for these games and to explore new games. -If you like AwesomBoard, do not hesitate to star this repository! +If you like EveryBoard, do not hesitate to star this repository! # Development ## Local server @@ -22,3 +22,15 @@ Then, run `npm test` Run `./scripts/update-translations.sh` to update the translation files. Then, translate in `translations/messages.fr.xlf`. Finally, run `./scripts/check-translations.sh` to check that you haven't forgot anything and to generate the final translation files that will be used in deployment. + +## PR Merge procedure +### For the PR submitter +After the PR has been approved for merging: + - Update the global thresholds in `src/karma.conf.js` (`coverageReporter.check.global`) to match with your latest run from `npm test`. + - Update `index.html` with the number of tests + - Update the coverage data with `scripts/coverage.py generate` + +### For the PR merger + - Check that `src/karma.conf.js` has been updated + - Check that `index.html` has been updated + - Check that `coverage/*.csv` have been updated diff --git a/angular.json b/angular.json index ce015d60c..d9a4ec299 100644 --- a/angular.json +++ b/angular.json @@ -23,8 +23,22 @@ "src/assets" ], "styles": [ - "src/css/mystyles.css" + { + "input": "src/sass/dark.scss", + "bundleName": "dark", + "inject": false + }, + { + "input": "src/sass/light.scss", + "bundleName": "light", + "inject": false + } ], + "stylePreprocessorOptions": { + "includePaths": [ + "src/sass" + ] + }, "scripts": [ "./node_modules/jquery/dist/jquery.js", "./node_modules/popper.js/dist/umd/popper.js" @@ -55,7 +69,6 @@ "optimization": true, "outputHashing": "all", "sourceMap": false, - "extractCss": true, "namedChunks": false, "extractLicenses": true, "vendorChunk": false, @@ -88,8 +101,7 @@ "replace": "src/environments/environment.ts", "with": "src/environments/environment.local.ts" } - ], - "baseHref": "/board-test/" + ] } } }, @@ -101,6 +113,9 @@ "configurations": { "fr": { "browserTarget": "pantheonsgame:build:fr" + }, + "local": { + "browserTarget": "pantheonsgame:build:local" } } }, @@ -118,8 +133,13 @@ "tsConfig": "src/tsconfig.spec.json", "karmaConfig": "src/karma.conf.js", "styles": [ - "src/css/mystyles.css" + "src/sass/mystyles.scss" ], + "stylePreprocessorOptions": { + "includePaths": [ + "src/sass" + ] + }, "scripts": [], "assets": [ "src/favicon.ico", diff --git a/coverage/branches.csv b/coverage/branches.csv new file mode 100644 index 000000000..26f519b94 --- /dev/null +++ b/coverage/branches.csv @@ -0,0 +1,26 @@ +AttackEpaminondasMinimax.ts,1 +AwaleRules.ts,2 +AwaleMinimax.ts,2 +AuthenticationService.ts,1 +ActivesPartsService.ts,4 +ActivesUsersService.ts,1 +count-down.component.ts,1 +Coord.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,3 +part-creation.component.ts,3 +Player.ts,1 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,3 +Rules.ts,1 +SixMinimax.ts,6 +SiamPiece.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv new file mode 100644 index 000000000..c0d4ff5ea --- /dev/null +++ b/coverage/functions.csv @@ -0,0 +1,11 @@ +AuthenticationService.ts,2 +ActivesPartsService.ts,5 +ActivesUsersService.ts,3 +Minimax.ts,1 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,2 +PieceThreat.ts,1 +PylosState.ts,1 +QuartoRules.ts,1 +server-page.component.ts,1 +SixMinimax.ts,3 diff --git a/coverage/lines.csv b/coverage/lines.csv new file mode 100644 index 000000000..682ca078b --- /dev/null +++ b/coverage/lines.csv @@ -0,0 +1,25 @@ +AwaleRules.ts,1 +AuthenticationService.ts,3 +ActivesPartsService.ts,13 +ActivesUsersService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PieceThreat.ts,1 +Player.ts,2 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 +Rules.ts,1 +server-page.component.ts,1 +SixMinimax.ts,13 +SiamPiece.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv new file mode 100644 index 000000000..b6a4bc2e3 --- /dev/null +++ b/coverage/statements.csv @@ -0,0 +1,26 @@ +AwaleRules.ts,1 +AuthenticationService.ts,3 +ActivesPartsService.ts,15 +ActivesUsersService.ts,5 +Coord.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PieceThreat.ts,1 +Player.ts,2 +PylosState.ts,2 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 +Rules.ts,1 +server-page.component.ts,1 +SixMinimax.ts,13 +SiamPiece.ts,1 diff --git a/e2e/src/app.po.ts b/e2e/src/app.po.ts index 98aa583c6..2498a15d6 100644 --- a/e2e/src/app.po.ts +++ b/e2e/src/app.po.ts @@ -1,11 +1,2 @@ -import { browser, by, element } from 'protractor'; - export class AppPage { - navigateTo() { - return browser.get('/'); - } - - getParagraphText() { - return element(by.css('app-root h1')).getText(); - } } diff --git a/firebase.json b/firebase.json index 3cf9016fa..647fd50a7 100644 --- a/firebase.json +++ b/firebase.json @@ -7,6 +7,12 @@ "**/node_modules/**" ] }, + "firestore": { + "rules": "rules/firestore.json" + }, + "database": { + "rules": "rules/database.json" + }, "emulators": { "auth": { "port": 9099 diff --git a/functions/index.ts b/functions/index.ts new file mode 100644 index 000000000..52440edbe --- /dev/null +++ b/functions/index.ts @@ -0,0 +1,41 @@ +/* eslint-disable */ +/** + * Since functions are no longer available for free use in firebase + * here is the code still running but that cannot be changed. + * Considers an user connected when an handshake with a user is made + * Consider an unser disconnected when an handshake breaks. + * Unfortunately, when a user creates two handshakes (in two browsers) + * then break one of those two, he'll become disconnected + */ +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +admin.initializeApp(); + +// Since this code will be running in the Cloud Functions environment +// we call initialize Firestore without any arguments because it +// detects authentication from the environment. +const firestore = admin.firestore(); + +// Create a new function which is triggered on changes to /status/{uid} +// Note: This is a Realtime Database trigger, *not* Cloud Firestore. +exports.onUserStatusChanged = functions.database.ref('/status/{uid}').onUpdate( + async(change: any, context: any) => { + console.log(context.params.uid + " now"); + const eventStatus = change.after.val(); + + const joueurFirestoreRef = firestore.doc(`joueurs/${context.params.uid}`); + + const userSnapshot = await change.after.ref.once('value'); + const user = userSnapshot.val(); + console.log(user, eventStatus); + if (user.last_changed > eventStatus.last_changed) { + return null; + } + eventStatus.last_changed = new Date(eventStatus.last_changed); + + return joueurFirestoreRef.set(eventStatus, { merge: true }); + }); +exports.test = functions.database.ref('/test').onUpdate( + async(change: any, context: any) => { + console.log('saluk'); + }); diff --git a/index.html b/index.html deleted file mode 100644 index 7e4f8807c..000000000 --- a/index.html +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - diff --git a/package.json b/package.json index 55c7d6d97..9a57f2817 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,14 @@ "scripts": { "ng": "ng", "start": "ng serve", + "start:emulator": "bash ./scripts/start-in-emulator.sh", "build": "ng build --configuration=production", "build:dev": "ng build --configuration=production,dev", "build:test": "ng build --configuration=test", "test": "bash ./scripts/test.sh", "test:ci": "bash ./scripts/test.sh --watch=false --browsers=ChromeHeadlessCustom", "lint": "eslint --ext ts ./", - "e2e": "ng e2e", - "css-build": "sass --no-source-map src/sass/mystyles.scss src/css/mystyles.css" + "e2e": "ng e2e" }, "private": true, "dependencies": { @@ -23,9 +23,14 @@ "@angular/platform-browser": "^11.2.14", "@angular/platform-browser-dynamic": "^11.2.14", "@angular/router": "^11.2.14", + "@creativebulma/bulma-tooltip": "^1.2.0", + "@fortawesome/angular-fontawesome": "^0.8.2", + "@fortawesome/fontawesome-svg-core": "^1.2.34", + "@fortawesome/free-solid-svg-icons": "^5.15.2", "bulma": "^0.9.2", "bulma-slider": "^2.0.4", "bulma-toast": "^2.4.1", + "bulmaswatch": "^0.8.1", "core-js": "^3.17.3", "firebase": "^8.2.3", "jquery": "^3.5.1", @@ -37,19 +42,25 @@ }, "devDependencies": { "@angular-devkit/build-angular": "^0.1102.14", + "@angular-eslint/builder": "4.3.0", + "@angular-eslint/eslint-plugin": "4.3.0", + "@angular-eslint/eslint-plugin-template": "4.3.0", + "@angular-eslint/schematics": "4.3.0", + "@angular-eslint/template-parser": "4.3.0", "@angular/cli": "^11.2.14", "@angular/compiler": "^11.2.14", "@angular/compiler-cli": "^11.2.14", "@angular/localize": "^11.2.14", "@types/jasmine": "^3.9.0", - "@typescript-eslint/eslint-plugin": "^4.14.0", - "@typescript-eslint/parser": "^4.14.0", - "eslint": "^7.18.0", + "@typescript-eslint/eslint-plugin": "4.16.1", + "@typescript-eslint/parser": "4.16.1", + "angular-eslint": "^0.0.1-alpha.0", + "eslint": "^7.6.0", "eslint-config-google": "^0.14.0", "eslint-config-standard": "^16.0.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^4.2.3", + "eslint-plugin-promise": "^5.1.0", "firebase-tools": "^9.18.0", "karma": "^6.3.2", "karma-chrome-launcher": "^3.1.0", diff --git a/rules/database.json b/rules/database.json new file mode 100644 index 000000000..a0084ec1c --- /dev/null +++ b/rules/database.json @@ -0,0 +1,7 @@ +{ + /* Visit https://firebase.google.com/docs/database/security to learn more about security rules. */ + "rules": { + ".read": true, + ".write": true + } +} diff --git a/rules/firestore.json b/rules/firestore.json new file mode 100644 index 000000000..95b2c125a --- /dev/null +++ b/rules/firestore.json @@ -0,0 +1,53 @@ +rules_version = "2"; +service cloud.firestore { + match /databases/{database}/documents { + match /foo/{document=**} { + // only used for testing purposes, do not push to production + allow read, write: if true; + } + // joueurs will be renamed to users + match /joueurs/{userId} { + // Anybody can create a user + allow create: if true; + // Only a user can update its own fields + allow update: if isOwner(userId); + // Anybody can read the fields of a user + allow read: if true; + } + match /users/{userId} { + // Anybody can create a user + allow create: if true; + // Only a user can update its own fields + allow update: if isOwner(userId); + // Anybody can read the fields of a user + allow read: if true; + } + match /{databases}/{docId} { + allow read: if true; + allow write: if isAllowed(); + } + function isAllowed() { + return userVerified() || + userIsGoogle(); + } + function userVerified() { + return isAuthentified() && + request.auth.uid != null && + request.auth.token.email_verified == true; + } + function userIsGoogle() { + return request != null && + request.auth != null && + request.auth.token.email.matches('.*google[.]com$') == true; + } + function isAuthentified() { + return request != null && + request.auth != null; + } + function isOwner(userId) { + return request != null && + request.auth != null && + request.auth.uid == userId; + } + } +} diff --git a/scripts/coverage.py b/scripts/coverage.py new file mode 100755 index 000000000..ee41c3d26 --- /dev/null +++ b/scripts/coverage.py @@ -0,0 +1,103 @@ +#!/usr/bin/python +import os +import sys +from lxml import html +import pandas +from glob import glob + +if len(sys.argv) < 2: + print('Usage: %s [generate|check]' % sys.argv[0]) + exit(1) + +def sort_function(x): + return str.lower(x[0]) +def to_missing(x): + "Converts from the string AA/BB to the number BB-AA" + [low, high] = x.split('/') + return int(high)-int(low) + +def load_coverage_data(): + files = glob("coverage/**/*.ts.html", recursive=True) + data = { + 'statements': {}, + 'branches': {}, + 'functions': {}, + 'lines': {}, + } + for path in files: + f = open(path, mode='r', encoding='utf8') + page = f.read() + f.close() + tree = html.fromstring(page) + filename = os.path.split(path)[1][:-5] + xpath_results = tree.xpath("//span[contains(@class, 'fraction')]/text()") + data['statements'][filename] = to_missing(xpath_results[0]) + data['branches'][filename] = to_missing(xpath_results[1]) + data['functions'][filename] = to_missing(xpath_results[2]) + data['lines'][filename] = to_missing(xpath_results[3]) + return data + +def load_stored_coverage_from(path): + data = pandas.read_csv(path, header=None, encoding='utf8') + files = data[0] + values = data[1] + return dict(sorted(zip(files, values), key=sort_function)) + +def load_stored_coverage(): + return { + 'statements': load_stored_coverage_from('coverage/statements.csv'), + 'branches': load_stored_coverage_from('coverage/branches.csv'), + 'functions': load_stored_coverage_from('coverage/functions.csv'), + 'lines': load_stored_coverage_from('coverage/lines.csv') + } + +def generate_in_file(data, path): + f = open(path, mode='w', encoding='utf8') + for directory in sorted(data, key=sort_function): + if data[directory] > 0: + # Only store if coverage is > 0 + f.write('%s,%d\n' % (directory, data[directory])) + f.close() + +def generate(): + data = load_coverage_data() + generate_in_file(data['statements'], 'coverage/statements.csv') + generate_in_file(data['branches'], 'coverage/branches.csv') + generate_in_file(data['functions'], 'coverage/functions.csv') + generate_in_file(data['lines'], 'coverage/lines.csv') + print('CSV files generated with success') + +def check(): + old = load_stored_coverage() + new = load_coverage_data() + + decreased = False + for type_ in ['statements', 'branches', 'functions', 'lines']: + for directory in set.union(set(old[type_]), set(new[type_])): + if directory in old[type_] and directory in new[type_]: + new_missing = new[type_][directory] + old_missing = old[type_][directory] + if new_missing > old_missing: + decreased = True + print('ERROR: increased missing %s in coverage of %s, from %d to %d' % (type_, directory, old_missing, new_missing)) + elif new_missing < old_missing: + print('GOOD: decreased missing %s in coverage of %s, from %d to %d' % (type_, directory, old_missing, new_missing)) + elif not (directory in new[type_]): + # directory was removed, everything is fine + continue + elif not (directory in old[type_]): + # new directory, we require 100% coverage + new_missing = new[type_][directory] + if new_missing > 0: + decreased = True + print('ERROR: increased missing %s in coverage of %s: uncovered %d %s' % (type_, directory, new_missing, type_)) + if decreased: + exit(1) # fail for CI script + +if sys.argv[1] == 'check': + check() +elif sys.argv[1] == 'generate': + generate() +else: + print('Usage: %s [generate|check]' % sys.argv[0]) + exit(1) diff --git a/scripts/max-uncovered.py b/scripts/max-uncovered.py deleted file mode 100755 index 61fa40f39..000000000 --- a/scripts/max-uncovered.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/python -from lxml import html - -f = open('coverage/index.html',mode='r') -page = f.read() -f.close() -tree = html.fromstring(page) - -def to_missing(x): - "Converts from the string AA/BB to the number BB-AA" - [low, high] = x.split('/') - return int(high)-int(low) - -dirs = tree.xpath('//td/a/text()') -statements = map(to_missing, tree.xpath('//tr/td[4]/text()')) -branches = map(to_missing, tree.xpath('//tr/td[6]/text()')) - -print('Missing branches:') -total_branches = 0 -for (d, b) in sorted(zip(dirs, branches), key=lambda x: -x[1]): - if b > 0: - print('%s: %d' % (d, b)) - total_branches += b - -print('Total: %d' % total_branches) - -print('') -print('Missing statements:') -total_statements = 0 -for (d, s) in sorted(zip(dirs, statements), key=lambda x: -x[1]): - if s > 0: - print('%s: %d' % (d, s)) - total_statements += s -print('Total: %d' % total_statements) diff --git a/scripts/square-image.py b/scripts/square-image.py new file mode 100644 index 000000000..52dd9f5bb --- /dev/null +++ b/scripts/square-image.py @@ -0,0 +1,27 @@ +from PIL import Image, ImageOps +import sys + +if len(sys.argv) != 2: + print('I need an image file path as argument') + +im_pth = sys.argv[1] +print('Squaring %s' % im_pth) + +im = Image.open(im_pth) +old_size = im.size +# We preserve the image quality, so keep the max of its width/height +desired_size = max(im.size[0], im.size[1]) + +ratio = float(desired_size)/max(old_size) +new_size = tuple([int(x*ratio) for x in old_size]) + +# resize the image so that it is square +im = im.resize(new_size, Image.ANTIALIAS) + +# we pad with the color present in (0, 0), which is supposed to be the background color +color = im.getpixel((3, 3)) +new_im = Image.new("RGB", (desired_size, desired_size), color) +new_im.paste(im, ((desired_size-new_size[0])//2, + (desired_size-new_size[1])//2)) + +new_im.save(sys.argv[1]) diff --git a/scripts/start-in-emulator.sh b/scripts/start-in-emulator.sh new file mode 100644 index 000000000..1f06ba919 --- /dev/null +++ b/scripts/start-in-emulator.sh @@ -0,0 +1,3 @@ +#!/bin/sh +ARGS="$@" +npx firebase emulators:exec --only firestore,auth,database --import=./data/ --export-on-exit --project 'my-project' "ng serve --configuration local $ARGS" --ui diff --git a/scripts/test.sh b/scripts/test.sh index 787bea00d..f546a6be1 100644 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,3 +1,3 @@ #!/bin/sh ARGS="$@" -npx firebase emulators:exec --only firestore --project 'testing' "ng test --configuration local --code-coverage $ARGS" +npx firebase emulators:exec --only firestore,auth,database --project 'my-project' "ng test --configuration local --code-coverage $ARGS" --ui diff --git a/scripts/tmp b/scripts/tmp new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/update-images.sh b/scripts/update-images.sh index e7e20e6b6..7c06e8cfe 100755 --- a/scripts/update-images.sh +++ b/scripts/update-images.sh @@ -1,9 +1,24 @@ #!/bin/sh -npm start & -sleep 100 # wait until NPM has started +npm start& NPM="$!" +sleep 100 # wait until npm has started + grep "new GameInfo" src/app/components/normal-component/pick-game/pick-game.component.ts | sed "s/.*new GameInfo([^,]*, '\([^']*\)'.*/\1/" > scripts/games.txt +python scripts/screenshot.py || exit +mv *.png src/assets/images/light/ + +# Change theme by simply copying the CSS +cp src/sass/light.scss src/sass/light.scss.tmp +cp src/sass/dark.scss src/sass/light.scss +sleep 10 # Need to wait a bit for ng to refresh + python scripts/screenshot.py -kill $NPM -mv *.png src/assets/images/ +mv *.png src/assets/images/dark/ +# Restore the CSS +mv src/sass/light.scss.tmp src/sass/light.scss rm scripts/games.txt +for image in $(ls src/assets/images/dark/*.png src/assets/images/light/*.png); do + python scripts/square-image.py $image +done + +kill $NPM diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f8af3a422..b2e1128b1 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,7 +1,10 @@ import { Component } from '@angular/core'; +import { ThemeService } from './services/ThemeService'; @Component({ selector: 'app-root', templateUrl: './app.component.html', }) -export class AppComponent { } +export class AppComponent { + constructor(private _themeService: ThemeService) {} +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 8262e94b2..17ad6e506 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -20,9 +20,6 @@ import { AuthenticationService } from './services/AuthenticationService'; import { GameService } from './services/GameService'; import { JoinerService } from './services/JoinerService'; -import { EmailVerified } from './guard/EmailVerified'; -import { MustVerifyEmail } from './guard/MustVerifyEmail'; - import { AppComponent } from './app.component'; import { HeaderComponent } from './components/normal-component/header/header.component'; import { WelcomeComponent } from './components/normal-component/welcome/welcome.component'; @@ -40,9 +37,7 @@ import { LocalGameWrapperComponent } import { TutorialGameWrapperComponent } from './components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component'; import { GameIncluderComponent } from './components/game-components/game-includer/game-includer.component'; -import { InscriptionComponent } from './components/normal-component/inscription/inscription.component'; -import { ConfirmInscriptionComponent } - from './components/normal-component/confirm-inscription/confirm-inscription.component'; +import { RegisterComponent } from './components/normal-component/register/register.component'; import { LocalGameCreationComponent } from './components/normal-component/local-game-creation/local-game-creation.component'; import { OnlineGameCreationComponent } @@ -53,8 +48,11 @@ import { HumanDuration } from './utils/TimeUtils'; import { NextGameLoadingComponent } from './components/normal-component/next-game-loading/next-game-loading.component'; import { AbaloneComponent } from './games/abalone/abalone.component'; +import { ApagosComponent } from './games/apagos/apagos.component'; import { AwaleComponent } from './games/awale/awale.component'; +import { BrandhubComponent } from './games/tafl/brandhub/brandhub.component'; import { CoerceoComponent } from './games/coerceo/coerceo.component'; +import { DiamComponent } from './games/diam/diam.component'; import { DvonnComponent } from './games/dvonn/dvonn.component'; import { EncapsuleComponent } from './games/encapsule/encapsule.component'; import { EpaminondasComponent } from './games/epaminondas/epaminondas.component'; @@ -72,26 +70,41 @@ import { ReversiComponent } from './games/reversi/reversi.component'; import { SaharaComponent } from './games/sahara/sahara.component'; import { SiamComponent } from './games/siam/siam.component'; import { SixComponent } from './games/six/six.component'; -import { TablutComponent } from './games/tablut/tablut.component'; +import { TablutComponent } from './games/tafl/tablut/tablut.component'; import { YinshComponent } from './games/yinsh/yinsh.component'; import { environment } from 'src/environments/environment'; import { USE_EMULATOR as USE_FIRESTORE_EMULATOR } from '@angular/fire/firestore'; +import { USE_EMULATOR as USE_DATABASE_EMULATOR } from '@angular/fire/database'; +import { USE_EMULATOR as USE_AUTH_EMULATOR } from '@angular/fire/auth'; +import { USE_EMULATOR as USE_FUNCTIONS_EMULATOR } from '@angular/fire/functions'; import { LocaleUtils } from './utils/LocaleUtils'; +import { VerifiedAccountGuard } from './guard/verified-account.guard'; +import { VerifyAccountComponent } from './components/normal-component/verify-account/verify-account.component'; +import { ConnectedButNotVerifiedGuard } from './guard/connected-but-not-verified.guard'; +import { NotConnectedGuard } from './guard/not-connected.guard'; +import { AutofocusDirective } from './directives/autofocus.directive'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { ToggleVisibilityDirective } from './directives/toggle-visibility.directive'; +import { ResetPasswordComponent } from './components/normal-component/reset-password/reset-password.component'; +import { ThemeService } from './services/ThemeService'; +import { SettingsComponent } from './components/normal-component/settings/settings.component'; registerLocaleData(localeFr); const routes: Route [] = [ { path: 'login', component: LoginComponent }, - { path: 'server', component: ServerPageComponent, canActivate: [EmailVerified] }, - { path: 'inscription', component: InscriptionComponent }, - { path: 'confirm-inscription', component: ConfirmInscriptionComponent, canActivate: [MustVerifyEmail] }, - { path: 'notFound', component: NotFoundComponent, canActivate: [EmailVerified] }, - { path: 'nextGameLoading', component: NextGameLoadingComponent, canActivate: [EmailVerified] }, + { path: 'server', component: ServerPageComponent, canActivate: [VerifiedAccountGuard] }, + { path: 'settings', component: SettingsComponent }, + { path: 'register', component: RegisterComponent, canActivate: [NotConnectedGuard] }, + { path: 'reset-password', component: ResetPasswordComponent, canActivate: [NotConnectedGuard] }, + { path: 'notFound', component: NotFoundComponent, canActivate: [VerifiedAccountGuard] }, + { path: 'nextGameLoading', component: NextGameLoadingComponent, canActivate: [VerifiedAccountGuard] }, + { path: 'verify-account', component: VerifyAccountComponent, canActivate: [ConnectedButNotVerifiedGuard] }, - { path: 'play', component: OnlineGameCreationComponent, canActivate: [EmailVerified] }, - { path: 'play/:compo/:id', component: OnlineGameWrapperComponent, canActivate: [EmailVerified] }, + { path: 'play', component: OnlineGameCreationComponent, canActivate: [VerifiedAccountGuard] }, + { path: 'play/:compo/:id', component: OnlineGameWrapperComponent, canActivate: [VerifiedAccountGuard] }, { path: 'local', component: LocalGameCreationComponent }, { path: 'local/:compo', component: LocalGameWrapperComponent }, { path: 'tutorial', component: TutorialGameCreationComponent }, @@ -110,7 +123,7 @@ const routes: Route [] = [ PickGameComponent, ChatComponent, PartCreationComponent, - InscriptionComponent, + RegisterComponent, NotFoundComponent, NextGameLoadingComponent, CountDownComponent, @@ -118,14 +131,19 @@ const routes: Route [] = [ LocalGameWrapperComponent, TutorialGameWrapperComponent, GameIncluderComponent, - ConfirmInscriptionComponent, LocalGameCreationComponent, OnlineGameCreationComponent, TutorialGameCreationComponent, + VerifyAccountComponent, + ResetPasswordComponent, + SettingsComponent, AbaloneComponent, + ApagosComponent, AwaleComponent, + BrandhubComponent, CoerceoComponent, + DiamComponent, DvonnComponent, EncapsuleComponent, EpaminondasComponent, @@ -147,29 +165,8 @@ const routes: Route [] = [ YinshComponent, HumanDuration, - ], - entryComponents: [ - AbaloneComponent, - AwaleComponent, - DvonnComponent, - EncapsuleComponent, - EpaminondasComponent, - GipfComponent, - GoComponent, - KamisadoComponent, - LinesOfActionComponent, - MinimaxTestingComponent, - P4Component, - PentagoComponent, - PylosComponent, - QuartoComponent, - QuixoComponent, - ReversiComponent, - SaharaComponent, - SiamComponent, - SixComponent, - TablutComponent, - YinshComponent, + AutofocusDirective, + ToggleVisibilityDirective, ], imports: [ BrowserModule, @@ -180,9 +177,13 @@ const routes: Route [] = [ AngularFireModule.initializeApp(environment.firebaseConfig), AngularFirestoreModule, BrowserAnimationsModule, + FontAwesomeModule, ], providers: [ + { provide: USE_AUTH_EMULATOR, useValue: environment.emulatorConfig.auth }, + { provide: USE_DATABASE_EMULATOR, useValue: environment.emulatorConfig.database }, { provide: USE_FIRESTORE_EMULATOR, useValue: environment.emulatorConfig.firestore }, + { provide: USE_FUNCTIONS_EMULATOR, useValue: environment.emulatorConfig.functions }, AuthenticationService, GameService, JoinerService, @@ -190,6 +191,7 @@ const routes: Route [] = [ ChatService, PartDAO, AngularFireAuth, + ThemeService, { provide: LOCALE_ID, useValue: LocaleUtils.getLocale() }, ], bootstrap: [AppComponent], diff --git a/src/app/components/game-components/GameComponentUtils.ts b/src/app/components/game-components/GameComponentUtils.ts index 86b960bab..cf5d091a8 100644 --- a/src/app/components/game-components/GameComponentUtils.ts +++ b/src/app/components/game-components/GameComponentUtils.ts @@ -1,5 +1,6 @@ import { Coord } from 'src/app/jscaip/Coord'; import { Orthogonal } from 'src/app/jscaip/Direction'; +import { Utils } from 'src/app/utils/utils'; export class GameComponentUtils { public static getArrowTransform(caseSize: number, coord: Coord, direction: Orthogonal): string { @@ -22,7 +23,8 @@ export class GameComponentUtils { dy = 0; angle = 180; break; - case Orthogonal.RIGHT: + default: + Utils.expectToBe(direction, Orthogonal.RIGHT); dx = 1; dy = 0; angle = 0; diff --git a/src/app/components/game-components/game-component/GameComponent.spec.ts b/src/app/components/game-components/game-component/GameComponent.spec.ts index d958d01c3..d4609f1b9 100644 --- a/src/app/components/game-components/game-component/GameComponent.spec.ts +++ b/src/app/components/game-components/game-component/GameComponent.spec.ts @@ -3,9 +3,19 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { AppModule } from 'src/app/app.module'; +import { ChatDAO } from 'src/app/dao/ChatDAO'; +import { JoinerDAO } from 'src/app/dao/JoinerDAO'; +import { UserDAO } from 'src/app/dao/UserDAO'; +import { PartDAO } from 'src/app/dao/PartDAO'; +import { ChatDAOMock } from 'src/app/dao/tests/ChatDAOMock.spec'; +import { JoinerDAOMock } from 'src/app/dao/tests/JoinerDAOMock.spec'; +import { UserDAOMock } from 'src/app/dao/tests/UserDAOMock.spec'; +import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; +import { DiamPiece } from 'src/app/games/diam/DiamPiece'; import { EncapsulePiece } from 'src/app/games/encapsule/EncapsulePiece'; import { Direction } from 'src/app/jscaip/Direction'; -import { AuthenticationService } from 'src/app/services/AuthenticationService'; +import { Player } from 'src/app/jscaip/Player'; +import { AuthenticationService, AuthUser } from 'src/app/services/AuthenticationService'; import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { ActivatedRouteStub } from 'src/app/utils/tests/TestUtils.spec'; @@ -13,6 +23,7 @@ import { GameInfo, PickGameComponent } from '../../normal-component/pick-game/pi import { GameWrapperMessages } from '../../wrapper-components/GameWrapper'; import { LocalGameWrapperComponent } from '../../wrapper-components/local-game-wrapper/local-game-wrapper.component'; import { AbstractGameComponent } from './GameComponent'; +import { Utils } from 'src/app/utils/utils'; describe('GameComponent', () => { @@ -31,13 +42,40 @@ describe('GameComponent', () => { RouterTestingModule.withRoutes([ { path: 'local', component: LocalGameWrapperComponent }]), ], + declarations: [ + LocalGameWrapperComponent, + ], schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [ - { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: AuthenticationService, useClass: AuthenticationServiceMock }, + { provide: PartDAO, useClass: PartDAOMock }, + { provide: JoinerDAO, useClass: JoinerDAOMock }, + { provide: ChatDAO, useClass: ChatDAOMock }, + { provide: UserDAO, useClass: UserDAOMock }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, ], }).compileComponents(); - AuthenticationServiceMock.setUser(AuthenticationService.NOT_CONNECTED); + AuthenticationServiceMock.setUser(AuthUser.NOT_CONNECTED); + })); + it('should fail if pass() is called on a game that does not support it', fakeAsync(async() => { + spyOn(Utils, 'handleError').and.returnValue(null); + // given such a game, like Abalone + activatedRouteStub.setRoute('compo', 'Abalone'); + fixture = TestBed.createComponent(LocalGameWrapperComponent); + component = fixture.debugElement.componentInstance; + component.observerRole = 1; + fixture.detectChanges(); + tick(1); + expect(component.gameComponent).toBeDefined(); + + // when we try to pass + const result: MGPValidation = await component.gameComponent.pass(); + + // then it gives an error and handleError is called + const error: string = 'GameComponent.pass() called on a game that does not redefine it'; + expect(result.isFailure()).toBeTrue(); + expect(result.getReason()).toEqual(error); + expect(Utils.handleError).toHaveBeenCalledWith(error); })); it('Clicks method should refuse when observer click', fakeAsync(async() => { const clickableMethods: { [gameName: string]: { [methodName: string]: unknown[] } } = { @@ -46,8 +84,18 @@ describe('GameComponent', () => { onCaseClick: [0, 0], chooseDirection: [Direction.UP], }, + Apagos: { + onSquareClick: [0], + onArrowClick: [0, Player.ONE], + }, Awale: { onClick: [0, 0] }, + Brandhub: { onClick: [0, 0] }, Coerceo: { onClick: [0, 0] }, + Diam: { + onSpaceClick: [0], + onPieceInGameClick: [0, 0], + onRemainingPieceClick: [DiamPiece.ZERO_FIRST], + }, Dvonn: { onClick: [0, 0] }, Encapsule: { onBoardClick: [0, 0], @@ -90,7 +138,7 @@ describe('GameComponent', () => { }, Six: { onPieceClick: [0, 0], - onNeighboorClick: [0, 0], + onNeighborClick: [0, 0], }, Tablut: { onClick: [0, 0] }, Yinsh: { onClick: [0, 0] }, @@ -110,6 +158,7 @@ describe('GameComponent', () => { tick(1); expect(component.gameComponent).toBeDefined(); for (const methodName of Object.keys(game)) { + expect(component.gameComponent[methodName]).withContext(`click method ${methodName} should be defined for game ${gameName}`).toBeDefined(); const clickResult: MGPValidation = await component.gameComponent[methodName](...game[methodName]); expect(clickResult).toEqual(refusal); } @@ -125,9 +174,7 @@ describe('GameComponent', () => { TestBed.createComponent(gameInfo.component).debugElement.componentInstance; expect(gameComponent.encoder).withContext('Encoder missing for ' + gameInfo.urlName).toBeTruthy(); expect(gameComponent.tutorial).withContext('tutorial missing for ' + gameInfo.urlName).toBeTruthy(); - if (gameComponent.tutorial) { - expect(gameComponent.tutorial.length).withContext('tutorial empty for ' + gameInfo.urlName).toBeGreaterThan(0); - } + expect(gameComponent.tutorial.length).withContext('tutorial empty for ' + gameInfo.urlName).toBeGreaterThan(0); } })); }); diff --git a/src/app/components/game-components/game-component/GameComponent.ts b/src/app/components/game-components/game-component/GameComponent.ts index 1fbfa43fa..89bd8fc81 100644 --- a/src/app/components/game-components/game-component/GameComponent.ts +++ b/src/app/components/game-components/game-component/GameComponent.ts @@ -1,6 +1,5 @@ import { Move } from '../../../jscaip/Move'; import { Rules } from '../../../jscaip/Rules'; -import { LegalityStatus } from 'src/app/jscaip/LegalityStatus'; import { Component } from '@angular/core'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { Player } from 'src/app/jscaip/Player'; @@ -8,7 +7,10 @@ import { Minimax } from 'src/app/jscaip/Minimax'; import { MoveEncoder } from 'src/app/jscaip/Encoder'; import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; import { TutorialStep } from '../../wrapper-components/tutorial-game-wrapper/TutorialStep'; -import { AbstractGameState } from 'src/app/jscaip/GameState'; +import { GameState } from 'src/app/jscaip/GameState'; +import { Utils } from 'src/app/utils/utils'; +import { of } from 'rxjs'; +import { MGPOptional } from 'src/app/utils/MGPOptional'; /** @@ -18,16 +20,16 @@ import { AbstractGameState } from 'src/app/jscaip/GameState'; */ @Component({ template: '', - styleUrls: ['./game-component.css'], + styleUrls: ['./game-component.scss'], }) export abstract class GameComponent, M extends Move, - S extends AbstractGameState, - L extends LegalityStatus = LegalityStatus> + S extends GameState, + L = void> { public encoder: MoveEncoder; - public CASE_SIZE: number = 100; + public SPACE_SIZE: number = 100; public readonly STROKE_WIDTH: number = 8; @@ -35,11 +37,11 @@ export abstract class GameComponent, public rules: R; - public availableMinimaxes: Minimax[]; + public availableMinimaxes: Minimax[]; public canPass: boolean; - public showScore: boolean; + public scores: MGPOptional = MGPOptional.empty(); public imagesLocation: string = 'assets/images/'; @@ -49,8 +51,7 @@ export abstract class GameComponent, public chooseMove: (move: M, state: S, - scorePlayerZero: number, - scorePlayerOne: number) => Promise; + scores?: readonly [number, number]) => Promise; public canUserPlay: (element: string) => MGPValidation; @@ -88,15 +89,32 @@ export abstract class GameComponent, switch (player) { case Player.ZERO: return 'player0'; case Player.ONE: return 'player1'; - case Player.NONE: return ''; + default: + Utils.expectToBe(player, Player.NONE); + return ''; } } + public pass(): Promise { + Utils.handleError('GameComponent.pass() called on a game that does not redefine it'); + return of(MGPValidation.failure('GameComponent.pass() called on a game that does not redefine it')).toPromise(); + } public getTurn(): number { return this.rules.node.gameState.turn; } + public getCurrentPlayer(): Player { + return this.rules.node.gameState.getCurrentPlayer(); + } + public getState(): S { + return this.rules.node.gameState; + } + public getPreviousState(): S { + return this.rules.node.mother.get().gameState; + } } -export abstract class AbstractGameComponent extends GameComponent, +export abstract class AbstractGameComponent extends GameComponent, Move, - AbstractGameState> { + GameState, unknown> +{ } + diff --git a/src/app/components/game-components/game-component/HexagonalGameComponent.ts b/src/app/components/game-components/game-component/HexagonalGameComponent.ts index 06c7f7fd0..3ef025426 100644 --- a/src/app/components/game-components/game-component/HexagonalGameComponent.ts +++ b/src/app/components/game-components/game-component/HexagonalGameComponent.ts @@ -1,8 +1,7 @@ import { Component } from '@angular/core'; import { Coord } from 'src/app/jscaip/Coord'; -import { AbstractGameState } from 'src/app/jscaip/GameState'; +import { GameState } from 'src/app/jscaip/GameState'; import { HexaLayout } from 'src/app/jscaip/HexaLayout'; -import { LegalityStatus } from 'src/app/jscaip/LegalityStatus'; import { Move } from 'src/app/jscaip/Move'; import { Rules } from 'src/app/jscaip/Rules'; import { Table } from 'src/app/utils/ArrayUtils'; @@ -11,9 +10,9 @@ import { GameComponent } from './GameComponent'; @Component({ template: '' }) export abstract class HexagonalGameComponent, M extends Move, - S extends AbstractGameState, + S extends GameState, P, - L extends LegalityStatus = LegalityStatus> + L = void> extends GameComponent { diff --git a/src/app/components/game-components/game-component/TriangularGameComponent.ts b/src/app/components/game-components/game-component/TriangularGameComponent.ts index 7e2202c08..379505d9f 100644 --- a/src/app/components/game-components/game-component/TriangularGameComponent.ts +++ b/src/app/components/game-components/game-component/TriangularGameComponent.ts @@ -1,6 +1,5 @@ import { Component } from '@angular/core'; import { Coord } from 'src/app/jscaip/Coord'; -import { LegalityStatus } from 'src/app/jscaip/LegalityStatus'; import { Move } from 'src/app/jscaip/Move'; import { GameComponent } from './GameComponent'; import { GameState } from 'src/app/jscaip/GameState'; @@ -10,12 +9,12 @@ import { Rules } from 'src/app/jscaip/Rules'; @Component({ template: '' }) export abstract class TriangularGameComponent, M extends Move, - S extends GameState, + S extends GameState, P, - L extends LegalityStatus = LegalityStatus> + L = void> extends GameComponent { - public CASE_SIZE: number = 50; + public SPACE_SIZE: number = 50; public board: Table

; @@ -29,22 +28,22 @@ export abstract class TriangularGameComponent, return strings.reduce((sum: string, last: string) => sum + ',' + last); } public getDownwardCoordinate(x: number, y: number): Coord[] { - const left: number = this.CASE_SIZE * 0.5 * x; - const middle: number = this.CASE_SIZE * 0.5 * (x + 1); - const right: number = this.CASE_SIZE * 0.5 * (x + 2); - const top: number = this.CASE_SIZE * y; - const bottom: number = this.CASE_SIZE * (y + 1); + const left: number = this.SPACE_SIZE * 0.5 * x; + const middle: number = this.SPACE_SIZE * 0.5 * (x + 1); + const right: number = this.SPACE_SIZE * 0.5 * (x + 2); + const top: number = this.SPACE_SIZE * y; + const bottom: number = this.SPACE_SIZE * (y + 1); const leftCorner: Coord = new Coord(left, top); const middleCorner: Coord = new Coord(middle, bottom); const rightCorner: Coord = new Coord(right, top); return [leftCorner, middleCorner, rightCorner, leftCorner]; } public getUpwardCoordinate(x: number, y: number): Coord[] { - const left: number = this.CASE_SIZE * 0.5 * x; - const middle: number = this.CASE_SIZE * 0.5 * (x + 1); - const right: number = this.CASE_SIZE * 0.5 * (x + 2); - const top: number = this.CASE_SIZE * y; - const bottom: number = this.CASE_SIZE * (y + 1); + const left: number = this.SPACE_SIZE * 0.5 * x; + const middle: number = this.SPACE_SIZE * 0.5 * (x + 1); + const right: number = this.SPACE_SIZE * 0.5 * (x + 2); + const top: number = this.SPACE_SIZE * y; + const bottom: number = this.SPACE_SIZE * (y + 1); const leftCorner: Coord = new Coord(left, bottom); const middleCorner: Coord = new Coord(middle, top); const rightCorner: Coord = new Coord(right, bottom); @@ -55,12 +54,12 @@ export abstract class TriangularGameComponent, else return this.getUpwardPyramidCoordinate(x, y); } public getDownwardPyramidCoordinate(x: number, y: number): string { - const zx: number = this.CASE_SIZE * x / 2; - const zy: number = this.CASE_SIZE * y; + const zx: number = this.SPACE_SIZE * x / 2; + const zy: number = this.SPACE_SIZE * y; const UP_LEFT: string = zx + ', ' + zy; - const UP_RIGHT: string = (zx+this.CASE_SIZE) + ', ' + zy; - const DOWN_CENTER: string = (zx+(this.CASE_SIZE/2)) + ', ' + (zy+this.CASE_SIZE); - const CENTER: string = (zx+(this.CASE_SIZE / 2)) + ', ' + (zy+(this.CASE_SIZE / 2)); + const UP_RIGHT: string = (zx+this.SPACE_SIZE) + ', ' + zy; + const DOWN_CENTER: string = (zx+(this.SPACE_SIZE/2)) + ', ' + (zy+this.SPACE_SIZE); + const CENTER: string = (zx+(this.SPACE_SIZE / 2)) + ', ' + (zy+(this.SPACE_SIZE / 2)); return UP_LEFT + ',' + DOWN_CENTER + ',' + CENTER + ',' + @@ -74,12 +73,12 @@ export abstract class TriangularGameComponent, UP_RIGHT; } public getUpwardPyramidCoordinate(x: number, y: number): string { - const zx: number = this.CASE_SIZE * x / 2; - const zy: number = (y + 1) * this.CASE_SIZE; + const zx: number = this.SPACE_SIZE * x / 2; + const zy: number = (y + 1) * this.SPACE_SIZE; const DOWN_LEFT: string = zx + ', ' + zy; - const DOWN_RIGHT: string = (zx + this.CASE_SIZE) + ', ' + zy; - const UP_CENTER: string = (zx + (this.CASE_SIZE / 2)) + ', ' + (zy - this.CASE_SIZE); - const CENTER: string = (zx + (this.CASE_SIZE / 2)) + ', ' + (zy- (this.CASE_SIZE / 2)); + const DOWN_RIGHT: string = (zx + this.SPACE_SIZE) + ', ' + zy; + const UP_CENTER: string = (zx + (this.SPACE_SIZE / 2)) + ', ' + (zy - this.SPACE_SIZE); + const CENTER: string = (zx + (this.SPACE_SIZE / 2)) + ', ' + (zy- (this.SPACE_SIZE / 2)); return DOWN_LEFT + ',' + UP_CENTER + ',' + CENTER + ',' + diff --git a/src/app/components/game-components/game-component/game-component.css b/src/app/components/game-components/game-component/game-component.scss similarity index 51% rename from src/app/components/game-components/game-component/game-component.css rename to src/app/components/game-components/game-component/game-component.scss index 861a2aea6..76a3fd7df 100644 --- a/src/app/components/game-components/game-component/game-component.css +++ b/src/app/components/game-components/game-component/game-component.scss @@ -1,98 +1,115 @@ .base { - stroke: black; + stroke: var(--base-stroke); stroke-width: 8; - fill: lightgrey; + fill: var(--background-fill); + stroke-linecap: butt; + stroke-linejoin: round; } .base-no-fill { - stroke: black; + stroke: var(--base-stroke); stroke-width: 8; } .arrow { - stroke: black; + stroke: var(--base-stroke); stroke-width: 3; } +.text { + fill: var(--base-stroke); +} .white-background { fill: white; } +.white-stroke { + stroke: white; +} .background { - fill: lightgray; + fill: var(--background-fill); } .background2 { - fill: gray; + fill: var(--alt-background-fill); } .background3 { - fill: dimgray; + fill: var(--alt-alt-background-fill); } .player0 { - fill: #994d00; + fill: var(--player0); +} +.player0-alternate { + fill: var(--player0-alternate); } .player0-stroke { - stroke: #994d00; + stroke: var(--player0); } .player1 { - fill: #ffc34d; + fill: var(--player1); +} +.player1-alternate { + fill: var(--player1-alternate); } .player1-stroke { - stroke: #ffc34d; + stroke: var(--player1); } .other-piece { - fill: #5c0838; + fill: var(--nonplayer); } .other-piece-light { - fill: #8d2660; + fill: var(--nonplayer-light); } .other-piece-stroke { - stroke: #5c0838; + stroke: var(--nonplayer); } .dashed-stroke { stroke-dasharray: 2; } .pre-captured { - fill: pink; + fill: var(--pre-captured); } .captured { - fill: red; + fill: var(--captured); } .captured2 { - fill: #990000; + fill: var(--alt-captured); } .captured-stroke { - stroke: red; + stroke: var(--captured); } .moved { - fill: gray; + fill: var(--moved); } .moved-stroke { - stroke: gray; + stroke: var(--moved); } .indicator { - fill: green; + fill: var(--indicator); stroke: none; } .highlighted { - stroke: #e6e600; + stroke: var(--selectable); } .last-move { - stroke: orange; + stroke: var(--last-move); } .victory { - fill: #e6e600; + fill: var(--victory); } .victory-stroke { - stroke: #e6e600; + stroke: var(--victory); } .selected { - stroke: blue; + stroke: var(--selected); } .selected-fill { - fill: blue; + fill: var(--selected); } .clickable { - stroke: yellow; + stroke: var(--clickable); +} +.clickable-hover:hover { + stroke: var(--clickable); } .capturable { stroke-width: 2; - stroke: yellow; + stroke: var(--capturable); } .capturable:hover { stroke-width: 8; @@ -106,6 +123,9 @@ .small-stroke { stroke-width: 2; } +.mid-small-stroke { + stroke-width: 3; +} .mid-stroke { stroke-width: 5; } diff --git a/src/app/components/game-components/rectangular-game-component/RectangularGameComponent.ts b/src/app/components/game-components/rectangular-game-component/RectangularGameComponent.ts index cef320074..f30b15828 100644 --- a/src/app/components/game-components/rectangular-game-component/RectangularGameComponent.ts +++ b/src/app/components/game-components/rectangular-game-component/RectangularGameComponent.ts @@ -1,5 +1,4 @@ import { Move } from '../../../jscaip/Move'; -import { LegalityStatus } from 'src/app/jscaip/LegalityStatus'; import { Component } from '@angular/core'; import { GameStateWithTable } from 'src/app/jscaip/GameStateWithTable'; import { GameComponent } from '../game-component/GameComponent'; @@ -13,7 +12,7 @@ export abstract class RectangularGameComponent, M extends Move, S extends GameStateWithTable

, P, - L extends LegalityStatus = LegalityStatus> + L = void> extends GameComponent { diff --git a/src/app/components/normal-component/chat/chat.component.html b/src/app/components/normal-component/chat/chat.component.html index aad9de52c..9488bbadf 100644 --- a/src/app/components/normal-component/chat/chat.component.html +++ b/src/app/components/normal-component/chat/chat.component.html @@ -46,8 +46,9 @@ + (click)="sendMessage()"> + +

@@ -58,6 +59,8 @@
-

Only connected users can see the chat

+
+

Only connected users can see the chat.

+
diff --git a/src/app/components/normal-component/chat/chat.component.spec.ts b/src/app/components/normal-component/chat/chat.component.spec.ts index 5675af111..8775b27ce 100644 --- a/src/app/components/normal-component/chat/chat.component.spec.ts +++ b/src/app/components/normal-component/chat/chat.component.spec.ts @@ -1,6 +1,6 @@ import { fakeAsync, TestBed } from '@angular/core/testing'; import { ChatComponent } from './chat.component'; -import { AuthenticationService } from 'src/app/services/AuthenticationService'; +import { AuthUser } from 'src/app/services/AuthenticationService'; import { ChatService } from 'src/app/services/ChatService'; import { ChatDAO } from 'src/app/dao/ChatDAO'; import { DebugElement } from '@angular/core'; @@ -37,211 +37,225 @@ describe('ChatComponent', () => { component.turn = 2; chatService = TestBed.inject(ChatService); chatDAO = TestBed.inject(ChatDAO); - chatDAO.set('fauxChat', { messages: [] }); - })); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should not observe (load messages) and show disconnected chat for unlogged user', fakeAsync(async() => { - spyOn(chatService, 'startObserving'); + await chatDAO.set('fauxChat', { messages: [] }); spyOn(chatService, 'stopObserving'); - spyOn(component, 'loadChatContent'); - // given a user that is not connected - AuthenticationServiceMock.setUser(AuthenticationService.NOT_CONNECTED); - - // when the component is initialized - component.ngOnInit(); - testUtils.detectChanges(); - - // It should not observe, not load the chat content, and show the disconnected chat - expect(chatService.startObserving).not.toHaveBeenCalled(); - expect(component.loadChatContent).not.toHaveBeenCalled(); - testUtils.expectElementToExist('#disconnected-chat'); - - component.ngOnDestroy(); - await testUtils.whenStable(); - expect(chatService.stopObserving).not.toHaveBeenCalled(); - })); - it('should propose to hide chat when chat is visible, and work', fakeAsync(async() => { - // Given the user is connected - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - testUtils.detectChanges(); - let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); - const chat: DebugElement = testUtils.findElement('#chatForm'); - expect(switchButton.nativeElement.innerText).toEqual('Hide chat'); - expect(chat).withContext('Chat should be visible on init').toBeTruthy(); - - // when switching the chat visibility - testUtils.clickElement('#switchChatVisibilityButton'); - testUtils.detectChanges(); - - switchButton = testUtils.findElement('#switchChatVisibilityButton'); - // Then the chat is not visible and the button changes its text - expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'); - testUtils.expectElementNotToExist('#chatDiv'); - testUtils.expectElementNotToExist('#chatForm'); - })); - it('should propose to show chat when chat is hidden, and work', fakeAsync(async() => { - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); - testUtils.detectChanges(); - - // Given that the chat is hidden - let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); - let chat: DebugElement = testUtils.findElement('#chatForm'); - expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'); - expect(chat).withContext('Chat should be hidden').toBeFalsy(); - - // when showing the chat - testUtils.clickElement('#switchChatVisibilityButton'); - testUtils.detectChanges(); - - // then the chat is shown - switchButton = testUtils.findElement('#switchChatVisibilityButton'); - chat = testUtils.findElement('#chatForm'); - expect(switchButton.nativeElement.innerText).toEqual('Hide chat'); - expect(chat).withContext('Chat should be visible after calling show').toBeTruthy(); })); - it('should show how many messages where sent since you hide the chat', fakeAsync(async() => { - // Given a hidden chat with no message - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); - testUtils.detectChanges(); - let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); - expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'); - - // when a new message is received - await chatDAO.update('fauxChat', { messages: [MSG, MSG, MSG] }); - testUtils.detectChanges(); - - // then the button shows how many new messages there are - switchButton = testUtils.findElement('#switchChatVisibilityButton'); - expect(switchButton.nativeElement.innerText).toEqual('Show chat (3 new messages)'); - })); - it('should scroll to the bottom on load', fakeAsync(async() => { - // Given a visible chat with multiple messages - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - spyOn(component, 'scrollTo'); - await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES }); - - // when the chat is initialized - testUtils.detectChanges(); - - const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); - expect(component.scrollTo).toHaveBeenCalledWith(chatDiv.nativeElement.scrollHeight); - })); - it('should not scroll down upon new messages if the user scrolled up, but show an indicator', fakeAsync(async() => { - const SCROLL: number = 200; - // Given a visible chat with multiple messages, that has been scrolled up - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - testUtils.detectChanges(); - await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES }); - testUtils.detectChanges(); - - const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); - chatDiv.nativeElement.scroll({ top: SCROLL, left: 0, behavior: 'auto' }); // user scrolled up in the chat - chatDiv.nativeElement.dispatchEvent(new Event('scroll')); - testUtils.detectChanges(); - - // when a new message is received - await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES.concat(MSG) }); + it('should create', fakeAsync(async() => { + // wait for the chat to be initialized (without it, ngOnInit will not be called) testUtils.detectChanges(); - - // then the scroll value did not change - expect(chatDiv.nativeElement.scrollTop).toBe(SCROLL); - // and the indicator shows t hat there is a new message - const indicator: DebugElement = testUtils.findElement('#scrollToBottomIndicator'); - expect(indicator.nativeElement.innerHTML).toEqual('1 new message ↓'); - })); - it('should scroll to bottom when clicking on the new message indicator', fakeAsync(async() => { - // Given a visible chat with the indicator - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - testUtils.detectChanges(); - await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES }); - testUtils.detectChanges(); - - const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); - chatDiv.nativeElement.scroll({ top: 0, left: 0, behavior: 'auto' }); // user scrolled up in the chat - chatDiv.nativeElement.dispatchEvent(new Event('scroll')); - testUtils.detectChanges(); - - await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES.concat(MSG) }); // new message has been received - testUtils.detectChanges(); - - // when the indicator is clicked - spyOn(component, 'scrollToBottom').and.callThrough(); - testUtils.clickElement('#scrollToBottomIndicator'); - testUtils.detectChanges(); - await testUtils.whenStable(); - - // then the view is scrolled to the bottom - expect(component.scrollToBottom).toHaveBeenCalled(); - // and the indicator has disappeared - testUtils.expectElementNotToExist('#scrollToBottomIndicator'); - })); - it('should reset new messages count once messages have been read', fakeAsync(async() => { - // Given a hidden chat with one unseen message - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); - testUtils.detectChanges(); - const chat: Partial = { messages: [{ sender: 'roger', content: 'Saluuuut', currentTurn: 0, postedTime: 5 }] }; - await chatDAO.update('fauxChat', chat); - testUtils.detectChanges(); - let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); - expect(switchButton.nativeElement.innerText).toEqual('Show chat (1 new message)'); - - // When the chat is shown and then hidden again - testUtils.clickElement('#switchChatVisibilityButton'); - testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); - testUtils.detectChanges(); - - // Then the button text is updated - switchButton = testUtils.findElement('#switchChatVisibilityButton'); - expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'); - })); - it('should send messages using the chat service', fakeAsync(async() => { - spyOn(chatService, 'sendMessage'); - // given a chat - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - testUtils.detectChanges(); - - // when the form is filled and the send button clicked - const messageInput: DebugElement = testUtils.findElement('#message'); - messageInput.nativeElement.value = 'hello'; - messageInput.nativeElement.dispatchEvent(new Event('input')); - await testUtils.whenStable(); - - testUtils.clickElement('#send'); - testUtils.detectChanges(); - await testUtils.whenStable(); - - // then the message is sent - expect(chatService.sendMessage).toHaveBeenCalledWith(AuthenticationServiceMock.CONNECTED.pseudo, 2, 'hello'); - // and the form is cleared - expect(messageInput.nativeElement.value).toBe(''); - })); - it('should scroll to bottom when sending a message', fakeAsync(async() => { - // given a chat with many messages - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES.concat(MSG) }); // new message has been received - testUtils.detectChanges(); - spyOn(component, 'scrollTo'); - - // when a message is sent - component.userMessage = 'hello there'; - testUtils.detectChanges(); - await component.sendMessage(); - testUtils.detectChanges(); - - // then we scroll to the bottom - const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); - expect(component.scrollTo).toHaveBeenCalledWith(chatDiv.nativeElement.scrollHeight); + expect(component).toBeTruthy(); })); - afterAll(() => { - component.ngOnDestroy(); + describe('disconnected chat', () => { + it('should not observe (load messages) and show disconnected chat for unlogged user', fakeAsync(async() => { + spyOn(chatService, 'startObserving'); + spyOn(component, 'loadChatContent'); + // given a user that is not connected + AuthenticationServiceMock.setUser(AuthUser.NOT_CONNECTED); + + // when the component is initialized + component.ngOnInit(); + testUtils.detectChanges(); + spyOn(component['authSubscription'], 'unsubscribe'); + + // It should not observe, not load the chat content, and show the disconnected chat + expect(chatService.startObserving).not.toHaveBeenCalled(); + expect(component.loadChatContent).not.toHaveBeenCalled(); + testUtils.expectElementToExist('#disconnected-chat'); + + component.ngOnDestroy(); + await testUtils.whenStable(); + // chatService.stopObserving should not have been called neither + expect(chatService.stopObserving).not.toHaveBeenCalled(); + // but the auth subscription needs to be cancelled! + expect(component['authSubscription'].unsubscribe).toHaveBeenCalled(); + })); + }); + describe('connected chat', () => { + it('should propose to hide chat when chat is visible, and work', fakeAsync(async() => { + // Given a user that is connected + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + testUtils.detectChanges(); + let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); + const chat: DebugElement = testUtils.findElement('#chatForm'); + expect(switchButton.nativeElement.innerText).toEqual('Hide chat'.toUpperCase()); + expect(chat).withContext('Chat should be visible on init').toBeTruthy(); + + // when switching the chat visibility + testUtils.clickElement('#switchChatVisibilityButton'); + testUtils.detectChanges(); + + switchButton = testUtils.findElement('#switchChatVisibilityButton'); + // Then the chat is not visible and the button changes its text + expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'.toUpperCase()); + testUtils.expectElementNotToExist('#chatDiv'); + testUtils.expectElementNotToExist('#chatForm'); + })); + it('should propose to show chat when chat is hidden, and work', fakeAsync(async() => { + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + testUtils.detectChanges(); + testUtils.clickElement('#switchChatVisibilityButton'); + testUtils.detectChanges(); + + // Given that the chat is hidden + let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); + let chat: DebugElement = testUtils.findElement('#chatForm'); + expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'.toUpperCase()); + expect(chat).withContext('Chat should be hidden').toBeFalsy(); + + // when showing the chat + testUtils.clickElement('#switchChatVisibilityButton'); + testUtils.detectChanges(); + + // then the chat is shown + switchButton = testUtils.findElement('#switchChatVisibilityButton'); + chat = testUtils.findElement('#chatForm'); + expect(switchButton.nativeElement.innerText).toEqual('Hide chat'.toUpperCase()); + expect(chat).withContext('Chat should be visible after calling show').toBeTruthy(); + })); + it('should show how many messages where sent since you hide the chat', fakeAsync(async() => { + // Given a hidden chat with no message + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + testUtils.detectChanges(); + testUtils.clickElement('#switchChatVisibilityButton'); + testUtils.detectChanges(); + let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); + expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'.toUpperCase()); + + // when a new message is received + await chatDAO.update('fauxChat', { messages: [MSG, MSG, MSG] }); + testUtils.detectChanges(); + + // then the button shows how many new messages there are + switchButton = testUtils.findElement('#switchChatVisibilityButton'); + expect(switchButton.nativeElement.innerText).toEqual('Show chat (3 new messages)'.toUpperCase()); + })); + it('should scroll to the bottom on load', fakeAsync(async() => { + // Given a visible chat with multiple messages + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + spyOn(component, 'scrollTo'); + await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES }); + + // when the chat is initialized + testUtils.detectChanges(); + + const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); + expect(component.scrollTo).toHaveBeenCalledWith(chatDiv.nativeElement.scrollHeight); + })); + it('should not scroll down upon new messages if the user scrolled up, but show an indicator', fakeAsync(async() => { + const SCROLL: number = 200; + // Given a visible chat with multiple messages, that has been scrolled up + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + testUtils.detectChanges(); + await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES }); + testUtils.detectChanges(); + + const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); + chatDiv.nativeElement.scroll({ top: SCROLL, left: 0, behavior: 'auto' }); // user scrolled up in the chat + chatDiv.nativeElement.dispatchEvent(new Event('scroll')); + testUtils.detectChanges(); + + // when a new message is received + await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES.concat(MSG) }); + testUtils.detectChanges(); + + // then the scroll value did not change + expect(chatDiv.nativeElement.scrollTop).toBe(SCROLL); + // and the indicator shows t hat there is a new message + const indicator: DebugElement = testUtils.findElement('#scrollToBottomIndicator'); + expect(indicator.nativeElement.innerHTML).toEqual('1 new message ↓'); + })); + it('should scroll to bottom when clicking on the new message indicator', fakeAsync(async() => { + // Given a visible chat with the indicator + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + testUtils.detectChanges(); + await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES }); + testUtils.detectChanges(); + + const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); + chatDiv.nativeElement.scroll({ top: 0, left: 0, behavior: 'auto' }); // user scrolled up in the chat + chatDiv.nativeElement.dispatchEvent(new Event('scroll')); + testUtils.detectChanges(); + + await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES.concat(MSG) }); // new message has been received + testUtils.detectChanges(); + + // when the indicator is clicked + spyOn(component, 'scrollToBottom').and.callThrough(); + testUtils.clickElement('#scrollToBottomIndicator'); + testUtils.detectChanges(); + await testUtils.whenStable(); + + // then the view is scrolled to the bottom + expect(component.scrollToBottom).toHaveBeenCalled(); + // and the indicator has disappeared + testUtils.expectElementNotToExist('#scrollToBottomIndicator'); + })); + it('should reset new messages count once messages have been read', fakeAsync(async() => { + // Given a hidden chat with one unseen message + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + testUtils.detectChanges(); + testUtils.clickElement('#switchChatVisibilityButton'); + testUtils.detectChanges(); + const chat: Partial = { messages: [{ sender: 'roger', content: 'Saluuuut', currentTurn: 0, postedTime: 5 }] }; + await chatDAO.update('fauxChat', chat); + testUtils.detectChanges(); + let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); + expect(switchButton.nativeElement.innerText).toEqual('Show chat (1 new message)'.toUpperCase()); + + // When the chat is shown and then hidden again + testUtils.clickElement('#switchChatVisibilityButton'); + testUtils.detectChanges(); + testUtils.clickElement('#switchChatVisibilityButton'); + testUtils.detectChanges(); + + // Then the button text is updated + switchButton = testUtils.findElement('#switchChatVisibilityButton'); + expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'.toUpperCase()); + })); + it('should send messages using the chat service', fakeAsync(async() => { + spyOn(chatService, 'sendMessage'); + // given a chat + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + testUtils.detectChanges(); + + // when the form is filled and the send button clicked + const messageInput: DebugElement = testUtils.findElement('#message'); + messageInput.nativeElement.value = 'hello'; + messageInput.nativeElement.dispatchEvent(new Event('input')); + await testUtils.whenStable(); + + testUtils.clickElement('#send'); + testUtils.detectChanges(); + await testUtils.whenStable(); + + // then the message is sent + const username: string = AuthenticationServiceMock.CONNECTED.username.get(); + expect(chatService.sendMessage).toHaveBeenCalledWith(username, 'hello', 2); + // and the form is cleared + expect(messageInput.nativeElement.value).toBe(''); + })); + it('should scroll to bottom when sending a message', fakeAsync(async() => { + // given a chat with many messages + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + await chatDAO.update('fauxChat', { messages: LOTS_OF_MESSAGES.concat(MSG) }); // new message has been received + testUtils.detectChanges(); + spyOn(component, 'scrollTo'); + + // when a message is sent + component.userMessage = 'hello there'; + testUtils.detectChanges(); + await component.sendMessage(); + testUtils.detectChanges(); + + // then we scroll to the bottom + const chatDiv: DebugElement = testUtils.findElement('#chatDiv'); + expect(component.scrollTo).toHaveBeenCalledWith(chatDiv.nativeElement.scrollHeight); + })); + afterEach(fakeAsync(async() => { + component.ngOnDestroy(); + await testUtils.whenStable(); + // For the connected chat, the subscription need to be properly closed + expect(chatService.stopObserving).toHaveBeenCalled(); + })); }); }); diff --git a/src/app/components/normal-component/chat/chat.component.ts b/src/app/components/normal-component/chat/chat.component.ts index 0d4cbb44b..47d49de8d 100644 --- a/src/app/components/normal-component/chat/chat.component.ts +++ b/src/app/components/normal-component/chat/chat.component.ts @@ -5,6 +5,8 @@ import { AuthenticationService, AuthUser } from 'src/app/services/Authentication import { IChatId } from 'src/app/domain/ichat'; import { assert, display } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { faReply, IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-chat', @@ -13,10 +15,10 @@ import { MGPOptional } from 'src/app/utils/MGPOptional'; export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { public static VERBOSE: boolean = false; - @Input() public chatId: string; - @Input() public turn: number; + @Input() public chatId!: string; + @Input() public turn?: number; public userMessage: string = ''; - public userName: MGPOptional = MGPOptional.empty(); + public username: MGPOptional = MGPOptional.empty(); public connected: boolean = false; public chat: IMessage[] = []; @@ -25,9 +27,13 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { public showUnreadMessagesButton: boolean = false; public visible: boolean = true; + public faReply: IconDefinition = faReply; + private isNearBottom: boolean = true; private notYetScrolled: boolean = true; + private authSubscription!: Subscription; // Initialized in ngOnInit + @ViewChild('chatDiv') chatDiv: ElementRef; constructor(private chatService: ChatService, @@ -35,20 +41,19 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { display(ChatComponent.VERBOSE, 'ChatComponent constructor'); } public ngOnInit(): void { - display(ChatComponent.VERBOSE, 'ChatComponent.ngOnInit'); + display(ChatComponent.VERBOSE, `ChatComponent.ngOnInit for chat ${this.chatId}`); assert(this.chatId != null && this.chatId !== '', 'No chat to join mentionned'); - - this.authenticationService.getJoueurObs() - .subscribe((joueur: AuthUser) => { - if (this.isConnectedUser(joueur)) { - display(ChatComponent.VERBOSE, JSON.stringify(joueur) + ' just connected'); - this.userName = MGPOptional.of(joueur.pseudo); + this.authSubscription = this.authenticationService.getUserObs() + .subscribe((user: AuthUser) => { + if (this.isConnectedUser(user)) { + display(ChatComponent.VERBOSE, JSON.stringify(user) + ' just connected'); + this.username = user.username; this.connected = true; this.loadChatContent(); } else { display(ChatComponent.VERBOSE, 'No User Logged'); - this.userName = MGPOptional.empty(); + this.username = MGPOptional.empty(); this.connected = false; } }); @@ -56,11 +61,11 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { public ngAfterViewChecked(): void { this.scrollToBottomIfNeeded(); } - public isConnectedUser(joueur: { pseudo: string; verified: boolean;}): boolean { - return joueur && joueur.pseudo && joueur.pseudo !== ''; + public isConnectedUser(user: AuthUser): boolean { + return user.username.isPresent() && user.username.get() !== ''; } public loadChatContent(): void { - display(ChatComponent.VERBOSE, `User '` + this.userName + `' logged, loading chat content`); + display(ChatComponent.VERBOSE, `User '${this.username}' logged, loading chat content`); this.chatService.startObserving(this.chatId, (id: IChatId) => { this.updateMessages(id); @@ -122,12 +127,13 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { }); } public async sendMessage(): Promise { - assert(this.userName.isPresent(), 'disconnected user is not able to send a message'); + assert(this.username.isPresent(), 'disconnected user is not able to send a message'); const content: string = this.userMessage; this.userMessage = ''; // clears it first to seem more responsive - await this.chatService.sendMessage(this.userName.get(), this.turn, content); + await this.chatService.sendMessage(this.username.get(), content, this.turn); } public ngOnDestroy(): void { + this.authSubscription.unsubscribe(); if (this.chatService.isObserving()) { this.chatService.stopObserving(); } diff --git a/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.html b/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.html deleted file mode 100644 index e356eaf8d..000000000 --- a/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
-

Successfully signed up

-
-
-

An email will be sent to you: click on the link and reconnect yourself. - This email could arrive in your spam folder.

-
-
diff --git a/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.spec.ts b/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.spec.ts deleted file mode 100644 index 33a3d2722..000000000 --- a/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ConfirmInscriptionComponent } from './confirm-inscription.component'; -import { AuthenticationService } from 'src/app/services/AuthenticationService'; -import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; - -describe('ConfirmInscriptionComponent', () => { - - let component: ConfirmInscriptionComponent; - - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ - ConfirmInscriptionComponent, - ], - providers: [ - { provide: AuthenticationService, useClass: AuthenticationServiceMock }, - ], - }).compileComponents(); - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - fixture = TestBed.createComponent(ConfirmInscriptionComponent); - component = fixture.componentInstance; - }); - it('should create and send notification', () => { - spyOn(component.authService, 'sendEmailVerification'); - fixture.detectChanges(); - expect(component).toBeTruthy(); - expect(component.authService.sendEmailVerification).toHaveBeenCalled(); - }); -}); diff --git a/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.ts b/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.ts deleted file mode 100644 index 68b7f45cc..000000000 --- a/src/app/components/normal-component/confirm-inscription/confirm-inscription.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { AuthenticationService } from 'src/app/services/AuthenticationService'; -import { display } from 'src/app/utils/utils'; - -@Component({ - selector: 'app-confirm-inscription', - templateUrl: './confirm-inscription.component.html', -}) -export class ConfirmInscriptionComponent implements OnInit { - - public static VERBOSE: boolean = false; - - constructor(public authService: AuthenticationService) {} - - public ngOnInit(): Promise { - display(ConfirmInscriptionComponent.VERBOSE, 'ConfirmInscriptionComponent.onInit()'); - - return this.authService.sendEmailVerification(); - } -} diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index 08f1c376c..d456958bb 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -1,4 +1,12 @@ -
+

{{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

-
\ No newline at end of file + +
diff --git a/src/app/components/normal-component/count-down/count-down.component.spec.ts b/src/app/components/normal-component/count-down/count-down.component.spec.ts index e4adddaa8..a3ab83fcf 100644 --- a/src/app/components/normal-component/count-down/count-down.component.spec.ts +++ b/src/app/components/normal-component/count-down/count-down.component.spec.ts @@ -1,21 +1,17 @@ import { DebugElement } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { CountDownComponent } from './count-down.component'; describe('CountDownComponent', () => { - let component: CountDownComponent; + let testUtils: SimpleComponentTestUtils; - let fixture: ComponentFixture; + let component: CountDownComponent; beforeEach(fakeAsync(async() => { - await TestBed.configureTestingModule({ - declarations: [CountDownComponent], - }).compileComponents(); - fixture = TestBed.createComponent(CountDownComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + testUtils = await SimpleComponentTestUtils.create(CountDownComponent); + component = testUtils.getComponent(); })); it('should create', () => { expect(component).toBeTruthy(); @@ -40,14 +36,15 @@ describe('CountDownComponent', () => { }); it('should show remaining time once set', () => { component.setDuration(62000); - fixture.detectChanges(); - const element: DebugElement = fixture.debugElement.query(By.css('#remainingTime')); + testUtils.detectChanges(); + const element: DebugElement = testUtils.findElement('#remainingTime'); const timeText: string = element.nativeElement.innerHTML; expect(timeText).toBe('1:02'); }); it('should throw when starting stopped chrono again', () => { component.setDuration(1250); component.start(); + expect(component.isStarted()).toBeTrue(); component.stop(); expect(() => component.start()).toThrowError('Should not start a chrono that has not been set!'); }); @@ -92,34 +89,34 @@ describe('CountDownComponent', () => { component.setDuration(3000); component.start(); tick(1000); - fixture.detectChanges(); - let timeText: string = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('0:02'); tick(1000); - fixture.detectChanges(); - timeText = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('0:01'); component.stop(); })); it('should update written time correctly (closest rounding) even when playing in less than refreshing time', fakeAsync(() => { spyOn(component.outOfTimeAction, 'emit').and.callThrough(); component.setDuration(599501); // 9 minutes 59 sec 501 ms - fixture.detectChanges(); - let timeText: string = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('9:59'); component.start(); tick(401); // 9 min 59.501s -> 9 min 59.1 (9:59) component.pause(); - fixture.detectChanges(); - timeText = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('9:59'); component.resume(); tick(200); // 9 min 59.1 -> 9 min 58.9 (9:58) component.pause(); - fixture.detectChanges(); - timeText = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('9:58'); })); it('should emit when timeout reached', fakeAsync(() => { @@ -131,21 +128,44 @@ describe('CountDownComponent', () => { tick(1000); expect(component.outOfTimeAction.emit).toHaveBeenCalledOnceWith(); })); + describe('Add Time Button', () => { + it('should offer opportunity to add time if allowed', fakeAsync(async() => { + // Given a CountDownComponent allowed to add time, with 1 minute remaining + component.canAddTime = true; + component.remainingMs = 60 * 1000; + testUtils.detectChanges(); + + // when clicking the add time button + spyOn(component.addTimeToOpponent, 'emit').and.callThrough(); + await testUtils.clickElement('#addTimeButton'); + + // the component should have called addTimeToOpponent + expect(component.addTimeToOpponent.emit).toHaveBeenCalledOnceWith(); + })); + it('should not display button when not allowed to add time', fakeAsync(async() => { + // given a CountDownComponent not allowed to add time + component.canAddTime = false; + testUtils.detectChanges(); + + // the component should not have that button + testUtils.expectElementNotToExist('#addTimeButton'); + })); + }); describe('Style depending of remaining time', () => { it('Should be safe style when upper than limit', () => { component.dangerTimeLimit = 10 * 1000; component.setDuration(12 * 1000); - expect(component.getTimeStyle()).toEqual(component.SAFE_TIME); + expect(component.getTimeStyle()).toEqual(CountDownComponent.SAFE_TIME); }); it('Should be first danger style when lower than limit and even remaining second', () => { component.dangerTimeLimit = 10 * 1000; component.setDuration(9 * 1000); - expect(component.getTimeStyle()).toEqual(component.DANGER_TIME_EVEN); + expect(component.getTimeStyle()).toEqual(CountDownComponent.DANGER_TIME_EVEN); }); it('Should be second danger style when lower than limit and odd remaining second', () => { component.dangerTimeLimit = 10 * 1000; component.setDuration(8 * 1000); - expect(component.getTimeStyle()).toEqual(component.DANGER_TIME_ODD); + expect(component.getTimeStyle()).toEqual(CountDownComponent.DANGER_TIME_ODD); }); it('Should be in passive style when passive', () => { // given a chrono that could be in danger time style @@ -155,7 +175,7 @@ describe('CountDownComponent', () => { component.active = false; // then it should still be in passive style - expect(component.getTimeStyle()).toEqual(component.PASSIVE_STYLE); + expect(component.getTimeStyle()).toEqual(CountDownComponent.PASSIVE_STYLE); }); }); }); diff --git a/src/app/components/normal-component/count-down/count-down.component.ts b/src/app/components/normal-component/count-down/count-down.component.ts index 6775b6bbd..75cc62758 100644 --- a/src/app/components/normal-component/count-down/count-down.component.ts +++ b/src/app/components/normal-component/count-down/count-down.component.ts @@ -12,36 +12,38 @@ export class CountDownComponent implements OnInit, OnDestroy { @Input() debugName: string; @Input() dangerTimeLimit: number; @Input() active: boolean; + @Input() canAddTime: boolean; public remainingMs: number; public displayedSec: number; public displayedMinute: number; - private timeoutHandleGlobal: number; - private timeoutHandleSec: number; + private timeoutHandleGlobal: number | null; + private timeoutHandleSec: number | null; private isPaused: boolean = true; private isSet: boolean = false; private started: boolean = false; private startTime: number; @Output() outOfTimeAction: EventEmitter = new EventEmitter(); + @Output() addTimeToOpponent: EventEmitter = new EventEmitter(); - public readonly DANGER_TIME_EVEN: { [key: string]: string } = { + public static readonly DANGER_TIME_EVEN: { [key: string]: string } = { 'color': 'red', 'font-weight': 'bold', }; - public readonly DANGER_TIME_ODD: { [key: string]: string } = { + public static readonly DANGER_TIME_ODD: { [key: string]: string } = { 'color': 'white', 'font-weight': 'bold', 'background-color': 'red', }; - public readonly PASSIVE_STYLE: { [key: string]: string } = { + public static readonly PASSIVE_STYLE: { [key: string]: string } = { 'color': 'lightgrey', 'background-color': 'darkgrey', 'font-size': 'italic', }; - public readonly SAFE_TIME: { [key: string]: string } = { color: 'black' }; + public static readonly SAFE_TIME: { [key: string]: string } = { color: 'black' }; - public style: { [key: string]: string } = this.SAFE_TIME; + public style: { [key: string]: string } = CountDownComponent.SAFE_TIME; public ngOnInit(): void { display(CountDownComponent.VERBOSE, 'CountDownComponent.ngOnInit (' + this.debugName + ')'); @@ -56,9 +58,20 @@ export class CountDownComponent implements OnInit, OnDestroy { this.changeDuration(duration); } public changeDuration(ms: number): void { + let mustResume: boolean = false; + if (this.isPaused === false) { + this.pause(); + mustResume = true; + } this.remainingMs = ms; - this.displayedSec = ms % (60 * 1000); - this.displayedMinute = (ms - this.displayedSec) / (60 * 1000); + this.displayDuration(); + if (mustResume) { + this.resume(); + } + } + private displayDuration(): void { + this.displayedSec = this.remainingMs % (60 * 1000); + this.displayedMinute = (this.remainingMs - this.displayedSec) / (60 * 1000); this.displayedSec = Math.floor(this.displayedSec / 1000); } public start(): void { @@ -134,38 +147,49 @@ export class CountDownComponent implements OnInit, OnDestroy { } public getTimeStyle(): { [key: string]: string } { if (this.active === false) { - return this.PASSIVE_STYLE; + return CountDownComponent.PASSIVE_STYLE; } if (this.remainingMs < this.dangerTimeLimit) { if (this.remainingMs % 2000 < 1000) { - return this.DANGER_TIME_ODD; + return CountDownComponent.DANGER_TIME_ODD; } else { - return this.DANGER_TIME_EVEN; + return CountDownComponent.DANGER_TIME_EVEN; } } else { - return this.SAFE_TIME; + return CountDownComponent.SAFE_TIME; } } + public getBackgroundColor(): { [key: string]: string } { + const buttonStyle: { [key: string]: string } = this.getTimeStyle(); + return { 'background-color': buttonStyle['background-color'] }; + } private updateShownTime(): void { const now: number = Date.now(); this.remainingMs -= (now - this.startTime); - this.changeDuration(this.remainingMs); + this.displayDuration(); this.style = this.getTimeStyle(); this.startTime = now; - if (!this.isPaused) { + if (this.isPaused === false) { this.countSeconds(); } } private clearTimeouts(): void { display(CountDownComponent.VERBOSE, this.debugName + '.clearTimeouts'); - clearTimeout(this.timeoutHandleSec); - this.timeoutHandleSec = null; + if (this.timeoutHandleSec != null) { + clearTimeout(this.timeoutHandleSec); + this.timeoutHandleSec = null; + } - clearTimeout(this.timeoutHandleGlobal); - this.timeoutHandleGlobal = null; + if (this.timeoutHandleGlobal != null) { + clearTimeout(this.timeoutHandleGlobal); + this.timeoutHandleGlobal = null; + } } public ngOnDestroy(): void { this.clearTimeouts(); } + public addTime(): void { + this.addTimeToOpponent.emit(); + } } diff --git a/src/app/components/normal-component/header/header.component.html b/src/app/components/normal-component/header/header.component.html index 4d10c5fd8..f6a728f88 100644 --- a/src/app/components/normal-component/header/header.component.html +++ b/src/app/components/normal-component/header/header.component.html @@ -1,4 +1,4 @@ -