UNPKG

chai-latte

Version:

Build expressive & readable fluent interface libraries.

505 lines (419 loc) 19.4 kB
import { expression } from '../../'; import { combine } from '../combine'; import { combineTest } from './lib/combineTest'; function createBuildTest(buildCallback, createTests, config: { only } = { only: false }) { const startOfFnBodyIdx = buildCallback.toString().match('=>').index + 2; const testName = buildCallback.toString().slice(startOfFnBodyIdx).trim(); const testBody = () => { const callback = jest.fn(); const [{ api }] = expression(buildCallback,callback); buildCallback(api); createTests(callback); }; if (config.only) { it.only(testName, testBody); } else { it(testName,testBody); } } function buildFluentMock(buildCallback, forceCallback?) { const callback = forceCallback ?? jest.fn(); const [registeredFluentAPI] = expression(buildCallback,callback); return [registeredFluentAPI.api, callback]; } createBuildTest.only = (a, b) => createBuildTest(a, b, { only: true }); describe('Single Builder', () => { createBuildTest(({ the }) => the.man.should.do(), (callback) => { expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(undefined); }) createBuildTest(({ the }) => the.man.should.do('a string'), (callback) => { expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string'); }) createBuildTest(({ the }) => the.man.do('a string').and.do(), (callback) => { expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', undefined); }) createBuildTest(({ the }) => the.man.do('a string').and.do('another string'), (callback) => { expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', 'another string'); }) createBuildTest(({ the }) => the('a string').and.do('another string'), (callback) => { expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', 'another string'); }) createBuildTest(({ the }) => the('a string').and('another string').do(), (callback) => { expect(callback).toHaveBeenCalled(); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', 'another string', undefined); }) it('should be able to call it several times', () => { const [{ the }, callback] = buildFluentMock(({ the }) => the('a string').and('another string').do()); the('a string').and('another string').do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', 'another string', undefined); callback.mockClear(); the('a string').and('another string').do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', 'another string', undefined); callback.mockClear(); the('a string').and('another string').do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', 'another string', undefined); callback.mockClear(); }) it('should not allow unsupported arguments', () => { { const [{ the }] = buildFluentMock(({ the }) => the('a string').and('another string').do()); expect(() => the('something else')).toThrowError('Unsupported Argument'); expect(() => the('a string').and('something else')).toThrowError('Unsupported Argument'); } { const [{ the }, callback] = buildFluentMock(({ the }) => the(String).and().do()); expect(() => the('name').and().do('aaaa')).toThrowError('Unsupported Argument'); expect(callback).not.toHaveBeenCalled(); } }) it('works if argument matches String class', () => { const [{ the }, callback] = buildFluentMock(({ the }) => the(String).and(String).do()); the('a string').and('another string').do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith('a string', 'another string', undefined); callback.mockClear(); const arg = new String('false'); the(String).and(arg).do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(String, arg, undefined); callback.mockClear(); }) it('returns what callback returns', () => { const expectedReturn = Symbol(); const callback = jest.fn().mockReturnValue(expectedReturn); const [{ api: { the }}] = expression(({ the }) => the('1').and('2').do(), callback); expect(the('1').and('2').do()).toBe(expectedReturn); }) it('works if argument matches Boolean class', () => { const [{ the }, callback] = buildFluentMock(({ the }) => the(Boolean).and(Boolean).do()); the(true).and(false).do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(true, false, undefined); callback.mockClear(); const arg = new Boolean(false); the(Boolean).and(arg).do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(Boolean, arg, undefined); callback.mockClear(); }) it('works if argument matches Number class', () => { const [{ the }, callback] = buildFluentMock(({ the }) => the(Number).and(Number).and(Number).and(Number).do()); the(3).and(Infinity).and(NaN).and(-Infinity).do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(3, Infinity, NaN, -Infinity, undefined); callback.mockClear(); const arg = new Number(99); the(Number).and(Number).and(Number).and(arg).do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(Number, Number, Number, arg, undefined); callback.mockClear(); }) it('works if argument inherites from class', () => { class Parent {}; class Child extends Parent {}; class GrandChild extends Child {}; class OtherChild extends Parent {}; const parent = new Parent() const child = new Child() const grandChild = new GrandChild() const otherChild = new OtherChild() const [{ the }, callback] = buildFluentMock(({ the }) => the(Parent).and(Parent).and(Parent).and(Parent).do()); the(parent).and(child).and(grandChild).and(otherChild).do() expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(parent,child, grandChild, otherChild, undefined); }) it('should not allow to undefined access property', () => { const [{ the }, callback] = buildFluentMock(({ the }) => the('s1').do()); const attempt = () => the('s1').and('s2').do(); expect(attempt).toThrow(); expect(callback).not.toHaveBeenCalled(); }); it.skip('should check that callback uses same number of arguments as expression', () => { throw new Error('TODO'); }); it.skip('can be chained liked regular objects', () => { // Not working at the moment // Everytine we access an attribute or call a function // we should forward a copy of the arguments instead of using the same reference const [builder1, callback1] = buildFluentMock(({ the }) => the(Number).name(Number).do(Number)); const { the } = combine(builder1); const chain = the(1).name(2); Array.from({ length: 5 }).forEach((_, i) => { chain.do(i); }); expect(callback1).toHaveBeenCalledTimes(5); callback1.calls expect(callback1.mock.calls[0]).toMatchObject([1,2,0]); expect(callback1.mock.calls[1]).toMatchObject([1,2,1]); expect(callback1.mock.calls[2]).toMatchObject([1,2,2]); expect(callback1.mock.calls[3]).toMatchObject([1,2,3]); expect(callback1.mock.calls[4]).toMatchObject([1,2,4]); }) it.skip('uncompleted sentences should not trigger calls', () => { const [builder1, callback1] = buildFluentMock(({ the }) => the(Number).name(Number).do(Number)); const { the } = combine(builder1); the(1).name(2); the(1).name(3).do(4); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(1,3,4); }) }) describe('Debug', () => { it('should create a debugId for each expression', () => { const [exp] = expression(({ the }) => the(String).name.do(String).it(String), () => {}); expect(exp.expression.debugId).toEqual('the(String).name.do(String).it(String)'); }) }) describe('Aliases', () => { it('should define expression aliases', () => { const expectedReturn = Symbol(); const callback = jest.fn().mockReturnValue(expectedReturn); const { the } = combine( expression( ({ the }) => the('1').and('2').do(), ({ the }) => the('1').also.and('2').then.do(), callback ), ); expect(the('1').and('2').do()).toBe(expectedReturn); expect(the('1').also.and('2').then.do()).toBe(expectedReturn); }) it('should not allow aliases with different signatures', () => { const expectedReturn = Symbol(); const callback = jest.fn().mockReturnValue(expectedReturn); const shouldThrow = () => expression( ({ the }) => the('1').and('2').do(), ({ the }) => the('1').also.and('3').then.do(), callback ); expect(shouldThrow).toThrow(); }) }) describe('Combined Builders', () => { function buildFluentAPIMock(buildCallback, forceCallback?) { const callback = forceCallback ?? jest.fn(); const registeredFluentAPI = expression(buildCallback,callback); return [registeredFluentAPI, callback]; } combineTest('should combine several fluent apis', () => { const [builder1, callback1] = buildFluentAPIMock(({ the }) => the.man.should.do()); const [builder2, callback2] = buildFluentAPIMock(({ el }) => el.man.should.do('a string')); const { the, el } = combine(builder1, builder2); el.man.should.do('a string') expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith('a string'); callback2.mockClear(); the.man.should.do(); expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(undefined); }) combineTest('combined functions: call correct callback based on argument', () => { const [builder1, callback1] = buildFluentAPIMock(({ the }) => the.man()); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the.man('a string')); const { the } = combine(builder1, builder2); the.man('a string') expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith('a string'); }) combineTest('prop can be callable and also be accessed like an object', () => { const [builder1, callback1] = buildFluentAPIMock(({ the }) => the.man.should().do()); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the.man.should.do('a string')); const { the } = combine(builder1, builder2); the.man.should.do('a string') expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith('a string'); callback2.mockClear(); the.man.should().do() expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(undefined, undefined); }); const nativeFnProps = ["length","prototype", "name", "apply", "call", "arguments", "bind"]; nativeFnProps.forEach((nativefnProp) => { combineTest(`native function prop (${nativefnProp}) should be usable by the fluent api`, () => { const [builder1, callback1] = buildFluentAPIMock(({ the }) => the().do()); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the[nativefnProp]()); const [builder3, callback3] = buildFluentAPIMock(({ the }) => the[nativefnProp].do()); const { the } = combine(builder2, builder1, builder3); expect(typeof the).toBe('function'); the().do(); expect(callback2).not.toBeCalled(); expect(callback3).not.toBeCalled(); expect(callback1).toBeCalledTimes(1); expect(callback1).toBeCalledWith(undefined, undefined); callback1.mockClear(); the[nativefnProp]() expect(callback1).not.toBeCalled(); expect(callback3).not.toBeCalled(); expect(callback2).toBeCalledTimes(1); expect(callback2).toBeCalledWith(undefined); callback2.mockClear(); the[nativefnProp].do() expect(callback1).not.toBeCalled(); expect(callback2).not.toBeCalled(); expect(callback3).toBeCalledTimes(1); expect(callback3).toBeCalledWith(undefined); }); }) // add more of these tests combineTest('return correct subtree', () => { const [builder1, callback1] = buildFluentAPIMock(({ the }) => the.man('1').should.build()); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the.man('2').should.action()); const [builder3, callback3] = buildFluentAPIMock(({ the }) => the.man('3').should.build()); const { the } = combine(builder1, builder2, builder3); the.man('3').should.build() expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); expect(callback3).toHaveBeenCalledTimes(1); expect(callback3).toHaveBeenCalledWith('3', undefined); callback3.mockClear(); the.man('1').should.build() expect(callback2).not.toHaveBeenCalled(); expect(callback3).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith('1', undefined); callback1.mockClear(); the.man('2').should.action() expect(callback1).not.toHaveBeenCalled(); expect(callback3).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith('2', undefined); callback2.mockClear(); }); combineTest('matches multiple inherited class and proritize more precise match', () => { class Parent {}; class Child extends Parent {}; const [builder1, callback1] = buildFluentAPIMock(({ the }) => the(Parent).do()); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the(Child).do()); const { the } = combine(builder2, builder1); const parent = new Parent(); const child = new Child(); the(child).do(); expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith(child, undefined); callback2.mockClear(); the(parent).do(); expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(parent, undefined); }); combineTest('matches first builder if compatible, should override parent api', () => { class Parent {}; class Child extends Parent {}; const [builder1, callback1] = buildFluentAPIMock(({ the }) => the(Parent).action('2')); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the(Child).action('1')); const { the } = combine(builder2, builder1); const child = new Child(); the(child).action('1'); expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith(child, '1'); callback2.mockClear(); the(child).action('2'); expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(child, '2'); callback1.mockClear(); }); combineTest('1. matches first builder if compatible', () => { class Parent {}; class Child extends Parent {}; const [builder1, callback1] = buildFluentAPIMock(({ the }) => the(Parent).action('1')); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the(Child).do()); const { the } = combine(builder2, builder1); const parent = new Parent(); const child = new Child(); the(child).action('1'); expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(child, '1'); callback1.mockClear(); the(child).do(); expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith(child, undefined); callback2.mockClear(); the(parent).action('1'); expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(parent, '1'); callback1.mockClear(); expect(() => the(parent).do()).toThrow(); }); combineTest('2. matches first builder if compatible', () => { class Parent {}; class Child extends Parent {}; const [builder1, callback1] = buildFluentAPIMock(({ the }) => the(Parent).action('1')); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the(Child).action()); const { the } = combine(builder2, builder1); const parent = new Parent(); const child = new Child(); the(child).action('1'); expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(child, '1'); callback1.mockClear(); the(child).action(); expect(callback1).not.toHaveBeenCalled(); expect(callback2).toHaveBeenCalledTimes(1); expect(callback2).toHaveBeenCalledWith(child, undefined); callback2.mockClear(); the(parent).action('1'); expect(callback2).not.toHaveBeenCalled(); expect(callback1).toHaveBeenCalledTimes(1); expect(callback1).toHaveBeenCalledWith(parent, '1'); callback1.mockClear(); expect(() => the(parent).action()).toThrow(); }); combineTest('should throw if a fluent api overrides another one', () => { const [builder1, callback1] = buildFluentAPIMock(({ the }) => the.do()); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the.do().add()); expect(() => combine(builder1, builder2)).toThrowError('Incompatible Fluent API'); }); describe('Complexe Cases', () => { const createCallbackTester = (callbacks) => (idx, args) => { callbacks.forEach((callback, index) => { if (idx - 1 === index) { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(...args); } else { expect(callback).not.toHaveBeenCalled(); } }) callbacks.forEach((callback) => { callback.mockClear(); }) } combineTest('complexe case 1', () => { const [builder1, callback1] = buildFluentAPIMock(({ the }) => the.name.do()); const [builder2, callback2] = buildFluentAPIMock(({ the }) => the.name()); const [builder3, callback3] = buildFluentAPIMock(({ the }) => the().name.do()); const [builder4, callback4] = buildFluentAPIMock(({ the }) => the().name().do()); const { the } = combine(builder1, builder2, builder3, builder4); const testCallback = createCallbackTester([callback1, callback2, callback3, callback4]) the.name.do(); testCallback(1, [undefined]) the.name(); testCallback(2, [undefined]) the().name.do(); testCallback(3, [undefined, undefined]) the().name().do(); testCallback(4, [undefined, undefined, undefined]); }) }); });