/* vim: set ai sts=4 sw=4 : */
/**
 * TestIckle JavaScript Test Library
 *
 * This file provides some minimal test functionality to find and execute
 * functions names testSomething, presenting statistics and results in a
 * semi-pleasant manner.
 *
 * This was originally developed to run on new and *old* browsers, and
 * therefore tries to avoid relying on functions missing in older browsers.  It
 * contains ugly code for patching arrays to include 'push' and 'map' type
 * functionality, as well as some hideous reporting/presentation code (the
 * result of removing a 'sprintf' dependency, which itself depends on three or
 * four modern functions.)
 *
 * Variables inserted into Global Namespace:
 * IcKle, assert, assertStrictEquals, assertFail, assertUndefined
 *
 * Discussion of 'ickle': http://www.randomhouse.com/wotd/index.pperl?date=19990726
 *
 * @version 2007.03.14
 * @author <a class="vcard url fn" href="http://hexmen.com/blog/">Ash Searle</a>
 * @license http://creativecommons.org/licenses/by/2.5/
 */

/**
 * Assert the condition is true
 * @param assertion the assertion (only boolean <code>true</code> passed the assertion);
 * @param message (optional)
 */
function assert(assertion, message) {
    // keep track of number of assertions
    Ickle.currentTest.assertions++;
    // use strict equality
    if (assertion === true) {
	// window.console && console.info(message);
	Ickle.currentTest.passes++;
    } else {
	// window.console && console.warn(message);
	Ickle.currentTest.failures.push(message);
    }
}

function assertStrictEquals(expected, received, message) {
    assert(expected === received, message || 'Expected: ' + typeof(expected) + '(' + expected + '), received: ' + typeof(received) + '(' + received + ')');
}

function assertUndefined(value) {
    assertStrictEquals(void(0), value);
}

function assertFail(message) {
    assert(false, message);
}

/**
 * Execute and display the results of running a set of tests.
 * @param tests an optional array of functions.  If the array is given, we
 * assume it contains test functions to be executed.  If no array is passed in,
 * we search for functions in global-scope named 'testSomething', and execute
 * the functions in the order we find them.
 *
 * Implementation note: once the DOM is loaded, the function will/may be passed an Event object!
 */
function Ickle(tests) {
    function fix(array) {
	// Temporary fix for IE5:
	// Normally, we'd implement Array.prototype.push, but as this library
	// was developed specifically to test a prototype'd push method, we
	// can't go trampling all over it.
	if (!array.push) {
	    array.push = function() {
		var n = this.length >>> 0;
		for (var i = 0; i < arguments.length; i++) {
		    this[n] = arguments[i];
		    n = n + 1 >>> 0;
		}
		this.length = n;
		return n;
	    };
	}
	if (!array.map$) {
	    // inplace map (cf: ruby's map!)
	    array.map$ = function(f) {
		for (var i = 0; i < this.length; i++) {
		    this[i] = f(this[i]);
		}
		return this;
	    }
	}
	return array;
    }

    function findTests() {
	var property, functionNames = fix([]), scripts = document.scripts;

	// try the standard way first:
	for (property in window) {
	    if (typeof window[property] == 'function' && property.substring(0, 4) == 'test') {
		functionNames.push(property);
	    }
	}

	if (functionNames.length == 0 && scripts) {
	    // Internet Explorer kludge: iterate over JavaScript source looking
	    // for any functions called testSomething
	    for (var i = 0; i < scripts.length; i++) {
		var source = scripts[i].text;
		var matches = source.match(/\bfunction test[A-Z0-9][a-zA-Z0-9-_$]*\b/g);
		for (var j = 0; matches && j < matches.length; j++) {
		    property = matches[j].replace(/function\s+/, '');
		    if (typeof window[property] == 'function') {
			functionNames.push(property);
		    }
		}
	    }
	}

	// functionNames now contains the *names* of all functions we wish to test.
	// Note: these function names /may/ not be available in global-scope
	return functionNames;
    }

    function htmlEscape(o) {
	return String(o).replace(/&/g, '\u0000').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\u0000/g, '&amp;');
    }

    function formatError(e) {
	if (String(e) == '[object Error]') {
	    var text = fix([]);
	    for (var p in e) {
		text.push(String(p), ': ', String(e[p]), '\n');
	    }
	    e = text.join('');
	}
	return '<pre style="display: block">' + htmlEscape(e) + '</pre>';
    }

    function toPercent(number) {
	return String((number * 100) >>> 0) + '.' + (String((number * 100) % 1) + '000').substring(2,4);
    }

    // find tests
    if (tests == null || !(tests instanceof Array)) {
	tests = findTests();
    }

    // set-up variable to hold the report and test-suite stats.
    var suite = {tests: fix([]), passed: 0, assertions: 0, passedAssertions: 0, failures: 0, errors: 0};

    // iterate over all tests
    for (var i = 0, l = (tests && tests.length) || 0; i < l; i++) {
	var test = tests[i];
	Ickle.currentTest = {name: test, assertions: 0, passes: 0, failures: fix([]), errors: fix([])};
	try {
	    if (typeof test == 'function') {
		test();
	    } else if (typeof window[test] == 'function') {
		window[test]();
	    } else {
		assert(false, 'Could not find function in global scope: ' + test);
	    }
	} catch (e) {
	    // window.console && console.error(e);
	    Ickle.currentTest.errors.push(e);
	}
	suite.tests.push(Ickle.currentTest);
    }

    // process stats
    for (var i = 0, l = suite.tests.length; i < l; i++) {
	var test = suite.tests[i];
	suite.assertions += test.assertions;
	suite.passedAssertions += test.passes;
	if (test.errors.length) {
	    suite.errors++;
	} else if (test.failures.length) {
	    suite.failures++;
	} else {
	    suite.passed++;
	}
    }

    // generate report/summary (Yes, I KNOW this is fugly)
    // TODO: extract/use/hard-code a style-sheet instead of using inline-styles...?
    var html = fix([]);
    var bgColor = suite.errors ? '#f66' : suite.failures ? '#faa' : '#afa';
    var testPercent = suite.tests.length ? toPercent(suite.passed / suite.tests.length) : 'n/a';
    var assertionPercent = suite.assertions ? toPercent(suite.passedAssertions / suite.assertions) : 'n/a';
    html.push(Array('<p style="border: medium solid #000; padding: 0.2em; background-color: ', bgColor, '">Summary: ', testPercent, '% tests (', suite.passed, '/', suite.tests.length, ') with ', suite.errors, ' errors, ', suite.failures, ' failures.  ',
    assertionPercent, '% attempted assertions (', suite.passedAssertions, '/', suite.assertions, ')</p>').join(''));
    if (suite.tests.length) {
	html.push(
		'<table bordercolor="#000" border="1" cellpadding="2" cellspacing="1" style="border: 1px dashed #000; border-collapse: collapse; font-family: Tahoma">',
		'<caption>Test Results</caption>',
		'<thead><tr><th>Status</th><th>Test</th><th>Message</th></thead>',
		'<tbody>'
		);
	for (var i = 0, l = suite.tests.length; i < l; i++) {
	    var testResults = suite.tests[i];
	    var status = testResults.errors.length ? 'Error' : testResults.failures.length ? 'Failed' : 'Passed';
	    var bgColor = testResults.errors.length ? '#f66' : testResults.failures.length ? '#faa' : '#afa';
	    var name = testResults.name;
	    var percent = 'n/a';
	    if (testResults.errors.length == 0 && testResults.assertions) {
		percent = toPercent((testResults.assertions - testResults.failures.length) / testResults.assertions);
	    }
	    var passedAssertions = testResults.assertions - testResults.failures.length;
	    var message = Array(percent, '% assertions (', passedAssertions, '/', testResults.assertions, ') with ', testResults.failures.length, ' failures, ', testResults.errors.length, ' errors').join('');
	    if (testResults.failures.length || testResults.errors.length) {
		html.push(Array('<tr onclick="this.parentNode.rows[this.sectionRowIndex+1].style.display=&quot;&quot;" style="cursor: pointer; cursor: hand; background-color: ', bgColor, '"><td>', status, '</td><td>', name, '</td><td>', message, '</td></tr>').join(''));
		html.push('<tr style="display: none"><td colspan="3">');
		if (testResults.failures.length) {
		    html.push('<ol><li>', testResults.failures.map$(htmlEscape).join('</li><li>'), '</li></ol>');
		}
		if (testResults.errors.length) {
		    html.push('<ol><li>', testResults.errors.map$(formatError).join('</li><li>'), '</li></ol>');
		}
		html.push('</td></tr>');
	    } else {
		html.push(Array('<tr style="background-color: ', bgColor, '"><td>', status, '</td><td>', name, '</td><td>', message, '</td></tr>').join(''));
	    }
	}
	html.push(
		'</tbody>',
		'</table>'
		);
    }

    // output results
    var results = document.getElementById('results');
    if (!results) {
	results = document.body.appendChild(document.createElement('div'));
	results.id = 'results';
	results.style.fontSize = 'medium';
	results.style.fontFamily = 'Tahoma';
    }
    results.innerHTML = html.join('\n');

    // make browser jupm to results:
    location.hash = 'results';
}
