commit 651a21a03562c2706de355b50288775bc2c5049e Author: Alexander Zinn Date: Sat Aug 16 14:17:46 2025 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..69a45c1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@techniker-me:registry=https://registry-node.techniker.me +//registry-node.techniker.me/:_authToken="${NODE_REGISTRY_AUTH_TOKEN}" \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8fdd954 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..caa814d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "arrowParens": "avoid", + "bracketSameLine": true, + "bracketSpacing": false, + "printWidth": 160, + "semi": true, + "singleAttributePerLine": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/README b/README new file mode 100644 index 0000000..7d2c25b --- /dev/null +++ b/README @@ -0,0 +1,27 @@ +# @zinntechniker/tools + +A library of useful tools + +### Assertions + +- Assert unreachable + +### Disposables + +- Disposable +- DisposabeList + +### Events + +- EventEmitter +- EventPublisher + +### Maths + +- Averager +- Random + +### Observables + +- Subject +- ReadOnlySubject diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..f836c76 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,11 @@ +telemetry = false + +[install] +exact = true + +[install.lockfile] +save = false + +[test] +coverage = true +coverageSkipTestFiles = true \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c16afea --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,41 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default [ + { + ...tseslint.configs.recommended, + ...pluginJs.configs.recommended, + ignores: [ + // Existing ignores + './test/', + 'node_modules/**', // Explicitly ignore node_modules and subdirs + // Add Bun-specific cache ignores (adjust paths if needed) + '**/.bun/**', // Covers Bun's cache dirs + '/home/teamcity-agent/.bun/**', // Absolute path for CI (if known; customize for your agent) + // Other common ignores + '**/dist/**', // Build outputs + '**/build/**', + '**/temp/**', + '**/cache/**', + '**/*.min.js', // Minified files + ], + files: ['./src/**/*.{ts,js,tsx,jsx}'], // Expanded to include JSX if needed; keeps it scoped to src + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + parserOptions: { + ecmaVersion: 'latest', // Ensure modern JS support + sourceType: 'module', + }, + }, + rules: { + // Optional: Suppress specific warnings seen in log (e.g., unused disables) + 'no-unused-vars': 'warn', // Downgrade if needed + // Add rules to handle common log issues (customize as needed) + '@typescript-eslint/no-non-null-assertion': 'off', // Temporarily disable if causing many errors + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + // Ignore undefined rules in third-party code + 'eslint-plugin/no-property-in-node': 'off', + }, + }, +]; diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..cc840fd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,15 @@ +# examples + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.1.20. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/examples/bun.lockb b/examples/bun.lockb new file mode 100755 index 0000000..2ce339f Binary files /dev/null and b/examples/bun.lockb differ diff --git a/examples/bunfig.toml b/examples/bunfig.toml new file mode 100644 index 0000000..8fd35a9 --- /dev/null +++ b/examples/bunfig.toml @@ -0,0 +1,11 @@ +telemetry = false + +[install] +exact = true + +[install.lockfile] +save = false + +[test] +coverage = true +coverageSkipTestFiles = true diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..82a3c54 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "@zinntechniker/tools-examples", + "version": "0.0.1", + "type": "module", + "author": { + "name": "Alexander Zinn", + "git+url": "https://github.com/zinntechniker/tools" + }, + "devDependencies": { + "@types/bun": "latest", + "@zinntechniker/tools": "latest", + "typescript": "5.5.2" + }, + "publishConfig": { + "registry": "https://registry-node.techniker.me" + } +} diff --git a/examples/src/example-observables.ts b/examples/src/example-observables.ts new file mode 100644 index 0000000..906e3d7 --- /dev/null +++ b/examples/src/example-observables.ts @@ -0,0 +1,13 @@ +import Tools from '@zinntechniker/tools'; + +const { + observables: {Subject, ReadOnlySubject} +} = Tools; + +const subject = new Subject(0); +const subjectSubscriber = (value: number) => { + console.log("Subject's value changed to [%o]", value); +}; +subject.subscribe(subjectSubscriber); + +subject.value = 12; diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..7c7361f --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src"], + "exclude": ["./dist", "./test"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..73c6f0a --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "@techniker-me/tools", + "version": "2025.0.16", + "type": "module", + "private": false, + "types": "./dist/types/index.d.ts", + "exports": { + "types": "./dist/types/index.d.ts", + "import": "./dist/node/index.js", + "browser": "./dist/browser/index.js", + "default": "./dist/node/index.js" + }, + "author": { + "name": "Alexander Zinn", + "git+url": "https://git.techniker.me/techniker-me/tools.git" + }, + "publishConfig": { + "registry": "http://localhost:7873" + }, + "scripts": { + "ci-install": "bun install", + "ci-test": "bun test", + "ci-build": "bash scripts/ci-build.sh", + "ci-deploy:ga": "bash scripts/ci-deploy.sh --beta false", + "ci-deploy:beta": "bash scripts/ci-deploy.sh --beta true", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test": "bun test", + "build:node:debug": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/node", + "build:browser:debug": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --sourcemap=inline --outdir=dist/browser", + "build:node": "bun build ./src/index.ts --target=node --sourcemap=none --format=esm --splitting --minify --outdir=dist/node", + "build:browser": "bun build ./src/index.ts --target=browser --sourcemap=none --format=esm --splitting --minify --outdir=dist/browser", + "build:types": "bunx tsc -p tsconfig.d.json", + "build:prepare-package-json": "bash scripts/prepare-package-json.sh" + }, + "devDependencies": { + "@eslint/js": "8.57.1", + "@types/bun": "latest", + "@types/node": "22.5.2", + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", + "eslint": "8.57.1", + "globals": "15.9.0", + "prettier": "3.3.3", + "typescript": "5.5.4", + "typescript-eslint": "8.4.0" + }, + "files": [ + "dist", + "browser", + "node", + "types", + "package.json", + "README.md" + ] +} diff --git a/scripts/ci-build.sh b/scripts/ci-build.sh new file mode 100755 index 0000000..b8df7be --- /dev/null +++ b/scripts/ci-build.sh @@ -0,0 +1,29 @@ +#! /usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +distDirectory=${DIST_DIRECTORY:-"dist"} + +rm -rf ${distDirectory} + +bun run lint +bun run build:node +bun run build:browser +bun run build:types +bun run build:prepare-package-json + +echo "Copying [.npmrc] to [${distDirectory}]" +cp .npmrc ./${distDirectory} + +echo "Copying [.nvmrc] to [${distDirectory}]" +cp .nvmrc ./${distDirectory} + +echo "Copying [README.md] to [${distDirectory}]" +cp README ./${distDirectory} + +ls ${distDirectory} + +echo -e "\nci-build complete!" +exit 0 diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh new file mode 100755 index 0000000..88b7d3f --- /dev/null +++ b/scripts/ci-deploy.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + + +registryUrl="http://localhost:4873" +# registryUrl="https://registry-node.techniker.me" +packageVersionToDeploy="" +isBeta="false" + +while [[ "${#}" -gt 0 ]]; do + case "${1}" in + --beta) + isBeta="${2}" + shift 2 + ;; + --version) + packageVersionToDeploy="${2}" + shift 2 + ;; + *) + echo "Unknown option [${1}]" + exit "${LINENO}" + ;; + esac +done + +function cleanDirectory { + local directory="${1}" + + if [ -d "${directory}" ]; then + echo "Deleting [${directory}]..." + + rm -rf "${directory}" + fi +} + +function removePackageJsonMember { + local packageJsonPath="dist/package.json" + local memberToRemove="${1}" + + if [ -f "${packageJsonPath}" ]; then + echo "Removing [${memberToRemove}] from the dist/package.json" + + jq "del(.${memberToRemove})" "${packageJsonPath}" >tmp.$$.json && mv tmp.$$.json "$packageJsonPath" + else + echo "Error: [${packageJsonPath}] not found." + fi +} + +function updatePackageJsonVersion { + local versionToUpdate="${1}" + + if [ isBeta == "true" ]; then + echo "Version to update [${versionToUpdate}] Contains beta" + echo "Updating package.json version to [${versionToUpdate}]" + + local packageJsonVersion=$(jq -r '.version' package.json) + + sed -i "s/\"version\": \"${packageJsonVersion}\"/\"version\": \"${versionToUpdate}\"/" dist/package.json + fi +} + +echo "Deploying [${packageVersionToDeploy}]" +echo "isBeta [${isBeta}]" + +cleanDirectory "dist" + +bun run ci-build + +removePackageJsonMember "devDependencies" +removePackageJsonMember "scripts" + +echo "publishing to ${registryUrl}" +if [ "${isBeta}" == "true" ]; then + updatePackageJsonVersion "${packageVersionToDeploy}" + npm publish --registry "${registryUrl}" --tag beta +else + npm publish --registry "${registryUrl}" +fi + diff --git a/scripts/prepare-package-json.sh b/scripts/prepare-package-json.sh new file mode 100755 index 0000000..a516f9a --- /dev/null +++ b/scripts/prepare-package-json.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +distDirectory=${DIST_DIRECTORY:-"dist"} +packageJsonPath=${PACKAGE_JSON_PATH:-"package.json"} + +if [ ! -d "${distDirectory}" ]; then + echo "Unable to prepare package.json, [${distDirectory}] not found" + exit $LINENO +fi + +echo "Preparing [package.json] to [${distDirectory}]" +jq '{name, version, author, type, types, exports, files, publishConfig}' "${packageJsonPath}" > "${distDirectory}/package.json" diff --git a/src/assertions/assertUnreachable.ts b/src/assertions/assertUnreachable.ts new file mode 100644 index 0000000..0e58f35 --- /dev/null +++ b/src/assertions/assertUnreachable.ts @@ -0,0 +1,3 @@ +export default function assertUnreachable(never: never): never { + throw new Error(`Should not have reached [${never}]`); +} diff --git a/src/assertions/index.ts b/src/assertions/index.ts new file mode 100644 index 0000000..db703d9 --- /dev/null +++ b/src/assertions/index.ts @@ -0,0 +1,3 @@ +import assertUnreachable from './assertUnreachable'; + +export {assertUnreachable}; diff --git a/src/disposables/Disposable.ts b/src/disposables/Disposable.ts new file mode 100644 index 0000000..0e3bfb1 --- /dev/null +++ b/src/disposables/Disposable.ts @@ -0,0 +1,23 @@ +import type IDisposable from './IDisposable'; + +export default class Disposable implements IDisposable { + private _isDisposed = false; + private _disposable: () => void; + + constructor(disposable: () => void) { + this._disposable = disposable; + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + public dispose(): void { + if (this._isDisposed) { + return; + } + + this._disposable(); + this._isDisposed = true; + } +} diff --git a/src/disposables/DisposableList.ts b/src/disposables/DisposableList.ts new file mode 100644 index 0000000..98e311f --- /dev/null +++ b/src/disposables/DisposableList.ts @@ -0,0 +1,17 @@ +import type IDisposable from './IDisposable'; + +export default class DisposableList implements IDisposable { + private readonly _disposables: IDisposable[] = []; + + public add(disposable: IDisposable): void { + this._disposables.push(disposable); + } + + public dispose(): void { + while (this._disposables.length) { + const disposable = this._disposables.shift() as IDisposable; + + disposable.dispose(); + } + } +} diff --git a/src/disposables/IDisposable.ts b/src/disposables/IDisposable.ts new file mode 100644 index 0000000..8917250 --- /dev/null +++ b/src/disposables/IDisposable.ts @@ -0,0 +1,3 @@ +export default interface IDisposable { + dispose(): void; +} diff --git a/src/disposables/index.ts b/src/disposables/index.ts new file mode 100644 index 0000000..3665977 --- /dev/null +++ b/src/disposables/index.ts @@ -0,0 +1,6 @@ +import type IDisposable from './IDisposable'; +import Disposable from './Disposable'; +import DisposableList from './DisposableList'; + +export type {IDisposable}; +export {Disposable, DisposableList}; diff --git a/src/events/EventEmitter.ts b/src/events/EventEmitter.ts new file mode 100644 index 0000000..c55ef2e --- /dev/null +++ b/src/events/EventEmitter.ts @@ -0,0 +1,23 @@ +import type IDisposable from '../disposables/IDisposable'; +import type IEvent from './IEvent'; +import {Disposable} from '../disposables'; + +export default class EventEmitter implements IDisposable { + private readonly _listeners: Array<(event: IEvent) => void> = []; + + public subscribe(listener: (event: IEvent) => void): Disposable { + const listenerIndex = this._listeners.push(listener); + + return new Disposable(() => this._listeners.splice(listenerIndex - 1, 1)); + } + + public emit>(event: Event): void { + this._listeners.forEach(listener => listener(event)); + } + + public dispose(): void { + while (this._listeners.length) { + this._listeners.shift(); + } + } +} diff --git a/src/events/EventPublisher.ts b/src/events/EventPublisher.ts new file mode 100644 index 0000000..b23db68 --- /dev/null +++ b/src/events/EventPublisher.ts @@ -0,0 +1,39 @@ +import type IEvent from './IEvent'; +import {Disposable, DisposableList} from '../disposables'; +import EventEmitter from './EventEmitter'; + +export default class EventPublisher { + private readonly _eventEmitters: Record> = {}; + private readonly _disposables = new DisposableList(); + + constructor() { + this._eventEmitters['no-subscribers'] = new EventEmitter(); + } + + public addNoSubscriberHandler(handler: (event: IEvent) => Promise | void) { + return this._eventEmitters['no-subscribers'].subscribe(handler); + } + + public subscribe(event: string | number, handler: (event: IEvent) => Promise | void): Disposable { + const emitter = this._eventEmitters[event] ?? this.createEmitter(event); + const subscription = emitter.subscribe(handler); + + this._disposables.add(subscription); + + return subscription; + } + + public publish(eventName: string | number, event: IEvent): void { + (this._eventEmitters[eventName] || this._eventEmitters['no-subscribers']).emit>(event); + } + + public dispose(): void { + Object.values(this._eventEmitters).forEach(emitter => emitter.dispose()); + } + + private createEmitter(event: string | number): EventEmitter> { + const eventEmitter = new EventEmitter>(); + + return (this._eventEmitters[event] = eventEmitter); + } +} diff --git a/src/events/IEvent.ts b/src/events/IEvent.ts new file mode 100644 index 0000000..4d6b6f3 --- /dev/null +++ b/src/events/IEvent.ts @@ -0,0 +1,4 @@ +export default interface IEvent { + type: string; + payload?: Payload; +} diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..3c2f61a --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,6 @@ +import type IEvent from './IEvent'; +import EventEmitter from './EventEmitter'; +import EventPublisher from './EventPublisher'; + +export type {IEvent}; +export {EventEmitter, EventPublisher}; diff --git a/src/functions/index.ts b/src/functions/index.ts new file mode 100644 index 0000000..f941cb4 --- /dev/null +++ b/src/functions/index.ts @@ -0,0 +1,3 @@ +import {createRangeIterator} from './rangeIterator'; + +export {createRangeIterator}; diff --git a/src/functions/rangeIterator.ts b/src/functions/rangeIterator.ts new file mode 100644 index 0000000..48e4ee4 --- /dev/null +++ b/src/functions/rangeIterator.ts @@ -0,0 +1,16 @@ +export function createRangeIterator(start: number, end: number, step = 1) { + return { + [Symbol.iterator]() { + return this; + }, + next() { + if (start < end) { + start = start + step; + + return {value: start, done: false}; + } + + return {value: end, done: true}; + } + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6b5be9e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import type IDisposable from './disposables/IDisposable'; +import type IEvent from './events/IEvent'; + +export type {IDisposable, IEvent}; + +export {assertUnreachable} from './assertions'; +export {Disposable, DisposableList} from './disposables'; +export {EventEmitter, EventPublisher} from './events'; +export {Averager, Random} from './maths'; +export {Subject, ReadOnlySubject} from './observables'; +export {Strings} from './strings'; +export {createRangeIterator} from './functions'; diff --git a/src/maths/Averager.ts b/src/maths/Averager.ts new file mode 100644 index 0000000..60b7497 --- /dev/null +++ b/src/maths/Averager.ts @@ -0,0 +1,33 @@ +import {Subject, ReadOnlySubject} from '../observables'; + +export default class Averager { + private readonly _sampleSize: number; + private readonly _samples: number[] = []; + private readonly _average = new Subject(0); + private readonly _readOnlyAverage = new ReadOnlySubject(this._average); + private _onPush: (sample: number) => void = this.onPushOnly.bind(this); + + constructor(sampleSize: number) { + this._sampleSize = sampleSize; + } + + get average(): ReadOnlySubject { + return this._readOnlyAverage; + } + + public push(value: number): void { + this._onPush(value); + } + + private onPushOnly(sample: number) { + if (this._samples.push(sample) === this._sampleSize) { + this._onPush = this.pushAndCalculateAverage.bind(this); + } + } + + private pushAndCalculateAverage(sample: number) { + this._samples.push(sample); + this._samples.shift(); + this._average.value = this._samples.reduce((sum, sampleValue) => sum + sampleValue, 0) / this._sampleSize; + } +} diff --git a/src/maths/Random.ts b/src/maths/Random.ts new file mode 100644 index 0000000..70adeba --- /dev/null +++ b/src/maths/Random.ts @@ -0,0 +1,23 @@ +export default class Random { + private static readonly lowercaseLetters = 'abcdefghijklmnopqrstuvwxyz'; + private static readonly uppercaseLatters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + private static readonly letters = this.lowercaseLetters + this.uppercaseLatters; + + public static number(): number { + return Math.random(); + } + + public static numberInRange(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + public static string(length: number): string { + let result = ''; + + for (let idx = 0; idx < length; idx++) { + result += this.letters[this.numberInRange(0, this.letters.length - 1)]; + } + + return result; + } +} diff --git a/src/maths/index.ts b/src/maths/index.ts new file mode 100644 index 0000000..fee2ea8 --- /dev/null +++ b/src/maths/index.ts @@ -0,0 +1,4 @@ +import Averager from './Averager'; +import Random from './Random'; + +export {Averager, Random}; diff --git a/src/observables/ReadOnlySubject.ts b/src/observables/ReadOnlySubject.ts new file mode 100644 index 0000000..72242d8 --- /dev/null +++ b/src/observables/ReadOnlySubject.ts @@ -0,0 +1,18 @@ +import Disposable from '../disposables/Disposable'; +import Subject from './Subject'; + +export default class ReadOnlySubject { + private readonly _subject: Subject; + + constructor(subject: Subject) { + this._subject = subject; + } + + get value(): T { + return this._subject.value; + } + + public subscribe(listener: (value: T) => void): Disposable { + return this._subject.subscribe(listener); + } +} diff --git a/src/observables/Subject.ts b/src/observables/Subject.ts new file mode 100644 index 0000000..08f2da4 --- /dev/null +++ b/src/observables/Subject.ts @@ -0,0 +1,39 @@ +import Disposable from '../disposables/Disposable'; + +export default class Subject { + private readonly _listeners: Array<(value: T) => void> = []; + private _value: T; + + constructor(value: T) { + this._value = value; + } + + set value(value: T) { + if (this._value === value) { + return; + } + + this._value = value; + this.notifyListeners(value); + } + + get value(): T { + return this._value; + } + + public subscribe(listener: (value: T) => void): Disposable { + const listenerIndex = this._listeners.push(listener); + + listener(this.value); + + return new Disposable(() => { + this._listeners.splice(listenerIndex, 1); + }); + } + + private notifyListeners(value: T): void { + for (const listener of this._listeners) { + listener(value); + } + } +} diff --git a/src/observables/index.ts b/src/observables/index.ts new file mode 100644 index 0000000..fae5a89 --- /dev/null +++ b/src/observables/index.ts @@ -0,0 +1,4 @@ +import Subject from './Subject'; +import ReadOnlySubject from './ReadOnlySubject'; + +export {Subject, ReadOnlySubject}; diff --git a/src/strings/index.ts b/src/strings/index.ts new file mode 100644 index 0000000..424e184 --- /dev/null +++ b/src/strings/index.ts @@ -0,0 +1,15 @@ +export class Strings { + static random(length = 8) { + if (length > 11) { + throw new Error(`[Strings] max length [11]`); + } + + return Math.random() + .toString(36) + .substring(2, length + 2); + } + + constructor() { + throw new Error('Strings is a static class that may not be instantiated'); + } +} diff --git a/test/assertions/assertUnreachable.test.ts b/test/assertions/assertUnreachable.test.ts new file mode 100644 index 0000000..bd22451 --- /dev/null +++ b/test/assertions/assertUnreachable.test.ts @@ -0,0 +1,10 @@ +import {describe, it, expect} from 'bun:test'; +import {assertUnreachable} from '../../src/assertions'; + +describe(`When asserting unreachable code`, () => { + it(`throws an error`, () => { + const value = 'NEVER_VALUE'; + + expect(() => assertUnreachable(value)).toThrowError(); + }); +}); diff --git a/test/disposables/Disposable.test.ts b/test/disposables/Disposable.test.ts new file mode 100644 index 0000000..6439443 --- /dev/null +++ b/test/disposables/Disposable.test.ts @@ -0,0 +1,41 @@ +import type {Mock} from 'bun:test'; +import {mock, describe, it, beforeEach, expect} from 'bun:test'; +import {Disposable} from '../../src/disposables'; + +describe(`When using a Disposable`, () => { + describe(`Given a Disposable`, () => { + let mockFunction: Mock; + let disposable: Disposable; + + beforeEach(() => { + mockFunction = mock(() => {}); + disposable = new Disposable(mockFunction); + }); + + describe(`Given the disposable is disposed`, () => { + beforeEach(() => { + disposable.dispose(); + }); + + it(`executes the disposble`, () => { + expect(mockFunction).toHaveBeenCalled(); + }); + + it(`marks the disposbale disposed`, () => { + expect(disposable.isDisposed).toBeTrue(); + }); + + describe(`Given the disposable is repeatedly disposed`, () => { + beforeEach(() => { + for (let count = 0; count < 10; count++) { + disposable.dispose(); + } + }); + + it(`disposes the disposable only once`, () => { + expect(mockFunction).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); diff --git a/test/disposables/DisposableList.test.ts b/test/disposables/DisposableList.test.ts new file mode 100644 index 0000000..e1782c7 --- /dev/null +++ b/test/disposables/DisposableList.test.ts @@ -0,0 +1,36 @@ +import type {Mock} from 'bun:test'; +import {mock, describe, it, beforeAll, beforeEach, expect} from 'bun:test'; +import {Disposable, DisposableList} from '../../src/disposables'; + +describe(`When using a DisposableList`, () => { + let disposableList: DisposableList; + + beforeEach(() => { + disposableList = new DisposableList(); + }); + + describe(`Given a disposbale list with multiple disposables`, () => { + let mocks: Array>; + let mockDisposables: Disposable[]; + + beforeEach(() => { + mocks = [mock(() => {}), mock(() => {}), mock(() => {}), mock(() => {})]; + mockDisposables = mocks.map(mock => new Disposable(mock)); + mockDisposables.forEach(mockDisposable => disposableList.add(mockDisposable)); + }); + + describe(`Given the disposable list is disposed`, () => { + beforeEach(() => { + disposableList.dispose(); + }); + + it(`disposes all of the disposables in the list`, () => { + mocks.forEach(mock => expect(mock).toHaveBeenCalled()); + }); + + it(`marks the disposable as disposed`, () => { + mockDisposables.forEach(disposable => expect(disposable.isDisposed).toBeTrue()); + }); + }); + }); +}); diff --git a/test/events/EventEmitter.test.ts b/test/events/EventEmitter.test.ts new file mode 100644 index 0000000..03b994c --- /dev/null +++ b/test/events/EventEmitter.test.ts @@ -0,0 +1,19 @@ +import {afterAll, beforeEach, describe, expect, it, mock} from 'bun:test'; +import {EventEmitter} from '../../src/events'; + +describe(`When emitting an event`, () => { + const eventEmitter: EventEmitter = new EventEmitter(); + + describe(`Given a callback is subscribed`, () => { + const mockListener = mock(() => undefined); + const subscription = eventEmitter.subscribe(mockListener); + + describe(`Given an event is emitted`, () => { + beforeEach(() => eventEmitter.emit('mock event')); + + it(`notifies the callback with the event`, () => expect(mockListener).toHaveBeenCalled()); + }); + + afterAll(() => subscription.dispose()); + }); +}); diff --git a/test/events/EventPublisher.test.ts b/test/events/EventPublisher.test.ts new file mode 100644 index 0000000..34804ba --- /dev/null +++ b/test/events/EventPublisher.test.ts @@ -0,0 +1,3 @@ +import {describe} from 'bun:test'; + +describe.todo(`When publishing events`); diff --git a/test/functions/range.test.ts b/test/functions/range.test.ts new file mode 100644 index 0000000..3497f15 --- /dev/null +++ b/test/functions/range.test.ts @@ -0,0 +1,20 @@ +import {describe, it, expect, mock} from 'bun:test'; +import {createRangeIterator} from '../../src/functions'; + +describe('When generating a range iterator', () => { + describe('Given start and stop values', () => { + const start = 1; + const stop = 10; + const mockFn = mock(() => undefined); + + it('generates an iterator of the correct length', () => { + const rangeIterator = createRangeIterator(1, 10); + + for (const idx of rangeIterator) { + mockFn(); + } + + expect(mockFn).toHaveBeenCalledTimes(9); + }); + }); +}); diff --git a/test/observables/ReadOnlySubject.test.ts b/test/observables/ReadOnlySubject.test.ts new file mode 100644 index 0000000..bb3ed57 --- /dev/null +++ b/test/observables/ReadOnlySubject.test.ts @@ -0,0 +1,60 @@ +import {jest, describe, it, expect} from 'bun:test'; +import {Subject, ReadOnlySubject} from '../../src/observables'; + +describe(`A ReadOnlySubject`, () => { + it(`exposes the Subject value`, () => { + const subject = new Subject(`init-value`); + const readOnlySubject = new ReadOnlySubject(subject); + + expect(readOnlySubject.value).toBe(`init-value`); + }); + + it(`updates the ReadOnlySubject value when set`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + + subject.value = 'some thing'; + + expect(readOnlySubject.value).toBe('some thing'); + }); + + it(`notifies subcribers when subscribing`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + const listener = jest.fn() as (value: string) => void; + + readOnlySubject.subscribe(listener); + + expect(listener).toHaveBeenCalledWith('init-value'); + }); + + it(`notifies subcribers when value changes`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + const listener = jest.fn() as (value: string) => void; + + readOnlySubject.subscribe(listener); + + subject.value = 'some thing'; + + expect(listener).toHaveBeenCalledWith('some thing'); + }); + + it(`notifies all subcribers when value changes`, () => { + const subject = new Subject('init-value'); + const readOnlySubject = new ReadOnlySubject(subject); + const listener1 = jest.fn() as (value: string) => void; + const listener2 = jest.fn() as (value: string) => void; + const listener3 = jest.fn() as (value: string) => void; + + readOnlySubject.subscribe(listener1); + readOnlySubject.subscribe(listener2); + readOnlySubject.subscribe(listener3); + + subject.value = 'some thing'; + + expect(listener1).toHaveBeenCalledWith('some thing'); + expect(listener2).toHaveBeenCalledWith('some thing'); + expect(listener3).toHaveBeenCalledWith('some thing'); + }); +}); diff --git a/test/observables/Subject.test.ts b/test/observables/Subject.test.ts new file mode 100644 index 0000000..7527a62 --- /dev/null +++ b/test/observables/Subject.test.ts @@ -0,0 +1,55 @@ +import {jest, describe, it, expect} from 'bun:test'; +import {Subject} from '../../src/observables'; + +describe('A Subject', () => { + it('exposes the Subject value', () => { + const subject = new Subject('init-value'); + + expect(subject.value).toBe('init-value'); + }); + + it('updates the Subject value when set', () => { + const subject = new Subject('init-value'); + + subject.value = 'some thing'; + + expect(subject.value).toBe('some thing'); + }); + + it('notifies subcribers when subscribing', () => { + const subject = new Subject('init-value'); + const listener = jest.fn() as (value: string) => void; + + subject.subscribe(listener); + + expect(listener).toHaveBeenCalledWith('init-value'); + }); + + it('notifies subcribers when value changes', () => { + const subject = new Subject('init-value'); + const listener = jest.fn() as (value: string) => void; + + subject.subscribe(listener); + + subject.value = 'some thing'; + + expect(listener).toHaveBeenCalledWith('some thing'); + }); + + it('notifies all subcribers when value changes', () => { + const subject = new Subject('init-value'); + const listener1 = jest.fn() as (value: string) => void; + const listener2 = jest.fn() as (value: string) => void; + const listener3 = jest.fn() as (value: string) => void; + + subject.subscribe(listener1); + subject.subscribe(listener2); + subject.subscribe(listener3); + + subject.value = 'some thing'; + + expect(listener1).toHaveBeenCalledWith('some thing'); + expect(listener2).toHaveBeenCalledWith('some thing'); + expect(listener3).toHaveBeenCalledWith('some thing'); + }); +}); diff --git a/test/strings/WhenGeneratingRandomStrings.test.ts b/test/strings/WhenGeneratingRandomStrings.test.ts new file mode 100644 index 0000000..13a639c --- /dev/null +++ b/test/strings/WhenGeneratingRandomStrings.test.ts @@ -0,0 +1,39 @@ +import {describe, beforeAll, it, expect} from 'bun:test'; +import {Strings} from '../../src/strings'; + +const randomStringCount = 11; + +describe('When generating random strings', () => { + it('generates a random string', () => { + const randomString = Strings.random(); + + expect(typeof randomString).toBe('string'); + expect(randomString.length).toBeGreaterThan(0); + }); + + describe('Given a length argument', () => { + const length = 4; + + it('generates a random string of the given length', () => { + const randomString = Strings.random(length); + + expect(typeof randomString).toBe('string'); + expect(randomString.length).toBeGreaterThan(0); + expect(randomString.length).toBe(length); + }); + }); + + describe(`Given [${randomStringCount}] are generated`, () => { + let randomStrings; + + beforeAll(() => { + randomStrings = Array.from({length: randomStringCount}, () => Strings.random()); + }); + + it('generates random strings such that no strings are identical', () => { + const uniqueRandomStrings = new Set(randomStrings); + + expect(uniqueRandomStrings.size).toBe(randomStringCount); + }); + }); +}); diff --git a/tsconfig.d.json b/tsconfig.d.json new file mode 100644 index 0000000..c579e5f --- /dev/null +++ b/tsconfig.d.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["esnext", "dom"], + "target": "es6", + "module": "esnext", + + // Bundler mode + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "allowArbitraryExtensions": true, + "isolatedModules": true, + "declaration": true, + "emitDeclarationOnly": true, + "declarationDir": "dist/types", + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src"], + "exclude": ["./dist", "./test"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c8f0850 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src"], + "exclude": ["./dist"] +}