diff options
| author | Nick Pope <nick@nickpope.me.uk> | 2023-08-31 02:57:40 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-08-30 22:57:40 -0300 |
| commit | 500e01073adda32d5149624ee9a5cb7aa3d3583f (patch) | |
| tree | f9416872a811aa39646deaf002414e0a7841b6d1 /tests | |
| parent | 68a8996bdfce2d191decd7b1c1a2b9fdea8e4b2f (diff) | |
Fixed #31262 -- Added support for mappings on model fields and ChoiceField's choices.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/field_deconstruction/tests.py | 15 | ||||
| -rw-r--r-- | tests/forms_tests/field_tests/test_choicefield.py | 51 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_choicewidget.py | 19 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_radioselect.py | 18 | ||||
| -rw-r--r-- | tests/forms_tests/widget_tests/test_select.py | 142 | ||||
| -rw-r--r-- | tests/invalid_models_tests/test_ordinary_fields.py | 51 | ||||
| -rw-r--r-- | tests/migrations/test_writer.py | 10 | ||||
| -rw-r--r-- | tests/model_fields/models.py | 34 | ||||
| -rw-r--r-- | tests/model_fields/test_integerfield.py | 8 | ||||
| -rw-r--r-- | tests/model_fields/tests.py | 20 | ||||
| -rw-r--r-- | tests/utils_tests/test_choices.py | 305 |
11 files changed, 552 insertions, 121 deletions
diff --git a/tests/field_deconstruction/tests.py b/tests/field_deconstruction/tests.py index 3663886708..3b10ee0091 100644 --- a/tests/field_deconstruction/tests.py +++ b/tests/field_deconstruction/tests.py @@ -2,6 +2,7 @@ from django.apps import apps from django.db import models from django.test import SimpleTestCase, override_settings from django.test.utils import isolate_lru_cache +from django.utils.choices import normalize_choices class FieldDeconstructionTests(SimpleTestCase): @@ -105,12 +106,22 @@ class FieldDeconstructionTests(SimpleTestCase): self.assertEqual(kwargs, {"choices": [(0, "0"), (1, "1"), (2, "2")]}) def test_choices_iterable(self): - # Pass an iterator (but not an iterable) to choices. + # Pass an iterable (but not an iterator) to choices. field = models.IntegerField(choices="012345") name, path, args, kwargs = field.deconstruct() self.assertEqual(path, "django.db.models.IntegerField") self.assertEqual(args, []) - self.assertEqual(kwargs, {"choices": ["0", "1", "2", "3", "4", "5"]}) + self.assertEqual(kwargs, {"choices": normalize_choices("012345")}) + + def test_choices_callable(self): + def get_choices(): + return [(i, str(i)) for i in range(3)] + + field = models.IntegerField(choices=get_choices) + name, path, args, kwargs = field.deconstruct() + self.assertEqual(path, "django.db.models.IntegerField") + self.assertEqual(args, []) + self.assertEqual(kwargs, {"choices": get_choices}) def test_csi_field(self): field = models.CommaSeparatedIntegerField(max_length=100) diff --git a/tests/forms_tests/field_tests/test_choicefield.py b/tests/forms_tests/field_tests/test_choicefield.py index e7893abe57..ebc6c14bea 100644 --- a/tests/forms_tests/field_tests/test_choicefield.py +++ b/tests/forms_tests/field_tests/test_choicefield.py @@ -65,6 +65,51 @@ class ChoiceFieldTest(FormFieldAssertionsMixin, SimpleTestCase): f = ChoiceField(choices=choices) self.assertEqual("J", f.clean("J")) + def test_choicefield_callable_mapping(self): + def choices(): + return {"J": "John", "P": "Paul"} + + f = ChoiceField(choices=choices) + self.assertEqual("J", f.clean("J")) + + def test_choicefield_callable_grouped_mapping(self): + def choices(): + return { + "Numbers": {"1": "One", "2": "Two"}, + "Letters": {"3": "A", "4": "B"}, + } + + f = ChoiceField(choices=choices) + for i in ("1", "2", "3", "4"): + with self.subTest(i): + self.assertEqual(i, f.clean(i)) + + def test_choicefield_mapping(self): + f = ChoiceField(choices={"J": "John", "P": "Paul"}) + self.assertEqual("J", f.clean("J")) + + def test_choicefield_grouped_mapping(self): + f = ChoiceField( + choices={ + "Numbers": (("1", "One"), ("2", "Two")), + "Letters": (("3", "A"), ("4", "B")), + } + ) + for i in ("1", "2", "3", "4"): + with self.subTest(i): + self.assertEqual(i, f.clean(i)) + + def test_choicefield_grouped_mapping_inner_dict(self): + f = ChoiceField( + choices={ + "Numbers": {"1": "One", "2": "Two"}, + "Letters": {"3": "A", "4": "B"}, + } + ) + for i in ("1", "2", "3", "4"): + with self.subTest(i): + self.assertEqual(i, f.clean(i)) + def test_choicefield_callable_may_evaluate_to_different_values(self): choices = [] @@ -76,11 +121,13 @@ class ChoiceFieldTest(FormFieldAssertionsMixin, SimpleTestCase): choices = [("J", "John")] form = ChoiceFieldForm() - self.assertEqual([("J", "John")], list(form.fields["choicefield"].choices)) + self.assertEqual(choices, list(form.fields["choicefield"].choices)) + self.assertEqual(choices, list(form.fields["choicefield"].widget.choices)) choices = [("P", "Paul")] form = ChoiceFieldForm() - self.assertEqual([("P", "Paul")], list(form.fields["choicefield"].choices)) + self.assertEqual(choices, list(form.fields["choicefield"].choices)) + self.assertEqual(choices, list(form.fields["choicefield"].widget.choices)) def test_choicefield_disabled(self): f = ChoiceField(choices=[("J", "John"), ("P", "Paul")], disabled=True) diff --git a/tests/forms_tests/widget_tests/test_choicewidget.py b/tests/forms_tests/widget_tests/test_choicewidget.py index 129178f207..abd1961b32 100644 --- a/tests/forms_tests/widget_tests/test_choicewidget.py +++ b/tests/forms_tests/widget_tests/test_choicewidget.py @@ -9,13 +9,26 @@ class ChoiceWidgetTest(WidgetTest): widget = ChoiceWidget @property - def nested_widget(self): - return self.widget( + def nested_widgets(self): + nested_widget = self.widget( choices=( ("outer1", "Outer 1"), ('Group "1"', (("inner1", "Inner 1"), ("inner2", "Inner 2"))), - ) + ), + ) + nested_widget_dict = self.widget( + choices={ + "outer1": "Outer 1", + 'Group "1"': {"inner1": "Inner 1", "inner2": "Inner 2"}, + }, + ) + nested_widget_dict_tuple = self.widget( + choices={ + "outer1": "Outer 1", + 'Group "1"': (("inner1", "Inner 1"), ("inner2", "Inner 2")), + }, ) + return (nested_widget, nested_widget_dict, nested_widget_dict_tuple) def test_deepcopy(self): """ diff --git a/tests/forms_tests/widget_tests/test_radioselect.py b/tests/forms_tests/widget_tests/test_radioselect.py index 5e5cea9d35..be336151ef 100644 --- a/tests/forms_tests/widget_tests/test_radioselect.py +++ b/tests/forms_tests/widget_tests/test_radioselect.py @@ -13,7 +13,6 @@ class RadioSelectTest(ChoiceWidgetTest): widget = RadioSelect def test_render(self): - choices = BLANK_CHOICE_DASH + self.beatles html = """ <div> <div> @@ -33,7 +32,10 @@ class RadioSelectTest(ChoiceWidgetTest): </div> </div> """ - self.check_html(self.widget(choices=choices), "beatle", "J", html=html) + beatles_with_blank = BLANK_CHOICE_DASH + self.beatles + for choices in (beatles_with_blank, dict(beatles_with_blank)): + with self.subTest(choices): + self.check_html(self.widget(choices=choices), "beatle", "J", html=html) def test_nested_choices(self): nested_choices = ( @@ -312,7 +314,9 @@ class RadioSelectTest(ChoiceWidgetTest): </div> </div> """ - self.check_html(self.nested_widget, "nestchoice", None, html=html) + for widget in self.nested_widgets: + with self.subTest(widget): + self.check_html(widget, "nestchoice", None, html=html) def test_choices_select_outer(self): html = """ @@ -334,7 +338,9 @@ class RadioSelectTest(ChoiceWidgetTest): </div> </div> """ - self.check_html(self.nested_widget, "nestchoice", "outer1", html=html) + for widget in self.nested_widgets: + with self.subTest(widget): + self.check_html(widget, "nestchoice", "outer1", html=html) def test_choices_select_inner(self): html = """ @@ -356,7 +362,9 @@ class RadioSelectTest(ChoiceWidgetTest): </div> </div> """ - self.check_html(self.nested_widget, "nestchoice", "inner2", html=html) + for widget in self.nested_widgets: + with self.subTest(widget): + self.check_html(widget, "nestchoice", "inner2", html=html) def test_render_attrs(self): """ diff --git a/tests/forms_tests/widget_tests/test_select.py b/tests/forms_tests/widget_tests/test_select.py index 60a0b72880..6164d0b6b3 100644 --- a/tests/forms_tests/widget_tests/test_select.py +++ b/tests/forms_tests/widget_tests/test_select.py @@ -11,19 +11,17 @@ class SelectTest(ChoiceWidgetTest): widget = Select def test_render(self): - self.check_html( - self.widget(choices=self.beatles), - "beatle", - "J", - html=( - """<select name="beatle"> - <option value="J" selected>John</option> - <option value="P">Paul</option> - <option value="G">George</option> - <option value="R">Ringo</option> - </select>""" - ), - ) + html = """ + <select name="beatle"> + <option value="J" selected>John</option> + <option value="P">Paul</option> + <option value="G">George</option> + <option value="R">Ringo</option> + </select> + """ + for choices in (self.beatles, dict(self.beatles)): + with self.subTest(choices): + self.check_html(self.widget(choices=choices), "beatle", "J", html=html) def test_render_none(self): """ @@ -237,52 +235,46 @@ class SelectTest(ChoiceWidgetTest): """ Choices can be nested one level in order to create HTML optgroups. """ - self.check_html( - self.nested_widget, - "nestchoice", - None, - html=( - """<select name="nestchoice"> - <option value="outer1">Outer 1</option> - <optgroup label="Group "1""> - <option value="inner1">Inner 1</option> - <option value="inner2">Inner 2</option> - </optgroup> - </select>""" - ), - ) + html = """ + <select name="nestchoice"> + <option value="outer1">Outer 1</option> + <optgroup label="Group "1""> + <option value="inner1">Inner 1</option> + <option value="inner2">Inner 2</option> + </optgroup> + </select> + """ + for widget in self.nested_widgets: + with self.subTest(widget): + self.check_html(widget, "nestchoice", None, html=html) def test_choices_select_outer(self): - self.check_html( - self.nested_widget, - "nestchoice", - "outer1", - html=( - """<select name="nestchoice"> - <option value="outer1" selected>Outer 1</option> - <optgroup label="Group "1""> - <option value="inner1">Inner 1</option> - <option value="inner2">Inner 2</option> - </optgroup> - </select>""" - ), - ) + html = """ + <select name="nestchoice"> + <option value="outer1" selected>Outer 1</option> + <optgroup label="Group "1""> + <option value="inner1">Inner 1</option> + <option value="inner2">Inner 2</option> + </optgroup> + </select> + """ + for widget in self.nested_widgets: + with self.subTest(widget): + self.check_html(widget, "nestchoice", "outer1", html=html) def test_choices_select_inner(self): - self.check_html( - self.nested_widget, - "nestchoice", - "inner1", - html=( - """<select name="nestchoice"> - <option value="outer1">Outer 1</option> - <optgroup label="Group "1""> - <option value="inner1" selected>Inner 1</option> - <option value="inner2">Inner 2</option> - </optgroup> - </select>""" - ), - ) + html = """ + <select name="nestchoice"> + <option value="outer1">Outer 1</option> + <optgroup label="Group "1""> + <option value="inner1" selected>Inner 1</option> + <option value="inner2">Inner 2</option> + </optgroup> + </select> + """ + for widget in self.nested_widgets: + with self.subTest(widget): + self.check_html(widget, "nestchoice", "inner1", html=html) @override_settings(USE_THOUSAND_SEPARATOR=True) def test_doesnt_localize_option_value(self): @@ -312,24 +304,7 @@ class SelectTest(ChoiceWidgetTest): """ self.check_html(self.widget(choices=choices), "time", None, html=html) - def test_optgroups(self): - choices = [ - ( - "Audio", - [ - ("vinyl", "Vinyl"), - ("cd", "CD"), - ], - ), - ( - "Video", - [ - ("vhs", "VHS Tape"), - ("dvd", "DVD"), - ], - ), - ("unknown", "Unknown"), - ] + def _test_optgroups(self, choices): groups = list( self.widget(choices=choices).optgroups( "name", @@ -418,6 +393,27 @@ class SelectTest(ChoiceWidgetTest): ) self.assertEqual(index, 2) + def test_optgroups(self): + choices_dict = { + "Audio": [ + ("vinyl", "Vinyl"), + ("cd", "CD"), + ], + "Video": [ + ("vhs", "VHS Tape"), + ("dvd", "DVD"), + ], + "unknown": "Unknown", + } + choices_list = list(choices_dict.items()) + choices_nested_dict = { + k: dict(v) if isinstance(v, list) else v for k, v in choices_dict.items() + } + + for choices in (choices_dict, choices_list, choices_nested_dict): + with self.subTest(choices): + self._test_optgroups(choices) + def test_doesnt_render_required_when_impossible_to_select_empty_field(self): widget = self.widget(choices=[("J", "John"), ("P", "Paul")]) self.assertIs(widget.use_required_attribute(initial=None), False) diff --git a/tests/invalid_models_tests/test_ordinary_fields.py b/tests/invalid_models_tests/test_ordinary_fields.py index 063c99e8bd..6014448013 100644 --- a/tests/invalid_models_tests/test_ordinary_fields.py +++ b/tests/invalid_models_tests/test_ordinary_fields.py @@ -199,7 +199,8 @@ class CharFieldTests(TestCase): field.check(), [ Error( - "'choices' must be an iterable (e.g., a list or tuple).", + "'choices' must be a mapping (e.g. a dictionary) or an iterable " + "(e.g. a list or tuple).", obj=field, id="fields.E004", ), @@ -217,8 +218,9 @@ class CharFieldTests(TestCase): field.check(), [ Error( - "'choices' must be an iterable containing (actual value, " - "human readable name) tuples.", + "'choices' must be a mapping of actual values to human readable " + "names or an iterable containing (actual value, human readable " + "name) tuples.", obj=field, id="fields.E005", ), @@ -260,8 +262,9 @@ class CharFieldTests(TestCase): field.check(), [ Error( - "'choices' must be an iterable containing (actual " - "value, human readable name) tuples.", + "'choices' must be a mapping of actual values to human " + "readable names or an iterable containing (actual value, " + "human readable name) tuples.", obj=field, id="fields.E005", ), @@ -309,8 +312,9 @@ class CharFieldTests(TestCase): field.check(), [ Error( - "'choices' must be an iterable containing (actual value, " - "human readable name) tuples.", + "'choices' must be a mapping of actual values to human readable " + "names or an iterable containing (actual value, human readable " + "name) tuples.", obj=field, id="fields.E005", ), @@ -337,8 +341,9 @@ class CharFieldTests(TestCase): field.check(), [ Error( - "'choices' must be an iterable containing (actual value, " - "human readable name) tuples.", + "'choices' must be a mapping of actual values to human readable " + "names or an iterable containing (actual value, human readable " + "name) tuples.", obj=field, id="fields.E005", ), @@ -386,6 +391,26 @@ class CharFieldTests(TestCase): ], ) + def test_choices_callable(self): + def get_choices(): + return [(i, i) for i in range(3)] + + class Model(models.Model): + field = models.CharField(max_length=10, choices=get_choices) + + field = Model._meta.get_field("field") + self.assertEqual( + field.check(), + [ + Error( + "'choices' must be a mapping (e.g. a dictionary) or an iterable " + "(e.g. a list or tuple).", + obj=field, + id="fields.E004", + ), + ], + ) + def test_bad_db_index_value(self): class Model(models.Model): field = models.CharField(max_length=10, db_index="bad") @@ -854,7 +879,8 @@ class IntegerFieldTests(SimpleTestCase): field.check(), [ Error( - "'choices' must be an iterable (e.g., a list or tuple).", + "'choices' must be a mapping (e.g. a dictionary) or an iterable " + "(e.g. a list or tuple).", obj=field, id="fields.E004", ), @@ -872,8 +898,9 @@ class IntegerFieldTests(SimpleTestCase): field.check(), [ Error( - "'choices' must be an iterable containing (actual value, human " - "readable name) tuples.", + "'choices' must be a mapping of actual values to human readable " + "names or an iterable containing (actual value, human readable " + "name) tuples.", obj=field, id="fields.E005", ), diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py index 5ee814d2af..e28e52ffad 100644 --- a/tests/migrations/test_writer.py +++ b/tests/migrations/test_writer.py @@ -460,6 +460,16 @@ class WriterTests(SimpleTestCase): "default=datetime.date(1969, 11, 19))", ) + def test_serialize_dictionary_choices(self): + for choices in ({"Group": [(2, "2"), (1, "1")]}, {"Group": {2: "2", 1: "1"}}): + with self.subTest(choices): + field = models.IntegerField(choices=choices) + string = MigrationWriter.serialize(field)[0] + self.assertEqual( + string, + "models.IntegerField(choices=[('Group', [(2, '2'), (1, '1')])])", + ) + def test_serialize_nested_class(self): for nested_cls in [self.NestedEnum, self.NestedChoices]: cls_name = nested_cls.__name__ diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index 1776cb9bcb..e1a5a3872f 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -31,24 +31,18 @@ class Bar(models.Model): class Whiz(models.Model): - CHOICES = ( - ( - "Group 1", - ( - (1, "First"), - (2, "Second"), - ), + CHOICES = { + "Group 1": { + 1: "First", + 2: "Second", + }, + "Group 2": ( + (3, "Third"), + (4, "Fourth"), ), - ( - "Group 2", - ( - (3, "Third"), - (4, "Fourth"), - ), - ), - (0, "Other"), - (5, _("translated")), - ) + 0: "Other", + 5: _("translated"), + } c = models.IntegerField(choices=CHOICES, null=True) @@ -61,7 +55,7 @@ WhizDelayed._meta.get_field("c").choices = Whiz.CHOICES class WhizIter(models.Model): - c = models.IntegerField(choices=iter(Whiz.CHOICES), null=True) + c = models.IntegerField(choices=iter(Whiz.CHOICES.items()), null=True) class WhizIterEmpty(models.Model): @@ -78,6 +72,10 @@ class Choiceful(models.Model): no_choices = models.IntegerField(null=True) empty_choices = models.IntegerField(choices=(), null=True) with_choices = models.IntegerField(choices=[(1, "A")], null=True) + with_choices_dict = models.IntegerField(choices={1: "A"}, null=True) + with_choices_nested_dict = models.IntegerField( + choices={"Thing": {1: "A"}}, null=True + ) empty_choices_bool = models.BooleanField(choices=()) empty_choices_text = models.TextField(choices=()) choices_from_enum = models.IntegerField(choices=Suit) diff --git a/tests/model_fields/test_integerfield.py b/tests/model_fields/test_integerfield.py index 6761589b7e..0d91cff9eb 100644 --- a/tests/model_fields/test_integerfield.py +++ b/tests/model_fields/test_integerfield.py @@ -279,6 +279,14 @@ class ValidationTests(SimpleTestCase): f = models.IntegerField(choices=(("group", ((10, "A"), (20, "B"))), (30, "C"))) self.assertEqual(10, f.clean(10, None)) + def test_choices_validation_supports_named_groups_dicts(self): + f = models.IntegerField(choices={"group": ((10, "A"), (20, "B")), 30: "C"}) + self.assertEqual(10, f.clean(10, None)) + + def test_choices_validation_supports_named_groups_nested_dicts(self): + f = models.IntegerField(choices={"group": {10: "A", 20: "B"}, 30: "C"}) + self.assertEqual(10, f.clean(10, None)) + def test_nullable_integerfield_raises_error_with_blank_false(self): f = models.IntegerField(null=True, blank=False) with self.assertRaises(ValidationError): diff --git a/tests/model_fields/tests.py b/tests/model_fields/tests.py index 91ef6058f9..bc57a00a1d 100644 --- a/tests/model_fields/tests.py +++ b/tests/model_fields/tests.py @@ -156,15 +156,21 @@ class ChoicesTests(SimpleTestCase): cls.empty_choices_bool = Choiceful._meta.get_field("empty_choices_bool") cls.empty_choices_text = Choiceful._meta.get_field("empty_choices_text") cls.with_choices = Choiceful._meta.get_field("with_choices") + cls.with_choices_dict = Choiceful._meta.get_field("with_choices_dict") + cls.with_choices_nested_dict = Choiceful._meta.get_field( + "with_choices_nested_dict" + ) cls.choices_from_enum = Choiceful._meta.get_field("choices_from_enum") cls.choices_from_iterator = Choiceful._meta.get_field("choices_from_iterator") def test_choices(self): self.assertIsNone(self.no_choices.choices) - self.assertEqual(self.empty_choices.choices, ()) - self.assertEqual(self.empty_choices_bool.choices, ()) - self.assertEqual(self.empty_choices_text.choices, ()) + self.assertEqual(self.empty_choices.choices, []) + self.assertEqual(self.empty_choices_bool.choices, []) + self.assertEqual(self.empty_choices_text.choices, []) self.assertEqual(self.with_choices.choices, [(1, "A")]) + self.assertEqual(self.with_choices_dict.choices, [(1, "A")]) + self.assertEqual(self.with_choices_nested_dict.choices, [("Thing", [(1, "A")])]) self.assertEqual( self.choices_from_iterator.choices, [(0, "0"), (1, "1"), (2, "2")] ) @@ -175,6 +181,8 @@ class ChoicesTests(SimpleTestCase): self.assertEqual(self.empty_choices_bool.flatchoices, []) self.assertEqual(self.empty_choices_text.flatchoices, []) self.assertEqual(self.with_choices.flatchoices, [(1, "A")]) + self.assertEqual(self.with_choices_dict.flatchoices, [(1, "A")]) + self.assertEqual(self.with_choices_nested_dict.flatchoices, [(1, "A")]) self.assertEqual( self.choices_from_iterator.flatchoices, [(0, "0"), (1, "1"), (2, "2")] ) @@ -290,11 +298,11 @@ class GetChoicesTests(SimpleTestCase): ("b", "Bar"), ( "Group", - ( + [ ("", "No Preference"), ("fg", "Foo"), ("bg", "Bar"), - ), + ], ), ] f = models.CharField(choices=choices) @@ -302,7 +310,7 @@ class GetChoicesTests(SimpleTestCase): def test_lazy_strings_not_evaluated(self): lazy_func = lazy(lambda x: 0 / 0, int) # raises ZeroDivisionError if evaluated. - f = models.CharField(choices=[(lazy_func("group"), (("a", "A"), ("b", "B")))]) + f = models.CharField(choices=[(lazy_func("group"), [("a", "A"), ("b", "B")])]) self.assertEqual(f.get_choices(include_blank=True)[0], ("", "---------")) diff --git a/tests/utils_tests/test_choices.py b/tests/utils_tests/test_choices.py new file mode 100644 index 0000000000..d96c3d49c4 --- /dev/null +++ b/tests/utils_tests/test_choices.py @@ -0,0 +1,305 @@ +from unittest import mock + +from django.db.models import TextChoices +from django.test import SimpleTestCase +from django.utils.choices import CallableChoiceIterator, normalize_choices +from django.utils.translation import gettext_lazy as _ + + +class NormalizeFieldChoicesTests(SimpleTestCase): + expected = [ + ("C", _("Club")), + ("D", _("Diamond")), + ("H", _("Heart")), + ("S", _("Spade")), + ] + expected_nested = [ + ("Audio", [("vinyl", _("Vinyl")), ("cd", _("CD"))]), + ("Video", [("vhs", _("VHS Tape")), ("dvd", _("DVD"))]), + ("unknown", _("Unknown")), + ] + invalid = [ + 1j, + 123, + 123.45, + "invalid", + b"invalid", + _("invalid"), + object(), + None, + True, + False, + ] + invalid_iterable = [ + # Special cases of a string-likes which would unpack incorrectly. + ["ab"], + [b"ab"], + [_("ab")], + # Non-iterable items or iterable items with incorrect number of + # elements that cannot be unpacked. + [123], + [("value",)], + [("value", "label", "other")], + ] + invalid_nested = [ + # Nested choices can only be two-levels deep, so return callables, + # mappings, iterables, etc. at deeper levels unmodified. + [("Group", [("Value", lambda: "Label")])], + [("Group", [("Value", {"Label 1?": "Label 2?"})])], + [("Group", [("Value", [("Label 1?", "Label 2?")])])], + ] + + def test_empty(self): + def generator(): + yield from () + + for choices in ({}, [], (), set(), frozenset(), generator()): + with self.subTest(choices=choices): + self.assertEqual(normalize_choices(choices), []) + + def test_choices(self): + class Medal(TextChoices): + GOLD = "GOLD", _("Gold") + SILVER = "SILVER", _("Silver") + BRONZE = "BRONZE", _("Bronze") + + expected = [ + ("GOLD", _("Gold")), + ("SILVER", _("Silver")), + ("BRONZE", _("Bronze")), + ] + self.assertEqual(normalize_choices(Medal), expected) + + def test_callable(self): + def get_choices(): + return { + "C": _("Club"), + "D": _("Diamond"), + "H": _("Heart"), + "S": _("Spade"), + } + + get_choices_spy = mock.Mock(wraps=get_choices) + output = normalize_choices(get_choices_spy) + + get_choices_spy.assert_not_called() + self.assertIsInstance(output, CallableChoiceIterator) + self.assertEqual(list(output), self.expected) + get_choices_spy.assert_called_once() + + def test_mapping(self): + choices = { + "C": _("Club"), + "D": _("Diamond"), + "H": _("Heart"), + "S": _("Spade"), + } + self.assertEqual(normalize_choices(choices), self.expected) + + def test_iterable(self): + choices = [ + ("C", _("Club")), + ("D", _("Diamond")), + ("H", _("Heart")), + ("S", _("Spade")), + ] + self.assertEqual(normalize_choices(choices), self.expected) + + def test_iterator(self): + def generator(): + yield "C", _("Club") + yield "D", _("Diamond") + yield "H", _("Heart") + yield "S", _("Spade") + + choices = generator() + self.assertEqual(normalize_choices(choices), self.expected) + + def test_nested_callable(self): + def get_audio_choices(): + return [("vinyl", _("Vinyl")), ("cd", _("CD"))] + + def get_video_choices(): + return [("vhs", _("VHS Tape")), ("dvd", _("DVD"))] + + def get_media_choices(): + return [ + ("Audio", get_audio_choices), + ("Video", get_video_choices), + ("unknown", _("Unknown")), + ] + + get_media_choices_spy = mock.Mock(wraps=get_media_choices) + output = normalize_choices(get_media_choices_spy) + + get_media_choices_spy.assert_not_called() + self.assertIsInstance(output, CallableChoiceIterator) + self.assertEqual(list(output), self.expected_nested) + get_media_choices_spy.assert_called_once() + + def test_nested_mapping(self): + choices = { + "Audio": {"vinyl": _("Vinyl"), "cd": _("CD")}, + "Video": {"vhs": _("VHS Tape"), "dvd": _("DVD")}, + "unknown": _("Unknown"), + } + self.assertEqual(normalize_choices(choices), self.expected_nested) + + def test_nested_iterable(self): + choices = [ + ("Audio", [("vinyl", _("Vinyl")), ("cd", _("CD"))]), + ("Video", [("vhs", _("VHS Tape")), ("dvd", _("DVD"))]), + ("unknown", _("Unknown")), + ] + self.assertEqual(normalize_choices(choices), self.expected_nested) + + def test_nested_iterator(self): + def generate_audio_choices(): + yield "vinyl", _("Vinyl") + yield "cd", _("CD") + + def generate_video_choices(): + yield "vhs", _("VHS Tape") + yield "dvd", _("DVD") + + def generate_media_choices(): + yield "Audio", generate_audio_choices() + yield "Video", generate_video_choices() + yield "unknown", _("Unknown") + + choices = generate_media_choices() + self.assertEqual(normalize_choices(choices), self.expected_nested) + + def test_callable_non_canonical(self): + # Canonical form is list of 2-tuple, but nested lists should work. + def get_choices(): + return [ + ["C", _("Club")], + ["D", _("Diamond")], + ["H", _("Heart")], + ["S", _("Spade")], + ] + + get_choices_spy = mock.Mock(wraps=get_choices) + output = normalize_choices(get_choices_spy) + + get_choices_spy.assert_not_called() + self.assertIsInstance(output, CallableChoiceIterator) + self.assertEqual(list(output), self.expected) + get_choices_spy.assert_called_once() + + def test_iterable_non_canonical(self): + # Canonical form is list of 2-tuple, but nested lists should work. + choices = [ + ["C", _("Club")], + ["D", _("Diamond")], + ["H", _("Heart")], + ["S", _("Spade")], + ] + self.assertEqual(normalize_choices(choices), self.expected) + + def test_iterator_non_canonical(self): + # Canonical form is list of 2-tuple, but nested lists should work. + def generator(): + yield ["C", _("Club")] + yield ["D", _("Diamond")] + yield ["H", _("Heart")] + yield ["S", _("Spade")] + + choices = generator() + self.assertEqual(normalize_choices(choices), self.expected) + + def test_nested_callable_non_canonical(self): + # Canonical form is list of 2-tuple, but nested lists should work. + + def get_audio_choices(): + return [["vinyl", _("Vinyl")], ["cd", _("CD")]] + + def get_video_choices(): + return [["vhs", _("VHS Tape")], ["dvd", _("DVD")]] + + def get_media_choices(): + return [ + ["Audio", get_audio_choices], + ["Video", get_video_choices], + ["unknown", _("Unknown")], + ] + + get_media_choices_spy = mock.Mock(wraps=get_media_choices) + output = normalize_choices(get_media_choices_spy) + + get_media_choices_spy.assert_not_called() + self.assertIsInstance(output, CallableChoiceIterator) + self.assertEqual(list(output), self.expected_nested) + get_media_choices_spy.assert_called_once() + + def test_nested_iterable_non_canonical(self): + # Canonical form is list of 2-tuple, but nested lists should work. + choices = [ + ["Audio", [["vinyl", _("Vinyl")], ["cd", _("CD")]]], + ["Video", [["vhs", _("VHS Tape")], ["dvd", _("DVD")]]], + ["unknown", _("Unknown")], + ] + self.assertEqual(normalize_choices(choices), self.expected_nested) + + def test_nested_iterator_non_canonical(self): + # Canonical form is list of 2-tuple, but nested lists should work. + def generator(): + yield ["Audio", [["vinyl", _("Vinyl")], ["cd", _("CD")]]] + yield ["Video", [["vhs", _("VHS Tape")], ["dvd", _("DVD")]]] + yield ["unknown", _("Unknown")] + + choices = generator() + self.assertEqual(normalize_choices(choices), self.expected_nested) + + def test_nested_mixed_mapping_and_iterable(self): + # Although not documented, as it's better to stick to either mappings + # or iterables, nesting of mappings within iterables and vice versa + # works and is likely to occur in the wild. This is supported by the + # recursive call to `normalize_choices()` which will normalize nested + # choices. + choices = { + "Audio": [("vinyl", _("Vinyl")), ("cd", _("CD"))], + "Video": [("vhs", _("VHS Tape")), ("dvd", _("DVD"))], + "unknown": _("Unknown"), + } + self.assertEqual(normalize_choices(choices), self.expected_nested) + choices = [ + ("Audio", {"vinyl": _("Vinyl"), "cd": _("CD")}), + ("Video", {"vhs": _("VHS Tape"), "dvd": _("DVD")}), + ("unknown", _("Unknown")), + ] + self.assertEqual(normalize_choices(choices), self.expected_nested) + + def test_iterable_set(self): + # Although not documented, as sets are unordered which results in + # randomised order in form fields, passing a set of 2-tuples works. + # Consistent ordering of choices on model fields in migrations is + # enforced by the migrations serializer. + choices = { + ("C", _("Club")), + ("D", _("Diamond")), + ("H", _("Heart")), + ("S", _("Spade")), + } + self.assertEqual(sorted(normalize_choices(choices)), sorted(self.expected)) + + def test_unsupported_values_returned_unmodified(self): + # Unsupported values must be returned unmodified for model system check + # to work correctly. + for value in self.invalid + self.invalid_iterable + self.invalid_nested: + with self.subTest(value=value): + self.assertEqual(normalize_choices(value), value) + + def test_unsupported_values_from_callable_returned_unmodified(self): + for value in self.invalid_iterable + self.invalid_nested: + with self.subTest(value=value): + self.assertEqual(list(normalize_choices(lambda: value)), value) + + def test_unsupported_values_from_iterator_returned_unmodified(self): + for value in self.invalid_nested: + with self.subTest(value=value): + self.assertEqual( + list(normalize_choices((lambda: (yield from value))())), + value, + ) |
