/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ts=2 sw=2 sts=2 et: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// Note: Class syntax roughly based on:
// https://developer.mozilla.org/en/Core_JavaScript_1.5_Guide/Inheritance
const SVG_NS = "http://www.w3.org/2000/svg";
const XLINK_NS = "http://www.w3.org/1999/xlink";

const MPATH_TARGET_ID = "smilTestUtilsTestingPath";

function extend(child, supertype) {
  child.prototype.__proto__ = supertype.prototype;
}

// General Utility Methods
var SMILUtil = {
  // Returns the first matched <svg> node in the document
  getSVGRoot() {
    return SMILUtil.getFirstElemWithTag("svg");
  },

  // Returns the first element in the document with the matching tag
  getFirstElemWithTag(aTargetTag) {
    var elemList = document.getElementsByTagName(aTargetTag);
    return !elemList.length ? null : elemList[0];
  },

  // Simple wrapper for getComputedStyle
  getComputedStyleSimple(elem, prop) {
    return window.getComputedStyle(elem).getPropertyValue(prop);
  },

  getAttributeValue(elem, attr) {
    if (attr.attrName == SMILUtil.getMotionFakeAttributeName()) {
      // Fake motion "attribute" -- "computed value" is the element's CTM
      return elem.getCTM();
    }
    if (attr.attrType == "CSS") {
      return SMILUtil.getComputedStyleWrapper(elem, attr.attrName);
    }
    if (attr.attrType == "XML") {
      // XXXdholbert This is appropriate for mapped attributes, but not
      // for other attributes.
      return SMILUtil.getComputedStyleWrapper(elem, attr.attrName);
    }
    throw new Error(`Unexpected attribute value ${attr.attrType}`);
  },

  // Smart wrapper for getComputedStyle, which will generate a "fake" computed
  // style for recognized shorthand properties (font, font-variant, overflow, marker)
  getComputedStyleWrapper(elem, propName) {
    // Special cases for shorthand properties (which aren't directly queriable
    // via getComputedStyle)
    var computedStyle;
    if (propName == "font") {
      var subProps = [
        "font-style",
        "font-variant-caps",
        "font-weight",
        "font-size",
        "line-height",
        "font-family",
      ];
      for (var i in subProps) {
        var subPropStyle = SMILUtil.getComputedStyleSimple(elem, subProps[i]);
        if (subPropStyle) {
          if (subProps[i] == "line-height") {
            // There needs to be a "/" before line-height
            subPropStyle = "/ " + subPropStyle;
          }
          if (!computedStyle) {
            computedStyle = subPropStyle;
          } else {
            computedStyle = computedStyle + " " + subPropStyle;
          }
        }
      }
    } else if (propName == "font-variant") {
      // xxx - this isn't completely correct but it's sufficient for what's
      //       being tested here
      computedStyle = SMILUtil.getComputedStyleSimple(
        elem,
        "font-variant-caps"
      );
    } else if (propName == "marker") {
      var subProps = ["marker-end", "marker-mid", "marker-start"];
      for (var i in subProps) {
        if (!computedStyle) {
          computedStyle = SMILUtil.getComputedStyleSimple(elem, subProps[i]);
        } else {
          is(
            computedStyle,
            SMILUtil.getComputedStyleSimple(elem, subProps[i]),
            "marker sub-properties should match each other " +
              "(they shouldn't be individually set)"
          );
        }
      }
    } else if (propName == "overflow") {
      var subProps = ["overflow-x", "overflow-y"];
      for (var i in subProps) {
        if (!computedStyle) {
          computedStyle = SMILUtil.getComputedStyleSimple(elem, subProps[i]);
        } else {
          is(
            computedStyle,
            SMILUtil.getComputedStyleSimple(elem, subProps[i]),
            "overflow sub-properties should match each other " +
              "(they shouldn't be individually set)"
          );
        }
      }
    } else {
      computedStyle = SMILUtil.getComputedStyleSimple(elem, propName);
    }
    return computedStyle;
  },

  getMotionFakeAttributeName() {
    return "_motion";
  },

  // Return stripped px value from specified value.
  stripPx: str => str.replace(/px\s*$/, ""),
};

var CTMUtil = {
  CTM_COMPONENTS_ALL: ["a", "b", "c", "d", "e", "f"],
  CTM_COMPONENTS_ROTATE: ["a", "b", "c", "d"],

  // Function to generate a CTM Matrix from a "summary"
  // (a 3-tuple containing [tX, tY, theta])
  generateCTM(aCtmSummary) {
    if (!aCtmSummary || aCtmSummary.length != 3) {
      ok(false, "Unexpected CTM summary tuple length: " + aCtmSummary.length);
    }
    var tX = aCtmSummary[0];
    var tY = aCtmSummary[1];
    var theta = aCtmSummary[2];
    var cosTheta = Math.cos(theta);
    var sinTheta = Math.sin(theta);
    var newCtm = {
      a: cosTheta,
      c: -sinTheta,
      e: tX,
      b: sinTheta,
      d: cosTheta,
      f: tY,
    };
    return newCtm;
  },

  /// Helper for isCtmEqual
  isWithinDelta(aTestVal, aExpectedVal, aErrMsg, aIsTodo) {
    var testFunc = aIsTodo ? todo : ok;
    const delta = 0.00001; // allowing margin of error = 10^-5
    ok(
      aTestVal >= aExpectedVal - delta && aTestVal <= aExpectedVal + delta,
      aErrMsg + " | got: " + aTestVal + ", expected: " + aExpectedVal
    );
  },

  assertCTMEqual(aLeftCtm, aRightCtm, aComponentsToCheck, aErrMsg, aIsTodo) {
    var foundCTMDifference = false;
    for (var j in aComponentsToCheck) {
      var curComponent = aComponentsToCheck[j];
      if (!aIsTodo) {
        CTMUtil.isWithinDelta(
          aLeftCtm[curComponent],
          aRightCtm[curComponent],
          aErrMsg + " | component: " + curComponent,
          false
        );
      } else if (aLeftCtm[curComponent] != aRightCtm[curComponent]) {
        foundCTMDifference = true;
      }
    }

    if (aIsTodo) {
      todo(!foundCTMDifference, aErrMsg + " | (currently marked todo)");
    }
  },

  assertCTMNotEqual(aLeftCtm, aRightCtm, aComponentsToCheck, aErrMsg, aIsTodo) {
    // CTM should not match initial one
    var foundCTMDifference = false;
    for (var j in aComponentsToCheck) {
      var curComponent = aComponentsToCheck[j];
      if (aLeftCtm[curComponent] != aRightCtm[curComponent]) {
        foundCTMDifference = true;
        break; // We found a difference, as expected. Success!
      }
    }

    if (aIsTodo) {
      todo(foundCTMDifference, aErrMsg + " | (currently marked todo)");
    } else {
      ok(foundCTMDifference, aErrMsg);
    }
  },
};

// Wrapper for timing information
function SMILTimingData(aBegin, aDur) {
  this._begin = aBegin;
  this._dur = aDur;
}
SMILTimingData.prototype = {
  _begin: null,
  _dur: null,
  getBeginTime() {
    return this._begin;
  },
  getDur() {
    return this._dur;
  },
  getEndTime() {
    return this._begin + this._dur;
  },
  getFractionalTime(aPortion) {
    return this._begin + aPortion * this._dur;
  },
};

/**
 * Attribute: a container for information about an attribute we'll
 * attempt to animate with SMIL in our tests.
 *
 * See also the factory methods below: NonAnimatableAttribute(),
 * NonAdditiveAttribute(), and AdditiveAttribute().
 *
 * @param aAttrName The name of the attribute
 * @param aAttrType The type of the attribute ("CSS" vs "XML")
 * @param aTargetTag The name of an element that this attribute could be
 *                   applied to.
 * @param aIsAnimatable A bool indicating whether this attribute is defined as
 *                      animatable in the SVG spec.
 * @param aIsAdditive   A bool indicating whether this attribute is defined as
 *                      additive (i.e. supports "by" animation) in the SVG spec.
 */
function Attribute(
  aAttrName,
  aAttrType,
  aTargetTag,
  aIsAnimatable,
  aIsAdditive
) {
  this.attrName = aAttrName;
  this.attrType = aAttrType;
  this.targetTag = aTargetTag;
  this.isAnimatable = aIsAnimatable;
  this.isAdditive = aIsAdditive;
}
Attribute.prototype = {
  // Member variables
  attrName: null,
  attrType: null,
  isAnimatable: null,
  testcaseList: null,
};

// Generators for Attribute objects.  These allow lists of attribute
// definitions to be more human-readible than if we were using Attribute() with
// boolean flags, e.g. "Attribute(..., true, true), Attribute(..., true, false)
function NonAnimatableAttribute(aAttrName, aAttrType, aTargetTag) {
  return new Attribute(aAttrName, aAttrType, aTargetTag, false, false);
}
function NonAdditiveAttribute(aAttrName, aAttrType, aTargetTag) {
  return new Attribute(aAttrName, aAttrType, aTargetTag, true, false);
}
function AdditiveAttribute(aAttrName, aAttrType, aTargetTag) {
  return new Attribute(aAttrName, aAttrType, aTargetTag, true, true);
}

/**
 * TestcaseBundle: a container for a group of tests for a particular attribute
 *
 * @param aAttribute An Attribute object for the attribute
 * @param aTestcaseList An array of AnimTestcase objects
 */
function TestcaseBundle(aAttribute, aTestcaseList, aSkipReason) {
  this.animatedAttribute = aAttribute;
  this.testcaseList = aTestcaseList;
  this.skipReason = aSkipReason;
}
TestcaseBundle.prototype = {
  // Member variables
  animatedAttribute: null,
  testcaseList: null,
  skipReason: null,

  // Methods
  go(aTimingData) {
    if (this.skipReason) {
      todo(
        false,
        "Skipping a bundle for '" +
          this.animatedAttribute.attrName +
          "' because: " +
          this.skipReason
      );
    } else {
      // Sanity Check: Bundle should have > 0 testcases
      if (!this.testcaseList || !this.testcaseList.length) {
        ok(
          false,
          "a bundle for '" +
            this.animatedAttribute.attrName +
            "' has no testcases"
        );
      }

      var targetElem = SMILUtil.getFirstElemWithTag(
        this.animatedAttribute.targetTag
      );

      if (!targetElem) {
        ok(
          false,
          "Error: can't find an element of type '" +
            this.animatedAttribute.targetTag +
            "', so I can't test property '" +
            this.animatedAttribute.attrName +
            "'"
        );
        return;
      }

      for (var testcaseIdx in this.testcaseList) {
        var testcase = this.testcaseList[testcaseIdx];
        if (testcase.skipReason) {
          todo(
            false,
            "Skipping a testcase for '" +
              this.animatedAttribute.attrName +
              "' because: " +
              testcase.skipReason
          );
        } else {
          testcase.runTest(
            targetElem,
            this.animatedAttribute,
            aTimingData,
            false
          );
          testcase.runTest(
            targetElem,
            this.animatedAttribute,
            aTimingData,
            true
          );
        }
      }
    }
  },
};

/**
 * AnimTestcase: an abstract class that represents an animation testcase.
 * (e.g. a set of "from"/"to" values to test)
 */
function AnimTestcase() {} // abstract => no constructor
AnimTestcase.prototype = {
  // Member variables
  _animElementTagName: "animate", // Can be overridden for e.g. animateColor
  computedValMap: null,
  skipReason: null,

  // Methods
  /**
   * runTest: Runs this AnimTestcase
   *
   * @param aTargetElem The node to be targeted in our test animation.
   * @param aTargetAttr An Attribute object representing the attribute
   *                    to be targeted in our test animation.
   * @param aTimeData A SMILTimingData object with timing information for
   *                  our test animation.
   * @param aIsFreeze If true, indicates that our test animation should use
   *                  fill="freeze"; otherwise, we'll default to fill="remove".
   */
  runTest(aTargetElem, aTargetAttr, aTimeData, aIsFreeze) {
    // SANITY CHECKS
    if (!SMILUtil.getSVGRoot().animationsPaused()) {
      ok(false, "Should start each test with animations paused");
    }
    if (SMILUtil.getSVGRoot().getCurrentTime() != 0) {
      ok(false, "Should start each test at time = 0");
    }

    // SET UP
    // Cache initial computed value
    var baseVal = SMILUtil.getAttributeValue(aTargetElem, aTargetAttr);

    // Create & append animation element
    var anim = this.setupAnimationElement(aTargetAttr, aTimeData, aIsFreeze);
    aTargetElem.appendChild(anim);

    // Build a list of [seek-time, expectedValue, errorMessage] triplets
    var seekList = this.buildSeekList(
      aTargetAttr,
      baseVal,
      aTimeData,
      aIsFreeze
    );

    // DO THE ACTUAL TESTING
    this.seekAndTest(seekList, aTargetElem, aTargetAttr);

    // CLEAN UP
    aTargetElem.removeChild(anim);
    SMILUtil.getSVGRoot().setCurrentTime(0);
  },

  // HELPER FUNCTIONS
  // setupAnimationElement: <animate> element
  // Subclasses should extend this parent method
  setupAnimationElement(aAnimAttr, aTimeData, aIsFreeze) {
    var animElement = document.createElementNS(
      SVG_NS,
      this._animElementTagName
    );
    animElement.setAttribute("attributeName", aAnimAttr.attrName);
    animElement.setAttribute("attributeType", aAnimAttr.attrType);
    animElement.setAttribute("begin", aTimeData.getBeginTime());
    animElement.setAttribute("dur", aTimeData.getDur());
    if (aIsFreeze) {
      animElement.setAttribute("fill", "freeze");
    }
    return animElement;
  },

  buildSeekList(aAnimAttr, aBaseVal, aTimeData, aIsFreeze) {
    if (!aAnimAttr.isAnimatable) {
      return this.buildSeekListStatic(
        aAnimAttr,
        aBaseVal,
        aTimeData,
        "defined as non-animatable in SVG spec"
      );
    }
    if (this.computedValMap.noEffect) {
      return this.buildSeekListStatic(
        aAnimAttr,
        aBaseVal,
        aTimeData,
        "testcase specified to have no effect"
      );
    }
    return this.buildSeekListAnimated(
      aAnimAttr,
      aBaseVal,
      aTimeData,
      aIsFreeze
    );
  },

  seekAndTest(aSeekList, aTargetElem, aTargetAttr) {
    var svg = document.getElementById("svg");
    for (var i in aSeekList) {
      var entry = aSeekList[i];
      SMILUtil.getSVGRoot().setCurrentTime(entry[0]);

      // Bug 1379908: The computed value of stroke-* properties should be
      // serialized with px units, but currently Gecko and Servo don't do that
      // when animating these values.
      if (
        ["stroke-width", "stroke-dasharray", "stroke-dashoffset"].includes(
          aTargetAttr.attrName
        )
      ) {
        var attr = SMILUtil.stripPx(
          SMILUtil.getAttributeValue(aTargetElem, aTargetAttr)
        );
        var expectedVal = SMILUtil.stripPx(entry[1]);
        is(attr, expectedVal, entry[2]);
        return;
      }
      is(
        SMILUtil.getAttributeValue(aTargetElem, aTargetAttr),
        entry[1],
        entry[2]
      );
    }
  },

  // methods that expect to be overridden in subclasses
  buildSeekListStatic(aAnimAttr, aBaseVal, aTimeData, aReasonStatic) {},
  buildSeekListAnimated(aAnimAttr, aBaseVal, aTimeData, aIsFreeze) {},
};

// Abstract parent class to share code between from-to & from-by testcases.
function AnimTestcaseFrom() {} // abstract => no constructor
AnimTestcaseFrom.prototype = {
  // Member variables
  from: null,

  // Methods
  setupAnimationElement(aAnimAttr, aTimeData, aIsFreeze) {
    // Call super, and then add my own customization
    var animElem = AnimTestcase.prototype.setupAnimationElement.apply(this, [
      aAnimAttr,
      aTimeData,
      aIsFreeze,
    ]);
    animElem.setAttribute("from", this.from);
    return animElem;
  },

  buildSeekListStatic(aAnimAttr, aBaseVal, aTimeData, aReasonStatic) {
    var seekList = new Array();
    var msgPrefix =
      aAnimAttr.attrName + ": shouldn't be affected by animation ";
    seekList.push([
      aTimeData.getBeginTime(),
      aBaseVal,
      msgPrefix + "(at animation begin) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 2),
      aBaseVal,
      msgPrefix + "(at animation mid) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getEndTime(),
      aBaseVal,
      msgPrefix + "(at animation end) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getEndTime() + aTimeData.getDur(),
      aBaseVal,
      msgPrefix + "(after animation end) - " + aReasonStatic,
    ]);
    return seekList;
  },

  buildSeekListAnimated(aAnimAttr, aBaseVal, aTimeData, aIsFreeze) {
    var seekList = new Array();
    var msgPrefix = aAnimAttr.attrName + ": ";
    if (aTimeData.getBeginTime() > 0.1) {
      seekList.push([
        aTimeData.getBeginTime() - 0.1,
        aBaseVal,
        msgPrefix +
          "checking that base value is set " +
          "before start of animation",
      ]);
    }

    seekList.push([
      aTimeData.getBeginTime(),
      this.computedValMap.fromComp || this.from,
      msgPrefix +
        "checking that 'from' value is set " +
        "at start of animation",
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 2),
      this.computedValMap.midComp || this.computedValMap.toComp || this.to,
      msgPrefix + "checking value halfway through animation",
    ]);

    var finalMsg;
    var expectedEndVal;
    if (aIsFreeze) {
      expectedEndVal = this.computedValMap.toComp || this.to;
      finalMsg = msgPrefix + "[freeze-mode] checking that final value is set ";
    } else {
      expectedEndVal = aBaseVal;
      finalMsg =
        msgPrefix + "[remove-mode] checking that animation is cleared ";
    }
    seekList.push([
      aTimeData.getEndTime(),
      expectedEndVal,
      finalMsg + "at end of animation",
    ]);
    seekList.push([
      aTimeData.getEndTime() + aTimeData.getDur(),
      expectedEndVal,
      finalMsg + "after end of animation",
    ]);
    return seekList;
  },
};
extend(AnimTestcaseFrom, AnimTestcase);

/*
 * A testcase for a simple "from-to" animation
 * @param aFrom  The 'from' value
 * @param aTo    The 'to' value
 * @param aComputedValMap  A hash-map that contains some computed values,
 *                         if they're needed, as follows:
 *    - fromComp: Computed value version of |aFrom| (if different from |aFrom|)
 *    - midComp:  Computed value that we expect to visit halfway through the
 *                animation (if different from |aTo|)
 *    - toComp:   Computed value version of |aTo| (if different from |aTo|)
 *    - noEffect: Special flag -- if set, indicates that this testcase is
 *                expected to have no effect on the computed value. (e.g. the
 *                given values are invalid.)
 * @param aSkipReason  If this test-case is known to currently fail, this
 *                     parameter should be a string explaining why.
 *                     Otherwise, this value should be null (or omitted).
 *
 */
function AnimTestcaseFromTo(aFrom, aTo, aComputedValMap, aSkipReason) {
  this.from = aFrom;
  this.to = aTo;
  this.computedValMap = aComputedValMap || {}; // Let aComputedValMap be omitted
  this.skipReason = aSkipReason;
}
AnimTestcaseFromTo.prototype = {
  // Member variables
  to: null,

  // Methods
  setupAnimationElement(aAnimAttr, aTimeData, aIsFreeze) {
    // Call super, and then add my own customization
    var animElem = AnimTestcaseFrom.prototype.setupAnimationElement.apply(
      this,
      [aAnimAttr, aTimeData, aIsFreeze]
    );
    animElem.setAttribute("to", this.to);
    return animElem;
  },
};
extend(AnimTestcaseFromTo, AnimTestcaseFrom);

/*
 * A testcase for a simple "from-by" animation.
 *
 * @param aFrom  The 'from' value
 * @param aBy    The 'by' value
 * @param aComputedValMap  A hash-map that contains some computed values that
 *                         we expect to visit, as follows:
 *    - fromComp: Computed value version of |aFrom| (if different from |aFrom|)
 *    - midComp:  Computed value that we expect to visit halfway through the
 *                animation (|aFrom| + |aBy|/2)
 *    - toComp:   Computed value of the animation endpoint (|aFrom| + |aBy|)
 *    - noEffect: Special flag -- if set, indicates that this testcase is
 *                expected to have no effect on the computed value. (e.g. the
 *                given values are invalid.  Or the attribute may be animatable
 *                and additive, but the particular "from" & "by" values that
 *                are used don't support addition.)
 * @param aSkipReason  If this test-case is known to currently fail, this
 *                     parameter should be a string explaining why.
 *                     Otherwise, this value should be null (or omitted).
 */
function AnimTestcaseFromBy(aFrom, aBy, aComputedValMap, aSkipReason) {
  this.from = aFrom;
  this.by = aBy;
  this.computedValMap = aComputedValMap;
  this.skipReason = aSkipReason;
  if (
    this.computedValMap &&
    !this.computedValMap.noEffect &&
    !this.computedValMap.toComp
  ) {
    ok(false, "AnimTestcaseFromBy needs expected computed final value");
  }
}
AnimTestcaseFromBy.prototype = {
  // Member variables
  by: null,

  // Methods
  setupAnimationElement(aAnimAttr, aTimeData, aIsFreeze) {
    // Call super, and then add my own customization
    var animElem = AnimTestcaseFrom.prototype.setupAnimationElement.apply(
      this,
      [aAnimAttr, aTimeData, aIsFreeze]
    );
    animElem.setAttribute("by", this.by);
    return animElem;
  },
  buildSeekList(aAnimAttr, aBaseVal, aTimeData, aIsFreeze) {
    if (!aAnimAttr.isAdditive) {
      return this.buildSeekListStatic(
        aAnimAttr,
        aBaseVal,
        aTimeData,
        "defined as non-additive in SVG spec"
      );
    }
    // Just use inherited method
    return AnimTestcaseFrom.prototype.buildSeekList.apply(this, [
      aAnimAttr,
      aBaseVal,
      aTimeData,
      aIsFreeze,
    ]);
  },
};
extend(AnimTestcaseFromBy, AnimTestcaseFrom);

/*
 * A testcase for a "paced-mode" animation
 * @param aValues   An array of values, to be used as the "Values" list
 * @param aComputedValMap  A hash-map that contains some computed values,
 *                         if they're needed, as follows:
 *      - comp0:   The computed value at the start of the animation
 *      - comp1_6: The computed value exactly 1/6 through animation
 *      - comp1_3: The computed value exactly 1/3 through animation
 *      - comp2_3: The computed value exactly 2/3 through animation
 *      - comp1:   The computed value of the animation endpoint
 *  The math works out easiest if...
 *    (a) aValuesString has 3 entries in its values list: vA, vB, vC
 *    (b) dist(vB, vC) = 2 * dist(vA, vB)
 *  With this setup, we can come up with expected intermediate values according
 *  to the following rules:
 *    - comp0 should be vA
 *    - comp1_6 should be us halfway between vA and vB
 *    - comp1_3 should be vB
 *    - comp2_3 should be halfway between vB and vC
 *    - comp1 should be vC
 * @param aSkipReason  If this test-case is known to currently fail, this
 *                     parameter should be a string explaining why.
 *                     Otherwise, this value should be null (or omitted).
 */
function AnimTestcasePaced(aValuesString, aComputedValMap, aSkipReason) {
  this.valuesString = aValuesString;
  this.computedValMap = aComputedValMap;
  this.skipReason = aSkipReason;
  if (
    this.computedValMap &&
    (!this.computedValMap.comp0 ||
      !this.computedValMap.comp1_6 ||
      !this.computedValMap.comp1_3 ||
      !this.computedValMap.comp2_3 ||
      !this.computedValMap.comp1)
  ) {
    ok(false, "This AnimTestcasePaced has an incomplete computed value map");
  }
}
AnimTestcasePaced.prototype = {
  // Member variables
  valuesString: null,

  // Methods
  setupAnimationElement(aAnimAttr, aTimeData, aIsFreeze) {
    // Call super, and then add my own customization
    var animElem = AnimTestcase.prototype.setupAnimationElement.apply(this, [
      aAnimAttr,
      aTimeData,
      aIsFreeze,
    ]);
    animElem.setAttribute("values", this.valuesString);
    animElem.setAttribute("calcMode", "paced");
    return animElem;
  },
  buildSeekListAnimated(aAnimAttr, aBaseVal, aTimeData, aIsFreeze) {
    var seekList = new Array();
    var msgPrefix = aAnimAttr.attrName + ": checking value ";
    seekList.push([
      aTimeData.getBeginTime(),
      this.computedValMap.comp0,
      msgPrefix + "at start of animation",
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 6),
      this.computedValMap.comp1_6,
      msgPrefix + "1/6 of the way through animation.",
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 3),
      this.computedValMap.comp1_3,
      msgPrefix + "1/3 of the way through animation.",
    ]);
    seekList.push([
      aTimeData.getFractionalTime(2 / 3),
      this.computedValMap.comp2_3,
      msgPrefix + "2/3 of the way through animation.",
    ]);

    var finalMsg;
    var expectedEndVal;
    if (aIsFreeze) {
      expectedEndVal = this.computedValMap.comp1;
      finalMsg =
        aAnimAttr.attrName +
        ": [freeze-mode] checking that final value is set ";
    } else {
      expectedEndVal = aBaseVal;
      finalMsg =
        aAnimAttr.attrName +
        ": [remove-mode] checking that animation is cleared ";
    }
    seekList.push([
      aTimeData.getEndTime(),
      expectedEndVal,
      finalMsg + "at end of animation",
    ]);
    seekList.push([
      aTimeData.getEndTime() + aTimeData.getDur(),
      expectedEndVal,
      finalMsg + "after end of animation",
    ]);
    return seekList;
  },
  buildSeekListStatic(aAnimAttr, aBaseVal, aTimeData, aReasonStatic) {
    var seekList = new Array();
    var msgPrefix =
      aAnimAttr.attrName + ": shouldn't be affected by animation ";
    seekList.push([
      aTimeData.getBeginTime(),
      aBaseVal,
      msgPrefix + "(at animation begin) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 6),
      aBaseVal,
      msgPrefix + "(1/6 of the way through animation) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 3),
      aBaseVal,
      msgPrefix + "(1/3 of the way through animation) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getFractionalTime(2 / 3),
      aBaseVal,
      msgPrefix + "(2/3 of the way through animation) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getEndTime(),
      aBaseVal,
      msgPrefix + "(at animation end) - " + aReasonStatic,
    ]);
    seekList.push([
      aTimeData.getEndTime() + aTimeData.getDur(),
      aBaseVal,
      msgPrefix + "(after animation end) - " + aReasonStatic,
    ]);
    return seekList;
  },
};
extend(AnimTestcasePaced, AnimTestcase);

/*
 * A testcase for an <animateMotion> animation.
 *
 * @param aAttrValueHash   A hash-map mapping attribute names to values.
 *                         Should include at least 'path', 'values', 'to'
 *                         or 'by' to describe the motion path.
 * @param aCtmMap  A hash-map that contains summaries of the expected resulting
 *                 CTM at various points during the animation. The CTM is
 *                 summarized as a tuple of three numbers: [tX, tY, theta]
                   (indicating a translate(tX,tY) followed by a rotate(theta))
 *      - ctm0:   The CTM summary at the start of the animation
 *      - ctm1_6: The CTM summary at exactly 1/6 through animation
 *      - ctm1_3: The CTM summary at exactly 1/3 through animation
 *      - ctm2_3: The CTM summary at exactly 2/3 through animation
 *      - ctm1:   The CTM summary at the animation endpoint
 *
 *  NOTE: For paced-mode animation (the default for animateMotion), the math
 *  works out easiest if:
 *    (a) our motion path has 3 points: vA, vB, vC
 *    (b) dist(vB, vC) = 2 * dist(vA, vB)
 *  (See discussion in header comment for AnimTestcasePaced.)
 *
 * @param aSkipReason  If this test-case is known to currently fail, this
 *                     parameter should be a string explaining why.
 *                     Otherwise, this value should be null (or omitted).
 */
function AnimMotionTestcase(aAttrValueHash, aCtmMap, aSkipReason) {
  this.attrValueHash = aAttrValueHash;
  this.ctmMap = aCtmMap;
  this.skipReason = aSkipReason;
  if (
    this.ctmMap &&
    (!this.ctmMap.ctm0 ||
      !this.ctmMap.ctm1_6 ||
      !this.ctmMap.ctm1_3 ||
      !this.ctmMap.ctm2_3 ||
      !this.ctmMap.ctm1)
  ) {
    ok(false, "This AnimMotionTestcase has an incomplete CTM map");
  }
}
AnimMotionTestcase.prototype = {
  // Member variables
  _animElementTagName: "animateMotion",

  // Implementations of inherited methods that we need to override:
  // --------------------------------------------------------------
  setupAnimationElement(aAnimAttr, aTimeData, aIsFreeze) {
    var animElement = document.createElementNS(
      SVG_NS,
      this._animElementTagName
    );
    animElement.setAttribute("begin", aTimeData.getBeginTime());
    animElement.setAttribute("dur", aTimeData.getDur());
    if (aIsFreeze) {
      animElement.setAttribute("fill", "freeze");
    }
    for (var attrName in this.attrValueHash) {
      if (attrName == "mpath") {
        this.createPath(this.attrValueHash[attrName]);
        this.createMpath(animElement);
      } else {
        animElement.setAttribute(attrName, this.attrValueHash[attrName]);
      }
    }
    return animElement;
  },

  createPath(aPathDescription) {
    var path = document.createElementNS(SVG_NS, "path");
    path.setAttribute("d", aPathDescription);
    path.setAttribute("id", MPATH_TARGET_ID);
    return SMILUtil.getSVGRoot().appendChild(path);
  },

  createMpath(aAnimElement) {
    var mpath = document.createElementNS(SVG_NS, "mpath");
    mpath.setAttributeNS(XLINK_NS, "href", "#" + MPATH_TARGET_ID);
    return aAnimElement.appendChild(mpath);
  },

  // Override inherited seekAndTest method since...
  // (a) it expects a computedValMap and we have a computed-CTM map instead
  // and (b) it expects we might have no effect (for non-animatable attrs)
  buildSeekList(aAnimAttr, aBaseVal, aTimeData, aIsFreeze) {
    var seekList = new Array();
    var msgPrefix = "CTM mismatch ";
    seekList.push([
      aTimeData.getBeginTime(),
      CTMUtil.generateCTM(this.ctmMap.ctm0),
      msgPrefix + "at start of animation",
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 6),
      CTMUtil.generateCTM(this.ctmMap.ctm1_6),
      msgPrefix + "1/6 of the way through animation.",
    ]);
    seekList.push([
      aTimeData.getFractionalTime(1 / 3),
      CTMUtil.generateCTM(this.ctmMap.ctm1_3),
      msgPrefix + "1/3 of the way through animation.",
    ]);
    seekList.push([
      aTimeData.getFractionalTime(2 / 3),
      CTMUtil.generateCTM(this.ctmMap.ctm2_3),
      msgPrefix + "2/3 of the way through animation.",
    ]);

    var finalMsg;
    var expectedEndVal;
    if (aIsFreeze) {
      expectedEndVal = CTMUtil.generateCTM(this.ctmMap.ctm1);
      finalMsg =
        aAnimAttr.attrName +
        ": [freeze-mode] checking that final value is set ";
    } else {
      expectedEndVal = aBaseVal;
      finalMsg =
        aAnimAttr.attrName +
        ": [remove-mode] checking that animation is cleared ";
    }
    seekList.push([
      aTimeData.getEndTime(),
      expectedEndVal,
      finalMsg + "at end of animation",
    ]);
    seekList.push([
      aTimeData.getEndTime() + aTimeData.getDur(),
      expectedEndVal,
      finalMsg + "after end of animation",
    ]);
    return seekList;
  },

  // Override inherited seekAndTest method
  // (Have to use assertCTMEqual() instead of is() for comparison, to check each
  // component of the CTM and to allow for a small margin of error.)
  seekAndTest(aSeekList, aTargetElem, aTargetAttr) {
    var svg = document.getElementById("svg");
    for (var i in aSeekList) {
      var entry = aSeekList[i];
      SMILUtil.getSVGRoot().setCurrentTime(entry[0]);
      CTMUtil.assertCTMEqual(
        aTargetElem.getCTM(),
        entry[1],
        CTMUtil.CTM_COMPONENTS_ALL,
        entry[2],
        false
      );
    }
  },

  // Override "runTest" method so we can remove any <path> element that we
  // created at the end of each test.
  runTest(aTargetElem, aTargetAttr, aTimeData, aIsFreeze) {
    AnimTestcase.prototype.runTest.apply(this, [
      aTargetElem,
      aTargetAttr,
      aTimeData,
      aIsFreeze,
    ]);
    var pathElem = document.getElementById(MPATH_TARGET_ID);
    if (pathElem) {
      SMILUtil.getSVGRoot().removeChild(pathElem);
    }
  },
};
extend(AnimMotionTestcase, AnimTestcase);

// MAIN METHOD
function testBundleList(aBundleList, aTimingData) {
  for (var bundleIdx in aBundleList) {
    aBundleList[bundleIdx].go(aTimingData);
  }
}
