std4453/bilibili-danmaku-client

View on GitHub
src/util/helpers.js

Summary

Maintainability
A
0 mins
Test Coverage
/**
 * The helpers used while making Definitions.
 * They are mostly used to simplify code of Definitions and accelerate development.
 * Whether these helpers make code easier to read depends on whether the reader
 * has a good understanding of the helper methods. In most cases, is the documentation
 * of the helper methods are carefully read, the reader should be able to understand
 * Definitions without great difficulties, and, considering the reduced code length
 * and the improved coercion, readability should be better. However, exceptions might
 * exist.
 * Meanwhile, both the helpers and the Definition code are considered internal, that
 * is, one should rely on the project Wiki for reference and documentation instead of
 * in-code comments without good reasons.
 * For documentation about the concepts Definition, compile, Input, see compile.js.
 */

const {
    isEmpty,
    negate,
    isString,
    isFunction,
    isArray,
    isObject,
    fromPairs,
    camelCase,
} = require('lodash');

const compile = require('./compile');

/**
 * Boolify input.
 * As defined in the test, asFlag() should:
 * - return true for true.
 * - return false for false.
 * - return true for 1.
 * - return true for 0.
 * Otherwise, behavior is undefined and not tested.
 *
 * @param {any} input The input to boolify.
 * @returns {boolean} The boolified result.
 */
const asFlag = input => !!input;

/**
 * Return an Definition that executes the specified Definition on the Input mapped with mapper.
 *
 * @param {Function} mapper The function used to map Input.
 * @param {any} def The Definition.
 * @returns {Function} The returned Definition.
 */
const on = (mapper, def) => {
    const compiled = compile(def);
    return input => compiled(mapper(input));
};

/**
 * Return an Definition that:
 * - if predicate(mapper(input)) returns true, execute def1 on the Input mapped with mapper.
 * - otherwise, execute def2 on the input mapper with mapper.
 *
 * @param {Function} mapper The function used to map input.
 * @param {Function} predicate The functino used to decide whether the Definition should be
 *   executed or not.
 * @param {any} def1 The Definition used when predicate(mapper(input)) is true.
 * @param {any} def2 The Definitino used when predicate(mapper(input)) is false. Default to
 *   null.
 * @returns {Function} The returned Definition.
 */
const onWhen = (mapper, predicate, def1, def2 = null) => {
    const compiled1 = compile(def1);
    const compiled2 = compile(def2);
    return on(mapper, input => (predicate(input) ? compiled1(input) : compiled2(null)));
};

/**
 * Return onWhen(mapper, _.negate(_.isEmpty), def).
 * See documentation of lodash for details.
 *
 * The typical usage of onExist is:
 * onExist(a => a.data, { foo: d => d.foo });
 * Which ensures that, if a.data is undefined or {}, d => d.foo will not even be executed
 * and no Error is thrown.
 * However _.isEmpty() is just a very simple check, so use this method only when applicable.
 *
 * @param {Function} mapper The function used to map input.e
 * @param {any} def1 The Definition used when predicate(mapper(input)) is true.
 * @param {any} def2 The Definitino used when predicate(mapper(input)) is false. Default to
 *   null.
 * @returns The returned Definition.
 */
const onExist = (mapper, def1, def2) => onWhen(mapper, negate(isEmpty), def1, def2);

/**
 * Turn input into a function that maps input value to desired value.
 * Used only in coonvertNames().
 * As defined in the test, toMapVal() should:
 * - return unchanged for a function.
 * - return v => v otherwise.
 *
 * @param {any} input The input to turn into a function.
 * @returns {Function} The returned function.
 */
const toMapVal = (input) => {
    if (isFunction(input)) return input;
    return v => v;
};

/**
 * Turn input into a function that maps key to desired key.
 * Used only in convertNames().
 * As defined in the test. toMapKey() should:
 * - return unchanged for a function.
 * - return () => src for a string str.
 * - return _.camelCase otherwise.
 * _.camelCase is used only for the convenience of Definitions in definitions.js,
 * and is due to change as the Definitions evolves.
 *
 * @param {any} input The input to turn into a function.
 * @returns {Function} The returned function.
 */
const toMapKey = (input) => {
    if (isFunction(input)) return input;
    if (isString(input)) return () => input;
    return camelCase;
};

/**
 * Turn array of input into an { name: string, mapVal: Function, mapKey: Function }.
 * This method is implements the common logics in spread() and spreadObj().
 * The behavior of this method is the convenience of Definitions in definitions.js,
 * and is due to change as the Definitions evolves.
 * In short, it supports a convenient way to extract data from Input arrays or objects,
 * probably changing the value and the key with a mapper function, and using the
 * mapped key and value to create a Property Definition.
 * In the meanwhile, convertNames() provides default values to enable creating Property
 * Definitions simply and shortly.
 * Its detailed behavior is, map each name of names with:
 * - If name is a string, map [name, undefined, undefined] instead.
 * - If name is an Array, return { name: name[0], mapVal: toMapVal(name[1]), mapKey: name[2] }.
 * - Otherwise, return null.
 * The return value is an array, each element being either:
 * - An object with { name: string, mapVal: Function, mapKey: Function }.
 * - Or null.
 * Each element of the return value corresponds to the element in names of the same index,
 * which enables the funcionality of spread().
 *
 * @param {(string | Array | *)[]} names The names to convert.
 * @returns {(Object | null)[]} The converted names.
 * @see #toMapKey
 * @see #toMapVal
 * @see #spread
 * @see #spreadObj
 */
const convertNames = (...names) => names
    .map(name => (isString(name) ? [name] : name))
    .map(compile(onWhen(a => a, isArray, {
        name: a => a[0],
        mapVal: a => toMapVal(a[1]),
        mapKey: a => toMapKey(a[2]),
    })));

/**
 * Return a Definition which spread the given names.
 * The behavior of this method is the convenience of Definitions in definitions.js,
 * and is due to change as the Definitions evolves.
 * The name spread comes from the spread syntax of ECMAScript, since this method
 * vaguely expresses the same idea.
 * This method spreads the given names into an object to create an Definition.
 * Typically, it is used as follows:
 * const def = spread('foo', ['bar', parseInt], 0, ['baz', 0, 'fooBar'], ['bad_case', 0]);
 * And using def to parse:
 * ['1', '2', '3', '4', '5']
 * you will get:
 * { foo: '1', bar: 2, fooBar: '4', badCase: '5' }
 * Explanations.
 * The arguments are used like mappers. When the returned Definition gets an array input,
 * it maps the elements and return an object.
 * As you can see, giving a simple 'foo' sets the 'foo' property with the value of input[0],
 * Giving ['bar', parseInt] sets the 'bar' property with parseInt(input[1]).
 * Giving 0 ignores the input element, that is, input[2]. (0 is used only because it is short)
 * Giving ['baz', 0, 'fooBar'] sets the 'fooBar' property with input[3], where 0 specifies
 * input[3] is not mapped and returned directly, 'fooBar' is the final property name, and
 * 'foo' is ignored. (But this will be different in spreadObj()).
 * Giving ['bad_case', 0] sets the 'badCase' property with input[4], where 0 specifies
 * that input[4] is not mapped, 'bad_case' is used to produce the property name 'badCase'
 * (by converting it into camelCase when no convert function is specified, see toMapKey()).
 * When using spread(), the last 2 syntaxes are rarely used, but they do have their reason
 * of existence in spreadObj().
 * In short, it uses convertNames(), ignores null return elements, and returns a Definiton
 * that maps every input element into a property of the returned object.
 *
 * @param {any[]} names The names to spread.
 * @returns {Function} The returned Definition.
 * @see #convertNames
 * @see #spreadObj
 */
const spread = (...names) =>
    fromPairs(convertNames(...names)
        .map((converted, index) => {
            if (!isObject(converted)) return undefined;
            const { name, mapVal, mapKey } = converted;
            return [mapKey(name), a => mapVal(a[index])];
        })
        .filter(isArray));

/**
 * Return a Definition which spread the given names.
 * The behavior of this method is the convenience of Definitions in definitions.js,
 * and is due to change as the Definitions evolves.
 * The name spread comes from the object spread syntax of ECMAScript, since this method
 * vaguely expresses the same idea.
 * This method spreads the given names into an object to create an Definition.
 * Typically, it is used as follows:
 * const def = spreadObj('foo', ['bar', parseInt], 0, ['baz', 0, 'fooBar'], ['bad_case', 0]);
 * And using def to parse:
 * { foo: '1', bar: '2', baa: '3', baz: '4', bad_case: '5' }
 * you will get:
 * { foo: '1', bar: 2, fooBar: '4', badCase: '5' }
 * Explanations.
 * The arguments are used like mappers. When the returned Definition gets an object input,
 * it maps the properties and return another object.
 * As you can see, giving a simple 'foo' sets the 'foo' property with the value of input.foo,
 * Giving ['bar', parseInt] sets the 'bar' property with parseInt(input.bar).
 * Giving 0 will be meaningless, this syntax is only available in spread().
 * Giving ['baz', 0, 'fooBar'] sets the 'fooBar' property with input.baz, where 0 specifies
 * input.baz is not mapped and returned directly. (0 is used only because it is short, any
 * non-string and non-Array value will have the same effect) 'fooBar' is the final property
 * name, and 'baz' is the property name in input.
 * Giving ['bad_case', 0] sets the 'badCase' property with input.bad_case, where 0 specifies
 * that input.foo_bar is not mapped, 'bad_case' the property name in input and 'badCase' is
 * the camelCase of 'bad_case'. (This is the default behavior since the key mapper is not
 * specified. See toMapKey() for details).
 * When using spread(), the third syntax is rarely used, but it is useful in spreadObj().
 * In short, it uses convertNames(), and returns a Definiton that maps every input property
 * into a property of the returned object.
 *
 * @param {any[]} names The names to spread.
 * @returns {Function} The returned Definition.
 * @see #convertNames
 * @see #spread
 */
const spreadObj = (...names) =>
    fromPairs(convertNames(...names)
        .filter(isObject)
        .map(({ name, mapVal, mapKey }) => [mapKey(name), o => mapVal(o[name])]));

module.exports = {
    asFlag,
    onWhen,
    on,
    onExist,
    toMapVal,
    toMapKey,
    convertNames,
    spread,
    spreadObj,
};