import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Flowful, actionCreator, createHandler, createActionMap, flowful } from '../src/index'; import { FlowfulError } from '../src/error'; import { skipDevMessages } from '../src/dev'; import { MODE_DEV, setGlobalMode } from '../src/mode'; setGlobalMode(MODE_DEV); skipDevMessages(true); describe('Flowful', () => { describe('addHandlers', () => { let flowful: Flowful; const actionA = actionCreator('actionA'); const actionB = actionCreator('actionB'); const handler1 = createHandler({ name: 'handler1', inputs: [actionA], run() {} }); const handler2 = createHandler({ name: 'handler2', inputs: [actionB], run() {} }); const handler3 = createHandler({ name: 'handler3', inputs: [actionA], run() {} }); beforeEach(() => { flowful = new Flowful(); }); it('adds multiple valid handlers', () => { const handlersToAdd = [handler1, handler2]; const result = flowful.addHandlers(handlersToAdd); expect(result).toBe(true); expect(flowful.hasHandler(handler1)).toBe(true); expect(flowful.hasHandler(handler2)).toBe(true); expect((flowful as any)._handlers.size).toBe(2); expect((flowful as any)._handlersByInput.get(actionA)).toContain(handler1); expect((flowful as any)._handlersByInput.get(actionB)).toContain(handler2); }); it('returns true for an empty array', () => { const result = flowful.addHandlers([]); expect(result).toBe(true); expect((flowful as any)._handlers.size).toBe(0); }); it('returns false if any handler is already added', () => { expect(flowful.addHandler(handler1)).toBe(true); const handlersToAdd = [handler1, handler2]; expect(flowful.addHandlers(handlersToAdd)).toBe(false); expect(flowful.hasHandler(handler1)).toBe(true); expect(flowful.hasHandler(handler2)).toBe(true); // handler2 should still be added expect((flowful as any)._handlers.size).toBe(2); }); it('adds all handlers even if some were already present', () => { flowful.addHandler(handler1); const handlersToAdd = [handler1, handler2, handler3]; flowful.addHandlers(handlersToAdd); expect(flowful.hasHandler(handler1)).toBe(true); expect(flowful.hasHandler(handler2)).toBe(true); expect(flowful.hasHandler(handler3)).toBe(true); expect((flowful as any)._handlers.size).toBe(3); expect((flowful as any)._handlersByInput.get(actionA)).toContain(handler1); expect((flowful as any)._handlersByInput.get(actionA)).toContain(handler3); expect((flowful as any)._handlersByInput.get(actionB)).toContain(handler2); }); it('throws FlowfulError if the argument is not an array', () => { // @ts-expect-error testing invalid input expect(() => flowful.addHandlers({})).toThrow(FlowfulError); // @ts-expect-error testing invalid input expect(() => flowful.addHandlers(null)).toThrow(FlowfulError); // @ts-expect-error testing invalid input expect(() => flowful.addHandlers('string')).toThrow(FlowfulError); }); it('throws FlowfulError if any handler in the array is invalid', () => { const invalidHandler = { name: 'invalid' }; // Missing invoke const handlersToAdd = [handler1, invalidHandler as any]; expect(() => flowful.addHandlers(handlersToAdd)).toThrow(FlowfulError); // Ensure the valid handler before the invalid one was added expect(flowful.hasHandler(handler1)).toBe(true); }); it('throws FlowfulError on circular dependency in DEV mode (one)', () => { const handlerCircular1 = createHandler({ name: 'circ1', inputs: [actionA], outputs: [actionA], run() {} }); const handlersToAdd = [handlerCircular1]; let devFlowful = new Flowful(); expect(() => devFlowful.addHandler(handlerCircular1)).toThrow(FlowfulError); devFlowful = new Flowful(); expect(() => devFlowful.addHandlers(handlersToAdd)).toThrow(FlowfulError); }); it('throws FlowfulError on circular dependency in DEV mode (two)', () => { const actionC = actionCreator('actionC'); const handlerCircular1 = createHandler({ name: 'circ1', inputs: [actionA], outputs: [actionB], run() {} }); const handlerCircular2 = createHandler({ name: 'circ2', inputs: [actionB], outputs: [actionA], run() {} }); const handlersToAdd = [handlerCircular1, handlerCircular2]; let devFlowful = new Flowful(); // Adding the first one is fine expect(devFlowful.addHandler(handlerCircular1)).toBe(true); // Adding the second one creates the circular dependency expect(() => devFlowful.addHandler(handlerCircular2)).toThrow(FlowfulError); // Reset and test with addHandlers devFlowful = new Flowful(); expect(() => devFlowful.addHandlers(handlersToAdd)).toThrow(FlowfulError); }); it('throws FlowfulError on circular dependency in DEV mode (three)', () => { const actionC = actionCreator('actionC'); const handlerCircular1 = createHandler({ name: 'circ1', inputs: [actionA], outputs: [actionB], run() {} }); const handlerCircular2 = createHandler({ name: 'circ2', inputs: [actionB], outputs: [actionC], run() {} }); const handlerCircular3 = createHandler({ name: 'circ3', inputs: [actionC], outputs: [actionA], run() {} }); const handlersToAdd = [handlerCircular1, handlerCircular2, handlerCircular3]; let devFlowful = new Flowful(); expect(devFlowful.addHandler(handlerCircular1)).toBe(true); expect(devFlowful.addHandler(handlerCircular2)).toBe(true); expect(() => devFlowful.addHandler(handlerCircular3)).toThrow(FlowfulError); devFlowful = new Flowful(); expect(() => devFlowful.addHandlers(handlersToAdd)).toThrow(FlowfulError); }); }); describe('removeHandlers', () => { let flowful: Flowful; const actionA = actionCreator('actionA'); const actionB = actionCreator('actionB'); const handler1 = createHandler({ name: 'handler1', inputs: [actionA], run() {} }); const handler2 = createHandler({ name: 'handler2', inputs: [actionB], run() {} }); const handler3 = createHandler({ name: 'handler3', inputs: [actionA, actionB], run() {} }); beforeEach(() => { flowful = new Flowful(); flowful.addHandlers([handler1, handler2, handler3]); }); it('removes multiple existing handlers', () => { const handlersToRemove = [handler1, handler3]; const result = flowful.removeHandlers(handlersToRemove); expect(result).toBe(true); expect(flowful.hasHandler(handler1)).toBe(false); expect(flowful.hasHandler(handler2)).toBe(true); expect(flowful.hasHandler(handler3)).toBe(false); expect((flowful as any)._handlers.size).toBe(1); expect((flowful as any)._handlersByInput.has(actionA)).toBe(false); // handler1 and handler3 removed expect((flowful as any)._handlersByInput.get(actionB)).toContain(handler2); expect((flowful as any)._handlersByInput.get(actionB).size).toBe(1); }); it('removes all existing handlers', () => { const handlersToRemove = [handler1, handler2, handler3]; const result = flowful.removeHandlers(handlersToRemove); expect(result).toBe(true); expect(flowful.hasHandler(handler1)).toBe(false); expect(flowful.hasHandler(handler2)).toBe(false); expect(flowful.hasHandler(handler3)).toBe(false); expect((flowful as any)._handlers.size).toBe(0); expect((flowful as any)._handlersByInput.size).toBe(0); }); it('returns false if any handler was not found, but removes existing ones', () => { const handlerNotInFlowful = createHandler({ name: 'notAdded', inputs: [actionA], run() {} }); const handlersToRemove = [handler1, handlerNotInFlowful]; const result = flowful.removeHandlers(handlersToRemove); expect(result).toBe(false); expect(flowful.hasHandler(handler1)).toBe(false); // Removed expect(flowful.hasHandler(handler2)).toBe(true); // Still present expect(flowful.hasHandler(handler3)).toBe(true); // Still present expect((flowful as any)._handlers.size).toBe(2); expect((flowful as any)._handlersByInput.get(actionA)).toContain(handler3); expect((flowful as any)._handlersByInput.get(actionA).size).toBe(1); expect((flowful as any)._handlersByInput.get(actionB)).toContain(handler2); expect((flowful as any)._handlersByInput.get(actionB)).toContain(handler3); expect((flowful as any)._handlersByInput.get(actionB).size).toBe(2); }); it('returns false if all handlers to remove were not found', () => { const handlerNotInFlowful1 = createHandler({ name: 'notAdded1', inputs: [actionA], run() {} }); const handlerNotInFlowful2 = createHandler({ name: 'notAdded2', inputs: [actionB], run() {} }); const handlersToRemove = [handlerNotInFlowful1, handlerNotInFlowful2]; const result = flowful.removeHandlers(handlersToRemove); expect(result).toBe(false); expect(flowful.hasHandler(handler1)).toBe(true); expect(flowful.hasHandler(handler2)).toBe(true); expect(flowful.hasHandler(handler3)).toBe(true); expect((flowful as any)._handlers.size).toBe(3); expect((flowful as any)._handlersByInput.get(actionA).size).toBe(2); expect((flowful as any)._handlersByInput.get(actionB).size).toBe(2); }); it('returns true for an empty array', () => { const result = flowful.removeHandlers([]); expect(result).toBe(true); expect((flowful as any)._handlers.size).toBe(3); // No handlers removed }); it('throws FlowfulError if the argument is not an array', () => { // @ts-expect-error testing invalid input expect(() => flowful.removeHandlers({})).toThrow(FlowfulError); // @ts-expect-error testing invalid input expect(() => flowful.removeHandlers(null)).toThrow(FlowfulError); // @ts-expect-error testing invalid input expect(() => flowful.removeHandlers('string')).toThrow(FlowfulError); }); it('correctly updates _handlersByInput when removing handlers', () => { const handlerA1 = createHandler({ name: 'hA1', inputs: [actionA], run() {} }); const handlerA2 = createHandler({ name: 'hA2', inputs: [actionA], run() {} }); const handlerB1 = createHandler({ name: 'hB1', inputs: [actionB], run() {} }); flowful = new Flowful(); flowful.addHandlers([handlerA1, handlerA2, handlerB1]); expect((flowful as any)._handlersByInput.get(actionA).size).toBe(2); expect((flowful as any)._handlersByInput.get(actionB).size).toBe(1); // Remove one handler for actionA let result = flowful.removeHandlers([handlerA1]); expect(result).toBe(true); expect((flowful as any)._handlersByInput.get(actionA).size).toBe(1); expect((flowful as any)._handlersByInput.get(actionA)).toContain(handlerA2); expect((flowful as any)._handlersByInput.get(actionB).size).toBe(1); // Remove the other handler for actionA result = flowful.removeHandlers([handlerA2]); expect(result).toBe(true); expect((flowful as any)._handlersByInput.has(actionA)).toBe(false); // Key should be deleted expect((flowful as any)._handlersByInput.get(actionB).size).toBe(1); // Remove the handler for actionB result = flowful.removeHandlers([handlerB1]); expect(result).toBe(true); expect((flowful as any)._handlersByInput.has(actionB)).toBe(false); // Key should be deleted expect((flowful as any)._handlersByInput.size).toBe(0); expect((flowful as any)._handlers.size).toBe(0); }); it('should not invoke removed handlers', () => { const actionC = actionCreator('actionC'); const callback = vi.fn(); const handlerToRemove = createHandler({ name: 'handlerToRemove', inputs: [actionC], run() { callback(); }, }); flowful.addHandler(handlerToRemove); flowful.removeHandlers([handlerToRemove]); expect(callback).not.toHaveBeenCalled(); flowful.dispatchActionMap(createActionMap(actionC())); expect(callback).not.toHaveBeenCalled(); }); }); describe('_isHandlerInterested', () => { let flowful: Flowful; const actionA = actionCreator('actionA'); const actionB = actionCreator('actionB'); const actionC = actionCreator('actionC'); const actionD = actionCreator('actionD'); beforeEach(() => { flowful = new Flowful(); }); // Helper to access protected method const isInterested = (handler: any, map: any) => { // Need to cast to 'any' to access protected member in test return (flowful as any)._isHandlerInterested(handler, map); }; it('should return true when handler input size <= map size and map contains a handler input', () => { const handler = createHandler({ name: 'h1', inputs: [actionA], run() {} }); const map = createActionMap(actionA(1), actionB('test')); // map size = 2, handler input size = 1 expect(isInterested(handler, map)).toBe(true); }); it('should return true when handler input size <= map size and map contains one of multiple handler inputs', () => { const handler = createHandler({ name: 'h1', inputs: [actionA, actionC], run() {} }); const map = createActionMap(actionA(1), actionB('test'), actionD()); // map size = 3, handler input size = 2 expect(isInterested(handler, map)).toBe(true); }); it('should return false when handler input size <= map size and map does not contain any handler inputs', () => { const handler = createHandler({ name: 'h1', inputs: [actionC], run() {} }); const map = createActionMap(actionA(1), actionB('test')); // map size = 2, handler input size = 1 expect(isInterested(handler, map)).toBe(false); }); it('should return true when handler input size > map size and map contains a handler input', () => { const handler = createHandler({ name: 'h1', inputs: [actionA, actionB, actionC], run() {} }); const map = createActionMap(actionB('test')); // map size = 1, handler input size = 3 expect(isInterested(handler, map)).toBe(true); }); it('should return true when handler input size > map size and map contains one of multiple handler inputs', () => { const handler = createHandler({ name: 'h1', inputs: [actionA, actionB, actionC, actionD], run() {} }); const map = createActionMap(actionB('test'), actionA(5)); // map size = 2, handler input size = 4 expect(isInterested(handler, map)).toBe(true); }); it('should return false when handler input size > map size and map does not contain any handler inputs', () => { const handler = createHandler({ name: 'h1', inputs: [actionA, actionB, actionC], run() {} }); const map = createActionMap(actionD()); // map size = 1, handler input size = 3 expect(isInterested(handler, map)).toBe(false); }); it('should return false when handler has no inputs', () => { const handler = createHandler({ name: 'h1', inputs: [], run() {} }); const map = createActionMap(actionA(1)); expect(isInterested(handler, map)).toBe(false); }); it('should return false when action map is empty', () => { const handler = createHandler({ name: 'h1', inputs: [actionA], run() {} }); const map = createActionMap(); expect(isInterested(handler, map)).toBe(false); }); it('should return false when both handler inputs and action map are empty', () => { const handler = createHandler({ name: 'h1', inputs: [], run() {} }); const map = createActionMap(); expect(isInterested(handler, map)).toBe(false); }); it('should not invoke handler that is not interested but exists inside handlers array', () => { const actionC = actionCreator('actionC'); const callback1 = vi.fn(); const firstHandler = createHandler({ name: 'firstHandler', inputs: [actionA], outputs: [actionC], run() { callback1(); // we don't want to call the second handler }, }); const callback2 = vi.fn(); const secondHandler = createHandler({ name: 'secondHandler', inputs: [actionC], run() { // this should not be called callback2(); }, }); flowful.addHandler(firstHandler); flowful.addHandler(secondHandler); flowful.dispatchActionMap(createActionMap(actionA(1))); expect(callback1).toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); }); }); describe('dispatch', () => { it('empty action map should not run _collectHandlers', () => { // @ts-expect-error testing private method const collectHandlersSpy = vi.spyOn(flowful, '_collectHandlers'); flowful.dispatchActionMap(createActionMap()); expect(collectHandlersSpy).not.toHaveBeenCalled(); }); }); });