diff --git a/packtools/sps/validation/response.py b/packtools/sps/validation/response.py new file mode 100644 index 000000000..fd43c155e --- /dev/null +++ b/packtools/sps/validation/response.py @@ -0,0 +1,294 @@ +""" +Validations for the element according to SPS 1.10 specification. + +This module implements validations for the element, which identifies +a set of responses related to a letter or commentary, mandatorily published +alongside the letter/commentary. + +Reference: https://docs.google.com/document/d/1GTv4Inc2LS_AXY-ToHT3HmO66UT0VAHWJNOIqzBNSgA/edit#heading=h.response +""" + +from packtools.sps.validation.utils import build_response + + +XML_LANG = "{http://www.w3.org/XML/1998/namespace}lang" + + +class ResponseValidation: + """ + Validates elements according to SPS 1.10 rules. + + Validation rules: + - Presence of @response-type attribute + - Value of @response-type must be "reply" + - Presence of @xml:lang attribute + - Presence of @id attribute + - Uniqueness of @id across all elements + - Presence of child element + - Presence of child element + """ + + def __init__(self, xmltree, params=None): + self.xmltree = xmltree + self.params = params or {} + + def _get_response_elements(self): + """ + Yield context dicts for each element found in the document. + + Searches for elements as children of
and + . + """ + root = self.xmltree.find(".") + if root is None: + return + + for response_node in root.xpath(".//response"): + parent_node = response_node.getparent() + if parent_node is not None: + parent_tag = parent_node.tag + if parent_tag == "article": + parent_id = None + parent_article_type = parent_node.get("article-type") + parent_lang = parent_node.get(XML_LANG) + elif parent_tag == "sub-article": + parent_id = parent_node.get("id") + parent_article_type = parent_node.get("article-type") + parent_lang = parent_node.get(XML_LANG) + else: + parent_id = None + parent_article_type = None + parent_lang = None + else: + parent_tag = None + parent_id = None + parent_article_type = None + parent_lang = None + + yield { + "node": response_node, + "parent": parent_tag, + "parent_id": parent_id, + "parent_article_type": parent_article_type, + "parent_lang": parent_lang, + "response_type": (response_node.get("response-type") or "").strip() or None, + "xml_lang": (response_node.get(XML_LANG) or "").strip() or None, + "id": (response_node.get("id") or "").strip() or None, + "has_front_stub": response_node.find("front-stub") is not None, + "has_body": response_node.find("body") is not None, + } + + def _build_parent_info(self, ctx): + return { + "parent": ctx["parent"], + "parent_id": ctx["parent_id"], + "parent_article_type": ctx["parent_article_type"], + "parent_lang": ctx["parent_lang"], + } + + def validate(self): + yield from self.validate_response_type_presence() + yield from self.validate_response_type_value() + yield from self.validate_xml_lang_presence() + yield from self.validate_id_presence() + yield from self.validate_id_uniqueness() + yield from self.validate_front_stub_presence() + yield from self.validate_body_presence() + + def validate_response_type_presence(self): + """ + Rule 1: Validate that @response-type attribute is present in . + """ + error_level = self.params.get( + "response_type_presence_error_level", "CRITICAL" + ) + for ctx in self._get_response_elements(): + response_type = ctx["response_type"] + is_valid = bool(response_type) + yield build_response( + title="response @response-type presence", + parent=self._build_parent_info(ctx), + item="response", + sub_item="@response-type", + validation_type="exist", + is_valid=is_valid, + expected="reply", + obtained=response_type, + advice='Add @response-type="reply" to .', + data=ctx.get("id"), + error_level=error_level, + element_name="response", + attribute_name="response-type", + ) + + def validate_response_type_value(self): + """ + Rule 2: Validate that @response-type value is "reply". + """ + error_level = self.params.get( + "response_type_value_error_level", "ERROR" + ) + for ctx in self._get_response_elements(): + response_type = ctx["response_type"] + if not response_type: + continue + is_valid = response_type == "reply" + yield build_response( + title="response @response-type value", + parent=self._build_parent_info(ctx), + item="response", + sub_item="@response-type", + validation_type="value", + is_valid=is_valid, + expected="reply", + obtained=response_type, + advice='Replace @response-type with "reply" in .', + data=ctx.get("id"), + error_level=error_level, + element_name="response", + attribute_name="response-type", + ) + + def validate_xml_lang_presence(self): + """ + Rule 3: Validate that @xml:lang attribute is present in . + """ + error_level = self.params.get( + "xml_lang_presence_error_level", "CRITICAL" + ) + for ctx in self._get_response_elements(): + xml_lang = ctx["xml_lang"] + is_valid = bool(xml_lang) + yield build_response( + title="response @xml:lang presence", + parent=self._build_parent_info(ctx), + item="response", + sub_item="@xml:lang", + validation_type="exist", + is_valid=is_valid, + expected="a valid xml:lang value", + obtained=xml_lang, + advice="Add @xml:lang to .", + data=ctx.get("id"), + error_level=error_level, + element_name="response", + attribute_name="xml:lang", + ) + + def validate_id_presence(self): + """ + Rule 4: Validate that @id attribute is present in . + """ + error_level = self.params.get( + "id_presence_error_level", "CRITICAL" + ) + for ctx in self._get_response_elements(): + response_id = ctx["id"] + is_valid = bool(response_id) + yield build_response( + title="response @id presence", + parent=self._build_parent_info(ctx), + item="response", + sub_item="@id", + validation_type="exist", + is_valid=is_valid, + expected="a unique id value", + obtained=response_id, + advice="Add @id to .", + data=ctx.get("id"), + error_level=error_level, + element_name="response", + attribute_name="id", + ) + + def validate_id_uniqueness(self): + """ + Rule 5: Validate that each has a unique @id value. + """ + error_level = self.params.get( + "id_uniqueness_error_level", "ERROR" + ) + seen_ids = {} + contexts = list(self._get_response_elements()) + for ctx in contexts: + response_id = ctx["id"] + if not response_id: + continue + if response_id in seen_ids: + seen_ids[response_id] += 1 + else: + seen_ids[response_id] = 1 + + duplicates = {k for k, v in seen_ids.items() if v > 1} + if not duplicates: + return + + for ctx in contexts: + response_id = ctx["id"] + if response_id not in duplicates: + continue + yield build_response( + title="response @id uniqueness", + parent=self._build_parent_info(ctx), + item="response", + sub_item="@id", + validation_type="unique", + is_valid=False, + expected="a unique @id for each ", + obtained=response_id, + advice=f'Replace duplicate @id="{response_id}" with a unique value in .', + data=response_id, + error_level=error_level, + element_name="response", + attribute_name="id", + ) + + def validate_front_stub_presence(self): + """ + Rule 6: Validate that is present in . + """ + error_level = self.params.get( + "front_stub_presence_error_level", "WARNING" + ) + for ctx in self._get_response_elements(): + is_valid = ctx["has_front_stub"] + yield build_response( + title="response front-stub presence", + parent=self._build_parent_info(ctx), + item="response", + sub_item="front-stub", + validation_type="exist", + is_valid=is_valid, + expected=" element", + obtained="front-stub" if is_valid else None, + advice="Add with response metadata inside .", + data=ctx.get("id"), + error_level=error_level, + element_name="response", + sub_element_name="front-stub", + ) + + def validate_body_presence(self): + """ + Rule 7: Validate that is present in . + """ + error_level = self.params.get( + "body_presence_error_level", "WARNING" + ) + for ctx in self._get_response_elements(): + is_valid = ctx["has_body"] + yield build_response( + title="response body presence", + parent=self._build_parent_info(ctx), + item="response", + sub_item="body", + validation_type="exist", + is_valid=is_valid, + expected=" element", + obtained="body" if is_valid else None, + advice="Add with response content inside .", + data=ctx.get("id"), + error_level=error_level, + element_name="response", + sub_element_name="body", + ) diff --git a/packtools/sps/validation/xml_validations.py b/packtools/sps/validation/xml_validations.py index d62b5f9f9..cedfa1ecf 100644 --- a/packtools/sps/validation/xml_validations.py +++ b/packtools/sps/validation/xml_validations.py @@ -48,6 +48,7 @@ from packtools.sps.validation.history import HistoryValidation from packtools.sps.validation.ext_link import ExtLinkValidation from packtools.sps.validation.graphic import XMLGraphicValidation +from packtools.sps.validation.response import ResponseValidation def validate_affiliations(xmltree, params): @@ -374,3 +375,19 @@ def validate_graphics(xmltree, params): graphic_rules = params["graphic_rules"] validator = XMLGraphicValidation(xmltree, graphic_rules) yield from validator.validate() + + +def validate_response(xmltree, params): + """ + Validates elements according to SPS 1.10 specification. + + Validates: + - @response-type presence and value ("reply") + - @xml:lang presence + - @id presence and uniqueness + - presence + - presence + """ + response_rules = params.get("response_rules", {}) + validator = ResponseValidation(xmltree, response_rules) + yield from validator.validate() diff --git a/packtools/sps/validation/xml_validator.py b/packtools/sps/validation/xml_validator.py index 1b6d7b311..41928bf63 100644 --- a/packtools/sps/validation/xml_validator.py +++ b/packtools/sps/validation/xml_validator.py @@ -157,3 +157,7 @@ def validate_xml_content(xmltree, rules): "group": "graphic", "items": xml_validations.validate_graphics(xmltree, params), } + yield { + "group": "response", + "items": xml_validations.validate_response(xmltree, params), + } diff --git a/packtools/sps/validation_rules/response_rules.json b/packtools/sps/validation_rules/response_rules.json new file mode 100644 index 000000000..2e8827c00 --- /dev/null +++ b/packtools/sps/validation_rules/response_rules.json @@ -0,0 +1,11 @@ +{ + "response_rules": { + "response_type_presence_error_level": "CRITICAL", + "response_type_value_error_level": "ERROR", + "xml_lang_presence_error_level": "CRITICAL", + "id_presence_error_level": "CRITICAL", + "id_uniqueness_error_level": "ERROR", + "front_stub_presence_error_level": "WARNING", + "body_presence_error_level": "WARNING" + } +} diff --git a/tests/sps/validation/test_response.py b/tests/sps/validation/test_response.py new file mode 100644 index 000000000..30123c146 --- /dev/null +++ b/tests/sps/validation/test_response.py @@ -0,0 +1,659 @@ +""" +Tests for element validations according to SPS 1.10. + +This module tests the validation rules for the element, +ensuring compliance with the SPS 1.10 specification. +""" + +from unittest import TestCase +from lxml import etree + +from packtools.sps.validation.response import ResponseValidation + + +class TestResponseTypePresence(TestCase): + """Tests for Rule 1: @response-type presence.""" + + def test_response_type_present(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_response_type_missing(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "CRITICAL") + + def test_response_type_empty(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "CRITICAL") + + def test_response_type_whitespace_only(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "CRITICAL") + + +class TestResponseTypeValue(TestCase): + """Tests for Rule 2: @response-type value must be 'reply'.""" + + def test_response_type_reply(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_value()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_response_type_wrong_value(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_value()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "ERROR") + self.assertEqual(results[0]["got_value"], "answer") + + def test_response_type_uppercase(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_value()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "ERROR") + self.assertEqual(results[0]["got_value"], "Reply") + + def test_response_type_missing_skips_value_check(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_response_type_value()) + + self.assertEqual(len(results), 0) + + +class TestXmlLangPresence(TestCase): + """Tests for Rule 3: @xml:lang presence.""" + + def test_xml_lang_present(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_xml_lang_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_xml_lang_missing(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_xml_lang_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "CRITICAL") + + def test_xml_lang_empty(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_xml_lang_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "CRITICAL") + + +class TestIdPresence(TestCase): + """Tests for Rule 4: @id presence.""" + + def test_id_present(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_id_missing(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "CRITICAL") + + def test_id_empty(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "CRITICAL") + + +class TestIdUniqueness(TestCase): + """Tests for Rule 5: @id uniqueness.""" + + def test_unique_ids(self): + xml = """ +
+ +

Content

+ + +

First response

+
+ + +

Second response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_uniqueness()) + + self.assertEqual(len(results), 0) + + def test_duplicate_ids(self): + xml = """ +
+ +

Content

+ + +

First response

+
+ + +

Second response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_uniqueness()) + + self.assertEqual(len(results), 2) + for r in results: + self.assertEqual(r["response"], "ERROR") + self.assertEqual(r["got_value"], "S1") + + def test_three_duplicate_ids(self): + xml = """ +
+ +

Content

+ + +

First

+
+ + +

Second

+
+ + +

Third

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_uniqueness()) + + self.assertEqual(len(results), 3) + + def test_no_responses_no_errors(self): + xml = """ +
+ +

Content

+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_uniqueness()) + + self.assertEqual(len(results), 0) + + def test_missing_ids_not_counted_as_duplicates(self): + xml = """ +
+ +

Content

+ + +

First

+
+ + +

Second

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_id_uniqueness()) + + self.assertEqual(len(results), 0) + + +class TestFrontStubPresence(TestCase): + """Tests for Rule 6: presence.""" + + def test_front_stub_present(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_front_stub_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_front_stub_missing(self): + xml = """ +
+ +

Content

+ +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_front_stub_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "WARNING") + + +class TestBodyPresence(TestCase): + """Tests for Rule 7: presence.""" + + def test_body_present(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_body_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "OK") + + def test_body_missing(self): + xml = """ +
+ +

Content

+ + + +
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate_body_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "WARNING") + + +class TestMultipleResponses(TestCase): + """Tests with multiple response elements.""" + + def test_multiple_valid_responses(self): + xml = """ +
+ +

Content

+ + +

First response

+
+ + +

Second response

+
+ + +

Third response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate()) + + ok_results = [r for r in results if r["response"] == "OK"] + error_results = [r for r in results if r["response"] != "OK"] + self.assertEqual(len(error_results), 0) + # 7 rules × 3 responses = 21, minus uniqueness (0 issues) = at least 18 + self.assertGreater(len(ok_results), 0) + + def test_no_response_elements_yields_nothing(self): + xml = """ +
+ +

Content

+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate()) + + self.assertEqual(len(results), 0) + + +class TestResponseInSubArticle(TestCase): + """Tests for response elements within sub-article.""" + + def test_response_in_sub_article(self): + xml = """ +
+ +

Content

+ + +

Translated content

+ + +

Resposta em português.

+
+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate()) + + error_results = [r for r in results if r["response"] != "OK"] + self.assertEqual(len(error_results), 0) + + # Verify parent info points to sub-article + for r in results: + if r["response"] == "OK": + self.assertEqual(r["parent"], "sub-article") + self.assertEqual(r["parent_id"], "sub1") + + +class TestValidateAll(TestCase): + """Tests for the validate() method that runs all validations.""" + + def test_valid_response(self): + xml = """ +
+ +

Content

+ + + + + + Smith + John + + + + +

Response content.

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate()) + + error_results = [r for r in results if r["response"] != "OK"] + self.assertEqual(len(error_results), 0) + + def test_response_all_attributes_missing(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate()) + + critical_results = [r for r in results if r["response"] == "CRITICAL"] + # Missing: @response-type, @xml:lang, @id = 3 CRITICAL + self.assertEqual(len(critical_results), 3) + + def test_response_with_all_errors(self): + """Response with wrong type, missing lang, missing id, no front-stub, no body.""" + xml = """ +
+ +

Content

+ + +
+ """ + tree = etree.fromstring(xml) + validator = ResponseValidation(tree) + results = list(validator.validate()) + + # @response-type value error (ERROR), @xml:lang missing (CRITICAL), + # @id missing (CRITICAL), no front-stub (WARNING), no body (WARNING) + error_results = [r for r in results if r["response"] != "OK"] + self.assertGreaterEqual(len(error_results), 4) + + +class TestCustomErrorLevels(TestCase): + """Tests for custom error levels via params.""" + + def test_custom_error_level_for_response_type(self): + xml = """ +
+ +

Content

+ + +

Response

+
+
+ """ + tree = etree.fromstring(xml) + params = {"response_type_presence_error_level": "WARNING"} + validator = ResponseValidation(tree, params) + results = list(validator.validate_response_type_presence()) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["response"], "WARNING") + + def test_custom_error_level_for_id_uniqueness(self): + xml = """ +
+ +

Content

+ + +

First

+
+ + +

Second

+
+
+ """ + tree = etree.fromstring(xml) + params = {"id_uniqueness_error_level": "CRITICAL"} + validator = ResponseValidation(tree, params) + results = list(validator.validate_id_uniqueness()) + + for r in results: + self.assertEqual(r["response"], "CRITICAL")