diff --git a/README.md b/README.md index 52c4320..52bbfd8 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,33 @@ But got: ["----^------------!"] ``` +## toEmitValueFirst +Verifies that the first emitted value from observable matches expected value. +```js + it('Should verify first emitted value', () => { + const x = cold('--a---b---c--|'); + expect(x).toEmitValueFirst('a'); + }); +``` + +## toEmitValueLast +Verifies that the last emitted value from observable matches expected value. +```js + it('Should verify last emitted value', () => { + const x = cold('--a---b---c--|'); + expect(x).toEmitValueLast('c'); + }); +``` + +## toEmitValueNth +Verifies that the N emitted value from observable matches expected value. +```js + it('Should verify second emitted value', () => { + const x = cold('--a---b---c--|'); + expect(x).toEmitValueNth('b', 1); + }); +``` + ## toSatisfyOnFlush Allows you to assert on certain side effects/conditions that should be satisfied when the observable has been flushed (finished) ```js diff --git a/index.ts b/index.ts index 3cb9d5b..f9361a0 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,8 @@ import { ColdObservable } from './src/rxjs/cold-observable'; import { HotObservable } from './src/rxjs/hot-observable'; import { Scheduler } from './src/rxjs/scheduler'; import { stripAlignmentChars } from './src/rxjs/strip-alignment-chars'; -import { Subscription } from 'rxjs'; +import { Observable, skip, Subscription, take, takeLast } from 'rxjs'; +import { toEmitValue } from './src/to-emit-value'; export type ObservableWithSubscriptions = ColdObservable | HotObservable; @@ -21,6 +22,12 @@ declare global { toBeMarble(marble: string): R; toSatisfyOnFlush(func: () => void): R; + + toEmitValueFirst(value: unknown): R; + + toEmitValueLast(value: unknown): R; + + toEmitValueNth(value: unknown, index: number): R; } } } @@ -32,6 +39,9 @@ declare module 'expect' { toHaveNoSubscriptions(): R; toBeMarble(marble: string): R; toSatisfyOnFlush(func: () => void): R; + toEmitValueFirst(value: unknown): R; + toEmitValueLast(value: unknown): R; + toEmitValueNth(value: unknown, index: number): R; } } @@ -83,21 +93,32 @@ expect.extend({ // tslint:disable:no-string-literal const flushTests = Scheduler.get()['flushTests']; flushTests[flushTests.length - 1].ready = true; - onFlush.push(func); + Scheduler.onFlush(func); return dummyResult; }, -}); -let onFlush: (() => void)[] = []; + toEmitValueFirst(observable: Observable, expectedValue: unknown) { + toEmitValue(observable.pipe(take(1)), expectedValue); + + return dummyResult; + }, + + toEmitValueLast(observable: Observable, expectedValue: unknown) { + toEmitValue(observable.pipe(takeLast(1)), expectedValue); + + return dummyResult; + }, + + toEmitValueNth(observable: Observable, expectedValue: unknown, index: number) { + toEmitValue(observable.pipe(skip(index), take(1)), expectedValue); + + return dummyResult; + }, +}); beforeEach(() => { Scheduler.init(); - onFlush = []; }); afterEach(() => { - Scheduler.get().run(() => {}); - while (onFlush.length > 0) { - onFlush.shift()?.(); - } - Scheduler.reset(); + Scheduler.flush(); }); diff --git a/spec/__snapshots__/to-emit-value-first.spec.ts.snap b/spec/__snapshots__/to-emit-value-first.spec.ts.snap new file mode 100644 index 0000000..89db2e5 --- /dev/null +++ b/spec/__snapshots__/to-emit-value-first.spec.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toEmitValueFirst matcher test negative cases Should fail if no values emitted 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "(a|)" +But got: + "" + +Difference: + +- Expected ++ Received + +- (a|)" +`; + +exports[`toEmitValueFirst matcher test negative cases Should fail if no values emitted and completed 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "---(a|)" +But got: + "---|" + +Difference: + +- Expected ++ Received + +- ---(a|) ++ ---|" +`; + +exports[`toEmitValueFirst matcher test negative cases Should fail if other value emits 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "---(a|)" +But got: + "---(b|)" + +Difference: + +- Expected ++ Received + +- ---(a|) ++ ---(b|)" +`; diff --git a/spec/__snapshots__/to-emit-value-last.spec.ts.snap b/spec/__snapshots__/to-emit-value-last.spec.ts.snap new file mode 100644 index 0000000..333d1b5 --- /dev/null +++ b/spec/__snapshots__/to-emit-value-last.spec.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toEmitValueLast matcher test negative cases Should fail if no values emitted 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "(a|)" +But got: + "" + +Difference: + +- Expected ++ Received + +- (a|)" +`; + +exports[`toEmitValueLast matcher test negative cases Should fail if no values emitted and completed 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "---(a|)" +But got: + "---|" + +Difference: + +- Expected ++ Received + +- ---(a|) ++ ---|" +`; + +exports[`toEmitValueLast matcher test negative cases Should fail if observable never completes 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "(b|)" +But got: + "" + +Difference: + +- Expected ++ Received + +- (b|)" +`; + +exports[`toEmitValueLast matcher test negative cases Should fail if other value emits 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "-------(b|)" +But got: + "-------(a|)" + +Difference: + +- Expected ++ Received + +- -------(b|) ++ -------(a|)" +`; diff --git a/spec/__snapshots__/to-emit-value-nth.spec.ts.snap b/spec/__snapshots__/to-emit-value-nth.spec.ts.snap new file mode 100644 index 0000000..436c722 --- /dev/null +++ b/spec/__snapshots__/to-emit-value-nth.spec.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toEmitValueNth matcher test negative cases Should fail if no values emitted 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "(a|)" +But got: + "" + +Difference: + +- Expected ++ Received + +- (a|)" +`; + +exports[`toEmitValueNth matcher test negative cases Should fail if no values emitted and completed 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "---(a|)" +But got: + "---|" + +Difference: + +- Expected ++ Received + +- ---(a|) ++ ---|" +`; + +exports[`toEmitValueNth matcher test negative cases Should fail if other value emits 1`] = ` +"expect(received).toBeNotifications(expected) + +Expected notifications to be: + "--(a|)" +But got: + "--(b|)" + +Difference: + +- Expected ++ Received + +- --(a|) ++ --(b|)" +`; diff --git a/spec/to-emit-value-first.spec.ts b/spec/to-emit-value-first.spec.ts new file mode 100644 index 0000000..3295396 --- /dev/null +++ b/spec/to-emit-value-first.spec.ts @@ -0,0 +1,54 @@ +import { cold, Scheduler } from '../index'; + +describe('toEmitValueFirst matcher test', () => { + describe('positive cases', () => { + it('Should pass if observable completes', () => { + const a$ = cold('a-|'); + + expect(a$).toEmitValueFirst('a'); + }); + + it('Should pass if observable not completes', () => { + const a$ = cold('a---'); + + expect(a$).toEmitValueFirst('a'); + }); + + it('Should pass if value emits later', () => { + const a$ = cold('---a---'); + + expect(a$).toEmitValueFirst('a'); + }); + + it('Should pass if multiple value emits later', () => { + const a$ = cold('---a--b---'); + + expect(a$).toEmitValueFirst('a'); + }); + }); + + describe('negative cases', () => { + it('Should fail if other value emits', () => { + const a$ = cold('---b---'); + + expect(a$).toEmitValueFirst('a'); + }); + + it('Should fail if no values emitted', () => { + const a$ = cold('---'); + + expect(a$).toEmitValueFirst('a'); + }); + + it('Should fail if no values emitted and completed', () => { + const a$ = cold('---|'); + + expect(a$).toEmitValueFirst('a'); + }); + + afterEach(() => { + expect(() => Scheduler.flush()).toThrowErrorMatchingSnapshot(); + Scheduler.init(); + }); + }); +}); diff --git a/spec/to-emit-value-last.spec.ts b/spec/to-emit-value-last.spec.ts new file mode 100644 index 0000000..aea2472 --- /dev/null +++ b/spec/to-emit-value-last.spec.ts @@ -0,0 +1,54 @@ +import { cold, Scheduler } from '../index'; + +describe('toEmitValueLast matcher test', () => { + describe('positive cases', () => { + it('Should pass if observable completes', () => { + const a$ = cold('b-|'); + + expect(a$).toEmitValueLast('b'); + }); + + it('Should pass if value emits later', () => { + const a$ = cold('---b---|'); + + expect(a$).toEmitValueLast('b'); + }); + + it('Should pass if multiple value emits later', () => { + const a$ = cold('---a--b---|'); + + expect(a$).toEmitValueLast('b'); + }); + }); + + describe('negative cases', () => { + it('Should fail if other value emits', () => { + const a$ = cold('---a---|'); + + expect(a$).toEmitValueLast('b'); + }); + + it('Should fail if observable never completes', () => { + const a$ = cold('---b---'); + + expect(a$).toEmitValueLast('b'); + }); + + it('Should fail if no values emitted', () => { + const a$ = cold('---'); + + expect(a$).toEmitValueLast('a'); + }); + + it('Should fail if no values emitted and completed', () => { + const a$ = cold('---|'); + + expect(a$).toEmitValueLast('a'); + }); + + afterEach(() => { + expect(() => Scheduler.flush()).toThrowErrorMatchingSnapshot(); + Scheduler.init(); + }); + }); +}); diff --git a/spec/to-emit-value-nth.spec.ts b/spec/to-emit-value-nth.spec.ts new file mode 100644 index 0000000..1c32410 --- /dev/null +++ b/spec/to-emit-value-nth.spec.ts @@ -0,0 +1,42 @@ +import { cold, Scheduler } from '../index'; + +describe('toEmitValueNth matcher test', () => { + describe('positive cases', () => { + it('Should pass if observable completes', () => { + const a$ = cold('a-b-|'); + + expect(a$).toEmitValueNth('b', 1); + }); + + it('Should pass if observable never completes', () => { + const a$ = cold('a-b-'); + + expect(a$).toEmitValueNth('b', 1); + }); + }); + + describe('negative cases', () => { + it('Should fail if other value emits', () => { + const a$ = cold('a-b-|'); + + expect(a$).toEmitValueNth('a', 1); + }); + + it('Should fail if no values emitted', () => { + const a$ = cold('---'); + + expect(a$).toEmitValueNth('a', 1); + }); + + it('Should fail if no values emitted and completed', () => { + const a$ = cold('---|'); + + expect(a$).toEmitValueNth('a', 1); + }); + + afterEach(() => { + expect(() => Scheduler.flush()).toThrowErrorMatchingSnapshot(); + Scheduler.init(); + }); + }); +}); diff --git a/src/rxjs/notification-factories.ts b/src/rxjs/notification-factories.ts new file mode 100644 index 0000000..df487d1 --- /dev/null +++ b/src/rxjs/notification-factories.ts @@ -0,0 +1,40 @@ +import { CompleteNotification, ErrorNotification, NextNotification } from 'rxjs'; + +/** + * A completion object optimized for memory use and created to be the + * same "shape" as other notifications in v8. + * @internal + */ +export const COMPLETE_NOTIFICATION = (() => createNotification('C', undefined, undefined) as CompleteNotification)(); + +/** + * Internal use only. Creates an optimized error notification that is the same "shape" + * as other notifications. + * @internal + */ +export function errorNotification(error: any): ErrorNotification { + return createNotification('E', undefined, error) as any; +} + +/** + * Internal use only. Creates an optimized next notification that is the same "shape" + * as other notifications. + * @internal + */ +export function nextNotification(value: T) { + return createNotification('N', value, undefined) as NextNotification; +} + +/** + * Ensures that all notifications created internally have the same "shape" in v8. + * + * TODO: This is only exported to support a crazy legacy test in `groupBy`. + * @internal + */ +export function createNotification(kind: 'N' | 'E' | 'C', value: any, error: any) { + return { + kind, + value, + error, + }; +} diff --git a/src/rxjs/scheduler.ts b/src/rxjs/scheduler.ts index 677f75e..7715899 100644 --- a/src/rxjs/scheduler.ts +++ b/src/rxjs/scheduler.ts @@ -5,21 +5,36 @@ import { TestScheduler } from 'rxjs/testing'; import { assertDeepEqual } from './assert-deep-equal'; export class Scheduler { - public static instance: TestScheduler | null; + private static instance: TestScheduler | null; + private static onFlushCallbacks: (() => void)[] = []; public static init(): void { - Scheduler.instance = new TestScheduler(assertDeepEqual); + this.instance = new TestScheduler(assertDeepEqual); + this.onFlushCallbacks = []; } public static get(): TestScheduler { - if (Scheduler.instance) { - return Scheduler.instance; + if (this.instance) { + return this.instance; } throw new Error('Scheduler is not initialized'); } public static reset(): void { - Scheduler.instance = null; + this.instance = null; + this.onFlushCallbacks = []; + } + + public static onFlush(callback: () => void): void { + this.onFlushCallbacks.push(callback); + } + + public static flush(): void { + this.get().run(() => {}); + while (this.onFlushCallbacks.length > 0) { + this.onFlushCallbacks.shift()?.(); + } + this.reset(); } public static materializeInnerObservable(observable: Observable, outerFrame: number): TestMessages { diff --git a/src/to-emit-value.ts b/src/to-emit-value.ts new file mode 100644 index 0000000..89c1e35 --- /dev/null +++ b/src/to-emit-value.ts @@ -0,0 +1,44 @@ +import { Observable } from 'rxjs'; +import { TestMessages } from './rxjs/types'; +import { COMPLETE_NOTIFICATION, errorNotification, nextNotification } from './rxjs/notification-factories'; +import { assertDeepEqual } from './rxjs/assert-deep-equal'; +import { Scheduler } from './rxjs/scheduler'; + +export function toEmitValue(observable: Observable, expectedValue: unknown): void { + const actual: TestMessages = []; + const expected: TestMessages = []; + const flushTest = { actual, expected, ready: false }; + + Scheduler.get().schedule(() => { + observable.subscribe({ + next: (value) => { + actual.push({ frame: Scheduler.get().frame, notification: nextNotification(value) }); + expected.push({ frame: Scheduler.get().frame, notification: nextNotification(expectedValue) }); + flushTest.ready = true; + }, + error: (error) => { + actual.push({ frame: Scheduler.get().frame, notification: errorNotification(error) }); + expected.push({ frame: Scheduler.get().frame, notification: nextNotification(expectedValue) }); + flushTest.ready = true; + }, + complete: () => { + actual.push({ frame: Scheduler.get().frame, notification: COMPLETE_NOTIFICATION }); + if (!expected.length) { + expected.push({ frame: Scheduler.get().frame, notification: nextNotification(expectedValue) }); + } + expected.push({ frame: Scheduler.get().frame, notification: COMPLETE_NOTIFICATION }); + flushTest.ready = true; + }, + }); + }, 0); + + Scheduler.get()['flushTests'].push(flushTest); + + Scheduler.onFlush(() => { + expected.push({ frame: 0, notification: nextNotification(expectedValue) }); + expected.push({ frame: 0, notification: COMPLETE_NOTIFICATION }); + if (!flushTest.ready) { + assertDeepEqual(actual, expected); + } + }); +}