lib/reporters/mico.js

const file = require('../file.js').file;

/**
 * @module pete/lib/reporters/mico
 */
module.exports = createMiniCompare;

/**
 * @private
 * @see {@link https://github.com/mochajs/mocha/blob/ca9eba6fd7177dc482756d96a058eb4169292bd5/lib/reporters/base.js#L48}
 */
/* eslint-disable no-magic-numbers */
const COLORS = {
	decor    : 90,
	pass     : 32,
	fail     : 31,
	pending  : 36,
	default  : 0,
	fast     : 32,
	medium   : 33,
	slow     : 31,
	underline: 4
};
/* eslint-enable no-magic-numbers */

/**
 * @private
 */
const SECOND = 1000000000;

/**
 * @private
 */
const TIMECONS = ['🕧', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘'];

/**
 * "Loss" column thresholds.
 * Each value is a upper limit to match given category.
 * @private
 */
const RESULT_BEST = 1;
const RESULT_FAST = 1.1;
const RESULT_MEDIUM = 1.5;
const RESULT_SLOW = Infinity;

/**
 * "Bar" column thresholds.
 * Each value is a upper limit to match given category.
 * @private
 */
const PERCENTILE_MOST = 7;
const PERCENTILE_RARE = 9;

const BAR_SCORE_FAST = 1;
const BAR_SCORE_MEDIUM = 5;

/**
 * "Bar" column characters.
 * @private
 */
const RESULT_BAR_75 = ['🮂', '🮂', '🮂', '▀', '▀', '▀', '🮅', '🮅', '🮅', '🮔'];
const RESULT_BAR_90 = ['𜴅', '𜴢', '𜶘', '𜴦', '𜴦', '𜴦', '𜶫', '𜶫', '𜶫', '🮔'];
const RESULT_BAR_99 = ['𜹓', '𜹛', '𜹻', '𜹟', '𜹟', '𜹟', '𜹿', '𜹿', '𜹿', '🮔'];

/**
 * Max value for percentage.
 * @private
 */
const MAX_PERCENT = 100;

/**
 * Max value after which "loss" column will round value to full integer.
 * @private
 */
const CROP_LOSS_ABOVE = 10000;

/**
 * This is a bit stupid, but eslint...
 *
 * @private
 */
const HALF = 0.5;

/**
 * @private
 */
const FORMATTER = new Intl.NumberFormat();

/**
 * @typedef {object} Column
 * @private
 */

const COLUMNS = {
	string: {
		init: function init (report, value) {
			this.value = value;
			return this;
		},
		sort: index => (a, b) => {
			if (!a[index]) {
				return 1;
			}

			if (!b[index]) {
				return -1;
			}

			return a[index].value.localeCompare(b[index].value, undefined, {numeric: true});
		},
		showError: false,
		align    : 'left',
		left     : '',
		right    : '   ',
		pre      : {value: ''},
		post     : {value: ''}
	},
	number: {
		init: function init (report, value) {
			this.failed = Boolean(report.error);
			this.score = typeof value === 'undefined' || value === null ? this.fallback : value;
			this.value = FORMATTER.format(this.score.toFixed(2)); // eslint-disable-line no-magic-numbers
			return this;
		},
		sort: index => (a, b) => {
			if (!a[index] || a[index].failed) {
				return 1;
			}

			if (!b[index] || b[index].failed) {
				return -1;
			}

			return a[index].score - b[index].score;
		},
		calc: function calc (best) {
			if (this.score === Infinity) {
				this.pre.value = this.pre.value.replace(/[^\n]/g, ' ');
				this.post.value = this.post.value.replace(/[^\n]/g, ' ');
				return;
			}

			if (this.score === best) {
				return;
			}

			if (this.pre.value !== COLUMNS.number.pre.value) {
				return;
			}

			var loss = Number((this.score / best).toFixed(1));

			if (loss < 2) { // eslint-disable-line no-magic-numbers
				this.pre.value = `${TIMECONS[Math.floor(loss % 1 * 10)]}  `; // eslint-disable-line no-magic-numbers
			}
			else {
				this.pre.value = '🌜  ';
			}
		},
		showError: false,
		left     : ' ',
		right    : '  ',
		align    : 'right',
		best     : Math.min,
		worst    : Math.max,
		pre      : {value: '🕛  '},
		post     : {value: 'ns'},
		fallback : Infinity
	}
};

COLUMNS.undefined = Object.assign(Object.create(COLUMNS.number), {
	init: function init (report) { // eslint-disable-line no-unused-vars
		this.score = Infinity;
		this.value = '';
		return this;
	}
});

COLUMNS.status = Object.assign(Object.create(COLUMNS.string), {
	init: function init (report) {
		this.score = report.error ? 1 : 0;
		this.value = report.error ? '✗' : '✔';
		this.color = report.error ? 'fail' : 'pass';
		return this;
	},
	showError: true,
	best     : Math.min,
	worst    : Math.max,
	left     : ' ',
	right    : ' '
});

COLUMNS.name = Object.assign(Object.create(COLUMNS.string), {showError: true});

COLUMNS.mode = Object.assign(Object.create(COLUMNS.string), {
	init: function init (report) {
		this.value = report.mode === 'regular' ? '' : report.value;
		return this;
	}
});

COLUMNS.max = Object.assign(Object.create(COLUMNS.number), {
	calc: function calc (best) {
		COLUMNS.number.calc.call(this, best);
		this.pre.value = '';
	},
	pre: {value: ''}
});

COLUMNS.ops = Object.assign(Object.create(COLUMNS.number), {
	init: function init (report, value) {
		COLUMNS.number.init.call(this, report, value);
		this.score = report.mean ? SECOND / report.mean : this.fallback;
		this.value = FORMATTER.format(this.score.toFixed(0));
		return this;
	},
	sort: index => (a, b) => {
		if (!a[index] || a[index].failed) {
			return 1;
		}

		if (!b[index] || b[index].failed) {
			return -1;
		}

		return b[index].score - a[index].score;
	},
	best    : Math.max,
	worst   : Math.min,
	pre     : {value: '~'},
	post    : {value: '/s'},
	fallback: 0
});

COLUMNS.loss = Object.assign(Object.create(COLUMNS.number), {
	init: function init (report, value) {
		if (typeof value === 'undefined' || isNaN(value)) {
			value = report.mean || Infinity;
		}
		COLUMNS.number.init.call(this, report, value);
		this.srcValue = value;
		this.score = value;
		this.value = '000.00';
		return this;
	},
	calc: function calc (best, worst) { // eslint-disable-line no-unused-vars
		this.score = this.srcValue / (best || 1);
		if (isNaN(this.score)) {
			this.score = Infinity;
		}

		this.value = this.score === Infinity ? FORMATTER.format(this.score) : this.score.toFixed(2); // eslint-disable-line no-magic-numbers

		if (this.value >= CROP_LOSS_ABOVE) {
			this.value = Math.floor(this.value);
		}

		if (this.score <= RESULT_BEST) {
			this.color = 'decor';
		}
		else if (this.score <= RESULT_FAST) {
			this.color = 'fast';
		}
		else if (this.score <= RESULT_MEDIUM) {
			this.color = 'medium';
		}
		else if (this.score <= RESULT_SLOW) {
			this.color = 'slow';
		}

		if (this.score === Infinity) {
			this.pre.value = this.pre.value.replace(/[^\n]/g, ' ');
		}

		return this;
	},
	pre     : {value: '×'},
	post    : {value: ''},
	fallback: Infinity
});

COLUMNS.bar = Object.assign(Object.create(COLUMNS.number), {
	init: function init (report, value) {
		COLUMNS.number.init.call(this, report, value);
		this.value = '';
		this.max = report.max || this.fallback;
		this.pvs = Object.keys(report.percentiles)
			.map(k => ({p: Number(k.substring(1)), v: report.percentiles[k] || this.fallback}))
			.sort((a, b) => a.p - b.p);
		return this;
	},
	calc: function calc (best, worst, cols) {
		this.value = cols.c('', 'default');

		const cwidth = cols.sumWidth - this.pre.value.length - this.post.value.length;

		this.pvs.unshift({p: 0, v: 0});
		if (this.pvs[this.pvs.length - 1].p < MAX_PERCENT) {
			this.pvs.push({p: MAX_PERCENT, v: this.max});
		}

		var w = 0;
		var width = 0;
		var chunks = new Array(this.pvs.length);
		for (var i = this.pvs.length - 1; i > 0; i--) {
			// Always round up, if bar will get too wide, it will be cropped.
			w = Math.max(Math.ceil((this.pvs[i].p - this.pvs[i - 1].p) / MAX_PERCENT * cwidth), 1);
			width += w;

			if (width > cwidth) {
				// Since we're starting from the top percentiles, worst case scenario will crop lowest percentiles.
				w -= width - cwidth;
			}

			chunks[i] = chunk(Math.floor(this.pvs[i].p / MAX_PERCENT * 10), Math.ceil(this.pvs[i].v / best), w); // eslint-disable-line no-magic-numbers
		}

		this.value = chunks.join('');

		/**
		 * @private
		 * @param {number}  percentile   which percentile (1-10)
		 * @param {number}  score        value represented by the chunk
		 * @param {number}  chars        number of chars in chunk
		 */
		function chunk (percentile, score, chars) {
			if (chars < 1) {
				return '';
			}

			var char = '';
			var color = '';

			if (percentile <= PERCENTILE_MOST) {
				char = RESULT_BAR_75[Math.min(score, RESULT_BAR_75.length - 1)] || '!';
			}
			else if (percentile <= PERCENTILE_RARE) {
				char = RESULT_BAR_90[Math.min(score, RESULT_BAR_90.length - 1)] || '!';
			}
			else {
				char = RESULT_BAR_99[Math.min(score, RESULT_BAR_99.length - 1)] || '!';
			}

			if (score <= BAR_SCORE_FAST) {
				color = 'fast';
			}
			else if (score <= BAR_SCORE_MEDIUM) {
				color = 'medium';
			}
			else {
				color = 'slow';
			}

			return cols.c(char + char.repeat(chars - 1), color);
		}

		return this;
	},
	wholeRow: true,
	left    : '',
	right   : '',
	color   : 'inherit',
	pre     : {value: ''},
	post    : {value: ''}
});

COLUMNS.samples = Object.assign(Object.create(COLUMNS.number), {
	init: function init (report, value) {
		COLUMNS.number.init.call(this, report, value);
		this.score = value || this.fallback;
		this.value = FORMATTER.format(this.score);
		return this;
	},
	sort: index => (a, b) => {
		if (!a[index] || a[index].failed) {
			return 1;
		}

		if (!b[index] || b[index].failed) {
			return -1;
		}

		return b[index].score - a[index].score;
	},
	best    : Math.max,
	worst   : Math.min,
	pre     : {value: '|'},
	post    : {value: '|'},
	fallback: 0
});

/**
 * Create and return report function that will output reports in "human-friendly" format.
 *
 * @alias module:pete/lib/reporters/mico
 * @param {object} [options]
 * @param {object} [options.out]        path to file that should be created
 * @param {object} [options.callback]   to call once on `out` stream `close` event
 * @param {string} [options.cols]       comma-separated list of columns to output, e.g., "status,name,max,p50,p99,samples,ops,=p99,*p99". "=" means bar instead of column. "*" means calculate "loss" column.
 * @param {string} [options.ignore]     comma-separated list of run modes to ignore in results, e.g., "warmup,skip"
 * @param {string} [options.sort]       name of column by which results should be sorted, defaults to last of the columns
 * @param {string} [options.colorize]   set to "no" or "false" to prevent colorizing output, defaults to "yes" when output `isTTY`
 * @return {module:pete/lib/getReporters~report}
 */
function createMiniCompare (options) {
	var index = 0;
	var out = (options && options.out) || process.stdout;
	var callback = (options && options.callback) || null;
	var isTTY = out.isTTY;

	var reports = [];

	if (typeof out === 'string') {
		try {
			out = file(out, true);
		}
		catch (e) {
			console.error(e);
			out = process.stderr;
			isTTY = out.isTTY;
		}
	}

	if (typeof callback === 'function') {
		out.once('close', callback);
	}

	return function mico (err, report) {
		if (index < 0) {
			console.error(new Error('MiCo reporter was called after it was closed'));
			return;
		}

		if (!err && !report) {
			if (isTTY) {
				out.clearLine(0);
				out.cursorTo(0);
			}
			out.end(compare(reports, options, isTTY));
			reports = [];
			index = -1;
			return;
		}

		index++;

		if (report) {
			reports.push(report);
		}
		else if (err) {
			console.error(err);
		}

		if (isTTY) {
			out.cursorTo(0);
			process.stdout.write(`${err ? '✗' : '✔'} ${index}. ${report?.name} # ${report?.mode}`);
			out.clearLine(1);
		}
	};
}

/**
 * Call this through `array.map`.
 *
 * @private
 * @this {module:pete/lib/createReport~Report}
 * @param {string}   columnName
 * @param {number}   index
 * @param {string[]} columns
 * @return {Column}
 */
function reportToColumn (columnName, index, columns) {
	var value = this[columnName];
	if (typeof this[columnName] === 'undefined') {
		var m = columnName.match(/^(?<mode>[x×*=]|)(?<name>p\d+(?:[.]\d+)?|mean|min|max|stddev)$/);
		if (m && m.groups) {
			value = this.percentiles[m.groups.name] || this[m.groups.name];
			if (m.groups.mode) {
				columnName = m.groups.mode === '=' ? 'bar' : 'loss';
			}
			else {
				columnName = m.groups.name;
			}
		}
	}

	var c = COLUMNS[columnName] || COLUMNS[typeof value];

	if (!c) {
		return null;
	}

	var result = Object.create(c).init(this, value);

	if (result.best && !isNaN(result.score) && result.score !== null) {
		columns.best = columns.best || [];
		columns.best[index] = result.best(typeof columns.best[index] === 'undefined' ? result.score : columns.best[index], result.score);
	}

	if (result.worst && !isNaN(result.score) && result.score !== null) {
		columns.worst = columns.worst || [];
		columns.worst[index] = result.worst(typeof columns.worst[index] === 'undefined' ? result.score : columns.worst[index], result.score);
	}

	if (!result.wholeRow) {
		columns.widths = columns.widths || [];
		columns.widths[index] = Math.max(columns.widths[index] || 0, result.value.length);

		columns.maxWidth = columns.maxWidth || 0;
		columns.maxWidth = Math.max(columns.maxWidth, result.value.length);
	}

	return result;
}

/**
 * Call this through `array.map`.
 *
 * @private
 * @this {string[]}
 * @param {module:pete/lib/createReport~Report} report
 * @return {Column[]}
 */
function getResult (report) {
	var columns = this.map(reportToColumn, report);
	if (report.error) {
		columns.error = report.error;
	}

	this.sumWidth = Math.max(this.sumWidth || 0, columns.reduce((sum, col, i) => {
		if (col.wholeRow) {
			columns.bar = i;
			return sum;
		}

		return sum
			+ col.left.length
			+ col.pre.value.length
			+ this.widths[i]
			+ col.post.value.length
			+ col.right.length;
	}, 0));

	return columns;
}

/**
 * Turn reports into rows of columns of data.
 *
 * @private
 * @param {module:pete/lib/createReport~Report[]} reports
 * @param {string[]}                              columnNames
 * @return {Array<Column[]>}
 */
function getResults (reports, columnNames) {
	return reports.map(getResult, columnNames);
}

/**
 * Turn column data into a "header" value for results table.
 *
 * @private
 * @param {Column}   col
 * @param {object[]} cols
 * @param {string}   pad
 * @param {number}   index
 * @return {string}
 */
function columnToHeader (col, cols, pad, index) {
	if (col.wholeRow) {
		return '';
	}

	var result = col.left;

	var text = cols[index];

	var maxWidth = col.pre.value.length + cols.widths[index] + col.post.value.length;
	if (maxWidth < 1) {
		text = '';
	}

	if (text.length > maxWidth) {
		text = maxWidth < 1 ? '' : text.substring(0, maxWidth);
	}

	if (col.align === 'left') {
		result += text;
		result += pad.substring(0, maxWidth - text.length);
	}
	else {
		result += pad.substring(0, Math.ceil((maxWidth * HALF) - (text.length * HALF)));
		result += text;
		result += pad.substring(0, Math.floor((maxWidth * HALF) - (text.length * HALF)));
	}

	result += col.right;

	return result;
}

/**
 * Turn column data into a "cell" value for results table.
 *
 * @private
 * @param {object}   score
 * @param {object[]} cols
 * @param {string}   pad
 * @return {string}
 */
function scoreToRow (score, cols, pad) {
	const c = cols.c;
	var errorShown = false;

	return score.reduce((result, col, index) => {
		if (col.wholeRow) {
			return result;
		}

		if (score.error && (!col || !col.showError)) {
			if (!errorShown) {
				result += (col && col.left) || '';
				result += c(score.error, 'fail');
				errorShown = true;
			}
			return result;
		}

		if (col.calc) {
			col.calc(cols.best && cols.best[index], cols.worst && cols.worst[index], cols);
		}

		const color = col.color
			|| (score.error && 'fail')
			|| (typeof col.score === 'undefined' && 'default')
			|| (col.score === cols.best[index] ? 'fast' : '')
			|| (col.score === cols.worst[index] ? 'slow' : '')
			|| 'default';

		result += col.left;
		if (col.pre && col.pre.value) {
			result += c(col.pre.value, col.pre.color === 'inherit' ? color : col.pre.color || 'decor');
		}
		if (col.align === 'right') {
			result += pad.substring(0, cols.widths[index] - col.value.length);
		}
		result += c(col.value, color);
		if (col.post && col.post.value) {
			result += c(col.post.value, col.post.color === 'inherit' ? color : col.post.color || 'decor');
		}
		if (col.align === 'left') {
			result += pad.substring(0, cols.widths[index] - col.value.length);
		}
		result += col.right;

		return result;
	}, '');
}

/**
 * Compare reports and generate table of scores sorted from "best" to "worst".
 *
 * @private
 * @param {module:pete/lib/createReport~Report[]} reports
 * @param {object}                                options
 * @param {boolean}                               useColors
 * @return {string}
 */
function compare (reports, options, useColors = false) {
	var ignore = (options && options.ignore && options.ignore.split(',')) || ['warmup', 'skip'];
	var allowed = reports.filter(report => ignore.indexOf(report.mode) < 0);
	var cols = (options && options.cols && options.cols.split(',')) || ['status', 'name', 'p99', 'max', 'ops', 'samples', '*p99', '=p99'];
	var sortBy = (options && options.sort) || cols[cols.length - 1];

	var scores = getResults(allowed, cols);
	if (scores.length < 1) {
		return '\n  No results found.\n';
	}

	if (options && options.colorize !== undefined) {
		useColors = options.colorize !== 'no' && options.colorize !== 'false';
	}

	if (sortBy) {
		var colIndex = cols.indexOf(sortBy);
		var sort = (COLUMNS[sortBy] || scores[0][colIndex] || '').sort;
		if (sort) {
			scores.sort(sort(colIndex, cols));
		}
	}
	var pad = ' '.repeat(cols.maxWidth);
	var c = useColors ? colorize : text => text;
	var color = 'default';

	var result = '';
	scores[0].forEach((col, index) => {
		result += columnToHeader(col, cols, pad, index);
	});
	result = `  ${c(result, 'decor')}\n  ${c(result.replace(/[^\n]/g, '-'), 'decor')}\n`;

	cols.c = c;
	scores.forEach(score => {
		result += '  ';
		result += scoreToRow(score, cols, pad);
		result += '\n';

		if (typeof score.bar !== 'undefined') {
			const bar = score[score.bar];
			if (bar.calc) {
				bar.calc(cols.best && cols.best[score.bar], cols.worst && cols.worst[score.bar], cols);
			}
			result += '  ';
			result += c(bar.pre.value, bar.pre.color === 'inherit' ? color : bar.pre.color || 'decor');
			result += bar.value;
			result += c(bar.post.value, bar.post.color === 'inherit' ? color : bar.post.color || 'decor');
			result += '\n';
		}
	});

	var outro = '';
	if (reports.length > 0) {
		const firstReport = reports[0];
		const percentiles = Object.keys(firstReport.percentiles).map(key => key.substring(1));
		outro = `\n  Results above are based on values of following percentiles:\n  ${percentiles.join(', ')}.\n`;
	}

	return result + outro;
}

/**
 * Apply colors and styles to the text.
 *
 * @private
 * @param {string}   text
 * @param {string[]} types
 * @return {string}
 */
function colorize (text, ...types) {
	if (!types || types.length < 1) {
		return text;
	}

	const color = types.map(type => COLORS[type]).join('m\u001b[');

	return `\u001b[${color}m${text}\u001b[0m`;
}