From 2936de7118d91339d66d8b53cb38861717b36d7a Mon Sep 17 00:00:00 2001 From: Ni55aN Date: Fri, 27 Dec 2024 01:20:47 +0200 Subject: [PATCH] test: setup --- eslint.config.mjs | 10 +- package.json | 1 + test/control-flow-engine.test.ts | 53 ++++++++++ test/control-flow.test.ts | 141 +++++++++++++++++++++++++ test/dataflow-engine.test.ts | 170 +++++++++++++++++++++++++++++++ test/dataflow.test.ts | 121 ++++++++++++++++++++++ test/utils/cache.test.ts | 50 +++++++++ test/utils/cancellable.test.ts | 87 ++++++++++++++++ tsconfig.json | 2 +- 9 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 test/control-flow-engine.test.ts create mode 100644 test/control-flow.test.ts create mode 100644 test/dataflow-engine.test.ts create mode 100644 test/dataflow.test.ts create mode 100644 test/utils/cache.test.ts create mode 100644 test/utils/cancellable.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 50ff4a9..36a0948 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,5 +2,13 @@ import tseslint from 'typescript-eslint'; import configs from 'rete-cli/configs/eslint.mjs'; export default tseslint.config( - ...configs + ...configs, + { + files: ['**/*.test.ts'], + rules: { + 'eslint-disable': 'off', + 'init-declarations': 'off', + 'max-statements': 'off', + } + } ) \ No newline at end of file diff --git a/package.json b/package.json index 6935204..47d0a10 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "rete build -c rete.config.ts", "lint": "rete lint", + "test": "rete test", "doc": "rete doc" }, "author": "Vitaliy Stoliarov", diff --git a/test/control-flow-engine.test.ts b/test/control-flow-engine.test.ts new file mode 100644 index 0000000..d35e86b --- /dev/null +++ b/test/control-flow-engine.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { ClassicPreset, NodeEditor } from 'rete' + +import { ControlFlowEngine, ControlFlowEngineScheme } from '../src/control-flow-engine' + +class Node extends ClassicPreset.Node { + constructor(public label: string) { + super(label) + + this.addInput('input', new ClassicPreset.Input(new ClassicPreset.Socket('input'))) + this.addOutput('output', new ClassicPreset.Output(new ClassicPreset.Socket('output'))) + } + + execute(input: string, forward: (output: string) => void): void { + forward('output') + } +} + +class Connection extends ClassicPreset.Connection { + constructor(public node1: N, public output: string, public node2: N, public input: string) { + super(node1, output, node2, input) + } +} + +describe('ControlFlow', () => { + let editor!: NodeEditor + let controlFlow: ControlFlowEngine + + beforeEach(() => { + editor = new NodeEditor() + controlFlow = new ControlFlowEngine() + + editor.use(controlFlow) + }) + + it('executes sequence of nodes', async () => { + const node1 = new Node('label1') + const node2 = new Node('label2') + + await editor.addNode(node1) + await editor.addNode(node2) + + await editor.addConnection(new Connection(node1, 'output', node2, 'input')) + + const spy1 = jest.spyOn(node1, 'execute') + const spy2 = jest.spyOn(node2, 'execute') + + controlFlow.execute(node1.id) + + expect(spy1).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + }) +}) diff --git a/test/control-flow.test.ts b/test/control-flow.test.ts new file mode 100644 index 0000000..9fdd35d --- /dev/null +++ b/test/control-flow.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from '@jest/globals' +import { ClassicPreset, NodeEditor } from 'rete' + +import { ControlFlow } from '../src/control-flow' +import { ClassicScheme } from '../src/types' + +describe('ControlFlow', () => { + let editor!: NodeEditor + let controlFlow: ControlFlow + + beforeEach(() => { + editor = new NodeEditor() + controlFlow = new ControlFlow(editor) + }) + + it('adds a node to the control flow', () => { + const node = new ClassicPreset.Node('label') + + controlFlow.add(node, { + inputs: () => [], + outputs: () => [], + execute: () => null + }) + + expect(controlFlow.setups.has(node.id)).toBe(true) + }) + + it('removes a node from the control flow', () => { + const node = new ClassicPreset.Node('label') + + controlFlow.add(node, { + inputs: () => [], + outputs: () => [], + execute: () => null + }) + + controlFlow.remove(node.id) + + expect(controlFlow.setups.has(node.id)).toBe(false) + }) + + it('executes a node', () => { + const node = new ClassicPreset.Node('label') + const fn1 = jest.fn() + + controlFlow.add(node, { + inputs: () => [], + outputs: () => [], + execute: fn1 + }) + + expect(fn1).not.toHaveBeenCalled() + controlFlow.execute(node.id) + expect(fn1).toHaveBeenCalled() + }) + + it('executes sequence of nodes', async () => { + const node1 = new ClassicPreset.Node('label') + const node2 = new ClassicPreset.Node('label') + + node1.addOutput('out', new ClassicPreset.Output(new ClassicPreset.Socket('number'))) + node2.addInput('in', new ClassicPreset.Input(new ClassicPreset.Socket('number'))) + + await editor.addConnection(new ClassicPreset.Connection(node1, 'out', node2, 'in')) + + const fn1 = jest.fn() + const fn2 = jest.fn() + + controlFlow.add(node1, { + inputs: () => [], + outputs: () => ['out'], + execute: (input, forward) => { + fn1() + forward('out') + } + }) + controlFlow.add(node2, { + inputs: () => ['in'], + outputs: () => [], + execute: fn2 + }) + + expect(fn1).not.toHaveBeenCalled() + expect(fn2).not.toHaveBeenCalled() + controlFlow.execute(node1.id) + expect(fn1).toHaveBeenCalled() + expect(fn2).toHaveBeenCalled() + }) + + it('throws error when node is not initialized', () => { + const node = new ClassicPreset.Node('label') + + expect(() => controlFlow.execute(node.id)).toThrowError('node is not initialized') + }) + + it('throws error when trying to add a node more than once', () => { + const node = new ClassicPreset.Node('label') + + controlFlow.add(node, { + inputs: () => [], + outputs: () => [], + execute: () => null + }) + + expect(() => controlFlow.add(node, { + inputs: () => [], + outputs: () => [], + execute: () => null + })).toThrowError('already processed') + }) + + it('throws error when trying to remove a node that does not exist', () => { + const node = new ClassicPreset.Node('label') + + expect(() => controlFlow.remove(node.id)).not.toThrow() + }) + + it('throws error when input is not specified', () => { + const node = new ClassicPreset.Node('label') + + controlFlow.add(node, { + inputs: () => [], + outputs: () => [], + execute: () => null + }) + + expect(() => controlFlow.execute(node.id, 'input')).toThrowError('inputs don\'t have a key') + }) + + it('throws error when output is not specified', () => { + const node = new ClassicPreset.Node('label') + + controlFlow.add(node, { + inputs: () => [], + outputs: () => [], + execute: (inputs, forward) => forward('output') + }) + + expect(() => controlFlow.execute(node.id)).toThrowError('outputs don\'t have a key') + }) +}) diff --git a/test/dataflow-engine.test.ts b/test/dataflow-engine.test.ts new file mode 100644 index 0000000..00ee6ce --- /dev/null +++ b/test/dataflow-engine.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { ClassicPreset, NodeEditor } from 'rete' + +import { DataflowEngine, DataflowEngineScheme, DataflowNode } from '../src/dataflow-engine' + +class Node extends ClassicPreset.Node implements DataflowNode { + constructor(public label: string, private produces: string) { + super(label) + + this.addInput('input', new ClassicPreset.Input(new ClassicPreset.Socket('input'))) + this.addOutput('output', new ClassicPreset.Output(new ClassicPreset.Socket('output'))) + } + + data(): { output: string } { + return { + output: this.produces + } + } +} + +class Connection extends ClassicPreset.Connection { + constructor(public node1: N, public output: string, public node2: N, public input: string) { + super(node1, output, node2, input) + } +} + +describe('ControlFlow', () => { + let editor!: NodeEditor + let dataflow: DataflowEngine + + beforeEach(() => { + editor = new NodeEditor() + dataflow = new DataflowEngine() + + editor.use(dataflow) + }) + + it('collects input data from the predecessor nodes', async () => { + const node1 = new Node('label1', 'data1') + const node2 = new Node('label2', 'data2') + const node3 = new Node('label3', 'data3') + + await editor.addNode(node1) + await editor.addNode(node2) + await editor.addNode(node3) + + await editor.addConnection(new Connection(node1, 'output', node3, 'input')) + await editor.addConnection(new Connection(node2, 'output', node3, 'input')) + + const spy1 = jest.spyOn(node1, 'data') + const spy2 = jest.spyOn(node2, 'data') + const spy3 = jest.spyOn(node3, 'data') + + expect(spy1).not.toHaveBeenCalled() + expect(spy2).not.toHaveBeenCalled() + expect(spy3).not.toHaveBeenCalled() + + await dataflow.fetchInputs(node3.id) + + expect(spy1).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(spy3).not.toHaveBeenCalled() + + expect(spy1).toHaveReturnedWith({ output: 'data1' }) + expect(spy2).toHaveReturnedWith({ output: 'data2' }) + }) + + it('fetches data from the leaf node', async () => { + const node1 = new Node('label1', 'data1') + const node2 = new Node('label2', 'data2') + const node3 = new Node('label3', 'data3') + + await editor.addNode(node1) + await editor.addNode(node2) + await editor.addNode(node3) + + await editor.addConnection(new Connection(node1, 'output', node3, 'input')) + await editor.addConnection(new Connection(node2, 'output', node3, 'input')) + + const spy1 = jest.spyOn(node1, 'data') + const spy2 = jest.spyOn(node2, 'data') + const spy3 = jest.spyOn(node3, 'data') + + expect(spy1).not.toHaveBeenCalled() + expect(spy2).not.toHaveBeenCalled() + expect(spy3).not.toHaveBeenCalled() + + await dataflow.fetch(node3.id) + + expect(spy1).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(spy3).toHaveBeenCalled() + + expect(spy1).toHaveReturnedWith({ output: 'data1' }) + expect(spy2).toHaveReturnedWith({ output: 'data2' }) + expect(spy3).toHaveReturnedWith({ output: 'data3' }) + }) + + it('caches the data', async () => { + const node = new Node('label', 'data') + + await editor.addNode(node) + + const spy = jest.spyOn(node, 'data') + + expect(spy).not.toHaveBeenCalled() + + await dataflow.fetch(node.id) + + expect(spy).toHaveBeenCalledTimes(1) + + await dataflow.fetch(node.id) + + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('clears the cache', async () => { + const node = new Node('label', 'data') + + await editor.addNode(node) + + const spy = jest.spyOn(node, 'data') + + expect(spy).not.toHaveBeenCalled() + + await dataflow.fetch(node.id) + + expect(spy).toHaveBeenCalledTimes(1) + + dataflow.reset(node.id) + await dataflow.fetch(node.id) + + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('resets the cache of the node and all its successors', async () => { + const node1 = new Node('label1', 'data1') + const node2 = new Node('label2', 'data2') + const node3 = new Node('label3', 'data3') + + await editor.addNode(node1) + await editor.addNode(node2) + await editor.addNode(node3) + + await editor.addConnection(new Connection(node1, 'output', node2, 'input')) + await editor.addConnection(new Connection(node2, 'output', node3, 'input')) + + const spy1 = jest.spyOn(node1, 'data') + const spy3 = jest.spyOn(node3, 'data') + + expect(spy1).not.toHaveBeenCalled() + expect(spy3).not.toHaveBeenCalled() + + await dataflow.fetch(node3.id) + + expect(spy1).toHaveBeenCalled() + expect(spy3).toHaveBeenCalledTimes(1) + + await dataflow.fetch(node3.id) + + expect(spy1).toHaveBeenCalledTimes(1) + expect(spy3).toHaveBeenCalledTimes(1) + + dataflow.reset(node2.id) + await dataflow.fetch(node3.id) + + expect(spy1).toHaveBeenCalledTimes(1) + expect(spy3).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/dataflow.test.ts b/test/dataflow.test.ts new file mode 100644 index 0000000..524acac --- /dev/null +++ b/test/dataflow.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from '@jest/globals' +import { ClassicPreset, NodeEditor } from 'rete' + +import { Dataflow } from '../src/dataflow' +import { ClassicScheme } from '../src/types' + +describe('Dataflow', () => { + let editor!: NodeEditor + let dataflow: Dataflow + + beforeEach(() => { + editor = new NodeEditor() + dataflow = new Dataflow(editor) + }) + + it('adds a node to the dataflow', () => { + const node = new ClassicPreset.Node('label') + + dataflow.add(node, { + inputs: () => [], + outputs: () => [], + data: () => Promise.resolve({}) + }) + + expect(dataflow.setups.has(node.id)).toBe(true) + }) + + it('throws error when adding a node that is already processed', () => { + const node = new ClassicPreset.Node('label') + const setup = { + inputs: () => [], + outputs: () => [], + data: () => Promise.resolve({}) + } + + dataflow.add(node, setup) + + expect(() => dataflow.add(node, setup)).toThrow('already processed') + }) + + it('removes a node from the dataflow', () => { + const node = new ClassicPreset.Node('label') + + dataflow.add(node, { + inputs: () => [], + outputs: () => [], + data: () => Promise.resolve({}) + }) + dataflow.remove(node.id) + + expect(dataflow.setups.has(node.id)).toBe(false) + }) + + it('fetchs inputs of a node', async () => { + const data = 'my data' + const node1 = new ClassicPreset.Node('label1') + const node2 = new ClassicPreset.Node('label2') + + node1.addOutput('out', new ClassicPreset.Output(new ClassicPreset.Socket('output'))) + node2.addInput('in', new ClassicPreset.Input(new ClassicPreset.Socket('input'))) + + dataflow.add(node1, { + inputs: () => [], + outputs: () => ['out'], + data: () => Promise.resolve({ out: data }) + }) + dataflow.add(node2, { + inputs: () => ['in'], + outputs: () => [], + data: async fetchInputs => { + const inputs = await fetchInputs() + + return { in: inputs.in } + } + }) + + await editor.addConnection(new ClassicPreset.Connection(node1, 'out', node2, 'in')) + + const inputs = await dataflow.fetchInputs(node2.id) + + expect(inputs).toEqual({ in: [data] }) + }) + + it('fetchs outputs of a node', async () => { + const node = new ClassicPreset.Node('label') + + node.addOutput('out', new ClassicPreset.Output(new ClassicPreset.Socket('output'))) + + dataflow.add(node, { + inputs: () => [], + outputs: () => ['out'], + data: () => ({ out: 'data' }) + }) + + const outputs = await dataflow.fetch(node.id) + + expect(outputs).toEqual({ out: 'data' }) + }) + + it('throws error if node is not initialized when fetching inputs', async () => { + await expect(dataflow.fetchInputs('1')).rejects.toThrow('node is not initialized') + }) + + it('throws error if node is not initialized when fetching outputs', async () => { + await expect(dataflow.fetch('1')).rejects.toThrow('node is not initialized') + }) + + it('throws error if data function does not return all required outputs', async () => { + const node = new ClassicPreset.Node('label') + + node.addOutput('out', new ClassicPreset.Output(new ClassicPreset.Socket('output'))) + + dataflow.add(node, { + inputs: () => [], + outputs: () => ['out'], + data: () => ({}) + }) + + await expect(dataflow.fetch(node.id)).rejects.toThrow(Error) + }) +}) diff --git a/test/utils/cache.test.ts b/test/utils/cache.test.ts new file mode 100644 index 0000000..8b79e5d --- /dev/null +++ b/test/utils/cache.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +import { Cache } from '../../src/utils/cache' + +describe('Cache', () => { + let cache: Cache + + beforeEach(() => { + cache = new Cache() + }) + + it('adds and gets item from cache', () => { + cache.add('key1', 1) + expect(cache.get('key1')).toBe(1) + }) + + it('throws error when adding duplicate key', () => { + cache.add('key1', 1) + expect(() => cache.add('key1', 2)).toThrow('cache already exists') + }) + + it('patches existing item in cache', () => { + cache.add('key1', 1) + cache.patch('key1', 2) + expect(cache.get('key1')).toBe(2) + }) + + it('deletes item from cache', () => { + cache.add('key1', 1) + cache.delete('key1') + expect(cache.get('key1')).toBeUndefined() + }) + + it('calls onDelete callback when item is deleted', () => { + const onDelete = jest.fn() + + cache = new Cache(onDelete) + cache.add('key1', 1) + cache.delete('key1') + expect(onDelete).toHaveBeenCalledWith(1) + }) + + it('clears all items from cache', () => { + cache.add('key1', 1) + cache.add('key2', 2) + cache.clear() + expect(cache.get('key1')).toBeUndefined() + expect(cache.get('key2')).toBeUndefined() + }) +}) diff --git a/test/utils/cancellable.test.ts b/test/utils/cancellable.test.ts new file mode 100644 index 0000000..3065e08 --- /dev/null +++ b/test/utils/cancellable.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from '@jest/globals' + +import { Cancelled, createCancellblePromise } from '../../src/utils/cancellable' + +function createControlledPromise() { + let resolvePromise: ((value: T) => void) | null = null + + const promise = new Promise(resolve => { + resolvePromise = resolve + }) + + return [promise, async (value: T) => { + if (!resolvePromise) throw new Error('Promise is not created') + resolvePromise(value) + await new Promise(resolve => process.nextTick(resolve)) + }] as const +} + +describe('Cancellable', () => { + it('executes sequence of callbacks', async () => { + const promise = createCancellblePromise( + () => 1, + n => n + 1, + n => n * 2 + ) + + expect(promise).toBeInstanceOf(Promise) + + const result = await promise + + expect(result).toBe(4) + }) + + it('executes sequence of promises', async () => { + const promise = createCancellblePromise( + () => Promise.resolve(1), + n => Promise.resolve(n + 1), + n => Promise.resolve(n * 2) + ) + + expect(promise).toBeInstanceOf(Promise) + + const result = await promise + + expect(result).toBe(4) + }) + + it('handles nested promises correctly', async () => { + const [promise1, resolvePromise1] = createControlledPromise() + const fn1 = jest.fn((x: number) => x + 1) + const [promise2, resolvePromise2] = createControlledPromise() + const promise = createCancellblePromise( + () => promise1, + fn1, + () => promise2 + ) + + expect(fn1).not.toBeCalled() + await resolvePromise1(1) + expect(fn1).toBeCalled() + await resolvePromise2(3) + + const result = await promise + + expect(result).toBe(3) + }) + + it('cancels promise', async () => { + const [promise1, resolvePromise1] = createControlledPromise() + const fn1 = jest.fn((x: number) => x + 1) + const [promise2, resolvePromise2] = createControlledPromise() + const promise = createCancellblePromise( + () => promise1, + fn1, + () => promise2 + ) + + expect(promise.cancel).toBeInstanceOf(Function) + expect(fn1).not.toBeCalled() + await resolvePromise1(1) + expect(fn1).toBeCalled() + promise.cancel!() + await resolvePromise2(3) + + await expect(promise).rejects.toThrow(Cancelled) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 108617b..ca55a44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "rete-cli/configs/tsconfig.json", - "include": ["src"] + "include": ["src", "test"] }