diff options
Diffstat (limited to 'node_modules/rxjs/src/internal/testing/TestScheduler.ts')
| -rw-r--r-- | node_modules/rxjs/src/internal/testing/TestScheduler.ts | 690 |
1 files changed, 690 insertions, 0 deletions
diff --git a/node_modules/rxjs/src/internal/testing/TestScheduler.ts b/node_modules/rxjs/src/internal/testing/TestScheduler.ts new file mode 100644 index 0000000..0045166 --- /dev/null +++ b/node_modules/rxjs/src/internal/testing/TestScheduler.ts @@ -0,0 +1,690 @@ +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; + } + } +} |
