Skip to content

Commit

Permalink
stateful simulation class for fsm
Browse files Browse the repository at this point in the history
  • Loading branch information
dearlordylord committed Jan 7, 2024
1 parent 8d0732b commit 4f2b892
Show file tree
Hide file tree
Showing 26 changed files with 520 additions and 40 deletions.
25 changes: 25 additions & 0 deletions adapters/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
1 change: 1 addition & 0 deletions adapters/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
stateful adapters for fsm; to be a main convenient OO interface for lib users
11 changes: 11 additions & 0 deletions adapters/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'adapters',
preset: '../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../coverage/adapters',
};
12 changes: 12 additions & 0 deletions adapters/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
30 changes: 30 additions & 0 deletions adapters/project.json
Original file line number Diff line number Diff line change
@@ -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": []
}
1 change: 1 addition & 0 deletions adapters/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { StatefulSimulation } from './lib/statefulSimulation';
38 changes: 38 additions & 0 deletions adapters/src/lib/statefulSimulation.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
145 changes: 145 additions & 0 deletions adapters/src/lib/statefulSimulation.ts
Original file line number Diff line number Diff line change
@@ -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 = <QueueItemType extends string>(
a: QueueItem<QueueItemType> | null,
b: QueueItem<QueueItemType> | null
): boolean => {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
return eqQueueItem(b)(a);
};

export const StatefulSimulation = class<QueueItemType extends string = string> {
// set only thru #setState
#state = empty<QueueItemType>();
#setState = (state1: State<QueueItemType>) => {
const queueItem = current(this.#state);
const nextQueueItem = current(state1);
this.#state = state1;
if (!areQueueItemsEqual(queueItem, nextQueueItem)) {
this.#reportQueueItem(nextQueueItem);
}
};
readonly #state0: State<QueueItemType>;
#intervalHandle: ReturnType<typeof setInterval> | null = null;
readonly leniency: number;
readonly stopOnEmpty: boolean;
isRunning =
() /*: this is {#intervalHandle: number} - not with ts classes.*/ =>
this.#intervalHandle !== null;
constructor(
queue: QueueItem<QueueItemType>[],
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<number, (next: QueueItem | null) => 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<QueueItemType> | 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<QueueItemType>[]) => {
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);
};
};
22 changes: 22 additions & 0 deletions adapters/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
10 changes: 10 additions & 0 deletions adapters/tsconfig.lib.json
Original file line number Diff line number Diff line change
@@ -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"]
}
14 changes: 14 additions & 0 deletions adapters/tsconfig.spec.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
3 changes: 2 additions & 1 deletion facade/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions facade/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export {
restart,
reset,
} from '@jikan/fsm';

export { StatefulSimulation as Timer } from '@jikan/adapters';
Loading

0 comments on commit 4f2b892

Please sign in to comment.