Skip to content

Commit

Permalink
feat(TestScheduler): add expectObservable(a$).toEqual(b$).
Browse files Browse the repository at this point in the history
Adds a feature that allows two observables to be tested for equality of output.
  • Loading branch information
benlesh committed Dec 14, 2020
1 parent facdc52 commit 3372c72
Show file tree
Hide file tree
Showing 2 changed files with 97 additions and 72 deletions.
5 changes: 3 additions & 2 deletions spec/schedulers/TestScheduler-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,9 @@ describe('TestScheduler', () => {
const expected = ' -abcdef|';

expectObservable(output).toBe(expected);
expectSubscriptions(obs1.subscriptions).toBe('^-----!');
expectSubscriptions(obs2.subscriptions).toBe('^------!');
expectObservable(output).toEqual(cold(expected));
expectSubscriptions(obs1.subscriptions).toBe(['^-----!', '^-----!']);
expectSubscriptions(obs2.subscriptions).toBe(['^------!', '^------!']);
});
});

Expand Down
164 changes: 94 additions & 70 deletions src/internal/testing/TestScheduler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @prettier */
import { Observable } from '../Observable';
import { ColdObservable } from './ColdObservable';
import { HotObservable } from './HotObservable';
Expand Down Expand Up @@ -114,42 +115,44 @@ export class TestScheduler extends VirtualTimeScheduler {
return subject;
}

private materializeInnerObservable(observable: Observable<any>,
outerFrame: number): TestMessage[] {
private materializeInnerObservable(observable: Observable<any>, outerFrame: number): TestMessage[] {
const messages: TestMessage[] = [];
observable.subscribe((value) => {
messages.push({ frame: this.frame - outerFrame, notification: nextNotification(value) });
}, (error) => {
messages.push({ frame: this.frame - outerFrame, notification: errorNotification(error) });
}, () => {
messages.push({ frame: this.frame - outerFrame, notification: COMPLETE_NOTIFICATION });
});
observable.subscribe(
(value) => {
messages.push({ frame: this.frame - outerFrame, notification: nextNotification(value) });
},
(error) => {
messages.push({ frame: this.frame - outerFrame, notification: errorNotification(error) });
},
() => {
messages.push({ frame: this.frame - outerFrame, notification: COMPLETE_NOTIFICATION });
}
);
return messages;
}

expectObservable(observable: Observable<any>,
subscriptionMarbles: string | null = null): ({ toBe: observableToBeFn }) {
expectObservable<T>(observable: Observable<T>, subscriptionMarbles: string | null = null) {
const actual: TestMessage[] = [];
const flushTest: FlushableTest = { actual, ready: false };
const subscriptionParsed = TestScheduler.parseMarblesAsSubscriptions(subscriptionMarbles, this.runMode);
const subscriptionFrame = subscriptionParsed.subscribedFrame === Infinity ?
0 : subscriptionParsed.subscribedFrame;
const subscriptionFrame = subscriptionParsed.subscribedFrame === Infinity ? 0 : subscriptionParsed.subscribedFrame;
const unsubscriptionFrame = subscriptionParsed.unsubscribedFrame;
let subscription: Subscription;

this.schedule(() => {
subscription = observable.subscribe(x => {
let value = x;
// Support Observable-of-Observables
if (x instanceof Observable) {
value = this.materializeInnerObservable(value, this.frame);
subscription = observable.subscribe(
(x) => {
// Support Observable-of-Observables
const value = x instanceof Observable ? this.materializeInnerObservable(x, this.frame) : x;
actual.push({ frame: this.frame, notification: nextNotification(value) });
},
(error) => {
actual.push({ frame: this.frame, notification: errorNotification(error) });
},
() => {
actual.push({ frame: this.frame, notification: COMPLETE_NOTIFICATION });
}
actual.push({ frame: this.frame, notification: nextNotification(value) });
}, (error) => {
actual.push({ frame: this.frame, notification: errorNotification(error) });
}, () => {
actual.push({ frame: this.frame, notification: COMPLETE_NOTIFICATION });
});
);
}, subscriptionFrame);

if (unsubscriptionFrame !== Infinity) {
Expand All @@ -163,22 +166,41 @@ export class TestScheduler extends VirtualTimeScheduler {
toBe(marbles: string, values?: any, errorValue?: any) {
flushTest.ready = true;
flushTest.expected = TestScheduler.parseMarbles(marbles, values, errorValue, true, runMode);
}
},
toEqual: (other: Observable<T>) => {
flushTest.ready = true;
flushTest.expected = [];
this.schedule(() => {
subscription = other.subscribe(
(x) => {
// Support Observable-of-Observables
const value = x instanceof Observable ? this.materializeInnerObservable(x, this.frame) : x;
flushTest.expected!.push({ frame: this.frame, notification: nextNotification(value) });
},
(error) => {
flushTest.expected!.push({ frame: this.frame, notification: errorNotification(error) });
},
() => {
flushTest.expected!.push({ frame: this.frame, notification: COMPLETE_NOTIFICATION });
}
);
}, subscriptionFrame);
},
};
}

expectSubscriptions(actualSubscriptionLogs: SubscriptionLog[]): ({ toBe: subscriptionLogsToBeFn }) {
expectSubscriptions(actualSubscriptionLogs: SubscriptionLog[]): { toBe: subscriptionLogsToBeFn } {
const flushTest: FlushableTest = { actual: actualSubscriptionLogs, ready: false };
this.flushTests.push(flushTest);
const { runMode } = this;
return {
toBe(marblesOrMarblesArray: string | string[]) {
const marblesArray: string[] = (typeof marblesOrMarblesArray === 'string') ? [marblesOrMarblesArray] : marblesOrMarblesArray;
const marblesArray: string[] = typeof marblesOrMarblesArray === 'string' ? [marblesOrMarblesArray] : marblesOrMarblesArray;
flushTest.ready = true;
flushTest.expected = marblesArray.map(marbles =>
TestScheduler.parseMarblesAsSubscriptions(marbles, runMode)
).filter(marbles => marbles.subscribedFrame !== Infinity);
}
flushTest.expected = marblesArray
.map((marbles) => TestScheduler.parseMarblesAsSubscriptions(marbles, runMode))
.filter((marbles) => marbles.subscribedFrame !== Infinity);
},
};
}

Expand All @@ -190,7 +212,7 @@ export class TestScheduler extends VirtualTimeScheduler {

super.flush();

this.flushTests = this.flushTests.filter(test => {
this.flushTests = this.flushTests.filter((test) => {
if (test.ready) {
this.assertDeepEqual(test.actual, test.expected);
return false;
Expand Down Expand Up @@ -239,16 +261,14 @@ export class TestScheduler extends VirtualTimeScheduler {
break;
case '^':
if (subscriptionFrame !== Infinity) {
throw new Error('found a second subscription point \'^\' in a ' +
'subscription marble diagram. There can only be one.');
throw new Error("found a second subscription point '^' in a " + 'subscription marble diagram. There can only be one.');
}
subscriptionFrame = groupStart > -1 ? groupStart : frame;
advanceFrameBy(1);
break;
case '!':
if (unsubscriptionFrame !== Infinity) {
throw new Error('found a second unsubscription point \'!\' in a ' +
'subscription marble diagram. There can only be one.');
throw new Error("found a second unsubscription point '!' in a " + 'subscription marble diagram. There can only be one.');
}
unsubscriptionFrame = groupStart > -1 ? groupStart : frame;
break;
Expand Down Expand Up @@ -286,8 +306,7 @@ export class TestScheduler extends VirtualTimeScheduler {
}
}

throw new Error('there can only be \'^\' and \'!\' markers in a ' +
'subscription marble diagram. Found instead \'' + c + '\'.');
throw new Error("there can only be '^' and '!' markers in a " + "subscription marble diagram. Found instead '" + c + "'.");
}

frame = nextFrame;
Expand All @@ -301,31 +320,33 @@ export class TestScheduler extends VirtualTimeScheduler {
}

/** @nocollapse */
static parseMarbles(marbles: string,
values?: any,
errorValue?: any,
materializeInnerObservables: boolean = false,
runMode = false): TestMessage[] {
static parseMarbles(
marbles: string,
values?: any,
errorValue?: any,
materializeInnerObservables: boolean = false,
runMode = false
): TestMessage[] {
if (marbles.indexOf('!') !== -1) {
throw new Error('conventional marble diagrams cannot have the ' +
'unsubscription marker "!"');
throw new Error('conventional marble diagrams cannot have the ' + 'unsubscription marker "!"');
}
// Spreading the marbles into an array leverages ES2015's support for emoji
// characters when iterating strings.
const characters = [...marbles];
const len = characters.length;
const testMessages: TestMessage[] = [];
const subIndex = runMode ? marbles.replace(/^[ ]+/, '').indexOf('^') : marbles.indexOf('^');
let frame = subIndex === -1 ? 0 : (subIndex * -this.frameTimeFactor);
const getValue = typeof values !== 'object' ?
(x: any) => x :
(x: any) => {
// Support Observable-of-Observables
if (materializeInnerObservables && values[x] instanceof ColdObservable) {
return values[x].messages;
}
return values[x];
};
let frame = subIndex === -1 ? 0 : subIndex * -this.frameTimeFactor;
const getValue =
typeof values !== 'object'
? (x: any) => x
: (x: any) => {
// Support Observable-of-Observables
if (materializeInnerObservables && values[x] instanceof ColdObservable) {
return values[x].messages;
}
return values[x];
};
let groupStart = -1;

for (let i = 0; i < len; i++) {
Expand Down Expand Up @@ -433,26 +454,26 @@ export class TestScheduler extends VirtualTimeScheduler {
const delegate = {
requestAnimationFrame(callback: FrameRequestCallback) {
if (!map) {
throw new Error("animate() was not called within run()");
throw new Error('animate() was not called within run()');
}
const handle = ++lastHandle;
map.set(handle, callback);
return handle;
},
cancelAnimationFrame(handle: number) {
if (!map) {
throw new Error("animate() was not called within run()");
throw new Error('animate() was not called within run()');
}
map.delete(handle);
}
},
};

const animate = (marbles: string) => {
if (map) {
throw new Error('animate() must not be called more than once within run()');
}
if (/[|#]/.test(marbles)) {
throw new Error('animate() must not complete or error')
throw new Error('animate() must not complete or error');
}
map = new Map<number, FrameRequestCallback>();
const messages = TestScheduler.parseMarbles(marbles, undefined, undefined, undefined, true);
Expand Down Expand Up @@ -489,14 +510,17 @@ export class TestScheduler extends VirtualTimeScheduler {
// animate run helper.

let lastHandle = 0;
const scheduleLookup = new Map<number, {
due: number;
duration: number;
handle: number;
handler: () => void;
subscription: Subscription;
type: 'immediate' | 'interval' | 'timeout';
}>();
const scheduleLookup = new Map<
number,
{
due: number;
duration: number;
handle: number;
handler: () => void;
subscription: Subscription;
type: 'immediate' | 'interval' | 'timeout';
}
>();

const run = () => {
// Whenever a scheduled run is executed, it must run a single immediate
Expand Down Expand Up @@ -565,7 +589,7 @@ export class TestScheduler extends VirtualTimeScheduler {
value.subscription.unsubscribe();
scheduleLookup.delete(handle);
}
}
},
};

const interval = {
Expand All @@ -587,7 +611,7 @@ export class TestScheduler extends VirtualTimeScheduler {
value.subscription.unsubscribe();
scheduleLookup.delete(handle);
}
}
},
};

const timeout = {
Expand All @@ -609,7 +633,7 @@ export class TestScheduler extends VirtualTimeScheduler {
value.subscription.unsubscribe();
scheduleLookup.delete(handle);
}
}
},
};

return { immediate, interval, timeout };
Expand Down

0 comments on commit 3372c72

Please sign in to comment.