// Copyright 2009 The Closure Library Authors. All Rights Reserved.
//
// Licensed 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.

/**
 * @fileoverview Defines test classes for tests that can wait for conditions.
 *
 * Normal unit tests must complete their test logic within a single function
 * execution. This is ideal for most tests, but makes it difficult to test
 * routines that require real time to complete. The tests and TestCase in this
 * file allow for tests that can wait until a condition is true before
 * continuing execution.
 *
 * Each test has the typical three phases of execution: setUp, the test itself,
 * and tearDown. During each phase, the test function may add wait conditions,
 * which result in new test steps being added for that phase. All steps in a
 * given phase must complete before moving on to the next phase. An error in
 * any phase will stop that test and report the error to the test runner.
 *
 * This class should not be used where adequate mocks exist. Time-based routines
 * should use the MockClock, which runs much faster and provides equivalent
 * results. Continuation tests should be used for testing code that depends on
 * browser behaviors that are difficult to mock. For example, testing code that
 * relies on Iframe load events, event or layout code that requires a setTimeout
 * to become valid, and other browser-dependent native object interactions for
 * which mocks are insufficient.
 *
 * Sample usage:
 *
 * <pre>
 * var testCase = new goog.testing.ContinuationTestCase();
 * testCase.autoDiscoverTests();
 *
 * if (typeof G_testRunner != 'undefined') {
 *   G_testRunner.initialize(testCase);
 * }
 *
 * function testWaiting() {
 *   var someVar = true;
 *   waitForTimeout(function() {
 *     assertTrue(someVar)
 *   }, 500);
 * }
 *
 * function testWaitForEvent() {
 *   var et = goog.events.EventTarget();
 *   waitForEvent(et, 'test', function() {
 *     // Test step runs after the event fires.
 *   })
 *   et.dispatchEvent(et, 'test');
 * }
 *
 * function testWaitForCondition() {
 *   var counter = 0;
 *
 *   waitForCondition(function() {
 *     // This function is evaluated periodically until it returns true, or it
 *     // times out.
 *     return ++counter >= 3;
 *   }, function() {
 *     // This test step is run once the condition becomes true.
 *     assertEquals(3, counter);
 *   });
 * }
 * </pre>
 *
 * @author brenneman@google.com (Shawn Brenneman)
 */


goog.provide('goog.testing.ContinuationTestCase');
goog.provide('goog.testing.ContinuationTestCase.Step');
goog.provide('goog.testing.ContinuationTestCase.Test');

goog.require('goog.array');
goog.require('goog.events.EventHandler');
goog.require('goog.testing.TestCase');
goog.require('goog.testing.asserts');



/**
 * Constructs a test case that supports tests with continuations. Test functions
 * may issue "wait" commands that suspend the test temporarily and continue once
 * the wait condition is met.
 *
 * @param {string=} opt_name Optional name for the test case.
 * @constructor
 * @extends {goog.testing.TestCase}
 * @final
 */
goog.testing.ContinuationTestCase = function(opt_name) {
  goog.testing.TestCase.call(this, opt_name);

  /**
   * An event handler for waiting on Closure or browser events during tests.
   * @type {goog.events.EventHandler<!goog.testing.ContinuationTestCase>}
   * @private
   */
  this.handler_ = new goog.events.EventHandler(this);
};
goog.inherits(goog.testing.ContinuationTestCase, goog.testing.TestCase);


/**
 * The default maximum time to wait for a single test step in milliseconds.
 * @type {number}
 */
goog.testing.ContinuationTestCase.MAX_TIMEOUT = 1000;


/**
 * Lock used to prevent multiple test steps from running recursively.
 * @type {boolean}
 * @private
 */
goog.testing.ContinuationTestCase.locked_ = false;


/**
 * The current test being run.
 * @type {goog.testing.ContinuationTestCase.Test}
 * @private
 */
goog.testing.ContinuationTestCase.prototype.currentTest_ = null;


/**
 * Enables or disables the wait functions in the global scope.
 * @param {boolean} enable Whether the wait functions should be exported.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.enableWaitFunctions_ =
    function(enable) {
  if (enable) {
    goog.exportSymbol('waitForCondition',
                      goog.bind(this.waitForCondition, this));
    goog.exportSymbol('waitForEvent', goog.bind(this.waitForEvent, this));
    goog.exportSymbol('waitForTimeout', goog.bind(this.waitForTimeout, this));
  } else {
    // Internet Explorer doesn't allow deletion of properties on the window.
    goog.global['waitForCondition'] = undefined;
    goog.global['waitForEvent'] = undefined;
    goog.global['waitForTimeout'] = undefined;
  }
};


/** @override */
goog.testing.ContinuationTestCase.prototype.runTests = function() {
  this.enableWaitFunctions_(true);
  goog.testing.ContinuationTestCase.superClass_.runTests.call(this);
};


/** @override */
goog.testing.ContinuationTestCase.prototype.finalize = function() {
  this.enableWaitFunctions_(false);
  goog.testing.ContinuationTestCase.superClass_.finalize.call(this);
};


/** @override */
goog.testing.ContinuationTestCase.prototype.cycleTests = function() {
  // Get the next test in the queue.
  if (!this.currentTest_) {
    this.currentTest_ = this.createNextTest_();
  }

  // Run the next step of the current test, or exit if all tests are complete.
  if (this.currentTest_) {
    this.runNextStep_();
  } else {
    this.finalize();
  }
};


/**
 * Creates the next test in the queue.
 * @return {goog.testing.ContinuationTestCase.Test} The next test to execute, or
 *     null if no pending tests remain.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.createNextTest_ = function() {
  var test = this.next();
  if (!test) {
    return null;
  }


  var name = test.name;
  goog.testing.TestCase.currentTestName = name;
  this.result_.runCount++;
  this.log('Running test: ' + name);

  return new goog.testing.ContinuationTestCase.Test(
      new goog.testing.TestCase.Test(name, this.setUp, this),
      test,
      new goog.testing.TestCase.Test(name, this.tearDown, this));
};


/**
 * Cleans up a finished test and cycles to the next test.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.finishTest_ = function() {
  var err = this.currentTest_.getError();
  if (err) {
    this.doError(this.currentTest_, err);
  } else {
    this.doSuccess(this.currentTest_);
  }

  goog.testing.TestCase.currentTestName = null;
  this.currentTest_ = null;
  this.locked_ = false;
  this.handler_.removeAll();

  this.timeout(goog.bind(this.cycleTests, this), 0);
};


/**
 * Executes the next step in the current phase, advancing through each phase as
 * all steps are completed.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.runNextStep_ = function() {
  if (this.locked_) {
    // Attempting to run a step before the previous step has finished. Try again
    // after that step has released the lock.
    return;
  }

  var phase = this.currentTest_.getCurrentPhase();

  if (!phase || !phase.length) {
    // No more steps for this test.
    this.finishTest_();
    return;
  }

  // Find the next step that is not in a wait state.
  var stepIndex = goog.array.findIndex(phase, function(step) {
    return !step.waiting;
  });

  if (stepIndex < 0) {
    // All active steps are currently waiting. Return until one wakes up.
    return;
  }

  this.locked_ = true;
  var step = phase[stepIndex];

  try {
    step.execute();
    // Remove the successfully completed step. If an error is thrown, all steps
    // will be removed for this phase.
    goog.array.removeAt(phase, stepIndex);

  } catch (e) {
    this.currentTest_.setError(e);

    // An assertion has failed, or an exception was raised. Clear the current
    // phase, whether it is setUp, test, or tearDown.
    this.currentTest_.cancelCurrentPhase();

    // Cancel the setUp and test phase no matter where the error occurred. The
    // tearDown phase will still run if it has pending steps.
    this.currentTest_.cancelTestPhase();
  }

  this.locked_ = false;
  this.runNextStep_();
};


/**
 * Creates a new test step that will run after a user-specified
 * timeout.  No guarantee is made on the execution order of the
 * continuation, except for those provided by each browser's
 * window.setTimeout. In particular, if two continuations are
 * registered at the same time with very small delta for their
 * durations, this class can not guarantee that the continuation with
 * the smaller duration will be executed first.
 * @param {Function} continuation The test function to invoke after the timeout.
 * @param {number=} opt_duration The length of the timeout in milliseconds.
 */
goog.testing.ContinuationTestCase.prototype.waitForTimeout =
    function(continuation, opt_duration) {
  var step = this.addStep_(continuation);
  step.setTimeout(goog.bind(this.handleComplete_, this, step),
                  opt_duration || 0);
};


/**
 * Creates a new test step that will run after an event has fired. If the event
 * does not fire within a reasonable timeout, the test will fail.
 * @param {goog.events.EventTarget|EventTarget} eventTarget The target that will
 *     fire the event.
 * @param {string} eventType The type of event to listen for.
 * @param {Function} continuation The test function to invoke after the event
 *     fires.
 */
goog.testing.ContinuationTestCase.prototype.waitForEvent = function(
    eventTarget,
    eventType,
    continuation) {

  var step = this.addStep_(continuation);

  var duration = goog.testing.ContinuationTestCase.MAX_TIMEOUT;
  step.setTimeout(goog.bind(this.handleTimeout_, this, step, duration),
                  duration);

  this.handler_.listenOnce(eventTarget,
                           eventType,
                           goog.bind(this.handleComplete_, this, step));
};


/**
 * Creates a new test step which will run once a condition becomes true. The
 * condition will be polled at a user-specified interval until it becomes true,
 * or until a maximum timeout is reached.
 * @param {Function} condition The condition to poll.
 * @param {Function} continuation The test code to evaluate once the condition
 *     becomes true.
 * @param {number=} opt_interval The polling interval in milliseconds.
 * @param {number=} opt_maxTimeout The maximum amount of time to wait for the
 *     condition in milliseconds (defaults to 1000).
 */
goog.testing.ContinuationTestCase.prototype.waitForCondition = function(
    condition,
    continuation,
    opt_interval,
    opt_maxTimeout) {

  var interval = opt_interval || 100;
  var timeout = opt_maxTimeout || goog.testing.ContinuationTestCase.MAX_TIMEOUT;

  var step = this.addStep_(continuation);
  this.testCondition_(step, condition, goog.now(), interval, timeout);
};


/**
 * Creates a new asynchronous test step which will be added to the current test
 * phase.
 * @param {Function} func The test function that will be executed for this step.
 * @return {!goog.testing.ContinuationTestCase.Step} A new test step.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.addStep_ = function(func) {
  if (!this.currentTest_) {
    throw Error('Cannot add test steps outside of a running test.');
  }

  var step = new goog.testing.ContinuationTestCase.Step(
      this.currentTest_.name,
      func,
      this.currentTest_.scope);
  this.currentTest_.addStep(step);
  return step;
};


/**
 * Handles completion of a step's wait condition. Advances the test, allowing
 * the step's test method to run.
 * @param {goog.testing.ContinuationTestCase.Step} step The step that has
 *     finished waiting.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.handleComplete_ = function(step) {
  step.clearTimeout();
  step.waiting = false;
  this.runNextStep_();
};


/**
 * Handles the timeout event for a step that has exceeded the maximum time. This
 * causes the current test to fail.
 * @param {goog.testing.ContinuationTestCase.Step} step The timed-out step.
 * @param {number} duration The length of the timeout in milliseconds.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.handleTimeout_ =
    function(step, duration) {
  step.ref = function() {
    fail('Continuation timed out after ' + duration + 'ms.');
  };

  // Since the test is failing, cancel any other pending event listeners.
  this.handler_.removeAll();
  this.handleComplete_(step);
};


/**
 * Tests a wait condition and executes the associated test step once the
 * condition is true.
 *
 * If the condition does not become true before the maximum duration, the
 * interval will stop and the test step will fail in the kill timer.
 *
 * @param {goog.testing.ContinuationTestCase.Step} step The waiting test step.
 * @param {Function} condition The test condition.
 * @param {number} startTime Time when the test step began waiting.
 * @param {number} interval The duration in milliseconds to wait between tests.
 * @param {number} timeout The maximum amount of time to wait for the condition
 *     to become true. Measured from the startTime in milliseconds.
 * @private
 */
goog.testing.ContinuationTestCase.prototype.testCondition_ = function(
    step,
    condition,
    startTime,
    interval,
    timeout) {

  var duration = goog.now() - startTime;

  if (condition()) {
    this.handleComplete_(step);
  } else if (duration < timeout) {
    step.setTimeout(goog.bind(this.testCondition_,
                              this,
                              step,
                              condition,
                              startTime,
                              interval,
                              timeout),
                    interval);
  } else {
    this.handleTimeout_(step, duration);
  }
};



/**
 * Creates a continuation test case, which consists of multiple test steps that
 * occur in several phases.
 *
 * The steps are distributed between setUp, test, and tearDown phases. During
 * the execution of each step, 0 or more steps may be added to the current
 * phase. Once all steps in a phase have completed, the next phase will be
 * executed.
 *
 * If any errors occur (such as an assertion failure), the setUp and Test phases
 * will be cancelled immediately. The tearDown phase will always start, but may
 * be cancelled as well if it raises an error.
 *
 * @param {goog.testing.TestCase.Test} setUp A setUp test method to run before
 *     the main test phase.
 * @param {goog.testing.TestCase.Test} test A test method to run.
 * @param {goog.testing.TestCase.Test} tearDown A tearDown test method to run
 *     after the test method completes or fails.
 * @constructor
 * @extends {goog.testing.TestCase.Test}
 * @final
 */
goog.testing.ContinuationTestCase.Test = function(setUp, test, tearDown) {
  // This test container has a name, but no evaluation function or scope.
  goog.testing.TestCase.Test.call(this, test.name, null, null);

  /**
   * The list of test steps to run during setUp.
   * @type {Array<goog.testing.TestCase.Test>}
   * @private
   */
  this.setUp_ = [setUp];

  /**
   * The list of test steps to run for the actual test.
   * @type {Array<goog.testing.TestCase.Test>}
   * @private
   */
  this.test_ = [test];

  /**
   * The list of test steps to run during the tearDown phase.
   * @type {Array<goog.testing.TestCase.Test>}
   * @private
   */
  this.tearDown_ = [tearDown];
};
goog.inherits(goog.testing.ContinuationTestCase.Test,
              goog.testing.TestCase.Test);


/**
 * The first error encountered during the test run, if any.
 * @type {Error}
 * @private
 */
goog.testing.ContinuationTestCase.Test.prototype.error_ = null;


/**
 * @return {Error} The first error to be raised during the test run or null if
 *     no errors occurred.
 */
goog.testing.ContinuationTestCase.Test.prototype.getError = function() {
  return this.error_;
};


/**
 * Sets an error for the test so it can be reported. Only the first error set
 * during a test will be reported. Additional errors that occur in later test
 * phases will be discarded.
 * @param {Error} e An error.
 */
goog.testing.ContinuationTestCase.Test.prototype.setError = function(e) {
  this.error_ = this.error_ || e;
};


/**
 * @return {Array<goog.testing.TestCase.Test>} The current phase of steps
 *    being processed. Returns null if all steps have been completed.
 */
goog.testing.ContinuationTestCase.Test.prototype.getCurrentPhase = function() {
  if (this.setUp_.length) {
    return this.setUp_;
  }

  if (this.test_.length) {
    return this.test_;
  }

  if (this.tearDown_.length) {
    return this.tearDown_;
  }

  return null;
};


/**
 * Adds a new test step to the end of the current phase. The new step will wait
 * for a condition to be met before running, or will fail after a timeout.
 * @param {goog.testing.ContinuationTestCase.Step} step The test step to add.
 */
goog.testing.ContinuationTestCase.Test.prototype.addStep = function(step) {
  var phase = this.getCurrentPhase();
  if (phase) {
    phase.push(step);
  } else {
    throw Error('Attempted to add a step to a completed test.');
  }
};


/**
 * Cancels all remaining steps in the current phase. Called after an error in
 * any phase occurs.
 */
goog.testing.ContinuationTestCase.Test.prototype.cancelCurrentPhase =
    function() {
  this.cancelPhase_(this.getCurrentPhase());
};


/**
 * Skips the rest of the setUp and test phases, but leaves the tearDown phase to
 * clean up.
 */
goog.testing.ContinuationTestCase.Test.prototype.cancelTestPhase = function() {
  this.cancelPhase_(this.setUp_);
  this.cancelPhase_(this.test_);
};


/**
 * Clears a test phase and cancels any pending steps found.
 * @param {Array<goog.testing.TestCase.Test>} phase A list of test steps.
 * @private
 */
goog.testing.ContinuationTestCase.Test.prototype.cancelPhase_ =
    function(phase) {
  while (phase && phase.length) {
    var step = phase.pop();
    if (step instanceof goog.testing.ContinuationTestCase.Step) {
      step.clearTimeout();
    }
  }
};



/**
 * Constructs a single step in a larger continuation test. Each step is similar
 * to a typical TestCase test, except it may wait for an event or timeout to
 * occur before running the test function.
 *
 * @param {string} name The test name.
 * @param {Function} ref The test function to run.
 * @param {Object=} opt_scope The object context to run the test in.
 * @constructor
 * @extends {goog.testing.TestCase.Test}
 * @final
 */
goog.testing.ContinuationTestCase.Step = function(name, ref, opt_scope) {
  goog.testing.TestCase.Test.call(this, name, ref, opt_scope);
};
goog.inherits(goog.testing.ContinuationTestCase.Step,
              goog.testing.TestCase.Test);


/**
 * Whether the step is currently waiting for a condition to continue. All new
 * steps begin in wait state.
 * @type {boolean}
 */
goog.testing.ContinuationTestCase.Step.prototype.waiting = true;


/**
 * A saved reference to window.clearTimeout so that MockClock or other overrides
 * don't affect continuation timeouts.
 * @type {Function}
 * @private
 */
goog.testing.ContinuationTestCase.Step.protectedClearTimeout_ =
    window.clearTimeout;


/**
 * A saved reference to window.setTimeout so that MockClock or other overrides
 * don't affect continuation timeouts.
 * @type {Function}
 * @private
 */
goog.testing.ContinuationTestCase.Step.protectedSetTimeout_ = window.setTimeout;


/**
 * Key to this step's timeout. If the step is waiting for an event, the timeout
 * will be used as a kill timer. If the step is waiting
 * @type {number}
 * @private
 */
goog.testing.ContinuationTestCase.Step.prototype.timeout_;


/**
 * Starts a timeout for this step. Each step may have only one timeout active at
 * a time.
 * @param {Function} func The function to call after the timeout.
 * @param {number} duration The number of milliseconds to wait before invoking
 *     the function.
 */
goog.testing.ContinuationTestCase.Step.prototype.setTimeout =
    function(func, duration) {

  this.clearTimeout();

  var setTimeout = goog.testing.ContinuationTestCase.Step.protectedSetTimeout_;
  this.timeout_ = setTimeout(func, duration);
};


/**
 * Clears the current timeout if it is active.
 */
goog.testing.ContinuationTestCase.Step.prototype.clearTimeout = function() {
  if (this.timeout_) {
    var clear = goog.testing.ContinuationTestCase.Step.protectedClearTimeout_;

    clear(this.timeout_);
    delete this.timeout_;
  }
};
