Permalink
Browse files

Fixed #16921 -- Added assertHTMLEqual and assertHTMLNotEqual assertio…

…ns, and converted Django tests to use them where appropriate. Thanks Greg Müllegger.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@17414 bcc190cf-cafb-0310-a4f2-bffc1f526a37
  • Loading branch information...
1 parent c82f1dc commit 844a24bbb97af663ebf8dbeab4499acafe105943 @carljm carljm committed Jan 31, 2012
Showing with 1,345 additions and 639 deletions.
  1. +221 −0 django/test/html.py
  2. +62 −4 django/test/testcases.py
  3. +94 −0 django/utils/htmlparser.py
  4. +15 −0 docs/releases/1.4.txt
  5. +60 −2 docs/topics/testing.txt
  6. +4 −4 tests/modeltests/generic_relations/tests.py
  7. +24 −24 tests/modeltests/model_forms/tests.py
  8. +42 −42 tests/modeltests/model_formsets/tests.py
  9. +3 −3 tests/regressiontests/admin_inlines/tests.py
  10. +25 −26 tests/regressiontests/admin_views/tests.py
  11. +19 −19 tests/regressiontests/admin_widgets/tests.py
  12. +5 −6 tests/regressiontests/forms/tests/error_messages.py
  13. +19 −16 tests/regressiontests/forms/tests/extra.py
  14. +2 −1 tests/regressiontests/forms/tests/fields.py
  15. +180 −177 tests/regressiontests/forms/tests/forms.py
  16. +19 −19 tests/regressiontests/forms/tests/formsets.py
  17. +2 −2 tests/regressiontests/forms/tests/models.py
  18. +14 −14 tests/regressiontests/forms/tests/regressions.py
  19. +14 −13 tests/regressiontests/forms/tests/util.py
  20. +202 −202 tests/regressiontests/forms/tests/widgets.py
  21. +8 −8 tests/regressiontests/generic_inline_admin/tests.py
  22. +5 −5 tests/regressiontests/i18n/tests.py
  23. +1 −1 tests/regressiontests/localflavor/ar/tests.py
  24. +1 −1 tests/regressiontests/localflavor/at/tests.py
  25. +1 −1 tests/regressiontests/localflavor/au/tests.py
  26. +2 −2 tests/regressiontests/localflavor/be/tests.py
  27. +1 −1 tests/regressiontests/localflavor/br/tests.py
  28. +1 −1 tests/regressiontests/localflavor/ca/tests.py
  29. +1 −1 tests/regressiontests/localflavor/ch/tests.py
  30. +1 −1 tests/regressiontests/localflavor/cl/tests.py
  31. +1 −1 tests/regressiontests/localflavor/cn/tests.py
  32. +1 −1 tests/regressiontests/localflavor/co/tests.py
  33. +1 −1 tests/regressiontests/localflavor/cz/tests.py
  34. +1 −1 tests/regressiontests/localflavor/de/tests.py
  35. +1 −1 tests/regressiontests/localflavor/ec/tests.py
  36. +2 −2 tests/regressiontests/localflavor/es/tests.py
  37. +1 −1 tests/regressiontests/localflavor/fi/tests.py
  38. +1 −1 tests/regressiontests/localflavor/fr/tests.py
  39. +3 −3 tests/regressiontests/localflavor/hr/tests.py
  40. +2 −2 tests/regressiontests/localflavor/id/tests.py
  41. +1 −1 tests/regressiontests/localflavor/ie/tests.py
  42. +1 −1 tests/regressiontests/localflavor/in_/tests.py
  43. +1 −1 tests/regressiontests/localflavor/is_/tests.py
  44. +1 −1 tests/regressiontests/localflavor/it/tests.py
  45. +1 −1 tests/regressiontests/localflavor/jp/tests.py
  46. +2 −2 tests/regressiontests/localflavor/mk/tests.py
  47. +2 −2 tests/regressiontests/localflavor/mx/tests.py
  48. +1 −1 tests/regressiontests/localflavor/nl/tests.py
  49. +2 −2 tests/regressiontests/localflavor/pl/tests.py
  50. +2 −2 tests/regressiontests/localflavor/py/tests.py
  51. +1 −1 tests/regressiontests/localflavor/ro/tests.py
  52. +2 −2 tests/regressiontests/localflavor/ru/tests.py
  53. +1 −1 tests/regressiontests/localflavor/se/tests.py
  54. +1 −1 tests/regressiontests/localflavor/si/tests.py
  55. +2 −2 tests/regressiontests/localflavor/sk/tests.py
  56. +3 −3 tests/regressiontests/localflavor/us/tests.py
  57. +1 −1 tests/regressiontests/localflavor/uy/tests.py
  58. +1 −1 tests/regressiontests/model_forms_regress/tests.py
  59. +2 −2 tests/regressiontests/modeladmin/tests.py
  60. +254 −0 tests/regressiontests/test_utils/tests.py
  61. +1 −1 tests/regressiontests/views/tests/generic/create_update.py
View
@@ -0,0 +1,221 @@
+"""
+Comparing two html documents.
+"""
+import re
+from HTMLParser import HTMLParseError
+from django.utils.encoding import force_unicode
+from django.utils.htmlparser import HTMLParser
+
+
+WHITESPACE = re.compile('\s+')
+
+
+def normalize_whitespace(string):
+ return WHITESPACE.sub(' ', string)
+
+
+class Element(object):
+ def __init__(self, name, attributes):
+ self.name = name
+ self.attributes = sorted(attributes)
+ self.children = []
+
+ def append(self, element):
+ if isinstance(element, basestring):
+ element = force_unicode(element)
+ element = normalize_whitespace(element)
+ if self.children:
+ if isinstance(self.children[-1], basestring):
+ self.children[-1] += element
+ self.children[-1] = normalize_whitespace(self.children[-1])
+ return
+ elif self.children:
+ # removing last children if it is only whitespace
+ # this can result in incorrect dom representations since
+ # whitespace between inline tags like <span> is significant
+ if isinstance(self.children[-1], basestring):
+ if self.children[-1].isspace():
+ self.children.pop()
+ if element:
+ self.children.append(element)
+
+ def finalize(self):
+ def rstrip_last_element(children):
+ if children:
+ if isinstance(children[-1], basestring):
+ children[-1] = children[-1].rstrip()
+ if not children[-1]:
+ children.pop()
+ children = rstrip_last_element(children)
+ return children
+
+ rstrip_last_element(self.children)
+ for i, child in enumerate(self.children):
+ if isinstance(child, basestring):
+ self.children[i] = child.strip()
+ elif hasattr(child, 'finalize'):
+ child.finalize()
+
+ def __eq__(self, element):
+ if not hasattr(element, 'name'):
+ return False
+ if hasattr(element, 'name') and self.name != element.name:
+ return False
+ if len(self.attributes) != len(element.attributes):
+ return False
+ if self.attributes != element.attributes:
+ # attributes without a value is same as attribute with value that
+ # equals the attributes name:
+ # <input checked> == <input checked="checked">
+ for i in range(len(self.attributes)):
+ attr, value = self.attributes[i]
+ other_attr, other_value = element.attributes[i]
+ if value is None:
+ value = attr
+ if other_value is None:
+ other_value = other_attr
+ if attr != other_attr or value != other_value:
+ return False
+ if self.children != element.children:
+ return False
+ return True
+
+ def __ne__(self, element):
+ return not self.__eq__(element)
+
+ def _count(self, element, count=True):
+ if not isinstance(element, basestring):
+ if self == element:
+ return 1
+ i = 0
+ for child in self.children:
+ # child is text content and element is also text content, then
+ # make a simple "text" in "text"
+ if isinstance(child, basestring):
+ if isinstance(element, basestring):
+ if count:
+ i += child.count(element)
+ elif element in child:
+ return 1
+ else:
+ i += child._count(element, count=count)
+ if not count and i:
+ return i
+ return i
+
+ def __contains__(self, element):
+ return self._count(element, count=False) > 0
+
+ def count(self, element):
+ return self._count(element, count=True)
+
+ def __getitem__(self, key):
+ return self.children[key]
+
+ def __unicode__(self):
+ output = u'<%s' % self.name
+ for key, value in self.attributes:
+ if value:
+ output += u' %s="%s"' % (key, value)
+ else:
+ output += u' %s' % key
+ if self.children:
+ output += u'>\n'
+ output += u''.join(unicode(c) for c in self.children)
+ output += u'\n</%s>' % self.name
+ else:
+ output += u' />'
+ return output
+
+ def __repr__(self):
+ return unicode(self)
+
+
+class RootElement(Element):
+ def __init__(self):
+ super(RootElement, self).__init__(None, ())
+
+ def __unicode__(self):
+ return u''.join(unicode(c) for c in self.children)
+
+
+class Parser(HTMLParser):
+ SELF_CLOSING_TAGS = ('br' , 'hr', 'input', 'img', 'meta', 'spacer',
+ 'link', 'frame', 'base', 'col')
+
+ def __init__(self):
+ HTMLParser.__init__(self)
+ self.root = RootElement()
+ self.open_tags = []
+ self.element_positions = {}
+
+ def error(self, msg):
+ raise HTMLParseError(msg, self.getpos())
+
+ def format_position(self, position=None, element=None):
+ if not position and element:
+ position = self.element_positions[element]
+ if position is None:
+ position = self.getpos()
+ if hasattr(position, 'lineno'):
+ position = position.lineno, position.offset
+ return 'Line %d, Column %d' % position
+
+ @property
+ def current(self):
+ if self.open_tags:
+ return self.open_tags[-1]
+ else:
+ return self.root
+
+ def handle_startendtag(self, tag, attrs):
+ self.handle_starttag(tag, attrs)
+ if tag not in self.SELF_CLOSING_TAGS:
+ self.handle_endtag(tag)
+
+ def handle_starttag(self, tag, attrs):
+ element = Element(tag, attrs)
+ self.current.append(element)
+ if tag not in self.SELF_CLOSING_TAGS:
+ self.open_tags.append(element)
+ self.element_positions[element] = self.getpos()
+
+ def handle_endtag(self, tag):
+ if not self.open_tags:
+ self.error("Unexpected end tag `%s` (%s)" % (
+ tag, self.format_position()))
+ element = self.open_tags.pop()
+ while element.name != tag:
+ if not self.open_tags:
+ self.error("Unexpected end tag `%s` (%s)" % (
+ tag, self.format_position()))
+ element = self.open_tags.pop()
+
+ def handle_data(self, data):
+ self.current.append(data)
+
+ def handle_charref(self, name):
+ self.current.append('&%s;' % name)
+
+ def handle_entityref(self, name):
+ self.current.append('&%s;' % name)
+
+
+def parse_html(html):
+ """
+ Takes a string that contains *valid* HTML and turns it into a Python object
+ structure that can be easily compared against other HTML on semantic
+ equivilance. Syntactical differences like which quotation is used on
+ arguments will be ignored.
+
+ """
+ parser = Parser()
+ parser.feed(html)
+ parser.close()
+ document = parser.root
+ document.finalize()
+ # Removing ROOT element if it's not necessary
+ if len(document.children) == 1:
+ if not isinstance(document.children[0], basestring):
+ document = document.children[0]
+ return document
@@ -1,5 +1,6 @@
from __future__ import with_statement
+import difflib
import os
import re
import sys
@@ -29,12 +30,14 @@
from django.http import QueryDict
from django.test import _doctest as doctest
from django.test.client import Client
+from django.test.html import HTMLParseError, parse_html
from django.test.signals import template_rendered
from django.test.utils import (get_warnings_state, restore_warnings_state,
override_settings)
from django.test.utils import ContextList
from django.utils import simplejson, unittest as ut2
from django.utils.encoding import smart_str, force_unicode
+from django.utils.unittest.util import safe_repr
from django.views.static import serve
__all__ = ('DocTestRunner', 'OutputChecker', 'TestCase', 'TransactionTestCase',
@@ -78,6 +81,16 @@ def restore_transaction_methods():
transaction.leave_transaction_management = real_leave_transaction_management
transaction.managed = real_managed
+
+def assert_and_parse_html(self, html, user_msg, msg):
+ try:
+ dom = parse_html(html)
+ except HTMLParseError, e:
+ standardMsg = u'%s\n%s' % (msg, e.msg)
+ self.fail(self._formatMessage(user_msg, standardMsg))
+ return dom
+
+
class OutputChecker(doctest.OutputChecker):
def check_output(self, want, got, optionflags):
"""
@@ -396,6 +409,39 @@ def assertFieldOutput(self, fieldclass, valid, invalid, field_args=None,
self.assertTrue(isinstance(fieldclass(*field_args, **field_kwargs),
fieldclass))
+ def assertHTMLEqual(self, html1, html2, msg=None):
+ """
+ Asserts that two html snippets are semantically the same,
+ e.g. whitespace in most cases is ignored, attribute ordering is not
+ significant. The passed in arguments must be valid HTML.
+
+ """
+ dom1 = assert_and_parse_html(self, html1, msg,
+ u'First argument is not valid html:')
+ dom2 = assert_and_parse_html(self, html2, msg,
+ u'Second argument is not valid html:')
+
+ if dom1 != dom2:
+ standardMsg = '%s != %s' % (
+ safe_repr(dom1, True), safe_repr(dom2, True))
+ diff = ('\n' + '\n'.join(difflib.ndiff(
+ unicode(dom1).splitlines(),
+ unicode(dom2).splitlines())))
+ standardMsg = self._truncateMessage(standardMsg, diff)
+ self.fail(self._formatMessage(msg, standardMsg))
+
+ def assertHTMLNotEqual(self, html1, html2, msg=None):
+ """Asserts that two HTML snippets are not semantically equivalent."""
+ dom1 = assert_and_parse_html(self, html1, msg,
+ u'First argument is not valid html:')
+ dom2 = assert_and_parse_html(self, html2, msg,
+ u'Second argument is not valid html:')
+
+ if dom1 == dom2:
+ standardMsg = '%s == %s' % (
+ safe_repr(dom1, True), safe_repr(dom2, True))
+ self.fail(self._formatMessage(msg, standardMsg))
+
class TransactionTestCase(SimpleTestCase):
# The class we'll use for the test client self.client.
@@ -554,7 +600,7 @@ def assertRedirects(self, response, expected_url, status_code=302,
(url, expected_url))
def assertContains(self, response, text, count=None, status_code=200,
- msg_prefix=''):
+ msg_prefix='', html=False):
"""
Asserts that a response indicates that some content was retrieved
successfully, (i.e., the HTTP status code was as expected), and that
@@ -576,7 +622,13 @@ def assertContains(self, response, text, count=None, status_code=200,
msg_prefix + "Couldn't retrieve content: Response code was %d"
" (expected %d)" % (response.status_code, status_code))
text = smart_str(text, response._charset)
- real_count = response.content.count(text)
+ content = response.content
+ if html:
+ content = assert_and_parse_html(self, content, None,
+ u"Response's content is not valid html:")
+ text = assert_and_parse_html(self, text, None,
+ u"Second argument is not valid html:")
+ real_count = content.count(text)
if count is not None:
self.assertEqual(real_count, count,
msg_prefix + "Found %d instances of '%s' in response"
@@ -586,7 +638,7 @@ def assertContains(self, response, text, count=None, status_code=200,
msg_prefix + "Couldn't find '%s' in response" % text)
def assertNotContains(self, response, text, status_code=200,
- msg_prefix=''):
+ msg_prefix='', html=False):
"""
Asserts that a response indicates that some content was retrieved
successfully, (i.e., the HTTP status code was as expected), and that
@@ -606,7 +658,13 @@ def assertNotContains(self, response, text, status_code=200,
msg_prefix + "Couldn't retrieve content: Response code was %d"
" (expected %d)" % (response.status_code, status_code))
text = smart_str(text, response._charset)
- self.assertEqual(response.content.count(text), 0,
+ content = response.content
+ if html:
+ content = assert_and_parse_html(self, content, None,
+ u'Response\'s content is no valid html:')
+ text = assert_and_parse_html(self, text, None,
+ u'Second argument is no valid html:')
+ self.assertEqual(content.count(text), 0,
msg_prefix + "Response should not contain '%s'" % text)
def assertFormError(self, response, form, field, errors, msg_prefix=''):
Oops, something went wrong.

0 comments on commit 844a24b

Please sign in to comment.