diff --git a/src/css_selectors/parse.py b/src/css_selectors/parse.py index ce4ee8e348..603c18aa51 100644 --- a/src/css_selectors/parse.py +++ b/src/css_selectors/parse.py @@ -14,7 +14,7 @@ import re import operator import string -from css_selectors.errors import SelectorSyntaxError +from css_selectors.errors import SelectorSyntaxError, ExpressionError if sys.version_info[0] < 3: _unicode = unicode @@ -159,6 +159,7 @@ class Function(object): self.selector = selector self.name = ascii_lower(name) self.arguments = arguments + self._parsed_arguments = None def __repr__(self): return '%s[%r:%s(%s)]' % ( @@ -168,6 +169,19 @@ class Function(object): def argument_types(self): return [token.type for token in self.arguments] + @property + def parsed_arguments(self): + if self._parsed_arguments is None: + try: + self._parsed_arguments = parse_series(self.arguments) + except ValueError: + raise ExpressionError("Invalid series: '%r'" % self.arguments) + return self._parsed_arguments + + def parse_arguments(self): + if not self.arguments_parsed: + self.arguments_parsed = True + def specificity(self): a, b, c = self.selector.specificity() b += 1 diff --git a/src/css_selectors/select.py b/src/css_selectors/select.py index 3da128fb67..2f60b5cef4 100644 --- a/src/css_selectors/select.py +++ b/src/css_selectors/select.py @@ -9,6 +9,7 @@ __copyright__ = '2015, Kovid Goyal ' import re, itertools from collections import OrderedDict, defaultdict from functools import wraps +from itertools import chain from lxml import etree @@ -90,13 +91,16 @@ class Select(object): Tags are returned in document order. Note that attribute and tag names are matched case-insensitively. Also namespaces are ignored (this is for - performance of the common case). + performance of the common case). The UI related selectors are not + implemented, such as :enabled, :diabled, :checked, :hover, etc. Similarly, + the non-element related selectors such as ::first-line, ::first-letter, + ::before, etc. are not implemented. WARNING: This class uses internal caches. You *must not* make any changes to the lxml tree. If you do make some changes, either create a new Select object or call :meth:`invalidate_caches`. - This class can be easily sub-classes to work with tree implementations + This class can be easily sub-classed to work with tree implementations other than lxml. Simply override the methods in the ``Tree Integration`` block. @@ -135,6 +139,11 @@ class Select(object): self._attrib_map = None self._attrib_space_map = None self._lang_map = None + self.map_tag_name = ascii_lower + if '{' in self.root.tag: + def map_tag_name(x): + return ascii_lower(x.rpartition('}')[2]) + self.map_tag_name = map_tag_name def __call__(self, selector): 'Return an iterator over all matching tags, in document order.' @@ -159,13 +168,8 @@ class Select(object): def element_map(self): if self._element_map is None: self._element_map = em = defaultdict(OrderedSet) - map_tag_name = ascii_lower - if '{' in self.root.tag: - def map_tag_name(x): - return ascii_lower(x.rpartition('}')[2]) - for tag in self.itertag(): - em[map_tag_name(tag.tag)].add(tag) + em[self.map_tag_name(tag.tag)].add(tag) return self._element_map @property @@ -251,6 +255,38 @@ class Select(object): def iterclasstags(self): return get_compiled_xpath('//*[@class]')(self.root) + + def sibling_count(self, child, before=True, same_type=False): + ' Return the number of siblings before or after child or raise ValueError if child has no parent. ' + parent = child.getparent() + if parent is None: + raise ValueError('Child has no parent') + if same_type: + siblings = OrderedSet(child.itersiblings(preceding=before)) + return len(self.element_map[self.map_tag_name(child.tag)] & siblings) + else: + if before: + return parent.index(child) + return len(parent) - parent.index(child) - 1 + + def all_sibling_count(self, child, same_type=False): + ' Return the number of siblings of child or raise ValueError if child has no parent ' + parent = child.getparent() + if parent is None: + raise ValueError('Child has no parent') + if same_type: + siblings = OrderedSet(chain(child.itersiblings(preceding=False), child.itersiblings(preceding=True))) + return len(self.element_map[self.map_tag_name(child.tag)] & siblings) + else: + return len(parent) - 1 + + def is_empty(self, elem): + for child in elem: + # Check for comment/PI nodes with tail text + if child.tail: + return False + return len(tuple(elem.iterchildren('*'))) == 0 and not elem.text + # }}} # Combinators {{{ @@ -324,6 +360,13 @@ def select_class(cache, selector): if elem in items: yield elem +def select_negation(cache, selector): + 'Implement :not()' + exclude = frozenset(cache.iterparsedselector(selector.subselector)) + for item in cache.iterparsedselector(selector.selector): + if item not in exclude: + yield item + # Attribute selectors {{{ def select_attrib(cache, selector): @@ -381,17 +424,24 @@ def select_substringmatch(cache, attrib, value): def select_function(cache, function): """Select with a functional pseudo-class.""" + fname = function.name.replace('-', '_') try: - func = cache.dispatch_map[function.name.replace('-', '_')] + func = cache.dispatch_map[fname] except KeyError: raise ExpressionError( "The pseudo-class :%s() is unknown" % function.name) - items = frozenset(func(cache, function)) - for item in cache.iterparsedselector(function.selector): - if item in items: - yield item + if fname == 'lang': + items = frozenset(func(cache, function)) + for item in cache.iterparsedselector(function.selector): + if item in items: + yield item + else: + for item in cache.iterparsedselector(function.selector): + if func(cache, function, item): + yield item def select_lang(cache, function): + ' Implement :lang() ' if function.argument_types() not in (['STRING'], ['IDENT']): raise ExpressionError("Expected a single string or ident for :lang(), got %r" % function.arguments) lang = function.arguments[0].value @@ -403,12 +453,118 @@ def select_lang(cache, function): for elem in elem_set: yield elem +def select_nth_child(cache, function, elem): + ' Implement :nth-child() ' + a, b = function.parsed_arguments + try: + num = cache.sibling_count(elem) + 1 + except ValueError: + return False + if a == 0: + return num == b + n = (num - b) / a + return n.is_integer() and n > -1 + +def select_nth_last_child(cache, function, elem): + ' Implement :nth-last-child() ' + a, b = function.parsed_arguments + try: + num = cache.sibling_count(elem, before=False) + 1 + except ValueError: + return False + if a == 0: + return num == b + n = (num - b) / a + return n.is_integer() and n > -1 + +def select_nth_of_type(cache, function, elem): + ' Implement :nth-of-type() ' + a, b = function.parsed_arguments + try: + num = cache.sibling_count(elem, same_type=True) + 1 + except ValueError: + return False + if a == 0: + return num == b + n = (num - b) / a + return n.is_integer() and n > -1 + +def select_nth_last_of_type(cache, function, elem): + ' Implement :nth-last-of-type() ' + a, b = function.parsed_arguments + try: + num = cache.sibling_count(elem, before=False, same_type=True) + 1 + except ValueError: + return False + if a == 0: + return num == b + n = (num - b) / a + return n.is_integer() and n > -1 + +# }}} + +# Pseudo elements {{{ + +def select_pseudo(cache, pseudo): + if pseudo.ident == 'root': + yield cache.root + return + + try: + func = cache.dispatch_map[pseudo.ident.replace('-', '_')] + except KeyError: + raise ExpressionError( + "The pseudo-class :%s is not supported" % pseudo.ident) + + for item in cache.iterparsedselector(pseudo.selector): + if func(cache, item): + yield item + +def select_first_child(cache, elem): + try: + return cache.sibling_count(elem) == 0 + except ValueError: + return False + +def select_last_child(cache, elem): + try: + return cache.sibling_count(elem, before=False) == 0 + except ValueError: + return False + +def select_only_child(cache, elem): + try: + return cache.all_sibling_count(elem) == 0 + except ValueError: + return False + +def select_first_of_type(cache, elem): + try: + return cache.sibling_count(elem, same_type=True) == 0 + except ValueError: + return False + +def select_last_of_type(cache, elem): + try: + return cache.sibling_count(elem, before=False, same_type=True) == 0 + except ValueError: + return False + +def select_only_of_type(cache, elem): + try: + return cache.all_sibling_count(elem, same_type=True) == 0 + except ValueError: + return False + +def select_empty(cache, elem): + return cache.is_empty(elem) + # }}} default_dispatch_map = {name.partition('_')[2]:obj for name, obj in globals().items() if name.startswith('select_') and callable(obj)} if __name__ == '__main__': from pprint import pprint - root = etree.fromstring('

') + root = etree.fromstring('

') select = Select(root, trace=True) - pprint(list(select('p a'))) + pprint(list(select('p *:root'))) diff --git a/src/css_selectors/tests.py b/src/css_selectors/tests.py index ad2d9ea05c..0a880d790f 100644 --- a/src/css_selectors/tests.py +++ b/src/css_selectors/tests.py @@ -6,14 +6,26 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' -import unittest, sys, argparse +import unittest, sys, argparse, json -from lxml import etree +from lxml import etree, html from css_selectors.errors import SelectorSyntaxError from css_selectors.parse import tokenize, parse from css_selectors.select import Select +def run_webkit_selector(page, selector): + return json.loads(page.mainFrame().evaluateJavaScript( + ''' + var nodes = document.querySelectorAll(%s); + var ans = []; + var i = 0; + for (var i = 0; i < nodes.length; i++) + ans.push(nodes[i].getAttribute("id")); + JSON.stringify(ans); + ''' % json.dumps(selector) + ) or '[]') + class TestCSSSelectors(unittest.TestCase): # Test data {{{ @@ -67,6 +79,318 @@ c"> cde"> ''' + HTML_SHAKESPEARE = ''' + + + + + + +
+
+

As You Like It

+
+by William Shakespeare +
+
+

ACT I, SCENE III. A room in the palace.

+
+
Enter CELIA and ROSALIND
+
+
CELIA
+
+
Why, cousin! why, Rosalind! Cupid have mercy! not a word?
+
+
ROSALIND
+
+
Not one to throw at a dog.
+
+
CELIA
+
+
No, thy words are too precious to be cast away upon
+
curs; throw some of them at me; come, lame me with reasons.
+
+
ROSALIND
+
CELIA
+
+
But is all this for your father?
+
+
+
Then there were two cousins laid up; when the one
+
should be lamed with reasons and the other mad
+
without any.
+
+
ROSALIND
+
+
No, some of it is for my child's father. O, how
+
full of briers is this working-day world!
+
+
CELIA
+
+
They are but burs, cousin, thrown upon thee in
+
holiday foolery: if we walk not in the trodden
+
paths our very petticoats will catch them.
+
+
ROSALIND
+
+
I could shake them off my coat: these burs are in my heart.
+
+
CELIA
+
+
Hem them away.
+
+
ROSALIND
+
+
I would try, if I could cry 'hem' and have him.
+
+
CELIA
+
+
Come, come, wrestle with thy affections.
+
+
ROSALIND
+
+
O, they take the part of a better wrestler than myself!
+
+
CELIA
+
+
O, a good wish upon you! you will try in time, in
+
despite of a fall. But, turning these jests out of
+
service, let us talk in good earnest: is it
+
possible, on such a sudden, you should fall into so
+
strong a liking with old Sir Rowland's youngest son?
+
+
ROSALIND
+
+
The duke my father loved his father dearly.
+
+
CELIA
+
+
Doth it therefore ensue that you should love his son
+
dearly? By this kind of chase, I should hate him,
+
for my father hated his father dearly; yet I hate
+
not Orlando.
+
+
ROSALIND
+
+
No, faith, hate him not, for my sake.
+
+
CELIA
+
+
Why should I not? doth he not deserve well?
+
+
ROSALIND
+
+
Let me love him for that, and do you love him
+
because I do. Look, here comes the duke.
+
+
CELIA
+
+
With his eyes full of anger.
+
Enter DUKE FREDERICK, with Lords
+
+
DUKE FREDERICK
+
+
Mistress, dispatch you with your safest haste
+
And get you from our court.
+
+
ROSALIND
+
+
Me, uncle?
+
+
DUKE FREDERICK
+
+
You, cousin
+
Within these ten days if that thou be'st found
+
So near our public court as twenty miles,
+
Thou diest for it.
+
+
ROSALIND
+
+
I do beseech your grace,
+
Let me the knowledge of my fault bear with me:
+
If with myself I hold intelligence
+
Or have acquaintance with mine own desires,
+
If that I do not dream or be not frantic,--
+
As I do trust I am not--then, dear uncle,
+
Never so much as in a thought unborn
+
Did I offend your highness.
+
+
DUKE FREDERICK
+
+
Thus do all traitors:
+
If their purgation did consist in words,
+
They are as innocent as grace itself:
+
Let it suffice thee that I trust thee not.
+
+
ROSALIND
+
+
Yet your mistrust cannot make me a traitor:
+
Tell me whereon the likelihood depends.
+
+
DUKE FREDERICK
+
+
Thou art thy father's daughter; there's enough.
+
+
ROSALIND
+
+
So was I when your highness took his dukedom;
+
So was I when your highness banish'd him:
+
Treason is not inherited, my lord;
+
Or, if we did derive it from our friends,
+
What's that to me? my father was no traitor:
+
Then, good my liege, mistake me not so much
+
To think my poverty is treacherous.
+
+
CELIA
+
+
Dear sovereign, hear me speak.
+
+
DUKE FREDERICK
+
+
Ay, Celia; we stay'd her for your sake,
+
Else had she with her father ranged along.
+
+
CELIA
+
+
I did not then entreat to have her stay;
+
It was your pleasure and your own remorse:
+
I was too young that time to value her;
+
But now I know her: if she be a traitor,
+
Why so am I; we still have slept together,
+
Rose at an instant, learn'd, play'd, eat together,
+
And wheresoever we went, like Juno's swans,
+
Still we went coupled and inseparable.
+
+
DUKE FREDERICK
+
+
She is too subtle for thee; and her smoothness,
+
Her very silence and her patience
+
Speak to the people, and they pity her.
+
Thou art a fool: she robs thee of thy name;
+
And thou wilt show more bright and seem more virtuous
+
When she is gone. Then open not thy lips:
+
Firm and irrevocable is my doom
+
Which I have pass'd upon her; she is banish'd.
+
+
CELIA
+
+
Pronounce that sentence then on me, my liege:
+
I cannot live out of her company.
+
+
DUKE FREDERICK
+
+
You are a fool. You, niece, provide yourself:
+
If you outstay the time, upon mine honour,
+
And in the greatness of my word, you die.
+
Exeunt DUKE FREDERICK and Lords
+
+
CELIA
+
+
O my poor Rosalind, whither wilt thou go?
+
Wilt thou change fathers? I will give thee mine.
+
I charge thee, be not thou more grieved than I am.
+
+
ROSALIND
+
+
I have more cause.
+
+
CELIA
+
+
Thou hast not, cousin;
+
Prithee be cheerful: know'st thou not, the duke
+
Hath banish'd me, his daughter?
+
+
ROSALIND
+
+
That he hath not.
+
+
CELIA
+
+
No, hath not? Rosalind lacks then the love
+
Which teacheth thee that thou and I am one:
+
Shall we be sunder'd? shall we part, sweet girl?
+
No: let my father seek another heir.
+
Therefore devise with me how we may fly,
+
Whither to go and what to bear with us;
+
And do not seek to take your change upon you,
+
To bear your griefs yourself and leave me out;
+
For, by this heaven, now at our sorrows pale,
+
Say what thou canst, I'll go along with thee.
+
+
ROSALIND
+
+
Why, whither shall we go?
+
+
CELIA
+
+
To seek my uncle in the forest of Arden.
+
+
ROSALIND
+
+
Alas, what danger will it be to us,
+
Maids as we are, to travel forth so far!
+
Beauty provoketh thieves sooner than gold.
+
+
CELIA
+
+
I'll put myself in poor and mean attire
+
And with a kind of umber smirch my face;
+
The like do you: so shall we pass along
+
And never stir assailants.
+
+
ROSALIND
+
+
Were it not better,
+
Because that I am more than common tall,
+
That I did suit me all points like a man?
+
A gallant curtle-axe upon my thigh,
+
A boar-spear in my hand; and--in my heart
+
Lie there what hidden woman's fear there will--
+
We'll have a swashing and a martial outside,
+
As many other mannish cowards have
+
That do outface it with their semblances.
+
+
CELIA
+
+
What shall I call thee when thou art a man?
+
+
ROSALIND
+
+
I'll have no worse a name than Jove's own page;
+
And therefore look you call me Ganymede.
+
But what will you be call'd?
+
+
CELIA
+
+
Something that hath a reference to my state
+
No longer Celia, but Aliena.
+
+
ROSALIND
+
+
But, cousin, what if we assay'd to steal
+
The clownish fool out of your father's court?
+
Would he not be a comfort to our travel?
+
+
CELIA
+
+
He'll go along o'er the wide world with me;
+
Leave me alone to woo him. Let's away,
+
And get our jewels and our wealth together,
+
Devise the fittest time and safest way
+
To hide us from pursuit that will be made
+
After my flight. Now go we in content
+
To liberty and not to banishment.
+
Exeunt
+
+
+
+
+ + +''' + + # }}} ae = unittest.TestCase.assertEqual @@ -337,18 +661,26 @@ cde"> document = etree.fromstring(self.HTML_IDS) select = Select(document) + from PyQt5.Qt import QApplication, QWebPage + app = QApplication([]) + w = QWebPage() + w.mainFrame().setHtml(self.HTML_IDS) + def select_ids(selector): for elem in select(selector): - yield elem.get('id') or 'nil' + yield elem.get('id') def pcss(main, *selectors, **kwargs): result = list(select_ids(main)) for selector in selectors: self.ae(list(select_ids(selector)), result) + if not kwargs.get('skip_webkit'): + wk = set(run_webkit_selector(w, main)) + self.ae(set(result), wk, 'WebKit did not match result for: %r. Result: %r WebKit: %r' % (main, set(result), wk)) return result all_ids = pcss('*') self.ae(all_ids[:6], [ - 'html', 'nil', 'link-href', 'link-nohref', 'nil', 'outer-div']) + 'html', None, 'link-href', 'link-nohref', None, 'outer-div']) self.ae(all_ids[-1:], ['foobar-span']) self.ae(pcss('div'), ['outer-div', 'li-div', 'foobar-div']) self.ae(pcss('DIV'), [ @@ -366,15 +698,120 @@ cde"> self.ae(pcss('a[href^=""]'), []) self.ae(pcss('a[href$="org"]'), ['nofollow-anchor']) self.ae(pcss('a[href$=""]'), []) - self.ae(pcss('div[foobar~="bc"]', 'div[foobar~="cde"]'), ['foobar-div']) + self.ae(pcss('div[foobar~="bc"]', 'div[foobar~="cde"]', skip_webkit=True), ['foobar-div']) self.ae(pcss('[foobar~="ab bc"]', '[foobar~=""]', '[foobar~=" \t"]'), []) self.ae(pcss('div[foobar~="cd"]'), []) self.ae(pcss('*[lang|="En"]', '[lang|="En-us"]'), ['second-li']) # Attribute values are case sensitive - self.ae(pcss('*[lang|="en"]', '[lang|="en-US"]'), []) + self.ae(pcss('*[lang|="en"]', '[lang|="en-US"]', skip_webkit=True), []) self.ae(pcss('*[lang|="e"]'), []) - self.ae(pcss(':lang("EN")', '*:lang(en-US)'), ['second-li', 'li-div']) + self.ae(pcss(':lang("EN")', '*:lang(en-US)', skip_webkit=True), ['second-li', 'li-div']) self.ae(pcss(':lang("e")'), []) + self.ae(pcss('li:nth-child(1)', 'li:first-child'), ['first-li']) + self.ae(pcss('li:nth-child(3)'), ['third-li']) + self.ae(pcss('li:nth-child(10)'), []) + self.ae(pcss('li:nth-child(2n)', 'li:nth-child(even)', 'li:nth-child(2n+0)'), ['second-li', 'fourth-li', 'sixth-li']) + self.ae(pcss('li:nth-child(+2n+1)', 'li:nth-child(odd)'), ['first-li', 'third-li', 'fifth-li', 'seventh-li']) + self.ae(pcss('li:nth-child(2n+4)'), ['fourth-li', 'sixth-li']) + self.ae(pcss('li:nth-child(3n+1)'), ['first-li', 'fourth-li', 'seventh-li']) + self.ae(pcss('li:nth-last-child(0)'), []) + self.ae(pcss('li:nth-last-child(1)', 'li:last-child'), ['seventh-li']) + self.ae(pcss('li:nth-last-child(2n)', 'li:nth-last-child(even)'), ['second-li', 'fourth-li', 'sixth-li']) + self.ae(pcss('li:nth-last-child(2n+2)'), ['second-li', 'fourth-li', 'sixth-li']) + self.ae(pcss('ol:first-of-type'), ['first-ol']) + self.ae(pcss('ol:nth-child(1)'), []) + self.ae(pcss('ol:nth-of-type(2)'), ['second-ol']) + self.ae(pcss('ol:nth-last-of-type(1)'), ['second-ol']) + self.ae(pcss('span:only-child'), ['foobar-span']) + self.ae(pcss('li div:only-child'), ['li-div']) + self.ae(pcss('div *:only-child'), ['li-div', 'foobar-span']) + self.ae(pcss('p *:only-of-type', skip_webkit=True), ['p-em', 'fieldset']) + self.ae(pcss('p:only-of-type', skip_webkit=True), ['paragraph']) + self.ae(pcss('a:empty', 'a:EMpty'), ['name-anchor']) + self.ae(pcss('li:empty'), ['third-li', 'fourth-li', 'fifth-li', 'sixth-li']) + self.ae(pcss(':root', 'html:root', 'li:root'), ['html']) + self.ae(pcss('* :root', 'p *:root'), []) + self.ae(pcss('.a', '.b', '*.a', 'ol.a'), ['first-ol']) + self.ae(pcss('.c', '*.c'), ['first-ol', 'third-li', 'fourth-li']) + self.ae(pcss('ol *.c', 'ol li.c', 'li ~ li.c', 'ol > li.c'), [ + 'third-li', 'fourth-li']) + self.ae(pcss('#first-li', 'li#first-li', '*#first-li'), ['first-li']) + self.ae(pcss('li div', 'li > div', 'div div'), ['li-div']) + self.ae(pcss('div > div'), []) + self.ae(pcss('div>.c', 'div > .c'), ['first-ol']) + self.ae(pcss('div + div'), ['foobar-div']) + self.ae(pcss('a ~ a'), ['tag-anchor', 'nofollow-anchor']) + self.ae(pcss('a[rel="tag"] ~ a'), ['nofollow-anchor']) + self.ae(pcss('ol#first-ol li:last-child'), ['seventh-li']) + self.ae(pcss('ol#first-ol *:last-child'), ['li-div', 'seventh-li']) + self.ae(pcss('#outer-div:first-child'), ['outer-div']) + self.ae(pcss('#outer-div :first-child'), [ + 'name-anchor', 'first-li', 'li-div', 'p-b', + 'checkbox-fieldset-disabled', 'area-href']) + self.ae(pcss('a[href]'), ['tag-anchor', 'nofollow-anchor']) + self.ae(pcss(':not(*)'), []) + self.ae(pcss('a:not([href])'), ['name-anchor']) + self.ae(pcss('ol :Not(li[class])', skip_webkit=True), [ + 'first-li', 'second-li', 'li-div', + 'fifth-li', 'sixth-li', 'seventh-li']) + self.ae(pcss(r'di\a0 v', r'div\['), []) + self.ae(pcss(r'[h\a0 ref]', r'[h\]ref]'), []) + + del app + + def test_select_shakespeare(self): + document = html.document_fromstring(self.HTML_SHAKESPEARE) + select = Select(document) + count = lambda s: sum(1 for r in select(s)) + + # Data borrowed from http://mootools.net/slickspeed/ + + # Changed from original; probably because I'm only + self.ae(count('*'), 249) + assert count('div:only-child') == 22 # ? + assert count('div:nth-child(even)') == 106 + assert count('div:nth-child(2n)') == 106 + assert count('div:nth-child(odd)') == 137 + assert count('div:nth-child(2n+1)') == 137 + assert count('div:nth-child(n)') == 243 + assert count('div:last-child') == 53 + assert count('div:first-child') == 51 + assert count('div > div') == 242 + assert count('div + div') == 190 + assert count('div ~ div') == 190 + assert count('body') == 1 + assert count('body div') == 243 + assert count('div') == 243 + assert count('div div') == 242 + assert count('div div div') == 241 + assert count('div, div, div') == 243 + assert count('div, a, span') == 243 + assert count('.dialog') == 51 + assert count('div.dialog') == 51 + assert count('div .dialog') == 51 + assert count('div.character, div.dialog') == 99 + assert count('div.direction.dialog') == 0 + assert count('div.dialog.direction') == 0 + assert count('div.dialog.scene') == 1 + assert count('div.scene.scene') == 1 + assert count('div.scene .scene') == 0 + assert count('div.direction .dialog ') == 0 + assert count('div .dialog .direction') == 4 + assert count('div.dialog .dialog .direction') == 4 + assert count('#speech5') == 1 + assert count('div#speech5') == 1 + assert count('div #speech5') == 1 + assert count('div.scene div.dialog') == 49 + assert count('div#scene1 div.dialog div') == 142 + assert count('#scene1 #speech1') == 1 + assert count('div[class]') == 103 + assert count('div[class=dialog]') == 50 + assert count('div[class^=dia]') == 51 + assert count('div[class$=log]') == 50 + assert count('div[class*=sce]') == 1 + assert count('div[class|=dialog]') == 50 # ? Seems right + assert count('div[class~=dialog]') == 51 # ? Seems right + # }}} # Run tests {{{