Skip to content

Commit

Permalink
feat(TestScheduler): add an animate "run mode" helper (#5607)
Browse files Browse the repository at this point in the history
* chore: remove duplicate interface

* chore: add providers

* chore: use rAF provider

* test: TestScheduler

* chore: enable Prettier

* test: reimplement animationFrames tests

* fix: prevent unnecessary cancellation attempts

* chore: update API guardian

* refactor: rename repaints to animate

* refactor: move animate into createAnimator, etc.

* chore: update API guardian

* docs: add animate run helper link and docs

* chore: recompile and update API guardian
  • Loading branch information
cartant committed Jul 31, 2020
1 parent cf3f4c2 commit edd6731
Show file tree
Hide file tree
Showing 14 changed files with 364 additions and 265 deletions.
1 change: 1 addition & 0 deletions api_guard/dist/types/testing/index.d.ts
@@ -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
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
@@ -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
@@ -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']);
});
});

0 comments on commit edd6731

Please sign in to comment.