From f7efbfd3b7cdc85e6de63ecdf7f72a725808e4ff Mon Sep 17 00:00:00 2001 From: Travis Dart Date: Sat, 30 May 2020 22:18:31 -0500 Subject: [PATCH] Bugfixes and refactoring. --- README.md | 12 ++--- formstorm/FormElement.py | 56 ++++++++++++++++++---- formstorm/FormTest.py | 26 ++++------ formstorm/iterhelpers.py | 50 +++++++++---------- tests/fstest/settings.py | 39 +++++---------- tests/fstestapp/apps.py | 8 ---- tests/fstestapp/migrations/0001_initial.py | 46 +++++------------- tests/fstestapp/models.py | 6 ++- tests/fstestapp/test.py | 53 +++++++++++++++----- 9 files changed, 153 insertions(+), 143 deletions(-) delete mode 100644 tests/fstestapp/apps.py diff --git a/README.md b/README.md index 4a30f31..565d928 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ of valid and invalid values for each field: class Book(models.Model): title = models.CharField(max_length=100, blank=False, null=False) - subtitle = models.CharField(max_length=100) + subtitle = models.CharField(max_length=100, blank=True, default="") class BookForm(ModelForm): @@ -29,11 +29,11 @@ of valid and invalid values for each field: form = BookForm title = FormElement( good = ["Moby Dick"], - bad = [None, '', 'A'*101], + bad = [None, "", "A"*101], ) subtitle = FormElement( - good = [None, "", "or The Whale"], - bad = ["A"*101] + good = ["", "or The Whale"], + bad = [None, "A"*101] ) @@ -51,11 +51,11 @@ each field's possible values. Namely, the form will be tested with these values: | title | subtitle | result | |-----------|--------------|---------| -| Moby Dick | None | Valid | +| Moby Dick | "" | Valid | +| Moby Dick | None | Invalid | | None | None | Invalid | | "" | None | Invalid | | AA[...]AA | None | Invalid | -| Moby Dick | "" | Valid | | None | "" | Invalid | | "" | "" | Invalid | | AA[...]AA | "" | Invalid | diff --git a/formstorm/FormElement.py b/formstorm/FormElement.py index 4c8063b..0c79ee9 100644 --- a/formstorm/FormElement.py +++ b/formstorm/FormElement.py @@ -18,19 +18,55 @@ def __init__(self, self.only_if = only_if self.not_if = not_if - def build_iterator(self, ref_model, is_e2e): - for i, g in enumerate(self.good): - if type(g) is Q: - ref_object = ref_model.objects.get(g) - if is_e2e: # If we're doing an e2e test, reference by name. - self.good[i] = text_type(ref_object) - else: - self.good[i] = ref_object.pk + def build_iterator(self, form, field_name, is_e2e): + """ + Suppose in a Book model, Book.genre is a foreign key to a Genre model, + and in the Book test we have: + + genre = FormElement( + good=[Q(name="Mystery")], + ) + + If good=[Q(name="Mystery")] then replace good with the pk of the + Mystery object. Essentially (although this is not the algorithm used) + the object is replaced with: + good = [ Genre.objects.get(name="Mystery").pk ] + """ + def _get_pk_for_q(q_object): + # 1st: Find the model that the field points to. + form_field = form._meta.model._meta.get_field(field_name) + try: # Python 3 + ref_model = form_field.remote_field.model + except AttributeError: + ref_model = form_field.rel.to + + # 2nd: Get the object that the Q object points to. + ref_object = ref_model.objects.get(q_object) + + # 3rd: Get the identifier for that object. + if is_e2e: # If we're doing an e2e test, reference by name. + return text_type(ref_object) + else: + return ref_object.pk + + def _replace_all_q(value_list): + for i, g in enumerate(value_list): + if type(g) is Q: + value_list[i] = _get_pk_for_q(g) + elif type(g) in [list, tuple]: + value_list[i] = [ + _get_pk_for_q(j) + if type(j) is Q else j + for j in g + ] + return value_list + + self.good = _replace_all_q(self.good) + self.bad = _replace_all_q(self.bad) self.iterator = chain( [(x, True) for x in self.good], [(x, False) for x in self.bad], - # self.values + self.values ) - return self.iterator diff --git a/formstorm/FormTest.py b/formstorm/FormTest.py index f5a4037..8838bf6 100644 --- a/formstorm/FormTest.py +++ b/formstorm/FormTest.py @@ -20,21 +20,11 @@ def _build_elements(self): for e in dir(self): # Filter out this class's FormElement properties if type(getattr(self, e)) is FormElement: - # If this field is a fk/m2m, get the model that it points to. - form_field = self.form._meta.model._meta.get_field(e) - try: # Python 3 - ref_model = form_field.remote_field.model - except AttributeError: - try: - ref_model = form_field.rel.to - except AttributeError: - ref_model = None - self.elements[e] = getattr(self, e).build_iterator( is_e2e=self.is_e2e, - ref_model=ref_model + form=self.form, + field_name=e ) - getattr(self, e) def __init__(self): self._is_modelform = ModelForm in self.form.mro() @@ -43,18 +33,22 @@ def __init__(self): self._iterator = dict_combo(self.elements) def _run(self, is_uniqueness_test=False): - # i is a dictionary whose elements are tuples - # in the form (value, is_good) + # i is a dictionary containing tuples in the form (value, is_good) for i in self._iterator: # if any field is invalid, the form is invalid. form_is_good = all([x[1][1] for x in i.items()]) - form_values = {k: v[0] for k, v in i.items()} + form_values = {k: v[0] for k, v in i.items() if v[0] is not None} if self._is_modelform and not is_uniqueness_test: sid = transaction.savepoint() self.submit_form(form_values) - assert self.is_good() == form_is_good + + if self.is_good() != form_is_good: + # print("form_values", form_values) + # print("errors", self.bound_form.errors) + raise AssertionError + if is_uniqueness_test and form_is_good: self.submit_form(form_values) assert not self.is_good() diff --git a/formstorm/iterhelpers.py b/formstorm/iterhelpers.py index dbb6199..0ab9216 100644 --- a/formstorm/iterhelpers.py +++ b/formstorm/iterhelpers.py @@ -8,10 +8,9 @@ def every_combo(items): list(every_combo([1, 2, 3])) [(1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)] """ - return itertools.chain(*[ - itertools.combinations(items, i) - for i in range(1, len(items) + 1) - ]) + x = [itertools.combinations(items, i) for i in range(1, len(items) + 1)] + y = list(itertools.chain(*x)) + return y def dict_combo(params): @@ -22,36 +21,33 @@ def dict_combo(params): "c": [True, False, None], } for x in dict_combo(params): - print x + print(x) Returns: - {'a': 'A', 'c': True, 'b': 0} - {'a': 'A', 'c': True, 'b': 1} - {'a': 'A', 'c': False, 'b': 0} - {'a': 'A', 'c': False, 'b': 1} - {'a': 'A', 'c': None, 'b': 0} - {'a': 'A', 'c': None, 'b': 1} - {'a': 'B', 'c': True, 'b': 0} - {'a': 'B', 'c': True, 'b': 1} - {'a': 'B', 'c': False, 'b': 0} - {'a': 'B', 'c': False, 'b': 1} - {'a': 'B', 'c': None, 'b': 0} - {'a': 'B', 'c': None, 'b': 1} - {'a': 'C', 'c': True, 'b': 0} - {'a': 'C', 'c': True, 'b': 1} - {'a': 'C', 'c': False, 'b': 0} - {'a': 'C', 'c': False, 'b': 1} - {'a': 'C', 'c': None, 'b': 0} - {'a': 'C', 'c': None, 'b': 1} + {'a': 'A', 'b': 0, 'c': True} + {'a': 'A', 'b': 0, 'c': False} + {'a': 'A', 'b': 0, 'c': None} + {'a': 'A', 'b': 1, 'c': True} + {'a': 'A', 'b': 1, 'c': False} + {'a': 'A', 'b': 1, 'c': None} + {'a': 'B', 'b': 0, 'c': True} + {'a': 'B', 'b': 0, 'c': False} + {'a': 'B', 'b': 0, 'c': None} + {'a': 'B', 'b': 1, 'c': True} + {'a': 'B', 'b': 1, 'c': False} + {'a': 'B', 'b': 1, 'c': None} + {'a': 'C', 'b': 0, 'c': True} + {'a': 'C', 'b': 0, 'c': False} + {'a': 'C', 'b': 0, 'c': None} + {'a': 'C', 'b': 1, 'c': True} + {'a': 'C', 'b': 1, 'c': False} + {'a': 'C', 'b': 1, 'c': None} """ # We don't want the key-->value paring to get mismatched. # Convert a dictionary to (key, value) tuples, # Then convert to a list of keys and a list of values # The index of the key will be the index of the value. - keys, values = zip(*[ - (key, value) - for key, value in params.items() - ]) + keys, values = zip(*[(key, value) for key, value in params.items()]) # Take the cartesian product of all the sets. # This will return an iterator with one item from each set, # We take this an pack it back into a dictionary. diff --git a/tests/fstest/settings.py b/tests/fstest/settings.py index 039f107..c0e53e5 100644 --- a/tests/fstest/settings.py +++ b/tests/fstest/settings.py @@ -13,14 +13,12 @@ import os import sys - # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Manually add the formstorm folder to the Python path. sys.path.append(os.path.dirname(BASE_DIR)) - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ @@ -32,18 +30,12 @@ ALLOWED_HOSTS = [] - # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'fstestapp' + 'django.contrib.admin', 'django.contrib.auth', + 'django.contrib.contenttypes', 'django.contrib.sessions', + 'django.contrib.messages', 'django.contrib.staticfiles', 'fstestapp' ] MIDDLEWARE = [ @@ -76,7 +68,6 @@ WSGI_APPLICATION = 'fstest.wsgi.application' - # Database # https://docs.djangoproject.com/en/2.0/ref/settings/#databases @@ -87,35 +78,28 @@ } } - # Password validation # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': ( - 'django.contrib.auth.password_validation.' - 'UserAttributeSimilarityValidator' - ), + 'NAME': ('django.contrib.auth.password_validation.' + 'UserAttributeSimilarityValidator'), }, { - 'NAME': ( - 'django.contrib.auth.password_validation.MinimumLengthValidator' - ), + 'NAME': + ('django.contrib.auth.password_validation.MinimumLengthValidator'), }, { - 'NAME': ( - 'django.contrib.auth.password_validation.CommonPasswordValidator' - ), + 'NAME': + ('django.contrib.auth.password_validation.CommonPasswordValidator'), }, { - 'NAME': ( - 'django.contrib.auth.password_validation.NumericPasswordValidator' - ), + 'NAME': + ('django.contrib.auth.password_validation.NumericPasswordValidator'), }, ] - # Internationalization # https://docs.djangoproject.com/en/2.0/topics/i18n/ @@ -129,7 +113,6 @@ USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.0/howto/static-files/ diff --git a/tests/fstestapp/apps.py b/tests/fstestapp/apps.py deleted file mode 100644 index b41b8c3..0000000 --- a/tests/fstestapp/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.apps import AppConfig - - -class FSTestAppConfig(AppConfig): - name = 'fstestapp' diff --git a/tests/fstestapp/migrations/0001_initial.py b/tests/fstestapp/migrations/0001_initial.py index f6c8783..74c1d42 100644 --- a/tests/fstestapp/migrations/0001_initial.py +++ b/tests/fstestapp/migrations/0001_initial.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.10 on 2018-08-20 03:46 -from __future__ import unicode_literals +# Generated by Django 3.0.5 on 2020-04-19 04:51 from django.db import migrations, models import django.db.models.deletion @@ -17,45 +15,27 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Author', fields=[ - ('id', models.AutoField(auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID')), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Book', + name='Genre', fields=[ - ('id', models.AutoField(auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID')), - ('title', models.CharField(max_length=100, unique=True)), - ('subtitle', models.CharField(blank=True, - max_length=100, - null=True)), - ('is_fiction', models.BooleanField(default=False)), - ('pages', models.IntegerField(default=False)), - ('author', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to='fstestapp.Author') - ), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Genre', + name='Book', fields=[ - ('id', models.AutoField(auto_created=True, - primary_key=True, - serialize=False, - verbose_name='ID')), - ('name', models.CharField(max_length=100)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, unique=True)), + ('subtitle', models.CharField(blank=True, default='', max_length=100)), + ('is_fiction', models.BooleanField(default=False)), + ('pages', models.IntegerField(default=False)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fstestapp.Author')), + ('genre', models.ManyToManyField(to='fstestapp.Genre')), ], ), - migrations.AddField( - model_name='book', - name='genre', - field=models.ManyToManyField(to='fstestapp.Genre'), - ), ] diff --git a/tests/fstestapp/models.py b/tests/fstestapp/models.py index f3a8699..07fa991 100644 --- a/tests/fstestapp/models.py +++ b/tests/fstestapp/models.py @@ -11,8 +11,10 @@ class Genre(models.Model): class Book(models.Model): title = models.CharField(max_length=100, unique=True) - subtitle = models.CharField(max_length=100, blank=True, null=True) + subtitle = models.CharField( + max_length=100, blank=True, null=False, default="" + ) author = models.ForeignKey(Author, on_delete=models.CASCADE) is_fiction = models.BooleanField(default=False) - pages = models.IntegerField(default=False) + pages = models.IntegerField() genre = models.ManyToManyField(Genre) diff --git a/tests/fstestapp/test.py b/tests/fstestapp/test.py index 5c12933..3b1c6a0 100644 --- a/tests/fstestapp/test.py +++ b/tests/fstestapp/test.py @@ -1,24 +1,16 @@ from formstorm import FormTest, FormElement -from formstorm.iterhelpers import every_combo +from formstorm.iterhelpers import every_combo, dict_combo from .forms import BookForm from django.test import TestCase from .models import Author, Genre from django.db.models import Q -from django.apps import apps -from fstestapp.apps import FSTestAppConfig - - -class AppConfigTest(TestCase): - def test_apps(self): - self.assertEqual(FSTestAppConfig.name, 'fstestapp') - self.assertEqual(apps.get_app_config('fstestapp').name, 'fstestapp') class BookFormTest(FormTest): form = BookForm title = FormElement( good=["Moby Dick"], - bad=[None, '', "A"*101], + bad=[None, "", "A"*101], ) subtitle = FormElement( good=[None, "", "or The Whale"], @@ -29,8 +21,8 @@ class BookFormTest(FormTest): bad=[None, "", -1] ) is_fiction = FormElement( - good=[True, False], - bad=[None, "", -1, "A"] + good=[True, False, None, "", -1, "A"], + bad=[] # Boolean input is either truthy or falsy, so nothing is bad. ) pages = FormElement( good=[0, 10, 100], @@ -46,6 +38,42 @@ class BookFormTest(FormTest): ) +class UtilTest(TestCase): + def test_every_combo(self): + x = every_combo([1, 2, 3]) + assert list(x) == [(1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)] + + def test_dict_combo(self): + x = dict_combo({ + "a": ["A", "B", "C"], + "b": [0, 1], + "c": [True, False, None], + }) + + y = [ + {'a': 'A', 'b': 0, 'c': True}, + {'a': 'A', 'b': 0, 'c': False}, + {'a': 'A', 'b': 0, 'c': None}, + {'a': 'A', 'b': 1, 'c': True}, + {'a': 'A', 'b': 1, 'c': False}, + {'a': 'A', 'b': 1, 'c': None}, + {'a': 'B', 'b': 0, 'c': True}, + {'a': 'B', 'b': 0, 'c': False}, + {'a': 'B', 'b': 0, 'c': None}, + {'a': 'B', 'b': 1, 'c': True}, + {'a': 'B', 'b': 1, 'c': False}, + {'a': 'B', 'b': 1, 'c': None}, + {'a': 'C', 'b': 0, 'c': True}, + {'a': 'C', 'b': 0, 'c': False}, + {'a': 'C', 'b': 0, 'c': None}, + {'a': 'C', 'b': 1, 'c': True}, + {'a': 'C', 'b': 1, 'c': False}, + {'a': 'C', 'b': 1, 'c': None}, + ] + + assert list(x) == y + + class BookTestCase(TestCase): def setUp(self): Genre(name="Mystery").save() @@ -57,4 +85,3 @@ def setUp(self): def test_book_form(self): self.theBookFormTest.run() -