time/src/assert-equal.ts
import xs, {Stream} from 'xstream';
import {deepEqual} from 'assert';
import * as variableDiff from 'variable-diff';
function checkEqual(
completeStore: any,
assert: any,
interval: number,
comparator: any
) {
const usingCustomComparator = comparator !== deepEqual;
const failReasons: Array<any> = [];
if (completeStore.actual.length !== completeStore.expected.length) {
failReasons.push(`Length of actual and expected differs`);
}
completeStore.actual.forEach((actual: any, index: number) => {
const expected = completeStore.expected[index];
if (actual === undefined) {
failReasons.push(`Actual at index ${index} was undefined`);
return;
}
if (expected === undefined) {
failReasons.push(`Expected at index ${index} was undefined`);
return;
}
if (actual.type !== expected.type) {
failReasons.push(
`Expected type ${expected.type} at time ${actual.time} but got ${
actual.type
}`
);
}
if (actual.type === 'complete') {
const rightTime =
diagramFrame(actual.time, interval) ===
diagramFrame(expected.time, interval);
if (!rightTime) {
failReasons.push(
`Expected stream to complete at ${expected.time} but completed at ${
actual.time
}`
);
}
}
if (actual.type === 'next') {
const rightTime =
diagramFrame(actual.time, interval) ===
diagramFrame(expected.time, interval);
let rightValue = true;
try {
const comparatorResult = comparator(actual.value, expected.value);
if (typeof comparatorResult === 'boolean') {
rightValue = comparatorResult;
}
} catch (error) {
rightValue = false;
assert.unexpectedErrors.push(error);
}
if (rightValue && !rightTime) {
failReasons.push(
`Right value at wrong time, expected at ${expected.time} but ` +
`happened at ${actual.time} (${JSON.stringify(actual.value)})`
);
}
if (!rightTime || !rightValue) {
const errorMessage = [
`Expected value at time ${expected.time} but got different value at ${
actual.time
}\n`,
];
if (usingCustomComparator) {
const message = `Expected ${JSON.stringify(
expected.value
)}, got ${JSON.stringify(actual.value)}`;
errorMessage.push(message);
} else {
const diffMessage = [
`Diff (actual => expected):`,
variableDiff(actual.value, expected.value).text,
].join('\n');
errorMessage.push(diffMessage);
}
failReasons.push(errorMessage.join('\n'));
}
}
if (actual.type === 'error') {
const rightTime =
diagramFrame(actual.time, interval) ===
diagramFrame(expected.time, interval);
let pass = true;
if (expected.type !== 'error') {
pass = false;
}
if (!rightTime) {
pass = false;
}
if (!pass) {
failReasons.push(`Unexpected error occurred`);
assert.unexpectedErrors.push(actual.error);
}
}
});
if (failReasons.length === 0) {
assert.state = 'passed';
} else {
assert.state = 'failed';
assert.error = new Error(
strip(`
Expected
${diagramString(completeStore.expected, interval)}
Got
${diagramString(completeStore.actual, interval)}
Failed because:
${failReasons.map(reason => ` * ${reason}`).join('\n')}
${displayUnexpectedErrors(assert.unexpectedErrors)}
`)
);
}
}
function makeAssertEqual(
timeSource: any,
schedule: any,
currentTime: () => number,
interval: number,
addAssert: any
) {
return function assertEqual(
actual: Stream<any>,
expected: Stream<any>,
comparator = deepEqual
) {
const completeStore: any = {};
const Time = timeSource();
const assert = {
state: 'pending',
error: null,
unexpectedErrors: [],
finish: () => {
checkEqual(completeStore, assert, interval, comparator);
},
};
addAssert(assert);
const actualLog$ = Time.record(actual);
const expectedLog$ = Time.record(expected);
xs.combine(
xs.fromObservable(actualLog$),
xs.fromObservable(expectedLog$)
).addListener({
next([aLog, bLog]) {
completeStore.actual = aLog;
completeStore.expected = bLog;
},
complete() {
checkEqual(completeStore, assert, interval, comparator);
},
});
};
}
function fill<T>(array: Array<T>, value: T) {
let i = 0;
while (i < array.length) {
array[i] = value;
i++;
}
return array;
}
function diagramFrame(time: number, interval: number): number {
return Math.ceil(time / interval);
}
function chunkBy(values: Array<any>, f: any) {
function chunkItGood({items, previousValue}: any, value: any) {
const v = f(value);
if (v !== previousValue) {
return {
items: [...items, [value]],
previousValue: v,
};
}
const lastItem = items[items.length - 1];
return {
items: items.slice(0, -1).concat([lastItem.concat(value)]),
previousValue,
};
}
return values.reduce(chunkItGood, {items: [], previousValue: undefined})
.items;
}
function characterString(entry: any) {
if (entry.type === 'next') {
return stringify(entry.value);
}
if (entry.type === 'complete') {
return '|';
}
if (entry.type === 'error') {
return '#';
}
}
function diagramString(entries: Array<any>, interval: number): string {
if (entries.length === 0) {
return '<empty stream>';
}
const maxTime = Math.max(...entries.map(entry => entry.time));
const characterCount = Math.ceil(maxTime / interval);
const diagram = fill(new Array(characterCount), '-');
const chunks = chunkBy(entries, (entry: any) =>
Math.max(0, Math.floor(entry.time / interval))
);
chunks.forEach((chunk: any) => {
const characterIndex = Math.max(0, Math.floor(chunk[0].time / interval));
if (chunk.length === 1) {
diagram[characterIndex] = characterString(chunk[0]);
} else {
diagram[characterIndex] = ['(', ...chunk.map(characterString), ')'].join(
''
);
}
});
return diagram.join('');
}
function strip(str: string): string {
const lines = str.split('\n');
return lines.map(line => line.replace(/^\s{12}/, '')).join('\n');
}
function stringify(value: any): string {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
function displayUnexpectedErrors(errors: Array<any>) {
if (errors.length === 0) {
return ``;
}
const messages = errors.map(error => error.stack).join('\n \n ');
return `Unexpected error:\n ${messages}`;
}
export {makeAssertEqual};