1
# Copyright 2010 Canonical Ltd. This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
6
'BrowsesWithQueryLimit',
9
'DoesNotCorrectlyProvide',
11
'EqualsIgnoringWhitespace',
20
'ProvidesAndIsProxied',
23
from lazr.lifecycle.snapshot import Snapshot
24
from testtools import matchers
25
from testtools.content import Content
26
from testtools.content_type import UTF8_TEXT
27
from testtools.matchers import (
28
DocTestMatches as OriginalDocTestMatches,
35
from zope.interface.exceptions import (
37
BrokenMethodImplementation,
40
from zope.interface.verify import verifyObject
41
from zope.security.proxy import (
46
from canonical.launchpad.webapp import canonical_url
47
from canonical.launchpad.webapp.batching import BatchNavigator
48
from lp.testing import normalize_whitespace
49
from lp.testing._login import person_logged_in
50
from lp.testing._webservice import QueryCollector
53
class BrowsesWithQueryLimit(Matcher):
54
"""Matches the rendering of an objects default view with a query limit.
56
This is a wrapper for HasQueryCount which does the heavy lifting on the
57
query comparison - BrowsesWithQueryLimit simply provides convenient
58
glue to use a userbrowser and view an object.
61
def __init__(self, query_limit, user, view_name="+index", **options):
62
"""Create a BrowsesWithQueryLimit checking for limit query_limit.
64
:param query_limit: The number of queries permited for the page.
65
:param user: The user to use to render the page.
66
:param view_name: The name of the view to use to render tha page.
67
:param options: Additional options for view generation eg rootsite.
69
self.query_limit = query_limit
71
self.view_name = view_name
72
self.options = options
74
def match(self, context):
75
# circular dependencies.
76
from canonical.launchpad.testing.pages import setupBrowserForUser
77
with person_logged_in(self.user):
78
context_url = canonical_url(
79
context, view_name=self.view_name, **self.options)
80
browser = setupBrowserForUser(self.user)
81
collector = QueryCollector()
84
browser.open(context_url)
85
counter = HasQueryCount(LessThan(self.query_limit))
86
# When bug 724691 is fixed, this can become an AnnotateMismatch to
87
# describe the object being rendered.
88
return counter.match(collector)
90
# Unregister now in case this method is called multiple
91
# times in a single test.
92
collector.unregister()
95
return "BrowsesWithQueryLimit(%s, %s)" % (self.query_limit, self.user)
98
class DoesNotProvide(Mismatch):
99
"""An object does not provide an interface."""
101
def __init__(self, obj, interface):
102
"""Create a DoesNotProvide Mismatch.
104
:param obj: the object that does not match.
105
:param interface: the Interface that the object was supposed to match.
108
self.interface = interface
111
return "%r does not provide %r." % (self.obj, self.interface)
114
class DoesNotCorrectlyProvide(DoesNotProvide):
115
"""An object does not correctly provide an interface."""
117
def __init__(self, obj, interface, extra=None):
118
"""Create a DoesNotCorrectlyProvide Mismatch.
120
:param obj: the object that does not match.
121
:param interface: the Interface that the object was supposed to match.
122
:param extra: any extra information about the mismatch as a string,
125
super(DoesNotCorrectlyProvide, self).__init__(obj, interface)
129
if self.extra is not None:
130
extra = ": %s" % self.extra
133
return ("%r claims to provide %r, but does not do so correctly%s"
134
% (self.obj, self.interface, extra))
137
class Provides(Matcher):
138
"""Test that an object provides a certain interface."""
140
def __init__(self, interface):
141
"""Create a Provides Matcher.
143
:param interface: the Interface that the object should provide.
145
self.interface = interface
148
return "provides %r." % self.interface
150
def match(self, matchee):
151
if not self.interface.providedBy(matchee):
152
return DoesNotProvide(matchee, self.interface)
156
if not verifyObject(self.interface, matchee):
158
except (BrokenImplementation, BrokenMethodImplementation,
159
DoesNotImplement), e:
163
return DoesNotCorrectlyProvide(
164
matchee, self.interface, extra=extra)
168
class HasQueryCount(Matcher):
169
"""Adapt a Binary Matcher to the query count on a QueryCollector.
171
If there is a mismatch, the queries from the collector are provided as a
175
def __init__(self, count_matcher):
176
"""Create a HasQueryCount that will match using count_matcher."""
177
self.count_matcher = count_matcher
180
return "HasQueryCount(%s)" % self.count_matcher
182
def match(self, something):
183
mismatch = self.count_matcher.match(something.count)
186
return _MismatchedQueryCount(mismatch, something)
189
class _MismatchedQueryCount(Mismatch):
190
"""The Mismatch for a HasQueryCount matcher."""
192
def __init__(self, mismatch, query_collector):
193
self.count_mismatch = mismatch
194
self.query_collector = query_collector
197
return "queries do not match: %s" % (self.count_mismatch.describe(),)
199
def get_details(self):
201
for query in self.query_collector.queries:
202
result.append(unicode(query).encode('utf8'))
203
return {'queries': Content(UTF8_TEXT, lambda: ['\n'.join(result)])}
206
class IsNotProxied(Mismatch):
207
"""An object is not proxied."""
209
def __init__(self, obj):
210
"""Create an IsNotProxied Mismatch.
212
:param obj: the object that is not proxied.
217
return "%r is not proxied." % self.obj
220
class IsProxied(Matcher):
221
"""Check that an object is proxied."""
226
def match(self, matchee):
227
if not builtin_isinstance(matchee, Proxy):
228
return IsNotProxied(matchee)
232
class ProvidesAndIsProxied(Matcher):
233
"""Test that an object implements an interface, and is proxied."""
235
def __init__(self, interface):
236
"""Create a ProvidesAndIsProxied matcher.
238
:param interface: the Interface the object must provide.
240
self.interface = interface
243
return "Provides %r and is proxied." % self.interface
245
def match(self, matchee):
246
mismatch = Provides(self.interface).match(matchee)
247
if mismatch is not None:
249
return IsProxied().match(matchee)
252
class DoesNotContain(Mismatch):
254
def __init__(self, matchee, expected):
255
"""Create a DoesNotContain Mismatch.
257
:param matchee: the string that did not match.
258
:param expected: the string that `matchee` was expected to contain.
260
self.matchee = matchee
261
self.expected = expected
264
return "'%s' does not contain '%s'." % (
265
self.matchee, self.expected)
268
class Contains(Matcher):
269
"""Checks whether one string contains another."""
271
def __init__(self, expected):
272
"""Create a Contains Matcher.
274
:param expected: the string that matchees should contain.
276
self.expected = expected
279
return "Contains '%s'." % self.expected
281
def match(self, matchee):
282
if self.expected not in matchee:
283
return DoesNotContain(matchee, self.expected)
287
class IsConfiguredBatchNavigator(Matcher):
288
"""Check that an object is a batch navigator."""
290
def __init__(self, singular, plural, batch_size=None):
291
"""Create a ConfiguredBatchNavigator.
293
:param singular: The singular header the batch should be using.
294
:param plural: The plural header the batch should be using.
295
:param batch_size: The batch size that should be configured by
298
self._single = Equals(singular)
299
self._plural = Equals(plural)
302
self._batch = Equals(batch_size)
303
self.matchers = dict(
304
_singular_heading=self._single, _plural_heading=self._plural)
306
self.matchers['default_size'] = self._batch
310
batch = ", %r" % self._batch.expected
313
return "ConfiguredBatchNavigator(%r, %r%s)" % (
314
self._single.expected, self._plural.expected, batch)
316
def match(self, matchee):
317
if not isinstance(matchee, BatchNavigator):
318
# Testtools doesn't have an IsInstanceMismatch yet.
319
return matchers._BinaryMismatch(
320
BatchNavigator, 'isinstance', matchee)
322
for attrname, matcher in self.matchers.items():
323
mismatch = matcher.match(getattr(matchee, attrname))
324
if mismatch is not None:
325
mismatches.append(mismatch)
327
return MismatchesAll(mismatches)
330
class WasSnapshotted(Mismatch):
332
def __init__(self, matchee, attribute):
333
self.matchee = matchee
334
self.attribute = attribute
337
return "Snapshot of %s should not include %s" % (
338
self.matchee, self.attribute)
341
class DoesNotSnapshot(Matcher):
342
"""Checks that certain fields are skipped on Snapshots."""
344
def __init__(self, attr_list, interface, error_msg=None):
345
self.attr_list = attr_list
346
self.interface = interface
347
self.error_msg = error_msg
350
return "Does not include %s when Snapshot is provided %s." % (
351
', '.join(self.attr_list), self.interface)
353
def match(self, matchee):
354
snapshot = Snapshot(matchee, providing=self.interface)
356
for attribute in self.attr_list:
357
if hasattr(snapshot, attribute):
358
mismatches.append(WasSnapshotted(matchee, attribute))
360
if len(mismatches) == 0:
363
return MismatchesAll(mismatches)
366
def DocTestMatches(example):
367
"""See if a string matches a doctest example.
369
Uses the default doctest flags used across Launchpad.
371
from canonical.launchpad.testing.systemdocs import default_optionflags
372
return OriginalDocTestMatches(example, default_optionflags)
375
class SoupMismatch(Mismatch):
377
def __init__(self, widget_id, soup_content):
378
self.widget_id = widget_id
379
self.soup_content = soup_content
381
def get_details(self):
382
return {'content': self.soup_content}
385
class MissingElement(SoupMismatch):
388
return 'No HTML element found with id %r' % self.widget_id
391
class MultipleElements(SoupMismatch):
394
return 'HTML id %r found multiple times in document' % self.widget_id
397
class MatchesTagText(Matcher):
398
"""Match against the extracted text of the tag."""
400
def __init__(self, soup_content, tag_id):
401
"""Construct the matcher with the soup content."""
402
self.soup_content = soup_content
406
return "matches widget %r text" % self.tag_id
408
def match(self, matchee):
409
# Here to avoid circular dependancies.
410
from canonical.launchpad.testing.pages import extract_text
411
widgets = self.soup_content.findAll(id=self.tag_id)
412
if len(widgets) == 0:
413
return MissingElement(self.tag_id, self.soup_content)
414
elif len(widgets) > 1:
415
return MultipleElements(self.tag_id, self.soup_content)
417
text_matcher = DocTestMatches(extract_text(widget))
418
return text_matcher.match(matchee)
421
class MatchesPickerText(Matcher):
422
"""Match against the text in a widget."""
424
def __init__(self, soup_content, widget_id):
425
"""Construct the matcher with the soup content."""
426
self.soup_content = soup_content
427
self.widget_id = widget_id
430
return "matches widget %r text" % self.widget_id
432
def match(self, matchee):
433
# Here to avoid circular dependancies.
434
from canonical.launchpad.testing.pages import extract_text
435
widgets = self.soup_content.findAll(id=self.widget_id)
436
if len(widgets) == 0:
437
return MissingElement(self.widget_id, self.soup_content)
438
elif len(widgets) > 1:
439
return MultipleElements(self.widget_id, self.soup_content)
441
text = widget.findAll(attrs={'class': 'yui3-activator-data-box'})[0]
442
text_matcher = DocTestMatches(extract_text(text))
443
return text_matcher.match(matchee)
446
class EqualsIgnoringWhitespace(Equals):
447
"""Compare equality, ignoring whitespace in strings.
449
Whitespace in strings is normalized before comparison. All other objects
450
are compared as they come.
453
def __init__(self, expected):
454
if isinstance(expected, (str, unicode)):
455
expected = normalize_whitespace(expected)
456
super(EqualsIgnoringWhitespace, self).__init__(expected)
458
def match(self, observed):
459
if isinstance(observed, (str, unicode)):
460
observed = normalize_whitespace(observed)
461
return super(EqualsIgnoringWhitespace, self).match(observed)