chai-latte
Version:
Build expressive & readable fluent interface libraries.
505 lines (419 loc) • 19.4 kB
text/typescript
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]);
})
});
});