[Web Animations] Expose Web Animations CSS integration as an experimental feature
[WebKit-https.git] / LayoutTests / animations / resources / animation-test-helpers.js
1 /* This is the helper function to run animation tests:
2
3 Test page requirements:
4 - The body must contain an empty div with id "result"
5 - Call this function directly from the <script> inside the test page
6
7 Function parameters:
8     expected [required]: an array of arrays defining a set of CSS properties that must have given values at specific times (see below)
9     callback [optional]: a function to be executed just before the test starts (none by default)
10     event [optional]: which DOM event to wait for before starting the test ("webkitAnimationStart" by default)
11
12     Each sub-array must contain these items in this order:
13     - the name of the CSS animation (may be null) [1]
14     - the time in seconds at which to snapshot the CSS property
15     - the id of the element on which to get the CSS property value [2]
16     - the name of the CSS property to get [3]
17     - the expected value for the CSS property
18     - the tolerance to use when comparing the effective CSS property value with its expected value
19
20     [1] If null is passed, a regular setTimeout() will be used instead to snapshot the animated property in the future,
21     instead of fast forwarding using the pauseAnimationAtTimeOnElement() function.
22     
23     [2] If a single string is passed, it is the id of the element to test. If an array with 2 elements is passed they
24     are the ids of 2 elements, whose values are compared for equality. In this case the expected value is ignored
25     but the tolerance is used in the comparison. If the second element is prefixed with "static:", no animation on that
26     element is required, allowing comparison with an unanimated "expected value" element.
27     
28     If a string with a '.' is passed, this is an element in an iframe. The string before the dot is the iframe id
29     and the string after the dot is the element name in that iframe.
30
31     [3] If the CSS property name is "webkitTransform", expected value must be an array of 1 or more numbers corresponding to the matrix elements,
32     or a string which will be compared directly (useful if the expected value is "none")
33     If the CSS property name is "webkitTransform.N", expected value must be a number corresponding to the Nth element of the matrix
34
35 */
36
37 function isCloseEnough(actual, expected, tolerance)
38 {
39     if (isNaN(actual) || isNaN(expected))
40         return false;
41     var diff = Math.abs(actual - expected);
42     return diff <= tolerance;
43 }
44
45 function matrixStringToArray(s)
46 {
47     if (s == "none")
48         return [ 1, 0, 0, 1, 0, 0 ];
49     var m = s.split("(");
50     m = m[1].split(")");
51     return m[0].split(",");
52 }
53
54 function parseCSSImage(s)
55 {
56     // Special case none.
57     if (s == "none")
58         return ["none"];
59
60     // Separate function name from function value.
61     var matches = s.match("([\\w\\-]+)\\((.*)\\)");
62     if (!matches){
63         console.error("Parsing error. Value not a CSS Image function ", s);
64         return false;
65     }
66
67     var functionName = matches[1];
68     var functionValue = matches[2];
69
70     // Generator functions can have CSS images as values themself.
71     // These functions will call parseCSSImage for each CSS Image.
72     switch (functionName) {
73     case "filter":
74         return parseFilterImage(functionValue);
75     case "cross-fade":
76     case "-webkit-cross-fade":
77         return parseCrossFade(functionValue);
78     case "url":
79         return ["url", functionValue];
80     // FIXME: Add support for linear and redial gradient.
81     default:
82         // All supported filter functions must be listed above.
83         return false;
84     }
85 }
86
87 // This should just be called by parseCSSImage.
88 function parseCrossFade(s)
89 {
90     var matches = s.match("(.*)\\s*,\\s*(.*)\\s*,\\s*(.*)\\s*");
91     if (!matches) {
92         console.error("Parsing error on 'cross-fade()'.");
93         return null;
94     }
95
96     var from = parseCSSImage(matches[1]);
97     var to = parseCSSImage(matches[2]);
98     if (!from || !to) {
99         console.error("Parsing error on images passed to 'cross-fade()' ", s);
100         return null;
101     }
102
103     var fadeValue = matches[3];
104     var percent;
105     if (isNaN(fadeValue)) {
106         // Check if last char is '%' and rip it off.
107         // Normalize it to number.
108         if (fadeValue.search('%') != fadeValue.length - 1) {
109             console.error("Passed value to 'cross-fade()' is not a number or percentage ", fadeValue);
110             return null;
111         }
112         fadeValue = fadeValue.slice(0, fadeValue.length - 1);
113         if (isNaN(fadeValue)) {
114             console.error("Passed value to 'cross-fade()' is not a number or percentage ", fadeValue);
115             return null;
116         }
117         percent = parseFloat(fadeValue) / 100;
118     } else
119         percent = parseFloat(fadeValue);
120
121     return ["cross-fade", from, to, percent];
122 }
123
124 // This should just be called by parseCSSImage.
125 function parseFilterImage(s)
126 {
127     // Separate image value from filter function list.
128     var matches = s.match("([\\-\\w]+\\(.*\\))\\s*,\\s*(.*)\\s*");
129     if (!matches) {
130         console.error("Parsing error on 'filter()' ", s);
131         return false;
132     }
133
134     var image = parseCSSImage(matches[1]);
135     if (!image) {
136         console.error("Parsing error on image passed to 'filter()' ", s);
137         return false;
138     }
139
140     var filterFunctionList = parseFilterFunctionList(matches[2]);
141     if (!filterFunctionList) {
142         console.error("Parsing error on filter function list passed to 'filter()' ", s);
143         return false;
144     }
145
146     return ["-webkit-filter", image, filterFunctionList];
147 }
148
149 function parseFilterFunctionList(s)
150 {
151     var reg = /\)*\s*(blur|brightness|contrast|drop\-shadow|grayscale|hue\-rotate|invert|opacity|saturate|sepia|url)\(/
152     var matches = s.split(reg);
153
154     // First item must be empty. All other items are of functionName, functionValue.
155     if (!matches || matches.shift() != "")
156         return null;
157
158     // Odd items are the function name, even items the function value.
159     if (!matches.length || matches.length % 2)
160         return null;
161
162     var functionList = [];
163     for (var i = 0; i < matches.length; i += 2) {
164         var functionName = matches[i];
165         var functionValue = matches[i+1];
166         functionList.push(functionName);
167         if (functionName == "drop-shadow" || functionName == "url") {
168             // FIXME: Support parsing of drop-shadow.
169             functionList.push(functionValue);
170             continue;
171         }
172         functionList.push(parseFloat(functionValue));
173     }
174     return functionList;
175 }
176
177 function parseBasicShape(s)
178 {
179     var shapeFunction = s.match(/(\w+)\((.+)\)/);
180     if (!shapeFunction)
181         return null;
182
183     var matches;
184     switch (shapeFunction[1]) {
185     case "inset":
186         matches = s.match("inset\\(\\s*(.*)\\s*\\)");
187         matches = matches[1].split(/\s+/);
188         matches.unshift(s);
189         break;
190     case "circle":
191         matches = s.match("circle\\((.*)\\s+at\\s+(.*)\\s+(.*)\\)");
192         break;
193     case "ellipse":
194         matches = s.match("ellipse\\((.*)\\s+(.*)\\s+at\\s+(.*)\\s+(.*)\\)");
195         break;
196     case "polygon":
197         matches = s.match("polygon\\(\\s*(.*)\\s*\\)");
198         matches = matches[1].split(/\s*,\s*/);
199         matches = matches.map(function(match) {
200             return match.split(/\s+/);
201         });
202         matches = Array.prototype.concat.apply([s], matches);
203         break;
204     default:
205         return null;
206     }
207
208     if (!matches)
209         return null;
210
211     matches.shift();
212     var i = 0;
213     if (shapeFunction[1] == "polygon")
214         i++; // skip nonzero|evenodd below
215
216     // Normalize percentage values.
217     for (; i < matches.length; ++i) {
218         var param = parseFloat(matches[i]);
219
220         if (isNaN(param))
221             continue;
222
223         if (matches[i].indexOf('%') != -1)
224             matches[i] = param / 100;
225         else
226             matches[i] = param;
227     }
228
229     return {"shape": shapeFunction[1], "params": matches};
230 }
231
232 function compareCSSImages(computedValue, expectedValue, tolerance)
233 {
234     var actual = typeof computedValue === "string" ? parseCSSImage(computedValue) : computedValue;
235     var expected = typeof expectedValue === "string" ? parseCSSImage(expectedValue) : expectedValue;
236     if (!actual || !Array.isArray(actual)         // Check that: actual is an array,
237         || !expected || !Array.isArray(expected)  // expected is an array,
238         || actual[0] != expected[0]) {            // and image function names are the same.
239         console.error("Unexpected mismatch between CSS Image functions.");
240         return false;
241     }
242     switch (actual[0]) {
243     case "none":
244         return true;
245     case "-webkit-filter":
246         return compareCSSImages(actual[1], expected[1], tolerance)
247             && compareFilterFunctions(actual[2], expected[2], tolerance);
248     case "cross-fade":
249     case "-webkit-cross-fade":
250         return compareCSSImages(actual[1], expected[1], tolerance)
251             && compareCSSImages(actual[2], expected[2], tolerance)
252             && isCloseEnough(actual[3], expected[3], tolerance);
253     case "url":
254         return actual[1].search(expected[1]) >= 0;
255     default:
256         console.error("Unknown CSS Image function ", actual[0]);
257         return false;
258     }
259 }
260
261 function compareFontVariationSettings(computedValue, expectedValue, tolerance)
262 {
263     if (!computedValue)
264         return false;
265     if (computedValue == "normal" || expectedValue == "normal")
266         return computedValue == expectedValue;
267     var computed = computedValue.split(", ");
268     var expected = expectedValue.split(", ");
269     if (computed.length != expected.length)
270         return false;
271     for (var i = 0; i < computed.length; ++i) {
272         var computedPieces = computed[i].split(" ");
273         var expectedPieces = expected[i].split(" ");
274         if (computedPieces.length != 2 || expectedPieces.length != 2)
275             return false;
276         if (computedPieces[0] != expectedPieces[0])
277             return false;
278         var computedNumber = Number.parseFloat(computedPieces[1]);
279         var expectedNumber = Number.parseFloat(expectedPieces[1]);
280         if (Math.abs(computedNumber - expectedNumber) > tolerance)
281             return false;
282     }
283     return true;
284 }
285
286 function compareFontStyle(computedValue, expectedValue, tolerance)
287 {
288         var computed = computedValue.split(" ");
289         var expected = expectedValue.split(" ");
290         var computedAngle = computed[1].split("deg");
291         var expectedAngle = expected[1].split("deg");
292         return computed[0] == expected[0] && Math.abs(computedAngle[0] - expectedAngle[0]) <= tolerance;
293 }
294
295 // Called by CSS Image function filter() as well as filter property.
296 function compareFilterFunctions(computedValue, expectedValue, tolerance)
297 {
298     var actual = typeof computedValue === "string" ? parseFilterFunctionList(computedValue) : computedValue;
299     var expected = typeof expectedValue === "string" ? parseFilterFunctionList(expectedValue) : expectedValue;
300     if (!actual || !Array.isArray(actual)         // Check that: actual is an array,
301         || !expected || !Array.isArray(expected)  // expected is an array,
302         || !actual.length                         // actual array has entries,
303         || actual.length != expected.length       // actual and expected have same length
304         || actual.length % 2 == 1)                // and image function names are the same.
305         return false;
306
307     for (var i = 0; i < actual.length; i += 2) {
308         if (actual[i] != expected[i]) {
309             console.error("Filter functions do not match.");
310             return false;
311         }
312         if (!isCloseEnough(actual[i+1], expected[i+1], tolerance)) {
313             console.error("Filter function values do not match.");
314             return false;
315         }
316     }
317     return true;
318 }
319
320 function basicShapeParametersMatch(paramList1, paramList2, tolerance)
321 {
322     if (paramList1.shape != paramList2.shape
323         || paramList1.params.length != paramList2.params.length)
324         return false;
325     var i = 0;
326     for (; i < paramList1.params.length; ++i) {
327         var param1 = paramList1.params[i], 
328             param2 = paramList2.params[i];
329         if (param1 === param2)
330             continue;
331         var match = isCloseEnough(param1, param2, tolerance);
332         if (!match)
333             return false;
334     }
335     return true;
336 }
337
338 function checkExpectedValue(expected, index)
339 {
340     var animationName = expected[index][0];
341     var time = expected[index][1];
342     var elementId = expected[index][2];
343     var property = expected[index][3];
344     var expectedValue = expected[index][4];
345     var tolerance = expected[index][5];
346
347     // Check for a pair of element Ids
348     var compareElements = false;
349     var element2Static = false;
350     var elementId2;
351     if (typeof elementId != "string") {
352         if (elementId.length != 2)
353             return;
354             
355         elementId2 = elementId[1];
356         elementId = elementId[0];
357
358         if (elementId2.indexOf("static:") == 0) {
359             elementId2 = elementId2.replace("static:", "");
360             element2Static = true;
361         }
362
363         compareElements = true;
364     }
365     
366     // Check for a dot separated string
367     var iframeId;
368     if (!compareElements) {
369         var array = elementId.split('.');
370         if (array.length == 2) {
371             iframeId = array[0];
372             elementId = array[1];
373         }
374     }
375
376     if (animationName && hasPauseAnimationAPI && !pauseAnimationAtTimeOnElement(animationName, time, document.getElementById(elementId))) {
377         result += "FAIL - animation \"" + animationName + "\" is not running" + "<br>";
378         return;
379     }
380     
381     if (compareElements && !element2Static && animationName && hasPauseAnimationAPI && !pauseAnimationAtTimeOnElement(animationName, time, document.getElementById(elementId2))) {
382         result += "FAIL - animation \"" + animationName + "\" is not running" + "<br>";
383         return;
384     }
385     
386     var computedValue, computedValue2;
387     if (compareElements) {
388         computedValue = getPropertyValue(property, elementId, iframeId);
389         computedValue2 = getPropertyValue(property, elementId2, iframeId);
390
391         if (comparePropertyValue(property, computedValue, computedValue2, tolerance))
392             result += "PASS - \"" + property + "\" property for \"" + elementId + "\" and \"" + elementId2 + 
393                             "\" elements at " + time + "s are close enough to each other" + "<br>";
394         else
395             result += "FAIL - \"" + property + "\" property for \"" + elementId + "\" and \"" + elementId2 + 
396                             "\" elements at " + time + "s saw: \"" + computedValue + "\" and \"" + computedValue2 + 
397                                             "\" which are not close enough to each other" + "<br>";
398     } else {
399         var elementName;
400         if (iframeId)
401             elementName = iframeId + '.' + elementId;
402         else
403             elementName = elementId;
404
405         computedValue = getPropertyValue(property, elementId, iframeId);
406
407         if (comparePropertyValue(property, computedValue, expectedValue, tolerance))
408             result += "PASS - \"" + property + "\" property for \"" + elementName + "\" element at " + time + 
409                             "s saw something close to: " + expectedValue + "<br>";
410         else
411             result += "FAIL - \"" + property + "\" property for \"" + elementName + "\" element at " + time + 
412                             "s expected: " + expectedValue + " but saw: " + computedValue + "<br>";
413     }
414 }
415
416
417 function getPropertyValue(property, elementId, iframeId)
418 {
419     var computedValue;
420     var element;
421     if (iframeId)
422         element = document.getElementById(iframeId).contentDocument.getElementById(elementId);
423     else
424         element = document.getElementById(elementId);
425
426     if (property == "lineHeight")
427         computedValue = parseInt(window.getComputedStyle(element).lineHeight);
428     else if (property == "backgroundImage"
429                || property == "borderImageSource"
430                || property == "listStyleImage"
431                || property == "webkitMaskImage"
432                || property == "webkitMaskBoxImage"
433                || property == "filter"
434                || property == "colorFilter"
435                || property == "webkitFilter"
436                || property == "webkitBackdropFilter"
437                || property == "webkitClipPath"
438                || property == "webkitShapeInside"
439                || property == "webkitShapeOutside"
440                || property == "font-variation-settings"
441                || property == "font-style"
442                || !property.indexOf("webkitTransform")
443                || !property.indexOf("transform")) {
444         computedValue = window.getComputedStyle(element)[property.split(".")[0]];
445     } else if (property == "font-stretch") {
446         var computedStyle = window.getComputedStyle(element).getPropertyCSSValue(property);
447         computedValue = computedStyle.getFloatValue(CSSPrimitiveValue.CSS_PERCENTAGE);
448     } else {
449         var computedStyle = window.getComputedStyle(element).getPropertyCSSValue(property);
450         computedValue = computedStyle.getFloatValue(CSSPrimitiveValue.CSS_NUMBER);
451     }
452
453     return computedValue;
454 }
455
456 function comparePropertyValue(property, computedValue, expectedValue, tolerance)
457 {
458     var result = true;
459
460     if (!property.indexOf("webkitTransform") || !property.indexOf("transform")) {
461         if (typeof expectedValue == "string")
462             result = (computedValue == expectedValue);
463         else if (typeof expectedValue == "number") {
464             var m = matrixStringToArray(computedValue);
465             result = isCloseEnough(parseFloat(m[parseInt(property.substring(16))]), expectedValue, tolerance);
466         } else {
467             var m = matrixStringToArray(computedValue);
468             for (i = 0; i < expectedValue.length; ++i) {
469                 result = isCloseEnough(parseFloat(m[i]), expectedValue[i], tolerance);
470                 if (!result)
471                     break;
472             }
473         }
474     } else if (property == "webkitFilter" || property == "webkitBackdropFilter" || property == "filter" || property == "colorFilter") {
475         var filterParameters = parseFilterFunctionList(computedValue);
476         var filter2Parameters = parseFilterFunctionList(expectedValue);
477         result = compareFilterFunctions(filterParameters, filter2Parameters, tolerance);
478     } else if (property == "webkitClipPath" || property == "webkitShapeInside" || property == "webkitShapeOutside") {
479         var clipPathParameters = parseBasicShape(computedValue);
480         var clipPathParameters2 = parseBasicShape(expectedValue);
481         if (!clipPathParameters || !clipPathParameters2)
482             result = false;
483         result = basicShapeParametersMatch(clipPathParameters, clipPathParameters2, tolerance);
484     } else if (property == "backgroundImage"
485                || property == "borderImageSource"
486                || property == "listStyleImage"
487                || property == "webkitMaskImage"
488                || property == "webkitMaskBoxImage")
489         result = compareCSSImages(computedValue, expectedValue, tolerance);
490     else if (property == "font-variation-settings")
491         result = compareFontVariationSettings(computedValue, expectedValue, tolerance);
492     else if (property == "font-style")
493         result = compareFontStyle(computedValue, expectedValue, tolerance);
494     else
495         result = isCloseEnough(computedValue, expectedValue, tolerance);
496     return result;
497 }
498
499 function endTest(finishCallback)
500 {
501     document.getElementById('result').innerHTML = result;
502
503     if (finishCallback)
504         finishCallback();
505
506     if (window.testRunner)
507         testRunner.notifyDone();
508 }
509
510 function checkExpectedValueCallback(expected, index)
511 {
512     return function() { checkExpectedValue(expected, index); };
513 }
514
515 function pauseAnimationAtTimeOnElement(animationName, time, element)
516 {
517     // If we haven't opted into CSS Animations and CSS Transitions as Web Animations, use the internal API.
518     if ('internals' in window && !internals.settings.webAnimationsCSSIntegrationEnabled())
519         return internals.pauseAnimationAtTimeOnElement(animationName, time, element);
520
521     // Otherwise, use the Web Animations API.
522     const animations = element.getAnimations();
523     for (let animation of animations) {
524         if (animation instanceof CSSAnimation && animation.animationName == animationName) {
525             animation.currentTime = time * 1000;
526             animation.pause();
527             return true;
528         }
529     }
530     return false;
531 }
532
533 var testStarted = false;
534 function startTest(expected, startCallback, finishCallback)
535 {
536     if (testStarted) return;
537     testStarted = true;
538
539     if (startCallback)
540         startCallback();
541
542     var maxTime = 0;
543
544     for (var i = 0; i < expected.length; ++i) {
545         var animationName = expected[i][0];
546         var time = expected[i][1];
547
548         // We can only use the animation fast-forward mechanism if there's an animation name
549         if (animationName && hasPauseAnimationAPI)
550             checkExpectedValue(expected, i);
551         else {
552             if (time > maxTime)
553                 maxTime = time;
554
555             window.setTimeout(checkExpectedValueCallback(expected, i), time * 1000);
556         }
557     }
558
559     if (maxTime > 0)
560         window.setTimeout(function () {
561             endTest(finishCallback);
562         }, maxTime * 1000 + 50);
563     else
564         endTest(finishCallback);
565 }
566
567 var result = "";
568 var hasPauseAnimationAPI = true;
569
570 if (window.testRunner)
571     testRunner.waitUntilDone();
572
573 function runAnimationTest(expected, startCallback, event, disablePauseAnimationAPI, doPixelTest, finishCallback)
574 {
575     if (disablePauseAnimationAPI)
576         hasPauseAnimationAPI = false;
577
578     if (window.testRunner) {
579         if (!doPixelTest)
580             testRunner.dumpAsText();
581     }
582     
583     if (!expected)
584         throw("Expected results are missing!");
585     
586     var target = document;
587     if (event == undefined)
588         waitForAnimationToStart(target, function() { startTest(expected, startCallback, finishCallback); });
589     else if (event == "load")
590         window.addEventListener(event, function() {
591             startTest(expected, startCallback, finishCallback);
592         }, false);
593 }
594
595 function waitForAnimationToStart(element, callback)
596 {
597     element.addEventListener('webkitAnimationStart', function() {
598         window.setTimeout(callback, 0); // delay to give hardware animations a chance to start
599     }, false);
600 }