Skip to content
This repository was archived by the owner on Oct 1, 2024. It is now read-only.

Commit ed7cf54

Browse files
committed
ElementNode find() functionality and unit tests.
1 parent a404e65 commit ed7cf54

File tree

2 files changed

+264
-9
lines changed

2 files changed

+264
-9
lines changed

polyplug.py

+132-6
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def get_value(self):
161161
self.expect(self.QUOTES)
162162
while True:
163163
c = self.char
164-
if not (c.isalpha() or c.isdigit() or c in "_-."):
164+
if not (c.isalpha() or c.isdigit() or c in "_-. ;:!"):
165165
break
166166
result += self.get_char()
167167
self.expect(self.QUOTES)
@@ -296,10 +296,16 @@ def __init__(self, **kwargs):
296296

297297
@property
298298
def outerHTML(self):
299+
"""
300+
Get a string representation of the element's outer HTML.
301+
"""
299302
return NotImplemented
300303

301304
@property
302305
def as_dict(self):
306+
"""
307+
JSON serializable.
308+
"""
303309
return NotImplemented
304310

305311

@@ -356,7 +362,7 @@ def outerHTML(self):
356362
"""
357363
result = "<" + self.tagName
358364
for attr, val in self.attributes.items():
359-
result += " " + attr + "=\"" + val + "\""
365+
result += " " + attr + '="' + val + '"'
360366
result += ">"
361367
if self.tagName == "textarea":
362368
result += self.value
@@ -386,6 +392,9 @@ def innerHTML(self, raw):
386392

387393
@property
388394
def childNodes(self):
395+
"""
396+
A data structure representing the tree of child nodes.
397+
"""
389398
if self.tagName == "textarea":
390399
# The textarea doesn't have children. Only a text value.
391400
return []
@@ -411,6 +420,9 @@ def childNodes(self):
411420

412421
@property
413422
def as_dict(self):
423+
"""
424+
JSON serializable.
425+
"""
414426
result = {
415427
"nodeType": 1,
416428
"tagName": self.tagName,
@@ -422,6 +434,98 @@ def as_dict(self):
422434
result["value"] = self.value
423435
return result
424436

437+
def find(self, selector):
438+
"""
439+
Recursively check this node, and its children, and return a result
440+
representing nodes that match "selector" string. The selector
441+
string understands:
442+
443+
* .my-id - returns the first ElementNode with the id "my-id". Returns
444+
None if no match is found.
445+
* #my-class - returns a list containing elements with the class
446+
"my-class". Returns an empty list if no match is found.
447+
* li - returns a list containing elements with the tagName "li".
448+
Returns an empty list if no match is found.
449+
"""
450+
# Validate selector.
451+
if not selector:
452+
raise ValueError("Missing selector.")
453+
result = None
454+
if selector[0] == ".":
455+
# Select by unique id.
456+
target_id = selector[1:]
457+
if target_id:
458+
return self._find_by_id(target_id)
459+
else:
460+
raise ValueError("Invalid id.")
461+
elif selector[0] == "#":
462+
# Select by css class.
463+
target_class = selector[1:]
464+
if target_class:
465+
return self._find_by_class(target_class)
466+
else:
467+
raise ValueError("Invalid class.")
468+
else:
469+
# select by tagName.
470+
if selector.isalpha():
471+
return self._find_by_tagName(selector.lower())
472+
else:
473+
raise ValueError("Invalid tag name.")
474+
475+
def _find_by_id(self, target):
476+
"""
477+
Return this node, or the first of its children, if it has the id
478+
attribute of target. Returns None if no match is found.
479+
"""
480+
my_id = self.attributes.get("id")
481+
if my_id and my_id == target:
482+
return self
483+
else:
484+
for child in (
485+
node
486+
for node in self.childNodes
487+
if isinstance(node, ElementNode)
488+
):
489+
result = child._find_by_id(target)
490+
if result:
491+
return result
492+
return None
493+
494+
def _find_by_class(self, target):
495+
"""
496+
Return a list containing this node, or any of its children, if the
497+
node has the associated target class.
498+
"""
499+
result = []
500+
class_attr = self.attributes.get("class", "")
501+
if class_attr:
502+
classes = [
503+
class_name
504+
for class_name in class_attr.split(" ")
505+
if class_name
506+
]
507+
if target in classes:
508+
result.append(self)
509+
for child in (
510+
node for node in self.childNodes if isinstance(node, ElementNode)
511+
):
512+
result.extend(child._find_by_class(target))
513+
return result
514+
515+
def _find_by_tagName(self, target):
516+
"""
517+
Return a list containing this node, or any of its children, if the
518+
node has the associated target as its tagName (e.g. "div" or "p").
519+
"""
520+
result = []
521+
if self.tagName == target:
522+
result.append(self)
523+
for child in (
524+
node for node in self.childNodes if isinstance(node, ElementNode)
525+
):
526+
result.extend(child._find_by_tagName(target))
527+
return result
528+
425529

426530
class TextNode(Node):
427531
"""
@@ -441,6 +545,9 @@ def outerHTML(self):
441545

442546
@property
443547
def as_dict(self):
548+
"""
549+
JSON serializable.
550+
"""
444551
return {
445552
"nodeType": 3,
446553
"nodeName": "#text",
@@ -467,6 +574,9 @@ def outerHTML(self):
467574

468575
@property
469576
def as_dict(self):
577+
"""
578+
JSON serializable.
579+
"""
470580
return {
471581
"nodeType": 8,
472582
"nodeName": "#comment",
@@ -492,14 +602,30 @@ def outerHTML(self):
492602

493603
@property
494604
def as_dict(self):
605+
"""
606+
JSON serializable.
607+
"""
495608
return {"nodeType": 11, "childNodes": []}
496609

497610

498611
def plug(query, event_type):
499612
"""
500-
A decorator to plug a Python function into a DOM event specified by a
501-
query to match elements in the DOM tree, and an event_type (e.g. "click").
613+
A decorator wrapper to plug a Python function into a DOM event specified
614+
by a query to match elements in the DOM tree, and an event_type (e.g.
615+
"click").
502616
503-
The decorator must ... TODO: FINISH THIS
617+
Returns a decorator function that wraps the user's own function that is
618+
to be registered.
619+
620+
This decorator wrapper function creates a closure in which various
621+
contextual aspects are contained.
504622
"""
505-
pass
623+
624+
def decorator(fn):
625+
@wraps(fn)
626+
def wrapper(*args, **kwargs):
627+
fn(*args, **kwargs)
628+
629+
return wrapper
630+
631+
return decorator

tests/test_polyplug.py

+132-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import pytest
77
import polyplug
8+
from unittest import mock
89

910

1011
DOM_FROM_JSON = {
@@ -330,7 +331,7 @@ def test_element_node_get_outer_html():
330331
"""
331332
n = polyplug.ElementNode(tagName="div", attributes={"foo": "bar"})
332333
n.add_child(polyplug.TextNode(nodeValue="Hello"))
333-
assert n.outerHTML == "<div foo=\"bar\">Hello</div>"
334+
assert n.outerHTML == '<div foo="bar">Hello</div>'
334335

335336

336337
def test_element_node_get_inner_html_empty():
@@ -349,6 +350,7 @@ def test_element_node_get_set_inner_html_complex():
349350
n.innerHTML = "<!-- comment --><p>Hello</p>"
350351
assert n.innerHTML == "<!-- comment --><p>Hello</p>"
351352

353+
352354
def test_element_node_set_inner_html_empty():
353355
"""
354356
Set the innerHTML of the node as empty.
@@ -367,6 +369,133 @@ def test_element_node_set_inner_html_textarea():
367369
n.innerHTML = "<textarea>Test <fake html></textarea>"
368370
assert n.innerHTML == "<textarea>Test <fake html></textarea>"
369371

372+
373+
def test_element_node_find():
374+
"""
375+
The find method validates the selector.
376+
"""
377+
# Can't be empty.
378+
selector = ""
379+
n = polyplug.ElementNode(tagName="div")
380+
with pytest.raises(ValueError):
381+
n.find(selector)
382+
# Must be a valid id.
383+
selector = ".my-id" # valid
384+
n._find_by_id = mock.MagicMock()
385+
n.find(selector)
386+
n._find_by_id.assert_called_once_with("my-id")
387+
selector = "." # in-valid
388+
with pytest.raises(ValueError):
389+
n.find(selector)
390+
# Must be a valid class.
391+
selector = "#my-class" # valid
392+
n._find_by_class = mock.MagicMock()
393+
n.find(selector)
394+
n._find_by_class.assert_called_once_with("my-class")
395+
selector = "#" # in-valid
396+
with pytest.raises(ValueError):
397+
n.find(selector)
398+
# Must be a valid tagName.
399+
selector = "tagName" # valid
400+
n._find_by_tagName = mock.MagicMock()
401+
n.find(selector)
402+
n._find_by_tagName.assert_called_once_with("tagname") # lowercase!
403+
selector = "not a tag name because spaces etc" # in-valid
404+
with pytest.raises(ValueError):
405+
n.find(selector)
406+
407+
408+
def test_element_node_find_by_id():
409+
"""
410+
The expected individual nodes are returned for various combinations of
411+
searching the tree by unique id.
412+
"""
413+
# Will return itself as the first match.
414+
n = polyplug.ElementNode(tagName="div", attributes={"id": "foo"})
415+
assert n == n.find(".foo")
416+
# Will return the expected child.
417+
n = polyplug.ElementNode(tagName="div")
418+
n.innerHTML = "<ul><li>Nope</li><li id='foo'>Yup</li></ul>"
419+
result = n.find(".foo")
420+
assert isinstance(result, polyplug.ElementNode)
421+
assert result.innerHTML == "Yup"
422+
# Returns None if no match.
423+
assert n.find(".bar") is None
424+
425+
426+
def test_element_node_find_by_class():
427+
"""
428+
The expected collection of matching nodes are returned for various
429+
combinations of searching the tree by CSS class.
430+
"""
431+
# Returns itself if matched.
432+
n = polyplug.ElementNode(tagName="div", attributes={"class": "foo"})
433+
assert [
434+
n,
435+
] == n.find("#foo")
436+
# Returns expected children (along with itself).
437+
n = polyplug.ElementNode(tagName="div", attributes={"class": "foo"})
438+
n.innerHTML = "<ul><li class='foo'>Yup</li><li class='foo'>Yup</li></ul>"
439+
result = n.find("#foo")
440+
assert len(result) == 3
441+
assert result[0] == n
442+
assert result[1].tagName == "li"
443+
assert result[2].tagName == "li"
444+
# Returns just expected children (not itself).
445+
n = polyplug.ElementNode(tagName="div", attributes={"class": "bar"})
446+
n.innerHTML = "<ul><li class='foo'>Yup</li><li class='foo'>Yup</li></ul>"
447+
result = n.find("#foo")
448+
assert len(result) == 2
449+
assert result[0].tagName == "li"
450+
assert result[1].tagName == "li"
451+
# Returns just expected children with multiple classes.
452+
n = polyplug.ElementNode(tagName="div", attributes={"class": "bar foo"})
453+
n.innerHTML = (
454+
"<ul><li class='foo bar'>Yup</li><li class='foobar'>Nope</li></ul>"
455+
)
456+
result = n.find("#foo")
457+
assert len(result) == 2
458+
assert result[0] == n
459+
assert result[1].tagName == "li"
460+
# No match returns an empty list.
461+
n = polyplug.ElementNode(tagName="div", attributes={"class": "bar"})
462+
n.innerHTML = "<ul><li class='foo'>Nope</li><li class='foo'>Nope</li></ul>"
463+
result = n.find("#baz")
464+
assert result == []
465+
466+
467+
def test_element_node_find_by_tagName():
468+
"""
469+
The expected collection of matching nodes are returned for various
470+
combinations of searching the tree by tagName.
471+
"""
472+
# Returns itself if matched.
473+
n = polyplug.ElementNode(tagName="div")
474+
assert [
475+
n,
476+
] == n.find("div")
477+
# Returns expected children (along with itself).
478+
n = polyplug.ElementNode(tagName="li")
479+
n.innerHTML = "<ul><li>Yup</li><li>Yup</li></ul>"
480+
result = n.find("li")
481+
assert len(result) == 3
482+
assert result[0] == n
483+
assert result[1].innerHTML == "Yup"
484+
assert result[2].innerHTML == "Yup"
485+
# Returns just expected children (not itself).
486+
n = polyplug.ElementNode(tagName="div")
487+
n.innerHTML = "<ul><li>Yup</li><li>Yup</li></ul>"
488+
result = n.find("li")
489+
assert len(result) == 2
490+
assert result[0].innerHTML == "Yup"
491+
assert result[1].innerHTML == "Yup"
492+
# No match returns an empty list.
493+
n = polyplug.ElementNode(tagName="div")
494+
n.innerHTML = "<ul><li>Nope</li><li>Nope</li></ul>"
495+
result = n.find("p")
496+
assert result == []
497+
498+
370499
def test_text_node():
371500
"""
372501
The TextNode instantiates as expected.
@@ -526,9 +655,9 @@ def test_htmltokenizer_get_value():
526655
"""
527656
Given a potential value for an attribute, grab and return it.
528657
"""
529-
raw = "='foo'>"
658+
raw = "='font: arial; font-weight: bold!important;'>"
530659
tok = polyplug.HTMLTokenizer(raw)
531-
assert tok.get_value() == "foo"
660+
assert tok.get_value() == "font: arial; font-weight: bold!important;"
532661
assert tok.expect(">") is None
533662

534663

0 commit comments

Comments
 (0)