diff --git a/adapters/.eslintrc.json b/adapters/.eslintrc.json new file mode 100644 index 0000000..4c52ca2 --- /dev/null +++ b/adapters/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 0000000..53f796e --- /dev/null +++ b/adapters/README.md @@ -0,0 +1 @@ +stateful adapters for fsm; to be a main convenient OO interface for lib users diff --git a/adapters/jest.config.ts b/adapters/jest.config.ts new file mode 100644 index 0000000..8a15a40 --- /dev/null +++ b/adapters/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'adapters', + preset: '../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../coverage/adapters', +}; diff --git a/adapters/package.json b/adapters/package.json new file mode 100644 index 0000000..fa7428a --- /dev/null +++ b/adapters/package.json @@ -0,0 +1,12 @@ +{ + "name": "@jikan/adapters", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0", + "@jikan/fsm": "*", + "@jikan/utils": "*" + }, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts" +} diff --git a/adapters/project.json b/adapters/project.json new file mode 100644 index 0000000..acc6f61 --- /dev/null +++ b/adapters/project.json @@ -0,0 +1,30 @@ +{ + "name": "adapters", + "$schema": "../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "adapters/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/adapters", + "main": "adapters/src/index.ts", + "tsConfig": "adapters/tsconfig.lib.json", + "assets": ["adapters/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "adapters/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/adapters/src/index.ts b/adapters/src/index.ts new file mode 100644 index 0000000..5ae7d17 --- /dev/null +++ b/adapters/src/index.ts @@ -0,0 +1 @@ +export { StatefulSimulation } from './lib/statefulSimulation'; diff --git a/adapters/src/lib/statefulSimulation.spec.ts b/adapters/src/lib/statefulSimulation.spec.ts new file mode 100644 index 0000000..17ca859 --- /dev/null +++ b/adapters/src/lib/statefulSimulation.spec.ts @@ -0,0 +1,38 @@ +jest.useFakeTimers(); + +import { StatefulSimulation } from './statefulSimulation'; +import { BASIC_EXERCISE_PROGRAM } from '@jikan/test-utils'; + +describe('statefulSimulation', () => { + it('runs a basic program', () => { + const onChangeSpy = jest.fn(); + const sim = new StatefulSimulation(BASIC_EXERCISE_PROGRAM, { + onChange: onChangeSpy, + stopOnEmpty: true, + }); + expect(sim.isRunning()).toBe(false); + sim.start(); + expect(sim.isRunning()).toBe(true); + while (sim.isRunning()) { + jest.runOnlyPendingTimers(); + } + const initAndEnd = 2; + const totalCalls = + BASIC_EXERCISE_PROGRAM.map(({ duration }) => duration).reduce( + (a, b) => a + b, + 0 + ) / sim.leniency; + expect(onChangeSpy).toHaveBeenCalledTimes(totalCalls + initAndEnd); + }); + it('resets to the initial state on done', () => { + const sim = new StatefulSimulation(BASIC_EXERCISE_PROGRAM, { + stopOnEmpty: true, + }); + sim.start(); + const l = sim.length(); + while (sim.isRunning()) { + jest.runOnlyPendingTimers(); + } + expect(sim.length()).toBe(l); + }); +}); diff --git a/adapters/src/lib/statefulSimulation.ts b/adapters/src/lib/statefulSimulation.ts new file mode 100644 index 0000000..ef7a7b9 --- /dev/null +++ b/adapters/src/lib/statefulSimulation.ts @@ -0,0 +1,145 @@ +import { + empty, + QueueItem, + tick, + push, + restart, + State, + isEmpty, + current, + eqQueueItem, +} from '@jikan/fsm'; +import { assertExists } from '@jikan/utils'; + +const DEFAULT_OPTS = { leniency: 100 /*ms*/, stopOnEmpty: true } as const; +const SUSPICIOUSLY_TOO_MANY_LISTENERS = 100; +const SUSPICIOUSLY_TOO_MANY_LISTENERS_MSG = (n: number) => + `Suspiciously many listeners: ${n}. Please check that you clean up listener functions calling the cleanup function returned from onChange`; +let isSuspiciouslyTooManyListenersReported = false; +const checkTooManyListeners = (n: number) => { + if ( + n >= SUSPICIOUSLY_TOO_MANY_LISTENERS && + !isSuspiciouslyTooManyListenersReported + ) { + console.warn(SUSPICIOUSLY_TOO_MANY_LISTENERS_MSG(n)); + isSuspiciouslyTooManyListenersReported = true; + } +}; + +const areQueueItemsEqual = ( + a: QueueItem | null, + b: QueueItem | null +): boolean => { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + return eqQueueItem(b)(a); +}; + +export const StatefulSimulation = class { + // set only thru #setState + #state = empty(); + #setState = (state1: State) => { + const queueItem = current(this.#state); + const nextQueueItem = current(state1); + this.#state = state1; + if (!areQueueItemsEqual(queueItem, nextQueueItem)) { + this.#reportQueueItem(nextQueueItem); + } + }; + readonly #state0: State; + #intervalHandle: ReturnType | null = null; + readonly leniency: number; + readonly stopOnEmpty: boolean; + isRunning = + () /*: this is {#intervalHandle: number} - not with ts classes.*/ => + this.#intervalHandle !== null; + constructor( + queue: QueueItem[], + opts: { + leniency?: number; + onChange?: (next: QueueItem | null) => void; + stopOnEmpty?: boolean; + } = DEFAULT_OPTS + ) { + const opts_ = { ...DEFAULT_OPTS, ...opts }; + if (opts_.leniency <= 0) throw new Error('leniency must be positive'); + this.push(queue); + this.#state0 = this.#state; + this.leniency = opts_.leniency; + this.stopOnEmpty = opts_.stopOnEmpty; + // nb! not cleanable, should be fine as it exists together with the object lifetime and semantics seem to match the Constructor assumptions + if (opts_.onChange) + this.onChange(opts_.onChange, { + withCurrent: true, + }); + } + #changeListeners = new Map void>(); + #nextListenerId = 1; + + onChange = ( + f: (next: QueueItem | null) => void, + opts: { + withCurrent: boolean; + } = { + withCurrent: true, + } + ): (() => void) => { + if (opts.withCurrent) f(this.current()); + const listenerId = this.#nextListenerId; + this.#changeListeners.set(listenerId, f); + this.#nextListenerId = listenerId + 1; + checkTooManyListeners(this.#changeListeners.size); + return () => { + this.#changeListeners.delete(listenerId); + }; + }; + #reportQueueItem = (next: QueueItem | null) => { + // called before #state change; TODO don't depend on execution order as much + this.#changeListeners.forEach((f) => f(next)); + }; + #tick = (step: number) => { + const [state1, queueItems] = tick(step)(this.#state); + this.#setState(state1); + return queueItems; + }; + push = (queueItems: QueueItem[]) => { + this.#setState(push(queueItems)(this.#state)); + }; + restart = () => { + this.#setState(restart(this.#state)); + }; + reset = () => { + this.#setState(this.#state0); + }; + pause = () => { + if (!this.isRunning()) return; + clearInterval(assertExists(this.#intervalHandle)); + this.#intervalHandle = null; + }; + start = () => { + if (this.isRunning()) return; + let lastMs = Date.now(); + this.#intervalHandle = setInterval(() => { + const now = Date.now(); + const delta = now - lastMs; + lastMs = now; + this.#tick(delta); + if (this.isEmpty() && this.stopOnEmpty) { + this.stop(); + } + }, this.leniency); + }; + stop = () => { + this.pause(); + this.reset(); + }; + isEmpty = () => { + return isEmpty(this.#state); + }; + length = () => { + return this.#state.queue.length; + }; + current = () => { + return current(this.#state); + }; +}; diff --git a/adapters/tsconfig.json b/adapters/tsconfig.json new file mode 100644 index 0000000..451a604 --- /dev/null +++ b/adapters/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/adapters/tsconfig.lib.json b/adapters/tsconfig.lib.json new file mode 100644 index 0000000..6f3c503 --- /dev/null +++ b/adapters/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/adapters/tsconfig.spec.json b/adapters/tsconfig.spec.json new file mode 100644 index 0000000..663c878 --- /dev/null +++ b/adapters/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/facade/package.json b/facade/package.json index b4d88b2..79aa035 100644 --- a/facade/package.json +++ b/facade/package.json @@ -3,7 +3,8 @@ "version": "0.0.1", "dependencies": { "tslib": "^2.3.0", - "@jikan/fsm": "*" + "@jikan/fsm": "*", + "@jikan/adapters": "*" }, "type": "commonjs", "main": "./src/index.js", diff --git a/facade/src/index.ts b/facade/src/index.ts index a47a5ee..b19e7bd 100644 --- a/facade/src/index.ts +++ b/facade/src/index.ts @@ -11,3 +11,5 @@ export { restart, reset, } from '@jikan/fsm'; + +export { StatefulSimulation as Timer } from '@jikan/adapters'; diff --git a/fsm/src/lib/fsm.spec.ts b/fsm/src/lib/fsm.spec.ts index 6374dbf..39f869e 100644 --- a/fsm/src/lib/fsm.spec.ts +++ b/fsm/src/lib/fsm.spec.ts @@ -9,46 +9,10 @@ import { showPrintDuration0QueueItemError, State, tick, + eqQueueItem, } from './fsm'; import fc from 'fast-check'; - -const BASIC_EXERCISE_PROGRAM = (() => { - const ROUND_TYPES = ['warmup', 'exercise', 'rest', 'cooldown'] as const; - type RoundType = (typeof ROUND_TYPES)[number]; - type ExerciseQueueItem = QueueItem; - const ONE_MINUTE = 60 * 1000; - const TOTAL_ROUNDS = 10; - const WARMUP_DURATION = 5 * ONE_MINUTE; - const EXERCISE_DURATION = 3 * ONE_MINUTE; - // noinspection PointlessArithmeticExpressionJS - const REST_DURATION = 1 * ONE_MINUTE; - const COOLDOWN_DURATION = 5 * ONE_MINUTE; - const DURATIONS: { - [K in RoundType]: number; - } = { - warmup: WARMUP_DURATION, - exercise: EXERCISE_DURATION, - rest: REST_DURATION, - cooldown: COOLDOWN_DURATION, - }; - return [...Array(TOTAL_ROUNDS + 2 /*warmup/cooldown*/).keys()] - .map( - (i): RoundType => - i === 0 - ? 'warmup' - : i === TOTAL_ROUNDS + 1 - ? 'cooldown' - : i % 2 === 1 - ? 'exercise' - : 'rest' - ) - .map( - (kind): ExerciseQueueItem => ({ - kind, - duration: DURATIONS[kind], - }) - ); -})(); +import { BASIC_EXERCISE_PROGRAM } from '@jikan/test-utils'; describe('fsm', () => { describe('push', () => { @@ -307,4 +271,28 @@ describe('fsm', () => { ); }); }); + describe('eqQueueItem', () => { + it('works', () => { + const a: QueueItem<'a'> = { + kind: 'a', + duration: 1, + }; + const b: QueueItem<'a'> = { + kind: 'a', + duration: 1, + }; + const c: QueueItem<'a'> = { + kind: 'a', + duration: 2, + }; + const d: QueueItem<'b'> = { + kind: 'b', + duration: 1, + }; + expect(eqQueueItem(b)(a)).toBe(true); + expect(eqQueueItem(c)(a)).toBe(false); + // @ts-expect-error type mismatch + expect(eqQueueItem(d)(a)).toBe(false); + }); + }); }); diff --git a/fsm/src/lib/fsm.ts b/fsm/src/lib/fsm.ts index 769360c..4d051ba 100644 --- a/fsm/src/lib/fsm.ts +++ b/fsm/src/lib/fsm.ts @@ -24,6 +24,12 @@ const showQueueItem = ( queueItem: QueueItem ): string => `${queueItem.kind}(${queueItem.duration})`; +// not entirely reliable (same kind+duration don't mean same item in general) but good enough for our purposes +export const eqQueueItem = + (b: QueueItem) => + (a: QueueItem): boolean => + a.kind === b.kind && a.duration === b.duration; + // fifo type Queue = readonly QueueItem[]; @@ -118,7 +124,11 @@ export const pop = ( export const currentNE = ( state: NonEmptyState -): QueueItem => lastNEA(state.queue); +): QueueItem => ({ + ...lastNEA(state.queue), + // users are interested in current left duration, not the programmed one + duration: state.duration, +}); export const current = ( state: State diff --git a/test-utils/.eslintrc.json b/test-utils/.eslintrc.json new file mode 100644 index 0000000..4c52ca2 --- /dev/null +++ b/test-utils/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/test-utils/README.md b/test-utils/README.md new file mode 100644 index 0000000..21a3564 --- /dev/null +++ b/test-utils/README.md @@ -0,0 +1,11 @@ +# test-utils + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build test-utils` to build the library. + +## Running unit tests + +Run `nx test test-utils` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/test-utils/jest.config.ts b/test-utils/jest.config.ts new file mode 100644 index 0000000..bc54085 --- /dev/null +++ b/test-utils/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'test-utils', + preset: '../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../coverage/test-utils', +}; diff --git a/test-utils/package.json b/test-utils/package.json new file mode 100644 index 0000000..4dc50de --- /dev/null +++ b/test-utils/package.json @@ -0,0 +1,10 @@ +{ + "name": "@jikan/test-utils", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0" + }, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts" +} diff --git a/test-utils/project.json b/test-utils/project.json new file mode 100644 index 0000000..323172c --- /dev/null +++ b/test-utils/project.json @@ -0,0 +1,30 @@ +{ + "name": "test-utils", + "$schema": "../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "test-utils/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/test-utils", + "main": "test-utils/src/index.ts", + "tsConfig": "test-utils/tsconfig.lib.json", + "assets": ["test-utils/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "test-utils/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/test-utils/src/index.ts b/test-utils/src/index.ts new file mode 100644 index 0000000..1bf4db0 --- /dev/null +++ b/test-utils/src/index.ts @@ -0,0 +1 @@ +export * from './lib/test-utils'; diff --git a/test-utils/src/lib/test-utils.ts b/test-utils/src/lib/test-utils.ts new file mode 100644 index 0000000..db0da04 --- /dev/null +++ b/test-utils/src/lib/test-utils.ts @@ -0,0 +1,34 @@ +export const BASIC_EXERCISE_PROGRAM = (() => { + const ROUND_TYPES = ['warmup', 'exercise', 'rest', 'cooldown'] as const; + type RoundType = (typeof ROUND_TYPES)[number]; + const ONE_MINUTE = 60 * 1000; + const TOTAL_ROUNDS = 10; + const WARMUP_DURATION = 5 * ONE_MINUTE; + const EXERCISE_DURATION = 3 * ONE_MINUTE; + // noinspection PointlessArithmeticExpressionJS + const REST_DURATION = 1 * ONE_MINUTE; + const COOLDOWN_DURATION = 5 * ONE_MINUTE; + const DURATIONS: { + [K in RoundType]: number; + } = { + warmup: WARMUP_DURATION, + exercise: EXERCISE_DURATION, + rest: REST_DURATION, + cooldown: COOLDOWN_DURATION, + }; + return [...Array(TOTAL_ROUNDS + 2 /*warmup/cooldown*/).keys()] + .map( + (i): RoundType => + i === 0 + ? 'warmup' + : i === TOTAL_ROUNDS + 1 + ? 'cooldown' + : i % 2 === 1 + ? 'exercise' + : 'rest' + ) + .map((kind) => ({ + kind, + duration: DURATIONS[kind], + })); +})(); diff --git a/test-utils/tsconfig.json b/test-utils/tsconfig.json new file mode 100644 index 0000000..451a604 --- /dev/null +++ b/test-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/test-utils/tsconfig.lib.json b/test-utils/tsconfig.lib.json new file mode 100644 index 0000000..6f3c503 --- /dev/null +++ b/test-utils/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/test-utils/tsconfig.spec.json b/test-utils/tsconfig.spec.json new file mode 100644 index 0000000..663c878 --- /dev/null +++ b/test-utils/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 7b688d0..b064d91 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,8 +16,10 @@ "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { + "@jikan/adapters": ["adapters/src/index.ts"], "@jikan/facade": ["facade/src/index.ts"], "@jikan/fsm": ["fsm/src/index.ts"], + "@jikan/test-utils": ["test-utils/src/index.ts"], "@jikan/utils": ["utils/src/index.ts"] } },