Skip to content

Commit 36c9ddf

Browse files
fix: CheckLogger bug + increase test coverage (#587)
1 parent d4ec722 commit 36c9ddf

3 files changed

Lines changed: 226 additions & 19 deletions

File tree

src/extensions/score_metamodel/checks/graph_checks.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -107,37 +107,48 @@ def filter_needs_by_criteria(
107107
needs_selection_criteria: dict[str, str],
108108
log: CheckLogger,
109109
) -> list[NeedItem]:
110-
"""Create a list of needs that match the selection criteria.:
111-
- If it is an include selection add the include to the pattern
112-
- If it is an exclude selection add a "^" to the pattern
113110
"""
111+
Filter needs by include/exclude type patterns and an additional condition.
114112
113+
The function:
114+
- accepts exactly one selector key: "include" or "exclude"
115+
- validates that "condition" exists
116+
- logs warnings for unknown need types in selector patterns
117+
- returns needs matching selector + condition
118+
"""
115119
selected_needs: list[NeedItem] = []
116-
pattern: list[str] = []
117-
need_pattern: str = list(needs_selection_criteria.keys())[0]
118-
# Verify Inputs
119-
if need_pattern in ["include", "exclude"]:
120-
for pat in list(needs_selection_criteria.values())[0].split(","):
121-
pattern.append(pat.lstrip())
120+
121+
if "include" in needs_selection_criteria and "exclude" in needs_selection_criteria:
122+
raise ValueError(
123+
f"Invalid need selection: both include and exclude are set: {needs_selection_criteria}"
124+
)
125+
126+
if "include" in needs_selection_criteria:
127+
need_pattern = "include"
128+
raw_patterns = needs_selection_criteria["include"]
129+
elif "exclude" in needs_selection_criteria:
130+
need_pattern = "exclude"
131+
raw_patterns = needs_selection_criteria["exclude"]
122132
else:
123133
raise ValueError(f"Invalid need selection: {needs_selection_criteria}")
124134

125-
if "condition" in needs_selection_criteria:
126-
condition = needs_selection_criteria["condition"]
127-
else:
135+
if "condition" not in needs_selection_criteria:
128136
raise ValueError(f"Invalid selection: {needs_selection_criteria}")
129137

138+
condition = needs_selection_criteria["condition"]
139+
pattern = [pat.strip() for pat in raw_patterns.split(",") if pat.strip()]
140+
130141
for pat in pattern:
131142
if not any(need_type["directive"] == pat for need_type in needs_types):
132143
log.warning(f"Unknown need type `{pat}` in graph check.", location="")
133144

134145
for need in needs:
135-
if need_pattern == "include":
136-
sel = need["type"] in pattern
137-
else:
138-
sel = need["type"] not in pattern
139-
140-
if sel and (eval_need_condition(need, condition, log)):
146+
sel = (
147+
need["type"] in pattern
148+
if need_pattern == "include"
149+
else need["type"] not in pattern
150+
)
151+
if sel and eval_need_condition(need, condition, log):
141152
selected_needs.append(need)
142153

143154
return selected_needs

src/extensions/score_metamodel/log.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ def _log_message(
9191
self._info_count += 1
9292
else:
9393
self.warning(msg, location)
94-
self._warning_count += 1
9594

9695
def info(
9796
self,
@@ -106,6 +105,7 @@ def warning(
106105
location: Location,
107106
):
108107
self._log.warning(msg, type="score_metamodel", location=location)
108+
self._warning_count += 1
109109

110110
@property
111111
def warnings(self):
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# *******************************************************************************
2+
# Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
#
4+
# See the NOTICE file(s) distributed with this work for additional
5+
# information regarding copyright ownership.
6+
#
7+
# This program and the accompanying materials are made available under the
8+
# terms of the Apache License Version 2.0 which is available at
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# SPDX-License-Identifier: Apache-2.0
12+
# *******************************************************************************
13+
14+
from __future__ import annotations
15+
16+
from typing import Any
17+
18+
import pytest
19+
20+
# Adjust this import path if your project layout differs.
21+
import score_metamodel.checks.graph_checks as graph_checks
22+
from score_metamodel.tests import fake_check_logger, need as test_need
23+
from sphinx_needs.config import NeedType
24+
25+
26+
class DummyNeedsView:
27+
"""Minimal NeedsView-like test double."""
28+
29+
def __init__(self, needs: list[dict[str, Any]]) -> None:
30+
"""Create a view over needs represented as dict-like structures."""
31+
self._needs = needs
32+
33+
def values(self) -> list[dict[str, Any]]:
34+
"""Return all needs."""
35+
return self._needs
36+
37+
def filter_is_external(self, is_external: bool) -> DummyNeedsView:
38+
"""Filter needs by their is_external flag."""
39+
return DummyNeedsView(
40+
[n for n in self._needs if n.get("is_external", False) == is_external]
41+
)
42+
43+
44+
class NeedObject(dict):
45+
"""Need object that supports both mapping and attribute style access used by code."""
46+
47+
@property
48+
def id(self) -> str:
49+
"""Return need identifier."""
50+
return self["id"]
51+
52+
@property
53+
def _links(self) -> dict[str, list[NeedObject]]:
54+
"""Return links map."""
55+
return self["links"]
56+
57+
58+
def test_eval_need_check_invalid_check_parts_raises_value_error() -> None:
59+
"""Raise error when a check does not contain exactly three parts."""
60+
log = fake_check_logger()
61+
need = test_need(status="valid")
62+
63+
with pytest.raises(ValueError, match="Invalid check defined"):
64+
graph_checks.eval_need_check(need, "status==valid", log)
65+
66+
67+
def test_eval_need_check_unknown_operator_raises_value_error() -> None:
68+
"""Raise error when an unsupported binary operator is used."""
69+
log = fake_check_logger()
70+
need = test_need(status="valid")
71+
72+
with pytest.raises(ValueError, match="Binary Operator not defined"):
73+
graph_checks.eval_need_check(need, "status <> valid", log)
74+
75+
76+
def test_eval_need_check_missing_attribute_logs_and_returns_false() -> None:
77+
"""Return False and log warning when referenced attribute is not present."""
78+
log = fake_check_logger()
79+
need = test_need(status="valid")
80+
81+
result = graph_checks.eval_need_check(need, "priority == high", log)
82+
83+
assert result is False
84+
log.assert_warning("Attribute not defined: priority")
85+
86+
result2 = graph_checks.eval_need_condition(need, {"not": ["status == valid"]}, log)
87+
88+
assert result2 is False
89+
# assert "Attribute not defined: priority" in msg
90+
91+
92+
def test_eval_need_condition_invalid_type_raises_value_error() -> None:
93+
"""Raise error for condition values that are neither string nor dict."""
94+
log = fake_check_logger()
95+
need = test_need(status="valid")
96+
97+
with pytest.raises(ValueError, match="Invalid condition type"):
98+
graph_checks.eval_need_condition(need, 123, log) # type: ignore[arg-type]
99+
100+
101+
def test_eval_need_condition_not_with_wrong_operand_count_raises_value_error() -> None:
102+
"""Raise error when 'not' does not receive exactly one operand."""
103+
log = fake_check_logger()
104+
need = test_need(status="valid")
105+
106+
with pytest.raises(ValueError, match="requires exactly one operand"):
107+
graph_checks.eval_need_condition(
108+
need,
109+
{"not": ["status == valid", "status != invalid"]},
110+
log,
111+
)
112+
113+
114+
def test_eval_need_condition_and_or_xor_branches() -> None:
115+
"""Evaluate logical combination branches and, or and xor."""
116+
log = fake_check_logger()
117+
need = test_need(status="valid", kind="req")
118+
119+
and_result = graph_checks.eval_need_condition(
120+
need,
121+
{"and": ["status == valid", "kind == req"]},
122+
log,
123+
)
124+
or_result = graph_checks.eval_need_condition(
125+
need,
126+
{"or": ["status == invalid", "kind == req"]},
127+
log,
128+
)
129+
xor_result = graph_checks.eval_need_condition(
130+
need,
131+
{"xor": ["status == valid", "kind == req"]},
132+
log,
133+
)
134+
135+
assert and_result is True
136+
assert or_result is True
137+
assert xor_result is False
138+
139+
with pytest.raises(ValueError, match="Unsupported condition operator: blah"):
140+
graph_checks.eval_need_condition(
141+
need,
142+
{"blah": ["status == valid", "kind == req"]},
143+
log,
144+
)
145+
146+
147+
def test_filter_needs_by_criteria_invalid() -> None:
148+
"""Raise error when include/exclude selector key is invalid."""
149+
log = fake_check_logger()
150+
test_need()
151+
needs_types = [NeedType({"title": "testtype", "prefix": "t", "directive": "req"})]
152+
needs = [test_need(id="N1", type="testtype", status="valid")]
153+
154+
with pytest.raises(ValueError, match="Invalid need selection"):
155+
graph_checks.filter_needs_by_criteria(
156+
needs_types,
157+
needs,
158+
{"invalid": "req", "condition": "status == valid"},
159+
log,
160+
)
161+
162+
with pytest.raises(ValueError, match="Invalid selection"):
163+
graph_checks.filter_needs_by_criteria(
164+
needs_types,
165+
needs,
166+
{"exclude": "req"},
167+
log,
168+
)
169+
with pytest.raises(
170+
ValueError, match="Invalid need selection: both include and exclude are set"
171+
):
172+
graph_checks.filter_needs_by_criteria(
173+
needs_types,
174+
needs,
175+
{"include": "req", "exclude": "req"},
176+
log,
177+
)
178+
179+
180+
def test_filter_needs_by_criteria_unknown_type_logs_warning() -> None:
181+
"""Log warning when selected pattern contains unknown need type."""
182+
log = fake_check_logger()
183+
needs_types = [NeedType({"title": "testtype", "prefix": "t", "directive": "req"})]
184+
needs = [test_need(id="N1", type="whasxd", status="valid")]
185+
186+
selected = graph_checks.filter_needs_by_criteria(
187+
needs_types,
188+
needs,
189+
{"include": "unknown", "condition": "status == valid"},
190+
log,
191+
)
192+
assert selected == []
193+
assert log.warnings == 1
194+
log.assert_warning(
195+
"Unknown need type `unknown` in graph check.", expect_location=False
196+
)

0 commit comments

Comments
 (0)