summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorNick Pope <nick@nickpope.me.uk>2023-08-31 02:57:40 +0100
committerGitHub <noreply@github.com>2023-08-30 22:57:40 -0300
commit500e01073adda32d5149624ee9a5cb7aa3d3583f (patch)
treef9416872a811aa39646deaf002414e0a7841b6d1 /tests
parent68a8996bdfce2d191decd7b1c1a2b9fdea8e4b2f (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.py15
-rw-r--r--tests/forms_tests/field_tests/test_choicefield.py51
-rw-r--r--tests/forms_tests/widget_tests/test_choicewidget.py19
-rw-r--r--tests/forms_tests/widget_tests/test_radioselect.py18
-rw-r--r--tests/forms_tests/widget_tests/test_select.py142
-rw-r--r--tests/invalid_models_tests/test_ordinary_fields.py51
-rw-r--r--tests/migrations/test_writer.py10
-rw-r--r--tests/model_fields/models.py34
-rw-r--r--tests/model_fields/test_integerfield.py8
-rw-r--r--tests/model_fields/tests.py20
-rw-r--r--tests/utils_tests/test_choices.py305
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 &quot;1&quot;">
- <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 &quot;1&quot;">
+ <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 &quot;1&quot;">
- <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 &quot;1&quot;">
+ <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 &quot;1&quot;">
- <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 &quot;1&quot;">
+ <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,
+ )