Skip to content

Commit 5972114

Browse files
committed
jsonread: add test coverage for filter-engine, contract and end-to-end coverage
- test_jq_engine: test current jq-imitating filter handling - test_filter_contract: black-box tests against evaluate_filter() - test_plugin: test parse_item and poll_device with mockup
1 parent 78bb571 commit 5972114

8 files changed

Lines changed: 388 additions & 0 deletions

File tree

jsonread/tests/__init__.py

Whitespace-only changes.

jsonread/tests/base.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import os
2+
3+
from plugins.jsonread import JSONREAD
4+
from tests.mock.core import MockSmartHome
5+
6+
7+
class JsonreadTestBase:
8+
"""
9+
Shared plugin-instantiation helper for jsonread tests.
10+
11+
SmartPlugin parameter loading isn't available in test environments, so
12+
get_parameter_value() would return None for everything — inject the
13+
plugin.yaml defaults directly as a class-level parameter dict instead,
14+
same pattern as plugins/database/tests/base.py.
15+
"""
16+
17+
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures')
18+
19+
def plugin(self, url=None, cycle=30):
20+
self.sh = MockSmartHome()
21+
JSONREAD._parameters = {'url': url or f'file://{self.FIXTURES_DIR}/fronius.json', 'cycle': cycle}
22+
return JSONREAD(self.sh)
23+
24+
def fixture_path(self, name):
25+
return os.path.join(self.FIXTURES_DIR, name)

jsonread/tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""
2+
Pytest configuration and fixtures for the jsonread plugin tests.
3+
4+
Patches lib.item to expose Item at the top-level namespace.
5+
6+
Background: lib/item/__init__.py only re-exports Items (the container),
7+
not Item (the individual item class). The shared SmartHomeNG test mock
8+
(tests/mock/core.py) still uses lib.item.Item, which causes an
9+
AttributeError on current shng code. Until that mock is fixed upstream,
10+
we patch it here so this test suite can run without a modified shng
11+
checkout. Same workaround as plugins/database/tests/conftest.py.
12+
"""
13+
14+
import lib.item
15+
import lib.item.item
16+
17+
if not hasattr(lib.item, 'Item'):
18+
lib.item.Item = lib.item.item.Item
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"Body": {
3+
"Data": [
4+
{
5+
"Current_AC_Phase_1": 0.455,
6+
"Current_AC_Phase_2": 2.34,
7+
"Current_AC_Phase_3": -0.433,
8+
"Current_AC_Sum": 2.362,
9+
"Details": {
10+
"Manufacturer": "Fronius",
11+
"Model": "Smart Meter TS 65A-3",
12+
"Serial": "2099316145"
13+
},
14+
"Enable": 1,
15+
"EnergyReactive_VArAC_Sum_Consumed": 128311.0,
16+
"EnergyReactive_VArAC_Sum_Produced": 4002094.0,
17+
"EnergyReal_WAC_Minus_Absolute": 6224438.0,
18+
"EnergyReal_WAC_Plus_Absolute": 654850.0,
19+
"EnergyReal_WAC_Sum_Consumed": 654850.0,
20+
"EnergyReal_WAC_Sum_Produced": 6224438.0,
21+
"Frequency_Phase_Average": 50.0,
22+
"Meter_Location_Current": 0.0,
23+
"PowerApparent_S_Phase_1": 57.4,
24+
"PowerApparent_S_Phase_2": 497.3,
25+
"PowerApparent_S_Phase_3": 92.5,
26+
"PowerApparent_S_Sum": 645.2,
27+
"PowerFactor_Phase_1": -0.464,
28+
"PowerFactor_Phase_2": 0.077,
29+
"PowerFactor_Phase_3": -0.896,
30+
"PowerFactor_Sum": 0.001,
31+
"PowerReactive_Q_Phase_1": 28.3,
32+
"PowerReactive_Q_Phase_2": -494.6,
33+
"PowerReactive_Q_Phase_3": -15.5,
34+
"PowerReactive_Q_Sum": -479.5,
35+
"PowerReal_P_Phase_1": 49.9,
36+
"PowerReal_P_Phase_2": 51.0,
37+
"PowerReal_P_Phase_3": -91.2,
38+
"PowerReal_P_Sum": 0.5,
39+
"TimeStamp": 1782510015,
40+
"Visible": 1,
41+
"Voltage_AC_PhaseToPhase_12": 407.8,
42+
"Voltage_AC_PhaseToPhase_23": 409.0,
43+
"Voltage_AC_PhaseToPhase_31": 408.4,
44+
"Voltage_AC_Phase_1": 235.1,
45+
"Voltage_AC_Phase_2": 235.8,
46+
"Voltage_AC_Phase_3": 236.5
47+
},
48+
{
49+
"Current_AC_Phase_1": 0.073,
50+
"Current_AC_Phase_2": 0.0,
51+
"Current_AC_Phase_3": 0.0,
52+
"Current_AC_Sum": 0.073
53+
}
54+
]
55+
}
56+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
fronius_smartmeter:
2+
current_phase_1:
3+
type: num
4+
jsonread_filter: .Body.Data["0"].Current_AC_Phase_1
5+
6+
manufacturer:
7+
type: str
8+
jsonread_filter: .Body.Data["0"].Details.Manufacturer
9+
10+
second_reading_current:
11+
type: num
12+
jsonread_filter: .Body.Data["1"].Current_AC_Phase_1
13+
14+
unmatched:
15+
type: str
16+
jsonread_filter: .Body.Data["0"].DoesNotExist
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Behavioral contract tests for JSONREAD.evaluate_filter().
4+
5+
Unlike test_jq_engine.py, these tests don't care *how* a filter string is
6+
turned into a value — they pin "given this exact jsonread_filter string
7+
(as it appears in a real items.yaml) and this exact JSON document, the
8+
item ends up with this exact value." That's the part of the plugin's
9+
behavior users actually depend on, and the part that must not regress.
10+
11+
Why this matters for a future jq -> jmespath (or any other engine) swap:
12+
when that happens, the *filter syntax* in items.yaml will necessarily
13+
change (jmespath doesn't speak jq's dialect) — but the JSON fixtures and
14+
expected output values below do not need to change at all, because
15+
they're describing the data, not the engine. The intended workflow for
16+
that migration:
17+
18+
1. Keep FRONIUS_FIXTURE and the expected-value assertions exactly as is.
19+
2. Replace only the left-hand filter-string literals with their
20+
jmespath equivalents.
21+
3. Re-run this file. Any genuine behavior regression (wrong value,
22+
wrong type, item not resolved) shows up here immediately, without
23+
needing to also re-derive "what should this have produced" from
24+
scratch.
25+
26+
If a translated expression can't be made to produce the same expected
27+
value, that's a real semantic gap between the two engines worth a
28+
deliberate decision — not something to discover via a user bug report,
29+
which is how the bug this branch fixes was originally found.
30+
"""
31+
32+
import json
33+
import os
34+
import unittest
35+
36+
from .base import JsonreadTestBase
37+
38+
FIXTURE_PATH = os.path.join(os.path.dirname(__file__), 'fixtures', 'fronius.json')
39+
40+
41+
class TestFilterContractAgainstFroniusFixture(JsonreadTestBase, unittest.TestCase):
42+
"""
43+
Filter strings below are copied verbatim from
44+
etc/items/fronius_smartmeter.yaml — this is the actual configuration
45+
a real user runs, not a simplified stand-in.
46+
"""
47+
48+
@classmethod
49+
def setUpClass(cls):
50+
with open(FIXTURE_PATH) as f:
51+
cls.data = json.load(f)
52+
53+
def setUp(self):
54+
self.plg = self.plugin()
55+
56+
def test_current_phase_1(self):
57+
self.assertEqual(self.plg.evaluate_filter('.Body.Data["0"].Current_AC_Phase_1', self.data), 0.455)
58+
59+
def test_current_sum(self):
60+
self.assertEqual(self.plg.evaluate_filter('.Body.Data["0"].Current_AC_Sum', self.data), 2.362)
61+
62+
def test_nested_details_manufacturer(self):
63+
self.assertEqual(self.plg.evaluate_filter('.Body.Data["0"].Details.Manufacturer', self.data), 'Fronius')
64+
65+
def test_nested_details_serial_is_a_string(self):
66+
# Serial looks numeric but is a JSON string in the source data —
67+
# a filter must not coerce it.
68+
value = self.plg.evaluate_filter('.Body.Data["0"].Details.Serial', self.data)
69+
self.assertEqual(value, '2099316145')
70+
self.assertIsInstance(value, str)
71+
72+
def test_voltage_phase_3(self):
73+
self.assertEqual(self.plg.evaluate_filter('.Body.Data["0"].Voltage_AC_Phase_3', self.data), 236.5)
74+
75+
def test_second_array_element(self):
76+
# Body.Data[1] only has a handful of fields populated in real
77+
# Fronius responses — this is the case that most clearly proves
78+
# array indexing (not just "first element") works.
79+
self.assertEqual(self.plg.evaluate_filter('.Body.Data["1"].Current_AC_Phase_1', self.data), 0.073)
80+
81+
def test_field_absent_on_second_element_resolves_to_none(self):
82+
# Body.Data[1] has no Details key at all in real responses.
83+
self.assertIsNone(self.plg.evaluate_filter('.Body.Data["1"].Details.Manufacturer', self.data))
84+
85+
def test_unknown_path_resolves_to_none_not_an_error(self):
86+
self.assertIsNone(self.plg.evaluate_filter('.Body.Data["0"].DoesNotExist', self.data))
87+
88+
89+
if __name__ == '__main__':
90+
unittest.main(verbosity=2)

jsonread/tests/test_jq_engine.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Tests for the jq-subset engine internals (jq_compile/_traverse/jq_step/
4+
jq_full/jq_condition/jq_unwrap) in plugins/jsonread/__init__.py.
5+
6+
These pin down the *current* hand-rolled implementation's specific
7+
mechanics (how a path token is split, how brackets are parsed, ...). They
8+
are expected to be thrown away or substantially rewritten if/when the
9+
engine is swapped for a real library (jmespath, pyjq, ...) — that's fine,
10+
that's what they're for. The behavior that needs to survive a swap lives
11+
in test_filter_contract.py instead; see that file's module docstring.
12+
"""
13+
14+
import unittest
15+
16+
from .base import JsonreadTestBase
17+
18+
19+
class _EngineBase(JsonreadTestBase, unittest.TestCase):
20+
def setUp(self):
21+
self.plg = self.plugin()
22+
23+
24+
class TestJqCompile(_EngineBase):
25+
def test_single_path_no_pipe(self):
26+
self.assertEqual(self.plg.jq_compile('.a.b'), ('.a.b',))
27+
28+
def test_splits_on_pipe(self):
29+
self.assertEqual(self.plg.jq_compile('.a | .b'), ('.a', '.b'))
30+
31+
def test_respects_parens_around_pipe(self):
32+
self.assertEqual(self.plg.jq_compile('(.a | .b)'), ('.a', '.b'))
33+
34+
def test_splits_select_from_following_path(self):
35+
compiled = self.plg.jq_compile('.items[] | select(.id==1).name')
36+
self.assertEqual(compiled, ('.items[]', 'select(.id==1)', '.name'))
37+
38+
39+
class TestTraversePlainKeys(_EngineBase):
40+
def test_resolves_nested_dict_path(self):
41+
data = {'a': {'b': 5}}
42+
self.assertEqual(self.plg._traverse(data, '.a.b'), [5])
43+
44+
def test_missing_key_returns_empty(self):
45+
data = {'a': {}}
46+
self.assertEqual(self.plg._traverse(data, '.a.b'), [])
47+
48+
def test_broadcasts_over_list_obj(self):
49+
data = [{'a': 1}, {'a': 2}]
50+
self.assertEqual(self.plg._traverse(data, '.a'), [1, 2])
51+
52+
53+
class TestTraverseBracketIndices(_EngineBase):
54+
"""
55+
Regression coverage for the bug fixed in this branch: .Data["0"] and
56+
.Data[0] were being treated as one literal (and always-missing) key
57+
name, so every filter using array indexing silently resolved to
58+
nothing.
59+
"""
60+
61+
def setUp(self):
62+
super().setUp()
63+
self.data = {'Data': [{'x': 'first'}, {'x': 'second'}]}
64+
65+
def test_quoted_string_index(self):
66+
self.assertEqual(self.plg._traverse(self.data, '.Data["0"].x'), ['first'])
67+
68+
def test_unquoted_numeric_index(self):
69+
self.assertEqual(self.plg._traverse(self.data, '.Data[0].x'), ['first'])
70+
71+
def test_second_element_index(self):
72+
self.assertEqual(self.plg._traverse(self.data, '.Data["1"].x'), ['second'])
73+
74+
def test_out_of_range_index_returns_empty(self):
75+
self.assertEqual(self.plg._traverse(self.data, '.Data["5"].x'), [])
76+
77+
def test_empty_brackets_still_flattens(self):
78+
self.assertEqual(sorted(self.plg._traverse(self.data, '.Data[].x')), ['first', 'second'])
79+
80+
81+
class TestJqCondition(_EngineBase):
82+
def test_equals_numeric(self):
83+
self.assertTrue(self.plg.jq_condition('.id==1', {'id': 1}))
84+
85+
def test_equals_string(self):
86+
self.assertTrue(self.plg.jq_condition('.name=="a"', {'name': 'a'}))
87+
88+
def test_not_equal(self):
89+
self.assertTrue(self.plg.jq_condition('.id!=2', {'id': 1}))
90+
91+
def test_greater_than(self):
92+
self.assertTrue(self.plg.jq_condition('.id>1', {'id': 2}))
93+
self.assertFalse(self.plg.jq_condition('.id>1', {'id': 1}))
94+
95+
96+
class TestJqFullWithSelect(_EngineBase):
97+
def test_select_filters_list_then_extracts_field(self):
98+
data = {'items': [{'id': 1, 'name': 'a'}, {'id': 2, 'name': 'b'}]}
99+
compiled = self.plg.jq_compile('.items[] | select(.id==2).name')
100+
self.assertEqual(self.plg.jq_full(compiled, data), ['b'])
101+
102+
103+
class TestJqUnwrap(_EngineBase):
104+
def test_empty_list_becomes_none(self):
105+
self.assertIsNone(self.plg.jq_unwrap([]))
106+
107+
def test_single_item_list_unwraps(self):
108+
self.assertEqual(self.plg.jq_unwrap([42]), 42)
109+
110+
def test_multi_item_list_stays_a_list(self):
111+
self.assertEqual(self.plg.jq_unwrap([1, 2]), [1, 2])
112+
113+
114+
if __name__ == '__main__':
115+
unittest.main(verbosity=2)

jsonread/tests/test_plugin.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env python3
2+
"""
3+
End-to-end test for jsonread: parse_item() registers an item with a
4+
jsonread_filter attribute, poll_device() fetches the configured URL (here
5+
a file:// URL onto a fixture, exercising the plugin's real FileAdapter
6+
wiring) and writes the resolved value onto the item itself.
7+
8+
This is the layer the bug fixed in this branch actually broke: jq engine
9+
unit tests in isolation wouldn't have caught a wiring problem between
10+
parse_item/poll_device and the item, and the engine tests alone don't
11+
prove the plugin ever calls item(value) with the right value for a real
12+
item.conf attribute.
13+
"""
14+
15+
import os
16+
import unittest
17+
18+
import tests.common as common
19+
from tests.mock.core import MockSmartHome
20+
21+
from .base import JsonreadTestBase
22+
23+
common.register_shng_log_levels()
24+
25+
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures')
26+
27+
28+
class TestPluginEndToEnd(JsonreadTestBase, unittest.TestCase):
29+
def setUp(self):
30+
self.sh = MockSmartHome()
31+
self.sh.with_items_from(os.path.join(FIXTURES_DIR, 'test_items.yaml'))
32+
from plugins.jsonread import JSONREAD
33+
34+
JSONREAD._parameters = {'url': f'file://{FIXTURES_DIR}/fronius.json', 'cycle': 30}
35+
self.plg = JSONREAD(self.sh)
36+
for item in self.sh.return_items():
37+
self.plg.parse_item(item)
38+
39+
def test_poll_device_fills_simple_numeric_item(self):
40+
self.plg.poll_device()
41+
42+
item = self.sh.return_item('fronius_smartmeter.current_phase_1')
43+
self.assertEqual(item(), 0.455)
44+
45+
def test_poll_device_fills_nested_string_item(self):
46+
self.plg.poll_device()
47+
48+
item = self.sh.return_item('fronius_smartmeter.manufacturer')
49+
self.assertEqual(item(), 'Fronius')
50+
51+
def test_poll_device_fills_second_array_element(self):
52+
self.plg.poll_device()
53+
54+
item = self.sh.return_item('fronius_smartmeter.second_reading_current')
55+
self.assertEqual(item(), 0.073)
56+
57+
def test_poll_device_leaves_unmatched_filter_at_default(self):
58+
self.plg.poll_device()
59+
60+
item = self.sh.return_item('fronius_smartmeter.unmatched')
61+
# str-type default is '' — item(None) on a str item casts via
62+
# cast_str, confirming "no match" doesn't raise and doesn't
63+
# silently leave stale data from a previous poll either.
64+
self.assertEqual(item(), '')
65+
66+
67+
if __name__ == '__main__':
68+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)