window.onload = function() {

nf.Testing.init();
if(navigator.userAgent.match(/msie/i)) {
    alert('TestCentre uses advanced software features not currently supported ' +
        'by Internet Explorer. We recommend Firefox or Safari as alternatives.')
}

};

// test controller class nf.Testing = {

TEST_PROMPT: 'Enter a descriptive name for this test',

DEFAULT_TEST: "# Netfira WebConnect Test Script\n" +
    "\n" +
    "# Please refer to WebConnect documentation for script syntax.",

DEFAULT_TEST_NAME: 'Untitled Test',

DEFAULT_ORDER_TIMEOUT: 60,

isBusy: null,

isTesting: null,

busy: function(isBusy) {
    this.isBusy = !!isBusy;
    nf.className(document.body, 'busy', isBusy ? 1 : -1);
},

tests: [],

activeTestIndex: -1,

activeTest: function() {
    return this.tests.length
        ? this.tests[this.activeTestIndex]
        : null;
},

init: function() {

    this.busy(true);

    this.testList = new nf.ListView(nf('#tests'));
    this.results.list = new nf.ListView(nf('#actions'));

    nf.api.get('info', function(x) {
        this.owner.receiveSchema(x.info.schema);
        this.owner.locale = x.info.locale;
        this.owner.allowCustomFields = x.info.customFields;
    }).owner = this;

    this.results.rootElement = nf('#results');

    document.getElementsByTagName('head')[0].appendChild(nf(
        '<link>',
        null,
        {
            rel: 'shortcut icon',
            type: 'image/gif',
            href: 'data:image/gif;base64,' + this.icon
        }
    ));

},

receiveSchema: function(schema) {

    this.schema = schema;

    // reverse lookup

    var i;
    this.singular = {};
    for(i in schema) {
        this.singular[schema[i].singular] = i;
    }

    nf.api.get('settings/testScripts', function(x) {
        this.owner.receiveTests(x)
    }).owner = this;

},

receiveTests: function(r) {

    var i;

    if(r instanceof Array) {
        for(i = 0; i < r.length; i++) {
            this.addTest(new this.Test(r[i].name, r[i].source));
        }
        this.showTest(0);
    } else {
        this.create();
    }

    this.busy(false);
},

rename: function() {

    if(this.isBusy || this.isTesting) {
        return;
    }

    var test = this.activeTest(),
        a = test.listItem.firstChild,
        newName = prompt(this.TEST_PROMPT, test.name);

    if(!newName) {
        return;
    }

    test.name = newName;
    a.innerHTML = '';
    nf('<', a, newName);

    nf('#source').focus();

},

del: function() {

    if(this.isBusy || this.isTesting) {
        return;
    }

    if(this.tests.length == 1) {
        alert("You can't delete the only remaining test. Please add another first.");
    }

    else if(confirm("Delete test '" + this.activeTest().name + "'?")) {
        this.testList.removeItem(this.activeTest().listItem);
        this.tests.splice(this.activeTestIndex, 1);
        var i = this.activeTestIndex;
        this.activeTestIndex = -1;
        if(i == this.tests.length) {
            i--;
        }
        this.showTest(i);
    }
},

add: function() {

    if(this.isBusy || this.isTesting) {
        return;
    }

    var x = prompt(this.TEST_PROMPT, this.DEFAULT_TEST_NAME);
    if(x) {
        this.create(x);
    }
},

create: function(name) {
    this.addTest(new this.Test(name || this.DEFAULT_TEST_NAME, this.DEFAULT_TEST))
},

save: function() {

    if(this.isBusy || this.isTesting) {
        return;
    }

    this.readSource();

    var data = [],
        i;

    for(i = 0; i < this.tests.length; i++) {
        data.push({
            name: this.tests[i].name,
            source: this.tests[i].source
        });
    }

    this.busy(true);

    nf.api.put('settings/testScripts', data, function() {
        this.owner.busy(false)
    }).owner = this;

},

addTest: function(test) {
    this.tests.push(test);
    this.showTest(this.tests.length - 1);
},

showTest: function(test) {
    if(this.activeTestIndex >= 0) {
        this.readSource();
    }
    if(typeof test == 'number') {
        test = this.tests[test];
    }
    this.activeTestIndex = test.index();
    this.testList.activateItem(test.listItem);
    with(nf('#source')) {
        value = test.source;
        focus();
    }

},

run: function() {

    if(this.isBusy || this.isTesting) {
        return;
    }

    this.isTesting = true;

    this.readSource();

    var test = this.activeTest();
    if(!test) {
        return;
    }

    this.results.list.clear();

    test.run();

},

readSource: function() {
    var test = this.activeTest();
    if(test) {
        test.source = nf('#source').value;
    }
},

// results view
results: {

    lastResult: null,

    list: null,

    scroll: function() {
        var div = nf('#results');
        div.scrollTop = div.scrollHeight;
    },

    newResultView: function() {
        var ret = this.lastResult = new nf.Testing.ResultView(this.list.addItem());
        this.scroll();
        return ret;
    },

    addStartTime: function() {
        var result = this.newResultView();
        result.setClass('time');
        result.setHeading('Begin at ' + nf.timeString());
    },

    addEndTime: function(success) {
        var result = this.newResultView();
        result.setClass('time');
        result.setHeading((success ? 'Complete at ' : 'Fail at ') + nf.timeString());
    },

    sendFile: function(type, name, size) {
        var result = this.newResultView();
        result.setClass('sendFile');
        result.setHeading('Send ' + type + ' ', name, ' (', size, ' bytes)');
    },

    addAction: function(action) {

        var result = this.newResultView(),
            i, c = nf.Testing.Test.COMMANDS, commandName, ext;

        for(i in c) {
            if(c[i] == action.command) {
                result.setClass(commandName = i.toLowerCase());
            }
        }

        if(action.command == c.UPDATE || action.command == c.DELETE || action.command == c.REPLICATE) {
            result.setHeading(
                    nf.ucfirst(commandName) + ' ' + action.type + ' ',
                action.id()
            );
        }

        else if(action.command == c.ADD || action.command == c.REMOVE) {
            result.setHeading(
                    nf.ucfirst(commandName) + ' ' + action.types[0] + ' ',
                action.ids[0],
                    (action.command == c.ADD ? ' to ' : ' from ') + action.types[1] + ' ',
                action.ids[1]
            );
        }

        else if(action.command == c.WAIT) {
            result.setHeading(
                'Wait ',
                Math.max(0, Math.round(parseFloat(action.duration) * 10) / 10),
                    action.duration == 1 ? ' second' : ' seconds'
            );
        }

        else if(action.command == c.COMMIT) {
            if('timeout' in action) {
                result.setHeading('Commit ', i = action.test.commitSize(), (i == 1 ? ' change' : ' changes') + ' (', action.timeout, ' sec max)');
            }
            else {
                result.setHeading('Commit ', i = action.test.commitSize(), i == 1 ? ' change' : ' changes');
            }
            if(!i) {
                result.setStatus('Nothing to commit');
            }
        }

        else if(action.command == c.PURGE) {
            if('timeout' in action) {
                result.setHeading('Purge ', action.table, ' (', action.timeout, ' sec max)');
            }
            else {
                result.setHeading('Purge ', action.table);
            }
        }

        else if(action.command == c.ORDERS) {
            result.setHeading('Fetch Orders (', action.timeout, ' sec max)');
        }

        else if(action.command == c.LOCALE) {
            result.setHeading('Use ', action.locale, ' as default locale');
        }

        if(action.command == c.UPDATE) {

            if(fileTables.indexOf(action.table) != -1) {

                // file records

                result.setClass('file');
                if(ext = action.id().match(/\.(jpe?g|gif|png|bmp)$/i)) {
                    result.setImage('data:image/' + ext[1] + ';base64,' + action.base64);
                }

                result.setChecksum(action.fields.checksum);

                result.setFileSize(action.binary.length);

            } else {

                // regular records

                var fields = {};
                for(i in action.fields) {
                    if(i != action.def.primaryKey[0]) {
                        fields[i] = action.fields[i];
                    }
                }

                result.setDetails(fields);

            }
        }

        return result;
    },

    addData: function(title, data, excludeTime) {
        this.lastResult.addData(title, data, excludeTime);
        this.scroll();
    },

    showWaitTime: function(seconds) {

        seconds = Math.max(0, Math.round(parseFloat(seconds) * 10) / 10);

        if(!seconds) {
            this.lastResult.setClass('done');
            this.lastResult.setStatus('Done')
        } else {
            this.lastResult.setStatus('', seconds.toFixed(1), ' seconds left');
        }

        this.scroll();

    },

    showElapsed: function(seconds, complete) {

        seconds = Math.max(0, Math.round(parseFloat(seconds) * 10) / 10);

        if(!complete) {
            this.lastResult.setStatus('', seconds.toFixed(1), ' seconds elapsed');
        }
        else {
            this.lastResult.setStatus('Complete in ', seconds.toFixed(1), ' seconds');
            this.lastResult.setClass('done');
        }

    },

    showChangeCount: function(portion, total) {
        this.lastResult.setInfo(
            'Processed ',
            portion,
            ' of ',
            total,
                ' change' + (total == 1 ? '' : 's')
        );
        this.scroll();
    },

    showCompleteStatus: function(complete) {
        this.lastResult.setInfo(complete ? 'All records deleted' : 'Some records remain');
        this.scroll();
    },

    showParseException: function(e) {
        var result = this.newResultView();
        result.setClass('parse error');
        result.setHeading('Parse error on line ', e.lineNumber + 1);
        result.setStatus(e.description);
        result.setErrorData(e.line);
        this.scroll();
    },

    showUserError: function(data) {
        var result = this.newResultView();
        result.setClass('user error');
        result.setHeading('Server returned user error code ', data.errorCode);
        result.setErrorData(data);
        this.scroll();
    },

    showServerError: function(code, data) {
        var result = this.newResultView();
        result.setClass('server error');
        result.setHeading('Server responded with code ', code);
        result.setErrorData(data);
        this.scroll();
    },

    showError: function(heading, info) {
        var result = this.newResultView();
        result.setClass('error');
        result.setHeading(heading);
        result.setStatus(info);
        this.scroll();
    },

    confirmOrder: function(order) {
        var result = this.newResultView();
        result.setClass('confirmOrder');
        result.setHeading('Confirm Order ', order.fields.orderId);
        this.addData('Order Data', order, true);
    }

},

// result view controller
ResultView: function(rootElement) {
    this.rootElement = rootElement;
},

// test model class
Test: function(name, source) {
    var that = this;
    this.source = source;
    this.name = name;
    this.actions = [];
    this.actionIndex = -1;
    this.listItem = nf.Testing.testList.addItem();
    nf(
        '<',
        nf(
            '<a>',
            this.listItem,
            {
                href: 'javascript:',
                onclick: function() {
                    if(!(nf.Testing.isBusy || nf.Testing.isTesting)) {
                        nf.Testing.showTest(that)
                    }
                }
            }
        ),
        name
    );
    this.listItem.firstChild.ondblclick = function() {
        nf.Testing.rename()
    };
},

ImportExport: {

    importing: null,

    im: function() {
        nf('#importExport').className = 'import';
        nf('#series').value = '';
        this.show(true);
        this.importing = true;
    },

    ex: function() {
        nf('#importExport').className = 'export';

        var i, x = '', t = nf.Testing.tests;

        for(i = 0; i < t.length; i++) {
            x += "- " + t[i].name + "\n\n" + t[i].source + "\n\n";
        }

        nf('#series').value = x;

        this.show(true);
        this.importing = false;
    },

    show: function(show) {
        nf.Testing.busy(show);
        nf('#importExport').style.display = show ? 'block' : 'none';
        if(show) {
            with(nf('#series')) {
                focus();
                select();
            }
        }
    },

    ok: function() {
        if(this.importing) {
            var t = nf('#series').value.trim().split(/\s*(?:[\r\n]+|^)-\s*/),
                i, m;
            if(!t || t.length <= 1) {
                return alert('The input you entered appears to be invalid.');
            }
            nf.Testing.tests = [];
            nf.Testing.testList.clear();
            nf.Testing.activeTestIndex = -1;
            for(i = 1; i < t.length; i++) {
                m = t[i].match(/^(.*?)[\r\n]+([\s\S]*)$/);
                if(m) {
                    nf.Testing.addTest(new nf.Testing.Test(m[1].trim(), m[2].trim()));
                }
            }
            nf.Testing.showTest(0);
        }
        this.show(false);
    },

    cancel: function() {
        this.show(false);
    }
},

icon: 'R0lGODlhEAAQALMAAON4ce2Siu2wrtg4ONdJRfXOzeeEe+BtZ9hbVNdSTNomLNxkXdkuMtc/Puqf' +
    'nv///yH5BAAAAAAALAAAAAAQABAAAASQMMhgDADnLISSl9WVbZ2XEJV1LYKQLB5xqkfwGK1xEzyB' +
    'HYaDo4QYLngNzeHhMJ18AkSjwUEYirVggLCYUhOthMFRKAgODYBhMPC0ZOby9MJ+Ak6OS2CKYDNk' +
    'CA9bXmwHAgQMfwRpCQ4EbA1bCYmKU0tCAgsPB5QKXg0Djw4ODA0MCqieoGx+lKeprK2JqbQRADs='

};

nf.Testing.ResultView.prototype = {

setClass: function(c) {
    nf.className(this.rootElement, c, 1);
},
inClass: function(c) {
    return nf.className(this.rootElement, c);
},
addData: function(title, data, excludeTime, expanded) {
    var div = nf('<div>', null, {className: 'data ' + title.toLowerCase().replace(/\s+([a-z])/, function(a, b) {
            return b.toUpperCase()
        })}),
        formatted = nf('<div>', div, {className: 'formatted'});
    this.addExpander(title, div, false, expanded);
    if(!excludeTime) {
        this.addTime();
    }
    this.rootElement.appendChild(div);
    this.showDataIn(data, formatted);
    if(typeof data == 'object') {
        var raw = nf('<div>', div, {className: 'raw'});
        this.addExpander('Raw view', raw, div);
        div.appendChild(raw);
        nf('<', raw, nf.json_encode(data));
    }
},
showDataIn: function(data, target) {

    var ol, i, tbody, tr;

    if(data === null) {
        nf.className(target, 'null', 1);
        target.innerHTML = 'NULL';
    }

    else if(data === true || data === false) {
        nf.className(target, 'boolean', 1);
        target.innerHTML = data ? 'True' : 'False';
    }

    else if(typeof data === 'number' || typeof data === 'string') {
        nf.className(target, typeof data, 1);
        target.innerHTML = '';
        nf('<', target, data.toString());
    }

    else if(data instanceof Array) {
        nf.className(target, 'array', 1);
        ol = nf('<ol>', target);
        ol.start = 0;
        ol.style.counterReset = 'item 0';
        for(i = 0; i < data.length; i++) {
            arguments.callee(data[i], nf('<li>', ol));
        }
    }

    else {
        nf.className(target, 'object', 1);
        tbody = nf('<tbody>', nf('<table>', target, {cellSpacing: 0}));
        for(i in data) {
            tr = nf('<tr>', tbody);
            nf('<', nf('<th>', tr), nf.Testing.ResultView.prototype.hyphenate(i));
            arguments.callee(data[i], nf('<td>', tr));
        }
    }

},

hyphenate: function(str) {
    return str
        .replace(/([^A-Z])([A-Z])/g, '$1 $2')
        .replace(/&/g, ' & ')
        .replace(/^([a-z])/, function(a) {
            return a.toUpperCase()
        });
},

addTime: function() {
    nf('<', nf('<span>', this.rootElement.lastChild, {className: 'time'}), ' at ' + nf.timeString());
},
setElement: function(className, strings, nodeName) {
    if(!(className in this)) {
        this[className] = nf('<' + (nodeName || 'div') + '>', this.rootElement, {className: className});
    }
    this[className].innerHTML = '';
    for(var i = 0; i < strings.length; i++) {
        if(i % 2) {
            nf('<', nf('<strong>', this[className]), strings[i]);
        }
        else {
            nf('<', this[className], strings[i]);
        }
    }
},
setHeading: function() {
    this.setElement('heading', arguments, 'h4');
},
setStatus: function() {
    this.setElement('status', arguments);
},
setInfo: function() {
    this.setElement('info', arguments);
    this.setClass('hasInfo');
},
setErrorData: function(data) {
    if(typeof data == 'string') {
        this.setElement('errorData', arguments, 'pre');
    }
    else {
        this.addData('Details', data, true, true);
    }
},
setImage: function(src) {
    var pa = this.getPreviewArea(),
        img;
    pa.innerHTML = '';
    img = nf('<img>', pa, {src: src});
    if(img.height > 130) {
        img.height = 130;
    }
    if(img.width > 160) {
        delete img['height'];
        img.width = 160;
    }
},
setFileSize: function(bytes) {
    if(!('fileSize' in this)) {
        this.fileSize = nf('<div>', this.getPreviewArea(), {className: 'fileSize'});
    }
    this.fileSize.innerHTML = bytes + ' bytes';
},
setChecksum: function(md5) {
    if(!('checksum' in this)) {
        this.checksum = nf('<pre>', this.getPreviewArea(), {className: 'checksum'});
    }
    this.checksum.innerHTML = '';
    nf('<', this.checksum, this.splitChecksum(md5.substr(0, 11)) + "\n" + this.splitChecksum(md5.substr(11)));
},
splitChecksum: function(half) {
    return half.substr(0, 3) + ' ' +
        half.substr(3, 3) + ' ' +
        half.substr(6, 3) + ' ' +
        half.substr(9);
},
getPreviewArea: function() {
    if(!('previewArea' in this)) {
        this.previewArea = nf('<div>', null, {className: 'preview'});
        this.addExpander('Preview', this.previewArea);
        this.rootElement.appendChild(this.previewArea);
    }
    return this.previewArea;
},
setDetails: function(data) {
    if(!('detailsTable' in this)) {
        this.detailsTable = nf('<table>');
        nf.className(this.addExpander('Details', this.detailsTable), 'details', 1);
        this.detailsTable.cellSpacing = 0;
        this.detailsTable.className = 'details';
        nf('<tbody>', this.detailsTable);
        this.rootElement.appendChild(this.detailsTable);
    }
    var tbody = this.detailsTable.tBodies[0],
        i, j, tr = null, c = false;

    while(tbody.firstChild) {
        tbody.removeChild(tbody.firstChild);
    }

    for(i in data) {
        if(typeof data[i] == 'object') {
            for(j in data[i]) {
                tr = this.addDetailToTBody(i + '.' + j, data[i][j], tbody, c ? '' : 'first');
                c = true;
            }
        }
        else {
            tr = this.addDetailToTBody(i, data[i], tbody, c ? '' : 'first');
        }
        c = true;
    }
    if(tr) {
        nf.className('tr', 'last', 1);
    }

},
addDetailToTBody: function(field, value, tbody, className) {
    var tr = nf('<tr>', tbody, {className: className}),
        c = {className: value.match(/^-?(\d+(\.\d*)|\.\d+)$/) ? 'number' : 'string'};
    nf('<', nf('<th>', tbody, c), field);
    nf('<', nf('<td>', tbody, c), c.className == 'number' ? parseFloat(value).toString() : value);
    return tr;
},
addExpander: function(text, target, parent, expanded) {
    var div = nf('<div>', parent || this.rootElement, {className: 'expander'}),
        ret = nf('<a>', div);
    nf('<', ret, text);
    ret.href = 'javascript:';
    ret.className = expanded ? 'open' : 'closed';
    if(!expanded) {
        target.style.display = 'none';
    }
    ret.eTarget = target;
    ret.onclick = function() {
        var open = nf.className(this, 'open');
        nf.className(this, 'open', open ? -1 : 1);
        nf.className(this, 'closed', open ? 1 : -1);
        this.eTarget.style.display = open ? 'none' : '';
    };
}

};

// test model class nf.Testing.Test.prototype = {

index: function() {
    var i = nf.Testing.tests.length;
    while(i--) {
        if(nf.Testing.tests[i] === this) {
            return i;
        }
    }
},

run: function() {

    var e;

    if(this.actionIndex != -1) // already running
    {
        return;
    }

    nf.Testing.results.addStartTime();

    try {
        this.parse();
    } catch(e) {
        if(e instanceof this.ParseException) {
            nf.Testing.results.showParseException(e);
            return this.terminate(false);
        } else {
            throw(e);
        }
    }

    this.commit = {
        records: {},
        relations: {}
    };

    this.runNextAction();

},

commitSize: function() {
    if(!('commit' in this)) {
        return 0;
    }
    var ret = 0, i, j;
    for(i in this.commit) {
        for(j in this.commit[i]) {
            ret += this.commit[i][j].length;
        }
    }
    return ret;
},

terminate: function(success) {
    if('commit' in this) {
        delete this['commit'];
    }
    this.actionIndex = -1;
    nf.Testing.results.addEndTime(success);
    nf.Testing.isTesting = false;
},

runNextAction: function(doNotIncrement) {

    if(!doNotIncrement) {
        this.actionIndex++;
    }

    if(this.actionIndex >= this.actions.length) {
        return this.terminate(true);
    }

    var action = this.actions[this.actionIndex],
        commands = nf.Testing.Test.COMMANDS,
        i, group;

    nf.Testing.results.addAction(action);

    switch(action.command) {

        case commands.UPDATE:
        case commands.DELETE:

            if(!(action.type in this.commit.records)) {
                this.commit.records[action.type] = [];
            }

            group = this.commit.records[action.type];

            i = group.length;
            while(i--) {
                if(group[i][action.def.primaryKey[0]] == action.id()) {
                    group.splice(i, 1);
                    break;
                }
            }

            group.push(action.fields);

            break;

        case commands.ADD:
        case commands.REMOVE:

            if(action.types[0] > action.types[1]) {
                action.types = [action.types[1], action.types[0]];
                action.ids = [action.ids[1], action.ids[0]];
            }

            group = encodeURIComponent(action.types[0])
                + '&'
                + encodeURIComponent(action.types[1]);

            if(!(group in this.commit.relations)) {
                this.commit.relations[group] = [];
            }

            group = this.commit.relations[group];

            i = group.length;
            while(i--) {
                if(group[i].a == action.ids[0] && group[i].b == action.ids[1]) {
                    group.splice(i, 1);
                    return;
                }
            }

            group.push({
                a: action.ids[0],
                b: action.ids[1],
                x: action.command == commands.ADD
            });

            break;

        case commands.WAIT:

            this.startTimer(action.duration);
            this.ontimeout = this.runNextAction;

            return;

        case commands.COMMIT:

            if(this.commitSize()) {
                this.setApiTimeout(action);
                this.startTimer();
                nf.Testing.results.addData('Request', this.commit);
                this.lastCall = nf.api.put(
                    'commit', this.commit,
                    this.commitResponse
                ).set('owner', this);
                return;
            }

            break;

        case commands.REPLICATE:

            this.lastCall = nf.api.post(
                    'records/' + action.type + '/external',
                action.id(),
                this.replicateResponse
            ).set('owner', this);
            nf.Testing.results.addData('Request', this.lastCall.body);

            return;

        case commands.PURGE:

            this.setApiTimeout(action);
            this.startTimer();
            this.lastCall = nf.api.del(
                    "records/" + action.type,
                this.purgeResponse
            ).set('owner', this);
            nf.Testing.results.addData('Request', this.lastCall.body);

            return;

        case commands.ORDERS:

            this.setApiTimeout(action);
            this.startTimer();
            this.lastCall = nf.api.get(
                'newOrders',
                this.ordersResponse
            ).set('owner', this);
            nf.Testing.results.addData('Request', this.lastCall.body);

            return;

    }

    this.runNextAction();

},

replicateResponse: function(r) {
    if(!this.success) {
        return this.owner.showErrorResponse(r);
    }
    nf.Testing.results.addData('Response', r);
    this.owner.runNextAction();
},

setApiTimeout: function(action) {
    var secs = (action && ('timeout' in action)) ? action.timeout : null;
    if(typeof secs == 'number') {
        nf.api.headers['X-Timeout'] = secs;
    }
    else if('X-Timeout' in nf.api.headers) {
        delete nf.api.headers['X-Timeout'];
    }
},

showErrorResponse: function(r) {
    var code = this.lastCall.xh.status;
    if(code >= 300) {
        nf.Testing.results.addData('Error', 'Status code ' + code + ' (see report below)');
        nf.Testing.results.showServerError(code, r);
    } else {
        nf.Testing.results.addData('Error', 'User error code ' + r.errorCode + ' (see report below)');
        nf.Testing.results.showUserError(r);
    }
    return this.terminate(false);
},

purgeResponse: function(r) {

    this.owner.stopTimer();
    if(!this.success) {
        return this.owner.showErrorResponse(r);
    }
    nf.Testing.results.showCompleteStatus(!r.incomplete);
    nf.Testing.results.addData('Response', r);
    this.owner.runNextAction(r.incomplete);

},

ordersResponse: function(r) {
    this.owner.stopTimer();
    if(!this.success) {
        return this.owner.showErrorResponse(r);
    }
    nf.Testing.results.addData('Response', r);
    this.owner.confirmOrders(r.orders);
},

confirmOrders: function(orders) {
    if(!orders.length) {
        return this.runNextAction();
    }
    var order = orders.shift();
    nf.Testing.results.confirmOrder(order);
    this.startTimer();
    this.setApiTimeout();
    this.lastCall = nf.api.put(
            'confirmOrder?id=' + encodeURIComponent(order.fields.orderId),
        null,
        this.confirmOrderResponse
    ).set('owner', this).set('orders', orders);
    nf.Testing.results.addData('Request', this.lastCall.body);
},

confirmOrderResponse: function(r) {
    this.owner.stopTimer();
    if(!this.success) {
        return this.owner.showErrorResponse(r);
    }
    nf.Testing.results.addData('Response', r);
    this.owner.confirmOrders(this.orders);
},

commitResponse: function(r) {

    this.owner.stopTimer();

    if(!this.success) {
        return this.owner.showErrorResponse(r);
    }

    nf.Testing.results.addData('Response', r);

    var type, i, j, commitSize = this.owner.commitSize();

    if('records' in r.complete) {
        for(type in r.complete.records) {
            for(i in r.complete.records[type]) {
                for(j = 0; j < this.owner.commit.records[type].length; j++) {
                    if(this.owner.commit.records[type][j][nf.Testing.schema[nf.Testing.singular[type]].primaryKey[0]] == i) {
                        this.owner.commit.records[type].splice(j, 1);
                    }
                }
            }
        }
    }

    if('relations' in r.complete) {
        for(type in r.complete.relations) {
            for(i = 0; i < r.complete.relations[type].length; i++) {
                for(j = 0; j < this.owner.commit.relations[type].length; j++) {
                    if(r.complete.relations[type][i].a === this.owner.commit.relations[type][j].a &&
                        r.complete.relations[type][i].b === this.owner.commit.relations[type][j].b) {
                        this.owner.commit.relations[type].splice(j, 1);
                    }
                }
            }
        }
    }

    if(commitSize == this.owner.commitSize()) {
        nf.Testing.results.showError('Fatal error', 'No changes were processed. Try increasing timeout.');
        return this.owner.terminate(false);
    }

    nf.Testing.results.showChangeCount(commitSize - this.owner.commitSize(), commitSize);

    this.owner.filesToSend = r.filesToSend;

    this.owner.sendFiles();

},

sendFiles: function() {

    var type, id, i;
    for(type in this.filesToSend) {
        if(this.filesToSend[type].length) {
            id = this.filesToSend[type].shift();
            i = this.actions.length;
            while(i--) {
                if(this.actions[i].command == nf.Testing.Test.COMMANDS.UPDATE &&
                    this.actions[i].type == type &&
                    this.actions[i].id() == id) {
                    break;
                }
            }
            nf.api.headers['Content-Type'] = 'application/octet-stream';
            nf.api.post('files/' + type + '/' + id, this.actions[i].binary, this.fileSent).owner = this;
            delete nf.api.headers['Content-Type'];
            nf.Testing.results.sendFile(type, id, this.actions[i].binary.length);
            this.startTimer();
            return;
        }
    }
    this.runNextAction(this.commitSize());
},

fileSent: function(r) {
    this.owner.stopTimer();
    this.owner.sendFiles();
},

startTimer: function(from) {
    var that = this;
    this.countDownFrom = from || null;
    this.startTime = new Date();
    this.updateTimer();
    this.timerInterval = setInterval(function() {
        that.updateTimer()
    }, 100);
},

stopTimer: function() {
    clearInterval(this.timerInterval);
    if(this.countDownFrom) {
        nf.Testing.results.showWaitTime(0);
    }
    else {
        nf.Testing.results.showElapsed(this.elapsed(), true);
    }
},

elapsed: function() {
    return ((new Date()).getTime() - this.startTime.getTime()) / 1000;
},

updateTimer: function() {
    if(this.countDownFrom) {
        if(this.elapsed() >= this.countDownFrom) {
            this.stopTimer();
            this.runNextAction();
        } else {
            nf.Testing.results.showWaitTime(this.countDownFrom - this.elapsed());
        }
    }
    else {
        nf.Testing.results.showElapsed(this.elapsed());
    }
},

parse: function() {

    var lines = this.source.trim().split(/\s*[\r\n]+\s*/),
        i, line, command, commandId, action, attr, parts, j, k,
        lastAction = null, binary = false, localize,
        commands = nf.Testing.Test.COMMANDS;

    this.actions = [];
    this.locale = nf.Testing.locale;

    for(i = 0; i < lines.length; i++) {

        line = lines[i];

        // blanks
        if(line == '') {
            throw new this.ParseException(i, line, "The test source is empty.");
        }

        // comments
        if(line.substr(0, 1) == '#') {
            continue;
        }

        // attributes
        if(!binary && (attr = line.match(/^(\w+)(?:\.(\w+))?\s*=\s*(.*)$/))) {

            if(lastAction === null) {
                throw new this.ParseException(i, line, "Tried to assign property without and UPDATE command.");
            }

            localize = false;

            if(lastAction.def.localize.indexOf(attr[1]) != -1) {
                localize = true;
            }
            else if(attr[1] in lastAction.def.columns) {
                if(attr[2]) {
                    throw new this.ParseException(i, line,
                            "Cannot localize field " + lastTable.type + "." + attr[1]);
                }
            } else {
                if(!nf.Testing.allowCustomFields) {
                    throw new this.ParseException(i, line,
                            "Field '" + attr[1] + "' is not in " + lastAction.table +
                            " schema and the server does not allow custom fields.");
                }
                localize = true;
            }

            if(localize) {
                if(!(attr[1] in lastAction.fields)) {
                    lastAction.fields[attr[1]] = {};
                }
                lastAction.fields[attr[1]][attr[2] || this.locale] = attr[3];
            } else {
                lastAction.fields[attr[1]] = attr[3];
            }

            continue;
        } else if(binary === null) {
            binary = true;
        }

        // binary (base64)
        if(binary) {
            if(line.match(/;$/)) {
                line = line.replace(';', '');
                binary = false;
            }
            lastAction.base64 += line;
            if(!binary) {
                if(!nf.base64.validate(lastAction.base64)) {
                    throw new this.ParseException(i, lastAction.base64, "Invalid base64 blob.");
                }
                lastAction.binary = nf.base64.decode(lastAction.base64);
                lastAction.fields.checksum = nf.base64.encode(nf.md5(lastAction.binary, true)).substr(0, 22);
            }
            continue;
        }

        // commands
        command = line.match(/^\S*/)[0].toUpperCase();
        if(command in commands) {

            commandId = commands[command];
            action = new nf.Testing.Test.Action(commandId);
            action.test = this;

            parts = line.match(arguments.callee.patterns[commandId]);
            if(!parts) {
                throw new this.ParseException(i, line, "Invalid " + command + " command.");
            }

            switch(commandId) {

                case commands.UPDATE:
                case commands.DELETE:
                case commands.REPLICATE:

                    action.type = null;
                    for(j in nf.Testing.singular) {
                        if(j.toLowerCase() == parts[1].toLowerCase()) {
                            action.type = j;
                        }
                    }
                    if(action.type === null) {
                        throw new this.ParseException(i, line, "Unknown type '" + parts[1] + "'.");
                    }

                    action.table = nf.Testing.singular[action.type];
                    action.def = nf.Testing.schema[action.table];

                    // Work-around for server's dependence on the @active flag

// action.fields = {'@active': commandId === commands.DELETE ? '0' : '1'};

                    action.fields = {};

                    action.fields[action.def.primaryKey[0]] = parts[2];

                    if(commandId == commands.DELETE) {
                        action.fields[DELETE_KEY] = true;
                    }
                    else if(commandId == commands.UPDATE) {
                        lastAction = action;
                        if(fileTables.indexOf(action.table) != -1) {
                            action.base64 = '';
                            binary = null;
                        }
                    }

                    break;

                case commands.PURGE:

                    action.table = null;
                    for(j in nf.Testing.schema) {
                        if(j.toLowerCase() == parts[1].toLowerCase()) {
                            action.table = j;
                        }
                    }
                    if(action.table === null) {
                        throw new this.ParseException(i, line, "Unknown table '" + parts[1] + "'.");
                    }

                    action.type = nf.Testing.schema[action.table].singular;

                    if(typeof parts[2] == 'string') {
                        action.timeout = parseInt(parts[2]);
                    }

                    break;

                case commands.ADD:
                case commands.REMOVE:

                    action.types = [null, null];

                    var types = [parts[1], parts[3]];

                    for(k = 0; k < 2; k++) {
                        for(j in nf.Testing.singular) {
                            if(j.toLowerCase() == types[k].toLowerCase()) {
                                action.types[k] = j;
                            }
                        }
                        if(action.types[k] === null) {
                            throw new this.ParseException(i, line, "Unknown type '" + types[k] + "'.");
                        }
                    }

                    action.ids = [parts[2], parts[4]];

                    break;

                case commands.ORDERS:

                    action.timeout = nf.Testing.DEFAULT_ORDER_TIMEOUT;

                case commands.COMMIT:

                    if(typeof parts[1] == 'string') {
                        action.timeout = parseInt(parts[1]);
                    }

                    break;

                case commands.WAIT:

                    action.duration = parseFloat(parts[1]);

                    break;

                case commands.LOCALE:

                    action.locale = this.locale = parts[1];

                    break;

            }

            this.actions.push(action);

        } else {
            throw new this.ParseException(i, line, "Unknown command.");
        }
    }

},

// parse exception
ParseException: function(i, line, description) {

    this.lineNumber = i;
    this.line = line;
    this.description = description;

}

};

nf.Testing.Test.prototype.ParseException.prototype = {

toString: function() {
    return "Line " + this.lineNumber + " : " + this.line + "\n" + this.description;
}

};

// test action model class nf.Testing.Test.Action = function(command) {

if(typeof command == 'number') {
    return this.command = command;
}

var c = nf.Testing.Test.COMMANDS,
    i;

for(i in c) {
    if(i == command.toUpperCase()) {
        this.command = c[i];
    }
}

};

nf.Testing.Test.Action.prototype = {

id: function() {
    if('fields' in this) {
        return this.fields[this.def.primaryKey[0]];
    }
}

};

nf.Testing.Test.COMMANDS = {

UPDATE: 1,
DELETE: 2,
PURGE: 3,
ADD: 4,
REMOVE: 5,
COMMIT: 6,
WAIT: 7,
ORDERS: 8,
REPLICATE: 9,
LOCALE: 10

};

nf.Testing.Test.prototype.parse.patterns = {}; with(nf.Testing.Test.prototype.parse) {

patterns[nf.Testing.Test.COMMANDS.UPDATE] = /^UPDATE\s+(\w+)\s+(\S.*)$/i;
patterns[nf.Testing.Test.COMMANDS.DELETE] = /^DELETE\s+(\w+)\s+(\S.*)$/i;
patterns[nf.Testing.Test.COMMANDS.REPLICATE] = /^REPLICATE\s+(\w+)\s+(\S.*)$/i;
patterns[nf.Testing.Test.COMMANDS.PURGE] = /^PURGE\s+(\w+)(?:\s+(\d+))?$/i;
patterns[nf.Testing.Test.COMMANDS.ADD] = /^ADD\s+(\w+)\s+(.+?)\s+TO\s+(\w+)\s+(.+)$/i;
patterns[nf.Testing.Test.COMMANDS.REMOVE] = /^REMOVE\s+(\w+)\s+(.+?)\s+FROM\s+(\w+)\s+(.+)$/i;
patterns[nf.Testing.Test.COMMANDS.COMMIT] = /^COMMIT(?:\s+(\d+))?$/i;
patterns[nf.Testing.Test.COMMANDS.WAIT] = /^WAIT\s+(\d+(?:\.\d*)?|\.\d+)$/i;
patterns[nf.Testing.Test.COMMANDS.ORDERS] = /^ORDERS(?:\s+(\d+))?$/i;
patterns[nf.Testing.Test.COMMANDS.LOCALE] = /^LOCALE\s+(\w+)\s*$/i;

}