Skip to content

Commit 1b497e3

Browse files
committed
Regroup animate tests to prevent race conditions
1 parent bd6e8c0 commit 1b497e3

File tree

4 files changed

+154
-92
lines changed

4 files changed

+154
-92
lines changed

src/plot_api/plot_api.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2901,7 +2901,7 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt
29012901

29022902
var i, frame;
29032903
var frameList = [];
2904-
var allFrames = typeof groupNameOrFrameList === 'undefined';
2904+
var allFrames = groupNameOrFrameList === undefined || groupNameOrFrameList === null;
29052905
if(allFrames || typeof groupNameOrFrameList === 'string') {
29062906
for(i = 0; i < trans._frames.length; i++) {
29072907
frame = trans._frames[i];
@@ -2929,7 +2929,12 @@ Plotly.animate = function(gd, groupNameOrFrameList, transitionOpts, animationOpt
29292929
discardExistingFrames();
29302930
}
29312931

2932-
queueFrames(frameList);
2932+
if(frameList.length > 0) {
2933+
queueFrames(frameList);
2934+
} else {
2935+
gd.emit('plotly_animated');
2936+
resolve();
2937+
}
29332938
});
29342939
};
29352940

src/plots/plots.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,7 @@ plots.purge = function(gd) {
870870
// remove modebar
871871
if(fullLayout._modeBar) fullLayout._modeBar.destroy();
872872

873-
if(gd._transitionData._animationRaf) {
873+
if(gd._transitionData && gd._transitionData._animationRaf) {
874874
cancelAnimationFrame(gd._transitionData._animationRaf);
875875
}
876876

test/jasmine/tests/animate_test.js

Lines changed: 133 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -9,49 +9,55 @@ var delay = require('../assets/delay');
99

1010
var mock = require('@mocks/animation');
1111

12-
function runTests(duration) {
13-
describe('Test animate API (frame duration = ' + duration + ')', function() {
14-
'use strict';
12+
describe('Test animate API', function() {
13+
'use strict';
1514

16-
var gd, mockCopy;
17-
var transOpts = {frameduration: duration};
15+
var gd, mockCopy;
1816

19-
function verifyQueueEmpty(gd) {
20-
expect(gd._transitionData._frameQueue.length).toEqual(0);
21-
}
17+
function verifyQueueEmpty(gd) {
18+
expect(gd._transitionData._frameQueue.length).toEqual(0);
19+
}
2220

23-
function verifyFrameTransitionOrder(gd, expectedFrames) {
24-
var calls = PlotlyInternal.transition.calls;
21+
function verifyFrameTransitionOrder(gd, expectedFrames) {
22+
var calls = PlotlyInternal.transition.calls;
2523

26-
expect(calls.count()).toEqual(expectedFrames.length);
24+
expect(calls.count()).toEqual(expectedFrames.length);
2725

28-
for(var i = 0; i < calls.count(); i++) {
29-
expect(calls.argsFor(i)[1]).toEqual(
30-
gd._transitionData._frameHash[expectedFrames[i]].data
31-
);
32-
}
26+
for(var i = 0; i < calls.count(); i++) {
27+
expect(calls.argsFor(i)[1]).toEqual(
28+
gd._transitionData._frameHash[expectedFrames[i]].data
29+
);
3330
}
31+
}
3432

35-
beforeEach(function(done) {
36-
gd = createGraphDiv();
33+
beforeEach(function(done) {
34+
gd = createGraphDiv();
3735

38-
mockCopy = Lib.extendDeep({}, mock);
39-
40-
spyOn(PlotlyInternal, 'transition').and.callFake(function() {
41-
// Transition's fake behaviro is to resolve after a short period of time:
42-
return Promise.resolve().then(delay(duration));
43-
});
36+
mockCopy = Lib.extendDeep({}, mock);
4437

45-
Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() {
46-
Plotly.addFrames(gd, mockCopy.frames);
47-
}).then(done);
38+
spyOn(PlotlyInternal, 'transition').and.callFake(function() {
39+
// Transition's fake behaviro is to resolve after a short period of time:
40+
return Promise.resolve().then(delay(5));
4841
});
4942

50-
afterEach(function() {
51-
// *must* purge between tests otherwise dangling async events might not get cleaned up properly:
52-
Plotly.purge(gd);
53-
destroyGraphDiv();
54-
});
43+
Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() {
44+
Plotly.addFrames(gd, mockCopy.frames);
45+
}).then(done);
46+
});
47+
48+
afterEach(function() {
49+
// *must* purge between tests otherwise dangling async events might not get cleaned up properly:
50+
Plotly.purge(gd);
51+
destroyGraphDiv();
52+
});
53+
54+
for(var i = 0; i < 2; i++) {
55+
// Run tests for 0ms and 30ms duration:
56+
runTests(10 + 30 * i);
57+
}
58+
59+
function runTests(duration) {
60+
var transOpts = {frameduration: duration};
5561

5662
it('animates to a frame', function(done) {
5763
Plotly.animate(gd, ['frame0'], {duration: 1.2345}).then(function() {
@@ -83,54 +89,51 @@ function runTests(duration) {
8389
Plotly.animate(gd, ['foobar'], transOpts).then(fail).then(done, done);
8490
});
8591

86-
it('animates to a single frame', function(done) {
87-
Plotly.animate(gd, ['frame0'], transOpts).then(function() {
88-
expect(PlotlyInternal.transition.calls.count()).toEqual(1);
92+
it('animates all frames if list is null', function(done) {
93+
Plotly.animate(gd, null, transOpts).then(function() {
94+
verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']);
8995
verifyQueueEmpty(gd);
9096
}).catch(fail).then(done);
9197
});
9298

93-
it('animates to a list of frames', function(done) {
94-
Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() {
95-
expect(PlotlyInternal.transition.calls.count()).toEqual(2);
99+
it('animates all frames if list is undefined', function(done) {
100+
Plotly.animate(gd, undefined, transOpts).then(function() {
101+
verifyFrameTransitionOrder(gd, ['base', 'frame0', 'frame1', 'frame2', 'frame3']);
96102
verifyQueueEmpty(gd);
97103
}).catch(fail).then(done);
98104
});
99105

100-
it('animates frames by group', function(done) {
101-
Plotly.animate(gd, 'even-frames', transOpts).then(function() {
102-
expect(PlotlyInternal.transition.calls.count()).toEqual(2);
106+
it('animates to a single frame', function(done) {
107+
Plotly.animate(gd, ['frame0'], transOpts).then(function() {
108+
expect(PlotlyInternal.transition.calls.count()).toEqual(1);
103109
verifyQueueEmpty(gd);
104110
}).catch(fail).then(done);
105111
});
106112

107-
it('animates groups in the correct order', function(done) {
108-
Plotly.animate(gd, 'even-frames', transOpts);
109-
Plotly.animate(gd, 'odd-frames', transOpts).then(function() {
110-
verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']);
113+
it('animates to an empty list', function(done) {
114+
Plotly.animate(gd, [], transOpts).then(function() {
115+
expect(PlotlyInternal.transition.calls.count()).toEqual(0);
111116
verifyQueueEmpty(gd);
112117
}).catch(fail).then(done);
113118
});
114119

115-
it('drops queued frames when immediate = true', function(done) {
116-
Plotly.animate(gd, 'even-frames', transOpts);
117-
Plotly.animate(gd, 'odd-frames', transOpts, {immediate: true}).then(function() {
118-
verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame3']);
120+
it('animates to a list of frames', function(done) {
121+
Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(function() {
122+
expect(PlotlyInternal.transition.calls.count()).toEqual(2);
119123
verifyQueueEmpty(gd);
120124
}).catch(fail).then(done);
121125
});
122126

123-
it('animates frames in the correct order', function(done) {
124-
Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() {
125-
verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']);
127+
it('animates frames by group', function(done) {
128+
Plotly.animate(gd, 'even-frames', transOpts).then(function() {
129+
expect(PlotlyInternal.transition.calls.count()).toEqual(2);
126130
verifyQueueEmpty(gd);
127131
}).catch(fail).then(done);
128132
});
129133

130-
it('animates frames and groups in sequence', function(done) {
131-
Plotly.animate(gd, 'even-frames', transOpts);
134+
it('animates frames in the correct order', function(done) {
132135
Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() {
133-
verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']);
136+
verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']);
134137
verifyQueueEmpty(gd);
135138
}).catch(fail).then(done);
136139
});
@@ -204,18 +207,23 @@ function runTests(duration) {
204207
}).catch(fail).then(done);
205208
});
206209

207-
it('rejects when an animation is interrupted', function(done) {
208-
var interrupted = false;
209-
Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(fail, function() {
210-
interrupted = true;
211-
});
212-
213-
Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() {
214-
expect(interrupted).toBe(true);
215-
verifyFrameTransitionOrder(gd, ['frame0', 'frame2']);
210+
it('resolves at the end of each animation sequence', function(done) {
211+
Plotly.animate(gd, 'even-frames', transOpts).then(function() {
212+
return Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts);
213+
}).then(function() {
214+
verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']);
216215
verifyQueueEmpty(gd);
217216
}).catch(fail).then(done);
218217
});
218+
}
219+
220+
// The tests above use promises to ensure ordering, but the tests below this call Plotly.animate
221+
// without chaining promises which would result in race conditions. This is not invalid behavior,
222+
// but it doesn't ensure proper ordering and completion, so these must be performed with finite
223+
// duration. Stricly speaking, these tests *do* involve race conditions, but the finite duration
224+
// prevents that from causing problems.
225+
describe('Calling Plotly.animate synchronously in series', function() {
226+
var transOpts = {frameduration: 30};
219227

220228
it('emits plotly_animationinterrupted when an animation is interrupted', function(done) {
221229
var interrupted = false;
@@ -231,6 +239,7 @@ function runTests(duration) {
231239
}).catch(fail).then(done);
232240
});
233241

242+
234243
it('queues successive animations', function(done) {
235244
var starts = 0;
236245
var ends = 0;
@@ -243,27 +252,75 @@ function runTests(duration) {
243252
expect(starts).toEqual(1);
244253
});
245254

246-
Plotly.animate(gd, 'even-frames', transOpts);
247-
Plotly.animate(gd, 'odd-frames', transOpts).then(delay(10)).then(function() {
255+
Plotly.animate(gd, 'even-frames', {duration: 16});
256+
Plotly.animate(gd, 'odd-frames', {duration: 16}).then(delay(10)).then(function() {
248257
expect(ends).toEqual(1);
249258
verifyQueueEmpty(gd);
250259
}).catch(fail).then(done);
251260
});
252261

253-
it('resolves at the end of each animation sequence', function(done) {
254-
Plotly.animate(gd, 'even-frames', transOpts).then(function() {
255-
return Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts);
256-
}).then(function() {
262+
it('an empty list with immediate dumps previous frames', function(done) {
263+
Plotly.animate(gd, ['frame0', 'frame1'], {frameduration: 50});
264+
Plotly.animate(gd, [], null, {immediate: true}).then(function() {
265+
expect(PlotlyInternal.transition.calls.count()).toEqual(1);
266+
verifyQueueEmpty(gd);
267+
}).catch(fail).then(done);
268+
});
269+
270+
it('animates groups in the correct order', function(done) {
271+
Plotly.animate(gd, 'even-frames', transOpts);
272+
Plotly.animate(gd, 'odd-frames', transOpts).then(function() {
273+
verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame1', 'frame3']);
274+
verifyQueueEmpty(gd);
275+
}).catch(fail).then(done);
276+
});
277+
278+
it('drops queued frames when immediate = true', function(done) {
279+
Plotly.animate(gd, 'even-frames', transOpts);
280+
Plotly.animate(gd, 'odd-frames', transOpts, {immediate: true}).then(function() {
281+
verifyFrameTransitionOrder(gd, ['frame0', 'frame1', 'frame3']);
282+
verifyQueueEmpty(gd);
283+
}).catch(fail).then(done);
284+
});
285+
286+
it('animates frames and groups in sequence', function(done) {
287+
Plotly.animate(gd, 'even-frames', transOpts);
288+
Plotly.animate(gd, ['frame0', 'frame2', 'frame1', 'frame3'], transOpts).then(function() {
257289
verifyFrameTransitionOrder(gd, ['frame0', 'frame2', 'frame0', 'frame2', 'frame1', 'frame3']);
258290
verifyQueueEmpty(gd);
259291
}).catch(fail).then(done);
260292
});
293+
294+
it('rejects when an animation is interrupted', function(done) {
295+
var interrupted = false;
296+
Plotly.animate(gd, ['frame0', 'frame1'], transOpts).then(fail, function() {
297+
interrupted = true;
298+
});
299+
300+
Plotly.animate(gd, ['frame2'], transOpts, {immediate: true}).then(function() {
301+
expect(interrupted).toBe(true);
302+
verifyFrameTransitionOrder(gd, ['frame0', 'frame2']);
303+
verifyQueueEmpty(gd);
304+
}).catch(fail).then(done);
305+
});
261306
});
262-
}
263307

264-
for(var i = 0; i < 2; i++) {
265-
// Set a duration:
266-
var d = 30 * i;
308+
it('animates reasonably even when transition duration >> frame duration', function(done) {
309+
var starts = 0;
310+
var ends= 0;
311+
312+
gd.on('plotly_animating', function() {
313+
starts++;
314+
}).on('plotly_animated', function() {
315+
ends++;
316+
});
317+
318+
Plotly.animate(gd, ['frame0', 'frame1'], {duration: 200, frameduration: 20}).then(function() {
319+
expect(starts).toEqual(1);
320+
expect(ends).toEqual(1);
321+
expect(PlotlyInternal.transition.calls.count()).toEqual(2);
322+
verifyQueueEmpty(gd);
323+
}).catch(fail).then(done);
324+
});
267325

268-
runTests(d);
269-
}
326+
});

0 commit comments

Comments
 (0)