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;
@@ -29,22 +28,22 @@ export abstract class TriangularGameComponent ,
P,
- L extends LegalityStatus = LegalityStatus>
+ L = void>
extends GameComponent {{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}