diff --git a/.github/workflows/publish-npmjs.yaml b/.github/workflows/publish-npmjs.yaml index 2d99d30b..82babc62 100644 --- a/.github/workflows/publish-npmjs.yaml +++ b/.github/workflows/publish-npmjs.yaml @@ -39,6 +39,7 @@ jobs: - run: npm publish dist/ppwcode/ng-router --access public - run: npm publish dist/ppwcode/ng-state-management --access public - run: npm publish dist/ppwcode/ng-unit-testing --access public + - run: npm publish dist/ppwcode/ng-utils --access public - run: npm publish dist/ppwcode/ng-wireframe --access public - run: npm run ci:build - name: Copy index.html to 404.html # For GitHub Pages SPA support diff --git a/angular.json b/angular.json index 7c8342d2..a62eb80c 100644 --- a/angular.json +++ b/angular.json @@ -199,6 +199,49 @@ } } }, + "@ppwcode/ng-utils": { + "projectType": "library", + "root": "projects/ppwcode/ng-utils", + "sourceRoot": "projects/ppwcode/ng-utils/src", + "prefix": "ppw", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/ppwcode/ng-utils/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/ppwcode/ng-utils/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ppwcode/ng-utils/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "karmaConfig": "karma.conf.js", + "tsConfig": "projects/ppwcode/ng-utils/tsconfig.spec.json", + "polyfills": [ + "zone.js", + "zone.js/testing" + ] + } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "projects/ppwcode/ng-utils/**/*.ts", + "projects/ppwcode/ng-utils/**/*.html" + ] + } + } + } + }, "@ppwcode/ng-wireframe": { "projectType": "library", "root": "projects/ppwcode/ng-wireframe", diff --git a/package-lock.json b/package-lock.json index 46c4b07f..b7121a10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "@fortawesome/fontawesome-free": "7.1.0", "@ngx-translate/core": "17.0.0", "@ngx-translate/http-loader": "17.0.0", - "@ppwcode/js-ts-oddsandends": "1.4.2", "@types/luxon": "3.7.1", "file-saver-es": "2.0.5", "luxon": "3.7.1", @@ -1987,21 +1986,6 @@ "node": ">=6" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -3819,15 +3803,6 @@ "license": "MIT", "optional": true }, - "node_modules/@ppwcode/js-ts-oddsandends": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@ppwcode/js-ts-oddsandends/-/js-ts-oddsandends-1.4.2.tgz", - "integrity": "sha512-5MlZ6LDiAS5CbP2kXcqioXZ011qABc7rsSo/GYUUJqC/5ezg/j2YGUexTvZ5CYGhy8bXuehA/tUAgzBbPjyMJg==", - "license": "UNLICENSED", - "dependencies": { - "joi": "^17.4.0" - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz", @@ -4571,27 +4546,6 @@ "tslib": "^2.1.0" } }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "license": "BSD-3-Clause" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "license": "BSD-3-Clause" - }, "node_modules/@sigstore/bundle": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", @@ -5622,9 +5576,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", - "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", + "version": "2.9.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", + "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8128,9 +8082,9 @@ "license": "MIT" }, "node_modules/htmlparser2": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", - "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -8143,14 +8097,14 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.2.1", - "entities": "^6.0.0" + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, "node_modules/htmlparser2/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -8652,19 +8606,6 @@ "dev": true, "license": "MIT" }, - "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", diff --git a/package.json b/package.json index 2abf27bd..2fa9930e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "@fortawesome/fontawesome-free": "7.1.0", "@ngx-translate/core": "17.0.0", "@ngx-translate/http-loader": "17.0.0", - "@ppwcode/js-ts-oddsandends": "1.4.2", "@types/luxon": "3.7.1", "file-saver-es": "2.0.5", "luxon": "3.7.1", diff --git a/projects/ppwcode/ng-async/package.json b/projects/ppwcode/ng-async/package.json index 24a85630..efb6815b 100644 --- a/projects/ppwcode/ng-async/package.json +++ b/projects/ppwcode/ng-async/package.json @@ -9,8 +9,8 @@ "@angular/core": "^21.0.2", "@ngx-translate/core": "^17.0.0", "file-saver-es": "^2.0.5", - "@ppwcode/js-ts-oddsandends": "^1.4.2", - "@ppwcode/ng-common-components": "^21.0.0" + "@ppwcode/ng-common-components": "^21.0.0", + "@ppwcode/ng-utils": "^21.0.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/projects/ppwcode/ng-async/src/lib/downloads/save-downloaded-file.ts b/projects/ppwcode/ng-async/src/lib/downloads/save-downloaded-file.ts index 0ba9ad40..62019c8f 100644 --- a/projects/ppwcode/ng-async/src/lib/downloads/save-downloaded-file.ts +++ b/projects/ppwcode/ng-async/src/lib/downloads/save-downloaded-file.ts @@ -1,5 +1,5 @@ import { saveAs } from 'file-saver-es' -import { notNull } from '@ppwcode/js-ts-oddsandends/lib/conditional-assert' +import { notNull } from '@ppwcode/ng-utils' import { AsyncResult } from '../models/async-result' import { FileDownload } from '../models/file-download' diff --git a/projects/ppwcode/ng-async/src/lib/models/file-download.ts b/projects/ppwcode/ng-async/src/lib/models/file-download.ts index 21538e79..1b5994ac 100644 --- a/projects/ppwcode/ng-async/src/lib/models/file-download.ts +++ b/projects/ppwcode/ng-async/src/lib/models/file-download.ts @@ -1,5 +1,5 @@ import { HttpResponse } from '@angular/common/http' -import { notUndefined } from '@ppwcode/js-ts-oddsandends/lib/conditional-assert' +import { notUndefined } from '@ppwcode/ng-utils' export interface FileDownload { blob: Blob diff --git a/projects/ppwcode/ng-async/src/lib/models/paged-entities.ts b/projects/ppwcode/ng-async/src/lib/models/paged-entities.ts index d7f520a0..bf62224a 100644 --- a/projects/ppwcode/ng-async/src/lib/models/paged-entities.ts +++ b/projects/ppwcode/ng-async/src/lib/models/paged-entities.ts @@ -1,4 +1,4 @@ -import { notUndefined } from '@ppwcode/js-ts-oddsandends/lib/conditional-assert' +import { notUndefined } from '@ppwcode/ng-utils' export interface PagedEntitiesDto { /** The current page number (1-based). */ diff --git a/projects/ppwcode/ng-common-components/package.json b/projects/ppwcode/ng-common-components/package.json index a5a5fb0c..fcb65e24 100644 --- a/projects/ppwcode/ng-common-components/package.json +++ b/projects/ppwcode/ng-common-components/package.json @@ -7,7 +7,8 @@ "peerDependencies": { "@angular/common": "^21.0.2", "@angular/core": "^21.0.2", - "@ppwcode/ng-common": "^21.0.0" + "@ppwcode/ng-common": "^21.0.0", + "@ppwcode/ng-utils": "^21.0.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/projects/ppwcode/ng-common-components/src/lib/table/abstract-table.component.ts b/projects/ppwcode/ng-common-components/src/lib/table/abstract-table.component.ts index 4f9333e7..9f8a0cae 100644 --- a/projects/ppwcode/ng-common-components/src/lib/table/abstract-table.component.ts +++ b/projects/ppwcode/ng-common-components/src/lib/table/abstract-table.component.ts @@ -26,7 +26,7 @@ import { import { FormArray, FormGroup } from '@angular/forms' import { MatTable, MatTableDataSource } from '@angular/material/table' import { Sort } from '@angular/material/sort' -import { assert, notUndefined } from '@ppwcode/js-ts-oddsandends/lib/conditional-assert' +import { assert, notUndefined } from '@ppwcode/ng-utils' import { mixinHandleSubscriptions } from '@ppwcode/ng-common' import { PpwColumnDirective } from './column-directives/ppw-column.directive' import { Column, ColumnType } from './columns/column' diff --git a/projects/ppwcode/ng-common-components/src/lib/table/column-directives/ppw-column.directive.ts b/projects/ppwcode/ng-common-components/src/lib/table/column-directives/ppw-column.directive.ts index 6e1950fc..dc6db122 100644 --- a/projects/ppwcode/ng-common-components/src/lib/table/column-directives/ppw-column.directive.ts +++ b/projects/ppwcode/ng-common-components/src/lib/table/column-directives/ppw-column.directive.ts @@ -10,7 +10,7 @@ import { Signal, TemplateRef } from '@angular/core' -import { notUndefined } from '@ppwcode/js-ts-oddsandends/lib/conditional-assert' +import { notUndefined } from '@ppwcode/ng-utils' import { Column, ColumnType } from '../columns/column' import { DateColumn } from '../columns/date-column' import { NumberColumn } from '../columns/number-column' diff --git a/projects/ppwcode/ng-common/package.json b/projects/ppwcode/ng-common/package.json index 8c8ffc31..b47717ec 100644 --- a/projects/ppwcode/ng-common/package.json +++ b/projects/ppwcode/ng-common/package.json @@ -6,7 +6,8 @@ }, "peerDependencies": { "@angular/common": "^21.0.2", - "@angular/core": "^21.0.2" + "@angular/core": "^21.0.2", + "@ppwcode/ng-utils": "^21.0.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/projects/ppwcode/ng-common/src/lib/global-error-handler/global-error-handler.ts b/projects/ppwcode/ng-common/src/lib/global-error-handler/global-error-handler.ts index 1f682425..26bb1797 100644 --- a/projects/ppwcode/ng-common/src/lib/global-error-handler/global-error-handler.ts +++ b/projects/ppwcode/ng-common/src/lib/global-error-handler/global-error-handler.ts @@ -1,7 +1,7 @@ import { HttpErrorResponse } from '@angular/common/http' import { ErrorHandler, inject, Injectable, Injector, NgZone } from '@angular/core' import { MatDialog, MatDialogRef } from '@angular/material/dialog' -import { notUndefined } from '@ppwcode/js-ts-oddsandends/lib/conditional-assert' +import { notUndefined } from '@ppwcode/ng-utils' import { GlobalErrorDialogComponent } from './global-error-dialog.component' /** diff --git a/projects/ppwcode/ng-unit-testing/package.json b/projects/ppwcode/ng-unit-testing/package.json index 039ee1ef..070c4635 100644 --- a/projects/ppwcode/ng-unit-testing/package.json +++ b/projects/ppwcode/ng-unit-testing/package.json @@ -7,6 +7,8 @@ "peerDependencies": { "@angular/common": "^21.0.2", "@angular/core": "^21.0.2", + "@ppwcode/ng-async": "^21.0.0", + "@ppwcode/ng-utils": "^21.0.0", "jasmine-core": "^3.9.0 || ^4.0.0 || ^5.0.0" }, "dependencies": { diff --git a/projects/ppwcode/ng-unit-testing/src/lib/http/http-call-tester.ts b/projects/ppwcode/ng-unit-testing/src/lib/http/http-call-tester.ts index 42c72f99..d351ccee 100644 --- a/projects/ppwcode/ng-unit-testing/src/lib/http/http-call-tester.ts +++ b/projects/ppwcode/ng-unit-testing/src/lib/http/http-call-tester.ts @@ -1,6 +1,6 @@ import { TestRequest } from '@angular/common/http/testing' -import { notUndefined } from '@ppwcode/js-ts-oddsandends/lib/conditional-assert' import { FileDownload } from '@ppwcode/ng-async' +import { notUndefined } from '@ppwcode/ng-utils' import { noop, Observable } from 'rxjs' import { expectOneCallToUrl, ResponseOptions } from './http-client-testing-controller' diff --git a/projects/ppwcode/ng-utils/.eslintrc.json b/projects/ppwcode/ng-utils/.eslintrc.json new file mode 100644 index 00000000..a060900c --- /dev/null +++ b/projects/ppwcode/ng-utils/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "extends": "../../../.eslintrc.json", + "ignorePatterns": ["!**/*"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@ppwcode/ng-utils", + "message": "Use a relative import instead. Importing from @ppwcode/ng-utils is not allowed inside this lib directory." + } + ] + } + ] + }, + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "ppw", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "ppw", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "rules": {} + } + ] +} diff --git a/projects/ppwcode/ng-utils/README.md b/projects/ppwcode/ng-utils/README.md new file mode 100644 index 00000000..75cd33aa --- /dev/null +++ b/projects/ppwcode/ng-utils/README.md @@ -0,0 +1,5 @@ +# NgUtils + +This library provides utilities often used in Angular applications. + +## Usage diff --git a/projects/ppwcode/ng-utils/ng-package.json b/projects/ppwcode/ng-utils/ng-package.json new file mode 100644 index 00000000..31e126b6 --- /dev/null +++ b/projects/ppwcode/ng-utils/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist/ppwcode/ng-utils", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ppwcode/ng-utils/package.json b/projects/ppwcode/ng-utils/package.json new file mode 100644 index 00000000..b225def0 --- /dev/null +++ b/projects/ppwcode/ng-utils/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ppwcode/ng-utils", + "version": "21.0.0", + "repository": { + "url": "https://github.com/peopleware/angular-sdk" + }, + "peerDependencies": {}, + "dependencies": { + "tslib": "^2.3.0" + }, + "sideEffects": false +} diff --git a/projects/ppwcode/ng-utils/src/lib/assertion.spec.ts b/projects/ppwcode/ng-utils/src/lib/assertion.spec.ts new file mode 100644 index 00000000..77187aa9 --- /dev/null +++ b/projects/ppwcode/ng-utils/src/lib/assertion.spec.ts @@ -0,0 +1,50 @@ +import { natural, noDuplicates } from './assertion' + +describe('assertion', () => { + describe('#natural', () => { + it('says true for undefined', () => { + const result = natural() + expect(result).toBe(true) + }) + + it('says true for a natural', () => { + const result = natural(7) + expect(result).toBe(true) + }) + + const cases = [Math.PI, -7, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] + + cases.forEach((n) => { + it(`says false for ${n}`, () => { + const result = natural(n) + expect(result).toBe(false) + }) + }) + }) + describe('#noDuplicates', () => { + it('returns true for the empty array', () => { + const result = noDuplicates([]) + expect(result).toBeTruthy() + }) + it('returns true for an array with 1 element', () => { + const result = noDuplicates([1]) + expect(result).toBeTruthy() + }) + it('returns true for an array with many non-duplicate elements', () => { + const result = noDuplicates([1, 4, 3, '', {}, true, {}]) + expect(result).toBeTruthy() + }) + it('returns false for an array with 2 duplicate elements', () => { + const result = noDuplicates([4, 4]) + expect(result).toBeFalsy() + }) + it('returns false for an array with many elements, with 1 duplicate', () => { + const result = noDuplicates([1, 4, 3, '', {}, '', true]) + expect(result).toBeFalsy() + }) + it('returns false for an array with many elements, with several duplicate', () => { + const result = noDuplicates([1, 4, true, 3, '', {}, '', 3, true]) + expect(result).toBeFalsy() + }) + }) +}) diff --git a/projects/ppwcode/ng-utils/src/lib/assertion.ts b/projects/ppwcode/ng-utils/src/lib/assertion.ts new file mode 100644 index 00000000..fde6e610 --- /dev/null +++ b/projects/ppwcode/ng-utils/src/lib/assertion.ts @@ -0,0 +1,9 @@ +/** + * Assertion that expresses that the {@code number} argument is ∈ ℕ, or `undefined` + */ +export const natural = (i?: number) => i === undefined || (Number.isInteger(i) && i >= 0) + +/** + * The given array does not contain duplicate entries according to `===`. + */ +export const noDuplicates = (arr: Array): boolean => arr.every((el, i) => arr.indexOf(el) === i) diff --git a/projects/ppwcode/ng-utils/src/lib/conditional-assert.spec.ts b/projects/ppwcode/ng-utils/src/lib/conditional-assert.spec.ts new file mode 100644 index 00000000..3299105b --- /dev/null +++ b/projects/ppwcode/ng-utils/src/lib/conditional-assert.spec.ts @@ -0,0 +1,302 @@ +import { natural } from './assertion' +import { + assert, + baseViolationMessage, + ConditionViolation, + notNull, + notNullViolationMessage, + notUndefined, + notUndefinedViolationMessage, + settings, + violationMessage +} from './conditional-assert' +import Spy = jasmine.Spy + +interface Fixture { + originalConditionalAssertEnabled: boolean + originalLogViolations: boolean + spy: Spy +} + +describe('Conditional Assert ', () => { + beforeEach(function (this: Fixture): void { + this.originalConditionalAssertEnabled = settings.enabled + this.originalLogViolations = settings.logViolations + }) + + afterEach(function (this: Fixture): void { + settings.enabled = this.originalConditionalAssertEnabled + settings.logViolations = this.originalLogViolations + }) + + describe('#violationMessage', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const circular: any = {} + circular.prop = circular + const subjects = [ + 0, + 1, + Number.NaN, + 'this is a string', + true, + false, + {}, + circular, + new Date(), + // TODO Symbol('A'), + null, + undefined, + () => 0, + (a: number) => { + const b = a / 2 + + return b * 2 + } + ] + const array = subjects.slice() + array.push(subjects.slice()) + subjects.push(array) + + const customMessages = ['custom message', undefined] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cases: Array<{ subject: any; customMessage?: string }> = subjects.reduce( + (acc1, s) => + customMessages.reduce((acc2, cm) => { + acc2.push({ subject: s, customMessage: cm }) + + return acc2 + }, acc1), + [] + ) + + const assertion = () => true + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const escapeRegExp = (str: any): string => `${str}`.replace(/([()[+*])/g, '\\$1') + + cases.forEach((c) => { + it(`creates the expected message for ${c.subject} ${ + c.customMessage ? 'with' : 'without' + } a custom message`, () => { + const result = violationMessage(c.subject, assertion, c.customMessage) + expect(result).toMatch(new RegExp(`^${baseViolationMessage}: «`)) + expect(result).toMatch( + new RegExp(`^${baseViolationMessage}: «${c.customMessage || escapeRegExp(assertion)}»`) + ) + expect(result).toMatch(new RegExp(`«${escapeRegExp(c.subject)}»$`)) + }) + }) + + it('creates the expected message for a multiline assertion', () => { + const multilineAssertion = () => { + const a = 4 + + return a % 2 === 0 + } + const result = violationMessage(7, multilineAssertion) + expect(result).toMatch(new RegExp(escapeRegExp(multilineAssertion.toString().replace(/\s+/g, ' ')))) + }) + }) + + describe('#assert', () => { + const subject = 5 + const alwaysTrue = () => true + const alwaysFalse = () => false + const customMessage = 'custom message' + + const generateNoProblemTests = () => { + it('does nothing when the assertion evaluates to true, with the default message', () => { + assert(subject, alwaysTrue) + expect(5).not.toBeUndefined() // Jasmine complains when there is no expect + }) + + it('does nothing when the assertion evaluates to true, with a custom message', () => { + assert(subject, alwaysTrue, customMessage) + expect(5).not.toBeUndefined() // Jasmine complains when there is no expect + }) + } + + describe('enabled', () => { + beforeEach(() => { + settings.enabled = true + }) + + generateNoProblemTests() + + it('throws when the assertion fails, with the default message', () => { + const expectedMessage = violationMessage(subject, alwaysFalse) + expect(assert.bind(undefined, subject, alwaysFalse)).toThrowError(ConditionViolation, expectedMessage) + }) + + it('throws when the assertion fails, with a custom message', () => { + const expectedMessage = violationMessage(subject, alwaysFalse, customMessage) + expect(assert.bind(undefined, subject, alwaysFalse, customMessage)).toThrowError( + ConditionViolation, + expectedMessage + ) + }) + + describe('log violations', () => { + beforeEach(function (this: Fixture): void { + this.spy = spyOn(console, 'error') // spy released by Jasmine automatically + }) + + it('does not log violations to the console when requested', function (this: Fixture): void { + settings.logViolations = false + try { + assert(5, (t) => t === 3) + // should have failed and have written to console + fail() + } catch (err) { + expect(err).toBeDefined() + expect(this.spy).not.toHaveBeenCalled() + } + }) + + it('logs violations to the console when requested', function (this: Fixture): void { + settings.logViolations = true + try { + assert(5, (t) => t === 3) + // should have failed and have written to console + fail() + } catch (err) { + expect(err).toBeDefined() + expect(this.spy).toHaveBeenCalledWith(err) + } + }) + }) + }) + + describe('disabled', () => { + beforeEach(() => { + settings.enabled = false + }) + + generateNoProblemTests() + + it('does not throw when the assertion failed, with the default message', () => { + assert(subject, alwaysFalse) + expect(5).not.toBeUndefined() // Jasmine complains when there is no expect + }) + + it('does not throw when the assertion failed, with a custom message', () => { + assert(subject, alwaysFalse, customMessage) + expect(5).not.toBeUndefined() // Jasmine complains when there is no expect + }) + }) + }) + + describe('#notUndefined', () => { + const generateNoProblemTests = () => { + it('returns t when it is actual, and cannot be undefined', () => { + const t = 5 + const result = notUndefined(t) + expect(result).toBe(t) + }) + + it('returns t when it is actual, and can be undefined', () => { + const t: number | undefined = 5 + const result = notUndefined(t) + expect(result).toBe(t) + }) + } + + describe('enabled', () => { + beforeEach(() => { + settings.enabled = true + }) + + generateNoProblemTests() + + it('throws when t is undefined', () => { + const t: number | undefined = undefined + expect(notUndefined.bind(undefined, t)).toThrowError( + ConditionViolation, + violationMessage(undefined, () => true, notUndefinedViolationMessage) + ) + }) + }) + + describe('disabled', () => { + beforeEach(() => { + settings.enabled = false + }) + + generateNoProblemTests() + + it('does not throw when t is undefined, but returns undefined', () => { + const t: number | undefined = undefined + const result = notUndefined(t) + expect(result).toBeUndefined() + }) + }) + }) + + describe('#notNull', () => { + const generateNoProblemTests = () => { + it('returns t when it is actual, and cannot be null', () => { + const t = 5 + const result = notNull(t) + expect(result).toBe(t) + }) + + it('returns t when it is actual, and can be null', () => { + const t: number | null = 5 + const result = notNull(t) + expect(result).toBe(t) + }) + } + + describe('enabled', () => { + beforeEach(() => { + settings.enabled = true + }) + + generateNoProblemTests() + + it('throws when t is null', () => { + const t: number | null = null + expect(notNull.bind(null, t)).toThrowError( + ConditionViolation, + violationMessage(null, () => true, notNullViolationMessage) + ) + }) + }) + + describe('disabled', () => { + beforeEach(() => { + settings.enabled = false + }) + + generateNoProblemTests() + + it('does not throw when t is null, but returns null', () => { + const t: number | null = null + const result = notNull(t) + expect(result).toBeNull() + }) + }) + }) + + describe('#natural', () => { + it('says true for undefined', () => { + const result = natural() + expect(result).toBe(true) + }) + + it('says true for a natural', () => { + const result = natural(7) + expect(result).toBe(true) + }) + + const cases = [Math.PI, -7, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] + + cases.forEach((n) => { + it(`says false for ${n}`, () => { + const result = natural(n) + expect(result).toBe(false) + }) + }) + }) +}) diff --git a/projects/ppwcode/ng-utils/src/lib/conditional-assert.ts b/projects/ppwcode/ng-utils/src/lib/conditional-assert.ts new file mode 100644 index 00000000..8144ebc6 --- /dev/null +++ b/projects/ppwcode/ng-utils/src/lib/conditional-assert.ts @@ -0,0 +1,142 @@ +/** + * Functions to be used as assertions in code. If the assertion.ts fails, a {@link ConditionViolation} is thrown. + * + * Assertions are only tested if {@link settings.enabled} is {@code true}. + */ + +export const settings = { + /** + * Are the conditional assertions enabled? + */ + enabled: true, + + /** + * Should {@link assert} log violations on the console itself?s + */ + logViolations: false +} + +/** + * Flags a violation of a condition. + */ +export class ConditionViolation extends Error { + // istanbul ignore next MUDO Remove this line when we use es2015+ as target. This ignore is necessary as long as we work with es5. + constructor(message: string) { + super(message) + + // Set the prototype explicitly. + Object.setPrototypeOf(this, ConditionViolation.prototype) + } +} + +export const baseViolationMessage = 'Condition Violation' +export const notUndefinedViolationMessage = 'value was asserted not to be `undefined`' +export const notNullViolationMessage = 'value was asserted not to be `null`' + +// TODO support Symbols +// IDEA get a better string representation for all cases of `subject` and assertion.ts in the message (see @toryt/contrcts-) +export const violationMessage = (subject: T, assertion: (subject: T) => boolean, message?: string): string => + `${baseViolationMessage}: «${message || assertion.toString().replace(/\s+/g, ' ')}» failed for «${String( + subject + )}»` + +/** + * Assert that {@code assertion.ts(subject)} is {@code true}. + * + * If successful, does nothing. + * + * Throws a {@code ConditionViolation} if {@code assertion.ts(subject)} is falsy. + * + * The intention is for this error to be caught eventually near the top of the stack, and logged or shown. + * + * In some cases however, notably when the error is thrown in badly written asynchronous code, the error is never + * caught, and lost in limbo. Such occurrences are hard to debug. Therefor, this function can, if desired log the error itself + * before it is thrown. This is done by setting {@link logViolations} to {@code true}. + * + * Assertions are only tested if {@link settings.enabled} is {@code true}. + * + * @Example: + * + * ``` + * function (a: number) { + * assert(a, Number.isInteger) + * assert(a, a => a >= 0) + * + * … + * } + */ +export const assert = (subject: T, assertion: (subject: T) => boolean, message?: string) => { + if (settings.enabled && !assertion(subject)) { + // IDEA add subject and condition to error for easy reference when it occurs; see, e.g., Node AssertionCondition + const err = new ConditionViolation(violationMessage(subject, assertion, message)) + if (settings.logViolations) { + console.error(err) + } + throw err + } +} + +/** + * Assert that {@code t}, of type {@code T | undefined}, is not undefined, and thus of type {@code T}. + * + * If successful, returns the actual value of {@code t} with type {@code T}. + * + * Throws a {@code ConditionViolation} if {@code t} is `undefined`. + * + * Assertions are only tested if {@link settings.enabled} is {@code true}. + * + * Usage: + * + * ``` + * const optionalA?: A = … + * const a: A = notUndefined(optionalA) + * ``` + * + * This replaces + * + * ``` + * const optionalA?: A = … + * const a: A = optionalA as A + * ``` + * + * The difference is that with `as`, no {@code Error} is thrown if `optionalA` is `undefined`. The violation only turns up later, when, and + * if `a` is actually accessed. + */ +export const notUndefined = (t?: T): T => { + assert(t, (u) => u !== undefined, notUndefinedViolationMessage) + + return t as T +} + +/** + * Assert that {@code t}, of type {@code T | null}, is not null, and thus of type {@code T}. + * + * If successful, returns the actual value of {@code t} with type {@code T}. + * + * Throws a {@code ConditionViolation} if {@code t} is `null`. + * + * Assertions are only tested if {@link settings.enabled} is {@code true}. + * + * Usage: + * + * ``` + * const nullableA: A | null = … + * const a: A = notNull(optionalA) + * ``` + * + * This replaces + * + * ``` + * const nullableA: A | null = … + * const a: A = nullableA! + * ``` + * + * The difference is that with `!`, no {@code Error} is thrown if `nullableA` is `null`. The violation only turns up later, when, and + * if `a` is actually accessed. + */ +export const notNull = (t: T | null): T => { + assert(t, (u) => u !== null, notNullViolationMessage) + + // tslint:disable-next-line:no-non-null-assertion.ts + return t! +} diff --git a/projects/ppwcode/ng-utils/src/public-api.ts b/projects/ppwcode/ng-utils/src/public-api.ts new file mode 100644 index 00000000..a17f5084 --- /dev/null +++ b/projects/ppwcode/ng-utils/src/public-api.ts @@ -0,0 +1,5 @@ +/* + * Public API Surface of ng-utils + */ +export * from './lib/conditional-assert' +export * from './lib/assertion' diff --git a/projects/ppwcode/ng-utils/tsconfig.lib.json b/projects/ppwcode/ng-utils/tsconfig.lib.json new file mode 100644 index 00000000..1f50a4d8 --- /dev/null +++ b/projects/ppwcode/ng-utils/tsconfig.lib.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.angular.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "**/*.spec.ts" + ] +} diff --git a/projects/ppwcode/ng-utils/tsconfig.lib.prod.json b/projects/ppwcode/ng-utils/tsconfig.lib.prod.json new file mode 100644 index 00000000..06de549e --- /dev/null +++ b/projects/ppwcode/ng-utils/tsconfig.lib.prod.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/projects/ppwcode/ng-utils/tsconfig.spec.json b/projects/ppwcode/ng-utils/tsconfig.spec.json new file mode 100644 index 00000000..80f875ef --- /dev/null +++ b/projects/ppwcode/ng-utils/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/scripts/ci/build-libs.sh b/scripts/ci/build-libs.sh index 524034bd..10ade45d 100755 --- a/scripts/ci/build-libs.sh +++ b/scripts/ci/build-libs.sh @@ -1,7 +1,7 @@ #!/bin/bash export NODE_ENV="ci" -declare -a LIBRARIES_LIST=("ng-common" "ng-common-components" "ng-async" "ng-dialogs" "ng-forms" "ng-router" "ng-state-management" "ng-unit-testing" "ng-wireframe") +declare -a LIBRARIES_LIST=("ng-utils" "ng-common" "ng-common-components" "ng-async" "ng-dialogs" "ng-forms" "ng-router" "ng-state-management" "ng-unit-testing" "ng-wireframe") declare -a LIBRARIES_COUNT=${#LIBRARIES_LIST[@]} # Loop over libs diff --git a/scripts/ci/test-libs.js b/scripts/ci/test-libs.js index 8599781b..ebf26ed4 100644 --- a/scripts/ci/test-libs.js +++ b/scripts/ci/test-libs.js @@ -9,6 +9,7 @@ const projects = [ '@ppwcode/ng-router', '@ppwcode/ng-state-management', '@ppwcode/ng-unit-testing', + '@ppwcode/ng-utils', '@ppwcode/ng-wireframe', 'ppwcode' ] diff --git a/tsconfig.angular.json b/tsconfig.angular.json index 4d09fd15..55d02ca8 100644 --- a/tsconfig.angular.json +++ b/tsconfig.angular.json @@ -11,6 +11,7 @@ "@ppwcode/ng-router": ["dist/ppwcode/ng-router"], "@ppwcode/ng-state-management": ["dist/ppwcode/ng-state-management"], "@ppwcode/ng-unit-testing": ["dist/ppwcode/ng-unit-testing"], + "@ppwcode/ng-utils": ["dist/ppwcode/ng-utils"], "@ppwcode/ng-wireframe": ["dist/ppwcode/ng-wireframe"] }, "baseUrl": "./", diff --git a/tsconfig.json b/tsconfig.json index 7a6fe00f..043ac3d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "@ppwcode/ng-router": ["projects/ppwcode/ng-router/src/public-api"], "@ppwcode/ng-state-management": ["projects/ppwcode/ng-state-management/src/public-api"], "@ppwcode/ng-unit-testing": ["projects/ppwcode/ng-unit-testing/src/public-api"], + "@ppwcode/ng-utils": ["projects/ppwcode/ng-utils/src/public-api"], "@ppwcode/ng-wireframe": ["projects/ppwcode/ng-wireframe/src/public-api"] } }