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

Commit e4593cb

Browse files
committed
Initial work with tests for message handling in Python.
1 parent ed7cf54 commit e4593cb

File tree

3 files changed

+326
-7
lines changed

3 files changed

+326
-7
lines changed

SpecRunner.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<body>
2222
<div style="display: none;">
2323
<p id="testParaID">This is a test paragraph, with an id.</p>
24-
<p class="testParaClass">This is a test paragrapch, with a class.</p>
24+
<p class="testParaClass">This is a test paragraph, with a class.</p>
2525
<button id="testButton">Click Me</button>
2626
<button id="testButton2">Click Me Again</button>
2727
<div id="testMutate"></div>

polyplug.py

+111-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import binascii
2+
import builtins
3+
import hashlib
14
import json
25

36

4-
def output(content):
5-
print(json.dumps({"type": "stdout", "content": content}))
7+
# Registered event listeners.
8+
LISTENERS = {}
69

710

811
class Query:
@@ -525,7 +528,7 @@ def _find_by_tagName(self, target):
525528
):
526529
result.extend(child._find_by_tagName(target))
527530
return result
528-
531+
529532

530533
class TextNode(Node):
531534
"""
@@ -608,6 +611,62 @@ def as_dict(self):
608611
return {"nodeType": 11, "childNodes": []}
609612

610613

614+
def get_listener_id(query, event_type, listener):
615+
"""
616+
Given a query, event type and listener function, generate a unique id from
617+
this combination.
618+
"""
619+
raw = str(query.as_dict) + event_type + listener.__name__
620+
return binascii.hexlify(
621+
hashlib.sha256(raw.encode("utf-8")).digest()
622+
).decode("ascii")
623+
624+
625+
def print(*args, **kwargs):
626+
"""
627+
Overridden print so output is handled correctly via JSON message passing
628+
instead of just raw text.
629+
"""
630+
sep = kwargs.get("sep", " ")
631+
end = kwargs.get("end", "\n")
632+
content = sep.join(args) + end
633+
builtins.print(json.dumps({"type": "stdout", "content": content}))
634+
635+
636+
def update(query, target):
637+
"""
638+
Update the DOM so the node[s] matching the query are mutated to the state
639+
defined by the target node.
640+
"""
641+
builtins.print(
642+
json.dumps(
643+
{
644+
"type": "updateDOM",
645+
"query": query.as_dict,
646+
"target": target.as_dict,
647+
}
648+
)
649+
)
650+
651+
652+
def remove(query, event_type, listener):
653+
"""
654+
Remove the referenced listener from handling the event_type from the
655+
node[s] matching the query.
656+
"""
657+
listener_id = get_listener_id(query, event_type, listener)
658+
del LISTENERS[listener_id]
659+
builtins.print(
660+
json.dumps(
661+
{
662+
"type": "removeEvent",
663+
"query": query.as_dict,
664+
"eventType": event_type,
665+
}
666+
)
667+
)
668+
669+
611670
def plug(query, event_type):
612671
"""
613672
A decorator wrapper to plug a Python function into a DOM event specified
@@ -622,10 +681,56 @@ def plug(query, event_type):
622681
"""
623682

624683
def decorator(fn):
625-
@wraps(fn)
626-
def wrapper(*args, **kwargs):
627-
fn(*args, **kwargs)
684+
"""
685+
Register the function via the query and event_type.
686+
"""
687+
688+
def wrapper(event):
689+
return fn(event)
690+
691+
listener_id = get_listener_id(query, event_type, wrapper)
692+
builtins.print(
693+
json.dumps(
694+
{
695+
"type": "registerEvent",
696+
"query": query.as_dict,
697+
"eventType": event_type,
698+
"listener": listener_id,
699+
}
700+
)
701+
)
628702

703+
LISTENERS[listener_id] = wrapper
629704
return wrapper
630705

631706
return decorator
707+
708+
709+
def receive(raw):
710+
"""
711+
Given a raw JSON message, decode it, find the expected handler function,
712+
re-constitute the DOM, and call the handler with the appropriate context.
713+
"""
714+
try:
715+
msg = json.loads(raw)
716+
event_type = msg.get("type")
717+
target = msg.get("target")
718+
listener = msg.get("listener")
719+
if event_type and target and listener:
720+
if listener in LISTENERS:
721+
event = DomEvent(event_type, ElementNode(**target))
722+
LISTENERS[listener](event)
723+
else:
724+
raise RuntimeError("No such listener: " + listener)
725+
else:
726+
raise ValueError("Incomplete message received: " + raw)
727+
except Exception as ex:
728+
context = {"type": type(ex).__name__, "msg": str(ex)}
729+
builtins.print(
730+
json.dumps(
731+
{
732+
"type": "error",
733+
"context": context,
734+
}
735+
)
736+
)

tests/test_polyplug.py

+214
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
"""
22
Exercise PolyPlug.
33
"""
4+
import builtins
45
import copy
56
import json
67
import pytest
78
import polyplug
89
from unittest import mock
910

1011

12+
@pytest.fixture(autouse=True)
13+
def test_wrapper():
14+
"""
15+
Ensures clean state.
16+
"""
17+
# Clear the listeners.
18+
polyplug.LISTENERS = {}
19+
# Run the test.
20+
yield
21+
# ???
22+
# Profit!
23+
24+
1125
DOM_FROM_JSON = {
1226
"nodeType": 1,
1327
"tagName": "div",
@@ -892,3 +906,203 @@ def test_htmltokenizer_tokenize_complex_tree():
892906
tok = polyplug.HTMLTokenizer(raw)
893907
tok.tokenize(parent)
894908
assert parent.as_dict == expected
909+
910+
911+
def test_get_listener_id():
912+
"""
913+
Return a string containing a hex representation of a sha256 hash of the
914+
passed in Query, event type and listener function.
915+
"""
916+
q = polyplug.Query(id="foo")
917+
event_type = "click"
918+
919+
def test_fn():
920+
pass
921+
922+
id_1 = polyplug.get_listener_id(q, event_type, test_fn)
923+
id_2 = polyplug.get_listener_id(q, event_type, test_fn)
924+
assert id_1 == id_2 # These should be the same..!
925+
926+
927+
def test_print():
928+
"""
929+
The polyplug print function emits the expected JSON message.
930+
"""
931+
with mock.patch("builtins.print") as mock_print:
932+
# Simple case with defaults.
933+
polyplug.print("Hello", "world")
934+
mock_print.assert_called_once_with(
935+
'{"type": "stdout", "content": "Hello world\\n"}'
936+
)
937+
mock_print.reset_mock()
938+
# More complex with sep and end
939+
polyplug.print("Hello", "world", sep="-", end="")
940+
mock_print.assert_called_once_with(
941+
'{"type": "stdout", "content": "Hello-world"}'
942+
)
943+
944+
945+
def test_update():
946+
"""
947+
Given a query object and a representation of a target node, the expected
948+
updateDOM message is emitted, with the correct payload.
949+
"""
950+
query = polyplug.Query(id="foo")
951+
raw_dom = copy.deepcopy(DOM_FROM_JSON)
952+
target = polyplug.ElementNode(**raw_dom)
953+
with mock.patch("builtins.print") as mock_print:
954+
polyplug.update(query, target)
955+
assert mock_print.call_count == 1
956+
msg = json.loads(mock_print.call_args.args[0])
957+
assert msg["type"] == "updateDOM"
958+
assert msg["query"]["id"] == "foo"
959+
assert msg["target"] == DOM_FROM_JSON
960+
961+
962+
def test_remove():
963+
""" """
964+
with mock.patch("builtins.print") as mock_print:
965+
966+
@polyplug.plug(polyplug.Query(id="foo"), "some-event")
967+
def test_fn(event):
968+
return "It works!"
969+
970+
assert mock_print.call_count == 1
971+
listener_id = polyplug.get_listener_id(
972+
polyplug.Query(id="foo"), "some-event", test_fn
973+
)
974+
assert listener_id in polyplug.LISTENERS
975+
mock_print.reset_mock()
976+
polyplug.remove(polyplug.Query(id="foo"), "some-event", test_fn)
977+
assert mock_print.call_count == 1
978+
msg = json.loads(mock_print.call_args.args[0])
979+
assert msg["type"] == "removeEvent"
980+
assert msg["query"]["id"] == "foo"
981+
assert msg["eventType"] == "some-event"
982+
assert listener_id not in polyplug.LISTENERS
983+
984+
985+
def test_plug_decorator_register():
986+
"""
987+
Ensure the expected register JSON message is emitted when the decorator is
988+
used on a user's function.
989+
"""
990+
with mock.patch("builtins.print") as mock_print:
991+
992+
@polyplug.plug(polyplug.Query(id="foo"), "some-event")
993+
def test_fn(event):
994+
return "It works!"
995+
996+
assert mock_print.call_count == 1
997+
msg = json.loads(mock_print.call_args.args[0])
998+
assert msg["type"] == "registerEvent"
999+
assert msg["listener"] == polyplug.get_listener_id(
1000+
polyplug.Query(id="foo"), "some-event", test_fn
1001+
)
1002+
assert msg["query"]["id"] == "foo"
1003+
assert msg["eventType"] == "some-event"
1004+
result = polyplug.LISTENERS[msg["listener"]](None)
1005+
assert result == "It works!"
1006+
1007+
1008+
def test_receive_bad_json():
1009+
"""
1010+
If the receive function get a non-JSON message, it complains with an error
1011+
message of its own.
1012+
"""
1013+
with mock.patch("builtins.print") as mock_print:
1014+
polyplug.receive("not VALID")
1015+
assert mock_print.call_count == 1
1016+
msg = json.loads(mock_print.call_args.args[0])
1017+
assert msg["type"] == "error"
1018+
assert msg["context"]["type"] == "JSONDecodeError"
1019+
assert (
1020+
msg["context"]["msg"]
1021+
== "Expecting value: line 1 column 1 (char 0)"
1022+
)
1023+
1024+
1025+
def test_receive_incomplete_message():
1026+
"""
1027+
If the receive function gets valid JSON that is the wrong "shape", it
1028+
complains with a message of its own.
1029+
"""
1030+
with mock.patch("builtins.print") as mock_print:
1031+
polyplug.receive(json.dumps({"foo": "bar"}))
1032+
assert mock_print.call_count == 1
1033+
msg = json.loads(mock_print.call_args.args[0])
1034+
assert msg["type"] == "error"
1035+
assert msg["context"]["type"] == "ValueError"
1036+
assert (
1037+
msg["context"]["msg"]
1038+
== 'Incomplete message received: {"foo": "bar"}'
1039+
)
1040+
1041+
1042+
def test_receive_no_listener():
1043+
"""
1044+
If the receive function gets a valid message but the referenced listener
1045+
function doesn't exist, it complains with a message of its own.
1046+
"""
1047+
with mock.patch("builtins.print") as mock_print:
1048+
polyplug.receive(
1049+
json.dumps(
1050+
{
1051+
"type": "some-event",
1052+
"target": DOM_FROM_JSON,
1053+
"listener": "does_not_exist",
1054+
}
1055+
)
1056+
)
1057+
assert mock_print.call_count == 1
1058+
msg = json.loads(mock_print.call_args.args[0])
1059+
assert msg["type"] == "error"
1060+
assert msg["context"]["type"] == "RuntimeError"
1061+
assert msg["context"]["msg"] == "No such listener: does_not_exist"
1062+
1063+
1064+
def test_receive_for_registered_listener():
1065+
"""
1066+
If the receive function gets a valid message for an existing event,
1067+
the function is called with the expected DomEvent object.
1068+
"""
1069+
with mock.patch("builtins.print") as mock_print:
1070+
# To be called when there's user defined work to be done.
1071+
mock_work = mock.MagicMock()
1072+
1073+
@polyplug.plug(polyplug.Query(id="foo"), "some-event")
1074+
def test_fn(event):
1075+
"""
1076+
Do some work as if an end user.
1077+
"""
1078+
# Expected eventType.
1079+
if event.event_type != "some-event":
1080+
raise ValueError("It broke! Wrong event.")
1081+
# The target represents the expected element.
1082+
if event.target.tagName != "div":
1083+
raise ValueError("It broke! Wrong target root.")
1084+
# It's possible to find one of the expected child nodes.
1085+
ul = event.target.find(".list")
1086+
if ul.tagName != "ul":
1087+
raise ValueError("It broke! Wrong child nodes.")
1088+
# Signal things worked out. ;-)
1089+
mock_work("It works!")
1090+
1091+
assert mock_print.call_count == 1
1092+
mock_print.reset_mock()
1093+
1094+
listener_id = polyplug.get_listener_id(
1095+
polyplug.Query(id="foo"), "some-event", test_fn
1096+
)
1097+
1098+
polyplug.receive(
1099+
json.dumps(
1100+
{
1101+
"type": "some-event",
1102+
"target": DOM_FROM_JSON,
1103+
"listener": listener_id,
1104+
}
1105+
)
1106+
)
1107+
1108+
mock_work.assert_called_once_with("It works!")

0 commit comments

Comments
 (0)