diff --git a/spec/schedulers/TestScheduler-spec.ts b/spec/schedulers/TestScheduler-spec.ts index 287fd0fb03..848255f358 100644 --- a/spec/schedulers/TestScheduler-spec.ts +++ b/spec/schedulers/TestScheduler-spec.ts @@ -1,8 +1,8 @@ import { expect } from 'chai'; import { hot, cold, expectObservable, expectSubscriptions, time } from '../helpers/marble-testing'; import { TestScheduler } from 'rxjs/testing'; -import { Observable, NEVER, EMPTY, Subject, of, merge, Notification } from 'rxjs'; -import { delay, debounceTime } from 'rxjs/operators'; +import { Observable, NEVER, EMPTY, Subject, of, concat, merge, Notification } from 'rxjs'; +import { delay, debounceTime, concatMap } from 'rxjs/operators'; declare const rxTestScheduler: TestScheduler; @@ -67,6 +67,28 @@ describe('TestScheduler', () => { { frame: 30, notification: Notification.createNext('c') } ]); }); + + it('should ignore whitespace when runMode=true', () => { + const runMode = true; + const result = TestScheduler.parseMarbles(' -a - b - c | ', { a: 'A', b: 'B', c: 'C' }, undefined, undefined, runMode); + expect(result).deep.equal([ + { frame: 10, notification: Notification.createNext('A') }, + { frame: 30, notification: Notification.createNext('B') }, + { frame: 50, notification: Notification.createNext('C') }, + { frame: 60, notification: Notification.createComplete() } + ]); + }); + + it('should suppport time progression syntax when runMode=true', () => { + const runMode = true; + const result = TestScheduler.parseMarbles('10.2ms a 1.2s b 1m c|', { a: 'A', b: 'B', c: 'C' }, undefined, undefined, runMode); + expect(result).deep.equal([ + { frame: 10.2, notification: Notification.createNext('A') }, + { frame: 10.2 + 10 + (1.2 * 1000), notification: Notification.createNext('B') }, + { frame: 10.2 + 10 + (1.2 * 1000) + 10 + (1000 * 60), notification: Notification.createNext('C') }, + { frame: 10.2 + 10 + (1.2 * 1000) + 10 + (1000 * 60) + 10, notification: Notification.createComplete() } + ]); + }); }); describe('parseMarblesAsSubscriptions()', () => { @@ -87,6 +109,20 @@ describe('TestScheduler', () => { expect(result.subscribedFrame).to.equal(30); expect(result.unsubscribedFrame).to.equal(30); }); + + it('should ignore whitespace when runMode=true', () => { + const runMode = true; + const result = TestScheduler.parseMarblesAsSubscriptions(' - - - - ^ - - ! -- - ', runMode); + expect(result.subscribedFrame).to.equal(40); + expect(result.unsubscribedFrame).to.equal(70); + }); + + it('should suppport time progression syntax when runMode=true', () => { + const runMode = true; + const result = TestScheduler.parseMarblesAsSubscriptions('10.2ms ^ 1.2s - 1m !', runMode); + expect(result.subscribedFrame).to.equal(10.2); + expect(result.unsubscribedFrame).to.equal(10.2 + 10 + (1.2 * 1000) + 10 + (1000 * 60)); + }); }); describe('createTime()', () => { @@ -262,6 +298,36 @@ describe('TestScheduler', () => { expect(actual).deep.equal(expected); }; + describe('marble diagrams', () => { + it('should ignore whitespace', () => { + const testScheduler = new TestScheduler(assertDeepEquals); + + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + const input = cold(' -a - b - c | '); + const output = input.pipe( + concatMap(d => of(d).pipe( + delay(10) + )) + ); + const expected = ' -- 9ms a 9ms b 9ms (c|) '; + + expectObservable(output).toBe(expected); + expectSubscriptions(input.subscriptions).toBe(' ^- - - - --------------------------!'); + }); + }); + + it('should support time progression syntax', () => { + const testScheduler = new TestScheduler(assertDeepEquals); + + testScheduler.run(({ cold, hot, flush, expectObservable, expectSubscriptions }) => { + const output = cold('10.2ms a 1.2s b 1m c|'); + const expected = ' 10.2ms a 1.2s b 1m c|'; + + expectObservable(output).toBe(expected); + }); + }); + }); + it('should provide the correct helpers', () => { const testScheduler = new TestScheduler(assertDeepEquals); @@ -273,9 +339,9 @@ describe('TestScheduler', () => { expect(expectSubscriptions).to.be.a('function'); const obs1 = cold('-a-c-e|'); - const obs2 = hot('-^-b-d-f|'); + const obs2 = hot(' ^-b-d-f|'); const output = merge(obs1, obs2); - const expected = '-abcdef|'; + const expected = ' -abcdef|'; expectObservable(output).toBe(expected); expectSubscriptions(obs1.subscriptions).toBe('^-----!'); @@ -283,12 +349,38 @@ describe('TestScheduler', () => { }); }); + it('should have each frame represent a single virtual millisecond', () => { + const testScheduler = new TestScheduler(assertDeepEquals); + + testScheduler.run(({ cold, expectObservable }) => { + const output = cold('-a-b-c--------|').pipe( + debounceTime(5) + ); + const expected = ' ------ 4ms c---|'; + expectObservable(output).toBe(expected); + }); + }); + + it('should have no maximum frame count', () => { + const testScheduler = new TestScheduler(assertDeepEquals); + + testScheduler.run(({ cold, expectObservable }) => { + const output = cold('-a|').pipe( + delay(1000 * 10) + ); + const expected = ' - 10s a|'; + expectObservable(output).toBe(expected); + }); + }); + it('should make operators that use AsyncScheduler automatically use TestScheduler for actual scheduling', () => { const testScheduler = new TestScheduler(assertDeepEquals); testScheduler.run(({ cold, expectObservable }) => { - const output = cold('-a-b-(c|)').pipe(debounceTime(20), delay(10)); - const expected = '------(c|)'; + const output = cold('-a-b-c--------|').pipe( + debounceTime(5) + ); + const expected = ' ----------c---|'; expectObservable(output).toBe(expected); }); }); @@ -298,8 +390,12 @@ describe('TestScheduler', () => { expect(actual).deep.equal(expected); }); testScheduler.run(({ cold, expectObservable }) => { - const output = cold('-a-b-(c|)').pipe(debounceTime(20), delay(10)); - const expected = '------(c|)'; + const output = cold('-a-b-c|').pipe( + concatMap(d => of(d).pipe( + delay(10) + )) + ); + const expected = ' -- 9ms a 9ms b 9ms (c|)'; expectObservable(output).toBe(expected); expect(testScheduler['flushTests'].length).to.equal(1); @@ -314,8 +410,12 @@ describe('TestScheduler', () => { const testScheduler = new TestScheduler(assertDeepEquals); testScheduler.run(({ cold, expectObservable, flush }) => { - const output = cold('-a-b-(c|)').pipe(debounceTime(20), delay(10)); - const expected = '------(c|)'; + const output = cold('-a-b-c|').pipe( + concatMap(d => of(d).pipe( + delay(10) + )) + ); + const expected = ' -- 9ms a 9ms b 9ms (c|)'; expectObservable(output).toBe(expected); expect(testScheduler['flushTests'].length).to.equal(1); diff --git a/src/internal/scheduler/VirtualTimeScheduler.ts b/src/internal/scheduler/VirtualTimeScheduler.ts index 2fa78de788..a542ec11bd 100644 --- a/src/internal/scheduler/VirtualTimeScheduler.ts +++ b/src/internal/scheduler/VirtualTimeScheduler.ts @@ -38,10 +38,6 @@ export class VirtualTimeScheduler extends AsyncScheduler { throw error; } } - - public schedule(work: (this: SchedulerAction, state?: T) => void, delay: number = 0, state?: T): Subscription { - return new VirtualAction(this, work).schedule(state, delay); - } } /** diff --git a/src/internal/testing/TestScheduler.ts b/src/internal/testing/TestScheduler.ts index 318da3370b..329b3f3319 100644 --- a/src/internal/testing/TestScheduler.ts +++ b/src/internal/testing/TestScheduler.ts @@ -10,7 +10,7 @@ import { AsyncScheduler } from '../scheduler/AsyncScheduler'; const defaultMaxFrame: number = 750; -interface RunHelpers { +export interface RunHelpers { cold: typeof TestScheduler.prototype.createColdObservable; hot: typeof TestScheduler.prototype.createHotObservable; flush: typeof TestScheduler.prototype.flush; @@ -31,6 +31,7 @@ export class TestScheduler extends VirtualTimeScheduler { public readonly hotObservables: HotObservable[] = []; public readonly coldObservables: ColdObservable[] = []; private flushTests: FlushableTest[] = []; + private runMode = false; constructor(public assertDeepEqual: (actual: any, expected: any) => boolean | void) { super(VirtualAction, defaultMaxFrame); @@ -51,7 +52,7 @@ export class TestScheduler extends VirtualTimeScheduler { if (marbles.indexOf('!') !== -1) { throw new Error('cold observable cannot have unsubscription marker "!"'); } - const messages = TestScheduler.parseMarbles(marbles, values, error); + const messages = TestScheduler.parseMarbles(marbles, values, error, undefined, this.runMode); const cold = new ColdObservable(messages, this); this.coldObservables.push(cold); return cold; @@ -61,7 +62,7 @@ export class TestScheduler extends VirtualTimeScheduler { if (marbles.indexOf('!') !== -1) { throw new Error('hot observable cannot have unsubscription marker "!"'); } - const messages = TestScheduler.parseMarbles(marbles, values, error); + const messages = TestScheduler.parseMarbles(marbles, values, error, undefined, this.runMode); const subject = new HotObservable(messages, this); this.hotObservables.push(subject); return subject; @@ -85,7 +86,7 @@ export class TestScheduler extends VirtualTimeScheduler { const actual: TestMessage[] = []; const flushTest: FlushableTest = { actual, ready: false }; const unsubscriptionFrame = TestScheduler - .parseMarblesAsSubscriptions(unsubscriptionMarbles).unsubscribedFrame; + .parseMarblesAsSubscriptions(unsubscriptionMarbles, this.runMode).unsubscribedFrame; let subscription: Subscription; this.schedule(() => { @@ -108,11 +109,12 @@ export class TestScheduler extends VirtualTimeScheduler { } 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); + flushTest.expected = TestScheduler.parseMarbles(marbles, values, errorValue, true, runMode); } }; } @@ -120,12 +122,13 @@ export class TestScheduler extends VirtualTimeScheduler { expectSubscriptions(actualSubscriptionLogs: SubscriptionLog[]): ({ toBe: subscriptionLogsToBeFn }) { const flushTest: FlushableTest = { actual: actualSubscriptionLogs, ready: false }; this.flushTests.push(flushTest); + const { runMode } = this; return { toBe(marbles: string | string[]) { const marblesArray: string[] = (typeof marbles === 'string') ? [marbles] : marbles; flushTest.ready = true; flushTest.expected = marblesArray.map(marbles => - TestScheduler.parseMarblesAsSubscriptions(marbles) + TestScheduler.parseMarblesAsSubscriptions(marbles, runMode) ); } }; @@ -152,7 +155,7 @@ export class TestScheduler extends VirtualTimeScheduler { } /** @nocollapse */ - static parseMarblesAsSubscriptions(marbles: string): SubscriptionLog { + static parseMarblesAsSubscriptions(marbles: string, runMode = false): SubscriptionLog { if (typeof marbles !== 'string') { return new SubscriptionLog(Number.POSITIVE_INFINITY); } @@ -160,19 +163,31 @@ export class TestScheduler extends VirtualTimeScheduler { let groupStart = -1; let subscriptionFrame = Number.POSITIVE_INFINITY; let unsubscriptionFrame = Number.POSITIVE_INFINITY; + let frame = 0; for (let i = 0; i < len; i++) { - const frame = i * this.frameTimeFactor; + let nextFrame = frame; + const advanceFrameBy = (count: number) => { + nextFrame += count * this.frameTimeFactor; + }; const c = marbles[i]; switch (c) { - case '-': 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 !== Number.POSITIVE_INFINITY) { @@ -180,6 +195,7 @@ export class TestScheduler extends VirtualTimeScheduler { 'subscription marble diagram. There can only be one.'); } subscriptionFrame = groupStart > -1 ? groupStart : frame; + advanceFrameBy(1); break; case '!': if (unsubscriptionFrame !== Number.POSITIVE_INFINITY) { @@ -189,9 +205,44 @@ export class TestScheduler extends VirtualTimeScheduler { unsubscriptionFrame = groupStart > -1 ? groupStart : frame; break; default: + // time progression syntax + if (runMode && c.match(/^[0-9]$/)) { + // Time progression must be preceeded by at least one space + // if it's not at the beginning of the diagram + if (i === 0 || marbles[i - 1] === ' ') { + const buffer = marbles.slice(i); + 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) { @@ -205,15 +256,16 @@ export class TestScheduler extends VirtualTimeScheduler { static parseMarbles(marbles: string, values?: any, errorValue?: any, - materializeInnerObservables: boolean = false): TestMessage[] { + materializeInnerObservables: boolean = false, + runMode = false): TestMessage[] { if (marbles.indexOf('!') !== -1) { throw new Error('conventional marble diagrams cannot have the ' + 'unsubscription marker "!"'); } const len = marbles.length; const testMessages: TestMessage[] = []; - const subIndex = marbles.indexOf('^'); - const frameOffset = subIndex === -1 ? 0 : (subIndex * -this.frameTimeFactor); + 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) => { @@ -226,41 +278,99 @@ export class TestScheduler extends VirtualTimeScheduler { let groupStart = -1; for (let i = 0; i < len; i++) { - const frame = i * this.frameTimeFactor + frameOffset; + let nextFrame = frame; + const advanceFrameBy = (count: number) => { + nextFrame += count * this.frameTimeFactor; + }; + let notification: Notification; const c = marbles[i]; switch (c) { - case '-': 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 = Notification.createComplete(); + advanceFrameBy(1); break; case '^': + advanceFrameBy(1); break; case '#': notification = Notification.createError(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 preceeded by at least one space + // if it's not at the beginning of the diagram + if (i === 0 || marbles[i - 1] === ' ') { + const buffer = marbles.slice(i); + 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 = Notification.createNext(getValue(c)); + advanceFrameBy(1); break; } if (notification) { testMessages.push({ frame: groupStart > -1 ? groupStart : frame, notification }); } + + frame = nextFrame; } return testMessages; } run(callback: (helpers: RunHelpers) => T): T { + const prevFrameTimeFactor = TestScheduler.frameTimeFactor; + const prevMaxFrames = this.maxFrames; + + TestScheduler.frameTimeFactor = 1; + this.maxFrames = Number.POSITIVE_INFINITY; + this.runMode = true; AsyncScheduler.delegate = this; + const helpers = { cold: this.createColdObservable.bind(this), hot: this.createHotObservable.bind(this), @@ -270,6 +380,10 @@ export class TestScheduler extends VirtualTimeScheduler { }; const ret = callback(helpers); this.flush(); + + TestScheduler.frameTimeFactor = prevFrameTimeFactor; + this.maxFrames = prevMaxFrames; + this.runMode = false; AsyncScheduler.delegate = undefined; return ret;