rxjs
Version:
Reactive Extensions for modern JavaScript
691 lines (633 loc) • 25.3 kB
text/typescript
import { Observable } from '../Observable';
import { ColdObservable } from './ColdObservable';
import { HotObservable } from './HotObservable';
import { TestMessage } from './TestMessage';
import { SubscriptionLog } from './SubscriptionLog';
import { Subscription } from '../Subscription';
import { VirtualTimeScheduler, VirtualAction } from '../scheduler/VirtualTimeScheduler';
import { ObservableNotification } from '../types';
import { COMPLETE_NOTIFICATION, errorNotification, nextNotification } from '../NotificationFactories';
import { dateTimestampProvider } from '../scheduler/dateTimestampProvider';
import { performanceTimestampProvider } from '../scheduler/performanceTimestampProvider';
import { animationFrameProvider } from '../scheduler/animationFrameProvider';
import type { TimerHandle } from '../scheduler/timerHandle';
import { immediateProvider } from '../scheduler/immediateProvider';
import { intervalProvider } from '../scheduler/intervalProvider';
import { timeoutProvider } from '../scheduler/timeoutProvider';
const defaultMaxFrame: number = 750;
export interface RunHelpers {
cold: typeof TestScheduler.prototype.createColdObservable;
hot: typeof TestScheduler.prototype.createHotObservable;
flush: typeof TestScheduler.prototype.flush;
time: typeof TestScheduler.prototype.createTime;
expectObservable: typeof TestScheduler.prototype.expectObservable;
expectSubscriptions: typeof TestScheduler.prototype.expectSubscriptions;
animate: (marbles: string) => void;
}
interface FlushableTest {
ready: boolean;
actual?: any[];
expected?: any[];
}
export type observableToBeFn = (marbles: string, values?: any, errorValue?: any) => void;
export type subscriptionLogsToBeFn = (marbles: string | string[]) => void;
export class TestScheduler extends VirtualTimeScheduler {
/**
* The number of virtual time units each character in a marble diagram represents. If
* the test scheduler is being used in "run mode", via the `run` method, this is temporarily
* set to `1` for the duration of the `run` block, then set back to whatever value it was.
*/
static frameTimeFactor = 10;
/**
* @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.
*/
public readonly hotObservables: HotObservable<any>[] = [];
/**
* @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.
*/
public readonly coldObservables: ColdObservable<any>[] = [];
/**
* Test meta data to be processed during `flush()`
*/
private flushTests: FlushableTest[] = [];
/**
* Indicates whether the TestScheduler instance is operating in "run mode",
* meaning it's processing a call to `run()`
*/
private runMode = false;
/**
*
* @param assertDeepEqual A function to set up your assertion for your test harness
*/
constructor(public assertDeepEqual: (actual: any, expected: any) => boolean | void) {
super(VirtualAction, defaultMaxFrame);
}
createTime(marbles: string): number {
const indexOf = this.runMode ? marbles.trim().indexOf('|') : marbles.indexOf('|');
if (indexOf === -1) {
throw new Error('marble diagram for time should have a completion marker "|"');
}
return indexOf * TestScheduler.frameTimeFactor;
}
/**
* @param marbles A diagram in the marble DSL. Letters map to keys in `values` if provided.
* @param values Values to use for the letters in `marbles`. If omitted, the letters themselves are used.
* @param error The error to use for the `#` marble (if present).
*/
createColdObservable<T = string>(marbles: string, values?: { [marble: string]: T }, error?: any): ColdObservable<T> {
if (marbles.indexOf('^') !== -1) {
throw new Error('cold observable cannot have subscription offset "^"');
}
if (marbles.indexOf('!') !== -1) {
throw new Error('cold observable cannot have unsubscription marker "!"');
}
const messages = TestScheduler.parseMarbles(marbles, values, error, undefined, this.runMode);
const cold = new ColdObservable<T>(messages, this);
this.coldObservables.push(cold);
return cold;
}
/**
* @param marbles A diagram in the marble DSL. Letters map to keys in `values` if provided.
* @param values Values to use for the letters in `marbles`. If omitted, the letters themselves are used.
* @param error The error to use for the `#` marble (if present).
*/
createHotObservable<T = string>(marbles: string, values?: { [marble: string]: T }, error?: any): HotObservable<T> {
if (marbles.indexOf('!') !== -1) {
throw new Error('hot observable cannot have unsubscription marker "!"');
}
const messages = TestScheduler.parseMarbles(marbles, values, error, undefined, this.runMode);
const subject = new HotObservable<T>(messages, this);
this.hotObservables.push(subject);
return subject;
}
private materializeInnerObservable(observable: Observable<any>, outerFrame: number): TestMessage[] {
const messages: TestMessage[] = [];
observable.subscribe({
next: (value) => {
messages.push({ frame: this.frame - outerFrame, notification: nextNotification(value) });
},
error: (error) => {
messages.push({ frame: this.frame - outerFrame, notification: errorNotification(error) });
},
complete: () => {
messages.push({ frame: this.frame - outerFrame, notification: COMPLETE_NOTIFICATION });
},
});
return messages;
}
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 unsubscriptionFrame = subscriptionParsed.unsubscribedFrame;
let subscription: Subscription;
this.schedule(() => {
subscription = observable.subscribe({
next: (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: (error) => {
actual.push({ frame: this.frame, notification: errorNotification(error) });
},
complete: () => {
actual.push({ frame: this.frame, notification: COMPLETE_NOTIFICATION });
},
});
}, subscriptionFrame);
if (unsubscriptionFrame !== Infinity) {
this.schedule(() => subscription.unsubscribe(), unsubscriptionFrame);
}
this.flushTests.push(flushTest);
const { runMode } = this;
return {
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({
next: (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: (error) => {
flushTest.expected!.push({ frame: this.frame, notification: errorNotification(error) });
},
complete: () => {
flushTest.expected!.push({ frame: this.frame, notification: COMPLETE_NOTIFICATION });
},
});
}, subscriptionFrame);
},
};
}
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;
flushTest.ready = true;
flushTest.expected = marblesArray
.map((marbles) => TestScheduler.parseMarblesAsSubscriptions(marbles, runMode))
.filter((marbles) => marbles.subscribedFrame !== Infinity);
},
};
}
flush() {
const hotObservables = this.hotObservables;
while (hotObservables.length > 0) {
hotObservables.shift()!.setup();
}
super.flush();
this.flushTests = this.flushTests.filter((test) => {
if (test.ready) {
this.assertDeepEqual(test.actual, test.expected);
return false;
}
return true;
});
}
static parseMarblesAsSubscriptions(marbles: string | null, runMode = false): SubscriptionLog {
if (typeof marbles !== 'string') {
return new SubscriptionLog(Infinity);
}
// Spreading the marbles into an array leverages ES2015's support for emoji
// characters when iterating strings.
const characters = [...marbles];
const len = characters.length;
let groupStart = -1;
let subscriptionFrame = Infinity;
let unsubscriptionFrame = Infinity;
let frame = 0;
for (let i = 0; i < len; i++) {
let nextFrame = frame;
const advanceFrameBy = (count: number) => {
nextFrame += count * this.frameTimeFactor;
};
const c = characters[i];
switch (c) {
case ' ':
// Whitespace no longer advances time
if (!runMode) {
advanceFrameBy(1);
}
break;
case '-':
advanceFrameBy(1);
break;
case '(':
groupStart = frame;
advanceFrameBy(1);
break;
case ')':
groupStart = -1;
advanceFrameBy(1);
break;
case '^':
if (subscriptionFrame !== Infinity) {
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.');
}
unsubscriptionFrame = groupStart > -1 ? groupStart : frame;
break;
default:
// time progression syntax
if (runMode && c.match(/^[0-9]$/)) {
// Time progression must be preceded by at least one space
// if it's not at the beginning of the diagram
if (i === 0 || characters[i - 1] === ' ') {
const buffer = characters.slice(i).join('');
const match = buffer.match(/^([0-9]+(?:\.[0-9]+)?)(ms|s|m) /);
if (match) {
i += match[0].length - 1;
const duration = parseFloat(match[1]);
const unit = match[2];
let durationInMs: number;
switch (unit) {
case 'ms':
durationInMs = duration;
break;
case 's':
durationInMs = duration * 1000;
break;
case 'm':
durationInMs = duration * 1000 * 60;
break;
default:
break;
}
advanceFrameBy(durationInMs! / this.frameTimeFactor);
break;
}
}
}
throw new Error("there can only be '^' and '!' markers in a " + "subscription marble diagram. Found instead '" + c + "'.");
}
frame = nextFrame;
}
if (unsubscriptionFrame < 0) {
return new SubscriptionLog(subscriptionFrame);
} else {
return new SubscriptionLog(subscriptionFrame, unsubscriptionFrame);
}
}
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 "!"');
}
// 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 groupStart = -1;
for (let i = 0; i < len; i++) {
let nextFrame = frame;
const advanceFrameBy = (count: number) => {
nextFrame += count * this.frameTimeFactor;
};
let notification: ObservableNotification<any> | undefined;
const c = characters[i];
switch (c) {
case ' ':
// Whitespace no longer advances time
if (!runMode) {
advanceFrameBy(1);
}
break;
case '-':
advanceFrameBy(1);
break;
case '(':
groupStart = frame;
advanceFrameBy(1);
break;
case ')':
groupStart = -1;
advanceFrameBy(1);
break;
case '|':
notification = COMPLETE_NOTIFICATION;
advanceFrameBy(1);
break;
case '^':
advanceFrameBy(1);
break;
case '#':
notification = errorNotification(errorValue || 'error');
advanceFrameBy(1);
break;
default:
// Might be time progression syntax, or a value literal
if (runMode && c.match(/^[0-9]$/)) {
// Time progression must be preceded by at least one space
// if it's not at the beginning of the diagram
if (i === 0 || characters[i - 1] === ' ') {
const buffer = characters.slice(i).join('');
const match = buffer.match(/^([0-9]+(?:\.[0-9]+)?)(ms|s|m) /);
if (match) {
i += match[0].length - 1;
const duration = parseFloat(match[1]);
const unit = match[2];
let durationInMs: number;
switch (unit) {
case 'ms':
durationInMs = duration;
break;
case 's':
durationInMs = duration * 1000;
break;
case 'm':
durationInMs = duration * 1000 * 60;
break;
default:
break;
}
advanceFrameBy(durationInMs! / this.frameTimeFactor);
break;
}
}
}
notification = nextNotification(getValue(c));
advanceFrameBy(1);
break;
}
if (notification) {
testMessages.push({ frame: groupStart > -1 ? groupStart : frame, notification });
}
frame = nextFrame;
}
return testMessages;
}
private createAnimator() {
if (!this.runMode) {
throw new Error('animate() must only be used in run mode');
}
// The TestScheduler assigns a delegate to the provider that's used for
// requestAnimationFrame (rAF). The delegate works in conjunction with the
// animate run helper to coordinate the invocation of any rAF callbacks,
// that are effected within tests, with the animation frames specified by
// the test's author - in the marbles that are passed to the animate run
// helper. This allows the test's author to write deterministic tests and
// gives the author full control over when - or if - animation frames are
// 'painted'.
let lastHandle = 0;
let map: Map<number, FrameRequestCallback> | undefined;
const delegate = {
requestAnimationFrame(callback: FrameRequestCallback) {
if (!map) {
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()');
}
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');
}
map = new Map<number, FrameRequestCallback>();
const messages = TestScheduler.parseMarbles(marbles, undefined, undefined, undefined, true);
for (const message of messages) {
this.schedule(() => {
const now = this.now();
// Capture the callbacks within the queue and clear the queue
// before enumerating the callbacks, as callbacks might
// reschedule themselves. (And, yeah, we're using a Map to represent
// the queue, but the values are guaranteed to be returned in
// insertion order, so it's all good. Trust me, I've read the docs.)
const callbacks = Array.from(map!.values());
map!.clear();
for (const callback of callbacks) {
callback(now);
}
}, message.frame);
}
};
return { animate, delegate };
}
private createDelegates() {
// When in run mode, the TestScheduler provides alternate implementations
// of set/clearImmediate and set/clearInterval. These implementations are
// consumed by the scheduler implementations via the providers. This is
// done to effect deterministic asap and async scheduler behavior so that
// all of the schedulers are testable in 'run mode'. Prior to v7,
// delegation occurred at the scheduler level. That is, the asap and
// animation frame schedulers were identical in behavior to the async
// scheduler. Now, when in run mode, asap actions are prioritized over
// async actions and animation frame actions are coordinated using the
// animate run helper.
let lastHandle = 0;
const scheduleLookup = new Map<
TimerHandle,
{
due: number;
duration: number;
handle: TimerHandle;
handler: () => void;
subscription: Subscription;
type: 'immediate' | 'interval' | 'timeout';
}
>();
const run = () => {
// Whenever a scheduled run is executed, it must run a single immediate
// or interval action - with immediate actions being prioritized over
// interval and timeout actions.
const now = this.now();
const scheduledRecords = Array.from(scheduleLookup.values());
const scheduledRecordsDue = scheduledRecords.filter(({ due }) => due <= now);
const dueImmediates = scheduledRecordsDue.filter(({ type }) => type === 'immediate');
if (dueImmediates.length > 0) {
const { handle, handler } = dueImmediates[0];
scheduleLookup.delete(handle);
handler();
return;
}
const dueIntervals = scheduledRecordsDue.filter(({ type }) => type === 'interval');
if (dueIntervals.length > 0) {
const firstDueInterval = dueIntervals[0];
const { duration, handler } = firstDueInterval;
firstDueInterval.due = now + duration;
// The interval delegate must behave like setInterval, so run needs to
// be rescheduled. This will continue until the clearInterval delegate
// unsubscribes and deletes the handle from the map.
firstDueInterval.subscription = this.schedule(run, duration);
handler();
return;
}
const dueTimeouts = scheduledRecordsDue.filter(({ type }) => type === 'timeout');
if (dueTimeouts.length > 0) {
const { handle, handler } = dueTimeouts[0];
scheduleLookup.delete(handle);
handler();
return;
}
throw new Error('Expected a due immediate or interval');
};
// The following objects are the delegates that replace conventional
// runtime implementations with TestScheduler implementations.
//
// The immediate delegate is depended upon by the asapScheduler.
//
// The interval delegate is depended upon by the asyncScheduler.
//
// The timeout delegate is not depended upon by any scheduler, but it's
// included here because the onUnhandledError and onStoppedNotification
// configuration points use setTimeout to avoid producer interference. It's
// inclusion allows for the testing of these configuration points.
const immediate = {
setImmediate: (handler: () => void) => {
const handle = ++lastHandle;
scheduleLookup.set(handle, {
due: this.now(),
duration: 0,
handle,
handler,
subscription: this.schedule(run, 0),
type: 'immediate',
});
return handle;
},
clearImmediate: (handle: TimerHandle) => {
const value = scheduleLookup.get(handle);
if (value) {
value.subscription.unsubscribe();
scheduleLookup.delete(handle);
}
},
};
const interval = {
setInterval: (handler: () => void, duration = 0) => {
const handle = ++lastHandle;
scheduleLookup.set(handle, {
due: this.now() + duration,
duration,
handle,
handler,
subscription: this.schedule(run, duration),
type: 'interval',
});
return handle;
},
clearInterval: (handle: TimerHandle) => {
const value = scheduleLookup.get(handle);
if (value) {
value.subscription.unsubscribe();
scheduleLookup.delete(handle);
}
},
};
const timeout = {
setTimeout: (handler: () => void, duration = 0) => {
const handle = ++lastHandle;
scheduleLookup.set(handle, {
due: this.now() + duration,
duration,
handle,
handler,
subscription: this.schedule(run, duration),
type: 'timeout',
});
return handle;
},
clearTimeout: (handle: TimerHandle) => {
const value = scheduleLookup.get(handle);
if (value) {
value.subscription.unsubscribe();
scheduleLookup.delete(handle);
}
},
};
return { immediate, interval, timeout };
}
/**
* The `run` method performs the test in 'run mode' - in which schedulers
* used within the test automatically delegate to the `TestScheduler`. That
* is, in 'run mode' there is no need to explicitly pass a `TestScheduler`
* instance to observable creators or operators.
*
* @see {@link /guide/testing/marble-testing}
*/
run<T>(callback: (helpers: RunHelpers) => T): T {
const prevFrameTimeFactor = TestScheduler.frameTimeFactor;
const prevMaxFrames = this.maxFrames;
TestScheduler.frameTimeFactor = 1;
this.maxFrames = Infinity;
this.runMode = true;
const animator = this.createAnimator();
const delegates = this.createDelegates();
animationFrameProvider.delegate = animator.delegate;
dateTimestampProvider.delegate = this;
immediateProvider.delegate = delegates.immediate;
intervalProvider.delegate = delegates.interval;
timeoutProvider.delegate = delegates.timeout;
performanceTimestampProvider.delegate = this;
const helpers: RunHelpers = {
cold: this.createColdObservable.bind(this),
hot: this.createHotObservable.bind(this),
flush: this.flush.bind(this),
time: this.createTime.bind(this),
expectObservable: this.expectObservable.bind(this),
expectSubscriptions: this.expectSubscriptions.bind(this),
animate: animator.animate,
};
try {
const ret = callback(helpers);
this.flush();
return ret;
} finally {
TestScheduler.frameTimeFactor = prevFrameTimeFactor;
this.maxFrames = prevMaxFrames;
this.runMode = false;
animationFrameProvider.delegate = undefined;
dateTimestampProvider.delegate = undefined;
immediateProvider.delegate = undefined;
intervalProvider.delegate = undefined;
timeoutProvider.delegate = undefined;
performanceTimestampProvider.delegate = undefined;
}
}
}