← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/packageset-picker into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/packageset-picker into lp:launchpad with lp:~allenap/launchpad/javascript-utils-2 as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #814531 in Launchpad itself: "PackagesetPickerWidget does not consistently fire choicesChange events"
  https://bugs.launchpad.net/launchpad/+bug/814531

For more details, see:
https://code.launchpad.net/~allenap/launchpad/packageset-picker/+merge/70762

This branch does the following:

- Change ChoiceListWidget to support a richer model of choices, like
  SelectWidget. At the moment ChoiceListWidget uses the choice values
  as labels too, and doesn't allow associating arbitrary data with
  choices.

- Ensure that the selection is preserved, as much as possible, when
  changing "choices".

- Reimplement ChoiceListWidget.remove_choices and .add_choices using
  the attributes.

- ChoiceListWidget's "type" attribute should be changed to resemble
  SelectWidget's "multiple" attribute. The implementation should use
  checkboxes or radio buttons, but not expose that detail.

- Pay attention to events during the changes. Things like choiceChange
  and choicesChange need to be fired consistently.

My next steps - before landing this change - will be:

- Change anything that depends on or inherits from SelectWidget to use
  ChoiceListWidget.

- Remove SelectWidget.

-- 
https://code.launchpad.net/~allenap/launchpad/packageset-picker/+merge/70762
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/packageset-picker into lp:launchpad.
=== modified file 'lib/lp/app/javascript/formwidgets/formwidgets.js'
--- lib/lp/app/javascript/formwidgets/formwidgets.js	2011-08-05 08:42:36 +0000
+++ lib/lp/app/javascript/formwidgets/formwidgets.js	2011-08-08 16:18:30 +0000
@@ -14,6 +14,10 @@
 
 var namespace = Y.namespace('lp.app.formwidgets');
 
+var owns = Y.Object.owns,
+    values = Y.Object.values,
+    attrcaller = Y.lp.extras.attrcaller;
+
 
 /**
  * A form row matching that which LaunchpadForm presents, containing a
@@ -189,19 +193,19 @@
          */
         choices: {
             getter: function() {
-                return this.fieldNode.all("li > input").get("value");
+                return this.fieldNode.all("li").map(
+                    this._choiceFromNode, this);
             },
             setter: function(value, name) {
-                var choices = Y.Array.unique(value).sort();
+                var compare = Y.bind(this._compareChoices, this);
+                var choices = this._convertChoices(value).sort(compare);
+                var create = Y.bind(this._nodeFromChoice, this);
                 var list = Y.Node.create("<ul />");
-                var self = this;
-                choices.forEach(
-                    function(choice) {
-                       var item = self._createChoice(choice);
-                       list.append(item);
-                    }
-                );
+                var append = Y.bind(list.append, list);
+                choices.map(create).forEach(append);
+                var selection = this.get("choice");
                 this.fieldNode.empty().append(list);
+                this.set("choice", selection);
             }
         },
 
@@ -212,27 +216,32 @@
          */
         choice: {
             setter: function(value, name) {
-                if (!Y.Lang.isArray(value)) {
-                    value = [value];
-                }
-                this.fieldNode.all("li > input").each(
-                    function(node) {
-                        node.set(
-                            "checked",
-                            value.indexOf(node.get("value")) >= 0);
-                    }
-                );
+                if (value === null) {
+                    // De-select everything.
+                    this.fieldNode.all("li > input").set("checked", false);
+                }
+                else {
+                    var choices = this._convertChoices(
+                        Y.Lang.isArray(value) ? value : [value]);
+                    var choicemap = {};
+                    choices.forEach(
+                        function(choice) {
+                            choicemap[choice.value] = true;
+                        }
+                    );
+                    this.fieldNode.all("li > input").each(
+                        function(node) {
+                            var checked = owns(choicemap, node.get("value"));
+                            node.set("checked", checked);
+                        }
+                    );
+                }
             },
             getter: function() {
-                var choice = [];
-                this.fieldNode.all("li > input").each(
-                    function(node) {
-                        if (node.get("checked")) {
-                            choice.push(node.get("value"));
-                        }
-                    }
-                );
-                if (this.get("type") === "radio") {
+                var inputs = this.fieldNode.all("li > input:checked");
+                var nodes = inputs.map(attrcaller("ancestor"));
+                var choice = nodes.map(this._choiceFromNode, this);
+                if (!this.get("multiple")) {
                     if (choice.length === 0) {
                         choice = null;
                     }
@@ -248,14 +257,17 @@
         },
 
         /**
-         * The input type to display. Choose from "checkbox" or "radio".
+         * Whether multiple choices can be made.
          *
-         * @property type
+         * @property multiple
          */
-        type: {
-            value: "checkbox",
+        multiple: {
+            value: true,
             setter: function(value, name) {
-                this.fieldNode.all("li > input").set("type", value);
+                value = value ? true : false;
+                var field_type = value ? "checkbox" : "radio";
+                this.fieldNode.all("li > input").set("type", field_type);
+                return value;
             }
         }
 
@@ -266,92 +278,129 @@
 Y.extend(ChoiceListWidget, FormRowWidget, {
 
     /**
-     * Helper method to create an entry for the select widget.
-     *
-     * @method _createChoice
-     */
-    _createChoice: function(choice) {
-         var field_name = this.get("name");
-         var field_type = this.get("type");
-         var item = Y.Node.create(
+     * Convert a "bare" choice into a object choice with text and
+     * value keys.
+     *
+     * @param {String|Object} choice
+     */
+    _convertChoice: function(choice) {
+        return Y.Lang.isString(choice) ?
+            {value: choice, text: choice} : choice;
+    },
+
+    /**
+     * Convert an array of choices using _convertChoice.
+     *
+     * @param {Array|Y.Array} choices
+     */
+    _convertChoices: function(choices) {
+        return Y.Array.map(choices, this._convertChoice, this);
+    },
+
+    /**
+     * Default method to sort choices.
+     *
+     * @param {Object|String} ca A choice. If it is an object it must
+     *     contain a <code>text</code> attribute.
+     * @param {Object|String} cb A choice. If it is an object it must
+     *     contain a <code>text</code> attribute.
+     */
+    _compareChoices: function(ca, cb) {
+        var keya = Y.Lang.isObject(ca) ? ca.text : ca,
+            keyb = Y.Lang.isObject(cb) ? cb.text : cb;
+        if (keya == keyb) {
+            return 0;
+        }
+        else {
+            return (keya > keyb) ? 1 : -1;
+        }
+    },
+
+    /**
+     * Return a node that represents the given choice object.
+     *
+     * @method _nodeFromChoice
+     */
+    _nodeFromChoice: function(choice) {
+        var field_name = this.get("name");
+        var field_type = this.get("multiple") ? "checkbox" : "radio";
+        var item = Y.Node.create(
             "<li><input /> <label /></li>");
         item.one("input")
             .set("type", field_type)
             .set("name", field_name)
-            .set("value", choice);
+            .set("value", choice.value);
         item.one("label")
             .setAttribute(
                 "for", item.one("input").generateID())
             .setStyle("font-weight", "normal")
-            .set("text", choice);
+            .set("text", choice.text);
+        item.setData(choice.data);
         return item;
     },
 
-   /**
+    /**
+     * Return a choice object represented by the given node.
+     *
+     * @method _choiceFromNode
+     */
+    _choiceFromNode: function(node) {
+        return {
+            data: node.getData(),
+            text: node.one("label").get("text"),
+            value: node.one("input").get("value")
+        };
+    },
+
+    /**
      * Remove a list of choices from the possible widget's choices.
      *
      * @method remove_choices
      */
     remove_choices: function(choices) {
-        choices.forEach(
+        var choicemap = {};
+        // Create a mapping of value -> choice for the given choices.
+        this._convertChoices(choices).forEach(
             function(choice) {
-                this.fieldNode.all("select > option").each(
-                    function(option) { options.push(option); });
-                this.fieldNode.all(
-                    "li input[value=" + choice + "]").each(
-                        function(li_input) {
-                            li_input.get('parentNode').remove();
-                        }
-                );
-            },
-            this
+                choicemap[choice.value] = true;
+            }
         );
+        // Filter out the choices mentioned.
+        choices = this.get("choices").filter(function(choice) {
+            return !owns(choicemap, choice.value);
+        });
+        // Set back again.
+        this.set("choices", choices);
+        // Tell everyone!
         Y.lazr.anim.green_flash({node: this.fieldNode}).run();
     },
 
-    _sorted_position: function(choice) {
-        var options = [];
-        this.fieldNode.all("input").each(
-            function(node) {
-                options.push(node.get('value'));
-            }
-        );
-        options.push(choice);
-        return options.sort().indexOf(choice);
-    },
-
-   /**
+    /**
      * Add new choices (if they are not already present).
      *
      * @method add_choices
      */
-    add_choices: function(new_choices) {
-        new_choices.forEach(
+    add_choices: function(choices) {
+        var choicemap = {};
+        // Create a mapping of value -> choice for the given choices.
+        this._convertChoices(choices).forEach(
             function(choice) {
-                if (this.fieldNode.all(
-                    "li > input[value=" + choice + "]").isEmpty()) {
-                    var list = this.fieldNode.one('ul');
-                    if (list === null) {
-                        list = Y.Node.create("<ul />");
-                        this.fieldNode.empty().append(list);
-                    }
-                    var option = this._createChoice(choice);
-                    var options = list.all('input');
-                    if (options.isEmpty()) {
-                        list.append(option);
-                    }
-                    else {
-                        var pos = this._sorted_position(choice);
-                        if (pos === 0) {
-                            list.prepend(option);
-                        }
-                        else {
-                            list.insertBefore(option, options.item(pos));
-                        }
-                    }
-                }
-            }, this
+                choicemap[choice.value] = choice;
+            }
         );
+        // Allow existing choices to be redefined.
+        choices = this.get("choices").map(function(choice) {
+            if (owns(choicemap, choice.value)) {
+                choice = choicemap[choice.value];
+                delete choicemap[choice.value];
+            }
+            return choice;
+        });
+        // Add the new choices (i.e. what remains in choicemap).
+        choices = choices.concat(values(choicemap));
+        // Set back again.
+        this.set("choices", choices);
+        // Tell everyone!
         Y.lazr.anim.green_flash({node: this.fieldNode}).run();
     }
 

=== modified file 'lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js'
--- lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js	2011-08-05 08:54:55 +0000
+++ lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js	2011-08-08 16:18:30 +0000
@@ -44,7 +44,7 @@
                 Y.Node.create("<input /><input />"));
             this.widget.set("name", "field");
             this.widget.render(this.container);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["field", "field"],
                 this.container.all("input").get("name"));
         },
@@ -55,7 +55,7 @@
             this.widget.set("name", "field");
             this.widget.render(this.container);
             this.widget.set("name", "plain");
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["plain", "plain"],
                 this.container.all("input").get("name"));
         },
@@ -63,7 +63,7 @@
         testRenderLabel: function() {
             this.widget.set("label", "Test label");
             this.widget.render(this.container);
-            Assert.areEqual(
+            Assert.areSame(
                 "Test label",
                 this.container.one("label").get("text"));
         },
@@ -72,7 +72,7 @@
             this.widget.set("label", "Test label");
             this.widget.render(this.container);
             this.widget.set("label", "Another label");
-            Assert.areEqual(
+            Assert.areSame(
                 "Another label",
                 this.container.one("label").get("text"));
         },
@@ -80,7 +80,7 @@
         testRenderDescription: function() {
             this.widget.set("description", "Test description.");
             this.widget.render(this.container);
-            Assert.areEqual(
+            Assert.areSame(
                 "Test description.",
                 this.container.one("p.formHelp").get("text"));
         },
@@ -89,7 +89,7 @@
             this.widget.set("description", "Test description.");
             this.widget.render(this.container);
             this.widget.set("description", "Another description.");
-            Assert.areEqual(
+            Assert.areSame(
                 "Another description.",
                 this.container.one("p.formHelp").get("text"));
         },
@@ -100,11 +100,11 @@
             this.widget.render(this.container);
             Assert.isFalse(this.container
                 .one('span.helper').hasClass("unseen"));
-            Assert.areEqual(
+            Assert.areSame(
                 "Help text",
                 this.container.one("a")
                    .one('span.invisible-link').get("text"));
-            Assert.areEqual(
+            Assert.areSame(
                 "http://test.com/test.html";,
                 this.container.one("a").get('href'));
         },
@@ -113,10 +113,10 @@
             this.widget.set("help",
                 {link: "http://test.com/test.html";, text: "Help text"});
             this.widget.render(this.container);
-            Assert.areEqual(
+            Assert.areSame(
                 "http://test.com/test.html";,
                 this.widget.get("help").link);
-            Assert.areEqual(
+            Assert.areSame(
                 "Help text",
                 this.widget.get("help").text);
          },
@@ -128,11 +128,11 @@
             this.widget.set(
                 "help",
                 {link: "http://test.com/test2.html";, text: "Help text2"});
-            Assert.areEqual(
+            Assert.areSame(
                 "Help text2",
                 this.container.one("a")
                     .one('span.invisible-link').get("text"));
-            Assert.areEqual(
+            Assert.areSame(
                 "http://test.com/test2.html";,
                 this.container.one("a").get('href'));
         },
@@ -159,7 +159,7 @@
 
         testShowError: function() {
             this.widget.showError("Unrealistic expectations.");
-            Assert.areEqual(
+            Assert.areSame(
                 "Unrealistic expectations.",
                 this.widget.fieldNode.one("p").get("text"));
         }
@@ -180,90 +180,204 @@
             this.container.remove(true);
         },
 
+        testCompareChoices: function() {
+            // _compareChoices can compare objects or strings.
+            var widget = this.widget;
+            var comparisons = [
+                // String comparisons.
+                {ca: "foo", cb: "foo", expected: +0},
+                {ca: "foo", cb: "bar", expected: +1},
+                {ca: "bar", cb: "foo", expected: -1},
+                // Object comparisons.
+                {ca: {text: "foo"}, cb: {text: "foo"}, expected: +0},
+                {ca: {text: "foo"}, cb: {text: "bar"}, expected: +1},
+                {ca: {text: "bar"}, cb: {text: "foo"}, expected: -1},
+                // Mixed comparisons.
+                {ca: {text: "foo"}, cb: "foo", expected: +0},
+                {ca: "foo", cb: {text: "foo"}, expected: +0},
+                {ca: {text: "foo"}, cb: "bar", expected: +1},
+                {ca: "foo", cb: {text: "bar"}, expected: +1},
+                {ca: {text: "bar"}, cb: "foo", expected: -1},
+                {ca: "bar", cb: {text: "foo"}, expected: -1}
+            ];
+            Y.each(comparisons, function(comparison) {
+                Assert.areSame(
+                    comparison.expected,
+                    widget._compareChoices(
+                        comparison.ca, comparison.cb));
+            });
+        },
+
         testRenderChoices: function() {
             this.widget.set("choices", ["a", "b"]);
             this.widget.render(this.container);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "b"],
                 this.container.all("li > input").get("value"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "b"],
                 this.container.all("li > label").get("text"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["checkbox", "checkbox"],
                 this.container.all("li > input").getAttribute("type"));
         },
 
+        testRenderObjectChoices: function() {
+            var choices = [
+                {value: "a", text: "A", data: "aaa"},
+                {value: "b", text: "B", data: "bbb"}
+            ];
+            this.widget.set("choices", choices);
+            this.widget.render(this.container);
+            ArrayAssert.itemsAreSame(
+                ["a", "b"],
+                this.container.all("li > input").get("value"));
+            ArrayAssert.itemsAreSame(
+                ["A", "B"],
+                this.container.all("li > label").get("text"));
+            var items = [];
+            this.container.all("li").each(
+                function(node) { items.push(node); });
+            ArrayAssert.itemsAreSame(
+                ["aaa", "bbb"],
+                this.container.all("li").map(
+                    function(node) { return node.getData(); }));
+        },
+
         testRenderChoicesChange: function() {
             this.widget.set("choices", ["a", "b"]);
             this.widget.render(this.container);
             this.widget.set("choices", ["c", "d", "e"]);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["c", "d", "e"],
                 this.container.all("li > input").get("value"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["c", "d", "e"],
                 this.container.all("li > label").get("text"));
         },
 
-       testRenderAddChoices: function() {
+        testRenderAddChoices: function() {
             this.widget.add_choices(["a", "b"]);
             this.widget.render(this.container);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "b"],
                 this.container.all("li > input").get("value"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "b"],
                 this.container.all("li > label").get("text"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["checkbox", "checkbox"],
                 this.container.all("li > input").getAttribute("type"));
         },
 
         testRenderRemoveChoices: function() {
-            this.widget.add_choices(["a", "b", "c", "d"]);
+            this.widget.set("choices", ["a", "b", "c", "d"]);
             this.widget.render(this.container);
             this.widget.remove_choices(["b", "d"]);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "c"],
                 this.container.all("li > input").get("value"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "c"],
                 this.container.all("li > label").get("text"));
         },
 
-        testRenderChoicesChangeType: function() {
+        testRenderChoicesChangeMultiple: function() {
             this.widget.set("choices", ["a", "b"]);
             this.widget.render(this.container);
-            this.widget.set("type", "radio");
-            ArrayAssert.itemsAreEqual(
+            this.widget.set("multiple", false);
+            ArrayAssert.itemsAreSame(
                 ["radio", "radio"],
                 this.container.all("li > input").getAttribute("type"));
-
+        },
+
+        testChoicesWithStrings: function() {
+            /* The choices attribute should be an array, and it may
+               contain unadorned strings. However, it always returns
+               object choices in sorted order. */
+            ArrayAssert.itemsAreSame(
+                [], this.widget.get("choices"));
+            this.widget.set("choices", ["foo", "bar"]);
+            var choices = this.widget.get("choices");
+            choices.forEach(function(choice) {
+                Assert.isObject(choice);
+                Assert.areSame(choice.text, choice.value);
+            });
+            ArrayAssert.itemsAreSame(
+                ["bar", "foo"], attrselect("text")(choices));
+            ArrayAssert.itemsAreSame(
+                ["bar", "foo"], attrselect("value")(choices));
+        },
+
+        testChoicesWithObjects: function() {
+            /* The choices attribute should be an array, and it may
+               contain objects with text and value keys. It always
+               returns object choices in order, sorted by text. */
+            ArrayAssert.itemsAreSame(
+                [], this.widget.get("choices"));
+            this.widget.set("choices", [
+                {text: "Bbb", value: "xxx"},
+                {text: "Aaa", value: "zzz"}
+                ]);
+            var choices = this.widget.get("choices");
+            choices.forEach(function(choice) {
+                Assert.isObject(choice);
+            });
+            ArrayAssert.itemsAreSame(
+                ["Aaa", "Bbb"], attrselect("text")(choices));
+            ArrayAssert.itemsAreSame(
+                ["zzz", "xxx"], attrselect("value")(choices));
+        },
+
+        testChoicesWithObjectsAndData: function() {
+            /* The choices attribute should be an array. The objects
+               it contains can optionally have a data key which is
+               stored and returned when referencing the choices
+               attribute. */
+            ArrayAssert.itemsAreSame(
+                [], this.widget.get("choices"));
+            this.widget.set("choices", [
+                {text: "Bbb", value: "xxx", data: 123},
+                {text: "Aaa", value: "zzz", data: 456}
+                ]);
+            var choices = this.widget.get("choices");
+            ArrayAssert.itemsAreSame(
+                [456, 123], attrselect("data")(choices));
+        },
+
+        testChoicesPreservesSelection: function() {
+            /* Setting new choices preserves, as much as possible, the
+               existing selection. */
+            this.widget.set("choices", ["a", "b", "c"]);
+            this.widget.set("choice", ["a", "c"]);
+            this.widget.set("choices", ["a", "b", "d"]);
+            var choice = this.widget.get("choice");
+            ArrayAssert.itemsAreSame(
+                ["a"], attrselect("value")(choice));
         },
 
         testChoiceWithCheckBox: function() {
             this.widget
-                .set("type", "checkbox")
+                .set("multiple", true)
                 .set("choices", ["a", "b"]);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 [], this.widget.get("choice"));
             this.widget.fieldNode.one("input[value=a]")
                 .set("checked", "checked");
-            ArrayAssert.itemsAreEqual(
-                ["a"], this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                ["a"], attrselect("value")(this.widget.get("choice")));
         },
 
         testChoiceWithRadio: function() {
             // When both radio buttons are checked (this is possible in some
             // broken DOMs/JS engined), choice is undefined.
             this.widget
-                .set("type", "radio")
+                .set("multiple", false)
                 .set("choices", ["a", "b"]);
             Assert.isNull(this.widget.get("choice"));
             this.widget.fieldNode.one("input[value=a]")
                 .set("checked", "checked");
-            Assert.areEqual("a", this.widget.get("choice"));
+            Assert.areSame("a", this.widget.get("choice").value);
             this.widget.fieldNode.one("input[value=b]")
                 .set("checked", "checked");
             if (this.widget.fieldNode.one("input[value=a]").get("checked")) {
@@ -273,42 +387,84 @@
             }
             else {
                 // The host browser's DOM/JS is sane.
-                ArrayAssert.itemsAreEqual(
-                    ["b"], this.widget.get("choice"));
+                ArrayAssert.itemsAreSame(
+                    ["b"], this.widget.get("choice").value);
             }
         },
 
         testSetChoiceWithCheckBox: function() {
             this.widget
-                .set("type", "checkbox")
+                .set("multiple", true)
                 .set("choices", ["a", "b"])
                 .set("choice", "a");
-            ArrayAssert.itemsAreEqual(
-                ["a"], this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                ["a"], attrselect("value")(this.widget.get("choice")));
             this.widget.set("choice", ["a"]);
-            ArrayAssert.itemsAreEqual(
-                ["a"], this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                ["a"], attrselect("value")(this.widget.get("choice")));
             this.widget.set("choice", ["a", "b"]);
-            ArrayAssert.itemsAreEqual(
-                ["a", "b"], this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                ["a", "b"], attrselect("value")(this.widget.get("choice")));
             this.widget.set("choice", ["b", "c"]);
-            ArrayAssert.itemsAreEqual(
-                ["b"], this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                ["b"], attrselect("value")(this.widget.get("choice")));
         },
 
         testSetChoiceWithRadio: function() {
             this.widget
-                .set("type", "radio")
+                .set("multiple", false)
                 .set("choices", ["a", "b"])
                 .set("choice", "a");
-            ArrayAssert.itemsAreEqual(
-                "a", this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                "a", this.widget.get("choice").value);
             this.widget.set("choice", ["a"]);
-            ArrayAssert.itemsAreEqual(
-                "a", this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                "a", this.widget.get("choice").value);
             this.widget.set("choice", "b");
-            ArrayAssert.itemsAreEqual(
-                "b", this.widget.get("choice"));
+            ArrayAssert.itemsAreSame(
+                "b", this.widget.get("choice").value);
+        },
+
+        testSetChoiceWithObjectChoice: function() {
+            /* An object choice (i.e. an object with text, value and
+               optional data members) can be passed into when setting
+               the choice attribute. Only the value is considered. */
+            this.widget
+                .set("multiple", false)
+                .set("choices", ["a", "b"])
+                .set("choice", {text: "A", value: "a"});
+            ArrayAssert.itemsAreSame(
+                "a", this.widget.get("choice").value);
+        },
+
+        testAddChoicesEvents: function() {
+            /* Calling add_choices() causes choicesChange and
+               choiceChange events to fire. */
+            var events = [], push = Y.bind(events.push, events);
+            this.widget.after("choicesChange", push);
+            this.widget.after("choiceChange", push);
+            this.widget.add_choices(["a", "b"]);
+            ArrayAssert.containsMatch(
+                function(event) { return event.attrName == "choice"; },
+                events);
+            ArrayAssert.containsMatch(
+                function(event) { return event.attrName == "choices"; },
+                events);
+        },
+
+        testRemoveChoicesEvents: function() {
+            /* Calling remove_choices() causes choicesChange and
+               choiceChange events to fire. */
+            var events = [], push = Y.bind(events.push, events);
+            this.widget.after("choicesChange", push);
+            this.widget.after("choiceChange", push);
+            this.widget.remove_choices(["a", "b"]);
+            ArrayAssert.containsMatch(
+                function(event) { return event.attrName == "choice"; },
+                events);
+            ArrayAssert.containsMatch(
+                function(event) { return event.attrName == "choices"; },
+                events);
         }
 
     };
@@ -340,10 +496,10 @@
                 .set("name", "foo")
                 .set("choices", this.choices);
             var select = this.widget.fieldNode.one("select");
-            Assert.areEqual("foo", select.get("name"));
+            Assert.areSame("foo", select.get("name"));
             this.widget
                 .set("name", "bar");
-            Assert.areEqual("bar", select.get("name"));
+            Assert.areSame("bar", select.get("name"));
         },
 
         testChoices: function() {
@@ -351,13 +507,13 @@
             var choices_observed = this.widget.get("choices");
             /* We have to compare bit by bit ourselves because
                Javascript is a language born in hell. */
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 attrselect("value")(this.choices),
                 attrselect("value")(choices_observed));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 attrselect("text")(this.choices),
                 attrselect("text")(choices_observed));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 attrselect("data")(this.choices),
                 attrselect("data")(choices_observed));
         },
@@ -365,10 +521,10 @@
         testRenderChoices: function() {
             this.widget.set("choices", this.choices);
             this.widget.render(this.container);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "b", "c"],
                 this.container.all("select > option").get("value"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["A", "B", "C"],
                 this.container.all("select > option").get("text"));
         },
@@ -392,10 +548,10 @@
                 {value: "c", text: "C", data: 789}
             ];
             this.widget.set("choices", choices2);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["b", "c"],
                 this.container.all("select > option").get("value"));
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["B", "C"],
                 this.container.all("select > option").get("text"));
         },
@@ -408,15 +564,15 @@
                but this appears impossible; the browser seems to
                select the first option when rendering. */
             this.widget.fieldNode.all("option").set("selected", false);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 [], this.widget.get("choice"));
             this.widget.fieldNode.one("option[value=a]")
                 .set("selected", true);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a"], this.widget.get("choice"));
             this.widget.fieldNode.one("option[value=c]")
                 .set("selected", true);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "c"], this.widget.get("choice"));
         },
 
@@ -425,21 +581,21 @@
                 .set("multiple", true)
                 .set("choices", this.choices)
                 .set("choice", "a");
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a"], this.widget.get("choice"));
             this.widget.set("choice", ["a"]);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a"], this.widget.get("choice"));
             this.widget.set("choice", ["a", "b"]);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["a", "b"], this.widget.get("choice"));
             this.widget.set("choice", ["b", "z"]);
-            ArrayAssert.itemsAreEqual(
+            ArrayAssert.itemsAreSame(
                 ["b"], this.widget.get("choice"));
         },
 
         testSize: function() {
-            Assert.areEqual(1, this.widget.get("size"));
+            Assert.areSame(1, this.widget.get("size"));
         },
 
         testRenderSize: function() {
@@ -447,7 +603,7 @@
                 .set("choices", this.choices)
                 .set("size", 7)
                 .render(this.container);
-            Assert.areEqual(
+            Assert.areSame(
                 7, this.widget.fieldNode.one("select").get("size"));
         },
 
@@ -457,7 +613,7 @@
                 .set("size", 3)
                 .render(this.container)
                 .set("size", 5);
-            Assert.areEqual(
+            Assert.areSame(
                 5, this.widget.fieldNode.one("select").get("size"));
         },
 
@@ -466,7 +622,7 @@
             /* Without argument, autoSize() sets the size to the same
                as the number of choices. */
             this.widget.autoSize();
-            Assert.areEqual(3, this.widget.get("size"));
+            Assert.areSame(3, this.widget.get("size"));
         },
 
         testAutoSizeMoreChoicesThanMaxiumum: function() {
@@ -475,7 +631,7 @@
                choices unless there are more than the specified
                maximum. */
             this.widget.autoSize(2);
-            Assert.areEqual(2, this.widget.get("size"));
+            Assert.areSame(2, this.widget.get("size"));
         },
 
         testAutoSizeFewerChoicesThanMaxiumum: function() {
@@ -483,11 +639,11 @@
             /* autoSize() sets the size to the same as the number of
                choices. */
             this.widget.autoSize(5);
-            Assert.areEqual(3, this.widget.get("size"));
+            Assert.areSame(3, this.widget.get("size"));
         },
 
         testMultiple: function() {
-            Assert.areEqual(false, this.widget.get("multiple"));
+            Assert.areSame(false, this.widget.get("multiple"));
         },
 
         testRenderMultiple: function() {
@@ -567,7 +723,7 @@
 
         testShowError: function() {
             this.widget.showError("The Man From U.N.C.L.E.");
-            Assert.areEqual(
+            Assert.areSame(
                 "The Man From U.N.C.L.E.",
                 this.actions.one("p.error.message").get("text"));
         },


Follow ups