Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(TestScheduler): add an animate "run mode" helper #5607

Merged
merged 13 commits into from
Jul 31, 2020
1 change: 1 addition & 0 deletions api_guard/dist/types/testing/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface RunHelpers {
animate: (marbles: string) => void;
cold: typeof TestScheduler.prototype.createColdObservable;
expectObservable: typeof TestScheduler.prototype.expectObservable;
expectSubscriptions: typeof TestScheduler.prototype.expectSubscriptions;
Expand Down
11 changes: 11 additions & 0 deletions docs_app/content/guide/testing/marble-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ Although `run()` executes entirely synchronously, the helper functions inside yo
- `expectObservable(actual: Observable<T>, subscriptionMarbles?: string).toBe(marbleDiagram: string, values?: object, error?: any)` - schedules an assertion for when the TestScheduler flushes. Give `subscriptionMarbles` as parameter to change the schedule of subscription and unsubscription. If you don't provide the `subscriptionMarbles` parameter it will subscribe at the beginning and never unsubscribe. Read below about subscription marble diagram.
- `expectSubscriptions(actualSubscriptionLogs: SubscriptionLog[]).toBe(subscriptionMarbles: string)` - like `expectObservable` schedules an assertion for when the testScheduler flushes. Both `cold()` and `hot()` return an observable with a property `subscriptions` of type `SubscriptionLog[]`. Give `subscriptions` as parameter to `expectSubscriptions` to assert whether it matches the `subscriptionsMarbles` marble diagram given in `toBe()`. Subscription marble diagrams are slightly different than Observable marble diagrams. Read more below.
- `flush()` - immediately starts virtual time. Not often used since `run()` will automatically flush for you when your callback returns, but in some cases you may wish to flush more than once or otherwise have more control.
- `animate()` - specifies when requested animation frames will be 'painted'. `animate` accepts a marble diagram and each value emission in the diagram indicates when a 'paint' occurs - at which time, any queued `requestAnimationFrame` callbacks will be executed. Call `animate` at the beginning of your test and align the marble diagrams so that it's clear when the callbacks will be executed:

```ts
testScheduler.run(helpers => {
const { animate, cold } = helpers;
animate(' ---x---x---x---x');
const requests = cold('-r-------r------')
/* ... */
const expected = ' ---a-------b----';
});
```

## Marble syntax

Expand Down
102 changes: 0 additions & 102 deletions spec/helpers/test-helper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { of, asyncScheduler, Observable, scheduled, ObservableInput } from 'rxjs';
import { observable } from 'rxjs/internal/symbol/observable';
import { iterator } from 'rxjs/internal/symbol/iterator';
import * as sinon from 'sinon';
import { expect } from 'chai';

if (process && process.on) {
Expand Down Expand Up @@ -72,104 +71,3 @@ export const NO_SUBS: string[] = [];
export function assertDeepEquals (actual: any, expected: any) {
expect(actual).to.deep.equal(expected);
}

let _raf: any;
let _caf: any;
let _id = 0;

/**
* A type used to test `requestAnimationFrame`
*/
export interface RAFTestTools {
/**
* Synchronously fire the next scheduled animation frame
*/
tick(): void;

/**
* Synchronously fire all scheduled animation frames
*/
flush(): void;

/**
* Un-monkey-patch `requestAnimationFrame` and `cancelAnimationFrame`
*/
restore(): void;
}

/**
* Monkey patches `requestAnimationFrame` and `cancelAnimationFrame`, returning a
* toolset to allow animation frames to be synchronously controlled.
*
* ### Usage
* ```ts
* let raf: RAFTestTools;
*
* beforeEach(() => {
* // patch requestAnimationFrame
* raf = stubRAF();
* });
*
* afterEach(() => {
* // unpatch
* raf.restore();
* });
*
* it('should fire handlers', () => {
* let test = false;
* // use requestAnimationFrame as normal
* requestAnimationFrame(() => test = true);
* // no frame has fired yet (this would be generally true anyhow)
* expect(test).to.equal(false);
* // manually fire the next animation frame
* raf.tick();
* // frame as fired
* expect(test).to.equal(true);
* // raf is now a SinonStub that can be asserted against
* expect(requestAnimationFrame).to.have.been.calledOnce;
* });
* ```
*/
export function stubRAF(): RAFTestTools {
_raf = requestAnimationFrame;
_caf = cancelAnimationFrame;

const handlers: any[] = [];

(requestAnimationFrame as any) = sinon.stub().callsFake((handler: Function) => {
const id = _id++;
handlers.push({ id, handler });
return id;
});

(cancelAnimationFrame as any) = sinon.stub().callsFake((id: number) => {
const index = handlers.findIndex(x => x.id === id);
if (index >= 0) {
handlers.splice(index, 1);
}
});

function tick() {
if (handlers.length > 0) {
handlers.shift().handler();
}
}

function flush() {
while (handlers.length > 0) {
handlers.shift().handler();
}
}

return {
tick,
flush,
restore() {
(requestAnimationFrame as any) = _raf;
(cancelAnimationFrame as any) = _caf;
_raf = _caf = undefined;
handlers.length = 0;
_id = 0;
}
};
}
222 changes: 84 additions & 138 deletions spec/observables/dom/animationFrames-spec.ts
Original file line number Diff line number Diff line change
@@ -1,166 +1,112 @@
/** @prettier */
import { expect } from 'chai';
import { animationFrames, Subject } from 'rxjs';
import * as sinon from 'sinon';
import { take, takeUntil } from 'rxjs/operators';
import { RAFTestTools, stubRAF } from '../../helpers/test-helper';
import { animationFrames } from 'rxjs';
import { mergeMapTo, take, takeUntil } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { observableMatcher } from '../../helpers/observableMatcher';
import { requestAnimationFrameProvider } from 'rxjs/internal/scheduler/requestAnimationFrameProvider';

describe('animationFrame', () => {
let raf: RAFTestTools;
let DateStub: sinon.SinonStub;
let now = 1000;
describe('animationFrames', () => {
let testScheduler: TestScheduler;

beforeEach(() => {
raf = stubRAF();
DateStub = sinon.stub(Date, 'now').callsFake(() => {
return ++now;
});
});

afterEach(() => {
raf.restore();
DateStub.restore();
testScheduler = new TestScheduler(observableMatcher);
});

it('should animate', function () {
const results: any[] = [];
const source$ = animationFrames();

const subs = source$.subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
testScheduler.run(({ animate, cold, expectObservable, time }) => {
animate(' ---x---x---x');
const mapped = cold('-m ');
const tm = time(' -| ');
const ta = time(' ---| ');
const tb = time(' -------| ');
const tc = time(' -----------|');
const expected = ' ---a---b---c';
const subs = ' ^----------!';

const result = mapped.pipe(mergeMapTo(animationFrames()));
expectObservable(result, subs).toBe(expected, {
a: ta - tm,
b: tb - tm,
c: tc - tm,
});
});

expect(DateStub).to.have.been.calledOnce;

expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).to.have.been.calledTwice;
expect(results).to.deep.equal([1]);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
expect(results).to.deep.equal([1, 2]);

raf.tick();
expect(results).to.deep.equal([1, 2, 3]);

// Stop the animation loop
subs.unsubscribe();
});

it('should use any passed timestampProvider', () => {
const results: any[] = [];
let i = 0;
const timestampProvider = {
now: sinon.stub().callsFake(() => {
return [100, 200, 210, 300][i++];
})
return [50, 100, 200, 300][i++];
}),
};

const source$ = animationFrames(timestampProvider);

const subs = source$.subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
testScheduler.run(({ animate, cold, expectObservable }) => {
animate(' ---x---x---x');
const mapped = cold('-m ');
const expected = ' ---a---b---c';
const subs = ' ^----------!';

const result = mapped.pipe(mergeMapTo(animationFrames(timestampProvider)));
expectObservable(result, subs).toBe(expected, {
a: 50,
b: 150,
c: 250,
});
});

expect(DateStub).not.to.have.been.called;
expect(timestampProvider.now).to.have.been.calledOnce;
expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).not.to.have.been.called;
expect(timestampProvider.now).to.have.been.calledTwice;
expect(results).to.deep.equal([100]);

raf.tick();
expect(DateStub).not.to.have.been.called;
expect(timestampProvider.now).to.have.been.calledThrice;
expect(results).to.deep.equal([100, 110]);

raf.tick();
expect(results).to.deep.equal([100, 110, 200]);

// Stop the animation loop
subs.unsubscribe();
});

it('should compose with take', () => {
const results: any[] = [];
const source$ = animationFrames();
expect(requestAnimationFrame).not.to.have.been.called;

source$.pipe(
take(2),
).subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
testScheduler.run(({ animate, cold, expectObservable, time }) => {
const requestSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'cancelAnimationFrame');

animate(' ---x---x---x');
const mapped = cold('-m ');
const tm = time(' -| ');
const ta = time(' ---| ');
const tb = time(' -------| ');
const expected = ' ---a---b ';

const result = mapped.pipe(mergeMapTo(animationFrames().pipe(take(2))));
expectObservable(result).toBe(expected, {
a: ta - tm,
b: tb - tm,
});

testScheduler.flush();
// Requests are made at times tm and ta
expect(requestSpy.callCount).to.equal(2);
// No request cancellation is effected, as unsubscription occurs before rescheduling
expect(cancelSpy.callCount).to.equal(0);
});

expect(DateStub).to.have.been.calledOnce;
expect(requestAnimationFrame).to.have.been.calledOnce;

expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).to.have.been.calledTwice;
expect(requestAnimationFrame).to.have.been.calledTwice;
expect(results).to.deep.equal([1]);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
// It shouldn't reschedule, because there are no more subscribers
// for the animation loop.
expect(requestAnimationFrame).to.have.been.calledTwice;
expect(results).to.deep.equal([1, 2, 'done']);

// Since there should be no more subscribers listening on the loop
// the latest animation frame should be cancelled.
expect(cancelAnimationFrame).to.have.been.calledOnce;
});

it('should compose with takeUntil', () => {
const subject = new Subject<void>();
const results: any[] = [];
const source$ = animationFrames();
expect(requestAnimationFrame).not.to.have.been.called;

source$.pipe(
takeUntil(subject),
).subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
testScheduler.run(({ animate, cold, expectObservable, hot, time }) => {
const requestSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'cancelAnimationFrame');

animate(' ---x---x---x');
const mapped = cold('-m ');
const tm = time(' -| ');
const ta = time(' ---| ');
const tb = time(' -------| ');
const signal = hot(' ^--------s--');
const expected = ' ---a---b ';

const result = mapped.pipe(mergeMapTo(animationFrames().pipe(takeUntil(signal))));
expectObservable(result).toBe(expected, {
a: ta - tm,
b: tb - tm,
});

testScheduler.flush();
// Requests are made at times tm and ta and tb
expect(requestSpy.callCount).to.equal(3);
// Unsubscription effects request cancellation when signalled
expect(cancelSpy.callCount).to.equal(1);
});

expect(DateStub).to.have.been.calledOnce;
expect(requestAnimationFrame).to.have.been.calledOnce;

expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).to.have.been.calledTwice;
expect(requestAnimationFrame).to.have.been.calledTwice;
expect(results).to.deep.equal([1]);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
expect(requestAnimationFrame).to.have.been.calledThrice;
expect(results).to.deep.equal([1, 2]);
expect(cancelAnimationFrame).not.to.have.been.called;

// Complete the observable via `takeUntil`.
subject.next();
expect(cancelAnimationFrame).to.have.been.calledOnce;
expect(results).to.deep.equal([1, 2, 'done']);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
expect(requestAnimationFrame).to.have.been.calledThrice;
expect(results).to.deep.equal([1, 2, 'done']);
});
});
Loading