← Back to team overview

openerp-dev-web team mailing list archive

lp:~openerp-dev/openobject-client-web/proto61-new-dataset-API-xmo into lp:~openerp-dev/openobject-client-web/trunk-proto61

 

Xavier (Open ERP) has proposed merging lp:~openerp-dev/openobject-client-web/proto61-new-dataset-API-xmo into lp:~openerp-dev/openobject-client-web/trunk-proto61.

Requested reviews:
  OpenERP R&D Team (openerp-dev)

For more details, see:
https://code.launchpad.net/~openerp-dev/openobject-client-web/proto61-new-dataset-API-xmo/+merge/54507

Implementation of a new DataSet API as discussed with fme, al.

Addition of some documentation on the JS and Python sides.
-- 
https://code.launchpad.net/~openerp-dev/openobject-client-web/proto61-new-dataset-API-xmo/+merge/54507
Your team OpenERP R&D Team is requested to review the proposed merge of lp:~openerp-dev/openobject-client-web/proto61-new-dataset-API-xmo into lp:~openerp-dev/openobject-client-web/trunk-proto61.
=== modified file 'addons/base/controllers/main.py'
--- addons/base/controllers/main.py	2011-03-21 15:40:34 +0000
+++ addons/base/controllers/main.py	2011-03-23 12:39:27 +0000
@@ -167,15 +167,71 @@
 
     @openerpweb.jsonrequest
     def fields(self, req, model):
-        return {'fields': req.session.model(model).fields_get(False)}
-
-    @openerpweb.jsonrequest
-    def load(self, req, model, domain=[], fields=['id']):
-        m = req.session.model(model)
-        ids = m.search(domain)
-        values = m.read(ids, fields)
-        return {'ids': ids, 'values': values}
-
+        return {'fields': req.session.model(model).fields_get()}
+
+    @openerpweb.jsonrequest
+    def find(self, request, model, fields=False, offset=0, limit=False,
+             domain=None, context=None, sort=None):
+        return self.do_find(request, model, fields, offset, limit,
+                     domain, context, sort)
+    def do_find(self, request, model, fields=False, offset=0, limit=False,
+             domain=None, context=None, sort=None):
+        """ Performs a search() followed by a read() (if needed) using the
+        provided search criteria
+
+        :param request: a JSON-RPC request object
+        :type request: openerpweb.JsonRequest
+        :param model: the name of the model to search on
+        :type model: str
+        :param fields: a list of the fields to return in the result records
+        :type fields: [str]
+        :param offset: from which index should the results start being returned
+        :type offset: int
+        :param limit: the maximum number of records to return
+        :type limit: int
+        :param domain: the search domain for the query
+        :type domain: list
+        :param context: the context in which the search should be executed
+        :type context: dict
+        :param sort: sorting directives
+        :type sort: list
+        :returns: a list of result records
+        :rtype: list
+        """
+        Model = request.session.model(model)
+        ids = Model.search(domain or [], offset or 0, limit or False,
+                           sort or False, context or False)
+        if fields and fields == ['id']:
+            # shortcut read if we only want the ids
+            return map(lambda id: {'id': id}, ids)
+        return Model.read(ids, fields or False)
+
+    @openerpweb.jsonrequest
+    def get(self, request, model, ids):
+        self.do_get(request, model, ids)
+
+    def do_get(self, request, model, ids):
+        """ Fetches and returns the records of the model ``model`` whose ids
+        are in ``ids``.
+
+        The results are in the same order as the inputs, but elements may be
+        missing (if there is no record left for the id)
+
+        :param request: the JSON-RPC2 request object
+        :type request: openerpweb.JsonRequest
+        :param model: the model to read from
+        :type model: str
+        :param ids: a list of identifiers
+        :type ids: list
+        :returns: a list of records, in the same order as the list of ids
+        :rtype: list
+        """
+        Model = request.session.model(model)
+        records = Model.read(ids)
+
+        record_map = dict((record['id'], record) for record in records)
+
+        return [record_map[id] for id in ids if record_map.get(id)]
 
 class DataRecord(openerpweb.Controller):
     _cp_path = "/base/datarecord"

=== modified file 'addons/base/static/openerp/base.xml'
--- addons/base/static/openerp/base.xml	2011-03-22 17:18:01 +0000
+++ addons/base/static/openerp/base.xml	2011-03-23 12:39:27 +0000
@@ -348,7 +348,6 @@
         <input id="mode_list" type="button" value="ListView"/>
         <input id="mode_form" type="button" value="FormView"/>
     </div>
-    <div id="oe_action_dataset" style="display:none;"></div>
     <div id="oe_action_search" ></div>
     <div id="oe_action_list" ></div>
     <div id="oe_action_form" ></div>

=== modified file 'addons/base/static/openerp/js/base_views.js'
--- addons/base/static/openerp/js/base_views.js	2011-03-22 17:18:01 +0000
+++ addons/base/static/openerp/js/base_views.js	2011-03-23 12:39:27 +0000
@@ -44,7 +44,7 @@
     },
     do_action_window: function(action) {
         this.formview_id = false;
-        this.dataset = new openerp.base.DataSet(this.session, "oe_action_dataset", action.res_model);
+        this.dataset = new openerp.base.DataSet(this.session, action.res_model);
         this.dataset.start();
 
         // Locate first tree view
@@ -94,60 +94,253 @@
 // to replace Action
 });
 
+/**
+ * Management interface between views and the collection of selected OpenERP
+ * records (represents the view's state?)
+ */
 openerp.base.DataSet =  openerp.base.Controller.extend({
-    init: function(session, element_id, model) {
-        this._super(session, element_id);
+    init: function(session, model) {
+        this._super(session);
         this.model = model;
-        // SHOULD USE THE ONE FROM FIELDS VIEW GET BECAUSE OF SELECTION
-        // Should merge those 2
-        this.model_fields = null;
-        this.fields = [];
-
-        this.domain = [];
-        this.context = {};
-        this.order = "";
-        this.count = null;
-        this.ids = [];
-        this.values = {};
-/*
-    group_by
-        rows record
-            fields of row1 field fieldname
-                { type: value: text: text_format: text_completions type_*: a
-*/
+
+        this._fields = null;
+
+        this._ids = [];
+        this._active_ids = null;
+        this._active_id_index = 0;
+
+        this._sort = [];
+        this._domain = [];
+        this._context = {};
     },
     start: function() {
+        // TODO: fields_view_get fields selection?
         this.rpc("/base/dataset/fields", {"model":this.model}, this.on_fields);
     },
     on_fields: function(result) {
-        this.model_fields = result.fields;
+        this._fields = result._fields;
         this.on_ready();
     },
-    do_load: function(offset, limit) {
-        this.rpc("/base/dataset/load", {model: this.model, fields: this.fields }, this.on_loaded);
-    },
-    on_loaded: function(data) {
-        this.ids = data.ids;
-        this.values = data.values;
-    },
-    on_reloaded: function(ids) {
+
+    /**
+     * Fetch all the records selected by this DataSet, based on its domain
+     * and context.
+     *
+     * Fires the on_ids event.
+     *
+     * @param {Number} [offset=0] The index from which selected records should be returned
+     * @param {Number} [limit=null] The maximum number of records to return
+     * @returns itself
+     */
+    fetch: function (offset, limit) {
+        offset = offset || 0;
+        limit = limit || null;
+        this.rpc('/base/dataset/find', {
+            model: this.model,
+            fields: this._fields,
+            domain: this._domain,
+            context: this._context,
+            sort: this._sort,
+            offset: offset,
+            limit: limit
+        }, _.bind(function (records) {
+            var data_records = _.map(
+                records, function (record) {
+                    return new openerp.base.DataRecord(
+                        this.session, this.model,
+                        this._fields, record);
+                }, this);
+
+            this.on_fetch(data_records, {
+                offset: offset,
+                limit: limit,
+                domain: this._domain,
+                context: this._context,
+                sort: this._sort
+            });
+        }, this));
+        return this;
+    },
+    /**
+     * @event
+     *
+     * Fires after the DataSet fetched the records matching its internal ids selection
+     * 
+     * @param {Array} records An array of the DataRecord fetched
+     * @param event The on_fetch event object
+     * @param {Number} event.offset the offset with which the original DataSet#fetch call was performed
+     * @param {Number} event.limit the limit set on the original DataSet#fetch call
+     * @param {Array} event.domain the domain set on the DataSet before DataSet#fetch was called
+     * @param {Object} event.context the context set on the DataSet before DataSet#fetch was called
+     * @param {Array} event.sort the sorting criteria used to get the ids
+     */
+    on_fetch: function (records, event) { },
+
+    /**
+     * Fetch all the currently active records for this DataSet (records selected via DataSet#select)
+     *
+     * @returns itself
+     */
+    active_ids: function () {
+        this.rpc('/base/dataset/get', {
+            ids: this.get_active_ids(),
+            model: this.model
+        }, _.bind(function (records) {
+            this.on_active_ids(_.map(
+                records, function (record) {
+                    return new openerp.base.DataRecord(
+                        this.session, this.model,
+                        this._fields, record);
+                }, this));
+        }, this));
+        return this;
+    },
+    /**
+     * @event
+     *
+     * Fires after the DataSet fetched the records matching its internal active ids selection
+     *
+     * @param {Array} records An array of the DataRecord fetched
+     */
+    on_active_ids: function (records) { },
+
+    /**
+     * Fetches the current active record for this DataSet
+     *
+     * @returns itself
+     */
+    active_id: function () {
+        this.rpc('/base/dataset/get', {
+            ids: [this.get_active_id()],
+            model: this.model
+        }, _.bind(function (records) {
+            var record = records[0];
+            this.on_active_id(
+                record && new openerp.base.DataRecord(
+                        this.session, this.model,
+                        this._fields, record));
+        }, this));
+        return this;
+    },
+    /**
+     * Fires after the DataSet fetched the record matching the current active record
+     *
+     * @param record the record matching the provided id, or null if there is no record for this id
+     */
+    on_active_id: function (record) {
+
+    },
+
+    /**
+     * Configures the DataSet
+     * 
+     * @param options DataSet options
+     * @param {Array} options.domain the domain to assign to this DataSet for filtering
+     * @param {Object} options.context the context this DataSet should use during its calls
+     * @param {Array} options.sort the sorting criteria for this DataSet
+     * @returns itself
+     */
+    set: function (options) {
+        if (options.domain) {
+            this._domain = _.clone(options.domain);
+        }
+        if (options.context) {
+            this._context = _.clone(options.context);
+        }
+        if (options.sort) {
+            this._sort = _.clone(options.sort);
+        }
+        return this;
+    },
+
+    /**
+     * Activates the previous id in the active sequence. If there is no previous id, wraps around to the last one
+     * @returns itself
+     */
+    prev: function () {
+        this._active_id_index -= 1;
+        if (this._active_id_index < 0) {
+            this._active_id_index = this._active_ids.length - 1;
+        }
+        return this;
+    },
+    /**
+     * Activates the next id in the active sequence. If there is no next id, wraps around to the first one
+     * @returns itself
+     */
+    next: function () {
+        this._active_id_index += 1;
+        if (this._active_id_index >= this._active_ids.length) {
+            this._active_id_index = 0;
+        }
+        return this;
+    },
+
+    /**
+     * Sets active_ids by value:
+     *
+     * * Activates all ids part of the current selection
+     * * Sets active_id to be the first id of the selection
+     *
+     * @param {Array} ids the list of ids to activate
+     * @returns itself
+     */
+    select: function (ids) {
+        this._active_ids = ids;
+        this._active_id_index = 0;
+        return this;
+    },
+    /**
+     * Fetches the ids of the currently selected records, if any.
+     */
+    get_active_ids: function () {
+        return this._active_ids;
+    },
+    /**
+     * Sets the current active_id by value
+     *
+     * If there are no active_ids selected, selects the provided id as the sole active_id
+     *
+     * If there are ids selected and the provided id is not in them, raise an error
+     *
+     * @param {Object} id the id to activate
+     * @returns itself
+     */
+    activate: function (id) {
+        if(!this._active_ids) {
+            this._active_ids = [id];
+            this._active_id_index = 0;
+        } else {
+            var index = _.indexOf(this._active_ids, id);
+            if (index == -1) {
+                throw new Error(
+                    "Could not find id " + id +
+                    " in array [" + this._active_ids.join(', ') + "]");
+            }
+            this._active_id_index = index;
+        }
+        return this;
+    },
+    /**
+     * Fetches the id of the current active record, if any.
+     *
+     * @returns record? record id or <code>null</code>
+     */
+    get_active_id: function () {
+        if (!this._active_ids) {
+            return null;
+        }
+        return this._active_ids[this._active_id_index];
     }
 });
 
 openerp.base.DataRecord =  openerp.base.Controller.extend({
-    init: function(session,  model, fields) {
+    init: function(session, model, fields, values) {
         this._super(session, null);
         this.model = model;
-        this.id = null;
+        this.id = values.id || null;
         this.fields = fields;
-        this.values = {};
-    },
-    load: function(id) {
-        this.id = id;
-        this.rpc("/base/datarecord/load", {"model": this.model, "id": id, "fields": "todo"}, this.on_loaded);
-    },
-    on_loaded: function(result) {
-        this.values = result.value;
+        this.values = values;
     },
     on_change: function() {
     },
@@ -207,7 +400,7 @@
         // collect all non disabled domains definitions, AND them
         // evaluate as python expression
         // save the result in this.domain
-        this.dataset.do_load();
+        this.dataset.fetch();
     },
     on_clear: function() {
     }
@@ -231,7 +424,6 @@
     init: function(session, element_id, dataset, view_id) {
         this._super(session, element_id);
         this.dataset = dataset;
-        this.dataset_index = 0;
         this.model = dataset.model;
         this.view_id = view_id;
         this.fields_views = {};
@@ -256,22 +448,10 @@
         }
         // bind to all wdigets that have onchange ??
 
-        // When the dataset is loaded load the first record (like gtk)
-        this.dataset.on_loaded.add_last(this.do_load_record);
-
-        // When a datarecord is loaded display the values in the inputs
-        this.datarecord = new openerp.base.DataRecord(this.session, this.model,{});
-        this.datarecord.on_loaded.add_last(this.on_record_loaded);
-
-    },
-    do_load_record: function() {
-        // if dataset is empty display the empty datarecord
-        if(this.dataset.ids.length == 0) {
-            this.on_record_loaded();
-        }
-        this.datarecord.load(this.dataset.ids[this.dataset_index]);
-    },
-    on_record_loaded: function() {
+        this.dataset.on_fetch.add(this.on_record_loaded);
+    },
+    on_record_loaded: function(records) {
+        this.datarecord = records[0];
         for (var f in this.fields) {
             this.fields[f].set_value(this.datarecord.values[f]);
         }
@@ -317,20 +497,18 @@
                 this.colmodel.push({ name: col.attrs.name, index: col.attrs.name });
             }
         }
-        //this.log(this.cols);
         this.dataset.fields = this.cols;
-        this.dataset.on_loaded.add_last(this.do_fill_table);
+        this.dataset.on_fetch.add(this.do_fill_table);
     },
-    do_fill_table: function() {
-        //this.log("do_fill_table");
+    do_fill_table: function(records) {
+        this.log("do_fill_table");
         
         var self = this;
         //this.log(this.dataset.data);
         var rows = [];
-        var ids = this.dataset.ids;
-        for(var i = 0; i < ids.length; i++)  {
+        for(var i = 0; i < records.length; i++)  {
             // TODO very strange is sometimes non existing ? even as admin ? example ir.ui.menu
-            var row = this.dataset.values[ids[i]];
+            var row = records[i].values;
             if(row)
                 rows.push(row);
 //            else

=== added file 'addons/base/static/openerp/js/tests.js'
--- addons/base/static/openerp/js/tests.js	1970-01-01 00:00:00 +0000
+++ addons/base/static/openerp/js/tests.js	2011-03-23 12:39:27 +0000
@@ -0,0 +1,351 @@
+/**
+ * @function
+ * Defines a module scope (which lasts until the next call to module).
+ *
+ * This module scopes implies setup and teardown callbacks running for each test.
+ *
+ * @param {String} name the name of the module
+ * @param {Object} [lifecycle] callbacks to run before and after each test of the module
+ * @param {Function} lifecycle.setup function running before each test of this module
+ * @param {Function} lifecycle.teardown function running after each test of this module
+ */
+var module;
+/**
+ * @function
+ * Defines a given test to run. Runs all the assertions present in the test
+ *
+ * @param {String} name the name of the test
+ * @param {Number} [expected] number of assertions expected to run in this test (useful for asynchronous tests)
+ * @param {Function} test the testing code to run, holding a sequence of assertions (at least one)
+ */
+var test;
+/**
+ * @function
+ * Defines an asynchronous test: equivalent to calling stop() at the start of
+ * a normal test().
+ *
+ * The test code needs to restart the test runner via start()
+ * 
+ * @param {String} name the name of the test
+ * @param {Number} [expected] number of assertions expected to run in this test (useful for asynchronous tests)
+ * @param {Function} test the testing code to run, holding a sequence of assertions (at least one)
+ */
+var asyncTest;
+/**
+ * @function
+ * The most basic boolean assertion (~assertTrue or assert).
+ *
+ * Passes if its argument is truthy
+ *
+ * @param {Boolean} state an arbitrary expression, evaluated in a boolean context
+ * @param {String} [message] the message to output with the assertion result
+ */
+var ok;
+/**
+ * @function
+ * Equality assertion (~assertEqual)
+ *
+ * Passes if both arguments are equal (via <code>==</code>)
+ *
+ * @param {Object} actual the object to check for correctness (processing result)
+ * @param {Object} expected the object to check against
+ * @param {String} [message] message output with the assertion result
+ */
+var equal;
+/**
+ * @function
+ * Inequality assertion (~assertNotEqual)
+ *
+ * Passes if the arguments are different (via <code>!=</code>)
+ *
+ * @param {Object} actual the object to check for correctness (processing result)
+ * @param {Object} expected the object to check against
+ * @param {String} [message] message output with the assertion result
+ */
+var notEqual;
+/**
+ * @function
+ * Recursive equality assertion.
+ *
+ * Works on primitive types using <code>===</code> and traversing through
+ * Objects and Arrays as well checking their components
+ * 
+ * @param {Object} actual the object to check for correctness (processing result)
+ * @param {Object} expected the object to check against
+ * @param {String} [message] message output with the assertion result
+ */
+var deepEqual;
+/**
+ * @function
+ * Recursive inequality assertion.
+ *
+ * Works on primitive types using <code>!==</code> and traversing through
+ * Objects and Arrays as well checking their components
+ *
+ * @param {Object} actual the object to check for correctness (processing result)
+ * @param {Object} expected the object to check against
+ * @param {String} [message] message output with the assertion result
+ */
+var notDeepEqual;
+/**
+ * @function
+ * Strict equality assertion (~assertEqual)
+ *
+ * Passes if both arguments are identical (via <code>===</code>)
+ *
+ * @param {Object} actual the object to check for correctness (processing result)
+ * @param {Object} expected the object to check against
+ * @param {String} [message] message output with the assertion result
+ */
+var strictEqual;
+/**
+ * @function
+ * Strict inequality assertion (~assertNotEqual)
+ *
+ * Passes if both arguments are identical (via <code>!==</code>)
+ *
+ * @param {Object} actual the object to check for correctness (processing result)
+ * @param {Object} expected the object to check against
+ * @param {String} [message] message output with the assertion result
+ */
+var notStrictEqual;
+/**
+ * @function
+ * Passes if the provided block raised an exception.
+ *
+ * The <code>expect</code> argument can be provided to perform further assertion checks on the exception itself:
+ * * If it's a <code>RegExp</code> test the exception against the regexp (message?)
+ * * If it's a constructor, check if the exception is an instance of it
+ * * If it's an other type of function, call it with the exception as first parameter
+ *   - If the function returns true, the assertion validates
+ *   - Otherwise it fails
+ *
+ * @param {Function} block function which should raise an exception when called
+ * @param {Object} [expect] a RegExp, a constructor or a Function
+ * @param {String} [message] message output with the assertion result
+ */
+var raises;
+/**
+ * @function
+ * Starts running the test runner again from the point where it was
+ * <code>stop</code>ped.
+ *
+ * Used to resume testing after a callback.
+ */
+var start;
+/**
+ * @function
+ * Stops the test runner in order to wait for an asynchronous test to run
+ *
+ * @param {Number} [timeout] fails the test after the timeout triggers, only for debugging tests
+ */
+var stop;
+
+var Session = function () {
+    return {
+        rpc: function (_url, params, on_success) {
+            setTimeout(on_success);
+        }
+    };
+};
+$(document).ready(function () {
+    var openerp;
+    module("ids_callback", {
+        setup: function () {
+            openerp = window.openerp.init();
+        }
+    });
+    asyncTest("Baseline event attributes", 6, function () {
+        var dataset = new openerp.base.DataSet(
+                new Session());
+        dataset.on_fetch.add(function (records, event) {
+            deepEqual(records, [], 'No records returned');
+            equal(event.offset, 0, 'No offset set in call');
+            equal(event.limit, null, 'No limit set in call');
+            deepEqual(event.domain, [], 'No domain on the dataset');
+            deepEqual(event.context, {}, 'No context on the dataset');
+            deepEqual(event.sort, [], 'The dataset is not sorted');
+            start();
+        });
+        dataset.fetch();
+    });
+    asyncTest("Offset and limit", 2, function () {
+        var dataset = new openerp.base.DataSet(
+                new Session());
+        dataset.on_fetch.add(function (records, event) {
+            equal(event.offset, 20);
+            equal(event.limit, 42);
+            start();
+        });
+        dataset.fetch(20, 42);
+    });
+    asyncTest("Domain and context propagation", 3, function () {
+        var dataset = new openerp.base.DataSet(
+                new Session());
+        var domain_value = [['foo', '=', 'bar']];
+        var context_value= {active_id:3, active_ids:42};
+        var sort_value = ['foo'];
+        dataset.on_fetch.add(function (records, event) {
+            deepEqual(event.domain, domain_value);
+            deepEqual(event.context, context_value);
+            deepEqual(event.sort, sort_value);
+            start();
+        });
+        dataset.set({
+            domain: domain_value,
+            context: context_value,
+            sort: sort_value
+        });
+        dataset.fetch();
+    });
+    asyncTest("Data records", function () {
+        var dataset = new openerp.base.DataSet({
+            rpc: function (url, _params, on_success) {
+                equal('/base/dataset/find', url);
+                _.delay(on_success, 0, [
+                    {id: 1, sequence: 3, name: "dummy", age: 42},
+                    {id: 5, sequence: 7, name: "whee", age: 55}
+                ]);
+            }
+        });
+        dataset.on_fetch.add(function (records) {
+            equal(records.length, 2, "I loaded two virtual records");
+            var d1 = records[0],
+                d2 = records[1];
+            ok(d1 instanceof openerp.base.DataRecord);
+            ok(d2 instanceof openerp.base.DataRecord);
+            start();
+        });
+        dataset.fetch();
+    });
+
+    var dataset;
+    module("set", {
+        setup: function () {
+            var openerp = window.openerp.init();
+            dataset = new openerp.base.DataSet();
+        }
+    });
+    test('Basic properties setting', function () {
+        var domain_value = [['foo', '=', 'bar']];
+        var result = dataset.set({
+            domain: domain_value
+        });
+        ok(dataset === result);
+        deepEqual(domain_value, dataset._domain);
+    });
+    test("Ensure changes don't stick", function () {
+        var domain = [['foo', '=', 'bar']];
+        dataset.set({
+            domain: domain
+        });
+        domain.pop();
+        deepEqual([['foo', '=', 'bar']], dataset._domain);
+    });
+
+    module('ids_activation', {
+        setup: function () {
+            var openerp = window.openerp.init();
+            dataset = new openerp.base.DataSet();
+        }
+    });
+    test('activate id', function () {
+        dataset.activate(1);
+        deepEqual(dataset.get_active_ids(), [1]);
+        equal(dataset.get_active_id(), 1);
+    });
+    test('set active_ids', function () {
+        dataset.select([1, 2, 3]);
+        deepEqual(dataset.get_active_ids(), [1, 2, 3],
+                "selecting an ids range");
+        equal(dataset.get_active_id(), 1);
+    });
+    test('activate incorrect id', function () {
+        dataset.select([1, 2, 3]);
+        raises(function () { dataset.activate(42); },
+               "Activating an id not present in the selection is an error");
+    });
+    test('reset active id on set active ids', function () {
+        dataset.select([1, 2, 3]).activate(3).select([1, 2, 3]);
+        equal(dataset.get_active_id(), 1,
+              "selecting an ids range resets the active id");
+    });
+
+    module('active_id_iteration', {
+        setup: function () {
+            var openerp = window.openerp.init();
+            dataset = new openerp.base.DataSet();
+            dataset.select([1, 2, 3]);
+        }
+    });
+    test('step forward', function () {
+        dataset.activate(1);
+        dataset.next();
+        equal(dataset.get_active_id(), 2);
+    });
+    test('wraparound forward', function () {
+        dataset.activate(3);
+        dataset.next();
+        equal(dataset.get_active_id(), 1);
+    });
+    test('step back', function () {
+        dataset.activate(3);
+        dataset.prev();
+        equal(dataset.get_active_id(), 2);
+    });
+    test('wraparound back', function () {
+        dataset.activate(1);
+        dataset.prev();
+        equal(dataset.get_active_id(), 3);
+    });
+
+    var ResponseAssertSession = function (response_ids) {
+        return {
+            rpc: function (url, params, on_success) {
+                equal(url, '/base/dataset/get');
+                deepEqual(params.ids, response_ids);
+                _.delay(on_success, 0, _.map(
+                    params.ids, function (id) {
+                        return {id: id, sequence: id, name: 'foo'+id};
+                    }
+                ));
+            }
+        };
+    };
+
+    module('active_ids', {
+        setup: function () {
+            openerp = window.openerp.init();
+        }
+    });
+    asyncTest('Get pre-set active_ids', 6, function () {
+        var dataset = new openerp.base.DataSet(
+            new ResponseAssertSession([1, 2, 3]));
+        dataset.select([1, 2, 3]);
+        dataset.on_active_ids.add(function (data_records) {
+            equal(data_records.length, 3);
+            equal(data_records[0].values.id, 1);
+            equal(data_records[1].values.id, 2);
+            equal(data_records[2].values.id, 3);
+            start();
+        });
+        dataset.active_ids();
+    });
+
+    module('active_id', {
+        setup: function () {
+            openerp = window.openerp.init();
+        }
+    });
+    test('Get pre-set active_id', 3, function () {
+        var dataset = new openerp.base.DataSet(
+            new ResponseAssertSession([42]));
+        stop(500);
+        dataset.select([1, 2, 3, 42]).activate(42);
+        dataset.on_active_id.add(function (data_record) {
+            equal(data_record.values.id, 42);
+            start();
+        });
+        dataset.active_id();
+    });
+});

=== added file 'addons/base/static/openerp/test.html'
--- addons/base/static/openerp/test.html	1970-01-01 00:00:00 +0000
+++ addons/base/static/openerp/test.html	2011-03-23 12:39:27 +0000
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html style="height: 100%">
+<head>
+    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+    <title>OpenERP</title>
+    <link rel="shortcut icon" href="/openobject/static/images/favicon.ico" type="image/x-icon"/>
+
+    <link rel="stylesheet" href="/base/static/qunit/qunit-2011-23-22.css">
+    <script src="/base/static/qunit/qunit-2011-23-22.js" type="text/javascript"></script>
+
+    <script src="/base/static/underscore/underscore.js" type="text/javascript"></script>
+
+    <!-- jquery -->
+    <script src="/base/static/jquery/jquery-1.5.1.js"></script>
+
+    <script src="/base/static/openerp/js/base.js"></script>
+    <script src="/base/static/openerp/js/base_chrome.js"></script>
+    <script src="/base/static/openerp/js/base_views.js"></script>
+
+</head>
+    <body id="oe" class="openerp">
+        <h1 id="qunit-header">OpenERP Base Test Suite</h1>
+        <h2 id="qunit-banner"></h2>
+        <div id="qunit-testrunner-toolbar"></div>
+        <h2 id="qunit-userAgent"></h2>
+        <ol id="qunit-tests"></ol>
+    </body>
+    <script type="text/javascript" src="/base/static/openerp/js/tests.js"></script>
+</html>

=== added directory 'addons/base/static/qunit'
=== added file 'addons/base/static/qunit/qunit-2011-23-22.css'
--- addons/base/static/qunit/qunit-2011-23-22.css	1970-01-01 00:00:00 +0000
+++ addons/base/static/qunit/qunit-2011-23-22.css	2011-03-23 12:39:27 +0000
@@ -0,0 +1,248 @@
+/** Font Family and Sizes */
+
+#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
+    font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial;
+}
+
+#qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li {
+    font-size: small;
+}
+
+#qunit-tests {
+    font-size: smaller;
+}
+
+/** Resets */
+
+#qunit-tests, #qunit-tests ol, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult {
+    margin: 0;
+    padding: 0;
+}
+
+/** Header */
+
+#qunit-header {
+    padding: 0.5em 0 0.5em 1em;
+
+    color: #8699a4;
+    background-color: #0d3349;
+
+    font-size: 1.5em;
+    line-height: 1em;
+    font-weight: normal;
+
+    border-radius: 15px 15px 0 0;
+    -moz-border-radius: 15px 15px 0 0;
+    -webkit-border-top-right-radius: 15px;
+    -webkit-border-top-left-radius: 15px;
+}
+
+#qunit-header a {
+    text-decoration: none;
+    color: #c2ccd1;
+}
+
+#qunit-header a:hover,
+#qunit-header a:focus {
+    color: #fff;
+}
+
+#qunit-banner {
+    height: 5px;
+}
+
+#qunit-testrunner-toolbar {
+    padding: 0.5em 0 0.5em 2em;
+    color: #5E740B;
+    background-color: #eee;
+}
+
+#qunit-userAgent {
+    padding: 0.5em 0 0.5em 2.5em;
+    background-color: #2b81af;
+    color: #fff;
+    text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
+}
+
+/** Tests: Pass/Fail */
+
+#qunit-tests {
+    list-style-position: inside;
+}
+
+#qunit-tests li {
+    padding: 0.4em 0.5em 0.4em 2.5em;
+    border-bottom: 1px solid #fff;
+    list-style-position: inside;
+}
+
+#qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running {
+    display: none;
+}
+
+#qunit-tests li strong {
+    cursor: pointer;
+}
+
+#qunit-tests li a {
+    padding: 0.5em;
+    color: #c2ccd1;
+    text-decoration: none;
+}
+
+#qunit-tests li a:hover,
+#qunit-tests li a:focus {
+    color: #000;
+}
+
+#qunit-tests ol {
+    margin-top: 0.5em;
+    padding: 0.5em;
+
+    background-color: #fff;
+
+    border-radius: 15px;
+    -moz-border-radius: 15px;
+    -webkit-border-radius: 15px;
+
+    box-shadow: inset 0px 2px 13px #999;
+    -moz-box-shadow: inset 0px 2px 13px #999;
+    -webkit-box-shadow: inset 0px 2px 13px #999;
+}
+
+#qunit-tests table {
+    border-collapse: collapse;
+    margin-top: .2em;
+}
+
+#qunit-tests th {
+    text-align: right;
+    vertical-align: top;
+    padding: 0 .5em 0 0;
+}
+
+#qunit-tests td {
+    vertical-align: top;
+}
+
+#qunit-tests pre {
+    margin: 0;
+    white-space: pre-wrap;
+    word-wrap: break-word;
+}
+
+#qunit-tests del {
+    background-color: #e0f2be;
+    color: #374e0c;
+    text-decoration: none;
+}
+
+#qunit-tests ins {
+    background-color: #ffcaca;
+    color: #500;
+    text-decoration: none;
+}
+
+/*** Test Counts */
+
+#qunit-tests b.counts {
+    color: black;
+}
+
+#qunit-tests b.passed {
+    color: #5E740B;
+}
+
+#qunit-tests b.failed {
+    color: #710909;
+}
+
+#qunit-tests li li {
+    margin: 0.5em;
+    padding: 0.4em 0.5em 0.4em 0.5em;
+    background-color: #fff;
+    border-bottom: none;
+    list-style-position: inside;
+}
+
+/*** Passing Styles */
+
+#qunit-tests li li.pass {
+    color: #5E740B;
+    background-color: #fff;
+    border-left: 26px solid #C6E746;
+}
+
+#qunit-tests .pass {
+    color: #528CE0;
+    background-color: #D2E0E6;
+}
+
+#qunit-tests .pass .test-name {
+    color: #366097;
+}
+
+#qunit-tests .pass .test-actual,
+#qunit-tests .pass .test-expected {
+    color: #999999;
+}
+
+#qunit-banner.qunit-pass {
+    background-color: #C6E746;
+}
+
+/*** Failing Styles */
+
+#qunit-tests li li.fail {
+    color: #710909;
+    background-color: #fff;
+    border-left: 26px solid #EE5757;
+}
+
+#qunit-tests > li:last-child {
+    border-radius: 0 0 15px 15px;
+    -moz-border-radius: 0 0 15px 15px;
+    -webkit-border-bottom-right-radius: 15px;
+    -webkit-border-bottom-left-radius: 15px;
+}
+
+#qunit-tests .fail {
+    color: #000000;
+    background-color: #EE5757;
+}
+
+#qunit-tests .fail .test-name,
+#qunit-tests .fail .module-name {
+    color: #000000;
+}
+
+#qunit-tests .fail .test-actual {
+    color: #EE5757;
+}
+
+#qunit-tests .fail .test-expected {
+    color: green;
+}
+
+#qunit-banner.qunit-fail {
+    background-color: #EE5757;
+}
+
+/** Result */
+
+#qunit-testresult {
+    padding: 0.5em 0.5em 0.5em 2.5em;
+
+    color: #2b81af;
+    background-color: #D2E0E6;
+
+    border-bottom: 1px solid white;
+}
+
+/** Fixture */
+
+#qunit-fixture {
+    position: absolute;
+    top: -10000px;
+    left: -10000px;
+}

=== added file 'addons/base/static/qunit/qunit-2011-23-22.js'
--- addons/base/static/qunit/qunit-2011-23-22.js	1970-01-01 00:00:00 +0000
+++ addons/base/static/qunit/qunit-2011-23-22.js	2011-03-23 12:39:27 +0000
@@ -0,0 +1,1456 @@
+/*
+ * QUnit - A JavaScript Unit Testing Framework
+ * 
+ * http://docs.jquery.com/QUnit
+ *
+ * Copyright (c) 2011 John Resig, Jörn Zaefferer
+ * Dual licensed under the MIT (MIT-LICENSE.txt)
+ * or GPL (GPL-LICENSE.txt) licenses.
+ */
+
+(function(window) {
+
+    var defined = {
+        setTimeout: typeof window.setTimeout !== "undefined",
+        sessionStorage: (function() {
+            try {
+                return !!sessionStorage.getItem;
+            } catch(e) {
+                return false;
+            }
+        })()
+    };
+
+    var testId = 0;
+
+    var Test = function(name, testName, expected, testEnvironmentArg, async, callback) {
+        this.name = name;
+        this.testName = testName;
+        this.expected = expected;
+        this.testEnvironmentArg = testEnvironmentArg;
+        this.async = async;
+        this.callback = callback;
+        this.assertions = [];
+    };
+    Test.prototype = {
+        init: function() {
+            var tests = id("qunit-tests");
+            if (tests) {
+                var b = document.createElement("strong");
+                b.innerHTML = "Running " + this.name;
+                var li = document.createElement("li");
+                li.appendChild(b);
+                li.className = "running";
+                li.id = this.id = "test-output" + testId++;
+                tests.appendChild(li);
+            }
+        },
+        setup: function() {
+            if (this.module != config.previousModule) {
+                if (config.previousModule) {
+                    QUnit.moduleDone({
+                                name: config.previousModule,
+                                failed: config.moduleStats.bad,
+                                passed: config.moduleStats.all - config.moduleStats.bad,
+                                total: config.moduleStats.all
+                            });
+                }
+                config.previousModule = this.module;
+                config.moduleStats = { all: 0, bad: 0 };
+                QUnit.moduleStart({
+                            name: this.module
+                        });
+            }
+
+            config.current = this;
+            this.testEnvironment = extend({
+                        setup: function() {
+                        },
+                        teardown: function() {
+                        }
+                    }, this.moduleTestEnvironment);
+            if (this.testEnvironmentArg) {
+                extend(this.testEnvironment, this.testEnvironmentArg);
+            }
+
+            QUnit.testStart({
+                        name: this.testName
+                    });
+
+            // allow utility functions to access the current test environment
+            // TODO why??
+            QUnit.current_testEnvironment = this.testEnvironment;
+
+            try {
+                if (!config.pollution) {
+                    saveGlobal();
+                }
+
+                this.testEnvironment.setup.call(this.testEnvironment);
+            } catch(e) {
+                QUnit.ok(false, "Setup failed on " + this.testName + ": " + e.message);
+            }
+        },
+        run: function() {
+            if (this.async) {
+                QUnit.stop();
+            }
+
+            if (config.notrycatch) {
+                this.callback.call(this.testEnvironment);
+                return;
+            }
+            try {
+                this.callback.call(this.testEnvironment);
+            } catch(e) {
+                fail("Test " + this.testName + " died, exception and test follows", e, this.callback);
+                QUnit.ok(false, "Died on test #" + (this.assertions.length + 1) + ": " + e.message + " - " + QUnit.jsDump.parse(e));
+                // else next test will carry the responsibility
+                saveGlobal();
+
+                // Restart the tests if they're blocking
+                if (config.blocking) {
+                    start();
+                }
+            }
+        },
+        teardown: function() {
+            try {
+                checkPollution();
+                this.testEnvironment.teardown.call(this.testEnvironment);
+            } catch(e) {
+                QUnit.ok(false, "Teardown failed on " + this.testName + ": " + e.message);
+            }
+        },
+        finish: function() {
+            if (this.expected && this.expected != this.assertions.length) {
+                QUnit.ok(false, "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run");
+            }
+
+            var good = 0, bad = 0,
+                    tests = id("qunit-tests");
+
+            config.stats.all += this.assertions.length;
+            config.moduleStats.all += this.assertions.length;
+
+            if (tests) {
+                var ol = document.createElement("ol");
+
+                for (var i = 0; i < this.assertions.length; i++) {
+                    var assertion = this.assertions[i];
+
+                    var li = document.createElement("li");
+                    li.className = assertion.result ? "pass" : "fail";
+                    li.innerHTML = assertion.message || (assertion.result ? "okay" : "failed");
+                    ol.appendChild(li);
+
+                    if (assertion.result) {
+                        good++;
+                    } else {
+                        bad++;
+                        config.stats.bad++;
+                        config.moduleStats.bad++;
+                    }
+                }
+
+                // store result when possible
+                if (QUnit.config.reorder && defined.sessionStorage) {
+                    if (bad) {
+                        sessionStorage.setItem("qunit-" + this.module + "-" + this.testName, bad)
+                    } else {
+                        sessionStorage.removeItem("qunit-" + this.testName);
+                    }
+                }
+
+                if (bad == 0) {
+                    ol.style.display = "none";
+                }
+
+                var b = document.createElement("strong");
+                b.innerHTML = this.name + " <b class='counts'>(<b class='failed'>" + bad + "</b>, <b class='passed'>" + good + "</b>, " + this.assertions.length + ")</b>";
+
+                var a = document.createElement("a");
+                a.innerHTML = "Rerun";
+                a.href = QUnit.url({ filter: getText([b]).replace(/\([^)]+\)$/, "").replace(/(^\s*|\s*$)/g, "") });
+
+                addEvent(b, "click", function() {
+                    var next = b.nextSibling.nextSibling,
+                            display = next.style.display;
+                    next.style.display = display === "none" ? "block" : "none";
+                });
+
+                addEvent(b, "dblclick", function(e) {
+                    var target = e && e.target ? e.target : window.event.srcElement;
+                    if (target.nodeName.toLowerCase() == "span" || target.nodeName.toLowerCase() == "b") {
+                        target = target.parentNode;
+                    }
+                    if (window.location && target.nodeName.toLowerCase() === "strong") {
+                        window.location = QUnit.url({ filter: getText([target]).replace(/\([^)]+\)$/, "").replace(/(^\s*|\s*$)/g, "") });
+                    }
+                });
+
+                var li = id(this.id);
+                li.className = bad ? "fail" : "pass";
+                li.removeChild(li.firstChild);
+                li.appendChild(b);
+                li.appendChild(a);
+                li.appendChild(ol);
+
+            } else {
+                for (var i = 0; i < this.assertions.length; i++) {
+                    if (!this.assertions[i].result) {
+                        bad++;
+                        config.stats.bad++;
+                        config.moduleStats.bad++;
+                    }
+                }
+            }
+
+            try {
+                QUnit.reset();
+            } catch(e) {
+                fail("reset() failed, following Test " + this.testName + ", exception and reset fn follows", e, QUnit.reset);
+            }
+
+            QUnit.testDone({
+                        name: this.testName,
+                        failed: bad,
+                        passed: this.assertions.length - bad,
+                        total: this.assertions.length
+                    });
+        },
+
+        queue: function() {
+            var test = this;
+            synchronize(function() {
+                test.init();
+            });
+            function run() {
+                // each of these can by async
+                synchronize(function() {
+                    test.setup();
+                });
+                synchronize(function() {
+                    test.run();
+                });
+                synchronize(function() {
+                    test.teardown();
+                });
+                synchronize(function() {
+                    test.finish();
+                });
+            }
+
+            // defer when previous test run passed, if storage is available
+            var bad = QUnit.config.reorder && defined.sessionStorage && +sessionStorage.getItem("qunit-" + this.module + "-" + this.testName);
+            if (bad) {
+                run();
+            } else {
+                synchronize(run);
+            }
+            ;
+        }
+
+    };
+
+    var QUnit = {
+
+        // call on start of module test to prepend name to all tests
+        module: function(name, testEnvironment) {
+            config.currentModule = name;
+            config.currentModuleTestEnviroment = testEnvironment;
+        },
+
+        asyncTest: function(testName, expected, callback) {
+            if (arguments.length === 2) {
+                callback = expected;
+                expected = 0;
+            }
+
+            QUnit.test(testName, expected, callback, true);
+        },
+
+        test: function(testName, expected, callback, async) {
+            var name = '<span class="test-name">' + testName + '</span>', testEnvironmentArg;
+
+            if (arguments.length === 2) {
+                callback = expected;
+                expected = null;
+            }
+            // is 2nd argument a testEnvironment?
+            if (expected && typeof expected === 'object') {
+                testEnvironmentArg = expected;
+                expected = null;
+            }
+
+            if (config.currentModule) {
+                name = '<span class="module-name">' + config.currentModule + "</span>: " + name;
+            }
+
+            if (!validTest(config.currentModule + ": " + testName)) {
+                return;
+            }
+
+            var test = new Test(name, testName, expected, testEnvironmentArg, async, callback);
+            test.module = config.currentModule;
+            test.moduleTestEnvironment = config.currentModuleTestEnviroment;
+            test.queue();
+        },
+
+        /**
+         * Specify the number of expected assertions to gurantee that failed test (no assertions are run at all) don't slip through.
+         */
+        expect: function(asserts) {
+            config.current.expected = asserts;
+        },
+
+        /**
+         * Asserts true.
+         * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" );
+         */
+        ok: function(a, msg) {
+            a = !!a;
+            var details = {
+                result: a,
+                message: msg
+            };
+            msg = escapeHtml(msg);
+            QUnit.log(details);
+            config.current.assertions.push({
+                        result: a,
+                        message: msg
+                    });
+        },
+
+        /**
+         * Checks that the first two arguments are equal, with an optional message.
+         * Prints out both actual and expected values.
+         *
+         * Prefered to ok( actual == expected, message )
+         *
+         * @example equal( format("Received {0} bytes.", 2), "Received 2 bytes." );
+         *
+         * @param Object actual
+         * @param Object expected
+         * @param String message (optional)
+         */
+        equal: function(actual, expected, message) {
+            QUnit.push(expected == actual, actual, expected, message);
+        },
+
+        notEqual: function(actual, expected, message) {
+            QUnit.push(expected != actual, actual, expected, message);
+        },
+
+        deepEqual: function(actual, expected, message) {
+            QUnit.push(QUnit.equiv(actual, expected), actual, expected, message);
+        },
+
+        notDeepEqual: function(actual, expected, message) {
+            QUnit.push(!QUnit.equiv(actual, expected), actual, expected, message);
+        },
+
+        strictEqual: function(actual, expected, message) {
+            QUnit.push(expected === actual, actual, expected, message);
+        },
+
+        notStrictEqual: function(actual, expected, message) {
+            QUnit.push(expected !== actual, actual, expected, message);
+        },
+
+        raises: function(block, expected, message) {
+            var actual, ok = false;
+
+            if (typeof expected === 'string') {
+                message = expected;
+                expected = null;
+            }
+
+            try {
+                block();
+            } catch (e) {
+                actual = e;
+            }
+
+            if (actual) {
+                // we don't want to validate thrown error
+                if (!expected) {
+                    ok = true;
+                    // expected is a regexp
+                } else if (QUnit.objectType(expected) === "regexp") {
+                    ok = expected.test(actual);
+                    // expected is a constructor
+                } else if (actual instanceof expected) {
+                    ok = true;
+                    // expected is a validation function which returns true is validation passed
+                } else if (expected.call({}, actual) === true) {
+                    ok = true;
+                }
+            }
+
+            QUnit.ok(ok, message);
+        },
+
+        start: function() {
+            config.semaphore--;
+            if (config.semaphore > 0) {
+                // don't start until equal number of stop-calls
+                return;
+            }
+            if (config.semaphore < 0) {
+                // ignore if start is called more often then stop
+                config.semaphore = 0;
+            }
+            // A slight delay, to avoid any current callbacks
+            if (defined.setTimeout) {
+                window.setTimeout(function() {
+                    if (config.timeout) {
+                        clearTimeout(config.timeout);
+                    }
+
+                    config.blocking = false;
+                    process();
+                }, 13);
+            } else {
+                config.blocking = false;
+                process();
+            }
+        },
+
+        stop: function(timeout) {
+            config.semaphore++;
+            config.blocking = true;
+
+            if (timeout && defined.setTimeout) {
+                clearTimeout(config.timeout);
+                config.timeout = window.setTimeout(function() {
+                    QUnit.ok(false, "Test timed out");
+                    QUnit.start();
+                }, timeout);
+            }
+        }
+    };
+
+// Backwards compatibility, deprecated
+    QUnit.equals = QUnit.equal;
+    QUnit.same = QUnit.deepEqual;
+
+// Maintain internal state
+    var config = {
+        // The queue of tests to run
+        queue: [],
+
+        // block until document ready
+        blocking: true,
+
+        // by default, run previously failed tests first
+        // very useful in combination with "Hide passed tests" checked
+        reorder: true,
+
+        noglobals: false,
+        notrycatch: false
+    };
+
+// Load paramaters
+    (function() {
+        var location = window.location || { search: "", protocol: "file:" },
+                params = location.search.slice(1).split("&"),
+                length = params.length,
+                urlParams = {},
+                current;
+
+        if (params[ 0 ]) {
+            for (var i = 0; i < length; i++) {
+                current = params[ i ].split("=");
+                current[ 0 ] = decodeURIComponent(current[ 0 ]);
+                // allow just a key to turn on a flag, e.g., test.html?noglobals
+                current[ 1 ] = current[ 1 ] ? decodeURIComponent(current[ 1 ]) : true;
+                urlParams[ current[ 0 ] ] = current[ 1 ];
+                if (current[ 0 ] in config) {
+                    config[ current[ 0 ] ] = current[ 1 ];
+                }
+            }
+        }
+
+        QUnit.urlParams = urlParams;
+        config.filter = urlParams.filter;
+
+        // Figure out if we're running the tests from a server or not
+        QUnit.isLocal = !!(location.protocol === 'file:');
+    })();
+
+// Expose the API as global variables, unless an 'exports'
+// object exists, in that case we assume we're in CommonJS
+    if (typeof exports === "undefined" || typeof require === "undefined") {
+        extend(window, QUnit);
+        window.QUnit = QUnit;
+    } else {
+        extend(exports, QUnit);
+        exports.QUnit = QUnit;
+    }
+
+// define these after exposing globals to keep them in these QUnit namespace only
+    extend(QUnit, {
+                config: config,
+
+                // Initialize the configuration options
+                init: function() {
+                    extend(config, {
+                                stats: { all: 0, bad: 0 },
+                                moduleStats: { all: 0, bad: 0 },
+                                started: +new Date,
+                                updateRate: 1000,
+                                blocking: false,
+                                autostart: true,
+                                autorun: false,
+                                filter: "",
+                                queue: [],
+                                semaphore: 0
+                            });
+
+                    var tests = id("qunit-tests"),
+                            banner = id("qunit-banner"),
+                            result = id("qunit-testresult");
+
+                    if (tests) {
+                        tests.innerHTML = "";
+                    }
+
+                    if (banner) {
+                        banner.className = "";
+                    }
+
+                    if (result) {
+                        result.parentNode.removeChild(result);
+                    }
+
+                    if (tests) {
+                        result = document.createElement("p");
+                        result.id = "qunit-testresult";
+                        result.className = "result";
+                        tests.parentNode.insertBefore(result, tests);
+                        result.innerHTML = 'Running...<br/>&nbsp;';
+                    }
+                },
+
+                /**
+                 * Resets the test setup. Useful for tests that modify the DOM.
+                 *
+                 * If jQuery is available, uses jQuery's html(), otherwise just innerHTML.
+                 */
+                reset: function() {
+                    if (window.jQuery) {
+                        jQuery("#main, #qunit-fixture").html(config.fixture);
+                    } else {
+                        var main = id('main') || id('qunit-fixture');
+                        if (main) {
+                            main.innerHTML = config.fixture;
+                        }
+                    }
+                },
+
+                /**
+                 * Trigger an event on an element.
+                 *
+                 * @example triggerEvent( document.body, "click" );
+                 *
+                 * @param {Element} elem
+                 * @param {String} type
+                 */
+                triggerEvent: function(elem, type, event) {
+                    if (document.createEvent) {
+                        event = document.createEvent("MouseEvents");
+                        event.initMouseEvent(type, true, true, elem.ownerDocument.defaultView,
+                                0, 0, 0, 0, 0, false, false, false, false, 0, null);
+                        elem.dispatchEvent(event);
+
+                    } else if (elem.fireEvent) {
+                        elem.fireEvent("on" + type);
+                    }
+                },
+
+                // Safe object type checking
+                is: function(type, obj) {
+                    return QUnit.objectType(obj) == type;
+                },
+
+                objectType: function(obj) {
+                    if (typeof obj === "undefined") {
+                        return "undefined";
+
+                        // consider: typeof null === object
+                    }
+                    if (obj === null) {
+                        return "null";
+                    }
+
+                    var type = Object.prototype.toString.call(obj)
+                            .match(/^\[object\s(.*)\]$/)[1] || '';
+
+                    switch (type) {
+                        case 'Number':
+                            if (isNaN(obj)) {
+                                return "nan";
+                            } else {
+                                return "number";
+                            }
+                        case 'String':
+                        case 'Boolean':
+                        case 'Array':
+                        case 'Date':
+                        case 'RegExp':
+                        case 'Function':
+                            return type.toLowerCase();
+                    }
+                    if (typeof obj === "object") {
+                        return "object";
+                    }
+                    return undefined;
+                },
+
+                push: function(result, actual, expected, message) {
+                    var details = {
+                        result: result,
+                        message: message,
+                        actual: actual,
+                        expected: expected
+                    };
+
+                    message = escapeHtml(message) || (result ? "okay" : "failed");
+                    message = '<span class="test-message">' + message + "</span>";
+                    expected = escapeHtml(QUnit.jsDump.parse(expected));
+                    actual = escapeHtml(QUnit.jsDump.parse(actual));
+                    var output = message + '<table><tr class="test-expected"><th>Expected: </th><td><pre>' + expected + '</pre></td></tr>';
+                    if (actual != expected) {
+                        output += '<tr class="test-actual"><th>Result: </th><td><pre>' + actual + '</pre></td></tr>';
+                        output += '<tr class="test-diff"><th>Diff: </th><td><pre>' + QUnit.diff(expected, actual) + '</pre></td></tr>';
+                    }
+                    if (!result) {
+                        var source = sourceFromStacktrace();
+                        if (source) {
+                            details.source = source;
+                            output += '<tr class="test-source"><th>Source: </th><td><pre>' + source + '</pre></td></tr>';
+                        }
+                    }
+                    output += "</table>";
+
+                    QUnit.log(details);
+
+                    config.current.assertions.push({
+                                result: !!result,
+                                message: output
+                            });
+                },
+
+                url: function(params) {
+                    params = extend(extend({}, QUnit.urlParams), params);
+                    var querystring = "?",
+                            key;
+                    for (key in params) {
+                        querystring += encodeURIComponent(key) + "=" +
+                                encodeURIComponent(params[ key ]) + "&";
+                    }
+                    return window.location.pathname + querystring.slice(0, -1);
+                },
+
+                // Logging callbacks; all receive a single argument with the listed properties
+                // run test/logs.html for any related changes
+                begin: function() {
+                },
+                // done: { failed, passed, total, runtime }
+                done: function() {
+                },
+                // log: { result, actual, expected, message }
+                log: function() {
+                },
+                // testStart: { name }
+                testStart: function() {
+                },
+                // testDone: { name, failed, passed, total }
+                testDone: function() {
+                },
+                // moduleStart: { name }
+                moduleStart: function() {
+                },
+                // moduleDone: { name, failed, passed, total }
+                moduleDone: function() {
+                }
+            });
+
+    if (typeof document === "undefined" || document.readyState === "complete") {
+        config.autorun = true;
+    }
+
+    addEvent(window, "load", function() {
+        QUnit.begin({});
+
+        // Initialize the config, saving the execution queue
+        var oldconfig = extend({}, config);
+        QUnit.init();
+        extend(config, oldconfig);
+
+        config.blocking = false;
+
+        var userAgent = id("qunit-userAgent");
+        if (userAgent) {
+            userAgent.innerHTML = navigator.userAgent;
+        }
+        var banner = id("qunit-header");
+        if (banner) {
+            banner.innerHTML = '<a href="' + QUnit.url({ filter: undefined }) + '"> ' + banner.innerHTML + '</a> ' +
+                    '<label><input name="noglobals" type="checkbox"' + ( config.noglobals ? ' checked="checked"' : '' ) + '>noglobals</label>' +
+                    '<label><input name="notrycatch" type="checkbox"' + ( config.notrycatch ? ' checked="checked"' : '' ) + '>notrycatch</label>';
+            addEvent(banner, "change", function(event) {
+                var params = {};
+                params[ event.target.name ] = event.target.checked ? true : undefined;
+                window.location = QUnit.url(params);
+            });
+        }
+
+        var toolbar = id("qunit-testrunner-toolbar");
+        if (toolbar) {
+            var filter = document.createElement("input");
+            filter.type = "checkbox";
+            filter.id = "qunit-filter-pass";
+            addEvent(filter, "click", function() {
+                var ol = document.getElementById("qunit-tests");
+                if (filter.checked) {
+                    ol.className = ol.className + " hidepass";
+                } else {
+                    var tmp = " " + ol.className.replace(/[\n\t\r]/g, " ") + " ";
+                    ol.className = tmp.replace(/ hidepass /, " ");
+                }
+                if (defined.sessionStorage) {
+                    if (filter.checked) {
+                        sessionStorage.setItem("qunit-filter-passed-tests", "true");
+                    } else {
+                        sessionStorage.removeItem("qunit-filter-passed-tests");
+                    }
+                }
+            });
+            if (defined.sessionStorage && sessionStorage.getItem("qunit-filter-passed-tests")) {
+                filter.checked = true;
+                var ol = document.getElementById("qunit-tests");
+                ol.className = ol.className + " hidepass";
+            }
+            toolbar.appendChild(filter);
+
+            var label = document.createElement("label");
+            label.setAttribute("for", "qunit-filter-pass");
+            label.innerHTML = "Hide passed tests";
+            toolbar.appendChild(label);
+        }
+
+        var main = id('main') || id('qunit-fixture');
+        if (main) {
+            config.fixture = main.innerHTML;
+        }
+
+        if (config.autostart) {
+            QUnit.start();
+        }
+    });
+
+    function done() {
+        config.autorun = true;
+
+        // Log the last module results
+        if (config.currentModule) {
+            QUnit.moduleDone({
+                        name: config.currentModule,
+                        failed: config.moduleStats.bad,
+                        passed: config.moduleStats.all - config.moduleStats.bad,
+                        total: config.moduleStats.all
+                    });
+        }
+
+        var banner = id("qunit-banner"),
+                tests = id("qunit-tests"),
+                runtime = +new Date - config.started,
+                passed = config.stats.all - config.stats.bad,
+                html = [
+                    'Tests completed in ',
+                    runtime,
+                    ' milliseconds.<br/>',
+                    '<span class="passed">',
+                    passed,
+                    '</span> tests of <span class="total">',
+                    config.stats.all,
+                    '</span> passed, <span class="failed">',
+                    config.stats.bad,
+                    '</span> failed.'
+                ].join('');
+
+        if (banner) {
+            banner.className = (config.stats.bad ? "qunit-fail" : "qunit-pass");
+        }
+
+        if (tests) {
+            id("qunit-testresult").innerHTML = html;
+        }
+
+        QUnit.done({
+                    failed: config.stats.bad,
+                    passed: passed,
+                    total: config.stats.all,
+                    runtime: runtime
+                });
+    }
+
+    function validTest(name) {
+        var filter = config.filter,
+                run = false;
+
+        if (!filter) {
+            return true;
+        }
+
+        var not = filter.charAt(0) === "!";
+        if (not) {
+            filter = filter.slice(1);
+        }
+
+        if (name.indexOf(filter) !== -1) {
+            return !not;
+        }
+
+        if (not) {
+            run = true;
+        }
+
+        return run;
+    }
+
+// so far supports only Firefox, Chrome and Opera (buggy)
+// could be extended in the future to use something like https://github.com/csnover/TraceKit
+    function sourceFromStacktrace() {
+        try {
+            throw new Error();
+        } catch (e) {
+            if (e.stacktrace) {
+                // Opera
+                return e.stacktrace.split("\n")[6];
+            } else if (e.stack) {
+                // Firefox, Chrome
+                return e.stack.split("\n")[4];
+            }
+        }
+    }
+
+    function escapeHtml(s) {
+        if (!s) {
+            return "";
+        }
+        s = s + "";
+        return s.replace(/[\&"<>\\]/g, function(s) {
+            switch (s) {
+                case "&": return "&amp;";
+                case "\\": return "\\\\";
+                case '"': return '\"';
+                case "<": return "&lt;";
+                case ">": return "&gt;";
+                default: return s;
+            }
+        });
+    }
+
+    function synchronize(callback) {
+        config.queue.push(callback);
+
+        if (config.autorun && !config.blocking) {
+            process();
+        }
+    }
+
+    function process() {
+        var start = (new Date()).getTime();
+
+        while (config.queue.length && !config.blocking) {
+            if (config.updateRate <= 0 || (((new Date()).getTime() - start) < config.updateRate)) {
+                config.queue.shift()();
+            } else {
+                window.setTimeout(process, 13);
+                break;
+            }
+        }
+        if (!config.blocking && !config.queue.length) {
+            done();
+        }
+    }
+
+    function saveGlobal() {
+        config.pollution = [];
+
+        if (config.noglobals) {
+            for (var key in window) {
+                config.pollution.push(key);
+            }
+        }
+    }
+
+    function checkPollution(name) {
+        var old = config.pollution;
+        saveGlobal();
+
+        var newGlobals = diff(config.pollution, old);
+        if (newGlobals.length > 0) {
+            ok(false, "Introduced global variable(s): " + newGlobals.join(", "));
+        }
+
+        var deletedGlobals = diff(old, config.pollution);
+        if (deletedGlobals.length > 0) {
+            ok(false, "Deleted global variable(s): " + deletedGlobals.join(", "));
+        }
+    }
+
+// returns a new Array with the elements that are in a but not in b
+    function diff(a, b) {
+        var result = a.slice();
+        for (var i = 0; i < result.length; i++) {
+            for (var j = 0; j < b.length; j++) {
+                if (result[i] === b[j]) {
+                    result.splice(i, 1);
+                    i--;
+                    break;
+                }
+            }
+        }
+        return result;
+    }
+
+    function fail(message, exception, callback) {
+        if (typeof console !== "undefined" && console.error && console.warn) {
+            console.error(message);
+            console.error(exception);
+            console.warn(callback.toString());
+
+        } else if (window.opera && opera.postError) {
+            opera.postError(message, exception, callback.toString);
+        }
+    }
+
+    function extend(a, b) {
+        for (var prop in b) {
+            if (b[prop] === undefined) {
+                delete a[prop];
+            } else {
+                a[prop] = b[prop];
+            }
+        }
+
+        return a;
+    }
+
+    function addEvent(elem, type, fn) {
+        if (elem.addEventListener) {
+            elem.addEventListener(type, fn, false);
+        } else if (elem.attachEvent) {
+            elem.attachEvent("on" + type, fn);
+        } else {
+            fn();
+        }
+    }
+
+    function id(name) {
+        return !!(typeof document !== "undefined" && document && document.getElementById) &&
+                document.getElementById(name);
+    }
+
+// Test for equality any JavaScript type.
+// Discussions and reference: http://philrathe.com/articles/equiv
+// Test suites: http://philrathe.com/tests/equiv
+// Author: Philippe Rathé <prathe@xxxxxxxxx>
+    QUnit.equiv = function () {
+
+        var innerEquiv; // the real equiv function
+        var callers = []; // stack to decide between skip/abort functions
+        var parents = []; // stack to avoiding loops from circular referencing
+
+        // Call the o related callback with the given arguments.
+        function bindCallbacks(o, callbacks, args) {
+            var prop = QUnit.objectType(o);
+            if (prop) {
+                if (QUnit.objectType(callbacks[prop]) === "function") {
+                    return callbacks[prop].apply(callbacks, args);
+                } else {
+                    return callbacks[prop]; // or undefined
+                }
+            }
+        }
+
+        var callbacks = function () {
+
+            // for string, boolean, number and null
+            function useStrictEquality(b, a) {
+                if (b instanceof a.constructor || a instanceof b.constructor) {
+                    // to catch short annotaion VS 'new' annotation of a declaration
+                    // e.g. var i = 1;
+                    //      var j = new Number(1);
+                    return a == b;
+                } else {
+                    return a === b;
+                }
+            }
+
+            return {
+                "string": useStrictEquality,
+                "boolean": useStrictEquality,
+                "number": useStrictEquality,
+                "null": useStrictEquality,
+                "undefined": useStrictEquality,
+
+                "nan": function (b) {
+                    return isNaN(b);
+                },
+
+                "date": function (b, a) {
+                    return QUnit.objectType(b) === "date" && a.valueOf() === b.valueOf();
+                },
+
+                "regexp": function (b, a) {
+                    return QUnit.objectType(b) === "regexp" &&
+                            a.source === b.source && // the regex itself
+                            a.global === b.global && // and its modifers (gmi) ...
+                            a.ignoreCase === b.ignoreCase &&
+                            a.multiline === b.multiline;
+                },
+
+                // - skip when the property is a method of an instance (OOP)
+                // - abort otherwise,
+                //   initial === would have catch identical references anyway
+                "function": function () {
+                    var caller = callers[callers.length - 1];
+                    return caller !== Object &&
+                            typeof caller !== "undefined";
+                },
+
+                "array": function (b, a) {
+                    var i, j, loop;
+                    var len;
+
+                    // b could be an object literal here
+                    if (! (QUnit.objectType(b) === "array")) {
+                        return false;
+                    }
+
+                    len = a.length;
+                    if (len !== b.length) { // safe and faster
+                        return false;
+                    }
+
+                    //track reference to avoid circular references
+                    parents.push(a);
+                    for (i = 0; i < len; i++) {
+                        loop = false;
+                        for (j = 0; j < parents.length; j++) {
+                            if (parents[j] === a[i]) {
+                                loop = true;//dont rewalk array
+                            }
+                        }
+                        if (!loop && ! innerEquiv(a[i], b[i])) {
+                            parents.pop();
+                            return false;
+                        }
+                    }
+                    parents.pop();
+                    return true;
+                },
+
+                "object": function (b, a) {
+                    var i, j, loop;
+                    var eq = true; // unless we can proove it
+                    var aProperties = [], bProperties = []; // collection of strings
+
+                    // comparing constructors is more strict than using instanceof
+                    if (a.constructor !== b.constructor) {
+                        return false;
+                    }
+
+                    // stack constructor before traversing properties
+                    callers.push(a.constructor);
+                    //track reference to avoid circular references
+                    parents.push(a);
+
+                    for (i in a) { // be strict: don't ensures hasOwnProperty and go deep
+                        loop = false;
+                        for (j = 0; j < parents.length; j++) {
+                            if (parents[j] === a[i])
+                                loop = true; //don't go down the same path twice
+                        }
+                        aProperties.push(i); // collect a's properties
+
+                        if (!loop && ! innerEquiv(a[i], b[i])) {
+                            eq = false;
+                            break;
+                        }
+                    }
+
+                    callers.pop(); // unstack, we are done
+                    parents.pop();
+
+                    for (i in b) {
+                        bProperties.push(i); // collect b's properties
+                    }
+
+                    // Ensures identical properties name
+                    return eq && innerEquiv(aProperties.sort(), bProperties.sort());
+                }
+            };
+        }();
+
+        innerEquiv = function () { // can take multiple arguments
+            var args = Array.prototype.slice.apply(arguments);
+            if (args.length < 2) {
+                return true; // end transition
+            }
+
+            return (function (a, b) {
+                if (a === b) {
+                    return true; // catch the most you can
+                } else if (a === null || b === null || typeof a === "undefined" || typeof b === "undefined" || QUnit.objectType(a) !== QUnit.objectType(b)) {
+                    return false; // don't lose time with error prone cases
+                } else {
+                    return bindCallbacks(a, callbacks, [b, a]);
+                }
+
+                // apply transition with (1..n) arguments
+            })(args[0], args[1]) && arguments.callee.apply(this, args.splice(1, args.length - 1));
+        };
+
+        return innerEquiv;
+
+    }();
+
+    /**
+     * jsDump
+     * Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com | http://flesler.blogspot.com
+     * Licensed under BSD (http://www.opensource.org/licenses/bsd-license.php)
+     * Date: 5/15/2008
+     * @projectDescription Advanced and extensible data dumping for Javascript.
+     * @version 1.0.0
+     * @author Ariel Flesler
+     * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html}
+     */
+    QUnit.jsDump = (function() {
+        function quote(str) {
+            return '"' + str.toString().replace(/"/g, '\\"') + '"';
+        }
+
+        function literal(o) {
+            return o + '';
+        }
+
+        function join(pre, arr, post) {
+            var s = jsDump.separator(),
+                    base = jsDump.indent(),
+                    inner = jsDump.indent(1);
+            if (arr.join)
+                arr = arr.join(',' + s + inner);
+            if (!arr)
+                return pre + post;
+            return [ pre, inner + arr, base + post ].join(s);
+        }
+        
+        function array(arr) {
+            var i = arr.length,    ret = Array(i);
+            this.up();
+            while (i--)
+                ret[i] = this.parse(arr[i]);
+            this.down();
+            return join('[', ret, ']');
+        }
+
+        var reName = /^function (\w+)/;
+
+        var jsDump = {
+            parse:function(obj, type) { //type is used mostly internally, you can fix a (custom)type in advance
+                var parser = this.parsers[ type || this.typeOf(obj) ];
+                type = typeof parser;
+
+                return type == 'function' ? parser.call(this, obj) :
+                        type == 'string' ? parser :
+                                this.parsers.error;
+            },
+            typeOf:function(obj) {
+                var type;
+                if (obj === null) {
+                    type = "null";
+                } else if (typeof obj === "undefined") {
+                    type = "undefined";
+                } else if (QUnit.is("RegExp", obj)) {
+                    type = "regexp";
+                } else if (QUnit.is("Date", obj)) {
+                    type = "date";
+                } else if (QUnit.is("Function", obj)) {
+                    type = "function";
+                } else if (typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined") {
+                    type = "window";
+                } else if (obj.nodeType === 9) {
+                    type = "document";
+                } else if (obj.nodeType) {
+                    type = "node";
+                } else if (typeof obj === "object" && typeof obj.length === "number" && obj.length >= 0) {
+                    type = "array";
+                } else {
+                    type = typeof obj;
+                }
+                return type;
+            },
+            separator:function() {
+                return this.multiline ? this.HTML ? '<br />' : '\n' : this.HTML ? '&nbsp;' : ' ';
+            },
+            indent:function(extra) {// extra can be a number, shortcut for increasing-calling-decreasing
+                if (!this.multiline)
+                    return '';
+                var chr = this.indentChar;
+                if (this.HTML)
+                    chr = chr.replace(/\t/g, '   ').replace(/ /g, '&nbsp;');
+                return Array(this._depth_ + (extra || 0)).join(chr);
+            },
+            up:function(a) {
+                this._depth_ += a || 1;
+            },
+            down:function(a) {
+                this._depth_ -= a || 1;
+            },
+            setParser:function(name, parser) {
+                this.parsers[name] = parser;
+            },
+            // The next 3 are exposed so you can use them
+            quote:quote,
+            literal:literal,
+            join:join,
+            //
+            _depth_: 1,
+            // This is the list of parsers, to modify them, use jsDump.setParser
+            parsers:{
+                window: '[Window]',
+                document: '[Document]',
+                error:'[ERROR]', //when no parser is found, shouldn't happen
+                unknown: '[Unknown]',
+                'null':'null',
+                'undefined':'undefined',
+                'function':function(fn) {
+                    var ret = 'function',
+                            name = 'name' in fn ? fn.name : (reName.exec(fn) || [])[1];//functions never have name in IE
+                    if (name)
+                        ret += ' ' + name;
+                    ret += '(';
+
+                    ret = [ ret, QUnit.jsDump.parse(fn, 'functionArgs'), '){'].join('');
+                    return join(ret, QUnit.jsDump.parse(fn, 'functionCode'), '}');
+                },
+                array: array,
+                nodelist: array,
+                arguments: array,
+                object:function(map) {
+                    var ret = [ ];
+                    QUnit.jsDump.up();
+                    for (var key in map)
+                        ret.push(QUnit.jsDump.parse(key, 'key') + ': ' + QUnit.jsDump.parse(map[key]));
+                    QUnit.jsDump.down();
+                    return join('{', ret, '}');
+                },
+                node:function(node) {
+                    var open = QUnit.jsDump.HTML ? '&lt;' : '<',
+                            close = QUnit.jsDump.HTML ? '&gt;' : '>';
+
+                    var tag = node.nodeName.toLowerCase(),
+                            ret = open + tag;
+
+                    for (var a in QUnit.jsDump.DOMAttrs) {
+                        var val = node[QUnit.jsDump.DOMAttrs[a]];
+                        if (val)
+                            ret += ' ' + a + '=' + QUnit.jsDump.parse(val, 'attribute');
+                    }
+                    return ret + close + open + '/' + tag + close;
+                },
+                functionArgs:function(fn) {//function calls it internally, it's the arguments part of the function
+                    var l = fn.length;
+                    if (!l) return '';
+
+                    var args = Array(l);
+                    while (l--)
+                        args[l] = String.fromCharCode(97 + l);//97 is 'a'
+                    return ' ' + args.join(', ') + ' ';
+                },
+                key:quote, //object calls it internally, the key part of an item in a map
+                functionCode:'[code]', //function calls it internally, it's the content of the function
+                attribute:quote, //node calls it internally, it's an html attribute value
+                string:quote,
+                date:quote,
+                regexp:literal, //regex
+                number:literal,
+                'boolean':literal
+            },
+            DOMAttrs:{//attributes to dump from nodes, name=>realName
+                id:'id',
+                name:'name',
+                'class':'className'
+            },
+            HTML:false,//if true, entities are escaped ( <, >, \t, space and \n )
+            indentChar:'  ',//indentation unit
+            multiline:true //if true, items in a collection, are separated by a \n, else just a space.
+        };
+
+        return jsDump;
+    })();
+
+// from Sizzle.js
+    function getText(elems) {
+        var ret = "", elem;
+
+        for (var i = 0; elems[i]; i++) {
+            elem = elems[i];
+
+            // Get the text from text nodes and CDATA nodes
+            if (elem.nodeType === 3 || elem.nodeType === 4) {
+                ret += elem.nodeValue;
+
+                // Traverse everything else, except comment nodes
+            } else if (elem.nodeType !== 8) {
+                ret += getText(elem.childNodes);
+            }
+        }
+
+        return ret;
+    }
+
+    /*
+     * Javascript Diff Algorithm
+     *  By John Resig (http://ejohn.org/)
+     *  Modified by Chu Alan "sprite"
+     *
+     * Released under the MIT license.
+     *
+     * More Info:
+     *  http://ejohn.org/projects/javascript-diff-algorithm/
+     *
+     * Usage: QUnit.diff(expected, actual)
+     *
+     * QUnit.diff("the quick brown fox jumped over", "the quick fox jumps over") == "the  quick <del>brown </del> fox <del>jumped </del><ins>jumps </ins> over"
+     */
+    QUnit.diff = (function() {
+        function diff(o, n) {
+            var ns = new Object();
+            var os = new Object();
+
+            for (var i = 0; i < n.length; i++) {
+                if (ns[n[i]] == null)
+                    ns[n[i]] = {
+                        rows: new Array(),
+                        o: null
+                    };
+                ns[n[i]].rows.push(i);
+            }
+
+            for (var i = 0; i < o.length; i++) {
+                if (os[o[i]] == null)
+                    os[o[i]] = {
+                        rows: new Array(),
+                        n: null
+                    };
+                os[o[i]].rows.push(i);
+            }
+
+            for (var i in ns) {
+                if (ns[i].rows.length == 1 && typeof(os[i]) != "undefined" && os[i].rows.length == 1) {
+                    n[ns[i].rows[0]] = {
+                        text: n[ns[i].rows[0]],
+                        row: os[i].rows[0]
+                    };
+                    o[os[i].rows[0]] = {
+                        text: o[os[i].rows[0]],
+                        row: ns[i].rows[0]
+                    };
+                }
+            }
+
+            for (var i = 0; i < n.length - 1; i++) {
+                if (n[i].text != null && n[i + 1].text == null && n[i].row + 1 < o.length && o[n[i].row + 1].text == null &&
+                        n[i + 1] == o[n[i].row + 1]) {
+                    n[i + 1] = {
+                        text: n[i + 1],
+                        row: n[i].row + 1
+                    };
+                    o[n[i].row + 1] = {
+                        text: o[n[i].row + 1],
+                        row: i + 1
+                    };
+                }
+            }
+
+            for (var i = n.length - 1; i > 0; i--) {
+                if (n[i].text != null && n[i - 1].text == null && n[i].row > 0 && o[n[i].row - 1].text == null &&
+                        n[i - 1] == o[n[i].row - 1]) {
+                    n[i - 1] = {
+                        text: n[i - 1],
+                        row: n[i].row - 1
+                    };
+                    o[n[i].row - 1] = {
+                        text: o[n[i].row - 1],
+                        row: i - 1
+                    };
+                }
+            }
+
+            return {
+                o: o,
+                n: n
+            };
+        }
+
+        return function(o, n) {
+            o = o.replace(/\s+$/, '');
+            n = n.replace(/\s+$/, '');
+            var out = diff(o == "" ? [] : o.split(/\s+/), n == "" ? [] : n.split(/\s+/));
+
+            var str = "";
+
+            var oSpace = o.match(/\s+/g);
+            if (oSpace == null) {
+                oSpace = [" "];
+            }
+            else {
+                oSpace.push(" ");
+            }
+            var nSpace = n.match(/\s+/g);
+            if (nSpace == null) {
+                nSpace = [" "];
+            }
+            else {
+                nSpace.push(" ");
+            }
+
+            if (out.n.length == 0) {
+                for (var i = 0; i < out.o.length; i++) {
+                    str += '<del>' + out.o[i] + oSpace[i] + "</del>";
+                }
+            }
+            else {
+                if (out.n[0].text == null) {
+                    for (n = 0; n < out.o.length && out.o[n].text == null; n++) {
+                        str += '<del>' + out.o[n] + oSpace[n] + "</del>";
+                    }
+                }
+
+                for (var i = 0; i < out.n.length; i++) {
+                    if (out.n[i].text == null) {
+                        str += '<ins>' + out.n[i] + nSpace[i] + "</ins>";
+                    }
+                    else {
+                        var pre = "";
+
+                        for (n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++) {
+                            pre += '<del>' + out.o[n] + oSpace[n] + "</del>";
+                        }
+                        str += " " + out.n[i].text + nSpace[i] + pre;
+                    }
+                }
+            }
+
+            return str;
+        };
+    })();
+
+})(this);

=== added file 'addons/base/tests/test_dataset.py'
--- addons/base/tests/test_dataset.py	1970-01-01 00:00:00 +0000
+++ addons/base/tests/test_dataset.py	2011-03-23 12:39:27 +0000
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+import mock
+import unittest2
+import base.controllers.main
+
+class TestDataSetController(unittest2.TestCase):
+    def setUp(self):
+        self.dataset = base.controllers.main.DataSet()
+        self.request = mock.Mock()
+        self.read = self.request.session.model().read
+        self.search = self.request.session.model().search
+
+    def test_empty_find(self):
+        self.search.return_value = []
+        self.read.return_value = []
+
+        self.assertFalse(self.dataset.do_find(self.request, 'fake.model'))
+        self.read.assert_called_once_with([], False)
+
+    def test_regular_find(self):
+        self.search.return_value = [1, 2, 3]
+
+        self.dataset.do_find(self.request, 'fake.model')
+        self.read.assert_called_once_with([1, 2, 3], False)
+
+    def test_ids_shortcut(self):
+        self.search.return_value = [1, 2, 3]
+        self.read.return_value = [
+            {'id': 1, 'name': 'foo'},
+            {'id': 2, 'name': 'bar'},
+            {'id': 3, 'name': 'qux'}
+        ]
+
+        self.assertEqual(
+            self.dataset.do_find(self.request, 'fake.model', ['id']),
+            [{'id': 1}, {'id': 2}, {'id': 3}])
+        self.assertFalse(self.read.called)
+
+    def test_get(self):
+        self.read.return_value = [
+            {'id': 1, 'name': 'baz'},
+            {'id': 3, 'name': 'foo'},
+            {'id': 2, 'name': 'bar'}
+        ]
+
+        result = self.dataset.do_get(
+            self.request, 'fake.model', [3, 2, 1])
+        self.read.assert_called_once_with(
+            [3, 2, 1])
+        self.assertFalse(self.search.called)
+
+        self.assertEqual(
+            result,
+            [
+                {'id': 3, 'name': 'foo'},
+                {'id': 2, 'name': 'bar'},
+                {'id': 1, 'name': 'baz'}
+            ]
+        )
+
+    def test_get_missing_result(self):
+        self.read.return_value = [
+            {'id': 1, 'name': 'baz'},
+            {'id': 2, 'name': 'bar'}
+        ]
+
+        result = self.dataset.do_get(
+            self.request, 'fake.model', [3, 2, 1])
+
+        self.assertEqual(
+            result,
+            [
+                {'id': 2, 'name': 'bar'},
+                {'id': 1, 'name': 'baz'}
+            ]
+        )

=== modified file 'doc/source/addons.rst'
--- doc/source/addons.rst	2011-03-22 17:18:01 +0000
+++ doc/source/addons.rst	2011-03-23 12:39:27 +0000
@@ -97,9 +97,183 @@
     :param openerp.base.Controller view: The view to which the widget belongs
     :param Object node: the ``fields_view_get`` descriptor for the widget
 
-.. js:attribute:: openerp.base.Widget.$element
-
-    The widget's root element as jQuery object
+    .. js:attribute:: $element
+
+        The widget's root element as jQuery object
+
+.. js:class:: openerp.base.DataSet(session, model)
+
+    :param openerp.base.Session session: the RPC session object
+    :param String model: the model managed by this dataset
+
+    The DataSet is the abstraction for a sequence of records stored in
+    database.
+
+    It provides interfaces for reading records based on search
+    criteria, and for selecting and fetching records based on
+    activated ids.
+
+    .. js:function:: fetch([offset][, limit])
+
+       :param Number offset: the index from which records should start
+                             being returned (section)
+       :param Number limit: the maximum number of records to return
+       :returns: the dataset instance it was called on
+
+       Asynchronously fetches the records selected by the DataSet's
+       domain and context, in the provided sort order if any.
+
+       Only fetches the fields selected by the DataSet.
+
+       On success, triggers :js:func:`on_fetch`
+
+    .. js:function:: on_fetch(records, event)
+
+        :param Array records: an array of
+                             :js:class:`openerp.base.DataRecord`
+                             matching the DataSet's selection
+        :param event: a data holder letting the event handler fetch
+                     meta-informations about the event.
+        :type event: OnFetchEvent
+
+        Fired after :js:func:`fetch` is done fetching the records
+        selected by the DataSet.
+
+    .. js:function:: active_ids
+
+        :returns: the dataset instance it was called on
+
+        Asynchronously fetches the active records for this DataSet.
+
+        On success, triggers :js:func:`on_active_ids`
+
+    .. js:function:: on_active_ids(records)
+
+        :param Array records: an array of
+                              :js:class:`openerp.base.DataRecord`
+                              matching the currently active ids
+
+        Fired after :js:func:`active_ids` fetched the records matching
+        the DataSet's active ids.
+
+    .. js:function:: active_id
+
+        :returns: the dataset instance in was called on
+
+        Asynchronously fetches the current active record.
+
+        On success, triggers :js:func:`on_active_id`
+
+    .. js:function:: on_active_id(record)
+
+        :param Object record: the record fetched by
+                              :js:func:`active_id`, or ``null``
+        :type record: openerp.base.DataRecord
+
+        Fired after :js:func:`active_id` fetched the record matching
+        the dataset's active id
+
+    .. js:function:: set(options)
+
+        :param Object options: the options to set on the dataset
+        :type options: DataSetOptions
+        :returns: the dataset instance it was called on
+
+        Configures the data set by setting various properties on it
+
+    .. js:function:: prev
+
+        :returns: the dataset instance it was called on
+
+        Activates the id preceding the current one in the active ids
+        sequence of the dataset.
+
+        If the current active id is at the start of the sequence,
+        wraps back to the last id of the sequence.
+
+    .. js:function:: next
+
+        :returns: the dataset instance it was called on
+
+        Activates the id following the current one in the active ids
+        sequence.
+
+        If the current active id is the last of the sequence, wraps
+        back to the beginning of the active ids sequence.
+
+    .. js:function:: select(ids)
+
+        :param Array ids: the identifiers to activate on the dataset
+        :returns: the dataset instance it was called on
+
+        Activates all the ids specified in the dataset, resets the
+        current active id to be the first id of the new sequence.
+
+        The internal order will be the same as the ids list provided.
+
+    .. js:function:: get_active_ids
+
+        :returns: the list of current active ids for the dataset
+
+    .. js:function:: activate(id)
+
+        :param Number id: the id to activate
+        :returns: the dataset instance it was called on
+
+        Activates the id provided in the dataset. If no ids are
+        selected, selects the id in the dataset.
+
+        If ids are already selected and the provided id is not in that
+        selection, raises an error.
+
+    .. js:function:: get_active_id
+
+        :returns: the dataset's current active id
+
+.. js:class:: openerp.base.DataRecord(session, model, fields, values)
+
+Ad-hoc objects and structural types
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+These objects are not associated with any specific class, they're
+generally literal objects created on the spot. Names are merely
+convenient ways to refer to them and their properties.
+
+.. js:class:: OnFetchEvent
+
+    .. js:attribute:: context
+
+        The context used for the :js:func:`fetch` call (domain set on
+        the :js:class:`openerp.base.DataSet` when ``fetch`` was
+        called)
+
+    .. js:attribute:: domain
+
+        The domain used for the :js:func:`fetch` call
+
+    .. js:attribute:: limit
+
+        The limit with which the original :js:func:`fetch` call was
+        performed
+
+    .. js:attribute:: offset
+
+        The offset with which the original :js:func:`fetch` call was
+        performed
+
+    .. js:attribute:: sort
+
+       The sorting criteria active on the
+       :js:class:`openerp.base.DataSet` when :js:func:`fetch` was
+       called
+
+.. js:class:: DataSetOptions
+
+    .. js:attribute:: context
+
+    .. js:attribute:: domain
+
+    .. js:attribute:: sort
 
 * Addons lifecycle (loading, execution, events, ...)
 

=== modified file 'doc/source/conf.py'
--- doc/source/conf.py	2011-03-22 16:57:42 +0000
+++ doc/source/conf.py	2011-03-23 12:39:27 +0000
@@ -16,7 +16,8 @@
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.insert(0, os.path.abspath('.'))
+sys.path.insert(0, os.path.abspath('../../addons'))
+sys.path.insert(0, os.path.abspath('../..'))
 
 # -- General configuration -----------------------------------------------------
 

=== modified file 'doc/source/development.rst'
--- doc/source/development.rst	2011-03-21 12:23:28 +0000
+++ doc/source/development.rst	2011-03-23 12:39:27 +0000
@@ -8,6 +8,15 @@
 * Style guide and coding conventions (PEP8? More)
 * Test frameworks in JS?
 
+Internal API Doc
+----------------
+
+Python
+++++++
+
+.. autoclass:: base.controllers.main.DataSet
+    :members:
+
 Testing
 -------
 


Follow ups