//////////////////////////////////////////////////////////////////////////////// // // Licensed to the Apache Software Foundation (ASF) under one or more // contributor license agreements. See the NOTICE file distributed with // this work for additional information regarding copyright ownership. // The ASF licenses this file to You under the Apache License, Version 2.0 // (the "License"); you may not use this file except in compliance with // the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // //////////////////////////////////////////////////////////////////////////////// package { import flash.events.Event; import flash.events.TimerEvent; import flash.utils.Timer; import flash.utils.getQualifiedClassName; import mx.collections.ArrayCollection; import mx.core.IVisualElement; import mx.core.IVisualElementContainer; import mx.core.UIComponent; import mx.effects.CompositeEffect; import mx.effects.Effect; import mx.events.EffectEvent; import mx.geom.TransformOffsets; import mx.states.Transition; import spark.primitives.supportClasses.GraphicElement; /** * * This class provides some APIs that can be useful for writing Mustella effects * and transitions tests. This will be instrumental in the Catalyst matrix tests. * * It might be useful to think about building a set of TestStep classes that wrap * some of this functionality. * * @author Steven Shongrunden (stshongr@adobe.com) * */ public class EffectTesting { // whether to seek the effect on effectStart (default: false) public static var requestedSeek:Boolean = false; // what time to seek to in an effect (default: NaN - seek to the end of the effect) public static var requestedSeekTime:Number = NaN; // the current effect being played [Bindable] public static var currentEffect:Effect; // the document that ready events will be dispatched from and transitions will be pulled from private static var rootDocument:UIComponent; // the character used to separate the elements in the expected values string private static var elementSeparator:String = "|"; // the character used to separate the values in the expected values string private static var propertySeparator:String = ","; // keep track of the details of the latest comparison to ease further investigation private static var lastResult:ArrayCollection; /** * Sets up for an effect test. This allows you to seek to a specific time in an effect. * * Call this method after the ResetComponent in your (non-transition) effects test. */ public static function setupEffectTest(document:Object, effect:Effect):String { // reset the test properties resetProperties(document); // null check the effect if (effect == null) throw new Error("ERROR: You must provide a non-null effect to test."); // set the current effect currentEffect = effect; // handle the effectEnd event currentEffect.removeEventListener(EffectEvent.EFFECT_END, handleEffectEnd); currentEffect.addEventListener(EffectEvent.EFFECT_END, handleEffectEnd); // dispatches a setupComplete event and returns setupComplete String so you can // use this with either an AssertMethodValue or RunCode in Mustella rootDocument.dispatchEvent(new Event('setupComplete')); return "setupComplete"; } /** * Sets up for a transitions test. This allows you to seek to a specific time in a transition. * * It parses all of the transitions in a document and sets up event listeners in a way that allows * seeking to a specific time in the transition. * * Call this method after the ResetComponent in your transitions test. */ public static function setupTransitionTest(document:Object):String { resetProperties(document); var transitions:Array = rootDocument.transitions; // don't manage any listeners if there aren't any transitions if (transitions == null) throw new Error("ERROR: document has no transitions"); // add event listeners to each transition for each (var t:Transition in transitions){ // remove the effectStart event listener and add it again so we don't pile them up t.effect.removeEventListener(EffectEvent.EFFECT_START, handleEffectStart); t.effect.addEventListener(EffectEvent.EFFECT_START, handleEffectStart); // remove the effectEnd event listener and add it again so we don't pile them up t.effect.removeEventListener(EffectEvent.EFFECT_END, handleEffectEnd); t.effect.addEventListener(EffectEvent.EFFECT_END, handleEffectEnd); } // dispatches a setupComplete event and returns setupComplete String so you can // use this with either an AssertMethodValue or RunCode in Mustella rootDocument.dispatchEvent(new Event('setupComplete')); return "setupComplete"; } /** * Called by the setup methods to reset the properties in this class */ private static function resetProperties(document:Object):void { // null checks if (document == null) throw new Error("ERROR: You must provide a non-null document."); if (!(document is UIComponent)) throw new Error("ERROR: document must be a UIComponent"); // reset the rootDocument rootDocument = document as UIComponent; // reset the seek information requestedSeek = false; requestedSeekTime = NaN; } /** * Called on effect start, kicks off the seek behavior if it is requested. */ private static function handleEffectStart(event:EffectEvent):void { trace('effect start'); currentEffect = event.target as Effect; // seek if it was requested if (requestedSeek){ // wait roughly a frame then pause the effect before seeking var timer:Timer = new Timer(0); timer.repeatCount = 1; timer.addEventListener(TimerEvent.TIMER, function(e:Event):void{ seekCurrentEffect(); }); timer.start(); } } /** * Pauses then seeks to the position in the current effect. Fires an event when that is done. */ public static function seekCurrentEffect():void { var seekTime:Number = requestedSeekTime; var c:CompositeEffect = currentEffect as CompositeEffect; // seek to the end if a specific seek time was not requested if (isNaN(seekTime)){ // set the seekTime to the end of the effect if (c){ // if its a Parallel/Sequence then use the compositeDuration that also handle startDelay seekTime = c.compositeDuration; } else { // just a plain effect so use startDelay + duration seekTime = currentEffect.startDelay + currentEffect.duration; } } trace('effect seek to ' + seekTime); // pause then seek currentEffect.pause(); currentEffect.playheadTime = seekTime; // dispatch a ready event on the document rootDocument.dispatchEvent(new Event("seekAssertionReady")); } /** * TODO: The inclusion of this method in the API is not fully baked. * This method's name/signature/existance could change in the future * when it is properly implemented. */ public static function seekCurrentEffectTo(time:Number):void { currentEffect.playheadTime = time; rootDocument.dispatchEvent(new Event("seekAssertionReady")); } /** * TODO: The inclusion of this method in the API is not fully baked. * This method's name/signature/existance could change in the future * when it is properly implemented. */ public static function getCurrentEffectDuration():Number { var c:CompositeEffect = currentEffect as CompositeEffect; if (c){ // if its a Parallel/Sequence then use the compositeDuration that also handle startDelay return c.compositeDuration; } else { // just a plain effect so use startDelay + duration return currentEffect.startDelay + currentEffect.duration; } } /** * Resumes the current effect */ public static function resumeCurrentEffect():void { trace("effect resume"); currentEffect.resume(); } /** * Fires an event after the effectEnd event that signifies an assertion is now valid. * * In a transition this gets called after the state values have been slammed in. */ private static function handleEffectEnd(e:EffectEvent):void { trace('effect end'); // dispatch a ready event on the document rootDocument.dispatchEvent(new Event("endAssertionReady")); } /** * Given a root element it compares a set of properties across that element and any of its ancestors. * * Sample usage: * * assertPropertySet(test1, 'width, height', '70,22|10,10', 0) * outputs: 'FAIL: test1.width: expected 70 +/- 0, but received 100') * * @param rootContainer - the root element to inspect * @param propertyNameString - a string deliminated with a character that lists the properties to inspect * @param expectedValuesString - a string deliminiated with a character that lists the values to expect * @param tolerance - the amount of difference between actual and expected is allowed before failure * @param depth - how deep to recurse in the rootContainer * * @return - a string of either "PASS" or "FAIL: ..." with a failure message */ public static function assertPropertySet(rootContainer:IVisualElement, propertyNamesString:String, expectedValuesString:String, tolerance:Number = 0, depth:int = -1):String { return checkPropertySet(rootContainer, false, propertyNamesString, expectedValuesString, tolerance, depth); } /** * Given a root element it compares a set of properties across that element and any of its ancestors * using the postLayoutTransformOffsets object of those elements. * * Sample usage: * * assertPostLayoutPropertySet(test1, 'rotationX, rotationY', '45,45|0,0', 0) * outputs: 'FAIL: test1.rotationX: expected 45 +/- 0, but received 0') * * Use null as an expected value if postLayoutTransformOffsets is null for example: * - properties: 'rotationX,rotationY' * - expected string: 'null,null|null,null' * * @param rootContainer - the root element to inspect * @param propertyNameString - a string deliminated with a character that lists the properties to inspect * @param expectedValuesString - a string deliminiated with a character that lists the values to expect * @param tolerance - the amount of difference between actual and expected is allowed before failure * @param depth - how deep to recurse in the rootContainer * * @return - a string of either "PASS" or "FAIL: ..." with a failure message */ public static function assertPostLayoutPropertySet(rootContainer:IVisualElement, propertyNamesString:String, expectedValuesString:String, tolerance:Number = 0, depth:int = -1):String { return checkPropertySet(rootContainer, true, propertyNamesString, expectedValuesString, tolerance, depth); } /** * Workhorse method that is exposed via the two public assert methods. * * Given a root element it compares a set of properties across that element and any of its ancestors * * @param rootContainer - the root element to inspect * @param postLayout - whether to look at the postLayoutTransformOffsets object of an element * @param propertyNameString - a string deliminated with a character that lists the properties to inspect * @param expectedValuesString - a string deliminiated with a character that lists the values to expect * @param tolerance - the amount of difference between actual and expected is allowed before failure * @param depth - how deep to recurse in the rootContainer * * @return - a string of either "PASS" or "FAIL: ..." with a failure message * */ private static function checkPropertySet(rootContainer:IVisualElement, postLayout:Boolean, propertyNamesString:String, expectedValuesString:String, tolerance:Number = 0, depth:int = -1):String { // reset the result of the last comparison // add to this collection at any point a comparison happens lastResult = new ArrayCollection(); // get the list of elements to inspect properties of var elementsToInspect:Array = getElementsToInspect(rootContainer, depth); // get the list of properties to inspect on each element var propertyNames:Array = getPropertyNames(propertyNamesString); // split up the expectedValue string into values for each element var expectedElementValues:Array = expectedValuesString.split(elementSeparator); // string that represents the reason for fail var failString:String = ""; if (elementsToInspect.length != expectedElementValues.length){ // this will also catch existance failures, for example if an // element is supposed to be included or excluded from a state failString = "FAIL: number of elements (" + elementsToInspect.length + ") != number of expected elements (" + expectedElementValues.length + ")"; logResult(failString); return failString; } // Go through each of the elements recursively in the rootContainer for (var i:int = 0; i < elementsToInspect.length; i++){ var element:IVisualElement = elementsToInspect[i]; var expectedPropertyValues:Array = expectedElementValues[i].split(propertySeparator); // check for a malformed expected string if (propertyNames.length != expectedPropertyValues.length){ failString = "FAIL: number of properties != number of expected values for " + getElementId(element); logResult(failString); return failString; } // log that we are checking this property logResult(getElementId(element)); // check each property value for (var j:int = 0; j < propertyNames.length; j++){ var propertyName:String = propertyNames[j]; var e:* = expectedPropertyValues[j]; var a:*; // First need to decide whether to grab the property values from // the element or its postLayoutTransformOffsets if (postLayout){ if (element.postLayoutTransformOffsets){ a = element.postLayoutTransformOffsets[propertyName]; } else { a = null; } } else { a = element[propertyName]; } // prepare the log object for this property var logItem:Object = new Object(); logItem.target = getElementId(element); logItem.propertyName = propertyName; logItem.actual = a; logItem.expected = e; logItem.tolerance = tolerance; logItem.postLayout = postLayout; logItem.depth = "TODO"; // TODO: one day might want to keep track of the depth of this item logItem.result = "Unknown"; // // String comparison // // First just check if expected == actual via a simple string comparison. // If so then move on to the next propertyName, otherwise investigate further // via null and number comparisons. if (String(e) == String(a)){ // this property passed // log the pass logResult("PASS", logItem); continue; } // // Null comparison // // expected == actual == null so this is fine, continue to next propertyName if (e == 'null' && a == null){ // log the pass logResult("PASS", logItem); continue; } // expected or actual is null, but not both (because of above) so fail if (e == 'null' || a == null){ failString = "FAIL: " + describeFailureLocation(element, propertyName, postLayout) + ": " + a + ", but expected " + e; // log the fail logResult(failString, logItem); return failString; } // // Number comparison // // This approach assumes that it's ok treating undefined and NaN the same. // This is because Number(undefined) gets turned into NaN, if this is a limitation // might have to revisit this in the future. var expectedValue:Number = Number(e); var actualValue:Number = Number(a); // // NaN comparison // // expected == actual == NaN, so this is fine, continue to next propertyName if (isNaN(actualValue) && isNaN(expectedValue)){ // log the pass logResult("PASS", logItem); continue; } // expected or actual is NaN, but not both (because of above) so fail if (isNaN(actualValue) || isNaN(expectedValue)){ failString = "FAIL: " + describeFailureLocation(element, propertyName, postLayout) + ": expected " + expectedValue + ' plus or minus ' + tolerance + ", but received " + actualValue; // log the fail logResult(failString, logItem); return failString; } // // Number tolerance comparison // // expected differs from actual by more than the tolerance so fail if (Math.abs(actualValue - expectedValue) > tolerance){ failString = "FAIL: " + describeFailureLocation(element, propertyName, postLayout) + ": expected " + expectedValue + ' plus or minus ' + tolerance + ", but received " + actualValue; // log the fail logResult(failString, logItem); return failString; } // at this point the property passed // log the pass logResult("PASS", logItem); } // at this point the element passed, no need to log here } return "PASS"; } /** * Adds a result to the log. * * @param result - a simple string to add to the log * @param details - an object that if not null is added to the log after setting details.result equal to the first parameter */ private static function logResult(result:String, details:Object = null):void { if (details != null){ details.result = result; lastResult.addItem(details); } else { lastResult.addItem(result); } } /** * Returns the log of the last assertion result */ public static function getLastResult():ArrayCollection { return lastResult; } /** * Generates a string that describes what property of what element has failed. * * ex: * target.width * target.postLayoutTransformOffsets.width */ private static function describeFailureLocation(element:IVisualElement, propertyName:String, postLayout:Boolean):String{ var output:String = ""; output += getElementId(element); if (postLayout) output += ".postLayoutTransformOffsets"; output += "." + propertyName; return output; } /** * Given a root element and a string of property names this returns the formatted string of * each property value against that element and all descendants in a format that the assertion * methods require. * * @param rootContainer * @param propertyNamesString - ex: 'width, height, alpha' * @param postLayout - set to true if you want to access the properties of the postLayoutTransformOffsets * @param requestedDepth - the depth to recurse (-1 by default for full recursion) * * @return string */ public static function generatePropertySet(rootContainer:IVisualElement, propertyNamesString:String, postLayout:Boolean = false, requestedDepth:int = -1):String { // get the list of elements to inspect properties of var elementsToInspect:Array = getElementsToInspect(rootContainer, requestedDepth); var propertyNames:Array = getPropertyNames(propertyNamesString); var output:String = ""; // for each element for (var i:int = 0; i < elementsToInspect.length; i++){ var e:IVisualElement = elementsToInspect[i]; // for each property for (var j:int = 0; j < propertyNames.length; j++){ // the property name var propertyName:String = propertyNames[j]; // concatenate the value if (postLayout){ if (e.postLayoutTransformOffsets){ // access the value via the transform offsets output += e.postLayoutTransformOffsets[propertyName]; } else { // the transform offsets are null output += "null"; } } else { // access the value directly output += e[propertyName]; } // concatenate the value separator if (j < propertyNames.length - 1) output += ","; } // concatenate the element separator if (i < elementsToInspect.length - 1) output += elementSeparator; } return output; } /** * Returns an array of property names parsed from a comma separated string with * spaces removed. */ private static function getPropertyNames(s:String):Array { // strip spaces while (s.indexOf(" ") != -1){ s = s.replace(' ',''); } return s.split(propertySeparator); } /** * Returns the id of an element, if one is not defined then it returns the class name */ private static function getElementId(element:IVisualElement):String { var s:String = String(Object(element).id); return (s != "null") ? s : flash.utils.getQualifiedClassName(element).split("::")[1]; } /** * Returns an array of all elements in a root element. If the element is not a * container then it just returns an array of that element. */ public static function getElementsToInspect(root:IVisualElement, requestedDepth:int):Array { var output:Array = new Array(); if (root is IVisualElementContainer){ // if its a container then recursively get all the elements to requestedDepth output = getDescendants(root as IVisualElementContainer, requestedDepth); } else { // just return the element output.push(root); } return output; } /** * Recursively generates an array of all elements in a given container (including itself) to a requested depth */ private static function getDescendants(rootContainer:IVisualElementContainer, requestedDepth:int, depth:int = 0):Array{ var output:Array = new Array(); // push the container element output.push(rootContainer); // return if we've gone past the requested depth (and a requestedDepth of not -1) if (requestedDepth != -1 && (depth >= requestedDepth)){ return output; } for (var i:int = 0; i < rootContainer.numElements; i++){ var e:IVisualElement = rootContainer.getElementAt(i); if (e is IVisualElementContainer){ // recursively get the elements of the container output = output.concat(getDescendants(e as IVisualElementContainer, requestedDepth, depth+1)); } else { // push the non-container element output.push(e); } } return output; } } }