Skip to content

Commit f2776b4

Browse files
committed
Fix test
1 parent eaeee9e commit f2776b4

2 files changed

Lines changed: 118 additions & 80 deletions

File tree

features/steps/generic_steps.py

Lines changed: 12 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,6 @@ def check_file_exists(path):
8484
assert os.path.isfile(path), f"Expected {path} to exist, but it didn't!"
8585

8686

87-
def check_json(path: Union[str, os.PathLike], content: str) -> None:
88-
"""Check a JSON file."""
89-
90-
with open(path, "r", encoding="UTF-8") as file_to_check:
91-
actual_json = json.load(file_to_check)
92-
expected_json = json.loads(content)
93-
94-
check_content(
95-
json.dumps(expected_json, indent=4, sort_keys=True).splitlines(),
96-
json.dumps(actual_json, indent=4, sort_keys=True).splitlines(),
97-
)
98-
99-
10087
def apply_archive_substitutions(text: str, context) -> str:
10188
"""Replace archive-related dynamic placeholders with values stored on *context*."""
10289
if hasattr(context, "archive_sha256"):
@@ -110,67 +97,6 @@ def apply_archive_substitutions(text: str, context) -> str:
11097
return text
11198

11299

113-
def _json_subset_matches(expected, actual) -> bool:
114-
"""Return *True* when *expected* is a subset of *actual* (recursive).
115-
116-
**List matching is greedy and order-sensitive.** Each item in *expected*
117-
is matched against *actual* in order, claiming the first unused actual
118-
item that satisfies the subset check. This means an earlier expected
119-
item can consume the only actual item that a later, more specific
120-
expected item would need. For example, with::
121-
122-
expected = [{"a": 1}, {"a": 1, "b": 2}]
123-
actual = [{"a": 1, "b": 2}]
124-
125-
the first expected item matches ``{"a": 1, "b": 2}`` (leaving nothing
126-
for the second), so the overall match returns *False* even though
127-
``{"a": 1, "b": 2}`` satisfies the second item. Consumers should
128-
**not** rely on non-deterministic matching; instead, pre-order *expected*
129-
lists from most-specific to least-specific to avoid this behaviour.
130-
"""
131-
if isinstance(expected, dict):
132-
if not isinstance(actual, dict):
133-
return False
134-
return all(
135-
k in actual and _json_subset_matches(v, actual[k])
136-
for k, v in expected.items()
137-
)
138-
if isinstance(expected, list):
139-
if not isinstance(actual, list):
140-
return False
141-
matched = [False] * len(actual)
142-
for exp_item in expected:
143-
found = False
144-
for i, act_item in enumerate(actual):
145-
if not matched[i] and _json_subset_matches(exp_item, act_item):
146-
matched[i] = True
147-
found = True
148-
break
149-
if not found:
150-
return False
151-
return True
152-
return expected == actual
153-
154-
155-
def check_json_subset(path: Union[str, os.PathLike], content: str, context) -> None:
156-
"""Assert that a JSON file *contains* the given key-values (subset match).
157-
158-
Dynamic placeholders (``<archive-sha256>``, ``<archive-url>``) in
159-
*content* are substituted with values from *context* before parsing.
160-
"""
161-
content = apply_archive_substitutions(content, context)
162-
163-
with open(path, "r", encoding="UTF-8") as file_to_check:
164-
actual_json = json.load(file_to_check)
165-
expected_json = json.loads(content)
166-
167-
assert _json_subset_matches(expected_json, actual_json), (
168-
f"JSON subset mismatch.\n"
169-
f"Expected subset:\n{json.dumps(expected_json, indent=4, sort_keys=True)}\n"
170-
f"Actual:\n{json.dumps(actual_json, indent=4, sort_keys=True)}"
171-
)
172-
173-
174100
def check_content(
175101
expected_content: Iterable[str], actual_content: Iterable[str], strict=False
176102
) -> None:
@@ -405,6 +331,18 @@ def step_impl(context, name):
405331
check_file_exists(name)
406332

407333

334+
def check_json(path: Union[str, os.PathLike], content: str) -> None:
335+
"""Check a JSON file for exact equality (after normalising formatting)."""
336+
with open(path, "r", encoding="UTF-8") as file_to_check:
337+
actual_json = json.load(file_to_check)
338+
expected_json = json.loads(content)
339+
340+
check_content(
341+
json.dumps(expected_json, indent=4, sort_keys=True).splitlines(),
342+
json.dumps(actual_json, indent=4, sort_keys=True).splitlines(),
343+
)
344+
345+
408346
@then("the '{name}' file contains")
409347
def step_impl(context, name):
410348
if name.endswith(".json"):
@@ -418,12 +356,6 @@ def step_impl(_, name):
418356
assert os.path.exists(name), f"Expected {name} to exist, but it didn't!"
419357

420358

421-
@then("the '{name}' json file includes")
422-
def step_impl(context, name):
423-
"""Partial JSON match - the expected JSON must be a *subset* of the actual file."""
424-
check_json_subset(name, context.text, context)
425-
426-
427359
def multisub(patterns: List[Tuple[Pattern[str], str]], text: str) -> str:
428360
"""Apply a list of tuples that each contain a regex + replace string."""
429361
for pattern, replace in patterns:

features/steps/json_steps.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Steps and helpers for JSON-based feature tests."""
2+
3+
# pylint: disable=function-redefined, missing-function-docstring, import-error, not-callable
4+
# pyright: reportRedeclaration=false, reportAttributeAccessIssue=false, reportCallIssue=false
5+
6+
import json
7+
import os
8+
import re
9+
from typing import Union
10+
11+
from behave import then # pylint: disable=no-name-in-module
12+
13+
from features.steps.generic_steps import apply_archive_substitutions
14+
15+
_iso_timestamp_value = re.compile(
16+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\+\d{2}:\d{2}$"
17+
)
18+
_urn_uuid_value = re.compile(
19+
r"^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
20+
)
21+
22+
23+
def _normalise_json(obj):
24+
"""Replace dynamic scalar values (timestamps, UUIDs) with stable placeholders."""
25+
if isinstance(obj, str):
26+
if _iso_timestamp_value.match(obj):
27+
return "[timestamp]"
28+
if _urn_uuid_value.match(obj):
29+
return "[urn-uuid]"
30+
return obj
31+
if isinstance(obj, dict):
32+
return {k: _normalise_json(v) for k, v in obj.items()}
33+
if isinstance(obj, list):
34+
return [_normalise_json(item) for item in obj]
35+
return obj
36+
37+
38+
def _json_subset_matches(expected, actual) -> bool:
39+
"""Return *True* when *expected* is a subset of *actual* (recursive).
40+
41+
**List matching is greedy and order-sensitive.** Each item in *expected*
42+
is matched against *actual* in order, claiming the first unused actual
43+
item that satisfies the subset check. This means an earlier expected
44+
item can consume the only actual item that a later, more specific
45+
expected item would need. For example, with::
46+
47+
expected = [{"a": 1}, {"a": 1, "b": 2}]
48+
actual = [{"a": 1, "b": 2}]
49+
50+
the first expected item matches ``{"a": 1, "b": 2}`` (leaving nothing
51+
for the second), so the overall match returns *False* even though
52+
``{"a": 1, "b": 2}`` satisfies the second item. Consumers should
53+
**not** rely on non-deterministic matching; instead, pre-order *expected*
54+
lists from most-specific to least-specific to avoid this behaviour.
55+
"""
56+
if isinstance(expected, dict):
57+
if not isinstance(actual, dict):
58+
return False
59+
return all(
60+
k in actual and _json_subset_matches(v, actual[k])
61+
for k, v in expected.items()
62+
)
63+
if isinstance(expected, list):
64+
if not isinstance(actual, list):
65+
return False
66+
matched = [False] * len(actual)
67+
for exp_item in expected:
68+
found = False
69+
for i, act_item in enumerate(actual):
70+
if not matched[i] and _json_subset_matches(exp_item, act_item):
71+
matched[i] = True
72+
found = True
73+
break
74+
if not found:
75+
return False
76+
return True
77+
return expected == actual
78+
79+
80+
def check_json_subset(path: Union[str, os.PathLike], content: str, context) -> None:
81+
"""Assert that a JSON file *contains* the given key-values (subset match).
82+
83+
Dynamic placeholders (``<archive-sha256>``, ``<archive-url>``) in
84+
*content* are substituted with values from *context* before parsing.
85+
Dynamic values (timestamps, UUIDs) are normalised in both sides before
86+
comparison so that feature files can contain any placeholder value.
87+
"""
88+
content = apply_archive_substitutions(content, context)
89+
90+
with open(path, "r", encoding="UTF-8") as file_to_check:
91+
actual_json = json.load(file_to_check)
92+
93+
expected_json = _normalise_json(json.loads(content))
94+
actual_json = _normalise_json(actual_json)
95+
96+
assert _json_subset_matches(expected_json, actual_json), (
97+
f"JSON subset mismatch.\n"
98+
f"Expected subset:\n{json.dumps(expected_json, indent=4, sort_keys=True)}\n"
99+
f"Actual:\n{json.dumps(actual_json, indent=4, sort_keys=True)}"
100+
)
101+
102+
103+
@then("the '{name}' json file includes")
104+
def step_impl(context, name):
105+
"""Partial JSON match - the expected JSON must be a *subset* of the actual file."""
106+
check_json_subset(name, context.text, context)

0 commit comments

Comments
 (0)