Initial Commit

This commit is contained in:
2025-08-16 14:17:46 -04:00
commit 651a21a035
49 changed files with 1347 additions and 0 deletions

175
.gitignore vendored Normal file
View File

@@ -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

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
@techniker-me:registry=https://registry-node.techniker.me
//registry-node.techniker.me/:_authToken="${NODE_REGISTRY_AUTH_TOKEN}"

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

12
.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSameLine": true,
"bracketSpacing": false,
"printWidth": 160,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}

27
README Normal file
View File

@@ -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

11
bunfig.toml Normal file
View File

@@ -0,0 +1,11 @@
telemetry = false
[install]
exact = true
[install.lockfile]
save = false
[test]
coverage = true
coverageSkipTestFiles = true

41
eslint.config.js Normal file
View File

@@ -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',
},
},
];

175
examples/.gitignore vendored Normal file
View File

@@ -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

15
examples/README.md Normal file
View File

@@ -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.

BIN
examples/bun.lockb Executable file

Binary file not shown.

11
examples/bunfig.toml Normal file
View File

@@ -0,0 +1,11 @@
telemetry = false
[install]
exact = true
[install.lockfile]
save = false
[test]
coverage = true
coverageSkipTestFiles = true

17
examples/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,13 @@
import Tools from '@zinntechniker/tools';
const {
observables: {Subject, ReadOnlySubject}
} = Tools;
const subject = new Subject<number>(0);
const subjectSubscriber = (value: number) => {
console.log("Subject's value changed to [%o]", value);
};
subject.subscribe(subjectSubscriber);
subject.value = 12;

28
examples/tsconfig.json Normal file
View File

@@ -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"]
}

56
package.json Normal file
View File

@@ -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"
]
}

29
scripts/ci-build.sh Executable file
View File

@@ -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

83
scripts/ci-deploy.sh Executable file
View File

@@ -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

16
scripts/prepare-package-json.sh Executable file
View File

@@ -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"

View File

@@ -0,0 +1,3 @@
export default function assertUnreachable(never: never): never {
throw new Error(`Should not have reached [${never}]`);
}

3
src/assertions/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import assertUnreachable from './assertUnreachable';
export {assertUnreachable};

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,3 @@
export default interface IDisposable {
dispose(): void;
}

6
src/disposables/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import type IDisposable from './IDisposable';
import Disposable from './Disposable';
import DisposableList from './DisposableList';
export type {IDisposable};
export {Disposable, DisposableList};

View File

@@ -0,0 +1,23 @@
import type IDisposable from '../disposables/IDisposable';
import type IEvent from './IEvent';
import {Disposable} from '../disposables';
export default class EventEmitter<T> implements IDisposable {
private readonly _listeners: Array<(event: IEvent<T>) => void> = [];
public subscribe(listener: (event: IEvent<T>) => void): Disposable {
const listenerIndex = this._listeners.push(listener);
return new Disposable(() => this._listeners.splice(listenerIndex - 1, 1));
}
public emit<Event extends IEvent<T>>(event: Event): void {
this._listeners.forEach(listener => listener(event));
}
public dispose(): void {
while (this._listeners.length) {
this._listeners.shift();
}
}
}

View File

@@ -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<string, EventEmitter<any>> = {};
private readonly _disposables = new DisposableList();
constructor() {
this._eventEmitters['no-subscribers'] = new EventEmitter();
}
public addNoSubscriberHandler(handler: (event: IEvent<any>) => Promise<void> | void) {
return this._eventEmitters['no-subscribers'].subscribe(handler);
}
public subscribe<T>(event: string | number, handler: (event: IEvent<T>) => Promise<void> | void): Disposable {
const emitter = this._eventEmitters[event] ?? this.createEmitter<T>(event);
const subscription = emitter.subscribe(handler);
this._disposables.add(subscription);
return subscription;
}
public publish<T>(eventName: string | number, event: IEvent<T>): void {
(this._eventEmitters[eventName] || this._eventEmitters['no-subscribers']).emit<IEvent<T>>(event);
}
public dispose(): void {
Object.values(this._eventEmitters).forEach(emitter => emitter.dispose());
}
private createEmitter<T>(event: string | number): EventEmitter<IEvent<T>> {
const eventEmitter = new EventEmitter<IEvent<T>>();
return (this._eventEmitters[event] = eventEmitter);
}
}

4
src/events/IEvent.ts Normal file
View File

@@ -0,0 +1,4 @@
export default interface IEvent<Payload> {
type: string;
payload?: Payload;
}

6
src/events/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import type IEvent from './IEvent';
import EventEmitter from './EventEmitter';
import EventPublisher from './EventPublisher';
export type {IEvent};
export {EventEmitter, EventPublisher};

3
src/functions/index.ts Normal file
View File

@@ -0,0 +1,3 @@
import {createRangeIterator} from './rangeIterator';
export {createRangeIterator};

View File

@@ -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};
}
};
}

12
src/index.ts Normal file
View File

@@ -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';

33
src/maths/Averager.ts Normal file
View File

@@ -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<number>(0);
private readonly _readOnlyAverage = new ReadOnlySubject<number>(this._average);
private _onPush: (sample: number) => void = this.onPushOnly.bind(this);
constructor(sampleSize: number) {
this._sampleSize = sampleSize;
}
get average(): ReadOnlySubject<number> {
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;
}
}

23
src/maths/Random.ts Normal file
View File

@@ -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;
}
}

4
src/maths/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import Averager from './Averager';
import Random from './Random';
export {Averager, Random};

View File

@@ -0,0 +1,18 @@
import Disposable from '../disposables/Disposable';
import Subject from './Subject';
export default class ReadOnlySubject<T> {
private readonly _subject: Subject<T>;
constructor(subject: Subject<T>) {
this._subject = subject;
}
get value(): T {
return this._subject.value;
}
public subscribe(listener: (value: T) => void): Disposable {
return this._subject.subscribe(listener);
}
}

View File

@@ -0,0 +1,39 @@
import Disposable from '../disposables/Disposable';
export default class Subject<T> {
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);
}
}
}

4
src/observables/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import Subject from './Subject';
import ReadOnlySubject from './ReadOnlySubject';
export {Subject, ReadOnlySubject};

15
src/strings/index.ts Normal file
View File

@@ -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');
}
}

View File

@@ -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();
});
});

View File

@@ -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<any>;
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);
});
});
});
});
});

View File

@@ -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<Mock<any>>;
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());
});
});
});
});

View File

@@ -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<string> = new EventEmitter<string>();
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());
});
});

View File

@@ -0,0 +1,3 @@
import {describe} from 'bun:test';
describe.todo(`When publishing events`);

View File

@@ -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);
});
});
});

View File

@@ -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<string>(`init-value`);
const readOnlySubject = new ReadOnlySubject(subject);
expect(readOnlySubject.value).toBe(`init-value`);
});
it(`updates the ReadOnlySubject value when set`, () => {
const subject = new Subject<string>('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<string>('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<string>('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<string>('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');
});
});

View File

@@ -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<string>('init-value');
expect(subject.value).toBe('init-value');
});
it('updates the Subject value when set', () => {
const subject = new Subject<string>('init-value');
subject.value = 'some thing';
expect(subject.value).toBe('some thing');
});
it('notifies subcribers when subscribing', () => {
const subject = new Subject<string>('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<string>('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<string>('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');
});
});

View File

@@ -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);
});
});
});

30
tsconfig.d.json Normal file
View File

@@ -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"]
}

28
tsconfig.json Normal file
View File

@@ -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"]
}