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
2 changes: 1 addition & 1 deletion api_guard/dist/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ export declare class VirtualAction<T> extends AsyncAction<T> {
protected recycleAsyncId(scheduler: VirtualTimeScheduler, id?: any, delay?: number): any;
protected requestAsyncId(scheduler: VirtualTimeScheduler, id?: any, delay?: number): any;
schedule(state?: T, delay?: number): Subscription;
static sortActions<T>(a: VirtualAction<T>, b: VirtualAction<T>): 1 | 0 | -1;
static sortActions<T>(a: VirtualAction<T>, b: VirtualAction<T>): 0 | 1 | -1;
cartant marked this conversation as resolved.
Show resolved Hide resolved
}

export declare class VirtualTimeScheduler extends AsyncScheduler {
Expand Down
1 change: 1 addition & 0 deletions api_guard/dist/types/testing/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface RunHelpers {
expectSubscriptions: typeof TestScheduler.prototype.expectSubscriptions;
flush: typeof TestScheduler.prototype.flush;
hot: typeof TestScheduler.prototype.createHotObservable;
repaints: (marbles: string) => void;
time: typeof TestScheduler.prototype.createTime;
}

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(({ cold, expectObservable, repaints, time }) => {
repaints(' ---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(({ cold, expectObservable, repaints }) => {
repaints(' ---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(({ cold, expectObservable, repaints, time }) => {
const requestSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'cancelAnimationFrame');

repaints(' ---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(({ cold, expectObservable, hot, repaints, time }) => {
const requestSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'requestAnimationFrame');
const cancelSpy = sinon.spy(requestAnimationFrameProvider.delegate!, 'cancelAnimationFrame');

repaints(' ---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